Commit 38fb258

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2023-12-08 15:39:32
implement CsvConverter
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 8cdf078
lib/model/export_import/csv_converter.dart
@@ -0,0 +1,115 @@
+
+import 'package:blood_pressure_app/model/blood_pressure.dart';
+import 'package:blood_pressure_app/model/export_import/column.dart';
+import 'package:blood_pressure_app/model/export_import/legacy_column.dart' show RowDataFieldType;
+import 'package:blood_pressure_app/model/export_import/record_parsing_result.dart';
+import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
+import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
+import 'package:collection/collection.dart';
+import 'package:csv/csv.dart';
+import 'package:flutter/material.dart';
+
+/// Utility class to convert between csv strings and [BloodPressureRecord]s.
+class CsvConverter {
+  /// Create converter between csv strings and [BloodPressureRecord] values that respects settings.
+  CsvConverter(this.settings, this.availableColumns);
+
+  /// Settings that apply for ex- and import.
+  final CsvExportSettings settings;
+
+  /// Columns manager used for ex- and import.
+  final ExportColumnsManager availableColumns;
+
+  /// Create the contents of a csv file from passed records.
+  String create(List<BloodPressureRecord> records) {
+    final columns = settings.exportFieldsConfiguration.getActiveColumns(availableColumns);
+    final table = records.map(
+      (record) => columns.map(
+        (column) => column.encode(record)
+      ).toList()
+    ).toList();
+
+    final csvCreator = ListToCsvConverter(
+        fieldDelimiter: settings.fieldDelimiter,
+        textDelimiter: settings.textDelimiter
+    );
+
+    return csvCreator.convert(table);
+  }
+
+  /// Attempts to parse a csv string.
+  /// 
+  /// Validates that the first line of the file contains columns present 
+  /// in [availableColumns]. When a column is present multiple times only 
+  /// the first one counts.
+  /// A needle pin takes precedent over a color.
+  RecordParsingResult parse(String csvString) {
+    // Turn csv into lines.
+    final lines = (){
+        final converter = CsvToListConverter(
+          fieldDelimiter: settings.fieldDelimiter,
+          textDelimiter: settings.textDelimiter,
+          shouldParseNumbers: false,
+        );
+        final csvLines = converter.convert(csvString, eol: '\r\n');
+        if (csvLines.length < 2) return converter.convert(csvString, eol: '\n');
+        return csvLines;
+    }();
+    if (lines.length < 2) return RecordParsingResult.err(RecordParsingErrorType.emptyFile);
+
+    // Get and validate columns from csv title.
+    final List<ExportColumn> columns = [];
+    for (final titleText in lines.removeAt(0)) {
+      assert(titleText is String);
+      final column = availableColumns.firstWhere(
+              (c) => c.csvTitle == titleText
+                  && c.restoreAbleType != null);
+      if (column == null) return RecordParsingResult.err(RecordParsingErrorType.unknownColumn);
+      columns.add(column);
+    }
+    if (columns.where((e) => e.restoreAbleType == RowDataFieldType.timestamp).isEmpty) {
+      return RecordParsingResult.err(RecordParsingErrorType.timeNotRestoreable);
+    }
+
+    // Convert data to records.
+    final List<BloodPressureRecord> records = [];
+    for (final currentLine in lines) {
+      if (currentLine.length < columns.length) {
+        return RecordParsingResult.err(RecordParsingErrorType.expectedMoreFields);
+      }
+      
+      final List<(RowDataFieldType, dynamic)> recordPieces = [];
+      for (int idx = 0; idx < columns.length; idx++) {
+        assert(currentLine[idx] is String);
+        final piece = columns[idx].decode(currentLine[idx]);
+        if (piece?.$1 != columns[idx].restoreAbleType) { // validation
+          return RecordParsingResult.err(RecordParsingErrorType.unparsableField);
+        }
+        if (piece != null) recordPieces.add(piece);
+      }
+      
+      final DateTime timestamp = recordPieces.firstWhere(
+              (piece) => piece.$1 == RowDataFieldType.timestamp).$2;
+      final int? sys = recordPieces.firstWhereOrNull(
+              (piece) => piece.$1 == RowDataFieldType.sys)?.$2;
+      final int? dia = recordPieces.firstWhereOrNull(
+              (piece) => piece.$1 == RowDataFieldType.dia)?.$2;
+      final int? pul = recordPieces.firstWhereOrNull(
+              (piece) => piece.$1 == RowDataFieldType.pul)?.$2;
+      final String note = recordPieces.firstWhereOrNull(
+              (piece) => piece.$1 == RowDataFieldType.notes)?.$2 ?? '';
+      MeasurementNeedlePin? needlePin = recordPieces.firstWhereOrNull(
+              (piece) => piece.$1 == RowDataFieldType.needlePin)?.$2;
+      if (needlePin == null) {
+        final Color? color = recordPieces.firstWhereOrNull(
+                (piece) => piece.$1 == RowDataFieldType.color)?.$2;
+        if (color != null) needlePin = MeasurementNeedlePin(color);
+      }
+
+      records.add(BloodPressureRecord(timestamp, sys, dia, pul, note, needlePin: needlePin));
+    }
+    
+    assert(records.length == lines.length, 'every line should have been parse'); // first line got removed
+    return RecordParsingResult.ok(records);
+  }
+}
lib/model/export_import/legacy_column.dart
@@ -95,6 +95,7 @@ enum RowDataFieldType {
   /// Guarantees [String] is returned.
   notes,
   @Deprecated('use needlePin instead') // TODO: implement conversion to needle pin?
+  /// Guarantees [Color] is returned.
   color,
   /// Guarantees that the returned type is of type [MeasurementNeedlePin].
   needlePin;  // TODO implement in ScriptedFormatter
lib/model/export_import/record_formatter.dart
@@ -38,7 +38,6 @@ class ScriptedFormatter implements Formatter {
 
   @override
   (RowDataFieldType, dynamic)? decode(String formattedRecord) {
-    print('$pattern.decode($formattedRecord)');
     if (restoreAbleType == null) return null;
 
     final valueRegex = RegExp(pattern.replaceAll(RegExp(r'\$(TIMESTAMP|COLOR|SYS|DIA|PUL|NOTE)'), '(?<value>.*)'),);
lib/model/export_import/record_parsing_result.dart
@@ -34,14 +34,22 @@ class RecordParsingResult {
   }
 }
 
+// TODO: consider converting to sealed class to allow passing error details.
 /// Indicates what type error occurred while trying to decode a csv data.
 enum RecordParsingErrorType {
   /// There are not enough lines in the csv file to parse the record.
   emptyFile,
 
-  formatNotReversible,
+  /// There is no column with this csv title that can be reversed.
+  unknownColumn,
 
-  /// There is no column with this csv title.
-  unknownColumn
+  /// The current line has less fields than the first line.
+  expectedMoreFields,
+
+  /// There is no column that allows restoring a timestamp.
+  timeNotRestoreable,
+
+  /// The corresponding column couldn't decode a specific field in the csv file.
+  unparsableField,
   // TODO ...
 }
\ No newline at end of file
lib/model/storage/export_columns_store.dart
@@ -41,13 +41,23 @@ class ExportColumnsManager extends ChangeNotifier { // TODO: separate ExportColu
     notifyListeners();
   }
 
+  // TODO test
   /// Get any defined column (user or build in) by identifier.
-  ExportColumn? getColumn(String identifier) {// TODO test
-    return userColumns[identifier]
-        ?? NativeColumn.allColumns.where(
-            (c) => c.internalIdentifier == identifier).firstOrNull
-        ?? BuildInColumn.allColumns.where(
-                (c) => c.internalIdentifier == identifier).firstOrNull; // ?? ...
+  ExportColumn? getColumn(String identifier) => 
+      firstWhere((c) => c.internalIdentifier == identifier);
+  
+  // TODO test
+  /// Get the first of column that satisfies [test].
+  /// 
+  /// Checks in the order: 
+  /// 1. userColumns 
+  /// 2. NativeColumn 
+  /// 3. BuildInColumn
+  ExportColumn? firstWhere(bool Function(ExportColumn) test) {
+    return userColumns.values.where(test).firstOrNull
+        ?? NativeColumn.allColumns.where(test).firstOrNull
+        ?? BuildInColumn.allColumns.where(test).firstOrNull; 
+        // ?? ...
   }
 
   String toJson() { // TODO: update from and TO json to new style