diff --git a/bin/cellar.dart b/bin/cellar.dart new file mode 100644 index 0000000..c29f4f7 --- /dev/null +++ b/bin/cellar.dart @@ -0,0 +1,261 @@ +import 'dart:io'; + +import 'package:cellar/cellar.dart'; + +const _usage = ''' +cellar - a spreadsheet engine, workbook store and invoice toolkit in pure Dart + +Usage: + dart run cellar [args] + dart run cellar [options] (shorthand for the sheet command) + +Commands: + sheet Load a CSV sheet, optionally edit and print it + workbook Load a JSON workbook and print a sheet + invoice Compute and render an invoice + functions List every supported formula function + help Show this message + +Sheet options: + --set REF=CONTENT Set a cell before printing (keep leading '=' for a formula) + --show REF Print value and raw content of a single cell + --formulas Print the raw formulas grid instead of computed values + --csv-values Emit the computed sheet as CSV + --csv-formulas Emit the formulas sheet as CSV + +Workbook options: + --sheet NAME Which sheet to print (default: first) + --set NAME!REF=VAL Set a cell on a sheet before printing + --show NAME!REF Print a single cell value + --formulas Print raw formulas instead of values + +Invoice options: + --html Emit an HTML invoice instead of the text render + --out FILE Write the render to FILE instead of stdout +'''; + +void main(List args) { + if (args.isEmpty || args.first == 'help' || args.first == '--help') { + stdout.write(_usage); + return; + } + + final command = args.first; + final rest = args.sublist(1); + switch (command) { + case 'sheet': + _runSheet(rest); + case 'workbook': + _runWorkbook(rest); + case 'invoice': + _runInvoice(rest); + case 'functions': + stdout.writeln(functionNames().join('\n')); + default: + if (command.startsWith('--')) { + stderr.writeln('Unknown option: $command'); + exit(2); + } + _runSheet(args); + } +} + +void _runSheet(List args) { + String? path; + final assignments = []; + final shows = []; + var showFormulas = false; + var emitCsvValues = false; + var emitCsvFormulas = false; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + switch (arg) { + case '--set': + assignments.add(_requireValue(args, ++i, '--set')); + case '--show': + shows.add(_requireValue(args, ++i, '--show')); + case '--formulas': + showFormulas = true; + case '--csv-values': + emitCsvValues = true; + case '--csv-formulas': + emitCsvFormulas = true; + default: + if (arg.startsWith('--')) { + stderr.writeln('Unknown option: $arg'); + exit(2); + } + path = arg; + } + } + + if (path == null) { + stderr.writeln('No input file given.'); + exit(2); + } + + final file = File(path); + if (!file.existsSync()) { + stderr.writeln('File not found: $path'); + exit(2); + } + + final sheet = Sheet(); + importCsv(sheet, file.readAsStringSync()); + + for (final assignment in assignments) { + final separator = assignment.indexOf('='); + if (separator < 1) { + stderr.writeln('Invalid --set argument: $assignment'); + exit(2); + } + sheet.setValue(assignment.substring(0, separator), assignment.substring(separator + 1)); + } + + if (emitCsvValues) { + stdout.writeln(exportValues(sheet)); + return; + } + if (emitCsvFormulas) { + stdout.writeln(exportFormulas(sheet)); + return; + } + if (shows.isNotEmpty) { + for (final reference in shows) { + _printCell(sheet, reference); + } + return; + } + + stdout.write(renderGrid(sheet, formulas: showFormulas)); +} + +void _runWorkbook(List args) { + String? path; + String? sheetName; + final assignments = []; + final shows = []; + var showFormulas = false; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + switch (arg) { + case '--sheet': + sheetName = _requireValue(args, ++i, '--sheet'); + case '--set': + assignments.add(_requireValue(args, ++i, '--set')); + case '--show': + shows.add(_requireValue(args, ++i, '--show')); + case '--formulas': + showFormulas = true; + default: + if (arg.startsWith('--')) { + stderr.writeln('Unknown option: $arg'); + exit(2); + } + path = arg; + } + } + + if (path == null) { + stderr.writeln('No workbook file given.'); + exit(2); + } + final file = File(path); + if (!file.existsSync()) { + stderr.writeln('File not found: $path'); + exit(2); + } + + final workbook = decodeWorkbookJson(file.readAsStringSync()); + + for (final assignment in assignments) { + final separator = assignment.indexOf('='); + if (separator < 1) { + stderr.writeln('Invalid --set argument: $assignment'); + exit(2); + } + final target = assignment.substring(0, separator); + final content = assignment.substring(separator + 1); + final (sheet, reference) = _splitQualified(workbook, target, sheetName); + sheet.setValue(reference, content); + } + + if (shows.isNotEmpty) { + for (final show in shows) { + final (sheet, reference) = _splitQualified(workbook, show, sheetName); + _printCell(sheet, reference); + } + return; + } + + final sheet = sheetName == null ? workbook.firstSheet! : workbook.sheet(sheetName); + stdout.writeln('# ${sheet.name}'); + stdout.write(renderGrid(sheet, formulas: showFormulas)); +} + +void _runInvoice(List args) { + String? path; + String? outPath; + var html = false; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + switch (arg) { + case '--html': + html = true; + case '--out': + outPath = _requireValue(args, ++i, '--out'); + default: + if (arg.startsWith('--')) { + stderr.writeln('Unknown option: $arg'); + exit(2); + } + path = arg; + } + } + + if (path == null) { + stderr.writeln('No invoice file given.'); + exit(2); + } + final file = File(path); + if (!file.existsSync()) { + stderr.writeln('File not found: $path'); + exit(2); + } + + final invoice = invoiceFromJsonString(file.readAsStringSync()); + final rendered = html ? renderInvoiceHtml(invoice) : renderInvoiceText(invoice); + if (outPath != null) { + File(outPath).writeAsStringSync(rendered); + stdout.writeln('Wrote ${html ? 'HTML' : 'text'} invoice to $outPath'); + } else { + stdout.write(rendered); + } +} + +(Sheet, String) _splitQualified(Workbook workbook, String reference, String? fallback) { + final bang = reference.indexOf('!'); + if (bang < 0) { + final sheet = fallback == null ? workbook.firstSheet! : workbook.sheet(fallback); + return (sheet, reference); + } + return (workbook.sheet(reference.substring(0, bang)), reference.substring(bang + 1)); +} + +void _printCell(Sheet sheet, String reference) { + final address = CellAddress.parse(reference); + final raw = sheet.rawAt(address); + final value = sheet.displayAt(address); + stdout.writeln('${address.label}: value=$value content=${raw.isEmpty ? '(empty)' : raw}'); +} + +String _requireValue(List args, int index, String option) { + if (index >= args.length) { + stderr.writeln('Missing value for $option'); + exit(2); + } + return args[index]; +} diff --git a/example/invoice.csv b/example/invoice.csv new file mode 100644 index 0000000..112cf5e --- /dev/null +++ b/example/invoice.csv @@ -0,0 +1,10 @@ +Item,Qty,Unit,LineTotal +Widget,3,4.50,=B2*C2 +Gadget,5,2.00,=B3*C3 +Gizmo,2,9.99,=B4*C4 +Subtotal,,,=SUM(D2:D4) +Tax 23pct,,,"=ROUND(D5*0.23,2)" +Total,,,=D5+D6 +Items,,,"=COUNT(B2:B4)" +Avg line,,,"=ROUND(AVERAGE(D2:D4),2)" +Note,,,"=IF(D7>40,""large order"",""small order"")" diff --git a/examples/invoice.json b/examples/invoice.json new file mode 100644 index 0000000..26d6b6c --- /dev/null +++ b/examples/invoice.json @@ -0,0 +1,24 @@ +{ + "number": "FV/2026/08/001", + "issueDate": "2026-08-28", + "dueDate": "2026-09-11", + "currency": "PLN", + "seller": { + "name": "Vulp Sp. z o.o.", + "address": "ul. Przykladowa 1, 00-001 Warszawa", + "taxId": "PL1234567890", + "email": "billing@vulp.example" + }, + "buyer": { + "name": "Acme Corp", + "address": "ul. Klienta 5, 30-001 Krakow", + "taxId": "PL9876543210" + }, + "paymentTerms": "14 days, transfer", + "notes": "Thank you for your business.", + "lines": [ + {"description": "Consulting hours", "quantity": 12, "unitPrice": 150.0, "vat": 23, "unit": "h"}, + {"description": "Annual software license", "quantity": 2, "unitPrice": 500.0, "vat": 23, "discount": 0.1}, + {"description": "Printed manual", "quantity": 4, "unitPrice": 25.0, "vat": 5, "unit": "pcs"} + ] +}