Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2c9be564 | |||
| 8980f9599b | |||
| 7a91a37049 |
@@ -0,0 +1,7 @@
|
||||
# Copy to .env (gitignored, never commit the real value) to require an
|
||||
# API key on the server's /jobs endpoint - see docker-compose.yml and
|
||||
# tooling-container/src/server.py. Leave FUELISH_API_KEY unset/empty for
|
||||
# the original LAN-only/no-auth setup.
|
||||
#
|
||||
# Generate one with: openssl rand -hex 32
|
||||
FUELISH_API_KEY=
|
||||
@@ -66,8 +66,15 @@ jobs:
|
||||
set -euo pipefail
|
||||
APK="$(ls dist/fuelish-*.apk)"
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||
printf '%s\n' '${{ secrets.DL_SFTP_KEY }}' > ~/.ssh/dl_sftp_key
|
||||
chmod 600 ~/.ssh/dl_sftp_key
|
||||
# Sanity-check the key is actually loadable before attempting the
|
||||
# SFTP connection - fails fast with a clear message instead of
|
||||
# SFTP's much less obvious "Permission denied" when the key
|
||||
# material itself is malformed (e.g. mangled by how the secret
|
||||
# was entered into Gitea - see DL_SFTP_KEY in README.md).
|
||||
ssh-keygen -y -f ~/.ssh/dl_sftp_key > /dev/null \
|
||||
|| { echo "FAIL: ~/.ssh/dl_sftp_key is not a valid private key - re-check the DL_SFTP_KEY secret" >&2; exit 1; }
|
||||
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
uploader@dl.ladkau.de <<EOF
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# Local registry credentials (see registry.env.example)
|
||||
/registry.env
|
||||
|
||||
# Local API key for docker-compose.yml's api service (see .env.example)
|
||||
/.env
|
||||
|
||||
# Local Claude Code settings
|
||||
/.claude/settings.local.json
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ implausibly - is `12_adaptive_preprocess_ocr.sh` below.
|
||||
steps are short-lived and stateless (one `docker run` per receipt).
|
||||
`3_extract_data.sh` reaches it via `--network host` at
|
||||
`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
|
||||
`tooling-container/` image, tagged from `tooling-container/VERSION`.
|
||||
Image name defaults to `fuelish/tools`, overridable via
|
||||
|
||||
@@ -157,12 +157,21 @@ is found next to the photo):
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
By default this has no auth (fine for a LAN-only setup - see the app's
|
||||
`network_security_config.xml` for the HTTP-only/private-IP-range
|
||||
restriction this implies). For a deployment reachable outside a trusted
|
||||
LAN, copy `.env.example` to `.env`, set `FUELISH_API_KEY` (e.g.
|
||||
`openssl rand -hex 32`), and enter the same key in the Android app's
|
||||
Settings tab alongside the server URL - every request then needs a
|
||||
matching `X-API-Key` header (see `tooling-container/src/server.py`).
|
||||
This only checks the key; it doesn't add TLS - put a reverse proxy in
|
||||
front of the server for anything public.
|
||||
|
||||
Then, in the Android app's Settings tab, point it at
|
||||
`http://<this-host>:8080` (both HTTP-only - see the app's
|
||||
`network_security_config.xml` - and LAN-only by design, no auth/TLS).
|
||||
Take a photo, watch the 4-stage progress (upload → preprocess → OCR →
|
||||
extract), correct anything the model got wrong, and save. The Receipts
|
||||
tab lists everything saved so far and exports to CSV or `.ods`.
|
||||
`http://<this-host>:8080`. Take a photo, watch the progress (upload →
|
||||
preprocess+OCR → extract), correct anything the model got wrong, and
|
||||
save. The Receipts tab lists everything saved so far and exports to CSV
|
||||
or `.ods`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -32,13 +32,14 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||
.readTimeout(3, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
|
||||
suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
|
||||
.build()
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body)
|
||||
.withApiKey(apiKey).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
||||
val text = response.body?.string().orEmpty()
|
||||
@@ -50,8 +51,9 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
|
||||
suspend fun getJobStatus(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
|
||||
.withApiKey(apiKey).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
|
||||
val text = response.body?.string().orEmpty()
|
||||
@@ -84,3 +86,9 @@ sealed interface HealthCheckResult {
|
||||
data object Ok : HealthCheckResult
|
||||
data class Failed(val message: String) : HealthCheckResult
|
||||
}
|
||||
|
||||
/** Matches server.py's require_api_key(): omits the header entirely for
|
||||
* a blank key rather than sending an empty one, so a server with no
|
||||
* FUELISH_API_KEY configured behaves identically either way. */
|
||||
private fun Request.Builder.withApiKey(apiKey: String): Request.Builder =
|
||||
if (apiKey.isBlank()) this else header("X-API-Key", apiKey)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
/**
|
||||
* Everything the app needs that isn't a saved receipt: just the fuelish
|
||||
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
|
||||
* Everything the app needs that isn't a saved receipt: the fuelish
|
||||
* server's base URL (e.g. "http://192.168.1.10:8080") and, if the server
|
||||
* has FUELISH_API_KEY set (see tooling-container/src/server.py), the
|
||||
* matching key to send as X-API-Key. Blank apiKey is fine against a
|
||||
* server with no key configured - that's the original no-auth setup.
|
||||
* Persisted via Preferences DataStore, see [ConfigRepository].
|
||||
*/
|
||||
data class AppConfig(
|
||||
val serverUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
)
|
||||
|
||||
@@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore<Preferences> by preferencesDataSt
|
||||
class ConfigRepository(private val context: Context) {
|
||||
private object Keys {
|
||||
val SERVER_URL = stringPreferencesKey("server_url")
|
||||
val API_KEY = stringPreferencesKey("api_key")
|
||||
}
|
||||
|
||||
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
|
||||
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
|
||||
AppConfig(
|
||||
serverUrl = prefs[Keys.SERVER_URL] ?: "",
|
||||
apiKey = prefs[Keys.API_KEY] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(config: AppConfig) {
|
||||
context.configDataStore.edit { prefs ->
|
||||
prefs[Keys.SERVER_URL] = config.serverUrl
|
||||
prefs[Keys.API_KEY] = config.apiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ class ReceiptRepository(context: Context) {
|
||||
|
||||
suspend fun getById(id: Long): Receipt? = dao.getById(id)
|
||||
|
||||
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
|
||||
suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String =
|
||||
api.submitImage(baseUrl, apiKey, imageFile)
|
||||
|
||||
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
|
||||
suspend fun pollJob(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse =
|
||||
api.getJobStatus(baseUrl, apiKey, jobId)
|
||||
|
||||
suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
|
||||
|
||||
|
||||
@@ -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.export.exportCsv as writeCsvRows
|
||||
import de.ladkau.fuelish.export.exportOds as writeOdsRows
|
||||
import de.ladkau.fuelish.export.exportPhotos as writePhotosZip
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import java.io.File
|
||||
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)
|
||||
|
||||
/** 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) {
|
||||
val app = getApplication<Application>()
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -117,6 +117,15 @@ fun FuelishApp() {
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -36,23 +36,24 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
||||
viewModelScope.launch {
|
||||
_uiState.value = CaptureUiState.Uploading
|
||||
try {
|
||||
val baseUrl = configRepository.config.first().serverUrl
|
||||
val config = configRepository.config.first()
|
||||
val baseUrl = config.serverUrl
|
||||
if (baseUrl.isBlank()) {
|
||||
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
||||
return@launch
|
||||
}
|
||||
val jobId = repository.submitImage(baseUrl, imageFile)
|
||||
pollUntilDone(baseUrl, jobId)
|
||||
val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
|
||||
pollUntilDone(baseUrl, config.apiKey, jobId)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
|
||||
private suspend fun pollUntilDone(baseUrl: String, apiKey: String, jobId: String) {
|
||||
var lastStage: String? = null
|
||||
while (true) {
|
||||
val status = repository.pollJob(baseUrl, jobId)
|
||||
val status = repository.pollJob(baseUrl, apiKey, jobId)
|
||||
when (status.status) {
|
||||
JobStatusResponse.STATUS_DONE -> {
|
||||
AppLog.log("Job $jobId done")
|
||||
|
||||
@@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -32,14 +37,25 @@ private val DELETE_COLUMN_WIDTH = 48.dp
|
||||
@Composable
|
||||
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
|
||||
val receipts by viewModel.receipts.collectAsState()
|
||||
val sort by viewModel.sort.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
|
||||
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()
|
||||
|
||||
if (receipts.isEmpty()) {
|
||||
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),
|
||||
)
|
||||
} else {
|
||||
@@ -58,32 +74,80 @@ fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel
|
||||
}
|
||||
|
||||
@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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.overview_column_date),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
SortableHeaderCell(
|
||||
label = stringResource(R.string.overview_column_date),
|
||||
column = SortColumn.DATE,
|
||||
sort = sort,
|
||||
onClick = onColumnClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_column_litres),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
SortableHeaderCell(
|
||||
label = stringResource(R.string.overview_column_litres),
|
||||
column = SortColumn.LITRES,
|
||||
sort = sort,
|
||||
onClick = onColumnClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_column_total),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
SortableHeaderCell(
|
||||
label = stringResource(R.string.overview_column_total),
|
||||
column = SortColumn.TOTAL_PRICE,
|
||||
sort = sort,
|
||||
onClick = onColumnClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
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
|
||||
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
|
||||
Row(
|
||||
|
||||
@@ -5,17 +5,56 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import de.ladkau.fuelish.data.ReceiptRepository
|
||||
import de.ladkau.fuelish.export.toExportRow
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
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) {
|
||||
private val repository = ReceiptRepository(application)
|
||||
|
||||
val receipts: StateFlow<List<Receipt>> = repository.receipts
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
|
||||
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) {
|
||||
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 }
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.ladkau.fuelish.R
|
||||
@@ -38,6 +39,14 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = config.apiKey,
|
||||
onValueChange = { viewModel.update { c -> c.copy(apiKey = it) } },
|
||||
label = { Text(stringResource(R.string.settings_api_key)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
||||
Text(stringResource(R.string.capture_save))
|
||||
|
||||
@@ -34,16 +34,21 @@
|
||||
<string name="receipt_delete">Delete</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_litres">Litres</string>
|
||||
<string name="overview_column_total">Total</string>
|
||||
<string name="overview_delete">Delete</string>
|
||||
<string name="overview_export_csv">Export CSV</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="settings_server_url">Server URL</string>
|
||||
<string name="settings_server_url_example">http://192.168.1.10:8080</string>
|
||||
<string name="settings_api_key">API key (leave blank if the server has none set)</string>
|
||||
<string name="settings_test_connection">Test connection</string>
|
||||
<string name="settings_test_testing">Testing…</string>
|
||||
<string name="settings_test_success">Connected successfully</string>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
#
|
||||
# Usage: docker compose up -d
|
||||
# API reachable at http://<this-host>:8080 from any device on the LAN.
|
||||
#
|
||||
# For a deployment reachable outside a trusted LAN, copy .env.example to
|
||||
# .env and set FUELISH_API_KEY - every /jobs request then needs a
|
||||
# matching X-API-Key header (see tooling-container/src/server.py).
|
||||
# Unset (the default), the server has no auth, same as before. This
|
||||
# server does not terminate TLS itself - put a reverse proxy in front of
|
||||
# it for anything public.
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
@@ -26,6 +33,7 @@ services:
|
||||
OLLAMA_URL: http://ollama:11434/api/chat
|
||||
OLLAMA_BASE_URL: http://ollama:11434
|
||||
OLLAMA_MODEL: qwen2.5:3b
|
||||
FUELISH_API_KEY: ${FUELISH_API_KEY:-}
|
||||
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- "8080:8000"
|
||||
|
||||
@@ -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")
|
||||
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 = {
|
||||
"type": "object",
|
||||
@@ -57,6 +62,9 @@ Respond with ONLY the JSON object, no other text.
|
||||
|
||||
|
||||
def structure(ocr_text: str) -> dict:
|
||||
options = {"temperature": 0, "num_ctx": NUM_CTX}
|
||||
if NUM_THREAD:
|
||||
options["num_thread"] = int(NUM_THREAD)
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
@@ -64,7 +72,7 @@ def structure(ocr_text: str) -> dict:
|
||||
],
|
||||
"format": SCHEMA,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0, "num_ctx": 3072},
|
||||
"options": options,
|
||||
}
|
||||
|
||||
start = time.time()
|
||||
|
||||
@@ -10,8 +10,13 @@ Jobs are processed asynchronously so the app can show real per-stage
|
||||
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
|
||||
reports which of preprocessing/ocr/extracting is currently running, and
|
||||
the final result once done. Job state lives in memory only - this is a
|
||||
personal, LAN-only, single-instance server with no need for persistence
|
||||
here (the Android app is the system of record for confirmed receipts).
|
||||
personal, single-instance server with no need for persistence here (the
|
||||
Android app is the system of record for confirmed receipts).
|
||||
|
||||
Auth: set FUELISH_API_KEY to require a matching X-API-Key header on
|
||||
/jobs (GET /health stays open for reverse-proxy health checks). Leave it
|
||||
unset for the original LAN-only/no-auth setup. This server does not
|
||||
terminate TLS itself - see require_api_key()'s docstring.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
@@ -21,17 +26,34 @@ import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from fastapi import FastAPI, HTTPException, UploadFile
|
||||
from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
|
||||
from adaptive import preprocess_and_ocr_adaptive
|
||||
from extract import structure
|
||||
|
||||
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
|
||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
||||
API_KEY = os.environ.get("FUELISH_API_KEY", "")
|
||||
|
||||
app = FastAPI(title="fuelish")
|
||||
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
def require_api_key(provided: str | None = Security(_api_key_header)) -> None:
|
||||
"""No-op when FUELISH_API_KEY isn't set - the unchanged default for a
|
||||
LAN-only deployment with no key configured. When it is set, every
|
||||
request to a protected route must carry a matching X-API-Key header.
|
||||
This only checks the key; it doesn't terminate TLS - a reverse proxy
|
||||
in front of this server (e.g. Traefik) is expected to do that for any
|
||||
deployment reachable outside a trusted LAN."""
|
||||
if not API_KEY:
|
||||
return
|
||||
if provided != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="invalid or missing API key")
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
|
||||
@@ -96,7 +118,7 @@ def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
|
||||
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@app.post("/jobs", status_code=201)
|
||||
@app.post("/jobs", status_code=201, dependencies=[Depends(require_api_key)])
|
||||
async def create_job(image: UploadFile) -> JSONResponse:
|
||||
job_id = uuid.uuid4().hex
|
||||
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
|
||||
@@ -113,7 +135,7 @@ async def create_job(image: UploadFile) -> JSONResponse:
|
||||
return JSONResponse({"job_id": job_id}, status_code=201)
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}")
|
||||
@app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)])
|
||||
def get_job(job_id: str) -> dict:
|
||||
with _jobs_lock:
|
||||
job = _jobs.get(job_id)
|
||||
|
||||
Reference in New Issue
Block a user