diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dde4b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Files and directories created by pub +.dart_tool/ +.packages + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ + +# WebStorm IDE +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0124dbc --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# CSV Pretty Printer + +Formats a Comma Separated Variables (CSV) file into HTML. + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a686c1b --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,14 @@ +# Defines a default set of lint rules enforced for +# projects at Google. For details and rationale, +# see https://github.com/dart-lang/pedantic#enabled-lints. +include: package:pedantic/analysis_options.yaml + +# For lint rules and documentation, see http://dart-lang.github.io/linter/lints. +# Uncomment to specify additional rules. +# linter: +# rules: +# - camel_case_types + +analyzer: +# exclude: +# - path/to/excluded/files/** diff --git a/bin/csv2html.dart b/bin/csv2html.dart new file mode 100755 index 0000000..7a5c8f4 --- /dev/null +++ b/bin/csv2html.dart @@ -0,0 +1,226 @@ +#!/usr/bin/env dart --no-sound-null-safety +// Pretty prints CSV into HTML. + +import 'dart:io'; + +// ignore: import_of_legacy_library_into_null_safe +import 'package:args/args.dart'; + +// ignore: import_of_legacy_library_into_null_safe +import 'package:path/path.dart' as p; + +import 'package:csv2html/csv_data.dart'; + +final _name = 'csv2html'; +final _version = '1.0.0'; + +//################################################################ +/// Configuration + +class Config { + //================================================================ + // Constructors + + //---------------------------------------------------------------- + /// Create config from parsing command line arguments. + + factory Config.parse(List args) { + try { + const _oParamTemplate = 'template'; + const _oParamOutput = 'output'; + + const _oParamIncludeRecords = 'records'; + const _oParamIncludeRecordsContents = 'contents'; + const _oParamIncludeProperties = 'properties'; + const _oParamIncludePropertiesIndex = 'index'; + + const _oParamQuiet = 'quiet'; + const _oParamVersion = 'version'; + const _oParamHelp = 'help'; + + final parser = ArgParser(allowTrailingOptions: true) + ..addOption(_oParamTemplate, + abbr: 't', help: 'template', valueHelp: 'FILE') + ..addOption(_oParamOutput, + abbr: 'o', help: 'output file', valueHelp: 'FILE') + ..addFlag(_oParamIncludeRecords, + help: 'include records', negatable: true, defaultsTo: true) + ..addFlag(_oParamIncludeRecordsContents, + help: 'include contents of records', + negatable: true, + defaultsTo: true) + ..addFlag(_oParamIncludeProperties, + help: 'include properties', negatable: true, defaultsTo: true) + ..addFlag(_oParamIncludePropertiesIndex, + help: 'include index of properties', + negatable: true, + defaultsTo: true) + ..addFlag(_oParamVersion, + help: 'display version information and exit', negatable: false) + ..addFlag(_oParamQuiet, help: 'do not show warnings', negatable: false) + ..addFlag(_oParamHelp, + abbr: 'h', help: 'display this help and exit', negatable: false); + + final results = parser.parse(args); + + // ignore: avoid_as + if (results[_oParamHelp] as bool) { + stdout.write('Usage: $_name [options]\n${parser.usage}\n'); + exit(0); + } + + // ignore: avoid_as + if (results[_oParamVersion] as bool) { + stdout.write('$_name $_version\n'); + exit(0); + } + + // ignore: avoid_as + final incRecords = results[_oParamIncludeRecords] as bool; + + // ignore: avoid_as + final incRecordsContents = results[_oParamIncludeRecordsContents] as bool; + + // ignore: avoid_as + final incProperties = results[_oParamIncludeProperties] as bool; + + // ignore: avoid_as + final incPropertiesIndex = results[_oParamIncludePropertiesIndex] as bool; + + // ignore: avoid_as + final quiet = results[_oParamQuiet] as bool; + + // Template + + final templateFilename = results[_oParamTemplate] as String; + + final outFile = results[_oParamOutput] as String; + + // Data filename + + String dataFilename; + + final rest = results.rest; + if (rest.isEmpty) { + stderr.write('$_name: missing CSV data filename (-h for help)\n'); + exit(2); + } else if (rest.length == 1) { + dataFilename = rest.first; + } else { + stderr.write('$_name: too many arguments\n'); + exit(2); + } + + // Title + + return Config._(dataFilename, templateFilename, outFile, + includeRecords: incRecords, + includeRecordsContents: incRecordsContents, + includeProperties: incProperties, + includePropertiesIndex: incPropertiesIndex, + quiet: quiet); + } on FormatException catch (e) { + stderr.write('$_name: usage error: ${e.message}\n'); + exit(2); + } + } + + //---------------------------------------------------------------- + + Config._(this.dataFilename, this.templateFilename, this.outFilename, + {required this.includeRecords, + required this.includeRecordsContents, + required this.includeProperties, + required this.includePropertiesIndex, + required this.quiet}); + + //================================================================ + // Members + + /// CSV filename + final String dataFilename; + + /// Optional template filename + final String? templateFilename; + + /// Optional output filename + final String? outFilename; + + final bool includeRecords; + + final bool includeRecordsContents; + + final bool includeProperties; + + final bool includePropertiesIndex; + + /// Quiet mode + final bool quiet; +} + +//################################################################ + +void main(List arguments) { + // Parse command line + + final config = Config.parse(arguments); + + try { + // Load + + final data = CsvData.load(File(config.dataFilename).readAsStringSync()); + + final defaultTitle = p.split(config.dataFilename).last; + + RecordTemplate template; + + final tName = config.templateFilename; + if (tName != null) { + final f = File(tName); + template = RecordTemplate.load(f.readAsStringSync()); + } else { + template = RecordTemplate(data); + } + + // Output destination + + final outFile = config.outFilename; + final out = (outFile != null) ? File(outFile).openWrite() : stdout; + + // Process + + final fmt = Formatter(template, + includeRecords: config.includeRecords, + includeRecordsContents: config.includeRecordsContents, + includeProperties: config.includeProperties, + includePropertiesIndex: config.includePropertiesIndex); + + final warnings = fmt.toHtml(data, defaultTitle, out, + timestamp: File(config.dataFilename).lastModifiedSync()); + + // Show warnings + + if (!config.quiet) { + final unused = template.unusedProperties(data).toList()..sort(); + for (final name in unused) { + stderr.write('Warning: property not in template: $name\n'); + } + + for (final w in warnings) { + stderr.write('Warning: property "${w.propertyName}"' + ': no enumeration for value: "${w.value}"\n'); + } + } + + out.close(); + } on CsvDataException catch (e) { + stderr.write('Error: ${config.dataFilename}: $e\n'); + exit(1); + } on TemplateException catch (e) { + stderr.write('Error: ${config.templateFilename}: $e\n'); + exit(1); + } on PropertyNotInDataException catch (e) { + stderr.write('Error: ${config.templateFilename}: $e\n'); + exit(1); + } +} diff --git a/lib/csv_data.dart b/lib/csv_data.dart new file mode 100644 index 0000000..3f02933 --- /dev/null +++ b/lib/csv_data.dart @@ -0,0 +1,11 @@ +library csv_data; + +import 'dart:convert'; +import 'dart:io'; + +// ignore: import_of_legacy_library_into_null_safe +import 'package:csv/csv.dart'; + +part 'src/csv_data.dart'; +part 'src/template.dart'; +part 'src/format_html.dart'; diff --git a/lib/src/csv_data.dart b/lib/src/csv_data.dart new file mode 100644 index 0000000..8c3bba3 --- /dev/null +++ b/lib/src/csv_data.dart @@ -0,0 +1,189 @@ +part of csv_data; + +//################################################################ +/// Exception thrown when the CSV data is not valid. + +class CsvDataException implements Exception { + CsvDataException(this.lineNum, this.message); + + /// Line in the CSV that contains the problem. + final int lineNum; + + /// Description of the problem. + final String message; +} + +//################################################################ +/// Record from the CSV. +/// +/// A **record** represents a row from the CSV file. + +class Record { + Record(this._rowNumber); + + final int _rowNumber; + + final Map _fields = {}; + + void operator []=(String name, String value) { + _fields[name] = value; + } + + String operator [](String name) { + return _fields[name] ?? ''; + } + + String get identifier => 'r$_rowNumber'; +} + +//################################################################ +/// Data from the CSV. +/// +/// CSV data consists of a sequence of records with values corresponding to +/// named properties. These are available through [records] and [propertyNames]. +/// +/// The property names are extracted from the first row of the [csvText] and +/// the records are extracted from all the other rows. Every field in the +/// first row must be non-blank and must be unique. The columns correspond to +/// the properties. All the other rows must have no more fields than the number +/// of properties named in the first row. If there are less fields, the value of +/// the remaining properties in the record are assigned the empty string. + +class CsvData { + //================================================================ + // Constructors + + //---------------------------------------------------------------- + /// Parses the [text] as Comma Separated Variables (CSV) data. + + factory CsvData.load(String csvText, {String eol = '\n'}) { + final data = CsvToListConverter(eol: eol, shouldParseNumbers: false) + .convert(csvText); + + // Extract property names from the header row + + final propertyNames = []; + + var column = 0; + for (final name in data.first.map((s) => s.trim())) { + column++; + + if (name is String) { + if (name.isNotEmpty) { + propertyNames.add(name); + } else { + throw CsvDataException(1, 'column $column: blank property name'); + } + } else { + throw CsvDataException(1, 'column $column: property name not string'); + } + } + + // Check all property names are unique + + final seen = {}; + for (final name in propertyNames) { + if (seen.contains(name)) { + throw CsvDataException(1, 'duplicate property name: $name'); + } else { + seen.add(name); + } + } + + // Extract records from all the other rows + + final records = []; + + var lineNum = 1; // skipping header row + for (final row in data.getRange(1, data.length)) { + lineNum++; + + final record = Record(lineNum); + + // Assign fields to property values + + var index = 0; + + while (index < row.length && index < propertyNames.length) { + record[propertyNames[index]] = row[index].trim(); + index++; + } + + // If there are extra fields, they must all be blank + + while (index < row.length) { + if (row[index].trim.isNotEmpty) { + throw CsvDataException( + lineNum, 'column ${index + 1}: more fields than properties'); + } + index++; + } + + records.add(record); + + /* Do we want to ignore totally empty records? + if (row.any((v) => v.trim().isNotEmpty)) { + records.add(record); + } */ + } + + // Create the object + + return CsvData._init(propertyNames, records); + } + + //---------------------------------------------------------------- + /// Internal constructor. + + CsvData._init(this._propertyNames, this._records); + + //================================================================ + // Members + + //---------------------------------------------------------------- + + final List _propertyNames; + + final List _records; + + //================================================================ + + //---------------------------------------------------------------- + /// The names of the properties. + + Iterable get propertyNames => _propertyNames; + + //---------------------------------------------------------------- + /// The records. + + Iterable get records => _records; + + //---------------------------------------------------------------- + /// Sort the records using the sort properties. + + void sort(Iterable sortProperties) { + if (sortProperties.isNotEmpty) { + // Perform sorting (putting empty values at the end) + + _records.sort((a, b) { + for (final propertyName in sortProperties) { + final v1 = a[propertyName]; + final v2 = b[propertyName]; + + if (v1.isNotEmpty && v2.isNotEmpty) { + final c = v1.compareTo(v2); + if (c != 0) { + return c; + } + } else if (v1.isNotEmpty) { + return -1; // v2 is empty + } else if (v2.isNotEmpty) { + return 1; // v1 is empty + } + } // all sort properties + + return 0; // equal + }); + } + } +} diff --git a/lib/src/format_html.dart b/lib/src/format_html.dart new file mode 100644 index 0000000..207668b --- /dev/null +++ b/lib/src/format_html.dart @@ -0,0 +1,638 @@ +part of csv_data; + +//################################################################ + +class PropertyNotInDataException implements Exception { + PropertyNotInDataException(this.propertyName); + + final String propertyName; + + @override + String toString() => 'Property in template, but not in data: $propertyName'; +} + +//################################################################ +/// Indicates an enumeration is missing a value. + +class NoEnumeration { + NoEnumeration(this.propertyName, this.value); + + final String propertyName; + final String value; +} + +//################################################################ + +class Formatter { + //================================================================ + + Formatter(this._template, + {this.includeRecords = true, + this.includeRecordsContents = true, + this.includeProperties = true, + this.includePropertiesIndex = true}); + + //================================================================ + + /// Show the records (and maybe the table of contents). + + final bool includeRecords; + + /// Show the table of contents (only if [includeRecords] is also true). + + final bool includeRecordsContents; + + /// Show the properties (and maybe the index of properties). + + final bool includeProperties; + + /// Show the index of properties (only if [includeProperties] is also true). + + final bool includePropertiesIndex; + + /// The template to interpret the CSV data. + + final RecordTemplate _template; + + //================================================================ + + //---------------------------------------------------------------- + /// Produce HTML. + /// + /// Format the [data] as HTML and write the HTML to [buf]. + /// + /// The [defaultTitle] will be used as the title, if the template does not + /// have a title. + /// + /// If a value for [timestamp] is provided, the footer shows it. Usually, + /// the caller can pass in the last modified date of the CSV data as the + /// timestamp to display. + + List toHtml(CsvData data, String defaultTitle, IOSink buf, + {DateTime? timestamp}) { + final propertyId = _checkProperties(data); + + data.sort(_template.sortProperties); // sort the records + + final warnings = []; + + // Generate HTML + + _showHead(buf, defaultTitle); + + if (includeRecords) { + if (includeRecordsContents) { + _showRecordContents(data, buf); + } + + _showRecords(data, propertyId, buf, warnings); + } + + if (includeProperties) { + _showProperties(data, propertyId, buf, warnings); + if (includePropertiesIndex) { + _propertiesIndex(data, propertyId, buf); + } + } + + _showFooter(timestamp, buf); + + return warnings; + } + + //---------------- + /// Assign IDs to all the properties. + /// + /// Returns a map from property name to property ID. + /// + /// Also checks if all the properties in the template's items appear in the + /// [data]. Throws a [PropertyNotInDataException] if the template refers to + /// a property that doesn't exist in the data. + /// + /// The IDs will be used as fragment identifiers in the HTML. + + Map _checkProperties(CsvData data) { + // Assign identifiers to each property name + + final propertyId = {}; + var count = 0; + for (final name in data.propertyNames) { + count++; + propertyId[name] = 'p$count'; + } + + // Check all template items refer to properties that exist in the data + + for (final item in _template.items) { + if (item is TemplateItemScalar) { + if (!propertyId.containsKey(item.propertyName)) { + throw PropertyNotInDataException(item.propertyName); + } + } else if (item is TemplateItemGroup) { + for (final m in item.members) { + if (!propertyId.containsKey(m.propertyName)) { + throw PropertyNotInDataException(m.propertyName); + } + } + } else if (item is TemplateItemIgnore) { + if (!propertyId.containsKey(item.propertyName)) { + throw PropertyNotInDataException(item.propertyName); + } + } else { + assert(false, 'unexpected class: ${item.runtimeType}'); + } + } + + // Check sort properties all exist in the data + + for (final name in _template.sortProperties) { + if (!data.propertyNames.contains(name)) { + TemplateException(0, 'sort property not in data: $name'); + } + } + + // Check identifier properties all exist in the data + + for (final name in _template.identifierProperties) { + if (!data.propertyNames.contains(name)) { + TemplateException(0, 'identifier property not in data: $name'); + } + } + + // Return map from property name to property ID + + return propertyId; + } + + //---------------- + + void _showHead(IOSink buf, String defaultTitle) { + final title = _template.title.isNotEmpty ? _template.title : defaultTitle; + + buf.write(''' + + + + + + + + + + + +
+

${hText(title)}

+'''); + + if (_template.subtitle.isNotEmpty) { + buf.write('

${hText(_template.subtitle)}

\n'); + } + + buf.write('
\n\n'); + } + + //---------------------------------------------------------------- + + void _showRecordContents(CsvData records, IOSink buf) { + // Table of contents + + buf.write('
\n

Contents

\n\n'); + + for (final entry in records.records) { + buf.write(''); + _showIdentitiesInTd(entry, buf); + buf.write('\n'); + } + + buf.write('
\n
\n\n'); + } + + //---------------- + + void _showIdentitiesInTd(Record record, IOSink buf) { + for (final property in _template.identifierProperties) { + final value = record[property]; + final valueHtml = (value.isNotEmpty) ? hText(value) : '—'; + + buf.write(''); + if (includeRecords) { + buf.write('$valueHtml\n'); + } else { + buf.write(valueHtml); + } + buf.write('\n'); + } + } + + //---------------------------------------------------------------- + + void _showRecords(CsvData records, Map propertyId, IOSink buf, + List warnings) { + // Records + + buf.write('

Records

\n\n'); + + for (final entry in records.records) { + _showRecord(entry, propertyId, buf, warnings); + } + + buf.write('\n\n\n'); + } + + //---------------- + + void _showRecord(Record record, Map propertyId, IOSink buf, + List warnings) { + // Heading + + var hasHeadingValue = false; + + buf.write('
\n'); + + buf.write('

'); + for (var x = 0; x < _template.identifierProperties.length - 1; x++) { + final preTitle = record[_template.identifierProperties[x]]; + if (preTitle.isNotEmpty) { + buf.write('${hText(preTitle)}
\n'); + hasHeadingValue = true; + } + } + final mainTitle = record[_template.identifierProperties.last]; + if (mainTitle.isNotEmpty) { + buf.write(hText(mainTitle)); + hasHeadingValue = true; + } + + if (!hasHeadingValue) { + buf.write('(Untitled)'); + } + buf.write('

\n'); + + // Entry data + + buf.write('\n'); + + for (final item in _template.items) { + if (item is TemplateItemScalar) { + _showRecordSingular(item, record, propertyId, buf, warnings); + } else if (item is TemplateItemGroup) { + _showRecordGroup(item, record, propertyId, buf); + } else if (item is TemplateItemIgnore) { + // ignore + } + } + + buf.write('
\n
\n\n'); + } + + //---------------- + + void _showRecordSingular( + TemplateItemScalar item, + Record record, + Map propertyId, + IOSink buf, + List warnings) { + final value = record[item.propertyName]; + + if (value.isNotEmpty) { + // Property name + + buf.write(''); + + if (includeProperties) { + final id = propertyId[item.propertyName]; + buf.write('${hText(item.displayText)}'); + } else { + buf.write(hText(item.displayText)); + } + buf.write(''); + + _value(item.propertyName, value, item.enumerations, buf, warnings); + + buf.write('\n'); + } + } + + void _value( + String propertyName, + String value, + Map? enumerations, + IOSink buf, + List warnings) { + var displayValue = value; + String? title; + if (enumerations != null && value.isNotEmpty) { + if (enumerations.containsKey(value)) { + displayValue = enumerations[value]!; + title = value; + } else { + warnings.add(NoEnumeration(propertyName, value)); + } + } + + buf.write('' + '${hText(displayValue)}'); + } + //---------------- + + void _showRecordGroup(TemplateItemGroup item, Record entry, + Map propertyId, IOSink buf) { + var started = false; + + for (final member in item.members) { + final value = entry[member.propertyName]; + + if (value.isNotEmpty) { + if (!started) { + buf.write('${hText(item.displayText)}\n'); + started = true; + } + + if (member.displayText.isNotEmpty) { + buf.write(''); + + if (includeProperties) { + final id = propertyId[member.propertyName]; + buf.write('${hText(member.displayText)}'); + } else { + buf.write(hText(member.displayText)); + } + + buf.write(''); + } + + var displayValue = value; + if (member.enumerations != null) { + if (member.enumerations!.containsKey(value)) { + displayValue = member.enumerations![value]!; + buf.write('${hText(displayValue)}: '); + } + } + + buf.write('${hText(displayValue)}
\n'); + } + } + + if (started) { + buf.write('\n\n'); + } + } + + //---------------------------------------------------------------- + + void _showProperties(CsvData records, Map propertyId, + IOSink buf, List warnings) { + // Dump properties used in the template items + + buf.write('
\n

Properties

\n\n'); + + final usedColumns = {}; + + for (final item in _template.items) { + if (item is TemplateItemScalar) { + _propertySimple( + item, propertyId, records.records, buf, usedColumns, warnings); + } else if (item is TemplateItemGroup) { + _propertyGroup( + item, propertyId, records.records, buf, usedColumns, warnings); + } else if (item is TemplateItemIgnore) { + _propertyUnused( + item, propertyId, records.records, buf, usedColumns, warnings); + } else { + assert(false, 'unexpected class: ${item.runtimeType}'); + } + } + + // Dump properties not in the template items + + for (final propertyName in records.propertyNames) { + if (!usedColumns.contains(propertyName)) { + final id = propertyId[propertyName]!; + buf.write('

' + '${hText(propertyName)}

\n\n'); + + for (final entry in records.records) { + final value = entry[propertyName]; + if (value.isNotEmpty) { + buf.write(''); + _showIdentitiesInTd(entry, buf); + buf.write('\n'); + } + } + buf.write('
${hText(value)}
\n\n'); + } + } + + buf.write('
\n'); + } + + //---------------- + + void _propertySimple( + TemplateItemScalar item, + Map propertyId, + Iterable records, + IOSink buf, + Set usedColumns, + List warnings) { + final id = propertyId[item.propertyName]!; + buf.write('
\n' + '

${hText(item.propertyName)}

\n'); + + buf.write('\n'); + + for (final entry in records) { + buf.write(''); + _showIdentitiesInTd(entry, buf); + _value(item.propertyName, entry[item.propertyName], item.enumerations, + buf, warnings); + buf.write('\n'); + } + + buf.write('
\n' + '\n\n'); + + usedColumns.add(item.propertyName); + } + + //---------------- + + void _propertyGroup( + TemplateItemGroup item, + Map propertyId, + Iterable records, + IOSink buf, + Set usedColumns, + List warnings) { + for (final member in item.members) { + _propertySimple(member, propertyId, records, buf, usedColumns, warnings); + } + } + + //---------------- + + void _propertyUnused( + TemplateItemIgnore item, + Map propertyId, + Iterable records, + IOSink buf, + Set usedColumns, + List warnings) { + final id = propertyId[item.propertyName]!; + buf.write('
\n' + '

${hText(item.propertyName)}

\n'); + + buf.write('\n'); + + for (final entry in records) { + buf.write(''); + _showIdentitiesInTd(entry, buf); + _value(item.propertyName, entry[item.propertyName], item.enumerations, + buf, warnings); + buf.write('\n'); + } + + buf.write('
\n
\n\n'); + + usedColumns.add(item.propertyName); + } + + void _propertiesIndex( + CsvData data, Map propertyId, IOSink buf) { + if (includePropertiesIndex) { + // Property index + + final orderedPropertyNames = data.propertyNames.toList()..sort(); + + buf.write('\n

Index

\n
    \n'); + for (final v in orderedPropertyNames) { + final id = propertyId[v]!; + buf.write('
  1. ${hText(v)}
  2. \n'); + } + buf.write('
\n
\n\n'); + } + } + + //---------------------------------------------------------------- + + void _showFooter(DateTime? timestamp, IOSink buf) { + // Visible footer + + buf.write('
\n'); + + if (timestamp != null) { + final ts = timestamp.toIso8601String().substring(0, 10); + buf.write('

${hText(ts)}

\n'); + } + + buf.write(''' +
+ + + + + +'''); + } + + //================================================================ + // Escape functions for HTML + + static final _htmlEscapeText = HtmlEscape(HtmlEscapeMode.element); + static final _htmlEscapeAttr = HtmlEscape(HtmlEscapeMode.attribute); + + /// Escape string for use in HTML attributes + + static String hText(String s) => _htmlEscapeText.convert(s); + + /// Escape string for use in HTML content + + static String hAttr(String s) => _htmlEscapeAttr.convert(s); +} diff --git a/lib/src/template.dart b/lib/src/template.dart new file mode 100644 index 0000000..37ef667 --- /dev/null +++ b/lib/src/template.dart @@ -0,0 +1,266 @@ +part of csv_data; + +//################################################################ +/// Base class for a template item. + +abstract class TemplateItem {} + +//################################################################ +/// Item to indicate a property is to be ignored. + +class TemplateItemIgnore extends TemplateItem { + TemplateItemIgnore(this.propertyName, this.enumerations); + + final String propertyName; + final Map? enumerations; +} + +//################################################################ +/// Item to indicate a single property. + +class TemplateItemScalar extends TemplateItem { + TemplateItemScalar(this.propertyName, this.displayText, this.enumerations); + + final String propertyName; + + final Map? enumerations; + + final String displayText; +} + +//################################################################ +/// Item to indicate a group of properties. + +class TemplateItemGroup extends TemplateItem { + TemplateItemGroup(this.displayText, this.members); + + final String displayText; + + final List members; +} + +//################################################################ +/// Exception thrown when there is a problem with a template. + +class TemplateException implements Exception { + TemplateException(this.lineNum, this.message); + + final int lineNum; + final String message; +} + +//################################################################ +/// Template for a record. +/// +/// A template identifies properties and how they are to be interpreted. +/// +/// The main part of a template is an ordered list of [items]. These identify +/// individual properties [TemplateItemScalar], groups of properties +/// [TemplateItemGroup], or properties that are to be ignored +/// [TemplateItemIgnore]. +/// +/// The template has a list of [sortProperties] that can be used to sort +/// the records and a list of [identifierProperties] that are used to identify +/// a record. +/// +/// The template also specifies a [title] and [subtitle]. + +class RecordTemplate { + //================================================================ + + //---------------------------------------------------------------- + /// Create a default template based on the CSV data. + /// + /// The default template uses each of the data's property names as an item + /// (in the same order), and the first property as the identifying property. + /// There are no sort properties. + + RecordTemplate(CsvData data) { + for (final propertyName in data.propertyNames) { + items.add(TemplateItemScalar(propertyName, propertyName, null)); + + if (identifierProperties.isEmpty) { + // Use first property as the identifier + identifierProperties.add(propertyName); + } + } + } + + //---------------------------------------------------------------- + /// Create a template by loading it from a file. + + RecordTemplate.load(String specification) { + // Parse specification as CSV + + final data = CsvToListConverter(eol: '\n', shouldParseNumbers: false) + .convert(specification); + + // Ignore header row + // - column name + // - label + // - enumeration + + // Extract template items from all the other rows + + String? groupLabel; + List? groupItems; + + var lineNum = 1; + for (final row in data.getRange(1, data.length)) { + lineNum++; + + final displayText = row[0]; + final propertyName = row[1]; + final enumStr = row[2]; + // 4th column is for comments + + if (4 < row.length) { + throw TemplateException(lineNum, 'too many fields in row $lineNum'); + } + + final enumerations = _parseEnum(lineNum, enumStr); + + if (propertyName == '#TITLE') { + title = displayText; + } else if (propertyName == '#SUBTITLE') { + subtitle = displayText; + } else if (displayText == '#UNUSED') { + items.add(TemplateItemIgnore(propertyName, enumerations)); + } else if (displayText == '#SORT') { + if (sortProperties.contains(propertyName)) { + throw TemplateException( + lineNum, 'duplicate sort property: $propertyName'); + } + sortProperties.add(propertyName); + } else if (displayText == '#IDENTIFIER') { + identifierProperties.add(propertyName); + } else if (displayText.startsWith('#')) { + throw TemplateException( + lineNum, 'Unexpected display text: $displayText'); + } else if (propertyName.startsWith('#')) { + throw TemplateException( + lineNum, 'Unexpected property name: $propertyName'); + } else if (propertyName.isNotEmpty || displayText.isNotEmpty) { + if (groupItems == null) { + if (propertyName.isNotEmpty) { + // Simple item + + final item = + TemplateItemScalar(propertyName, displayText, enumerations); + + items.add(item); + } else { + // Start of group + groupLabel = displayText; + groupItems = []; + } + } else { + // Add to group + final item = + TemplateItemScalar(propertyName, displayText, enumerations); + groupItems.add(item); + } + } else { + // Blank line + if (groupItems != null) { + // Complete the group + items.add( + TemplateItemGroup(groupLabel ?? 'Unnamed group', groupItems)); + groupItems = null; + } + } + } + + if (groupItems != null) { + // Complete the last group + items.add(TemplateItemGroup(groupLabel ?? 'Unnamed group', groupItems)); + groupItems = null; + } + + // Make sure there is at least one identifier property to use + + if (identifierProperties.isEmpty) { + // Default to first property as the identifier property + + for (final item in items) { + if (item is TemplateItemScalar) { + identifierProperties.add(item.propertyName); + break; + } else if (item is TemplateItemIgnore) { + identifierProperties.add(item.propertyName); + break; + } + } + + if (identifierProperties.isEmpty) { + throw TemplateException(lineNum, 'no identifier column'); + } + } + } + + //================================================================ + // Members + + String title = ''; + + String subtitle = ''; + + final List sortProperties = []; + + final List identifierProperties = []; + + final List items = []; + + //================================================================ + + Map? _parseEnum(int lineNum, String str) { + final result = {}; + + for (final pair + in str.split(';').map((x) => x.trim()).where((y) => y.isNotEmpty)) { + final equalsIndex = pair.indexOf('='); + if (0 < equalsIndex) { + final key = pair.substring(0, equalsIndex).trim(); + final label = pair.substring(equalsIndex + 1).trim(); + + if (result.containsKey(key)) { + throw TemplateException(lineNum, 'duplicate key in enum: $key'); + } + result[key] = label; + } else { + throw TemplateException(lineNum, 'bad enum missing equals: $pair'); + } + } + + return result.isNotEmpty ? result : null; + } + + //---------------------------------------------------------------- + /// Identify any properties in the data that do not appear in the template. + + Set unusedProperties(CsvData data) { + final usedColumns = {}; + + for (final item in items) { + if (item is TemplateItemScalar) { + usedColumns.add(item.propertyName); + } else if (item is TemplateItemGroup) { + for (final member in item.members) { + usedColumns.add(member.propertyName); + } + } else if (item is TemplateItemIgnore) { + usedColumns.add(item.propertyName); + } + } + + final unusedColumns = {}; + + for (final propertyName in data.propertyNames) { + if (!usedColumns.contains(propertyName)) { + unusedColumns.add(propertyName); + } + } + + return unusedColumns; + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..0159160 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,40 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: "direct main" + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + csv: + dependency: "direct main" + description: + name: csv + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.4" + path: + dependency: "direct main" + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + pedantic: + dependency: "direct dev" + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.2" +sdks: + dart: ">=2.12.0-0 <3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..e2fa742 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,15 @@ +name: csv2html +description: Converts a CSV file into pretty printed HTML +version: 1.0.0 +homepage: https://github.com/qcif/csv2html + +environment: + sdk: '>=2.12.0-0 <3.0.0' + +dependencies: + args: ^1.5.1 + csv: ^4.1.0 + path: ^1.7.0 + +dev_dependencies: + pedantic: ^1.9.0