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
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue