diff --git a/CLAUDE.md b/CLAUDE.md index 4d3a893..eb15ac2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,12 +247,18 @@ echo '[{"op":"put","ref":"A1","value":"2025-01-15"}]' | xl ... # → Date # Explicit format hints echo '[{"op":"put","ref":"A1","value":0.455,"format":"percent"}]' | xl ... +# Batch putf: "formula" accepted as alias for "value" +echo '[{"op":"putf","ref":"D14","formula":"=SUM(D5:D12)"}]' | xl -f in.xlsx -o out.xlsx batch - + # Formula dragging (shifts references like Excel fill-down) echo '[{"op":"putf","ref":"B2:B10","value":"=A2*2","from":"B2"}]' | xl -f in.xlsx -o out.xlsx --stream batch - # Explicit formula array echo '[{"op":"putf","ref":"B2:B4","values":["=A2*2","=A3*2","=A4*2"]}]' | xl ... +# Dry-run: validate batch JSON without writing (no --file or --output needed) +echo '[{"op":"putf","ref":"A1","formula":"=1+1"}]' | xl batch --dry-run - + # Comments, visibility, autofit, sheet management echo '[{"op":"comment","ref":"A1","text":"Note","author":"User"}]' | xl ... echo '[{"op":"clear","range":"A1:B10","all":true}]' | xl ... diff --git a/plugin/skills/xl-cli/SKILL.md b/plugin/skills/xl-cli/SKILL.md index 5fc51b4..b425109 100644 --- a/plugin/skills/xl-cli/SKILL.md +++ b/plugin/skills/xl-cli/SKILL.md @@ -296,6 +296,10 @@ Apply multiple operations atomically: {"op": "put", "ref": "B2:B4", "values": [1234.56, 5678.90, 9012.34]} {"op": "put", "ref": "C2:C4", "values": ["$1,234", "$5,678", "$9,012"]} +// Single formula (use "value" or "formula" — both accepted) +{"op": "putf", "ref": "D14", "value": "=SUM(D5:D12)"} +{"op": "putf", "ref": "D14", "formula": "=SUM(D5:D12)"} + // Drag formula across range (Excel-style $ anchoring) {"op": "putf", "ref": "B2:B10", "value": "=SUM($A$1:A2)", "from": "B2"} @@ -409,6 +413,11 @@ echo '[ ]' | xl -f template.xlsx -s Sheet1 -o report.xlsx batch - ``` +**Dry-run validation** (validate JSON without writing — no `--file` or `--output` needed): +```bash +echo '[{"op":"putf","ref":"A1","formula":"=SUM(B1:B10)"},{"op":"style","range":"A1","bold":true}]' | xl batch --dry-run - +``` + **All batch operations**: `put`, `putf`, `style`, `merge`, `unmerge`, `colwidth`, `rowheight`, `comment`, `remove-comment`, `clear`, `col-hide`, `col-show`, `row-hide`, `row-show`, `autofit`, `add-sheet`, `rename-sheet` ### CSV to Styled Table @@ -497,6 +506,7 @@ xl -f huge.xlsx --max-size 500 cell A1 # Custom 500MB limit | `--backend ` | | Write backend: scalaxml (default) or saxstax (36-39% faster). Reads always use StAX. | | `--max-size ` | | Override 100MB security limit (0 = unlimited) | | `--stream` | | O(1) memory mode for reads + writes (search/stats/bounds/view/put/putf/style) | +| `--dry-run` | | Validate batch JSON and show summary without writing (batch only) | ### Info Commands diff --git a/xl-cli/src/com/tjclp/xl/cli/Command.scala b/xl-cli/src/com/tjclp/xl/cli/Command.scala index 3db9eb0..16d3f4a 100644 --- a/xl-cli/src/com/tjclp/xl/cli/Command.scala +++ b/xl-cli/src/com/tjclp/xl/cli/Command.scala @@ -78,7 +78,7 @@ enum CliCommand: ) case RowOp(row: Int, height: Option[Double], hide: Boolean, show: Boolean) case ColOp(col: String, width: Option[Double], hide: Boolean, show: Boolean, autoFit: Boolean) - case Batch(source: String) // "-" for stdin or file path + case Batch(source: String, dryRun: Boolean = false) // "-" for stdin or file path case Import( csvPath: String, startRef: Option[String], diff --git a/xl-cli/src/com/tjclp/xl/cli/Main.scala b/xl-cli/src/com/tjclp/xl/cli/Main.scala index a90331e..2d8d2e5 100644 --- a/xl-cli/src/com/tjclp/xl/cli/Main.scala +++ b/xl-cli/src/com/tjclp/xl/cli/Main.scala @@ -32,7 +32,7 @@ import com.tjclp.xl.cli.raster.{ Resvg, RsvgConvert } -import com.tjclp.xl.cli.helpers.SheetResolver +import com.tjclp.xl.cli.helpers.{BatchParser, SheetResolver} import com.tjclp.xl.cli.output.Format /** Read version from generated resource, fallback to dev */ @@ -112,7 +112,18 @@ object Main val infoOpts = functionsCmd.map(_ => runInfo()) val rasterOpts = rasterizersCmd.map(_ => runRasterizers()) - rasterOpts orElse infoOpts orElse standaloneOpts orElse headlessOpts orElse sheetsOpts orElse workbookOpts orElse sheetReadOnlyOpts orElse sheetWriteOpts + // Batch dry-run: only needs batch source, no --file or --output + val dryRunFlag = + Opts.flag("dry-run", "Validate batch JSON without writing") + val batchDryRunOpts = + Opts + .subcommand("batch", batchHelp) { + (batchArg, dryRunFlag) + .mapN((src, _) => CliCommand.Batch(src, dryRun = true)) + } + .map(runBatchDryRun) + + rasterOpts orElse infoOpts orElse standaloneOpts orElse headlessOpts orElse sheetsOpts orElse workbookOpts orElse sheetReadOnlyOpts orElse batchDryRunOpts orElse sheetWriteOpts // ========================================================================== // Global options @@ -688,7 +699,7 @@ USAGE: OPERATIONS: put {"op": "put", "ref": "A1", "value": "Hello"} - putf {"op": "putf", "ref": "A1", "value": "=SUM(B1:B10)"} + putf {"op": "putf", "ref": "A1", "value": "=SUM(B1:B10)"} (also accepts "formula") style {"op": "style", "range": "A1:D1", "bold": true, "bg": "#FFFF00"} merge {"op": "merge", "range": "A1:D1"} unmerge {"op": "unmerge", "range": "A1:D1"} @@ -714,11 +725,15 @@ EXAMPLE: {"op": "putf", "ref": "C2", "value": "=B2*1.1"} ] -Operations execute in order. Use "-" to read from stdin.""" +Operations execute in order. Use "-" to read from stdin. +Use --dry-run to validate JSON without writing.""" + + private val dryRunOpt = + Opts.flag("dry-run", "Validate batch JSON and show summary without writing").orFalse val batchCmd: Opts[CliCommand] = Opts.subcommand("batch", batchHelp) { - batchArg.map(CliCommand.Batch.apply) + (batchArg, dryRunOpt).mapN(CliCommand.Batch.apply) } // --- Import command --- @@ -886,6 +901,10 @@ Operations execute in order. Use "-" to read from stdin.""" IO.println(output).as(ExitCode.Success) } + private def runBatchDryRun(cmd: CliCommand): IO[ExitCode] = + val CliCommand.Batch(source, _) = cmd: @unchecked + batchDryRun(source).flatMap(IO.println).as(ExitCode.Success) + /** * Check all rasterizers and format a status table. * @@ -1185,6 +1204,16 @@ Operations execute in order. Use "-" to read from stdin.""" ) ) + /** Validate batch JSON and show summary without writing. */ + private def batchDryRun(source: String): IO[String] = + BatchParser.readBatchInput(source).flatMap { input => + BatchParser.parseBatchOperations(input).map { result => + result.warnings.foreach(System.err.println) + val summary = BatchParser.formatSummary(result.ops) + s"Dry run - ${result.ops.size} operations parsed:\n$summary" + } + } + /** Execute streaming write command (O(1) memory transform). */ private def executeStreamingWrite( filePath: Path, @@ -1256,7 +1285,7 @@ Operations execute in order. Use "-" to read from stdin.""" case Some(outputPath) => StreamingWriteCommands.putFormula(filePath, outputPath, sheetNameOpt, refStr, formulas) - case CliCommand.Batch(source) => + case CliCommand.Batch(source, _) => outputOpt match case None => IO.raiseError(new Exception("--output is required for batch command")) @@ -1421,7 +1450,7 @@ Operations execute in order. Use "-" to read from stdin.""" WriteCommands.col(wb, sheetOpt, colStr, width, hide, show, autoFit, _, _, _) ) - case CliCommand.Batch(source) => + case CliCommand.Batch(source, _) => requireOutput(outputOpt, backendOpt, stream)( WriteCommands.batch(wb, sheetOpt, source, _, _, _) ) diff --git a/xl-cli/src/com/tjclp/xl/cli/commands/WriteCommands.scala b/xl-cli/src/com/tjclp/xl/cli/commands/WriteCommands.scala index c6f95eb..bc5865a 100644 --- a/xl-cli/src/com/tjclp/xl/cli/commands/WriteCommands.scala +++ b/xl-cli/src/com/tjclp/xl/cli/commands/WriteCommands.scala @@ -701,43 +701,7 @@ object WriteCommands: BatchParser.applyBatchOperations(wb, sheetOpt, result.ops).flatMap { updatedWb => writeWorkbook(updatedWb, outputPath, config, stream).map { _ => val ops = result.ops - val summary = ops - .map { - case BatchParser.BatchOp.Put(ref, value, fmt) => - val fmtStr = fmt - .map { - case NumFmt.Custom(code) => s" ($code)" - case f => s" ($f)" - } - .getOrElse("") - s" PUT $ref = $value$fmtStr" - case BatchParser.BatchOp.PutFormula(ref, formula) => s" PUTF $ref = $formula" - case BatchParser.BatchOp.PutFormulaDragging(range, formula, from) => - s" PUTF $range = $formula (from $from)" - case BatchParser.BatchOp.PutFormulas(range, formulas) => - s" PUTF $range = [${formulas.length} formulas]" - case BatchParser.BatchOp.PutValues(range, values) => - s" PUT $range = [${values.length} values]" - case BatchParser.BatchOp.Style(range, _) => s" STYLE $range" - case BatchParser.BatchOp.Merge(range) => s" MERGE $range" - case BatchParser.BatchOp.Unmerge(range) => s" UNMERGE $range" - case BatchParser.BatchOp.ColWidth(col, width) => s" COLWIDTH $col = $width" - case BatchParser.BatchOp.RowHeight(row, height) => s" ROWHEIGHT $row = $height" - case BatchParser.BatchOp.AddComment(ref, text, _) => - s" COMMENT $ref = \"$text\"" - case BatchParser.BatchOp.RemoveComment(ref) => s" REMOVE-COMMENT $ref" - case BatchParser.BatchOp.Clear(range, _, _, _) => s" CLEAR $range" - case BatchParser.BatchOp.ColHide(col) => s" COL-HIDE $col" - case BatchParser.BatchOp.ColShow(col) => s" COL-SHOW $col" - case BatchParser.BatchOp.RowHide(row) => s" ROW-HIDE $row" - case BatchParser.BatchOp.RowShow(row) => s" ROW-SHOW $row" - case BatchParser.BatchOp.AutoFit(cols) => - s" AUTOFIT ${cols.getOrElse("all")}" - case BatchParser.BatchOp.AddSheet(name, _) => s" ADD-SHEET $name" - case BatchParser.BatchOp.RenameSheet(from, to) => - s" RENAME-SHEET $from -> $to" - } - .mkString("\n") + val summary = BatchParser.formatSummary(ops) s"Applied ${ops.size} operations:\n$summary\n${saveSuffix(outputPath, stream)}" } } diff --git a/xl-cli/src/com/tjclp/xl/cli/helpers/BatchParser.scala b/xl-cli/src/com/tjclp/xl/cli/helpers/BatchParser.scala index f239711..3972679 100644 --- a/xl-cli/src/com/tjclp/xl/cli/helpers/BatchParser.scala +++ b/xl-cli/src/com/tjclp/xl/cli/helpers/BatchParser.scala @@ -102,6 +102,43 @@ object BatchParser: */ final case class ParseResult(ops: Vector[BatchOp], warnings: Vector[String]) + /** Format a human-readable summary of batch operations. */ + def formatSummary(ops: Vector[BatchOp]): String = + ops + .map { + case BatchOp.Put(ref, value, fmt) => + val fmtStr = fmt + .map { + case NumFmt.Custom(code) => s" ($code)" + case f => s" ($f)" + } + .getOrElse("") + s" PUT $ref = $value$fmtStr" + case BatchOp.PutFormula(ref, formula) => s" PUTF $ref = $formula" + case BatchOp.PutFormulaDragging(range, formula, from) => + s" PUTF $range = $formula (from $from)" + case BatchOp.PutFormulas(range, formulas) => + s" PUTF $range = [${formulas.length} formulas]" + case BatchOp.PutValues(range, values) => + s" PUT $range = [${values.length} values]" + case BatchOp.Style(range, _) => s" STYLE $range" + case BatchOp.Merge(range) => s" MERGE $range" + case BatchOp.Unmerge(range) => s" UNMERGE $range" + case BatchOp.ColWidth(col, width) => s" COLWIDTH $col = $width" + case BatchOp.RowHeight(row, height) => s" ROWHEIGHT $row = $height" + case BatchOp.AddComment(ref, text, _) => s" COMMENT $ref = \"$text\"" + case BatchOp.RemoveComment(ref) => s" REMOVE-COMMENT $ref" + case BatchOp.Clear(range, _, _, _) => s" CLEAR $range" + case BatchOp.ColHide(col) => s" COL-HIDE $col" + case BatchOp.ColShow(col) => s" COL-SHOW $col" + case BatchOp.RowHide(row) => s" ROW-HIDE $row" + case BatchOp.RowShow(row) => s" ROW-SHOW $row" + case BatchOp.AutoFit(cols) => s" AUTOFIT ${cols.getOrElse("all")}" + case BatchOp.AddSheet(name, _) => s" ADD-SHEET $name" + case BatchOp.RenameSheet(from, to) => s" RENAME-SHEET $from -> $to" + } + .mkString("\n") + /** * Read batch input from file or stdin. * @@ -333,7 +370,7 @@ object BatchParser: private val knownPutProps = Set("op", "ref", "value", "values", "format", "detect") /** Known properties for 'putf' operation */ - private val knownPutfProps = Set("op", "ref", "value", "values", "from") + private val knownPutfProps = Set("op", "ref", "value", "formula", "values", "from") /** Known properties for 'style' operation */ private val knownStyleProps = Set( @@ -659,10 +696,11 @@ object BatchParser: ) ) - /** Extract value field as string (for formulas) */ + /** Extract value field as string (for formulas). Accepts "formula" as alias for "value". */ private def requireStringValue(objMap: ObjMap, idx: Int): String = objMap .get("value") + .orElse(objMap.get("formula")) .map { case v if v.strOpt.isDefined => v.str case v if v.numOpt.isDefined => v.num.toString @@ -671,7 +709,7 @@ object BatchParser: case _ => throw new Exception(s"Object ${idx + 1}: Unsupported value type for 'value'") } .getOrElse( - throw new Exception(s"Object ${idx + 1}: Missing 'value' field") + throw new Exception(s"Object ${idx + 1}: Missing 'value' (or 'formula') field") ) /** Parse style properties from JSON object. */ diff --git a/xl-cli/test/src/com/tjclp/xl/cli/MainSpec.scala b/xl-cli/test/src/com/tjclp/xl/cli/MainSpec.scala index 5564b4f..107ddf1 100644 --- a/xl-cli/test/src/com/tjclp/xl/cli/MainSpec.scala +++ b/xl-cli/test/src/com/tjclp/xl/cli/MainSpec.scala @@ -905,6 +905,57 @@ class MainSpec extends CatsEffectSuite: case other => fail(s"Expected PutFormulas, got $other") } + test("parseBatchJson: putf accepts 'formula' as alias for 'value'") { + val json = """[{"op": "putf", "ref": "D14", "formula": "=SUM(D5:D12)"}]""" + val result = BatchParser.parseBatchJson(json) + + assert(result.isRight, s"Should parse: $result") + val op = result.toOption.get.ops.head + op match + case BatchOp.PutFormula(ref, formula) => + assertEquals(ref, "D14") + assertEquals(formula, "=SUM(D5:D12)") + case other => fail(s"Expected PutFormula, got $other") + } + + test("parseBatchJson: putf 'formula' alias works with dragging") { + val json = """[{"op": "putf", "ref": "B2:B10", "formula": "=A2*2", "from": "B2"}]""" + val result = BatchParser.parseBatchJson(json) + + assert(result.isRight, s"Should parse: $result") + val op = result.toOption.get.ops.head + op match + case BatchOp.PutFormulaDragging(range, formula, from) => + assertEquals(range, "B2:B10") + assertEquals(formula, "=A2*2") + assertEquals(from, "B2") + case other => fail(s"Expected PutFormulaDragging, got $other") + } + + test("parseBatchJson: putf 'value' still preferred over 'formula' when both present") { + val json = """[{"op": "putf", "ref": "A1", "value": "=B1", "formula": "=C1"}]""" + val result = BatchParser.parseBatchJson(json) + + assert(result.isRight, s"Should parse: $result") + val op = result.toOption.get.ops.head + op match + case BatchOp.PutFormula(_, formula) => + assertEquals(formula, "=B1") + case other => fail(s"Expected PutFormula with 'value' winning, got $other") + } + + test("formatSummary: produces expected summary lines") { + val ops = Vector( + BatchOp.PutFormula("A1", "=SUM(B1:B10)"), + BatchOp.Style("A1:D1", BatchParser.StyleProps()), + BatchOp.Merge("A1:D1") + ) + val summary = BatchParser.formatSummary(ops) + assert(summary.contains("PUTF A1 = =SUM(B1:B10)")) + assert(summary.contains("STYLE A1:D1")) + assert(summary.contains("MERGE A1:D1")) + } + test("parseBatchJson: backward compatible plain string remains text") { val json = """[{"op": "put", "ref": "A1", "value": "Hello World"}]""" val result = BatchParser.parseBatchJson(json)