Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...
Expand Down
10 changes: 10 additions & 0 deletions plugin/skills/xl-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -497,6 +506,7 @@ xl -f huge.xlsx --max-size 500 cell A1 # Custom 500MB limit
| `--backend <type>` | | Write backend: scalaxml (default) or saxstax (36-39% faster). Reads always use StAX. |
| `--max-size <MB>` | | 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

Expand Down
2 changes: 1 addition & 1 deletion xl-cli/src/com/tjclp/xl/cli/Command.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
43 changes: 36 additions & 7 deletions xl-cli/src/com/tjclp/xl/cli/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"}
Expand All @@ -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 ---
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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, _, _, _)
)
Expand Down
38 changes: 1 addition & 37 deletions xl-cli/src/com/tjclp/xl/cli/commands/WriteCommands.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
}
}
Expand Down
44 changes: 41 additions & 3 deletions xl-cli/src/com/tjclp/xl/cli/helpers/BatchParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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. */
Expand Down
51 changes: 51 additions & 0 deletions xl-cli/test/src/com/tjclp/xl/cli/MainSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading