feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)

Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
This commit is contained in:
vjrj 2026-07-09 21:23:46 +02:00
parent 12a2ee2d64
commit 6809dc6143
89 changed files with 17141 additions and 228 deletions

View file

@ -45,23 +45,23 @@
},
"backup": {
"title": "Copia de seguridad",
"exportCsv": "Exportar a CSV",
"exportCsvSubtitle": "Para hojas de cálculo — sin fotos",
"exportJson": "Exportar a JSON",
"exportJsonSubtitle": "Copia completa, se puede volver a importar",
"importJson": "Importar desde JSON",
"importJsonSubtitle": "Fusiona una copia guardada con este inventario",
"importCsv": "Importar desde CSV",
"importCsvSubtitle": "Añade una lista desde una hoja de cálculo",
"importConfirmTitle": "¿Importar una copia guardada?",
"exportJson": "Guardar una copia de seguridad",
"exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo",
"importJson": "Restaurar una copia",
"importJsonSubtitle": "Recupera una copia guardada — no se duplica nada",
"exportCsv": "Exportar a una hoja de cálculo",
"exportCsvSubtitle": "Una lista sencilla para Excel o LibreOffice — sin fotos",
"importCsv": "Importar una lista",
"importCsvSubtitle": "Añade entradas desde una hoja de cálculo",
"importConfirmTitle": "¿Restaurar una copia?",
"importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.",
"importCsvConfirmTitle": "¿Importar una lista CSV?",
"importCsvConfirmTitle": "¿Importar una lista?",
"importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.",
"importAction": "Importar",
"exportSaved": "Copia guardada",
"cancelled": "Cancelado",
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
"importCsvDone": "Importadas {count} desde CSV",
"importCsvDone": "Añadidas {count} entradas",
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
"failed": "Algo ha salido mal"
},
@ -112,7 +112,19 @@
"empty": "Aún no hay semillas. Toca + para añadir la primera.",
"noMatches": "Ninguna semilla coincide con los filtros.",
"clearFilters": "Quitar filtros",
"uncategorized": "Sin categoría"
"uncategorized": "Sin categoría",
"needsReproductionFilter": "Por reproducir"
},
"draft": {
"capture": "Capturar fotos",
"captured": "{n} capturadas por catalogar",
"triageTitle": "Por catalogar",
"triageCount": "{n} por catalogar",
"untitled": "Sin nombre",
"nameField": "Nombra esta semilla",
"nameHint": "¿Qué es?",
"suggestFromPhoto": "Sugerir nombre de la foto",
"discard": "Descartar"
},
"quickAdd": {
"title": "Añadir una semilla",
@ -151,13 +163,21 @@
"none": "Aún no hay pruebas de germinación.",
"result": "{percent}%"
},
"viability": {
"expiringSoon": "Úsala o multiplícala esta temporada",
"expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años",
"expired": "Supera su viabilidad típica — a multiplicar",
"expiredYears": "Supera su viabilidad típica (~{years} años) — a multiplicar"
},
"editVariety": {
"title": "Editar semilla",
"name": "Nombre",
"category": "Categoría",
"notes": "Notas",
"species": "Especie (del catálogo)",
"speciesHint": "Buscar una especie…"
"speciesHint": "Buscar una especie…",
"organic": "Ecológica",
"organicHint": "Cultivada de forma ecológica (eco)"
},
"addLot": {
"title": "Añadir lote",
@ -191,6 +211,65 @@
"bareRoot": "Raíz desnuda",
"rootBall": "Cepellón"
},
"provenance": {
"section": "De dónde viene",
"seedsFrom": "Semillas de",
"seedsFromHint": "Quién las cultivó o las dio",
"place": "Lugar",
"placeHint": "De dónde vienen (con provincia)",
"addSeedsFrom": "Semillas de",
"addPlace": "Lugar"
},
"abundance": {
"add": "Cuánta tengo",
"title": "Cuánta tengo",
"none": "Sin indicar",
"plentyToShare": "De sobra para compartir",
"enoughToShare": "Bastante, para compartir con moderación",
"enoughForMe": "Suficiente para mí",
"runningLow": "Queda poca"
},
"cropCalendar": {
"add": "Calendario de cultivo",
"title": "Calendario de cultivo",
"sow": "Siembra",
"transplant": "Trasplante",
"flowering": "Floración",
"fruiting": "Fructificación",
"seedHarvest": "Cosecha de semilla",
"unset": "—"
},
"needsReproduction": {
"label": "Reproducir esta temporada",
"hint": "Cultívala antes de que se acabe la semilla",
"badge": "Por reproducir"
},
"preservation": {
"add": "Cómo se conserva",
"title": "Cómo se conserva",
"none": "Sin especificar",
"jarWithDesiccant": "Bote con desecante",
"glassJar": "Bote de cristal",
"paperEnvelope": "Sobre de papel",
"paperBag": "Bolsa de papel",
"plasticBag": "Bolsa de plástico"
},
"conditionCheck": {
"advanced": "Conservación y detalles del banco",
"title": "Revisiones de conservación",
"add": "Añadir revisión",
"containers": "Botes / recipientes",
"desiccant": "Desecante",
"none": "Aún no hay revisiones.",
"summary": "{count} bote(s) · {state}"
},
"desiccant": {
"none": "No tiene",
"add": "Se pone",
"replace": "Se cambia",
"dry": "Azul — seco",
"fresh": "Recién puesto"
},
"unit": {
"aFew": "unas pocas",
"some": "algunas",