diff options
-rw-r--r-- | Base/res/js/Spreadsheet/runtime.js | 224 | ||||
-rw-r--r-- | Userland/Applications/Spreadsheet/JSIntegration.cpp | 26 | ||||
-rw-r--r-- | Userland/Applications/Spreadsheet/JSIntegration.h | 1 | ||||
-rw-r--r-- | Userland/Applications/Spreadsheet/Spreadsheet.cpp | 6 | ||||
-rw-r--r-- | Userland/Applications/Spreadsheet/Spreadsheet.h | 4 |
5 files changed, 212 insertions, 49 deletions
diff --git a/Base/res/js/Spreadsheet/runtime.js b/Base/res/js/Spreadsheet/runtime.js index 73a9e2fb74..7fc99def83 100644 --- a/Base/res/js/Spreadsheet/runtime.js +++ b/Base/res/js/Spreadsheet/runtime.js @@ -1,5 +1,7 @@ "use strict"; +const Break = {}; + // FIXME: Figure out a way to document non-function entities too. class Position { constructor(column, row, sheet) { @@ -80,42 +82,148 @@ class Position { } } -function range(start, end, columnStep, rowStep) { - columnStep = integer(columnStep ?? 1); - rowStep = integer(rowStep ?? 1); - if (!(start instanceof Position)) { - start = thisSheet.parse_cell_name(start) ?? { column: "A", row: 0 }; +class Ranges { + constructor(ranges) { + this.ranges = ranges; + } + + static from(...ranges) { + return new Ranges(ranges); } - if (!(end instanceof Position)) { - end = thisSheet.parse_cell_name(end) ?? start; + + forEach(callback) { + for (const range of this.ranges) { + if (range.forEach(callback) === Break) break; + } } - const cells = []; + union(other, direction = "right") { + if (direction === "left") { + if (other instanceof Ranges) return Ranges.from(...other.ranges, ...this.ranges); + return Ranges.from(other, ...this.ranges); + } else if (direction === "right") { + if (other instanceof Ranges) return Ranges.from(...this.ranges, ...other.ranges); + return Ranges.from(...this.ranges, other); + } else { + throw new Error(`Invalid direction '${direction}'`); + } + } + + toString() { + return `Ranges.from(${this.ranges.map(r => r.toString()).join(", ")})`; + } +} - const start_column_index = thisSheet.column_index(start.column); - const end_column_index = thisSheet.column_index(end.column); - const start_column = start_column_index > end_column_index ? end.column : start.column; - const distance = Math.abs(start_column_index - end_column_index); +class Range { + constructor(startingColumnName, endingColumnName, startingRow, endingRow, columnStep, rowStep) { + this.startingColumnName = startingColumnName; + this.endingColumnName = endingColumnName; + this.startingRow = startingRow; + this.endingRow = endingRow; + this.columnStep = columnStep ?? 1; + this.rowStep = rowStep ?? 1; + this.spansEntireColumn = endingRow === undefined; + if (!this.spansEntireColumn && startingRow === undefined) + throw new Error("A Range with a defined end row must also have a defined start row"); + + this.normalize(); + } - for (let col = 0; col <= distance; col += columnStep) { - const column = thisSheet.column_arithmetic(start_column, col); + forEach(callback) { + const ranges = []; + let startingColumnIndex = thisSheet.column_index(this.startingColumnName); + let endingColumnIndex = thisSheet.column_index(this.endingColumnName); + let columnDistance = endingColumnIndex - startingColumnIndex; for ( - let row = Math.min(start.row, end.row); - row <= Math.max(start.row, end.row); - row += rowStep + let columnOffset = 0; + columnOffset <= columnDistance; + columnOffset += this.columnStep ) { - cells.push(column + row); + const columnName = thisSheet.column_arithmetic(this.startingColumnName, columnOffset); + ranges.push({ + column: columnName, + rowStart: this.startingRow, + rowEnd: this.spansEntireColumn + ? thisSheet.get_column_bound(columnName) + : this.endingRow, + }); + } + + for (const range of ranges) { + for (let row = range.rowStart; row < range.rowEnd; row += this.rowStep) { + callback(range.column + row); + } + } + } + + union(other) { + if (other instanceof Ranges) return other.union(this, "left"); + + if (other instanceof Range) return Ranges.from(this, other); + + throw new Error(`Cannot add ${other} to a Range`); + } + + normalize() { + const startColumnIndex = thisSheet.column_index(this.startingColumnName); + const endColumnIndex = thisSheet.column_index(this.endingColumnName); + if (startColumnIndex > endColumnIndex) { + const temp = this.startingColumnName; + this.startingColumnName = this.endingColumnName; + this.endingColumnName = temp; + } + + if (this.startingRow !== undefined && this.endingRow !== undefined) { + if (this.startingRow > this.endingRow) { + const temp = this.startingRow; + this.startingRow = this.endingRow; + this.endingRow = temp; + } } } - return cells; + toString() { + return `Range(${this.startingColumnName}, ${this.endingColumnName}, ${this.startingRow}, ${this.endingRow}, ${this.columnStep}, ${this.rowStep})`; + } +} + +function range(start, end, columnStep, rowStep) { + columnStep = integer(columnStep ?? 1); + rowStep = integer(rowStep ?? 1); + if (!(start instanceof Position)) { + start = thisSheet.parse_cell_name(start) ?? { column: undefined, row: undefined }; + } + + let didAssignToEnd = false; + if (end !== undefined && !(end instanceof Position)) { + didAssignToEnd = true; + if (/^[a-zA-Z_]+$/.test(end)) end = { column: end, row: undefined }; + else end = thisSheet.parse_cell_name(end); + } else if (end === undefined) { + didAssignToEnd = true; + end = start; + } + + if (!didAssignToEnd) throw new Error(`Invalid value for range 'end': ${end}`); + + return new Range(start.column, end.column, start.row, end.row, columnStep, rowStep); } function R(fmt, ...args) { - if (args.length !== 0) throw new TypeError("R`` format must be literal"); + if (args.length !== 0) throw new TypeError("R`` format must be a literal"); fmt = fmt[0]; - return range(...fmt.split(":")); + + // CellName (: (CellName|ColumnName) (: Integer (: Integer)?)?)? + // ColumnName (: ColumnName (: Integer (: Integer)?)?)? + let specs = fmt.split(":"); + + if (specs.length > 4 || specs.length < 1) throw new SyntaxError(`Invalid range ${fmt}`); + + if (/^[a-zA-Z_]+\d+$/.test(specs[0])) return range(...specs); + + // Otherwise, it has to be a column name. + return new Range(specs[0], specs[1], undefined, undefined, specs[2], specs[3]); } function select(criteria, t, f) { @@ -150,10 +258,10 @@ function sheet(name) { } function reduce(op, accumulator, cells) { - for (let name of cells) { + cells.forEach(name => { let cell = thisSheet[name]; accumulator = op(accumulator, cell); - } + }); return accumulator; } @@ -163,13 +271,13 @@ function numericReduce(op, accumulator, cells) { function numericResolve(cells) { const values = []; - for (let name of cells) values.push(Number(thisSheet[name])); + cells.forEach(name => values.push(Number(thisSheet[name]))); return values; } function resolve(cells) { const values = []; - for (let name of cells) values.push(thisSheet[name]); + cells.forEach(name => values.push(thisSheet[name])); return values; } @@ -270,16 +378,6 @@ function internal_lookup( mode, reference ) { - lookup_outputs = lookup_outputs ?? lookup_inputs; - - if (lookup_inputs.length > lookup_outputs.length) - throw new Error( - `Uneven lengths in outputs and inputs: ${lookup_inputs.length} > ${lookup_outputs.length}` - ); - - let references = lookup_outputs; - lookup_inputs = resolve(lookup_inputs); - lookup_outputs = resolve(lookup_outputs); if_missing = if_missing ?? undefined; mode = mode ?? "exact"; const lookup_value = req_lookup_value; @@ -295,15 +393,40 @@ function internal_lookup( throw new Error(`Match mode '${mode}' not supported`); } - let retval = if_missing; - for (let i = 0; i < lookup_inputs.length; ++i) { - if (matches(lookup_inputs[i])) { - retval = reference ? Position.from_name(references[i]) : lookup_outputs[i]; - break; + let i = 0; + let didMatch = false; + let value = null; + let matchingName = null; + lookup_inputs.forEach(name => { + value = thisSheet[name]; + if (matches(value)) { + didMatch = true; + matchingName = name; + return Break; } + ++i; + }); + + if (!didMatch) return if_missing; + + if (lookup_outputs === undefined) { + if (reference) return Position.from_name(matchingName); + + return value; } - return retval; + lookup_outputs.forEach(name => { + matchingName = name; + if (i === 0) return Break; + --i; + }); + + if (i > 0) + throw new Error("Lookup target length must not be smaller than lookup source length"); + + if (reference) return Position.from_name(matchingName); + + return thisSheet[matchingName]; } function lookup(req_lookup_value, lookup_inputs, lookup_outputs, if_missing, mode) { @@ -349,11 +472,22 @@ R.__documentation = JSON.stringify({ argc: 1, argnames: ["range specifier"], doc: - "Generates a list of cell names in a rectangle defined by " + - "_range specifier_, which must be two cell names " + - "delimited by a comma ':'. Operates the same as [`range`](spreadsheet://doc/range)", + "Generates a Range object, denoted by the" + + "_range specifier_, which must conform to the following syntax.\n\n" + + "```\n" + + "RangeSpecifier : RangeBounds RangeStep?\n" + + "RangeBounds :\n" + + " CellName (':' CellName)?\n" + + " | ColumnName (':' ColumnName)?\n" + + "RangeStep : Integer (':' Integer)?\n" + + "```\n", examples: { - "R`A1:C4`": "Generate the range A1:C4", + "R`A1:C4`": + "Generate a Range representing all cells in a rectangle with the top-left cell A1, and the bottom-right cell C4", + "R`A`": "Generate a Range representing all the cells in the column A", + "R`A:C`": "Generate a Range representing all the cells in the columns A through C", + "R`A:C:2:2`": + "Generate a Range representing every other cells in every other column in A through C", }, }); diff --git a/Userland/Applications/Spreadsheet/JSIntegration.cpp b/Userland/Applications/Spreadsheet/JSIntegration.cpp index 681fa4d9d9..85d4f35346 100644 --- a/Userland/Applications/Spreadsheet/JSIntegration.cpp +++ b/Userland/Applications/Spreadsheet/JSIntegration.cpp @@ -157,6 +157,7 @@ void SheetGlobalObject::initialize_global_object() define_native_function("current_cell_position", current_cell_position, 0, attr); define_native_function("column_arithmetic", column_arithmetic, 2, attr); define_native_function("column_index", column_index, 1, attr); + define_native_function("get_column_bound", get_column_bound, 1, attr); } void SheetGlobalObject::visit_edges(Visitor& visitor) @@ -329,6 +330,31 @@ JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::column_arithmetic) return JS::js_string(vm, new_column.release_value()); } +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::get_column_bound) +{ + if (vm.argument_count() != 1) + return vm.throw_completion<JS::TypeError>(global_object, "Expected exactly one argument to get_column_bound()"); + + auto column_name = vm.argument(0); + if (!column_name.is_string()) + return vm.throw_completion<JS::TypeError>(global_object, JS::ErrorType::NotAnObjectOfType, "String"); + + auto& column_name_str = column_name.as_string().string(); + auto* this_object = TRY(vm.this_value(global_object).to_object(global_object)); + + if (!is<SheetGlobalObject>(this_object)) + return vm.throw_completion<JS::TypeError>(global_object, JS::ErrorType::NotAnObjectOfType, "SheetGlobalObject"); + + auto sheet_object = static_cast<SheetGlobalObject*>(this_object); + auto& sheet = sheet_object->m_sheet; + auto maybe_column_index = sheet.column_index(column_name_str); + if (!maybe_column_index.has_value()) + return vm.throw_completion<JS::TypeError>(global_object, String::formatted("'{}' is not a valid column", column_name_str)); + + auto bounds = sheet.written_data_bounds(*maybe_column_index); + return JS::Value(bounds.row); +} + WorkbookObject::WorkbookObject(Workbook& workbook) : JS::Object(*JS::Object::create(workbook.global_object(), workbook.global_object().object_prototype())) , m_workbook(workbook) diff --git a/Userland/Applications/Spreadsheet/JSIntegration.h b/Userland/Applications/Spreadsheet/JSIntegration.h index c82070eee9..24ed2e8bd7 100644 --- a/Userland/Applications/Spreadsheet/JSIntegration.h +++ b/Userland/Applications/Spreadsheet/JSIntegration.h @@ -38,6 +38,7 @@ public: JS_DECLARE_NATIVE_FUNCTION(current_cell_position); JS_DECLARE_NATIVE_FUNCTION(column_index); JS_DECLARE_NATIVE_FUNCTION(column_arithmetic); + JS_DECLARE_NATIVE_FUNCTION(get_column_bound); private: virtual void visit_edges(Visitor&) override; diff --git a/Userland/Applications/Spreadsheet/Spreadsheet.cpp b/Userland/Applications/Spreadsheet/Spreadsheet.cpp index 188a4d8e59..beaa6fc38e 100644 --- a/Userland/Applications/Spreadsheet/Spreadsheet.cpp +++ b/Userland/Applications/Spreadsheet/Spreadsheet.cpp @@ -468,12 +468,14 @@ RefPtr<Sheet> Sheet::from_json(const JsonObject& object, Workbook& workbook) return sheet; } -Position Sheet::written_data_bounds() const +Position Sheet::written_data_bounds(Optional<size_t> column_index) const { Position bound; - for (auto& entry : m_cells) { + for (auto const& entry : m_cells) { if (entry.value->data().is_empty()) continue; + if (column_index.has_value() && entry.key.column != *column_index) + continue; if (entry.key.row >= bound.row) bound.row = entry.key.row; if (entry.key.column >= bound.column) diff --git a/Userland/Applications/Spreadsheet/Spreadsheet.h b/Userland/Applications/Spreadsheet/Spreadsheet.h index e3f0f24e95..69248dff8b 100644 --- a/Userland/Applications/Spreadsheet/Spreadsheet.h +++ b/Userland/Applications/Spreadsheet/Spreadsheet.h @@ -127,8 +127,8 @@ public: void copy_cells(Vector<Position> from, Vector<Position> to, Optional<Position> resolve_relative_to = {}, CopyOperation copy_operation = CopyOperation::Copy); - /// Gives the bottom-right corner of the smallest bounding box containing all the written data. - Position written_data_bounds() const; + /// Gives the bottom-right corner of the smallest bounding box containing all the written data, optionally limited to the given column. + Position written_data_bounds(Optional<size_t> column_index = {}) const; bool columns_are_standard() const; |