feat(inventory): CSV/JSON export and JSON import with LWW reconciliation
Interchange export/import for Phase 1 (data-model §7): - JSON: canonical, versioned envelope (formatVersion 1) with all sync metadata verbatim, photos embedded as base64, tombstones excluded. Species are re-resolved on import by scientific name (catalog ids are per-install). Reader tolerates unknown fields/enum values (§5.2) and rejects newer format versions with a clear error. - CSV: export-only spreadsheet flatten, one row per lot, RFC 4180 escaping, never any photo bytes. - Import merges by UUIDv7 id in one transaction: insert-if-unknown preserving original stamps, last-writer-wins by packed HLC for known mutable rows, append-only movements; afterwards the local clock receiveEvent()s the newest imported stamp so it never runs behind. - Settings gains a Backup section (export CSV/JSON, import JSON with confirmation); file dialogs behind a FileService interface backed by file_picker (MIT). - Tests: codec round-trip and tolerance, reconciler LWW, repository export→import round-trip (fresh DB, idempotent re-import, newer-local wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
parent
136ed701a7
commit
2812c99280
25 changed files with 2207 additions and 7 deletions
|
|
@ -43,6 +43,23 @@
|
|||
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
|
||||
"aboutOpen": "About Tanemaki"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"exportCsv": "Export as CSV",
|
||||
"exportCsvSubtitle": "For spreadsheets — no photos",
|
||||
"exportJson": "Export as JSON",
|
||||
"exportJsonSubtitle": "Complete copy, can be imported back",
|
||||
"importJson": "Import from JSON",
|
||||
"importJsonSubtitle": "Merge a saved copy into this inventory",
|
||||
"importConfirmTitle": "Import a saved copy?",
|
||||
"importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.",
|
||||
"importAction": "Import",
|
||||
"exportSaved": "Copy saved",
|
||||
"cancelled": "Cancelled",
|
||||
"importDone": "Imported: {added} new, {updated} updated",
|
||||
"importFailed": "This file could not be read as a Tanemaki copy",
|
||||
"failed": "Something went wrong"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"kanji": "種まき",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,23 @@
|
|||
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
|
||||
"aboutOpen": "Acerca de Tanemaki"
|
||||
},
|
||||
"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",
|
||||
"importConfirmTitle": "¿Importar una copia guardada?",
|
||||
"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.",
|
||||
"importAction": "Importar",
|
||||
"exportSaved": "Copia guardada",
|
||||
"cancelled": "Cancelado",
|
||||
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
|
||||
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
|
||||
"failed": "Algo ha salido mal"
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de",
|
||||
"kanji": "種まき",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 2
|
||||
/// Strings: 336 (168 per locale)
|
||||
/// Strings: 366 (183 per locale)
|
||||
///
|
||||
/// Built on 2026-07-09 at 09:55 UTC
|
||||
/// Built on 2026-07-09 at 10:41 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
late final Translations$photo$en photo = Translations$photo$en.internal(_root);
|
||||
late final Translations$menu$en menu = Translations$menu$en.internal(_root);
|
||||
late final Translations$settings$en settings = Translations$settings$en.internal(_root);
|
||||
late final Translations$backup$en backup = Translations$backup$en.internal(_root);
|
||||
late final Translations$about$en about = Translations$about$en.internal(_root);
|
||||
late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
|
||||
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
|
||||
|
|
@ -209,6 +210,60 @@ class Translations$settings$en {
|
|||
String get aboutOpen => 'About Tanemaki';
|
||||
}
|
||||
|
||||
// Path: backup
|
||||
class Translations$backup$en {
|
||||
Translations$backup$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Backup'
|
||||
String get title => 'Backup';
|
||||
|
||||
/// en: 'Export as CSV'
|
||||
String get exportCsv => 'Export as CSV';
|
||||
|
||||
/// en: 'For spreadsheets — no photos'
|
||||
String get exportCsvSubtitle => 'For spreadsheets — no photos';
|
||||
|
||||
/// en: 'Export as JSON'
|
||||
String get exportJson => 'Export as JSON';
|
||||
|
||||
/// en: 'Complete copy, can be imported back'
|
||||
String get exportJsonSubtitle => 'Complete copy, can be imported back';
|
||||
|
||||
/// en: 'Import from JSON'
|
||||
String get importJson => 'Import from JSON';
|
||||
|
||||
/// en: 'Merge a saved copy into this inventory'
|
||||
String get importJsonSubtitle => 'Merge a saved copy into this inventory';
|
||||
|
||||
/// en: 'Import a saved copy?'
|
||||
String get importConfirmTitle => 'Import a saved copy?';
|
||||
|
||||
/// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'
|
||||
String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.';
|
||||
|
||||
/// en: 'Import'
|
||||
String get importAction => 'Import';
|
||||
|
||||
/// en: 'Copy saved'
|
||||
String get exportSaved => 'Copy saved';
|
||||
|
||||
/// en: 'Cancelled'
|
||||
String get cancelled => 'Cancelled';
|
||||
|
||||
/// en: 'Imported: {added} new, {updated} updated'
|
||||
String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated';
|
||||
|
||||
/// en: 'This file could not be read as a Tanemaki copy'
|
||||
String get importFailed => 'This file could not be read as a Tanemaki copy';
|
||||
|
||||
/// en: 'Something went wrong'
|
||||
String get failed => 'Something went wrong';
|
||||
}
|
||||
|
||||
// Path: about
|
||||
class Translations$about$en {
|
||||
Translations$about$en.internal(this._root);
|
||||
|
|
@ -976,6 +1031,21 @@ extension on Translations {
|
|||
'settings.about' => 'About',
|
||||
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
||||
'settings.aboutOpen' => 'About Tanemaki',
|
||||
'backup.title' => 'Backup',
|
||||
'backup.exportCsv' => 'Export as CSV',
|
||||
'backup.exportCsvSubtitle' => 'For spreadsheets — no photos',
|
||||
'backup.exportJson' => 'Export as JSON',
|
||||
'backup.exportJsonSubtitle' => 'Complete copy, can be imported back',
|
||||
'backup.importJson' => 'Import from JSON',
|
||||
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory',
|
||||
'backup.importConfirmTitle' => 'Import a saved copy?',
|
||||
'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.',
|
||||
'backup.importAction' => 'Import',
|
||||
'backup.exportSaved' => 'Copy saved',
|
||||
'backup.cancelled' => 'Cancelled',
|
||||
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated',
|
||||
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
|
||||
'backup.failed' => 'Something went wrong',
|
||||
'about.title' => 'About',
|
||||
'about.kanji' => '種まき',
|
||||
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
|
||||
@override late final _Translations$menu$es menu = _Translations$menu$es._(_root);
|
||||
@override late final _Translations$settings$es settings = _Translations$settings$es._(_root);
|
||||
@override late final _Translations$backup$es backup = _Translations$backup$es._(_root);
|
||||
@override late final _Translations$about$es about = _Translations$about$es._(_root);
|
||||
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
|
||||
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
|
||||
|
|
@ -144,6 +145,30 @@ class _Translations$settings$es extends Translations$settings$en {
|
|||
@override String get aboutOpen => 'Acerca de Tanemaki';
|
||||
}
|
||||
|
||||
// Path: backup
|
||||
class _Translations$backup$es extends Translations$backup$en {
|
||||
_Translations$backup$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Copia de seguridad';
|
||||
@override String get exportCsv => 'Exportar a CSV';
|
||||
@override String get exportCsvSubtitle => 'Para hojas de cálculo — sin fotos';
|
||||
@override String get exportJson => 'Exportar a JSON';
|
||||
@override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar';
|
||||
@override String get importJson => 'Importar desde JSON';
|
||||
@override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario';
|
||||
@override String get importConfirmTitle => '¿Importar una copia guardada?';
|
||||
@override String get 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.';
|
||||
@override String get importAction => 'Importar';
|
||||
@override String get exportSaved => 'Copia guardada';
|
||||
@override String get cancelled => 'Cancelado';
|
||||
@override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas';
|
||||
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
|
||||
@override String get failed => 'Algo ha salido mal';
|
||||
}
|
||||
|
||||
// Path: about
|
||||
class _Translations$about$es extends Translations$about$en {
|
||||
_Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
|
@ -661,6 +686,21 @@ extension on TranslationsEs {
|
|||
'settings.about' => 'Acerca de',
|
||||
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
|
||||
'settings.aboutOpen' => 'Acerca de Tanemaki',
|
||||
'backup.title' => 'Copia de seguridad',
|
||||
'backup.exportCsv' => 'Exportar a CSV',
|
||||
'backup.exportCsvSubtitle' => 'Para hojas de cálculo — sin fotos',
|
||||
'backup.exportJson' => 'Exportar a JSON',
|
||||
'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
|
||||
'backup.importJson' => 'Importar desde JSON',
|
||||
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario',
|
||||
'backup.importConfirmTitle' => '¿Importar una copia guardada?',
|
||||
'backup.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.',
|
||||
'backup.importAction' => 'Importar',
|
||||
'backup.exportSaved' => 'Copia guardada',
|
||||
'backup.cancelled' => 'Cancelado',
|
||||
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas',
|
||||
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
|
||||
'backup.failed' => 'Algo ha salido mal',
|
||||
'about.title' => 'Acerca de',
|
||||
'about.kanji' => '種まき',
|
||||
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue