feat(cli): add command-line interface
This commit is contained in:
1 parent
7616b89115
commit
7a2a2e9881
3 files changed
+295
No files matched your search
+261
@@ -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 <command> [args]
|
||||||
|
dart run cellar <file.csv> [options] (shorthand for the sheet command)
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
sheet <file.csv> Load a CSV sheet, optionally edit and print it
|
||||||
|
workbook <file.json> Load a JSON workbook and print a sheet
|
||||||
|
invoice <file.json> 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<String> 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<String> args) {
|
||||||
|
String? path;
|
||||||
|
final assignments = <String>[];
|
||||||
|
final shows = <String>[];
|
||||||
|
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<String> args) {
|
||||||
|
String? path;
|
||||||
|
String? sheetName;
|
||||||
|
final assignments = <String>[];
|
||||||
|
final shows = <String>[];
|
||||||
|
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<String> 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<String> args, int index, String option) {
|
||||||
|
if (index >= args.length) {
|
||||||
|
stderr.writeln('Missing value for $option');
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
return args[index];
|
||||||
|
}
|
||||||
@@ -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"")"
|
||||||
|
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user