@odoo/o-spreadsheet
Version:
A spreadsheet component
1,576 lines (1,566 loc) • 2.09 MB
JavaScript
/**
* This file is generated by o-spreadsheet build tools. Do not edit it.
* @see https://github.com/odoo/o-spreadsheet
* @version 19.1.0-alpha.3
* @date 2026-01-21T11:06:38.842Z
* @hash ceae12a
*/
class FunctionCodeBuilder {
scope;
code = "";
constructor(scope = new Scope()) {
this.scope = scope;
}
append(...lines) {
this.code += lines.map((line) => line.toString()).join("\n") + "\n";
}
return(expression) {
return new FunctionCodeImpl(this.scope, this.code, expression);
}
toString() {
return indentCode(this.code);
}
}
class FunctionCodeImpl {
scope;
returnExpression;
code;
constructor(scope, code, returnExpression) {
this.scope = scope;
this.returnExpression = returnExpression;
this.code = indentCode(code);
}
toString() {
return this.code;
}
assignResultToVariable() {
if (this.scope.isAlreadyDeclared(this.returnExpression)) {
return this;
}
const variableName = this.scope.nextVariableName();
const code = new FunctionCodeBuilder(this.scope);
code.append(this.code);
code.append(`const ${variableName} = ${this.returnExpression};`);
return code.return(variableName);
}
}
class Scope {
nextId = 1;
declaredVariables = new Set();
nextVariableName() {
const name = `_${this.nextId++}`;
this.declaredVariables.add(name);
return name;
}
isAlreadyDeclared(name) {
return this.declaredVariables.has(name);
}
}
/**
* Takes a list of strings that might be single or multiline
* and maps them in a list of single line strings.
*/
function splitLines(str) {
return str
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "");
}
function indentCode(code) {
let result = "";
let indentLevel = 0;
const lines = splitLines(code);
for (const line of lines) {
if (line.startsWith("}")) {
indentLevel--;
}
result += "\t".repeat(indentLevel) + line + "\n";
if (line.endsWith("{")) {
indentLevel++;
}
}
return result.trim();
}
//------------------------------------------------------------------------------
// Arg description DSL
//------------------------------------------------------------------------------
const ARG_REGEXP = /(.*?)\((.*?)\)(.*)/;
const ARG_TYPES = [
"ANY",
"BOOLEAN",
"DATE",
"NUMBER",
"STRING",
"RANGE",
"RANGE<BOOLEAN>",
"RANGE<DATE>",
"RANGE<NUMBER>",
"RANGE<STRING>",
"META",
"RANGE<META>",
];
function arg(definition, description = "", proposals) {
return makeArg(definition, description, proposals);
}
function makeArg(str, description, proposals) {
const parts = str.match(ARG_REGEXP);
const name = parts[1].trim();
if (!name) {
throw new Error(`Function argument definition is missing a name: '${str}'.`);
}
const types = [];
let isOptional = false;
let isRepeating = false;
let defaultValue;
for (const param of parts[2].split(",")) {
const key = param.trim().toUpperCase();
const type = ARG_TYPES.find((t) => key === t);
if (type) {
types.push(type);
}
else if (key === "RANGE<ANY>") {
types.push("RANGE");
}
else if (key === "OPTIONAL") {
isOptional = true;
}
else if (key === "REPEATING") {
isRepeating = true;
}
else if (key.startsWith("DEFAULT=")) {
defaultValue = param.trim().slice(8);
}
}
const result = {
name,
description,
type: types,
};
const acceptErrors = types.includes("ANY") || types.includes("RANGE");
if (acceptErrors) {
result.acceptErrors = true;
}
if (isOptional) {
result.optional = true;
}
if (isRepeating) {
result.repeating = true;
}
if (defaultValue !== undefined) {
result.default = true;
result.defaultValue = defaultValue;
}
if (types.some((t) => t.startsWith("RANGE"))) {
result.acceptMatrix = true;
}
if (types.every((t) => t.startsWith("RANGE"))) {
result.acceptMatrixOnly = true;
}
if (proposals && proposals.length > 0) {
result.proposalValues = proposals;
}
return result;
}
/**
* This function adds on description more general information derived from the
* arguments.
*
* This information is useful during compilation.
*/
function addMetaInfoFromArg(name, addDescr) {
let countArg = 0;
let minArg = 0;
let repeatingArg = 0;
let optionalArg = 0;
for (const arg of addDescr.args) {
countArg++;
if (!arg.optional && !arg.default) {
minArg++;
}
if (arg.repeating) {
repeatingArg++;
}
if ((arg.optional || arg.default) && !arg.repeating) {
optionalArg++;
}
}
const descr = addDescr;
descr.minArgRequired = minArg;
descr.maxArgPossible = repeatingArg ? Infinity : countArg;
descr.nbrArgRepeating = repeatingArg;
descr.nbrOptionalNonRepeatingArgs = optionalArg;
descr.hidden = addDescr.hidden || false;
descr.name = name;
return descr;
}
const cacheArgTargeting = {};
/**
* Returns a function that maps the position of a value in a function to its corresponding argument index.
*
* In most cases, the task is straightforward:
*
* In the formula "=SUM(11, 55, 66)" which is defined like this "SUM(value1, [value2, ...])":
* - 11 corresponds to the value1 argument => position will be 0
* - 55 and 66 correspond to the [value2, ...] argument => position will be 1
*
* In other cases, optional arguments could be defined after repeatable arguments,
* or even optional and required arguments could be mixed in unconventional ways.
*
* The next function has been designed to handle all possible configurations.
* The only restriction is if repeatable arguments are present in the function definition:
* - they must be defined consecutively
* - they must be in a quantity greater than the optional arguments.
*
* The markdown tables below illustrate how values are mapped to positions based on the number of values supplied.
* Each table represents a different function configuration, with columns representing the number of values supplied as arguments
* and rows representing the correspondence with the argument index.
*
* The tables are built based on the following conventions:
* - `m`: Mandatory argument (count as one argument)
* - `o`: Optional argument (count as zero or one argument)
* - `r`: Repeating argument (count as one or more arguments)
*
*
* Configuration 1: (m, o) like the CEILING function
*
* | | 1 | 2 |
* |---|---|---|
* | m | 0 | 0 |
* | o | | 1 |
*
*
* Configuration 2: (m, m, m, r, r) like the SUMIFS function
*
* | | 5 | 7 | 3 + 2n |
* |---|---|------|------------|
* | m | 0 | 0 | 0 |
* | m | 1 | 1 | 1 |
* | m | 2 | 2 | 2 |
* | r | 3 | 3, 5 | 3 + 2n |
* | r | 4 | 4, 6 | 3 + 2n + 1 |
*
*
* Configuration 3: (m, m, m, r, r, o) like the SWITCH function
*
* | | 5 | 6 | 7 | 8 | 3 + 2n | 3 + 2n + 1 |
* |---|---|---|------|------|------------|----------------|
* | m | 0 | 0 | 0 | 0 | 0 | 0 |
* | m | 1 | 1 | 1 | 1 | 1 | 1 |
* | m | 2 | 2 | 2 | 2 | 2 | 2 |
* | r | 3 | 3 | 3, 5 | 3, 5 | 3 + 2n | 3 + 2n |
* | r | 4 | 4 | 4, 6 | 4, 6 | 3 + 2n + 1 | 3 + 2n + 1 |
* | o | | 5 | | 7 | | 3 + 2N + 2 |
*
*
* Configuration 4: (m, o, m, o, r, r, r, m) a complex case to understand subtleties
*
* | | 6 | 7 | 8 | 9 | 10 | 11 | ... |
* |---|---|---|---|------|------|------|-----|
* | m | 0 | 0 | 0 | 0 | 0 | 0 | ... |
* | o | | 1 | 1 | | 1 | 1 | ... |
* | m | 1 | 2 | 2 | 1 | 2 | 2 | ... |
* | o | | | 3 | | | 3 | ... |
* | r | 2 | 3 | 4 | 2, 5 | 3, 6 | 4, 7 | ... |
* | r | 3 | 4 | 5 | 3, 6 | 4, 7 | 5, 8 | ... |
* | r | 4 | 5 | 6 | 4, 7 | 5, 8 | 6, 9 | ... |
* | m | 5 | 6 | 7 | 8 | 9 | 10 | ... |
*
*/
function argTargeting(functionDescription, nbrArgSupplied) {
const functionName = functionDescription.name;
const result = cacheArgTargeting[functionName]?.[nbrArgSupplied];
if (result) {
return result;
}
if (!cacheArgTargeting[functionName]) {
cacheArgTargeting[functionName] = {};
}
if (!cacheArgTargeting[functionName][nbrArgSupplied]) {
cacheArgTargeting[functionName][nbrArgSupplied] = _argTargeting(functionDescription, nbrArgSupplied);
}
return cacheArgTargeting[functionName][nbrArgSupplied];
}
function _argTargeting(functionDescription, nbrArgSupplied) {
const valueIndexToArgPosition = {};
const groupsOfOptionalRepeatingValues = functionDescription.nbrArgRepeating
? Math.floor((nbrArgSupplied - functionDescription.minArgRequired) / functionDescription.nbrArgRepeating)
: 0;
const nbrValueOptionalRepeating = functionDescription.nbrArgRepeating * groupsOfOptionalRepeatingValues;
const nbrValueOptional = nbrArgSupplied - functionDescription.minArgRequired - nbrValueOptionalRepeating;
let countValueSupplied = 0;
let countValueOptional = 0;
for (let i = 0; i < functionDescription.args.length; i++) {
const arg = functionDescription.args[i];
if ((arg.optional || arg.default) && !arg.repeating) {
if (countValueOptional < nbrValueOptional) {
valueIndexToArgPosition[countValueSupplied] = { index: i };
countValueSupplied++;
}
countValueOptional++;
continue;
}
if (arg.repeating) {
const groupOfMandatoryRepeatingValues = arg.optional ? 0 : 1;
// As we know all repeating arguments are consecutive,
// --> we will treat all repeating arguments in one go
// --> the index i will be incremented by the number of repeating values at the end of the loop
for (let j = 0; j < groupsOfOptionalRepeatingValues + groupOfMandatoryRepeatingValues; j++) {
for (let k = 0; k < functionDescription.nbrArgRepeating; k++) {
valueIndexToArgPosition[countValueSupplied] = { index: i + k, repeatingGroupIndex: j };
countValueSupplied++;
}
}
i += functionDescription.nbrArgRepeating - 1;
continue;
}
// End case: it's a required argument
valueIndexToArgPosition[countValueSupplied] = { index: i };
countValueSupplied++;
}
return (argPosition) => {
return valueIndexToArgPosition[argPosition];
};
}
//------------------------------------------------------------------------------
// Argument validation
//------------------------------------------------------------------------------
const META_TYPES = ["META", "RANGE<META>"];
function validateArguments(descr) {
if (descr.nbrArgRepeating && descr.nbrOptionalNonRepeatingArgs >= descr.nbrArgRepeating) {
throw new Error(`Function ${descr.name} has more optional arguments than repeatable ones.`);
}
let foundRepeating = false;
let consecutiveRepeating = false;
for (const current of descr.args) {
if (current.type.some((t) => META_TYPES.includes(t)) &&
current.type.some((t) => !META_TYPES.includes(t))) {
throw new Error(`Function ${descr.name} has a mix of META and non-META types in the same argument: ${current.type}.`);
}
if (current.repeating) {
if (!consecutiveRepeating && foundRepeating) {
throw new Error(`Function ${descr.name} has non-consecutive repeating arguments. All repeating arguments must be declared consecutively.`);
}
foundRepeating = true;
consecutiveRepeating = true;
}
else {
consecutiveRepeating = false;
}
}
}
let Registry$1 = class Registry {
content = {};
add(key, value) {
if (key in this.content) {
throw new Error(`${key} is already present in this registry!`);
}
return this.replace(key, value);
}
replace(key, value) {
this.content[key] = value;
return this;
}
get(key) {
const content = this.content[key];
if (!content && !(key in this.content)) {
throw new Error(`Cannot find ${key} in this registry!`);
}
return content;
}
contains(key) {
return key in this.content;
}
getAll() {
return Object.values(this.content);
}
getKeys() {
return Object.keys(this.content);
}
remove(key) {
delete this.content[key];
}
};
const defaultTranslate = (s) => s;
const defaultLoaded = () => false;
let _translate = defaultTranslate;
let _loaded = defaultLoaded;
function sprintf(s, ...values) {
if (values.length === 1 && typeof values[0] === "object" && !(values[0] instanceof String)) {
const valuesDict = values[0];
s = s.replace(/\%\(([^\)]+)\)s/g, (match, value) => valuesDict[value]);
}
else if (values.length > 0) {
s = s.replace(/\%s/g, () => values.shift());
}
return s;
}
/***
* Allow to inject a translation function from outside o-spreadsheet. This should be called before instantiating
* a model.
* @param tfn the function that will do the translation
* @param loaded a function that returns true when the translation is loaded
*/
function setTranslationMethod(tfn, loaded = () => true) {
_translate = tfn;
_loaded = loaded;
}
/**
* If no translation function has been set, this will mark the translation are loaded.
*
* By default, the translations should not be set as loaded, otherwise top-level translated constants will never be
* translated. But if by the time the model is instantiated no custom translation function has been set, we can set
* the default translation function as loaded so o-spreadsheet can be run in standalone with no translations.
*/
function setDefaultTranslationMethod() {
if (_translate === defaultTranslate && _loaded === defaultLoaded) {
_loaded = () => true;
}
}
const _t = function (s, ...values) {
if (!_loaded()) {
return new LazyTranslatedString(s, values);
}
return sprintf(_translate(s), ...values);
};
class LazyTranslatedString extends String {
values;
constructor(str, values) {
super(str);
this.values = values;
}
valueOf() {
const str = super.valueOf();
return _loaded() ? sprintf(_translate(str), ...this.values) : sprintf(str, ...this.values);
}
toString() {
return this.valueOf();
}
}
const CellErrorType = {
NotAvailable: "#N/A",
InvalidReference: "#REF",
BadExpression: "#BAD_EXPR",
CircularDependency: "#CYCLE",
UnknownFunction: "#NAME?",
DivisionByZero: "#DIV/0!",
InvalidNumber: "#NUM!",
SpilledBlocked: "#SPILL!",
GenericError: "#ERROR",
NullError: "#NULL!",
};
const errorTypes = new Set(Object.values(CellErrorType));
class EvaluationError {
message;
value;
constructor(message = _t("Error"), value = CellErrorType.GenericError) {
this.message = message;
this.value = value;
this.message = message.toString();
}
}
class BadExpressionError extends EvaluationError {
constructor(message = _t("Invalid expression")) {
super(message, CellErrorType.BadExpression);
}
}
class CircularDependencyError extends EvaluationError {
constructor(message = _t("Circular reference")) {
super(message, CellErrorType.CircularDependency);
}
}
class InvalidReferenceError extends EvaluationError {
constructor(message = _t("Invalid reference")) {
super(message, CellErrorType.InvalidReference);
}
}
class NotAvailableError extends EvaluationError {
constructor(message = _t("Data not available")) {
super(message, CellErrorType.NotAvailable);
}
}
class UnknownFunctionError extends EvaluationError {
constructor(message = _t("Unknown function")) {
super(message, CellErrorType.UnknownFunction);
}
}
class SplillBlockedError extends EvaluationError {
constructor(message = _t("Spill range is not empty")) {
super(message, CellErrorType.SpilledBlocked);
}
}
class DivisionByZeroError extends EvaluationError {
constructor(message = _t("Division by zero")) {
super(message, CellErrorType.DivisionByZero);
}
}
class NumberTooLargeError extends EvaluationError {
constructor(message = _t("Number too large")) {
super(message, CellErrorType.InvalidNumber);
}
}
const borderStyles = ["thin", "medium", "thick", "dashed", "dotted"];
function isMatrix(x) {
return Array.isArray(x) && Array.isArray(x[0]);
}
var DIRECTION;
(function (DIRECTION) {
DIRECTION["UP"] = "up";
DIRECTION["DOWN"] = "down";
DIRECTION["LEFT"] = "left";
DIRECTION["RIGHT"] = "right";
})(DIRECTION || (DIRECTION = {}));
// Colors
const HIGHLIGHT_COLOR = "#017E84";
const SELECTION_BORDER_COLOR = "#3266ca";
const BACKGROUND_CHART_COLOR = "#FFFFFF";
const LINK_COLOR = HIGHLIGHT_COLOR;
const GRAY_900 = "#111827";
const GRAY_300 = "#D8DADD";
const GRAY_200 = "#E7E9ED";
const TEXT_BODY = "#374151";
const TEXT_BODY_MUTED = TEXT_BODY + "C2";
const ACTION_COLOR = HIGHLIGHT_COLOR;
const CHART_PADDING = 20;
const CHART_PADDING_BOTTOM = 10;
const CHART_PADDING_TOP = 15;
const CHART_TITLE_FONT_SIZE = 16;
const CHART_AXIS_TITLE_FONT_SIZE = 12;
const SCORECARD_CHART_TITLE_FONT_SIZE = 14;
// Color picker defaults as upper case HEX to match `toHex`helper
const COLOR_PICKER_DEFAULTS = [
"#000000",
"#434343",
"#666666",
"#999999",
"#B7B7B7",
"#CCCCCC",
"#D9D9D9",
"#EFEFEF",
"#F3F3F3",
"#FFFFFF",
"#980000",
"#FF0000",
"#FF9900",
"#FFFF00",
"#00FF00",
"#00FFFF",
"#4A86E8",
"#0000FF",
"#9900FF",
"#FF00FF",
"#E6B8AF",
"#F4CCCC",
"#FCE5CD",
"#FFF2CC",
"#D9EAD3",
"#D0E0E3",
"#C9DAF8",
"#CFE2F3",
"#D9D2E9",
"#EAD1DC",
"#DD7E6B",
"#EA9999",
"#F9CB9C",
"#FFE599",
"#B6D7A8",
"#A2C4C9",
"#A4C2F4",
"#9FC5E8",
"#B4A7D6",
"#D5A6BD",
"#CC4125",
"#E06666",
"#F6B26B",
"#FFD966",
"#93C47D",
"#76A5AF",
"#6D9EEB",
"#6FA8DC",
"#8E7CC3",
"#C27BA0",
"#A61C00",
"#CC0000",
"#E69138",
"#F1C232",
"#6AA84F",
"#45818E",
"#3C78D8",
"#3D85C6",
"#674EA7",
"#A64D79",
"#85200C",
"#990000",
"#B45F06",
"#BF9000",
"#38761D",
"#134F5C",
"#1155CC",
"#0B5394",
"#351C75",
"#741B47",
"#5B0F00",
"#660000",
"#783F04",
"#7F6000",
"#274E13",
"#0C343D",
"#1C4587",
"#073763",
"#20124D",
"#4C1130",
];
const DEFAULT_CELL_WIDTH = 96;
const DEFAULT_CELL_HEIGHT = 23;
const SCROLLBAR_WIDTH = 15;
const MIN_CF_ICON_MARGIN = 4;
const MIN_CELL_TEXT_MARGIN = 4;
const PADDING_AUTORESIZE_VERTICAL = 3;
const PADDING_AUTORESIZE_HORIZONTAL = MIN_CELL_TEXT_MARGIN;
const GRID_ICON_MARGIN = 2;
const GRID_ICON_EDGE_LENGTH = 17;
const FOOTER_HEIGHT = 2 * DEFAULT_CELL_HEIGHT;
const DATA_VALIDATION_CHIP_MARGIN = 5;
// Style
const DEFAULT_STYLE = {
align: "left",
verticalAlign: "bottom",
wrapping: "overflow",
bold: false,
italic: false,
strikethrough: false,
underline: false,
fontSize: 10,
fillColor: "",
textColor: "",
rotation: 0,
};
const DEFAULT_VERTICAL_ALIGN = DEFAULT_STYLE.verticalAlign;
// Fonts
const DEFAULT_FONT_WEIGHT = "400";
const DEFAULT_FONT_SIZE = DEFAULT_STYLE.fontSize;
const DEFAULT_FONT = "'Roboto', arial";
// Max Number of history steps kept in memory
const MAX_HISTORY_STEPS = 99;
// Id of the first revision
const DEFAULT_REVISION_ID = "START_REVISION";
// Figure
const DEFAULT_FIGURE_HEIGHT = 335;
const DEFAULT_FIGURE_WIDTH = 536;
const FIGURE_BORDER_WIDTH = 1;
// Chart
const MAX_CHAR_LABEL = 20;
const FIGURE_ID_SPLITTER = "??";
const DEFAULT_SCORECARD_KEY_VALUE_FONT_SIZE = 32;
const DEFAULT_SCORECARD_BASELINE_FONT_SIZE = 16;
const DEFAULT_WINDOW_SIZE = 2;
// session
const DEBOUNCE_TIME = 200;
const MESSAGE_VERSION = 1;
const FORBIDDEN_SHEETNAME_CHARS_IN_EXCEL_REGEX = /'|\*|\?|\/|\\|\[|\]/;
// Cells
const FORMULA_REF_IDENTIFIER = "|";
let DEFAULT_SHEETVIEW_SIZE = 0;
function getDefaultSheetViewSize() {
return DEFAULT_SHEETVIEW_SIZE;
}
const NEWLINE = "\n";
// Pivot
const PIVOT_TABLE_CONFIG = {
hasFilters: false,
totalRow: false,
firstColumn: true,
lastColumn: false,
numberOfHeaders: 1,
bandedRows: true,
bandedColumns: false,
styleId: "TableStyleMedium5",
automaticAutofill: false,
};
const PIVOT_INDENT = 15;
const PIVOT_COLLAPSE_ICON_SIZE = 12;
const PIVOT_MAX_NUMBER_OF_CELLS = 5e5;
//------------------------------------------------------------------------------
// Miscellaneous
//------------------------------------------------------------------------------
const sanitizeSheetNameRegex = new RegExp(FORBIDDEN_SHEETNAME_CHARS_IN_EXCEL_REGEX, "g");
function isCloneable(obj) {
return "clone" in obj && obj.clone instanceof Function;
}
/**
* Escapes a string to use as a literal string in a RegExp.
* @url https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
*/
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Deep copy arrays, plain objects and primitive values.
* Throws an error for other types such as class instances.
* Sparse arrays remain sparse.
*/
function deepCopy(obj) {
switch (typeof obj) {
case "object": {
if (obj === null) {
return obj;
}
else if (isCloneable(obj)) {
return obj.clone();
}
else if (!(isPlainObject(obj) || obj instanceof Array)) {
throw new Error("Unsupported type: only objects and arrays are supported");
}
const result = Array.isArray(obj) ? new Array(obj.length) : {};
if (Array.isArray(obj)) {
for (let i = 0, len = obj.length; i < len; i++) {
if (i in obj) {
result[i] = deepCopy(obj[i]);
}
}
}
else {
for (const key in obj) {
result[key] = deepCopy(obj[key]);
}
}
return result;
}
case "number":
case "string":
case "boolean":
case "function":
case "undefined":
return obj;
default:
throw new Error(`Unsupported type: ${typeof obj}`);
}
}
/**
* Check if the object is a plain old javascript object.
*/
function isPlainObject(obj) {
return (typeof obj === "object" &&
obj !== null &&
// obj.constructor can be undefined when there's no prototype (`Object.create(null, {})`)
(obj?.constructor === Object || obj?.constructor === undefined));
}
/**
* Sanitize the name of a sheet, by eventually removing quotes.
*/
function getUnquotedSheetName(sheetName) {
return unquote(sheetName, "'");
}
/**
* Remove quotes from a quoted string.
*/
function unquote(string, quoteChar = '"') {
if (string.startsWith(quoteChar)) {
string = string.slice(1);
}
if (string.endsWith(quoteChar)) {
string = string.slice(0, -1);
}
return string;
}
/**
* Add quotes around the sheet name or any symbol name if it contains at least one non alphanumeric character.
*/
function getCanonicalSymbolName(symbolName) {
if (symbolName.match(/\w/g)?.length !== symbolName.length) {
symbolName = `'${symbolName}'`;
}
return symbolName;
}
/** Replace the excel-excluded characters of a sheetName */
function sanitizeSheetName(sheetName, replacementChar = " ") {
return sheetName.replace(sanitizeSheetNameRegex, replacementChar);
}
function clip(val, min, max) {
return val < min ? min : val > max ? max : val;
}
/**
* Create a range from start (included) to end (excluded).
* range(10, 13) => [10, 11, 12]
* range(2, 8, 2) => [2, 4, 6]
*/
function range(start, end, step = 1) {
if (end <= start && step > 0) {
return [];
}
if (step === 0) {
throw new Error("range() step must not be zero");
}
const length = Math.ceil(Math.abs((end - start) / step));
const array = Array(length);
for (let i = 0; i < length; i++) {
array[i] = start + i * step;
}
return array;
}
/**
* Groups consecutive numbers.
* The input array is assumed to be sorted
* @param numbers
*/
function groupConsecutive(numbers) {
return numbers.reduce((groups, currentRow, index, rows) => {
if (Math.abs(currentRow - rows[index - 1]) === 1) {
const lastGroup = groups[groups.length - 1];
lastGroup.push(currentRow);
}
else {
groups.push([currentRow]);
}
return groups;
}, []);
}
/**
* Create one generator from two generators by linking
* each item of the first generator to the next item of
* the second generator.
*
* Let's say generator G1 yields A, B, C and generator G2 yields X, Y, Z.
* The resulting generator of `linkNext(G1, G2)` will yield A', B', C'
* where `A' = A & {next: Y}`, `B' = B & {next: Z}` and `C' = C & {next: undefined}`
* @param generator
* @param nextGenerator
*/
function* linkNext(generator, nextGenerator) {
nextGenerator.next();
for (const item of generator) {
const nextItem = nextGenerator.next();
yield {
...item,
next: nextItem.done ? undefined : nextItem.value,
};
}
}
function isBoolean(str) {
const upperCased = str.toUpperCase();
return upperCased === "TRUE" || upperCased === "FALSE";
}
const MARKDOWN_LINK_REGEX = /^\[(.+)\]\((.+)\)$/;
//link must start with http or https
//https://stackoverflow.com/a/3809435/4760614
const WEB_LINK_REGEX = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/;
function isMarkdownLink(str) {
return MARKDOWN_LINK_REGEX.test(str);
}
/**
* Check if the string is a web link.
* e.g. http://odoo.com
*/
function isWebLink(str) {
return WEB_LINK_REGEX.test(str);
}
/**
* Build a markdown link from a label and an url
*/
function markdownLink(label, url) {
return `[${label}](${url})`;
}
function parseMarkdownLink(str) {
const matches = str.match(MARKDOWN_LINK_REGEX) || [];
const label = matches[1];
const url = matches[2];
if (!label || !url) {
throw new Error(`Could not parse markdown link ${str}.`);
}
return {
label,
url,
};
}
const O_SPREADSHEET_LINK_PREFIX = "o-spreadsheet://";
function isSheetUrl(url) {
return url.startsWith(O_SPREADSHEET_LINK_PREFIX);
}
function buildSheetLink(sheetId) {
return `${O_SPREADSHEET_LINK_PREFIX}${sheetId}`;
}
/**
* Parse a sheet link and return the sheet id
*/
function parseSheetUrl(sheetLink) {
if (sheetLink.startsWith(O_SPREADSHEET_LINK_PREFIX)) {
return sheetLink.slice(O_SPREADSHEET_LINK_PREFIX.length);
}
throw new Error(`${sheetLink} is not a valid sheet link`);
}
/**
* This helper function can be used as a type guard when filtering arrays.
* const foo: number[] = [1, 2, undefined, 4].filter(isDefined)
*/
function isDefined(argument) {
return argument !== undefined;
}
function isNotNull(argument) {
return argument !== null;
}
/**
* Check if all the values of an object, and all the values of the objects inside of it, are undefined.
*/
function isObjectEmptyRecursive(argument) {
if (argument === undefined)
return true;
return Object.values(argument).every((value) => typeof value === "object" ? isObjectEmptyRecursive(value) : !value);
}
/**
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds. If `immediate` is passed, the function is called is called
* immediately on the first call and the debouncing is triggered starting the second
* call in the defined time window.
*
* Example:
* debouncedFunction = debounce(() => console.log('Hello!'), 250);
* debouncedFunction(); debouncedFunction(); // Will log 'Hello!' after 250ms
*
* debouncedFunction = debounce(() => console.log('Hello!'), 250, true);
* debouncedFunction(); debouncedFunction(); // Will log 'Hello!' and relog it after 250ms
*
*
* Also decorate the argument function with two methods: stopDebounce and isDebouncePending.
*
* Inspired by https://davidwalsh.name/javascript-debounce-function
*/
function debounce(func, wait, immediate) {
let timeout = undefined;
let firstCalled = false;
const debounced = function () {
const context = this;
const args = Array.from(arguments);
if (!firstCalled && immediate) {
firstCalled = true;
return func.apply(context, args);
}
function later() {
timeout = undefined;
firstCalled = false;
func.apply(context, args);
}
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
debounced.isDebouncePending = () => timeout !== undefined;
debounced.stopDebounce = () => {
clearTimeout(timeout);
};
return debounced;
}
/**
* Creates a batched version of a callback so that all calls to it in the same
* microtick will only call the original callback once.
*
* @param callback the callback to batch
* @returns a batched version of the original callback
*
* Copied from odoo/owl repo.
*/
function batched(callback) {
let scheduled = false;
return async (...args) => {
if (!scheduled) {
scheduled = true;
await Promise.resolve();
scheduled = false;
callback(...args);
}
};
}
/*
* Concatenate an array of strings.
*/
function concat(chars) {
// ~40% faster than chars.join("")
let output = "";
for (let i = 0, len = chars.length; i < len; i++) {
output += chars[i];
}
return output;
}
/**
* Lazy value computed by the provided function.
*/
function lazy(fn) {
let isMemoized = false;
let memo;
const lazyValue = () => {
if (!isMemoized) {
memo = fn instanceof Function ? fn() : fn;
isMemoized = true;
}
return memo;
};
lazyValue.map = (callback) => lazy(() => callback(lazyValue()));
return lazyValue;
}
/**
* Find the next defined value after the given index in an array of strings. If there is no defined value
* after the index, return the closest defined value before the index. Return an empty string if no
* defined value was found.
*
*/
function findNextDefinedValue(arr, index) {
let value = arr.slice(index).find((val) => val);
if (!value) {
value = arr
.slice(0, index)
.reverse()
.find((val) => val);
}
return value || "";
}
/** Get index of first header added by an ADD_COLUMNS_ROWS command */
function getAddHeaderStartIndex(position, base) {
return position === "after" ? base + 1 : base;
}
/**
* Compares n objects.
*/
function deepEquals(...o) {
if (o.length <= 1)
return true;
for (let index = 1; index < o.length; index++) {
if (!_deepEquals(o[0], o[index]))
return false;
}
return true;
}
function _deepEquals(o1, o2) {
if (o1 === o2)
return true;
if ((o1 && !o2) || (o2 && !o1))
return false;
if (typeof o1 !== typeof o2)
return false;
if (typeof o1 !== "object")
return false;
// Objects can have different keys if the values are undefined
for (const key in o2) {
if (!(key in o1) && o2[key] !== undefined) {
return false;
}
}
for (const key in o1) {
if (typeof o1[key] !== typeof o2[key])
return false;
if (typeof o1[key] === "object") {
if (!_deepEquals(o1[key], o2[key]))
return false;
}
else {
if (o1[key] !== o2[key])
return false;
}
}
return true;
}
/**
* Compares two arrays.
* For performance reasons, this function is to be preferred
* to 'deepEquals' in the case we know that the inputs are arrays.
*/
function deepEqualsArray(arr1, arr2) {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (!deepEquals(arr1[i], arr2[i])) {
return false;
}
}
return true;
}
/**
* Check if the given array contains all the values of the other array.
* It makes the assumption that both array do not contain duplicates.
*/
function includesAll(arr, values) {
if (arr.length < values.length) {
return false;
}
const set = new Set(arr);
return values.every((value) => set.has(value));
}
/**
* Return an object with all the keys in the object that have a falsy value removed.
*/
function removeFalsyAttributes(obj) {
if (!obj)
return obj;
const cleanObject = { ...obj };
Object.keys(cleanObject).forEach((key) => !cleanObject[key] && delete cleanObject[key]);
return cleanObject;
}
/**
* Equivalent to "\s" in regexp, minus the new lines characters
*
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Character_Classes
*/
const specialWhiteSpaceSpecialCharacters = [
"\t",
"\f",
"\v",
String.fromCharCode(parseInt("00a0", 16)),
String.fromCharCode(parseInt("1680", 16)),
String.fromCharCode(parseInt("2000", 16)),
String.fromCharCode(parseInt("200a", 16)),
String.fromCharCode(parseInt("2028", 16)),
String.fromCharCode(parseInt("2029", 16)),
String.fromCharCode(parseInt("202f", 16)),
String.fromCharCode(parseInt("205f", 16)),
String.fromCharCode(parseInt("3000", 16)),
String.fromCharCode(parseInt("feff", 16)),
];
const specialWhiteSpaceRegexp = new RegExp(specialWhiteSpaceSpecialCharacters.join("|"), "g");
const newLineRegexp = /(\r\n|\r)/g;
const whiteSpaceCharacters = specialWhiteSpaceSpecialCharacters.concat([" "]);
/**
* Replace all different newlines characters by \n.
*/
function replaceNewLines(text) {
if (!text)
return "";
return text.replace(newLineRegexp, NEWLINE);
}
/**
* Determine if the numbers are consecutive.
*/
function isConsecutive(iterable) {
const array = Array.from(iterable).sort((a, b) => a - b); // sort numerically rather than lexicographically
for (let i = 1; i < array.length; i++) {
if (array[i] - array[i - 1] !== 1) {
return false;
}
}
return true;
}
/**
* Creates a version of the function that's memoized on the value of its first argument, if any.
*/
function memoize(func) {
const cache = new Map();
const funcName = func.name ? func.name + " (memoized)" : "memoized";
return {
[funcName](...args) {
if (!cache.has(args[0])) {
cache.set(args[0], func(...args));
}
return cache.get(args[0]);
},
}[funcName];
}
/**
* Removes the specified indexes from the array.
* Sparse (empty) elements are transformed to undefined (unless their index is explicitly removed).
*/
function removeIndexesFromArray(array, indexes) {
const toRemove = new Set(indexes);
const newArray = [];
for (let i = 0; i < array.length; i++) {
if (!toRemove.has(i)) {
newArray.push(array[i]);
}
}
return newArray;
}
function insertItemsAtIndex(array, items, index) {
return array.slice(0, index).concat(items).concat(array.slice(index));
}
function replaceItemAtIndex(array, newItem, index) {
const newArray = [...array];
newArray[index] = newItem;
return newArray;
}
function trimContent(content) {
const contentLines = content.split("\n");
return contentLines.map((line) => line.replace(/\s+/g, " ").trim()).join("\n");
}
function isNumberBetween(value, min, max) {
if (min > max) {
return isNumberBetween(value, max, min);
}
return value >= min && value <= max;
}
/**
* Get a Regex for the find & replace that matches the given search string and options.
*/
function getSearchRegex(searchStr, searchOptions) {
let searchValue = escapeRegExp(searchStr);
const flags = !searchOptions.matchCase ? "i" : "";
if (searchOptions.exactMatch) {
searchValue = `^${searchValue}$`;
}
return RegExp(searchValue, flags);
}
/**
* Alternative to Math.max that works with large arrays.
* Typically useful for arrays bigger than 100k elements.
*/
function largeMax(array) {
let len = array.length;
if (len < 100_000)
return Math.max(...array);
let max = -Infinity;
while (len--) {
max = array[len] > max ? array[len] : max;
}
return max;
}
/**
* Alternative to Math.min that works with large arrays.
* Typically useful for arrays bigger than 100k elements.
*/
function largeMin(array) {
let len = array.length;
if (len < 100_000)
return Math.min(...array);
let min = +Infinity;
while (len--) {
min = array[len] < min ? array[len] : min;
}
return min;
}
class TokenizingChars {
text;
currentIndex = 0;
current;
constructor(text) {
this.text = text;
this.current = text[0];
}
shift() {
const current = this.current;
const next = this.text[++this.currentIndex];
this.current = next;
return current;
}
advanceBy(length) {
this.currentIndex += length;
this.current = this.text[this.currentIndex];
}
isOver() {
return this.currentIndex >= this.text.length;
}
remaining() {
return this.text.substring(this.currentIndex);
}
currentStartsWith(str) {
if (this.current !== str[0]) {
return false;
}
for (let j = 1; j < str.length; j++) {
if (this.text[this.currentIndex + j] !== str[j]) {
return false;
}
}
return true;
}
}
/**
* Remove duplicates from an array.
*
* @param array The array to remove duplicates from.
* @param cb A callback to get an element value.
*/
function removeDuplicates(array, cb = (a) => a) {
const set = new Set();
return array.filter((item) => {
const key = cb(item);
if (set.has(key)) {
return false;
}
set.add(key);
return true;
});
}
/**
* Similar to transposing and array, but with POJOs instead of arrays. Useful, for example, when manipulating
* a POJO grid[col][row] and you want to transpose it to grid[row][col].
*
* The resulting object is created such as result[key1][key2] = pojo[key2][key1]
*/
function transpose2dPOJO(pojo) {
const result = {};
for (const key in pojo) {
for (const subKey in pojo[key]) {
if (!result[subKey]) {
result[subKey] = {};
}
result[subKey][key] = pojo[key][subKey];
}
}
return result;
}
function getUniqueText(text, texts, options = {}) {
const compute = options.compute ?? ((text, i) => `${text} (${i})`);
const computeFirstOne = options.computeFirstOne ?? false;
let i = options.start ?? 1;
let newText = computeFirstOne ? compute(text, i) : text;
while (texts.includes(newText)) {
newText = compute(text, i++);
}
return newText;
}
function isFormula(content) {
return content.startsWith("=") || content.startsWith("+");
}
// TODO: we should make make ChartStyle be the same as Style sometime ...
function chartStyleToCellStyle(style) {
return {
bold: style.bold,
italic: style.italic,
fontSize: style.fontSize,
textColor: style.color,
align: style.align,
};
}
// -----------------------------------------------------------------------------
// Date Type
// -----------------------------------------------------------------------------
/**
* A DateTime object that can be used to manipulate spreadsheet dates.
* Conceptually, a spreadsheet date is simply a number with a date format,
* and it is timezone-agnostic.
* This DateTime object consistently uses UTC time to represent a naive date and time.
*/
class DateTime {
jsDate;
constructor(year, month, day, hours = 0, minutes = 0, seconds = 0) {
this.jsDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds, 0));
}
static fromTimestamp(timestamp) {
const date = new Date(timestamp);
return new DateTime(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());
}
static now() {
const now = new Date();
return new DateTime(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds());
}
toString() {
return this.jsDate.toString();
}
toLocaleDateString() {
return this.jsDate.toLocaleDateString();
}
getTime() {
return this.jsDate.getTime();
}
getFullYear() {
return this.jsDate.getUTCFullYear();
}
getMonth() {
return this.jsDate.getUTCMonth();
}
getQuarter() {
return Math.floor(this.getMonth() / 3) + 1;
}
getDate() {
return this.jsDate.getUTCDate();
}
getDay() {
return this.jsDate.getUTCDay();
}
getHours() {
return this.jsDate.getUTCHours();
}
getMinutes() {
return this.jsDate.getUTCMinutes();
}
getSeconds() {
return this.jsDate.getUTCSeconds();
}
getIsoWeek() {
const date = new Date(this.jsDate.getTime());
const dayNumber = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNumber);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
setFullYear(year) {
return this.jsDate.setUTCFullYear(year);
}
setMonth(month) {
return this.jsDate.setUTCMonth(month);
}
setDate(date) {
return this.jsDate.setUTCDate(date);
}
setHours(hours) {
return this.jsDate.setUTCHours(hours);
}
setMinutes(minutes) {
return this.jsDate.setUTCMinutes(minutes);
}
setSeconds(seconds) {
return this.jsDate.setUTCSeconds(seconds);
}
}
// -----------------------------------------------------------------------------
// Parsing
// -----------------------------------------------------------------------------
const INITIAL_1900_DAY = new DateTime(1899, 11, 30);
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const CURRENT_MILLENIAL = 2000; // note: don't forget to update this in 2999
const CURRENT_YEAR = DateTime.now().getFullYear();
const CURRENT_MONTH = DateTime.now().getMonth();
const INITIAL_JS_DAY = DateTime.fromTimestamp(0);
const DATE_JS_1900_OFFSET = INITIAL_JS_DAY.getTime() - INITIAL_1900_DAY.getTime();
const mdyDateRegexp = /^\d{1,2}(\/|-|\s)\d{1,2}((\/|-|\s)\d{1,4})?$/;
const ymdDateRegexp = /^\d{3,4}(\/|-|\s)\d{1,2}(\/|-|\s)\d{1,2}$/;
const whiteSpaceChars = whiteSpaceCharacters.join("");
const dateSeparatorsRegex = new RegExp(`\/|-|${whiteSpaceCharacters.join("|")}`);
const dateRegexp = new RegExp(`^(\\d{1,4})[\/${whiteSpaceChars}\-](\\d{1,4})([\/${whiteSpaceChars}\-](\\d{1,4}))?$`);
const timeRegexp = /((\d+(:\d+)?(:\d+)?\s*(AM|PM))|(\d+:\d+(:\d+)?))$/;
/** Convert a value number representing a date, or return undefined if it isn't possible */
function valueToDateNumber(value, locale) {
switch (typeof value) {
case "number":
return value;
case "string":
if (isDateTime(value, locale)) {
return parseDateTime(value, locale)?.value;
}
return !value || isNaN(Number(value)) ? undefined : Number(value);
default:
return undefined;
}
}
function isDateTime(str, locale) {
return parseDateTime(str, locale) !== null;
}
const CACHE = new Map();
function parseDateTime(str, locale) {
if (!CACHE.has(locale)) {
CACHE.set(locale, new Map());
}
if (!CACHE.get(locale).has(str)) {
CACHE.get(locale).set(str, _parseDateTime(str, locale));
}
return CACHE.get(locale).get(str);
}
function _parseDateTime(str, locale) {
str = str.trim();
let time = null;
const timeMatch = str.match(timeRegexp);
if (timeMatch) {
time = parseTime(timeMatch[0]);
if (time === null) {
return null;
}
str = str.replace(timeMatch[0], "").trim();
}
let date = null;
const dateParts = getDateParts(str, locale);
if (dateParts) {
const separator = dateParts.dateString.match(dateSeparatorsRegex)[0];
date = parseDate(dateParts, separator);
if (date === null) {
return null;
}
str = str.replace(dateParts.dateString, "").trim();
}
if (str !== "" || !(date || time)) {
return null;
}
if (date && date.jsDate && time && time.jsDate) {
return {
value: date.value + time.value,
format: date.format + " " + (time.format === "hhhh:mm:ss" ? "hh:mm:ss" : time.format),
jsDate: new DateTime(date.jsDate.getFullYear() + time.jsDate.getFullYear() - 1899, date.jsDate.getMonth() + time.jsDate.getMonth() - 11, date.jsDate.getDate() + time.jsDate.getDate() - 30, date.jsDate.getHours() + time.jsDate.getHours(), date.jsDate.getMinutes() + time.jsDate.getMinutes(), date.jsDate.getSeconds() + time.jsDate.getSeconds()),
};
}
return date || time;
}
/**
* Returns the parts (day/month/year) of a date string corresponding to the given locale.
*
* - A string "xxxx-xx-xx" will be parsed as "y-m-d" no matter the locale.
* - A string "xx-xx-xxxx" will be parsed as "m-d-y" for mdy locale, and "d-m-y" for ymd and dmy locales.
* - A string "xx-xx-xx" will be "y-m-d" for ymd locale, "d-m-y" for dmy locale, "m-d-y" for mdy locale.
* - A string "xxxx-xx" will be parsed as "y-m" no matter the locale.
* - A string "xx-xx" will be parsed as "m-d" for mdy and ymd locales, and "d-m" for dmy locale.
*/
function getDateParts(dateString, locale) {
const match = dateString.match(dateRegexp);
if (!match) {
return null;
}
const [, part1, part2, , part3] = match;
if (part1.length > 2 && part3 && part3.length > 2) {
return null;
}
if (part1.length > 2) {
return { year: part1, month: part2, day: part3, dateString, type: "ymd" };
}
const localeDateType = getLocaleDateFormatType(locale);
if (!part3) {
if (part2.length > 2) {
// e.g. 11/2023
return { month: part1, year: part2, day: undefined, dateString, type: localeDateType };
}
if (localeDateType === "dmy") {
return { day: part1, month: part2, year: part3, dateString, type: "dmy" };
}
return { month: part1, day: part2, year: part3, dateString, type: "mdy" };
}
if (part3.length > 2) {
if (localeDateType === "mdy") {
return { month: part1, day: part2, year: part3, dateString, type: "mdy" };
}
return { day: part1, month: part2, year: part3, dateString, type: "dmy" };
}
if (localeDateType === "mdy") {
return { month: part1, day: part2, year: part3, dateString, type: "mdy" };
}
if (localeDateType === "ymd") {
return { year: part1, month: part2, day: part3, dateString, type: "ymd" };
}
if (localeDateType === "dmy") {
return { day: part1, month: part2, year: part3, dateString, type: "dmy" };
}
return null;
}
function getLocaleDateFormatType(locale) {
switch (locale.dateFormat[0]) {
case "d":
return "dmy";
case "m":
return "mdy";
case "y":
return "ymd";
}
throw new Error("Invalid date format in locale");
}
function parseDate(parts, separator) {
const { year: yearStr, month: monthStr, day: dayStr } = parts;
const month = inferMonth(monthStr);
const day = inferDay(dayStr);
const year = inferYear(yearStr);
if (year === null || month === null || day === null) {
return null;
}
// month + 1: months are 0-indexed in JS
const leadingZero = (monthStr?.length === 2 && month + 1 < 10) || (dayStr?.length === 2 && day < 10);
const fullYear = yearStr?.length !== 2;
const jsDate = new DateTime(year, month, day);
if (jsDate.getMonth() !== month || jsDate.getDate() !== day) {
// invalid date
return null;
}
const delta = jsDate.getTime() - INITIAL_1900_DAY.getTime();
const format = getFormatFromDateParts(parts, separator, leadingZero, fullYear);
return {
value: Math.round(delta / MS_PER_DAY),
format: format,
jsDate,
};
}
function getFormatFromDateParts(parts, sep, leadingZero, fullYear) {
const yearFmt = parts.year ? (fullYear ? "yyyy" : "yy") : undefined;
const monthFmt = parts.month ? (leadingZero ? "mm" : "m") : undefined;
const dayFmt = parts.day ? (leadingZero ? "dd" : "d") : undefined;
switch (parts.type) {
case "mdy":
retur