diff --git a/addons/spreadsheet/__init__.py b/addons/spreadsheet/__init__.py new file mode 100644 index 00000000000..d6210b1285d --- /dev/null +++ b/addons/spreadsheet/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/spreadsheet/__manifest__.py b/addons/spreadsheet/__manifest__.py new file mode 100644 index 00000000000..c8a1684ef85 --- /dev/null +++ b/addons/spreadsheet/__manifest__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': "Spreadsheet", + 'version': '1.0', + 'category': 'Hidden', + 'summary': 'Spreadsheet', + 'description': 'Spreadsheet', + 'depends': ['bus', 'web'], + 'data': [], + 'demo': [], + 'installable': True, + 'auto_install': False, + 'license': 'LGPL-3', + 'assets': { + 'spreadsheet.o_spreadsheet': [ + 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js', + 'spreadsheet/static/src/**/*.js', + ('remove', 'spreadsheet/static/src/assets_backend/**/*.js') + ], + 'web.assets_backend': [ + 'spreadsheet/static/src/**/*.scss', + 'spreadsheet/static/src/assets_backend/**/*.js' + ], + 'web.assets_qweb': [ + # Load all o_spreadsheet templates first to allow to inherit them + 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml', + 'spreadsheet/static/src/**/*.xml', + ], + 'web.qunit_suite_tests': [ + 'spreadsheet/static/tests/**/*', + ('include', 'spreadsheet.o_spreadsheet') + ] + } +} diff --git a/addons/spreadsheet/models/__init__.py b/addons/spreadsheet/models/__init__.py new file mode 100644 index 00000000000..1e5778f2654 --- /dev/null +++ b/addons/spreadsheet/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_currency +from . import res_currency_rate diff --git a/addons/spreadsheet/models/res_currency.py b/addons/spreadsheet/models/res_currency.py new file mode 100644 index 00000000000..53b4cb36730 --- /dev/null +++ b/addons/spreadsheet/models/res_currency.py @@ -0,0 +1,54 @@ +from odoo import api, models + + +class ResCurrency(models.Model): + _inherit = "res.currency" + + @api.model + def get_currencies_for_spreadsheet(self, currency_names): + """ + Returns the currency structure of provided currency names. + This function is meant to be called by the spreadsheet js lib, + hence the formatting of the result. + + :currency_names list(str): list of currency names (e.g. ["EUR", "USD", "CAD"]) + :return: list of dicts of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }` + """ + currencies = self.with_context(active_test=False).search( + [("name", "in", currency_names)], + ) + result = [] + for currency_name in currency_names: + currency = next(filter(lambda curr: curr.name == currency_name, currencies), None) + if currency: + currency_data = { + "code": currency.name, + "symbol": currency.symbol, + "decimalPlaces": currency.decimal_places, + "position": currency.position, + } + else: + currency_data = None + result.append(currency_data) + return result + + @api.model + def get_company_currency_for_spreadsheet(self, company_id=None): + """ + Returns the currency structure for the currency of the company. + This function is meant to be called by the spreadsheet js lib, + hence the formatting of the result. + + :company_id int: Id of the company + :return: dict of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }` + """ + company = self.env["res.company"].browse(company_id) if company_id else self.env.company + if not company.exists(): + return False + currency = company.currency_id + return { + "code": currency.name, + "symbol": currency.symbol, + "decimalPlaces": currency.decimal_places, + "position": currency.position, + } diff --git a/addons/spreadsheet/models/res_currency_rate.py b/addons/spreadsheet/models/res_currency_rate.py new file mode 100644 index 00000000000..5495967e680 --- /dev/null +++ b/addons/spreadsheet/models/res_currency_rate.py @@ -0,0 +1,29 @@ +from odoo import api, fields, models + + +class ResCurrencyRate(models.Model): + _inherit = "res.currency.rate" + + @api.model + def _get_rate_for_spreadsheet(self, currency_from_code, currency_to_code, date=None): + if not currency_from_code or not currency_to_code: + return False + Currency = self.env["res.currency"].with_context({"active_test": False}) + currency_from = Currency.search([("name", "=", currency_from_code)]) + currency_to = Currency.search([("name", "=", currency_to_code)]) + if not currency_from or not currency_to: + return False + company = self.env.company + date = fields.Date.from_string(date) if date else fields.Date.context_today(self) + return Currency._get_conversion_rate(currency_from, currency_to, company, date) + + @api.model + def get_rates_for_spreadsheet(self, requests): + result = [] + for request in requests: + record = request.copy() + record.update({ + "rate": self._get_rate_for_spreadsheet(request["from"], request["to"], request.get("date")), + }) + result.append(record) + return result diff --git a/addons/spreadsheet/static/img/spreadsheet.svg b/addons/spreadsheet/static/img/spreadsheet.svg new file mode 100644 index 00000000000..99e6078869e --- /dev/null +++ b/addons/spreadsheet/static/img/spreadsheet.svg @@ -0,0 +1 @@ +E1 \ No newline at end of file diff --git a/addons/spreadsheet/static/lib/chartjs-gauge/chartjs-gauge.js b/addons/spreadsheet/static/lib/chartjs-gauge/chartjs-gauge.js new file mode 100644 index 00000000000..317d56727db --- /dev/null +++ b/addons/spreadsheet/static/lib/chartjs-gauge/chartjs-gauge.js @@ -0,0 +1,363 @@ +/*! + * chartjs-gauge.js v0.3.0 + * https://github.com/haiiaaa/chartjs-gauge/ + * (c) 2021 chartjs-gauge.js Contributors + * Released under the MIT License + */ +(function (global, factory) { +typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js')) : +typeof define === 'function' && define.amd ? define(['chart.js'], factory) : +(global = global || self, global.Gauge = factory(global.Chart)); +}(this, (function (Chart) { 'use strict'; + +Chart = Chart && Object.prototype.hasOwnProperty.call(Chart, 'default') ? Chart['default'] : Chart; + +function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; +} + +function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + if (enumerableOnly) symbols = symbols.filter(function (sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + keys.push.apply(keys, symbols); + } + + return keys; +} + +function _objectSpread2(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] != null ? arguments[i] : {}; + + if (i % 2) { + ownKeys(Object(source), true).forEach(function (key) { + _defineProperty(target, key, source[key]); + }); + } else if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function (key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + } + + return target; +} + +Chart.defaults._set('gauge', { + needle: { + // Needle circle radius as the percentage of the chart area width + radiusPercentage: 2, + // Needle width as the percentage of the chart area width + widthPercentage: 3.2, + // Needle length as the percentage of the interval between inner radius (0%) and outer radius (100%) of the arc + lengthPercentage: 80, + // The color of the needle + color: 'rgba(0, 0, 0, 1)' + }, + valueLabel: { + // fontSize: undefined + display: true, + formatter: null, + color: 'rgba(255, 255, 255, 1)', + backgroundColor: 'rgba(0, 0, 0, 1)', + borderRadius: 5, + padding: { + top: 5, + right: 5, + bottom: 5, + left: 5 + }, + bottomMarginPercentage: 5 + }, + animation: { + duration: 1000, + animateRotate: true, + animateScale: false + }, + // The percentage of the chart that we cut out of the middle. + cutoutPercentage: 50, + // The rotation of the chart, where the first data arc begins. + rotation: -Math.PI, + // The total circumference of the chart. + circumference: Math.PI, + legend: { + display: false + }, + tooltips: { + enabled: false + } +}); + +var GaugeController = Chart.controllers.doughnut.extend({ + getValuePercent: function getValuePercent(_ref, value) { + var minValue = _ref.minValue, + data = _ref.data; + var min = minValue || 0; + var max = [undefined, null].includes(data[data.length - 1]) ? 1 : data[data.length - 1]; + var length = max - min; + var percent = (value - min) / length; + return percent; + }, + getWidth: function getWidth(chart) { + return chart.chartArea.right - chart.chartArea.left; + }, + getTranslation: function getTranslation(chart) { + var chartArea = chart.chartArea, + offsetX = chart.offsetX, + offsetY = chart.offsetY; + var centerX = (chartArea.left + chartArea.right) / 2; + var centerY = (chartArea.top + chartArea.bottom) / 2; + var dx = centerX + offsetX; + var dy = centerY + offsetY; + return { + dx: dx, + dy: dy + }; + }, + getAngle: function getAngle(_ref2) { + var chart = _ref2.chart, + valuePercent = _ref2.valuePercent; + var _chart$options = chart.options, + rotation = _chart$options.rotation, + circumference = _chart$options.circumference; + return rotation + circumference * valuePercent; + }, + + /* TODO set min padding, not applied until chart.update() (also chartArea must have been set) + setBottomPadding(chart) { + const needleRadius = this.getNeedleRadius(chart); + const padding = this.chart.config.options.layout.padding; + if (needleRadius > padding.bottom) { + padding.bottom = needleRadius; + return true; + } + return false; + }, + */ + drawNeedle: function drawNeedle(ease) { + if (!this.chart.animating) { + // triggered when hovering + ease = 1; + } + + var _this$chart = this.chart, + ctx = _this$chart.ctx, + config = _this$chart.config, + innerRadius = _this$chart.innerRadius, + outerRadius = _this$chart.outerRadius; + var dataset = config.data.datasets[this.index]; + + var _this$getMeta = this.getMeta(), + previous = _this$getMeta.previous; + + var _config$options$needl = config.options.needle, + radiusPercentage = _config$options$needl.radiusPercentage, + widthPercentage = _config$options$needl.widthPercentage, + lengthPercentage = _config$options$needl.lengthPercentage, + color = _config$options$needl.color; + var width = this.getWidth(this.chart); + var needleRadius = radiusPercentage / 100 * width; + var needleWidth = widthPercentage / 100 * width; + var needleLength = lengthPercentage / 100 * (outerRadius - innerRadius) + innerRadius; // center + + var _this$getTranslation = this.getTranslation(this.chart), + dx = _this$getTranslation.dx, + dy = _this$getTranslation.dy; // interpolate + + + var origin = this.getAngle({ + chart: this.chart, + valuePercent: previous.valuePercent + }); // TODO valuePercent is in current.valuePercent also + + var target = this.getAngle({ + chart: this.chart, + valuePercent: this.getValuePercent(dataset, dataset.value) + }); + var angle = origin + (target - origin) * ease; // draw + + ctx.save(); + ctx.translate(dx, dy); + ctx.rotate(angle); + ctx.fillStyle = color; // draw circle + + ctx.beginPath(); + ctx.ellipse(0, 0, needleRadius, needleRadius, 0, 0, 2 * Math.PI); + ctx.fill(); // draw needle + + ctx.beginPath(); + ctx.moveTo(0, needleWidth / 2); + ctx.lineTo(needleLength, 0); + ctx.lineTo(0, -needleWidth / 2); + ctx.fill(); + ctx.restore(); + }, + drawValueLabel: function drawValueLabel(ease) { + // eslint-disable-line no-unused-vars + if (!this.chart.config.options.valueLabel.display) { + return; + } + + var _this$chart2 = this.chart, + ctx = _this$chart2.ctx, + config = _this$chart2.config; + var defaultFontFamily = config.options.defaultFontFamily; + var dataset = config.data.datasets[this.index]; + var _config$options$value = config.options.valueLabel, + formatter = _config$options$value.formatter, + fontSize = _config$options$value.fontSize, + color = _config$options$value.color, + backgroundColor = _config$options$value.backgroundColor, + borderRadius = _config$options$value.borderRadius, + padding = _config$options$value.padding, + bottomMarginPercentage = _config$options$value.bottomMarginPercentage; + var width = this.getWidth(this.chart); + var bottomMargin = bottomMarginPercentage / 100 * width; + + var fmt = formatter || function (value) { + return value; + }; + + var valueText = fmt(dataset.value).toString(); + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + + if (fontSize) { + ctx.font = "".concat(fontSize, "px ").concat(defaultFontFamily); + } // const { width: textWidth, actualBoundingBoxAscent, actualBoundingBoxDescent } = ctx.measureText(valueText); + // const textHeight = actualBoundingBoxAscent + actualBoundingBoxDescent; + + + var _ctx$measureText = ctx.measureText(valueText), + textWidth = _ctx$measureText.width; // approximate height until browsers support advanced TextMetrics + + + var textHeight = Math.max(ctx.measureText('m').width, ctx.measureText("\uFF37").width); + var x = -(padding.left + textWidth / 2); + var y = -(padding.top + textHeight / 2); + var w = padding.left + textWidth + padding.right; + var h = padding.top + textHeight + padding.bottom; // center + + var _this$getTranslation2 = this.getTranslation(this.chart), + dx = _this$getTranslation2.dx, + dy = _this$getTranslation2.dy; // add rotation + + + var rotation = this.chart.options.rotation % (Math.PI * 2.0); + dx += bottomMargin * Math.cos(rotation + Math.PI / 2); + dy += bottomMargin * Math.sin(rotation + Math.PI / 2); // draw + + ctx.save(); + ctx.translate(dx, dy); // draw background + + ctx.beginPath(); + Chart.helpers.canvas.roundedRect(ctx, x, y, w, h, borderRadius); + ctx.fillStyle = backgroundColor; + ctx.fill(); // draw value text + + ctx.fillStyle = color || config.options.defaultFontColor; + var magicNumber = 0.075; // manual testing + + ctx.fillText(valueText, 0, textHeight * magicNumber); + ctx.restore(); + }, + // overrides + update: function update(reset) { + var dataset = this.chart.config.data.datasets[this.index]; + dataset.minValue = dataset.minValue || 0; + var meta = this.getMeta(); + var initialValue = { + valuePercent: 0 + }; // animations on will call update(reset) before update() + + if (reset) { + meta.previous = null; + meta.current = initialValue; + } else { + dataset.data.sort(function (a, b) { + return a - b; + }); + meta.previous = meta.current || initialValue; + meta.current = { + valuePercent: this.getValuePercent(dataset, dataset.value) + }; + } + + Chart.controllers.doughnut.prototype.update.call(this, reset); + }, + updateElement: function updateElement(arc, index, reset) { + // TODO handle reset and options.animation + Chart.controllers.doughnut.prototype.updateElement.call(this, arc, index, reset); + var dataset = this.getDataset(); + var data = dataset.data; // const { options } = this.chart.config; + // scale data + + var previousValue = index === 0 ? dataset.minValue : data[index - 1]; + var value = data[index]; + var startAngle = this.getAngle({ + chart: this.chart, + valuePercent: this.getValuePercent(dataset, previousValue) + }); + var endAngle = this.getAngle({ + chart: this.chart, + valuePercent: this.getValuePercent(dataset, value) + }); + var circumference = endAngle - startAngle; + arc._model = _objectSpread2({}, arc._model, { + startAngle: startAngle, + endAngle: endAngle, + circumference: circumference + }); + }, + draw: function draw(ease) { + Chart.controllers.doughnut.prototype.draw.call(this, ease); + this.drawNeedle(ease); + this.drawValueLabel(ease); + } +}); + +/* eslint-disable max-len, func-names */ +var polyfill = function polyfill() { + if (CanvasRenderingContext2D.prototype.ellipse === undefined) { + CanvasRenderingContext2D.prototype.ellipse = function (x, y, radiusX, radiusY, rotation, startAngle, endAngle, antiClockwise) { + this.save(); + this.translate(x, y); + this.rotate(rotation); + this.scale(radiusX, radiusY); + this.arc(0, 0, 1, startAngle, endAngle, antiClockwise); + this.restore(); + }; + } +}; + +polyfill(); +Chart.controllers.gauge = GaugeController; + +Chart.Gauge = function (context, config) { + config.type = 'gauge'; + return new Chart(context, config); +}; + +var index = Chart.Gauge; + +return index; + +}))); diff --git a/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js b/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js new file mode 100644 index 00000000000..8376dd3e9c5 --- /dev/null +++ b/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js @@ -0,0 +1,58 @@ +/** @odoo-module */ + +import { DataSources } from "@spreadsheet/data_sources/data_sources"; +import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors"; +import { migrate } from "@spreadsheet/o_spreadsheet/migration"; +import { download } from "@web/core/network/download"; +import { registry } from "@web/core/registry"; +import { browser } from "@web/core/browser/browser"; +import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; + +const { Model } = spreadsheet; + +async function downloadSpreadsheet(env, action) { + const { orm, name, data, stateUpdateMessages } = action.params; + const dataSources = new DataSources(orm); + const model = new Model(migrate(data), { dataSources }, stateUpdateMessages); + await dataSources.waitForAllLoaded(); + await waitForDataLoaded(model); + const { files } = model.exportXLSX(); + await download({ + url: "/spreadsheet/xlsx", + data: { + zip_name: `${name}.xlsx`, + files: JSON.stringify(files), + }, + }); +} + +/** + * Ensure that the spreadsheet does not contains cells that are in loading state + * @param {Model} model + * @returns {Promise} + */ +async function waitForDataLoaded(model) { + model.dispatch("EVALUATE_ALL_SHEETS"); + return new Promise((resolve, reject) => { + let interval = undefined; + interval = browser.setInterval(() => { + for (const sheetId of model.getters.getSheetIds()) { + for (const cell of Object.values(model.getters.getCells(sheetId))) { + if ( + cell.evaluated && + cell.evaluated.type === "error" && + cell.evaluated.error instanceof LoadingDataError + ) { + return; + } + } + } + browser.clearInterval(interval); + resolve(); + }, 50); + }); +} + +registry + .category("actions") + .add("action_download_spreadsheet", downloadSpreadsheet, { force: true }); diff --git a/addons/spreadsheet/static/src/assets_backend/constants.js b/addons/spreadsheet/static/src/assets_backend/constants.js new file mode 100644 index 00000000000..0369f1347d2 --- /dev/null +++ b/addons/spreadsheet/static/src/assets_backend/constants.js @@ -0,0 +1,25 @@ +/** @odoo-module */ + +import { _lt } from "@web/core/l10n/translation"; + +export const FILTER_DATE_OPTION = { + quarter: ["first_quarter", "second_quarter", "third_quarter", "fourth_quarter"], + year: ["this_year", "last_year", "antepenultimate_year"], +}; + +// TODO Remove this mapping, We should only need number > description to avoid multiple conversions +// This would require a migration though +export const monthsOptions = [ + { id: "january", description: _lt("January") }, + { id: "february", description: _lt("February") }, + { id: "march", description: _lt("March") }, + { id: "april", description: _lt("April") }, + { id: "may", description: _lt("May") }, + { id: "june", description: _lt("June") }, + { id: "july", description: _lt("July") }, + { id: "august", description: _lt("August") }, + { id: "september", description: _lt("September") }, + { id: "october", description: _lt("October") }, + { id: "november", description: _lt("November") }, + { id: "december", description: _lt("December") }, +]; diff --git a/addons/spreadsheet/static/src/assets_backend/spreadsheet_action_loader.js b/addons/spreadsheet/static/src/assets_backend/spreadsheet_action_loader.js new file mode 100644 index 00000000000..d85a0319f99 --- /dev/null +++ b/addons/spreadsheet/static/src/assets_backend/spreadsheet_action_loader.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { loadBundle } from "@web/core/assets"; +import { sprintf } from "@web/core/utils/strings"; + +const actionRegistry = registry.category("actions"); + +/** + * + * @param {object} env + * @param {string} actionName + * @param {function} actionLazyLoader + */ +export async function loadSpreadsheetAction(env, actionName, actionLazyLoader) { + await loadBundle("spreadsheet.o_spreadsheet"); + + if (actionRegistry.get(actionName) === actionLazyLoader) { + // At this point, the real spreadsheet client action should be loaded and have + // replaced this function in the action registry. If it's not the case, + // it probably means that there was a crash in the bundle (e.g. syntax + // error). In this case, this action will remain in the registry, which + // will lead to an infinite loop. To prevent that, we push another action + // in the registry. + actionRegistry.add( + actionName, + () => { + const msg = sprintf(env._t("%s couldn't be loaded"), actionName); + env.services.notification.add(msg, { type: "danger" }); + }, + { force: true } + ); + } +} + +const loadSpreadsheetDownloadAction = async (env, context) => { + await loadSpreadsheetAction(env, "action_download_spreadsheet", loadSpreadsheetDownloadAction); + + return { + ...context, + target: "current", + tag: "action_download_spreadsheet", + type: "ir.actions.client", + }; +}; + +actionRegistry.add("action_download_spreadsheet", loadSpreadsheetDownloadAction); diff --git a/addons/spreadsheet/static/src/chart/data_source/graph_data_source.js b/addons/spreadsheet/static/src/chart/data_source/graph_data_source.js new file mode 100644 index 00000000000..e2a503635fb --- /dev/null +++ b/addons/spreadsheet/static/src/chart/data_source/graph_data_source.js @@ -0,0 +1,44 @@ +/** @odoo-module */ + +import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source"; +import { _t } from "@web/core/l10n/translation"; +import { GraphModel } from "@web/views/graph/graph_model"; + +export default class GraphDataSource extends OdooViewsDataSource { + /** + * @override + * @param {Object} services Services (see DataSource) + */ + constructor(services, params) { + super(services, params); + } + + /** + * @protected + */ + async _load() { + await super._load(); + const metaData = { + fieldAttrs: {}, + ...this._metaData, + }; + this._model = new GraphModel( + { + _t, + }, + metaData, + { + orm: this._orm, + } + ); + await this._model.load(this._searchParams); + } + + getData() { + if (!this.isReady()) { + this.load(); + return { datasets: [], labels: [] }; + } + return this._model.data; + } +} diff --git a/addons/spreadsheet/static/src/chart/odoo_chart/index.js b/addons/spreadsheet/static/src/chart/odoo_chart/index.js new file mode 100644 index 00000000000..0c41270f362 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_chart/index.js @@ -0,0 +1,10 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { chartComponentRegistry } = spreadsheet.registries; +const { ChartJsComponent } = spreadsheet.components; + +chartComponentRegistry.add("odoo_bar", ChartJsComponent); +chartComponentRegistry.add("odoo_line", ChartJsComponent); +chartComponentRegistry.add("odoo_pie", ChartJsComponent); diff --git a/addons/spreadsheet/static/src/chart/odoo_chart/odoo_bar_chart.js b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_bar_chart.js new file mode 100644 index 00000000000..8441ca66453 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_bar_chart.js @@ -0,0 +1,101 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { _t } from "@web/core/l10n/translation"; +import { OdooChart } from "./odoo_chart"; + +const { chartRegistry } = spreadsheet.registries; + +const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers; + +export class OdooBarChart extends OdooChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.verticalAxisPosition = definition.verticalAxisPosition; + this.stacked = definition.stacked; + } + + getDefinition() { + return { + ...super.getDefinition(), + verticalAxisPosition: this.verticalAxisPosition, + stacked: this.stacked, + }; + } +} + +chartRegistry.add("odoo_bar", { + match: (type) => type === "odoo_bar", + createChart: (definition, sheetId, getters) => new OdooBarChart(definition, sheetId, getters), + getChartRuntime: createOdooChartRuntime, + validateChartDefinition: (validator, definition) => + OdooBarChart.validateChartDefinition(validator, definition), + transformDefinition: (definition) => OdooBarChart.transformDefinition(definition), + getChartDefinitionFromContextCreation: () => OdooBarChart.getDefinitionFromContextCreation(), + name: _t("Bar"), +}); + +function createOdooChartRuntime(chart, getters) { + const background = chart.background || "#FFFFFF"; + const dataSource = getters.getGraphDataSource(chart.id); + const { datasets, labels } = dataSource.getData(); + const chartJsConfig = getBarConfiguration(chart, labels); + const colors = new ChartColors(); + for (const { label, data } of datasets) { + const color = colors.next(); + const dataset = { + label, + data, + borderColor: color, + backgroundColor: color, + }; + chartJsConfig.data.datasets.push(dataset); + } + + return { background, chartJsConfig }; +} + +function getBarConfiguration(chart, labels) { + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, labels, fontColor); + config.type = chart.type.replace("odoo_", ""); + const legend = { + ...config.options.legend, + display: chart.legendPosition !== "none", + labels: { fontColor }, + }; + legend.position = chart.legendPosition; + config.options.legend = legend; + config.options.layout = { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }; + config.options.scales = { + xAxes: [ + { + ticks: { + // x axis configuration + maxRotation: 60, + minRotation: 15, + padding: 5, + labelOffset: 2, + fontColor, + }, + }, + ], + yAxes: [ + { + position: chart.verticalAxisPosition, + ticks: { + fontColor, + // y axis configuration + beginAtZero: true, // the origin of the y axis is always zero + }, + }, + ], + }; + if (chart.stacked) { + config.options.scales.xAxes[0].stacked = true; + config.options.scales.yAxes[0].stacked = true; + } + return config; +} diff --git a/addons/spreadsheet/static/src/chart/odoo_chart/odoo_chart.js b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_chart.js new file mode 100644 index 00000000000..d125cee45db --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_chart.js @@ -0,0 +1,118 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { AbstractChart, CommandResult } = spreadsheet; + +/** + * @typedef {import("@web/search/search_model").SearchParams} SearchParams + * + * @typedef MetaData + * @property {Array} domains + * @property {Array} groupBy + * @property {string} measure + * @property {string} mode + * @property {string} [order] + * @property {string} resModel + * @property {boolean} stacked + * + * @typedef OdooChartDefinition + * @property {string} id + * @property {string} type + * @property {MetaData} metaData + * @property {SearchParams} searchParams + * @property {string} title + * @property {string} background + * @property {string} legendPosition + * + * @typedef OdooChartDefinitionDataSource + * @property {MetaData} metaData + * @property {SearchParams} searchParams + * + */ + +export class OdooChart extends AbstractChart { + /** + * @param {OdooChartDefinition} definition + * @param {string} sheetId + * @param {Object} getters + */ + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.id = definition.id; + this.type = definition.type; + this.metaData = definition.metaData; + this.searchParams = definition.searchParams; + this.legendPosition = definition.legendPosition; + this.background = definition.background; + } + + static transformDefinition(definition) { + return definition; + } + + static validateChartDefinition(validator, definition) { + return CommandResult.Success; + } + + static getDefinitionFromContextCreation() { + throw new Error("It's not possible to convert an Odoo chart to a native chart"); + } + + /** + * @returns {OdooChartDefinitionDataSource} + */ + getDefinitionForDataSource() { + return { + metaData: { + ...this.metaData, + mode: this.type.replace("odoo_", ""), + }, + searchParams: this.searchParams, + }; + } + + /** + * @returns {OdooChartDefinition} + */ + getDefinition() { + return { + //@ts-ignore Defined in the parent class + title: this.title, + id: this.id, + background: this.background, + legendPosition: this.legendPosition, + metaData: this.metaData, + searchParams: this.searchParams, + type: this.type, + }; + } + + getDefinitionForExcel() { + // Export not supported + return undefined; + } + + /** + * @returns {OdooChart} + */ + updateRanges() { + // No range on this graph + return this; + } + + /** + * @returns {OdooChart} + */ + copyForSheetId() { + return this; + } + + getContextCreation() { + return {}; + } + + getSheetIdsUsedInChartRanges() { + return []; + } +} diff --git a/addons/spreadsheet/static/src/chart/odoo_chart/odoo_line_chart.js b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_line_chart.js new file mode 100644 index 00000000000..d1d85e6264c --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_line_chart.js @@ -0,0 +1,95 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { _t } from "@web/core/l10n/translation"; +import { OdooChart } from "./odoo_chart"; + +const { chartRegistry } = spreadsheet.registries; + +const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers; + +export class OdooLineChart extends OdooChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.verticalAxisPosition = definition.verticalAxisPosition; + } + + getDefinition() { + return { + ...super.getDefinition(), + verticalAxisPosition: this.verticalAxisPosition, + }; + } +} + +chartRegistry.add("odoo_line", { + match: (type) => type === "odoo_line", + createChart: (definition, sheetId, getters) => new OdooLineChart(definition, sheetId, getters), + getChartRuntime: createOdooChartRuntime, + validateChartDefinition: (validator, definition) => + OdooLineChart.validateChartDefinition(validator, definition), + transformDefinition: (definition) => OdooLineChart.transformDefinition(definition), + getChartDefinitionFromContextCreation: () => OdooLineChart.getDefinitionFromContextCreation(), + name: _t("Line"), +}); + +function createOdooChartRuntime(chart, getters) { + const dataSource = getters.getGraphDataSource(chart.id); + const background = chart.background || "#FFFFFF"; + const { datasets, labels } = dataSource.getData(); + const chartJsConfig = getLineConfiguration(chart, labels); + const colors = new ChartColors(); + for (const { label, data } of datasets) { + const color = colors.next(); + const dataset = { + label, + data, + lineTension: 0, + borderColor: color, + backgroundColor: color, + }; + chartJsConfig.data.datasets.push(dataset); + } + return { background, chartJsConfig }; +} + +function getLineConfiguration(chart, labels) { + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, labels, fontColor); + config.type = chart.type.replace("odoo_", ""); + const legend = { + ...config.options.legend, + display: chart.legendPosition !== "none", + labels: { fontColor }, + }; + legend.position = chart.legendPosition; + config.options.legend = legend; + config.options.layout = { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }; + config.options.scales = { + xAxes: [ + { + ticks: { + // x axis configuration + maxRotation: 60, + minRotation: 15, + padding: 5, + labelOffset: 2, + fontColor, + }, + }, + ], + yAxes: [ + { + position: chart.verticalAxisPosition, + ticks: { + fontColor, + // y axis configuration + beginAtZero: true, // the origin of the y axis is always zero + }, + }, + ], + }; + return config; +} diff --git a/addons/spreadsheet/static/src/chart/odoo_chart/odoo_pie_chart.js b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_pie_chart.js new file mode 100644 index 00000000000..1f6f8865f55 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_chart/odoo_pie_chart.js @@ -0,0 +1,73 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { _t } from "@web/core/l10n/translation"; +import { OdooChart } from "./odoo_chart"; + +const { chartRegistry } = spreadsheet.registries; + +const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers; + +chartRegistry.add("odoo_pie", { + match: (type) => type === "odoo_pie", + createChart: (definition, sheetId, getters) => new OdooChart(definition, sheetId, getters), + getChartRuntime: createOdooChartRuntime, + validateChartDefinition: (validator, definition) => + OdooChart.validateChartDefinition(validator, definition), + transformDefinition: (definition) => OdooChart.transformDefinition(definition), + getChartDefinitionFromContextCreation: () => OdooChart.getDefinitionFromContextCreation(), + name: _t("Pie"), +}); + +function createOdooChartRuntime(chart, getters) { + const background = chart.background || "#FFFFFF"; + const dataSource = getters.getGraphDataSource(chart.id); + const { datasets, labels } = dataSource.getData(); + const chartJsConfig = getPieConfiguration(chart, labels); + const colors = new ChartColors(); + for (const { label, data } of datasets) { + const backgroundColor = getPieColors(colors, datasets); + const dataset = { + label, + data, + borderColor: "#FFFFFF", + backgroundColor, + }; + chartJsConfig.data.datasets.push(dataset); + } + return { background, chartJsConfig }; +} + +function getPieConfiguration(chart, labels) { + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, labels, fontColor); + config.type = chart.type.replace("odoo_", ""); + const legend = { + ...config.options.legend, + display: chart.legendPosition !== "none", + labels: { fontColor }, + }; + legend.position = chart.legendPosition; + config.options.legend = legend; + config.options.layout = { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }; + config.options.tooltips = { + callbacks: { + title: function (tooltipItems, data) { + return data.datasets[tooltipItems[0].datasetIndex].label; + }, + }, + }; + return config; +} + +function getPieColors(colors, dataSetsValues) { + const pieColors = []; + const maxLength = Math.max(...dataSetsValues.map((ds) => ds.data.length)); + for (let i = 0; i <= maxLength; i++) { + pieColors.push(colors.next()); + } + + return pieColors; +} diff --git a/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.js b/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.js new file mode 100644 index 00000000000..0fc92705286 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { useService } from "@web/core/utils/hooks"; + +patch(spreadsheet.components.ChartFigure.prototype, "spreadsheet.ChartFigure", { + setup() { + this._super(); + this.menuService = useService("menu"); + this.actionService = useService("action"); + }, + async navigateToOdooMenu() { + const menu = this.env.model.getters.getChartOdooMenu(this.props.figure.id); + if (!menu) { + throw new Error(`Cannot find any menu associated with the chart`); + } + await this.actionService.doAction(menu.actionID); + }, + get hasOdooMenu() { + return this.env.model.getters.getChartOdooMenu(this.props.figure.id) !== undefined; + }, + async onClick() { + if (this.env.isDashboard() && this.hasOdooMenu) { + this.navigateToOdooMenu(); + } + }, +}); diff --git a/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.scss b/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.scss new file mode 100644 index 00000000000..a60bebea8a5 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.scss @@ -0,0 +1,9 @@ +.o-chart-menu { + .o-chart-menu-item { + padding-left: 7px; + } + + .o-chart-external-link { + font-size: 15px; + } +} diff --git a/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.xml b/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.xml new file mode 100644 index 00000000000..4d8eb9885d0 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/odoo_menu/chart_figure.xml @@ -0,0 +1,16 @@ + + + + + + + + + () => this.onClick() + env.isDashboard() and hasOdooMenu ? "button" : "" + + + diff --git a/addons/spreadsheet/static/src/chart/plugin/chart_odoo_menu_plugin.js b/addons/spreadsheet/static/src/chart/plugin/chart_odoo_menu_plugin.js new file mode 100644 index 00000000000..1392b2c8613 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/plugin/chart_odoo_menu_plugin.js @@ -0,0 +1,54 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +const { corePluginRegistry } = spreadsheet.registries; +const { coreTypes } = spreadsheet; + +/** Plugin that link charts with Odoo menus. It can contain either the Id of the odoo menu, or its xml id. */ +export default class ChartOdooMenuPlugin extends spreadsheet.CorePlugin { + constructor() { + super(...arguments); + this.odooMenuReference = {}; + } + + /** + * Handle a spreadsheet command + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "LINK_ODOO_MENU_TO_CHART": + this.history.update("odooMenuReference", cmd.chartId, cmd.odooMenuId); + break; + case "DELETE_FIGURE": + this.history.update("odooMenuReference", cmd.chartId, undefined); + break; + } + } + + /** + * Get odoo menu linked to the chart + * + * @param {string} chartId + * @returns {object | undefined} + */ + getChartOdooMenu(chartId) { + const menuId = this.odooMenuReference[chartId]; + return menuId ? this.getters.getIrMenu(menuId) : undefined; + } + + import(data) { + if (data.chartOdooMenusReferences) { + this.odooMenuReference = data.chartOdooMenusReferences; + } + } + + export(data) { + data.chartOdooMenusReferences = this.odooMenuReference; + } +} +ChartOdooMenuPlugin.modes = ["normal", "headless"]; +ChartOdooMenuPlugin.getters = ["getChartOdooMenu"]; + +corePluginRegistry.add("chartOdooMenuPlugin", ChartOdooMenuPlugin); +coreTypes.add("LINK_ODOO_MENU_TO_CHART"); diff --git a/addons/spreadsheet/static/src/chart/plugin/odoo_chart_plugin.js b/addons/spreadsheet/static/src/chart/plugin/odoo_chart_plugin.js new file mode 100644 index 00000000000..5017342c5b2 --- /dev/null +++ b/addons/spreadsheet/static/src/chart/plugin/odoo_chart_plugin.js @@ -0,0 +1,106 @@ +/** @odoo-module */ + +import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended"; +import GraphDataSource from "../data_source/graph_data_source"; + +const { CorePlugin } = spreadsheet; +const { corePluginRegistry } = spreadsheet.registries; + +/** + * @typedef {import("@web/views/graph/graph_model").GraphModel} GraphModel + */ + +export default class OdooGraphPlugin extends CorePlugin { + constructor(getters, history, range, dispatch, config, uuidGenerator) { + super(getters, history, range, dispatch, config, uuidGenerator); + this.dataSources = config.dataSources; + + /** @type {Object.} */ + this.graphsDataSources = {}; + } + + /** + * Handle a spreadsheet command + * + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "CREATE_CHART": { + switch (cmd.definition.type) { + case "odoo_pie": + case "odoo_bar": + case "odoo_line": + this._addGraphDataSource(cmd.id, cmd.definition.dataSourceId); + break; + } + break; + } + case "ADD_GRAPH_DOMAIN": + this.getters.getGraphDataSource(cmd.id).addDomain(cmd.domain); + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + /** + * Get all the odoo chart ids + * @returns {Array} + */ + getOdooChartIds() { + const ids = []; + for (const sheetId of this.getters.getSheetIds()) { + ids.push( + ...this.getters + .getChartIds(sheetId) + .filter((id) => this.getters.getChartType(id).startsWith("odoo_")) + ); + } + return ids; + } + + /** + * @param {string} id + * @returns {GraphDataSource|undefined} + */ + getGraphDataSource(id) { + const dataSourceId = this.graphsDataSources[id]; + return this.dataSources.get(dataSourceId); + } + + import(data) { + for (const sheet of data.sheets) { + if (sheet.figures) { + for (const figure of sheet.figures) { + if (figure.tag === "chart" && figure.data.type.startsWith("odoo_")) { + this._addGraphDataSource(figure.id, this.uuidGenerator.uuidv4()); + } + } + } + } + } + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + /** + * @param {string} id + * @param {string} dataSourceId + */ + _addGraphDataSource(id, dataSourceId) { + const graphsDataSources = { ...this.graphsDataSources }; + graphsDataSources[id] = dataSourceId; + const definition = this.getters.getChart(id).getDefinitionForDataSource(); + if (!this.dataSources.contains(dataSourceId)) { + this.dataSources.add(dataSourceId, GraphDataSource, definition); + } + this.history.update("graphsDataSources", graphsDataSources); + } +} + +OdooGraphPlugin.getters = ["getGraphDataSource", "getOdooChartIds"]; + +corePluginRegistry.add("odoo_graph_plugin", OdooGraphPlugin); diff --git a/addons/spreadsheet/static/src/chart/plugin/operational_transform.js b/addons/spreadsheet/static/src/chart/plugin/operational_transform.js new file mode 100644 index 00000000000..66a641bc7bb --- /dev/null +++ b/addons/spreadsheet/static/src/chart/plugin/operational_transform.js @@ -0,0 +1,21 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +const { inverseCommandRegistry, otRegistry } = spreadsheet.registries; + +function identity(cmd) { + return [cmd]; +} + +otRegistry.addTransformation( + "DELETE_FIGURE", + ["LINK_ODOO_MENU_TO_CHART"], + (toTransform, executed) => { + if (executed.id === toTransform.chartId) { + return undefined; + } + return toTransform; + } +); + +inverseCommandRegistry.add("LINK_ODOO_MENU_TO_CHART", identity); diff --git a/addons/spreadsheet/static/src/currency/currency_data_source.js b/addons/spreadsheet/static/src/currency/currency_data_source.js new file mode 100644 index 00000000000..adf39a03acf --- /dev/null +++ b/addons/spreadsheet/static/src/currency/currency_data_source.js @@ -0,0 +1,68 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import { ServerData } from "../data_sources/server_data"; + +/** + * @typedef Currency + * @property {string} name + * @property {string} code + * @property {string} symbol + * @property {number} decimalPlaces + * @property {"before" | "after"} position + */ +export class CurrencyDataSource { + constructor(services) { + this.serverData = new ServerData(services.orm, { + whenDataIsFetched: () => services.notify(), + }); + } + + /** + * Get the currency rate between the two given currencies + * @param {string} from Currency from + * @param {string} to Currency to + * @param {string|undefined} date + * @returns {number|undefined} + */ + getCurrencyRate(from, to, date) { + const data = this.serverData.batch.get("res.currency.rate", "get_rates_for_spreadsheet", { + from, + to, + date, + }); + const rate = data !== undefined ? data.rate : undefined; + if (rate === false) { + throw new Error(_t("Currency rate unavailable.")); + } + return rate; + } + + /** + * + * @param {number|undefined} companyId + * @returns {Currency} + */ + getCompanyCurrencyFormat(companyId) { + const result = this.serverData.get("res.currency", "get_company_currency_for_spreadsheet", [ + companyId, + ]); + if (result === false) { + throw new Error(_t("Currency not available for this company.")); + } + return result; + } + + /** + * Get all currencies from the server + * @param {string} currencyName + * @returns {Currency} + */ + getCurrency(currencyName) { + return this.serverData.batch.get( + "res.currency", + "get_currencies_for_spreadsheet", + currencyName + ); + } +} diff --git a/addons/spreadsheet/static/src/currency/formulas.js b/addons/spreadsheet/static/src/currency/formulas.js new file mode 100644 index 00000000000..90c6de3d765 --- /dev/null +++ b/addons/spreadsheet/static/src/currency/formulas.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; +const { args, toString, toJSDate } = spreadsheet.helpers; +const { functionRegistry } = spreadsheet.registries; + + +functionRegistry + .add("ODOO.CURRENCY.RATE", { + description: _t("This function takes in two currency codes as arguments, and returns the exchange rate from the first currency to the second as float."), + compute: function (currencyFrom, currencyTo, date) { + const from = toString(currencyFrom); + const to = toString(currencyTo); + const _date = date ? toJSDate(date) : undefined; + return this.getters.getCurrencyRate(from, to, _date); + }, + args: args(` + currency_from (string) ${_t("First currency code.")} + currency_to (string) ${_t("Second currency code.")} + date (date, optional) ${_t("Date of the rate.")} + `), + returns: ["NUMBER"], +}); diff --git a/addons/spreadsheet/static/src/currency/plugins/currency.js b/addons/spreadsheet/static/src/currency/plugins/currency.js new file mode 100644 index 00000000000..a05f351c3ca --- /dev/null +++ b/addons/spreadsheet/static/src/currency/plugins/currency.js @@ -0,0 +1,89 @@ +/** @odoo-module */ + +import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended"; +import { CurrencyDataSource } from "../currency_data_source"; +const { uiPluginRegistry } = spreadsheet.registries; + +const DATA_SOURCE_ID = "CURRENCIES"; + +/** + * @typedef {import("../currency_data_source").Currency} Currency + */ + +class CurrencyPlugin extends spreadsheet.UIPlugin { + constructor(getters, history, dispatch, config) { + super(getters, history, dispatch, config); + this.dataSources = config.dataSources; + if (this.dataSources) { + this.dataSources.add(DATA_SOURCE_ID, CurrencyDataSource); + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + /** + * Get the currency rate between the two given currencies + * @param {string} from Currency from + * @param {string} to Currency to + * @param {string} date + * @returns {number|string} + */ + getCurrencyRate(from, to, date) { + return ( + this.dataSources && this.dataSources.get(DATA_SOURCE_ID).getCurrencyRate(from, to, date) + ); + } + + /** + * + * @param {Currency | undefined} currency + * @private + * + * @returns {string | undefined} + */ + computeFormatFromCurrency(currency) { + if (!currency) { + return undefined; + } + const decimalFormatPart = currency.decimalPlaces + ? "." + "0".repeat(currency.decimalPlaces) + : ""; + const numberFormat = "#,##0" + decimalFormatPart; + const symbolFormatPart = "[$" + currency.symbol + "]"; + return currency.position === "after" + ? numberFormat + symbolFormatPart + : symbolFormatPart + numberFormat; + } + + /** + * Returns the default display format of a given currency + * @param {string} currencyName + * @returns {string | undefined} + */ + getCurrencyFormat(currencyName) { + const currency = + currencyName && + this.dataSources && + this.dataSources.get(DATA_SOURCE_ID).getCurrency(currencyName); + return this.computeFormatFromCurrency(currency); + } + + /** + * Returns the default display format of a the company currency + * @param {number|undefined} companyId + * @returns {string | undefined} + */ + getCompanyCurrencyFormat(companyId) { + const currency = + this.dataSources && + this.dataSources.get(DATA_SOURCE_ID).getCompanyCurrencyFormat(companyId); + return this.computeFormatFromCurrency(currency); + } +} + +CurrencyPlugin.modes = ["normal", "headless"]; +CurrencyPlugin.getters = ["getCurrencyRate", "getCurrencyFormat", "getCompanyCurrencyFormat"]; + +uiPluginRegistry.add("odooCurrency", CurrencyPlugin); diff --git a/addons/spreadsheet/static/src/data_sources/data_source.js b/addons/spreadsheet/static/src/data_sources/data_source.js new file mode 100644 index 00000000000..fb776d75044 --- /dev/null +++ b/addons/spreadsheet/static/src/data_sources/data_source.js @@ -0,0 +1,76 @@ +/** @odoo-module */ + +import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors"; + +/** + * DataSource is an abstract class that contains the logic of fetching and + * maintaining access to data that have to be loaded. + * + * A class which extends this class have to implement the `_load` method + * * which should load the data it needs + * + * Subclass can implement concrete methods to have access to a + * particular data. + */ +export class LoadableDataSource { + constructor(services) { + this._orm = services.orm; + this._metadataRepository = services.metadataRepository; + this._notify = services.notify; + + /** + * Last time that this dataSource has been updated + */ + this._lastUpdate = undefined; + + /** + * Promise to control the loading of data + */ + this._loadPromise = undefined; + this._isFullyLoaded = false; + } + + /** + * Load data in the model + * @param {Object} params Params for fetching data + * @param {boolean=false} params.reload Force the reload of the data + * + * @returns {Promise} Resolved when data are fetched. + */ + async load(params) { + if (params && params.reload) { + this._loadPromise = undefined; + } + if (!this._loadPromise) { + this._isFullyLoaded = false; + this._loadPromise = this._load().then(() => { + this._lastUpdate = Date.now(); + this._isFullyLoaded = true; + this._notify(); + }); + } + return this._loadPromise; + } + + get lastUpdate() { + return this._lastUpdate; + } + + /** + * @protected + */ + _assertDataIsLoaded() { + if (!this._isFullyLoaded) { + this.load(); + throw new LoadingDataError(); + } + } + + /** + * Load the data in the model + * + * @abstract + * @protected + */ + async _load() {} +} diff --git a/addons/spreadsheet/static/src/data_sources/data_sources.js b/addons/spreadsheet/static/src/data_sources/data_sources.js new file mode 100644 index 00000000000..7c9de63fee3 --- /dev/null +++ b/addons/spreadsheet/static/src/data_sources/data_sources.js @@ -0,0 +1,104 @@ +/** @odoo-module */ + +import { LoadableDataSource } from "./data_source"; +import { MetadataRepository } from "./metadata_repository"; + +const { EventBus } = owl; + +/** * + * @typedef {object} DataSourceServices + * @property {MetadataRepository} metadataRepository + * @property {import("@web/core/orm_service")} orm + * @property {() => void} notify + * + * @typedef {new (services: DataSourceServices, params: object) => any} DataSourceConstructor + */ + +export class DataSources extends EventBus { + constructor(orm) { + super(); + this._orm = orm; + this._metadataRepository = new MetadataRepository(orm); + this._metadataRepository.addEventListener("labels-fetched", () => this.notify()); + /** @type {Object.} */ + this._dataSources = {}; + } + + /** + * Create a new data source but do not register it. + * + * @param {DataSourceConstructor} cls Class to instantiate + * @param {object} params Params to give to data source + * + * @returns {any} + */ + create(cls, params) { + return new cls( + { + orm: this._orm, + metadataRepository: this._metadataRepository, + notify: () => this.notify(), + }, + params + ); + } + + /** + * Create a new data source and register it with the following id. + * + * @param {string} id + * @param {DataSourceConstructor} cls Class to instantiate + * @param {object} params Params to give to data source + * + * @returns {any} + */ + add(id, cls, params) { + this._dataSources[id] = this.create(cls, params); + return this._dataSources[id]; + } + + async load(id, reload = false) { + const dataSource = this.get(id); + if (dataSource instanceof LoadableDataSource) { + await dataSource.load({ reload }); + } + } + + /** + * Retrieve the data source with the following id. + * + * @param {string} id + * + * @returns {any} + */ + get(id) { + return this._dataSources[id]; + } + + /** + * Check if the following is correspond to a data source. + * + * @param {string} id + * + * @returns {boolean} + */ + contains(id) { + return id in this._dataSources; + } + + /** + * Notify that a data source has been updated. Could be useful to + * request a re-evaluation. + */ + notify() { + this.trigger("data-source-updated"); + } + + async waitForAllLoaded() { + await Promise.all( + Object.values(this._dataSources).map( + (ds) => ds instanceof LoadableDataSource && ds.load() + ) + ); + } +} diff --git a/addons/spreadsheet/static/src/data_sources/metadata_repository.js b/addons/spreadsheet/static/src/data_sources/metadata_repository.js new file mode 100644 index 00000000000..272cace0eb6 --- /dev/null +++ b/addons/spreadsheet/static/src/data_sources/metadata_repository.js @@ -0,0 +1,130 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import { sprintf } from "@web/core/utils/strings"; +import { ServerData } from "../data_sources/server_data"; + +import { LoadingDataError } from "../o_spreadsheet/errors"; + +const { EventBus } = owl; + +/** + * @typedef {object} Field + * @property {string} name technical name + * @property {string} type field type + * @property {string} string display name + * @property {string} [relation] related model technical name (only for relational fields) + */ +/** + * This class is used to provide facilities to fetch some common data. It's + * used in the data sources to obtain the fields (fields_get) and the display + * name of the models (display_name_for on ir.model). + * + * It also manages the labels of all the spreadsheet models (labels of basic + * fields or display name of relational fields). + * + * All the results are cached in order to avoid useless rpc calls, basically + * for different entities that are defined on the same model. + * + * Implementation note: + * For the labels, when someone is asking for a label which is not loaded yet, + * the proxy returns directly (undefined) and a request for a name_get will + * be triggered. All the requests created are batched and send, with only one + * request per model, after a clock cycle. + * At the end of this process, an event is triggered (labels-fetched) + */ +export class MetadataRepository extends EventBus { + constructor(orm) { + super(); + this.orm = orm.silent; + /** + * Contains the labels of records. It's organized in the following way: + * { + * "crm.lead": { + * "city": { + * "bruxelles": "Bruxelles", + * } + * }, + * } + */ + this._labels = {}; + + this.serverData = new ServerData(this.orm, { + whenDataIsFetched: () => this.trigger("labels-fetched"), + }); + } + + /** + * Get the display name of the given model + * + * @param {string} model Technical name + * @returns {Promise} Display name of the model + */ + async modelDisplayName(model) { + const result = await this.serverData.fetch("ir.model", "display_name_for", [[model]]); + return (result[0] && result[0].display_name) || ""; + } + + /** + * Get the list of fields for the given model + * + * @param {string} model Technical name + * @returns {Promise>} List of fields (result of fields_get) + */ + async fieldsGet(model) { + return this.serverData.fetch(model, "fields_get"); + } + + /** + * Add a label to the cache + * + * @param {string} model + * @param {string} field + * @param {any} value + * @param {string} label + */ + registerLabel(model, field, value, label) { + if (!this._labels[model]) { + this._labels[model] = {}; + } + if (!this._labels[model][field]) { + this._labels[model][field] = {}; + } + this._labels[model][field][value] = label; + } + + /** + * Get the label associated with the given arguments + * + * @param {string} model + * @param {string} field + * @param {any} value + * @returns {string} + */ + getLabel(model, field, value) { + return ( + this._labels[model] && this._labels[model][field] && this._labels[model][field][value] + ); + } + + /** + * Get the display name associated to the given model-id + * If the name is not yet loaded, a rpc will be triggered in the next clock + * cycle. + * + * @param {string} model + * @param {number} id + * @returns {string} + */ + getRecordDisplayName(model, id) { + try { + const result = this.serverData.batch.get(model, "name_get", id); + return result[1]; + } catch (e) { + if (e instanceof LoadingDataError) { + throw e; + } + throw new Error(sprintf(_t("Unable to fetch the label of %s of model %s"), id, model)); + } + } +} diff --git a/addons/spreadsheet/static/src/data_sources/odoo_views_data_source.js b/addons/spreadsheet/static/src/data_sources/odoo_views_data_source.js new file mode 100644 index 00000000000..b4bb96df099 --- /dev/null +++ b/addons/spreadsheet/static/src/data_sources/odoo_views_data_source.js @@ -0,0 +1,134 @@ +/** @odoo-module */ + +import { LoadableDataSource } from "./data_source"; +import { Domain } from "@web/core/domain"; +import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors"; + +/** + * @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field + */ + +/** + * Remove user specific info from the context + * @param {Object} context + * @returns {Object} + */ +function removeContextUserInfo(context) { + context = { ...context }; + delete context.allowed_company_ids; + delete context.tz; + delete context.lang; + delete context.uid; + return context; +} + +/** + * @typedef {Object} OdooModelMetaData + * @property {string} resModel + * @property {Array|undefined} fields + */ + +export class OdooViewsDataSource extends LoadableDataSource { + /** + * @override + * @param {Object} services + * @param {Object} params + * @param {OdooModelMetaData} params.metaData + * @param {Object} params.searchParams + */ + constructor(services, params) { + super(services); + this._metaData = JSON.parse(JSON.stringify(params.metaData)); + /** @protected */ + this._initialSearchParams = JSON.parse(JSON.stringify(params.searchParams)); + this._initialSearchParams.context = removeContextUserInfo( + this._initialSearchParams.context + ); + /** @private */ + this._customDomain = this._initialSearchParams.domain; + } + + /** + * @protected + */ + get _searchParams() { + return { + ...this._initialSearchParams, + domain: this._customDomain, + }; + } + + async loadMetadata() { + if (!this._metaData.fields) { + this._metaData.fields = await this._metadataRepository.fieldsGet( + this._metaData.resModel + ); + } + } + + /** + * @returns {Record} List of fields + */ + getFields() { + if (this._metaData.fields === undefined) { + this.loadMetadata(); + throw new LoadingDataError(); + } + return this._metaData.fields; + } + + /** + * @param {string} field Field name + * @returns {Field | undefined} Field + */ + getField(field) { + return this._metaData.fields[field]; + } + + /** + * @protected + */ + async _load() { + await this.loadMetadata(); + } + + /** + * @returns {boolean} + */ + isReady() { + return this._isFullyLoaded; + } + + isMetaDataLoaded() { + return this._metaData.fields !== undefined; + } + + /** + * Get the computed domain of this source + * @returns {Array} + */ + getComputedDomain() { + return this._customDomain; + } + + addDomain(domain) { + const newDomain = Domain.and([this._initialSearchParams.domain, domain]); + if (newDomain.toString() === new Domain(this._customDomain).toString()) { + return; + } + this._customDomain = newDomain.toList(); + if (this._loadPromise === undefined) { + // if the data source has never been loaded, there's no point + // at reloading it now. + return; + } + this.load({ reload: true }); + } + + /** + * @returns {Promise} Display name of the model + */ + getModelLabel() { + return this._metadataRepository.modelDisplayName(this._metaData.resModel); + } +} diff --git a/addons/spreadsheet/static/src/data_sources/server_data.js b/addons/spreadsheet/static/src/data_sources/server_data.js new file mode 100644 index 00000000000..8d56406c646 --- /dev/null +++ b/addons/spreadsheet/static/src/data_sources/server_data.js @@ -0,0 +1,308 @@ +/** @odoo-module */ +import { LoadingDataError } from "../o_spreadsheet/errors"; + +/** + * @param {T[]} array + * @returns {T[]} + * @template T + */ +function removeDuplicates(array) { + return [...new Set(array.map((el) => JSON.stringify(el)))].map((el) => JSON.parse(el)); +} + +class Request { + /** + * @param {string} resModel + * @param {string} method + * @param {unknown[]} args + */ + constructor(resModel, method, args) { + this.resModel = resModel; + this.method = method; + this.args = args; + this.key = `${resModel}/${method}(${JSON.stringify(args)})`; + } +} + +/** + * A batch request consists of multiple requests which are combined into a single RPC. + * + * The batch responsibility is to combine individual requests into a single RPC payload + * and to split the response back for individual requests. + * + * The server method must have the following API: + * - The input is a list of arguments. Each list item being the arguments of a single request. + * - The output is a list of results, ordered according to the input list + * + * ``` + * [result1, result2] = self.env['my.model'].my_batched_method([request_1_args, request_2_args]) + * ``` + */ +class ListRequestBatch { + /** + * @param {string} resModel + * @param {string} method + * @param {Request[]} requests + */ + constructor(resModel, method, requests = []) { + this.resModel = resModel; + this.method = method; + this.requests = requests; + } + + get payload() { + const payload = removeDuplicates(this.requests.map((request) => request.args).flat()); + return [payload]; + } + + /** + * @param {Request} request + */ + add(request) { + if (request.resModel !== this.resModel || request.method !== this.method) { + throw new Error( + `Request ${request.resModel}/${request.method} cannot be added to the batch ${this.resModel}/${this.method}` + ); + } + this.requests.push(request); + } + + /** + * Split the batched RPC response into single request results + * + * @param {T[]} results + * @returns {Map} + * @template T + */ + splitResponse(results) { + const split = new Map(); + for (let i = 0; i < this.requests.length; i++) { + split.set(this.requests[i], results[i]); + } + return split; + } +} + +export class ServerData { + /** + * @param {any} orm + * @param {object} params + * @param {function} params.whenDataIsFetched + */ + constructor(orm, { whenDataIsFetched }) { + this.orm = orm; + this.dataFetchedCallback = whenDataIsFetched; + /** @type {Record}*/ + this.cache = {}; + /** @type {Record>}*/ + this.asyncCache = {}; + this.batchEndpoints = {}; + } + + /** + * @returns {{get: (resModel:string, method: string, args: unknown) => any}} + */ + get batch() { + return { get: (resModel, method, args) => this._getBatchItem(resModel, method, args) }; + } + + /** + * @private + * @param {string} resModel + * @param {string} method + * @param {unknown} args + * @returns {any} + */ + _getBatchItem(resModel, method, args) { + const request = new Request(resModel, method, [args]); + if (!(request.key in this.cache)) { + const error = new LoadingDataError(); + this.cache[request.key] = error; + this._batch(request); + throw error; + } + return this._getOrThrowCachedResponse(request); + } + + /** + * @param {string} resModel + * @param {string} method + * @param {unknown[]} args + * @returns {any}} + */ + get(resModel, method, args) { + const request = new Request(resModel, method, args); + if (!(request.key in this.cache)) { + const error = new LoadingDataError(); + this.cache[request.key] = error; + this.orm + .call(resModel, method, args) + .then((result) => (this.cache[request.key] = result)) + .catch((error) => (this.cache[request.key] = error)) + .finally(() => this.dataFetchedCallback()); + throw error; + } + return this._getOrThrowCachedResponse(request); + } + + /** + * Returns the request result if cached or the associated promise + * @param {string} resModel + * @param {string} method + * @param {unknown[]} args + * @returns {Promise} + */ + async fetch(resModel, method, args) { + const request = new Request(resModel, method, args); + if (!(request.key in this.asyncCache)) { + this.asyncCache[request.key] = this.orm.call(resModel, method, args); + } + return this.asyncCache[request.key]; + } + + /** + * @private + * @param {Request} request + * @returns {void} + */ + _batch(request) { + const endpoint = this._getBatchEndPoint(request.resModel, request.method); + endpoint.call(request); + } + + /** + * @private + * @param {Request} request + * @return {unknown} + */ + _getOrThrowCachedResponse(request) { + const data = this.cache[request.key]; + if (data instanceof Error) { + throw data; + } + return data; + } + + /** + * @private + * @param {string} resModel + * @param {string} method + */ + _getBatchEndPoint(resModel, method) { + if (!this.batchEndpoints[resModel] || !this.batchEndpoints[resModel][method]) { + this.batchEndpoints[resModel] = { + ...this.batchEndpoints[resModel], + [method]: this._createBatchEndpoint(resModel, method), + }; + } + return this.batchEndpoints[resModel][method]; + } + + /** + * @private + * @param {string} resModel + * @param {string} method + */ + _createBatchEndpoint(resModel, method) { + return new BatchEndpoint(this.orm, resModel, method, { + whenDataIsFetched: () => this.dataFetchedCallback(), + successCallback: (request, result) => (this.cache[request.key] = result), + failureCallback: (request, error) => (this.cache[request.key] = error), + }); + } +} + +/** + * Collect multiple requests into a single batch. + */ +export default class BatchEndpoint { + /** + * @param {object} orm + * @param {string} resModel + * @param {string} method + * @param {object} callbacks + * @param {function} callbacks.successCallback + * @param {function} callbacks.failureCallback + * @param {function} callbacks.whenDataIsFetched + */ + constructor(orm, resModel, method, { successCallback, failureCallback, whenDataIsFetched }) { + this.orm = orm; + this.resModel = resModel; + this.method = method; + this.successCallback = successCallback; + this.failureCallback = failureCallback; + this.batchedFetchedCallback = whenDataIsFetched; + + this._isScheduled = false; + this._pendingBatch = new ListRequestBatch(resModel, method); + } + + /** + * @param {Request} request + */ + call(request) { + this._pendingBatch.add(request); + this._scheduleNextBatch(); + } + + /** + * @param {Map} batchResult + * @private + */ + _notifyResults(batchResult) { + for (const [request, result] of batchResult) { + this.successCallback(request, result); + } + } + + /** + * @private + */ + _scheduleNextBatch() { + if (this._isScheduled || this._pendingBatch.requests.length === 0) { + return; + } + this._isScheduled = true; + queueMicrotask(async () => { + try { + this._isScheduled = false; + const batch = this._pendingBatch; + const { resModel, method } = batch; + this._pendingBatch = new ListRequestBatch(resModel, method); + await this.orm + .call(resModel, method, batch.payload) + .then((result) => batch.splitResponse(result)) + .catch(() => this._retryOneByOne(batch)) + .then((batchResults) => this._notifyResults(batchResults)); + } finally { + this.batchedFetchedCallback(); + } + }); + } + + /** + * @private + * @param {ListRequestBatch} batch + * @returns {Promise>} + */ + async _retryOneByOne(batch) { + const mergedResults = new Map(); + const { resModel, method } = batch; + const singleRequestBatches = batch.requests.map( + (request) => new ListRequestBatch(resModel, method, [request]) + ); + const proms = []; + for (const batch of singleRequestBatches) { + const request = batch.requests[0]; + const prom = this.orm + .call(resModel, method, batch.payload) + .then((result) => + mergedResults.set(request, batch.splitResponse(result).get(request)) + ) + .catch((error) => mergedResults.set(request, error)); + proms.push(prom); + } + await Promise.allSettled(proms); + return mergedResults; + } +} diff --git a/addons/spreadsheet/static/src/global_filters/helpers.js b/addons/spreadsheet/static/src/global_filters/helpers.js new file mode 100644 index 00000000000..634a4390893 --- /dev/null +++ b/addons/spreadsheet/static/src/global_filters/helpers.js @@ -0,0 +1,115 @@ +/** @odoo-module */ + +import { serializeDate, serializeDateTime } from "@web/core/l10n/dates"; +import { Domain } from "@web/core/domain"; + +import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason"; +import { FILTER_DATE_OPTION, monthsOptions } from "@spreadsheet/assets_backend/constants"; +import { getPeriodOptions } from "@web/search/utils/dates"; +import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants"; + +const { DateTime } = luxon; + +export function checkFiltersTypeValueCombination(type, value) { + if (value !== undefined) { + switch (type) { + case "text": + if (typeof value !== "string") { + return CommandResult.InvalidValueTypeCombination; + } + break; + case "date": + if (typeof value === "string") { + const expectedValues = RELATIVE_DATE_RANGE_TYPES.map((val) => val.type); + if (!expectedValues.includes(value)) { + return CommandResult.InvalidValueTypeCombination; + } + } else if (typeof value !== "object" || Array.isArray(value)) { + // not a date + return CommandResult.InvalidValueTypeCombination; + } + break; + case "relation": + if (!Array.isArray(value)) { + return CommandResult.InvalidValueTypeCombination; + } + break; + } + } + return CommandResult.Success; +} + +/** + * Get a date domain relative to the current date. + * The domain will span the amount of time specified in rangeType and end the day before the current day. + * + * + * @param {Object} now current time, as luxon time + * @param {number} offset offset to add to the date + * @param {"last_month" | "last_week" | "last_year" | "last_three_years"} rangeType + * @param {string} fieldName + * @param {"date" | "datetime"} fieldType + * + * @returns {Domain|undefined} + */ +export function getRelativeDateDomain(now, offset, rangeType, fieldName, fieldType) { + let endDate = now.minus({ day: 1 }).endOf("day"); + let startDate = endDate; + switch (rangeType) { + case "last_week": { + const offsetParam = { day: 7 * offset }; + endDate = endDate.plus(offsetParam); + startDate = now.minus({ day: 7 }).plus(offsetParam); + break; + } + case "last_month": { + const offsetParam = { day: 30 * offset }; + endDate = endDate.plus(offsetParam); + startDate = now.minus({ day: 30 }).plus(offsetParam); + break; + } + case "last_year": { + const offsetParam = { day: 365 * offset }; + endDate = endDate.plus(offsetParam); + startDate = now.minus({ day: 365 }).plus(offsetParam); + break; + } + case "last_three_years": { + const offsetParam = { day: 3 * 365 * offset }; + endDate = endDate.plus(offsetParam); + startDate = now.minus({ day: 3 * 365 }).plus(offsetParam); + break; + } + default: + return undefined; + } + startDate = startDate.startOf("day"); + + let leftBound, rightBound; + if (fieldType === "date") { + leftBound = serializeDate(startDate); + rightBound = serializeDate(endDate); + } else { + leftBound = serializeDateTime(startDate); + rightBound = serializeDateTime(endDate); + } + + return new Domain(["&", [fieldName, ">=", leftBound], [fieldName, "<=", rightBound]]); +} + +/* + * Return a list of time options to choose from according to the requested + * type. Each option contains its (translated) description. + * @see getPeriodOptions + * @param {string} type "month" | "quarter" | "year" + * @returns {Array} + */ +export function dateOptions(type) { + if (type === "month") { + return monthsOptions; + } else { + return getPeriodOptions(DateTime.local()).filter(({ id }) => + FILTER_DATE_OPTION[type].includes(id) + ); + } +} diff --git a/addons/spreadsheet/static/src/global_filters/index.js b/addons/spreadsheet/static/src/global_filters/index.js new file mode 100644 index 00000000000..3dedf4fc5f8 --- /dev/null +++ b/addons/spreadsheet/static/src/global_filters/index.js @@ -0,0 +1,52 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import "@spreadsheet/pivot"; // filter depends on pivot and lists for its getters +import "@spreadsheet/list"; // filter depends on pivot and lists for its getters +import FiltersEvaluationPlugin from "./plugins/filters_evaluation_plugin"; +import FiltersPlugin from "./plugins/filters_plugin"; +const { inverseCommandRegistry } = spreadsheet.registries; + +function identity(cmd) { + return [cmd]; +} + +const { coreTypes, invalidateEvaluationCommands, readonlyAllowedCommands } = spreadsheet; +const { corePluginRegistry, uiPluginRegistry } = spreadsheet.registries; + +corePluginRegistry.add("odooFiltersPlugin", FiltersPlugin); +uiPluginRegistry.add("odooFiltersEvaluationPlugin", FiltersEvaluationPlugin); + +coreTypes.add("ADD_GLOBAL_FILTER"); +coreTypes.add("EDIT_GLOBAL_FILTER"); +coreTypes.add("REMOVE_GLOBAL_FILTER"); + +invalidateEvaluationCommands.add("ADD_GLOBAL_FILTER"); +invalidateEvaluationCommands.add("EDIT_GLOBAL_FILTER"); +invalidateEvaluationCommands.add("REMOVE_GLOBAL_FILTER"); +invalidateEvaluationCommands.add("SET_GLOBAL_FILTER_VALUE"); +invalidateEvaluationCommands.add("CLEAR_GLOBAL_FILTER_VALUE"); + +readonlyAllowedCommands.add("SET_GLOBAL_FILTER_VALUE"); +readonlyAllowedCommands.add("SET_MANY_GLOBAL_FILTER_VALUE"); +readonlyAllowedCommands.add("CLEAR_GLOBAL_FILTER_VALUE"); + +inverseCommandRegistry + .add("EDIT_GLOBAL_FILTER", identity) + .add("ADD_GLOBAL_FILTER", (cmd) => { + return [ + { + type: "REMOVE_GLOBAL_FILTER", + id: cmd.id, + }, + ]; + }) + .add("REMOVE_GLOBAL_FILTER", (cmd) => { + return [ + { + type: "ADD_GLOBAL_FILTER", + id: cmd.id, + filter: {}, + }, + ]; + }); diff --git a/addons/spreadsheet/static/src/global_filters/plugins/filters_evaluation_plugin.js b/addons/spreadsheet/static/src/global_filters/plugins/filters_evaluation_plugin.js new file mode 100644 index 00000000000..19c85a0f0b4 --- /dev/null +++ b/addons/spreadsheet/static/src/global_filters/plugins/filters_evaluation_plugin.js @@ -0,0 +1,514 @@ +/** @odoo-module */ + +/** + * @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field + * @typedef {import("./filters_plugin").GlobalFilter} GlobalFilter + */ + +import { _t } from "@web/core/l10n/translation"; +import { sprintf } from "@web/core/utils/strings"; +import { Domain } from "@web/core/domain"; +import { constructDateRange, getPeriodOptions, QUARTER_OPTIONS } from "@web/search/utils/dates"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason"; + +import { isEmpty } from "@spreadsheet/helpers/helpers"; +import { FILTER_DATE_OPTION } from "@spreadsheet/assets_backend/constants"; +import { + checkFiltersTypeValueCombination, + getRelativeDateDomain, +} from "@spreadsheet/global_filters/helpers"; +import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants"; + +const { DateTime } = luxon; + +const MONTHS = { + january: { value: 1, granularity: "month" }, + february: { value: 2, granularity: "month" }, + march: { value: 3, granularity: "month" }, + april: { value: 4, granularity: "month" }, + may: { value: 5, granularity: "month" }, + june: { value: 6, granularity: "month" }, + july: { value: 7, granularity: "month" }, + august: { value: 8, granularity: "month" }, + september: { value: 9, granularity: "month" }, + october: { value: 10, granularity: "month" }, + november: { value: 11, granularity: "month" }, + december: { value: 12, granularity: "month" }, +}; + +const { UuidGenerator } = spreadsheet.helpers; +const uuidGenerator = new UuidGenerator(); + +export default class FiltersEvaluationPlugin extends spreadsheet.UIPlugin { + constructor(getters, history, dispatch, config) { + super(getters, history, dispatch, config); + this.orm = config.evalContext.env ? config.evalContext.env.services.orm : undefined; + /** + * Cache record display names for relation filters. + * For each filter, contains a promise resolving to + * the list of display names. + */ + this.recordsDisplayName = {}; + /** @type {Object.|Object>} */ + this.values = {}; + } + + /** + * Check if the given command can be dispatched + * + * @param {Object} cmd Command + */ + allowDispatch(cmd) { + switch (cmd.type) { + case "SET_GLOBAL_FILTER_VALUE": { + const filter = this.getters.getGlobalFilter(cmd.id); + if (!filter) { + return CommandResult.FilterNotFound; + } + return checkFiltersTypeValueCombination(filter.type, cmd.value); + } + } + return CommandResult.Success; + } + + beforeHandle(cmd) { + switch (cmd.type) { + case "START": + // make sure the domains are correctly set before + // any evaluation + this._updateAllDomains(); + break; + } + } + + /** + * Handle a spreadsheet command + * + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "ADD_GLOBAL_FILTER": + this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames; + break; + case "EDIT_GLOBAL_FILTER": + if (this.values[cmd.id] && this.values[cmd.id].rangeType !== cmd.filter.rangeType) { + delete this.values[cmd.id]; + } + this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames; + break; + case "SET_GLOBAL_FILTER_VALUE": + this.recordsDisplayName[cmd.id] = cmd.displayNames; + this._setGlobalFilterValue(cmd.id, cmd.value); + break; + case "SET_MANY_GLOBAL_FILTER_VALUE": + for (const filter of cmd.filters) { + if (filter.value !== undefined) { + this.dispatch("SET_GLOBAL_FILTER_VALUE", { + id: filter.filterId, + value: filter.value, + }); + } else { + this.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id: filter.filterId }); + } + } + break; + case "REMOVE_GLOBAL_FILTER": + delete this.recordsDisplayName[cmd.id]; + delete this.values[cmd.id]; + break; + case "CLEAR_GLOBAL_FILTER_VALUE": + this.recordsDisplayName[cmd.id] = []; + this._clearGlobalFilterValue(cmd.id); + break; + } + switch (cmd.type) { + case "ADD_GLOBAL_FILTER": + case "EDIT_GLOBAL_FILTER": + case "SET_GLOBAL_FILTER_VALUE": + case "REMOVE_GLOBAL_FILTER": + case "CLEAR_GLOBAL_FILTER_VALUE": + this._updateAllDomains(); + break; + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + /** + * Get the current value of a global filter + * + * @param {string} filterId Id of the filter + * + * @returns {string|Array|Object} value Current value to set + */ + getGlobalFilterValue(filterId) { + const filter = this.getters.getGlobalFilter(filterId); + + const value = filterId in this.values ? this.values[filterId].value : filter.defaultValue; + + if (filter.type === "date" && isEmpty(value) && filter.defaultsToCurrentPeriod) { + return this._getValueOfCurrentPeriod(filterId); + } + + return value; + } + + /** + * @param {string} id Id of the filter + * + * @returns { boolean } true if the given filter is active + */ + isFilterActive(id) { + const { type } = this.getters.getGlobalFilter(id); + const value = this.getGlobalFilterValue(id); + switch (type) { + case "text": + return value; + case "date": + return ( + value && + (typeof value === "string" || value.yearOffset !== undefined || value.period) + ); + case "relation": + return value && value.length; + } + } + + /** + * Get the number of active global filters + * + * @returns {number} + */ + getActiveFilterCount() { + return this.getters.getGlobalFilters().filter((filter) => this.isFilterActive(filter.id)) + .length; + } + + getFilterDisplayValue(filterName) { + const filter = this.getters.getGlobalFilterLabel(filterName); + if (!filter) { + throw new Error(sprintf(_t(`Filter "%s" not found`), filterName)); + } + const value = this.getGlobalFilterValue(filter.id); + switch (filter.type) { + case "text": + return value || ""; + case "date": { + if (value && typeof value === "string") { + const type = RELATIVE_DATE_RANGE_TYPES.find((type) => type.type === value); + if (!type) { + return ""; + } + return type.description.toString(); + } + if (!value || value.yearOffset === undefined) { + return ""; + } + const periodOptions = getPeriodOptions(DateTime.local()); + const year = String(DateTime.local().year + value.yearOffset); + const period = periodOptions.find(({ id }) => value.period === id); + let periodStr = period && period.description; + // Named months aren't in getPeriodOptions + if (!period) { + periodStr = + MONTHS[value.period] && String(MONTHS[value.period].value).padStart(2, "0"); + } + return periodStr ? periodStr + "/" + year : year; + } + case "relation": + if (!value || !this.orm) { + return ""; + } + if (!this.recordsDisplayName[filter.id]) { + this.orm.call(filter.modelName, "name_get", [value]).then((result) => { + const names = result.map(([, name]) => name); + this.recordsDisplayName[filter.id] = names; + this.dispatch("EVALUATE_CELLS", { + sheetId: this.getters.getActiveSheetId(), + }); + }); + return ""; + } + return this.recordsDisplayName[filter.id].join(", "); + } + } + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * Set the current value of a global filter + * + * @param {string} id Id of the filter + * @param {string|Array|Object} value Current value to set + */ + _setGlobalFilterValue(id, value) { + this.values[id] = { value: value, rangeType: this.getters.getGlobalFilter(id).rangeType }; + } + + /** + * Get the filter value corresponding to the current period, depending of the type of range of the filter. + * For example if rangeType === "month", the value will be the current month of the current year. + * + * @param {string} filterId a global filter + * @return {Object} filter value + */ + _getValueOfCurrentPeriod(filterId) { + const filter = this.getters.getGlobalFilter(filterId); + const rangeType = filter.rangeType; + switch (rangeType) { + case "year": + return { yearOffset: 0 }; + case "month": { + const month = new Date().getMonth() + 1; + const period = Object.entries(MONTHS).find((item) => item[1].value === month)[0]; + return { yearOffset: 0, period }; + } + case "quarter": { + const quarter = Math.floor(new Date().getMonth() / 3); + const period = FILTER_DATE_OPTION.quarter[quarter]; + return { yearOffset: 0, period }; + } + } + return {}; + } + + /** + * Set the current value to empty values which functionally deactivate the filter + * + * @param {string} id Id of the filter + */ + _clearGlobalFilterValue(id) { + const { type, rangeType } = this.getters.getGlobalFilter(id); + let value; + switch (type) { + case "text": + value = ""; + break; + case "date": + value = { yearOffset: undefined }; + break; + case "relation": + value = []; + break; + } + this.values[id] = { value, rangeType }; + } + + /** + * Update the domain of all pivots and all lists + * + * @private + */ + _updateAllDomains() { + for (const pivotId of this.getters.getPivotIds()) { + const domain = this._getComputedDomain((filterId) => + this.getters.getGlobalFilterFieldPivot(filterId, pivotId) + ); + this.dispatch("ADD_PIVOT_DOMAIN", { id: pivotId, domain }); + } + for (const listId of this.getters.getListIds()) { + const domain = this._getComputedDomain((filterId) => + this.getters.getGlobalFilterFieldList(filterId, listId) + ); + this.dispatch("ADD_LIST_DOMAIN", { id: listId, domain }); + } + for (const chartId of this.getters.getOdooChartIds()) { + const domain = this._getComputedDomain((filterId) => + this.getters.getGlobalFilterFieldGraph(filterId, chartId) + ); + this.dispatch("ADD_GRAPH_DOMAIN", { id: chartId, domain }); + } + } + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + /** + * Get the domain relative to a date field + * + * @private + * + * @param {GlobalFilter} filter + * @param {Field} fieldDesc + * + * @returns {Domain|undefined} + */ + _getDateDomain(filter, fieldDesc) { + if (!this.isFilterActive(filter.id)) { + return undefined; + } + let granularity; + const value = this.getGlobalFilterValue(filter.id); + const field = fieldDesc.field; + const type = fieldDesc.type; + const offset = fieldDesc.offset || 0; + const now = DateTime.local(); + + if (filter.rangeType === "relative") { + return getRelativeDateDomain(now, offset, value, field, type); + } + + const setParam = { year: now.year }; + const yearOffset = value.yearOffset || 0; + const plusParam = { + years: filter.rangeType === "year" ? yearOffset + offset : yearOffset, + }; + if (!value.period || value.period === "empty") { + granularity = "year"; + } else { + switch (filter.rangeType) { + case "month": + granularity = "month"; + setParam.month = MONTHS[value.period].value; + plusParam.month = offset; + break; + case "quarter": + granularity = "quarter"; + setParam.quarter = QUARTER_OPTIONS[value.period].setParam.quarter; + plusParam.quarter = offset; + break; + } + } + return constructDateRange({ + referenceMoment: now, + fieldName: field, + fieldType: type, + granularity, + setParam, + plusParam, + }).domain; + } + + /** + * Get the domain relative to a text field + * + * @private + * + * @param {GlobalFilter} filter + * @param {Field} fieldDesc + * + * @returns {Domain|undefined} + */ + _getTextDomain(filter, fieldDesc) { + const value = this.getGlobalFilterValue(filter.id); + if (!value) { + return undefined; + } + const field = fieldDesc.field; + return new Domain([[field, "ilike", value]]); + } + + /** + * Get the domain relative to a relation field + * + * @private + * + * @param {GlobalFilter} filter + * @param {Field} fieldDesc + * + * @returns {Domain|undefined} + */ + _getRelationDomain(filter, fieldDesc) { + const values = this.getGlobalFilterValue(filter.id); + if (!values || values.length === 0) { + return undefined; + } + const field = fieldDesc.field; + return new Domain([[field, "in", values]]); + } + + /** + * Get the computed domain of a global filters applied to the field given + * by the callback + * + * @private + * + * @param {Function} getFieldDesc Callback to get the field description + * on which the global filters is applied + * + * @returns {string} + */ + _getComputedDomain(getFieldDesc) { + let domain = new Domain([]); + for (const filter of this.getters.getGlobalFilters()) { + const fieldDesc = getFieldDesc(filter.id); + if (!fieldDesc || !fieldDesc.field) { + continue; + } + let domainToAdd = undefined; + switch (filter.type) { + case "date": + domainToAdd = this._getDateDomain(filter, fieldDesc); + break; + case "text": + domainToAdd = this._getTextDomain(filter, fieldDesc); + break; + case "relation": + domainToAdd = this._getRelationDomain(filter, fieldDesc); + break; + } + if (domainToAdd) { + domain = Domain.and([domain, domainToAdd]); + } + } + return domain.toString(); + } + + /** + * Adds all active filters (and their values) at the time of export in a dedicated sheet + * + * @param {Object} data + */ + exportForExcel(data) { + if (this.getters.getGlobalFilters().length === 0) { + return; + } + const styles = Object.entries(data.styles); + let titleStyleId = + styles.findIndex((el) => JSON.stringify(el[1]) === JSON.stringify({ bold: true })) + 1; + + if (titleStyleId <= 0) { + titleStyleId = styles.length + 1; + data.styles[styles.length + 1] = { bold: true }; + } + + const cells = {}; + cells["A1"] = { content: "Filter", style: titleStyleId }; + cells["B1"] = { content: "Value", style: titleStyleId }; + let row = 2; + for (const filter of this.getters.getGlobalFilters()) { + const content = this.getFilterDisplayValue(filter.label); + cells[`A${row}`] = { content: filter.label }; + cells[`B${row}`] = { content }; + row++; + } + data.sheets.push({ + id: uuidGenerator.uuidv4(), + name: "Active Filters", + cells, + colNumber: 2, + rowNumber: this.getters.getGlobalFilters().length + 1, + cols: {}, + rows: {}, + merges: [], + figures: [], + conditionalFormats: [], + charts: [], + }); + } +} + +FiltersEvaluationPlugin.getters = [ + "getFilterDisplayValue", + "getGlobalFilterValue", + "getActiveFilterCount", + "isFilterActive", +]; diff --git a/addons/spreadsheet/static/src/global_filters/plugins/filters_plugin.js b/addons/spreadsheet/static/src/global_filters/plugins/filters_plugin.js new file mode 100644 index 00000000000..eaa713f6b36 --- /dev/null +++ b/addons/spreadsheet/static/src/global_filters/plugins/filters_plugin.js @@ -0,0 +1,288 @@ +/** @odoo-module */ + +/** + * @typedef {"year"|"month"|"quarter"|"relative"} RangeType + * +/** + * @typedef {Object} FilterMatchingField + * @property {string} field name of the field + * @property {string} type type of the field + * @property {number} [offset] offset to apply to the field (for date filters) + * + * @typedef {Object} GlobalFilter + * @property {string} id + * @property {string} label + * @property {string} type "text" | "date" | "relation" + * @property {RangeType} rangeType + * @property {Record} pivotFields + * @property {Record} listFields + * @property {Record} graphFields + * @property {string|Array|Object} defaultValue Default Value + * @property {number} [modelID] ID of the related model + * @property {string} [modelName] Name of the related model + */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason"; +import { checkFiltersTypeValueCombination } from "@spreadsheet/global_filters/helpers"; + + +export default class FiltersPlugin extends spreadsheet.CorePlugin { + constructor() { + super(...arguments); + /** @type {Object.} */ + this.globalFilters = {}; + } + + /** + * Check if the given command can be dispatched + * + * @param {Object} cmd Command + */ + allowDispatch(cmd) { + switch (cmd.type) { + case "EDIT_GLOBAL_FILTER": + if (!this.getGlobalFilter(cmd.id)) { + return CommandResult.FilterNotFound; + } else if (this._isDuplicatedLabel(cmd.id, cmd.filter.label)) { + return CommandResult.DuplicatedFilterLabel; + } + return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue); + case "REMOVE_GLOBAL_FILTER": + if (!this.getGlobalFilter(cmd.id)) { + return CommandResult.FilterNotFound; + } + break; + case "ADD_GLOBAL_FILTER": + if (this._isDuplicatedLabel(cmd.id, cmd.filter.label)) { + return CommandResult.DuplicatedFilterLabel; + } + return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue); + } + return CommandResult.Success; + } + + /** + * Handle a spreadsheet command + * + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "ADD_GLOBAL_FILTER": + this._addGlobalFilter(cmd.filter); + break; + case "EDIT_GLOBAL_FILTER": + this._editGlobalFilter(cmd.id, cmd.filter); + break; + case "REMOVE_GLOBAL_FILTER": + this._removeGlobalFilter(cmd.id); + break; + } + } + + // --------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------- + + /** + * Retrieve the global filter with the given id + * + * @param {string} id + * @returns {GlobalFilter|undefined} Global filter + */ + getGlobalFilter(id) { + return this.globalFilters[id]; + } + + /** + * Get the field name on which the global filter is applied for a pivot + * + * @param {string} filterId Id of the filter + * @param {string} pivotId Id of the pivot + * @returns {FilterMatchingField|undefined} + */ + getGlobalFilterFieldPivot(filterId, pivotId) { + return this.getGlobalFilter(filterId).pivotFields[pivotId]; + } + + /** + * Get the field name on which the global filter is applied for a list + * + * @param {string} filterId Id of the filter + * @param {string} listId Id of the list + * @returns {FilterMatchingField|undefined} + */ + getGlobalFilterFieldList(filterId, listId) { + return this.getGlobalFilter(filterId).listFields[listId]; + } + + /** + * Get the field name on which the global filter is applied for a graph + * + * @param {string} filterId Id of the filter + * @param {string} graphId Id of the Graph + * @returns {FilterMatchingField|undefined} + */ + getGlobalFilterFieldGraph(filterId, graphId) { + return this.getGlobalFilter(filterId).graphFields[graphId]; + } + + /** + * Get the global filter with the given name + * + * @param {string} label Label + * + * @returns {GlobalFilter|undefined} + */ + getGlobalFilterLabel(label) { + return this.getGlobalFilters().find((filter) => filter.label === label); + } + + /** + * Retrieve all the global filters + * + * @returns {Array} Array of Global filters + */ + getGlobalFilters() { + return Object.values(this.globalFilters); + } + + /** + * Get the default value of a global filter + * + * @param {string} id Id of the filter + * + * @returns {string|Array|Object} + */ + getGlobalFilterDefaultValue(id) { + return this.getGlobalFilter(id).defaultValue; + } + + // --------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------- + + /** + * Add a global filter + * + * @param {GlobalFilter} filter + */ + _addGlobalFilter(filter) { + const globalFilters = { ...this.globalFilters }; + globalFilters[filter.id] = filter; + this.history.update("globalFilters", globalFilters); + } + /** + * Remove a global filter + * + * @param {number} id Id of the filter to remove + */ + _removeGlobalFilter(id) { + const globalFilters = { ...this.globalFilters }; + delete globalFilters[id]; + this.history.update("globalFilters", globalFilters); + } + /** + * Edit a global filter + * + * @param {number} id Id of the filter to update + * @param {GlobalFilter} newFilter + */ + _editGlobalFilter(id, newFilter) { + const currentLabel = this.getGlobalFilter(id).label; + const globalFilters = { ...this.globalFilters }; + newFilter.id = id; + globalFilters[id] = newFilter; + this.history.update("globalFilters", globalFilters); + const newLabel = this.getGlobalFilter(id).label; + if (currentLabel !== newLabel) { + this._updateFilterLabelInFormulas(currentLabel, newLabel); + } + } + + // --------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------- + + /** + * Import the filters + * + * @param {Object} data + */ + import(data) { + for (const globalFilter of data.globalFilters || []) { + this.globalFilters[globalFilter.id] = globalFilter; + } + } + /** + * Export the filters + * + * @param {Object} data + */ + export(data) { + data.globalFilters = this.getGlobalFilters().map((filter) => ({ + ...filter, + })); + } + + // --------------------------------------------------------------------- + // Global filters + // --------------------------------------------------------------------- + + /** + * Update all ODOO.FILTER.VALUE formulas to reference a filter + * by its new label. + * + * @param {string} currentLabel + * @param {string} newLabel + */ + _updateFilterLabelInFormulas(currentLabel, newLabel) { + const sheetIds = this.getters.getSheetIds(); + currentLabel = currentLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + for (let sheetId of sheetIds) { + for (let cell of Object.values(this.getters.getCells(sheetId))) { + if (cell.isFormula()) { + const newContent = cell.content.replace( + new RegExp(`FILTER\\.VALUE\\(\\s*"${currentLabel}"\\s*\\)`, "g"), + `FILTER.VALUE("${newLabel}")` + ); + if (newContent !== cell.content) { + const { col, row } = this.getters.getCellPosition(cell.id); + this.dispatch("UPDATE_CELL", { + sheetId, + content: newContent, + col, + row, + }); + } + } + } + } + } + + /** + * Return true if the label is duplicated + * + * @param {string | undefined} filterId + * @param {string} label + * @returns {boolean} + */ + _isDuplicatedLabel(filterId, label) { + return ( + this.getGlobalFilters().findIndex( + (filter) => (!filterId || filter.id !== filterId) && filter.label === label + ) > -1 + ); + } +} + +FiltersPlugin.getters = [ + "getGlobalFilter", + "getGlobalFilters", + "getGlobalFilterDefaultValue", + "getGlobalFilterLabel", + "getGlobalFilterFieldPivot", + "getGlobalFilterFieldList", + "getGlobalFilterFieldGraph", +]; diff --git a/addons/spreadsheet/static/src/helpers/constants.js b/addons/spreadsheet/static/src/helpers/constants.js new file mode 100644 index 00000000000..da75ef547d8 --- /dev/null +++ b/addons/spreadsheet/static/src/helpers/constants.js @@ -0,0 +1,26 @@ +/** @odoo-module */ + +import { _lt } from "@web/core/l10n/translation"; + +export const DEFAULT_LINES_NUMBER = 20; + +export const FORMATS = { + day: { in: "DD MMM YYYY", out: "MM/DD/YYYY", display: "DD MMM YYYY", interval: "d" }, + week: { in: "[W]W YYYY", out: "WW/YYYY", display: "[W]W YYYY", interval: "w" }, + month: { in: "MMMM YYYY", out: "MM/YYYY", display: "MMMM YYYY", interval: "M" }, + quarter: { in: "Q YYYY", out: "Q/YYYY", display: "[Q]Q YYYY", interval: "Q" }, + year: { in: "YYYY", out: "YYYY", display: "YYYY", interval: "y" }, +}; + +export const HEADER_STYLE = { fillColor: "#f2f2f2" }; +export const TOP_LEVEL_STYLE = { bold: true, fillColor: "#f2f2f2" }; +export const MEASURE_STYLE = { fillColor: "#f2f2f2", textColor: "#756f6f" }; + +export const UNTITLED_SPREADSHEET_NAME = _lt("Untitled spreadsheet"); + +export const RELATIVE_DATE_RANGE_TYPES = [ + { type: "last_week", description: _lt("Last 7 Days") }, + { type: "last_month", description: _lt("Last 30 Days") }, + { type: "last_year", description: _lt("Last 365 Days") }, + { type: "last_three_years", description: _lt("Last 3 Years") }, +]; diff --git a/addons/spreadsheet/static/src/helpers/helpers.js b/addons/spreadsheet/static/src/helpers/helpers.js new file mode 100644 index 00000000000..3f90a3281c0 --- /dev/null +++ b/addons/spreadsheet/static/src/helpers/helpers.js @@ -0,0 +1,178 @@ +/** @odoo-module */ + +import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; +import { DataSources } from "../data_sources/data_sources"; +import { serializeDate } from "@web/core/l10n/dates"; +import { migrate } from "@spreadsheet/o_spreadsheet/migration"; + +const { DateTime } = luxon; + +const Model = spreadsheet.Model; + +/** + * Get the intersection of two arrays + * + * @param {Array} a + * @param {Array} b + * + * @private + * @returns {Array} intersection between a and b + */ +export function intersect(a, b) { + return a.filter((x) => b.includes(x)); +} + +/** + * Given an object of form {"1": {...}, "2": {...}, ...} get the maximum ID used + * in this object + * If the object has no keys, return 0 + * + * @param {Object} o an object for which the keys are an ID + * + * @returns {number} + */ +export function getMaxObjectId(o) { + const keys = Object.keys(o); + if (!keys.length) { + return 0; + } + const nums = keys.map((id) => parseInt(id, 10)); + const max = Math.max(...nums); + return max; +} + +/** + * see https://stackoverflow.com/a/30106551 + * @param {string} string + * @returns {string} + */ +function utf8ToBase64(str) { + // first we use encodeURIComponent to get percent-encoded UTF-8, + // then we convert the percent encodings into raw bytes which + // can be fed into btoa. + return btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes(match, p1) { + return String.fromCharCode("0x" + p1); + }) + ); +} + +/** + * see https://stackoverflow.com/a/30106551 + * @param {string} string + * @returns {string} + */ +function base64ToUtf8(str) { + // Going backwards: from bytestream, to percent-encoding, to original string. + return decodeURIComponent( + atob(str) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); +} + +/** + * Encode a json to a base64 string + * @param {object} json + */ +export function jsonToBase64(json) { + return utf8ToBase64(JSON.stringify(json)); +} + +/** + * Decode a base64 encoded json + * @param {string} string + */ +export function base64ToJson(string) { + return JSON.parse(base64ToUtf8(string)); +} + +/** + * Takes a template id as input, will convert the formulas + * from relative to absolute in a way that they can be used to create a sheet. + * + * @param {Function} rpc + * @param {number} templateId + * @returns {Promise} spreadsheetData + */ +export async function getDataFromTemplate(env, orm, templateId) { + let [{ data }] = await orm.read("spreadsheet.template", [templateId], ["data"]); + data = base64ToJson(data); + + const model = new Model(migrate(data), { + dataSources: new DataSources(orm), + }); + await model.config.dataSources.waitForAllLoaded(); + const proms = []; + for (const pivotId of model.getters.getPivotIds()) { + proms.push(model.getters.getPivotDataSource(pivotId).prepareForTemplateGeneration()); + } + await Promise.all(proms); + model.dispatch("CONVERT_PIVOT_FROM_TEMPLATE"); + return model.exportData(); +} + +/** converts and orderBy Object to a string equivalent that can be processed by orm.call */ +export function orderByToString(orderBy) { + return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", "); +} + +/** + * Convert a spreadsheet date representation to an odoo + * server formatted date + * + * @param {Date} value + * @returns {string} + */ +export function toServerDateString(value) { + const date = DateTime.fromJSDate(value); + return serializeDate(date); +} + +/** + * @param {number[]} array + * @returns {number} + */ +export function sum(array) { + return array.reduce((acc, n) => acc + n, 0); +} + +function camelToSnakeKey(word) { + const result = word.replace(/(.){1}([A-Z])/g, "$1 $2"); + return result.split(" ").join("_").toLowerCase(); +} + +/** + * Recursively convert camel case object keys to snake case keys + * @param {object} obj + * @returns {object} + */ +export function camelToSnakeObject(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const isPojo = typeof value === "object" && value !== null && value.constructor === Object; + result[camelToSnakeKey(key)] = isPojo ? camelToSnakeObject(value) : value; + } + return result; +} + +/** + * Check if the argument is falsy or is an empty object/array + * + * TODO : remove this and replace it by the one in o_spreadsheet xlsx import when its merged + */ +export function isEmpty(item) { + if (!item) { + return true; + } + if (typeof item === "object") { + if ( + Object.values(item).length === 0 || + Object.values(item).every((val) => val === undefined) + ) { + return true; + } + } + return false; +} diff --git a/addons/spreadsheet/static/src/helpers/odoo_functions_helpers.js b/addons/spreadsheet/static/src/helpers/odoo_functions_helpers.js new file mode 100644 index 00000000000..74ef9a42806 --- /dev/null +++ b/addons/spreadsheet/static/src/helpers/odoo_functions_helpers.js @@ -0,0 +1,64 @@ +/** @odoo-module **/ + +import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; + +const { parse } = spreadsheet; + +/** + * @typedef {Object} OdooFunctionDescription + * @property {string} functionName Name of the function + * @property {Array} args Arguments of the function + * @property {boolean} isMatched True if the function is matched by the matcher function + */ + +/** + * This function is used to search for the functions which match the given matcher + * from the given formula + * + * @param {string} formula + * @param {Function} matcher a predicate that matches a function name + * @private + * @returns {Array} + */ +export function getOdooFunctions(formula, matcher) { + let ast; + try { + ast = parse(formula); + } catch (_) { + return []; + } + return _getOdooFunctionsFromAST(ast, matcher); +} + +/** + * This function is used to search for the functions which match the given matcher + * from the given AST + * + * @param {Object} ast (see o-spreadsheet) + * @param {Function} matcher a predicate that matches a function name + * + * @private + * @returns {Array} + */ +function _getOdooFunctionsFromAST(ast, matcher) { + switch (ast.type) { + case "UNARY_OPERATION": + return _getOdooFunctionsFromAST(ast.operand, matcher); + case "BIN_OPERATION": { + return _getOdooFunctionsFromAST(ast.left, matcher).concat( + _getOdooFunctionsFromAST(ast.right, matcher) + ); + } + case "FUNCALL": { + const functionName = ast.value.toUpperCase(); + + if (matcher(functionName)) { + return [{ functionName, args: ast.args, isMatched: true }]; + } else { + return ast.args.map((arg) => _getOdooFunctionsFromAST(arg, matcher)).flat(); + } + } + default: + return []; + } +} diff --git a/addons/spreadsheet/static/src/ir_ui_menu/index.js b/addons/spreadsheet/static/src/ir_ui_menu/index.js new file mode 100644 index 00000000000..be2e2f496bf --- /dev/null +++ b/addons/spreadsheet/static/src/ir_ui_menu/index.js @@ -0,0 +1,73 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +import IrMenuPlugin from "./ir_ui_menu_plugin"; + +import { + isMarkdownIrMenuIdLink, + isMarkdownIrMenuXmlLink, + isMarkdownViewLink, + parseIrMenuXmlLink, + OdooViewLinkCell, + OdooMenuLinkCell, + parseViewLink, + parseIrMenuIdLink, +} from "./odoo_menu_link_cell"; + +const { cellRegistry, corePluginRegistry } = spreadsheet.registries; +const { parseMarkdownLink } = spreadsheet.helpers; + +corePluginRegistry.add("ir_ui_menu_plugin", IrMenuPlugin); + +export const spreadsheetLinkMenuCellService = { + dependencies: ["menu"], + start(env) { + function _getIrMenuByXmlId(xmlId) { + const menu = env.services.menu.getAll().find((menu) => menu.xmlid === xmlId); + if (!menu) { + throw new Error( + `Menu ${xmlId} not found. You may not have the required access rights.` + ); + } + return menu; + } + + cellRegistry + .add("OdooMenuIdLink", { + sequence: 65, + match: isMarkdownIrMenuIdLink, + createCell: (id, content, properties, sheetId, getters) => { + const { url } = parseMarkdownLink(content); + const menuId = parseIrMenuIdLink(url); + const menuName = env.services.menu.getMenu(menuId).name; + return new OdooMenuLinkCell(id, content, menuId, menuName, properties); + }, + }) + .add("OdooMenuXmlLink", { + sequence: 66, + match: isMarkdownIrMenuXmlLink, + createCell: (id, content, properties, sheetId, getters) => { + const { url } = parseMarkdownLink(content); + const xmlId = parseIrMenuXmlLink(url); + const menuId = _getIrMenuByXmlId(xmlId).id; + const menuName = _getIrMenuByXmlId(xmlId).name; + return new OdooMenuLinkCell(id, content, menuId, menuName, properties); + }, + }) + .add("OdooIrFilterLink", { + sequence: 67, + match: isMarkdownViewLink, + createCell: (id, content, properties, sheetId, getters) => { + const { url } = parseMarkdownLink(content); + const actionDescription = parseViewLink(url); + return new OdooViewLinkCell(id, content, actionDescription, properties); + }, + }); + + return true; + }, +}; + +registry.category("services").add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService); diff --git a/addons/spreadsheet/static/src/ir_ui_menu/ir_ui_menu_plugin.js b/addons/spreadsheet/static/src/ir_ui_menu/ir_ui_menu_plugin.js new file mode 100644 index 00000000000..6dae208cc54 --- /dev/null +++ b/addons/spreadsheet/static/src/ir_ui_menu/ir_ui_menu_plugin.js @@ -0,0 +1,24 @@ +/** @odoo-module */ +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +const { CorePlugin } = spreadsheet; + +export default class IrMenuPlugin extends CorePlugin { + constructor(getters, history, range, dispatch, config, uuidGenerator) { + super(getters, history, range, dispatch, config, uuidGenerator); + this.env = config.evalContext.env; + } + + /** + * Get an ir menu from an id or an xml id + * @param {number | string} menuId + * @returns {object | undefined} + */ + getIrMenu(menuId) { + let menu = this.env.services.menu.getMenu(menuId); + if (!menu) { + menu = this.env.services.menu.getAll().find((menu) => menu.xmlid === menuId); + } + return menu; + } +} +IrMenuPlugin.getters = ["getIrMenu"]; diff --git a/addons/spreadsheet/static/src/ir_ui_menu/odoo_menu_link_cell.js b/addons/spreadsheet/static/src/ir_ui_menu/odoo_menu_link_cell.js new file mode 100644 index 00000000000..9e60ad3b411 --- /dev/null +++ b/addons/spreadsheet/static/src/ir_ui_menu/odoo_menu_link_cell.js @@ -0,0 +1,171 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { LinkCell } = spreadsheet.cellTypes; +const { isMarkdownLink, parseMarkdownLink } = spreadsheet.helpers; + +const VIEW_PREFIX = "odoo://view/"; +const IR_MENU_ID_PREFIX = "odoo://ir_menu_id/"; +const IR_MENU_XML_ID_PREFIX = "odoo://ir_menu_xml_id/"; + +/** + * @typedef Action + * @property {Array} domain + * @property {Object} context + * @property {string} modelName + * @property {string} orderBy + * @property {Array<[boolean, string]} views + * + * @typedef ViewLinkDescription + * @property {string} name Action name + * @property {Action} action + * @property {string} viewType Type of view (list, pivot, ...) + */ + +/** + * + * @param {string} str + * @returns {boolean} + */ +export function isMarkdownViewLink(str) { + if (!isMarkdownLink(str)) { + return false; + } + const { url } = parseMarkdownLink(str); + return url.startsWith(VIEW_PREFIX); +} + +/** + * + * @param {string} viewLink + * @returns {ViewLinkDescription} + */ +export function parseViewLink(viewLink) { + if (viewLink.startsWith(VIEW_PREFIX)) { + return JSON.parse(viewLink.substr(VIEW_PREFIX.length)); + } + throw new Error(`${viewLink} is not a valid view link`); +} + +/** + * @param {ViewLinkDescription} viewDescription Id of the ir.filter + * @returns {string} + */ +export function buildViewLink(viewDescription) { + return `${VIEW_PREFIX}${JSON.stringify(viewDescription)}`; +} + +/** + * + * @param {string} str + * @returns + */ +export function isMarkdownIrMenuIdLink(str) { + if (!isMarkdownLink(str)) { + return false; + } + const { url } = parseMarkdownLink(str); + return url.startsWith(IR_MENU_ID_PREFIX); +} + +/** + * + * @param {string} irMenuLink + * @returns ir.ui.menu record id + */ +export function parseIrMenuIdLink(irMenuLink) { + if (irMenuLink.startsWith(IR_MENU_ID_PREFIX)) { + return parseInt(irMenuLink.substr(IR_MENU_ID_PREFIX.length), 10); + } + throw new Error(`${irMenuLink} is not a valid menu id link`); +} + +/** + * @param {number} menuId + * @returns + */ +export function buildIrMenuIdLink(menuId) { + return `${IR_MENU_ID_PREFIX}${menuId}`; +} + +/** + * + * @param {string} str + * @returns + */ +export function isMarkdownIrMenuXmlLink(str) { + if (!isMarkdownLink(str)) { + return false; + } + const { url } = parseMarkdownLink(str); + return url.startsWith(IR_MENU_XML_ID_PREFIX); +} + +/** + * + * @param {string} irMenuLink + * @returns ir.ui.menu record id + */ +export function parseIrMenuXmlLink(irMenuLink) { + if (irMenuLink.startsWith(IR_MENU_XML_ID_PREFIX)) { + return irMenuLink.substr(IR_MENU_XML_ID_PREFIX.length); + } + throw new Error(`${irMenuLink} is not a valid menu xml link`); +} +/** + * @param {number} menuXmlId + * @returns + */ +export function buildIrMenuXmlLink(menuXmlId) { + return `${IR_MENU_XML_ID_PREFIX}${menuXmlId}`; +} + +export class OdooMenuLinkCell extends LinkCell { + constructor(id, content, menuId, menuName, properties = {}) { + super(id, content, properties); + this.urlRepresentation = menuName; + this.isUrlEditable = false; + this._irMenuId = menuId; + } + + action(env) { + const menu = env.services.menu.getMenu(this._irMenuId); + env.services.action.doAction(menu.actionID); + } +} + +export class OdooViewLinkCell extends LinkCell { + /** + * + * @param {string} id + * @param {string} content + * @param {ViewLinkDescription} actionDescription + * @param {Object} properties + */ + constructor(id, content, actionDescription, properties = {}) { + super(id, content, properties); + this.urlRepresentation = actionDescription.name; + this.isUrlEditable = false; + this._viewType = actionDescription.viewType; + /** @type {Action} */ + this._action = actionDescription.action; + } + + action(env) { + env.services.action.doAction( + { + type: "ir.actions.act_window", + name: this.urlRepresentation, + res_model: this._action.modelName, + views: this._action.views, + target: "current", + domain: this._action.domain, + context: this._action.context, + }, + { + viewType: this._viewType, + } + ); + } +} diff --git a/addons/spreadsheet/static/src/list/index.js b/addons/spreadsheet/static/src/list/index.js new file mode 100644 index 00000000000..48c798e2672 --- /dev/null +++ b/addons/spreadsheet/static/src/list/index.js @@ -0,0 +1,49 @@ +/** @odoo-module */ + +import { _lt } from "@web/core/l10n/translation"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +import "./list_functions"; + +import ListPlugin from "@spreadsheet/list/plugins/list_plugin"; +import ListStructurePlugin from "@spreadsheet/list/plugins/list_structure_plugin"; + +import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "./list_actions"; +const { inverseCommandRegistry } = spreadsheet.registries; + +function identity(cmd) { + return [cmd]; +} + +const { coreTypes, readonlyAllowedCommands, invalidateEvaluationCommands } = spreadsheet; +const { corePluginRegistry, uiPluginRegistry, cellMenuRegistry } = spreadsheet.registries; + +corePluginRegistry.add("odooListPlugin", ListPlugin); + +uiPluginRegistry.add("odooListStructurePlugin", ListStructurePlugin); + +coreTypes.add("INSERT_ODOO_LIST"); +coreTypes.add("RENAME_ODOO_LIST"); +coreTypes.add("REMOVE_ODOO_LIST"); +coreTypes.add("RE_INSERT_ODOO_LIST"); +coreTypes.add("UPDATE_ODOO_LIST_DOMAIN"); + +invalidateEvaluationCommands.add("UPDATE_ODOO_LIST_DOMAIN"); +invalidateEvaluationCommands.add("REMOVE_ODOO_LIST"); + +readonlyAllowedCommands.add("ADD_LIST_DOMAIN"); + +cellMenuRegistry.add("list_see_record", { + name: _lt("See record"), + sequence: 200, + action: SEE_RECORD_LIST, + isVisible: SEE_RECORD_LIST_VISIBLE, +}); + +inverseCommandRegistry + .add("INSERT_ODOO_LIST", identity) + .add("UPDATE_ODOO_LIST_DOMAIN", identity) + .add("RE_INSERT_ODOO_LIST", identity) + .add("RENAME_ODOO_LIST", identity) + .add("REMOVE_ODOO_LIST", identity); diff --git a/addons/spreadsheet/static/src/list/list_actions.js b/addons/spreadsheet/static/src/list/list_actions.js new file mode 100644 index 00000000000..30cc3b52abf --- /dev/null +++ b/addons/spreadsheet/static/src/list/list_actions.js @@ -0,0 +1,37 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { getFirstListFunction, getNumberOfListFormulas } from "./list_helpers"; + +const { astToFormula } = spreadsheet; + +export const SEE_RECORD_LIST = async (env) => { + const cell = env.model.getters.getActiveCell(); + const { col, row, sheetId } = env.model.getters.getCellPosition(cell.id); + if (!cell) { + return; + } + const { args } = getFirstListFunction(cell.content); + const evaluatedArgs = args + .map(astToFormula) + .map((arg) => env.model.getters.evaluateFormula(arg)); + const listId = env.model.getters.getListIdFromPosition(sheetId, col, row); + const { model } = env.model.getters.getListDefinition(listId); + const dataSource = await env.model.getters.getAsyncListDataSource(listId); + const recordId = dataSource.getIdFromPosition(evaluatedArgs[1] - 1); + if (!recordId) { + return; + } + await env.services.action.doAction({ + type: "ir.actions.act_window", + res_model: model, + res_id: recordId, + views: [[false, "form"]], + view_mode: "form", + }); +}; + +export const SEE_RECORD_LIST_VISIBLE = (env) => { + const cell = env.model.getters.getActiveCell(); + return cell && getNumberOfListFormulas(cell.content) === 1; +}; diff --git a/addons/spreadsheet/static/src/list/list_data_source.js b/addons/spreadsheet/static/src/list/list_data_source.js new file mode 100644 index 00000000000..823dbf625fe --- /dev/null +++ b/addons/spreadsheet/static/src/list/list_data_source.js @@ -0,0 +1,160 @@ +/** @odoo-module */ + +import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source"; +import { orderByToString } from "@spreadsheet/helpers/helpers"; +import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors"; +import { _t } from "@web/core/l10n/translation"; +import { sprintf } from "@web/core/utils/strings"; + +import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; + +const { toNumber } = spreadsheet.helpers; + +/** + * @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field + * + * @typedef {Object} ListMetaData + * @property {Array} columns + * @property {string} resModel + * @property {Record} fields + * + * @typedef {Object} ListSearchParams + * @property {Array} orderBy + * @property {Object} domain + * @property {Object} context + */ + +export default class ListDataSource extends OdooViewsDataSource { + /** + * @override + * @param {Object} services Services (see DataSource) + * @param {Object} params + * @param {ListMetaData} params.metaData + * @param {ListSearchParams} params.searchParams + * @param {number} params.limit + */ + constructor(services, params) { + super(services, params); + this.limit = params.limit; + this._orm = services.orm; + this.data = []; + } + + async _load() { + await super._load(); + if (this.limit === 0) { + this.data = []; + return; + } + const { domain, orderBy, context } = this._searchParams; + this.data = await this._orm.searchRead( + this._metaData.resModel, + domain, + this._metaData.columns.filter((f) => this.getField(f)), + { + order: orderByToString(orderBy), + limit: this.limit, + context, + } + ); + } + + /** + * @param {number} position + * @returns {number} + */ + getIdFromPosition(position) { + this._assertDataIsLoaded(); + const record = this.data[position]; + return record ? record.id : undefined; + } + + /** + * @param {string} fieldName + * @returns {string} + */ + getListHeaderValue(fieldName) { + this._assertDataIsLoaded(); + const field = this.getField(fieldName); + return field ? field.string : fieldName; + } + + /** + * @param {number} position + * @param {string} fieldName + * @returns {string|number|undefined} + */ + getListCellValue(position, fieldName) { + this._assertDataIsLoaded(); + if (position >= this.limit) { + this.limit = position + 1; + // A reload is needed because the asked position is not already loaded. + this._triggerFetching(); + throw new LoadingDataError(); + } + const record = this.data[position]; + if (!record) { + return ""; + } + const field = this.getField(fieldName); + if (!field) { + throw new Error( + sprintf( + _t("The field %s does not exist or you do not have access to that field"), + fieldName + ) + ); + } + if (!(fieldName in record)) { + this._metaData.columns.push(fieldName); + this._metaData.columns = [...new Set(this._metaData.columns)]; //Remove duplicates + this._triggerFetching(); + return undefined; + } + switch (field.type) { + case "many2one": + return record[fieldName].length === 2 ? record[fieldName][1] : ""; + case "one2many": + case "many2many": { + const labels = record[fieldName] + .map((id) => this._metadataRepository.getRecordDisplayName(field.relation, id)) + .filter((value) => value !== undefined); + return labels.join(", "); + } + case "selection": { + const key = record[fieldName]; + const value = field.selection.find((array) => array[0] === key); + return value ? value[1] : ""; + } + case "boolean": + return record[fieldName] ? "TRUE" : "FALSE"; + case "date": + case "datetime": + return record[fieldName] ? toNumber(record[fieldName]) : ""; + default: + return record[fieldName] || ""; + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Ask the parent data source to force a reload of this data source in the + * next clock cycle. It's necessary when this.limit was updated and new + * records have to be fetched. + */ + _triggerFetching() { + if (this._fetchingPromise) { + return; + } + this._fetchingPromise = Promise.resolve().then(() => { + new Promise((resolve) => { + this.load({ reload: true }); + this._fetchingPromise = undefined; + resolve(); + }); + }); + } +} diff --git a/addons/spreadsheet/static/src/list/list_functions.js b/addons/spreadsheet/static/src/list/list_functions.js new file mode 100644 index 00000000000..efced2a1add --- /dev/null +++ b/addons/spreadsheet/static/src/list/list_functions.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ + +import { _t } from "web.core"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { args, toString, toNumber } = spreadsheet.helpers; +const { functionRegistry } = spreadsheet.registries; + +//-------------------------------------------------------------------------- +// Spreadsheet functions +//-------------------------------------------------------------------------- + +function assertListsExists(listId, getters) { + if (!getters.isExistingList(listId)) { + throw new Error(_.str.sprintf(_t('There is no list with id "%s"'), listId)); + } +} + +functionRegistry.add("ODOO.LIST", { + description: _t("Get the value from a list."), + args: args(` + list_id (string) ${_t("ID of the list.")} + index (string) ${_t("Position of the record in the list.")} + field_name (string) ${_t("Name of the field.")} + `), + compute: function (listId, index, fieldName) { + const id = toString(listId); + const position = toNumber(index) - 1; + const field = toString(fieldName); + assertListsExists(id, this.getters); + return this.getters.getListCellValue(id, position, field); + }, + computeFormat: function (listId, index, fieldName) { + const id = toString(listId.value); + const position = toNumber(index.value) - 1; + const field = this.getters.getListDataSource(id).getField(toString(fieldName.value)); + switch (field.type) { + case "integer": + return "0"; + case "float": + return "#,##0.00"; + case "monetary": { + const currencyName = this.getters.getListCellValue( + id, + position, + field.currency_field + ); + return this.getters.getCurrencyFormat(currencyName); + } + case "date": + return "m/d/yyyy"; + case "datetime": + return "m/d/yyyy hh:mm:ss"; + default: + return undefined; + } + }, + returns: ["NUMBER", "STRING"], +}); + +functionRegistry.add("ODOO.LIST.HEADER", { + description: _t("Get the header of a list."), + args: args(` + list_id (string) ${_t("ID of the list.")} + field_name (string) ${_t("Name of the field.")} + `), + compute: function (listId, fieldName) { + const id = toString(listId); + const field = toString(fieldName); + assertListsExists(id, this.getters); + return this.getters.getListHeaderValue(id, field); + }, + returns: ["NUMBER", "STRING"], +}); diff --git a/addons/spreadsheet/static/src/list/list_helpers.js b/addons/spreadsheet/static/src/list/list_helpers.js new file mode 100644 index 00000000000..f6747e2f6ed --- /dev/null +++ b/addons/spreadsheet/static/src/list/list_helpers.js @@ -0,0 +1,30 @@ +/** @odoo-module */ + +import { getOdooFunctions } from "../helpers/odoo_functions_helpers"; + +/** + * Parse a spreadsheet formula and detect the number of LIST functions that are + * present in the given formula. + * + * @param {string} formula + * + * @returns {number} + */ +export function getNumberOfListFormulas(formula) { + return getOdooFunctions(formula, (functionName) => + ["ODOO.LIST", "ODOO.LIST.HEADER"].includes(functionName) + ).filter((fn) => fn.isMatched).length; +} + +/** + * Get the first List function description of the given formula. + * + * @param {string} formula + * + * @returns {import("../helpers/odoo_functions_helpers").OdooFunctionDescription|undefined} + */ +export function getFirstListFunction(formula) { + return getOdooFunctions(formula, (functionName) => + ["ODOO.LIST", "ODOO.LIST.HEADER"].includes(functionName) + ).find((fn) => fn.isMatched); +} diff --git a/addons/spreadsheet/static/src/list/plugins/list_plugin.js b/addons/spreadsheet/static/src/list/plugins/list_plugin.js new file mode 100644 index 00000000000..cd06fd9bd75 --- /dev/null +++ b/addons/spreadsheet/static/src/list/plugins/list_plugin.js @@ -0,0 +1,369 @@ +/** @odoo-module */ + +import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended"; +import CommandResult from "../../o_spreadsheet/cancelled_reason"; +import { getMaxObjectId } from "../../helpers/helpers"; +import ListDataSource from "../list_data_source"; +import { TOP_LEVEL_STYLE } from "../../helpers/constants"; + +/** + * @typedef {Object} ListDefinition + * @property {Array} columns + * @property {Object} context + * @property {Array>} domain + * @property {string} id The id of the list + * @property {string} model The technical name of the model we are listing + * @property {string} name Name of the list + * @property {Array} orderBy + * + */ +export default class ListPlugin extends spreadsheet.CorePlugin { + constructor(getters, history, range, dispatch, config, uuidGenerator) { + super(getters, history, range, dispatch, config, uuidGenerator); + this.dataSources = config.dataSources; + + this.nextId = 1; + /** @type {Object.} */ + this.lists = {}; + } + + allowDispatch(cmd) { + switch (cmd.type) { + case "INSERT_ODOO_LIST": + if (cmd.id !== this.nextId.toString()) { + return CommandResult.InvalidNextId; + } + if (this.lists[cmd.id]) { + return CommandResult.ListIdDuplicated; + } + break; + case "RENAME_ODOO_LIST": + if (!(cmd.listId in this.lists)) { + return CommandResult.ListIdNotFound; + } + if (cmd.name === "") { + return CommandResult.EmptyName; + } + break; + } + return CommandResult.Success; + } + + /** + * Handle a spreadsheet command + * + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "INSERT_ODOO_LIST": { + const { + sheetId, + col, + row, + id, + definition, + dataSourceId, + linesNumber, + columns, + } = cmd; + const anchor = [col, row]; + this._addList(id, definition, dataSourceId); + this._insertList(sheetId, anchor, id, linesNumber, columns); + this.nextId = parseInt(id, 10) + 1; + break; + } + case "RE_INSERT_ODOO_LIST": { + const { sheetId, col, row, id, linesNumber, columns } = cmd; + const anchor = [col, row]; + this._insertList(sheetId, anchor, id, linesNumber, columns); + break; + } + case "RENAME_ODOO_LIST": { + this.history.update("lists", cmd.listId, "definition", "name", cmd.name); + break; + } + case "REMOVE_ODOO_LIST": { + const lists = { ...this.lists }; + delete lists[cmd.listId]; + this.history.update("lists", lists); + break; + } + case "UPDATE_ODOO_LIST_DOMAIN": { + this.history.update( + "lists", + cmd.listId, + "definition", + "searchParams", + "domain", + cmd.domain + ); + const list = this.lists[cmd.listId]; + this.dataSources.add(list.dataSourceId, ListDataSource, list.definition); + break; + } + case "UNDO": + case "REDO": { + const domainEditionCommands = cmd.commands.filter( + (cmd) => cmd.type === "UPDATE_ODOO_LIST_DOMAIN" + ); + for (const cmd of domainEditionCommands) { + const list = this.lists[cmd.listId]; + this.dataSources.add(list.dataSourceId, ListDataSource, list.definition); + } + break; + } + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + /** + * @param {number} id + * @returns {import("@spreadsheet/list/list_data_source").ListDataSource|undefined} + */ + getListDataSource(id) { + const dataSourceId = this.lists[id].dataSourceId; + return this.dataSources.get(dataSourceId); + } + + /** + * @param {number} id + * @returns {string} + */ + getListDisplayName(id) { + return `(#${id}) ${this.getListName(id)}`; + } + + /** + * @param {number} id + * @returns {string} + */ + getListName(id) { + return this.lists[id].definition.name; + } + + /** + * @param {number} id + * @returns {Promise} + */ + async getAsyncListDataSource(id) { + const dataSourceId = this.lists[id].dataSourceId; + await this.dataSources.load(dataSourceId); + return this.getListDataSource(id); + } + + /** + * Retrieve all the list ids + * + * @returns {Array} list ids + */ + getListIds() { + return Object.keys(this.lists); + } + + /** + * Retrieve the next available id for a new list + * + * @returns {string} id + */ + getNextListId() { + return this.nextId.toString(); + } + + /** + * @param {number} id + * @returns {ListDefinition} + */ + getListDefinition(id) { + const def = this.lists[id].definition; + return { + columns: [...def.metaData.columns], + domain: [...def.searchParams.domain], + model: def.metaData.resModel, + context: { ...def.searchParams.context }, + orderBy: [...def.searchParams.orderBy], + id, + name: def.name, + }; + } + + /** + * Check if an id is an id of an existing list + * + * @param {number} id Id of the list + * + * @returns {boolean} + */ + isExistingList(id) { + return id in this.lists; + } + + // --------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------- + + _addList(id, definition, dataSourceId) { + const lists = { ...this.lists }; + lists[id] = { + id, + definition, + dataSourceId, + }; + + if (!this.dataSources.contains(dataSourceId)) { + this.dataSources.add(dataSourceId, ListDataSource, definition); + } + this.history.update("lists", lists); + } + + /** + * Build an Odoo List + * @param {string} sheetId Id of the sheet + * @param {[number,number]} anchor Top-left cell in which the list should be inserted + * @param {string} id Id of the list + * @param {number} linesNumber Number of records to insert + * @param {Array} columns Columns ({name, type}) + */ + _insertList(sheetId, anchor, id, linesNumber, columns) { + this._resizeSheet(sheetId, anchor, columns.length, linesNumber + 1); + this._insertHeaders(sheetId, anchor, id, columns); + this._insertValues(sheetId, anchor, id, columns, linesNumber); + } + + _insertHeaders(sheetId, anchor, id, columns) { + let [col, row] = anchor; + for (const column of columns) { + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + content: `=ODOO.LIST.HEADER(${id},"${column.name}")`, + }); + col++; + } + this.dispatch("SET_FORMATTING", { + sheetId, + style: TOP_LEVEL_STYLE, + target: [ + { + top: anchor[1], + bottom: anchor[1], + left: anchor[0], + right: anchor[0] + columns.length - 1, + }, + ], + }); + } + + _insertValues(sheetId, anchor, id, columns, linesNumber) { + let col = anchor[0]; + let row = anchor[1] + 1; + for (let i = 1; i <= linesNumber; i++) { + col = anchor[0]; + for (const column of columns) { + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + content: `=ODOO.LIST(${id},${i},"${column.name}")`, + }); + col++; + } + row++; + } + } + + /** + * Resize the sheet to match the size of the listing. Columns and/or rows + * could be added to be sure to insert the entire sheet. + * + * @param {string} sheetId Id of the sheet + * @param {[number,number]} anchor Anchor of the list [col,row] + * @param {number} columns Number of columns of the list + * @param {number} rows Number of rows of the list + */ + _resizeSheet(sheetId, anchor, columns, rows) { + const numberCols = this.getters.getNumberCols(sheetId); + const deltaCol = numberCols - anchor[0]; + if (deltaCol < columns) { + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: "COL", + base: numberCols - 1, + sheetId: sheetId, + quantity: columns - deltaCol, + position: "after", + }); + } + const numberRows = this.getters.getNumberRows(sheetId); + const deltaRow = numberRows - anchor[1]; + if (deltaRow < rows) { + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: "ROW", + base: numberRows - 1, + sheetId: sheetId, + quantity: rows - deltaRow, + position: "after", + }); + } + } + + // --------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------- + + /** + * Import the lists + * + * @param {Object} data + */ + import(data) { + if (data.lists) { + for (const [id, list] of Object.entries(data.lists)) { + const definition = { + metaData: { + resModel: list.model, + columns: list.columns, + }, + searchParams: { + domain: list.domain, + context: list.context, + orderBy: list.orderBy, + }, + /** + * As we are not able to migrate the json, we have to fallback + * in the case where the name is not yet present + */ + name: list.name || list.model, + }; + this._addList(id, definition, this.uuidGenerator.uuidv4()); + } + } + this.nextId = data.listNextId || getMaxObjectId(this.lists) + 1; + } + /** + * Export the lists + * + * @param {Object} data + */ + export(data) { + data.lists = {}; + for (const id in this.lists) { + data.lists[id] = JSON.parse(JSON.stringify(this.getListDefinition(id))); + } + data.listNextId = this.nextId; + } +} + +ListPlugin.getters = [ + "getListDataSource", + "getListDisplayName", + "getAsyncListDataSource", + "getListDefinition", + "getListIds", + "getListName", + "getNextListId", + "isExistingList", +]; diff --git a/addons/spreadsheet/static/src/list/plugins/list_structure_plugin.js b/addons/spreadsheet/static/src/list/plugins/list_structure_plugin.js new file mode 100644 index 00000000000..87e2a2bd12e --- /dev/null +++ b/addons/spreadsheet/static/src/list/plugins/list_structure_plugin.js @@ -0,0 +1,194 @@ +/** @odoo-module */ + +import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended"; +import { getFirstListFunction } from "../list_helpers"; + +const { astToFormula } = spreadsheet; + +/** + * @typedef {import("./list_plugin").SpreadsheetList} SpreadsheetList + */ + +export default class ListStructurePlugin extends spreadsheet.UIPlugin { + constructor(getters, history, dispatch, config, selection) { + super(getters, history, dispatch, config, selection); + /** @type {string} */ + this.selectedListId = undefined; + this.selection.observe(this, { + handleEvent: this.handleEvent.bind(this), + }); + this.env = config.evalContext.env; + } + + handleEvent(event) { + switch (event.type) { + case "ZonesSelected": + if (this.getters.isDashboard()) { + const sheetId = this.getters.getActiveSheetId(); + const { col, row } = event.anchor.cell; + const cell = this.getters.getCell(sheetId, col, row); + if (cell && cell.content.startsWith("=ODOO.LIST(")) { + const { args } = getFirstListFunction(cell.content); + const evaluatedArgs = args + .map(astToFormula) + .map((arg) => this.getters.evaluateFormula(arg)); + if (evaluatedArgs.length < 3) { + return; + } + const listId = this.getters.getListIdFromPosition(sheetId, col, row); + const { model } = this.getters.getListDefinition(listId); + const dataSource = this.getters.getListDataSource(listId); + const recordId = dataSource.getIdFromPosition(evaluatedArgs[1] - 1); + if (!recordId) { + return; + } + this.env.services.action.doAction({ + type: "ir.actions.act_window", + res_model: model, + res_id: recordId, + views: [[false, "form"]], + view_mode: "form", + }); + } + } + break; + } + } + + /** + * Handle a spreadsheet command + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "SELECT_ODOO_LIST": + this._selectList(cmd.listId); + break; + case "ADD_LIST_DOMAIN": + this._addDomain(cmd.id, cmd.domain); + break; + case "REFRESH_ODOO_LIST": + this._refreshOdooList(cmd.listId); + break; + case "REFRESH_ALL_DATA_SOURCES": + this._refreshOdooLists(); + break; + } + } + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * Add an additional domain to a list + * + * @private + * + * @param {string} listId pivot id + * @param {Array>} domain + */ + _addDomain(listId, domain) { + this.getters.getListDataSource(listId).addDomain(domain); + } + + /** + * Refresh the cache of a list + * @param {string} listId Id of the list + */ + _refreshOdooList(listId) { + this.getters.getListDataSource(listId).load({ reload: true }); + } + + /** + * Refresh the cache of all the lists + */ + _refreshOdooLists() { + for (const listId of this.getters.getListIds()) { + this._refreshOdooList(listId); + } + } + + /** + * Select the given list id. If the id is undefined, it unselect the list. + * @param {number|undefined} listId Id of the list, or undefined to remove + * the selected list + */ + _selectList(listId) { + this.selectedListId = listId; + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + /** + * Get the computed domain of a list + * + * @param {string} listId Id of the list + * @returns {Array} + */ + getListComputedDomain(listId) { + return this.getters.getListDataSource(listId).getComputedDomain(); + } + + /** + * Get the id of the list at the given position. Returns undefined if there + * is no list at this position + * + * @param {string} sheetId Id of the sheet + * @param {number} col Index of the col + * @param {number} row Index of the row + * + * @returns {string|undefined} + */ + getListIdFromPosition(sheetId, col, row) { + const cell = this.getters.getCell(sheetId, col, row); + if (cell && cell.isFormula()) { + const listFunction = getFirstListFunction(cell.content); + if (listFunction) { + const content = astToFormula(listFunction.args[0]); + return this.getters.evaluateFormula(content).toString(); + } + } + return undefined; + } + + /** + * Get the value of a list header + * + * @param {string} listId Id of a list + * @param {string} fieldName + */ + getListHeaderValue(listId, fieldName) { + return this.getters.getListDataSource(listId).getListHeaderValue(fieldName); + } + + /** + * Get the value for a field of a record in the list + * @param {string} listId Id of the list + * @param {number} position Position of the record in the list + * @param {string} fieldName Field Name + * + * @returns {string|undefined} + */ + getListCellValue(listId, position, fieldName) { + return this.getters.getListDataSource(listId).getListCellValue(position, fieldName); + } + + /** + * Get the currently selected list id + * @returns {number|undefined} Id of the list, undefined if no one is selected + */ + getSelectedListId() { + return this.selectedListId; + } +} + +ListStructurePlugin.getters = [ + "getListComputedDomain", + "getListHeaderValue", + "getListIdFromPosition", + "getListCellValue", + "getSelectedListId", +]; diff --git a/addons/spreadsheet/static/src/o_spreadsheet/cancelled_reason.js b/addons/spreadsheet/static/src/o_spreadsheet/cancelled_reason.js new file mode 100644 index 00000000000..dbf69e5a4ae --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/cancelled_reason.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +export default { + Success: 0, // should be imported from o-spreadsheet instead of redefined here + FilterNotFound: 1000, + DuplicatedFilterLabel: 1001, + PivotCacheNotLoaded: 1002, + InvalidValueTypeCombination: 1003, + ListIdDuplicated: 1004, + InvalidNextId: 1005, + ListIdNotFound: 1006, + EmptyName: 1007, + PivotIdNotFound: 1008, +}; diff --git a/addons/spreadsheet/static/src/o_spreadsheet/errors.js b/addons/spreadsheet/static/src/o_spreadsheet/errors.js new file mode 100644 index 00000000000..67e5be8be07 --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/errors.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import spreadsheet from "./o_spreadsheet_extended"; + +const { EvaluationError, CellErrorLevel } = spreadsheet.helpers; + +export class LoadingDataError extends EvaluationError { + constructor() { + super(_t("Loading..."), _t("Data is loading"), CellErrorLevel.silent); + } +} diff --git a/addons/spreadsheet/static/src/o_spreadsheet/migration.js b/addons/spreadsheet/static/src/o_spreadsheet/migration.js new file mode 100644 index 00000000000..d2ceb149ebb --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/migration.js @@ -0,0 +1,146 @@ +/** @odoo-module */ + +import spreadsheet from "./o_spreadsheet_extended"; +const { load, CorePlugin, tokenize, parse, convertAstNodes, astToFormula } = spreadsheet; +const { corePluginRegistry } = spreadsheet.registries; + +export const ODOO_VERSION = 3; + +const MAP = { + PIVOT: "ODOO.PIVOT", + "PIVOT.HEADER": "ODOO.PIVOT.HEADER", + "PIVOT.POSITION": "ODOO.PIVOT.POSITION", + "FILTER.VALUE": "ODOO.FILTER.VALUE", + LIST: "ODOO.LIST", + "LIST.HEADER": "ODOO.LIST.HEADER", +}; + +const dmyRegex = /^([0|1|2|3][1-9])\/(0[1-9]|1[0-2])\/(\d{4})$/i; + +export function migrate(data) { + let _data = load(data); + const version = _data.odooVersion || 0; + if (version < 1) { + _data = migrate0to1(_data); + } + if (version < 2) { + _data = migrate1to2(_data); + } + if (version < 3) { + _data = migrate2to3(_data); + } + return _data; +} + +function tokensToString(tokens) { + return tokens.reduce((acc, token) => acc + token.value, ""); +} + +function migrate0to1(data) { + for (const sheet of data.sheets) { + for (const xc in sheet.cells || []) { + const cell = sheet.cells[xc]; + if (cell.content && cell.content.startsWith("=")) { + const tokens = tokenize(cell.content); + for (const token of tokens) { + if (token.type === "SYMBOL" && token.value.toUpperCase() in MAP) { + token.value = MAP[token.value.toUpperCase()]; + } + } + cell.content = tokensToString(tokens); + } + } + } + return data; +} + +function migrate1to2(data) { + for (const sheet of data.sheets) { + for (const xc in sheet.cells || []) { + const cell = sheet.cells[xc]; + if (cell.content && cell.content.startsWith("=")) { + try { + cell.content = migratePivotDaysParameters(cell.content); + } catch (_) { + continue; + } + } + } + } + return data; +} + +/** + * Migration of global filters + */ +function migrate2to3(data) { + if (data.globalFilters) { + for (const gf of data.globalFilters) { + if (gf.fields) { + gf.pivotFields = gf.fields; + delete gf.fields; + } + if ( + gf.type === "date" && + typeof gf.defaultValue === "object" && + "year" in gf.defaultValue + ) { + switch (gf.defaultValue.year) { + case "last_year": + gf.defaultValue.yearOffset = -1; + break; + case "antepenultimate_year": + gf.defaultValue.yearOffset = -2; + break; + case "this_year": + case undefined: + gf.defaultValue.yearOffset = 0; + break; + } + delete gf.defaultValue.year; + } + if (!gf.listFields) { + gf.listFields = {}; + } + if (!gf.graphFields) { + gf.graphFields = {}; + } + } + } + return data; +} + +/** + * Convert pivot formulas days parameters from day/month/year + * format to the standard spreadsheet month/day/year format. + * e.g. =PIVOT.HEADER(1,"create_date:day","30/07/2022") becomes =PIVOT.HEADER(1,"create_date:day","07/30/2022") + * @param {string} formulaString + * @returns {string} + */ +function migratePivotDaysParameters(formulaString) { + const ast = parse(formulaString); + const convertedAst = convertAstNodes(ast, "FUNCALL", (ast) => { + if (["ODOO.PIVOT", "ODOO.PIVOT.HEADER"].includes(ast.value.toUpperCase())) { + for (let subAst of ast.args) { + if (subAst.type === "STRING") { + const date = subAst.value.match(dmyRegex); + if (date) { + subAst.value = `${[date[2], date[1], date[3]].join("/")}`; + } + } + } + } + return ast; + }); + return "=" + astToFormula(convertedAst); +} + +export default class OdooVersion extends CorePlugin { + export(data) { + data.odooVersion = ODOO_VERSION; + } +} + +OdooVersion.getters = []; + +corePluginRegistry.add("odooMigration", OdooVersion); diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js new file mode 100644 index 00000000000..a0caf22c923 --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js @@ -0,0 +1,37644 @@ +(function (exports, owl) { + 'use strict'; + + /** + * Registry + * + * The Registry class is basically just a mapping from a string key to an object. + * It is really not much more than an object. It is however useful for the + * following reasons: + * + * 1. it let us react and execute code when someone add something to the registry + * (for example, the FunctionRegistry subclass this for this purpose) + * 2. it throws an error when the get operation fails + * 3. it provides a chained API to add items to the registry. + */ + class Registry { + constructor() { + this.content = {}; + } + /** + * Add an item to the registry + * + * Note that this also returns the registry, so another add method call can + * be chained + */ + add(key, value) { + this.content[key] = value; + return this; + } + /** + * Get an item from the registry + */ + get(key) { + /** + * Note: key in {} is ~12 times slower than {}[key]. + * So, we check the absence of key only when the direct access returns + * a falsy value. It's done to ensure that the registry can contains falsy values + */ + const content = this.content[key]; + if (!content) { + if (!(key in this.content)) { + throw new Error(`Cannot find ${key} in this registry!`); + } + } + return content; + } + /** + * Get a list of all elements in the registry + */ + getAll() { + return Object.values(this.content); + } + /** + * Get a list of all keys in the registry + */ + getKeys() { + return Object.keys(this.content); + } + /** + * Remove an item from the registry + */ + remove(key) { + delete this.content[key]; + } + } + + /** + * This registry is intended to map a cell content (raw string) to + * an instance of a cell. + */ + const chartRegistry = new Registry(); + const chartComponentRegistry = new Registry(); + + class ChartJsComponent extends owl.Component { + constructor() { + super(...arguments); + this.canvas = owl.useRef("graphContainer"); + } + get background() { + return this.chartRuntime.background; + } + get canvasStyle() { + return `background-color: ${this.background}`; + } + get chartRuntime() { + const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id); + if (!("chartJsConfig" in runtime)) { + throw new Error("Unsupported chart runtime"); + } + return runtime; + } + setup() { + owl.onMounted(() => { + const runtime = this.chartRuntime; + this.createChart(runtime.chartJsConfig); + }); + owl.onPatched(() => { + var _a, _b, _c, _d; + const chartData = this.chartRuntime.chartJsConfig; + if (chartData.data && chartData.data.datasets) { + this.chart.data = chartData.data; + if ((_a = chartData.options) === null || _a === void 0 ? void 0 : _a.title) { + this.chart.config.options.title = chartData.options.title; + } + if (chartData.options && "valueLabel" in chartData.options) { + if ((_b = chartData.options) === null || _b === void 0 ? void 0 : _b.valueLabel) { + this.chart.config.options.valueLabel = + chartData.options.valueLabel; + } + } + } + else { + this.chart.data.datasets = undefined; + } + this.chart.config.options.legend = (_c = chartData.options) === null || _c === void 0 ? void 0 : _c.legend; + this.chart.config.options.scales = (_d = chartData.options) === null || _d === void 0 ? void 0 : _d.scales; + this.chart.update({ duration: 0 }); + }); + } + createChart(chartData) { + const canvas = this.canvas.el; + const ctx = canvas.getContext("2d"); + this.chart = new window.Chart(ctx, chartData); + } + } + ChartJsComponent.template = "o-spreadsheet-ChartJsComponent"; + chartComponentRegistry.add("line", ChartJsComponent); + chartComponentRegistry.add("bar", ChartJsComponent); + chartComponentRegistry.add("pie", ChartJsComponent); + chartComponentRegistry.add("gauge", ChartJsComponent); + + /* + * usage: every string should be translated either with _lt if they are registered with a registry at + * the load of the app or with Spreadsheet._t in the templates. Spreadsheet._t is exposed in the + * sub-env of Spreadsheet components as _t + * */ + // define a mock translation function, when o-spreadsheet runs in standalone it doesn't translate any string + let _translate = (s) => s; + function sprintf(s, ...values) { + if (values.length === 1 && typeof values[0] === "object") { + 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. + * @param tfn the function that will do the translation + */ + function setTranslationMethod(tfn) { + _translate = tfn; + } + const _t = function (s, ...values) { + return sprintf(_translate(s), ...values); + }; + const _lt = function (str, ...values) { + // casts the object to unknown then to string to trick typescript into thinking that the object it receives is actually a string + // this way it will be typed correctly (behaves like a string) but tests like typeof _lt("whatever") will be object and not string ! + return new LazyTranslatedString(str, values); + }; + class LazyTranslatedString extends String { + constructor(str, values) { + super(str); + this.values = values; + } + valueOf() { + const str = super.valueOf(); + return sprintf(_translate(str), ...this.values); + } + toString() { + return this.valueOf(); + } + } + + var CellErrorType; + (function (CellErrorType) { + CellErrorType["NotAvailable"] = "#N/A"; + CellErrorType["InvalidReference"] = "#REF"; + CellErrorType["BadExpression"] = "#BAD_EXPR"; + CellErrorType["CircularDependency"] = "#CYCLE"; + CellErrorType["GenericError"] = "#ERROR"; + })(CellErrorType || (CellErrorType = {})); + var CellErrorLevel; + (function (CellErrorLevel) { + CellErrorLevel[CellErrorLevel["silent"] = 0] = "silent"; + CellErrorLevel[CellErrorLevel["error"] = 1] = "error"; + })(CellErrorLevel || (CellErrorLevel = {})); + class EvaluationError extends Error { + constructor(errorType, message, logLevel = CellErrorLevel.error) { + super(message); + this.errorType = errorType; + this.logLevel = logLevel; + } + } + class BadExpressionError extends EvaluationError { + constructor(errorMessage) { + super(CellErrorType.BadExpression, errorMessage); + } + } + class CircularDependencyError extends EvaluationError { + constructor() { + super(CellErrorType.CircularDependency, _lt("Circular reference")); + } + } + class InvalidReferenceError extends EvaluationError { + constructor() { + super(CellErrorType.InvalidReference, _lt("Invalid reference")); + } + } + class NotAvailableError extends EvaluationError { + constructor() { + super(CellErrorType.NotAvailable, _lt("Data not available"), CellErrorLevel.silent); + } + } + + // Colors + const BACKGROUND_GRAY_COLOR = "#f5f5f5"; + const BACKGROUND_HEADER_COLOR = "#F8F9FA"; + const BACKGROUND_HEADER_SELECTED_COLOR = "#E8EAED"; + const BACKGROUND_HEADER_ACTIVE_COLOR = "#595959"; + const TEXT_HEADER_COLOR = "#666666"; + const FIGURE_BORDER_COLOR = "#c9ccd2"; + const SELECTION_BORDER_COLOR = "#3266ca"; + const HEADER_BORDER_COLOR = "#C0C0C0"; + const CELL_BORDER_COLOR = "#E2E3E3"; + const BACKGROUND_CHART_COLOR = "#FFFFFF"; + const MENU_ITEM_DISABLED_COLOR = "#CACACA"; + const DEFAULT_COLOR_SCALE_MIDPOINT_COLOR = 0xb6d7a8; + // Dimensions + const MIN_ROW_HEIGHT = 10; + const MIN_COL_WIDTH = 5; + const HEADER_HEIGHT = 26; + const HEADER_WIDTH = 48; + const TOPBAR_HEIGHT = 63; + const BOTTOMBAR_HEIGHT = 36; + const DEFAULT_CELL_WIDTH = 96; + const DEFAULT_CELL_HEIGHT = 23; + const SCROLLBAR_WIDTH$1 = 15; + const AUTOFILL_EDGE_LENGTH = 8; + const ICON_EDGE_LENGTH = 18; + const UNHIDE_ICON_EDGE_LENGTH = 14; + const MIN_CF_ICON_MARGIN = 4; + const MIN_CELL_TEXT_MARGIN = 4; + const CF_ICON_EDGE_LENGTH = 15; + const PADDING_AUTORESIZE_VERTICAL = 3; + const PADDING_AUTORESIZE_HORIZONTAL = MIN_CELL_TEXT_MARGIN; + // Menus + const MENU_WIDTH = 250; + const MENU_ITEM_HEIGHT = 28; + const MENU_SEPARATOR_BORDER_WIDTH = 1; + const MENU_SEPARATOR_PADDING = 5; + const MENU_SEPARATOR_HEIGHT = MENU_SEPARATOR_BORDER_WIDTH + 2 * MENU_SEPARATOR_PADDING; + const FIGURE_BORDER_SIZE = 1; + // Fonts + const DEFAULT_FONT_WEIGHT = "400"; + const DEFAULT_FONT_SIZE = 10; + const HEADER_FONT_SIZE = 11; + const DEFAULT_FONT = "'Roboto', arial"; + // Borders + const DEFAULT_BORDER_DESC = ["thin", "#000"]; + const LINK_COLOR = "#01666b"; + const COLORS = [ + "#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", + ]; + // DateTimeRegex + const DATETIME_FORMAT = /[ymd:]/; + // Ranges + const INCORRECT_RANGE_STRING = CellErrorType.InvalidReference; + // 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; + // Chart + const MAX_CHAR_LABEL = 20; + const DEBOUNCE_TIME = 200; + const MESSAGE_VERSION = 1; + const LOADING = "Loading..."; + const DEFAULT_ERROR_MESSAGE = _lt("Invalid expression"); + const FORBIDDEN_SHEET_CHARS = ["'", "*", "?", "/", "\\", "[", "]"]; + const FORBIDDEN_IN_EXCEL_REGEX = /'|\*|\?|\/|\\|\[|\]/; + const DEFAULT_GAUGE_LOWER_COLOR = "#cc0000"; + const DEFAULT_GAUGE_MIDDLE_COLOR = "#f1c232"; + const DEFAULT_GAUGE_UPPER_COLOR = "#6aa84f"; + // Cells + const NULL_FORMAT = undefined; + const FORMULA_REF_IDENTIFIER = "|"; + var ComponentsImportance; + (function (ComponentsImportance) { + ComponentsImportance[ComponentsImportance["Grid"] = 0] = "Grid"; + ComponentsImportance[ComponentsImportance["Highlight"] = 5] = "Highlight"; + ComponentsImportance[ComponentsImportance["Figure"] = 10] = "Figure"; + ComponentsImportance[ComponentsImportance["Dropdown"] = 12] = "Dropdown"; + ComponentsImportance[ComponentsImportance["ScrollBar"] = 15] = "ScrollBar"; + ComponentsImportance[ComponentsImportance["Composer"] = 20] = "Composer"; + ComponentsImportance[ComponentsImportance["ColorPicker"] = 25] = "ColorPicker"; + ComponentsImportance[ComponentsImportance["IconPicker"] = 25] = "IconPicker"; + ComponentsImportance[ComponentsImportance["Popover"] = 30] = "Popover"; + ComponentsImportance[ComponentsImportance["ChartAnchor"] = 1000] = "ChartAnchor"; + })(ComponentsImportance || (ComponentsImportance = {})); + + const fontSizes = [ + { pt: 7.5, px: 10 }, + { pt: 8, px: 11 }, + { pt: 9, px: 12 }, + { pt: 10, px: 13 }, + { pt: 10.5, px: 14 }, + { pt: 11, px: 15 }, + { pt: 12, px: 16 }, + { pt: 14, px: 18.7 }, + { pt: 15, px: 20 }, + { pt: 16, px: 21.3 }, + { pt: 18, px: 24 }, + { pt: 22, px: 29.3 }, + { pt: 24, px: 32 }, + { pt: 26, px: 34.7 }, + { pt: 36, px: 48 }, + ]; + const fontSizeMap = {}; + for (let font of fontSizes) { + fontSizeMap[font.pt] = font.px; + } + + // ----------------------------------------------------------------------------- + // Date Type + // ----------------------------------------------------------------------------- + // ----------------------------------------------------------------------------- + // Parsing + // ----------------------------------------------------------------------------- + const INITIAL_1900_DAY = new Date(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 = new Date().getFullYear(); + const INITIAL_JS_DAY = new Date(0); + const DATE_JS_1900_OFFSET = INITIAL_JS_DAY - INITIAL_1900_DAY; + 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 timeRegexp = /((\d+(:\d+)?(:\d+)?\s*(AM|PM))|(\d+:\d+(:\d+)?))$/; + function parseDateTime(str) { + str = str.trim(); + let time; + const timeMatch = str.match(timeRegexp); + if (timeMatch) { + time = parseTime(timeMatch[0]); + if (time === null) { + return null; + } + str = str.replace(timeMatch[0], "").trim(); + } + let date; + const mdyDateMatch = str.match(mdyDateRegexp); + const ymdDateMatch = str.match(ymdDateRegexp); + if (mdyDateMatch || ymdDateMatch) { + let dateMatch; + if (mdyDateMatch) { + dateMatch = mdyDateMatch[0]; + date = parseDate(dateMatch, "mdy"); + } + else { + dateMatch = ymdDateMatch[0]; + date = parseDate(dateMatch, "ymd"); + } + if (date === null) { + return null; + } + str = str.replace(dateMatch, "").trim(); + } + if (str !== "" || !(date || time)) { + return null; + } + if (date && time) { + return { + value: date.value + time.value, + format: date.format + " " + (time.format === "hhhh:mm:ss" ? "hh:mm:ss" : time.format), + jsDate: new Date(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; + } + function parseDate(str, dateFormat) { + const isMDY = dateFormat === "mdy"; + const isYMD = dateFormat === "ymd"; + if (isMDY || isYMD) { + const parts = str.split(/\/|-|\s/); + const monthIndex = isMDY ? 0 : 1; + const dayIndex = isMDY ? 1 : 2; + const yearIndex = isMDY ? 2 : 0; + const month = Number(parts[monthIndex]); + const day = Number(parts[dayIndex]); + const leadingZero = (parts[monthIndex].length === 2 && month < 10) || (parts[dayIndex].length === 2 && day < 10); + const year = parts[yearIndex] ? inferYear(parts[yearIndex]) : CURRENT_YEAR; + const jsDate = new Date(year, month - 1, day); + const sep = str.match(/\/|-|\s/)[0]; + if (jsDate.getMonth() !== month - 1 || jsDate.getDate() !== day) { + // invalid date + return null; + } + const delta = jsDate - INITIAL_1900_DAY; + let format = leadingZero ? `mm${sep}dd` : `m${sep}d`; + if (parts[yearIndex]) { + format = isMDY ? format + sep + "yyyy" : "yyyy" + sep + format; + } + return { + value: Math.round(delta / MS_PER_DAY), + format: format, + jsDate, + }; + } + return null; + } + function inferYear(str) { + const nbr = Number(str); + switch (str.length) { + case 1: + return CURRENT_MILLENIAL + nbr; + case 2: + const offset = CURRENT_MILLENIAL + nbr > CURRENT_YEAR + 10 ? -100 : 0; + const base = CURRENT_MILLENIAL + offset; + return base + nbr; + case 3: + case 4: + return nbr; + } + return 0; + } + function parseTime(str) { + str = str.trim(); + if (timeRegexp.test(str)) { + const isAM = /AM/i.test(str); + const isPM = /PM/i.test(str); + const strTime = isAM || isPM ? str.substring(0, str.length - 2).trim() : str; + const parts = strTime.split(/:/); + const isMinutes = parts.length >= 2; + const isSeconds = parts.length === 3; + let hours = Number(parts[0]); + let minutes = isMinutes ? Number(parts[1]) : 0; + let seconds = isSeconds ? Number(parts[2]) : 0; + let format = isSeconds ? "hh:mm:ss" : "hh:mm"; + if (isAM || isPM) { + format += " a"; + } + else if (!isMinutes) { + return null; + } + if (hours >= 12 && isAM) { + hours -= 12; + } + else if (hours < 12 && isPM) { + hours += 12; + } + minutes += Math.floor(seconds / 60); + seconds %= 60; + hours += Math.floor(minutes / 60); + minutes %= 60; + if (hours >= 24) { + format = "hhhh:mm:ss"; + } + const jsDate = new Date(1899, 11, 30, hours, minutes, seconds); + return { + value: hours / 24 + minutes / 1440 + seconds / 86400, + format: format, + jsDate: jsDate, + }; + } + return null; + } + // ----------------------------------------------------------------------------- + // Conversion + // ----------------------------------------------------------------------------- + function numberToJsDate(value) { + const truncValue = Math.trunc(value); + let date = new Date(truncValue * MS_PER_DAY - DATE_JS_1900_OFFSET); + let time = value - truncValue; + time = time < 0 ? 1 + time : time; + const hours = Math.round(time * 24); + const minutes = Math.round((time - hours / 24) * 24 * 60); + const seconds = Math.round((time - hours / 24 - minutes / 24 / 60) * 24 * 60 * 60); + date.setHours(hours); + date.setMinutes(minutes); + date.setSeconds(seconds); + return date; + } + function jsDateToRoundNumber(date) { + const delta = date.getTime() - INITIAL_1900_DAY.getTime(); + return Math.round(delta / MS_PER_DAY); + } + + //------------------------------------------------------------------------------ + /** + * Stringify an object, like JSON.stringify, except that the first level of keys + * is ordered. + */ + function stringify(obj) { + return JSON.stringify(obj, Object.keys(obj).sort()); + } + /** + * Remove quotes from a quoted string + * ```js + * removeStringQuotes('"Hello"') + * > 'Hello' + * ``` + */ + function removeStringQuotes(str) { + if (str[0] === '"') { + str = str.slice(1); + } + if (str[str.length - 1] === '"' && str[str.length - 2] !== "\\") { + return str.slice(0, str.length - 1); + } + return str; + } + function isCloneable(obj) { + return "clone" in obj && obj.clone instanceof Function; + } + /** + * 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) { + const result = Array.isArray(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"); + } + 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 === void 0 ? void 0 : obj.constructor) === Object; + } + /** + * Sanitize the name of a sheet, by eventually removing quotes + * @param sheetName name of the sheet, potentially quoted with single quotes + */ + function getUnquotedSheetName(sheetName) { + if (sheetName.startsWith("'")) { + sheetName = sheetName.slice(1, -1).replace(/''/g, "'"); + } + return sheetName; + } + /** + * Add quotes around the sheet name if it contains at least one non alphanumeric character + * '\w' captures [0-9][a-z][A-Z] and _. + * @param sheetName Name of the sheet + */ + function getComposerSheetName(sheetName) { + var _a; + if (((_a = sheetName.match(/\w/g)) === null || _a === void 0 ? void 0 : _a.length) !== sheetName.length) { + sheetName = `'${sheetName}'`; + } + return sheetName; + } + function clip(val, min, max) { + return val < min ? min : val > max ? max : val; + } + /** Get the default height of the cell. The height depends on the font size */ + function getDefaultCellHeight(style) { + if (!(style === null || style === void 0 ? void 0 : style.fontSize)) { + return DEFAULT_CELL_HEIGHT; + } + return fontSizeInPixels(style) + 2 * PADDING_AUTORESIZE_VERTICAL; + } + function fontSizeInPixels(style) { + const sizeInPt = style.fontSize || DEFAULT_FONT_SIZE; + if (!fontSizeMap[sizeInPt]) { + throw new Error("Size of the font is not supported"); + } + return fontSizeMap[sizeInPt]; + } + function computeTextWidth(context, text, style) { + const italic = style.italic ? "italic " : ""; + const weight = style.bold ? "bold" : DEFAULT_FONT_WEIGHT; + const sizeInPt = style.fontSize || DEFAULT_FONT_SIZE; + const size = fontSizeMap[sizeInPt]; + context.font = `${italic}${weight} ${size}px ${DEFAULT_FONT}`; + return context.measureText(text).width; + } + /** + * Return the font size that makes the width of a text match the given line width. + * Minimum font size is 1. + * + * @param getTextWidth function that takes a fontSize as argument, and return the width of the text with this font size. + */ + function getFontSizeMatchingWidth(lineWidth, maxFontSize, getTextWidth, precision = 0.25) { + let minFontSize = 1; + if (getTextWidth(minFontSize) > lineWidth) + return minFontSize; + if (getTextWidth(maxFontSize) < lineWidth) + return maxFontSize; + // Dichotomic search + let fontSize = (minFontSize + maxFontSize) / 2; + let currentTextWidth = getTextWidth(fontSize); + // Use a maximum number of iterations to be safe, because measuring text isn't 100% precise + let iterations = 0; + while (Math.abs(currentTextWidth - lineWidth) > precision && iterations < 20) { + if (currentTextWidth >= lineWidth) { + maxFontSize = (minFontSize + maxFontSize) / 2; + } + else { + minFontSize = (minFontSize + maxFontSize) / 2; + } + fontSize = (minFontSize + maxFontSize) / 2; + currentTextWidth = getTextWidth(fontSize); + iterations++; + } + return fontSize; + } + function computeIconWidth(context, style) { + const sizeInPt = style.fontSize || DEFAULT_FONT_SIZE; + const size = fontSizeMap[sizeInPt]; + return size + 2 * MIN_CF_ICON_MARGIN; + } + /** + * 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"; + } + function isDateTime(str) { + return parseDateTime(str) !== null; + } + 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 isMarkdownSheetLink(str) { + if (!isMarkdownLink(str)) { + return false; + } + const { url } = parseMarkdownLink(str); + 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 parseSheetLink(sheetLink) { + if (sheetLink.startsWith(O_SPREADSHEET_LINK_PREFIX)) { + return sheetLink.substr(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$1(argument) { + return argument !== undefined; + } + /** + * 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); + } + /** + * Get the id of the given item (its key in the given dictionnary). + * If the given item does not exist in the dictionary, it creates one with a new id. + */ + function getItemId(item, itemsDic) { + for (let [key, value] of Object.entries(itemsDic)) { + if (stringify(value) === stringify(item)) { + return parseInt(key, 10); + } + } + // Generate new Id if the item didn't exist in the dictionary + const ids = Object.keys(itemsDic); + const maxId = ids.length === 0 ? 0 : Math.max(...ids.map((id) => parseInt(id, 10))); + itemsDic[maxId + 1] = item; + return maxId + 1; + } + /** + * This method comes from owl 1 as it was removed in owl 2 + * + * 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, trigger the function on the + * leading edge, instead of the trailing. + * + * Inspired by https://davidwalsh.name/javascript-debounce-function + */ + function debounce(func, wait, immediate) { + let timeout; + return function () { + const context = this; + const args = arguments; + function later() { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + } + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, 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(); + 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; + } + /** + * Compare two objects. + */ + function deepEquals(o1, o2) { + if (o1 === o2) + return true; + if ((o1 && !o2) || (o2 && !o1)) + return false; + // Objects can have different keys if the values are undefined + const keys = new Set(); + Object.keys(o1).forEach((key) => keys.add(key)); + Object.keys(o2).forEach((key) => keys.add(key)); + for (let key of keys) { + if (typeof o1[key] !== typeof o1[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; + } + /** + * Return an object with all the keys in the object that have a falsy value removed. + */ + function removeFalsyAttributes(obj) { + const cleanObject = { ...obj }; + Object.keys(cleanObject).forEach((key) => !cleanObject[key] && delete cleanObject[key]); + return cleanObject; + } + + const colors$1 = [ + "#eb6d00", + "#0074d9", + "#ad8e00", + "#169ed4", + "#b10dc9", + "#00a82d", + "#00a3a3", + "#f012be", + "#3d9970", + "#111111", + "#62A300", + "#ff4136", + "#949494", + "#85144b", + "#001f3f", + ]; + /* + * transform a color number (R * 256^2 + G * 256 + B) into classic hex6 value + * */ + function colorNumberString(color) { + return toHex(color.toString(16).padStart(6, "0")); + } + let colorIndex = 0; + function getNextColor() { + colorIndex = ++colorIndex % colors$1.length; + return colors$1[colorIndex]; + } + /** + * Converts any CSS color value to a standardized hex6 value. + * Accepts: hex3, hex6, hex8 and rgb (rgba is not supported) + * + * toHex("#ABC") + * >> "#AABBCC" + * + * toHex("#AAAFFF") + * >> "#AAAFFF" + * + * toHex("rgb(30, 80, 16)") + * >> "#1E5010" + * + */ + function toHex(color) { + if (color.includes("rgba")) { + throw new Error(`rgba() conversion currently not supported: ${color}`); + } + if (color.includes("rgb")) { + return rgbToHex6(color); + } + color = color.replace("#", "").toUpperCase(); + if (color.length === 3 || color.length === 4) { + color = color.split("").reduce((acc, h) => acc + h + h, ""); + } + if (color.replace(/[a-f0-9]/gi, "") !== "") { + throw new Error("invalid color"); + } + return "#" + color; + } + function isColorValid(color) { + try { + const { r, g, b, a } = colorToRGBA(color); + return (isColorValueValid(r) && isColorValueValid(g) && isColorValueValid(b) && isColorValueValid(a)); + } + catch (error) { + return false; + } + } + const isColorValueValid = (v) => v >= 0 && v <= 255; + function rgba(r, g, b, a = 1) { + const isInvalid = !isColorValueValid(r) || !isColorValueValid(g) || !isColorValueValid(b) || a < 0 || a > 1; + if (isInvalid) { + throw new Error(`Invalid RGBA values ${[r, g, b, a]}`); + } + return { a, b, g, r }; + } + /** + * The relative brightness of a point in the colorspace, normalized to 0 for + * darkest black and 1 for lightest white. + * https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ + function relativeLuminance(color) { + let { r, g, b } = colorToRGBA(color); + r /= 255; + g /= 255; + b /= 255; + const toLinearValue = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + const R = toLinearValue(r); + const G = toLinearValue(g); + const B = toLinearValue(b); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; + } + /** + * Convert a CSS rgb color string to a standardized hex6 color value. + * + * rgbToHex6("rgb(30, 80, 16)") + * >> "#1E5010" + */ + function rgbToHex6(color) { + return ("#" + + concat(color + .slice(4, -1) + .split(",") + .map((valueString) => parseInt(valueString, 10).toString(16).padStart(2, "0"))).toUpperCase()); + } + /** + * RGBA to HEX representation (#RRGGBBAA). + * + * https://css-tricks.com/converting-color-spaces-in-javascript/ + */ + function rgbaToHex(rgba) { + let r = rgba.r.toString(16); + let g = rgba.g.toString(16); + let b = rgba.b.toString(16); + let a = Math.round(rgba.a * 255).toString(16); + if (r.length == 1) + r = "0" + r; + if (g.length == 1) + g = "0" + g; + if (b.length == 1) + b = "0" + b; + if (a.length == 1) + a = "0" + a; + if (a === "ff") + a = ""; + return "#" + r + g + b + a; + } + /** + * Color string to RGBA representation + */ + function colorToRGBA(color) { + color = toHex(color); + let r; + let g; + let b; + let a; + if (color.length === 7) { + r = parseInt(color[1] + color[2], 16); + g = parseInt(color[3] + color[4], 16); + b = parseInt(color[5] + color[6], 16); + a = 255; + } + else if (color.length === 9) { + r = parseInt(color[1] + color[2], 16); + g = parseInt(color[3] + color[4], 16); + b = parseInt(color[5] + color[6], 16); + a = parseInt(color[7] + color[8], 16); + } + else { + throw new Error("Invalid color"); + } + a = +(a / 255).toFixed(3); + return { a, r, g, b }; + } + /** + * HSLA to RGBA. + * + * https://css-tricks.com/converting-color-spaces-in-javascript/ + */ + function hslaToRGBA(hsla) { + hsla = { ...hsla }; + // Must be fractions of 1 + hsla.s /= 100; + hsla.l /= 100; + let c = (1 - Math.abs(2 * hsla.l - 1)) * hsla.s; + let x = c * (1 - Math.abs(((hsla.h / 60) % 2) - 1)); + let m = hsla.l - c / 2; + let r = 0; + let g = 0; + let b = 0; + if (0 <= hsla.h && hsla.h < 60) { + r = c; + g = x; + b = 0; + } + else if (60 <= hsla.h && hsla.h < 120) { + r = x; + g = c; + b = 0; + } + else if (120 <= hsla.h && hsla.h < 180) { + r = 0; + g = c; + b = x; + } + else if (180 <= hsla.h && hsla.h < 240) { + r = 0; + g = x; + b = c; + } + else if (240 <= hsla.h && hsla.h < 300) { + r = x; + g = 0; + b = c; + } + else if (300 <= hsla.h && hsla.h < 360) { + r = c; + g = 0; + b = x; + } + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + return { a: hsla.a, r, g, b }; + } + /** + * HSLA to RGBA. + * + * https://css-tricks.com/converting-color-spaces-in-javascript/ + */ + function rgbaToHSLA(rgba) { + // Make r, g, and b fractions of 1 + const r = rgba.r / 255; + const g = rgba.g / 255; + const b = rgba.b / 255; + // Find greatest and smallest channel values + let cMin = Math.min(r, g, b); + let cMax = Math.max(r, g, b); + let delta = cMax - cMin; + let h = 0; + let s = 0; + let l = 0; + // Calculate hue + // No difference + if (delta == 0) + h = 0; + // Red is max + else if (cMax == r) + h = ((g - b) / delta) % 6; + // Green is max + else if (cMax == g) + h = (b - r) / delta + 2; + // Blue is max + else + h = (r - g) / delta + 4; + h = Math.round(h * 60); + // Make negative hues positive behind 360° + if (h < 0) + h += 360; + l = (cMax + cMin) / 2; + // Calculate saturation + s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + // Multiply l and s by 100 + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + return { a: rgba.a, h, s, l }; + } + + /** Reference of a cell (eg. A1, $B$5) */ + const cellReference = new RegExp(/\$?([A-Z]{1,3})\$?([0-9]{1,7})/, "i"); + // Same as above, but matches the exact string (nothing before or after) + const singleCellReference = new RegExp(/^\$?([A-Z]{1,3})\$?([0-9]{1,7})$/, "i"); + /** Reference of a column header (eg. A, AB) */ + const colHeader = new RegExp(/^([A-Z]{1,3})+$/, "i"); + /** Reference of a column (eg. A, $CA, Sheet1!B) */ + const colReference = new RegExp(/^\s*('.+'!|[^']+!)?\$?([A-Z]{1,3})$/, "i"); + /** Reference of a row (eg. 1, 59, Sheet1!9) */ + const rowReference = new RegExp(/^\s*('.+'!|[^']+!)?\$?([0-9]{1,7})$/, "i"); + /** Reference of a normal range or a full row range (eg. A1:B1, 1:$5, $A2:5) */ + const fullRowXc = /(\$?[A-Z]{1,3})?\$?[0-9]{1,7}\s*:\s*(\$?[A-Z]{1,3})?\$?[0-9]{1,7}\s*/i; + /** Reference of a normal range or a column row range (eg. A1:B1, A:$B, $A1:C) */ + const fullColXc = /\$?[A-Z]{1,3}(\$?[0-9]{1,7})?\s*:\s*\$?[A-Z]{1,3}(\$?[0-9]{1,7})?\s*/i; + /** Reference of a cell or a range, it can be a bounded range, a full row or a full column */ + const rangeReference = new RegExp(/^\s*('.+'!|[^']+!)?/.source + + "(" + + [cellReference.source, fullRowXc.source, fullColXc.source].join("|") + + ")" + + /$/.source, "i"); + /** + * Return true if the given xc is the reference of a column (e.g. A or AC or Sheet1!A) + */ + function isColReference(xc) { + return colReference.test(xc); + } + /** + * Return true if the given xc is the reference of a column (e.g. 1 or Sheet1!1) + */ + function isRowReference(xc) { + return rowReference.test(xc); + } + function isColHeader(str) { + return colHeader.test(str); + } + /** + * Return true if the given xc is the reference of a single cell, + * without any specified sheet (e.g. A1) + */ + function isSingleCellReference(xc) { + return singleCellReference.test(xc); + } + + //------------------------------------------------------------------------------ + /** + * Convert a (col) number to the corresponding letter. + * + * Examples: + * 0 => 'A' + * 25 => 'Z' + * 26 => 'AA' + * 27 => 'AB' + */ + function numberToLetters(n) { + if (n < 0) { + throw new Error(`number must be positive. Got ${n}`); + } + if (n < 26) { + return String.fromCharCode(65 + n); + } + else { + return numberToLetters(Math.floor(n / 26) - 1) + numberToLetters(n % 26); + } + } + /** + * Convert a string (describing a column) to its number value. + * + * Examples: + * 'A' => 0 + * 'Z' => 25 + * 'AA' => 26 + */ + function lettersToNumber(letters) { + let result = 0; + const l = letters.length; + for (let i = 0; i < l; i++) { + let n = letters.charCodeAt(i) - 65 + (i < l - 1 ? 1 : 0); + result += n * 26 ** (l - i - 1); + } + return result; + } + /** + * Convert a "XC" coordinate to cartesian coordinates. + * + * Examples: + * A1 => [0,0] + * B3 => [1,2] + * + * Note: it also accepts lowercase coordinates, but not fixed references + */ + function toCartesian(xc) { + xc = xc.toUpperCase().trim(); + const match = xc.match(cellReference); + if (match !== null) { + const [m, letters, numbers] = match; + if (m === xc) { + const col = lettersToNumber(letters); + const row = parseInt(numbers, 10) - 1; + return { col, row }; + } + } + throw new Error(`Invalid cell description: ${xc}`); + } + /** + * Convert from cartesian coordinate to the "XC" coordinate system. + * + * Examples: + * - 0,0 => A1 + * - 1,2 => B3 + * - 0,0, {colFixed: false, rowFixed: true} => A$1 + * - 1,2, {colFixed: true, rowFixed: false} => $B3 + */ + function toXC(col, row, rangePart = { colFixed: false, rowFixed: false }) { + return ((rangePart.colFixed ? "$" : "") + + numberToLetters(col) + + (rangePart.rowFixed ? "$" : "") + + String(row + 1)); + } + + const MAX_DELAY = 140; + const MIN_DELAY = 20; + const ACCELERATION = 0.035; + /** + * Decreasing exponential function used to determine the "speed" of edge-scrolling + * as the timeout delay. + * + * Returns a timeout delay in milliseconds. + */ + function scrollDelay(value) { + // decreasing exponential from MAX_DELAY to MIN_DELAY + return MIN_DELAY + (MAX_DELAY - MIN_DELAY) * Math.exp(-ACCELERATION * (value - 1)); + } + + /** + * Constant used to indicate the maximum of digits that is possible to display + * in a cell with standard size. + */ + const MAX_DECIMAL_PLACES = 20; + //from https://stackoverflow.com/questions/721304/insert-commas-into-number-string @Thomas/Alan Moore + const thousandsGroupsRegexp = /(\d+?)(?=(\d{3})+(?!\d)|$)/g; + const zeroRegexp = /0/g; + // ----------------------------------------------------------------------------- + // FORMAT REPRESENTATION CACHE + // ----------------------------------------------------------------------------- + const internalFormatByFormatString = {}; + function parseFormat(formatString) { + let internalFormat = internalFormatByFormatString[formatString]; + if (internalFormat === undefined) { + internalFormat = convertFormatToInternalFormat(formatString); + internalFormatByFormatString[formatString] = internalFormat; + } + return internalFormat; + } + // ----------------------------------------------------------------------------- + // APPLY FORMAT + // ----------------------------------------------------------------------------- + /** + * Formats a cell value with its format. + */ + function formatValue(value, format) { + switch (typeof value) { + case "string": + return value; + case "boolean": + return value ? "TRUE" : "FALSE"; + case "number": + // transform to internalNumberFormat + if (!format) { + format = createDefaultFormat(value); + } + const internalFormat = parseFormat(format); + return applyInternalFormat(value, internalFormat); + case "object": + return "0"; + } + } + function applyInternalFormat(value, internalFormat) { + if (internalFormat[0].type === "DATE") { + return applyDateTimeFormat(value, internalFormat[0].format); + } + let formattedValue = value < 0 ? "-" : ""; + for (let part of internalFormat) { + switch (part.type) { + case "NUMBER": + formattedValue += applyInternalNumberFormat(Math.abs(value), part.format); + break; + case "STRING": + formattedValue += part.format; + break; + } + } + return formattedValue; + } + function applyInternalNumberFormat(value, format) { + if (format.isPercent) { + value = value * 100; + } + value = value / format.magnitude; + let maxDecimals = 0; + if (format.decimalPart !== undefined) { + maxDecimals = format.decimalPart.length; + } + const { integerDigits, decimalDigits } = splitNumber(value, maxDecimals); + let formattedValue = applyIntegerFormat(integerDigits, format.integerPart, format.thousandsSeparator); + if (format.decimalPart !== undefined) { + formattedValue += "." + applyDecimalFormat(decimalDigits || "", format.decimalPart); + } + if (format.isPercent) { + formattedValue += "%"; + } + return formattedValue; + } + function applyIntegerFormat(integerDigits, integerFormat, hasSeparator) { + var _a; + const _integerDigits = integerDigits === "0" ? "" : integerDigits; + let formattedInteger = _integerDigits; + const delta = integerFormat.length - _integerDigits.length; + if (delta > 0) { + // ex: format = "0#000000" and integerDigit: "123" + const restIntegerFormat = integerFormat.substring(0, delta); // restIntegerFormat = "0#00" + const countZero = (restIntegerFormat.match(zeroRegexp) || []).length; // countZero = 3 + formattedInteger = "0".repeat(countZero) + formattedInteger; // return "000123" + } + if (hasSeparator) { + formattedInteger = ((_a = formattedInteger.match(thousandsGroupsRegexp)) === null || _a === void 0 ? void 0 : _a.join(",")) || formattedInteger; + } + return formattedInteger; + } + function applyDecimalFormat(decimalDigits, decimalFormat) { + // assume the format is valid (no commas) + let formattedDecimals = decimalDigits; + if (decimalFormat.length - decimalDigits.length > 0) { + const restDecimalFormat = decimalFormat.substring(decimalDigits.length, decimalFormat.length + 1); + const countZero = (restDecimalFormat.match(zeroRegexp) || []).length; + formattedDecimals = formattedDecimals + "0".repeat(countZero); + } + return formattedDecimals; + } + /** + * this is a cache that can contains number representation formats + * from 0 (minimum) to 20 (maximum) digits after the decimal point + */ + const numberRepresentation = []; + /** split a number into two strings that contain respectively: + * - all digit stored in the integer part of the number + * - all digit stored in the decimal part of the number + * + * The 'maxDecimal' parameter allows to indicate the number of digits to not + * exceed in the decimal part, in which case digits are rounded + * + * Intl.Numberformat is used to properly handle all the roundings. + * e.g. 1234.7 with format ### (<> maxDecimals=0) should become 1235, not 1234 + **/ + function splitNumber(value, maxDecimals = MAX_DECIMAL_PLACES) { + let formatter = numberRepresentation[maxDecimals]; + if (!formatter) { + formatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: maxDecimals, + useGrouping: false, + }); + numberRepresentation[maxDecimals] = formatter; + } + const [integerDigits, decimalDigits] = formatter.format(value).split("."); + return { integerDigits, decimalDigits }; + } + /** + * Check if the given format is a time, date or date time format. + */ + function isDateTimeFormat(format) { + try { + applyDateTimeFormat(1, format); + return true; + } + catch (error) { + return false; + } + } + function applyDateTimeFormat(value, format) { + // TODO: unify the format functions for date and datetime + // This requires some code to 'parse' or 'tokenize' the format, keep it in a + // cache, and use it in a single mapping, that recognizes the special list + // of tokens dd,d,m,y,h, ... and preserves the rest + const jsDate = numberToJsDate(value); + const indexH = format.indexOf("h"); + let strDate = ""; + let strTime = ""; + if (indexH > 0) { + strDate = formatJSDate(jsDate, format.substring(0, indexH - 1)); + strTime = formatJSTime(jsDate, format.substring(indexH)); + } + else if (indexH === 0) { + strTime = formatJSTime(jsDate, format); + } + else if (indexH < 0) { + strDate = formatJSDate(jsDate, format); + } + return strDate + (strDate && strTime ? " " : "") + strTime; + } + function formatJSDate(jsDate, format) { + const sep = format.match(/\/|-|\s/)[0]; + const parts = format.split(sep); + return parts + .map((p) => { + switch (p) { + case "d": + return jsDate.getDate(); + case "dd": + return jsDate.getDate().toString().padStart(2, "0"); + case "m": + return jsDate.getMonth() + 1; + case "mm": + return String(jsDate.getMonth() + 1).padStart(2, "0"); + case "yyyy": + return jsDate.getFullYear(); + default: + throw new Error(`invalid format: ${format}`); + } + }) + .join(sep); + } + function formatJSTime(jsDate, format) { + let parts = format.split(/:|\s/); + const dateHours = jsDate.getHours(); + const isMeridian = parts[parts.length - 1] === "a"; + let hours = dateHours; + let meridian = ""; + if (isMeridian) { + hours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + meridian = dateHours >= 12 ? " PM" : " AM"; + parts.pop(); + } + return (parts + .map((p) => { + switch (p) { + case "hhhh": + const helapsedHours = Math.floor((jsDate.getTime() - INITIAL_1900_DAY) / (60 * 60 * 1000)); + return helapsedHours.toString(); + case "hh": + return hours.toString().padStart(2, "0"); + case "mm": + return jsDate.getMinutes().toString().padStart(2, "0"); + case "ss": + return jsDate.getSeconds().toString().padStart(2, "0"); + default: + throw new Error(`invalid format: ${format}`); + } + }) + .join(":") + meridian); + } + // ----------------------------------------------------------------------------- + // CREATE / MODIFY FORMAT + // ----------------------------------------------------------------------------- + function createDefaultFormat(value) { + let { decimalDigits } = splitNumber(value, 10); + return decimalDigits ? "0." + "0".repeat(decimalDigits.length) : "0"; + } + function createLargeNumberFormat(format, magnitude, postFix) { + const internalFormat = parseFormat(format || "#,##0"); + const largeNumberFormat = internalFormat + .map((formatPart) => { + if (formatPart.type === "NUMBER") { + return [ + { + ...formatPart, + format: { + ...formatPart.format, + magnitude, + decimalPart: undefined, + }, + }, + { + type: "STRING", + format: postFix, + }, + ]; + } + return formatPart; + }) + .flat(); + return convertInternalFormatToFormat(largeNumberFormat); + } + function changeDecimalPlaces(format, step) { + const internalFormat = parseFormat(format); + const newInternalFormat = internalFormat.map((intFmt) => { + if (intFmt.type === "NUMBER") { + return { ...intFmt, format: changeInternalNumberFormatDecimalPlaces(intFmt.format, step) }; + } + else { + return intFmt; + } + }); + const newFormat = convertInternalFormatToFormat(newInternalFormat); + internalFormatByFormatString[newFormat] = newInternalFormat; + return newFormat; + } + function changeInternalNumberFormatDecimalPlaces(format, step) { + var _a; + const _format = { ...format }; + const sign = Math.sign(step); + const decimalLength = ((_a = _format.decimalPart) === null || _a === void 0 ? void 0 : _a.length) || 0; + const countZero = Math.min(Math.max(0, decimalLength + sign), MAX_DECIMAL_PLACES); + _format.decimalPart = "0".repeat(countZero); + if (_format.decimalPart === "") { + delete _format.decimalPart; + } + return _format; + } + // ----------------------------------------------------------------------------- + // MANAGING FORMAT + // ----------------------------------------------------------------------------- + /** + * Validates the provided format string and returns an InternalFormat Object. + */ + function convertFormatToInternalFormat(format) { + if (format === "") { + throw new Error("A format cannot be empty"); + } + let currentIndex = 0; + let result = []; + while (currentIndex < format.length) { + let closingIndex; + if (format.charAt(currentIndex) === "[") { + if (format.charAt(currentIndex + 1) !== "$") { + throw new Error(`Currency formats have to be prefixed by a $: ${format}`); + } + // manage brackets/customStrings + closingIndex = format.substring(currentIndex + 1).indexOf("]") + currentIndex + 2; + if (closingIndex === 0) { + throw new Error(`Invalid currency brackets format: ${format}`); + } + const str = format.substring(currentIndex + 2, closingIndex - 1); + if (str.includes("[")) { + throw new Error(`Invalid currency format: ${format}`); + } + result.push({ + type: "STRING", + format: str, + }); // remove leading "[$"" and ending "]". + } + else { + // rest of the time + const nextPartIndex = format.substring(currentIndex).indexOf("["); + closingIndex = nextPartIndex > -1 ? nextPartIndex + currentIndex : format.length; + const subFormat = format.substring(currentIndex, closingIndex); + if (subFormat.match(DATETIME_FORMAT)) { + result.push({ type: "DATE", format: subFormat }); + } + else { + result.push({ + type: "NUMBER", + format: convertToInternalNumberFormat(subFormat), + }); + } + } + currentIndex = closingIndex; + } + return result; + } + const magnitudeRegex = /,*?$/; + /** + * @param format a formatString that is only applicable to numbers. I.e. composed of characters 0 # , . % + */ + function convertToInternalNumberFormat(format) { + var _a; + format = format.trim(); + if (containsInvalidNumberChars(format)) { + throw new Error(`Invalid number format: ${format}`); + } + const isPercent = format.includes("%"); + const magnitudeCommas = ((_a = format.match(magnitudeRegex)) === null || _a === void 0 ? void 0 : _a[0]) || ""; + const magnitude = !magnitudeCommas ? 1 : 1000 ** magnitudeCommas.length; + let _format = format.slice(0, format.length - (magnitudeCommas.length || 0)); + const thousandsSeparator = _format.includes(","); + if (_format.match(/\..*,/)) { + throw new Error("A format can't contain ',' symbol in the decimal part"); + } + _format = _format.replace("%", "").replace(",", ""); + const extraSigns = _format.match(/[\%|,]/); + if (extraSigns) { + throw new Error(`A format can only contain a single '${extraSigns[0]}' symbol`); + } + const [integerPart, decimalPart] = _format.split("."); + if (decimalPart && decimalPart.length > 20) { + throw new Error("A format can't contain more than 20 decimal places"); + } + if (decimalPart !== undefined) { + return { + integerPart, + isPercent, + thousandsSeparator, + decimalPart, + magnitude, + }; + } + else { + return { + integerPart, + isPercent, + thousandsSeparator, + magnitude, + }; + } + } + const validNumberChars = /[,#0.%]/g; + function containsInvalidNumberChars(format) { + return Boolean(format.replace(validNumberChars, "")); + } + function convertInternalFormatToFormat(internalFormat) { + let format = ""; + for (let part of internalFormat) { + let currentFormat; + switch (part.type) { + case "NUMBER": + const fmt = part.format; + currentFormat = fmt.integerPart; + if (fmt.thousandsSeparator) { + currentFormat = currentFormat.slice(0, -3) + "," + currentFormat.slice(-3); + } + if (fmt.decimalPart !== undefined) { + currentFormat += "." + fmt.decimalPart; + } + if (fmt.isPercent) { + currentFormat += "%"; + } + if (fmt.magnitude) { + currentFormat += ",".repeat(Math.log10(fmt.magnitude) / 3); + } + break; + case "STRING": + currentFormat = `[$${part.format}]`; + break; + case "DATE": + currentFormat = part.format; + break; + } + format += currentFormat; + } + return format; + } + + /** + * This regexp is supposed to be as close as possible as the numberRegexp, but + * its purpose is to be used by the tokenizer. + * + * - it tolerates extra characters at the end. This is useful because the tokenizer + * only needs to find the number at the start of a string + * - it does not accept "," as thousand separator, because when we tokenize a + * formula, commas are used to separate arguments + * - it does not support % symbol, in formulas % is an operator + */ + const formulaNumberRegexp = /^-?\d+(\.?\d*(e\d+)?)?|^-?\.\d+/; + const pIntegerAndDecimals = "(\\d+(,\\d{3,})*(\\.\\d*)?)"; // pattern that match integer number with or without decimal digits + const pOnlyDecimals = "(\\.\\d+)"; // pattern that match only expression with decimal digits + const pScientificFormat = "(e(\\+|-)?\\d+)?"; // pattern that match scientific format between zero and one time (should be placed before pPercentFormat) + const pPercentFormat = "(\\s*%)?"; // pattern that match percent symbol between zero and one time + const pNumber = "(\\s*" + pIntegerAndDecimals + "|" + pOnlyDecimals + ")" + pScientificFormat + pPercentFormat; + const pMinus = "(\\s*-)?"; // pattern that match negative symbol between zero and one time + const pCurrencyFormat = "(\\s*[\\$€])?"; + const p1 = pMinus + pCurrencyFormat + pNumber; + const p2 = pMinus + pNumber + pCurrencyFormat; + const p3 = pCurrencyFormat + pMinus + pNumber; + const pNumberExp = "^((" + [p1, p2, p3].join(")|(") + "))$"; + const numberRegexp = new RegExp(pNumberExp, "i"); + /** + * Return true if the argument is a "number string". + * + * Note that "" (empty string) does not count as a number string + */ + function isNumber(value) { + if (!value) + return false; + // TO DO: add regexp for DATE string format (ex match: "28 02 2020") + return numberRegexp.test(value.trim()); + } + const invaluableSymbolsRegexp = /[,\$€]+/g; + /** + * Convert a string into a number. It assumes that the string actually represents + * a number (as determined by the isNumber function) + * + * Note that it accepts "" (empty string), even though it does not count as a + * number from the point of view of the isNumber function. + */ + function parseNumber(str) { + // remove invaluable characters + str = str.replace(invaluableSymbolsRegexp, ""); + let n = Number(str); + if (isNaN(n) && str.includes("%")) { + n = Number(str.split("%")[0]); + if (!isNaN(n)) { + return n / 100; + } + } + return n; + } + function percentile(values, percent, isInclusive) { + const sortedValues = [...values].sort((a, b) => a - b); + let percentIndex = (sortedValues.length + (isInclusive ? -1 : 1)) * percent; + if (!isInclusive) { + percentIndex--; + } + if (Number.isInteger(percentIndex)) { + return sortedValues[percentIndex]; + } + const indexSup = Math.ceil(percentIndex); + const indexLow = Math.floor(percentIndex); + return (sortedValues[indexSup] * (percentIndex - indexLow) + + sortedValues[indexLow] * (indexSup - percentIndex)); + } + + class RangeImpl { + constructor(args, getSheetSize) { + this.getSheetSize = getSheetSize; + this.prefixSheet = false; + this._zone = args.zone; + this.parts = args.parts; + this.prefixSheet = args.prefixSheet; + this.invalidXc = args.invalidXc; + this.sheetId = args.sheetId; + this.invalidSheetName = args.invalidSheetName; + } + static fromRange(range, getters) { + if (range instanceof RangeImpl) { + return range; + } + return new RangeImpl(range, getters.getSheetSize); + } + get unboundedZone() { + return this._zone; + } + get zone() { + const { left, top, bottom, right } = this._zone; + if (right !== undefined && bottom !== undefined) + return { left, top, right, bottom }; + else if (bottom === undefined && right !== undefined) { + return { right, top, left, bottom: this.getSheetSize(this.sheetId).height - 1 }; + } + else if (right === undefined && bottom !== undefined) { + return { bottom, left, top, right: this.getSheetSize(this.sheetId).width - 1 }; + } + throw new Error(_lt("Bad zone format")); + } + static getRangeParts(xc, zone) { + const parts = xc.split(":").map((p) => { + const isFullRow = isRowReference(p); + return { + colFixed: isFullRow ? false : p.startsWith("$"), + rowFixed: isFullRow ? p.startsWith("$") : p.includes("$", 1), + }; + }); + const isFullCol = zone.bottom === undefined; + const isFullRow = zone.right === undefined; + if (isFullCol) { + parts[0].rowFixed = parts[0].rowFixed || parts[1].rowFixed; + parts[1].rowFixed = parts[0].rowFixed || parts[1].rowFixed; + if (zone.left === zone.right) { + parts[0].colFixed = parts[0].colFixed || parts[1].colFixed; + parts[1].colFixed = parts[0].colFixed || parts[1].colFixed; + } + } + if (isFullRow) { + parts[0].colFixed = parts[0].colFixed || parts[1].colFixed; + parts[1].colFixed = parts[0].colFixed || parts[1].colFixed; + if (zone.top === zone.bottom) { + parts[0].rowFixed = parts[0].rowFixed || parts[1].rowFixed; + parts[1].rowFixed = parts[0].rowFixed || parts[1].rowFixed; + } + } + return parts; + } + get isFullCol() { + return this._zone.bottom === undefined; + } + get isFullRow() { + return this._zone.right === undefined; + } + get rangeData() { + return { + _zone: this._zone, + _sheetId: this.sheetId, + }; + } + /** + * Check that a zone is valid regarding the order of top-bottom and left-right. + * Left should be smaller than right, top should be smaller than bottom. + * If it's not the case, simply invert them, and invert the linked parts + * (in place!) + */ + orderZone() { + if (this._zone.right !== undefined && this._zone.right < this._zone.left) { + let right = this._zone.right; + this._zone.right = this._zone.left; + this._zone.left = right; + let rightFixed = this.parts[1].colFixed; + this.parts[1].colFixed = this.parts[0].colFixed; + this.parts[0].colFixed = rightFixed; + } + if (this._zone.bottom !== undefined && this._zone.bottom < this._zone.top) { + let bottom = this._zone.bottom; + this._zone.bottom = this._zone.top; + this._zone.top = bottom; + let bottomFixed = this.parts[1].rowFixed; + this.parts[1].rowFixed = this.parts[0].rowFixed; + this.parts[0].rowFixed = bottomFixed; + } + } + /** + * + * @param rangeParams optional, values to put in the cloned range instead of the current values of the range + */ + clone(rangeParams) { + return new RangeImpl({ + zone: (rangeParams === null || rangeParams === void 0 ? void 0 : rangeParams.zone) ? rangeParams.zone : { ...this._zone }, + sheetId: (rangeParams === null || rangeParams === void 0 ? void 0 : rangeParams.sheetId) ? rangeParams.sheetId : this.sheetId, + invalidSheetName: rangeParams && "invalidSheetName" in rangeParams // 'attr in obj' instead of just 'obj.attr' because we accept undefined values + ? rangeParams.invalidSheetName + : this.invalidSheetName, + invalidXc: rangeParams && "invalidXc" in rangeParams ? rangeParams.invalidXc : this.invalidXc, + parts: (rangeParams === null || rangeParams === void 0 ? void 0 : rangeParams.parts) + ? rangeParams.parts + : this.parts.map((part) => { + return { rowFixed: part.rowFixed, colFixed: part.colFixed }; + }), + prefixSheet: (rangeParams === null || rangeParams === void 0 ? void 0 : rangeParams.prefixSheet) ? rangeParams.prefixSheet : this.prefixSheet, + }, this.getSheetSize); + } + } + /** + * Copy a range. If the range is on the sheetIdFrom, the range will target + * sheetIdTo. + */ + function copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, range) { + const sheetId = range.sheetId === sheetIdFrom ? sheetIdTo : range.sheetId; + return range.clone({ sheetId }); + } + /** + * Create a range from a xc. If the xc is empty, this function returns undefined. + */ + function createRange(getters, sheetId, range) { + return range ? getters.getRangeFromSheetXC(sheetId, range) : undefined; + } + + function createDefaultRows(rowNumber) { + const rows = []; + for (let i = 0; i < rowNumber; i++) { + const row = { + cells: {}, + }; + rows.push(row); + } + return rows; + } + + /* + * https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript + * */ + class UuidGenerator { + constructor() { + this.isFastIdStrategy = false; + this.fastIdStart = 0; + } + setIsFastStrategy(isFast) { + this.isFastIdStrategy = isFast; + } + uuidv4() { + if (this.isFastIdStrategy) { + this.fastIdStart++; + return String(this.fastIdStart); + //@ts-ignore + } + else if (window.crypto && window.crypto.getRandomValues) { + //@ts-ignore + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)); + } + else { + // mainly for jest and other browsers that do not have the crypto functionality + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + var r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + } + } + + /** + * Convert from a cartesian reference to a Zone + * The range boundaries will be kept in the same order as the + * ones in the text. + * Examples: + * "A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 + * "B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 + * "Sheet1!A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 + * "Sheet1!B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 + * "C3:A1" ==> Top 2, Bottom 0, Left 2, Right 0 + * "A:A" ==> Top 0, Bottom undefined, Left 0, Right 0 + * "A:B3" or "B3:A" ==> Top 2, Bottom undefined, Left 0, Right 1 + * + * @param xc the string reference to convert + * + */ + function toZoneWithoutBoundaryChanges(xc) { + xc = xc.split("!").pop(); + const ranges = xc + .replace(/\$/g, "") + .split(":") + .map((x) => x.trim()); + let top, bottom, left, right; + let fullCol = false; + let fullRow = false; + let hasHeader = false; + const firstRangePart = ranges[0]; + const secondRangePart = ranges[1] && ranges[1]; + if (isColReference(firstRangePart)) { + left = right = lettersToNumber(firstRangePart); + top = bottom = 0; + fullCol = true; + } + else if (isRowReference(firstRangePart)) { + top = bottom = parseInt(firstRangePart, 10) - 1; + left = right = 0; + fullRow = true; + } + else { + const c = toCartesian(firstRangePart); + left = right = c.col; + top = bottom = c.row; + hasHeader = true; + } + if (ranges.length === 2) { + if (isColReference(secondRangePart)) { + right = lettersToNumber(secondRangePart); + fullCol = true; + } + else if (isRowReference(secondRangePart)) { + bottom = parseInt(secondRangePart, 10) - 1; + fullRow = true; + } + else { + const c = toCartesian(secondRangePart); + right = c.col; + bottom = c.row; + top = fullCol ? bottom : top; + left = fullRow ? right : left; + hasHeader = true; + } + } + if (fullCol && fullRow) { + throw new Error("Wrong zone xc. The zone cannot be at the same time a full column and a full row"); + } + const zone = { + top, + left, + bottom: fullCol ? undefined : bottom, + right: fullRow ? undefined : right, + }; + hasHeader = hasHeader && (fullRow || fullCol); + if (hasHeader) { + zone.hasHeader = hasHeader; + } + return zone; + } + /** + * Convert from a cartesian reference to a (possibly unbounded) Zone + * + * Examples: + * "A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 + * "B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 + * "B:B" ==> Top 0, Bottom undefined, Left: 1, Right: 1 + * "B2:B" ==> Top 1, Bottom undefined, Left: 1, Right: 1, hasHeader: 1 + * "Sheet1!A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 + * "Sheet1!B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 + * + * @param xc the string reference to convert + * + */ + function toUnboundedZone(xc) { + const zone = toZoneWithoutBoundaryChanges(xc); + if (zone.right !== undefined && zone.right < zone.left) { + const tmp = zone.left; + zone.left = zone.right; + zone.right = tmp; + } + if (zone.bottom !== undefined && zone.bottom < zone.top) { + const tmp = zone.top; + zone.top = zone.bottom; + zone.bottom = tmp; + } + return zone; + } + /** + * Convert from a cartesian reference to a Zone. + * Will return throw an error if given a unbounded zone (eg : A:A). + * + * Examples: + * "A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 + * "B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 + * "Sheet1!A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 + * "Sheet1!B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 + * + * @param xc the string reference to convert + * + */ + function toZone(xc) { + const zone = toUnboundedZone(xc); + if (zone.bottom === undefined || zone.right === undefined) { + throw new Error("This does not support unbounded ranges"); + } + return zone; + } + /** + * Check that the zone has valid coordinates and in + * the correct order. + */ + function isZoneValid(zone) { + // Typescript *should* prevent this kind of errors but + // it's better to be on the safe side at runtime as well. + const { bottom, top, left, right } = zone; + if ((bottom !== undefined && isNaN(bottom)) || + isNaN(top) || + isNaN(left) || + (right !== undefined && isNaN(right))) { + return false; + } + return ((zone.bottom === undefined || (zone.bottom >= zone.top && zone.bottom >= 0)) && + (zone.right === undefined || (zone.right >= zone.left && zone.right >= 0)) && + zone.top >= 0 && + zone.left >= 0); + } + /** + * Convert from zone to a cartesian reference + * + */ + function zoneToXc(zone) { + const { top, bottom, left, right } = zone; + const hasHeader = "hasHeader" in zone ? zone.hasHeader : false; + const isOneCell = top === bottom && left === right; + if (bottom === undefined && right !== undefined) { + return top === 0 && !hasHeader + ? `${numberToLetters(left)}:${numberToLetters(right)}` + : `${toXC(left, top)}:${numberToLetters(right)}`; + } + else if (right === undefined && bottom !== undefined) { + return left === 0 && !hasHeader + ? `${top + 1}:${bottom + 1}` + : `${toXC(left, top)}:${bottom + 1}`; + } + else if (bottom !== undefined && right !== undefined) { + return isOneCell ? toXC(left, top) : `${toXC(left, top)}:${toXC(right, bottom)}`; + } + throw new Error(_lt("Bad zone format")); + } + /** + * Expand a zone after inserting columns or rows. + */ + function expandZoneOnInsertion(zone, start, base, position, quantity) { + const dimension = start === "left" ? "columns" : "rows"; + const baseElement = position === "before" ? base - 1 : base; + const end = start === "left" ? "right" : "bottom"; + const zoneEnd = zone[end]; + let shouldIncludeEnd = false; + if (zoneEnd) { + shouldIncludeEnd = position === "before" ? zoneEnd > baseElement : zoneEnd >= baseElement; + } + if (zone[start] <= baseElement && shouldIncludeEnd) { + return createAdaptedZone(zone, dimension, "RESIZE", quantity); + } + if (baseElement < zone[start]) { + return createAdaptedZone(zone, dimension, "MOVE", quantity); + } + return { ...zone }; + } + /** + * Update the selection after column/row addition + */ + function updateSelectionOnInsertion(selection, start, base, position, quantity) { + const dimension = start === "left" ? "columns" : "rows"; + const baseElement = position === "before" ? base - 1 : base; + const end = start === "left" ? "right" : "bottom"; + if (selection[start] <= baseElement && selection[end] > baseElement) { + return createAdaptedZone(selection, dimension, "RESIZE", quantity); + } + if (baseElement < selection[start]) { + return createAdaptedZone(selection, dimension, "MOVE", quantity); + } + return { ...selection }; + } + /** + * Update the selection after column/row deletion + */ + function updateSelectionOnDeletion(zone, start, elements) { + const end = start === "left" ? "right" : "bottom"; + let newStart = zone[start]; + let newEnd = zone[end]; + for (let removedElement of elements.sort((a, b) => b - a)) { + if (zone[start] > removedElement) { + newStart--; + newEnd--; + } + if (zone[start] < removedElement && zone[end] >= removedElement) { + newEnd--; + } + } + return { ...zone, [start]: newStart, [end]: newEnd }; + } + /** + * Reduce a zone after deletion of elements + */ + function reduceZoneOnDeletion(zone, start, elements) { + const end = start === "left" ? "right" : "bottom"; + let newStart = zone[start]; + let newEnd = zone[end]; + const zoneEnd = zone[end]; + for (let removedElement of elements.sort((a, b) => b - a)) { + if (zone[start] > removedElement) { + newStart--; + if (newEnd !== undefined) + newEnd--; + } + if (zoneEnd !== undefined && + newEnd !== undefined && + zone[start] <= removedElement && + zoneEnd >= removedElement) { + newEnd--; + } + } + if (newEnd !== undefined && newStart > newEnd) { + return undefined; + } + return { ...zone, [start]: newStart, [end]: newEnd }; + } + /** + * Compute the union of multiple zones. + */ + function union(...zones) { + return { + top: Math.min(...zones.map((zone) => zone.top)), + left: Math.min(...zones.map((zone) => zone.left)), + bottom: Math.max(...zones.map((zone) => zone.bottom)), + right: Math.max(...zones.map((zone) => zone.right)), + }; + } + /** + * Compute the intersection of two zones. Returns nothing if the two zones don't overlap + */ + function intersection(z1, z2) { + if (!overlap(z1, z2)) { + return undefined; + } + return { + top: Math.max(z1.top, z2.top), + left: Math.max(z1.left, z2.left), + bottom: Math.min(z1.bottom, z2.bottom), + right: Math.min(z1.right, z2.right), + }; + } + /** + * Two zones are equal if they represent the same area, so we clearly cannot use + * reference equality. + */ + function isEqual(z1, z2) { + return (z1.left === z2.left && z1.right === z2.right && z1.top === z2.top && z1.bottom === z2.bottom); + } + /** + * Return true if two zones overlap, false otherwise. + */ + function overlap(z1, z2) { + if (z1.bottom < z2.top || z2.bottom < z1.top) { + return false; + } + if (z1.right < z2.left || z2.right < z1.left) { + return false; + } + return true; + } + function isInside(col, row, zone) { + const { left, right, top, bottom } = zone; + return col >= left && col <= right && row >= top && row <= bottom; + } + /** + * Check if a zone is inside another + */ + function isZoneInside(smallZone, biggerZone) { + return isEqual(union(biggerZone, smallZone), biggerZone); + } + /** + * Recompute the ranges of the zone to contain all the cells in zones, without the cells in toRemoveZones + * Also regroup zones together to shorten the string + */ + function recomputeZones(zonesXc, toRemoveZonesXc) { + const zones = zonesXc.map(toUnboundedZone); + const zonesToRemove = toRemoveZonesXc.map(toZone); + // Compute the max to replace the bottom of full columns and right of full rows by something + // bigger than any other col/row to be able to apply the algorithm while keeping tracks of what + // zones are full cols/rows + const maxBottom = Math.max(...zones.concat(zonesToRemove).map((zone) => zone.bottom || 0)); + const maxRight = Math.max(...zones.concat(zonesToRemove).map((zone) => zone.right || 0)); + const expandedZones = zones.map((zone) => ({ + ...zone, + bottom: zone.bottom === undefined ? maxBottom + 1 : zone.bottom, + right: zone.right === undefined ? maxRight + 1 : zone.right, + })); + const expandedZonesToRemove = zonesToRemove.map((zone) => ({ + ...zone, + bottom: zone.bottom === undefined ? maxBottom + 1 : zone.bottom, + right: zone.right === undefined ? maxRight + 1 : zone.right, + })); + const zonePositions = expandedZones.map(positions).flat(); + const positionsToRemove = expandedZonesToRemove.map(positions).flat(); + const positionToKeep = positionsDifference(zonePositions, positionsToRemove); + const columns = mergePositionsIntoColumns(positionToKeep); + return mergeAlignedColumns(columns) + .map((zone) => ({ + ...zone, + bottom: zone.bottom === maxBottom + 1 ? undefined : zone.bottom, + right: zone.right === maxRight + 1 ? undefined : zone.right, + })) + .map(zoneToXc); + } + /** + * Merge aligned adjacent columns into single zones + * e.g. A1:A5 and B1:B5 are merged into A1:B5 + */ + function mergeAlignedColumns(columns) { + if (columns.length === 0) { + return []; + } + if (columns.some((zone) => zone.left !== zone.right)) { + throw new Error("only columns can be merged"); + } + const done = []; + const cols = removeRedundantZones(columns); + const isAdjacentAndAligned = (zone, nextZone) => zone.top === nextZone.top && + zone.bottom === nextZone.bottom && + zone.right + 1 === nextZone.left; + while (cols.length) { + const merged = cols.reduce((zone, nextZone) => (isAdjacentAndAligned(zone, nextZone) ? union(zone, nextZone) : zone), cols.shift()); + done.push(merged); + } + return removeRedundantZones(done); + } + /** + * Remove redundant zones in the list. + * i.e. zones included in another zone. + */ + function removeRedundantZones(zones) { + const sortedZones = [...zones] + .sort((a, b) => b.right - a.right) + .sort((a, b) => b.bottom - a.bottom) + .sort((a, b) => a.top - b.top) + .sort((a, b) => a.left - b.left) + .reverse(); + const checked = []; + while (sortedZones.length !== 0) { + const zone = sortedZones.shift(); + const isIncludedInOther = sortedZones.some((otherZone) => isZoneInside(zone, otherZone)); + if (!isIncludedInOther) { + checked.push(zone); + } + } + return checked.reverse(); + } + /** + * Merge adjacent positions into vertical zones (columns) + */ + function mergePositionsIntoColumns(positions) { + if (positions.length === 0) { + return []; + } + const [startingPosition, ...sortedPositions] = [...positions] + .sort((a, b) => a.row - b.row) + .sort((a, b) => a.col - b.col); + const done = []; + let active = positionToZone(startingPosition); + for (const { col, row } of sortedPositions) { + if (isInside(col, row, active)) { + continue; + } + else if (col === active.left && row === active.bottom + 1) { + const bottom = active.bottom + 1; + active = { ...active, bottom }; + } + else { + done.push(active); + active = positionToZone({ col, row }); + } + } + return [...done, active]; + } + /** + * Returns positions in the first array which are not in the second array. + */ + function positionsDifference(positions, toRemove) { + const forbidden = new Set(toRemove.map(({ col, row }) => `${col}-${row}`)); + return positions.filter(({ col, row }) => !forbidden.has(`${col}-${row}`)); + } + function zoneToDimension(zone) { + return { + height: zone.bottom - zone.top + 1, + width: zone.right - zone.left + 1, + }; + } + function isOneDimensional(zone) { + const { width, height } = zoneToDimension(zone); + return width === 1 || height === 1; + } + /** + * Array of all positions in the zone. + */ + function positions(zone) { + const positions = []; + const [left, right] = [zone.right, zone.left].sort((a, b) => a - b); + const [top, bottom] = [zone.top, zone.bottom].sort((a, b) => a - b); + for (const col of range(left, right + 1)) { + for (const row of range(top, bottom + 1)) { + positions.push({ col, row }); + } + } + return positions; + } + /** + * This function returns a zone with coordinates modified according to the change + * applied to the zone. It may be possible to change the zone by resizing or moving + * it according to different dimensions. + * + * @param zone the zone to modify + * @param dimension the direction to change the zone among "columns", "rows" and + * "both" + * @param operation how to change the zone, modify its size "RESIZE" or modify + * its location "MOVE" + * @param by a number of how many units the change should be made. This parameter + * takes the form of a two-number array when the dimension is "both" + */ + function createAdaptedZone(zone, dimension, operation, by) { + const offsetX = dimension === "both" ? by[0] : dimension === "columns" ? by : 0; + const offsetY = dimension === "both" ? by[1] : dimension === "rows" ? by : 0; + // For full columns/rows, we have to make the distinction between the one that have a header and + // whose start should be moved (eg. A2:A), and those who don't (eg. A:A) + // The only time we don't want to move the start of the zone is if the zone is a full column (or a full row) + // without header and that we are adding/removing a row (or a column) + const hasHeader = "hasHeader" in zone ? zone.hasHeader : false; + let shouldStartBeMoved; + if (isFullCol(zone) && !hasHeader) { + shouldStartBeMoved = dimension !== "rows"; + } + else if (isFullRow(zone) && !hasHeader) { + shouldStartBeMoved = dimension !== "columns"; + } + else { + shouldStartBeMoved = true; + } + const newZone = { ...zone }; + if (shouldStartBeMoved && operation === "MOVE") { + newZone["left"] += offsetX; + newZone["top"] += offsetY; + } + if (newZone["right"] !== undefined) { + newZone["right"] += offsetX; + } + if (newZone["bottom"] !== undefined) { + newZone["bottom"] += offsetY; + } + return newZone; + } + /** + * Returns a Zone array with unique occurrence of each zone. + * For each multiple occurrence, the occurrence with the largest index is kept. + * This allows to always have the last selection made in the last position. + * */ + function uniqueZones(zones) { + return zones + .reverse() + .filter((zone, index, self) => index === + self.findIndex((z) => z.top === zone.top && + z.bottom === zone.bottom && + z.left === zone.left && + z.right === zone.right)) + .reverse(); + } + /** + * This function will find all overlapping zones in an array and transform them + * into an union of each one. + * */ + function mergeOverlappingZones(zones) { + return zones.reduce((dissociatedZones, zone) => { + const nextIndex = dissociatedZones.length; + for (let i = 0; i < nextIndex; i++) { + if (overlap(dissociatedZones[i], zone)) { + dissociatedZones[i] = union(dissociatedZones[i], zone); + return dissociatedZones; + } + } + dissociatedZones[nextIndex] = zone; + return dissociatedZones; + }, []); + } + /** + * This function will compare the modifications of selection to determine + * a cell that is part of the new zone and not the previous one. + */ + function findCellInNewZone(oldZone, currentZone) { + let col, row; + const { left: oldLeft, right: oldRight, top: oldTop, bottom: oldBottom } = oldZone; + const { left, right, top, bottom } = currentZone; + if (left != oldLeft) { + col = left; + } + else if (right != oldRight) { + col = right; + } + else { + col = left; + } + if (top != oldTop) { + row = top; + } + else if (bottom != oldBottom) { + row = bottom; + } + else { + row = top; + } + return { col, row }; + } + function organizeZone(zone) { + return { + top: Math.min(zone.top, zone.bottom), + bottom: Math.max(zone.top, zone.bottom), + left: Math.min(zone.left, zone.right), + right: Math.max(zone.left, zone.right), + }; + } + function positionToZone(position) { + return { left: position.col, right: position.col, top: position.row, bottom: position.row }; + } + function isFullRow(zone) { + return zone.right === undefined; + } + function isFullCol(zone) { + return zone.bottom === undefined; + } + function getZoneArea(zone) { + return (zone.bottom - zone.top + 1) * (zone.right - zone.left + 1); + } + + /** + * An AutofillModifierImplementation is used to describe how to handle a + * AutofillModifier. + */ + const autofillModifiersRegistry = new Registry(); + autofillModifiersRegistry + .add("INCREMENT_MODIFIER", { + apply: (rule, data) => { + var _a; + rule.current += rule.increment; + const content = rule.current.toString(); + const tooltipValue = formatValue(rule.current, (_a = data.cell) === null || _a === void 0 ? void 0 : _a.format); + return { + cellData: { + border: data.border, + style: data.cell && data.cell.style, + format: data.cell && data.cell.format, + content, + }, + tooltip: content ? { props: { content: tooltipValue } } : undefined, + }; + }, + }) + .add("COPY_MODIFIER", { + apply: (rule, data, getters) => { + var _a, _b; + const content = ((_a = data.cell) === null || _a === void 0 ? void 0 : _a.content) || ""; + return { + cellData: { + border: data.border, + style: data.cell && data.cell.style, + format: data.cell && data.cell.format, + content, + }, + tooltip: content ? { props: { content: (_b = data.cell) === null || _b === void 0 ? void 0 : _b.formattedValue } } : undefined, + }; + }, + }) + .add("FORMULA_MODIFIER", { + apply: (rule, data, getters, direction) => { + rule.current += rule.increment; + let x = 0; + let y = 0; + switch (direction) { + case 0 /* UP */: + x = 0; + y = -rule.current; + break; + case 1 /* DOWN */: + x = 0; + y = rule.current; + break; + case 2 /* LEFT */: + x = -rule.current; + y = 0; + break; + case 3 /* RIGHT */: + x = rule.current; + y = 0; + break; + } + if (!data.cell || !data.cell.isFormula()) { + return { cellData: {} }; + } + const sheetId = data.sheetId; + const ranges = getters.createAdaptedRanges(data.cell.dependencies, x, y, sheetId); + const content = getters.buildFormulaContent(sheetId, data.cell, ranges); + return { + cellData: { + border: data.border, + style: data.cell.style, + format: data.cell.format, + content, + }, + tooltip: content ? { props: { content } } : undefined, + }; + }, + }); + + var CellValueType; + (function (CellValueType) { + CellValueType["boolean"] = "boolean"; + CellValueType["number"] = "number"; + CellValueType["text"] = "text"; + CellValueType["empty"] = "empty"; + CellValueType["error"] = "error"; + })(CellValueType || (CellValueType = {})); + + function isSheetDependent(cmd) { + return "sheetId" in cmd; + } + function isGridDependent(cmd) { + return "dimension" in cmd && "sheetId" in cmd; + } + function isTargetDependent(cmd) { + return "target" in cmd && "sheetId" in cmd; + } + function isRangeDependant(cmd) { + return "ranges" in cmd; + } + function isPositionDependent(cmd) { + return "col" in cmd && "row" in cmd && "sheetId" in cmd; + } + const invalidateEvaluationCommands = new Set([ + "RENAME_SHEET", + "DELETE_SHEET", + "CREATE_SHEET", + "ADD_COLUMNS_ROWS", + "REMOVE_COLUMNS_ROWS", + "DELETE_CELL", + "INSERT_CELL", + "UNDO", + "REDO", + ]); + const invalidateCFEvaluationCommands = new Set([ + ...invalidateEvaluationCommands, + "DUPLICATE_SHEET", + "EVALUATE_CELLS", + "ADD_CONDITIONAL_FORMAT", + "REMOVE_CONDITIONAL_FORMAT", + "MOVE_CONDITIONAL_FORMAT", + ]); + const readonlyAllowedCommands = new Set([ + "START", + "ACTIVATE_SHEET", + "COPY", + "PREPARE_SELECTION_INPUT_EXPANSION", + "STOP_SELECTION_INPUT", + "RESIZE_VIEWPORT", + "SET_VIEWPORT_OFFSET", + "SELECT_SEARCH_NEXT_MATCH", + "SELECT_SEARCH_PREVIOUS_MATCH", + "REFRESH_SEARCH", + "UPDATE_SEARCH", + "CLEAR_SEARCH", + "EVALUATE_CELLS", + "SET_CURRENT_CONTENT", + "SET_FORMULA_VISIBILITY", + ]); + const coreTypes = new Set([ + /** CELLS */ + "UPDATE_CELL", + "UPDATE_CELL_POSITION", + "CLEAR_CELL", + "DELETE_CONTENT", + /** GRID SHAPE */ + "ADD_COLUMNS_ROWS", + "REMOVE_COLUMNS_ROWS", + "RESIZE_COLUMNS_ROWS", + "HIDE_COLUMNS_ROWS", + "UNHIDE_COLUMNS_ROWS", + "SET_GRID_LINES_VISIBILITY", + /** MERGE */ + "ADD_MERGE", + "REMOVE_MERGE", + /** SHEETS MANIPULATION */ + "CREATE_SHEET", + "DELETE_SHEET", + "DUPLICATE_SHEET", + "MOVE_SHEET", + "RENAME_SHEET", + "HIDE_SHEET", + "SHOW_SHEET", + /** RANGES MANIPULATION */ + "MOVE_RANGES", + /** CONDITIONAL FORMAT */ + "ADD_CONDITIONAL_FORMAT", + "REMOVE_CONDITIONAL_FORMAT", + "MOVE_CONDITIONAL_FORMAT", + /** FIGURES */ + "CREATE_FIGURE", + "DELETE_FIGURE", + "UPDATE_FIGURE", + /** FORMATTING */ + "SET_FORMATTING", + "CLEAR_FORMATTING", + "SET_BORDER", + "SET_DECIMAL", + /** CHART */ + "CREATE_CHART", + "UPDATE_CHART", + ]); + function isCoreCommand(cmd) { + return coreTypes.has(cmd.type); + } + function canExecuteInReadonly(cmd) { + return readonlyAllowedCommands.has(cmd.type); + } + /** + * Holds the result of a command dispatch. + * The command may have been successfully dispatched or cancelled + * for one or more reasons. + */ + class DispatchResult { + constructor(results = []) { + if (!Array.isArray(results)) { + results = [results]; + } + results = [...new Set(results)]; + this.reasons = results.filter((result) => result !== 0 /* Success */); + } + /** + * Static helper which returns a successful DispatchResult + */ + static get Success() { + return new DispatchResult(); + } + get isSuccessful() { + return this.reasons.length === 0; + } + /** + * Check if the dispatch has been cancelled because of + * the given reason. + */ + isCancelledBecause(reason) { + return this.reasons.includes(reason); + } + } + exports.CommandResult = void 0; + (function (CommandResult) { + CommandResult[CommandResult["Success"] = 0] = "Success"; + CommandResult[CommandResult["CancelledForUnknownReason"] = 1] = "CancelledForUnknownReason"; + CommandResult[CommandResult["WillRemoveExistingMerge"] = 2] = "WillRemoveExistingMerge"; + CommandResult[CommandResult["MergeIsDestructive"] = 3] = "MergeIsDestructive"; + CommandResult[CommandResult["CellIsMerged"] = 4] = "CellIsMerged"; + CommandResult[CommandResult["EmptyUndoStack"] = 5] = "EmptyUndoStack"; + CommandResult[CommandResult["EmptyRedoStack"] = 6] = "EmptyRedoStack"; + CommandResult[CommandResult["NotEnoughElements"] = 7] = "NotEnoughElements"; + CommandResult[CommandResult["NotEnoughSheets"] = 8] = "NotEnoughSheets"; + CommandResult[CommandResult["MissingSheetName"] = 9] = "MissingSheetName"; + CommandResult[CommandResult["DuplicatedSheetName"] = 10] = "DuplicatedSheetName"; + CommandResult[CommandResult["ForbiddenCharactersInSheetName"] = 11] = "ForbiddenCharactersInSheetName"; + CommandResult[CommandResult["WrongSheetMove"] = 12] = "WrongSheetMove"; + CommandResult[CommandResult["WrongSheetPosition"] = 13] = "WrongSheetPosition"; + CommandResult[CommandResult["InvalidAnchorZone"] = 14] = "InvalidAnchorZone"; + CommandResult[CommandResult["SelectionOutOfBound"] = 15] = "SelectionOutOfBound"; + CommandResult[CommandResult["TargetOutOfSheet"] = 16] = "TargetOutOfSheet"; + CommandResult[CommandResult["WrongCutSelection"] = 17] = "WrongCutSelection"; + CommandResult[CommandResult["WrongPasteSelection"] = 18] = "WrongPasteSelection"; + CommandResult[CommandResult["WrongPasteOption"] = 19] = "WrongPasteOption"; + CommandResult[CommandResult["WrongFigurePasteOption"] = 20] = "WrongFigurePasteOption"; + CommandResult[CommandResult["EmptyClipboard"] = 21] = "EmptyClipboard"; + CommandResult[CommandResult["EmptyRange"] = 22] = "EmptyRange"; + CommandResult[CommandResult["InvalidRange"] = 23] = "InvalidRange"; + CommandResult[CommandResult["InvalidZones"] = 24] = "InvalidZones"; + CommandResult[CommandResult["InvalidSheetId"] = 25] = "InvalidSheetId"; + CommandResult[CommandResult["InputAlreadyFocused"] = 26] = "InputAlreadyFocused"; + CommandResult[CommandResult["MaximumRangesReached"] = 27] = "MaximumRangesReached"; + CommandResult[CommandResult["InvalidChartDefinition"] = 28] = "InvalidChartDefinition"; + CommandResult[CommandResult["InvalidDataSet"] = 29] = "InvalidDataSet"; + CommandResult[CommandResult["InvalidLabelRange"] = 30] = "InvalidLabelRange"; + CommandResult[CommandResult["InvalidScorecardKeyValue"] = 31] = "InvalidScorecardKeyValue"; + CommandResult[CommandResult["InvalidScorecardBaseline"] = 32] = "InvalidScorecardBaseline"; + CommandResult[CommandResult["InvalidGaugeDataRange"] = 33] = "InvalidGaugeDataRange"; + CommandResult[CommandResult["EmptyGaugeRangeMin"] = 34] = "EmptyGaugeRangeMin"; + CommandResult[CommandResult["GaugeRangeMinNaN"] = 35] = "GaugeRangeMinNaN"; + CommandResult[CommandResult["EmptyGaugeRangeMax"] = 36] = "EmptyGaugeRangeMax"; + CommandResult[CommandResult["GaugeRangeMaxNaN"] = 37] = "GaugeRangeMaxNaN"; + CommandResult[CommandResult["GaugeRangeMinBiggerThanRangeMax"] = 38] = "GaugeRangeMinBiggerThanRangeMax"; + CommandResult[CommandResult["GaugeLowerInflectionPointNaN"] = 39] = "GaugeLowerInflectionPointNaN"; + CommandResult[CommandResult["GaugeUpperInflectionPointNaN"] = 40] = "GaugeUpperInflectionPointNaN"; + CommandResult[CommandResult["GaugeLowerBiggerThanUpper"] = 41] = "GaugeLowerBiggerThanUpper"; + CommandResult[CommandResult["InvalidAutofillSelection"] = 42] = "InvalidAutofillSelection"; + CommandResult[CommandResult["WrongComposerSelection"] = 43] = "WrongComposerSelection"; + CommandResult[CommandResult["MinBiggerThanMax"] = 44] = "MinBiggerThanMax"; + CommandResult[CommandResult["LowerBiggerThanUpper"] = 45] = "LowerBiggerThanUpper"; + CommandResult[CommandResult["MidBiggerThanMax"] = 46] = "MidBiggerThanMax"; + CommandResult[CommandResult["MinBiggerThanMid"] = 47] = "MinBiggerThanMid"; + CommandResult[CommandResult["FirstArgMissing"] = 48] = "FirstArgMissing"; + CommandResult[CommandResult["SecondArgMissing"] = 49] = "SecondArgMissing"; + CommandResult[CommandResult["MinNaN"] = 50] = "MinNaN"; + CommandResult[CommandResult["MidNaN"] = 51] = "MidNaN"; + CommandResult[CommandResult["MaxNaN"] = 52] = "MaxNaN"; + CommandResult[CommandResult["ValueUpperInflectionNaN"] = 53] = "ValueUpperInflectionNaN"; + CommandResult[CommandResult["ValueLowerInflectionNaN"] = 54] = "ValueLowerInflectionNaN"; + CommandResult[CommandResult["MinInvalidFormula"] = 55] = "MinInvalidFormula"; + CommandResult[CommandResult["MidInvalidFormula"] = 56] = "MidInvalidFormula"; + CommandResult[CommandResult["MaxInvalidFormula"] = 57] = "MaxInvalidFormula"; + CommandResult[CommandResult["ValueUpperInvalidFormula"] = 58] = "ValueUpperInvalidFormula"; + CommandResult[CommandResult["ValueLowerInvalidFormula"] = 59] = "ValueLowerInvalidFormula"; + CommandResult[CommandResult["InvalidSortZone"] = 60] = "InvalidSortZone"; + CommandResult[CommandResult["WaitingSessionConfirmation"] = 61] = "WaitingSessionConfirmation"; + CommandResult[CommandResult["MergeOverlap"] = 62] = "MergeOverlap"; + CommandResult[CommandResult["TooManyHiddenElements"] = 63] = "TooManyHiddenElements"; + CommandResult[CommandResult["Readonly"] = 64] = "Readonly"; + CommandResult[CommandResult["InvalidOffset"] = 65] = "InvalidOffset"; + CommandResult[CommandResult["InvalidViewportSize"] = 66] = "InvalidViewportSize"; + CommandResult[CommandResult["FigureDoesNotExist"] = 67] = "FigureDoesNotExist"; + CommandResult[CommandResult["InvalidConditionalFormatId"] = 68] = "InvalidConditionalFormatId"; + CommandResult[CommandResult["InvalidCellPopover"] = 69] = "InvalidCellPopover"; + CommandResult[CommandResult["EmptyTarget"] = 70] = "EmptyTarget"; + })(exports.CommandResult || (exports.CommandResult = {})); + + var DIRECTION; + (function (DIRECTION) { + DIRECTION[DIRECTION["UP"] = 0] = "UP"; + DIRECTION[DIRECTION["DOWN"] = 1] = "DOWN"; + DIRECTION[DIRECTION["LEFT"] = 2] = "LEFT"; + DIRECTION[DIRECTION["RIGHT"] = 3] = "RIGHT"; + })(DIRECTION || (DIRECTION = {})); + + var LAYERS; + (function (LAYERS) { + LAYERS[LAYERS["Background"] = 0] = "Background"; + LAYERS[LAYERS["Highlights"] = 1] = "Highlights"; + LAYERS[LAYERS["Clipboard"] = 2] = "Clipboard"; + LAYERS[LAYERS["Search"] = 3] = "Search"; + LAYERS[LAYERS["Chart"] = 4] = "Chart"; + LAYERS[LAYERS["Selection"] = 5] = "Selection"; + LAYERS[LAYERS["Autofill"] = 6] = "Autofill"; + LAYERS[LAYERS["Headers"] = 7] = "Headers"; + })(LAYERS || (LAYERS = {})); + + const autofillRulesRegistry = new Registry(); + /** + * Get the consecutive xc that are of type "number" or "date". + * Return the one which contains the given cell + */ + function getGroup(cell, cells) { + let group = []; + let found = false; + for (let x of cells) { + if (x === cell) { + found = true; + } + if ((x === null || x === void 0 ? void 0 : x.evaluated.type) === CellValueType.number) { + group.push(x.evaluated.value); + } + else { + if (found) { + return group; + } + group = []; + } + } + return group; + } + /** + * Get the average steps between numbers + */ + function getAverageIncrement(group) { + const averages = []; + let last = group[0]; + for (let i = 1; i < group.length; i++) { + const current = group[i]; + averages.push(current - last); + last = current; + } + return averages.reduce((a, b) => a + b, 0) / averages.length; + } + autofillRulesRegistry + .add("simple_value_copy", { + condition: (cell, cells) => { + var _a; + return cells.length === 1 && !cell.isFormula() && !((_a = cell.format) === null || _a === void 0 ? void 0 : _a.match(DATETIME_FORMAT)); + }, + generateRule: () => { + return { type: "COPY_MODIFIER" }; + }, + sequence: 10, + }) + .add("copy_text", { + condition: (cell) => !cell.isFormula() && cell.evaluated.type === CellValueType.text, + generateRule: () => { + return { type: "COPY_MODIFIER" }; + }, + sequence: 20, + }) + .add("update_formula", { + condition: (cell) => cell.isFormula(), + generateRule: (_, cells) => { + return { type: "FORMULA_MODIFIER", increment: cells.length, current: 0 }; + }, + sequence: 30, + }) + .add("increment_number", { + condition: (cell) => cell.evaluated.type === CellValueType.number, + generateRule: (cell, cells) => { + const group = getGroup(cell, cells); + let increment = 1; + if (group.length == 2) { + increment = (group[1] - group[0]) * 2; + } + else if (group.length > 2) { + increment = getAverageIncrement(group) * group.length; + } + return { + type: "INCREMENT_MODIFIER", + increment, + current: cell.evaluated.type === CellValueType.number ? cell.evaluated.value : 0, + }; + }, + sequence: 40, + }); + + /** + * This file is largely inspired by owl 1. + * `css` tag has been removed from owl 2 without workaround to manage css. + * So, the solution was to import the behavior of owl 1 directly in our + * codebase, with one difference: the css is added to the sheet as soon as the + * css tag is executed. In owl 1, the css was added as soon as a Component was + * created for the first time. + */ + const STYLESHEETS = {}; + let nextId = 0; + /** + * CSS tag helper for defining inline stylesheets. With this, one can simply define + * an inline stylesheet with just the following code: + * ```js + * css`.component-a { color: red; }`; + * ``` + */ + function css(strings, ...args) { + const name = `__sheet__${nextId++}`; + const value = String.raw(strings, ...args); + registerSheet(name, value); + activateSheet(name); + return name; + } + function processSheet(str) { + const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim()); + const selectorStack = []; + const parts = []; + let rules = []; + function generateSelector(stackIndex, parentSelector) { + const parts = []; + for (const selector of selectorStack[stackIndex]) { + let part = (parentSelector && parentSelector + " " + selector) || selector; + if (part.includes("&")) { + part = selector.replace(/&/g, parentSelector || ""); + } + if (stackIndex < selectorStack.length - 1) { + part = generateSelector(stackIndex + 1, part); + } + parts.push(part); + } + return parts.join(", "); + } + function generateRules() { + if (rules.length) { + parts.push(generateSelector(0) + " {"); + parts.push(...rules); + parts.push("}"); + rules = []; + } + } + while (tokens.length) { + let token = tokens.shift(); + if (token === "}") { + generateRules(); + selectorStack.pop(); + } + else { + if (tokens[0] === "{") { + generateRules(); + selectorStack.push(token.split(/\s*,\s*/)); + tokens.shift(); + } + if (tokens[0] === ";") { + rules.push(" " + token + ";"); + } + } + } + return parts.join("\n"); + } + function registerSheet(id, css) { + const sheet = document.createElement("style"); + sheet.textContent = processSheet(css); + STYLESHEETS[id] = sheet; + } + function activateSheet(id) { + const sheet = STYLESHEETS[id]; + sheet.setAttribute("component", id); + document.head.appendChild(sheet); + } + + const ERROR_TOOLTIP_HEIGHT = 40; + const ERROR_TOOLTIP_WIDTH = 180; + css /* scss */ ` + .o-error-tooltip { + font-size: 13px; + background-color: white; + border-left: 3px solid red; + padding: 10px; + } +`; + class ErrorToolTip extends owl.Component { + } + ErrorToolTip.size = { width: ERROR_TOOLTIP_WIDTH, height: ERROR_TOOLTIP_HEIGHT }; + ErrorToolTip.template = "o-spreadsheet-ErrorToolTip"; + ErrorToolTip.components = {}; + const ErrorToolTipPopoverBuilder = { + onHover: (position, getters) => { + const cell = getters.getCell(getters.getActiveSheetId(), position.col, position.row); + if ((cell === null || cell === void 0 ? void 0 : cell.evaluated.type) === CellValueType.error && + cell.evaluated.error.logLevel > CellErrorLevel.silent) { + return { + isOpen: true, + props: { text: cell.evaluated.error.message }, + Component: ErrorToolTip, + cellCorner: "TopRight", + }; + } + return { isOpen: false }; + }, + }; + + function getMenuChildren(node, env) { + const children = []; + for (const child of node.children) { + if (typeof child === "function") { + children.push(...child(env)); + } + else { + children.push(child); + } + } + return children.sort((a, b) => a.sequence - b.sequence); + } + function getMenuName(node, env) { + if (typeof node.name === "function") { + return node.name(env); + } + return node.name; + } + function getMenuDescription(node) { + return node.description ? node.description : ""; + } + + /** + * Return true if the event was triggered from + * a child element. + */ + function isChildEvent(parent, ev) { + return !!ev.target && parent.contains(ev.target); + } + function getTextDecoration({ strikethrough, underline, }) { + if (!strikethrough && !underline) { + return "none"; + } + return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`; + } + /** + * Convert the cell text style to CSS properties. + */ + function cellTextStyleToCss(style) { + const attributes = {}; + if (!style) + return attributes; + if (style.bold) { + attributes["font-weight"] = "bold"; + } + if (style.italic) { + attributes["font-style"] = "italic"; + } + if (style.strikethrough || style.underline) { + let decoration = style.strikethrough ? "line-through" : ""; + decoration = style.underline ? decoration + " underline" : decoration; + attributes["text-decoration"] = decoration; + } + if (style.textColor) { + attributes["color"] = style.textColor; + } + return attributes; + } + function cssPropertiesToCss(attributes) { + const str = Object.entries(attributes) + .map(([attName, attValue]) => `${attName}: ${attValue};`) + .join("\n"); + return "\n" + str + "\n"; + } + + /** + * Return the o-spreadsheet element position relative + * to the browser viewport. + */ + function useSpreadsheetPosition() { + const position = owl.useState({ x: 0, y: 0 }); + let spreadsheetElement = document.querySelector(".o-spreadsheet"); + updatePosition(); + function updatePosition() { + if (!spreadsheetElement) { + spreadsheetElement = document.querySelector(".o-spreadsheet"); + } + if (spreadsheetElement) { + const { top, left } = spreadsheetElement.getBoundingClientRect(); + position.x = left; + position.y = top; + } + } + owl.onMounted(updatePosition); + owl.onPatched(updatePosition); + return position; + } + /** + * Return the component (or ref's component) top left position (in pixels) relative + * to the upper left corner of the screen ( element). + * + * Note: when used with a component, it will + * return the portal position, not the teleported position. + */ + function useAbsolutePosition(ref) { + const position = owl.useState({ x: 0, y: 0 }); + function updateElPosition() { + const el = ref.el; + if (el === null) { + return; + } + const { top, left } = el.getBoundingClientRect(); + if (left !== position.x || top !== position.y) { + position.x = left; + position.y = top; + } + } + owl.onMounted(updateElPosition); + owl.onPatched(updateElPosition); + return position; + } + + class Popover extends owl.Component { + constructor() { + super(...arguments); + this.spreadsheetPosition = useSpreadsheetPosition(); + } + get style() { + // the props's position is expressed relative to the "body" element + // but we teleport the element in ".o-spreadsheet" to keep everything + // within our control and to avoid leaking into external DOM + const horizontalPosition = `left:${this.horizontalPosition() - this.spreadsheetPosition.x}`; + const verticalPosition = `top:${this.verticalPosition() - this.spreadsheetPosition.y}`; + const height = `max-height:${this.viewportDimension.height - BOTTOMBAR_HEIGHT - SCROLLBAR_WIDTH$1}`; + return ` + position: absolute; + z-index: ${ComponentsImportance.Popover}; + ${verticalPosition}px; + ${horizontalPosition}px; + ${height}px; + width:${this.props.childWidth}px; + overflow-y: auto; + overflow-x: hidden; + box-shadow: 1px 2px 5px 2px rgb(51 51 51 / 15%); + `; + } + get viewportDimension() { + return this.env.model.getters.getViewportDimensionWithHeaders(); + } + get shouldRenderRight() { + const { x } = this.props.position; + return x + this.props.childWidth < this.viewportDimension.width; + } + get shouldRenderBottom() { + const { y } = this.props.position; + return (y + this.props.childHeight < + this.viewportDimension.height + (this.env.isDashboard() ? 0 : TOPBAR_HEIGHT)); + } + horizontalPosition() { + const { x } = this.props.position; + if (this.shouldRenderRight) { + return x; + } + return x - this.props.childWidth - this.props.flipHorizontalOffset; + } + verticalPosition() { + const { y } = this.props.position; + if (this.shouldRenderBottom) { + return y; + } + return Math.max(y - this.props.childHeight + this.props.flipVerticalOffset, this.props.marginTop); + } + } + Popover.template = "o-spreadsheet-Popover"; + Popover.defaultProps = { + flipHorizontalOffset: 0, + flipVerticalOffset: 0, + verticalOffset: 0, + marginTop: 0, + onMouseWheel: () => { }, + }; + + //------------------------------------------------------------------------------ + // Context Menu Component + //------------------------------------------------------------------------------ + css /* scss */ ` + .o-menu { + background-color: white; + padding: 5px 0px; + .o-menu-item { + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + height: ${MENU_ITEM_HEIGHT}px; + padding: 4px 16px; + cursor: pointer; + user-select: none; + + .o-menu-item-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &.o-menu-root { + display: flex; + justify-content: space-between; + } + .o-menu-item-icon { + margin-top: auto; + margin-bottom: auto; + } + .o-icon { + width: 10px; + } + + &:not(.disabled) { + &:hover, + &.o-menu-item-active { + background-color: #ebebeb; + } + .o-menu-item-description { + color: grey; + } + } + &.disabled { + color: ${MENU_ITEM_DISABLED_COLOR}; + cursor: not-allowed; + } + } + + .o-separator { + border-bottom: ${MENU_SEPARATOR_BORDER_WIDTH}px solid #e0e2e4; + margin-top: ${MENU_SEPARATOR_PADDING}px; + margin-bottom: ${MENU_SEPARATOR_PADDING}px; + } + } +`; + class Menu extends owl.Component { + constructor() { + super(...arguments); + this.MENU_WIDTH = MENU_WIDTH; + this.subMenu = owl.useState({ + isOpen: false, + position: null, + scrollOffset: 0, + menuItems: [], + }); + this.menuRef = owl.useRef("menu"); + this.position = useAbsolutePosition(this.menuRef); + } + setup() { + owl.useExternalListener(window, "click", this.onClick); + owl.useExternalListener(window, "contextmenu", this.onContextMenu); + owl.onWillUpdateProps((nextProps) => { + if (nextProps.menuItems !== this.props.menuItems) { + this.closeSubMenu(); + } + }); + } + get subMenuPosition() { + const position = Object.assign({}, this.subMenu.position); + position.y -= this.subMenu.scrollOffset || 0; + return position; + } + get menuHeight() { + return this.menuComponentHeight(this.props.menuItems); + } + get subMenuHeight() { + return this.menuComponentHeight(this.subMenu.menuItems); + } + get popover() { + const isRoot = this.props.depth === 1; + let marginTop = 6; + if (!this.env.isDashboard()) { + marginTop += TOPBAR_HEIGHT + HEADER_HEIGHT; + } + return { + // some margin between the header and the component + marginTop, + flipHorizontalOffset: MENU_WIDTH * (this.props.depth - 1), + flipVerticalOffset: isRoot ? 0 : MENU_ITEM_HEIGHT, + }; + } + getColor(menu) { + return menu.textColor ? `color: ${menu.textColor}` : undefined; + } + async activateMenu(menu) { + var _a, _b; + const result = await menu.action(this.env); + this.close(); + (_b = (_a = this.props).onMenuClicked) === null || _b === void 0 ? void 0 : _b.call(_a, { detail: result }); + } + close() { + this.closeSubMenu(); + this.props.onClose(); + } + /** + * Return the number of pixels between the top of the menu + * and the menu item at a given index. + */ + subMenuVerticalPosition(position) { + const menusAbove = this.props.menuItems.slice(0, position); + return this.menuComponentHeight(menusAbove) + this.position.y; + } + onClick(ev) { + // Don't close a root menu when clicked to open the submenus. + const el = this.menuRef.el; + if (el && isChildEvent(el, ev)) { + return; + } + this.close(); + } + onContextMenu(ev) { + // Don't close a root menu when clicked to open the submenus. + const el = this.menuRef.el; + if (el && isChildEvent(el, ev)) { + return; + } + this.closeSubMenu(); + } + /** + * Return the total height (in pixels) needed for some + * menu items + */ + menuComponentHeight(menuItems) { + const separators = menuItems.filter((m) => m.separator); + const others = menuItems; + return MENU_ITEM_HEIGHT * others.length + separators.length * MENU_SEPARATOR_HEIGHT; + } + getName(menu) { + return getMenuName(menu, this.env); + } + getDescription(menu) { + return getMenuDescription(menu); + } + isRoot(menu) { + return !menu.action; + } + isEnabled(menu) { + if (menu.isEnabled(this.env)) { + return this.env.model.getters.isReadonly() ? menu.isReadonlyAllowed : true; + } + return false; + } + onScroll(ev) { + this.subMenu.scrollOffset = ev.target.scrollTop; + } + /** + * If the given menu is not disabled, open it's submenu at the + * correct position according to available surrounding space. + */ + openSubMenu(menu, position) { + const y = this.subMenuVerticalPosition(position); + this.subMenu.position = { + x: this.position.x + MENU_WIDTH, + y: y - (this.subMenu.scrollOffset || 0), + }; + this.subMenu.menuItems = getMenuChildren(menu, this.env).filter((item) => !item.isVisible || item.isVisible(this.env)); + this.subMenu.isOpen = true; + this.subMenu.parentMenu = menu; + } + isParentMenu(subMenu, menuItem) { + var _a; + return ((_a = subMenu.parentMenu) === null || _a === void 0 ? void 0 : _a.id) === menuItem.id; + } + closeSubMenu() { + this.subMenu.isOpen = false; + this.subMenu.parentMenu = undefined; + } + onClickMenu(menu, position) { + if (this.isEnabled(menu)) { + if (this.isRoot(menu)) { + this.openSubMenu(menu, position); + } + else { + this.activateMenu(menu); + } + } + } + onMouseOver(menu, position) { + if (menu.isEnabled(this.env)) { + if (this.isRoot(menu)) { + this.openSubMenu(menu, position); + } + else { + this.closeSubMenu(); + } + } + } + } + Menu.template = "o-spreadsheet-Menu"; + Menu.components = { Menu, Popover }; + Menu.defaultProps = { + depth: 1, + }; + + const LINK_TOOLTIP_HEIGHT = 43; + const LINK_TOOLTIP_WIDTH = 220; + css /* scss */ ` + .o-link-tool { + font-size: 13px; + background-color: white; + box-shadow: 0 1px 4px 3px rgba(60, 64, 67, 0.15); + padding: 6px 12px; + border-radius: 4px; + display: flex; + justify-content: space-between; + + img { + margin-right: 3px; + width: 16px; + height: 16px; + } + + a.o-link { + color: #01666b; + text-decoration: none; + flex-grow: 2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + a.o-link:hover { + text-decoration: none; + color: #001d1f; + cursor: pointer; + } + } + .o-link-icon { + float: right; + padding-left: 4%; + .o-icon { + height: 16px; + } + } + .o-link-icon .o-icon { + padding-top: 3px; + height: 13px; + } + .o-link-icon:hover { + cursor: pointer; + color: #000; + } +`; + class LinkDisplay extends owl.Component { + get cell() { + const { col, row } = this.props.cellPosition; + const sheetId = this.env.model.getters.getActiveSheetId(); + const cell = this.env.model.getters.getCell(sheetId, col, row); + if (cell === null || cell === void 0 ? void 0 : cell.isLink()) { + return cell; + } + throw new Error(`LinkDisplay Component can only be used with link cells. ${toXC(col, row)} is not a link.`); + } + openLink() { + this.cell.action(this.env); + } + edit() { + const { col, row } = this.props.cellPosition; + this.env.model.dispatch("OPEN_CELL_POPOVER", { + col, + row, + popoverType: "LinkEditor", + }); + } + unlink() { + const sheetId = this.env.model.getters.getActiveSheetId(); + const { col, row } = this.props.cellPosition; + const style = this.cell.style; + const textColor = (style === null || style === void 0 ? void 0 : style.textColor) === LINK_COLOR ? undefined : style === null || style === void 0 ? void 0 : style.textColor; + this.env.model.dispatch("UPDATE_CELL", { + col, + row, + sheetId, + content: this.cell.link.label, + style: { ...style, textColor, underline: undefined }, + }); + } + } + LinkDisplay.components = { Menu }; + LinkDisplay.template = "o-spreadsheet-LinkDisplay"; + LinkDisplay.size = { width: LINK_TOOLTIP_WIDTH, height: LINK_TOOLTIP_HEIGHT }; + const LinkCellPopoverBuilder = { + onHover: (position, getters) => { + const cell = getters.getCell(getters.getActiveSheetId(), position.col, position.row); + const shouldDisplayLink = (cell === null || cell === void 0 ? void 0 : cell.isLink()) && + getters.isVisibleInViewport(position.col, position.row, getters.getActiveViewport()); + if (!shouldDisplayLink) + return { isOpen: false }; + return { + isOpen: true, + Component: LinkDisplay, + props: { cellPosition: position }, + cellCorner: "BottomLeft", + }; + }, + }; + + const DEFAULT_MENU_ITEM = (key) => ({ + isVisible: () => true, + isEnabled: () => true, + isReadonlyAllowed: false, + description: "", + action: false, + children: [], + separator: false, + icon: false, + id: key, + }); + function createFullMenuItem(key, value) { + return Object.assign({}, DEFAULT_MENU_ITEM(key), value); + } + function isMenuItem(value) { + return typeof value !== "function"; + } + /** + * The class Registry is extended in order to add the function addChild + * + */ + class MenuItemRegistry extends Registry { + /** + * @override + */ + add(key, value) { + this.content[key] = createFullMenuItem(key, value); + return this; + } + /** + * Add a subitem to an existing item + * @param path Path of items to add this subitem + * @param value Subitem to add + */ + addChild(key, path, value) { + const root = path.splice(0, 1)[0]; + let node = this.content[root]; + if (!node) { + throw new Error(`Path ${root + ":" + path.join(":")} not found`); + } + for (let p of path) { + node = node.children.filter(isMenuItem).find((elt) => elt.id === p); + if (!node) { + throw new Error(`Path ${root + ":" + path.join(":")} not found`); + } + } + if (typeof value !== "function") { + node.children.push(createFullMenuItem(key, value)); + } + else { + node.children.push(value); + } + return this; + } + /** + * Get a list of all elements in the registry, ordered by sequence + * @override + */ + getAll() { + return super.getAll().sort((a, b) => a.sequence - b.sequence); + } + } + + //------------------------------------------------------------------------------ + // Link Menu Registry + //------------------------------------------------------------------------------ + const linkMenuRegistry = new MenuItemRegistry(); + linkMenuRegistry + .add("sheet", { + name: _lt("Link sheet"), + sequence: 10, + }) + .addChild("sheet_list", ["sheet"], (env) => { + const sheets = env.model.getters + .getSheetIds() + .map((sheetId) => env.model.getters.getSheet(sheetId)); + return sheets.map((sheet, i) => createFullMenuItem(sheet.id, { + name: sheet.name, + sequence: i, + action: () => ({ + link: { label: sheet.name, url: buildSheetLink(sheet.id) }, + urlRepresentation: sheet.name, + isUrlEditable: false, + }), + })); + }); + + const MENU_OFFSET_X = 320; + const MENU_OFFSET_Y = 100; + const PADDING = 12; + const LINK_EDITOR_WIDTH = 340; + const LINK_EDITOR_HEIGHT = 180; + css /* scss */ ` + .o-link-editor { + font-size: 13px; + background-color: white; + box-shadow: 0 1px 4px 3px rgba(60, 64, 67, 0.15); + padding: ${PADDING}px; + display: flex; + flex-direction: column; + border-radius: 4px; + .o-section { + .o-section-title { + font-weight: bold; + color: dimgrey; + margin-bottom: 5px; + } + } + .o-buttons { + padding-left: 16px; + padding-top: 16px; + padding-bottom: 16px; + text-align: right; + .o-button { + border: 1px solid lightgrey; + padding: 0px 20px 0px 20px; + border-radius: 4px; + font-weight: 500; + font-size: 14px; + height: 30px; + line-height: 16px; + background: white; + margin-right: 8px; + &:hover:enabled { + background-color: rgba(0, 0, 0, 0.08); + } + } + .o-button:enabled { + cursor: pointer; + } + .o-button:last-child { + margin-right: 0px; + } + } + input { + box-sizing: border-box; + width: 100%; + border-radius: 4px; + padding: 4px 23px 4px 10px; + border: none; + height: 24px; + border: 1px solid lightgrey; + } + .o-link-url { + position: relative; + flex-grow: 1; + button { + position: absolute; + right: 0px; + top: 0px; + border: none; + height: 20px; + width: 20px; + background-color: #fff; + margin: 2px 3px 1px 0px; + padding: 0px 1px 0px 0px; + } + button:hover { + cursor: pointer; + } + } + } +`; + class LinkEditor extends owl.Component { + constructor() { + super(...arguments); + this.menuItems = linkMenuRegistry.getAll(); + this.state = owl.useState(this.defaultState); + this.menu = owl.useState({ + isOpen: false, + }); + this.linkEditorRef = owl.useRef("linkEditor"); + this.position = useAbsolutePosition(this.linkEditorRef); + this.urlInput = owl.useRef("urlInput"); + } + setup() { + owl.onMounted(() => { var _a; return (_a = this.urlInput.el) === null || _a === void 0 ? void 0 : _a.focus(); }); + } + get defaultState() { + const { col, row } = this.props.cellPosition; + const sheetId = this.env.model.getters.getActiveSheetId(); + const cell = this.env.model.getters.getCell(sheetId, col, row); + if (cell === null || cell === void 0 ? void 0 : cell.isLink()) { + return { + link: { url: cell.link.url, label: cell.formattedValue }, + urlRepresentation: cell.urlRepresentation, + isUrlEditable: cell.isUrlEditable, + }; + } + return { + link: { url: "", label: (cell === null || cell === void 0 ? void 0 : cell.formattedValue) || "" }, + isUrlEditable: true, + urlRepresentation: "", + }; + } + get menuPosition() { + return { + x: this.position.x + MENU_OFFSET_X - PADDING - 2, + y: this.position.y + MENU_OFFSET_Y, + }; + } + onSpecialLink(ev) { + const { detail } = ev; + this.state.link.url = detail.link.url; + this.state.link.label = detail.link.label; + this.state.isUrlEditable = detail.isUrlEditable; + this.state.urlRepresentation = detail.urlRepresentation; + } + openMenu() { + this.menu.isOpen = true; + } + removeLink() { + this.state.link.url = ""; + this.state.urlRepresentation = ""; + this.state.isUrlEditable = true; + } + save() { + var _a, _b; + const { col, row } = this.props.cellPosition; + const label = this.state.link.label || this.state.link.url; + this.env.model.dispatch("UPDATE_CELL", { + col: col, + row: row, + sheetId: this.env.model.getters.getActiveSheetId(), + content: markdownLink(label, this.state.link.url), + }); + (_b = (_a = this.props).onClosed) === null || _b === void 0 ? void 0 : _b.call(_a); + } + cancel() { + var _a, _b; + (_b = (_a = this.props).onClosed) === null || _b === void 0 ? void 0 : _b.call(_a); + } + onKeyDown(ev) { + switch (ev.key) { + case "Enter": + if (this.state.link.url) { + this.save(); + } + break; + case "Escape": + this.cancel(); + break; + } + } + } + LinkEditor.template = "o-spreadsheet-LinkEditor"; + LinkEditor.size = { width: LINK_EDITOR_WIDTH, height: LINK_EDITOR_HEIGHT }; + LinkEditor.components = { Menu }; + const LinkEditorPopoverBuilder = { + onOpen: (position, getters) => { + return { + isOpen: true, + props: { cellPosition: position }, + Component: LinkEditor, + cellCorner: "BottomLeft", + }; + }, + }; + + const cellPopoverRegistry = new Registry(); + cellPopoverRegistry + .add("ErrorToolTip", ErrorToolTipPopoverBuilder) + .add("LinkCell", LinkCellPopoverBuilder) + .add("LinkEditor", LinkEditorPopoverBuilder); + + /** + * This registry is intended to map a cell content (raw string) to + * an instance of a cell. + */ + const cellRegistry = new Registry(); + + /** + * Registry intended to support usual currencies. It is mainly used to create + * currency formats that can be selected or modified when customizing formats. + */ + const currenciesRegistry = new Registry(); + + const figureRegistry = new Registry(); + + const inverseCommandRegistry = new Registry() + .add("ADD_COLUMNS_ROWS", inverseAddColumnsRows) + .add("REMOVE_COLUMNS_ROWS", inverseRemoveColumnsRows) + .add("ADD_MERGE", inverseAddMerge) + .add("REMOVE_MERGE", inverseRemoveMerge) + .add("CREATE_SHEET", inverseCreateSheet) + .add("DELETE_SHEET", inverseDeleteSheet) + .add("DUPLICATE_SHEET", inverseDuplicateSheet) + .add("CREATE_FIGURE", inverseCreateFigure) + .add("CREATE_CHART", inverseCreateChart) + .add("HIDE_COLUMNS_ROWS", inverseHideColumnsRows) + .add("UNHIDE_COLUMNS_ROWS", inverseUnhideColumnsRows); + for (const cmd of coreTypes.values()) { + try { + inverseCommandRegistry.get(cmd); + } + catch (_) { + inverseCommandRegistry.add(cmd, identity); + } + } + function identity(cmd) { + return [cmd]; + } + function inverseAddColumnsRows(cmd) { + const elements = []; + let start = cmd.base; + if (cmd.position === "after") { + start++; + } + for (let i = 0; i < cmd.quantity; i++) { + elements.push(i + start); + } + return [ + { + type: "REMOVE_COLUMNS_ROWS", + dimension: cmd.dimension, + elements, + sheetId: cmd.sheetId, + }, + ]; + } + function inverseAddMerge(cmd) { + return [{ type: "REMOVE_MERGE", sheetId: cmd.sheetId, target: cmd.target }]; + } + function inverseRemoveMerge(cmd) { + return [{ type: "ADD_MERGE", sheetId: cmd.sheetId, target: cmd.target }]; + } + function inverseCreateSheet(cmd) { + return [{ type: "DELETE_SHEET", sheetId: cmd.sheetId }]; + } + function inverseDuplicateSheet(cmd) { + return [{ type: "DELETE_SHEET", sheetId: cmd.sheetIdTo }]; + } + function inverseRemoveColumnsRows(cmd) { + const commands = []; + const elements = [...cmd.elements].sort((a, b) => a - b); + for (let group of groupConsecutive(elements)) { + const column = group[0] === 0 ? 0 : group[0] - 1; + const position = group[0] === 0 ? "before" : "after"; + commands.push({ + type: "ADD_COLUMNS_ROWS", + dimension: cmd.dimension, + quantity: group.length, + base: column, + sheetId: cmd.sheetId, + position, + }); + } + return commands; + } + function inverseDeleteSheet(cmd) { + return [{ type: "CREATE_SHEET", sheetId: cmd.sheetId, position: 1 }]; + } + function inverseCreateFigure(cmd) { + return [{ type: "DELETE_FIGURE", id: cmd.figure.id, sheetId: cmd.sheetId }]; + } + function inverseCreateChart(cmd) { + return [{ type: "DELETE_FIGURE", id: cmd.id, sheetId: cmd.sheetId }]; + } + function inverseHideColumnsRows(cmd) { + return [ + { + type: "UNHIDE_COLUMNS_ROWS", + sheetId: cmd.sheetId, + dimension: cmd.dimension, + elements: cmd.elements, + }, + ]; + } + function inverseUnhideColumnsRows(cmd) { + return [ + { + type: "HIDE_COLUMNS_ROWS", + sheetId: cmd.sheetId, + dimension: cmd.dimension, + elements: cmd.elements, + }, + ]; + } + + const SORT_TYPES = [ + CellValueType.number, + CellValueType.error, + CellValueType.text, + CellValueType.boolean, + ]; + function convertCell(cell, index) { + return { + index, + type: cell ? cell.evaluated.type : CellValueType.empty, + value: cell ? cell.evaluated.value : "", + }; + } + function sortCells(cells, sortDirection) { + const cellsWithIndex = cells.map(convertCell); + const emptyCells = cellsWithIndex.filter((x) => x.type === CellValueType.empty); + const nonEmptyCells = cellsWithIndex.filter((x) => x.type !== CellValueType.empty); + const inverse = sortDirection === "descending" ? -1 : 1; + return nonEmptyCells + .sort((left, right) => { + let typeOrder = SORT_TYPES.indexOf(left.type) - SORT_TYPES.indexOf(right.type); + if (typeOrder === 0) { + if (left.type === CellValueType.text || left.type === CellValueType.error) { + typeOrder = left.value.localeCompare(right.value); + } + else + typeOrder = left.value - right.value; + } + return inverse * typeOrder; + }) + .concat(emptyCells); + } + function interactiveSortSelection(env, sheetId, anchor, zone, sortDirection) { + let result = DispatchResult.Success; + //several columns => bypass the contiguity check + let multiColumns = zone.right > zone.left; + if (env.model.getters.doesIntersectMerge(sheetId, zone)) { + multiColumns = false; + let table; + for (let r = zone.top; r <= zone.bottom; r++) { + table = []; + for (let c = zone.left; c <= zone.right; c++) { + let merge = env.model.getters.getMerge(sheetId, c, r); + if (merge && !table.includes(merge.id.toString())) { + table.push(merge.id.toString()); + } + } + if (table.length >= 2) { + multiColumns = true; + break; + } + } + } + const { col, row } = anchor; + if (multiColumns) { + result = env.model.dispatch("SORT_CELLS", { sheetId, col, row, zone, sortDirection }); + } + else { + // check contiguity + const contiguousZone = env.model.getters.getContiguousZone(sheetId, zone); + if (isEqual(contiguousZone, zone)) { + // merge as it is + result = env.model.dispatch("SORT_CELLS", { + sheetId, + col, + row, + zone, + sortDirection, + }); + } + else { + env.askConfirmation(_lt("We found data next to your selection. Since this data was not selected, it will not be sorted. Do you want to extend your selection?"), () => { + zone = contiguousZone; + result = env.model.dispatch("SORT_CELLS", { + sheetId, + col, + row, + zone, + sortDirection, + }); + }, () => { + result = env.model.dispatch("SORT_CELLS", { + sheetId, + col, + row, + zone, + sortDirection, + }); + }); + } + } + if (result.isCancelledBecause(60 /* InvalidSortZone */)) { + const { col, row } = anchor; + env.model.selection.selectZone({ cell: { col, row }, zone }); + env.notifyUser(_lt("Cannot sort. To sort, select only cells or only merges that have the same size.")); + } + } + + function interactiveCut(env) { + const result = env.model.dispatch("CUT"); + if (!result.isSuccessful) { + if (result.isCancelledBecause(17 /* WrongCutSelection */)) { + env.notifyUser(_lt("This operation is not allowed with multiple selections.")); + } + } + } + + const PasteInteractiveContent = { + wrongPasteSelection: _lt("This operation is not allowed with multiple selections."), + willRemoveExistingMerge: _lt("This operation is not possible due to a merge. Please remove the merges first than try again."), + wrongFigurePasteOption: _lt("Cannot do a special paste of a figure."), + }; + function handlePasteResult(env, result) { + if (!result.isSuccessful) { + if (result.reasons.includes(18 /* WrongPasteSelection */)) { + env.notifyUser(PasteInteractiveContent.wrongPasteSelection); + } + else if (result.reasons.includes(2 /* WillRemoveExistingMerge */)) { + env.notifyUser(PasteInteractiveContent.willRemoveExistingMerge); + } + else if (result.reasons.includes(20 /* WrongFigurePasteOption */)) { + env.notifyUser(PasteInteractiveContent.wrongFigurePasteOption); + } + } + } + function interactivePaste(env, target, pasteOption) { + const result = env.model.dispatch("PASTE", { target, pasteOption }); + handlePasteResult(env, result); + } + + //------------------------------------------------------------------------------ + // Helpers + //------------------------------------------------------------------------------ + function getColumnsNumber(env) { + const activeCols = env.model.getters.getActiveCols(); + if (activeCols.size) { + return activeCols.size; + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + return zone.right - zone.left + 1; + } + } + function getRowsNumber(env) { + const activeRows = env.model.getters.getActiveRows(); + if (activeRows.size) { + return activeRows.size; + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + return zone.bottom - zone.top + 1; + } + } + function setFormatter(env, format) { + env.model.dispatch("SET_FORMATTING", { + sheetId: env.model.getters.getActiveSheetId(), + target: env.model.getters.getSelectedZones(), + format, + }); + } + function setStyle(env, style) { + env.model.dispatch("SET_FORMATTING", { + sheetId: env.model.getters.getActiveSheetId(), + target: env.model.getters.getSelectedZones(), + style, + }); + } + async function readOsClipboard(env) { + try { + return await env.clipboard.readText(); + } + catch (e) { + // Permission is required to read the clipboard. + console.warn("The OS clipboard could not be read."); + console.error(e); + return undefined; + } + } + //------------------------------------------------------------------------------ + // Simple actions + //------------------------------------------------------------------------------ + const UNDO_ACTION = (env) => env.model.dispatch("REQUEST_UNDO"); + const REDO_ACTION = (env) => env.model.dispatch("REQUEST_REDO"); + const COPY_ACTION = async (env) => { + env.model.dispatch("COPY"); + await env.clipboard.writeText(env.model.getters.getClipboardContent()); + }; + const CUT_ACTION = async (env) => { + interactiveCut(env); + await env.clipboard.writeText(env.model.getters.getClipboardContent()); + }; + const PASTE_ACTION = async (env) => { + const spreadsheetClipboard = env.model.getters.getClipboardContent(); + const osClipboard = await readOsClipboard(env); + const target = env.model.getters.getSelectedZones(); + if (osClipboard && osClipboard !== spreadsheetClipboard) { + env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { + target, + text: osClipboard, + }); + } + else { + interactivePaste(env, target); + } + }; + const PASTE_VALUE_ACTION = async (env) => { + const spreadsheetClipboard = env.model.getters.getClipboardContent(); + const osClipboard = await readOsClipboard(env); + const target = env.model.getters.getSelectedZones(); + if (osClipboard && osClipboard !== spreadsheetClipboard) { + env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { + target, + text: osClipboard, + }); + } + else { + env.model.dispatch("PASTE", { + target: env.model.getters.getSelectedZones(), + pasteOption: "onlyValue", + }); + } + }; + const PASTE_FORMAT_ACTION = (env) => interactivePaste(env, env.model.getters.getSelectedZones(), "onlyFormat"); + const DELETE_CONTENT_ACTION = (env) => env.model.dispatch("DELETE_CONTENT", { + sheetId: env.model.getters.getActiveSheetId(), + target: env.model.getters.getSelectedZones(), + }); + const SET_FORMULA_VISIBILITY_ACTION = (env) => env.model.dispatch("SET_FORMULA_VISIBILITY", { show: !env.model.getters.shouldShowFormulas() }); + const SET_GRID_LINES_VISIBILITY_ACTION = (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + env.model.dispatch("SET_GRID_LINES_VISIBILITY", { + sheetId, + areGridLinesVisible: !env.model.getters.getGridLinesVisibility(sheetId), + }); + }; + const IS_NOT_CUT_OPERATION = (env) => { + return !env.model.getters.isCutOperation(); + }; + //------------------------------------------------------------------------------ + // Grid manipulations + //------------------------------------------------------------------------------ + const DELETE_CONTENT_ROWS_NAME = (env) => { + if (env.model.getters.getSelectedZones().length > 1) { + return _lt("Clear rows"); + } + let first; + let last; + const activesRows = env.model.getters.getActiveRows(); + if (activesRows.size !== 0) { + first = Math.min(...activesRows); + last = Math.max(...activesRows); + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + first = zone.top; + last = zone.bottom; + } + if (first === last) { + return _lt("Clear row %s", (first + 1).toString()); + } + return _lt("Clear rows %s - %s", (first + 1).toString(), (last + 1).toString()); + }; + const DELETE_CONTENT_ROWS_ACTION = (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + const target = [...env.model.getters.getActiveRows()].map((index) => env.model.getters.getRowsZone(sheetId, index, index)); + env.model.dispatch("DELETE_CONTENT", { + target, + sheetId: env.model.getters.getActiveSheetId(), + }); + }; + const DELETE_CONTENT_COLUMNS_NAME = (env) => { + if (env.model.getters.getSelectedZones().length > 1) { + return _lt("Clear columns"); + } + let first; + let last; + const activeCols = env.model.getters.getActiveCols(); + if (activeCols.size !== 0) { + first = Math.min(...activeCols); + last = Math.max(...activeCols); + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + first = zone.left; + last = zone.right; + } + if (first === last) { + return _lt("Clear column %s", numberToLetters(first)); + } + return _lt("Clear columns %s - %s", numberToLetters(first), numberToLetters(last)); + }; + const DELETE_CONTENT_COLUMNS_ACTION = (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + const target = [...env.model.getters.getActiveCols()].map((index) => env.model.getters.getColsZone(sheetId, index, index)); + env.model.dispatch("DELETE_CONTENT", { + target, + sheetId: env.model.getters.getActiveSheetId(), + }); + }; + const REMOVE_ROWS_NAME = (env) => { + if (env.model.getters.getSelectedZones().length > 1) { + return _lt("Delete rows"); + } + let first; + let last; + const activesRows = env.model.getters.getActiveRows(); + if (activesRows.size !== 0) { + first = Math.min(...activesRows); + last = Math.max(...activesRows); + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + first = zone.top; + last = zone.bottom; + } + if (first === last) { + return _lt("Delete row %s", (first + 1).toString()); + } + return _lt("Delete rows %s - %s", (first + 1).toString(), (last + 1).toString()); + }; + const REMOVE_ROWS_ACTION = (env) => { + let rows = [...env.model.getters.getActiveRows()]; + if (!rows.length) { + const zone = env.model.getters.getSelectedZones()[0]; + for (let i = zone.top; i <= zone.bottom; i++) { + rows.push(i); + } + } + env.model.dispatch("REMOVE_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + dimension: "ROW", + elements: rows, + }); + }; + const REMOVE_COLUMNS_NAME = (env) => { + if (env.model.getters.getSelectedZones().length > 1) { + return _lt("Delete columns"); + } + let first; + let last; + const activeCols = env.model.getters.getActiveCols(); + if (activeCols.size !== 0) { + first = Math.min(...activeCols); + last = Math.max(...activeCols); + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + first = zone.left; + last = zone.right; + } + if (first === last) { + return _lt("Delete column %s", numberToLetters(first)); + } + return _lt("Delete columns %s - %s", numberToLetters(first), numberToLetters(last)); + }; + const REMOVE_COLUMNS_ACTION = (env) => { + let columns = [...env.model.getters.getActiveCols()]; + if (!columns.length) { + const zone = env.model.getters.getSelectedZones()[0]; + for (let i = zone.left; i <= zone.right; i++) { + columns.push(i); + } + } + env.model.dispatch("REMOVE_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + dimension: "COL", + elements: columns, + }); + }; + const INSERT_CELL_SHIFT_DOWN = (env) => { + const zone = env.model.getters.getSelectedZone(); + const result = env.model.dispatch("INSERT_CELL", { zone, shiftDimension: "ROW" }); + handlePasteResult(env, result); + }; + const INSERT_CELL_SHIFT_RIGHT = (env) => { + const zone = env.model.getters.getSelectedZone(); + const result = env.model.dispatch("INSERT_CELL", { zone, shiftDimension: "COL" }); + handlePasteResult(env, result); + }; + const DELETE_CELL_SHIFT_UP = (env) => { + const zone = env.model.getters.getSelectedZone(); + const result = env.model.dispatch("DELETE_CELL", { zone, shiftDimension: "ROW" }); + handlePasteResult(env, result); + }; + const DELETE_CELL_SHIFT_LEFT = (env) => { + const zone = env.model.getters.getSelectedZone(); + const result = env.model.dispatch("DELETE_CELL", { zone, shiftDimension: "COL" }); + handlePasteResult(env, result); + }; + const MENU_INSERT_ROWS_BEFORE_NAME = (env) => { + const number = getRowsNumber(env); + if (number === 1) { + return _lt("Row above"); + } + return _lt("%s Rows above", number.toString()); + }; + const ROW_INSERT_ROWS_BEFORE_NAME = (env) => { + const number = getRowsNumber(env); + return number === 1 ? _lt("Insert row above") : _lt("Insert %s rows above", number.toString()); + }; + const CELL_INSERT_ROWS_BEFORE_NAME = (env) => { + const number = getRowsNumber(env); + if (number === 1) { + return _lt("Insert row"); + } + return _lt("Insert %s rows", number.toString()); + }; + const INSERT_ROWS_BEFORE_ACTION = (env) => { + const activeRows = env.model.getters.getActiveRows(); + let row; + let quantity; + if (activeRows.size) { + row = Math.min(...activeRows); + quantity = activeRows.size; + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + row = zone.top; + quantity = zone.bottom - zone.top + 1; + } + env.model.dispatch("ADD_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + position: "before", + base: row, + quantity, + dimension: "ROW", + }); + }; + const MENU_INSERT_ROWS_AFTER_NAME = (env) => { + const number = getRowsNumber(env); + if (number === 1) { + return _lt("Row below"); + } + return _lt("%s Rows below", number.toString()); + }; + const ROW_INSERT_ROWS_AFTER_NAME = (env) => { + const number = getRowsNumber(env); + return number === 1 ? _lt("Insert row below") : _lt("Insert %s rows below", number.toString()); + }; + const INSERT_ROWS_AFTER_ACTION = (env) => { + const activeRows = env.model.getters.getActiveRows(); + let row; + let quantity; + if (activeRows.size) { + row = Math.max(...activeRows); + quantity = activeRows.size; + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + row = zone.bottom; + quantity = zone.bottom - zone.top + 1; + } + env.model.dispatch("ADD_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + position: "after", + base: row, + quantity, + dimension: "ROW", + }); + }; + const MENU_INSERT_COLUMNS_BEFORE_NAME = (env) => { + const number = getColumnsNumber(env); + if (number === 1) { + return _lt("Column left"); + } + return _lt("%s Columns left", number.toString()); + }; + const COLUMN_INSERT_COLUMNS_BEFORE_NAME = (env) => { + const number = getColumnsNumber(env); + return number === 1 + ? _lt("Insert column left") + : _lt("Insert %s columns left", number.toString()); + }; + const CELL_INSERT_COLUMNS_BEFORE_NAME = (env) => { + const number = getColumnsNumber(env); + if (number === 1) { + return _lt("Insert column"); + } + return _lt("Insert %s columns", number.toString()); + }; + const INSERT_COLUMNS_BEFORE_ACTION = (env) => { + const activeCols = env.model.getters.getActiveCols(); + let column; + let quantity; + if (activeCols.size) { + column = Math.min(...activeCols); + quantity = activeCols.size; + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + column = zone.left; + quantity = zone.right - zone.left + 1; + } + env.model.dispatch("ADD_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + position: "before", + dimension: "COL", + base: column, + quantity, + }); + }; + const MENU_INSERT_COLUMNS_AFTER_NAME = (env) => { + const number = getColumnsNumber(env); + if (number === 1) { + return _lt("Column right"); + } + return _lt("%s Columns right", number.toString()); + }; + const COLUMN_INSERT_COLUMNS_AFTER_NAME = (env) => { + const number = getColumnsNumber(env); + return number === 1 + ? _lt("Insert column right") + : _lt("Insert %s columns right", number.toString()); + }; + const INSERT_COLUMNS_AFTER_ACTION = (env) => { + const activeCols = env.model.getters.getActiveCols(); + let column; + let quantity; + if (activeCols.size) { + column = Math.max(...activeCols); + quantity = activeCols.size; + } + else { + const zone = env.model.getters.getSelectedZones()[0]; + column = zone.right; + quantity = zone.right - zone.left + 1; + } + env.model.dispatch("ADD_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + position: "after", + dimension: "COL", + base: column, + quantity, + }); + }; + const HIDE_COLUMNS_NAME = (env) => { + const cols = env.model.getters.getElementsFromSelection("COL"); + let first = cols[0]; + let last = cols[cols.length - 1]; + if (cols.length === 1) { + return _lt("Hide column %s", numberToLetters(first).toString()); + } + else if (last - first + 1 === cols.length) { + return _lt("Hide columns %s - %s", numberToLetters(first).toString(), numberToLetters(last).toString()); + } + else { + return _lt("Hide columns"); + } + }; + const HIDE_COLUMNS_ACTION = (env) => { + const columns = env.model.getters.getElementsFromSelection("COL"); + env.model.dispatch("HIDE_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + dimension: "COL", + elements: columns, + }); + }; + const UNHIDE_ALL_COLUMNS_ACTION = (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + env.model.dispatch("UNHIDE_COLUMNS_ROWS", { + sheetId, + dimension: "COL", + elements: Array.from(Array(env.model.getters.getNumberCols(sheetId)).keys()), + }); + }; + const UNHIDE_COLUMNS_ACTION = (env) => { + const columns = env.model.getters.getElementsFromSelection("COL"); + env.model.dispatch("UNHIDE_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + dimension: "COL", + elements: columns, + }); + }; + const HIDE_ROWS_NAME = (env) => { + const rows = env.model.getters.getElementsFromSelection("ROW"); + let first = rows[0]; + let last = rows[rows.length - 1]; + if (rows.length === 1) { + return _lt("Hide row %s", (first + 1).toString()); + } + else if (last - first + 1 === rows.length) { + return _lt("Hide rows %s - %s", (first + 1).toString(), (last + 1).toString()); + } + else { + return _lt("Hide rows"); + } + }; + const HIDE_ROWS_ACTION = (env) => { + const rows = env.model.getters.getElementsFromSelection("ROW"); + env.model.dispatch("HIDE_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + dimension: "ROW", + elements: rows, + }); + }; + const UNHIDE_ALL_ROWS_ACTION = (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + env.model.dispatch("UNHIDE_COLUMNS_ROWS", { + sheetId, + dimension: "ROW", + elements: Array.from(Array(env.model.getters.getNumberRows(sheetId)).keys()), + }); + }; + const UNHIDE_ROWS_ACTION = (env) => { + const columns = env.model.getters.getElementsFromSelection("ROW"); + env.model.dispatch("UNHIDE_COLUMNS_ROWS", { + sheetId: env.model.getters.getActiveSheetId(), + dimension: "ROW", + elements: columns, + }); + }; + //------------------------------------------------------------------------------ + // Sheets + //------------------------------------------------------------------------------ + const CREATE_SHEET_ACTION = (env) => { + const activeSheetId = env.model.getters.getActiveSheetId(); + const position = env.model.getters.getSheetIds().indexOf(activeSheetId) + 1; + const sheetId = env.model.uuidGenerator.uuidv4(); + env.model.dispatch("CREATE_SHEET", { sheetId, position }); + env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: sheetId }); + }; + //------------------------------------------------------------------------------ + // Charts + //------------------------------------------------------------------------------ + const CREATE_CHART = (env) => { + const zone = env.model.getters.getSelectedZone(); + let dataSetZone = zone; + const id = env.model.uuidGenerator.uuidv4(); + let labelRange; + if (zone.left !== zone.right) { + dataSetZone = { ...zone, left: zone.left + 1 }; + } + const dataSets = [zoneToXc(dataSetZone)]; + const sheetId = env.model.getters.getActiveSheetId(); + const viewport = env.model.getters.getActiveViewport(); + const left = env.model.getters.getColDimensions(sheetId, viewport.left).start; + const top = env.model.getters.getRowDimensions(sheetId, viewport.top).start; + const { width, height } = env.model.getters.getViewportDimension(); + const size = { width: DEFAULT_FIGURE_WIDTH, height: DEFAULT_FIGURE_HEIGHT }; + const position = { + x: left + Math.max(0, (width - DEFAULT_FIGURE_WIDTH) / 2), + y: top + Math.max(0, (height - DEFAULT_FIGURE_HEIGHT) / 2), + }; // Position at the center of the viewport + let dataSetsHaveTitle = false; + for (let x = dataSetZone.left; x <= dataSetZone.right; x++) { + const cell = env.model.getters.getCell(sheetId, x, zone.top); + if (cell && cell.evaluated.type !== CellValueType.number) { + dataSetsHaveTitle = true; + break; + } + } + if (zone.left !== zone.right) { + labelRange = zoneToXc({ + ...zone, + right: zone.left, + top: dataSetsHaveTitle ? zone.top + 1 : zone.top, + }); + } + const newLegendPos = dataSetZone.right === dataSetZone.left ? "none" : "top"; //Using the same variable as above to identify number of columns involved. + env.model.dispatch("CREATE_CHART", { + sheetId, + id, + position, + size, + definition: { + title: "", + dataSets, + labelRange, + type: "bar", + background: BACKGROUND_CHART_COLOR, + stackedBar: false, + dataSetsHaveTitle, + verticalAxisPosition: "left", + legendPosition: newLegendPos, + }, + }); + env.openSidePanel("ChartPanel", { figureId: id }); + }; + //------------------------------------------------------------------------------ + // Style/Format + //------------------------------------------------------------------------------ + const FORMAT_AUTOMATIC_ACTION = (env) => setFormatter(env, ""); + const FORMAT_NUMBER_ACTION = (env) => setFormatter(env, "#,##0.00"); + const FORMAT_PERCENT_ACTION = (env) => setFormatter(env, "0.00%"); + const FORMAT_CURRENCY_ACTION = (env) => setFormatter(env, "[$$]#,##0.00"); + const FORMAT_CURRENCY_ROUNDED_ACTION = (env) => setFormatter(env, "[$$]#,##0"); + const FORMAT_DATE_ACTION = (env) => setFormatter(env, "m/d/yyyy"); + const FORMAT_TIME_ACTION = (env) => setFormatter(env, "hh:mm:ss a"); + const FORMAT_DATE_TIME_ACTION = (env) => setFormatter(env, "m/d/yyyy hh:mm:ss"); + const FORMAT_DURATION_ACTION = (env) => setFormatter(env, "hhhh:mm:ss"); + const FORMAT_BOLD_ACTION = (env) => setStyle(env, { bold: !env.model.getters.getCurrentStyle().bold }); + const FORMAT_ITALIC_ACTION = (env) => setStyle(env, { italic: !env.model.getters.getCurrentStyle().italic }); + const FORMAT_STRIKETHROUGH_ACTION = (env) => setStyle(env, { strikethrough: !env.model.getters.getCurrentStyle().strikethrough }); + const FORMAT_UNDERLINE_ACTION = (env) => setStyle(env, { underline: !env.model.getters.getCurrentStyle().underline }); + const FORMAT_CLEARFORMAT_ACTION = (env) => { + env.model.dispatch("CLEAR_FORMATTING", { + sheetId: env.model.getters.getActiveSheetId(), + target: env.model.getters.getSelectedZones(), + }); + }; + //------------------------------------------------------------------------------ + // Side panel + //------------------------------------------------------------------------------ + const OPEN_CF_SIDEPANEL_ACTION = (env) => { + env.openSidePanel("ConditionalFormatting", { selection: env.model.getters.getSelectedZones() }); + }; + const OPEN_FAR_SIDEPANEL_ACTION = (env) => { + env.openSidePanel("FindAndReplace", {}); + }; + const OPEN_CUSTOM_CURRENCY_SIDEPANEL_ACTION = (env) => { + env.openSidePanel("CustomCurrency", {}); + }; + const INSERT_LINK = (env) => { + let { col, row } = env.model.getters.getPosition(); + env.model.dispatch("OPEN_CELL_POPOVER", { col, row, popoverType: "LinkEditor" }); + }; + //------------------------------------------------------------------------------ + // Sorting action + //------------------------------------------------------------------------------ + const SORT_CELLS_ASCENDING = (env) => { + const { anchor, zones } = env.model.getters.getSelection(); + const sheetId = env.model.getters.getActiveSheetId(); + interactiveSortSelection(env, sheetId, anchor.cell, zones[0], "ascending"); + }; + const SORT_CELLS_DESCENDING = (env) => { + const { anchor, zones } = env.model.getters.getSelection(); + const sheetId = env.model.getters.getActiveSheetId(); + interactiveSortSelection(env, sheetId, anchor.cell, zones[0], "descending"); + }; + const IS_ONLY_ONE_RANGE = (env) => { + return env.model.getters.getSelectedZones().length === 1; + }; + + //------------------------------------------------------------------------------ + // Context Menu Registry + //------------------------------------------------------------------------------ + const cellMenuRegistry = new MenuItemRegistry(); + cellMenuRegistry + .add("cut", { + name: _lt("Cut"), + description: "Ctrl+X", + sequence: 10, + action: CUT_ACTION, + }) + .add("copy", { + name: _lt("Copy"), + description: "Ctrl+C", + sequence: 20, + isReadonlyAllowed: true, + action: COPY_ACTION, + }) + .add("paste", { + name: _lt("Paste"), + description: "Ctrl+V", + sequence: 30, + action: PASTE_ACTION, + }) + .add("paste_special", { + name: _lt("Paste special"), + sequence: 40, + separator: true, + isVisible: IS_NOT_CUT_OPERATION, + }) + .addChild("paste_value_only", ["paste_special"], { + name: _lt("Paste values only"), + sequence: 10, + action: PASTE_VALUE_ACTION, + }) + .addChild("paste_format_only", ["paste_special"], { + name: _lt("Paste format only"), + sequence: 20, + action: PASTE_FORMAT_ACTION, + }) + .add("add_row_before", { + name: CELL_INSERT_ROWS_BEFORE_NAME, + sequence: 70, + action: INSERT_ROWS_BEFORE_ACTION, + isVisible: IS_ONLY_ONE_RANGE, + }) + .add("add_column_before", { + name: CELL_INSERT_COLUMNS_BEFORE_NAME, + sequence: 90, + action: INSERT_COLUMNS_BEFORE_ACTION, + isVisible: IS_ONLY_ONE_RANGE, + }) + .add("insert_cell", { + name: _lt("Insert cells"), + sequence: 100, + isVisible: IS_ONLY_ONE_RANGE, + separator: true, + }) + .addChild("insert_cell_down", ["insert_cell"], { + name: _lt("Shift down"), + sequence: 10, + action: INSERT_CELL_SHIFT_DOWN, + }) + .addChild("insert_cell_right", ["insert_cell"], { + name: _lt("Shift right"), + sequence: 20, + action: INSERT_CELL_SHIFT_RIGHT, + }) + .add("delete_row", { + name: REMOVE_ROWS_NAME, + sequence: 110, + action: REMOVE_ROWS_ACTION, + isVisible: IS_ONLY_ONE_RANGE, + }) + .add("delete_column", { + name: REMOVE_COLUMNS_NAME, + sequence: 120, + action: REMOVE_COLUMNS_ACTION, + isVisible: IS_ONLY_ONE_RANGE, + }) + .add("delete_cell", { + name: _lt("Delete cells"), + sequence: 130, + isVisible: IS_ONLY_ONE_RANGE, + }) + .addChild("delete_cell_up", ["delete_cell"], { + name: _lt("Shift up"), + sequence: 10, + action: DELETE_CELL_SHIFT_UP, + }) + .addChild("delete_cell_down", ["delete_cell"], { + name: _lt("Shift left"), + sequence: 20, + action: DELETE_CELL_SHIFT_LEFT, + }) + .add("insert_link", { + name: _lt("Insert link"), + separator: true, + sequence: 150, + action: INSERT_LINK, + }); + + const colMenuRegistry = new MenuItemRegistry(); + colMenuRegistry + .add("cut", { + name: _lt("Cut"), + description: "Ctrl+X", + sequence: 10, + action: CUT_ACTION, + }) + .add("copy", { + name: _lt("Copy"), + description: "Ctrl+C", + sequence: 20, + isReadonlyAllowed: true, + action: COPY_ACTION, + }) + .add("paste", { + name: _lt("Paste"), + description: "Ctrl+V", + sequence: 30, + action: PASTE_ACTION, + }) + .add("paste_special", { + name: _lt("Paste special"), + sequence: 40, + separator: true, + isVisible: IS_NOT_CUT_OPERATION, + }) + .addChild("paste_value_only", ["paste_special"], { + name: _lt("Paste value only"), + sequence: 10, + action: PASTE_VALUE_ACTION, + }) + .addChild("paste_format_only", ["paste_special"], { + name: _lt("Paste format only"), + sequence: 20, + action: PASTE_FORMAT_ACTION, + }) + .add("sort_columns", { + name: (env) => env.model.getters.getActiveCols().size > 1 ? _lt("Sort columns") : _lt("Sort column"), + sequence: 50, + isVisible: IS_ONLY_ONE_RANGE, + separator: true, + }) + .addChild("sort_ascending", ["sort_columns"], { + name: _lt("Ascending (A ⟶ Z)"), + sequence: 10, + action: SORT_CELLS_ASCENDING, + }) + .addChild("sort_descending", ["sort_columns"], { + name: _lt("Descending (Z ⟶ A)"), + sequence: 20, + action: SORT_CELLS_DESCENDING, + }) + .add("add_column_before", { + name: COLUMN_INSERT_COLUMNS_BEFORE_NAME, + sequence: 70, + action: INSERT_COLUMNS_BEFORE_ACTION, + }) + .add("add_column_after", { + name: COLUMN_INSERT_COLUMNS_AFTER_NAME, + sequence: 80, + action: INSERT_COLUMNS_AFTER_ACTION, + }) + .add("delete_column", { + name: REMOVE_COLUMNS_NAME, + sequence: 90, + action: REMOVE_COLUMNS_ACTION, + }) + .add("clear_column", { + name: DELETE_CONTENT_COLUMNS_NAME, + sequence: 100, + action: DELETE_CONTENT_COLUMNS_ACTION, + }) + .add("hide_columns", { + name: HIDE_COLUMNS_NAME, + sequence: 85, + action: HIDE_COLUMNS_ACTION, + isVisible: (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + const hiddenCols = env.model.getters.getHiddenColsGroups(sheetId).flat(); + return (env.model.getters.getNumberCols(sheetId) > + hiddenCols.length + env.model.getters.getElementsFromSelection("COL").length); + }, + separator: true, + }) + .add("unhide_columns", { + name: "Unhide columns", + sequence: 86, + action: UNHIDE_COLUMNS_ACTION, + isVisible: (env) => { + const hiddenCols = env.model.getters + .getHiddenColsGroups(env.model.getters.getActiveSheetId()) + .flat(); + const currentCols = env.model.getters.getElementsFromSelection("COL"); + return currentCols.some((col) => hiddenCols.includes(col)); + }, + separator: true, + }) + .add("conditional_formatting", { + name: _lt("Conditional formatting"), + sequence: 110, + action: OPEN_CF_SIDEPANEL_ACTION, + }); + + const dashboardMenuRegistry = new MenuItemRegistry(); + + const rowMenuRegistry = new MenuItemRegistry(); + rowMenuRegistry + .add("cut", { + name: _lt("Cut"), + sequence: 10, + description: "Ctrl+X", + action: CUT_ACTION, + }) + .add("copy", { + name: _lt("Copy"), + description: "Ctrl+C", + sequence: 20, + isReadonlyAllowed: true, + action: COPY_ACTION, + }) + .add("paste", { + name: _lt("Paste"), + description: "Ctrl+V", + sequence: 30, + action: PASTE_ACTION, + }) + .add("paste_special", { + name: _lt("Paste special"), + sequence: 40, + separator: true, + isVisible: IS_NOT_CUT_OPERATION, + }) + .addChild("paste_value_only", ["paste_special"], { + name: _lt("Paste value only"), + sequence: 10, + action: PASTE_VALUE_ACTION, + }) + .addChild("paste_format_only", ["paste_special"], { + name: _lt("Paste format only"), + sequence: 20, + action: PASTE_FORMAT_ACTION, + }) + .add("add_row_before", { + name: ROW_INSERT_ROWS_BEFORE_NAME, + sequence: 50, + action: INSERT_ROWS_BEFORE_ACTION, + }) + .add("add_row_after", { + name: ROW_INSERT_ROWS_AFTER_NAME, + sequence: 60, + action: INSERT_ROWS_AFTER_ACTION, + }) + .add("delete_row", { + name: REMOVE_ROWS_NAME, + sequence: 70, + action: REMOVE_ROWS_ACTION, + }) + .add("clear_row", { + name: DELETE_CONTENT_ROWS_NAME, + sequence: 80, + action: DELETE_CONTENT_ROWS_ACTION, + }) + .add("hide_rows", { + name: HIDE_ROWS_NAME, + sequence: 85, + action: HIDE_ROWS_ACTION, + isVisible: (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + const hiddenRows = env.model.getters.getHiddenRowsGroups(sheetId).flat(); + return (env.model.getters.getNumberRows(sheetId) > + hiddenRows.length + env.model.getters.getElementsFromSelection("ROW").length); + }, + separator: true, + }) + .add("unhide_rows", { + name: "Unhide rows", + sequence: 86, + action: UNHIDE_ROWS_ACTION, + isVisible: (env) => { + const hiddenRows = env.model.getters + .getHiddenRowsGroups(env.model.getters.getActiveSheetId()) + .flat(); + const currentRows = env.model.getters.getElementsFromSelection("ROW"); + return currentRows.some((col) => hiddenRows.includes(col)); + }, + separator: true, + }) + .add("conditional_formatting", { + name: _lt("Conditional formatting"), + sequence: 90, + action: OPEN_CF_SIDEPANEL_ACTION, + }); + + function interactiveRenameSheet(env, sheetId, errorText) { + const placeholder = env.model.getters.getSheetName(sheetId); + const title = _lt("Rename Sheet"); + const callback = (name) => { + if (name === null || name === placeholder) { + return; + } + if (name === "") { + interactiveRenameSheet(env, sheetId, _lt("The sheet name cannot be empty.")); + } + const result = env.model.dispatch("RENAME_SHEET", { sheetId, name }); + if (!result.isSuccessful) { + if (result.reasons.includes(10 /* DuplicatedSheetName */)) { + interactiveRenameSheet(env, sheetId, _lt("A sheet with the name %s already exists. Please select another name.", name)); + } + if (result.reasons.includes(11 /* ForbiddenCharactersInSheetName */)) { + interactiveRenameSheet(env, sheetId, _lt("Some used characters are not allowed in a sheet name (Forbidden characters are %s).", FORBIDDEN_SHEET_CHARS.join(" "))); + } + } + }; + env.editText(title, callback, { + placeholder: placeholder, + error: errorText, + }); + } + + const sheetMenuRegistry = new MenuItemRegistry(); + sheetMenuRegistry + .add("delete", { + name: _lt("Delete"), + sequence: 10, + isVisible: (env) => { + return env.model.getters.getSheetIds().length > 1; + }, + action: (env) => env.askConfirmation(_lt("Are you sure you want to delete this sheet ?"), () => { + env.model.dispatch("DELETE_SHEET", { sheetId: env.model.getters.getActiveSheetId() }); + }), + }) + .add("duplicate", { + name: _lt("Duplicate"), + sequence: 20, + action: (env) => { + const sheetIdFrom = env.model.getters.getActiveSheetId(); + const sheetIdTo = env.model.uuidGenerator.uuidv4(); + env.model.dispatch("DUPLICATE_SHEET", { + sheetId: sheetIdFrom, + sheetIdTo, + }); + env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom, sheetIdTo }); + }, + }) + .add("rename", { + name: _lt("Rename"), + sequence: 30, + action: (env) => interactiveRenameSheet(env, env.model.getters.getActiveSheetId()), + }) + .add("move_right", { + name: _lt("Move right"), + sequence: 40, + isVisible: (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + const sheetIds = env.model.getters.getVisibleSheetIds(); + return sheetIds.indexOf(sheetId) !== sheetIds.length - 1; + }, + action: (env) => env.model.dispatch("MOVE_SHEET", { + sheetId: env.model.getters.getActiveSheetId(), + direction: "right", + }), + }) + .add("move_left", { + name: _lt("Move left"), + sequence: 50, + isVisible: (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + return env.model.getters.getVisibleSheetIds()[0] !== sheetId; + }, + action: (env) => env.model.dispatch("MOVE_SHEET", { + sheetId: env.model.getters.getActiveSheetId(), + direction: "left", + }), + }) + .add("hide_sheet", { + name: _lt("Hide sheet"), + sequence: 60, + isVisible: (env) => env.model.getters.getVisibleSheetIds().length !== 1, + action: (env) => env.model.dispatch("HIDE_SHEET", { sheetId: env.model.getters.getActiveSheetId() }), + }); + + const CfTerms = { + Errors: { + [23 /* InvalidRange */]: _lt("The range is invalid"), + [48 /* FirstArgMissing */]: _lt("The argument is missing. Please provide a value"), + [49 /* SecondArgMissing */]: _lt("The second argument is missing. Please provide a value"), + [50 /* MinNaN */]: _lt("The minpoint must be a number"), + [51 /* MidNaN */]: _lt("The midpoint must be a number"), + [52 /* MaxNaN */]: _lt("The maxpoint must be a number"), + [53 /* ValueUpperInflectionNaN */]: _lt("The first value must be a number"), + [54 /* ValueLowerInflectionNaN */]: _lt("The second value must be a number"), + [44 /* MinBiggerThanMax */]: _lt("Minimum must be smaller then Maximum"), + [47 /* MinBiggerThanMid */]: _lt("Minimum must be smaller then Midpoint"), + [46 /* MidBiggerThanMax */]: _lt("Midpoint must be smaller then Maximum"), + [45 /* LowerBiggerThanUpper */]: _lt("Lower inflection point must be smaller than upper inflection point"), + [55 /* MinInvalidFormula */]: _lt("Invalid Minpoint formula"), + [57 /* MaxInvalidFormula */]: _lt("Invalid Maxpoint formula"), + [56 /* MidInvalidFormula */]: _lt("Invalid Midpoint formula"), + [58 /* ValueUpperInvalidFormula */]: _lt("Invalid upper inflection point formula"), + [59 /* ValueLowerInvalidFormula */]: _lt("Invalid lower inflection point formula"), + [22 /* EmptyRange */]: _lt("A range needs to be defined"), + Unexpected: _lt("The rule is invalid for an unknown reason"), + }, + ColorScale: _lt("Color scale"), + IconSet: _lt("Icon set"), + }; + const CellIsOperators = { + IsEmpty: _lt("Is empty"), + IsNotEmpty: _lt("Is not empty"), + ContainsText: _lt("Contains"), + NotContains: _lt("Does not contain"), + BeginsWith: _lt("Starts with"), + EndsWith: _lt("Ends with"), + Equal: _lt("Is equal to"), + NotEqual: _lt("Is not equal to"), + GreaterThan: _lt("Is greater than"), + GreaterThanOrEqual: _lt("Is greater than or equal to"), + LessThan: _lt("Is less than"), + LessThanOrEqual: _lt("Is less than or equal to"), + Between: _lt("Is between"), + NotBetween: _lt("Is not between"), + }; + const ChartTerms = { + Series: _lt("Series"), + Errors: { + Unexpected: _lt("The chart definition is invalid for an unknown reason"), + // BASIC CHART ERRORS (LINE | BAR | PIE) + [29 /* InvalidDataSet */]: _lt("The dataset is invalid"), + [30 /* InvalidLabelRange */]: _lt("Labels are invalid"), + // SCORECARD CHART ERRORS + [31 /* InvalidScorecardKeyValue */]: _lt("The key value is invalid"), + [32 /* InvalidScorecardBaseline */]: _lt("The baseline value is invalid"), + // GAUGE CHART ERRORS + [33 /* InvalidGaugeDataRange */]: _lt("The data range is invalid"), + [34 /* EmptyGaugeRangeMin */]: _lt("A minimum range limit value is needed"), + [35 /* GaugeRangeMinNaN */]: _lt("The minimum range limit value must be a number"), + [36 /* EmptyGaugeRangeMax */]: _lt("A maximum range limit value is needed"), + [37 /* GaugeRangeMaxNaN */]: _lt("The maximum range limit value must be a number"), + [38 /* GaugeRangeMinBiggerThanRangeMax */]: _lt("Minimum range limit must be smaller than maximum range limit"), + [39 /* GaugeLowerInflectionPointNaN */]: _lt("The lower inflection point value must be a number"), + [40 /* GaugeUpperInflectionPointNaN */]: _lt("The upper inflection point value must be a number"), + }, + }; + const NumberFormatTerms = { + Automatic: _lt("Automatic"), + Number: _lt("Number"), + Percent: _lt("Percent"), + Currency: _lt("Currency"), + CurrencyRounded: _lt("Currency rounded"), + Date: _lt("Date"), + Time: _lt("Time"), + DateTime: _lt("Date time"), + Duration: _lt("Duration"), + CustomCurrency: _lt("Custom currency"), + }; + const CustomCurrencyTerms = { + Custom: _lt("Custom"), + }; + + const topbarMenuRegistry = new MenuItemRegistry(); + topbarMenuRegistry + .add("file", { name: _lt("File"), sequence: 10 }) + .add("edit", { name: _lt("Edit"), sequence: 20 }) + .add("view", { name: _lt("View"), sequence: 30 }) + .add("insert", { name: _lt("Insert"), sequence: 40 }) + .add("format", { name: _lt("Format"), sequence: 50 }) + .add("data", { name: _lt("Data"), sequence: 60 }) + .addChild("save", ["file"], { + name: _lt("Save"), + description: "Ctrl+S", + sequence: 10, + action: () => console.log("Not implemented"), + }) + .addChild("undo", ["edit"], { + name: _lt("Undo"), + description: "Ctrl+Z", + sequence: 10, + action: UNDO_ACTION, + }) + .addChild("redo", ["edit"], { + name: _lt("Redo"), + description: "Ctrl+Y", + sequence: 20, + action: REDO_ACTION, + separator: true, + }) + .addChild("copy", ["edit"], { + name: _lt("Copy"), + description: "Ctrl+C", + sequence: 30, + isReadonlyAllowed: true, + action: COPY_ACTION, + }) + .addChild("cut", ["edit"], { + name: _lt("Cut"), + description: "Ctrl+X", + sequence: 40, + action: CUT_ACTION, + }) + .addChild("paste", ["edit"], { + name: _lt("Paste"), + description: "Ctrl+V", + sequence: 50, + action: PASTE_ACTION, + }) + .addChild("paste_special", ["edit"], { + name: _lt("Paste special"), + sequence: 60, + separator: true, + isVisible: IS_NOT_CUT_OPERATION, + }) + .addChild("paste_special_value", ["edit", "paste_special"], { + name: _lt("Paste value only"), + sequence: 10, + action: PASTE_VALUE_ACTION, + }) + .addChild("paste_special_format", ["edit", "paste_special"], { + name: _lt("Paste format only"), + sequence: 20, + action: PASTE_FORMAT_ACTION, + }) + .addChild("sort_range", ["data"], { + name: _lt("Sort range"), + sequence: 62, + isEnabled: IS_ONLY_ONE_RANGE, + separator: true, + }) + .addChild("sort_ascending", ["data", "sort_range"], { + name: _lt("Ascending (A ⟶ Z)"), + sequence: 10, + action: SORT_CELLS_ASCENDING, + }) + .addChild("sort_descending", ["data", "sort_range"], { + name: _lt("Descending (Z ⟶ A)"), + sequence: 20, + action: SORT_CELLS_DESCENDING, + }) + .addChild("find_and_replace", ["edit"], { + name: _lt("Find and replace"), + description: "Ctrl+H", + sequence: 65, + isReadonlyAllowed: true, + action: OPEN_FAR_SIDEPANEL_ACTION, + separator: true, + }) + .addChild("edit_delete_cell_values", ["edit"], { + name: _lt("Delete values"), + sequence: 70, + action: DELETE_CONTENT_ACTION, + }) + .addChild("edit_delete_row", ["edit"], { + name: REMOVE_ROWS_NAME, + sequence: 80, + action: REMOVE_ROWS_ACTION, + }) + .addChild("edit_delete_column", ["edit"], { + name: REMOVE_COLUMNS_NAME, + sequence: 90, + action: REMOVE_COLUMNS_ACTION, + }) + .addChild("edit_delete_cell_shift_up", ["edit"], { + name: _lt("Delete cell and shift up"), + sequence: 93, + action: DELETE_CELL_SHIFT_UP, + }) + .addChild("edit_delete_cell_shift_left", ["edit"], { + name: _lt("Delete cell and shift left"), + sequence: 97, + action: DELETE_CELL_SHIFT_LEFT, + }) + .addChild("edit_unhide_columns", ["edit"], { + name: _lt("Unhide all columns"), + sequence: 100, + action: UNHIDE_ALL_COLUMNS_ACTION, + isVisible: (env) => env.model.getters.getHiddenColsGroups(env.model.getters.getActiveSheetId()).length > 0, + }) + .addChild("edit_unhide_rows", ["edit"], { + name: _lt("Unhide all rows"), + sequence: 100, + action: UNHIDE_ALL_ROWS_ACTION, + isVisible: (env) => env.model.getters.getHiddenRowsGroups(env.model.getters.getActiveSheetId()).length > 0, + }) + .addChild("insert_row_before", ["insert"], { + name: MENU_INSERT_ROWS_BEFORE_NAME, + sequence: 10, + action: INSERT_ROWS_BEFORE_ACTION, + isVisible: (env) => env.model.getters.getActiveCols().size === 0, + }) + .addChild("insert_row_after", ["insert"], { + name: MENU_INSERT_ROWS_AFTER_NAME, + sequence: 20, + action: INSERT_ROWS_AFTER_ACTION, + isVisible: (env) => env.model.getters.getActiveCols().size === 0, + separator: true, + }) + .addChild("insert_column_before", ["insert"], { + name: MENU_INSERT_COLUMNS_BEFORE_NAME, + sequence: 30, + action: INSERT_COLUMNS_BEFORE_ACTION, + isVisible: (env) => env.model.getters.getActiveRows().size === 0, + }) + .addChild("insert_column_after", ["insert"], { + name: MENU_INSERT_COLUMNS_AFTER_NAME, + sequence: 40, + action: INSERT_COLUMNS_AFTER_ACTION, + isVisible: (env) => env.model.getters.getActiveRows().size === 0, + separator: true, + }) + .addChild("insert_insert_cell_shift_down", ["insert"], { + name: _lt("Insert cells and shift down"), + sequence: 43, + action: INSERT_CELL_SHIFT_DOWN, + }) + .addChild("insert_insert_cell_shift_right", ["insert"], { + name: _lt("Insert cells and shift right"), + sequence: 47, + action: INSERT_CELL_SHIFT_RIGHT, + separator: true, + }) + .addChild("insert_chart", ["insert"], { + name: _lt("Chart"), + sequence: 50, + action: CREATE_CHART, + }) + .addChild("insert_link", ["insert"], { + name: _lt("Link"), + separator: true, + sequence: 60, + action: INSERT_LINK, + }) + .addChild("insert_sheet", ["insert"], { + name: _lt("New sheet"), + sequence: 70, + action: CREATE_SHEET_ACTION, + separator: true, + }) + .addChild("view_gridlines", ["view"], { + name: (env) => env.model.getters.getGridLinesVisibility(env.model.getters.getActiveSheetId()) + ? _lt("Hide gridlines") + : _lt("Show gridlines"), + action: SET_GRID_LINES_VISIBILITY_ACTION, + sequence: 5, + separator: true, + }) + .addChild("view_formulas", ["view"], { + name: (env) => env.model.getters.shouldShowFormulas() ? _lt("Hide formulas") : _lt("Show formulas"), + action: SET_FORMULA_VISIBILITY_ACTION, + isReadonlyAllowed: true, + sequence: 10, + }) + .addChild("format_number", ["format"], { + name: _lt("Numbers"), + sequence: 10, + separator: true, + }) + .addChild("format_number_automatic", ["format", "format_number"], { + name: NumberFormatTerms.Automatic, + sequence: 10, + separator: true, + action: FORMAT_AUTOMATIC_ACTION, + }) + .addChild("format_number_number", ["format", "format_number"], { + name: NumberFormatTerms.Number, + description: "1,000.12", + sequence: 20, + action: FORMAT_NUMBER_ACTION, + }) + .addChild("format_number_percent", ["format", "format_number"], { + name: NumberFormatTerms.Percent, + description: "10.12%", + sequence: 30, + separator: true, + action: FORMAT_PERCENT_ACTION, + }) + .addChild("format_number_currency", ["format", "format_number"], { + name: NumberFormatTerms.Currency, + description: "$1,000.12", + sequence: 37, + action: FORMAT_CURRENCY_ACTION, + }) + .addChild("format_number_currency_rounded", ["format", "format_number"], { + name: NumberFormatTerms.CurrencyRounded, + description: "$1,000", + sequence: 38, + action: FORMAT_CURRENCY_ROUNDED_ACTION, + }) + .addChild("format_custom_currency", ["format", "format_number"], { + name: NumberFormatTerms.CustomCurrency, + sequence: 39, + separator: true, + action: OPEN_CUSTOM_CURRENCY_SIDEPANEL_ACTION, + }) + .addChild("format_number_date", ["format", "format_number"], { + name: NumberFormatTerms.Date, + description: "9/26/2008", + sequence: 40, + action: FORMAT_DATE_ACTION, + }) + .addChild("format_number_time", ["format", "format_number"], { + name: NumberFormatTerms.Time, + description: "10:43:00 PM", + sequence: 50, + action: FORMAT_TIME_ACTION, + }) + .addChild("format_number_date_time", ["format", "format_number"], { + name: NumberFormatTerms.DateTime, + description: "9/26/2008 22:43:00", + sequence: 60, + action: FORMAT_DATE_TIME_ACTION, + }) + .addChild("format_number_duration", ["format", "format_number"], { + name: NumberFormatTerms.Duration, + description: "27:51:38", + sequence: 70, + separator: true, + action: FORMAT_DURATION_ACTION, + }) + .addChild("format_bold", ["format"], { + name: _lt("Bold"), + sequence: 20, + description: "Ctrl+B", + action: FORMAT_BOLD_ACTION, + }) + .addChild("format_italic", ["format"], { + name: _lt("Italic"), + sequence: 30, + description: "Ctrl+I", + action: FORMAT_ITALIC_ACTION, + }) + .addChild("format_underline", ["format"], { + name: _lt("Underline"), + description: "Ctrl+U", + sequence: 40, + action: FORMAT_UNDERLINE_ACTION, + }) + .addChild("format_strikethrough", ["format"], { + name: _lt("Strikethrough"), + sequence: 50, + action: FORMAT_STRIKETHROUGH_ACTION, + separator: true, + }) + .addChild("format_font_size", ["format"], { + name: _lt("Font size"), + sequence: 60, + separator: true, + }) + .addChild("format_cf", ["format"], { + name: _lt("Conditional formatting"), + sequence: 70, + action: OPEN_CF_SIDEPANEL_ACTION, + separator: true, + }) + .addChild("format_clearFormat", ["format"], { + name: _lt("Clear formatting"), + sequence: 80, + action: FORMAT_CLEARFORMAT_ACTION, + separator: true, + }); + // Font-sizes + for (let fs of fontSizes) { + topbarMenuRegistry.addChild(`format_font_size_${fs.pt}`, ["format", "format_font_size"], { + name: fs.pt.toString(), + sequence: fs.pt, + action: (env) => setStyle(env, { fontSize: fs.pt }), + }); + } + + class OTRegistry extends Registry { + /** + * Add a transformation function to the registry. When the executed command + * happened, all the commands in toTransforms should be transformed using the + * transformation function given + */ + addTransformation(executed, toTransforms, fn) { + for (let toTransform of toTransforms) { + if (!this.content[toTransform]) { + this.content[toTransform] = new Map(); + } + this.content[toTransform].set(executed, fn); + } + return this; + } + /** + * Get the transformation function to transform the command toTransform, after + * that the executed command happened. + */ + getTransformation(toTransform, executed) { + return this.content[toTransform] && this.content[toTransform].get(executed); + } + } + const otRegistry = new OTRegistry(); + + const uuidGenerator$1 = new UuidGenerator(); + css /* scss */ ` + .o-selection { + .o-selection-input { + display: flex; + flex-direction: row; + + input { + padding: 4px 6px; + border-radius: 4px; + box-sizing: border-box; + flex-grow: 2; + } + input:focus { + outline: none; + } + input.o-required, + input.o-focused { + border-width: 2px; + padding: 3px 5px; + } + input.o-focused { + border-color: ${SELECTION_BORDER_COLOR}; + } + input.o-invalid { + border-color: red; + } + button.o-btn { + background: transparent; + border: none; + color: #333; + cursor: pointer; + } + button.o-btn-action { + margin: 8px 1px; + border-radius: 4px; + background: transparent; + border: 1px solid #dadce0; + color: #188038; + font-weight: bold; + font-size: 14px; + height: 25px; + } + } + } +`; + /** + * This component can be used when the user needs to input some + * ranges. He can either input the ranges with the regular DOM `` + * displayed or by selecting zones on the grid. + * + * onSelectionChanged is called every time the input value + * changes. + */ + class SelectionInput extends owl.Component { + constructor() { + super(...arguments); + this.id = uuidGenerator$1.uuidv4(); + this.previousRanges = this.props.ranges || []; + this.originSheet = this.env.model.getters.getActiveSheetId(); + this.state = owl.useState({ + isMissing: false, + }); + } + get ranges() { + const existingSelectionRange = this.env.model.getters.getSelectionInput(this.id); + const ranges = existingSelectionRange.length + ? existingSelectionRange + : this.props.ranges + ? this.props.ranges.map((xc, i) => ({ + xc, + id: i.toString(), + isFocused: false, + })) + : []; + return ranges.map((range) => ({ + ...range, + isValidRange: range.xc === "" || this.env.model.getters.isRangeValid(range.xc), + })); + } + get hasFocus() { + return this.ranges.filter((i) => i.isFocused).length > 0; + } + get canAddRange() { + return !this.props.hasSingleRange; + } + get isInvalid() { + return this.props.isInvalid || this.state.isMissing; + } + setup() { + owl.onMounted(() => this.enableNewSelectionInput()); + owl.onWillUnmount(async () => this.disableNewSelectionInput()); + owl.onPatched(() => this.checkChange()); + } + enableNewSelectionInput() { + this.env.model.dispatch("ENABLE_NEW_SELECTION_INPUT", { + id: this.id, + initialRanges: this.props.ranges, + hasSingleRange: this.props.hasSingleRange, + }); + } + disableNewSelectionInput() { + this.env.model.dispatch("DISABLE_SELECTION_INPUT", { id: this.id }); + } + checkChange() { + const value = this.env.model.getters.getSelectionInputValue(this.id); + if (this.previousRanges.join() !== value.join()) { + this.triggerChange(); + } + } + getColor(range) { + const color = range.color || "#000"; + return "color: " + color + ";"; + } + triggerChange() { + var _a, _b; + const ranges = this.env.model.getters.getSelectionInputValue(this.id); + (_b = (_a = this.props).onSelectionChanged) === null || _b === void 0 ? void 0 : _b.call(_a, ranges); + this.previousRanges = ranges; + } + focus(rangeId) { + this.state.isMissing = false; + this.env.model.dispatch("FOCUS_RANGE", { + id: this.id, + rangeId, + }); + } + addEmptyInput() { + this.env.model.dispatch("ADD_EMPTY_RANGE", { id: this.id }); + } + removeInput(rangeId) { + var _a, _b; + this.env.model.dispatch("REMOVE_RANGE", { id: this.id, rangeId }); + this.triggerChange(); + (_b = (_a = this.props).onSelectionConfirmed) === null || _b === void 0 ? void 0 : _b.call(_a); + } + onInputChanged(rangeId, ev) { + const target = ev.target; + this.env.model.dispatch("CHANGE_RANGE", { + id: this.id, + rangeId, + value: target.value, + }); + target.blur(); + this.triggerChange(); + } + disable() { + var _a, _b; + this.env.model.dispatch("UNFOCUS_SELECTION_INPUT"); + const ranges = this.env.model.getters.getSelectionInputValue(this.id); + if (this.props.required && ranges.length === 0) { + this.state.isMissing = true; + } + const activeSheetId = this.env.model.getters.getActiveSheetId(); + if (this.originSheet !== activeSheetId) { + this.env.model.dispatch("ACTIVATE_SHEET", { + sheetIdFrom: activeSheetId, + sheetIdTo: this.originSheet, + }); + } + (_b = (_a = this.props).onSelectionConfirmed) === null || _b === void 0 ? void 0 : _b.call(_a); + } + } + SelectionInput.template = "o-spreadsheet-SelectionInput"; + + class LineBarPieConfigPanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + datasetDispatchResult: undefined, + labelsDispatchResult: undefined, + }); + this.dataSeriesRanges = []; + } + setup() { + this.dataSeriesRanges = this.props.definition.dataSets; + this.labelRange = this.props.definition.labelRange; + } + get errorMessages() { + var _a, _b; + const cancelledReasons = [ + ...(((_a = this.state.datasetDispatchResult) === null || _a === void 0 ? void 0 : _a.reasons) || []), + ...(((_b = this.state.labelsDispatchResult) === null || _b === void 0 ? void 0 : _b.reasons) || []), + ]; + return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); + } + get isDatasetInvalid() { + var _a; + return !!((_a = this.state.datasetDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(29 /* InvalidDataSet */)); + } + get isLabelInvalid() { + var _a; + return !!((_a = this.state.labelsDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(30 /* InvalidLabelRange */)); + } + onUpdateDataSetsHaveTitle(ev) { + this.props.updateChart({ + dataSetsHaveTitle: ev.target.checked, + }); + } + /** + * Change the local dataSeriesRanges. The model should be updated when the + * button "confirm" is clicked + */ + onDataSeriesRangesChanged(ranges) { + this.dataSeriesRanges = ranges; + } + onDataSeriesConfirmed() { + this.state.datasetDispatchResult = this.props.updateChart({ + dataSets: this.dataSeriesRanges, + }); + } + /** + * Change the local labelRange. The model should be updated when the + * button "confirm" is clicked + */ + onLabelRangeChanged(ranges) { + this.labelRange = ranges[0]; + } + onLabelRangeConfirmed() { + this.state.labelsDispatchResult = this.props.updateChart({ + labelRange: this.labelRange, + }); + } + } + LineBarPieConfigPanel.template = "o-spreadsheet-LineBarPieConfigPanel"; + LineBarPieConfigPanel.components = { SelectionInput }; + + class BarConfigPanel extends LineBarPieConfigPanel { + onUpdateStacked(ev) { + this.props.updateChart({ + stackedBar: ev.target.checked, + }); + } + } + BarConfigPanel.template = "o-spreadsheet-BarConfigPanel"; + + /** + * AbstractChart is the class from which every Chart should inherit. + * The role of this class is to maintain the state of each chart. + */ + class AbstractChart { + constructor(definition, sheetId, getters) { + this.title = definition.title; + this.sheetId = sheetId; + this.getters = getters; + } + /** + * Validate the chart definition given as arguments. This function will be + * called from allowDispatch function + */ + static validateChartDefinition(validator, definition) { + throw new Error("This method should be implemented by sub class"); + } + /** + * Get a new chart definition transformed with the executed command. This + * functions will be called during operational transform process + */ + static transformDefinition(definition, executed) { + throw new Error("This method should be implemented by sub class"); + } + /** + * Get an empty definition based on the given context + */ + static getDefinitionFromContextCreation(context) { + throw new Error("This method should be implemented by sub class"); + } + } + + /** + * Convert a JS color hexadecimal to an excel compatible color. + * + * In Excel the color don't start with a '#' and the format is AARRGGBB instead of RRGGBBAA + */ + function toXlsxHexColor(color) { + color = toHex(color).replace("#", ""); + // alpha channel goes first + if (color.length === 8) { + return color.slice(6) + color.slice(0, 6); + } + return color; + } + + function transformZone(zone, executed) { + if (executed.type === "REMOVE_COLUMNS_ROWS") { + return reduceZoneOnDeletion(zone, executed.dimension === "COL" ? "left" : "top", executed.elements); + } + if (executed.type === "ADD_COLUMNS_ROWS") { + return expandZoneOnInsertion(zone, executed.dimension === "COL" ? "left" : "top", executed.base, executed.position, executed.quantity); + } + return { ...zone }; + } + + // HELPERS + function assert(condition, message) { + if (!condition()) { + throw new Error(message); + } + } + // ----------------------------------------------------------------------------- + // FORMAT FUNCTIONS + // ----------------------------------------------------------------------------- + const expectNumberValueError = (value) => _lt("The function [[FUNCTION_NAME]] expects a number value, but '%s' is a string, and cannot be coerced to a number.", value); + function toNumber(value) { + switch (typeof value) { + case "number": + return value; + case "boolean": + return value ? 1 : 0; + case "string": + if (isNumber(value) || value === "") { + return parseNumber(value); + } + const internalDate = parseDateTime(value); + if (internalDate) { + return internalDate.value; + } + throw new Error(expectNumberValueError(value)); + default: + return 0; + } + } + function strictToNumber(value) { + if (value === "") { + throw new Error(expectNumberValueError(value)); + } + return toNumber(value); + } + function toString(value) { + switch (typeof value) { + case "string": + return value; + case "number": + return value.toString(); + case "boolean": + return value ? "TRUE" : "FALSE"; + default: + return ""; + } + } + const expectBooleanValueError = (value) => _lt("The function [[FUNCTION_NAME]] expects a boolean value, but '%s' is a text, and cannot be coerced to a number.", value); + function toBoolean(value) { + switch (typeof value) { + case "boolean": + return value; + case "string": + if (value) { + let uppercaseVal = value.toUpperCase(); + if (uppercaseVal === "TRUE") { + return true; + } + if (uppercaseVal === "FALSE") { + return false; + } + throw new Error(expectBooleanValueError(value)); + } + else { + return false; + } + case "number": + return value ? true : false; + default: + return false; + } + } + function strictToBoolean(value) { + if (value === "") { + throw new Error(expectBooleanValueError(value)); + } + return toBoolean(value); + } + function toJsDate(value) { + return numberToJsDate(toNumber(value)); + } + // ----------------------------------------------------------------------------- + // VISIT FUNCTIONS + // ----------------------------------------------------------------------------- + function visitArgs(args, cellCb, dataCb) { + for (let arg of args) { + if (Array.isArray(arg)) { + // arg is ref to a Cell/Range + const lenRow = arg.length; + const lenCol = arg[0].length; + for (let y = 0; y < lenCol; y++) { + for (let x = 0; x < lenRow; x++) { + cellCb(arg[x][y]); + } + } + } + else { + // arg is set directly in the formula function + dataCb(arg); + } + } + } + function visitAny(args, cb) { + visitArgs(args, cb, cb); + } + function visitNumbers(args, cb) { + visitArgs(args, (cellValue) => { + if (typeof cellValue === "number") { + cb(cellValue); + } + }, (argValue) => { + cb(strictToNumber(argValue)); + }); + } + // ----------------------------------------------------------------------------- + // REDUCE FUNCTIONS + // ----------------------------------------------------------------------------- + function reduceArgs(args, cellCb, dataCb, initialValue) { + let val = initialValue; + for (let arg of args) { + if (Array.isArray(arg)) { + // arg is ref to a Cell/Range + const lenRow = arg.length; + const lenCol = arg[0].length; + for (let y = 0; y < lenCol; y++) { + for (let x = 0; x < lenRow; x++) { + val = cellCb(val, arg[x][y]); + } + } + } + else { + // arg is set directly in the formula function + val = dataCb(val, arg); + } + } + return val; + } + function reduceAny(args, cb, initialValue) { + return reduceArgs(args, cb, cb, initialValue); + } + function reduceNumbers(args, cb, initialValue) { + return reduceArgs(args, (acc, ArgValue) => { + if (typeof ArgValue === "number") { + return cb(acc, ArgValue); + } + return acc; + }, (acc, argValue) => { + return cb(acc, strictToNumber(argValue)); + }, initialValue); + } + function reduceNumbersTextAs0(args, cb, initialValue) { + return reduceArgs(args, (acc, ArgValue) => { + if (ArgValue !== undefined && ArgValue !== null) { + if (typeof ArgValue === "number") { + return cb(acc, ArgValue); + } + else if (typeof ArgValue === "boolean") { + return cb(acc, toNumber(ArgValue)); + } + else { + return cb(acc, 0); + } + } + return acc; + }, (acc, argValue) => { + return cb(acc, toNumber(argValue)); + }, initialValue); + } + // ----------------------------------------------------------------------------- + // CONDITIONAL EXPLORE FUNCTIONS + // ----------------------------------------------------------------------------- + /** + * This function allows to visit arguments and stop the visit if necessary. + * It is mainly used to bypass argument evaluation for functions like OR or AND. + */ + function conditionalVisitArgs(args, cellCb, dataCb) { + for (let arg of args) { + if (Array.isArray(arg)) { + // arg is ref to a Cell/Range + const lenRow = arg.length; + const lenCol = arg[0].length; + for (let y = 0; y < lenCol; y++) { + for (let x = 0; x < lenRow; x++) { + if (!cellCb(arg[x][y])) + return; + } + } + } + else { + // arg is set directly in the formula function + if (!dataCb(arg)) + return; + } + } + } + function conditionalVisitBoolean(args, cb) { + return conditionalVisitArgs(args, (ArgValue) => { + if (typeof ArgValue === "boolean") { + return cb(ArgValue); + } + if (typeof ArgValue === "number") { + return cb(ArgValue ? true : false); + } + return true; + }, (argValue) => { + if (argValue !== undefined && argValue !== null) { + return cb(strictToBoolean(argValue)); + } + return true; + }); + } + function getPredicate(descr, isQuery) { + let operator; + let operand; + let subString = descr.substring(0, 2); + if (subString === "<=" || subString === ">=" || subString === "<>") { + operator = subString; + operand = descr.substring(2); + } + else { + subString = descr.substring(0, 1); + if (subString === "<" || subString === ">" || subString === "=") { + operator = subString; + operand = descr.substring(1); + } + else { + operator = "="; + operand = descr; + } + } + if (isNumber(operand)) { + operand = toNumber(operand); + } + else if (operand === "TRUE" || operand === "FALSE") { + operand = toBoolean(operand); + } + const result = { operator, operand }; + if (typeof operand === "string") { + if (isQuery) { + operand += "*"; + } + result.regexp = operandToRegExp(operand); + } + return result; + } + function operandToRegExp(operand) { + let exp = ""; + let predecessor = ""; + for (let char of operand) { + if (char === "?" && predecessor !== "~") { + exp += "."; + } + else if (char === "*" && predecessor !== "~") { + exp += ".*"; + } + else { + if (char === "*" || char === "?") { + //remove "~" + exp = exp.slice(0, -1); + } + if (["^", ".", "[", "]", "$", "(", ")", "*", "+", "?", "|", "{", "}", "\\"].includes(char)) { + exp += "\\"; + } + exp += char; + } + predecessor = char; + } + return new RegExp("^" + exp + "$", "i"); + } + function evaluatePredicate(value, criterion) { + const { operator, operand } = criterion; + if (value === undefined || operand === undefined) { + return false; + } + if (typeof operand === "number" && operator === "=") { + return toString(value) === toString(operand); + } + if (operator === "<>" || operator === "=") { + let result; + if (typeof value === typeof operand) { + if (typeof value === "string" && criterion.regexp) { + result = criterion.regexp.test(value); + } + else { + result = value === operand; + } + } + else { + result = false; + } + return operator === "=" ? result : !result; + } + if (typeof value === typeof operand) { + switch (operator) { + case "<": + return value < operand; + case ">": + return value > operand; + case "<=": + return value <= operand; + case ">=": + return value >= operand; + } + } + return false; + } + /** + * Functions used especially for predicate evaluation on ranges. + * + * Take ranges with same dimensions and take predicates, one for each range. + * For (i, j) coordinates, if all elements with coordinates (i, j) of each + * range correspond to the associated predicate, then the function uses a callback + * function with the parameters "i" and "j". + * + * Syntax: + * visitMatchingRanges([range1, predicate1, range2, predicate2, ...], cb(i,j), likeSelection) + * + * - range1 (range): The range to check against predicate1. + * - predicate1 (string): The pattern or test to apply to range1. + * - range2: (range, repeatable) ranges to check. + * - predicate2 (string, repeatable): Additional pattern or test to apply to range2. + * + * - cb(i: number, j: number) => void: the callback function. + * + * - isQuery (boolean) indicates if the comparison with a string should be done as a SQL-like query. + * (Ex1 isQuery = true, predicate = "abc", element = "abcde": predicate match the element), + * (Ex2 isQuery = false, predicate = "abc", element = "abcde": predicate not match the element). + * (Ex3 isQuery = true, predicate = "abc", element = "abc": predicate match the element), + * (Ex4 isQuery = false, predicate = "abc", element = "abc": predicate match the element). + */ + function visitMatchingRanges(args, cb, isQuery = false) { + const countArg = args.length; + if (countArg % 2 === 1) { + throw new Error(_lt(`Function [[FUNCTION_NAME]] expects criteria_range and criterion to be in pairs.`)); + } + const dimRow = args[0].length; + const dimCol = args[0][0].length; + let predicates = []; + for (let i = 0; i < countArg - 1; i += 2) { + const criteriaRange = args[i]; + if (!Array.isArray(criteriaRange) || + criteriaRange.length !== dimRow || + criteriaRange[0].length !== dimCol) { + throw new Error(_lt(`Function [[FUNCTION_NAME]] expects criteria_range to have the same dimension`)); + } + const description = toString(args[i + 1]); + predicates.push(getPredicate(description, isQuery)); + } + for (let i = 0; i < dimRow; i++) { + for (let j = 0; j < dimCol; j++) { + let validatedPredicates = true; + for (let k = 0; k < countArg - 1; k += 2) { + const criteriaValue = args[k][i][j]; + const criterion = predicates[k / 2]; + validatedPredicates = evaluatePredicate(criteriaValue, criterion); + if (!validatedPredicates) { + break; + } + } + if (validatedPredicates) { + cb(i, j); + } + } + } + } + // ----------------------------------------------------------------------------- + // COMMON FUNCTIONS + // ----------------------------------------------------------------------------- + /** + * Perform a dichotomic search and return the index of the nearest match less than + * or equal to the target. If all values in the range are greater than the target, + * -1 is returned. + * If the range is not in sorted order, an incorrect value might be returned. + * + * Example: + * - [3, 6, 10], 3 => 0 + * - [3, 6, 10], 6 => 1 + * - [3, 6, 10], 9 => 1 + * - [3, 6, 10], 42 => 2 + * - [3, 6, 10], 2 => -1 + * - [3, undefined, 6, undefined, 10], 9 => 2 + * - [3, 6, undefined, undefined, undefined, 10], 2 => -1 + */ + function dichotomicPredecessorSearch(range, target) { + if (target === null) { + return -1; + } + const targetType = typeof target; + let valMin = undefined; + let valMinIndex = undefined; + let indexLeft = 0; + let indexRight = range.length - 1; + if (typeof range[indexLeft] === targetType && target < range[indexLeft]) { + return -1; + } + if (typeof range[indexRight] === targetType && range[indexRight] <= target) { + return indexRight; + } + let indexMedian; + let currentIndex; + let currentVal; + let currentType; + while (indexRight - indexLeft >= 0) { + indexMedian = Math.ceil((indexLeft + indexRight) / 2); + currentIndex = indexMedian; + currentVal = range[currentIndex]; + currentType = typeof currentVal; + // 1 - linear search to find value with the same type + while (indexLeft <= currentIndex && targetType !== currentType) { + currentIndex--; + currentVal = range[currentIndex]; + currentType = typeof currentVal; + } + // 2 - check if value match + if (currentType === targetType && currentVal <= target) { + if (valMin === undefined || + valMin < currentVal || + (valMin === currentVal && valMinIndex < currentIndex)) { + valMin = currentVal; + valMinIndex = currentIndex; + } + } + // 3 - give new indexs for the Binary search + if (currentType === targetType && currentVal > target) { + indexRight = currentIndex - 1; + } + else { + indexLeft = indexMedian + 1; + } + } + // note that valMinIndex could be 0 + return valMinIndex !== undefined ? valMinIndex : -1; + } + /** + * Perform a dichotomic search and return the index of the nearest match more than + * or equal to the target. If all values in the range are smaller than the target, + * -1 is returned. + * If the range is not in sorted order, an incorrect value might be returned. + * + * Example: + * - [10, 6, 3], 3 => 2 + * - [10, 6, 3], 6 => 1 + * - [10, 6, 3], 9 => 0 + * - [10, 6, 3], 42 => -1 + * - [10, 6, 3], 2 => 2 + * - [10, undefined, 6, undefined, 3], 9 => 0 + * - [10, 6, undefined, undefined, undefined, 3], 2 => 5 + */ + function dichotomicSuccessorSearch(range, target) { + const targetType = typeof target; + let valMax; + let valMaxIndex = undefined; + let indexLeft = 0; + let indexRight = range.length - 1; + if (typeof range[indexLeft] === targetType && target > range[indexLeft]) { + return -1; + } + if (typeof range[indexRight] === targetType && range[indexRight] > target) { + return indexRight; + } + let indexMedian; + let currentIndex; + let currentVal; + let currentType; + while (indexRight - indexLeft >= 0) { + indexMedian = Math.ceil((indexLeft + indexRight) / 2); + currentIndex = indexMedian; + currentVal = range[currentIndex]; + currentType = typeof currentVal; + // 1 - linear search to find value with the same type + while (indexLeft <= currentIndex && targetType !== currentType) { + currentIndex--; + currentVal = range[currentIndex]; + currentType = typeof currentVal; + } + // 2 - check if value match + if (currentType === targetType && currentVal >= target) { + if (valMax === undefined || + valMax > currentVal || + (valMax === currentVal && valMaxIndex > currentIndex)) { + valMax = currentVal; + valMaxIndex = currentIndex; + } + } + // 3 - give new indexs for the Binary search + if (currentType === targetType && currentVal <= target) { + indexRight = currentIndex - 1; + } + else { + indexLeft = indexMedian + 1; + } + } + // note that valMaxIndex could be 0 + return valMaxIndex !== undefined ? valMaxIndex : -1; + } + + /** + * This file contains helpers that are common to different charts (mainly + * line, bar and pie charts) + */ + /** + * Adapt ranges of a chart which support DataSet (dataSets and LabelRange). + */ + function updateChartRangesWithDataSets(getters, applyChange, chartDataSets, chartLabelRange) { + let isStale = false; + const dataSetsWithUndefined = []; + for (let index in chartDataSets) { + let ds = chartDataSets[index]; + if (ds.labelCell) { + const labelCell = adaptChartRange(ds.labelCell, applyChange); + if (ds.labelCell !== labelCell) { + isStale = true; + ds = { + ...ds, + labelCell: labelCell, + }; + } + } + const dataRange = adaptChartRange(ds.dataRange, applyChange); + if (dataRange === undefined || + getters.getRangeString(dataRange, dataRange.sheetId) === INCORRECT_RANGE_STRING) { + isStale = true; + ds = undefined; + } + else if (dataRange !== ds.dataRange) { + isStale = true; + ds = { + ...ds, + dataRange, + }; + } + dataSetsWithUndefined[index] = ds; + } + let labelRange = chartLabelRange; + const range = adaptChartRange(labelRange, applyChange); + if (range !== labelRange) { + isStale = true; + labelRange = range; + } + const dataSets = dataSetsWithUndefined.filter(isDefined$1); + return { + isStale, + dataSets, + labelRange, + }; + } + /** + * Copy the dataSets given. All the ranges which are on sheetIdFrom will target + * sheetIdTo. + */ + function copyDataSetsWithNewSheetId(sheetIdFrom, sheetIdTo, dataSets) { + return dataSets.map((ds) => { + return { + dataRange: copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, ds.dataRange), + labelCell: ds.labelCell + ? copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, ds.labelCell) + : undefined, + }; + }); + } + /** + * Copy a range. If the range is on the sheetIdFrom, the range will target + * sheetIdTo. + */ + function copyLabelRangeWithNewSheetId(sheetIdFrom, sheetIdTo, range) { + return range ? copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, range) : undefined; + } + /** + * Adapt a single range of a chart + */ + function adaptChartRange(range, applyChange) { + if (!range) { + return undefined; + } + const change = applyChange(range); + switch (change.changeType) { + case "NONE": + return range; + case "REMOVE": + return undefined; + default: + return change.range; + } + } + /** + * Create the dataSet objects from xcs + */ + function createDataSets(getters, dataSetsString, sheetId, dataSetsHaveTitle) { + const dataSets = []; + for (const sheetXC of dataSetsString) { + const dataRange = getters.getRangeFromSheetXC(sheetId, sheetXC); + const { unboundedZone: zone, sheetId: dataSetSheetId, invalidSheetName } = dataRange; + if (invalidSheetName) { + continue; + } + // It's a rectangle. We treat all columns (arbitrary) as different data series. + if (zone.left !== zone.right && zone.top !== zone.bottom) { + if (zone.right === undefined) { + // Should never happens because of the allowDispatch of charts, but just making sure + continue; + } + for (let column = zone.left; column <= zone.right; column++) { + const columnZone = { + ...zone, + left: column, + right: column, + }; + dataSets.push(createDataSet(getters, dataSetSheetId, columnZone, dataSetsHaveTitle + ? { + top: columnZone.top, + bottom: columnZone.top, + left: columnZone.left, + right: columnZone.left, + } + : undefined)); + } + } + else if (zone.left === zone.right && zone.top === zone.bottom) { + // A single cell. If it's only the title, the dataset is not added. + if (!dataSetsHaveTitle) { + dataSets.push(createDataSet(getters, dataSetSheetId, zone, undefined)); + } + } + else { + /* 1 row or 1 column */ + dataSets.push(createDataSet(getters, dataSetSheetId, zone, dataSetsHaveTitle + ? { + top: zone.top, + bottom: zone.top, + left: zone.left, + right: zone.left, + } + : undefined)); + } + } + return dataSets; + } + function createDataSet(getters, sheetId, fullZone, titleZone) { + if (fullZone.left !== fullZone.right && fullZone.top !== fullZone.bottom) { + throw new Error(`Zone should be a single column or row: ${zoneToXc(fullZone)}`); + } + if (titleZone) { + const dataXC = zoneToXc(fullZone); + const labelCellXC = zoneToXc(titleZone); + return { + labelCell: getters.getRangeFromSheetXC(sheetId, labelCellXC), + dataRange: getters.getRangeFromSheetXC(sheetId, dataXC), + }; + } + else { + return { + labelCell: undefined, + dataRange: getters.getRangeFromSheetXC(sheetId, zoneToXc(fullZone)), + }; + } + } + /** + * Transform a dataSet to a ExcelDataSet + */ + function toExcelDataset(getters, ds) { + var _a; + const labelZone = (_a = ds.labelCell) === null || _a === void 0 ? void 0 : _a.zone; + let dataZone = ds.dataRange.zone; + if (labelZone) { + const { height, width } = zoneToDimension(dataZone); + if (height === 1) { + dataZone = { ...dataZone, left: dataZone.left + 1 }; + } + else if (width === 1) { + dataZone = { ...dataZone, top: dataZone.top + 1 }; + } + } + const dataRange = ds.dataRange.clone({ zone: dataZone }); + return { + label: ds.labelCell ? getters.getRangeString(ds.labelCell, "forceSheetReference") : undefined, + range: getters.getRangeString(dataRange, "forceSheetReference"), + }; + } + /** + * Transform a chart definition which supports dataSets (dataSets and LabelRange) + * with an executed command + */ + function transformChartDefinitionWithDataSetsWithZone(definition, executed) { + let labelRange; + if (definition.labelRange) { + const labelZone = transformZone(toUnboundedZone(definition.labelRange), executed); + labelRange = labelZone ? zoneToXc(labelZone) : undefined; + } + const dataSets = definition.dataSets + .map(toUnboundedZone) + .map((zone) => transformZone(zone, executed)) + .filter(isDefined$1) + .map(zoneToXc); + return { + ...definition, + labelRange, + dataSets, + }; + } + const GraphColors = [ + // the same colors as those used in odoo reporting + "rgb(31,119,180)", + "rgb(255,127,14)", + "rgb(174,199,232)", + "rgb(255,187,120)", + "rgb(44,160,44)", + "rgb(152,223,138)", + "rgb(214,39,40)", + "rgb(255,152,150)", + "rgb(148,103,189)", + "rgb(197,176,213)", + "rgb(140,86,75)", + "rgb(196,156,148)", + "rgb(227,119,194)", + "rgb(247,182,210)", + "rgb(127,127,127)", + "rgb(199,199,199)", + "rgb(188,189,34)", + "rgb(219,219,141)", + "rgb(23,190,207)", + "rgb(158,218,229)", + ]; + class ChartColors { + constructor() { + this.graphColorIndex = 0; + } + next() { + return GraphColors[this.graphColorIndex++ % GraphColors.length]; + } + } + /** + * Choose a font color based on a background color. + * The font is white with a dark background. + */ + function chartFontColor(backgroundColor) { + if (!backgroundColor) { + return "#000000"; + } + return relativeLuminance(backgroundColor) < 0.3 ? "#FFFFFF" : "#000000"; + } + function checkDataset(definition) { + if (definition.dataSets) { + const invalidRanges = definition.dataSets.find((range) => !rangeReference.test(range)) !== undefined; + if (invalidRanges) { + return 29 /* InvalidDataSet */; + } + const zones = definition.dataSets.map(toUnboundedZone); + if (zones.some((zone) => zone.top !== zone.bottom && isFullRow(zone))) { + return 29 /* InvalidDataSet */; + } + } + return 0 /* Success */; + } + function checkLabelRange(definition) { + if (definition.labelRange) { + const invalidLabels = !rangeReference.test(definition.labelRange || ""); + if (invalidLabels) { + return 30 /* InvalidLabelRange */; + } + } + return 0 /* Success */; + } + // --------------------------------------------------------------------------- + // Scorecard + // --------------------------------------------------------------------------- + function getBaselineText(baseline, keyValue, baselineMode) { + if (!baseline) { + return ""; + } + else if (baselineMode === "text" || !isNumber(keyValue) || !isNumber(baseline.content)) { + return baseline.formattedValue; + } + else { + let diff = toNumber(keyValue) - toNumber(baseline.content); + if (baselineMode === "percentage") { + diff = (diff / toNumber(baseline.content)) * 100; + } + const baselineValue = Math.abs(parseFloat(diff.toFixed(2))); + let baselineStr = baselineValue.toLocaleString(); + if (baselineMode === "percentage") { + baselineStr += "%"; + } + else if (baseline.format) { + baselineStr = formatValue(baselineValue, baseline.format); + } + return baselineStr; + } + } + function getBaselineColor(baseline, baselineMode, keyValue, colorUp, colorDown) { + if (baselineMode === "text" || !isNumber(baseline) || !isNumber(keyValue)) { + return undefined; + } + const diff = toNumber(keyValue) - toNumber(baseline); + if (diff > 0) { + return colorUp; + } + else if (diff < 0) { + return colorDown; + } + return undefined; + } + function getBaselineArrowDirection(baseline, keyValue, baselineMode) { + if (baselineMode === "text" || !isNumber(baseline) || !isNumber(keyValue)) { + return "neutral"; + } + const diff = toNumber(keyValue) - toNumber(baseline); + if (diff > 0) { + return "up"; + } + else if (diff < 0) { + return "down"; + } + return "neutral"; + } + + /** + * This file contains helpers that are common to different runtime charts (mainly + * line, bar and pie charts) + */ + /** + * Get the data from a dataSet + */ + function getData(getters, ds) { + if (ds.dataRange) { + const labelCellZone = ds.labelCell ? [zoneToXc(ds.labelCell.zone)] : []; + const dataXC = recomputeZones([zoneToXc(ds.dataRange.zone)], labelCellZone)[0]; + if (dataXC === undefined) { + return []; + } + const dataRange = getters.getRangeFromSheetXC(ds.dataRange.sheetId, dataXC); + return getters.getRangeValues(dataRange); + } + return []; + } + function filterEmptyDataPoints(labels, datasets) { + const numberOfDataPoints = Math.max(labels.length, ...datasets.map((dataset) => { var _a; return ((_a = dataset.data) === null || _a === void 0 ? void 0 : _a.length) || 0; })); + const dataPointsIndexes = range(0, numberOfDataPoints).filter((dataPointIndex) => { + const label = labels[dataPointIndex]; + const values = datasets.map((dataset) => { var _a; return (_a = dataset.data) === null || _a === void 0 ? void 0 : _a[dataPointIndex]; }); + return label || values.some((value) => value === 0 || Boolean(value)); + }); + return { + labels: dataPointsIndexes.map((i) => labels[i] || ""), + dataSetsValues: datasets.map((dataset) => ({ + ...dataset, + data: dataPointsIndexes.map((i) => dataset.data[i]), + })), + }; + } + function truncateLabel(label) { + if (!label) { + return ""; + } + if (label.length > MAX_CHAR_LABEL) { + return label.substring(0, MAX_CHAR_LABEL) + "…"; + } + return label; + } + /** + * Get a default chart js configuration + */ + function getDefaultChartJsRuntime(chart, labels, fontColor) { + return { + type: chart.type, + options: { + // https://www.chartjs.org/docs/latest/general/responsive.html + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }, + elements: { + line: { + fill: false, // do not fill the area under line charts + }, + point: { + hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby + }, + }, + animation: { + duration: 0, // general animation time + }, + hover: { + animationDuration: 10, // duration of animations when hovering an item + }, + responsiveAnimationDuration: 0, + title: { + display: !!chart.title, + fontSize: 22, + fontStyle: "normal", + text: chart.title, + fontColor, + }, + legend: { + // Disable default legend onClick (show/hide dataset), to allow us to set a global onClick on the chart container. + // If we want to re-enable this in the future, we need to override the default onClick to stop the event propagation + onClick: undefined, + }, + }, + data: { + labels: labels.map(truncateLabel), + datasets: [], + }, + }; + } + function getLabelFormat(getters, range) { + var _a; + if (!range) + return undefined; + return (_a = getters.getCell(range.sheetId, range.zone.left, range.zone.top)) === null || _a === void 0 ? void 0 : _a.evaluated.format; + } + function getChartLabelValues(getters, dataSets, labelRange) { + let labels = { values: [], formattedValues: [] }; + if (labelRange) { + if (!labelRange.invalidXc && !labelRange.invalidSheetName) { + labels = { + formattedValues: getters.getRangeFormattedValues(labelRange), + values: getters + .getRangeValues(labelRange) + .map((val) => (val !== undefined && val !== null ? String(val) : "")), + }; + } + } + else if (dataSets.length === 1) { + for (let i = 0; i < getData(getters, dataSets[0]).length; i++) { + labels.formattedValues.push(""); + labels.values.push(""); + } + } + else { + if (dataSets[0]) { + const ranges = getData(getters, dataSets[0]); + labels = { + formattedValues: range(0, ranges.length).map((r) => r.toString()), + values: labels.formattedValues, + }; + } + } + return labels; + } + function getChartDatasetValues(getters, dataSets) { + const datasetValues = []; + for (const [dsIndex, ds] of Object.entries(dataSets)) { + let label; + if (ds.labelCell) { + const labelRange = ds.labelCell; + const cell = labelRange + ? getters.getCell(labelRange.sheetId, labelRange.zone.left, labelRange.zone.top) + : undefined; + label = + cell && labelRange + ? truncateLabel(cell.formattedValue) + : (label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`); + } + else { + label = label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`; + } + let data = ds.dataRange ? getData(getters, ds) : []; + datasetValues.push({ data, label }); + } + return datasetValues; + } + + chartRegistry.add("bar", { + match: (type) => type === "bar", + createChart: (definition, sheetId, getters) => new BarChart(definition, sheetId, getters), + getChartRuntime: createBarChartRuntime, + validateChartDefinition: (validator, definition) => BarChart.validateChartDefinition(validator, definition), + transformDefinition: (definition, executed) => BarChart.transformDefinition(definition, executed), + getChartDefinitionFromContextCreation: (context) => BarChart.getDefinitionFromContextCreation(context), + name: "Bar", + }); + class BarChart extends AbstractChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.type = "bar"; + this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); + this.labelRange = createRange(getters, sheetId, definition.labelRange); + this.background = definition.background; + this.verticalAxisPosition = definition.verticalAxisPosition; + this.legendPosition = definition.legendPosition; + this.stackedBar = definition.stackedBar; + } + static transformDefinition(definition, executed) { + return transformChartDefinitionWithDataSetsWithZone(definition, executed); + } + static validateChartDefinition(validator, definition) { + return validator.checkValidations(definition, checkDataset, checkLabelRange); + } + static getDefinitionFromContextCreation(context) { + return { + background: context.background, + dataSets: context.range ? context.range : [], + dataSetsHaveTitle: false, + stackedBar: false, + legendPosition: "top", + title: context.title || "", + type: "bar", + verticalAxisPosition: "left", + labelRange: context.auxiliaryRange || undefined, + }; + } + getContextCreation() { + return { + background: this.background, + title: this.title, + range: this.dataSets.map((ds) => this.getters.getRangeString(ds.dataRange, this.sheetId)), + auxiliaryRange: this.labelRange + ? this.getters.getRangeString(this.labelRange, this.sheetId) + : undefined, + }; + } + copyForSheetId(sheetId) { + const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); + const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); + return new BarChart(definition, sheetId, this.getters); + } + copyInSheetId(sheetId) { + const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); + return new BarChart(definition, sheetId, this.getters); + } + getDefinition() { + return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); + } + getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { + return { + type: "bar", + dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, + background: this.background, + dataSets: dataSets.map((ds) => this.getters.getRangeString(ds.dataRange, targetSheetId || this.sheetId)), + legendPosition: this.legendPosition, + verticalAxisPosition: this.verticalAxisPosition, + labelRange: labelRange + ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) + : undefined, + title: this.title, + stackedBar: this.stackedBar, + }; + } + getDefinitionForExcel() { + const dataSets = this.dataSets + .map((ds) => toExcelDataset(this.getters, ds)) + .filter((ds) => ds.range !== ""); // && range !== INCORRECT_RANGE_STRING ? show incorrect #ref ? + return { + ...this.getDefinition(), + backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), + fontColor: toXlsxHexColor(chartFontColor(this.background)), + dataSets, + stackedBar: this.stackedBar, + }; + } + getSheetIdsUsedInChartRanges() { + const sheetIds = new Set(); + const ranges = []; + this.dataSets.map((ds) => ds.dataRange).map((range) => ranges.push(range)); + this.dataSets + .map((ds) => ds.labelCell) + .filter(isDefined$1) + .map((range) => ranges.push(range)); + if (this.labelRange) { + ranges.push(this.labelRange); + } + for (const range of ranges) { + sheetIds.add(range.sheetId); + } + return Array.from(sheetIds); + } + updateRanges(applyChange) { + const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); + if (!isStale) { + return this; + } + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); + return new BarChart(definition, this.sheetId, this.getters); + } + } + function getBarConfiguration(chart, labels) { + var _a; + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, labels, fontColor); + const legend = { + labels: { fontColor }, + }; + if ((!chart.labelRange && chart.dataSets.length === 1) || chart.legendPosition === "none") { + legend.display = false; + } + else { + legend.position = chart.legendPosition; + } + config.options.legend = { ...(_a = config.options) === null || _a === void 0 ? void 0 : _a.legend, ...legend }; + config.options.layout = { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }; + config.options.scales = { + xAxes: [ + { + ticks: { + // x axis configuration + maxRotation: 60, + minRotation: 15, + padding: 5, + labelOffset: 2, + fontColor, + }, + }, + ], + yAxes: [ + { + position: chart.verticalAxisPosition, + ticks: { + fontColor, + // y axis configuration + beginAtZero: true, // the origin of the y axis is always zero + }, + }, + ], + }; + if (chart.stackedBar) { + config.options.scales.xAxes[0].stacked = true; + config.options.scales.yAxes[0].stacked = true; + } + return config; + } + function createBarChartRuntime(chart, getters) { + const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); + let labels = labelValues.formattedValues; + let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); + ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); + const config = getBarConfiguration(chart, labels); + const colors = new ChartColors(); + for (let { label, data } of dataSetsValues) { + const color = colors.next(); + const dataset = { + label, + data, + borderColor: color, + backgroundColor: color, + }; + config.data.datasets.push(dataset); + } + return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; + } + + /** + * Create a function used to create a Chart based on the definition + */ + function chartFactory(getters) { + const builders = chartRegistry.getAll(); + function createChart(id, definition, sheetId) { + const builder = builders.find((builder) => builder.match(definition.type)); + if (!builder) { + throw new Error(`No builder for this chart: ${definition.type}`); + } + return builder.createChart(definition, sheetId, getters); + } + return createChart; + } + /** + * Create a function used to create a Chart Runtime based on the chart class + * instance + */ + function chartRuntimeFactory(getters) { + const builders = chartRegistry.getAll(); + function createRuntimeChart(chart) { + const builder = builders.find((builder) => builder.match(chart.type)); + if (!builder) { + throw new Error("No runtime builder for this chart."); + } + return builder.getChartRuntime(chart, getters); + } + return createRuntimeChart; + } + /** + * Validate the chart definition given in arguments + */ + function validateChartDefinition(validator, definition) { + const validators = chartRegistry.getAll().find((validator) => validator.match(definition.type)); + if (!validators) { + throw new Error("Unknown chart type."); + } + return validators.validateChartDefinition(validator, definition); + } + /** + * Get a new chart definition transformed with the executed command. This + * functions will be called during operational transform process + */ + function transformDefinition(definition, executed) { + const transformation = chartRegistry.getAll().find((factory) => factory.match(definition.type)); + if (!transformation) { + throw new Error("Unknown chart type."); + } + return transformation.transformDefinition(definition, executed); + } + /** + * Get an empty definition based on the given context and the given type + */ + function getChartDefinitionFromContextCreation(context, type) { + const chartClass = chartRegistry.get(type); + return chartClass.getChartDefinitionFromContextCreation(context); + } + function getChartTypes() { + const result = {}; + for (const key of chartRegistry.getKeys()) { + result[key] = chartRegistry.get(key).name; + } + return result; + } + + chartRegistry.add("gauge", { + match: (type) => type === "gauge", + createChart: (definition, sheetId, getters) => new GaugeChart(definition, sheetId, getters), + getChartRuntime: createGaugeChartRuntime, + validateChartDefinition: (validator, definition) => GaugeChart.validateChartDefinition(validator, definition), + transformDefinition: (definition, executed) => GaugeChart.transformDefinition(definition, executed), + getChartDefinitionFromContextCreation: (context) => GaugeChart.getDefinitionFromContextCreation(context), + name: "Gauge", + }); + function isDataRangeValid(definition) { + return definition.dataRange && !rangeReference.test(definition.dataRange) + ? 33 /* InvalidGaugeDataRange */ + : 0 /* Success */; + } + function checkRangeLimits(check, batchValidations) { + return batchValidations((definition) => { + if (definition.sectionRule) { + return check(definition.sectionRule.rangeMin, "rangeMin"); + } + return 0 /* Success */; + }, (definition) => { + if (definition.sectionRule) { + return check(definition.sectionRule.rangeMax, "rangeMax"); + } + return 0 /* Success */; + }); + } + function checkInflectionPointsValue(check, batchValidations) { + return batchValidations((definition) => { + if (definition.sectionRule) { + return check(definition.sectionRule.lowerInflectionPoint.value, "lowerInflectionPointValue"); + } + return 0 /* Success */; + }, (definition) => { + if (definition.sectionRule) { + return check(definition.sectionRule.upperInflectionPoint.value, "upperInflectionPointValue"); + } + return 0 /* Success */; + }); + } + function checkRangeMinBiggerThanRangeMax(definition) { + if (definition.sectionRule) { + if (Number(definition.sectionRule.rangeMin) >= Number(definition.sectionRule.rangeMax)) { + return 38 /* GaugeRangeMinBiggerThanRangeMax */; + } + } + return 0 /* Success */; + } + function checkEmpty(value, valueName) { + if (value === "") { + switch (valueName) { + case "rangeMin": + return 34 /* EmptyGaugeRangeMin */; + case "rangeMax": + return 36 /* EmptyGaugeRangeMax */; + } + } + return 0 /* Success */; + } + function checkNaN(value, valueName) { + if (isNaN(value)) { + switch (valueName) { + case "rangeMin": + return 35 /* GaugeRangeMinNaN */; + case "rangeMax": + return 37 /* GaugeRangeMaxNaN */; + case "lowerInflectionPointValue": + return 39 /* GaugeLowerInflectionPointNaN */; + case "upperInflectionPointValue": + return 40 /* GaugeUpperInflectionPointNaN */; + } + } + return 0 /* Success */; + } + class GaugeChart extends AbstractChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.type = "gauge"; + this.dataRange = createRange(this.getters, this.sheetId, definition.dataRange); + this.sectionRule = definition.sectionRule; + this.background = definition.background; + } + static validateChartDefinition(validator, definition) { + return validator.checkValidations(definition, isDataRangeValid, validator.chainValidations(checkRangeLimits(checkEmpty, validator.batchValidations), checkRangeLimits(checkNaN, validator.batchValidations), checkRangeMinBiggerThanRangeMax), validator.chainValidations(checkInflectionPointsValue(checkNaN, validator.batchValidations))); + } + static transformDefinition(definition, executed) { + let dataRangeZone; + if (definition.dataRange) { + dataRangeZone = transformZone(toUnboundedZone(definition.dataRange), executed); + } + return { + ...definition, + dataRange: dataRangeZone ? zoneToXc(dataRangeZone) : undefined, + }; + } + static getDefinitionFromContextCreation(context) { + return { + background: context.background, + title: context.title || "", + type: "gauge", + dataRange: context.range ? context.range[0] : undefined, + sectionRule: { + colors: { + lowerColor: DEFAULT_GAUGE_LOWER_COLOR, + middleColor: DEFAULT_GAUGE_MIDDLE_COLOR, + upperColor: DEFAULT_GAUGE_UPPER_COLOR, + }, + rangeMin: "0", + rangeMax: "100", + lowerInflectionPoint: { + type: "percentage", + value: "15", + }, + upperInflectionPoint: { + type: "percentage", + value: "40", + }, + }, + }; + } + copyForSheetId(sheetId) { + const dataRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.dataRange); + const definition = this.getDefinitionWithSpecificRanges(dataRange, sheetId); + return new GaugeChart(definition, sheetId, this.getters); + } + copyInSheetId(sheetId) { + const definition = this.getDefinitionWithSpecificRanges(this.dataRange, sheetId); + return new GaugeChart(definition, sheetId, this.getters); + } + getSheetIdsUsedInChartRanges() { + return this.dataRange ? [this.dataRange.sheetId] : []; + } + getDefinition() { + return this.getDefinitionWithSpecificRanges(this.dataRange); + } + getDefinitionWithSpecificRanges(dataRange, targetSheetId) { + return { + background: this.background, + sectionRule: this.sectionRule, + title: this.title, + type: "gauge", + dataRange: dataRange + ? this.getters.getRangeString(dataRange, targetSheetId || this.sheetId) + : undefined, + }; + } + getDefinitionForExcel() { + // This kind of graph is not exportable in Excel + return undefined; + } + getContextCreation() { + return { + background: this.background, + title: this.title, + range: this.dataRange + ? [this.getters.getRangeString(this.dataRange, this.sheetId)] + : undefined, + }; + } + updateRanges(applyChange) { + const range = adaptChartRange(this.dataRange, applyChange); + if (this.dataRange === range) { + return this; + } + const definition = this.getDefinitionWithSpecificRanges(range); + return new GaugeChart(definition, this.sheetId, this.getters); + } + } + function getGaugeConfiguration(chart) { + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, [], fontColor); + config.options.hover = undefined; + config.options.events = []; + config.options.layout = { + padding: { left: 30, right: 30, top: chart.title ? 10 : 25, bottom: 25 }, + }; + config.options.needle = { + radiusPercentage: 2, + widthPercentage: 3.2, + lengthPercentage: 80, + color: "#000000", + }; + config.options.valueLabel = { + display: false, + formatter: null, + color: "#FFFFFF", + backgroundColor: "#000000", + fontSize: 30, + borderRadius: 5, + padding: { + top: 5, + right: 5, + bottom: 5, + left: 5, + }, + bottomMarginPercentage: 5, + }; + return config; + } + function createGaugeChartRuntime(chart, getters) { + const config = getGaugeConfiguration(chart); + const colors = chart.sectionRule.colors; + const lowerPoint = chart.sectionRule.lowerInflectionPoint; + const upperPoint = chart.sectionRule.upperInflectionPoint; + const lowerPointValue = Number(lowerPoint.value); + const upperPointValue = Number(upperPoint.value); + const minNeedleValue = Number(chart.sectionRule.rangeMin); + const maxNeedleValue = Number(chart.sectionRule.rangeMax); + const needleCoverage = maxNeedleValue - minNeedleValue; + const needleInflectionPoint = []; + if (lowerPoint.value !== "") { + const lowerPointNeedleValue = lowerPoint.type === "number" + ? lowerPointValue + : minNeedleValue + (needleCoverage * lowerPointValue) / 100; + needleInflectionPoint.push({ + value: clip(lowerPointNeedleValue, minNeedleValue, maxNeedleValue), + color: colors.lowerColor, + }); + } + if (upperPoint.value !== "") { + const upperPointNeedleValue = upperPoint.type === "number" + ? upperPointValue + : minNeedleValue + (needleCoverage * upperPointValue) / 100; + needleInflectionPoint.push({ + value: clip(upperPointNeedleValue, minNeedleValue, maxNeedleValue), + color: colors.middleColor, + }); + } + const data = []; + const backgroundColor = []; + needleInflectionPoint + .sort((a, b) => a.value - b.value) + .map((point) => { + data.push(point.value); + backgroundColor.push(point.color); + }); + // There's a bug in gauge lib when the last element in `data` is 0 (i.e. when the range maximum is 0). + // The value wrongly fallbacks to 1 because 0 is falsy + // See https://github.com/haiiaaa/chartjs-gauge/pull/33 + // https://github.com/haiiaaa/chartjs-gauge/blob/2ea50541d754d710cb30c2502fa690ac5dc27afd/src/controllers/controller.gauge.js#L52 + data.push(maxNeedleValue); + backgroundColor.push(colors.upperColor); + const dataRange = chart.dataRange; + const deltaBeyondRangeLimit = needleCoverage / 30; + let needleValue = minNeedleValue - deltaBeyondRangeLimit; // make needle value always at the minimum by default + let cellFormatter = null; + let displayValue = false; + if (dataRange !== undefined) { + const cell = getters.getCell(dataRange.sheetId, dataRange.zone.left, dataRange.zone.top); + if ((cell === null || cell === void 0 ? void 0 : cell.evaluated.type) === CellValueType.number) { + // in gauge graph "datasets.value" is used to calculate the angle of the + // needle in the graph. To prevent the needle from making 360° turns, we + // clip the value between a min and a max. This min and this max are slightly + // smaller and slightly larger than minRange and maxRange to mark the fact + // that the needle is out of the range limits + needleValue = clip(cell === null || cell === void 0 ? void 0 : cell.evaluated.value, minNeedleValue - deltaBeyondRangeLimit, maxNeedleValue + deltaBeyondRangeLimit); + cellFormatter = () => getters.getRangeFormattedValues(dataRange)[0]; + displayValue = true; + } + } + config.options.valueLabel.display = displayValue; + config.options.valueLabel.formatter = cellFormatter; + config.data.datasets.push({ + data, + minValue: Number(chart.sectionRule.rangeMin), + value: needleValue, + backgroundColor, + }); + return { + chartJsConfig: config, + background: getters.getBackgroundOfSingleCellChart(chart.background, dataRange), + }; + } + + const UNIT_LENGTH = { + second: 1000, + minute: 1000 * 60, + hour: 1000 * 3600, + day: 1000 * 3600 * 24, + month: 1000 * 3600 * 24 * 30, + year: 1000 * 3600 * 24 * 365, + }; + const Milliseconds = { + inSeconds: function (milliseconds) { + return Math.floor(milliseconds / UNIT_LENGTH.second); + }, + inMinutes: function (milliseconds) { + return Math.floor(milliseconds / UNIT_LENGTH.minute); + }, + inHours: function (milliseconds) { + return Math.floor(milliseconds / UNIT_LENGTH.hour); + }, + inDays: function (milliseconds) { + return Math.floor(milliseconds / UNIT_LENGTH.day); + }, + inMonths: function (milliseconds) { + return Math.floor(milliseconds / UNIT_LENGTH.month); + }, + inYears: function (milliseconds) { + return Math.floor(milliseconds / UNIT_LENGTH.year); + }, + }; + /** + * Regex to test if a format string is a date format that can be translated into a moment time format + */ + const timeFormatMomentCompatible = /^((d|dd|m|mm|yyyy|yy|hh|h|ss|a)(-|:|\s|\/))*(d|dd|m|mm|yyyy|yy|hh|h|ss|a)$/i; + /** Get the time options for the XAxis of ChartJS */ + function getChartTimeOptions(labels, labelFormat) { + const momentFormat = convertDateFormatForMoment(labelFormat); + const timeUnit = getBestTimeUnitForScale(labels, momentFormat); + const displayFormats = {}; + if (timeUnit) { + displayFormats[timeUnit] = momentFormat; + } + return { + parser: momentFormat, + displayFormats, + unit: timeUnit, + }; + } + /** + * Convert the given date format into a format that moment.js understands. + * + * https://momentjs.com/docs/#/parsing/string-format/ + */ + function convertDateFormatForMoment(format) { + format = format.replace(/y/g, "Y"); + format = format.replace(/d/g, "D"); + // "m" before "h" == month, "m" after "h" == minute + const indexH = format.indexOf("h"); + if (indexH >= 0) { + format = format.slice(0, indexH).replace(/m/g, "M") + format.slice(indexH); + } + else { + format = format.replace(/m/g, "M"); + } + // If we have an "a", we should display hours as AM/PM (h), otherwise display 24 hours format (H) + if (!format.includes("a")) { + format = format.replace(/h/g, "H"); + } + return format; + } + /** Get the minimum time unit that the format is able to display */ + function getFormatMinDisplayUnit(format) { + if (format.includes("s")) { + return "second"; + } + else if (format.includes("m")) { + return "minute"; + } + else if (format.includes("h") || format.includes("H")) { + return "hour"; + } + else if (format.includes("D")) { + return "day"; + } + else if (format.includes("M")) { + return "month"; + } + return "year"; + } + /** + * Returns the best time unit that should be used for the X axis of a chart in order to display all + * the labels correctly. + * + * There is two conditions : + * - the format of the labels should be able to display the unit. For example if the format is "DD/MM/YYYY" + * it makes no sense to try to use minutes in the X axis + * - we want the "best fit" unit. For example if the labels span a period of several days, we want to use days + * as a unit, but if they span 200 days, we'd like to use months instead + * + */ + function getBestTimeUnitForScale(labels, format) { + const labelDates = labels.map((label) => { var _a; return (_a = parseDateTime(label)) === null || _a === void 0 ? void 0 : _a.jsDate; }); + if (labelDates.some((date) => date === undefined) || labels.length < 2) { + return undefined; + } + const labelsTimestamps = labelDates.map((date) => date.getTime()); + const period = Math.max(...labelsTimestamps) - Math.min(...labelsTimestamps); + const minUnit = getFormatMinDisplayUnit(format); + if (UNIT_LENGTH.second >= UNIT_LENGTH[minUnit] && Milliseconds.inSeconds(period) < 180) { + return "second"; + } + else if (UNIT_LENGTH.minute >= UNIT_LENGTH[minUnit] && Milliseconds.inMinutes(period) < 180) { + return "minute"; + } + else if (UNIT_LENGTH.hour >= UNIT_LENGTH[minUnit] && Milliseconds.inHours(period) < 96) { + return "hour"; + } + else if (UNIT_LENGTH.day >= UNIT_LENGTH[minUnit] && Milliseconds.inDays(period) < 90) { + return "day"; + } + else if (UNIT_LENGTH.month >= UNIT_LENGTH[minUnit] && Milliseconds.inMonths(period) < 36) { + return "month"; + } + return "year"; + } + + chartRegistry.add("line", { + match: (type) => type === "line", + createChart: (definition, sheetId, getters) => new LineChart(definition, sheetId, getters), + getChartRuntime: createLineChartRuntime, + validateChartDefinition: (validator, definition) => LineChart.validateChartDefinition(validator, definition), + transformDefinition: (definition, executed) => LineChart.transformDefinition(definition, executed), + getChartDefinitionFromContextCreation: (context) => LineChart.getDefinitionFromContextCreation(context), + name: "Line", + }); + class LineChart extends AbstractChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.type = "line"; + this.dataSets = createDataSets(this.getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); + this.labelRange = createRange(this.getters, sheetId, definition.labelRange); + this.background = definition.background; + this.verticalAxisPosition = definition.verticalAxisPosition; + this.legendPosition = definition.legendPosition; + this.labelsAsText = definition.labelsAsText; + } + static validateChartDefinition(validator, definition) { + return validator.checkValidations(definition, checkDataset, checkLabelRange); + } + static transformDefinition(definition, executed) { + return transformChartDefinitionWithDataSetsWithZone(definition, executed); + } + static getDefinitionFromContextCreation(context) { + return { + background: context.background, + dataSets: context.range ? context.range : [], + dataSetsHaveTitle: false, + labelsAsText: false, + legendPosition: "top", + title: context.title || "", + type: "line", + verticalAxisPosition: "left", + labelRange: context.auxiliaryRange || undefined, + }; + } + getDefinition() { + return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); + } + getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { + return { + type: "line", + dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, + background: this.background, + dataSets: dataSets.map((ds) => this.getters.getRangeString(ds.dataRange, targetSheetId || this.sheetId)), + legendPosition: this.legendPosition, + verticalAxisPosition: this.verticalAxisPosition, + labelRange: labelRange + ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) + : undefined, + title: this.title, + labelsAsText: this.labelsAsText, + }; + } + getContextCreation() { + return { + background: this.background, + title: this.title, + range: this.dataSets.map((ds) => this.getters.getRangeString(ds.dataRange, this.sheetId)), + auxiliaryRange: this.labelRange + ? this.getters.getRangeString(this.labelRange, this.sheetId) + : undefined, + }; + } + updateRanges(applyChange) { + const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); + if (!isStale) { + return this; + } + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); + return new LineChart(definition, this.sheetId, this.getters); + } + getDefinitionForExcel() { + const dataSets = this.dataSets + .map((ds) => toExcelDataset(this.getters, ds)) + .filter((ds) => ds.range !== ""); // && range !== INCORRECT_RANGE_STRING ? show incorrect #ref ? + return { + ...this.getDefinition(), + backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), + fontColor: toXlsxHexColor(chartFontColor(this.background)), + dataSets, + }; + } + copyForSheetId(sheetId) { + const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); + const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); + return new LineChart(definition, sheetId, this.getters); + } + copyInSheetId(sheetId) { + const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); + return new LineChart(definition, sheetId, this.getters); + } + getSheetIdsUsedInChartRanges() { + const sheetIds = new Set(); + const ranges = []; + this.dataSets.map((ds) => ds.dataRange).map((range) => ranges.push(range)); + this.dataSets + .map((ds) => ds.labelCell) + .filter(isDefined$1) + .map((range) => ranges.push(range)); + if (this.labelRange) { + ranges.push(this.labelRange); + } + for (const range of ranges) { + sheetIds.add(range.sheetId); + } + return Array.from(sheetIds); + } + } + function fixEmptyLabelsForDateCharts(labels, dataSetsValues) { + if (labels.length === 0 || labels.every((label) => !label)) { + return { labels, dataSetsValues }; + } + const newLabels = [...labels]; + const newDatasets = deepCopy(dataSetsValues); + for (let i = 0; i < newLabels.length; i++) { + if (!newLabels[i]) { + newLabels[i] = findNextDefinedValue(newLabels, i); + for (let ds of newDatasets) { + ds.data[i] = undefined; + } + } + } + return { labels: newLabels, dataSetsValues: newDatasets }; + } + function canChartParseLabels(chart, getters) { + return canBeDateChart(chart, getters) || canBeLinearChart(chart, getters); + } + function getChartAxisType(chart, getters) { + if (isDateChart(chart, getters)) { + return "time"; + } + if (isLinearChart(chart, getters)) { + return "linear"; + } + return "category"; + } + function isDateChart(chart, getters) { + return !chart.labelsAsText && canBeDateChart(chart, getters); + } + function isLinearChart(chart, getters) { + return !chart.labelsAsText && canBeLinearChart(chart, getters); + } + function canBeDateChart(chart, getters) { + var _a; + if (!chart.labelRange || !chart.dataSets || !canBeLinearChart(chart, getters)) { + return false; + } + const labelFormat = (_a = getters.getCell(chart.labelRange.sheetId, chart.labelRange.zone.left, chart.labelRange.zone.top)) === null || _a === void 0 ? void 0 : _a.evaluated.format; + return Boolean(labelFormat && timeFormatMomentCompatible.test(labelFormat)); + } + function canBeLinearChart(chart, getters) { + if (!chart.labelRange || !chart.dataSets) { + return false; + } + const labels = getters.getRangeValues(chart.labelRange); + if (labels.some((label) => isNaN(Number(label)) && label)) { + return false; + } + if (labels.every((label) => !label)) { + return false; + } + return true; + } + function getLineConfiguration(chart, labels) { + var _a; + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, labels, fontColor); + const legend = { + labels: { fontColor }, + }; + if ((!chart.labelRange && chart.dataSets.length === 1) || chart.legendPosition === "none") { + legend.display = false; + } + else { + legend.position = chart.legendPosition; + } + config.options.legend = { ...(_a = config.options) === null || _a === void 0 ? void 0 : _a.legend, ...legend }; + config.options.layout = { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }; + config.options.scales = { + xAxes: [ + { + ticks: { + // x axis configuration + maxRotation: 60, + minRotation: 15, + padding: 5, + labelOffset: 2, + fontColor, + }, + }, + ], + yAxes: [ + { + position: chart.verticalAxisPosition, + ticks: { + fontColor, + // y axis configuration + beginAtZero: true, // the origin of the y axis is always zero + }, + }, + ], + }; + return config; + } + function createLineChartRuntime(chart, getters) { + const axisType = getChartAxisType(chart, getters); + const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); + let labels = axisType === "linear" ? labelValues.values : labelValues.formattedValues; + let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); + ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); + if (axisType === "time") { + ({ labels, dataSetsValues } = fixEmptyLabelsForDateCharts(labels, dataSetsValues)); + } + const config = getLineConfiguration(chart, labels); + const labelFormat = getLabelFormat(getters, chart.labelRange); + if (axisType === "time") { + config.options.scales.xAxes[0].type = "time"; + config.options.scales.xAxes[0].time = getChartTimeOptions(labels, labelFormat); + config.options.scales.xAxes[0].ticks.maxTicksLimit = 15; + } + else if (axisType === "linear") { + config.options.scales.xAxes[0].type = "linear"; + config.options.scales.xAxes[0].ticks.callback = (value) => formatValue(value, labelFormat); + } + const colors = new ChartColors(); + for (let { label, data } of dataSetsValues) { + if (["linear", "time"].includes(axisType)) { + // Replace empty string labels by undefined to make sure chartJS doesn't decide that "" is the same as 0 + data = data.map((y, index) => ({ x: labels[index] || undefined, y })); + } + const color = colors.next(); + const dataset = { + label, + data, + lineTension: 0, + borderColor: color, + backgroundColor: color, + }; + config.data.datasets.push(dataset); + } + return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; + } + + chartRegistry.add("pie", { + match: (type) => type === "pie", + createChart: (definition, sheetId, getters) => new PieChart(definition, sheetId, getters), + getChartRuntime: createPieChartRuntime, + validateChartDefinition: (validator, definition) => PieChart.validateChartDefinition(validator, definition), + transformDefinition: (definition, executed) => PieChart.transformDefinition(definition, executed), + getChartDefinitionFromContextCreation: (context) => PieChart.getDefinitionFromContextCreation(context), + name: "Pie", + }); + class PieChart extends AbstractChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.type = "pie"; + this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); + this.labelRange = createRange(getters, sheetId, definition.labelRange); + this.background = definition.background; + this.legendPosition = definition.legendPosition; + } + static transformDefinition(definition, executed) { + return transformChartDefinitionWithDataSetsWithZone(definition, executed); + } + static validateChartDefinition(validator, definition) { + return validator.checkValidations(definition, checkDataset, checkLabelRange); + } + static getDefinitionFromContextCreation(context) { + return { + background: context.background, + dataSets: context.range ? context.range : [], + dataSetsHaveTitle: false, + legendPosition: "top", + title: context.title || "", + type: "pie", + labelRange: context.auxiliaryRange || undefined, + }; + } + getDefinition() { + return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); + } + getContextCreation() { + return { + background: this.background, + title: this.title, + range: this.dataSets.map((ds) => this.getters.getRangeString(ds.dataRange, this.sheetId)), + auxiliaryRange: this.labelRange + ? this.getters.getRangeString(this.labelRange, this.sheetId) + : undefined, + }; + } + getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { + return { + type: "pie", + dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, + background: this.background, + dataSets: dataSets.map((ds) => this.getters.getRangeString(ds.dataRange, targetSheetId || this.sheetId)), + legendPosition: this.legendPosition, + labelRange: labelRange + ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) + : undefined, + title: this.title, + }; + } + copyForSheetId(sheetId) { + const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); + const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); + return new PieChart(definition, sheetId, this.getters); + } + copyInSheetId(sheetId) { + const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); + return new PieChart(definition, sheetId, this.getters); + } + getDefinitionForExcel() { + const dataSets = this.dataSets + .map((ds) => toExcelDataset(this.getters, ds)) + .filter((ds) => ds.range !== ""); // && range !== INCORRECT_RANGE_STRING ? show incorrect #ref ? + return { + ...this.getDefinition(), + backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), + fontColor: toXlsxHexColor(chartFontColor(this.background)), + verticalAxisPosition: "left", + dataSets, + }; + } + getSheetIdsUsedInChartRanges() { + const sheetIds = new Set(); + const ranges = []; + this.dataSets.map((ds) => ds.dataRange).map((range) => ranges.push(range)); + this.dataSets + .map((ds) => ds.labelCell) + .filter(isDefined$1) + .map((range) => ranges.push(range)); + if (this.labelRange) { + ranges.push(this.labelRange); + } + for (const range of ranges) { + sheetIds.add(range.sheetId); + } + return Array.from(sheetIds); + } + updateRanges(applyChange) { + const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); + if (!isStale) { + return this; + } + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); + return new PieChart(definition, this.sheetId, this.getters); + } + } + function getPieConfiguration(chart, labels) { + var _a; + const fontColor = chartFontColor(chart.background); + const config = getDefaultChartJsRuntime(chart, labels, fontColor); + const legend = { + labels: { fontColor }, + }; + if ((!chart.labelRange && chart.dataSets.length === 1) || chart.legendPosition === "none") { + legend.display = false; + } + else { + legend.position = chart.legendPosition; + } + config.options.legend = { ...(_a = config.options) === null || _a === void 0 ? void 0 : _a.legend, ...legend }; + config.options.layout = { + padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, + }; + config.options.tooltips = { + callbacks: { + title: function (tooltipItems, data) { + return data.datasets[tooltipItems[0].datasetIndex].label; + }, + }, + }; + return config; + } + function getPieColors(colors, dataSetsValues) { + const pieColors = []; + const maxLength = Math.max(...dataSetsValues.map((ds) => ds.data.length)); + for (let i = 0; i <= maxLength; i++) { + pieColors.push(colors.next()); + } + return pieColors; + } + function createPieChartRuntime(chart, getters) { + const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); + let labels = labelValues.formattedValues; + let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); + ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); + const config = getPieConfiguration(chart, labels); + const colors = new ChartColors(); + for (let { label, data } of dataSetsValues) { + const backgroundColor = getPieColors(colors, dataSetsValues); + const dataset = { + label, + data, + borderColor: "#FFFFFF", + backgroundColor, + }; + config.data.datasets.push(dataset); + } + return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; + } + + chartRegistry.add("scorecard", { + match: (type) => type === "scorecard", + createChart: (definition, sheetId, getters) => new ScorecardChart$1(definition, sheetId, getters), + getChartRuntime: createScorecardChartRuntime, + validateChartDefinition: (validator, definition) => ScorecardChart$1.validateChartDefinition(validator, definition), + transformDefinition: (definition, executed) => ScorecardChart$1.transformDefinition(definition, executed), + getChartDefinitionFromContextCreation: (context) => ScorecardChart$1.getDefinitionFromContextCreation(context), + name: "Scorecard", + }); + function checkKeyValue(definition) { + return definition.keyValue && !rangeReference.test(definition.keyValue) + ? 31 /* InvalidScorecardKeyValue */ + : 0 /* Success */; + } + function checkBaseline(definition) { + return definition.baseline && !rangeReference.test(definition.baseline) + ? 32 /* InvalidScorecardBaseline */ + : 0 /* Success */; + } + class ScorecardChart$1 extends AbstractChart { + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.type = "scorecard"; + this.keyValue = createRange(getters, sheetId, definition.keyValue); + this.baseline = createRange(getters, sheetId, definition.baseline); + this.baselineMode = definition.baselineMode; + this.baselineDescr = definition.baselineDescr; + this.background = definition.background; + this.baselineColorUp = definition.baselineColorUp; + this.baselineColorDown = definition.baselineColorDown; + } + static validateChartDefinition(validator, definition) { + return validator.checkValidations(definition, checkKeyValue, checkBaseline); + } + static getDefinitionFromContextCreation(context) { + return { + background: context.background, + type: "scorecard", + keyValue: context.range ? context.range[0] : undefined, + title: context.title || "", + baselineMode: "difference", + baselineColorUp: "#00A04A", + baselineColorDown: "#DC6965", + baseline: context.auxiliaryRange || "", + }; + } + static transformDefinition(definition, executed) { + let baselineZone; + let keyValueZone; + if (definition.baseline) { + baselineZone = transformZone(toUnboundedZone(definition.baseline), executed); + } + if (definition.keyValue) { + keyValueZone = transformZone(toUnboundedZone(definition.keyValue), executed); + } + return { + ...definition, + baseline: baselineZone ? zoneToXc(baselineZone) : undefined, + keyValue: keyValueZone ? zoneToXc(keyValueZone) : undefined, + }; + } + copyForSheetId(sheetId) { + const baseline = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.baseline); + const keyValue = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.keyValue); + const definition = this.getDefinitionWithSpecificRanges(baseline, keyValue, sheetId); + return new ScorecardChart$1(definition, sheetId, this.getters); + } + copyInSheetId(sheetId) { + const definition = this.getDefinitionWithSpecificRanges(this.baseline, this.keyValue, sheetId); + return new ScorecardChart$1(definition, sheetId, this.getters); + } + getDefinition() { + return this.getDefinitionWithSpecificRanges(this.baseline, this.keyValue); + } + getContextCreation() { + return { + background: this.background, + title: this.title, + range: this.keyValue ? [this.getters.getRangeString(this.keyValue, this.sheetId)] : undefined, + auxiliaryRange: this.baseline + ? this.getters.getRangeString(this.baseline, this.sheetId) + : undefined, + }; + } + getSheetIdsUsedInChartRanges() { + const sheetIds = new Set(); + if (this.baseline) { + sheetIds.add(this.baseline.sheetId); + } + if (this.keyValue) { + sheetIds.add(this.keyValue.sheetId); + } + return Array.from(sheetIds); + } + getDefinitionWithSpecificRanges(baseline, keyValue, targetSheetId) { + return { + baselineColorDown: this.baselineColorDown, + baselineColorUp: this.baselineColorUp, + baselineMode: this.baselineMode, + title: this.title, + type: "scorecard", + background: this.background, + baseline: baseline + ? this.getters.getRangeString(baseline, targetSheetId || this.sheetId) + : undefined, + baselineDescr: this.baselineDescr, + keyValue: keyValue + ? this.getters.getRangeString(keyValue, targetSheetId || this.sheetId) + : undefined, + }; + } + getDefinitionForExcel() { + // This kind of graph is not exportable in Excel + return undefined; + } + updateRanges(applyChange) { + const baseline = adaptChartRange(this.baseline, applyChange); + const keyValue = adaptChartRange(this.keyValue, applyChange); + if (this.baseline === baseline && this.keyValue === keyValue) { + return this; + } + const definition = this.getDefinitionWithSpecificRanges(baseline, keyValue); + return new ScorecardChart$1(definition, this.sheetId, this.getters); + } + } + function createScorecardChartRuntime(chart, getters) { + let keyValue = ""; + let formattedKeyValue = ""; + let keyValueCell; + if (chart.keyValue) { + const keyValueZone = chart.keyValue.zone; + keyValueCell = getters.getCell(chart.keyValue.sheetId, keyValueZone.left, keyValueZone.top); + keyValue = (keyValueCell === null || keyValueCell === void 0 ? void 0 : keyValueCell.evaluated.value) ? String(keyValueCell === null || keyValueCell === void 0 ? void 0 : keyValueCell.evaluated.value) : ""; + formattedKeyValue = (keyValueCell === null || keyValueCell === void 0 ? void 0 : keyValueCell.formattedValue) || ""; + } + let baselineCell; + if (chart.baseline) { + const baselineZone = chart.baseline.zone; + baselineCell = getters.getCell(chart.baseline.sheetId, baselineZone.left, baselineZone.top); + } + const baselineValue = (baselineCell === null || baselineCell === void 0 ? void 0 : baselineCell.content) || ""; + const background = getters.getBackgroundOfSingleCellChart(chart.background, chart.keyValue); + return { + title: chart.title, + keyValue: formattedKeyValue || keyValue, + baselineDisplay: getBaselineText(baselineCell, keyValue, chart.baselineMode), + baselineArrow: getBaselineArrowDirection(baselineValue, keyValue, chart.baselineMode), + baselineColor: getBaselineColor(baselineValue, chart.baselineMode, keyValue, chart.baselineColorUp, chart.baselineColorDown), + baselineDescr: chart.baselineDescr, + fontColor: chartFontColor(background), + background, + baselineStyle: chart.baselineMode !== "percentage" ? baselineCell === null || baselineCell === void 0 ? void 0 : baselineCell.style : undefined, + keyValueStyle: keyValueCell === null || keyValueCell === void 0 ? void 0 : keyValueCell.style, + }; + } + + const PICKER_PADDING = 6; + const LINE_VERTICAL_PADDING = 1; + const LINE_HORIZONTAL_PADDING = 6; + const ITEM_HORIZONTAL_MARGIN = 1; + const ITEM_EDGE_LENGTH = 18; + const ITEM_BORDER_WIDTH = 1; + const ITEMS_PER_LINE = 10; + const PICKER_WIDTH = ITEMS_PER_LINE * (ITEM_EDGE_LENGTH + ITEM_HORIZONTAL_MARGIN * 2 + 2 * ITEM_BORDER_WIDTH) + + 2 * LINE_HORIZONTAL_PADDING; + const GRADIENT_WIDTH = PICKER_WIDTH - 2 * LINE_HORIZONTAL_PADDING - 2 * ITEM_BORDER_WIDTH; + const GRADIENT_HEIGHT = PICKER_WIDTH - 50; + css /* scss */ ` + .o-color-picker { + position: absolute; + top: calc(100% + 5px); + z-index: ${ComponentsImportance.ColorPicker}; + box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); + background-color: white; + padding: ${PICKER_PADDING}px 0px; + line-height: 1.2; + width: ${GRADIENT_WIDTH + 2 * PICKER_PADDING}px; + + .o-color-picker-section-name { + margin: 0px ${ITEM_HORIZONTAL_MARGIN}px; + padding: 4px ${LINE_HORIZONTAL_PADDING}px; + } + .colors-grid { + display: grid; + padding: ${LINE_VERTICAL_PADDING}px ${LINE_HORIZONTAL_PADDING}px; + grid-template-columns: repeat(${ITEMS_PER_LINE}, 1fr); + grid-gap: ${ITEM_HORIZONTAL_MARGIN * 2}px; + } + .o-color-picker-line-item { + width: ${ITEM_EDGE_LENGTH}px; + height: ${ITEM_EDGE_LENGTH}px; + margin: 0px; + border-radius: 50px; + border: ${ITEM_BORDER_WIDTH}px solid #666666; + padding: 0px; + font-size: 16px; + background: white; + &:hover { + background-color: rgba(0, 0, 0, 0.08); + outline: 1px solid gray; + cursor: pointer; + } + } + .o-buttons { + padding: 6px; + display: flex; + .o-cancel { + margin: 0px ${ITEM_HORIZONTAL_MARGIN}px; + border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; + width: 100%; + padding: 5px; + font-size: 14px; + background: white; + border-radius: 4px; + &:hover:enabled { + background-color: rgba(0, 0, 0, 0.08); + } + } + } + .o-add-button { + border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; + padding: 4px; + background: white; + border-radius: 4px; + &:hover:enabled { + background-color: rgba(0, 0, 0, 0.08); + } + } + .o-separator { + border-bottom: ${MENU_SEPARATOR_BORDER_WIDTH}px solid #e0e2e4; + margin-top: ${MENU_SEPARATOR_PADDING}px; + margin-bottom: ${MENU_SEPARATOR_PADDING}px; + } + input { + box-sizing: border-box; + width: 100%; + border-radius: 4px; + padding: 4px 23px 4px 10px; + height: 24px; + border: 1px solid #c0c0c0; + margin: 0 2px 0 0; + } + input.o-wrong-color { + border-color: red; + } + .o-custom-selector { + padding: ${LINE_HORIZONTAL_PADDING}px; + position: relative; + .o-gradient { + background: linear-gradient(to bottom, hsl(0 100% 0%), transparent, hsl(0 0% 100%)), + linear-gradient( + to right, + hsl(0 100% 50%) 0%, + hsl(0.2turn 100% 50%) 20%, + hsl(0.3turn 100% 50%) 30%, + hsl(0.4turn 100% 50%) 40%, + hsl(0.5turn 100% 50%) 50%, + hsl(0.6turn 100% 50%) 60%, + hsl(0.7turn 100% 50%) 70%, + hsl(0.8turn 100% 50%) 80%, + hsl(0.9turn 100% 50%) 90%, + hsl(1turn 100% 50%) 100% + ); + border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; + width: ${GRADIENT_WIDTH}px; + height: ${GRADIENT_HEIGHT}px; + &:hover { + cursor: crosshair; + } + } + .o-custom-input-preview { + padding: 2px ${LINE_VERTICAL_PADDING}px; + display: flex; + } + .o-custom-input-buttons { + padding: 2px ${LINE_VERTICAL_PADDING}px; + text-align: right; + } + .o-color-preview { + border: 1px solid #c0c0c0; + border-radius: 4px; + width: 100%; + } + } + &.right { + left: 0; + } + &.left { + right: 0; + } + &.center { + left: calc(50% - ${PICKER_WIDTH / 2}px); + } + } + .o-magnifier-glass { + position: absolute; + border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; + border-radius: 50%; + width: 30px; + height: 30px; + } +`; + function computeCustomColor(ev) { + return rgbaToHex(hslaToRGBA({ + h: (360 * ev.offsetX) / GRADIENT_WIDTH, + s: 100, + l: (100 * ev.offsetY) / GRADIENT_HEIGHT, + a: 1, + })); + } + class ColorPicker extends owl.Component { + constructor() { + super(...arguments); + this.COLORS = COLORS; + this.state = owl.useState({ + showGradient: false, + currentColor: isColorValid(this.props.currentColor) ? this.props.currentColor : "", + isCurrentColorInvalid: false, + style: { + display: "none", + background: "#ffffff", + left: "0", + top: "0", + }, + }); + } + onColorClick(color) { + if (color) { + this.props.onColorPicked(color); + } + } + getCheckMarkColor() { + return chartFontColor(this.props.currentColor); + } + resetColor() { + this.props.onColorPicked(""); + } + setCustomColor(ev) { + if (!isColorValid(this.state.currentColor)) { + ev.stopPropagation(); + this.state.isCurrentColorInvalid = true; + return; + } + this.state.isCurrentColorInvalid = false; + this.props.onColorPicked(this.state.currentColor); + } + toggleColorPicker() { + this.state.showGradient = !this.state.showGradient; + } + computeCustomColor(ev) { + this.state.isCurrentColorInvalid = false; + this.state.currentColor = computeCustomColor(ev); + } + hideMagnifier(_ev) { + this.state.style.display = "none"; + } + showMagnifier(_ev) { + this.state.style.display = "block"; + } + moveMagnifier(ev) { + this.state.style.background = computeCustomColor(ev); + const shiftFromCursor = 10; + this.state.style.left = `${ev.offsetX + shiftFromCursor}px`; + this.state.style.top = `${ev.offsetY + shiftFromCursor}px`; + } + get magnifyingGlassStyle() { + const { display, background, left, top } = this.state.style; + return `display:${display};${display === "block" ? `background-color:${background};left:${left};top:${top};` : ""}`; + } + } + ColorPicker.template = "o-spreadsheet-ColorPicker"; + + class LineBarPieDesignPanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + fillColorTool: false, + }); + } + onClick(ev) { + this.state.fillColorTool = false; + } + setup() { + owl.useExternalListener(window, "click", this.onClick); + } + toggleColorPicker() { + this.state.fillColorTool = !this.state.fillColorTool; + } + updateBackgroundColor(color) { + this.props.updateChart({ + background: color, + }); + } + updateTitle(ev) { + this.props.updateChart({ + title: ev.target.value, + }); + } + updateSelect(attr, ev) { + this.props.updateChart({ + [attr]: ev.target.value, + }); + } + } + LineBarPieDesignPanel.template = "o-spreadsheet-LineBarPieDesignPanel"; + LineBarPieDesignPanel.components = { ColorPicker }; + + class BarChartDesignPanel extends LineBarPieDesignPanel { + } + BarChartDesignPanel.template = "o-spreadsheet-BarChartDesignPanel"; + + class GaugeChartConfigPanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + dataRangeDispatchResult: undefined, + }); + this.dataRange = this.props.definition.dataRange; + } + get configurationErrorMessages() { + var _a; + const cancelledReasons = [...(((_a = this.state.dataRangeDispatchResult) === null || _a === void 0 ? void 0 : _a.reasons) || [])]; + return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); + } + get isDataRangeInvalid() { + var _a; + return !!((_a = this.state.dataRangeDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(33 /* InvalidGaugeDataRange */)); + } + onDataRangeChanged(ranges) { + this.dataRange = ranges[0]; + } + updateDataRange() { + this.state.dataRangeDispatchResult = this.props.updateChart({ + dataRange: this.dataRange, + }); + } + } + GaugeChartConfigPanel.template = "o-spreadsheet-GaugeChartConfigPanel"; + GaugeChartConfigPanel.components = { SelectionInput }; + + css /* scss */ ` + .o-gauge-color-set { + .o-gauge-color-set-color-button { + display: inline-block; + border: 1px solid #dadce0; + border-radius: 4px; + cursor: pointer; + padding: 1px 2px; + } + .o-gauge-color-set-color-button:hover { + background-color: rgba(0, 0, 0, 0.08); + } + table { + table-layout: fixed; + margin-top: 2%; + display: table; + text-align: left; + font-size: 12px; + line-height: 18px; + width: 100%; + } + th.o-gauge-color-set-colorPicker { + width: 8%; + } + th.o-gauge-color-set-text { + width: 40%; + } + th.o-gauge-color-set-value { + width: 22%; + } + th.o-gauge-color-set-type { + width: 30%; + } + input, + select { + width: 100%; + height: 100%; + box-sizing: border-box; + } + } +`; + class GaugeChartDesignPanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + openedMenu: undefined, + sectionRuleDispatchResult: undefined, + }); + } + get designErrorMessages() { + var _a; + const cancelledReasons = [...(((_a = this.state.sectionRuleDispatchResult) === null || _a === void 0 ? void 0 : _a.reasons) || [])]; + return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); + } + updateBackgroundColor(color) { + this.state.openedMenu = undefined; + this.props.updateChart({ + background: color, + }); + } + updateTitle(ev) { + this.props.updateChart({ + title: ev.target.value, + }); + } + isRangeMinInvalid() { + var _a, _b, _c; + return !!(((_a = this.state.sectionRuleDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(34 /* EmptyGaugeRangeMin */)) || + ((_b = this.state.sectionRuleDispatchResult) === null || _b === void 0 ? void 0 : _b.isCancelledBecause(35 /* GaugeRangeMinNaN */)) || + ((_c = this.state.sectionRuleDispatchResult) === null || _c === void 0 ? void 0 : _c.isCancelledBecause(38 /* GaugeRangeMinBiggerThanRangeMax */))); + } + isRangeMaxInvalid() { + var _a, _b, _c; + return !!(((_a = this.state.sectionRuleDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(36 /* EmptyGaugeRangeMax */)) || + ((_b = this.state.sectionRuleDispatchResult) === null || _b === void 0 ? void 0 : _b.isCancelledBecause(37 /* GaugeRangeMaxNaN */)) || + ((_c = this.state.sectionRuleDispatchResult) === null || _c === void 0 ? void 0 : _c.isCancelledBecause(38 /* GaugeRangeMinBiggerThanRangeMax */))); + } + // --------------------------------------------------------------------------- + // COLOR_SECTION_TEMPLATE + // --------------------------------------------------------------------------- + get isLowerInflectionPointInvalid() { + var _a, _b; + return !!(((_a = this.state.sectionRuleDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(39 /* GaugeLowerInflectionPointNaN */)) || + ((_b = this.state.sectionRuleDispatchResult) === null || _b === void 0 ? void 0 : _b.isCancelledBecause(41 /* GaugeLowerBiggerThanUpper */))); + } + get isUpperInflectionPointInvalid() { + var _a, _b; + return !!(((_a = this.state.sectionRuleDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(40 /* GaugeUpperInflectionPointNaN */)) || + ((_b = this.state.sectionRuleDispatchResult) === null || _b === void 0 ? void 0 : _b.isCancelledBecause(41 /* GaugeLowerBiggerThanUpper */))); + } + updateInflectionPointValue(attr, ev) { + const sectionRule = deepCopy(this.props.definition.sectionRule); + sectionRule[attr].value = ev.target.value; + this.updateSectionRule(sectionRule); + } + updateInflectionPointType(attr, ev) { + const sectionRule = deepCopy(this.props.definition.sectionRule); + sectionRule[attr].type = ev.target.value; + this.updateSectionRule(sectionRule); + } + updateSectionColor(target, color) { + const sectionRule = deepCopy(this.props.definition.sectionRule); + sectionRule.colors[target] = color; + this.updateSectionRule(sectionRule); + this.closeMenus(); + } + updateRangeMin(ev) { + let sectionRule = deepCopy(this.props.definition.sectionRule); + sectionRule = { + ...sectionRule, + rangeMin: ev.target.value, + }; + this.updateSectionRule(sectionRule); + } + updateRangeMax(ev) { + let sectionRule = deepCopy(this.props.definition.sectionRule); + sectionRule = { + ...sectionRule, + rangeMax: ev.target.value, + }; + this.updateSectionRule(sectionRule); + } + toggleMenu(menu) { + const isSelected = this.state.openedMenu === menu; + this.closeMenus(); + if (!isSelected) { + this.state.openedMenu = menu; + } + } + updateSectionRule(sectionRule) { + this.state.sectionRuleDispatchResult = this.props.updateChart({ + sectionRule, + }); + } + closeMenus() { + this.state.openedMenu = undefined; + } + } + GaugeChartDesignPanel.template = "o-spreadsheet-GaugeChartDesignPanel"; + GaugeChartDesignPanel.components = { ColorPicker }; + + class LineConfigPanel extends LineBarPieConfigPanel { + get canTreatLabelsAsText() { + const chart = this.env.model.getters.getChart(this.props.figureId); + if (chart && chart instanceof LineChart) { + return canChartParseLabels(chart, this.env.model.getters); + } + return false; + } + onUpdateLabelsAsText(ev) { + this.props.updateChart({ + labelsAsText: ev.target.checked, + }); + } + } + LineConfigPanel.template = "o-spreadsheet-LineConfigPanel"; + + class LineChartDesignPanel extends LineBarPieDesignPanel { + } + LineChartDesignPanel.template = "o-spreadsheet-LineChartDesignPanel"; + + class ScorecardChartConfigPanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + keyValueDispatchResult: undefined, + baselineDispatchResult: undefined, + }); + this.keyValue = this.props.definition.keyValue; + this.baseline = this.props.definition.baseline; + } + get errorMessages() { + var _a, _b; + const cancelledReasons = [ + ...(((_a = this.state.keyValueDispatchResult) === null || _a === void 0 ? void 0 : _a.reasons) || []), + ...(((_b = this.state.baselineDispatchResult) === null || _b === void 0 ? void 0 : _b.reasons) || []), + ]; + return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); + } + get isKeyValueInvalid() { + var _a; + return !!((_a = this.state.keyValueDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(31 /* InvalidScorecardKeyValue */)); + } + get isBaselineInvalid() { + var _a; + return !!((_a = this.state.keyValueDispatchResult) === null || _a === void 0 ? void 0 : _a.isCancelledBecause(32 /* InvalidScorecardBaseline */)); + } + onKeyValueRangeChanged(ranges) { + this.keyValue = ranges[0]; + } + updateKeyValueRange() { + this.state.keyValueDispatchResult = this.props.updateChart({ + keyValue: this.keyValue, + }); + } + onBaselineRangeChanged(ranges) { + this.baseline = ranges[0]; + } + updateBaselineRange() { + this.state.baselineDispatchResult = this.props.updateChart({ + baseline: this.baseline, + }); + } + updateBaselineMode(ev) { + this.props.updateChart({ baselineMode: ev.target.value }); + } + } + ScorecardChartConfigPanel.template = "o-spreadsheet-ScorecardChartConfigPanel"; + ScorecardChartConfigPanel.components = { SelectionInput }; + + class ScorecardChartDesignPanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + openedColorPicker: undefined, + }); + } + updateTitle(ev) { + this.props.updateChart({ + title: ev.target.value, + }); + } + updateBaselineDescr(ev) { + this.props.updateChart({ baselineDescr: ev.target.value }); + } + openColorPicker(colorPickerId) { + this.state.openedColorPicker = colorPickerId; + } + setColor(color, colorPickerId) { + switch (colorPickerId) { + case "backgroundColor": + this.props.updateChart({ background: color }); + break; + case "baselineColorDown": + this.props.updateChart({ baselineColorDown: color }); + break; + case "baselineColorUp": + this.props.updateChart({ baselineColorUp: color }); + break; + } + this.state.openedColorPicker = undefined; + } + } + ScorecardChartDesignPanel.template = "o-spreadsheet-ScorecardChartDesignPanel"; + ScorecardChartDesignPanel.components = { ColorPicker }; + + const chartSidePanelComponentRegistry = new Registry(); + chartSidePanelComponentRegistry + .add("line", { + configuration: LineConfigPanel, + design: LineChartDesignPanel, + }) + .add("bar", { + configuration: BarConfigPanel, + design: BarChartDesignPanel, + }) + .add("pie", { + configuration: LineBarPieConfigPanel, + design: LineBarPieDesignPanel, + }) + .add("gauge", { + configuration: GaugeChartConfigPanel, + design: GaugeChartDesignPanel, + }) + .add("scorecard", { + configuration: ScorecardChartConfigPanel, + design: ScorecardChartDesignPanel, + }); + + css /* scss */ ` + .o-chart { + .o-panel { + display: flex; + .o-panel-element { + flex: 1 0 auto; + padding: 8px 0px; + text-align: center; + cursor: pointer; + border-right: 1px solid darkgray; + &.inactive { + background-color: ${BACKGROUND_HEADER_COLOR}; + border-bottom: 1px solid darkgray; + } + .fa { + margin-right: 4px; + } + } + .o-panel-element:last-child { + border-right: none; + } + } + + .o-with-color-picker { + position: relative; + } + .o-with-color-picker > span { + border-bottom: 4px solid; + } + } +`; + class ChartPanel extends owl.Component { + get figureId() { + return this.props.figureId; + } + setup() { + this.state = owl.useState({ + panel: "configuration", + }); + owl.onWillUpdateProps((nextProps) => { + if (!this.env.model.getters.isChartDefined(nextProps.figureId)) { + this.props.onCloseSidePanel(); + return; + } + }); + } + updateChart(updateDefinition) { + const definition = { + ...this.getChartDefinition(), + ...updateDefinition, + }; + return this.env.model.dispatch("UPDATE_CHART", { + definition, + id: this.figureId, + sheetId: this.env.model.getters.getActiveSheetId(), + }); + } + onTypeChange(type) { + const context = this.env.model.getters.getContextCreationChart(this.figureId); + if (!context) { + throw new Error("Chart not defined."); + } + const definition = getChartDefinitionFromContextCreation(context, type); + this.env.model.dispatch("UPDATE_CHART", { + definition, + id: this.figureId, + sheetId: this.env.model.getters.getActiveSheetId(), + }); + } + get chartPanel() { + const type = this.env.model.getters.getChartType(this.figureId); + if (!type) { + throw new Error("Chart not defined."); + } + const chartPanel = chartSidePanelComponentRegistry.get(type); + if (!chartPanel) { + throw new Error(`Component is not defined for type ${type}`); + } + return chartPanel; + } + getChartDefinition(figureId = this.figureId) { + return this.env.model.getters.getChartDefinition(figureId); + } + get chartTypes() { + return getChartTypes(); + } + activatePanel(panel) { + this.state.panel = panel; + } + } + ChartPanel.template = "o-spreadsheet-ChartPanel"; + + // ----------------------------------------------------------------------------- + // We need here the svg of the icons that we need to convert to images for the renderer + // ----------------------------------------------------------------------------- + const ARROW_DOWN = ''; + const ARROW_UP = ''; + const ARROW_RIGHT = ''; + const SMILE = ''; + const MEH = ''; + const FROWN = ''; + const GREEN_DOT = ''; + const YELLOW_DOT = ''; + const RED_DOT = ''; + function loadIconImage(svg) { + /** We have to add xmlns, as it's not added by owl in the canvas */ + svg = ` c.id === rules[0]); + if (cf) { + this.editConditionalFormat(cf); + } + } + owl.onWillUpdateProps((nextProps) => { + const newActiveSheetId = this.env.model.getters.getActiveSheetId(); + if (newActiveSheetId !== this.activeSheetId) { + this.activeSheetId = newActiveSheetId; + this.switchToList(); + } + else if (nextProps.selection !== this.props.selection) { + const sheetId = this.env.model.getters.getActiveSheetId(); + const rules = this.env.model.getters.getRulesSelection(sheetId, nextProps.selection || []); + if (rules.length === 1) { + const cf = this.conditionalFormats.find((c) => c.id === rules[0]); + if (cf) { + this.editConditionalFormat(cf); + } + } + else { + this.switchToList(); + } + } + }); + owl.useExternalListener(window, "click", this.closeMenus); + } + get conditionalFormats() { + return this.env.model.getters.getConditionalFormats(this.env.model.getters.getActiveSheetId()); + } + get isRangeValid() { + return this.state.errors.includes(22 /* EmptyRange */); + } + errorMessage(error) { + return CfTerms.Errors[error] || CfTerms.Errors.Unexpected; + } + /** + * Switch to the list view + */ + switchToList() { + this.state.mode = "list"; + this.state.currentCF = undefined; + this.state.currentCFType = undefined; + this.state.errors = []; + this.state.rules = this.getDefaultRules(); + } + getStyle(rule) { + if (rule.type === "CellIsRule") { + const fontWeight = rule.style.bold ? "bold" : "normal"; + const fontDecoration = getTextDecoration(rule.style); + const fontStyle = rule.style.italic ? "italic" : "normal"; + const color = rule.style.textColor || "none"; + const backgroundColor = rule.style.fillColor || "none"; + return `font-weight:${fontWeight}; + text-decoration:${fontDecoration}; + font-style:${fontStyle}; + color:${color}; + background-color:${backgroundColor};`; + } + else if (rule.type === "ColorScaleRule") { + const minColor = colorNumberString(rule.minimum.color); + const midColor = rule.midpoint ? colorNumberString(rule.midpoint.color) : null; + const maxColor = colorNumberString(rule.maximum.color); + const baseString = "background-image: linear-gradient(to right, "; + return midColor + ? baseString + minColor + ", " + midColor + ", " + maxColor + ")" + : baseString + minColor + ", " + maxColor + ")"; + } + return ""; + } + getDescription(cf) { + switch (cf.rule.type) { + case "CellIsRule": + const description = CellIsOperators[cf.rule.operator]; + if (cf.rule.values.length === 1) { + return `${description} ${cf.rule.values[0]}`; + } + if (cf.rule.values.length === 2) { + return _t("%s %s and %s", description, cf.rule.values[0], cf.rule.values[1]); + } + return description; + case "ColorScaleRule": + return CfTerms.ColorScale; + case "IconSetRule": + return CfTerms.IconSet; + default: + return ""; + } + } + saveConditionalFormat() { + if (this.state.currentCF) { + const invalidRanges = this.state.currentCF.ranges.some((xc) => !xc.match(rangeReference)); + if (invalidRanges) { + this.state.errors = [23 /* InvalidRange */]; + return; + } + const sheetId = this.env.model.getters.getActiveSheetId(); + const result = this.env.model.dispatch("ADD_CONDITIONAL_FORMAT", { + cf: { + rule: this.getEditorRule(), + id: this.state.mode === "edit" + ? this.state.currentCF.id + : this.env.model.uuidGenerator.uuidv4(), + }, + ranges: this.state.currentCF.ranges.map((xc) => this.env.model.getters.getRangeDataFromXc(sheetId, xc)), + sheetId, + }); + if (!result.isSuccessful) { + this.state.errors = result.reasons; + } + else { + this.switchToList(); + } + } + } + /** + * Get the rule currently edited with the editor + */ + getEditorRule() { + switch (this.state.currentCFType) { + case "CellIsRule": + return this.state.rules.cellIs; + case "ColorScaleRule": + return this.state.rules.colorScale; + case "IconSetRule": + return this.state.rules.iconSet; + } + throw new Error(`Invalid cf type: ${this.state.currentCFType}`); + } + getDefaultRules() { + return { + cellIs: { + type: "CellIsRule", + operator: "IsNotEmpty", + values: [], + style: { fillColor: "#b6d7a8" }, + }, + colorScale: { + type: "ColorScaleRule", + minimum: { type: "value", color: 0xffffff }, + midpoint: undefined, + maximum: { type: "value", color: 0x6aa84f }, + }, + iconSet: { + type: "IconSetRule", + icons: { + upper: "arrowGood", + middle: "arrowNeutral", + lower: "arrowBad", + }, + upperInflectionPoint: { + type: "percentage", + value: "66", + operator: "gt", + }, + lowerInflectionPoint: { + type: "percentage", + value: "33", + operator: "gt", + }, + }, + }; + } + /** + * Create a new CF, a CellIsRule by default + */ + addConditionalFormat() { + this.state.mode = "add"; + this.state.currentCFType = "CellIsRule"; + this.state.currentCF = { + id: this.env.model.uuidGenerator.uuidv4(), + ranges: this.env.model.getters + .getSelectedZones() + .map((zone) => this.env.model.getters.zoneToXC(this.env.model.getters.getActiveSheetId(), zone)), + }; + } + /** + * Delete a CF + */ + deleteConditionalFormat(cf) { + this.env.model.dispatch("REMOVE_CONDITIONAL_FORMAT", { + id: cf.id, + sheetId: this.env.model.getters.getActiveSheetId(), + }); + } + /** + * Edit an existing CF. Return without doing anything in reorder mode. + */ + editConditionalFormat(cf) { + if (this.state.mode === "reorder") + return; + this.state.mode = "edit"; + this.state.currentCF = cf; + this.state.currentCFType = cf.rule.type; + switch (cf.rule.type) { + case "CellIsRule": + this.state.rules.cellIs = cf.rule; + break; + case "ColorScaleRule": + this.state.rules.colorScale = cf.rule; + break; + case "IconSetRule": + this.state.rules.iconSet = cf.rule; + break; + } + } + /** + * Reorder existing CFs + */ + reorderConditionalFormats() { + this.state.mode = "reorder"; + } + reorderRule(cf, direction) { + this.env.model.dispatch("MOVE_CONDITIONAL_FORMAT", { + cfId: cf.id, + direction: direction, + sheetId: this.env.model.getters.getActiveSheetId(), + }); + } + changeRuleType(ruleType) { + if (this.state.currentCFType === ruleType || !this.state.rules) { + return; + } + this.state.errors = []; + this.state.currentCFType = ruleType; + } + onRangesChanged(ranges) { + if (this.state.currentCF) { + this.state.currentCF.ranges = ranges; + } + } + /***************************************************************************** + * Common + ****************************************************************************/ + toggleMenu(menu) { + const isSelected = this.state.openedMenu === menu; + this.closeMenus(); + if (!isSelected) { + this.state.openedMenu = menu; + } + } + closeMenus() { + this.state.openedMenu = undefined; + } + /***************************************************************************** + * Cell Is Rule + ****************************************************************************/ + get isValue1Invalid() { + var _a; + return !!((_a = this.state.errors) === null || _a === void 0 ? void 0 : _a.includes(48 /* FirstArgMissing */)); + } + get isValue2Invalid() { + var _a; + return !!((_a = this.state.errors) === null || _a === void 0 ? void 0 : _a.includes(49 /* SecondArgMissing */)); + } + toggleStyle(tool) { + const style = this.state.rules.cellIs.style; + style[tool] = !style[tool]; + this.closeMenus(); + } + setColor(target, color) { + this.state.rules.cellIs.style[target] = color; + this.closeMenus(); + } + /***************************************************************************** + * Color Scale Rule + ****************************************************************************/ + isValueInvalid(threshold) { + switch (threshold) { + case "minimum": + return (this.state.errors.includes(55 /* MinInvalidFormula */) || + this.state.errors.includes(47 /* MinBiggerThanMid */) || + this.state.errors.includes(44 /* MinBiggerThanMax */) || + this.state.errors.includes(50 /* MinNaN */)); + case "midpoint": + return (this.state.errors.includes(56 /* MidInvalidFormula */) || + this.state.errors.includes(51 /* MidNaN */) || + this.state.errors.includes(46 /* MidBiggerThanMax */)); + case "maximum": + return (this.state.errors.includes(57 /* MaxInvalidFormula */) || + this.state.errors.includes(52 /* MaxNaN */)); + default: + return false; + } + } + setColorScaleColor(target, color) { + const point = this.state.rules.colorScale[target]; + if (point) { + point.color = Number.parseInt(color.substr(1), 16); + } + this.closeMenus(); + } + getPreviewGradient() { + var _a; + const rule = this.state.rules.colorScale; + const minColor = colorNumberString(rule.minimum.color); + const midColor = colorNumberString(((_a = rule.midpoint) === null || _a === void 0 ? void 0 : _a.color) || DEFAULT_COLOR_SCALE_MIDPOINT_COLOR); + const maxColor = colorNumberString(rule.maximum.color); + const baseString = "background-image: linear-gradient(to right, "; + return rule.midpoint === undefined + ? baseString + minColor + ", " + maxColor + ")" + : baseString + minColor + ", " + midColor + ", " + maxColor + ")"; + } + getThresholdColor(threshold) { + return threshold + ? colorNumberString(threshold.color) + : colorNumberString(DEFAULT_COLOR_SCALE_MIDPOINT_COLOR); + } + onMidpointChange(ev) { + const type = ev.target.value; + const rule = this.state.rules.colorScale; + if (type === "none") { + rule.midpoint = undefined; + } + else { + rule.midpoint = { + color: DEFAULT_COLOR_SCALE_MIDPOINT_COLOR, + value: "", + ...rule.midpoint, + type, + }; + } + } + /***************************************************************************** + * Icon Set + ****************************************************************************/ + isInflectionPointInvalid(inflectionPoint) { + switch (inflectionPoint) { + case "lowerInflectionPoint": + return (this.state.errors.includes(54 /* ValueLowerInflectionNaN */) || + this.state.errors.includes(59 /* ValueLowerInvalidFormula */) || + this.state.errors.includes(45 /* LowerBiggerThanUpper */)); + case "upperInflectionPoint": + return (this.state.errors.includes(53 /* ValueUpperInflectionNaN */) || + this.state.errors.includes(58 /* ValueUpperInvalidFormula */) || + this.state.errors.includes(45 /* LowerBiggerThanUpper */)); + default: + return true; + } + } + reverseIcons() { + const icons = this.state.rules.iconSet.icons; + const upper = icons.upper; + icons.upper = icons.lower; + icons.lower = upper; + } + setIconSet(iconSet) { + const icons = this.state.rules.iconSet.icons; + icons.upper = this.iconSets[iconSet].good; + icons.middle = this.iconSets[iconSet].neutral; + icons.lower = this.iconSets[iconSet].bad; + } + setIcon(target, icon) { + this.state.rules.iconSet.icons[target] = icon; + } + } + ConditionalFormattingPanel.template = "o-spreadsheet-ConditionalFormattingPanel"; + ConditionalFormattingPanel.components = { SelectionInput, IconPicker, ColorPicker }; + + css /* scss */ ` + .o-custom-currency { + .o-format-proposals { + color: black; + } + } +`; + class CustomCurrencyPanel extends owl.Component { + setup() { + this.availableCurrencies = []; + this.state = owl.useState({ + selectedCurrencyIndex: 0, + currencyCode: "", + currencySymbol: "", + selectedFormatIndex: 0, + }); + owl.onWillStart(() => this.updateAvailableCurrencies()); + } + get formatProposals() { + const currency = this.availableCurrencies[this.state.selectedCurrencyIndex]; + const proposalBases = this.initProposalBases(currency.decimalPlaces); + const firstPosition = currency.position; + const secondPosition = currency.position === "before" ? "after" : "before"; + const symbol = this.state.currencySymbol.trim() ? this.state.currencySymbol : ""; + const code = this.state.currencyCode.trim() ? this.state.currencyCode : ""; + return code || symbol + ? [ + ...this.createFormatProposals(proposalBases, symbol, code, firstPosition), + ...this.createFormatProposals(proposalBases, symbol, code, secondPosition), + ] + : []; + } + get isSameFormat() { + const selectedFormat = this.formatProposals[this.state.selectedFormatIndex]; + return selectedFormat ? selectedFormat.format === this.getCommonFormat() : false; + } + async updateAvailableCurrencies() { + var _a, _b; + if (currenciesRegistry.getAll().length === 0) { + const currencies = (await ((_b = (_a = this.env).loadCurrencies) === null || _b === void 0 ? void 0 : _b.call(_a))) || []; + currencies.forEach((currency, index) => { + currenciesRegistry.add(index.toString(), currency); + }); + } + const emptyCurrency = { + name: this.env._t(CustomCurrencyTerms.Custom), + code: "", + symbol: "", + decimalPlaces: 2, + position: "after", + }; + this.availableCurrencies = [emptyCurrency, ...currenciesRegistry.getAll()]; + } + updateSelectCurrency(ev) { + const target = ev.target; + this.state.selectedCurrencyIndex = parseInt(target.value, 10); + const currency = this.availableCurrencies[this.state.selectedCurrencyIndex]; + this.state.currencyCode = currency.code; + this.state.currencySymbol = currency.symbol; + } + updateCode(ev) { + const target = ev.target; + this.state.currencyCode = target.value; + this.initAvailableCurrencies(); + } + updateSymbol(ev) { + const target = ev.target; + this.state.currencySymbol = target.value; + this.initAvailableCurrencies(); + } + updateSelectFormat(ev) { + const target = ev.target; + this.state.selectedFormatIndex = parseInt(target.value, 10); + } + apply() { + const selectedFormat = this.formatProposals[this.state.selectedFormatIndex]; + this.env.model.dispatch("SET_FORMATTING", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + format: selectedFormat.format, + }); + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + initAvailableCurrencies() { + this.state.selectedCurrencyIndex = 0; + } + initProposalBases(decimalPlaces) { + const result = [{ format: "#,##0", example: "1,000" }]; + const decimalRepresentation = decimalPlaces ? "." + "0".repeat(decimalPlaces) : ""; + if (decimalRepresentation) { + result.push({ + format: "#,##0" + decimalRepresentation, + example: "1,000" + decimalRepresentation, + }); + } + return result; + } + createFormatProposals(proposalBases, symbol, code, position) { + let formatProposals = []; + // 1 - add proposal with symbol and without code + if (symbol) { + for (let base of proposalBases) { + formatProposals.push(this.createFormatProposal(position, base.example, base.format, symbol)); + } + } + // 2 - if code exist --> add more proposal with symbol and with code + if (code) { + for (let base of proposalBases) { + const expression = (position === "after" ? " " : "") + code + " " + symbol; + formatProposals.push(this.createFormatProposal(position, base.example, base.format, expression)); + } + } + return formatProposals; + } + createFormatProposal(position, baseExample, formatBase, expression) { + const formatExpression = "[$" + expression + "]"; + return { + example: position === "before" ? expression + baseExample : baseExample + expression, + format: position === "before" ? formatExpression + formatBase : formatBase + formatExpression, + }; + } + getCommonFormat() { + var _a; + const selectedZones = this.env.model.getters.getSelectedZones(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const cells = selectedZones + .map((zone) => this.env.model.getters.getCellsInZone(sheetId, zone)) + .flat(); + const firstFormat = (_a = cells[0]) === null || _a === void 0 ? void 0 : _a.format; + return cells.every((cell) => (cell === null || cell === void 0 ? void 0 : cell.format) === firstFormat) ? firstFormat : undefined; + } + currencyDisplayName(currency) { + return currency.name + (currency.code ? ` (${currency.code})` : ""); + } + } + CustomCurrencyPanel.template = "o-spreadsheet-CustomCurrencyPanel"; + + css /* scss */ ` + .o-find-and-replace { + .o-far-item { + display: block; + .o-far-checkbox { + display: inline-block; + .o-far-input { + vertical-align: middle; + } + .o-far-label { + position: relative; + top: 1.5px; + padding-left: 4px; + } + } + } + outline: none; + height: 100%; + .o-input-search-container { + display: flex; + .o-input-with-count { + flex-grow: 1; + width: auto; + } + .o-input-without-count { + width: 100%; + } + .o-input-count { + width: fit-content; + padding: 4 0 4 4; + } + } + } +`; + class FindAndReplacePanel extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState(this.initialState()); + this.showFormulaState = false; + this.findAndReplaceRef = owl.useRef("findAndReplace"); + } + get hasSearchResult() { + return this.env.model.getters.getCurrentSelectedMatchIndex() !== null; + } + setup() { + this.showFormulaState = this.env.model.getters.shouldShowFormulas(); + owl.onMounted(() => this.focusInput()); + owl.onWillUnmount(() => { + this.env.model.dispatch("CLEAR_SEARCH"); + this.env.model.dispatch("SET_FORMULA_VISIBILITY", { show: this.showFormulaState }); + }); + } + onInput(ev) { + this.state.toSearch = ev.target.value; + this.debouncedUpdateSearch(); + } + onKeydownSearch(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.onSelectNextCell(); + } + } + onKeydownReplace(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.replace(); + } + } + onFocusSidePanel() { + this.state.searchOptions.searchFormulas = this.env.model.getters.shouldShowFormulas(); + this.env.model.dispatch("REFRESH_SEARCH"); + } + searchFormulas() { + this.env.model.dispatch("SET_FORMULA_VISIBILITY", { + show: this.state.searchOptions.searchFormulas, + }); + this.updateSearch(); + } + onSelectPreviousCell() { + this.env.model.dispatch("SELECT_SEARCH_PREVIOUS_MATCH"); + } + onSelectNextCell() { + this.env.model.dispatch("SELECT_SEARCH_NEXT_MATCH"); + } + updateSearch() { + if (this.state.toSearch) { + this.env.model.dispatch("UPDATE_SEARCH", { + toSearch: this.state.toSearch, + searchOptions: this.state.searchOptions, + }); + } + } + debouncedUpdateSearch() { + clearTimeout(this.inDebounce); + this.inDebounce = setTimeout(() => this.updateSearch.call(this), 400); + } + replace() { + this.env.model.dispatch("REPLACE_SEARCH", { + replaceWith: this.state.replaceWith, + }); + } + replaceAll() { + this.env.model.dispatch("REPLACE_ALL_SEARCH", { + replaceWith: this.state.replaceWith, + }); + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + focusInput() { + const el = this.findAndReplaceRef.el; + const input = el.querySelector(`input`); + if (input) { + input.focus(); + } + } + initialState() { + return { + toSearch: "", + replaceWith: "", + searchOptions: { + matchCase: false, + exactMatch: false, + searchFormulas: false, + }, + }; + } + } + FindAndReplacePanel.template = "o-spreadsheet-FindAndReplacePanel"; + + const sidePanelRegistry = new Registry(); + sidePanelRegistry.add("ConditionalFormatting", { + title: _lt("Conditional formatting"), + Body: ConditionalFormattingPanel, + }); + sidePanelRegistry.add("ChartPanel", { + title: _lt("Chart"), + Body: ChartPanel, + }); + sidePanelRegistry.add("FindAndReplace", { + title: _lt("Find and Replace"), + Body: FindAndReplacePanel, + }); + sidePanelRegistry.add("CustomCurrency", { + title: _lt("Custom currency format"), + Body: CustomCurrencyPanel, + }); + + class TopBarComponentRegistry extends Registry { + constructor() { + super(...arguments); + this.mapping = {}; + this.uuidGenerator = new UuidGenerator(); + } + add(name, value) { + const component = { ...value, id: this.uuidGenerator.uuidv4() }; + return super.add(name, component); + } + } + const topbarComponentRegistry = new TopBarComponentRegistry(); + + /* Sizes of boxes containing the texts, in percentage of the Chart size */ + const TITLE_FONT_SIZE = 18; + const BASELINE_BOX_HEIGHT_RATIO = 0.35; + const KEY_BOX_HEIGHT_RATIO = 0.65; + /** Baseline description should have a smaller font than the baseline */ + const BASELINE_DESCR_FONT_RATIO = 0.9; + /* Padding at the border of the chart, in percentage of the chart width */ + const CHART_PADDING_RATIO = 0.02; + /** + * Line height (in em) + * Having a line heigh =1em (=font size) don't work, the font will overflow. + */ + const LINE_HEIGHT = 1.2; + css /* scss */ ` + div.o-scorecard { + user-select: none; + background-color: white; + display: flex; + flex-direction: column; + box-sizing: border-box; + + .o-scorecard-content { + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + text-align: center; + } + + .o-title-text { + text-align: left; + height: ${LINE_HEIGHT + "em"}; + line-height: ${LINE_HEIGHT + "em"}; + overflow: hidden; + white-space: nowrap; + } + + .o-key-text { + line-height: ${LINE_HEIGHT + "em"}; + height: ${LINE_HEIGHT + "em"}; + overflow: hidden; + white-space: nowrap; + } + + .o-cf-icon { + display: inline-block; + width: 0.65em; + height: 1em; + line-height: 1em; + padding-bottom: 0.07em; + padding-right: 3px; + } + + .o-baseline-text { + line-height: ${LINE_HEIGHT + "em"}; + height: ${LINE_HEIGHT + "em"}; + overflow: hidden; + white-space: nowrap; + + .o-baseline-text-description { + white-space: pre; + } + } + } +`; + class ScorecardChart extends owl.Component { + constructor() { + super(...arguments); + this.ctx = document.createElement("canvas").getContext("2d"); + } + get runtime() { + return this.env.model.getters.getChartRuntime(this.props.figure.id); + } + get title() { + var _a; + return ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.title) || ""; + } + get keyValue() { + var _a; + return ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.keyValue) || ""; + } + get baseline() { + var _a; + return ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.baselineDisplay) || ""; + } + get baselineDescr() { + var _a; + const baselineDescr = ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.baselineDescr) || ""; + return this.baseline && baselineDescr ? " " + baselineDescr : baselineDescr; + } + get baselineArrowDirection() { + var _a; + return ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.baselineArrow) || "neutral"; + } + get backgroundColor() { + var _a; + return ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.background) || "#ffffff"; + } + get primaryFontColor() { + var _a; + return ((_a = this.runtime) === null || _a === void 0 ? void 0 : _a.fontColor) || "#000000"; + } + get secondaryFontColor() { + return relativeLuminance(this.primaryFontColor) <= 0.3 ? "#757575" : "#bbbbbb"; + } + get figure() { + return this.props.figure; + } + get chartStyle() { + return ` + height:${this.figure.height}px; + width:${this.figure.width}px; + padding:${this.chartPadding}px; + background:${this.backgroundColor}; + `; + } + get chartContentStyle() { + return ` + height:${this.getDrawableHeight()}px; + `; + } + get chartPadding() { + return this.figure.width * CHART_PADDING_RATIO; + } + getTextStyles() { + var _a, _b, _c; + // If the widest text overflows horizontally, scale it down, and apply the same scaling factors to all the other fonts. + const maxLineWidth = this.figure.width * (1 - 2 * CHART_PADDING_RATIO); + const widestElement = this.getWidestElement(); + const baseFontSize = widestElement.getElementMaxFontSize(this.getDrawableHeight(), this); + const fontSizeMatchingWidth = getFontSizeMatchingWidth(maxLineWidth, baseFontSize, (fontSize) => widestElement.getElementWidth(fontSize, this.ctx, this)); + let scalingFactor = fontSizeMatchingWidth / baseFontSize; + // Fonts sizes in px + const keyFontSize = new KeyValueElement().getElementMaxFontSize(this.getDrawableHeight(), this) * scalingFactor; + const baselineFontSize = new BaselineElement().getElementMaxFontSize(this.getDrawableHeight(), this) * scalingFactor; + return { + titleStyle: this.getTextStyle({ + fontSize: TITLE_FONT_SIZE, + color: this.secondaryFontColor, + }), + keyStyle: this.getTextStyle({ + fontSize: keyFontSize, + cellStyle: (_a = this.runtime) === null || _a === void 0 ? void 0 : _a.keyValueStyle, + color: this.primaryFontColor, + }), + baselineStyle: this.getTextStyle({ + fontSize: baselineFontSize, + }), + baselineValueStyle: this.getTextStyle({ + fontSize: baselineFontSize, + cellStyle: (_b = this.runtime) === null || _b === void 0 ? void 0 : _b.baselineStyle, + color: ((_c = this.runtime) === null || _c === void 0 ? void 0 : _c.baselineColor) || this.secondaryFontColor, + }), + baselineDescrStyle: this.getTextStyle({ + fontSize: baselineFontSize * BASELINE_DESCR_FONT_RATIO, + color: this.secondaryFontColor, + }), + }; + } + /** Return an CSS style string corresponding to the given arguments */ + getTextStyle(args) { + const cssAttributes = cellTextStyleToCss(args.cellStyle); + cssAttributes["font-size"] = `${args.fontSize}px`; + cssAttributes["display"] = "inline-block"; + if (!cssAttributes["color"] && args.color) { + cssAttributes["color"] = args.color; + } + return cssPropertiesToCss(cssAttributes); + } + /** Get the height of the chart minus all the vertical paddings */ + getDrawableHeight() { + const verticalPadding = 2 * this.chartPadding; + let availableHeight = this.figure.height - verticalPadding; + availableHeight -= this.title ? TITLE_FONT_SIZE * LINE_HEIGHT : 0; + return availableHeight; + } + /** Return the element with he widest text in the chart */ + getWidestElement() { + const baseline = new BaselineElement(); + const keyValue = new KeyValueElement(); + return baseline.getElementWidth(BASELINE_BOX_HEIGHT_RATIO, this.ctx, this) > + keyValue.getElementWidth(KEY_BOX_HEIGHT_RATIO, this.ctx, this) + ? baseline + : keyValue; + } + } + ScorecardChart.template = "o-spreadsheet-ScorecardChart"; + class BaselineElement { + getElementWidth(fontSize, ctx, chart) { + if (!chart.runtime) + return 0; + const baselineStr = chart.baseline; + // Put mock text to simulate the width of the up/down arrow + const largeText = chart.baselineArrowDirection !== "neutral" ? "A " + baselineStr : baselineStr; + ctx.font = `${fontSize}px ${DEFAULT_FONT}`; + let textWidth = ctx.measureText(largeText).width; + // Baseline descr font size should be smaller than baseline font size + ctx.font = `${fontSize * BASELINE_DESCR_FONT_RATIO}px ${DEFAULT_FONT}`; + textWidth += ctx.measureText(chart.baselineDescr).width; + return textWidth; + } + getElementMaxFontSize(availableHeight, chart) { + if (!chart.runtime) + return 0; + const haveBaseline = chart.baseline !== "" || chart.baselineDescr; + const maxHeight = haveBaseline ? BASELINE_BOX_HEIGHT_RATIO * availableHeight : 0; + return maxHeight / LINE_HEIGHT; + } + } + class KeyValueElement { + getElementWidth(fontSize, ctx, chart) { + if (!chart.runtime) + return 0; + const str = chart.keyValue || ""; + ctx.font = `${fontSize}px ${DEFAULT_FONT}`; + return ctx.measureText(str).width; + } + getElementMaxFontSize(availableHeight, chart) { + if (!chart.runtime) + return 0; + const haveBaseline = chart.baseline !== "" || chart.baselineDescr; + const maxHeight = haveBaseline ? KEY_BOX_HEIGHT_RATIO * availableHeight : availableHeight; + return maxHeight / LINE_HEIGHT; + } + } + chartComponentRegistry.add("scorecard", ScorecardChart); + + // ----------------------------------------------------------------------------- + // STYLE + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-chart-container { + width: 100%; + height: 100%; + position: relative; + + .o-chart-menu { + right: 0px; + display: none; + position: absolute; + padding: 5px; + } + + .o-chart-menu-item { + cursor: pointer; + } + } + .o-figure.active:focus, + .o-figure:hover { + .o-chart-container { + .o-chart-menu { + display: flex; + } + } + } +`; + class ChartFigure extends owl.Component { + constructor() { + super(...arguments); + this.menuState = owl.useState({ isOpen: false, position: null, menuItems: [] }); + this.chartContainerRef = owl.useRef("chartContainer"); + this.menuButtonRef = owl.useRef("menuButton"); + this.menuButtonPosition = useAbsolutePosition(this.menuButtonRef); + this.position = useAbsolutePosition(this.chartContainerRef); + } + getMenuItemRegistry() { + const registry = new MenuItemRegistry(); + registry.add("edit", { + name: _lt("Edit"), + sequence: 1, + action: () => this.env.openSidePanel("ChartPanel", { figureId: this.props.figure.id }), + }); + registry.add("copy", { + name: _lt("Copy"), + sequence: 2, + action: async () => { + this.env.model.dispatch("SELECT_FIGURE", { id: this.props.figure.id }); + this.env.model.dispatch("COPY"); + await this.env.clipboard.writeText(this.env.model.getters.getClipboardContent()); + }, + }); + registry.add("cut", { + name: _lt("Cut"), + sequence: 3, + action: async () => { + this.env.model.dispatch("SELECT_FIGURE", { id: this.props.figure.id }); + this.env.model.dispatch("CUT"); + await this.env.clipboard.writeText(this.env.model.getters.getClipboardContent()); + }, + }); + registry.add("delete", { + name: _lt("Delete"), + sequence: 10, + action: () => { + this.env.model.dispatch("DELETE_FIGURE", { + sheetId: this.env.model.getters.getActiveSheetId(), + id: this.props.figure.id, + }); + if (this.props.sidePanelIsOpen) { + this.env.toggleSidePanel("ChartPanel", { figureId: this.props.figure.id }); + } + this.props.onFigureDeleted(); + }, + }); + registry.add("refresh", { + name: _lt("Refresh"), + sequence: 11, + action: () => { + this.env.model.dispatch("REFRESH_CHART", { + id: this.props.figure.id, + }); + }, + }); + return registry; + } + get chartType() { + return this.env.model.getters.getChartType(this.props.figure.id); + } + onContextMenu(ev) { + const position = { + x: this.position.x + ev.offsetX, + y: this.position.y + ev.offsetY, + }; + this.openContextMenu(position); + } + showMenu() { + const position = { + x: this.menuButtonPosition.x - MENU_WIDTH, + y: this.menuButtonPosition.y, + }; + this.openContextMenu(position); + } + openContextMenu(position) { + const registry = this.getMenuItemRegistry(); + this.menuState.isOpen = true; + this.menuState.menuItems = registry.getAll().filter((x) => x.isVisible(this.env)); + this.menuState.position = position; + } + get chartComponent() { + const type = this.chartType; + const component = chartComponentRegistry.get(type); + if (!component) { + throw new Error(`Component is not defined for type ${type}`); + } + return component; + } + } + ChartFigure.template = "o-spreadsheet-ChartFigure"; + ChartFigure.components = { Menu }; + + function startDnd(onMouseMove, onMouseUp) { + const _onMouseUp = (ev) => { + onMouseUp(ev); + window.removeEventListener("mouseup", _onMouseUp); + window.removeEventListener("dragstart", _onDragStart); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("wheel", onMouseMove); + }; + function _onDragStart(ev) { + ev.preventDefault(); + } + window.addEventListener("mouseup", _onMouseUp); + window.addEventListener("dragstart", _onDragStart); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("wheel", onMouseMove); + } + /** + * Function to be used during a mousedown event, this function allows to + * perform actions related to the mousemove and mouseup events and adjusts the viewport + * when the new position related to the mousemove event is outside of it. + * Among inputs are two callback functions. First intended for actions performed during + * the mousemove event, it receives as parameters the current position of the mousemove + * (occurrence of the current column and the current row). Second intended for actions + * performed during the mouseup event. + */ + function dragAndDropBeyondTheViewport(element, env, cbMouseMove, cbMouseUp) { + const position = element.getBoundingClientRect(); + let timeOutId = null; + let currentEv; + const onMouseMove = (ev) => { + currentEv = ev; + if (timeOutId) { + return; + } + const offsetX = currentEv.clientX - position.left; + const offsetY = currentEv.clientY - position.top; + const edgeScrollInfoX = env.model.getters.getEdgeScrollCol(offsetX - HEADER_WIDTH); + const edgeScrollInfoY = env.model.getters.getEdgeScrollRow(offsetY - HEADER_HEIGHT); + const { top, left, bottom, right } = env.model.getters.getActiveViewport(); + let colIndex; + if (edgeScrollInfoX.canEdgeScroll) { + colIndex = edgeScrollInfoX.direction > 0 ? right : left - 1; + } + else { + colIndex = env.model.getters.getColIndex(offsetX - HEADER_WIDTH); + } + let rowIndex; + if (edgeScrollInfoY.canEdgeScroll) { + rowIndex = edgeScrollInfoY.direction > 0 ? bottom : top - 1; + } + else { + rowIndex = env.model.getters.getRowIndex(offsetY - HEADER_HEIGHT); + } + cbMouseMove(colIndex, rowIndex); + const sheetId = env.model.getters.getActiveSheetId(); + if (edgeScrollInfoX.canEdgeScroll) { + const { left, offsetY } = env.model.getters.getActiveViewport(); + const offsetX = env.model.getters.getColDimensions(sheetId, left + edgeScrollInfoX.direction).start; + env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY }); + timeOutId = setTimeout(() => { + timeOutId = null; + onMouseMove(currentEv); + }, Math.round(edgeScrollInfoX.delay)); + } + if (edgeScrollInfoY.canEdgeScroll) { + const { top, offsetX } = env.model.getters.getActiveViewport(); + const offsetY = env.model.getters.getRowDimensions(sheetId, top + edgeScrollInfoY.direction).start; + env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY }); + timeOutId = setTimeout(() => { + timeOutId = null; + onMouseMove(currentEv); + }, Math.round(edgeScrollInfoY.delay)); + } + }; + const onMouseUp = () => { + clearTimeout(timeOutId); + cbMouseUp(); + }; + startDnd(onMouseMove, onMouseUp); + } + + // ----------------------------------------------------------------------------- + // Autofill + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-autofill { + height: 6px; + width: 6px; + border: 1px solid white; + position: absolute; + background-color: #1a73e8; + + .o-autofill-handler { + position: absolute; + height: ${AUTOFILL_EDGE_LENGTH}px; + width: ${AUTOFILL_EDGE_LENGTH}px; + + &:hover { + cursor: crosshair; + } + } + + .o-autofill-nextvalue { + position: absolute; + background-color: #ffffff; + border: 1px solid black; + padding: 5px; + font-size: 12px; + pointer-events: none; + white-space: nowrap; + } + } +`; + class Autofill extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + position: { left: 0, top: 0 }, + handler: false, + }); + } + get style() { + const { left, top } = this.props.position; + return `top:${top}px;left:${left}px`; + } + get styleHandler() { + let position = this.state.handler ? this.state.position : { left: 0, top: 0 }; + return `top:${position.top}px;left:${position.left}px;`; + } + get styleNextvalue() { + let position = this.state.handler ? this.state.position : { left: 0, top: 0 }; + return `top:${position.top + 5}px;left:${position.left + 15}px;`; + } + getTooltip() { + const tooltip = this.env.model.getters.getAutofillTooltip(); + if (tooltip && !tooltip.component) { + tooltip.component = TooltipComponent; + } + return tooltip; + } + onMouseDown(ev) { + this.state.handler = true; + this.state.position = { left: 0, top: 0 }; + const { offsetY, offsetX } = this.env.model.getters.getActiveViewport(); + const start = { + left: ev.clientX + offsetX, + top: ev.clientY + offsetY, + }; + let lastCol; + let lastRow; + const onMouseUp = () => { + this.state.handler = false; + this.env.model.dispatch("AUTOFILL"); + }; + const onMouseMove = (ev) => { + const position = this.props.getGridBoundingClientRect(); + const { offsetY, offsetX } = this.env.model.getters.getActiveViewport(); + this.state.position = { + left: ev.clientX - start.left + offsetX, + top: ev.clientY - start.top + offsetY, + }; + const col = this.env.model.getters.getColIndex(ev.clientX - position.left - HEADER_WIDTH); + const row = this.env.model.getters.getRowIndex(ev.clientY - position.top - HEADER_HEIGHT); + if (lastCol !== col || lastRow !== row) { + const activeSheetId = this.env.model.getters.getActiveSheetId(); + const numberOfCols = this.env.model.getters.getNumberCols(activeSheetId); + const numberOfRows = this.env.model.getters.getNumberRows(activeSheetId); + lastCol = col === -1 ? lastCol : clip(col, 0, numberOfCols); + lastRow = row === -1 ? lastRow : clip(row, 0, numberOfRows); + if (lastCol !== undefined && lastRow !== undefined) { + this.env.model.dispatch("AUTOFILL_SELECT", { col: lastCol, row: lastRow }); + } + } + }; + startDnd(onMouseMove, onMouseUp); + } + onDblClick() { + this.env.model.dispatch("AUTOFILL_AUTO"); + } + } + Autofill.template = "o-spreadsheet-Autofill"; + class TooltipComponent extends owl.Component { + } + TooltipComponent.template = owl.xml /* xml */ ` + + `; + + css /* scss */ ` + .o-client-tag { + position: absolute; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + font-size: ${DEFAULT_FONT_SIZE}; + color: white; + opacity: 0; + pointer-events: none; + } +`; + class ClientTag extends owl.Component { + get tagStyle() { + const { col, row, color } = this.props; + const viewport = this.env.model.getters.getActiveViewport(); + const { height } = this.env.model.getters.getViewportDimensionWithHeaders(); + const { x, y } = this.env.model.getters.getRect({ left: col, top: row, right: col, bottom: row }, viewport); + return `bottom: ${height - y + 15}px;left: ${x - 1}px;border: 1px solid ${color};background-color: ${color};${this.props.active ? "opacity:1 !important" : ""}`; + } + } + ClientTag.template = "o-spreadsheet-ClientTag"; + + //------------------------------------------------------------------------------ + // Arg description DSL + //------------------------------------------------------------------------------ + const ARG_REGEXP = /(.*?)\((.*?)\)(.*)/; + const ARG_TYPES = [ + "ANY", + "BOOLEAN", + "DATE", + "NUMBER", + "STRING", + "RANGE", + "RANGE", + "RANGE", + "RANGE", + "RANGE", + "META", + ]; + /** + * This function is meant to be used as a tag for a template strings. + * + * Its job is to convert a textual description of the list of arguments into an + * actual array of Arg, suitable for consumption. + */ + function args(strings) { + let lines = strings.split("\n"); + const result = []; + for (let l of lines) { + l = l.trim(); + if (l) { + result.push(makeArg(l)); + } + } + return result; + } + function makeArg(str) { + let parts = str.match(ARG_REGEXP); + let name = parts[1].trim(); + let types = []; + let isOptional = false; + let isRepeating = false; + let isLazy = false; + let defaultValue; + for (let param of parts[2].split(",")) { + const key = param.trim().toUpperCase(); + let type = ARG_TYPES.find((t) => key === t); + if (type) { + types.push(type); + } + else if (key === "RANGE") { + types.push("RANGE"); + } + else if (key === "OPTIONAL") { + isOptional = true; + } + else if (key === "REPEATING") { + isRepeating = true; + } + else if (key === "LAZY") { + isLazy = true; + } + else if (key.startsWith("DEFAULT=")) { + defaultValue = param.trim().slice(8); + } + } + let description = parts[3].trim(); + const result = { + name, + description, + type: types, + }; + if (isOptional) { + result.optional = true; + } + if (isRepeating) { + result.repeating = true; + } + if (isLazy) { + result.lazy = true; + } + if (defaultValue !== undefined) { + result.default = true; + result.defaultValue = defaultValue; + } + return result; + } + /** + * This function adds on description more general information derived from the + * arguments. + * + * This information is useful during compilation. + */ + function addMetaInfoFromArg(addDescr) { + let countArg = 0; + let minArg = 0; + let repeatingArg = 0; + for (let arg of addDescr.args) { + countArg++; + if (!arg.optional && !arg.repeating && !arg.default) { + minArg++; + } + if (arg.repeating) { + repeatingArg++; + } + } + const descr = addDescr; + descr.minArgRequired = minArg; + descr.maxArgPossible = repeatingArg ? Infinity : countArg; + descr.nbrArgRepeating = repeatingArg; + descr.getArgToFocus = argTargeting(countArg, repeatingArg); + return descr; + } + /** + * Returns a function allowing finding which argument corresponds a position + * in a function. This is particularly useful for functions with repeatable + * arguments. + * + * Indeed the function makes it possible to etablish corespondance between + * arguments when the number of arguments supplied is greater than the number of + * arguments defined by the function. + * + * Ex: + * + * in the formula "=SUM(11, 55, 66)" which is defined like this "SUM(value1, [value2, ...])" + * - 11 corresponds to the value1 argument => position will be 1 + * - 55 corresponds to the [value2, ...] argument => position will be 2 + * - 66 corresponds to the [value2, ...] argument => position will be 2 + * + * in the formula "=AVERAGE.WEIGHTED(1, 2, 3, 4, 5, 6)" which is defined like this + * "AVERAGE.WEIGHTED(values, weights, [additional_values, ...], [additional_weights, ...])" + * - 1 corresponds to the values argument => position will be 1 + * - 2 corresponds to the weights argument => position will be 2 + * - 3 corresponds to the [additional_values, ...] argument => position will be 3 + * - 4 corresponds to the [additional_weights, ...] argument => position will be 4 + * - 5 corresponds to the [additional_values, ...] argument => position will be 3 + * - 6 corresponds to the [additional_weights, ...] argument => position will be 4 + */ + function argTargeting(countArg, repeatingArg) { + if (!repeatingArg) { + return (argPosition) => argPosition; + } + if (repeatingArg === 1) { + return (argPosition) => Math.min(argPosition, countArg); + } + const argBeforeRepeat = countArg - repeatingArg; + return (argPosition) => { + if (argPosition <= argBeforeRepeat) { + return argPosition; + } + const argAfterRepeat = (argPosition - argBeforeRepeat) % repeatingArg || repeatingArg; + return argBeforeRepeat + argAfterRepeat; + }; + } + //------------------------------------------------------------------------------ + // Argument validation + //------------------------------------------------------------------------------ + function validateArguments(args) { + let previousArgRepeating = false; + let previousArgOptional = false; + let previousArgDefault = false; + for (let current of args) { + if (current.type.includes("META") && current.type.length > 1) { + throw new Error(_lt("Function ${name} has an argument that has been declared with more than one type whose type 'META'. The 'META' type can only be declared alone.")); + } + if (previousArgRepeating && !current.repeating) { + throw new Error(_lt("Function ${name} has no-repeatable arguments declared after repeatable ones. All repeatable arguments must be declared last.")); + } + const previousIsOptional = previousArgOptional || previousArgRepeating || previousArgDefault; + const currentIsntOptional = !(current.optional || current.repeating || current.default); + if (previousIsOptional && currentIsntOptional) { + throw new Error(_lt("Function ${name} has at mandatory arguments declared after optional ones. All optional arguments must be after all mandatory arguments.")); + } + previousArgRepeating = current.repeating; + previousArgOptional = current.optional; + previousArgDefault = current.default; + } + } + + // ----------------------------------------------------------------------------- + // FORMAT.LARGE.NUMBER + // ----------------------------------------------------------------------------- + const FORMAT_LARGE_NUMBER = { + description: _lt(`Apply a large number format`), + args: args(` + value (number) ${_lt("The number.")} + unit (string, optional) ${_lt("The formatting unit. Use 'k', 'm', or 'b' to force the unit")} + `), + returns: ["NUMBER"], + computeFormat: (arg, unit) => { + const value = Math.abs(toNumber(arg.value)); + const format = arg.format; + if (unit !== undefined) { + const postFix = unit === null || unit === void 0 ? void 0 : unit.value; + switch (postFix) { + case "k": + return createLargeNumberFormat(format, 1e3, "k"); + case "m": + return createLargeNumberFormat(format, 1e6, "m"); + case "b": + return createLargeNumberFormat(format, 1e9, "b"); + default: + throw new Error(_lt("The formatting unit should be 'k', 'm' or 'b'.")); + } + } + if (value < 1e5) { + return createLargeNumberFormat(format, 0, ""); + } + else if (value < 1e8) { + return createLargeNumberFormat(format, 1e3, "k"); + } + else if (value < 1e11) { + return createLargeNumberFormat(format, 1e6, "m"); + } + return createLargeNumberFormat(format, 1e9, "b"); + }, + compute: function (value) { + return toNumber(value); + }, + }; + + var misc$1 = /*#__PURE__*/Object.freeze({ + __proto__: null, + FORMAT_LARGE_NUMBER: FORMAT_LARGE_NUMBER + }); + + const DEFAULT_FACTOR = 1; + const DEFAULT_MODE = 0; + const DEFAULT_PLACES = 0; + const DEFAULT_SIGNIFICANCE = 1; + // ----------------------------------------------------------------------------- + // ABS + // ----------------------------------------------------------------------------- + const ABS = { + description: _lt("Absolute value of a number."), + args: args(` + value (number) ${_lt("The number of which to return the absolute value.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.abs(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ACOS + // ----------------------------------------------------------------------------- + const ACOS = { + description: _lt("Inverse cosine of a value, in radians."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse cosine. Must be between -1 and 1, inclusive.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => Math.abs(_value) <= 1, _lt("The value (%s) must be between -1 and 1 inclusive.", _value.toString())); + return Math.acos(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ACOSH + // ----------------------------------------------------------------------------- + const ACOSH = { + description: _lt("Inverse hyperbolic cosine of a number."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse hyperbolic cosine. Must be greater than or equal to 1.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => _value >= 1, _lt("The value (%s) must be greater than or equal to 1.", _value.toString())); + return Math.acosh(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ACOT + // ----------------------------------------------------------------------------- + const ACOT = { + description: _lt("Inverse cotangent of a value."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse cotangent.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + const sign = Math.sign(_value) || 1; + // ACOT has two possible configurations: + // @compatibility Excel: return Math.PI / 2 - Math.atan(toNumber(_value)); + // @compatibility Google: return sign * Math.PI / 2 - Math.atan(toNumber(_value)); + return (sign * Math.PI) / 2 - Math.atan(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ACOTH + // ----------------------------------------------------------------------------- + const ACOTH = { + description: _lt("Inverse hyperbolic cotangent of a value."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse hyperbolic cotangent. Must not be between -1 and 1, inclusive.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => Math.abs(_value) > 1, _lt("The value (%s) cannot be between -1 and 1 inclusive.", _value.toString())); + return Math.log((_value + 1) / (_value - 1)) / 2; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ASIN + // ----------------------------------------------------------------------------- + const ASIN = { + description: _lt("Inverse sine of a value, in radians."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse sine. Must be between -1 and 1, inclusive.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => Math.abs(_value) <= 1, _lt("The value (%s) must be between -1 and 1 inclusive.", _value.toString())); + return Math.asin(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ASINH + // ----------------------------------------------------------------------------- + const ASINH = { + description: _lt("Inverse hyperbolic sine of a number."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse hyperbolic sine.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.asinh(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ATAN + // ----------------------------------------------------------------------------- + const ATAN = { + description: _lt("Inverse tangent of a value, in radians."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse tangent.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.atan(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ATAN2 + // ----------------------------------------------------------------------------- + const ATAN2 = { + description: _lt("Angle from the X axis to a point (x,y), in radians."), + args: args(` + x (number) ${_lt("The x coordinate of the endpoint of the line segment for which to calculate the angle from the x-axis.")} + y (number) ${_lt("The y coordinate of the endpoint of the line segment for which to calculate the angle from the x-axis.")} + `), + returns: ["NUMBER"], + compute: function (x, y) { + const _x = toNumber(x); + const _y = toNumber(y); + assert(() => _x !== 0 || _y !== 0, _lt(`Function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return Math.atan2(_y, _x); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ATANH + // ----------------------------------------------------------------------------- + const ATANH = { + description: _lt("Inverse hyperbolic tangent of a number."), + args: args(` + value (number) ${_lt("The value for which to calculate the inverse hyperbolic tangent. Must be between -1 and 1, exclusive.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => Math.abs(_value) < 1, _lt("The value (%s) must be between -1 and 1 exclusive.", _value.toString())); + return Math.atanh(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // CEILING + // ----------------------------------------------------------------------------- + const CEILING = { + description: _lt(`Rounds number up to nearest multiple of factor.`), + args: args(` + value (number) ${_lt("The value to round up to the nearest integer multiple of factor.")} + factor (number, default=${DEFAULT_FACTOR}) ${_lt("The number to whose multiples value will be rounded.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value, factor = DEFAULT_FACTOR) { + const _value = toNumber(value); + const _factor = toNumber(factor); + assert(() => _factor >= 0 || _value <= 0, _lt("The factor (%s) must be positive when the value (%s) is positive.", _factor.toString(), _value.toString())); + return _factor ? Math.ceil(_value / _factor) * _factor : 0; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // CEILING.MATH + // ----------------------------------------------------------------------------- + const CEILING_MATH = { + description: _lt(`Rounds number up to nearest multiple of factor.`), + args: args(` + number (number) ${_lt("The value to round up to the nearest integer multiple of significance.")} + significance (number, default=${DEFAULT_SIGNIFICANCE}) ${_lt("The number to whose multiples number will be rounded. The sign of significance will be ignored.")} + mode (number, default=${DEFAULT_MODE}) ${_lt("If number is negative, specifies the rounding direction. If 0 or blank, it is rounded towards zero. Otherwise, it is rounded away from zero.")} + `), + returns: ["NUMBER"], + computeFormat: (number) => number === null || number === void 0 ? void 0 : number.format, + compute: function (number, significance = DEFAULT_SIGNIFICANCE, mode = DEFAULT_MODE) { + let _significance = toNumber(significance); + if (_significance === 0) { + return 0; + } + const _number = toNumber(number); + _significance = Math.abs(_significance); + if (_number >= 0) { + return Math.ceil(_number / _significance) * _significance; + } + const _mode = toNumber(mode); + if (_mode === 0) { + return -Math.floor(Math.abs(_number) / _significance) * _significance; + } + return -Math.ceil(Math.abs(_number) / _significance) * _significance; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // CEILING.PRECISE + // ----------------------------------------------------------------------------- + const CEILING_PRECISE = { + description: _lt(`Rounds number up to nearest multiple of factor.`), + args: args(` + number (number) ${_lt("The value to round up to the nearest integer multiple of significance.")} + significance (number, default=${DEFAULT_SIGNIFICANCE}) ${_lt("The number to whose multiples number will be rounded.")} + `), + returns: ["NUMBER"], + computeFormat: (number) => number === null || number === void 0 ? void 0 : number.format, + compute: function (number, significance) { + return CEILING_MATH.compute(number, significance, 0); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COS + // ----------------------------------------------------------------------------- + const COS = { + description: _lt("Cosine of an angle provided in radians."), + args: args(` + angle (number) ${_lt("The angle to find the cosine of, in radians.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + return Math.cos(toNumber(angle)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COSH + // ----------------------------------------------------------------------------- + const COSH = { + description: _lt("Hyperbolic cosine of any real number."), + args: args(` + value (number) ${_lt("Any real value to calculate the hyperbolic cosine of.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.cosh(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COT + // ----------------------------------------------------------------------------- + const COT = { + description: _lt("Cotangent of an angle provided in radians."), + args: args(` + angle (number) ${_lt("The angle to find the cotangent of, in radians.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + const _angle = toNumber(angle); + assert(() => _angle !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return 1 / Math.tan(_angle); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COTH + // ----------------------------------------------------------------------------- + const COTH = { + description: _lt("Hyperbolic cotangent of any real number."), + args: args(` + value (number) ${_lt("Any real value to calculate the hyperbolic cotangent of.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => _value !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return 1 / Math.tanh(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COUNTBLANK + // ----------------------------------------------------------------------------- + const COUNTBLANK = { + description: _lt("Number of empty values."), + args: args(` + value1 (any, range) ${_lt("The first value or range in which to count the number of blanks.")} + value2 (any, range, repeating) ${_lt("Additional values or ranges in which to count the number of blanks.")} + `), + returns: ["NUMBER"], + compute: function (...argsValues) { + return reduceAny(argsValues, (acc, a) => (a === null || a === undefined || a === "" ? acc + 1 : acc), 0); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COUNTIF + // ----------------------------------------------------------------------------- + const COUNTIF = { + description: _lt("A conditional count across a range."), + args: args(` + range (range) ${_lt("The range that is tested against criterion.")} + criterion (string) ${_lt("The pattern or test to apply to range.")} + `), + returns: ["NUMBER"], + compute: function (...argsValues) { + let count = 0; + visitMatchingRanges(argsValues, (i, j) => { + count += 1; + }); + return count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COUNTIFS + // ----------------------------------------------------------------------------- + const COUNTIFS = { + description: _lt("Count values depending on multiple criteria."), + args: args(` + criteria_range1 (range) ${_lt("The range to check against criterion1.")} + criterion1 (string) ${_lt("The pattern or test to apply to criteria_range1.")} + criteria_range2 (any, range, repeating) ${_lt("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")} + criterion2 (string, repeating) ${_lt("Additional criteria to check.")} + `), + returns: ["NUMBER"], + compute: function (...argsValues) { + let count = 0; + visitMatchingRanges(argsValues, (i, j) => { + count += 1; + }); + return count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COUNTUNIQUE + // ----------------------------------------------------------------------------- + function isDefined(value) { + switch (value) { + case undefined: + return false; + case "": + return false; + case null: + return false; + default: + return true; + } + } + const COUNTUNIQUE = { + description: _lt("Counts number of unique values in a range."), + args: args(` + value1 (any, range) ${_lt("The first value or range to consider for uniqueness.")} + value2 (any, range, repeating) ${_lt("Additional values or ranges to consider for uniqueness.")} + `), + returns: ["NUMBER"], + compute: function (...argsValues) { + return reduceAny(argsValues, (acc, a) => (isDefined(a) ? acc.add(a) : acc), new Set()).size; + }, + }; + // ----------------------------------------------------------------------------- + // COUNTUNIQUEIFS + // ----------------------------------------------------------------------------- + const COUNTUNIQUEIFS = { + description: _lt("Counts number of unique values in a range, filtered by a set of criteria."), + args: args(` + range (range) ${_lt("The range of cells from which the number of unique values will be counted.")} + criteria_range1 (range) ${_lt("The range of cells over which to evaluate criterion1.")} + criterion1 (string) ${_lt("The pattern or test to apply to criteria_range1, such that each cell that evaluates to TRUE will be included in the filtered set.")} + criteria_range2 (any, range, repeating) ${_lt("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")} + criterion2 (string, repeating) ${_lt("The pattern or test to apply to criteria_range2.")} + `), + returns: ["NUMBER"], + compute: function (range, ...argsValues) { + let uniqueValues = new Set(); + visitMatchingRanges(argsValues, (i, j) => { + const value = range[i][j]; + if (isDefined(value)) { + uniqueValues.add(value); + } + }); + return uniqueValues.size; + }, + }; + // ----------------------------------------------------------------------------- + // CSC + // ----------------------------------------------------------------------------- + const CSC = { + description: _lt("Cosecant of an angle provided in radians."), + args: args(` + angle (number) ${_lt("The angle to find the cosecant of, in radians.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + const _angle = toNumber(angle); + assert(() => _angle !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return 1 / Math.sin(_angle); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // CSCH + // ----------------------------------------------------------------------------- + const CSCH = { + description: _lt("Hyperbolic cosecant of any real number."), + args: args(` + value (number) ${_lt("Any real value to calculate the hyperbolic cosecant of.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => _value !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return 1 / Math.sinh(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DECIMAL + // ----------------------------------------------------------------------------- + const DECIMAL = { + description: _lt("Converts from another base to decimal."), + args: args(` + value (string) ${_lt("The number to convert.")}, + base (number) ${_lt("The base to convert the value from.")}, + `), + returns: ["NUMBER"], + compute: function (value, base) { + let _base = toNumber(base); + _base = Math.floor(_base); + assert(() => 2 <= _base && _base <= 36, _lt("The base (%s) must be between 2 and 36 inclusive.", _base.toString())); + const _value = toString(value); + if (_value === "") { + return 0; + } + /** + * @compatibility: on Google sheets, expects the parameter 'value' to be positive. + * Return error if 'value' is positive. + * Remove '-?' in the next regex to catch this error. + */ + assert(() => !!_value.match(/^-?[a-z0-9]+$/i), _lt("The value (%s) must be a valid base %s representation.", _value, _base.toString())); + const deci = parseInt(_value, _base); + assert(() => !isNaN(deci), _lt("The value (%s) must be a valid base %s representation.", _value, _base.toString())); + return deci; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DEGREES + // ----------------------------------------------------------------------------- + const DEGREES = { + description: _lt(`Converts an angle value in radians to degrees.`), + args: args(` + angle (number) ${_lt("The angle to convert from radians to degrees.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + return (toNumber(angle) * 180) / Math.PI; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // EXP + // ----------------------------------------------------------------------------- + const EXP = { + description: _lt(`Euler's number, e (~2.718) raised to a power.`), + args: args(` + value (number) ${_lt("The exponent to raise e.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.exp(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // FLOOR + // ----------------------------------------------------------------------------- + const FLOOR = { + description: _lt(`Rounds number down to nearest multiple of factor.`), + args: args(` + value (number) ${_lt("The value to round down to the nearest integer multiple of factor.")} + factor (number, default=${DEFAULT_FACTOR}) ${_lt("The number to whose multiples value will be rounded.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value, factor = DEFAULT_FACTOR) { + const _value = toNumber(value); + const _factor = toNumber(factor); + assert(() => _factor >= 0 || _value <= 0, _lt("The factor (%s) must be positive when the value (%s) is positive.", _factor.toString(), _value.toString())); + return _factor ? Math.floor(_value / _factor) * _factor : 0; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // FLOOR.MATH + // ----------------------------------------------------------------------------- + const FLOOR_MATH = { + description: _lt(`Rounds number down to nearest multiple of factor.`), + args: args(` + number (number) ${_lt("The value to round down to the nearest integer multiple of significance.")} + significance (number, default=${DEFAULT_SIGNIFICANCE}) ${_lt("The number to whose multiples number will be rounded. The sign of significance will be ignored.")} + mode (number, default=${DEFAULT_MODE}) ${_lt("If number is negative, specifies the rounding direction. If 0 or blank, it is rounded away from zero. Otherwise, it is rounded towards zero.")} + `), + returns: ["NUMBER"], + computeFormat: (number) => number === null || number === void 0 ? void 0 : number.format, + compute: function (number, significance = DEFAULT_SIGNIFICANCE, mode = DEFAULT_MODE) { + let _significance = toNumber(significance); + if (_significance === 0) { + return 0; + } + const _number = toNumber(number); + _significance = Math.abs(_significance); + if (_number >= 0) { + return Math.floor(_number / _significance) * _significance; + } + const _mode = toNumber(mode); + if (_mode === 0) { + return -Math.ceil(Math.abs(_number) / _significance) * _significance; + } + return -Math.floor(Math.abs(_number) / _significance) * _significance; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // FLOOR.PRECISE + // ----------------------------------------------------------------------------- + const FLOOR_PRECISE = { + description: _lt(`Rounds number down to nearest multiple of factor.`), + args: args(` + number (number) ${_lt("The value to round down to the nearest integer multiple of significance.")} + significance (number, default=${DEFAULT_SIGNIFICANCE}) ${_lt("The number to whose multiples number will be rounded.")} + `), + returns: ["NUMBER"], + computeFormat: (number) => number === null || number === void 0 ? void 0 : number.format, + compute: function (number, significance = DEFAULT_SIGNIFICANCE) { + return FLOOR_MATH.compute(number, significance, 0); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISEVEN + // ----------------------------------------------------------------------------- + const ISEVEN = { + description: _lt(`Whether the provided value is even.`), + args: args(` + value (number) ${_lt("The value to be verified as even.")} + `), + returns: ["BOOLEAN"], + compute: function (value) { + const _value = strictToNumber(value); + return Math.floor(Math.abs(_value)) & 1 ? false : true; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISO.CEILING + // ----------------------------------------------------------------------------- + const ISO_CEILING = { + description: _lt(`Rounds number up to nearest multiple of factor.`), + args: args(` + number (number) ${_lt("The value to round up to the nearest integer multiple of significance.")} + significance (number, default=${DEFAULT_SIGNIFICANCE}) ${_lt("The number to whose multiples number will be rounded.")} + `), + returns: ["NUMBER"], + computeFormat: (number) => number === null || number === void 0 ? void 0 : number.format, + compute: function (number, significance = DEFAULT_SIGNIFICANCE) { + return CEILING_MATH.compute(number, significance, 0); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISODD + // ----------------------------------------------------------------------------- + const ISODD = { + description: _lt(`Whether the provided value is even.`), + args: args(` + value (number) ${_lt("The value to be verified as even.")} + `), + returns: ["BOOLEAN"], + compute: function (value) { + const _value = strictToNumber(value); + return Math.floor(Math.abs(_value)) & 1 ? true : false; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // LN + // ----------------------------------------------------------------------------- + const LN = { + description: _lt(`The logarithm of a number, base e (euler's number).`), + args: args(` + value (number) ${_lt("The value for which to calculate the logarithm, base e.")} + `), + returns: ["NUMBER"], + compute: function (value) { + const _value = toNumber(value); + assert(() => _value > 0, _lt("The value (%s) must be strictly positive.", _value.toString())); + return Math.log(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MOD + // ----------------------------------------------------------------------------- + const MOD = { + description: _lt(`Modulo (remainder) operator.`), + args: args(` + dividend (number) ${_lt("The number to be divided to find the remainder.")} + divisor (number) ${_lt("The number to divide by.")} + `), + returns: ["NUMBER"], + computeFormat: (dividend) => dividend === null || dividend === void 0 ? void 0 : dividend.format, + compute: function (dividend, divisor) { + const _divisor = toNumber(divisor); + assert(() => _divisor !== 0, _lt("The divisor must be different from 0.")); + const _dividend = toNumber(dividend); + const modulus = _dividend % _divisor; + // -42 % 10 = -2 but we want 8, so need the code below + if ((modulus > 0 && _divisor < 0) || (modulus < 0 && _divisor > 0)) { + return modulus + _divisor; + } + return modulus; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ODD + // ----------------------------------------------------------------------------- + const ODD = { + description: _lt(`Rounds a number up to the nearest odd integer.`), + args: args(` + value (number) ${_lt("The value to round to the next greatest odd number.")} + `), + returns: ["NUMBER"], + computeFormat: (number) => number === null || number === void 0 ? void 0 : number.format, + compute: function (value) { + const _value = toNumber(value); + let temp = Math.ceil(Math.abs(_value)); + temp = temp & 1 ? temp : temp + 1; + return _value < 0 ? -temp : temp; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // PI + // ----------------------------------------------------------------------------- + const PI = { + description: _lt(`The number pi.`), + args: [], + returns: ["NUMBER"], + compute: function () { + return Math.PI; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // POWER + // ----------------------------------------------------------------------------- + const POWER = { + description: _lt(`A number raised to a power.`), + args: args(` + base (number) ${_lt("The number to raise to the exponent power.")} + exponent (number) ${_lt("The exponent to raise base to.")} + `), + returns: ["NUMBER"], + computeFormat: (base) => base === null || base === void 0 ? void 0 : base.format, + compute: function (base, exponent) { + const _base = toNumber(base); + const _exponent = toNumber(exponent); + assert(() => _base >= 0 || Number.isInteger(_exponent), _lt("The exponent (%s) must be an integer when the base is negative.", _exponent.toString())); + return Math.pow(_base, _exponent); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // PRODUCT + // ----------------------------------------------------------------------------- + const PRODUCT = { + description: _lt("Result of multiplying a series of numbers together."), + args: args(` + factor1 (number, range) ${_lt("The first number or range to calculate for the product.")} + factor2 (number, range, repeating) ${_lt("More numbers or ranges to calculate for the product.")} + `), + returns: ["NUMBER"], + computeFormat: (factor1) => { + var _a; + return Array.isArray(factor1) ? (_a = factor1[0][0]) === null || _a === void 0 ? void 0 : _a.format : factor1 === null || factor1 === void 0 ? void 0 : factor1.format; + }, + compute: function (...factors) { + let count = 0; + let acc = 1; + for (let n of factors) { + if (Array.isArray(n)) { + for (let i of n) { + for (let j of i) { + if (typeof j === "number") { + acc *= j; + count += 1; + } + } + } + } + else if (n !== null && n !== undefined) { + acc *= strictToNumber(n); + count += 1; + } + } + if (count === 0) { + return 0; + } + return acc; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // RAND + // ----------------------------------------------------------------------------- + const RAND = { + description: _lt("A random number between 0 inclusive and 1 exclusive."), + args: [], + returns: ["NUMBER"], + compute: function () { + return Math.random(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // RANDBETWEEN + // ----------------------------------------------------------------------------- + const RANDBETWEEN = { + description: _lt("Random integer between two values, inclusive."), + args: args(` + low (number) ${_lt("The low end of the random range.")} + high (number) ${_lt("The high end of the random range.")} + `), + returns: ["NUMBER"], + computeFormat: (low) => low === null || low === void 0 ? void 0 : low.format, + compute: function (low, high) { + let _low = toNumber(low); + if (!Number.isInteger(_low)) { + _low = Math.ceil(_low); + } + let _high = toNumber(high); + if (!Number.isInteger(_high)) { + _high = Math.floor(_high); + } + assert(() => _low <= _high, _lt("The high (%s) must be greater than or equal to the low (%s).", _high.toString(), _low.toString())); + return _low + Math.ceil((_high - _low + 1) * Math.random()) - 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ROUND + // ----------------------------------------------------------------------------- + const ROUND = { + description: _lt("Rounds a number according to standard rules."), + args: args(` + value (number) ${_lt("The value to round to places number of places.")} + places (number, default=${DEFAULT_PLACES}) ${_lt("The number of decimal places to which to round.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value, places = DEFAULT_PLACES) { + const _value = toNumber(value); + let _places = toNumber(places); + const absValue = Math.abs(_value); + let tempResult; + if (_places === 0) { + tempResult = Math.round(absValue); + } + else { + if (!Number.isInteger(_places)) { + _places = Math.trunc(_places); + } + tempResult = Math.round(absValue * Math.pow(10, _places)) / Math.pow(10, _places); + } + return _value >= 0 ? tempResult : -tempResult; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ROUNDDOWN + // ----------------------------------------------------------------------------- + const ROUNDDOWN = { + description: _lt(`Rounds down a number.`), + args: args(` + value (number) ${_lt("The value to round to places number of places, always rounding down.")} + places (number, default=${DEFAULT_PLACES}) ${_lt("The number of decimal places to which to round.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value, places = DEFAULT_PLACES) { + const _value = toNumber(value); + let _places = toNumber(places); + const absValue = Math.abs(_value); + let tempResult; + if (_places === 0) { + tempResult = Math.floor(absValue); + } + else { + if (!Number.isInteger(_places)) { + _places = Math.trunc(_places); + } + tempResult = Math.floor(absValue * Math.pow(10, _places)) / Math.pow(10, _places); + } + return _value >= 0 ? tempResult : -tempResult; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ROUNDUP + // ----------------------------------------------------------------------------- + const ROUNDUP = { + description: _lt(`Rounds up a number.`), + args: args(` + value (number) ${_lt("The value to round to places number of places, always rounding up.")} + places (number, default=${DEFAULT_PLACES}) ${_lt("The number of decimal places to which to round.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value, places = DEFAULT_PLACES) { + const _value = toNumber(value); + let _places = toNumber(places); + const absValue = Math.abs(_value); + let tempResult; + if (_places === 0) { + tempResult = Math.ceil(absValue); + } + else { + if (!Number.isInteger(_places)) { + _places = Math.trunc(_places); + } + tempResult = Math.ceil(absValue * Math.pow(10, _places)) / Math.pow(10, _places); + } + return _value >= 0 ? tempResult : -tempResult; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SEC + // ----------------------------------------------------------------------------- + const SEC = { + description: _lt("Secant of an angle provided in radians."), + args: args(` + angle (number) ${_lt("The angle to find the secant of, in radians.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + return 1 / Math.cos(toNumber(angle)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SECH + // ----------------------------------------------------------------------------- + const SECH = { + description: _lt("Hyperbolic secant of any real number."), + args: args(` + value (number) ${_lt("Any real value to calculate the hyperbolic secant of.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return 1 / Math.cosh(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SIN + // ----------------------------------------------------------------------------- + const SIN = { + description: _lt("Sine of an angle provided in radians."), + args: args(` + angle (number) ${_lt("The angle to find the sine of, in radians.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + return Math.sin(toNumber(angle)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SINH + // ----------------------------------------------------------------------------- + const SINH = { + description: _lt("Hyperbolic sine of any real number."), + args: args(` + value (number) ${_lt("Any real value to calculate the hyperbolic sine of.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.sinh(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SQRT + // ----------------------------------------------------------------------------- + const SQRT = { + description: _lt("Positive square root of a positive number."), + args: args(` + value (number) ${_lt("The number for which to calculate the positive square root.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value) { + const _value = toNumber(value); + assert(() => _value >= 0, _lt("The value (%s) must be positive or null.", _value.toString())); + return Math.sqrt(_value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SUM + // ----------------------------------------------------------------------------- + const SUM = { + description: _lt("Sum of a series of numbers and/or cells."), + args: args(` + value1 (number, range) ${_lt("The first number or range to add together.")} + value2 (number, range, repeating) ${_lt("Additional numbers or ranges to add to value1.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + return reduceNumbers(values, (acc, a) => acc + a, 0); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SUMIF + // ----------------------------------------------------------------------------- + const SUMIF = { + description: _lt("A conditional sum across a range."), + args: args(` + criteria_range (range) ${_lt("The range which is tested against criterion.")} + criterion (string) ${_lt("The pattern or test to apply to range.")} + sum_range (range, default=criteria_range) ${_lt("The range to be summed, if different from range.")} + `), + returns: ["NUMBER"], + compute: function (criteriaRange, criterion, sumRange = undefined) { + if (sumRange === undefined) { + sumRange = criteriaRange; + } + let sum = 0; + visitMatchingRanges([criteriaRange, criterion], (i, j) => { + const value = sumRange[i][j]; + if (typeof value === "number") { + sum += value; + } + }); + return sum; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SUMIFS + // ----------------------------------------------------------------------------- + const SUMIFS = { + description: _lt("Sums a range depending on multiple criteria."), + args: args(` + sum_range (range) ${_lt("The range to sum.")} + criteria_range1 (range) ${_lt("The range to check against criterion1.")} + criterion1 (string) ${_lt("The pattern or test to apply to criteria_range1.")} + criteria_range2 (any, range, repeating) ${_lt("Additional ranges to check.")} + criterion2 (string, repeating) ${_lt("Additional criteria to check.")} + `), + returns: ["NUMBER"], + compute: function (sumRange, ...criters) { + let sum = 0; + visitMatchingRanges(criters, (i, j) => { + const value = sumRange[i][j]; + if (typeof value === "number") { + sum += value; + } + }); + return sum; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TAN + // ----------------------------------------------------------------------------- + const TAN = { + description: _lt("Tangent of an angle provided in radians."), + args: args(` + angle (number) ${_lt("The angle to find the tangent of, in radians.")} + `), + returns: ["NUMBER"], + compute: function (angle) { + return Math.tan(toNumber(angle)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TANH + // ----------------------------------------------------------------------------- + const TANH = { + description: _lt("Hyperbolic tangent of any real number."), + args: args(` + value (number) ${_lt("Any real value to calculate the hyperbolic tangent of.")} + `), + returns: ["NUMBER"], + compute: function (value) { + return Math.tanh(toNumber(value)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TRUNC + // ----------------------------------------------------------------------------- + const TRUNC = { + description: _lt("Truncates a number."), + args: args(` + value (number) ${_lt("The value to be truncated.")} + places (number, default=${DEFAULT_PLACES}) ${_lt("The number of significant digits to the right of the decimal point to retain.")} + `), + returns: ["NUMBER"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value, places = DEFAULT_PLACES) { + const _value = toNumber(value); + let _places = toNumber(places); + if (_places === 0) { + return Math.trunc(_value); + } + if (!Number.isInteger(_places)) { + _places = Math.trunc(_places); + } + return Math.trunc(_value * Math.pow(10, _places)) / Math.pow(10, _places); + }, + isExported: true, + }; + + var math = /*#__PURE__*/Object.freeze({ + __proto__: null, + ABS: ABS, + ACOS: ACOS, + ACOSH: ACOSH, + ACOT: ACOT, + ACOTH: ACOTH, + ASIN: ASIN, + ASINH: ASINH, + ATAN: ATAN, + ATAN2: ATAN2, + ATANH: ATANH, + CEILING: CEILING, + CEILING_MATH: CEILING_MATH, + CEILING_PRECISE: CEILING_PRECISE, + COS: COS, + COSH: COSH, + COT: COT, + COTH: COTH, + COUNTBLANK: COUNTBLANK, + COUNTIF: COUNTIF, + COUNTIFS: COUNTIFS, + COUNTUNIQUE: COUNTUNIQUE, + COUNTUNIQUEIFS: COUNTUNIQUEIFS, + CSC: CSC, + CSCH: CSCH, + DECIMAL: DECIMAL, + DEGREES: DEGREES, + EXP: EXP, + FLOOR: FLOOR, + FLOOR_MATH: FLOOR_MATH, + FLOOR_PRECISE: FLOOR_PRECISE, + ISEVEN: ISEVEN, + ISO_CEILING: ISO_CEILING, + ISODD: ISODD, + LN: LN, + MOD: MOD, + ODD: ODD, + PI: PI, + POWER: POWER, + PRODUCT: PRODUCT, + RAND: RAND, + RANDBETWEEN: RANDBETWEEN, + ROUND: ROUND, + ROUNDDOWN: ROUNDDOWN, + ROUNDUP: ROUNDUP, + SEC: SEC, + SECH: SECH, + SIN: SIN, + SINH: SINH, + SQRT: SQRT, + SUM: SUM, + SUMIF: SUMIF, + SUMIFS: SUMIFS, + TAN: TAN, + TANH: TANH, + TRUNC: TRUNC + }); + + // Note: dataY and dataX may not have the same dimension + function covariance(dataY, dataX, isSample) { + let flatDataY = []; + let flatDataX = []; + let lenY = 0; + let lenX = 0; + visitAny([dataY], (y) => { + flatDataY.push(y); + lenY += 1; + }); + visitAny([dataX], (x) => { + flatDataX.push(x); + lenX += 1; + }); + assert(() => lenY === lenX, _lt("[[FUNCTION_NAME]] has mismatched argument count %s vs %s.", lenY.toString(), lenX.toString())); + let count = 0; + let sumY = 0; + let sumX = 0; + for (let i = 0; i < lenY; i++) { + const valueY = flatDataY[i]; + const valueX = flatDataX[i]; + if (typeof valueY === "number" && typeof valueX === "number") { + count += 1; + sumY += valueY; + sumX += valueX; + } + } + assert(() => count !== 0 && (!isSample || count !== 1), _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + const averageY = sumY / count; + const averageX = sumX / count; + let acc = 0; + for (let i = 0; i < lenY; i++) { + const valueY = flatDataY[i]; + const valueX = flatDataX[i]; + if (typeof valueY === "number" && typeof valueX === "number") { + acc += (valueY - averageY) * (valueX - averageX); + } + } + return acc / (count - (isSample ? 1 : 0)); + } + function variance(args, isSample, textAs0) { + let count = 0; + let sum = 0; + const reduceFunction = textAs0 ? reduceNumbersTextAs0 : reduceNumbers; + sum = reduceFunction(args, (acc, a) => { + count += 1; + return acc + a; + }, 0); + assert(() => count !== 0 && (!isSample || count !== 1), _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + const average = sum / count; + return (reduceFunction(args, (acc, a) => acc + Math.pow(a - average, 2), 0) / + (count - (isSample ? 1 : 0))); + } + function centile(data, percent, isInclusive) { + const _percent = toNumber(percent); + assert(() => (isInclusive ? 0 <= _percent && _percent <= 1 : 0 < _percent && _percent < 1), _lt(`Function [[FUNCTION_NAME]] parameter 2 value is out of range.`)); + let sortedArray = []; + let index; + let count = 0; + visitAny(data, (d) => { + if (typeof d === "number") { + index = dichotomicPredecessorSearch(sortedArray, d); + sortedArray.splice(index + 1, 0, d); + count++; + } + }); + assert(() => count !== 0, _lt(`[[FUNCTION_NAME]] has no valid input data.`)); + if (!isInclusive) { + // 2nd argument must be between 1/(n+1) and n/(n+1) with n the number of data + assert(() => 1 / (count + 1) <= _percent && _percent <= count / (count + 1), _lt(`Function [[FUNCTION_NAME]] parameter 2 value is out of range.`)); + } + return percentile(sortedArray, _percent, isInclusive); + } + // ----------------------------------------------------------------------------- + // AVEDEV + // ----------------------------------------------------------------------------- + const AVEDEV = { + description: _lt("Average magnitude of deviations from mean."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + let count = 0; + const sum = reduceNumbers(values, (acc, a) => { + count += 1; + return acc + a; + }, 0); + assert(() => count !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + const average = sum / count; + return reduceNumbers(values, (acc, a) => acc + Math.abs(average - a), 0) / count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // AVERAGE + // ----------------------------------------------------------------------------- + const AVERAGE = { + description: _lt(`Numerical average value in a dataset, ignoring text.`), + args: args(` + value1 (number, range) ${_lt("The first value or range to consider when calculating the average value.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to consider when calculating the average value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + let count = 0; + const sum = reduceNumbers(values, (acc, a) => { + count += 1; + return acc + a; + }, 0); + assert(() => count !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return sum / count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // AVERAGE.WEIGHTED + // ----------------------------------------------------------------------------- + const rangeError = _lt(`[[FUNCTION_NAME]] has mismatched range sizes.`); + const negativeWeightError = _lt(`[[FUNCTION_NAME]] expects the weight to be positive or equal to 0.`); + const AVERAGE_WEIGHTED = { + description: _lt(`Weighted average.`), + args: args(` + values (number, range) ${_lt("Values to average.")} + weights (number, range) ${_lt("Weights for each corresponding value.")} + additional_values (number, range, repeating) ${_lt("Additional values to average.")} + additional_weights (number, range, repeating) ${_lt("Additional weights.")} + `), + returns: ["NUMBER"], + computeFormat: (values) => { + var _a; + return Array.isArray(values) ? (_a = values[0][0]) === null || _a === void 0 ? void 0 : _a.format : values === null || values === void 0 ? void 0 : values.format; + }, + compute: function (...values) { + let sum = 0; + let count = 0; + let value; + let weight; + assert(() => values.length % 2 === 0, _lt(`Wrong number of Argument[]. Expected an even number of Argument[].`)); + for (let n = 0; n < values.length - 1; n += 2) { + value = values[n]; + weight = values[n + 1]; + // if (typeof value != typeof weight) { + // throw new Error(rangeError); + // } + if (Array.isArray(value)) { + assert(() => Array.isArray(weight), rangeError); + let dimColValue = value.length; + let dimLinValue = value[0].length; + assert(() => dimColValue === weight.length && dimLinValue === weight[0].length, rangeError); + for (let i = 0; i < dimColValue; i++) { + for (let j = 0; j < dimLinValue; j++) { + let subValue = value[i][j]; + let subWeight = weight[i][j]; + let subValueIsNumber = typeof subValue === "number"; + let subWeightIsNumber = typeof subWeight === "number"; + // typeof subValue or subWeight can be 'number' or 'undefined' + assert(() => subValueIsNumber === subWeightIsNumber, _lt(`[[FUNCTION_NAME]] expects number values.`)); + if (subWeightIsNumber) { + assert(() => subWeight >= 0, negativeWeightError); + sum += subValue * subWeight; + count += subWeight; + } + } + } + } + else { + weight = toNumber(weight); + value = toNumber(value); + assert(() => weight >= 0, negativeWeightError); + sum += value * weight; + count += weight; + } + } + assert(() => count !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return sum / count; + }, + }; + // ----------------------------------------------------------------------------- + // AVERAGEA + // ----------------------------------------------------------------------------- + const AVERAGEA = { + description: _lt(`Numerical average value in a dataset.`), + args: args(` + value1 (number, range) ${_lt("The first value or range to consider when calculating the average value.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to consider when calculating the average value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + let count = 0; + const sum = reduceNumbersTextAs0(values, (acc, a) => { + count += 1; + return acc + a; + }, 0); + assert(() => count !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return sum / count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // AVERAGEIF + // ----------------------------------------------------------------------------- + const AVERAGEIF = { + description: _lt(`Average of values depending on criteria.`), + args: args(` + criteria_range (range) ${_lt("The range to check against criterion.")} + criterion (string) ${_lt("The pattern or test to apply to criteria_range.")} + average_range (range, default=criteria_range) ${_lt("The range to average. If not included, criteria_range is used for the average instead.")} + `), + returns: ["NUMBER"], + compute: function (criteriaRange, criterion, averageRange) { + if (averageRange === undefined || averageRange === null) { + averageRange = criteriaRange; + } + let count = 0; + let sum = 0; + visitMatchingRanges([criteriaRange, criterion], (i, j) => { + const value = (averageRange || criteriaRange)[i][j]; + if (typeof value === "number") { + count += 1; + sum += value; + } + }); + assert(() => count !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return sum / count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // AVERAGEIFS + // ----------------------------------------------------------------------------- + const AVERAGEIFS = { + description: _lt(`Average of values depending on multiple criteria.`), + args: args(` + average_range (range) ${_lt("The range to average.")} + criteria_range1 (range) ${_lt("The range to check against criterion1.")} + criterion1 (string) ${_lt("The pattern or test to apply to criteria_range1.")} + criteria_range2 (any, range, repeating) ${_lt("Additional criteria_range and criterion to check.")} + criterion2 (string, repeating) ${_lt("The pattern or test to apply to criteria_range2.")} + `), + returns: ["NUMBER"], + compute: function (averageRange, ...values) { + let count = 0; + let sum = 0; + visitMatchingRanges(values, (i, j) => { + const value = averageRange[i][j]; + if (typeof value === "number") { + count += 1; + sum += value; + } + }); + assert(() => count !== 0, _lt(`Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error.`)); + return sum / count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COUNT + // ----------------------------------------------------------------------------- + const COUNT = { + description: _lt(`The number of numeric values in dataset.`), + args: args(` + value1 (number, range) ${_lt("The first value or range to consider when counting.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to consider when counting.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + let count = 0; + for (let n of values) { + if (Array.isArray(n)) { + for (let i of n) { + for (let j of i) { + if (typeof j === "number") { + count += 1; + } + } + } + } + else if (typeof n !== "string" || isNumber(n) || parseDateTime(n)) { + count += 1; + } + } + return count; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COUNTA + // ----------------------------------------------------------------------------- + const COUNTA = { + description: _lt(`The number of values in a dataset.`), + args: args(` + value1 (any, range) ${_lt("The first value or range to consider when counting.")} + value2 (any, range, repeating) ${_lt("Additional values or ranges to consider when counting.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return reduceAny(values, (acc, a) => (a !== undefined && a !== null ? acc + 1 : acc), 0); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COVAR + // ----------------------------------------------------------------------------- + // Note: Unlike the VAR function which corresponds to the variance over a sample (VAR.S), + // the COVAR function corresponds to the covariance over an entire population (COVAR.P) + const COVAR = { + description: _lt(`The covariance of a dataset.`), + args: args(` + data_y (any, range) ${_lt("The range representing the array or matrix of dependent data.")} + data_x (any, range) ${_lt("The range representing the array or matrix of independent data.")} + `), + returns: ["NUMBER"], + compute: function (dataY, dataX) { + return covariance(dataY, dataX, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COVARIANCE.P + // ----------------------------------------------------------------------------- + const COVARIANCE_P = { + description: _lt(`The covariance of a dataset.`), + args: args(` + data_y (any, range) ${_lt("The range representing the array or matrix of dependent data.")} + data_x (any, range) ${_lt("The range representing the array or matrix of independent data.")} + `), + returns: ["NUMBER"], + compute: function (dataY, dataX) { + return covariance(dataY, dataX, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COVARIANCE.S + // ----------------------------------------------------------------------------- + const COVARIANCE_S = { + description: _lt(`The sample covariance of a dataset.`), + args: args(` + data_y (any, range) ${_lt("The range representing the array or matrix of dependent data.")} + data_x (any, range) ${_lt("The range representing the array or matrix of independent data.")} + `), + returns: ["NUMBER"], + compute: function (dataY, dataX) { + return covariance(dataY, dataX, true); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // LARGE + // ----------------------------------------------------------------------------- + const LARGE = { + description: _lt("Nth largest element from a data set."), + args: args(` + data (any, range) ${_lt("Array or range containing the dataset to consider.")} + n (number) ${_lt("The rank from largest to smallest of the element to return.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, n) { + const _n = Math.trunc(toNumber(n)); + let largests = []; + let index; + let count = 0; + visitAny([data], (d) => { + if (typeof d === "number") { + index = dichotomicPredecessorSearch(largests, d); + largests.splice(index + 1, 0, d); + count++; + if (count > _n) { + largests.shift(); + count--; + } + } + }); + const result = largests.shift(); + assert(() => result !== undefined, _lt(`[[FUNCTION_NAME]] has no valid input data.`)); + assert(() => count >= _n, _lt("Function [[FUNCTION_NAME]] parameter 2 value (%s) is out of range.", _n.toString())); + return result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MAX + // ----------------------------------------------------------------------------- + const MAX = { + description: _lt("Maximum value in a numeric dataset."), + args: args(` + value1 (number, range) ${_lt("The first value or range to consider when calculating the maximum value.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to consider when calculating the maximum value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + const result = reduceNumbers(values, (acc, a) => (acc < a ? a : acc), -Infinity); + return result === -Infinity ? 0 : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MAXA + // ----------------------------------------------------------------------------- + const MAXA = { + description: _lt("Maximum numeric value in a dataset."), + args: args(` + value1 (any, range) ${_lt("The first value or range to consider when calculating the maximum value.")} + value2 (any, range, repeating) ${_lt("Additional values or ranges to consider when calculating the maximum value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + const maxa = reduceNumbersTextAs0(values, (acc, a) => { + return Math.max(a, acc); + }, -Infinity); + return maxa === -Infinity ? 0 : maxa; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MAXIFS + // ----------------------------------------------------------------------------- + const MAXIFS = { + description: _lt("Returns the maximum value in a range of cells, filtered by a set of criteria."), + args: args(` + range (range) ${_lt("The range of cells from which the maximum will be determined.")} + criteria_range1 (range) ${_lt("The range of cells over which to evaluate criterion1.")} + criterion1 (string) ${_lt("The pattern or test to apply to criteria_range1, such that each cell that evaluates to TRUE will be included in the filtered set.")} + criteria_range2 (any, range, repeating) ${_lt("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")} + criterion2 (string, repeating) ${_lt("The pattern or test to apply to criteria_range2.")} + `), + returns: ["NUMBER"], + compute: function (range, ...args) { + let result = -Infinity; + visitMatchingRanges(args, (i, j) => { + const value = range[i][j]; + if (typeof value === "number") { + result = result < value ? value : result; + } + }); + return result === -Infinity ? 0 : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MEDIAN + // ----------------------------------------------------------------------------- + const MEDIAN = { + description: _lt("Median value in a numeric dataset."), + args: args(` + value1 (any, range) ${_lt("The first value or range to consider when calculating the median value.")} + value2 (any, range, repeating) ${_lt("Additional values or ranges to consider when calculating the median value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + let data = []; + visitNumbers(values, (arg) => { + data.push(arg); + }); + return centile(data, 0.5, true); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MIN + // ----------------------------------------------------------------------------- + const MIN = { + description: _lt("Minimum value in a numeric dataset."), + args: args(` + value1 (number, range) ${_lt("The first value or range to consider when calculating the minimum value.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to consider when calculating the minimum value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + const result = reduceNumbers(values, (acc, a) => (a < acc ? a : acc), Infinity); + return result === Infinity ? 0 : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MINA + // ----------------------------------------------------------------------------- + const MINA = { + description: _lt("Minimum numeric value in a dataset."), + args: args(` + value1 (number, range) ${_lt("The first value or range to consider when calculating the minimum value.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to consider when calculating the minimum value.")} + `), + returns: ["NUMBER"], + computeFormat: (value1) => { + var _a; + return Array.isArray(value1) ? (_a = value1[0][0]) === null || _a === void 0 ? void 0 : _a.format : value1 === null || value1 === void 0 ? void 0 : value1.format; + }, + compute: function (...values) { + const mina = reduceNumbersTextAs0(values, (acc, a) => { + return Math.min(a, acc); + }, Infinity); + return mina === Infinity ? 0 : mina; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MINIFS + // ----------------------------------------------------------------------------- + const MINIFS = { + description: _lt("Returns the minimum value in a range of cells, filtered by a set of criteria."), + args: args(` + range (range) ${_lt("The range of cells from which the minimum will be determined.")} + criteria_range1 (range) ${_lt("The range of cells over which to evaluate criterion1.")} + criterion1 (string) ${_lt("The pattern or test to apply to criteria_range1, such that each cell that evaluates to TRUE will be included in the filtered set.")} + criteria_range2 (any, range, repeating) ${_lt("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")} + criterion2 (string, repeating) ${_lt("The pattern or test to apply to criteria_range2.")} + `), + returns: ["NUMBER"], + compute: function (range, ...args) { + let result = Infinity; + visitMatchingRanges(args, (i, j) => { + const value = range[i][j]; + if (typeof value === "number") { + result = result > value ? value : result; + } + }); + return result === Infinity ? 0 : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // PERCENTILE + // ----------------------------------------------------------------------------- + const PERCENTILE = { + description: _lt("Value at a given percentile of a dataset."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + percentile (number) ${_lt("The percentile whose value within data will be calculated and returned.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, percentile) { + return PERCENTILE_INC.compute(data, percentile); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // PERCENTILE.EXC + // ----------------------------------------------------------------------------- + const PERCENTILE_EXC = { + description: _lt("Value at a given percentile of a dataset exclusive of 0 and 1."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + percentile (number) ${_lt("The percentile, exclusive of 0 and 1, whose value within 'data' will be calculated and returned.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, percentile) { + return centile([data], percentile, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // PERCENTILE.INC + // ----------------------------------------------------------------------------- + const PERCENTILE_INC = { + description: _lt("Value at a given percentile of a dataset."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + percentile (number) ${_lt("The percentile whose value within data will be calculated and returned.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, percentile) { + return centile([data], percentile, true); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // QUARTILE + // ----------------------------------------------------------------------------- + const QUARTILE = { + description: _lt("Value nearest to a specific quartile of a dataset."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + quartile_number (number) ${_lt("Which quartile value to return.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, quartileNumber) { + return QUARTILE_INC.compute(data, quartileNumber); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // QUARTILE.EXC + // ----------------------------------------------------------------------------- + const QUARTILE_EXC = { + description: _lt("Value nearest to a specific quartile of a dataset exclusive of 0 and 4."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + quartile_number (number) ${_lt("Which quartile value, exclusive of 0 and 4, to return.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, quartileNumber) { + const _quartileNumber = Math.trunc(toNumber(quartileNumber)); + return centile([data], 0.25 * _quartileNumber, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // QUARTILE.INC + // ----------------------------------------------------------------------------- + const QUARTILE_INC = { + description: _lt("Value nearest to a specific quartile of a dataset."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + quartile_number (number) ${_lt("Which quartile value to return.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, quartileNumber) { + const _quartileNumber = Math.trunc(toNumber(quartileNumber)); + return centile([data], 0.25 * _quartileNumber, true); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SMALL + // ----------------------------------------------------------------------------- + const SMALL = { + description: _lt("Nth smallest element in a data set."), + args: args(` + data (any, range) ${_lt("The array or range containing the dataset to consider.")} + n (number) ${_lt("The rank from smallest to largest of the element to return.")} + `), + returns: ["NUMBER"], + computeFormat: (data) => { + var _a; + return Array.isArray(data) ? (_a = data[0][0]) === null || _a === void 0 ? void 0 : _a.format : data === null || data === void 0 ? void 0 : data.format; + }, + compute: function (data, n) { + const _n = Math.trunc(toNumber(n)); + let largests = []; + let index; + let count = 0; + visitAny([data], (d) => { + if (typeof d === "number") { + index = dichotomicPredecessorSearch(largests, d); + largests.splice(index + 1, 0, d); + count++; + if (count > _n) { + largests.pop(); + count--; + } + } + }); + const result = largests.pop(); + assert(() => result !== undefined, _lt(`[[FUNCTION_NAME]] has no valid input data.`)); + assert(() => count >= _n, _lt("Function [[FUNCTION_NAME]] parameter 2 value (%s) is out of range.", _n.toString())); + return result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // STDEV + // ----------------------------------------------------------------------------- + const STDEV = { + description: _lt("Standard deviation."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return Math.sqrt(VAR.compute(...values)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // STDEV.P + // ----------------------------------------------------------------------------- + const STDEV_P = { + description: _lt("Standard deviation of entire population."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the population.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the population.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return Math.sqrt(VAR_P.compute(...values)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // STDEV.S + // ----------------------------------------------------------------------------- + const STDEV_S = { + description: _lt("Standard deviation."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return Math.sqrt(VAR_S.compute(...values)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // STDEVA + // ----------------------------------------------------------------------------- + const STDEVA = { + description: _lt("Standard deviation of sample (text as 0)."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return Math.sqrt(VARA.compute(...values)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // STDEVP + // ----------------------------------------------------------------------------- + const STDEVP = { + description: _lt("Standard deviation of entire population."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the population.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the population.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return Math.sqrt(VARP.compute(...values)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // STDEVPA + // ----------------------------------------------------------------------------- + const STDEVPA = { + description: _lt("Standard deviation of entire population (text as 0)."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the population.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the population.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return Math.sqrt(VARPA.compute(...values)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VAR + // ----------------------------------------------------------------------------- + const VAR = { + description: _lt("Variance."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return variance(values, true, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VAR.P + // ----------------------------------------------------------------------------- + const VAR_P = { + description: _lt("Variance of entire population."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the population.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the population.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return variance(values, false, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VAR.S + // ----------------------------------------------------------------------------- + const VAR_S = { + description: _lt("Variance."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return variance(values, true, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VARA + // ----------------------------------------------------------------------------- + const VARA = { + description: _lt("Variance of sample (text as 0)."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the sample.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the sample.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return variance(values, true, true); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VARP + // ----------------------------------------------------------------------------- + const VARP = { + description: _lt("Variance of entire population."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the population.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the population.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return variance(values, false, false); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VARPA + // ----------------------------------------------------------------------------- + const VARPA = { + description: _lt("Variance of entire population (text as 0)."), + args: args(` + value1 (number, range) ${_lt("The first value or range of the population.")} + value2 (number, range, repeating) ${_lt("Additional values or ranges to include in the population.")} + `), + returns: ["NUMBER"], + compute: function (...values) { + return variance(values, false, true); + }, + isExported: true, + }; + + var statistical = /*#__PURE__*/Object.freeze({ + __proto__: null, + AVEDEV: AVEDEV, + AVERAGE: AVERAGE, + AVERAGE_WEIGHTED: AVERAGE_WEIGHTED, + AVERAGEA: AVERAGEA, + AVERAGEIF: AVERAGEIF, + AVERAGEIFS: AVERAGEIFS, + COUNT: COUNT, + COUNTA: COUNTA, + COVAR: COVAR, + COVARIANCE_P: COVARIANCE_P, + COVARIANCE_S: COVARIANCE_S, + LARGE: LARGE, + MAX: MAX, + MAXA: MAXA, + MAXIFS: MAXIFS, + MEDIAN: MEDIAN, + MIN: MIN, + MINA: MINA, + MINIFS: MINIFS, + PERCENTILE: PERCENTILE, + PERCENTILE_EXC: PERCENTILE_EXC, + PERCENTILE_INC: PERCENTILE_INC, + QUARTILE: QUARTILE, + QUARTILE_EXC: QUARTILE_EXC, + QUARTILE_INC: QUARTILE_INC, + SMALL: SMALL, + STDEV: STDEV, + STDEV_P: STDEV_P, + STDEV_S: STDEV_S, + STDEVA: STDEVA, + STDEVP: STDEVP, + STDEVPA: STDEVPA, + VAR: VAR, + VAR_P: VAR_P, + VAR_S: VAR_S, + VARA: VARA, + VARP: VARP, + VARPA: VARPA + }); + + function getMatchingCells(database, field, criteria) { + // Example + var _a; + // # DATABASE # CRITERIA # field = "C" + // + // | A | B | C | | A | C | + // |===========| |=======| + // | 1 | x | j | |<2 | j | + // | 1 | Z | k | | | 7 | + // | 5 | y | 7 | + // 1 - Select coordinates of database columns ---------------------------------------------------- + const indexColNameDB = new Map(); + const dimRowDB = database.length; + for (let indexCol = dimRowDB - 1; indexCol >= 0; indexCol--) { + indexColNameDB.set(toString(database[indexCol][0]).toUpperCase(), indexCol); + } + // Example continuation: indexColNameDB = {"A" => 0, "B" => 1, "C" => 2} + // 2 - Check if the field parameter exists in the column names of the database ------------------- + // field may either be a text label corresponding to a column header in the + // first row of database or a numeric index indicating which column to consider, + // where the first column has the value 1. + if (typeof field !== "number" && typeof field !== "string") { + throw new Error(_lt("The field must be a number or a string")); + } + let index; + if (typeof field === "number") { + index = Math.trunc(field) - 1; + if (index < 0 || dimRowDB - 1 < index) { + throw new Error(_lt("The field (%s) must be one of %s or must be a number between 1 and %s inclusive.", field.toString(), dimRowDB.toString())); + } + } + else { + const colName = toString(field).toUpperCase(); + index = (_a = indexColNameDB.get(colName)) !== null && _a !== void 0 ? _a : -1; + if (index === -1) { + throw new Error(_lt("The field (%s) must be one of %s.", toString(field), [...indexColNameDB.keys()].toString())); + } + } + // Example continuation: index = 2 + // 3 - For each criteria row, find database row that correspond ---------------------------------- + const dimColCriteria = criteria[0].length; + if (dimColCriteria < 2) { + throw new Error(_lt("The criteria range contains %s row, it must be at least 2 rows.", dimColCriteria.toString())); + } + let matchingRows = new Set(); + const dimColDB = database[0].length; + for (let indexRow = 1; indexRow < dimColCriteria; indexRow++) { + let args = []; + let existColNameDB = true; + for (let indexCol = 0; indexCol < criteria.length; indexCol++) { + const currentName = toString(criteria[indexCol][0]).toUpperCase(); + const indexColDB = indexColNameDB.get(currentName); + const criter = criteria[indexCol][indexRow]; + if (criter !== undefined) { + if (indexColDB !== undefined) { + args.push([database[indexColDB].slice(1, dimColDB)]); + args.push(criter); + } + else { + existColNameDB = false; + break; + } + } + } + // Example continuation: args1 = [[1,1,5], "<2", ["j","k",7], "j"] + // Example continuation: args2 = [["j","k",7], "7"] + if (existColNameDB) { + if (args.length > 0) { + visitMatchingRanges(args, (i, j) => { + matchingRows.add(j); + }, true); + } + else { + // return indices of each database row when a criteria table row is void + matchingRows = new Set(Array(dimColDB - 1).keys()); + break; + } + } + } + // Example continuation: matchingRows = {0, 2} + // 4 - return for each database row corresponding, the cells corresponding to the field parameter + const fieldCol = database[index]; + // Example continuation:: fieldCol = ["C", "j", "k", 7] + const matchingCells = [...matchingRows].map((x) => fieldCol[x + 1]); + // Example continuation:: matchingCells = ["j", 7] + return matchingCells; + } + const databaseArgs = args(` + database (range) ${_lt("The array or range containing the data to consider, structured in such a way that the first row contains the labels for each column's values.")} + field (any) ${_lt("Indicates which column in database contains the values to be extracted and operated on.")} + criteria (range) ${_lt("An array or range containing zero or more criteria to filter the database values by before operating.")} +`); + // ----------------------------------------------------------------------------- + // DAVERAGE + // ----------------------------------------------------------------------------- + const DAVERAGE = { + description: _lt("Average of a set of values from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return AVERAGE.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DCOUNT + // ----------------------------------------------------------------------------- + const DCOUNT = { + description: _lt("Counts values from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return COUNT.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DCOUNTA + // ----------------------------------------------------------------------------- + const DCOUNTA = { + description: _lt("Counts values and text from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return COUNTA.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DGET + // ----------------------------------------------------------------------------- + const DGET = { + description: _lt("Single value from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + assert(() => cells.length === 1, _lt("More than one match found in DGET evaluation.")); + return cells[0]; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DMAX + // ----------------------------------------------------------------------------- + const DMAX = { + description: _lt("Maximum of values from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return MAX.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DMIN + // ----------------------------------------------------------------------------- + const DMIN = { + description: _lt("Minimum of values from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return MIN.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DPRODUCT + // ----------------------------------------------------------------------------- + const DPRODUCT = { + description: _lt("Product of values from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return PRODUCT.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DSTDEV + // ----------------------------------------------------------------------------- + const DSTDEV = { + description: _lt("Standard deviation of population sample from table."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return STDEV.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DSTDEVP + // ----------------------------------------------------------------------------- + const DSTDEVP = { + description: _lt("Standard deviation of entire population from table."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return STDEVP.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DSUM + // ----------------------------------------------------------------------------- + const DSUM = { + description: _lt("Sum of values from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return SUM.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DVAR + // ----------------------------------------------------------------------------- + const DVAR = { + description: _lt("Variance of population sample from table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return VAR.compute([cells]); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DVARP + // ----------------------------------------------------------------------------- + const DVARP = { + description: _lt("Variance of a population from a table-like range."), + args: databaseArgs, + returns: ["NUMBER"], + compute: function (database, field, criteria) { + const cells = getMatchingCells(database, field, criteria); + return VARP.compute([cells]); + }, + isExported: true, + }; + + var database = /*#__PURE__*/Object.freeze({ + __proto__: null, + DAVERAGE: DAVERAGE, + DCOUNT: DCOUNT, + DCOUNTA: DCOUNTA, + DGET: DGET, + DMAX: DMAX, + DMIN: DMIN, + DPRODUCT: DPRODUCT, + DSTDEV: DSTDEV, + DSTDEVP: DSTDEVP, + DSUM: DSUM, + DVAR: DVAR, + DVARP: DVARP + }); + + const DEFAULT_TYPE = 1; + const DEFAULT_WEEKEND = 1; + function isLeapYear(year) { + const _year = Math.trunc(year); + return (_year % 4 === 0 && _year % 100 != 0) || _year % 400 == 0; + } + // ----------------------------------------------------------------------------- + // DATE + // ----------------------------------------------------------------------------- + const DATE = { + description: _lt("Converts year/month/day into a date."), + args: args(` + year (number) ${_lt("The year component of the date.")} + month (number) ${_lt("The month component of the date.")} + day (number) ${_lt("The day component of the date.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (year, month, day) { + let _year = Math.trunc(toNumber(year)); + const _month = Math.trunc(toNumber(month)); + const _day = Math.trunc(toNumber(day)); + // For years less than 0 or greater than 10000, return #ERROR. + assert(() => 0 <= _year && _year <= 9999, _lt("The year (%s) must be between 0 and 9999 inclusive.", _year.toString())); + // Between 0 and 1899, we add that value to 1900 to calculate the year + if (_year < 1900) { + _year += 1900; + } + const jsDate = new Date(_year, _month - 1, _day); + const result = jsDateToRoundNumber(jsDate); + assert(() => result >= 0, _lt(`The function [[FUNCTION_NAME]] result must be greater than or equal 01/01/1900.`)); + return result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DATEVALUE + // ----------------------------------------------------------------------------- + const DATEVALUE = { + description: _lt("Converts a date string to a date value."), + args: args(` + date_string (string) ${_lt("The string representing the date.")} + `), + returns: ["NUMBER"], + compute: function (dateString) { + const _dateString = toString(dateString); + const internalDate = parseDateTime(_dateString); + assert(() => internalDate !== null, _lt("The date_string (%s) cannot be parsed to date/time.", _dateString.toString())); + return Math.trunc(internalDate.value); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DAY + // ----------------------------------------------------------------------------- + const DAY = { + description: _lt("Day of the month that a specific date falls on."), + args: args(` + date (string) ${_lt("The date from which to extract the day.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return toJsDate(date).getDate(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DAYS + // ----------------------------------------------------------------------------- + const DAYS = { + description: _lt("Number of days between two dates."), + args: args(` + end_date (date) ${_lt("The end of the date range.")} + start_date (date) ${_lt("The start of the date range.")} + `), + returns: ["NUMBER"], + compute: function (endDate, startDate) { + const _endDate = toJsDate(endDate); + const _startDate = toJsDate(startDate); + const dateDif = _endDate.getTime() - _startDate.getTime(); + return Math.round(dateDif / MS_PER_DAY); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // EDATE + // ----------------------------------------------------------------------------- + const EDATE = { + description: _lt("Date a number of months before/after another date."), + args: args(` + start_date (date) ${_lt("The date from which to calculate the result.")} + months (number) ${_lt("The number of months before (negative) or after (positive) 'start_date' to calculate.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (startDate, months) { + const _startDate = toJsDate(startDate); + const _months = Math.trunc(toNumber(months)); + const yStart = _startDate.getFullYear(); + const mStart = _startDate.getMonth(); + const dStart = _startDate.getDate(); + const jsDate = new Date(yStart, mStart + _months, dStart); + return jsDateToRoundNumber(jsDate); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // EOMONTH + // ----------------------------------------------------------------------------- + const EOMONTH = { + description: _lt("Last day of a month before or after a date."), + args: args(` + start_date (date) ${_lt("The date from which to calculate the result.")} + months (number) ${_lt("The number of months before (negative) or after (positive) 'start_date' to consider.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (startDate, months) { + const _startDate = toJsDate(startDate); + const _months = Math.trunc(toNumber(months)); + const yStart = _startDate.getFullYear(); + const mStart = _startDate.getMonth(); + const jsDate = new Date(yStart, mStart + _months + 1, 0); + return jsDateToRoundNumber(jsDate); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // HOUR + // ----------------------------------------------------------------------------- + const HOUR = { + description: _lt("Hour component of a specific time."), + args: args(` + time (date) ${_lt("The time from which to calculate the hour component.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return toJsDate(date).getHours(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISOWEEKNUM + // ----------------------------------------------------------------------------- + const ISOWEEKNUM = { + description: _lt("ISO week number of the year."), + args: args(` + date (date) ${_lt("The date for which to determine the ISO week number. Must be a reference to a cell containing a date, a function returning a date type, or a number.")} + `), + returns: ["NUMBER"], + compute: function (date) { + const _date = toJsDate(date); + const y = _date.getFullYear(); + // 1 - As the 1st week of a year can start the previous year or after the 1st + // january we first look if the date is in the weeks of the current year, previous + // year or year after. + // A - We look for the current year, the first days of the first week + // and the last days of the last week + // The first week of the year is the week that contains the first + // Thursday of the year. + let firstThursday = 1; + while (new Date(y, 0, firstThursday).getDay() !== 4) { + firstThursday += 1; + } + const firstDayOfFirstWeek = new Date(y, 0, firstThursday - 3); + // The last week of the year is the week that contains the last Thursday of + // the year. + let lastThursday = 31; + while (new Date(y, 11, lastThursday).getDay() !== 4) { + lastThursday -= 1; + } + const lastDayOfLastWeek = new Date(y, 11, lastThursday + 3); + // B - If our date > lastDayOfLastWeek then it's in the weeks of the year after + // If our date < firstDayOfFirstWeek then it's in the weeks of the year before + let offsetYear; + if (firstDayOfFirstWeek.getTime() <= _date.getTime()) { + if (_date.getTime() <= lastDayOfLastWeek.getTime()) { + offsetYear = 0; + } + else { + offsetYear = 1; + } + } + else { + offsetYear = -1; + } + // 2 - now that the year is known, we are looking at the difference between + // the first day of this year and the date. The difference in days divided by + // 7 gives us the week number + let firstDay; + switch (offsetYear) { + case 0: + firstDay = firstDayOfFirstWeek; + break; + case 1: + // firstDay is the 1st day of the 1st week of the year after + // firstDay = lastDayOfLastWeek + 1 Day + firstDay = new Date(y, 11, lastThursday + 3 + 1); + break; + case -1: + // firstDay is the 1st day of the 1st week of the previous year. + // The first week of the previous year is the week that contains the + // first Thursday of the previous year. + let firstThursdayPreviousYear = 1; + while (new Date(y - 1, 0, firstThursdayPreviousYear).getDay() !== 4) { + firstThursdayPreviousYear += 1; + } + firstDay = new Date(y - 1, 0, firstThursdayPreviousYear - 3); + break; + } + const diff = (_date.getTime() - firstDay.getTime()) / MS_PER_DAY; + return Math.floor(diff / 7) + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MINUTE + // ----------------------------------------------------------------------------- + const MINUTE = { + description: _lt("Minute component of a specific time."), + args: args(` + time (date) ${_lt("The time from which to calculate the minute component.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return toJsDate(date).getMinutes(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MONTH + // ----------------------------------------------------------------------------- + const MONTH = { + description: _lt("Month of the year a specific date falls in"), + args: args(` + date (date) ${_lt("The date from which to extract the month.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return toJsDate(date).getMonth() + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // NETWORKDAYS + // ----------------------------------------------------------------------------- + const NETWORKDAYS = { + description: _lt("Net working days between two provided days."), + args: args(` + start_date (date) ${_lt("The start date of the period from which to calculate the number of net working days.")} + end_date (date) ${_lt("The end date of the period from which to calculate the number of net working days.")} + holidays (date, range, optional) ${_lt("A range or array constant containing the date serial numbers to consider holidays.")} + `), + returns: ["NUMBER"], + compute: function (startDate, endDate, holidays) { + return NETWORKDAYS_INTL.compute(startDate, endDate, 1, holidays); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // NETWORKDAYS.INTL + // ----------------------------------------------------------------------------- + /** + * Transform weekend Spreadsheet information into Date Day JavaScript information. + * Take string (String method) or number (Number method), return array of numbers. + * + * String method: weekends can be specified using seven 0’s and 1’s, where the + * first number in the set represents Monday and the last number is for Sunday. + * A zero means that the day is a work day, a 1 means that the day is a weekend. + * For example, “0000011” would mean Saturday and Sunday are weekends. + * + * Number method: instead of using the string method above, a single number can + * be used. 1 = Saturday/Sunday are weekends, 2 = Sunday/Monday, and this pattern + * repeats until 7 = Friday/Saturday. 11 = Sunday is the only weekend, 12 = Monday + * is the only weekend, and this pattern repeats until 17 = Saturday is the only + * weekend. + * + * Example: + * - 11 return [0] (correspond to Sunday) + * - 12 return [1] (correspond to Monday) + * - 3 return [1,2] (correspond to Monday and Tuesday) + * - "0101010" return [2,4,6] (correspond to Tuesday, Thursday and Saturday) + */ + function weekendToDayNumber(weekend) { + // case "string" + if (typeof weekend === "string") { + assert(() => { + if (weekend.length !== 7) { + return false; + } + for (let day of weekend) { + if (day !== "0" && day !== "1") { + return false; + } + } + return true; + }, _lt('When weekend is a string (%s) it must be composed of "0" or "1".', weekend)); + let result = []; + for (let i = 0; i < 7; i++) { + if (weekend[i] === "1") { + result.push((i + 1) % 7); + } + } + return result; + } + //case "number" + if (typeof weekend === "number") { + assert(() => (1 <= weekend && weekend <= 7) || (11 <= weekend && weekend <= 17), _lt("The weekend (%s) must be a string or a number in the range 1-7 or 11-17.", weekend.toString())); + // case 1 <= weekend <= 7 + if (weekend <= 7) { + // 1 = Saturday/Sunday are weekends + // 2 = Sunday/Monday + // ... + // 7 = Friday/Saturday. + return [weekend - 2 === -1 ? 6 : weekend - 2, weekend - 1]; + } + // case 11 <= weekend <= 17 + // 11 = Sunday is the only weekend + // 12 = Monday is the only weekend + // ... + // 17 = Saturday is the only weekend. + return [weekend - 11]; + } + throw Error(_lt("The weekend must be a number or a string.")); + } + const NETWORKDAYS_INTL = { + description: _lt("Net working days between two dates (specifying weekends)."), + args: args(` + start_date (date) ${_lt("The start date of the period from which to calculate the number of net working days.")} + end_date (date) ${_lt("The end date of the period from which to calculate the number of net working days.")} + weekend (any, default=${DEFAULT_WEEKEND}) ${_lt("A number or string representing which days of the week are considered weekends.")} + holidays (date, range, optional) ${_lt("A range or array constant containing the dates to consider as holidays.")} + `), + returns: ["NUMBER"], + compute: function (startDate, endDate, weekend = DEFAULT_WEEKEND, holidays) { + const _startDate = toJsDate(startDate); + const _endDate = toJsDate(endDate); + const daysWeekend = weekendToDayNumber(weekend); + let timesHoliday = new Set(); + if (holidays !== undefined) { + visitAny([holidays], (h) => { + const holiday = toJsDate(h); + timesHoliday.add(holiday.getTime()); + }); + } + const invertDate = _startDate.getTime() > _endDate.getTime(); + const stopDate = new Date((invertDate ? _startDate : _endDate).getTime()); + let stepDate = new Date((invertDate ? _endDate : _startDate).getTime()); + const timeStopDate = stopDate.getTime(); + let timeStepDate = stepDate.getTime(); + let netWorkingDay = 0; + while (timeStepDate <= timeStopDate) { + if (!daysWeekend.includes(stepDate.getDay()) && !timesHoliday.has(timeStepDate)) { + netWorkingDay += 1; + } + stepDate.setDate(stepDate.getDate() + 1); + timeStepDate = stepDate.getTime(); + } + return invertDate ? -netWorkingDay : netWorkingDay; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // NOW + // ----------------------------------------------------------------------------- + const NOW = { + description: _lt("Current date and time as a date value."), + args: [], + returns: ["DATE"], + computeFormat: () => "m/d/yyyy hh:mm:ss", + compute: function () { + let today = new Date(); + today.setMilliseconds(0); + const delta = today.getTime() - INITIAL_1900_DAY.getTime(); + const time = today.getHours() / 24 + today.getMinutes() / 1440 + today.getSeconds() / 86400; + return Math.floor(delta / MS_PER_DAY) + time; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SECOND + // ----------------------------------------------------------------------------- + const SECOND = { + description: _lt("Minute component of a specific time."), + args: args(` + time (date) ${_lt("The time from which to calculate the second component.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return toJsDate(date).getSeconds(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TIME + // ----------------------------------------------------------------------------- + const TIME = { + description: _lt("Converts hour/minute/second into a time."), + args: args(` + hour (number) ${_lt("The hour component of the time.")} + minute (number) ${_lt("The minute component of the time.")} + second (number) ${_lt("The second component of the time.")} + `), + returns: ["DATE"], + computeFormat: () => "hh:mm:ss a", + compute: function (hour, minute, second) { + let _hour = Math.trunc(toNumber(hour)); + let _minute = Math.trunc(toNumber(minute)); + let _second = Math.trunc(toNumber(second)); + _minute += Math.floor(_second / 60); + _second = (_second % 60) + (_second < 0 ? 60 : 0); + _hour += Math.floor(_minute / 60); + _minute = (_minute % 60) + (_minute < 0 ? 60 : 0); + _hour %= 24; + assert(() => _hour >= 0, _lt(`The function [[FUNCTION_NAME]] result cannot be negative`)); + return _hour / 24 + _minute / (24 * 60) + _second / (24 * 60 * 60); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TIMEVALUE + // ----------------------------------------------------------------------------- + const TIMEVALUE = { + description: _lt("Converts a time string into its serial number representation."), + args: args(` + time_string (string) ${_lt("The string that holds the time representation.")} + `), + returns: ["NUMBER"], + compute: function (timeString) { + const _timeString = toString(timeString); + const internalDate = parseDateTime(_timeString); + assert(() => internalDate !== null, _lt("The time_string (%s) cannot be parsed to date/time.", _timeString)); + const result = internalDate.value - Math.trunc(internalDate.value); + return result < 0 ? 1 + result : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TODAY + // ----------------------------------------------------------------------------- + const TODAY = { + description: _lt("Current date as a date value."), + args: [], + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function () { + const today = new Date(); + const jsDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + return jsDateToRoundNumber(jsDate); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // WEEKDAY + // ----------------------------------------------------------------------------- + const WEEKDAY = { + description: _lt("Day of the week of the date provided (as number)."), + args: args(` + date (date) ${_lt("The date for which to determine the day of the week. Must be a reference to a cell containing a date, a function returning a date type, or a number.")} + type (number, default=${DEFAULT_TYPE}) ${_lt("A number indicating which numbering system to use to represent weekdays. By default, counts starting with Sunday = 1.")} + `), + returns: ["NUMBER"], + compute: function (date, type = DEFAULT_TYPE) { + const _date = toJsDate(date); + const _type = Math.round(toNumber(type)); + const m = _date.getDay(); + assert(() => [1, 2, 3].includes(_type), _lt("The type (%s) must be 1, 2 or 3.", _type.toString())); + if (_type === 1) + return m + 1; + if (_type === 2) + return m === 0 ? 7 : m; + return m === 0 ? 6 : m - 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // WEEKNUM + // ----------------------------------------------------------------------------- + const WEEKNUM = { + description: _lt("Week number of the year."), + args: args(` + date (date) ${_lt("The date for which to determine the week number. Must be a reference to a cell containing a date, a function returning a date type, or a number.")} + type (number, default=${DEFAULT_TYPE}) ${_lt("A number representing the day that a week starts on. Sunday = 1.")} + `), + returns: ["NUMBER"], + compute: function (date, type = DEFAULT_TYPE) { + const _date = toJsDate(date); + const _type = Math.round(toNumber(type)); + assert(() => _type === 1 || _type === 2 || (11 <= _type && _type <= 17) || _type === 21, _lt("The type (%s) is out of range.", _type.toString())); + if (_type === 21) { + return ISOWEEKNUM.compute(date); + } + let startDayOfWeek; + if (_type === 1 || _type === 2) { + startDayOfWeek = _type - 1; + } + else { + // case 11 <= _type <= 17 + startDayOfWeek = _type - 10 === 7 ? 0 : _type - 10; + } + const y = _date.getFullYear(); + let dayStart = 1; + let startDayOfFirstWeek = new Date(y, 0, dayStart); + while (startDayOfFirstWeek.getDay() !== startDayOfWeek) { + dayStart += 1; + startDayOfFirstWeek = new Date(y, 0, dayStart); + } + const dif = (_date.getTime() - startDayOfFirstWeek.getTime()) / MS_PER_DAY; + if (dif < 0) { + return 1; + } + return Math.floor(dif / 7) + (dayStart === 1 ? 1 : 2); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // WORKDAY + // ----------------------------------------------------------------------------- + const WORKDAY = { + description: _lt("Date after a number of workdays."), + args: args(` + start_date (date) ${_lt("The date from which to begin counting.")} + num_days (number) ${_lt("The number of working days to advance from start_date. If negative, counts backwards.")} + holidays (date, range, optional) ${_lt("A range or array constant containing the dates to consider holidays.")} + `), + returns: ["NUMBER"], + computeFormat: () => "m/d/yyyy", + compute: function (startDate, numDays, holidays = undefined) { + return WORKDAY_INTL.compute(startDate, numDays, 1, holidays); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // WORKDAY.INTL + // ----------------------------------------------------------------------------- + const WORKDAY_INTL = { + description: _lt("Date after a number of workdays (specifying weekends)."), + args: args(` + start_date (date) ${_lt("The date from which to begin counting.")} + num_days (number) ${_lt("The number of working days to advance from start_date. If negative, counts backwards.")} + weekend (any, default=${DEFAULT_WEEKEND}) ${_lt("A number or string representing which days of the week are considered weekends.")} + holidays (date, range, optional) ${_lt("A range or array constant containing the dates to consider holidays.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (startDate, numDays, weekend = DEFAULT_WEEKEND, holidays) { + let _startDate = toJsDate(startDate); + let _numDays = Math.trunc(toNumber(numDays)); + if (typeof weekend === "string") { + assert(() => weekend !== "1111111", _lt("The weekend (%s) must be different from '1111111'.", weekend)); + } + const daysWeekend = weekendToDayNumber(weekend); + let timesHoliday = new Set(); + if (holidays !== undefined) { + visitAny([holidays], (h) => { + const holiday = toJsDate(h); + timesHoliday.add(holiday.getTime()); + }); + } + let stepDate = new Date(_startDate.getTime()); + let timeStepDate = stepDate.getTime(); + const unitDay = Math.sign(_numDays); + let stepDay = Math.abs(_numDays); + while (stepDay > 0) { + stepDate.setDate(stepDate.getDate() + unitDay); + timeStepDate = stepDate.getTime(); + if (!daysWeekend.includes(stepDate.getDay()) && !timesHoliday.has(timeStepDate)) { + stepDay -= 1; + } + } + const delta = timeStepDate - INITIAL_1900_DAY.getTime(); + return Math.round(delta / MS_PER_DAY); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // YEAR + // ----------------------------------------------------------------------------- + const YEAR = { + description: _lt("Year specified by a given date."), + args: args(` + date (date) ${_lt("The date from which to extract the year.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return toJsDate(date).getFullYear(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // YEARFRAC + // ----------------------------------------------------------------------------- + const DEFAULT_DAY_COUNT_CONVENTION$1 = 0; + const YEARFRAC = { + description: _lt("Exact number of years between two dates."), + args: args(` + start_date (date) ${_lt("The start date to consider in the calculation. Must be a reference to a cell containing a date, a function returning a date type, or a number.")} + end_date (date) ${_lt("The end date to consider in the calculation. Must be a reference to a cell containing a date, a function returning a date type, or a number.")} + day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION$1}) ${_lt("An indicator of what day count method to use.")} + `), + returns: ["NUMBER"], + compute: function (startDate, endDate, dayCountConvention = DEFAULT_DAY_COUNT_CONVENTION$1) { + let _startDate = Math.trunc(toNumber(startDate)); + let _endDate = Math.trunc(toNumber(endDate)); + const _dayCountConvention = Math.trunc(toNumber(dayCountConvention)); + assert(() => _startDate >= 0, _lt("The start_date (%s) must be positive or null.", _startDate.toString())); + assert(() => _endDate >= 0, _lt("The end_date (%s) must be positive or null.", _endDate.toString())); + assert(() => 0 <= _dayCountConvention && _dayCountConvention <= 4, _lt("The day_count_convention (%s) must be between 0 and 4 inclusive.", _dayCountConvention.toString())); + if (_startDate === _endDate) { + return 0; + } + if (_startDate > _endDate) { + const stack = _endDate; + _endDate = _startDate; + _startDate = stack; + } + const jsStartDate = toJsDate(_startDate); + const jsEndDate = toJsDate(_endDate); + let dayStart = jsStartDate.getDate(); + let dayEnd = jsEndDate.getDate(); + const monthStart = jsStartDate.getMonth(); // january is 0 + const monthEnd = jsEndDate.getMonth(); // january is 0 + const yearStart = jsStartDate.getFullYear(); + const yearEnd = jsEndDate.getFullYear(); + let yearsStart = 0; + let yearsEnd = 0; + switch (_dayCountConvention) { + // 30/360 US convention -------------------------------------------------- + case 0: + if (dayStart === 31) + dayStart = 30; + if (dayStart === 30 && dayEnd === 31) + dayEnd = 30; + // If jsStartDate is the last day of February + if (monthStart === 1 && dayStart === (isLeapYear(yearStart) ? 29 : 28)) { + dayStart = 30; + // If jsEndDate is the last day of February + if (monthEnd === 1 && dayEnd === (isLeapYear(yearEnd) ? 29 : 28)) { + dayEnd = 30; + } + } + yearsStart = yearStart + (monthStart * 30 + dayStart) / 360; + yearsEnd = yearEnd + (monthEnd * 30 + dayEnd) / 360; + break; + // actual/actual convention ---------------------------------------------- + case 1: + let daysInYear = 365; + const isSameYear = yearStart === yearEnd; + const isOneDeltaYear = yearStart + 1 === yearEnd; + const isMonthEndBigger = monthStart < monthEnd; + const isSameMonth = monthStart === monthEnd; + const isDayEndBigger = dayStart < dayEnd; + // |-----| <-- one Year + // 'A' is start date + // 'B' is end date + if ((!isSameYear && !isOneDeltaYear) || + (!isSameYear && isMonthEndBigger) || + (!isSameYear && isSameMonth && isDayEndBigger)) { + // |---A-|-----|-B---| <-- !isSameYear && !isOneDeltaYear + // |---A-|----B|-----| <-- !isSameYear && isMonthEndBigger + // |---A-|---B-|-----| <-- !isSameYear && isSameMonth && isDayEndBigger + let countYears = 0; + let countDaysInYears = 0; + for (let y = yearStart; y <= yearEnd; y++) { + countYears++; + countDaysInYears += isLeapYear(y) ? 366 : 365; + } + daysInYear = countDaysInYears / countYears; + } + else if (!isSameYear) { + // |-AF--|B----|-----| + if (isLeapYear(yearStart) && monthStart < 2) { + daysInYear = 366; + } + // |--A--|FB---|-----| + if (isLeapYear(yearEnd) && (monthEnd > 1 || (monthEnd === 1 && dayEnd === 29))) { + daysInYear = 366; + } + } + else { + // remaining cases: + // + // |-F-AB|-----|-----| + // |AB-F-|-----|-----| + // |A-F-B|-----|-----| + // if February 29 occurs between date1 (exclusive) and date2 (inclusive) + // daysInYear --> 366 + if (isLeapYear(yearStart)) { + daysInYear = 366; + } + } + yearsStart = _startDate / daysInYear; + yearsEnd = _endDate / daysInYear; + break; + // actual/360 convention ------------------------------------------------- + case 2: + yearsStart = _startDate / 360; + yearsEnd = _endDate / 360; + break; + // actual/365 convention ------------------------------------------------- + case 3: + yearsStart = _startDate / 365; + yearsEnd = _endDate / 365; + break; + // 30/360 European convention -------------------------------------------- + case 4: + if (dayStart === 31) + dayStart = 30; + if (dayEnd === 31) + dayEnd = 30; + yearsStart = yearStart + (monthStart * 30 + dayStart) / 360; + yearsEnd = yearEnd + (monthEnd * 30 + dayEnd) / 360; + break; + } + return yearsEnd - yearsStart; + }, + }; + // ----------------------------------------------------------------------------- + // MONTH.START + // ----------------------------------------------------------------------------- + const MONTH_START = { + description: _lt("First day of the month preceding a date."), + args: args(` + date (date) ${_lt("The date from which to calculate the result.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (date) { + const _startDate = toJsDate(date); + const yStart = _startDate.getFullYear(); + const mStart = _startDate.getMonth(); + const jsDate = new Date(yStart, mStart, 1); + return jsDateToRoundNumber(jsDate); + }, + }; + // ----------------------------------------------------------------------------- + // MONTH.END + // ----------------------------------------------------------------------------- + const MONTH_END = { + description: _lt("Last day of the month following a date."), + args: args(` + date (date) ${_lt("The date from which to calculate the result.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (date) { + return EOMONTH.compute(date, 0); + }, + }; + // ----------------------------------------------------------------------------- + // QUARTER + // ----------------------------------------------------------------------------- + const QUARTER = { + description: _lt("Quarter of the year a specific date falls in"), + args: args(` + date (date) ${_lt("The date from which to extract the quarter.")} + `), + returns: ["NUMBER"], + compute: function (date) { + return Math.ceil((toJsDate(date).getMonth() + 1) / 3); + }, + }; + // ----------------------------------------------------------------------------- + // QUARTER.START + // ----------------------------------------------------------------------------- + const QUARTER_START = { + description: _lt("First day of the quarter of the year a specific date falls in."), + args: args(` + date (date) ${_lt("The date from which to calculate the start of quarter.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (date) { + const quarter = QUARTER.compute(date); + const year = YEAR.compute(date); + const jsDate = new Date(year, (quarter - 1) * 3, 1); + return jsDateToRoundNumber(jsDate); + }, + }; + // ----------------------------------------------------------------------------- + // QUARTER.END + // ----------------------------------------------------------------------------- + const QUARTER_END = { + description: _lt("Last day of the quarter of the year a specific date falls in."), + args: args(` + date (date) ${_lt("The date from which to calculate the end of quarter.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (date) { + const quarter = QUARTER.compute(date); + const year = YEAR.compute(date); + const jsDate = new Date(year, quarter * 3, 0); + return jsDateToRoundNumber(jsDate); + }, + }; + // ----------------------------------------------------------------------------- + // YEAR.START + // ----------------------------------------------------------------------------- + const YEAR_START = { + description: _lt("First day of the year a specific date falls in."), + args: args(` + date (date) ${_lt("The date from which to calculate the start of the year.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (date) { + const year = YEAR.compute(date); + const jsDate = new Date(year, 0, 1); + return jsDateToRoundNumber(jsDate); + }, + }; + // ----------------------------------------------------------------------------- + // YEAR.END + // ----------------------------------------------------------------------------- + const YEAR_END = { + description: _lt("Last day of the year a specific date falls in."), + args: args(` + date (date) ${_lt("The date from which to calculate the end of the year.")} + `), + returns: ["DATE"], + computeFormat: () => "m/d/yyyy", + compute: function (date) { + const year = YEAR.compute(date); + const jsDate = new Date(year + 1, 0, 0); + return jsDateToRoundNumber(jsDate); + }, + }; + + var date = /*#__PURE__*/Object.freeze({ + __proto__: null, + DATE: DATE, + DATEVALUE: DATEVALUE, + DAY: DAY, + DAYS: DAYS, + EDATE: EDATE, + EOMONTH: EOMONTH, + HOUR: HOUR, + ISOWEEKNUM: ISOWEEKNUM, + MINUTE: MINUTE, + MONTH: MONTH, + NETWORKDAYS: NETWORKDAYS, + NETWORKDAYS_INTL: NETWORKDAYS_INTL, + NOW: NOW, + SECOND: SECOND, + TIME: TIME, + TIMEVALUE: TIMEVALUE, + TODAY: TODAY, + WEEKDAY: WEEKDAY, + WEEKNUM: WEEKNUM, + WORKDAY: WORKDAY, + WORKDAY_INTL: WORKDAY_INTL, + YEAR: YEAR, + YEARFRAC: YEARFRAC, + MONTH_START: MONTH_START, + MONTH_END: MONTH_END, + QUARTER: QUARTER, + QUARTER_START: QUARTER_START, + QUARTER_END: QUARTER_END, + YEAR_START: YEAR_START, + YEAR_END: YEAR_END + }); + + const DEFAULT_DELTA_ARG = 0; + // ----------------------------------------------------------------------------- + // DELTA + // ----------------------------------------------------------------------------- + const DELTA = { + description: _lt("Compare two numeric values, returning 1 if they're equal."), + args: args(` + number1 (number) ${_lt("The first number to compare.")} + number2 (number, default=${DEFAULT_DELTA_ARG}) ${_lt("The second number to compare.")} + `), + returns: ["NUMBER"], + compute: function (number1, number2 = DEFAULT_DELTA_ARG) { + const _number1 = toNumber(number1); + const _number2 = toNumber(number2); + return _number1 === _number2 ? 1 : 0; + }, + isExported: true, + }; + + var engineering = /*#__PURE__*/Object.freeze({ + __proto__: null, + DELTA: DELTA + }); + + const DEFAULT_DAY_COUNT_CONVENTION = 0; + const DEFAULT_END_OR_BEGINNING = 0; + function newtonMethod(func, derivFunc, startValue, interMax, epsMax = 1e-10) { + let x = startValue; + let newX; + let xDelta; + let y; + let yEqual0 = false; + let count = 0; + do { + y = func(x); + newX = x - y / derivFunc(x); + xDelta = Math.abs(newX - x); + x = newX; + yEqual0 = xDelta < epsMax || Math.abs(y) < epsMax; + assert(() => count < interMax, _lt(`Function [[FUNCTION_NAME]] didn't find any result`)); + count++; + } while (!yEqual0); + return x; + } + // ----------------------------------------------------------------------------- + // DB + // ----------------------------------------------------------------------------- + const DB = { + description: _lt("Depreciation via declining balance method."), + args: args(` + cost (number) ${_lt("The initial cost of the asset.")} + salvage (number) ${_lt("The value of the asset at the end of depreciation.")} + life (number) ${_lt("The number of periods over which the asset is depreciated.")} + period (number) ${_lt("The single period within life for which to calculate depreciation.")} + month (number, optional) ${_lt("The number of months in the first year of depreciation.")} + `), + returns: ["NUMBER"], + // to do: replace by dollar format + computeFormat: () => "#,##0.00", + compute: function (cost, salvage, life, period, ...args) { + const _cost = toNumber(cost); + const _salvage = toNumber(salvage); + const _life = toNumber(life); + const _period = Math.trunc(toNumber(period)); + const _month = args.length ? Math.trunc(toNumber(args[0])) : 12; + const lifeLimit = _life + (_month === 12 ? 0 : 1); + assert(() => _cost > 0, _lt("The cost (%s) must be strictly positive.", _cost.toString())); + assert(() => _salvage >= 0, _lt("The salvage (%s) must be positive or null.", _salvage.toString())); + assert(() => _life > 0, _lt("The life (%s) must be strictly positive.", _life.toString())); + assert(() => _period > 0, _lt("The period (%s) must be strictly positive.", _period.toString())); + assert(() => 1 <= _month && _month <= 12, _lt("The month (%s) must be between 1 and 12 inclusive.", _month.toString())); + assert(() => _period <= lifeLimit, _lt("The period (%s) must be less than or equal to %s.", _period.toString(), lifeLimit.toString())); + const monthPart = _month / 12; + let rate = 1 - Math.pow(_salvage / _cost, 1 / _life); + // round to 3 decimal places + rate = Math.round(rate * 1000) / 1000; + let before = _cost; + let after = _cost * (1 - rate * monthPart); + for (let i = 1; i < _period; i++) { + before = after; + after = before * (1 - rate); + if (i === _life) { + after = before * (1 - rate * (1 - monthPart)); + } + } + return before - after; + }, + }; + // ----------------------------------------------------------------------------- + // DURATION + // ----------------------------------------------------------------------------- + const DURATION = { + description: _lt("Number of periods for an investment to reach a value."), + args: args(` + settlement (date) ${_lt("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")} + maturity (date) ${_lt("The maturity or end date of the security, when it can be redeemed at face, or par value.")} + rate (number) ${_lt("The annualized rate of interest.")} + yield (number) ${_lt("The expected annual yield of the security.")} + frequency (number) ${_lt("The number of interest or coupon payments per year (1, 2, or 4).")} + day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} ) ${_lt("An indicator of what day count method to use.")} + `), + returns: ["NUMBER"], + compute: function (settlement, maturity, rate, securityYield, frequency, dayCountConvention = DEFAULT_DAY_COUNT_CONVENTION) { + dayCountConvention = dayCountConvention || 0; + const start = Math.trunc(toNumber(settlement)); + const end = Math.trunc(toNumber(maturity)); + const _rate = toNumber(rate); + const _yield = toNumber(securityYield); + const _frequency = Math.trunc(toNumber(frequency)); + const _dayCountConvention = Math.trunc(toNumber(dayCountConvention)); + assert(() => start < end, _lt("The maturity (%s) must be strictly greater than the settlement (%s).", end.toString(), start.toString())); + assert(() => _rate >= 0, _lt("The rate (%s) must be positive or null.", _rate.toString())); + assert(() => _yield >= 0, _lt("The yield (%s) must be positive or null.", _yield.toString())); + assert(() => [1, 2, 4].includes(_frequency), _lt("The frequency (%s) must be one of %s", _frequency.toString(), [1, 2, 4].toString())); + assert(() => 0 <= _dayCountConvention && _dayCountConvention <= 4, _lt("The day_count_convention (%s) must be between 0 and 4 inclusive.", _dayCountConvention.toString())); + const years = YEARFRAC.compute(start, end, _dayCountConvention); + const timeFirstYear = years - Math.trunc(years) || 1 / _frequency; + const nbrCoupons = Math.ceil(years * _frequency); + // The DURATION function return the Macaulay duration + // See example: https://en.wikipedia.org/wiki/Bond_duration#Formulas + const cashFlowFromCoupon = _rate / _frequency; + const yieldPerPeriod = _yield / _frequency; + let count = 0; + let sum = 0; + for (let i = 1; i <= nbrCoupons; i++) { + const cashFlowPerPeriod = cashFlowFromCoupon + (i === nbrCoupons ? 1 : 0); + const presentValuePerPeriod = cashFlowPerPeriod / (1 + yieldPerPeriod) ** i; + sum += (timeFirstYear + (i - 1) / _frequency) * presentValuePerPeriod; + count += presentValuePerPeriod; + } + return count === 0 ? 0 : sum / count; + }, + }; + // ----------------------------------------------------------------------------- + // FV + // ----------------------------------------------------------------------------- + const DEFAULT_PRESENT_VALUE = 0; + const FV = { + description: _lt("Future value of an annuity investment."), + args: args(` + rate (number) ${_lt("The interest rate.")} + number_of_periods (number) ${_lt("The number of payments to be made.")} + payment_amount (number) ${_lt("The amount per period to be paid.")} + present_value (number, default=${DEFAULT_PRESENT_VALUE}) ${_lt("The current value of the annuity.")} + end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING}) ${_lt("Whether payments are due at the end (0) or beginning (1) of each period.")} + `), + returns: ["NUMBER"], + // to do: replace by dollar format + computeFormat: () => "#,##0.00", + compute: function (rate, numberOfPeriods, paymentAmount, presentValue = DEFAULT_PRESENT_VALUE, endOrBeginning = DEFAULT_END_OR_BEGINNING) { + presentValue = presentValue || 0; + endOrBeginning = endOrBeginning || 0; + const r = toNumber(rate); + const n = toNumber(numberOfPeriods); + const p = toNumber(paymentAmount); + const pv = toNumber(presentValue); + const type = toBoolean(endOrBeginning) ? 1 : 0; + return r ? -pv * (1 + r) ** n - (p * (1 + r * type) * ((1 + r) ** n - 1)) / r : -(pv + p * n); + }, + }; + // ----------------------------------------------------------------------------- + // IRR + // ----------------------------------------------------------------------------- + const DEFAULT_RATE_GUESS = 0.1; + const IRR = { + description: _lt("Internal rate of return given periodic cashflows."), + args: args(` + cashflow_amounts (number, range) ${_lt("An array or range containing the income or payments associated with the investment.")} + rate_guess (number, default=${DEFAULT_RATE_GUESS}) ${_lt("An estimate for what the internal rate of return will be.")} + `), + returns: ["NUMBER"], + computeFormat: () => "0%", + compute: function (cashFlowAmounts, rateGuess = DEFAULT_RATE_GUESS) { + const _rateGuess = toNumber(rateGuess); + assert(() => _rateGuess > -1, _lt("The rate_guess (%s) must be strictly greater than -1.", _rateGuess.toString())); + // check that values contains at least one positive value and one negative value + // and extract number present in the cashFlowAmount argument + let positive = false; + let negative = false; + let amounts = []; + visitNumbers([cashFlowAmounts], (amount) => { + if (amount > 0) + positive = true; + if (amount < 0) + negative = true; + amounts.push(amount); + }); + assert(() => positive && negative, _lt("The cashflow_amounts must include negative and positive values.")); + const firstAmount = amounts.shift(); + // The result of IRR is the rate at which the NPV() function will return zero with the given values. + // This algorithm uses the Newton's method on the NPV function to determine the result + // Newton's method: https://en.wikipedia.org/wiki/Newton%27s_method + // As the NPV function isn't continuous, we apply the Newton's method on the numerator of the NPV formula. + function npvNumerator(rate, startValue, values) { + const nbrValue = values.length; + let i = 0; + return values.reduce((acc, v) => { + i++; + return acc + v * rate ** (nbrValue - i); + }, startValue * rate ** nbrValue); + } + function npvNumeratorDeriv(rate, startValue, values) { + const nbrValue = values.length; + let i = 0; + return values.reduce((acc, v) => { + i++; + return acc + v * (nbrValue - i) * rate ** (nbrValue - i - 1); + }, startValue * nbrValue * rate ** (nbrValue - 1)); + } + function func(x) { + return npvNumerator(x, firstAmount, amounts); + } + function derivFunc(x) { + return npvNumeratorDeriv(x, firstAmount, amounts); + } + return newtonMethod(func, derivFunc, _rateGuess + 1, 20, 1e-5) - 1; + }, + }; + // ----------------------------------------------------------------------------- + // MDURATION + // ----------------------------------------------------------------------------- + const MDURATION = { + description: _lt("Modified Macaulay duration."), + args: args(` + settlement (date) ${_lt("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")} + maturity (date) ${_lt("The maturity or end date of the security, when it can be redeemed at face, or par value.")} + rate (number) ${_lt("The annualized rate of interest.")} + yield (number) ${_lt("The expected annual yield of the security.")} + frequency (number) ${_lt("The number of interest or coupon payments per year (1, 2, or 4).")} + day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} ) ${_lt("An indicator of what day count method to use.")} + `), + returns: ["NUMBER"], + compute: function (settlement, maturity, rate, securityYield, frequency, dayCountConvention = DEFAULT_DAY_COUNT_CONVENTION) { + const duration = DURATION.compute(settlement, maturity, rate, securityYield, frequency, dayCountConvention); + const y = toNumber(securityYield); + const k = Math.trunc(toNumber(frequency)); + return duration / (1 + y / k); + }, + }; + // ----------------------------------------------------------------------------- + // NPV + // ----------------------------------------------------------------------------- + function npvResult(r, startValue, values) { + let i = 0; + return reduceNumbers(values, (acc, v) => { + i++; + return acc + v / (1 + r) ** i; + }, startValue); + } + const NPV = { + description: _lt("The net present value of an investment based on a series of periodic cash flows and a discount rate."), + args: args(` + discount (number) ${_lt("The discount rate of the investment over one period.")} + cashflow1 (number, range) ${_lt("The first future cash flow.")} + cashflow2 (number, range, repeating) ${_lt("Additional future cash flows.")} + `), + returns: ["NUMBER"], + // to do: replace by dollar format + computeFormat: () => "#,##0.00", + compute: function (discount, ...values) { + const _discount = toNumber(discount); + assert(() => _discount !== -1, _lt("The discount (%s) must be different from -1.", _discount.toString())); + return npvResult(_discount, 0, values); + }, + }; + // ----------------------------------------------------------------------------- + // PDURATION + // ----------------------------------------------------------------------------- + const PDURATION = { + description: _lt("Computes the number of periods needed for an investment to reach a value."), + args: args(` + rate (number) ${_lt("The rate at which the investment grows each period.")} + present_value (number) ${_lt("The investment's current value.")} + future_value (number) ${_lt("The investment's desired future value.")} + `), + returns: ["NUMBER"], + compute: function (rate, presentValue, futureValue) { + const _rate = toNumber(rate); + const _presentValue = toNumber(presentValue); + const _futureValue = toNumber(futureValue); + assert(() => _rate > 0, _lt("The rate (%s) must be strictly positive.", _rate.toString())); + assert(() => _presentValue > 0, _lt("The present_value (%s) must be strictly positive.", _presentValue.toString())); + assert(() => _futureValue > 0, _lt("The future_value (%s) must be strictly positive.", _futureValue.toString())); + return (Math.log(_futureValue) - Math.log(_presentValue)) / Math.log(1 + _rate); + }, + }; + // ----------------------------------------------------------------------------- + // PV + // ----------------------------------------------------------------------------- + const DEFAULT_FUTURE_VALUE = 0; + const PV = { + description: _lt("Present value of an annuity investment."), + args: args(` + rate (number) ${_lt("The interest rate.")} + number_of_periods (number) ${_lt("The number of payments to be made.")} + payment_amount (number) ${_lt("The amount per period to be paid.")} + future_value (number, default=${DEFAULT_FUTURE_VALUE}) ${_lt("The future value remaining after the final payment has been made.")} + end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING}) ${_lt("Whether payments are due at the end (0) or beginning (1) of each period.")} + `), + returns: ["NUMBER"], + // to do: replace by dollar format + computeFormat: () => "#,##0.00", + compute: function (rate, numberOfPeriods, paymentAmount, futureValue = DEFAULT_FUTURE_VALUE, endOrBeginning = DEFAULT_END_OR_BEGINNING) { + futureValue = futureValue || 0; + endOrBeginning = endOrBeginning || 0; + const r = toNumber(rate); + const n = toNumber(numberOfPeriods); + const p = toNumber(paymentAmount); + const fv = toNumber(futureValue); + const type = toBoolean(endOrBeginning) ? 1 : 0; + return r ? -((p * (1 + r * type) * ((1 + r) ** n - 1)) / r + fv) / (1 + r) ** n : -(fv + p * n); + }, + }; + // ----------------------------------------------------------------------------- + // PRICE + // ----------------------------------------------------------------------------- + const PRICE = { + description: _lt("Price of a security paying periodic interest."), + args: args(` + settlement (date) ${_lt("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")} + maturity (date) ${_lt("The maturity or end date of the security, when it can be redeemed at face, or par value.")} + rate (number) ${_lt("The annualized rate of interest.")} + yield (number) ${_lt("The expected annual yield of the security.")} + redemption (number) ${_lt("The redemption amount per 100 face value, or par.")} + frequency (number) ${_lt("The number of interest or coupon payments per year (1, 2, or 4).")} + day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} ) ${_lt("An indicator of what day count method to use.")} + `), + returns: ["NUMBER"], + compute: function (settlement, maturity, rate, securityYield, redemption, frequency, dayCountConvention = DEFAULT_DAY_COUNT_CONVENTION) { + dayCountConvention = dayCountConvention || 0; + const _settlement = Math.trunc(toNumber(settlement)); + const _maturity = Math.trunc(toNumber(maturity)); + const _rate = toNumber(rate); + const _yield = toNumber(securityYield); + const _redemption = toNumber(redemption); + const _frequency = Math.trunc(toNumber(frequency)); + const _dayCountConvention = Math.trunc(toNumber(dayCountConvention)); + assert(() => _maturity > _settlement, _lt("The maturity (%s) must be strictly greater than the settlement (%s).", _maturity.toString(), _settlement.toString())); + assert(() => _rate >= 0, _lt("The rate (%s) must be positive or null.", _rate.toString())); + assert(() => _yield >= 0, _lt("The yield (%s) must be positive or null.", _yield.toString())); + assert(() => _redemption > 0, _lt("The redemption (%s) must be strictly positive.", _redemption.toString())); + assert(() => [1, 2, 4].includes(_frequency), _lt("The frequency (%s) must be one of %s.", _frequency.toString(), [1, 2, 4].toString())); + assert(() => 0 <= _dayCountConvention && _dayCountConvention <= 4, _lt("The day_count_convention (%s) must be between 0 and 4 inclusive.", _dayCountConvention.toString())); + const years = YEARFRAC.compute(_settlement, _maturity, _dayCountConvention); + const nbrRealCoupons = years * _frequency; + const nbrFullCoupons = Math.ceil(nbrRealCoupons); + const timeFirstCoupon = nbrRealCoupons - Math.floor(nbrRealCoupons) || 1; + const yieldFactorPerPeriod = 1 + _yield / _frequency; + const cashFlowFromCoupon = (100 * _rate) / _frequency; + if (nbrFullCoupons === 1) { + return ((cashFlowFromCoupon + _redemption) / ((timeFirstCoupon * _yield) / _frequency + 1) - + cashFlowFromCoupon * (1 - timeFirstCoupon)); + } + let cashFlowsPresentValue = 0; + for (let i = 1; i <= nbrFullCoupons; i++) { + cashFlowsPresentValue += + cashFlowFromCoupon / yieldFactorPerPeriod ** (i - 1 + timeFirstCoupon); + } + const redemptionPresentValue = _redemption / yieldFactorPerPeriod ** (nbrFullCoupons - 1 + timeFirstCoupon); + return (redemptionPresentValue + cashFlowsPresentValue - cashFlowFromCoupon * (1 - timeFirstCoupon)); + }, + }; + // ----------------------------------------------------------------------------- + // YIELD + // ----------------------------------------------------------------------------- + const YIELD = { + description: _lt("Annual yield of a security paying periodic interest."), + args: args(` + settlement (date) ${_lt("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")} + maturity (date) ${_lt("The maturity or end date of the security, when it can be redeemed at face, or par value.")} + rate (number) ${_lt("The annualized rate of interest.")} + price (number) ${_lt("The price at which the security is bought per 100 face value.")} + redemption (number) ${_lt("The redemption amount per 100 face value, or par.")} + frequency (number) ${_lt("The number of interest or coupon payments per year (1, 2, or 4).")} + day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} ) ${_lt("An indicator of what day count method to use.")} + `), + returns: ["NUMBER"], + compute: function (settlement, maturity, rate, price, redemption, frequency, dayCountConvention = DEFAULT_DAY_COUNT_CONVENTION) { + dayCountConvention = dayCountConvention || 0; + const _settlement = Math.trunc(toNumber(settlement)); + const _maturity = Math.trunc(toNumber(maturity)); + const _rate = toNumber(rate); + const _price = toNumber(price); + const _redemption = toNumber(redemption); + const _frequency = Math.trunc(toNumber(frequency)); + const _dayCountConvention = Math.trunc(toNumber(dayCountConvention)); + assert(() => _maturity > _settlement, _lt("The maturity (%s) must be strictly greater than the settlement (%s).", _maturity.toString(), _settlement.toString())); + assert(() => _rate >= 0, _lt("The rate (%s) must be positive or null.", _rate.toString())); + assert(() => _price > 0, _lt("The price (%s) must be strictly positive.", _price.toString())); + assert(() => _redemption > 0, _lt("The redemption (%s) must be strictly positive.", _redemption.toString())); + assert(() => [1, 2, 4].includes(_frequency), _lt("The frequency (%s) must be one of %s.", _frequency.toString(), [1, 2, 4].toString())); + assert(() => 0 <= _dayCountConvention && _dayCountConvention <= 4, _lt("The day_count_convention (%s) must between 0 and 4 inclusive.", _dayCountConvention.toString())); + const years = YEARFRAC.compute(_settlement, _maturity, _dayCountConvention); + const nbrRealCoupons = years * _frequency; + const nbrFullCoupons = Math.ceil(nbrRealCoupons); + const timeFirstCoupon = nbrRealCoupons - Math.floor(nbrRealCoupons) || 1; + const cashFlowFromCoupon = (100 * _rate) / _frequency; + if (nbrFullCoupons === 1) { + const subPart = _price + cashFlowFromCoupon * (1 - timeFirstCoupon); + return (((_redemption + cashFlowFromCoupon - subPart) * _frequency * (1 / timeFirstCoupon)) / + subPart); + } + // The result of YIELD function is the yield at which the PRICE function will return the given price. + // This algorithm uses the Newton's method on the PRICE function to determine the result. + // Newton's method: https://en.wikipedia.org/wiki/Newton%27s_method + // As the PRICE function isn't continuous, we apply the Newton's method on the numerator of the PRICE formula. + // For simplicity, it is not yield but yieldFactorPerPeriod (= 1 + yield / frequency) which will be calibrated in Newton's method. + // yield can be deduced from yieldFactorPerPeriod in sequence. + function priceNumerator(price, timeFirstCoupon, nbrFullCoupons, yieldFactorPerPeriod, cashFlowFromCoupon, redemption) { + let result = redemption - + (price + cashFlowFromCoupon * (1 - timeFirstCoupon)) * + yieldFactorPerPeriod ** (nbrFullCoupons - 1 + timeFirstCoupon); + for (let i = 1; i <= nbrFullCoupons; i++) { + result += cashFlowFromCoupon * yieldFactorPerPeriod ** (i - 1); + } + return result; + } + function priceNumeratorDeriv(price, timeFirstCoupon, nbrFullCoupons, yieldFactorPerPeriod, cashFlowFromCoupon) { + let result = -(price + cashFlowFromCoupon * (1 - timeFirstCoupon)) * + (nbrFullCoupons - 1 + timeFirstCoupon) * + yieldFactorPerPeriod ** (nbrFullCoupons - 2 + timeFirstCoupon); + for (let i = 1; i <= nbrFullCoupons; i++) { + result += cashFlowFromCoupon * (i - 1) * yieldFactorPerPeriod ** (i - 2); + } + return result; + } + function func(x) { + return priceNumerator(_price, timeFirstCoupon, nbrFullCoupons, x, cashFlowFromCoupon, _redemption); + } + function derivFunc(x) { + return priceNumeratorDeriv(_price, timeFirstCoupon, nbrFullCoupons, x, cashFlowFromCoupon); + } + const initYield = _rate + 1; + const initYieldFactorPerPeriod = 1 + initYield / _frequency; + const methodResult = newtonMethod(func, derivFunc, initYieldFactorPerPeriod, 100, 1e-5); + return (methodResult - 1) * _frequency; + }, + }; + // ----------------------------------------------------------------------------- + // YIELDMAT + // ----------------------------------------------------------------------------- + const YIELDMAT = { + description: _lt("Annual yield of a security paying interest at maturity."), + args: args(` + settlement (date) ${_lt("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")} + maturity (date) ${_lt("The maturity or end date of the security, when it can be redeemed at face, or par value.")} + issue (date) ${_lt("The date the security was initially issued.")} + rate (number) ${_lt("The annualized rate of interest.")} + price (number) ${_lt("The price at which the security is bought.")} + day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} ) ${_lt("An indicator of what day count method to use.")} + `), + returns: ["NUMBER"], + compute: function (settlement, maturity, issue, rate, price, dayCountConvention = DEFAULT_DAY_COUNT_CONVENTION) { + dayCountConvention = dayCountConvention || 0; + const _settlement = Math.trunc(toNumber(settlement)); + const _maturity = Math.trunc(toNumber(maturity)); + const _issue = Math.trunc(toNumber(issue)); + const _rate = toNumber(rate); + const _price = toNumber(price); + const _dayCountConvention = Math.trunc(toNumber(dayCountConvention)); + assert(() => _settlement >= _issue, _lt("The settlement (%s) must be greater than or equal to the issue (%s).", _settlement.toString(), _issue.toString())); + assert(() => _maturity > _settlement, _lt("The maturity (%s) must be strictly greater than the settlement (%s).", _maturity.toString(), _settlement.toString())); + assert(() => _rate >= 0, _lt("The rate (%s) must be positive or null.", _rate.toString())); + assert(() => _price > 0, _lt("The price (%s) must be strictly positive.", _price.toString())); + assert(() => 0 <= _dayCountConvention && _dayCountConvention <= 4, _lt("The day_count_convention (%s) must be between 0 and 4 inclusive.", _dayCountConvention.toString())); + const issueToMaturity = YEARFRAC.compute(_issue, _maturity, _dayCountConvention); + const issueToSettlement = YEARFRAC.compute(_issue, _settlement, _dayCountConvention); + const settlementToMaturity = YEARFRAC.compute(_settlement, _maturity, _dayCountConvention); + const numerator = (100 * (1 + _rate * issueToMaturity)) / (_price + 100 * _rate * issueToSettlement) - 1; + return numerator / settlementToMaturity; + }, + }; + + var financial = /*#__PURE__*/Object.freeze({ + __proto__: null, + DB: DB, + DURATION: DURATION, + FV: FV, + IRR: IRR, + MDURATION: MDURATION, + NPV: NPV, + PDURATION: PDURATION, + PV: PV, + PRICE: PRICE, + YIELD: YIELD, + YIELDMAT: YIELDMAT + }); + + // ----------------------------------------------------------------------------- + // ISERR + // ----------------------------------------------------------------------------- + const ISERR = { + description: _lt("Whether a value is an error other than #N/A."), + args: args(`value (any, lazy) ${_lt("The value to be verified as an error type.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + try { + value(); + return false; + } + catch (e) { + return (e === null || e === void 0 ? void 0 : e.errorType) != CellErrorType.NotAvailable; + } + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISERROR + // ----------------------------------------------------------------------------- + const ISERROR = { + description: _lt("Whether a value is an error."), + args: args(`value (any, lazy) ${_lt("The value to be verified as an error type.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + try { + value(); + return false; + } + catch (e) { + return true; + } + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISLOGICAL + // ----------------------------------------------------------------------------- + const ISLOGICAL = { + description: _lt("Whether a value is `true` or `false`."), + args: args(`value (any) ${_lt("The value to be verified as a logical TRUE or FALSE.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + return typeof value === "boolean"; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISNA + // ----------------------------------------------------------------------------- + const ISNA = { + description: _lt("Whether a value is the error #N/A."), + args: args(`value (any, lazy) ${_lt("The value to be verified as an error type.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + try { + value(); + return false; + } + catch (e) { + return (e === null || e === void 0 ? void 0 : e.errorType) == CellErrorType.NotAvailable; + } + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISNONTEXT + // ----------------------------------------------------------------------------- + const ISNONTEXT = { + description: _lt("Whether a value is non-textual."), + args: args(`value (any) ${_lt("The value to be checked.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + return typeof value !== "string"; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISNUMBER + // ----------------------------------------------------------------------------- + const ISNUMBER = { + description: _lt("Whether a value is a number."), + args: args(`value (any) ${_lt("The value to be verified as a number.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + return typeof value === "number"; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ISTEXT + // ----------------------------------------------------------------------------- + const ISTEXT = { + description: _lt("Whether a value is text."), + args: args(`value (any) ${_lt("The value to be verified as text.")}`), + returns: ["BOOLEAN"], + compute: function (value) { + return typeof value === "string"; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // NA + // ----------------------------------------------------------------------------- + const NA = { + description: _lt("Returns the error value #N/A."), + args: args(``), + returns: ["BOOLEAN"], + compute: function (value) { + throw new NotAvailableError(); + }, + isExported: true, + }; + + var info = /*#__PURE__*/Object.freeze({ + __proto__: null, + ISERR: ISERR, + ISERROR: ISERROR, + ISLOGICAL: ISLOGICAL, + ISNA: ISNA, + ISNONTEXT: ISNONTEXT, + ISNUMBER: ISNUMBER, + ISTEXT: ISTEXT, + NA: NA + }); + + // ----------------------------------------------------------------------------- + // AND + // ----------------------------------------------------------------------------- + const AND = { + description: _lt("Logical `and` operator."), + args: args(` + logical_expression1 (boolean, range) ${_lt("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE, or an expression that can be coerced to a logical value.")} + logical_expression2 (boolean, range, repeating) ${_lt("More expressions that represent logical values.")} + `), + returns: ["BOOLEAN"], + compute: function (...logicalExpressions) { + let foundBoolean = false; + let acc = true; + conditionalVisitBoolean(logicalExpressions, (arg) => { + foundBoolean = true; + acc = acc && arg; + return acc; + }); + assert(() => foundBoolean, _lt(`[[FUNCTION_NAME]] has no valid input data.`)); + return acc; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // IF + // ----------------------------------------------------------------------------- + const IF = { + description: _lt("Returns value depending on logical expression."), + args: args(` + logical_expression (boolean) ${_lt("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE.")} + value_if_true (any, lazy) ${_lt("The value the function returns if logical_expression is TRUE.")} + value_if_false (any, lazy, default=FALSE) ${_lt("The value the function returns if logical_expression is FALSE.")} + `), + returns: ["ANY"], + compute: function (logicalExpression, valueIfTrue, valueIfFalse = () => false) { + const result = toBoolean(logicalExpression) ? valueIfTrue() : valueIfFalse(); + return result === null || result === undefined ? "" : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // IFERROR + // ----------------------------------------------------------------------------- + const IFERROR = { + description: _lt("Value if it is not an error, otherwise 2nd argument."), + args: args(` + value (any, lazy) ${_lt("The value to return if value itself is not an error.")} + value_if_error (any, lazy, default=${_lt("An empty value")}) ${_lt("The value the function returns if value is an error.")} + `), + returns: ["ANY"], + computeFormat: (value, valueIfError = () => ({ value: "" })) => { + var _a; + try { + return value().format; + } + catch (e) { + return (_a = valueIfError()) === null || _a === void 0 ? void 0 : _a.format; + } + }, + compute: function (value, valueIfError = () => "") { + let result; + try { + result = value(); + } + catch (e) { + result = valueIfError(); + } + return result === null || result === undefined ? "" : result; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // IFS + // ----------------------------------------------------------------------------- + const IFS = { + description: _lt("Returns a value depending on multiple logical expressions."), + args: args(` + condition1 (boolean, lazy) ${_lt("The first condition to be evaluated. This can be a boolean, a number, an array, or a reference to any of those.")} + value1 (any, lazy) ${_lt("The returned value if condition1 is TRUE.")} + condition2 (boolean, lazy, repeating) ${_lt("Additional conditions to be evaluated if the previous ones are FALSE.")} + value2 (any, lazy, repeating) ${_lt("Additional values to be returned if their corresponding conditions are TRUE.")} + `), + returns: ["ANY"], + compute: function (...values) { + assert(() => values.length % 2 === 0, _lt(`Wrong number of arguments. Expected an even number of arguments.`)); + for (let n = 0; n < values.length - 1; n += 2) { + if (toBoolean(values[n]())) { + const returnValue = values[n + 1](); + return returnValue !== null ? returnValue : ""; + } + } + throw new Error(_lt(`No match.`)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // NOT + // ----------------------------------------------------------------------------- + const NOT = { + description: _lt("Returns opposite of provided logical value."), + args: args(`logical_expression (boolean) ${_lt("An expression or reference to a cell holding an expression that represents some logical value.")} + `), + returns: ["BOOLEAN"], + compute: function (logicalExpression) { + return !toBoolean(logicalExpression); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // OR + // ----------------------------------------------------------------------------- + const OR = { + description: _lt("Logical `or` operator."), + args: args(` + logical_expression1 (boolean, range) ${_lt("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE, or an expression that can be coerced to a logical value.")} + logical_expression2 (boolean, range, repeating) ${_lt("More expressions that evaluate to logical values.")} + `), + returns: ["BOOLEAN"], + compute: function (...logicalExpressions) { + let foundBoolean = false; + let acc = false; + conditionalVisitBoolean(logicalExpressions, (arg) => { + foundBoolean = true; + acc = acc || arg; + return !acc; + }); + assert(() => foundBoolean, _lt(`[[FUNCTION_NAME]] has no valid input data.`)); + return acc; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // XOR + // ----------------------------------------------------------------------------- + const XOR = { + description: _lt("Logical `xor` operator."), + args: args(` + logical_expression1 (boolean, range) ${_lt("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE, or an expression that can be coerced to a logical value.")} + logical_expression2 (boolean, range, repeating) ${_lt("More expressions that evaluate to logical values.")} + `), + returns: ["BOOLEAN"], + compute: function (...logicalExpressions) { + let foundBoolean = false; + let acc = false; + conditionalVisitBoolean(logicalExpressions, (arg) => { + foundBoolean = true; + acc = acc ? !arg : arg; + return true; // no stop condition + }); + assert(() => foundBoolean, _lt(`[[FUNCTION_NAME]] has no valid input data.`)); + return acc; + }, + isExported: true, + }; + + var logical = /*#__PURE__*/Object.freeze({ + __proto__: null, + AND: AND, + IF: IF, + IFERROR: IFERROR, + IFS: IFS, + NOT: NOT, + OR: OR, + XOR: XOR + }); + + const DEFAULT_IS_SORTED = true; + /** + * Perform a linear search and return the index of the perfect match. + * -1 is returned if no value is found. + * + * Example: + * - [3, 6, 10], 3 => 0 + * - [3, 6, 10], 6 => 1 + * - [3, 6, 10], 9 => -1 + * - [3, 6, 10], 2 => -1 + */ + function linearSearch(range, target) { + for (let i = 0; i < range.length; i++) { + if (range[i] === target) { + return i; + } + } + // no value is found, -1 is returned + return -1; + } + // ----------------------------------------------------------------------------- + // COLUMN + // ----------------------------------------------------------------------------- + const COLUMN = { + description: _lt("Column number of a specified cell."), + args: args(`cell_reference (meta, default=${_lt("The cell in which the formula is entered")}) ${_lt("The cell whose column number will be returned. Column A corresponds to 1.")} + `), + returns: ["NUMBER"], + compute: function (cellReference) { + var _a; + const _cellReference = cellReference || ((_a = this.__originCellXC) === null || _a === void 0 ? void 0 : _a.call(this)); + assert(() => !!_cellReference, "In this context, the function [[FUNCTION_NAME]] needs to have a cell or range in parameter."); + const zone = toZone(_cellReference); + return zone.left + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // COLUMNS + // ----------------------------------------------------------------------------- + const COLUMNS = { + description: _lt("Number of columns in a specified array or range."), + args: args(`range (meta) ${_lt("The range whose column count will be returned.")}`), + returns: ["NUMBER"], + compute: function (range) { + const zone = toZone(range); + return zone.right - zone.left + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // HLOOKUP + // ----------------------------------------------------------------------------- + const HLOOKUP = { + description: _lt(`Horizontal lookup`), + args: args(` + search_key (any) ${_lt("The value to search for. For example, 42, 'Cats', or I24.")} + range (range) ${_lt("The range to consider for the search. The first row in the range is searched for the key specified in search_key.")} + index (number) ${_lt("The row index of the value to be returned, where the first row in range is numbered 1.")} + is_sorted (boolean, default=${DEFAULT_IS_SORTED}) ${_lt("Indicates whether the row to be searched (the first row of the specified range) is sorted, in which case the closest match for search_key will be returned.")} + `), + returns: ["ANY"], + compute: function (searchKey, range, index, isSorted = DEFAULT_IS_SORTED) { + const _index = Math.trunc(toNumber(index)); + assert(() => 1 <= _index && _index <= range[0].length, _lt("[[FUNCTION_NAME]] evaluates to an out of bounds range.")); + const _isSorted = toBoolean(isSorted); + const firstRow = range.map((col) => col[0]); + let colIndex; + if (_isSorted) { + colIndex = dichotomicPredecessorSearch(firstRow, searchKey); + } + else { + colIndex = linearSearch(firstRow, searchKey); + } + assert(() => colIndex > -1, _lt("Did not find value '%s' in [[FUNCTION_NAME]] evaluation.", toString(searchKey))); + return range[colIndex][_index - 1]; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // LOOKUP + // ----------------------------------------------------------------------------- + const LOOKUP = { + description: _lt(`Look up a value.`), + args: args(` + search_key (any) ${_lt("The value to search for. For example, 42, 'Cats', or I24.")} + search_array (range) ${_lt("One method of using this function is to provide a single sorted row or column search_array to look through for the search_key with a second argument result_range. The other way is to combine these two arguments into one search_array where the first row or column is searched and a value is returned from the last row or column in the array. If search_key is not found, a non-exact match may be returned.")} + result_range (range, optional) ${_lt("The range from which to return a result. The value returned corresponds to the location where search_key is found in search_range. This range must be only a single row or column and should not be used if using the search_result_array method.")} + `), + returns: ["ANY"], + compute: function (searchKey, searchArray, resultRange) { + let nbCol = searchArray.length; + let nbRow = searchArray[0].length; + const verticalSearch = nbRow >= nbCol; + const searchRange = verticalSearch ? searchArray[0] : searchArray.map((c) => c[0]); + const index = dichotomicPredecessorSearch(searchRange, searchKey); + assert(() => index >= 0, _lt("Did not find value '%s' in [[FUNCTION_NAME]] evaluation.", toString(searchKey))); + if (resultRange === undefined) { + return (verticalSearch ? searchArray[nbCol - 1][index] : searchArray[index][nbRow - 1]); + } + nbCol = resultRange.length; + nbRow = resultRange[0].length; + assert(() => nbCol === 1 || nbRow === 1, _lt("The result_range must be a single row or a single column.")); + if (nbCol > 1) { + assert(() => index <= nbCol - 1, _lt("[[FUNCTION_NAME]] evaluates to an out of range row value %s.", (index + 1).toString())); + return resultRange[index][0]; + } + assert(() => index <= nbRow - 1, _lt("[[FUNCTION_NAME]] evaluates to an out of range column value %s.", (index + 1).toString())); + return resultRange[0][index]; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // MATCH + // ----------------------------------------------------------------------------- + const DEFAULT_SEARCH_TYPE = 1; + const MATCH = { + description: _lt(`Position of item in range that matches value.`), + args: args(` + search_key (any) ${_lt("The value to search for. For example, 42, 'Cats', or I24.")} + range (any, range) ${_lt("The one-dimensional array to be searched.")} + search_type (number, default=${DEFAULT_SEARCH_TYPE}) ${_lt("The search method. 1 (default) finds the largest value less than or equal to search_key when range is sorted in ascending order. 0 finds the exact value when range is unsorted. -1 finds the smallest value greater than or equal to search_key when range is sorted in descending order.")} + `), + returns: ["NUMBER"], + compute: function (searchKey, range, searchType = DEFAULT_SEARCH_TYPE) { + let _searchType = toNumber(searchType); + const nbCol = range.length; + const nbRow = range[0].length; + assert(() => nbCol === 1 || nbRow === 1, _lt("The range must be a single row or a single column.")); + let index = -1; + const _range = range.flat(); + _searchType = Math.sign(_searchType); + switch (_searchType) { + case 1: + index = dichotomicPredecessorSearch(_range, searchKey); + break; + case 0: + index = linearSearch(_range, searchKey); + break; + case -1: + index = dichotomicSuccessorSearch(_range, searchKey); + break; + } + assert(() => index >= 0, _lt("Did not find value '%s' in [[FUNCTION_NAME]] evaluation.", toString(searchKey))); + return index + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ROW + // ----------------------------------------------------------------------------- + const ROW = { + description: _lt("Row number of a specified cell."), + args: args(`cell_reference (meta, default=${_lt("The cell in which the formula is entered by default")}) ${_lt("The cell whose row number will be returned.")}`), + returns: ["NUMBER"], + compute: function (cellReference) { + var _a; + cellReference = cellReference || ((_a = this.__originCellXC) === null || _a === void 0 ? void 0 : _a.call(this)); + assert(() => !!cellReference, "In this context, the function [[FUNCTION_NAME]] needs to have a cell or range in parameter."); + const zone = toZone(cellReference); + return zone.top + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // ROWS + // ----------------------------------------------------------------------------- + const ROWS = { + description: _lt("Number of rows in a specified array or range."), + args: args(`range (meta) ${_lt("The range whose row count will be returned.")}`), + returns: ["NUMBER"], + compute: function (range) { + const zone = toZone(range); + return zone.bottom - zone.top + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // VLOOKUP + // ----------------------------------------------------------------------------- + const VLOOKUP = { + description: _lt(`Vertical lookup.`), + args: args(` + search_key (any) ${_lt("The value to search for. For example, 42, 'Cats', or I24.")} + range (any, range) ${_lt("The range to consider for the search. The first column in the range is searched for the key specified in search_key.")} + index (number) ${_lt("The column index of the value to be returned, where the first column in range is numbered 1.")} + is_sorted (boolean, default=${DEFAULT_IS_SORTED}) ${_lt("Indicates whether the column to be searched (the first column of the specified range) is sorted, in which case the closest match for search_key will be returned.")} + `), + returns: ["ANY"], + compute: function (searchKey, range, index, isSorted = DEFAULT_IS_SORTED) { + const _index = Math.trunc(toNumber(index)); + assert(() => 1 <= _index && _index <= range.length, _lt("[[FUNCTION_NAME]] evaluates to an out of bounds range.")); + const _isSorted = toBoolean(isSorted); + const firstCol = range[0]; + let rowIndex; + if (_isSorted) { + rowIndex = dichotomicPredecessorSearch(firstCol, searchKey); + } + else { + rowIndex = linearSearch(firstCol, searchKey); + } + assert(() => rowIndex > -1, _lt("Did not find value '%s' in [[FUNCTION_NAME]] evaluation.", toString(searchKey))); + return range[_index - 1][rowIndex]; + }, + isExported: true, + }; + + var lookup = /*#__PURE__*/Object.freeze({ + __proto__: null, + COLUMN: COLUMN, + COLUMNS: COLUMNS, + HLOOKUP: HLOOKUP, + LOOKUP: LOOKUP, + MATCH: MATCH, + ROW: ROW, + ROWS: ROWS, + VLOOKUP: VLOOKUP + }); + + // ----------------------------------------------------------------------------- + // ADD + // ----------------------------------------------------------------------------- + const ADD = { + description: _lt(`Sum of two numbers.`), + args: args(` + value1 (number) ${_lt("The first addend.")} + value2 (number) ${_lt("The second addend.")} + `), + returns: ["NUMBER"], + computeFormat: (value1, value2) => (value1 === null || value1 === void 0 ? void 0 : value1.format) || (value2 === null || value2 === void 0 ? void 0 : value2.format), + compute: function (value1, value2) { + return toNumber(value1) + toNumber(value2); + }, + }; + // ----------------------------------------------------------------------------- + // CONCAT + // ----------------------------------------------------------------------------- + const CONCAT = { + description: _lt(`Concatenation of two values.`), + args: args(` + value1 (string) ${_lt("The value to which value2 will be appended.")} + value2 (string) ${_lt("The value to append to value1.")} + `), + returns: ["STRING"], + compute: function (value1, value2) { + return toString(value1) + toString(value2); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // DIVIDE + // ----------------------------------------------------------------------------- + const DIVIDE = { + description: _lt(`One number divided by another.`), + args: args(` + dividend (number) ${_lt("The number to be divided.")} + divisor (number) ${_lt("The number to divide by.")} + `), + returns: ["NUMBER"], + computeFormat: (dividend, divisor) => (dividend === null || dividend === void 0 ? void 0 : dividend.format) || (divisor === null || divisor === void 0 ? void 0 : divisor.format), + compute: function (dividend, divisor) { + const _divisor = toNumber(divisor); + assert(() => _divisor !== 0, _lt("The divisor must be different from zero.")); + return toNumber(dividend) / _divisor; + }, + }; + // ----------------------------------------------------------------------------- + // EQ + // ----------------------------------------------------------------------------- + function isEmpty(value) { + return value === null || value === undefined; + } + const getNeutral = { number: 0, string: "", boolean: false }; + const EQ = { + description: _lt(`Equal.`), + args: args(` + value1 (any) ${_lt("The first value.")} + value2 (any) ${_lt("The value to test against value1 for equality.")} + `), + returns: ["BOOLEAN"], + compute: function (value1, value2) { + value1 = isEmpty(value1) ? getNeutral[typeof value2] : value1; + value2 = isEmpty(value2) ? getNeutral[typeof value1] : value2; + if (typeof value1 === "string") { + value1 = value1.toUpperCase(); + } + if (typeof value2 === "string") { + value2 = value2.toUpperCase(); + } + return value1 === value2; + }, + }; + // ----------------------------------------------------------------------------- + // GT + // ----------------------------------------------------------------------------- + function applyRelationalOperator(value1, value2, cb) { + value1 = isEmpty(value1) ? getNeutral[typeof value2] : value1; + value2 = isEmpty(value2) ? getNeutral[typeof value1] : value2; + if (typeof value1 !== "number") { + value1 = toString(value1).toUpperCase(); + } + if (typeof value2 !== "number") { + value2 = toString(value2).toUpperCase(); + } + const tV1 = typeof value1; + const tV2 = typeof value2; + if (tV1 === "string" && tV2 === "number") { + return true; + } + if (tV2 === "string" && tV1 === "number") { + return false; + } + return cb(value1, value2); + } + const GT = { + description: _lt(`Strictly greater than.`), + args: args(` + value1 (any) ${_lt("The value to test as being greater than value2.")} + value2 (any) ${_lt("The second value.")} + `), + returns: ["BOOLEAN"], + compute: function (value1, value2) { + return applyRelationalOperator(value1, value2, (v1, v2) => { + return v1 > v2; + }); + }, + }; + // ----------------------------------------------------------------------------- + // GTE + // ----------------------------------------------------------------------------- + const GTE = { + description: _lt(`Greater than or equal to.`), + args: args(` + value1 (any) ${_lt("The value to test as being greater than or equal to value2.")} + value2 (any) ${_lt("The second value.")} + `), + returns: ["BOOLEAN"], + compute: function (value1, value2) { + return applyRelationalOperator(value1, value2, (v1, v2) => { + return v1 >= v2; + }); + }, + }; + // ----------------------------------------------------------------------------- + // LT + // ----------------------------------------------------------------------------- + const LT = { + description: _lt(`Less than.`), + args: args(` + value1 (any) ${_lt("The value to test as being less than value2.")} + value2 (any) ${_lt("The second value.")} + `), + returns: ["BOOLEAN"], + compute: function (value1, value2) { + return !GTE.compute(value1, value2); + }, + }; + // ----------------------------------------------------------------------------- + // LTE + // ----------------------------------------------------------------------------- + const LTE = { + description: _lt(`Less than or equal to.`), + args: args(` + value1 (any) ${_lt("The value to test as being less than or equal to value2.")} + value2 (any) ${_lt("The second value.")} + `), + returns: ["BOOLEAN"], + compute: function (value1, value2) { + return !GT.compute(value1, value2); + }, + }; + // ----------------------------------------------------------------------------- + // MINUS + // ----------------------------------------------------------------------------- + const MINUS = { + description: _lt(`Difference of two numbers.`), + args: args(` + value1 (number) ${_lt("The minuend, or number to be subtracted from.")} + value2 (number) ${_lt("The subtrahend, or number to subtract from value1.")} + `), + returns: ["NUMBER"], + computeFormat: (value1, value2) => (value1 === null || value1 === void 0 ? void 0 : value1.format) || (value2 === null || value2 === void 0 ? void 0 : value2.format), + compute: function (value1, value2) { + return toNumber(value1) - toNumber(value2); + }, + }; + // ----------------------------------------------------------------------------- + // MULTIPLY + // ----------------------------------------------------------------------------- + const MULTIPLY = { + description: _lt(`Product of two numbers`), + args: args(` + factor1 (number) ${_lt("The first multiplicand.")} + factor2 (number) ${_lt("The second multiplicand.")} + `), + returns: ["NUMBER"], + computeFormat: (factor1, factor2) => (factor1 === null || factor1 === void 0 ? void 0 : factor1.format) || (factor2 === null || factor2 === void 0 ? void 0 : factor2.format), + compute: function (factor1, factor2) { + return toNumber(factor1) * toNumber(factor2); + }, + }; + // ----------------------------------------------------------------------------- + // NE + // ----------------------------------------------------------------------------- + const NE = { + description: _lt(`Not equal.`), + args: args(` + value1 (any) ${_lt("The first value.")} + value2 (any) ${_lt("The value to test against value1 for inequality.")} + `), + returns: ["BOOLEAN"], + compute: function (value1, value2) { + return !EQ.compute(value1, value2); + }, + }; + // ----------------------------------------------------------------------------- + // POW + // ----------------------------------------------------------------------------- + const POW = { + description: _lt(`A number raised to a power.`), + args: args(` + base (number) ${_lt("The number to raise to the exponent power.")} + exponent (number) ${_lt("The exponent to raise base to.")} + `), + returns: ["BOOLEAN"], + compute: function (base, exponent) { + return POWER.compute(base, exponent); + }, + }; + // ----------------------------------------------------------------------------- + // UMINUS + // ----------------------------------------------------------------------------- + const UMINUS = { + description: _lt(`A number with the sign reversed.`), + args: args(` + value (number) ${_lt("The number to have its sign reversed. Equivalently, the number to multiply by -1.")} + `), + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + returns: ["NUMBER"], + compute: function (value) { + return -toNumber(value); + }, + }; + // ----------------------------------------------------------------------------- + // UNARY_PERCENT + // ----------------------------------------------------------------------------- + const UNARY_PERCENT = { + description: _lt(`Value interpreted as a percentage.`), + args: args(` + percentage (number) ${_lt("The value to interpret as a percentage.")} + `), + returns: ["NUMBER"], + compute: function (percentage) { + return toNumber(percentage) / 100; + }, + }; + // ----------------------------------------------------------------------------- + // UPLUS + // ----------------------------------------------------------------------------- + const UPLUS = { + description: _lt(`A specified number, unchanged.`), + args: args(` + value (any) ${_lt("The number to return.")} + `), + returns: ["ANY"], + computeFormat: (value) => value === null || value === void 0 ? void 0 : value.format, + compute: function (value) { + return value === null ? "" : value; + }, + }; + + var operators = /*#__PURE__*/Object.freeze({ + __proto__: null, + ADD: ADD, + CONCAT: CONCAT, + DIVIDE: DIVIDE, + EQ: EQ, + GT: GT, + GTE: GTE, + LT: LT, + LTE: LTE, + MINUS: MINUS, + MULTIPLY: MULTIPLY, + NE: NE, + POW: POW, + UMINUS: UMINUS, + UNARY_PERCENT: UNARY_PERCENT, + UPLUS: UPLUS + }); + + const DEFAULT_STARTING_AT = 1; + // ----------------------------------------------------------------------------- + // CHAR + // ----------------------------------------------------------------------------- + const CHAR = { + description: _lt("Gets character associated with number."), + args: args(` + table_number (number) ${_lt("The number of the character to look up from the current Unicode table in decimal format.")} + `), + returns: ["STRING"], + compute: function (tableNumber) { + const _tableNumber = Math.trunc(toNumber(tableNumber)); + assert(() => _tableNumber >= 1, _lt("The table_number (%s) is out of range.", _tableNumber.toString())); + return String.fromCharCode(_tableNumber); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // CONCATENATE + // ----------------------------------------------------------------------------- + const CONCATENATE = { + description: _lt("Appends strings to one another."), + args: args(` + string1 (string, range) ${_lt("The initial string.")} + string2 (string, range, repeating) ${_lt("More strings to append in sequence.")} + `), + returns: ["STRING"], + compute: function (...values) { + return reduceAny(values, (acc, a) => acc + toString(a), ""); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // EXACT + // ----------------------------------------------------------------------------- + const EXACT = { + description: _lt("Tests whether two strings are identical."), + args: args(` + string1 (string) ${_lt("The first string to compare.")} + string2 (string) ${_lt("The second string to compare.")} + `), + returns: ["BOOLEAN"], + compute: function (string1, string2) { + return toString(string1) === toString(string2); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // FIND + // ----------------------------------------------------------------------------- + const FIND = { + description: _lt("First position of string found in text, case-sensitive."), + args: args(` + search_for (string) ${_lt("The string to look for within text_to_search.")} + text_to_search (string) ${_lt("The text to search for the first occurrence of search_for.")} + starting_at (number, default=${DEFAULT_STARTING_AT}) ${_lt("The character within text_to_search at which to start the search.")} + `), + returns: ["NUMBER"], + compute: function (searchFor, textToSearch, startingAt = DEFAULT_STARTING_AT) { + const _searchFor = toString(searchFor); + const _textToSearch = toString(textToSearch); + const _startingAt = toNumber(startingAt); + assert(() => _textToSearch !== "", _lt(`The text_to_search must be non-empty.`)); + assert(() => _startingAt >= 1, _lt("The starting_at (%s) must be greater than or equal to 1.", _startingAt.toString())); + const result = _textToSearch.indexOf(_searchFor, _startingAt - 1); + assert(() => result >= 0, _lt("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor.toString(), _textToSearch)); + return result + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // JOIN + // ----------------------------------------------------------------------------- + const JOIN = { + description: _lt("Concatenates elements of arrays with delimiter."), + args: args(` + delimiter (string) ${_lt("The character or string to place between each concatenated value.")} + value_or_array1 (string, range) ${_lt("The value or values to be appended using delimiter.")} + value_or_array2 (string, range, repeating) ${_lt("More values to be appended using delimiter.")} + `), + returns: ["STRING"], + compute: function (delimiter, ...valuesOrArrays) { + const _delimiter = toString(delimiter); + return reduceAny(valuesOrArrays, (acc, a) => (acc ? acc + _delimiter : "") + toString(a), ""); + }, + }; + // ----------------------------------------------------------------------------- + // LEFT + // ----------------------------------------------------------------------------- + const LEFT = { + description: _lt("Substring from beginning of specified string."), + args: args(` + text (string) ${_lt("The string from which the left portion will be returned.")} + number_of_characters (number, optional) ${_lt("The number of characters to return from the left side of string.")} + `), + returns: ["STRING"], + compute: function (text, ...args) { + const _numberOfCharacters = args.length ? toNumber(args[0]) : 1; + assert(() => _numberOfCharacters >= 0, _lt("The number_of_characters (%s) must be positive or null.", _numberOfCharacters.toString())); + return toString(text).substring(0, _numberOfCharacters); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // LEN + // ----------------------------------------------------------------------------- + const LEN = { + description: _lt("Length of a string."), + args: args(` + text (string) ${_lt("The string whose length will be returned.")} + `), + returns: ["NUMBER"], + compute: function (text) { + return toString(text).length; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // LOWER + // ----------------------------------------------------------------------------- + const LOWER = { + description: _lt("Converts a specified string to lowercase."), + args: args(` + text (string) ${_lt("The string to convert to lowercase.")} + `), + returns: ["STRING"], + compute: function (text) { + return toString(text).toLowerCase(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // REPLACE + // ----------------------------------------------------------------------------- + const REPLACE = { + description: _lt("Replaces part of a text string with different text."), + args: args(` + text (string) ${_lt("The text, a part of which will be replaced.")} + position (number) ${_lt("The position where the replacement will begin (starting from 1).")} + length (number) ${_lt("The number of characters in the text to be replaced.")} + new_text (string) ${_lt("The text which will be inserted into the original text.")} + `), + returns: ["STRING"], + compute: function (text, position, length, newText) { + const _position = toNumber(position); + assert(() => _position >= 1, _lt("The position (%s) must be greater than or equal to 1.", _position.toString())); + const _text = toString(text); + const _length = toNumber(length); + const _newText = toString(newText); + return _text.substring(0, _position - 1) + _newText + _text.substring(_position - 1 + _length); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // RIGHT + // ----------------------------------------------------------------------------- + const RIGHT = { + description: _lt("A substring from the end of a specified string."), + args: args(` + text (string) ${_lt("The string from which the right portion will be returned.")} + number_of_characters (number, optional) ${_lt("The number of characters to return from the right side of string.")} + `), + returns: ["STRING"], + compute: function (text, ...args) { + const _numberOfCharacters = args.length ? toNumber(args[0]) : 1; + assert(() => _numberOfCharacters >= 0, _lt("The number_of_characters (%s) must be positive or null.", _numberOfCharacters.toString())); + const _text = toString(text); + const stringLength = _text.length; + return _text.substring(stringLength - _numberOfCharacters, stringLength); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SEARCH + // ----------------------------------------------------------------------------- + const SEARCH = { + description: _lt("First position of string found in text, ignoring case."), + args: args(` + search_for (string) ${_lt("The string to look for within text_to_search.")} + text_to_search (string) ${_lt("The text to search for the first occurrence of search_for.")} + starting_at (number, default=${DEFAULT_STARTING_AT}) ${_lt("The character within text_to_search at which to start the search.")} + `), + returns: ["NUMBER"], + compute: function (searchFor, textToSearch, startingAt = DEFAULT_STARTING_AT) { + const _searchFor = toString(searchFor).toLowerCase(); + const _textToSearch = toString(textToSearch).toLowerCase(); + const _startingAt = toNumber(startingAt); + assert(() => _textToSearch !== "", _lt(`The text_to_search must be non-empty.`)); + assert(() => _startingAt >= 1, _lt("The starting_at (%s) must be greater than or equal to 1.", _startingAt.toString())); + const result = _textToSearch.indexOf(_searchFor, _startingAt - 1); + assert(() => result >= 0, _lt("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor, _textToSearch)); + return result + 1; + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // SUBSTITUTE + // ----------------------------------------------------------------------------- + const SUBSTITUTE = { + description: _lt("Replaces existing text with new text in a string."), + args: args(` + text_to_search (string) ${_lt("The text within which to search and replace.")} + search_for (string) ${_lt("The string to search for within text_to_search.")} + replace_with (string) ${_lt("The string that will replace search_for.")} + occurrence_number (number, optional) ${_lt("The instance of search_for within text_to_search to replace with replace_with. By default, all occurrences of search_for are replaced; however, if occurrence_number is specified, only the indicated instance of search_for is replaced.")} + `), + returns: ["NUMBER"], + compute: function (textToSearch, searchFor, replaceWith, occurrenceNumber) { + const _occurrenceNumber = toNumber(occurrenceNumber); + assert(() => _occurrenceNumber >= 0, _lt("The occurrenceNumber (%s) must be positive or null.", _occurrenceNumber.toString())); + const _textToSearch = toString(textToSearch); + const _searchFor = toString(searchFor); + if (_searchFor === "") { + return _textToSearch; + } + const _replaceWith = toString(replaceWith); + const reg = new RegExp(_searchFor, "g"); + if (_occurrenceNumber === 0) { + return _textToSearch.replace(reg, _replaceWith); + } + let n = 0; + return _textToSearch.replace(reg, (text) => (++n === _occurrenceNumber ? _replaceWith : text)); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TEXTJOIN + // ----------------------------------------------------------------------------- + const TEXTJOIN = { + description: _lt("Combines text from multiple strings and/or arrays."), + args: args(` + delimiter (string) ${_lt(" A string, possible empty, or a reference to a valid string. If empty, the text will be simply concatenated.")} + ignore_empty (boolean) ${_lt("A boolean; if TRUE, empty cells selected in the text arguments won't be included in the result.")} + text1 (string, range) ${_lt("Any text item. This could be a string, or an array of strings in a range.")} + text2 (string, range, repeating) ${_lt("Additional text item(s).")} + `), + returns: ["STRING"], + compute: function (delimiter, ignoreEmpty, ...textsOrArrays) { + const _delimiter = toString(delimiter); + const _ignoreEmpty = toBoolean(ignoreEmpty); + let n = 0; + return reduceAny(textsOrArrays, (acc, a) => !(_ignoreEmpty && toString(a) === "") ? (n++ ? acc + _delimiter : "") + toString(a) : acc, ""); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TRIM + // ----------------------------------------------------------------------------- + const TRIM = { + description: _lt("Removes space characters."), + args: args(` + text (string) ${_lt("The text or reference to a cell containing text to be trimmed.")} + `), + returns: ["STRING"], + compute: function (text) { + return toString(text).trim(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // UPPER + // ----------------------------------------------------------------------------- + const UPPER = { + description: _lt("Converts a specified string to uppercase."), + args: args(` + text (string) ${_lt("The string to convert to uppercase.")} + `), + returns: ["STRING"], + compute: function (text) { + return toString(text).toUpperCase(); + }, + isExported: true, + }; + // ----------------------------------------------------------------------------- + // TEXT + // ----------------------------------------------------------------------------- + const TEXT = { + description: _lt("Converts a number to text according to a specified format."), + args: args(` + number (number) ${_lt("The number, date or time to format.")} + format (string) ${_lt("The pattern by which to format the number, enclosed in quotation marks.")} + `), + returns: ["STRING"], + compute: function (number, format) { + const _number = toNumber(number); + return formatValue(_number, toString(format)); + }, + isExported: true, + }; + + var text = /*#__PURE__*/Object.freeze({ + __proto__: null, + CHAR: CHAR, + CONCATENATE: CONCATENATE, + EXACT: EXACT, + FIND: FIND, + JOIN: JOIN, + LEFT: LEFT, + LEN: LEN, + LOWER: LOWER, + REPLACE: REPLACE, + RIGHT: RIGHT, + SEARCH: SEARCH, + SUBSTITUTE: SUBSTITUTE, + TEXTJOIN: TEXTJOIN, + TRIM: TRIM, + UPPER: UPPER, + TEXT: TEXT + }); + + const functions$4 = { + database, + date, + financial, + info, + lookup, + logical, + math, + misc: misc$1, + operators, + statistical, + text, + engineering, + }; + const functionNameRegex = /^[A-Z0-9\.]+$/; + //------------------------------------------------------------------------------ + // Function registry + //------------------------------------------------------------------------------ + class FunctionRegistry extends Registry { + constructor() { + super(...arguments); + this.mapping = {}; + } + add(name, addDescr) { + name = name.toUpperCase(); + if (!name.match(functionNameRegex)) { + throw new Error(_lt("Invalid function name %s. Function names can exclusively contain alphanumerical values separated by dots (.)", name)); + } + const descr = addMetaInfoFromArg(addDescr); + validateArguments(descr.args); + function computeValueAndFormat(...args) { + const computeValue = descr.compute.bind(this); + const computeFormat = descr.computeFormat ? descr.computeFormat.bind(this) : () => undefined; + return { + value: computeValue(...extractArgValuesFromArgs(args)), + format: computeFormat(...args), + }; + } + this.mapping[name] = computeValueAndFormat; + super.add(name, descr); + return this; + } + } + function extractArgValuesFromArgs(args) { + return args.map((arg) => { + if (arg === undefined) { + return undefined; + } + if (typeof arg === "function") { + return () => _extractArgValuesFromArgs(arg()); + } + return _extractArgValuesFromArgs(arg); + }); + } + function _extractArgValuesFromArgs(arg) { + if (Array.isArray(arg)) { + return arg.map((col) => col.map((simpleArg) => simpleArg === null || simpleArg === void 0 ? void 0 : simpleArg.value)); + } + return arg === null || arg === void 0 ? void 0 : arg.value; + } + const functionRegistry = new FunctionRegistry(); + for (let category in functions$4) { + const fns = functions$4[category]; + for (let name in fns) { + const addDescr = fns[name]; + addDescr.category = category; + name = name.replace(/_/g, "."); + functionRegistry.add(name, { isExported: false, ...addDescr }); + } + } + + /** + * Tokenizer + * + * A tokenizer is a piece of code whose job is to transform a string into a list + * of "tokens". For example, "(12+" is converted into: + * [{type: "LEFT_PAREN", value: "("}, + * {type: "NUMBER", value: "12"}, + * {type: "OPERATOR", value: "+"}] + * + * As the example shows, a tokenizer does not care about the meaning behind those + * tokens. It only cares about the structure. + * + * The tokenizer is usually the first step in a compilation pipeline. Also, it + * is useful for the composer, which needs to be able to work with incomplete + * formulas. + */ + const functions$3 = functionRegistry.content; + const POSTFIX_UNARY_OPERATORS = ["%"]; + const OPERATORS = "+,-,*,/,:,=,<>,>=,>,<=,<,^,&".split(",").concat(POSTFIX_UNARY_OPERATORS); + function tokenize(str) { + const chars = str.split(""); + const result = []; + let tokenCount = 0; + while (chars.length) { + tokenCount++; + if (tokenCount > 100) { + throw new Error(_lt("This formula has over 100 parts. It can't be processed properly, consider splitting it into multiple cells")); + } + let token = tokenizeSpace(chars) || + tokenizeMisc(chars) || + tokenizeOperator(chars) || + tokenizeString(chars) || + tokenizeDebugger(chars) || + tokenizeInvalidRange(chars) || + tokenizeNumber(chars) || + tokenizeSymbol(chars); + if (!token) { + token = { type: "UNKNOWN", value: chars.shift() }; + } + result.push(token); + } + return result; + } + function tokenizeDebugger(chars) { + if (chars[0] === "?") { + chars.shift(); + return { type: "DEBUGGER", value: "?" }; + } + return null; + } + const misc = { + ",": "COMMA", + "(": "LEFT_PAREN", + ")": "RIGHT_PAREN", + }; + function tokenizeMisc(chars) { + if (chars[0] in misc) { + const value = chars.shift(); + const type = misc[value]; + return { type, value }; + } + return null; + } + function startsWith(chars, op) { + for (let i = 0; i < op.length; i++) { + if (op[i] !== chars[i]) { + return false; + } + } + return true; + } + function tokenizeOperator(chars) { + for (let op of OPERATORS) { + if (startsWith(chars, op)) { + chars.splice(0, op.length); + return { type: "OPERATOR", value: op }; + } + } + return null; + } + function tokenizeNumber(chars) { + const match = concat(chars).match(formulaNumberRegexp); + if (match) { + chars.splice(0, match[0].length); + return { type: "NUMBER", value: match[0] }; + } + return null; + } + function tokenizeString(chars) { + if (chars[0] === '"') { + const startChar = chars.shift(); + let letters = startChar; + while (chars[0] && (chars[0] !== startChar || letters[letters.length - 1] === "\\")) { + letters += chars.shift(); + } + if (chars[0] === '"') { + letters += chars.shift(); + } + return { + type: "STRING", + value: letters, + }; + } + return null; + } + const separatorRegexp = /\w|\.|!|\$/; + /** + * A "Symbol" is just basically any word-like element that can appear in a + * formula, which is not a string. So: + * A1 + * SUM + * CEILING.MATH + * A$1 + * Sheet2!A2 + * 'Sheet 2'!A2 + * + * are examples of symbols + */ + function tokenizeSymbol(chars) { + let result = ""; + // there are two main cases to manage: either something which starts with + // a ', like 'Sheet 2'A2, or a word-like element. + if (chars[0] === "'") { + let lastChar = chars.shift(); + result += lastChar; + while (chars[0]) { + lastChar = chars.shift(); + result += lastChar; + if (lastChar === "'") { + if (chars[0] && chars[0] === "'") { + lastChar = chars.shift(); + result += lastChar; + } + else { + break; + } + } + } + if (lastChar !== "'") { + return { + type: "UNKNOWN", + value: result, + }; + } + } + while (chars[0] && chars[0].match(separatorRegexp)) { + result += chars.shift(); + } + if (result.length) { + const value = result; + const isFunction = value.toUpperCase() in functions$3; + if (isFunction) { + return { type: "FUNCTION", value }; + } + const isReference = value.match(rangeReference); + if (isReference) { + return { type: "REFERENCE", value }; + } + else { + return { type: "SYMBOL", value }; + } + } + return null; + } + const whiteSpaceRegexp = /\s/; + function tokenizeSpace(chars) { + let length = 0; + while (chars[0] && chars[0].match(whiteSpaceRegexp)) { + length++; + chars.shift(); + } + if (length) { + return { type: "SPACE", value: " ".repeat(length) }; + } + return null; + } + function tokenizeInvalidRange(chars) { + if (startsWith(chars, INCORRECT_RANGE_STRING)) { + chars.splice(0, INCORRECT_RANGE_STRING.length); + return { type: "INVALID_REFERENCE", value: INCORRECT_RANGE_STRING }; + } + return null; + } + + const UNARY_OPERATORS_PREFIX = ["-", "+"]; + const UNARY_OPERATORS_POSTFIX = ["%"]; + const ASSOCIATIVE_OPERATORS = ["*", "+", "&"]; + const OP_PRIORITY = { + "^": 30, + "%": 30, + "*": 20, + "/": 20, + "+": 15, + "-": 15, + "&": 13, + ">": 10, + "<>": 10, + ">=": 10, + "<": 10, + "<=": 10, + "=": 10, + }; + const FUNCTION_BP = 6; + function bindingPower(token) { + switch (token.type) { + case "NUMBER": + case "SYMBOL": + case "REFERENCE": + return 0; + case "COMMA": + return 3; + case "LEFT_PAREN": + return 5; + case "RIGHT_PAREN": + return 5; + case "OPERATOR": + return OP_PRIORITY[token.value] || 15; + } + throw new Error(_lt("Unknown token: %s", token.value)); + } + function parsePrefix(current, tokens) { + var _a, _b, _c; + switch (current.type) { + case "DEBUGGER": + const next = parseExpression(tokens, 1000); + next.debug = true; + return next; + case "NUMBER": + return { type: "NUMBER", value: parseNumber(current.value) }; + case "STRING": + return { type: "STRING", value: removeStringQuotes(current.value) }; + case "FUNCTION": + if (tokens.shift().type !== "LEFT_PAREN") { + throw new Error(_lt("Wrong function call")); + } + else { + const args = []; + if (tokens[0] && tokens[0].type !== "RIGHT_PAREN") { + if (tokens[0].type === "COMMA") { + args.push({ type: "UNKNOWN", value: "" }); + } + else { + args.push(parseExpression(tokens, FUNCTION_BP)); + } + while (((_a = tokens[0]) === null || _a === void 0 ? void 0 : _a.type) === "COMMA") { + tokens.shift(); + const token = tokens[0]; + if ((token === null || token === void 0 ? void 0 : token.type) === "RIGHT_PAREN") { + args.push({ type: "UNKNOWN", value: "" }); + break; + } + else if ((token === null || token === void 0 ? void 0 : token.type) === "COMMA") { + args.push({ type: "UNKNOWN", value: "" }); + } + else { + args.push(parseExpression(tokens, FUNCTION_BP)); + } + } + } + const closingToken = tokens.shift(); + if (!closingToken || closingToken.type !== "RIGHT_PAREN") { + throw new Error(_lt("Wrong function call")); + } + return { type: "FUNCALL", value: current.value, args }; + } + case "INVALID_REFERENCE": + throw new InvalidReferenceError(); + case "REFERENCE": + if (((_b = tokens[0]) === null || _b === void 0 ? void 0 : _b.value) === ":" && ((_c = tokens[1]) === null || _c === void 0 ? void 0 : _c.type) === "REFERENCE") { + tokens.shift(); + const rightReference = tokens.shift(); + return { + type: "REFERENCE", + value: `${current.value}:${rightReference === null || rightReference === void 0 ? void 0 : rightReference.value}`, + }; + } + return { + type: "REFERENCE", + value: current.value, + }; + case "SYMBOL": + if (["TRUE", "FALSE"].includes(current.value.toUpperCase())) { + return { type: "BOOLEAN", value: current.value.toUpperCase() === "TRUE" }; + } + else { + if (current.value) { + throw new Error(_lt("Invalid formula")); + } + return { type: "STRING", value: current.value }; + } + case "LEFT_PAREN": + const result = parseExpression(tokens, 5); + if (!tokens.length || tokens[0].type !== "RIGHT_PAREN") { + throw new Error(_lt("Unmatched left parenthesis")); + } + tokens.shift(); + return result; + default: + if (current.type === "OPERATOR" && UNARY_OPERATORS_PREFIX.includes(current.value)) { + return { + type: "UNARY_OPERATION", + value: current.value, + operand: parseExpression(tokens, OP_PRIORITY[current.value]), + }; + } + throw new Error(_lt("Unexpected token: %s", current.value)); + } + } + function parseInfix(left, current, tokens) { + if (current.type === "OPERATOR") { + const bp = bindingPower(current); + if (UNARY_OPERATORS_POSTFIX.includes(current.value)) { + return { + type: "UNARY_OPERATION", + value: current.value, + operand: left, + postfix: true, + }; + } + else { + const right = parseExpression(tokens, bp); + return { + type: "BIN_OPERATION", + value: current.value, + left, + right, + }; + } + } + throw new Error(DEFAULT_ERROR_MESSAGE); + } + function parseExpression(tokens, bp) { + const token = tokens.shift(); + if (!token) { + throw new Error(DEFAULT_ERROR_MESSAGE); + } + let expr = parsePrefix(token, tokens); + while (tokens[0] && bindingPower(tokens[0]) > bp) { + expr = parseInfix(expr, tokens.shift(), tokens); + } + return expr; + } + /** + * Parse an expression (as a string) into an AST. + */ + function parse(str) { + return parseTokens(tokenize(str)); + } + function parseTokens(tokens) { + tokens = tokens.filter((x) => x.type !== "SPACE"); + if (tokens[0].type === "OPERATOR" && tokens[0].value === "=") { + tokens.splice(0, 1); + } + const result = parseExpression(tokens, 0); + if (tokens.length) { + throw new Error(DEFAULT_ERROR_MESSAGE); + } + return result; + } + /** + * Allows to visit all nodes of an AST and apply a mapping function + * to nodes of a specific type. + * Useful if you want to convert some part of a formula. + * + * e.g. + * ```ts + * convertAstNodes(ast, "FUNCALL", convertFormulaToExcel) + * + * function convertFormulaToExcel(ast: ASTFuncall) { + * // ... + * return modifiedAst + * } + * ``` + */ + function convertAstNodes(ast, type, fn) { + if (type === ast.type) { + ast = fn(ast); + } + switch (ast.type) { + case "FUNCALL": + return { + ...ast, + args: ast.args.map((child) => convertAstNodes(child, type, fn)), + }; + case "UNARY_OPERATION": + return { + ...ast, + operand: convertAstNodes(ast.operand, type, fn), + }; + case "BIN_OPERATION": + return { + ...ast, + right: convertAstNodes(ast.right, type, fn), + left: convertAstNodes(ast.left, type, fn), + }; + default: + return ast; + } + } + /** + * Converts an ast formula to the corresponding string + */ + function astToFormula(ast) { + switch (ast.type) { + case "FUNCALL": + const args = ast.args.map((arg) => astToFormula(arg)); + return `${ast.value}(${args.join(",")})`; + case "NUMBER": + return ast.value.toString(); + case "REFERENCE": + return ast.value; + case "STRING": + return `"${ast.value}"`; + case "BOOLEAN": + return ast.value ? "TRUE" : "FALSE"; + case "UNARY_OPERATION": + return ast.postfix + ? leftOperandToFormula(ast) + ast.value + : ast.value + rightOperandToFormula(ast); + case "BIN_OPERATION": + return leftOperandToFormula(ast) + ast.value + rightOperandToFormula(ast); + default: + return ast.value; + } + } + /** + * Convert the left operand of a binary operation to the corresponding string + * and enclose the result inside parenthesis if necessary. + */ + function leftOperandToFormula(operationAST) { + const mainOperator = operationAST.value; + const leftOperation = "left" in operationAST ? operationAST.left : operationAST.operand; + const leftOperator = leftOperation.value; + const needParenthesis = leftOperation.type === "BIN_OPERATION" && OP_PRIORITY[leftOperator] < OP_PRIORITY[mainOperator]; + return needParenthesis ? `(${astToFormula(leftOperation)})` : astToFormula(leftOperation); + } + /** + * Convert the right operand of a binary or unary operation to the corresponding string + * and enclose the result inside parenthesis if necessary. + */ + function rightOperandToFormula(operationAST) { + const mainOperator = operationAST.value; + const rightOperation = "right" in operationAST ? operationAST.right : operationAST.operand; + const rightPriority = OP_PRIORITY[rightOperation.value]; + const mainPriority = OP_PRIORITY[mainOperator]; + let needParenthesis = false; + if (rightOperation.type !== "BIN_OPERATION") { + needParenthesis = false; + } + else if (rightPriority < mainPriority) { + needParenthesis = true; + } + else if (rightPriority === mainPriority && !ASSOCIATIVE_OPERATORS.includes(mainOperator)) { + needParenthesis = true; + } + return needParenthesis ? `(${astToFormula(rightOperation)})` : astToFormula(rightOperation); + } + + var State; + (function (State) { + /** + * Initial state. + * Expecting any reference for the left part of a range + * e.g. "A1", "1", "A", "Sheet1!A1", "Sheet1!A" + */ + State[State["LeftRef"] = 0] = "LeftRef"; + /** + * Expecting any reference for the right part of a range + * e.g. "A1", "1", "A", "Sheet1!A1", "Sheet1!A" + */ + State[State["RightRef"] = 1] = "RightRef"; + /** + * Expecting the separator without any constraint on the right part + */ + State[State["Separator"] = 2] = "Separator"; + /** + * Expecting the separator for a full column range + */ + State[State["FullColumnSeparator"] = 3] = "FullColumnSeparator"; + /** + * Expecting the separator for a full row range + */ + State[State["FullRowSeparator"] = 4] = "FullRowSeparator"; + /** + * Expecting the right part of a full column range + * e.g. "1", "A1" + */ + State[State["RightColumnRef"] = 5] = "RightColumnRef"; + /** + * Expecting the right part of a full row range + * e.g. "A", "A1" + */ + State[State["RightRowRef"] = 6] = "RightRowRef"; + /** + * Final state. A range has been matched + */ + State[State["Found"] = 7] = "Found"; + })(State || (State = {})); + const goTo = (state, guard = () => true) => [ + { + goTo: state, + guard, + }, + ]; + const goToMulti = (state, guard = () => true) => ({ + goTo: state, + guard, + }); + const machine = { + [State.LeftRef]: { + REFERENCE: goTo(State.Separator), + NUMBER: goTo(State.FullRowSeparator), + SYMBOL: [ + goToMulti(State.FullColumnSeparator, (token) => isColReference(token.value)), + goToMulti(State.FullRowSeparator, (token) => isRowReference(token.value)), + ], + }, + [State.FullColumnSeparator]: { + SPACE: goTo(State.FullColumnSeparator), + OPERATOR: goTo(State.RightColumnRef, (token) => token.value === ":"), + }, + [State.FullRowSeparator]: { + SPACE: goTo(State.FullRowSeparator), + OPERATOR: goTo(State.RightRowRef, (token) => token.value === ":"), + }, + [State.Separator]: { + SPACE: goTo(State.Separator), + OPERATOR: goTo(State.RightRef, (token) => token.value === ":"), + }, + [State.RightRef]: { + SPACE: goTo(State.RightRef), + NUMBER: goTo(State.Found), + REFERENCE: goTo(State.Found, (token) => isSingleCellReference(token.value)), + SYMBOL: goTo(State.Found, (token) => isColHeader(token.value)), + }, + [State.RightColumnRef]: { + SPACE: goTo(State.RightColumnRef), + SYMBOL: goTo(State.Found, (token) => isColHeader(token.value)), + REFERENCE: goTo(State.Found, (token) => isSingleCellReference(token.value)), + }, + [State.RightRowRef]: { + SPACE: goTo(State.RightRowRef), + NUMBER: goTo(State.Found), + REFERENCE: goTo(State.Found, (token) => isSingleCellReference(token.value)), + }, + [State.Found]: {}, + }; + /** + * Check if the list of tokens starts with a sequence of tokens representing + * a range. + * If a range is found, the sequence is removed from the list and is returned + * as a single token. + */ + function matchReference(tokens) { + var _a; + let head = 0; + let transitions = machine[State.LeftRef]; + const matchedTokens = []; + while (transitions !== undefined) { + const token = tokens[head++]; + if (!token) { + return null; + } + const transition = (_a = transitions[token.type]) === null || _a === void 0 ? void 0 : _a.find((transition) => transition.guard(token)); + const nextState = transition ? transition.goTo : undefined; + switch (nextState) { + case undefined: + return null; + case State.Found: + matchedTokens.push(token); + tokens.splice(0, head); + return { + type: "REFERENCE", + value: concat(matchedTokens.map((token) => token.value)), + }; + default: + transitions = machine[nextState]; + matchedTokens.push(token); + break; + } + } + return null; + } + /** + * Take the result of the tokenizer and transform it to be usable in the + * manipulations of range + * + * @param formula + */ + function rangeTokenize(formula) { + const tokens = tokenize(formula); + const result = []; + while (tokens.length) { + result.push(matchReference(tokens) || tokens.shift()); + } + return result; + } + + const functions$2 = functionRegistry.content; + const OPERATOR_MAP = { + "=": "EQ", + "+": "ADD", + "-": "MINUS", + "*": "MULTIPLY", + "/": "DIVIDE", + ">=": "GTE", + "<>": "NE", + ">": "GT", + "<=": "LTE", + "<": "LT", + "^": "POWER", + "&": "CONCATENATE", + }; + const UNARY_OPERATOR_MAP = { + "-": "UMINUS", + "+": "UPLUS", + "%": "UNARY.PERCENT", + }; + /** + * Takes a list of strings that might be single or multiline + * and maps them in a list of single line strings. + */ + function splitCodeLines(codeBlocks) { + return codeBlocks + .join("\n") + .split("\n") + .filter((line) => line.trim() !== ""); + } + // this cache contains all compiled function code, grouped by "structure". For + // example, "=2*sum(A1:A4)" and "=2*sum(B1:B4)" are compiled into the same + // structural function. + // It is only exported for testing purposes + const functionCache = {}; + // ----------------------------------------------------------------------------- + // COMPILER + // ----------------------------------------------------------------------------- + function compile(formula) { + const tokens = rangeTokenize(formula); + const { dependencies, constantValues } = formulaArguments(tokens); + const cacheKey = compilationCacheKey(tokens, dependencies, constantValues); + if (!functionCache[cacheKey]) { + const ast = parseTokens([...tokens]); + let nextId = 1; + if (ast.type === "BIN_OPERATION" && ast.value === ":") { + throw new Error(_lt("Invalid formula")); + } + if (ast.type === "UNKNOWN") { + throw new Error(_lt("Invalid formula")); + } + const compiledAST = compileAST(ast); + const code = splitCodeLines([ + `// ${cacheKey}`, + compiledAST.code, + `return ${compiledAST.id};`, + ]).join("\n"); + let baseFunction = new Function("deps", // the dependencies in the current formula + "ref", // a function to access a certain dependency at a given index + "range", // same as above, but guarantee that the result is in the form of a range + "ctx", code); + functionCache[cacheKey] = { + // @ts-ignore + execute: baseFunction, + }; + /** + * This function compile the function arguments. It is mostly straightforward, + * except that there is a non trivial transformation in one situation: + * + * If a function argument is asking for a range, and get a cell, we transform + * the cell value into a range. This allow the grid model to differentiate + * between a cell value and a non cell value. + */ + function compileFunctionArgs(ast) { + const functionDefinition = functions$2[ast.value.toUpperCase()]; + const currentFunctionArguments = ast.args; + // check if arguments are supplied in the correct quantities + const nbrArg = currentFunctionArguments.length; + if (nbrArg < functionDefinition.minArgRequired) { + throw new Error(_lt("Invalid number of arguments for the %s function. Expected %s minimum, but got %s instead.", ast.value.toUpperCase(), functionDefinition.minArgRequired.toString(), nbrArg.toString())); + } + if (nbrArg > functionDefinition.maxArgPossible) { + throw new Error(_lt("Invalid number of arguments for the %s function. Expected %s maximum, but got %s instead.", ast.value.toUpperCase(), functionDefinition.maxArgPossible.toString(), nbrArg.toString())); + } + const repeatingArg = functionDefinition.nbrArgRepeating; + if (repeatingArg > 1) { + const argBeforeRepeat = functionDefinition.args.length - repeatingArg; + const nbrRepeatingArg = nbrArg - argBeforeRepeat; + if (nbrRepeatingArg % repeatingArg !== 0) { + throw new Error(_lt("Invalid number of arguments for the %s function. Expected all arguments after position %s to be supplied by groups of %s arguments", ast.value.toUpperCase(), argBeforeRepeat.toString(), repeatingArg.toString())); + } + } + let listArgs = []; + for (let i = 0; i < nbrArg; i++) { + const argPosition = functionDefinition.getArgToFocus(i + 1) - 1; + if (0 <= argPosition && argPosition < functionDefinition.args.length) { + const currentArg = currentFunctionArguments[i]; + const argDefinition = functionDefinition.args[argPosition]; + const argTypes = argDefinition.type || []; + // detect when an argument need to be evaluated as a meta argument + const isMeta = argTypes.includes("META"); + // detect when an argument need to be evaluated as a lazy argument + const isLazy = argDefinition.lazy; + const hasRange = argTypes.some((t) => t === "RANGE" || + t === "RANGE" || + t === "RANGE" || + t === "RANGE" || + t === "RANGE"); + const isRangeOnly = argTypes.every((t) => t === "RANGE" || + t === "RANGE" || + t === "RANGE" || + t === "RANGE" || + t === "RANGE"); + if (isRangeOnly) { + if (currentArg.type !== "REFERENCE") { + throw new Error(_lt("Function %s expects the parameter %s to be reference to a cell or range, not a %s.", ast.value.toUpperCase(), (i + 1).toString(), currentArg.type.toLowerCase())); + } + } + const compiledAST = compileAST(currentArg, isLazy, isMeta, hasRange, { + functionName: ast.value.toUpperCase(), + paramIndex: i + 1, + }); + listArgs.push(compiledAST); + } + } + return listArgs; + } + /** + * This function compiles all the information extracted by the parser into an + * executable code for the evaluation of the cells content. It uses a cash to + * not reevaluate identical code structures. + * + * The function is sensitive to two parameters “isLazy” and “isMeta”. These + * parameters may vary when compiling function arguments: + * + * - isLazy: In some cases the function arguments does not need to be + * evaluated before entering the functions. For example the IF function might + * take invalid arguments that do not need to be evaluate and thus should not + * create an error. For this we have lazy arguments. + * + * - isMeta: In some cases the function arguments expects information on the + * cell/range other than the associated value(s). For example the COLUMN + * function needs to receive as argument the coordinates of a cell rather + * than its value. For this we have meta arguments. + */ + function compileAST(ast, isLazy = false, isMeta = false, hasRange = false, referenceVerification = {}) { + const codeBlocks = []; + let id, fnName, statement; + if (ast.type !== "REFERENCE" && !(ast.type === "BIN_OPERATION" && ast.value === ":")) { + if (isMeta) { + throw new Error(_lt(`Argument must be a reference to a cell or range.`)); + } + } + if (ast.debug) { + codeBlocks.push("debugger;"); + } + switch (ast.type) { + case "BOOLEAN": + if (!isLazy) { + return { id: `{ value: ${ast.value} }`, code: "" }; + } + id = nextId++; + statement = `{ value: ${ast.value} }`; + break; + case "NUMBER": + id = nextId++; + statement = `{ value: this.constantValues.numbers[${constantValues.numbers.indexOf(ast.value)}] }`; + break; + case "STRING": + id = nextId++; + statement = `{ value: this.constantValues.strings[${constantValues.strings.indexOf(ast.value)}] }`; + break; + case "REFERENCE": + const referenceIndex = dependencies.indexOf(ast.value); + id = nextId++; + if (hasRange) { + statement = `range(deps[${referenceIndex}])`; + } + else { + statement = `ref(deps[${referenceIndex}], ${isMeta ? "true" : "false"}, "${referenceVerification.functionName || OPERATOR_MAP["="]}", ${referenceVerification.paramIndex})`; + } + break; + case "FUNCALL": + id = nextId++; + const args = compileFunctionArgs(ast); + codeBlocks.push(args.map((arg) => arg.code).join("\n")); + fnName = ast.value.toUpperCase(); + codeBlocks.push(`ctx.__lastFnCalled = '${fnName}';`); + statement = `ctx['${fnName}'](${args.map((arg) => arg.id)})`; + break; + case "UNARY_OPERATION": { + id = nextId++; + fnName = UNARY_OPERATOR_MAP[ast.value]; + const operand = compileAST(ast.operand, false, false, false, { + functionName: fnName, + }); + codeBlocks.push(operand.code); + codeBlocks.push(`ctx.__lastFnCalled = '${fnName}';`); + statement = `ctx['${fnName}'](${operand.id})`; + break; + } + case "BIN_OPERATION": { + id = nextId++; + fnName = OPERATOR_MAP[ast.value]; + const left = compileAST(ast.left, false, false, false, { + functionName: fnName, + }); + const right = compileAST(ast.right, false, false, false, { + functionName: fnName, + }); + codeBlocks.push(left.code); + codeBlocks.push(right.code); + codeBlocks.push(`ctx.__lastFnCalled = '${fnName}';`); + statement = `ctx['${fnName}'](${left.id}, ${right.id})`; + break; + } + case "UNKNOWN": + if (!isLazy) { + return { id: "undefined", code: "" }; + } + id = nextId++; + statement = `undefined`; + break; + } + if (isLazy) { + const lazyFunction = `const _${id} = () => {\n` + + `\t${splitCodeLines(codeBlocks).join("\n\t")}\n` + + `\treturn ${statement};\n` + + "}"; + return { id: `_${id}`, code: lazyFunction }; + } + else { + codeBlocks.push(`let _${id} = ${statement};`); + return { id: `_${id}`, code: codeBlocks.join("\n") }; + } + } + } + const compiledFormula = { + execute: functionCache[cacheKey].execute, + dependencies, + constantValues, + tokens, + }; + return compiledFormula; + } + /** + * Compute a cache key for the formula. + * References, numbers and strings are replaced with placeholders because + * the compiled formula does not depend on their actual value. + * Both `=A1+1+"2"` and `=A2+2+"3"` are compiled to the exact same function. + * + * A formula `=A1+A2+SUM(2, 2, "2")` have the cache key `=|0|+|1|+SUM(|N0|, |N0|, |S0|)` + */ + function compilationCacheKey(tokens, dependencies, constantValues) { + return concat(tokens.map((token) => { + switch (token.type) { + case "STRING": + const value = removeStringQuotes(token.value); + return `|S${constantValues.strings.indexOf(value)}|`; + case "NUMBER": + return `|N${constantValues.numbers.indexOf(parseNumber(token.value))}|`; + case "REFERENCE": + case "INVALID_REFERENCE": + return `|${dependencies.indexOf(token.value)}|`; + default: + return token.value; + } + })); + } + /** + * Return formula arguments which are references, strings and numbers. + */ + function formulaArguments(tokens) { + const constantValues = { + numbers: [], + strings: [], + }; + const dependencies = []; + for (const token of tokens) { + switch (token.type) { + case "INVALID_REFERENCE": + case "REFERENCE": + dependencies.push(token.value); + break; + case "STRING": + const value = removeStringQuotes(token.value); + if (!constantValues.strings.includes(value)) { + constantValues.strings.push(value); + } + break; + case "NUMBER": { + const value = parseNumber(token.value); + if (!constantValues.numbers.includes(value)) { + constantValues.numbers.push(value); + } + break; + } + } + } + return { + dependencies, + constantValues, + }; + } + + /** + * Add the following information on tokens: + * - length + * - start + * - end + */ + function enrichTokens(tokens) { + let current = 0; + return tokens.map((x) => { + const len = x.value.toString().length; + const token = Object.assign({}, x, { + start: current, + end: current + len, + length: len, + }); + current = token.end; + return token; + }); + } + /** + * add on each token the length, start and end + * also matches the opening to its closing parenthesis (using the same number) + */ + function mapParenthesis(tokens) { + let maxParen = 1; + const stack = []; + return tokens.map((token) => { + if (token.type === "LEFT_PAREN") { + stack.push(maxParen); + token.parenIndex = maxParen; + maxParen++; + } + else if (token.type === "RIGHT_PAREN") { + token.parenIndex = stack.pop(); + } + return token; + }); + } + /** + * add on each token its parent function and the index corresponding to + * its position as an argument of the function. + * In this example "=MIN(42,SUM(MAX(1,2),3))": + * - the parent function of the token correspond to number 42 is the MIN function + * - the argument position of the token correspond to number 42 is 0 + * - the parent function of the token correspond to number 3 is the SUM function + * - the argument position of the token correspond to number 3 is 1 + */ + function mapParentFunction(tokens) { + let stack = []; + let functionStarted = ""; + const res = tokens.map((token, i) => { + if (!["SPACE", "LEFT_PAREN"].includes(token.type)) { + functionStarted = ""; + } + switch (token.type) { + case "FUNCTION": + functionStarted = token.value; + break; + case "LEFT_PAREN": + stack.push({ parent: functionStarted, argPosition: 0 }); + functionStarted = ""; + break; + case "RIGHT_PAREN": + stack.pop(); + break; + case "COMMA": + if (stack.length) { + // increment position on current function + stack[stack.length - 1].argPosition++; + } + break; + } + if (stack.length) { + const functionContext = stack[stack.length - 1]; + if (functionContext.parent) { + token.functionContext = Object.assign({}, functionContext); + } + } + return token; + }); + return res; + } + /** + * Take the result of the tokenizer and transform it to be usable in the composer. + * + * @param formula + */ + function composerTokenize(formula) { + const tokens = rangeTokenize(formula); + return mapParentFunction(mapParenthesis(enrichTokens(tokens))); + } + + /** + * Change the reference types inside the given token, if the token represent a range or a cell + * + * Eg. : + * A1 => $A$1 => A$1 => $A1 => A1 + * A1:$B$1 => $A$1:B$1 => A$1:$B1 => $A1:B1 => A1:$B$1 + */ + function loopThroughReferenceType(token) { + if (token.type !== "REFERENCE") + return token; + const [range, sheet] = token.value.split("!").reverse(); + const [left, right] = range.split(":"); + const sheetRef = sheet ? `${sheet}!` : ""; + const updatedLeft = getTokenNextReferenceType(left); + const updatedRight = right ? `:${getTokenNextReferenceType(right)}` : ""; + return { ...token, value: sheetRef + updatedLeft + updatedRight }; + } + /** + * Get a new token with a changed type of reference from the given cell token symbol. + * Undefined behavior if given a token other than a cell or if the Xc contains a sheet reference + * + * A1 => $A$1 => A$1 => $A1 => A1 + */ + function getTokenNextReferenceType(xc) { + switch (getReferenceType(xc)) { + case "none": + xc = setXcToReferenceType(xc, "colrow"); + break; + case "colrow": + xc = setXcToReferenceType(xc, "row"); + break; + case "row": + xc = setXcToReferenceType(xc, "col"); + break; + case "col": + xc = setXcToReferenceType(xc, "none"); + break; + } + return xc; + } + /** + * Returns the given XC with the given reference type. + */ + function setXcToReferenceType(xc, referenceType) { + xc = xc.replace(/\$/g, ""); + let indexOfNumber; + switch (referenceType) { + case "col": + return "$" + xc; + case "row": + indexOfNumber = xc.search(/[0-9]/); + return xc.slice(0, indexOfNumber) + "$" + xc.slice(indexOfNumber); + case "colrow": + indexOfNumber = xc.search(/[0-9]/); + xc = xc.slice(0, indexOfNumber) + "$" + xc.slice(indexOfNumber); + return "$" + xc; + case "none": + return xc; + } + } + /** + * Return the type of reference used in the given XC of a cell. + * Undefined behavior if the XC have a sheet reference + */ + function getReferenceType(xcCell) { + if (isColAndRowFixed(xcCell)) { + return "colrow"; + } + else if (isColFixed(xcCell)) { + return "col"; + } + else if (isRowFixed(xcCell)) { + return "row"; + } + return "none"; + } + function isColFixed(xc) { + return xc.startsWith("$"); + } + function isRowFixed(xc) { + return xc.includes("$", 1); + } + function isColAndRowFixed(xc) { + return xc.startsWith("$") && xc.length > 1 && xc.slice(1).includes("$"); + } + + /** + * BasePlugin + * + * Since the spreadsheet internal state is quite complex, it is split into + * multiple parts, each managing a specific concern. + * + * This file introduce the BasePlugin, which is the common class that defines + * how each of these model sub parts should interact with each other. + * There are two kind of plugins: core plugins handling persistent data + * and UI plugins handling transient data. + */ + class BasePlugin { + constructor(stateObserver, dispatch, config) { + this.history = Object.assign(Object.create(stateObserver), { + update: stateObserver.addChange.bind(stateObserver, this), + selectCell: () => { }, + }); + this.dispatch = dispatch; + } + /** + * Export for excel should be available for all plugins, even for the UI. + * In some case, we need to export evaluated value, which is available from + * UI plugin only. + */ + exportForExcel(data) { } + // --------------------------------------------------------------------------- + // Command handling + // --------------------------------------------------------------------------- + /** + * Before a command is accepted, the model will ask each plugin if the command + * is allowed. If all of then return true, then we can proceed. Otherwise, + * the command is cancelled. + * + * There should not be any side effects in this method. + */ + allowDispatch(command) { + return 0 /* Success */; + } + /** + * This method is useful when a plugin need to perform some action before a + * command is handled in another plugin. This should only be used if it is not + * possible to do the work in the handle method. + */ + beforeHandle(command) { } + /** + * This is the standard place to handle any command. Most of the plugin + * command handling work should take place here. + */ + handle(command) { } + /** + * Sometimes, it is useful to perform some work after a command (and all its + * subcommands) has been completely handled. For example, when we paste + * multiple cells, we only want to reevaluate the cell values once at the end. + */ + finalize() { } + /** + * Combine multiple validation functions into a single function + * returning the list of result of every validation. + */ + batchValidations(...validations) { + return (toValidate) => validations.map((validation) => validation.call(this, toValidate)).flat(); + } + /** + * Combine multiple validation functions. Every validation is executed one after + * the other. As soon as one validation fails, it stops and the cancelled reason + * is returned. + */ + chainValidations(...validations) { + return (toValidate) => { + for (const validation of validations) { + let results = validation.call(this, toValidate); + if (!Array.isArray(results)) { + results = [results]; + } + const cancelledReasons = results.filter((result) => result !== 0 /* Success */); + if (cancelledReasons.length) { + return cancelledReasons; + } + } + return 0 /* Success */; + }; + } + checkValidations(command, ...validations) { + return this.batchValidations(...validations)(command); + } + } + BasePlugin.getters = []; + + /** + * UI plugins handle any transient data required to display a spreadsheet. + * They can draw on the grid canvas. + */ + class UIPlugin extends BasePlugin { + constructor(getters, state, dispatch, config, selection) { + super(state, dispatch, config); + this.getters = getters; + this.ui = config; + this.selection = selection; + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(ctx, layer) { } + } + UIPlugin.layers = []; + + const CELL_DELETED_MESSAGE = _lt("The cell you are trying to edit has been deleted."); + const SelectionIndicator = "␣"; + class EditionPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.col = 0; + this.row = 0; + this.mode = "inactive"; + this.sheetId = ""; + this.currentContent = ""; + this.currentTokens = []; + this.selectionStart = 0; + this.selectionEnd = 0; + this.selectionInitialStart = 0; + this.initialContent = ""; + this.previousRef = ""; + this.previousRange = undefined; + this.colorIndexByRange = {}; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "CHANGE_COMPOSER_CURSOR_SELECTION": + return this.validateSelection(this.currentContent.length, cmd.start, cmd.end); + case "SET_CURRENT_CONTENT": + if (cmd.selection) { + return this.validateSelection(cmd.content.length, cmd.selection.start, cmd.selection.end); + } + else { + return 0 /* Success */; + } + case "START_EDITION": + if (cmd.selection) { + const cell = this.getters.getActiveCell(); + const content = cmd.text || (cell === null || cell === void 0 ? void 0 : cell.composerContent) || ""; + return this.validateSelection(content.length, cmd.selection.start, cmd.selection.end); + } + else { + return 0 /* Success */; + } + default: + return 0 /* Success */; + } + } + handleEvent(event) { + if (this.mode !== "selecting") { + return; + } + switch (event.mode) { + case "newAnchor": + this.insertSelectedRange(event.anchor.zone); + break; + default: + this.replaceSelectedRanges(event.anchor.zone); + break; + } + } + handle(cmd) { + switch (cmd.type) { + case "CHANGE_COMPOSER_CURSOR_SELECTION": + this.selectionStart = cmd.start; + this.selectionEnd = cmd.end; + break; + case "STOP_COMPOSER_RANGE_SELECTION": + if (this.isSelectingForComposer()) { + this.mode = "editing"; + } + break; + case "START_EDITION": + this.startEdition(cmd.text, cmd.selection); + this.updateRangeColor(); + break; + case "STOP_EDITION": + if (cmd.cancel) { + this.cancelEdition(); + this.resetContent(); + } + else { + this.stopEdition(); + } + this.colorIndexByRange = {}; + break; + case "SET_CURRENT_CONTENT": + this.setContent(cmd.content, cmd.selection); + this.updateRangeColor(); + break; + case "REPLACE_COMPOSER_CURSOR_SELECTION": + this.replaceSelection(cmd.text); + break; + case "SELECT_FIGURE": + this.cancelEdition(); + this.resetContent(); + break; + case "ADD_COLUMNS_ROWS": + this.onAddElements(cmd); + break; + case "REMOVE_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.onColumnsRemoved(cmd); + } + else { + this.onRowsRemoved(cmd); + } + break; + case "START_CHANGE_HIGHLIGHT": + this.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + const range = this.getters.getRangeFromRangeData(cmd.range); + const previousRefToken = this.currentTokens + .filter((token) => token.type === "REFERENCE") + .find((token) => { + let value = token.value; + const [xc, sheet] = value.split("!").reverse(); + const sheetName = sheet || this.getters.getSheetName(this.sheetId); + const activeSheetId = this.getters.getActiveSheetId(); + if (this.getters.getSheetName(activeSheetId) !== sheetName) { + return false; + } + const refRange = this.getters.getRangeFromSheetXC(activeSheetId, xc); + return isEqual(this.getters.expandZone(activeSheetId, refRange.zone), range.zone); + }); + this.previousRef = previousRefToken.value; + this.previousRange = this.getters.getRangeFromSheetXC(this.getters.getActiveSheetId(), this.previousRef); + this.selectionInitialStart = previousRefToken.start; + break; + case "CHANGE_HIGHLIGHT": + const cmdRange = this.getters.getRangeFromRangeData(cmd.range); + const newRef = this.getRangeReference(cmdRange, this.previousRange.parts); + this.selectionStart = this.selectionInitialStart; + this.selectionEnd = this.selectionInitialStart + this.previousRef.length; + this.replaceSelection(newRef); + this.previousRef = newRef; + this.selectionStart = this.currentContent.length; + this.selectionEnd = this.currentContent.length; + break; + case "ACTIVATE_SHEET": + if (cmd.sheetIdFrom !== cmd.sheetIdTo) { + const { col, row } = this.getters.getNextVisibleCellPosition(cmd.sheetIdTo, 0, 0); + const zone = this.getters.expandZone(cmd.sheetIdTo, positionToZone({ col, row })); + this.selection.resetAnchor(this, { cell: { col, row }, zone }); + } + break; + case "DELETE_SHEET": + case "UNDO": + case "REDO": + const sheetIdExists = !!this.getters.tryGetSheet(this.sheetId); + if (!sheetIdExists && this.mode !== "inactive") { + this.sheetId = this.getters.getActiveSheetId(); + this.cancelEdition(); + this.resetContent(); + this.ui.notifyUI({ + type: "NOTIFICATION", + text: CELL_DELETED_MESSAGE, + }); + } + break; + case "CYCLE_EDITION_REFERENCES": + this.cycleReferences(); + break; + } + } + unsubscribe() { + this.mode = "inactive"; + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getEditionMode() { + return this.mode; + } + getCurrentContent() { + if (this.mode === "inactive") { + const cell = this.getters.getActiveCell(); + return (cell === null || cell === void 0 ? void 0 : cell.composerContent) || ""; + } + return this.currentContent; + } + getEditionSheet() { + return this.sheetId; + } + getComposerSelection() { + return { + start: this.selectionStart, + end: this.selectionEnd, + }; + } + isSelectingForComposer() { + return this.mode === "selecting"; + } + showSelectionIndicator() { + return this.isSelectingForComposer() && this.canStartComposerRangeSelection(); + } + getCurrentTokens() { + return this.currentTokens; + } + /** + * Return the (enriched) token just before the cursor. + */ + getTokenAtCursor() { + const start = Math.min(this.selectionStart, this.selectionEnd); + const end = Math.max(this.selectionStart, this.selectionEnd); + if (start === end && end === 0) { + return undefined; + } + else { + return this.currentTokens.find((t) => t.start <= start && t.end >= end); + } + } + // --------------------------------------------------------------------------- + // Misc + // --------------------------------------------------------------------------- + cycleReferences() { + const tokens = this.getTokensInSelection(); + const refTokens = tokens.filter((token) => token.type === "REFERENCE"); + if (refTokens.length === 0) + return; + const updatedReferences = tokens + .map(loopThroughReferenceType) + .map((token) => token.value) + .join(""); + const content = this.currentContent; + const start = tokens[0].start; + const end = tokens[tokens.length - 1].end; + const newContent = content.slice(0, start) + updatedReferences + content.slice(end); + const lengthDiff = newContent.length - content.length; + const startOfTokens = refTokens[0].start; + const endOfTokens = refTokens[refTokens.length - 1].end + lengthDiff; + const selection = { start: startOfTokens, end: endOfTokens }; + // Put the selection at the end of the token if we cycled on a single token + if (refTokens.length === 1 && this.selectionStart === this.selectionEnd) { + selection.start = selection.end; + } + this.dispatch("SET_CURRENT_CONTENT", { + content: newContent, + selection, + }); + } + validateSelection(length, start, end) { + return start >= 0 && start <= length && end >= 0 && end <= length + ? 0 /* Success */ + : 43 /* WrongComposerSelection */; + } + onColumnsRemoved(cmd) { + if (cmd.elements.includes(this.col) && this.mode !== "inactive") { + this.dispatch("STOP_EDITION", { cancel: true }); + this.ui.notifyUI({ + type: "NOTIFICATION", + text: CELL_DELETED_MESSAGE, + }); + return; + } + const { top, left } = updateSelectionOnDeletion({ left: this.col, right: this.col, top: this.row, bottom: this.row }, "left", [...cmd.elements]); + this.col = left; + this.row = top; + } + onRowsRemoved(cmd) { + if (cmd.elements.includes(this.row) && this.mode !== "inactive") { + this.dispatch("STOP_EDITION", { cancel: true }); + this.ui.notifyUI({ + type: "NOTIFICATION", + text: CELL_DELETED_MESSAGE, + }); + return; + } + const { top, left } = updateSelectionOnDeletion({ left: this.col, right: this.col, top: this.row, bottom: this.row }, "top", [...cmd.elements]); + this.col = left; + this.row = top; + } + onAddElements(cmd) { + const { top, left } = updateSelectionOnInsertion({ left: this.col, right: this.col, top: this.row, bottom: this.row }, cmd.dimension === "COL" ? "left" : "top", cmd.base, cmd.position, cmd.quantity); + this.col = left; + this.row = top; + } + /** + * Enable the selecting mode + */ + startComposerRangeSelection() { + if (this.sheetId === this.getters.getActiveSheetId()) { + const zone = positionToZone({ col: this.col, row: this.row }); + this.selection.resetAnchor(this, { cell: { col: this.col, row: this.row }, zone }); + } + this.mode = "selecting"; + this.selectionInitialStart = this.selectionStart; + } + /** + * start the edition of a cell + * @param str the key that is used to start the edition if it is a "content" key like a letter or number + * @param selection + * @private + */ + startEdition(str, selection) { + var _a; + const cell = this.getters.getActiveCell(); + if (str && ((_a = cell === null || cell === void 0 ? void 0 : cell.format) === null || _a === void 0 ? void 0 : _a.includes("%")) && isNumber(str)) { + selection = selection || { start: str.length, end: str.length }; + str = `${str}%`; + } + this.initialContent = (cell === null || cell === void 0 ? void 0 : cell.composerContent) || ""; + this.mode = "editing"; + const { col, row } = this.getters.getPosition(); + this.col = col; + this.row = row; + this.sheetId = this.getters.getActiveSheetId(); + this.setContent(str || this.initialContent, selection); + this.colorIndexByRange = {}; + const zone = positionToZone({ col: this.col, row: this.row }); + this.selection.capture(this, { cell: { col: this.col, row: this.row }, zone }, { + handleEvent: this.handleEvent.bind(this), + release: () => (this.mode = "inactive"), + }); + } + stopEdition() { + if (this.mode !== "inactive") { + const activeSheetId = this.getters.getActiveSheetId(); + this.cancelEdition(); + const { col, row } = this.getters.getMainCellPosition(this.sheetId, this.col, this.row); + let content = this.currentContent; + const didChange = this.initialContent !== content; + if (!didChange) { + return; + } + if (content) { + const cell = this.getters.getCell(activeSheetId, col, row); + if (content.startsWith("=")) { + const left = this.currentTokens.filter((t) => t.type === "LEFT_PAREN").length; + const right = this.currentTokens.filter((t) => t.type === "RIGHT_PAREN").length; + const missing = left - right; + if (missing > 0) { + content += concat(new Array(missing).fill(")")); + } + } + else if (cell === null || cell === void 0 ? void 0 : cell.isLink()) { + content = markdownLink(content, cell.link.url); + } + this.dispatch("UPDATE_CELL", { + sheetId: this.sheetId, + col, + row, + content, + }); + } + else { + this.dispatch("UPDATE_CELL", { + sheetId: this.sheetId, + content: "", + col, + row, + }); + } + this.setContent(""); + } + } + cancelEdition() { + if (this.mode === "inactive") { + return; + } + this.mode = "inactive"; + this.selection.release(this); + const sheetId = this.getters.getActiveSheetId(); + if (sheetId !== this.sheetId) { + this.dispatch("ACTIVATE_SHEET", { + sheetIdFrom: this.getters.getActiveSheetId(), + sheetIdTo: this.sheetId, + }); + } + } + /** + * Reset the current content to the active cell content + */ + resetContent() { + this.setContent(this.initialContent || ""); + } + setContent(text, selection) { + const isNewCurrentContent = this.currentContent !== text; + this.currentContent = text; + if (selection) { + this.selectionStart = selection.start; + this.selectionEnd = selection.end; + } + else { + this.selectionStart = this.selectionEnd = text.length; + } + if (isNewCurrentContent || this.mode !== "inactive") { + this.currentTokens = text.startsWith("=") ? composerTokenize(text) : []; + } + if (this.canStartComposerRangeSelection()) { + this.startComposerRangeSelection(); + } + } + insertSelectedRange(zone) { + // infer if range selected or selecting range from cursor position + const start = Math.min(this.selectionStart, this.selectionEnd); + const ref = this.getZoneReference(zone); + if (this.canStartComposerRangeSelection()) { + this.insertText(ref, start); + this.selectionInitialStart = start; + } + else { + this.insertText("," + ref, start); + this.selectionInitialStart = start + 1; + } + } + /** + * Replace the current reference selected by the new one. + * */ + replaceSelectedRanges(zone) { + const ref = this.getZoneReference(zone); + this.replaceText(ref, this.selectionInitialStart, this.selectionEnd); + } + getZoneReference(zone, fixedParts = [{ colFixed: false, rowFixed: false }]) { + const sheetId = this.getters.getActiveSheetId(); + let selectedXc = this.getters.zoneToXC(sheetId, zone, fixedParts); + if (this.getters.getEditionSheet() !== this.getters.getActiveSheetId()) { + const sheetName = getComposerSheetName(this.getters.getSheetName(this.getters.getActiveSheetId())); + selectedXc = `${sheetName}!${selectedXc}`; + } + return selectedXc; + } + getRangeReference(range, fixedParts = [{ colFixed: false, rowFixed: false }]) { + if (fixedParts.length === 1 && getZoneArea(range.zone) > 1) { + fixedParts.push({ ...fixedParts[0] }); + } + else if (fixedParts.length === 2 && getZoneArea(range.zone) === 1) { + fixedParts.pop(); + } + const newRange = range.clone({ parts: this.previousRange.parts }); + return this.getters.getSelectionRangeString(newRange, this.getters.getEditionSheet()); + } + /** + * Replace the current selection by a new text. + * The cursor is then set at the end of the text. + */ + replaceSelection(text) { + const start = Math.min(this.selectionStart, this.selectionEnd); + const end = Math.max(this.selectionStart, this.selectionEnd); + this.replaceText(text, start, end); + } + replaceText(text, start, end) { + this.currentContent = + this.currentContent.slice(0, start) + + this.currentContent.slice(end, this.currentContent.length); + this.insertText(text, start); + } + /** + * Insert a text at the given position. + * The cursor is then set at the end of the text. + */ + insertText(text, start) { + const content = this.currentContent.slice(0, start) + text + this.currentContent.slice(start); + const end = start + text.length; + this.dispatch("SET_CURRENT_CONTENT", { + content, + selection: { start: end, end }, + }); + } + updateRangeColor() { + if (!this.currentContent.startsWith("=") || this.mode === "inactive") { + return; + } + const editionSheetId = this.getters.getEditionSheet(); + const XCs = this.getReferencedRanges().map((range) => this.getters.getRangeString(range, editionSheetId)); + const colorsToKeep = {}; + for (const xc of XCs) { + if (this.colorIndexByRange[xc] !== undefined) { + colorsToKeep[xc] = this.colorIndexByRange[xc]; + } + } + const usedIndexes = new Set(Object.values(colorsToKeep)); + let currentIndex = 0; + const nextIndex = () => { + while (usedIndexes.has(currentIndex)) + currentIndex++; + usedIndexes.add(currentIndex); + return currentIndex; + }; + for (const xc of XCs) { + const colorIndex = xc in colorsToKeep ? colorsToKeep[xc] : nextIndex(); + colorsToKeep[xc] = colorIndex; + } + this.colorIndexByRange = colorsToKeep; + } + /** + * Highlight all ranges that can be found in the composer content. + */ + getComposerHighlights() { + if (!this.currentContent.startsWith("=") || this.mode === "inactive") { + return []; + } + const editionSheetId = this.getters.getEditionSheet(); + const rangeColor = (rangeString) => { + const colorIndex = this.colorIndexByRange[rangeString]; + return colors$1[colorIndex % colors$1.length]; + }; + return this.getReferencedRanges().map((range) => { + const rangeString = this.getters.getRangeString(range, editionSheetId); + return { + zone: range.zone, + color: rangeColor(rangeString), + sheetId: range.sheetId, + }; + }); + } + /** + * Return ranges currently referenced in the composer + */ + getReferencedRanges() { + const editionSheetId = this.getters.getEditionSheet(); + return this.currentTokens + .filter((token) => token.type === "REFERENCE") + .map((token) => this.getters.getRangeFromSheetXC(editionSheetId, token.value)); + } + /** + * Function used to determine when composer selection can start. + * Three conditions are necessary: + * - the previous token is among ["COMMA", "LEFT_PAREN", "OPERATOR"], and is not a postfix unary operator + * - the next token is missing or is among ["COMMA", "RIGHT_PAREN", "OPERATOR"] + * - Previous and next tokens can be separated by spaces + */ + canStartComposerRangeSelection() { + if (this.currentContent.startsWith("=")) { + const tokenAtCursor = this.getTokenAtCursor(); + if (!tokenAtCursor) { + return false; + } + const tokenIdex = this.currentTokens.map((token) => token.start).indexOf(tokenAtCursor.start); + let count = tokenIdex; + let currentToken = tokenAtCursor; + // check previous token + while (!["COMMA", "LEFT_PAREN", "OPERATOR"].includes(currentToken.type) || + POSTFIX_UNARY_OPERATORS.includes(currentToken.value)) { + if (currentToken.type !== "SPACE" || count < 1) { + return false; + } + count--; + currentToken = this.currentTokens[count]; + } + count = tokenIdex + 1; + currentToken = this.currentTokens[count]; + // check next token + while (currentToken && !["COMMA", "RIGHT_PAREN", "OPERATOR"].includes(currentToken.type)) { + if (currentToken.type !== "SPACE") { + return false; + } + count++; + currentToken = this.currentTokens[count]; + } + return true; + } + return false; + } + /** + * Return all the tokens between selectionStart and selectionEnd. + * Includes token that begin right on selectionStart or end right on selectionEnd. + */ + getTokensInSelection() { + const start = Math.min(this.selectionStart, this.selectionEnd); + const end = Math.max(this.selectionStart, this.selectionEnd); + return this.currentTokens.filter((t) => (t.start <= start && t.end >= start) || (t.start >= start && t.start < end)); + } + } + EditionPlugin.getters = [ + "getEditionMode", + "isSelectingForComposer", + "showSelectionIndicator", + "getCurrentContent", + "getEditionSheet", + "getComposerSelection", + "getCurrentTokens", + "getTokenAtCursor", + "getComposerHighlights", + ]; + + const functions$1 = functionRegistry.content; + const providerRegistry = new Registry(); + providerRegistry.add("functions", () => { + return Object.keys(functions$1).map((key) => { + return { + text: key, + description: functions$1[key].description, + }; + }); + }); + // ----------------------------------------------------------------------------- + // Autocomplete DropDown component + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-autocomplete-dropdown { + pointer-events: auto; + background-color: #fff; + & > div:hover { + background-color: #f2f2f2; + } + .o-autocomplete-value-focus { + background-color: rgba(0, 0, 0, 0.08); + } + + & > div { + display: flex; + flex-direction: column; + padding: 1px 0 5px 5px; + .o-autocomplete-description { + padding: 0 0 0 5px; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +`; + class TextValueProvider extends owl.Component { + constructor() { + super(...arguments); + this.state = owl.useState({ + values: [], + selectedIndex: 0, + }); + } + setup() { + owl.onMounted(() => this.filter(this.props.search)); + owl.onWillUpdateProps((nextProps) => this.checkUpdateProps(nextProps)); + this.props.exposeAPI({ + getValueToFill: () => this.getValueToFill(), + moveDown: () => this.moveDown(), + moveUp: () => this.moveUp(), + }); + } + checkUpdateProps(nextProps) { + if (nextProps.search !== this.props.search) { + this.filter(nextProps.search); + } + } + async filter(searchTerm) { + const provider = providerRegistry.get(this.props.provider); + let values = provider(); + if (this.props.filter) { + values = this.props.filter(searchTerm, values); + } + else { + values = values + .filter((t) => t.text.toUpperCase().startsWith(searchTerm.toUpperCase())) + .sort((l, r) => (l.text < r.text ? -1 : l.text > r.text ? 1 : 0)); + } + this.state.values = values.slice(0, 10); + this.state.selectedIndex = 0; + } + fillValue(index) { + this.state.selectedIndex = index; + this.props.onCompleted(this.getValueToFill()); + } + moveDown() { + this.state.selectedIndex = (this.state.selectedIndex + 1) % this.state.values.length; + } + moveUp() { + this.state.selectedIndex--; + if (this.state.selectedIndex < 0) { + this.state.selectedIndex = this.state.values.length - 1; + } + } + getValueToFill() { + if (this.state.values.length) { + return this.state.values[this.state.selectedIndex].text; + } + return undefined; + } + } + TextValueProvider.template = "o-spreadsheet-TextValueProvider"; + + class ContentEditableHelper { + constructor(el) { + this.el = el; + } + updateEl(el) { + this.el = el; + } + /** + * select the text at position start to end, no matter the children + */ + selectRange(start, end) { + let selection = window.getSelection(); + this.removeSelection(); + let range = document.createRange(); + if (start == end && start === 0) { + range.setStart(this.el, 0); + range.setEnd(this.el, 0); + selection.addRange(range); + } + else { + if (start < 0 || end > this.el.textContent.length) { + console.warn(`wrong selection asked start ${start}, end ${end}, text content length ${this.el.textContent.length}`); + if (start < 0) + start = 0; + if (end > this.el.textContent.length) + end = this.el.textContent.length; + } + let startNode = this.findChildAtCharacterIndex(start); + let endNode = this.findChildAtCharacterIndex(end); + range.setStart(startNode.node, startNode.offset); + selection.addRange(range); + selection.extend(endNode.node, endNode.offset); + } + } + /** + * finds the dom element that contains the character at `offset` + */ + findChildAtCharacterIndex(offset) { + let it = this.iterateChildren(this.el); + let current, previous; + let usedCharacters = offset; + do { + current = it.next(); + if (!current.done && !current.value.hasChildNodes()) { + if (current.value.textContent && current.value.textContent.length < usedCharacters) { + usedCharacters -= current.value.textContent.length; + } + else { + it.return(current.value); + } + previous = current.value; + } + } while (!current.done); + if (current.value) { + return { node: current.value, offset: usedCharacters }; + } + return { node: previous, offset: usedCharacters }; + } + /** + * Iterate over the dom tree starting at `el` and over all the children depth first. + * */ + *iterateChildren(el) { + yield el; + if (el.hasChildNodes()) { + for (let child of el.childNodes) { + yield* this.iterateChildren(child); + } + } + } + /** + * Sets (or Replaces all) the text inside the root element in the form of distinctive + * span for each element provided in `contents`. + * + * Each span will have its own fontcolor and specific class if provided in the HtmlContent object. + */ + setText(contents) { + if (contents.length === 0) { + return; + } + for (const content of contents) { + const span = document.createElement("span"); + span.innerText = content.value; + if (content.color) { + span.style.color = content.color; + } + if (content.class) { + span.classList.add(content.class); + } + this.el.appendChild(span); + } + } + /** + * remove the current selection of the user + * */ + removeSelection() { + let selection = window.getSelection(); + selection.removeAllRanges(); + } + removeAll() { + if (this.el) { + while (this.el.firstChild) { + this.el.removeChild(this.el.firstChild); + } + } + } + /** + * finds the indexes of the current selection. + * */ + getCurrentSelection() { + let { startElement, endElement, startSelectionOffset, endSelectionOffset } = this.getStartAndEndSelection(); + let startSizeBefore = this.findSizeBeforeElement(startElement); + let endSizeBefore = this.findSizeBeforeElement(endElement); + return { + start: startSizeBefore + startSelectionOffset, + end: endSizeBefore + endSelectionOffset, + }; + } + findSizeBeforeElement(nodeToFind) { + let it = this.iterateChildren(this.el); + let usedCharacters = 0; + let current = it.next(); + while (!current.done && current.value !== nodeToFind) { + if (!current.value.hasChildNodes()) { + if (current.value.textContent) { + usedCharacters += current.value.textContent.length; + } + } + current = it.next(); + } + return usedCharacters; + } + getStartAndEndSelection() { + const selection = document.getSelection(); + return { + startElement: selection.anchorNode || this.el, + startSelectionOffset: selection.anchorOffset, + endElement: selection.focusNode || this.el, + endSelectionOffset: selection.focusOffset, + }; + } + } + + // ----------------------------------------------------------------------------- + // Formula Assistant component + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-formula-assistant { + white-space: normal; + background-color: #fff; + .o-formula-assistant-head { + background-color: #f2f2f2; + padding: 10px; + } + .o-formula-assistant-core { + padding: 0px 0px 10px 0px; + margin: 10px; + border-bottom: 1px solid gray; + } + .o-formula-assistant-arg { + padding: 0px 10px 10px 10px; + display: flex; + flex-direction: column; + } + .o-formula-assistant-arg-description { + font-size: 85%; + } + .o-formula-assistant-focus { + div:first-child, + span { + color: purple; + text-shadow: 0px 0px 1px purple; + } + div:last-child { + color: black; + } + } + .o-formula-assistant-gray { + color: gray; + } + } + .o-formula-assistant-container { + user-select: none; + } + .o-formula-assistant-event-none { + pointer-events: none; + } + .o-formula-assistant-event-auto { + pointer-events: auto; + } + .o-formula-assistant-transparency { + opacity: 0.3; + } +`; + class FunctionDescriptionProvider extends owl.Component { + constructor() { + super(...arguments); + this.assistantState = owl.useState({ + allowCellSelectionBehind: false, + }); + this.timeOutId = 0; + } + setup() { + owl.onWillUnmount(() => { + if (this.timeOutId) { + clearTimeout(this.timeOutId); + } + }); + } + getContext() { + return this.props; + } + onMouseMove() { + this.assistantState.allowCellSelectionBehind = true; + if (this.timeOutId) { + clearTimeout(this.timeOutId); + } + this.timeOutId = setTimeout(() => { + this.assistantState.allowCellSelectionBehind = false; + }, 2000); + } + } + FunctionDescriptionProvider.template = "o-spreadsheet-FunctionDescriptionProvider"; + + const functions = functionRegistry.content; + const ASSISTANT_WIDTH = 300; + const FunctionColor = "#4a4e4d"; + const OperatorColor = "#3da4ab"; + const StringColor = "#00a82d"; + const SelectionIndicatorColor = "darkgrey"; + const NumberColor = "#02c39a"; + const MatchingParenColor = "black"; + const SelectionIndicatorClass = "selector-flag"; + const tokenColor = { + OPERATOR: OperatorColor, + NUMBER: NumberColor, + STRING: StringColor, + FUNCTION: FunctionColor, + DEBUGGER: OperatorColor, + LEFT_PAREN: FunctionColor, + RIGHT_PAREN: FunctionColor, + COMMA: FunctionColor, + }; + css /* scss */ ` + .o-composer-container { + padding: 0; + margin: 0; + border: 0; + z-index: ${ComponentsImportance.Composer}; + flex-grow: 1; + max-height: inherit; + .o-composer { + caret-color: black; + padding-left: 3px; + padding-right: 3px; + word-break: break-all; + &:focus { + outline: none; + } + &.unfocusable { + pointer-events: none; + } + span { + white-space: pre; + &.${SelectionIndicatorClass}:after { + content: "${SelectionIndicator}"; + color: ${SelectionIndicatorColor}; + } + } + } + .o-composer-assistant { + position: absolute; + margin: 4px; + pointer-events: none; + } + } + + /* Custom css to highlight topbar composer on focus */ + .o-topbar-toolbar .o-composer-container:focus-within { + border: 1px solid ${SELECTION_BORDER_COLOR}; + } +`; + class Composer extends owl.Component { + constructor() { + super(...arguments); + this.composerRef = owl.useRef("o_composer"); + this.contentHelper = new ContentEditableHelper(this.composerRef.el); + this.composerState = owl.useState({ + positionStart: 0, + positionEnd: 0, + }); + this.autoCompleteState = owl.useState({ + showProvider: false, + provider: "functions", + search: "", + }); + this.functionDescriptionState = owl.useState({ + showDescription: false, + functionName: "", + functionDescription: {}, + argToFocus: 0, + }); + this.isKeyStillDown = false; + this.borderStyle = `box-shadow: 0 1px 4px 3px rgba(60, 64, 67, 0.15);`; + // we can't allow input events to be triggered while we remove and add back the content of the composer in processContent + this.shouldProcessInputEvents = false; + this.tokens = []; + this.keyMapping = { + ArrowUp: this.processArrowKeys, + ArrowDown: this.processArrowKeys, + ArrowLeft: this.processArrowKeys, + ArrowRight: this.processArrowKeys, + Enter: this.processEnterKey, + Escape: this.processEscapeKey, + F2: () => console.warn("Not implemented"), + F4: this.processF4Key, + Tab: (ev) => this.processTabKey(ev), + }; + } + get assistantStyle() { + if (this.props.delimitation && this.props.rect) { + const { x: cellX, y: cellY, height: cellHeight } = this.props.rect; + const remainingHeight = this.props.delimitation.height - (cellY + cellHeight); + let assistantStyle = ""; + if (cellY > remainingHeight) { + // render top + assistantStyle += ` + top: -8px; + transform: translate(0, -100%); + `; + } + if (cellX + ASSISTANT_WIDTH > this.props.delimitation.width) { + // render left + assistantStyle += `right:0px;`; + } + return (assistantStyle += `width:${ASSISTANT_WIDTH}px;`); + } + return `width:${ASSISTANT_WIDTH}px;`; + } + setup() { + owl.onMounted(() => { + const el = this.composerRef.el; + this.contentHelper.updateEl(el); + this.processContent(); + }); + owl.onWillUnmount(() => { + var _a, _b; + (_b = (_a = this.props).onComposerUnmounted) === null || _b === void 0 ? void 0 : _b.call(_a); + }); + owl.onPatched(() => { + if (!this.isKeyStillDown) { + this.processContent(); + } + }); + } + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + processArrowKeys(ev) { + if (this.env.model.getters.isSelectingForComposer()) { + this.functionDescriptionState.showDescription = false; + // Prevent the default content editable behavior which moves the cursor + // but don't stop the event and let it bubble to the grid which will + // update the selection accordingly + ev.preventDefault(); + return; + } + const content = this.env.model.getters.getCurrentContent(); + if (this.props.focus === "cellFocus" && + !this.autoCompleteState.showProvider && + !content.startsWith("=")) { + this.env.model.dispatch("STOP_EDITION"); + return; + } + ev.stopPropagation(); + // only for arrow up and down + if (["ArrowUp", "ArrowDown"].includes(ev.key) && + this.autoCompleteState.showProvider && + this.autocompleteAPI) { + ev.preventDefault(); + if (ev.key === "ArrowUp") { + this.autocompleteAPI.moveUp(); + } + else { + this.autocompleteAPI.moveDown(); + } + } + } + processTabKey(ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (this.autoCompleteState.showProvider && this.autocompleteAPI) { + const autoCompleteValue = this.autocompleteAPI.getValueToFill(); + if (autoCompleteValue) { + this.autoComplete(autoCompleteValue); + return; + } + } + else { + // when completing with tab, if there is no value to complete, the active cell will be moved to the right. + // we can't let the model think that it is for a ref selection. + // todo: check if this can be removed someday + this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + } + const direction = ev.shiftKey ? "left" : "right"; + this.env.model.dispatch("STOP_EDITION"); + this.env.model.selection.moveAnchorCell(direction, "one"); + } + processEnterKey(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.isKeyStillDown = false; + if (this.autoCompleteState.showProvider && this.autocompleteAPI) { + const autoCompleteValue = this.autocompleteAPI.getValueToFill(); + if (autoCompleteValue) { + this.autoComplete(autoCompleteValue); + return; + } + } + this.env.model.dispatch("STOP_EDITION"); + const direction = ev.shiftKey ? "up" : "down"; + this.env.model.selection.moveAnchorCell(direction, "one"); + } + processEscapeKey() { + this.env.model.dispatch("STOP_EDITION", { cancel: true }); + } + processF4Key() { + this.env.model.dispatch("CYCLE_EDITION_REFERENCES"); + this.processContent(); + } + onKeydown(ev) { + let handler = this.keyMapping[ev.key]; + if (handler) { + handler.call(this, ev); + } + else { + ev.stopPropagation(); + } + const { start, end } = this.contentHelper.getCurrentSelection(); + if (!this.env.model.getters.isSelectingForComposer()) { + this.env.model.dispatch("CHANGE_COMPOSER_CURSOR_SELECTION", { start, end }); + this.isKeyStillDown = true; + } + } + /* + * Triggered automatically by the content-editable between the keydown and key up + * */ + onInput() { + if (this.props.focus === "inactive" || !this.shouldProcessInputEvents) { + return; + } + this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + const el = this.composerRef.el; + this.env.model.dispatch("SET_CURRENT_CONTENT", { + content: el.childNodes.length ? el.textContent : "", + selection: this.contentHelper.getCurrentSelection(), + }); + } + onKeyup(ev) { + this.isKeyStillDown = false; + if (this.props.focus === "inactive" || + ["Control", "Shift", "Tab", "Enter", "F4"].includes(ev.key)) { + return; + } + if (this.autoCompleteState.showProvider && ["ArrowUp", "ArrowDown"].includes(ev.key)) { + return; // already processed in keydown + } + if (this.env.model.getters.isSelectingForComposer() && + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(ev.key)) { + return; // already processed in keydown + } + ev.preventDefault(); + ev.stopPropagation(); + this.autoCompleteState.showProvider = false; + if (ev.ctrlKey && ev.key === " ") { + this.autoCompleteState.search = ""; + this.autoCompleteState.showProvider = true; + this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + return; + } + const { start: oldStart, end: oldEnd } = this.env.model.getters.getComposerSelection(); + const { start, end } = this.contentHelper.getCurrentSelection(); + if (start !== oldStart || end !== oldEnd) { + this.env.model.dispatch("CHANGE_COMPOSER_CURSOR_SELECTION", this.contentHelper.getCurrentSelection()); + } + this.processTokenAtCursor(); + this.processContent(); + } + onMousedown(ev) { + if (ev.button > 0) { + // not main button, probably a context menu + return; + } + this.contentHelper.removeSelection(); + } + onClick() { + if (this.env.model.getters.isReadonly()) { + return; + } + const newSelection = this.contentHelper.getCurrentSelection(); + this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + if (this.props.focus === "inactive") { + this.props.onComposerContentFocused(newSelection); + } + this.env.model.dispatch("CHANGE_COMPOSER_CURSOR_SELECTION", newSelection); + this.processTokenAtCursor(); + } + onBlur() { + this.isKeyStillDown = false; + } + onCompleted(text) { + text && this.autoComplete(text); + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + processContent() { + this.contentHelper.removeAll(); // removes the content of the composer, to be added just after + this.shouldProcessInputEvents = false; + if (this.props.focus !== "inactive") { + this.contentHelper.selectRange(0, 0); // move the cursor inside the composer at 0 0. + } + const content = this.getContent(); + if (content.length !== 0) { + this.contentHelper.setText(content); + const { start, end } = this.env.model.getters.getComposerSelection(); + if (this.props.focus !== "inactive") { + // Put the cursor back where it was before the rendering + this.contentHelper.selectRange(start, end); + } + } + this.shouldProcessInputEvents = true; + } + getContent() { + let content; + let value = this.env.model.getters.getCurrentContent(); + if (value === "") { + content = []; + } + else if (value.startsWith("=") && this.props.focus !== "inactive") { + content = this.getColoredTokens(); + } + else { + content = [{ value }]; + } + return content; + } + getColoredTokens() { + const tokens = this.env.model.getters.getCurrentTokens(); + const tokenAtCursor = this.env.model.getters.getTokenAtCursor(); + const result = []; + const { end, start } = this.env.model.getters.getComposerSelection(); + for (let token of tokens) { + switch (token.type) { + case "OPERATOR": + case "NUMBER": + case "FUNCTION": + case "COMMA": + case "STRING": + result.push({ value: token.value, color: tokenColor[token.type] || "#000" }); + break; + case "REFERENCE": + const [xc, sheet] = token.value.split("!").reverse(); + result.push({ value: token.value, color: this.rangeColor(xc, sheet) || "#000" }); + break; + case "SYMBOL": + let value = token.value; + if (["TRUE", "FALSE"].includes(value.toUpperCase())) { + result.push({ value: token.value, color: NumberColor }); + } + else { + result.push({ value: token.value, color: "#000" }); + } + break; + case "LEFT_PAREN": + case "RIGHT_PAREN": + // Compute the matching parenthesis + if (tokenAtCursor && + ["LEFT_PAREN", "RIGHT_PAREN"].includes(tokenAtCursor.type) && + tokenAtCursor.parenIndex && + tokenAtCursor.parenIndex === token.parenIndex) { + result.push({ value: token.value, color: MatchingParenColor }); + } + else { + result.push({ value: token.value, color: tokenColor[token.type] || "#000" }); + } + break; + default: + result.push({ value: token.value, color: "#000" }); + break; + } + if (this.env.model.getters.showSelectionIndicator() && end === start && end === token.end) { + result[result.length - 1].class = SelectionIndicatorClass; + } + } + return result; + } + rangeColor(xc, sheetName) { + if (this.props.focus === "inactive") { + return undefined; + } + const highlights = this.env.model.getters.getHighlights(); + const refSheet = sheetName + ? this.env.model.getters.getSheetIdByName(sheetName) + : this.env.model.getters.getEditionSheet(); + const highlight = highlights.find((highlight) => { + if (highlight.sheetId !== refSheet) + return false; + const range = this.env.model.getters.getRangeFromSheetXC(refSheet, xc); + let zone = range.zone; + const { height, width } = zoneToDimension(zone); + zone = height * width === 1 ? this.env.model.getters.expandZone(refSheet, zone) : zone; + return isEqual(zone, highlight.zone); + }); + return highlight && highlight.color ? highlight.color : undefined; + } + /** + * Compute the state of the composer from the tokenAtCursor. + * If the token is a function or symbol (that isn't a cell/range reference) we have to initialize + * the autocomplete engine otherwise we initialize the formula assistant. + */ + processTokenAtCursor() { + let content = this.env.model.getters.getCurrentContent(); + this.autoCompleteState.showProvider = false; + this.functionDescriptionState.showDescription = false; + if (content.startsWith("=")) { + const tokenAtCursor = this.env.model.getters.getTokenAtCursor(); + if (tokenAtCursor) { + const [xc] = tokenAtCursor.value.split("!").reverse(); + if (tokenAtCursor.type === "FUNCTION" || + (tokenAtCursor.type === "SYMBOL" && !rangeReference.test(xc))) { + // initialize Autocomplete Dropdown + this.autoCompleteState.search = tokenAtCursor.value; + this.autoCompleteState.showProvider = true; + } + else if (tokenAtCursor.functionContext && tokenAtCursor.type !== "UNKNOWN") { + // initialize Formula Assistant + const tokenContext = tokenAtCursor.functionContext; + const parentFunction = tokenContext.parent.toUpperCase(); + const description = functions[parentFunction]; + const argPosition = tokenContext.argPosition; + this.functionDescriptionState.functionName = parentFunction; + this.functionDescriptionState.functionDescription = description; + this.functionDescriptionState.argToFocus = description.getArgToFocus(argPosition + 1) - 1; + this.functionDescriptionState.showDescription = true; + } + } + } + } + autoComplete(value) { + if (value) { + const tokenAtCursor = this.env.model.getters.getTokenAtCursor(); + if (tokenAtCursor) { + let start = tokenAtCursor.end; + let end = tokenAtCursor.end; + // shouldn't it be REFERENCE ? + if (["SYMBOL", "FUNCTION"].includes(tokenAtCursor.type)) { + start = tokenAtCursor.start; + } + const tokens = this.env.model.getters.getCurrentTokens(); + if (this.autoCompleteState.provider && tokens.length) { + value += "("; + const currentTokenIndex = tokens.map((token) => token.start).indexOf(tokenAtCursor.start); + if (currentTokenIndex + 1 < tokens.length) { + const nextToken = tokens[currentTokenIndex + 1]; + if (nextToken.type === "LEFT_PAREN") { + end++; + } + } + } + this.env.model.dispatch("CHANGE_COMPOSER_CURSOR_SELECTION", { + start, + end, + }); + } + this.env.model.dispatch("REPLACE_COMPOSER_CURSOR_SELECTION", { + text: value, + }); + } + this.processTokenAtCursor(); + } + } + Composer.template = "o-spreadsheet-Composer"; + Composer.components = { TextValueProvider, FunctionDescriptionProvider }; + Composer.defaultProps = { + inputStyle: "", + focus: "inactive", + }; + + const SCROLLBAR_WIDTH = 14; + const SCROLLBAR_HIGHT = 15; + const COMPOSER_BORDER_WIDTH = 3 * 0.4 * window.devicePixelRatio || 1; + css /* scss */ ` + div.o-grid-composer { + z-index: ${ComponentsImportance.Composer}; + box-sizing: border-box; + position: absolute; + border: ${COMPOSER_BORDER_WIDTH}px solid ${SELECTION_BORDER_COLOR}; + } +`; + /** + * This component is a composer which positions itself on the grid at the anchor cell. + * It also applies the style of the cell to the composer input. + */ + class GridComposer extends owl.Component { + setup() { + this.gridComposerRef = owl.useRef("gridComposer"); + this.composerState = owl.useState({ + rect: null, + delimitation: null, + }); + const { col, row } = this.env.model.getters.getPosition(); + this.zone = this.env.model.getters.expandZone(this.env.model.getters.getActiveSheetId(), { + left: col, + right: col, + top: row, + bottom: row, + }); + this.rect = this.env.model.getters.getRect(this.zone, this.env.model.getters.getActiveViewport()); + owl.onMounted(() => { + const el = this.gridComposerRef.el; + //TODO Should be more correct to have a props that give the parent's clientHeight and clientWidth + const maxHeight = el.parentElement.clientHeight - this.rect.y - SCROLLBAR_HIGHT; + el.style.maxHeight = (maxHeight + "px"); + const maxWidth = el.parentElement.clientWidth - this.rect.x - SCROLLBAR_WIDTH; + el.style.maxWidth = (maxWidth + "px"); + this.composerState.rect = { + x: this.rect.x, + y: this.rect.y, + width: el.clientWidth, + height: el.clientHeight, + }; + this.composerState.delimitation = { + width: el.parentElement.clientWidth, + height: el.parentElement.clientHeight, + }; + }); + } + get containerStyle() { + const isFormula = this.env.model.getters.getCurrentContent().startsWith("="); + const cell = this.env.model.getters.getActiveCell(); + let style = {}; + if (cell) { + const cellPosition = this.env.model.getters.getCellPosition(cell.id); + style = this.env.model.getters.getCellComputedStyle(cellPosition.sheetId, cellPosition.col, cellPosition.row); + } + // position style + const { x: left, y: top, width, height } = this.rect; + // color style + const background = (!isFormula && style.fillColor) || "#ffffff"; + const color = (!isFormula && style.textColor) || "#000000"; + // font style + const fontSize = (!isFormula && style.fontSize) || 10; + const fontWeight = !isFormula && style.bold ? "bold" : 500; + const fontStyle = !isFormula && style.italic ? "italic" : "normal"; + const textDecoration = !isFormula ? getTextDecoration(style) : "none"; + // align style + let textAlign = "left"; + if (!isFormula) { + textAlign = style.align || (cell === null || cell === void 0 ? void 0 : cell.defaultAlign) || "left"; + } + return ` + left: ${left - 1}px; + top: ${top}px; + min-width: ${width + 2}px; + min-height: ${height + 1}px; + + background: ${background}; + color: ${color}; + + font-size: ${fontSizeMap[fontSize]}px; + font-weight: ${fontWeight}; + font-style: ${fontStyle}; + text-decoration: ${textDecoration}; + + text-align: ${textAlign}; + `; + } + get composerStyle() { + return ` + line-height: ${DEFAULT_CELL_HEIGHT}px; + max-height: inherit; + overflow: hidden; + `; + } + } + GridComposer.template = "o-spreadsheet-GridComposer"; + GridComposer.components = { Composer }; + + // ----------------------------------------------------------------------------- + // STYLE + // ----------------------------------------------------------------------------- + const ANCHOR_SIZE = 8; + const BORDER_WIDTH = 1; + const ACTIVE_BORDER_WIDTH = 2; + const MIN_FIG_SIZE = 80; + css /*SCSS*/ ` + .o-figure-wrapper { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + } + + div.o-figure { + box-sizing: content-box; + position: absolute; + bottom: 0px; + right: 0px; + border: solid ${FIGURE_BORDER_COLOR}; + &:focus { + outline: none; + } + &.active { + border: solid ${SELECTION_BORDER_COLOR}; + } + + &.o-dragging { + opacity: 0.9; + cursor: grabbing; + } + } + + .o-figure-container { + position: absolute; + box-sizing: content-box; + + .o-anchor { + z-index: ${ComponentsImportance.ChartAnchor}; + position: absolute; + width: ${ANCHOR_SIZE}px; + height: ${ANCHOR_SIZE}px; + background-color: #1a73e8; + outline: ${BORDER_WIDTH}px solid white; + + &.o-top { + cursor: n-resize; + } + &.o-topRight { + cursor: ne-resize; + } + &.o-right { + cursor: e-resize; + } + &.o-bottomRight { + cursor: se-resize; + } + &.o-bottom { + cursor: s-resize; + } + &.o-bottomLeft { + cursor: sw-resize; + } + &.o-left { + cursor: w-resize; + } + &.o-topLeft { + cursor: nw-resize; + } + } + } +`; + class FiguresContainer extends owl.Component { + constructor() { + super(...arguments); + this.figureRegistry = figureRegistry; + this.dnd = owl.useState({ + figureId: "", + x: 0, + y: 0, + width: 0, + height: 0, + }); + } + getVisibleFigures() { + const selectedId = this.env.model.getters.getSelectedFigureId(); + return this.env.model.getters.getVisibleFigures().map((f) => { + let figure = f; + // Returns current state of drag&drop figure instead of its stored state + if (this.dnd.figureId === f.id) { + figure = { + ...f, + x: this.dnd.x, + y: this.dnd.y, + width: this.dnd.width, + height: this.dnd.height, + }; + } + return { + id: f.id, + isSelected: f.id === selectedId, + figure: figure, + }; + }); + } + /** Get the current figure size, which is either the stored figure size of the DnD figure size */ + getFigureSize(info) { + const { figure, isSelected } = info; + const target = figure.id === (isSelected && this.dnd.figureId) ? this.dnd : figure; + const { width, height } = target; + return { width, height }; + } + getFigureSizeWithBorders(info) { + const { width, height } = this.getFigureSize(info); + const borders = this.getBorderWidth(info) * 2; + return { width: width + borders, height: height + borders }; + } + getBorderWidth(info) { + return info.isSelected ? ACTIVE_BORDER_WIDTH : this.env.isDashboard() ? 0 : BORDER_WIDTH; + } + getFigureStyle(info) { + const { width, height } = info.figure; + return `width:${width}px;height:${height}px;border-width: ${this.getBorderWidth(info)}px;`; + } + /** Get the overflow of the figure in the headers of the grid */ + getOverflow(info) { + const { figure, isSelected } = info; + const { offsetX, offsetY } = this.env.model.getters.getActiveViewport(); + const target = figure.id === (isSelected && this.dnd.figureId) ? this.dnd : figure; + let x = target.x - offsetX; + let y = target.y - offsetY; + const overflowX = this.env.isDashboard() ? 0 : Math.max(0, -x); + const overflowY = this.env.isDashboard() ? 0 : Math.max(0, -y); + return { overflowX, overflowY }; + } + getContainerStyle(info) { + const { figure, isSelected } = info; + const { offsetX, offsetY } = this.env.model.getters.getActiveViewport(); + const target = figure.id === (isSelected && this.dnd.figureId) ? this.dnd : figure; + const x = target.x - offsetX; + const y = target.y - offsetY; + const { width, height } = this.getFigureSizeWithBorders(info); + const { overflowX, overflowY } = this.getOverflow(info); + if (width < 0 || height < 0) { + return `display:none;`; + } + const borderOffset = BORDER_WIDTH - this.getBorderWidth(info); + // TODO : remove the +1 once 2951210 is fixed + return (`top:${y + borderOffset + overflowY + 1}px;` + + `left:${x + borderOffset + overflowX}px;` + + `width:${width - overflowX}px;` + + `height:${height - overflowY}px;` + + `z-index: ${ComponentsImportance.Figure + (info.isSelected ? 1 : 0)}`); + } + getAnchorPosition(anchor, info) { + const { width, height } = this.getFigureSizeWithBorders(info); + const { overflowX, overflowY } = this.getOverflow(info); + const anchorCenteringOffset = (ANCHOR_SIZE - ACTIVE_BORDER_WIDTH) / 2; + let x = 0; + let y = 0; + if (anchor.includes("top")) { + y = -anchorCenteringOffset; + } + else if (anchor.includes("bottom")) { + y = height - ACTIVE_BORDER_WIDTH - anchorCenteringOffset; + } + else { + y = (height - ACTIVE_BORDER_WIDTH) / 2 - anchorCenteringOffset; + } + if (anchor.includes("left")) { + x = -anchorCenteringOffset; + } + else if (anchor.includes("right")) { + x = width - ACTIVE_BORDER_WIDTH - anchorCenteringOffset; + } + else { + x = (width - ACTIVE_BORDER_WIDTH) / 2 - anchorCenteringOffset; + } + let visibility = "visible"; + if (overflowX && x < overflowX) { + visibility = "hidden"; + } + else if (overflowY && y < overflowY) { + visibility = "hidden"; + } + return `visibility : ${visibility};top:${y - overflowY}px; left:${x - overflowX}px;`; + } + setup() { + owl.onMounted(() => { + // horrible, but necessary + // the following line ensures that we render the figures with the correct + // viewport. The reason is that whenever we initialize the grid + // component, we do not know yet the actual size of the viewport, so the + // first owl rendering is done with an empty viewport. Only then we can + // compute which figures should be displayed, so we have to force a + // new rendering + this.render(); + }); + } + resize(figure, dirX, dirY, ev) { + ev.stopPropagation(); + const initialX = ev.clientX; + const initialY = ev.clientY; + this.dnd.figureId = figure.id; + this.dnd.x = figure.x; + this.dnd.y = figure.y; + this.dnd.width = figure.width; + this.dnd.height = figure.height; + const onMouseMove = (ev) => { + const deltaX = dirX * (ev.clientX - initialX); + const deltaY = dirY * (ev.clientY - initialY); + this.dnd.width = Math.max(figure.width + deltaX, MIN_FIG_SIZE); + this.dnd.height = Math.max(figure.height + deltaY, MIN_FIG_SIZE); + if (dirX < 0) { + this.dnd.x = figure.x - deltaX; + } + if (dirY < 0) { + this.dnd.y = figure.y - deltaY; + } + }; + const onMouseUp = (ev) => { + this.dnd.figureId = ""; + const update = { + x: this.dnd.x, + y: this.dnd.y, + }; + if (dirX) { + update.width = this.dnd.width; + } + if (dirY) { + update.height = this.dnd.height; + } + this.env.model.dispatch("UPDATE_FIGURE", { + sheetId: this.env.model.getters.getActiveSheetId(), + id: figure.id, + ...update, + }); + }; + startDnd(onMouseMove, onMouseUp); + } + onMouseDown(figure, ev) { + if (ev.button > 0 || this.env.model.getters.isReadonly()) { + // not main button, probably a context menu + return; + } + const selectResult = this.env.model.dispatch("SELECT_FIGURE", { id: figure.id }); + if (!selectResult.isSuccessful) { + return; + } + if (this.props.sidePanelIsOpen) { + this.env.openSidePanel("ChartPanel", { figureId: figure.id }); + } + const initialX = ev.clientX; + const initialY = ev.clientY; + this.dnd.figureId = figure.id; + this.dnd.x = figure.x; + this.dnd.y = figure.y; + this.dnd.width = figure.width; + this.dnd.height = figure.height; + const onMouseMove = (ev) => { + this.dnd.x = Math.max(figure.x - initialX + ev.clientX, 0); + this.dnd.y = Math.max(figure.y - initialY + ev.clientY, 0); + }; + const onMouseUp = (ev) => { + this.dnd.figureId = ""; + this.env.model.dispatch("UPDATE_FIGURE", { + sheetId: this.env.model.getters.getActiveSheetId(), + id: figure.id, + x: this.dnd.x, + y: this.dnd.y, + }); + }; + startDnd(onMouseMove, onMouseUp); + } + onKeyDown(figure, ev) { + switch (ev.key) { + case "Delete": + this.env.model.dispatch("DELETE_FIGURE", { + sheetId: this.env.model.getters.getActiveSheetId(), + id: figure.id, + }); + this.props.onFigureDeleted(); + ev.preventDefault(); + break; + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + case "ArrowUp": + const deltaMap = { + ArrowDown: [0, 1], + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + }; + const delta = deltaMap[ev.key]; + this.env.model.dispatch("UPDATE_FIGURE", { + sheetId: this.env.model.getters.getActiveSheetId(), + id: figure.id, + x: figure.x + delta[0], + y: figure.y + delta[1], + }); + ev.preventDefault(); + break; + } + } + } + FiguresContainer.template = "o-spreadsheet-FiguresContainer"; + FiguresContainer.components = {}; + figureRegistry.add("chart", { Component: ChartFigure, SidePanelComponent: "ChartPanel" }); + + class AbstractResizer extends owl.Component { + constructor() { + super(...arguments); + this.PADDING = 0; + this.MAX_SIZE_MARGIN = 0; + this.MIN_ELEMENT_SIZE = 0; + this.lastSelectedElementIndex = null; + this.state = owl.useState({ + resizerIsActive: false, + isResizing: false, + isMoving: false, + isSelecting: false, + waitingForMove: false, + activeElement: 0, + draggerLinePosition: 0, + draggerShadowPosition: 0, + draggerShadowThickness: 0, + delta: 0, + base: 0, + }); + } + _computeHandleDisplay(ev) { + const position = this._getEvOffset(ev); + const elementIndex = this._getElementIndex(position); + if (elementIndex < 0) { + return; + } + const dimensions = this._getDimensionsInViewport(elementIndex); + if (position - dimensions.start < this.PADDING && elementIndex !== this._getViewportOffset()) { + this.state.resizerIsActive = true; + this.state.draggerLinePosition = dimensions.start; + this.state.activeElement = this._getPreviousVisibleElement(elementIndex); + } + else if (dimensions.end - position < this.PADDING) { + this.state.resizerIsActive = true; + this.state.draggerLinePosition = dimensions.end; + this.state.activeElement = elementIndex; + } + else { + this.state.resizerIsActive = false; + } + } + _computeGrabDisplay(ev) { + const index = this._getElementIndex(this._getEvOffset(ev)); + const activeElements = this._getActiveElements(); + const selectedZoneStart = this._getSelectedZoneStart(); + const selectedZoneEnd = this._getSelectedZoneEnd(); + if (activeElements.has(selectedZoneStart)) { + if (selectedZoneStart <= index && index <= selectedZoneEnd) { + this.state.waitingForMove = true; + return; + } + } + this.state.waitingForMove = false; + } + onMouseMove(ev) { + if (this.state.isResizing || this.state.isMoving || this.state.isSelecting) { + return; + } + this._computeHandleDisplay(ev); + this._computeGrabDisplay(ev); + } + onMouseLeave() { + this.state.resizerIsActive = this.state.isResizing; + this.state.waitingForMove = false; + } + onDblClick(ev) { + this._fitElementSize(this.state.activeElement); + this.state.isResizing = false; + this._computeHandleDisplay(ev); + this._computeGrabDisplay(ev); + } + onMouseDown(ev) { + this.state.isResizing = true; + this.state.delta = 0; + const initialPosition = this._getClientPosition(ev); + const styleValue = this.state.draggerLinePosition; + const size = this._getElementSize(this.state.activeElement); + const minSize = styleValue - size + this.MIN_ELEMENT_SIZE; + const maxSize = this._getMaxSize(); + const onMouseUp = (ev) => { + this.state.isResizing = false; + if (this.state.delta !== 0) { + this._updateSize(); + } + }; + const onMouseMove = (ev) => { + this.state.delta = this._getClientPosition(ev) - initialPosition; + this.state.draggerLinePosition = styleValue + this.state.delta; + if (this.state.draggerLinePosition < minSize) { + this.state.draggerLinePosition = minSize; + this.state.delta = this.MIN_ELEMENT_SIZE - size; + } + if (this.state.draggerLinePosition > maxSize) { + this.state.draggerLinePosition = maxSize; + this.state.delta = maxSize - styleValue; + } + }; + startDnd(onMouseMove, onMouseUp); + } + select(ev) { + if (ev.button > 0) { + // not main button, probably a context menu + return; + } + const index = this._getElementIndex(this._getEvOffset(ev)); + if (index < 0) { + return; + } + if (this.state.waitingForMove === true) { + this.startMovement(ev); + return; + } + this.startSelection(ev, index); + } + startMovement(ev) { + this.state.waitingForMove = false; + this.state.isMoving = true; + const startDimensions = this._getDimensionsInViewport(this._getSelectedZoneStart()); + const endDimensions = this._getDimensionsInViewport(this._getSelectedZoneEnd()); + const initialPosition = this._getClientPosition(ev); + const defaultPosition = startDimensions.start; + this.state.draggerLinePosition = defaultPosition; + this.state.base = this._getSelectedZoneStart(); + this.state.draggerShadowPosition = defaultPosition; + this.state.draggerShadowThickness = endDimensions.end - startDimensions.start; + const mouseMoveMovement = (elementIndex, currentEv) => { + if (elementIndex >= 0) { + // define draggerLinePosition + const dimensions = this._getDimensionsInViewport(elementIndex); + if (elementIndex <= this._getSelectedZoneStart()) { + this.state.draggerLinePosition = dimensions.start; + this.state.base = elementIndex; + } + else if (this._getSelectedZoneEnd() < elementIndex) { + this.state.draggerLinePosition = dimensions.end; + this.state.base = elementIndex + 1; + } + else { + this.state.draggerLinePosition = startDimensions.start; + this.state.base = this._getSelectedZoneStart(); + } + // define draggerShadowPosition + const delta = this._getClientPosition(currentEv) - initialPosition; + this.state.draggerShadowPosition = Math.max(defaultPosition + delta, 0); + } + }; + const mouseUpMovement = (finalEv) => { + this.state.isMoving = false; + if (this.state.base !== this._getSelectedZoneStart()) { + this._moveElements(); + } + this._computeGrabDisplay(finalEv); + }; + this.dragOverlayBeyondTheViewport(ev, mouseMoveMovement, mouseUpMovement); + } + startSelection(ev, index) { + this.state.isSelecting = true; + if (ev.shiftKey) { + this._increaseSelection(index); + } + else { + this._selectElement(index, ev.ctrlKey); + } + this.lastSelectedElementIndex = index; + const mouseMoveSelect = (elementIndex) => { + if (elementIndex !== this.lastSelectedElementIndex && elementIndex !== -1) { + this._increaseSelection(elementIndex); + this.lastSelectedElementIndex = elementIndex; + } + }; + const mouseUpSelect = () => { + this.state.isSelecting = false; + this.lastSelectedElementIndex = null; + this.env.model.dispatch(ev.ctrlKey ? "PREPARE_SELECTION_INPUT_EXPANSION" : "STOP_SELECTION_INPUT"); + this._computeGrabDisplay(ev); + }; + this.dragOverlayBeyondTheViewport(ev, mouseMoveSelect, mouseUpSelect); + } + dragOverlayBeyondTheViewport(ev, cbMouseMove, cbMouseUp) { + let timeOutId = null; + let currentEv; + const initialPosition = this._getClientPosition(ev); + const initialOffset = this._getEvOffset(ev); + const onMouseMove = (ev) => { + // currentEv.target can be any DOM element + currentEv = ev; + if (timeOutId) { + return; + } + const delta = this._getClientPosition(currentEv) - initialPosition; + const position = initialOffset + delta; + const EdgeScrollInfo = this._getEdgeScroll(position); + const { first, last } = this._getBoundaries(); + let elementIndex; + if (EdgeScrollInfo.canEdgeScroll) { + elementIndex = EdgeScrollInfo.direction > 0 ? last : first - 1; + } + else { + elementIndex = this._getElementIndex(position); + } + cbMouseMove(elementIndex, currentEv); + // adjust viewport if necessary + if (EdgeScrollInfo.canEdgeScroll) { + this._adjustViewport(EdgeScrollInfo.direction); + timeOutId = setTimeout(() => { + timeOutId = null; + onMouseMove(currentEv); + }, Math.round(EdgeScrollInfo.delay)); + } + }; + const onMouseUp = (finalEv) => { + clearTimeout(timeOutId); + cbMouseUp(finalEv); + }; + startDnd(onMouseMove, onMouseUp); + } + onMouseUp(ev) { + this.lastSelectedElementIndex = null; + } + onContextMenu(ev) { + ev.preventDefault(); + const index = this._getElementIndex(this._getEvOffset(ev)); + if (index < 0) + return; + if (!this._getActiveElements().has(index)) { + this._selectElement(index, false); + } + const type = this._getType(); + this.props.onOpenContextMenu(type, ev.clientX, ev.clientY); + } + } + css /* scss */ ` + .o-col-resizer { + position: absolute; + top: 0; + left: ${HEADER_WIDTH}px; + right: 0; + height: ${HEADER_HEIGHT}px; + &.o-dragging { + cursor: grabbing; + } + &.o-grab { + cursor: grab; + } + .dragging-col-line { + top: ${HEADER_HEIGHT}px; + position: absolute; + width: 2px; + height: 10000px; + background-color: black; + } + .dragging-col-shadow { + top: ${HEADER_HEIGHT}px; + position: absolute; + height: 10000px; + background-color: black; + opacity: 0.1; + } + .o-handle { + position: absolute; + height: ${HEADER_HEIGHT}px; + width: 4px; + cursor: e-resize; + background-color: ${SELECTION_BORDER_COLOR}; + } + .dragging-resizer { + top: ${HEADER_HEIGHT}px; + position: absolute; + margin-left: 2px; + width: 1px; + height: 10000px; + background-color: ${SELECTION_BORDER_COLOR}; + } + .o-unhide { + width: ${UNHIDE_ICON_EDGE_LENGTH}px; + height: ${UNHIDE_ICON_EDGE_LENGTH}px; + position: absolute; + overflow: hidden; + border-radius: 2px; + top: calc(${HEADER_HEIGHT}px / 2 - ${UNHIDE_ICON_EDGE_LENGTH}px / 2); + } + .o-unhide:hover { + z-index: ${ComponentsImportance.Grid + 1}; + background-color: lightgrey; + } + .o-unhide > svg { + position: relative; + top: calc(${UNHIDE_ICON_EDGE_LENGTH}px / 2 - ${ICON_EDGE_LENGTH}px / 2); + } + } +`; + class ColResizer extends AbstractResizer { + setup() { + super.setup(); + this.colResizerRef = owl.useRef("colResizer"); + this.PADDING = 15; + this.MAX_SIZE_MARGIN = 90; + this.MIN_ELEMENT_SIZE = MIN_COL_WIDTH; + } + _getEvOffset(ev) { + return ev.offsetX; + } + _getViewportOffset() { + return this.env.model.getters.getActiveViewport().left; + } + _getClientPosition(ev) { + return ev.clientX; + } + _getElementIndex(position) { + return this.env.model.getters.getColIndex(position); + } + _getSelectedZoneStart() { + return this.env.model.getters.getSelectedZone().left; + } + _getSelectedZoneEnd() { + return this.env.model.getters.getSelectedZone().right; + } + _getEdgeScroll(position) { + return this.env.model.getters.getEdgeScrollCol(position); + } + _getBoundaries() { + const { left, right } = this.env.model.getters.getActiveViewport(); + return { first: left, last: right }; + } + _getDimensionsInViewport(index) { + return this.env.model.getters.getColDimensionsInViewport(this.env.model.getters.getActiveSheetId(), index); + } + _getElementSize(index) { + return this.env.model.getters.getColSize(this.env.model.getters.getActiveSheetId(), index); + } + _getMaxSize() { + return this.colResizerRef.el.clientWidth; + } + _updateSize() { + const index = this.state.activeElement; + const size = this.state.delta + this._getElementSize(index); + const cols = this.env.model.getters.getActiveCols(); + this.env.model.dispatch("RESIZE_COLUMNS_ROWS", { + dimension: "COL", + sheetId: this.env.model.getters.getActiveSheetId(), + elements: cols.has(index) ? [...cols] : [index], + size, + }); + } + _moveElements() { + const elements = []; + const start = this._getSelectedZoneStart(); + const end = this._getSelectedZoneEnd(); + for (let colIndex = start; colIndex <= end; colIndex++) { + elements.push(colIndex); + } + const result = this.env.model.dispatch("MOVE_COLUMNS_ROWS", { + sheetId: this.env.model.getters.getActiveSheetId(), + dimension: "COL", + base: this.state.base, + elements, + }); + if (!result.isSuccessful && result.reasons.includes(2 /* WillRemoveExistingMerge */)) { + this.env.notifyUser(_lt("Merged cells are preventing this operation. Unmerge those cells and try again.")); + } + } + _selectElement(index, ctrlKey) { + this.env.model.selection.selectColumn(index, ctrlKey ? "newAnchor" : "overrideSelection"); + } + _increaseSelection(index) { + this.env.model.selection.selectColumn(index, "updateAnchor"); + } + _adjustViewport(direction) { + const { left, offsetY } = this.env.model.getters.getActiveViewport(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const offsetX = this.env.model.getters.getColDimensions(sheetId, left + direction).start; + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY }); + } + _fitElementSize(index) { + const cols = this.env.model.getters.getActiveCols(); + this.env.model.dispatch("AUTORESIZE_COLUMNS", { + sheetId: this.env.model.getters.getActiveSheetId(), + cols: cols.has(index) ? [...cols] : [index], + }); + } + _getType() { + return "COL"; + } + _getActiveElements() { + return this.env.model.getters.getActiveCols(); + } + _getPreviousVisibleElement(index) { + const sheetId = this.env.model.getters.getActiveSheetId(); + let row; + for (row = index - 1; row >= 0; row--) { + if (!this.env.model.getters.isColHidden(sheetId, row)) { + break; + } + } + return row; + } + unhide(hiddenElements) { + this.env.model.dispatch("UNHIDE_COLUMNS_ROWS", { + sheetId: this.env.model.getters.getActiveSheetId(), + elements: hiddenElements, + dimension: "COL", + }); + } + unhideStyleValue(hiddenIndex) { + return this._getDimensionsInViewport(hiddenIndex).start; + } + } + ColResizer.template = "o-spreadsheet-ColResizer"; + css /* scss */ ` + .o-row-resizer { + position: absolute; + top: ${HEADER_HEIGHT}px; + left: 0; + right: 0; + width: ${HEADER_WIDTH}px; + height: 100%; + &.o-dragging { + cursor: grabbing; + } + &.o-grab { + cursor: grab; + } + .dragging-row-line { + left: ${HEADER_WIDTH}px; + position: absolute; + width: 10000px; + height: 2px; + background-color: black; + } + .dragging-row-shadow { + left: ${HEADER_WIDTH}px; + position: absolute; + width: 10000px; + background-color: black; + opacity: 0.1; + } + .o-handle { + position: absolute; + height: 4px; + width: ${HEADER_WIDTH}px; + cursor: n-resize; + background-color: ${SELECTION_BORDER_COLOR}; + } + .dragging-resizer { + left: ${HEADER_WIDTH}px; + position: absolute; + margin-top: 2px; + width: 10000px; + height: 1px; + background-color: ${SELECTION_BORDER_COLOR}; + } + .o-unhide { + width: ${UNHIDE_ICON_EDGE_LENGTH}px; + height: ${UNHIDE_ICON_EDGE_LENGTH}px; + position: absolute; + overflow: hidden; + border-radius: 2px; + left: calc(${HEADER_WIDTH}px - ${UNHIDE_ICON_EDGE_LENGTH}px - 2px); + } + .o-unhide > svg { + position: relative; + left: calc(${UNHIDE_ICON_EDGE_LENGTH}px / 2 - ${ICON_EDGE_LENGTH}px / 2); + top: calc(${UNHIDE_ICON_EDGE_LENGTH}px / 2 - ${ICON_EDGE_LENGTH}px / 2); + } + .o-unhide:hover { + z-index: ${ComponentsImportance.Grid + 1}; + background-color: lightgrey; + } + } +`; + class RowResizer extends AbstractResizer { + setup() { + super.setup(); + this.rowResizerRef = owl.useRef("rowResizer"); + this.PADDING = 5; + this.MAX_SIZE_MARGIN = 60; + this.MIN_ELEMENT_SIZE = MIN_ROW_HEIGHT; + } + _getEvOffset(ev) { + return ev.offsetY; + } + _getViewportOffset() { + return this.env.model.getters.getActiveViewport().top; + } + _getClientPosition(ev) { + return ev.clientY; + } + _getElementIndex(position) { + return this.env.model.getters.getRowIndex(position); + } + _getSelectedZoneStart() { + return this.env.model.getters.getSelectedZone().top; + } + _getSelectedZoneEnd() { + return this.env.model.getters.getSelectedZone().bottom; + } + _getEdgeScroll(position) { + return this.env.model.getters.getEdgeScrollRow(position); + } + _getBoundaries() { + const { top, bottom } = this.env.model.getters.getActiveViewport(); + return { first: top, last: bottom }; + } + _getDimensionsInViewport(index) { + return this.env.model.getters.getRowDimensionsInViewport(this.env.model.getters.getActiveSheetId(), index); + } + _getElementSize(index) { + return this.env.model.getters.getRowSize(this.env.model.getters.getActiveSheetId(), index); + } + _getMaxSize() { + return this.rowResizerRef.el.clientHeight; + } + _updateSize() { + const index = this.state.activeElement; + const size = this.state.delta + this._getElementSize(index); + const rows = this.env.model.getters.getActiveRows(); + this.env.model.dispatch("RESIZE_COLUMNS_ROWS", { + dimension: "ROW", + sheetId: this.env.model.getters.getActiveSheetId(), + elements: rows.has(index) ? [...rows] : [index], + size, + }); + } + _moveElements() { + const elements = []; + const start = this._getSelectedZoneStart(); + const end = this._getSelectedZoneEnd(); + for (let rowIndex = start; rowIndex <= end; rowIndex++) { + elements.push(rowIndex); + } + const result = this.env.model.dispatch("MOVE_COLUMNS_ROWS", { + sheetId: this.env.model.getters.getActiveSheetId(), + dimension: "ROW", + base: this.state.base, + elements, + }); + if (!result.isSuccessful && result.reasons.includes(2 /* WillRemoveExistingMerge */)) { + this.env.notifyUser(_lt("Merged cells are preventing this operation. Unmerge those cells and try again.")); + } + } + _selectElement(index, ctrlKey) { + this.env.model.selection.selectRow(index, ctrlKey ? "newAnchor" : "overrideSelection"); + } + _increaseSelection(index) { + this.env.model.selection.selectRow(index, "updateAnchor"); + } + _adjustViewport(direction) { + const { top, offsetX } = this.env.model.getters.getActiveViewport(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const offsetY = this.env.model.getters.getRowDimensions(sheetId, top + direction).start; + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY }); + } + _fitElementSize(index) { + const rows = this.env.model.getters.getActiveRows(); + this.env.model.dispatch("AUTORESIZE_ROWS", { + sheetId: this.env.model.getters.getActiveSheetId(), + rows: rows.has(index) ? [...rows] : [index], + }); + } + _getType() { + return "ROW"; + } + _getActiveElements() { + return this.env.model.getters.getActiveRows(); + } + _getPreviousVisibleElement(index) { + const sheetId = this.env.model.getters.getActiveSheetId(); + let row; + for (row = index - 1; row >= 0; row--) { + if (!this.env.model.getters.isRowHidden(sheetId, row)) { + break; + } + } + return row; + } + unhide(hiddenElements) { + this.env.model.dispatch("UNHIDE_COLUMNS_ROWS", { + sheetId: this.env.model.getters.getActiveSheetId(), + dimension: "ROW", + elements: hiddenElements, + }); + } + unhideStyleValue(hiddenIndex) { + return this._getDimensionsInViewport(hiddenIndex).start; + } + } + RowResizer.template = "o-spreadsheet-RowResizer"; + css /* scss */ ` + .o-overlay { + .all { + position: absolute; + top: 0; + left: 0; + right: 0; + width: ${HEADER_WIDTH}px; + height: ${HEADER_HEIGHT}px; + } + } +`; + class HeadersOverlay extends owl.Component { + selectAll() { + this.env.model.selection.selectAll(); + } + } + HeadersOverlay.template = "o-spreadsheet-HeadersOverlay"; + HeadersOverlay.components = { ColResizer, RowResizer }; + + /** + * Repeatedly calls a callback function with a time delay between calls. + */ + function useInterval(callback, delay) { + let intervalId; + const { setInterval, clearInterval } = window; + owl.useEffect(() => { + intervalId = setInterval(callback, delay); + return () => clearInterval(intervalId); + }, () => [delay]); + return { + pause: () => { + clearInterval(intervalId); + intervalId = undefined; + }, + resume: () => { + if (intervalId === undefined) { + intervalId = setInterval(callback, delay); + } + }, + }; + } + + css /* scss */ ` + .o-border { + position: absolute; + &:hover { + cursor: grab; + } + } + .o-moving { + cursor: grabbing; + } +`; + class Border extends owl.Component { + get style() { + const isTop = ["n", "w", "e"].includes(this.props.orientation); + const isLeft = ["n", "w", "s"].includes(this.props.orientation); + const isHorizontal = ["n", "s"].includes(this.props.orientation); + const isVertical = ["w", "e"].includes(this.props.orientation); + const sheetId = this.env.model.getters.getActiveSheetId(); + const z = this.props.zone; + const margin = 2; + const left = this.env.model.getters.getColDimensions(sheetId, z.left).start + margin; + const right = this.env.model.getters.getColDimensions(sheetId, z.right).end - 2 * margin; + const top = this.env.model.getters.getRowDimensions(sheetId, z.top).start + margin; + const bottom = this.env.model.getters.getRowDimensions(sheetId, z.bottom).end - 2 * margin; + const lineWidth = 4; + const leftValue = isLeft ? left : right; + const topValue = isTop ? top : bottom; + const widthValue = isHorizontal ? right - left : lineWidth; + const heightValue = isVertical ? bottom - top : lineWidth; + const { offsetX, offsetY } = this.env.model.getters.getActiveViewport(); + return ` + left:${leftValue + HEADER_WIDTH - offsetX}px; + top:${topValue + HEADER_HEIGHT - offsetY}px; + width:${widthValue}px; + height:${heightValue}px; + `; + } + onMouseDown(ev) { + this.props.onMoveHighlight(ev.clientX, ev.clientY); + } + } + Border.template = "o-spreadsheet-Border"; + + css /* scss */ ` + .o-corner { + position: absolute; + height: 6px; + width: 6px; + border: 1px solid white; + } + .o-corner-nw, + .o-corner-se { + &:hover { + cursor: nwse-resize; + } + } + .o-corner-ne, + .o-corner-sw { + &:hover { + cursor: nesw-resize; + } + } + .o-resizing { + cursor: grabbing; + } +`; + class Corner extends owl.Component { + constructor() { + super(...arguments); + this.isTop = this.props.orientation[0] === "n"; + this.isLeft = this.props.orientation[1] === "w"; + } + get style() { + const { offsetX, offsetY } = this.env.model.getters.getActiveViewport(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const z = this.props.zone; + const leftValue = this.isLeft + ? this.env.model.getters.getColDimensions(sheetId, z.left).start + : this.env.model.getters.getColDimensions(sheetId, z.right).end; + const topValue = this.isTop + ? this.env.model.getters.getRowDimensions(sheetId, z.top).start + : this.env.model.getters.getRowDimensions(sheetId, z.bottom).end; + return ` + left:${leftValue + HEADER_WIDTH - offsetX - AUTOFILL_EDGE_LENGTH / 2}px; + top:${topValue + HEADER_HEIGHT - offsetY - AUTOFILL_EDGE_LENGTH / 2}px; + background-color:${this.props.color}; + `; + } + onMouseDown(ev) { + this.props.onResizeHighlight(this.isLeft, this.isTop); + } + } + Corner.template = "o-spreadsheet-Corner"; + + css /*SCSS*/ ` + .o-highlight { + z-index: ${ComponentsImportance.Highlight}; + } +`; + class Highlight extends owl.Component { + constructor() { + super(...arguments); + this.highlightRef = owl.useRef("highlight"); + this.highlightState = owl.useState({ + shiftingMode: "none", + }); + } + onResizeHighlight(isLeft, isTop) { + const activeSheet = this.env.model.getters.getActiveSheet(); + this.highlightState.shiftingMode = "isResizing"; + const z = this.props.zone; + const pivotCol = isLeft ? z.right : z.left; + const pivotRow = isTop ? z.bottom : z.top; + let lastCol = isLeft ? z.left : z.right; + let lastRow = isTop ? z.top : z.bottom; + let currentZone = z; + this.env.model.dispatch("START_CHANGE_HIGHLIGHT", { + range: this.env.model.getters.getRangeDataFromZone(activeSheet.id, currentZone), + }); + const mouseMove = (col, row) => { + if (lastCol !== col || lastRow !== row) { + const activeSheetId = this.env.model.getters.getActiveSheetId(); + lastCol = clip(col === -1 ? lastCol : col, 0, this.env.model.getters.getNumberCols(activeSheetId) - 1); + lastRow = clip(row === -1 ? lastRow : row, 0, this.env.model.getters.getNumberRows(activeSheetId) - 1); + let newZone = { + left: Math.min(pivotCol, lastCol), + top: Math.min(pivotRow, lastRow), + right: Math.max(pivotCol, lastCol), + bottom: Math.max(pivotRow, lastRow), + }; + newZone = this.env.model.getters.expandZone(activeSheetId, newZone); + if (!isEqual(newZone, currentZone)) { + this.env.model.dispatch("CHANGE_HIGHLIGHT", { + range: this.env.model.getters.getRangeDataFromZone(activeSheet.id, newZone), + }); + currentZone = newZone; + } + } + }; + const mouseUp = () => { + this.highlightState.shiftingMode = "none"; + // To do: + // Command used here to restore focus to the current composer, + // to be changed when refactoring the 'edition' plugin + this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + }; + dragAndDropBeyondTheViewport(this.highlightRef.el.parentElement, this.env, mouseMove, mouseUp); + } + onMoveHighlight(clientX, clientY) { + this.highlightState.shiftingMode = "isMoving"; + const z = this.props.zone; + const parent = this.highlightRef.el.parentElement; + const position = parent.getBoundingClientRect(); + const activeSheetId = this.env.model.getters.getActiveSheetId(); + const initCol = this.env.model.getters.getColIndex(clientX - position.left - HEADER_WIDTH); + const initRow = this.env.model.getters.getRowIndex(clientY - position.top - HEADER_HEIGHT); + const deltaColMin = -z.left; + const deltaColMax = this.env.model.getters.getNumberCols(activeSheetId) - z.right - 1; + const deltaRowMin = -z.top; + const deltaRowMax = this.env.model.getters.getNumberRows(activeSheetId) - z.bottom - 1; + let currentZone = z; + this.env.model.dispatch("START_CHANGE_HIGHLIGHT", { + range: this.env.model.getters.getRangeDataFromZone(activeSheetId, currentZone), + }); + let lastCol = initCol; + let lastRow = initRow; + const mouseMove = (col, row) => { + if (lastCol !== col || lastRow !== row) { + lastCol = col === -1 ? lastCol : col; + lastRow = row === -1 ? lastRow : row; + const deltaCol = clip(lastCol - initCol, deltaColMin, deltaColMax); + const deltaRow = clip(lastRow - initRow, deltaRowMin, deltaRowMax); + let newZone = { + left: z.left + deltaCol, + top: z.top + deltaRow, + right: z.right + deltaCol, + bottom: z.bottom + deltaRow, + }; + newZone = this.env.model.getters.expandZone(activeSheetId, newZone); + if (!isEqual(newZone, currentZone)) { + this.env.model.dispatch("CHANGE_HIGHLIGHT", { + range: this.env.model.getters.getRangeDataFromZone(activeSheetId, newZone), + }); + currentZone = newZone; + } + } + }; + const mouseUp = () => { + this.highlightState.shiftingMode = "none"; + // To do: + // Command used here to restore focus to the current composer, + // to be changed when refactoring the 'edition' plugin + this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); + }; + dragAndDropBeyondTheViewport(parent, this.env, mouseMove, mouseUp); + } + } + Highlight.template = "o-spreadsheet-Highlight"; + Highlight.components = { + Corner, + Border, + }; + + class ScrollBar { + constructor(el, direction) { + this.el = el; + this.direction = direction; + } + get scroll() { + return this.direction === "horizontal" ? this.el.scrollLeft : this.el.scrollTop; + } + set scroll(value) { + if (this.direction === "horizontal") { + this.el.scrollLeft = value; + } + else { + this.el.scrollTop = value; + } + } + } + + const registries$1 = { + ROW: rowMenuRegistry, + COL: colMenuRegistry, + CELL: cellMenuRegistry, + DASHBOARD: dashboardMenuRegistry, + }; + // copy and paste are specific events that should not be managed by the keydown event, + // but they shouldn't be preventDefault and stopped (else copy and paste events will not trigger) + // and also should not result in typing the character C or V in the composer + const keyDownMappingIgnore = ["CTRL+C", "CTRL+V"]; + // ----------------------------------------------------------------------------- + // Error Tooltip Hook + // ----------------------------------------------------------------------------- + function useCellHovered(env) { + const hoveredPosition = owl.useState({}); + const { Date } = window; + const gridRef = owl.useRef("gridOverlay"); + const vScrollbarRef = owl.useRef("vscrollbar"); + const hScrollbarRef = owl.useRef("hscrollbar"); + let x = 0; + let y = 0; + let lastMoved = 0; + function getPosition() { + const col = env.model.getters.getColIndex(x); + const row = env.model.getters.getRowIndex(y); + return { col, row }; + } + const { pause, resume } = useInterval(checkTiming, 200); + function checkTiming() { + const { col, row } = getPosition(); + const delta = Date.now() - lastMoved; + if (delta > 300 && (col !== hoveredPosition.col || row !== hoveredPosition.row)) { + hoveredPosition.col = undefined; + hoveredPosition.row = undefined; + } + if (delta > 300) { + if (col < 0 || row < 0) { + return; + } + hoveredPosition.col = col; + hoveredPosition.row = row; + } + } + function updateMousePosition(e) { + x = e.offsetX; + y = e.offsetY; + lastMoved = Date.now(); + } + function recompute() { + const { col, row } = getPosition(); + if (col !== hoveredPosition.col || row !== hoveredPosition.row) { + hoveredPosition.col = undefined; + hoveredPosition.row = undefined; + } + } + function reset() { + hoveredPosition.col = undefined; + hoveredPosition.row = undefined; + } + owl.onMounted(() => { + const grid = gridRef.el; + grid.addEventListener("mousemove", updateMousePosition); + grid.addEventListener("mouseleave", pause); + grid.addEventListener("mouseenter", resume); + grid.addEventListener("mousedown", recompute); + vScrollbarRef.el.addEventListener("scroll", reset); + hScrollbarRef.el.addEventListener("scroll", reset); + }); + owl.onWillUnmount(() => { + const grid = gridRef.el; + grid.removeEventListener("mousemove", updateMousePosition); + grid.removeEventListener("mouseleave", pause); + grid.removeEventListener("mouseenter", resume); + grid.removeEventListener("mousedown", recompute); + vScrollbarRef.el.removeEventListener("scroll", reset); + hScrollbarRef.el.removeEventListener("scroll", reset); + }); + return hoveredPosition; + } + function useTouchMove(handler, canMoveUp) { + const canvasRef = owl.useRef("canvas"); + let x = null; + let y = null; + function onTouchStart(ev) { + if (ev.touches.length !== 1) + return; + x = ev.touches[0].clientX; + y = ev.touches[0].clientY; + } + function onTouchEnd() { + x = null; + y = null; + } + function onTouchMove(ev) { + if (ev.touches.length !== 1) + return; + // On mobile browsers, swiping down is often associated with "pull to refresh". + // We only want this behavior if the grid is already at the top. + // Otherwise we only want to move the canvas up, without triggering any refresh. + if (canMoveUp()) { + ev.preventDefault(); + ev.stopPropagation(); + } + const currentX = ev.touches[0].clientX; + const currentY = ev.touches[0].clientY; + handler(x - currentX, y - currentY); + x = currentX; + y = currentY; + } + owl.onMounted(() => { + canvasRef.el.addEventListener("touchstart", onTouchStart); + canvasRef.el.addEventListener("touchend", onTouchEnd); + canvasRef.el.addEventListener("touchmove", onTouchMove); + }); + owl.onWillUnmount(() => { + canvasRef.el.removeEventListener("touchstart", onTouchStart); + canvasRef.el.removeEventListener("touchend", onTouchEnd); + canvasRef.el.removeEventListener("touchmove", onTouchMove); + }); + } + // ----------------------------------------------------------------------------- + // TEMPLATE + // ----------------------------------------------------------------------------- + // ----------------------------------------------------------------------------- + // STYLE + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-grid { + position: relative; + overflow: hidden; + background-color: ${BACKGROUND_GRAY_COLOR}; + + > canvas { + border-top: 1px solid #e2e3e3; + border-bottom: 1px solid #e2e3e3; + + &:focus { + outline: none; + } + } + .o-scrollbar { + position: absolute; + overflow: auto; + z-index: ${ComponentsImportance.ScrollBar}; + background-color: ${BACKGROUND_GRAY_COLOR}; + + &.vertical { + right: 0; + bottom: ${SCROLLBAR_WIDTH$1}px; + width: ${SCROLLBAR_WIDTH$1}px; + overflow-x: hidden; + } + &.horizontal { + bottom: 0; + height: ${SCROLLBAR_WIDTH$1}px; + right: ${SCROLLBAR_WIDTH$1}px; + overflow-y: hidden; + } + &.corner { + right: 0px; + bottom: 0px; + height: ${SCROLLBAR_WIDTH$1}px; + width: ${SCROLLBAR_WIDTH$1}px; + border-top: 1px solid #e2e3e3; + border-left: 1px solid #e2e3e3; + } + } + + .o-grid-overlay { + position: absolute; + outline: none; + } + } +`; + // ----------------------------------------------------------------------------- + // JS + // ----------------------------------------------------------------------------- + class Grid extends owl.Component { + constructor() { + super(...arguments); + // this map will handle most of the actions that should happen on key down. The arrow keys are managed in the key + // down itself + this.keyDownMapping = { + ENTER: () => { + const cell = this.env.model.getters.getActiveCell(); + !cell || cell.isEmpty() + ? this.props.onGridComposerCellFocused() + : this.props.onComposerContentFocused(); + }, + TAB: () => this.env.model.selection.moveAnchorCell("right", "one"), + "SHIFT+TAB": () => this.env.model.selection.moveAnchorCell("left", "one"), + F2: () => { + const cell = this.env.model.getters.getActiveCell(); + !cell || cell.isEmpty() + ? this.props.onGridComposerCellFocused() + : this.props.onComposerContentFocused(); + }, + DELETE: () => { + this.env.model.dispatch("DELETE_CONTENT", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + }); + }, + "CTRL+A": () => this.env.model.selection.loopSelection(), + "CTRL+S": () => { + var _a, _b; + (_b = (_a = this.props).onSaveRequested) === null || _b === void 0 ? void 0 : _b.call(_a); + }, + "CTRL+Z": () => this.env.model.dispatch("REQUEST_UNDO"), + "CTRL+Y": () => this.env.model.dispatch("REQUEST_REDO"), + "CTRL+B": () => this.env.model.dispatch("SET_FORMATTING", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + style: { bold: !this.env.model.getters.getCurrentStyle().bold }, + }), + "CTRL+I": () => this.env.model.dispatch("SET_FORMATTING", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + style: { italic: !this.env.model.getters.getCurrentStyle().italic }, + }), + "CTRL+U": () => this.env.model.dispatch("SET_FORMATTING", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + style: { underline: !this.env.model.getters.getCurrentStyle().underline }, + }), + "ALT+=": () => { + var _a; + const sheetId = this.env.model.getters.getActiveSheetId(); + const mainSelectedZone = this.env.model.getters.getSelectedZone(); + const { anchor } = this.env.model.getters.getSelection(); + const sums = this.env.model.getters.getAutomaticSums(sheetId, mainSelectedZone, anchor.cell); + if (this.env.model.getters.isSingleCellOrMerge(sheetId, mainSelectedZone) || + (this.env.model.getters.isEmpty(sheetId, mainSelectedZone) && sums.length <= 1)) { + const zone = (_a = sums[0]) === null || _a === void 0 ? void 0 : _a.zone; + const zoneXc = zone ? this.env.model.getters.zoneToXC(sheetId, sums[0].zone) : ""; + const formula = `=SUM(${zoneXc})`; + this.props.onGridComposerCellFocused(formula, { start: 5, end: 5 + zoneXc.length }); + } + else { + this.env.model.dispatch("SUM_SELECTION"); + } + }, + "CTRL+HOME": () => { + const sheetId = this.env.model.getters.getActiveSheetId(); + const { col, row } = this.env.model.getters.getNextVisibleCellPosition(sheetId, 0, 0); + this.env.model.selection.selectCell(col, row); + }, + "CTRL+END": () => { + const sheetId = this.env.model.getters.getActiveSheetId(); + const col = this.env.model.getters.findVisibleHeader(sheetId, "COL", range(0, this.env.model.getters.getNumberCols(sheetId)).reverse()); + const row = this.env.model.getters.findVisibleHeader(sheetId, "ROW", range(0, this.env.model.getters.getNumberRows(sheetId)).reverse()); + this.env.model.selection.selectCell(col, row); + }, + "SHIFT+ ": () => { + const sheetId = this.env.model.getters.getActiveSheetId(); + const newZone = { + ...this.env.model.getters.getSelectedZone(), + left: 0, + right: this.env.model.getters.getNumberCols(sheetId) - 1, + }; + const position = this.env.model.getters.getPosition(); + this.env.model.selection.selectZone({ cell: position, zone: newZone }); + }, + "CTRL+ ": () => { + const sheetId = this.env.model.getters.getActiveSheetId(); + const newZone = { + ...this.env.model.getters.getSelectedZone(), + top: 0, + bottom: this.env.model.getters.getNumberRows(sheetId) - 1, + }; + const position = this.env.model.getters.getPosition(); + this.env.model.selection.selectZone({ cell: position, zone: newZone }); + }, + "CTRL+SHIFT+ ": () => { + this.env.model.selection.selectAll(); + }, + "SHIFT+PAGEDOWN": () => { + this.env.model.dispatch("ACTIVATE_NEXT_SHEET"); + }, + "SHIFT+PAGEUP": () => { + this.env.model.dispatch("ACTIVATE_PREVIOUS_SHEET"); + }, + PAGEDOWN: () => this.env.model.dispatch("SHIFT_VIEWPORT_DOWN"), + PAGEUP: () => this.env.model.dispatch("SHIFT_VIEWPORT_UP"), + }; + } + setup() { + this.menuState = owl.useState({ + isOpen: false, + position: null, + menuItems: [], + }); + this.vScrollbarRef = owl.useRef("vscrollbar"); + this.hScrollbarRef = owl.useRef("hscrollbar"); + this.gridRef = owl.useRef("grid"); + this.gridOverlay = owl.useRef("gridOverlay"); + this.canvas = owl.useRef("canvas"); + this.canvasPosition = useAbsolutePosition(this.canvas); + this.vScrollbar = new ScrollBar(this.vScrollbarRef.el, "vertical"); + this.hScrollbar = new ScrollBar(this.hScrollbarRef.el, "horizontal"); + this.currentSheet = this.env.model.getters.getActiveSheetId(); + this.clickedCol = 0; + this.clickedRow = 0; + this.hoveredCell = useCellHovered(this.env); + owl.useExternalListener(document.body, "cut", this.copy.bind(this, true)); + owl.useExternalListener(document.body, "copy", this.copy.bind(this, false)); + owl.useExternalListener(document.body, "paste", this.paste); + useTouchMove(this.moveCanvas.bind(this), () => this.vScrollbar.scroll > 0); + owl.onMounted(() => this.initGrid()); + owl.onPatched(() => { + this.drawGrid(); + this.resizeGrid(); + }); + this.props.exposeFocus(() => this.focus()); + } + initGrid() { + this.vScrollbar.el = this.vScrollbarRef.el; + this.hScrollbar.el = this.hScrollbarRef.el; + this.focus(); + this.resizeGrid(); + this.drawGrid(); + } + get gridOverlayStyle() { + return ` + top: ${this.env.isDashboard() ? 0 : HEADER_HEIGHT}px; + left: ${this.env.isDashboard() ? 0 : HEADER_WIDTH}px; + height: calc(100% - ${this.env.isDashboard() ? 0 : HEADER_HEIGHT}px); + width: calc(100% - ${this.env.isDashboard() ? 0 : HEADER_WIDTH}px); + `; + } + get vScrollbarStyle() { + return ` + top: ${this.env.isDashboard() ? 0 : HEADER_HEIGHT}px;`; + } + get hScrollbarStyle() { + return ` + left: ${this.env.isDashboard() ? 0 : HEADER_WIDTH}px;`; + } + get cellPopover() { + if (this.menuState.isOpen) { + return { isOpen: false }; + } + const popover = this.env.model.getters.getCellPopover(this.hoveredCell); + if (!popover.isOpen) { + return { isOpen: false }; + } + const coordinates = popover.coordinates; + return { + ...popover, + // transform from the "canvas coordinate system" to the "body coordinate system" + coordinates: { + x: coordinates.x + this.canvasPosition.x, + y: coordinates.y + this.canvasPosition.y, + }, + }; + } + get activeCellPosition() { + const { col, row } = this.env.model.getters.getPosition(); + return this.env.model.getters.getMainCellPosition(this.env.model.getters.getActiveSheetId(), col, row); + } + onClosePopover() { + this.closeOpenedPopover(); + this.focus(); + } + focus() { + if (!this.env.model.getters.getSelectedFigureId()) { + this.gridOverlay.el.focus(); + } + } + get gridEl() { + if (!this.gridRef.el) { + throw new Error("Grid el is not defined."); + } + return this.gridRef.el; + } + getGridBoundingClientRect() { + return this.gridEl.getBoundingClientRect(); + } + resizeGrid() { + const currentHeight = this.gridEl.clientHeight - SCROLLBAR_WIDTH$1; + const currentWidth = this.gridEl.clientWidth - SCROLLBAR_WIDTH$1; + const { height: viewportHeight, width: viewportWidth } = this.env.model.getters.getViewportDimensionWithHeaders(); + if (currentHeight != viewportHeight || currentWidth !== viewportWidth) { + this.env.model.dispatch("RESIZE_VIEWPORT", { + height: currentHeight - (this.env.isDashboard() ? 0 : HEADER_HEIGHT), + width: currentWidth - (this.env.isDashboard() ? 0 : HEADER_WIDTH), + }); + } + } + onScroll() { + const { offsetScrollbarX, offsetScrollbarY } = this.env.model.getters.getActiveViewport(); + if (offsetScrollbarX !== this.hScrollbar.scroll || + offsetScrollbarY !== this.vScrollbar.scroll) { + const { maxOffsetX, maxOffsetY } = this.env.model.getters.getMaximumViewportOffset(this.env.model.getters.getActiveSheet()); + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { + offsetX: Math.min(this.hScrollbar.scroll, maxOffsetX), + offsetY: Math.min(this.vScrollbar.scroll, maxOffsetY), + }); + } + } + checkSheetChanges() { + const currentSheet = this.env.model.getters.getActiveSheetId(); + if (currentSheet !== this.currentSheet) { + this.focus(); + this.currentSheet = currentSheet; + } + } + getAutofillPosition() { + const zone = this.env.model.getters.getSelectedZone(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const { offsetX, offsetY } = this.env.model.getters.getActiveViewport(); + const col = this.env.model.getters.getColDimensions(sheetId, zone.right); + const row = this.env.model.getters.getRowDimensions(sheetId, zone.bottom); + return { + left: col.end - AUTOFILL_EDGE_LENGTH / 2 + HEADER_WIDTH - offsetX, + top: row.end - AUTOFILL_EDGE_LENGTH / 2 + HEADER_HEIGHT - offsetY, + }; + } + isAutoFillActive() { + const zone = this.env.model.getters.getSelectedZone(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const { width, height } = this.env.model.getters.getViewportDimension(); + const { offsetX, offsetY } = this.env.model.getters.getActiveViewport(); + const rightCol = this.env.model.getters.getColDimensions(sheetId, zone.right); + const bottomRow = this.env.model.getters.getRowDimensions(sheetId, zone.bottom); + return (rightCol.end <= offsetX + width && + rightCol.end > offsetX && + bottomRow.end <= offsetY + height && + bottomRow.end > offsetY); + } + drawGrid() { + //reposition scrollbar + const { offsetScrollbarX, offsetScrollbarY } = this.env.model.getters.getActiveViewport(); + this.hScrollbar.scroll = offsetScrollbarX; + this.vScrollbar.scroll = offsetScrollbarY; + // check for position changes + this.checkSheetChanges(); + // drawing grid on canvas + const canvas = this.canvas.el; + const dpr = window.devicePixelRatio || 1; + const ctx = canvas.getContext("2d", { alpha: false }); + const thinLineWidth = 0.4 * dpr; + const renderingContext = { + ctx, + dpr, + thinLineWidth, + }; + const { width, height } = this.env.model.getters.getViewportDimensionWithHeaders(); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.setAttribute("style", `width:${width}px;height:${height}px;`); + ctx.translate(-0.5, -0.5); + ctx.scale(dpr, dpr); + this.env.model.drawGrid(renderingContext); + } + moveCanvas(deltaX, deltaY) { + this.vScrollbar.scroll = this.vScrollbar.scroll + deltaY; + this.hScrollbar.scroll = this.hScrollbar.scroll + deltaX; + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { + offsetX: this.hScrollbar.scroll, + offsetY: this.vScrollbar.scroll, + }); + } + getClientPositionKey(client) { + var _a, _b, _c; + return `${client.id}-${(_a = client.position) === null || _a === void 0 ? void 0 : _a.sheetId}-${(_b = client.position) === null || _b === void 0 ? void 0 : _b.col}-${(_c = client.position) === null || _c === void 0 ? void 0 : _c.row}`; + } + onMouseWheel(ev) { + if (ev.ctrlKey) { + return; + } + function normalize(val) { + return val * (ev.deltaMode === 0 ? 1 : DEFAULT_CELL_HEIGHT); + } + const deltaX = ev.shiftKey ? ev.deltaY : ev.deltaX; + const deltaY = ev.shiftKey ? ev.deltaX : ev.deltaY; + this.moveCanvas(normalize(deltaX), normalize(deltaY)); + } + isCellHovered(col, row) { + return this.hoveredCell.col === col && this.hoveredCell.row === row; + } + // --------------------------------------------------------------------------- + // Zone selection with mouse + // --------------------------------------------------------------------------- + /** + * Get the coordinates in pixels, with 0,0 being the top left of the grid itself + */ + getCoordinates(ev) { + const rect = this.gridOverlay.el.getBoundingClientRect(); + const x = ev.pageX - rect.left; + const y = ev.pageY - rect.top; + return [x, y]; + } + getCartesianCoordinates(ev) { + const [x, y] = this.getCoordinates(ev); + const colIndex = this.env.model.getters.getColIndex(x); + const rowIndex = this.env.model.getters.getRowIndex(y); + return [colIndex, rowIndex]; + } + onMouseDown(ev) { + if (ev.button > 0) { + // not main button, probably a context menu + return; + } + const [col, row] = this.getCartesianCoordinates(ev); + if (col < 0 || row < 0) { + return; + } + this.clickedCol = col; + this.clickedRow = row; + if (this.env.model.getters.isDashboard()) { + this.env.model.selection.selectCell(col, row); + return; + } + const sheetId = this.env.model.getters.getActiveSheetId(); + this.closeOpenedPopover(); + if (this.env.model.getters.getEditionMode() === "editing") { + this.env.model.dispatch("STOP_EDITION"); + } + if (ev.shiftKey) { + this.env.model.selection.setAnchorCorner(col, row); + } + else if (ev.ctrlKey) { + this.env.model.selection.addCellToSelection(col, row); + } + else { + this.env.model.selection.selectCell(col, row); + } + this.checkSheetChanges(); + let prevCol = col; + let prevRow = row; + let isEdgeScrolling = false; + let timeOutId = null; + let timeoutDelay = 0; + let currentEv; + const onMouseMove = (ev) => { + currentEv = ev; + if (timeOutId) { + return; + } + const [x, y] = this.getCoordinates(currentEv); + isEdgeScrolling = false; + timeoutDelay = 0; + const colEdgeScroll = this.env.model.getters.getEdgeScrollCol(x); + const rowEdgeScroll = this.env.model.getters.getEdgeScrollRow(y); + const { left, right, top, bottom } = this.env.model.getters.getActiveViewport(); + let col, row; + if (colEdgeScroll.canEdgeScroll) { + col = colEdgeScroll.direction > 0 ? right : left - 1; + } + else { + col = this.env.model.getters.getColIndex(x); + col = col === -1 ? prevCol : col; + } + if (rowEdgeScroll.canEdgeScroll) { + row = rowEdgeScroll.direction > 0 ? bottom : top - 1; + } + else { + row = this.env.model.getters.getRowIndex(y); + row = row === -1 ? prevRow : row; + } + isEdgeScrolling = colEdgeScroll.canEdgeScroll || rowEdgeScroll.canEdgeScroll; + timeoutDelay = Math.min(colEdgeScroll.canEdgeScroll ? colEdgeScroll.delay : MAX_DELAY, rowEdgeScroll.canEdgeScroll ? rowEdgeScroll.delay : MAX_DELAY); + if (col !== prevCol || row !== prevRow) { + prevCol = col; + prevRow = row; + this.env.model.selection.setAnchorCorner(col, row); + } + if (isEdgeScrolling) { + const offsetX = this.env.model.getters.getColDimensions(sheetId, left + colEdgeScroll.direction).start; + const offsetY = this.env.model.getters.getRowDimensions(sheetId, top + rowEdgeScroll.direction).start; + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY }); + timeOutId = setTimeout(() => { + timeOutId = null; + onMouseMove(currentEv); + }, Math.round(timeoutDelay)); + } + }; + const onMouseUp = (ev) => { + clearTimeout(timeOutId); + this.env.model.dispatch(ev.ctrlKey ? "PREPARE_SELECTION_INPUT_EXPANSION" : "STOP_SELECTION_INPUT"); + this.gridOverlay.el.removeEventListener("mousemove", onMouseMove); + if (this.env.model.getters.isPaintingFormat()) { + this.env.model.dispatch("PASTE", { + target: this.env.model.getters.getSelectedZones(), + }); + } + }; + startDnd(onMouseMove, onMouseUp); + } + onDoubleClick(ev) { + const [col, row] = this.getCartesianCoordinates(ev); + if (this.clickedCol === col && this.clickedRow === row) { + const cell = this.env.model.getters.getActiveCell(); + !cell || cell.isEmpty() + ? this.props.onGridComposerCellFocused() + : this.props.onComposerContentFocused(); + } + } + closeOpenedPopover() { + this.env.model.dispatch("CLOSE_CELL_POPOVER"); + } + // --------------------------------------------------------------------------- + // Keyboard interactions + // --------------------------------------------------------------------------- + processArrows(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.closeOpenedPopover(); + const arrowMap = { + ArrowDown: { direction: "down", delta: [0, 1] }, + ArrowLeft: { direction: "left", delta: [-1, 0] }, + ArrowRight: { direction: "right", delta: [1, 0] }, + ArrowUp: { direction: "up", delta: [0, -1] }, + }; + const { direction, delta } = arrowMap[ev.key]; + if (ev.shiftKey) { + const oldZone = this.env.model.getters.getSelectedZone(); + this.env.model.selection.resizeAnchorZone(direction, ev.ctrlKey ? "end" : "one"); + const newZone = this.env.model.getters.getSelectedZone(); + const viewport = this.env.model.getters.getActiveViewport(); + const sheetId = this.env.model.getters.getActiveSheetId(); + let { col, row } = findCellInNewZone(oldZone, newZone); + col = Math.min(col, this.env.model.getters.getNumberCols(sheetId) - 1); + row = Math.min(row, this.env.model.getters.getNumberRows(sheetId) - 1); + const { left, right, top, bottom, offsetX, offsetY } = viewport; + const newOffsetX = col < left || col > right - 1 + ? this.env.model.getters.getColDimensions(sheetId, left + delta[0]).start + : offsetX; + const newOffsetY = row < top || row > bottom - 1 + ? this.env.model.getters.getRowDimensions(sheetId, top + delta[1]).start + : offsetY; + if (newOffsetX !== offsetX || newOffsetY !== offsetY) { + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { + offsetX: newOffsetX, + offsetY: newOffsetY, + }); + } + } + else { + this.env.model.selection.moveAnchorCell(direction, ev.ctrlKey ? "end" : "one"); + } + if (this.env.model.getters.isPaintingFormat()) { + this.env.model.dispatch("PASTE", { + target: this.env.model.getters.getSelectedZones(), + }); + } + } + onKeydown(ev) { + if (this.env.isDashboard()) { + return; + } + if (ev.key.startsWith("Arrow")) { + this.processArrows(ev); + return; + } + let keyDownString = ""; + if (ev.ctrlKey) + keyDownString += "CTRL+"; + if (ev.metaKey) + keyDownString += "CTRL+"; + if (ev.altKey) + keyDownString += "ALT+"; + if (ev.shiftKey) + keyDownString += "SHIFT+"; + keyDownString += ev.key.toUpperCase(); + let handler = this.keyDownMapping[keyDownString]; + if (handler) { + ev.preventDefault(); + ev.stopPropagation(); + handler(); + return; + } + if (!keyDownMappingIgnore.includes(keyDownString)) { + if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) { + // if the user types a character on the grid, it means he wants to start composing the selected cell with that + // character + ev.preventDefault(); + ev.stopPropagation(); + this.props.onGridComposerCellFocused(ev.key); + } + } + } + // --------------------------------------------------------------------------- + // Context Menu + // --------------------------------------------------------------------------- + onCanvasContextMenu(ev) { + ev.preventDefault(); + const [col, row] = this.getCartesianCoordinates(ev); + if (col < 0 || row < 0) { + return; + } + const zones = this.env.model.getters.getSelectedZones(); + const lastZone = zones[zones.length - 1]; + let type = "CELL"; + if (!isInside(col, row, lastZone)) { + this.env.model.dispatch("UNFOCUS_SELECTION_INPUT"); + this.env.model.dispatch("STOP_EDITION"); + this.env.model.selection.selectCell(col, row); + } + else { + if (this.env.model.getters.getActiveCols().has(col)) { + type = "COL"; + } + else if (this.env.model.getters.getActiveRows().has(row)) { + type = "ROW"; + } + } + this.toggleContextMenu(type, ev.clientX, ev.clientY); + } + toggleContextMenu(type, x, y) { + this.closeOpenedPopover(); + if (this.env.model.getters.isDashboard()) { + type = "DASHBOARD"; + } + this.menuState.isOpen = true; + this.menuState.position = { x, y }; + this.menuState.menuItems = registries$1[type] + .getAll() + .filter((item) => !item.isVisible || item.isVisible(this.env)); + } + copy(cut, ev) { + if (!this.gridEl.contains(document.activeElement)) { + return; + } + /* If we are currently editing a cell, let the default behavior */ + if (this.env.model.getters.getEditionMode() !== "inactive") { + return; + } + if (cut) { + interactiveCut(this.env); + } + else { + this.env.model.dispatch("COPY"); + } + const content = this.env.model.getters.getClipboardContent(); + ev.clipboardData.setData("text/plain", content); + ev.preventDefault(); + } + paste(ev) { + if (!this.gridEl.contains(document.activeElement)) { + return; + } + const clipboardData = ev.clipboardData; + if (clipboardData.types.indexOf("text/plain") > -1) { + const content = clipboardData.getData("text/plain"); + const target = this.env.model.getters.getSelectedZones(); + const clipBoardString = this.env.model.getters.getClipboardContent(); + if (clipBoardString === content) { + // the paste actually comes from o-spreadsheet itself + interactivePaste(this.env, target); + } + else { + this.env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { + target, + text: content, + }); + } + } + } + closeMenu() { + this.menuState.isOpen = false; + this.focus(); + } + } + Grid.template = "o-spreadsheet-Grid"; + Grid.components = { + GridComposer, + HeadersOverlay, + Menu, + Autofill, + FiguresContainer, + ClientTag, + Highlight, + Popover, + }; + + /** + * Abstract base implementation of a cell. + * Concrete cell classes are responsible to build the raw cell `content` based on + * whatever data they have (formula, string, ...). + */ + class AbstractCell { + constructor(id, evaluated, properties) { + this.id = id; + this.style = properties.style; + this.format = properties.format; + this.evaluated = { ...evaluated, format: evaluated.format || properties.format }; + } + isFormula() { + return false; + } + isLink() { + return false; + } + isEmpty() { + return false; + } + get formattedValue() { + return formatValue(this.evaluated.value, this.evaluated.format); + } + get composerContent() { + return this.content; + } + get defaultAlign() { + switch (this.evaluated.type) { + case CellValueType.number: + case CellValueType.empty: + return "right"; + case CellValueType.boolean: + case CellValueType.error: + return "center"; + case CellValueType.text: + return "left"; + } + } + /** + * Only empty cells, text cells and numbers are valid + */ + get isAutoSummable() { + var _a; + switch (this.evaluated.type) { + case CellValueType.empty: + case CellValueType.text: + return true; + case CellValueType.number: + return !((_a = this.format) === null || _a === void 0 ? void 0 : _a.match(DATETIME_FORMAT)); + case CellValueType.error: + case CellValueType.boolean: + return false; + } + } + } + class EmptyCell extends AbstractCell { + constructor(id, properties = {}) { + super(id, { value: "", type: CellValueType.empty }, properties); + this.content = ""; + } + isEmpty() { + return true; + } + } + class NumberCell extends AbstractCell { + constructor(id, value, properties = {}) { + super(id, { value: value, type: CellValueType.number }, properties); + this.content = formatValue(this.evaluated.value); + } + get composerContent() { + var _a; + if ((_a = this.format) === null || _a === void 0 ? void 0 : _a.includes("%")) { + return `${this.evaluated.value * 100}%`; + } + return super.composerContent; + } + } + class BooleanCell extends AbstractCell { + constructor(id, value, properties = {}) { + super(id, { value: value, type: CellValueType.boolean }, properties); + this.content = this.evaluated.value ? "TRUE" : "FALSE"; + } + } + class TextCell extends AbstractCell { + constructor(id, value, properties = {}) { + super(id, { value: value, type: CellValueType.text }, properties); + this.content = this.evaluated.value; + } + } + /** + * A date time cell is a number cell with a required + * date time format. + */ + class DateTimeCell extends NumberCell { + constructor(id, value, properties) { + super(id, value, properties); + this.format = properties.format; + } + get composerContent() { + return formatValue(this.evaluated.value, this.format); + } + } + class LinkCell extends AbstractCell { + constructor(id, content, properties = {}) { + var _a; + const link = parseMarkdownLink(content); + properties = { + ...properties, + style: { + ...properties.style, + textColor: ((_a = properties.style) === null || _a === void 0 ? void 0 : _a.textColor) || LINK_COLOR, + }, + }; + super(id, { value: link.label, type: CellValueType.text }, properties); + this.link = link; + this.content = content; + } + isLink() { + return true; + } + get composerContent() { + return this.link.label; + } + } + /** + * Simple web link cell + */ + class WebLinkCell extends LinkCell { + constructor(id, content, properties = {}) { + super(id, content, properties); + this.link.url = this.withHttp(this.link.url); + this.link.isExternal = true; + this.content = markdownLink(this.link.label, this.link.url); + this.urlRepresentation = this.link.url; + this.isUrlEditable = true; + } + action(env) { + window.open(this.link.url, "_blank"); + } + /** + * Add the `https` prefix to the url if it's missing + */ + withHttp(url) { + return !/^https?:\/\//i.test(url) ? `https://${url}` : url; + } + } + /** + * Link redirecting to a given sheet in the workbook. + */ + class SheetLinkCell extends LinkCell { + constructor(id, content, properties = {}, sheetName) { + super(id, content, properties); + this.sheetName = sheetName; + this.sheetId = parseSheetLink(this.link.url); + this.isUrlEditable = false; + } + action(env) { + env.model.dispatch("ACTIVATE_SHEET", { + sheetIdFrom: env.model.getters.getActiveSheetId(), + sheetIdTo: this.sheetId, + }); + } + get urlRepresentation() { + return this.sheetName(this.sheetId) || _lt("Invalid sheet"); + } + } + class FormulaCell extends AbstractCell { + constructor(buildFormulaString, id, compiledFormula, dependencies, properties) { + super(id, { value: LOADING, type: CellValueType.text }, properties); + this.buildFormulaString = buildFormulaString; + this.compiledFormula = compiledFormula; + this.dependencies = dependencies; + } + get content() { + return this.buildFormulaString(this); + } + isFormula() { + return true; + } + assignEvaluation(value, format) { + switch (typeof value) { + case "number": + this.evaluated = { + value, + format, + type: CellValueType.number, + }; + break; + case "boolean": + this.evaluated = { + value, + format, + type: CellValueType.boolean, + }; + break; + case "string": + this.evaluated = { + value, + format, + type: CellValueType.text, + }; + break; + case "object": // null + this.evaluated = { + value: 0, + format, + type: CellValueType.number, + }; + break; + } + } + assignError(value, error) { + this.evaluated = { + value, + error, + type: CellValueType.error, + }; + } + } + /** + * Cell containing a formula which could not be compiled + * or a content which could not be parsed. + */ + class BadExpressionCell extends AbstractCell { + /** + * @param id + * @param content Invalid formula string + * @param error Compilation or parsing error + * @param properties + */ + constructor(id, content, error, properties) { + super(id, { + value: CellErrorType.BadExpression, + type: CellValueType.error, + error, + }, properties); + this.content = content; + } + } + + cellRegistry + .add("Formula", { + sequence: 10, + match: (content) => content.startsWith("="), + createCell: (id, content, properties, sheetId, getters) => { + const compiledFormula = compile(content); + const dependencies = compiledFormula.dependencies.map((xc) => getters.getRangeFromSheetXC(sheetId, xc)); + return new FormulaCell((cell) => getters.buildFormulaContent(sheetId, cell), id, compiledFormula, dependencies, properties); + }, + }) + .add("Empty", { + sequence: 20, + match: (content) => content === "", + createCell: (id, content, properties) => new EmptyCell(id, properties), + }) + .add("NumberWithDateTimeFormat", { + sequence: 25, + match: (content, format) => !!format && isNumber(content) && isDateTimeFormat(format), + createCell: (id, content, properties) => { + const format = properties.format; + return new DateTimeCell(id, parseNumber(content), { ...properties, format }); + }, + }) + .add("Number", { + sequence: 30, + match: (content) => isNumber(content), + createCell: (id, content, properties) => { + if (!properties.format) { + properties.format = detectNumberFormat(content); + } + return new NumberCell(id, parseNumber(content), properties); + }, + }) + .add("Boolean", { + sequence: 40, + match: (content) => isBoolean(content), + createCell: (id, content, properties) => { + return new BooleanCell(id, content.toUpperCase() === "TRUE" ? true : false, properties); + }, + }) + .add("DateTime", { + sequence: 50, + match: (content) => isDateTime(content), + createCell: (id, content, properties) => { + const internalDate = parseDateTime(content); + const format = properties.format || internalDate.format; + return new DateTimeCell(id, internalDate.value, { ...properties, format }); + }, + }) + .add("MarkdownSheetLink", { + sequence: 60, + match: (content) => isMarkdownSheetLink(content), + createCell: (id, content, properties, sheetId, getters) => { + return new SheetLinkCell(id, content, properties, (sheetId) => getters.tryGetSheetName(sheetId)); + }, + }) + .add("MarkdownLink", { + sequence: 70, + match: (content) => isMarkdownLink(content), + createCell: (id, content, properties) => { + return new WebLinkCell(id, content, properties); + }, + }) + .add("WebLink", { + sequence: 80, + match: (content) => isWebLink(content), + createCell: (id, content, properties) => { + return new WebLinkCell(id, markdownLink(content, content), properties); + }, + }); + /** + * Return a factory function which can instantiate cells of + * different types, based on a raw content. + * + * ``` + * // the createCell function can be used to instantiate new cells + * const createCell = cellFactory(getters); + * const cell = createCell(id, cellContent, cellProperties, sheetId) + * ``` + */ + function cellFactory(getters) { + const builders = cellRegistry.getAll().sort((a, b) => a.sequence - b.sequence); + return function createCell(id, content, properties, sheetId) { + const builder = builders.find((factory) => factory.match(content, properties.format)); + if (!builder) { + return new TextCell(id, content, properties); + } + try { + return builder.createCell(id, content, properties, sheetId, getters); + } + catch (error) { + return new BadExpressionCell(id, content, new BadExpressionError(error.message || DEFAULT_ERROR_MESSAGE), properties); + } + }; + } + function detectNumberFormat(content) { + const digitBase = content.includes(".") ? "0.00" : "0"; + const matchedCurrencies = content.match(/[\$€]/); + if (matchedCurrencies) { + const matchedFirstDigit = content.match(/[\d]/); + const currency = "[$" + matchedCurrencies.values().next().value + "]"; + if (matchedFirstDigit.index < matchedCurrencies.index) { + return "#,##" + digitBase + currency; + } + return currency + "#,##" + digitBase; + } + if (content.includes("%")) { + return digitBase + "%"; + } + return undefined; + } + + /** + * Parse a string representing a primitive cell value + */ + function parsePrimitiveContent(content) { + if (content === "") { + return ""; + } + else if (isNumber(content)) { + return parseNumber(content); + } + else if (isBoolean(content)) { + return content.toUpperCase() === "TRUE" ? true : false; + } + else if (isDateTime(content)) { + return parseDateTime(content).value; + } + else { + return content; + } + } + + /** + * Represent a raw XML string + */ + class XMLString { + /** + * @param xmlString should be a well formed, properly escaped XML string + */ + constructor(xmlString) { + this.xmlString = xmlString; + } + toString() { + return this.xmlString; + } + } + const XLSX_CHART_TYPES = [ + "areaChart", + "area3DChart", + "lineChart", + "line3DChart", + "stockChart", + "radarChart", + "scatterChart", + "pieChart", + "pie3DChart", + "doughnutChart", + "barChart", + "bar3DChart", + "ofPieChart", + "surfaceChart", + "surface3DChart", + "bubbleChart", + ]; + + /** In XLSX color format (no #) */ + const AUTO_COLOR = "000000"; + const XLSX_ICONSET_MAP = { + arrow: "3Arrows", + smiley: "3Symbols", + dot: "3TrafficLights1", + }; + const NAMESPACE = { + styleSheet: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + sst: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + Relationships: "http://schemas.openxmlformats.org/package/2006/relationships", + Types: "http://schemas.openxmlformats.org/package/2006/content-types", + worksheet: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + workbook: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + drawing: "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + }; + const DRAWING_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + const DRAWING_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + const CONTENT_TYPES = { + workbook: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + sheet: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + sharedStrings: "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", + styles: "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", + drawing: "application/vnd.openxmlformats-officedocument.drawing+xml", + chart: "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + themes: "application/vnd.openxmlformats-officedocument.theme+xml", + table: "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml", + pivot: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml", + externalLink: "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml", + }; + const RELATIONSHIP_NSR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const HEIGHT_FACTOR = 0.75; // 100px => 75 u + const WIDTH_FACTOR = 0.1317; // 100px => 13.17 u + /** unit : maximum number of characters a column can hold at the standard font size. What. */ + const EXCEL_DEFAULT_COL_WIDTH = 8.43; + /** unit : points */ + const EXCEL_DEFAULT_ROW_HEIGHT = 12.75; + const EXCEL_IMPORT_DEFAULT_NUMBER_OF_COLS = 30; + const EXCEL_IMPORT_DEFAULT_NUMBER_OF_ROWS = 100; + const FIRST_NUMFMT_ID = 164; + const FORCE_DEFAULT_ARGS_FUNCTIONS = { + FLOOR: [{ type: "NUMBER", value: 1 }], + CEILING: [{ type: "NUMBER", value: 1 }], + ROUND: [{ type: "NUMBER", value: 0 }], + ROUNDUP: [{ type: "NUMBER", value: 0 }], + ROUNDDOWN: [{ type: "NUMBER", value: 0 }], + }; + /** + * This list contains all "future" functions that are not compatible with older versions of Excel + * For more information, see https://docs.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/5d1b6d44-6fc1-4ecd-8fef-0b27406cc2bf + */ + const NON_RETROCOMPATIBLE_FUNCTIONS = [ + "ACOT", + "ACOTH", + "AGGREGATE", + "ARABIC", + "BASE", + "BETA.DIST", + "BETA.INV", + "BINOM.DIST", + "BINOM.DIST.RANGE", + "BINOM.INV", + "BITAND", + "BITLSHIFT", + "BITOR", + "BITRSHIFT", + "BITXOR", + "CEILING.MATH", + "CEILING.PRECISE", + "CHISQ.DIST", + "CHISQ.DIST.RT", + "CHISQ.INV", + "CHISQ.INV.RT", + "CHISQ.TEST", + "COMBINA", + "CONCAT", + "CONFIDENCE.NORM", + "CONFIDENCE.T", + "COT", + "COTH", + "COVARIANCE.P", + "COVARIANCE.S", + "CSC", + "CSCH", + "DAYS", + "DECIMAL", + "ERF.PRECISE", + "ERFC.PRECISE", + "EXPON.DIST", + "F.DIST", + "F.DIST.RT", + "F.INV", + "F.INV.RT", + "F.TEST", + "FILTERXML", + "FLOOR.MATH", + "FLOOR.PRECISE", + "FORECAST.ETS", + "FORECAST.ETS.CONFINT", + "FORECAST.ETS.SEASONALITY", + "FORECAST.ETS.STAT", + "FORECAST.LINEAR", + "FORMULATEXT", + "GAMMA", + "GAMMA.DIST", + "GAMMA.INV", + "GAMMALN.PRECISE", + "GAUSS", + "HYPGEOM.DIST", + "IFNA", + "IFS", + "IMCOSH", + "IMCOT", + "IMCSC", + "IMCSCH", + "IMSEC", + "IMSECH", + "IMSINH", + "IMTAN", + "ISFORMULA", + "ISOWEEKNUM", + "LOGNORM.DIST", + "LOGNORM.INV", + "MAXIFS", + "MINIFS", + "MODE.MULT", + "MODE.SNGL", + "MUNIT", + "NEGBINOM.DIST", + "NORM.DIST", + "NORM.INV", + "NORM.S.DIST", + "NORM.S.INV", + "NUMBERVALUE", + "PDURATION", + "PERCENTILE.EXC", + "PERCENTILE.INC", + "PERCENTRANK.EXC", + "PERCENTRANK.INC", + "PERMUTATIONA", + "PHI", + "POISSON.DIST", + "QUARTILE.EXC", + "QUARTILE.INC", + "QUERYSTRING", + "RANK.AVG", + "RANK.EQ", + "RRI", + "SEC", + "SECH", + "SHEET", + "SHEETS", + "SKEW.P", + "STDEV.P", + "STDEV.S", + "SWITCH", + "T.DIST", + "T.DIST.2T", + "T.DIST.RT", + "T.INV", + "T.INV.2T", + "T.TEST", + "TEXTJOIN", + "UNICHAR", + "UNICODE", + "VAR.P", + "VAR.S", + "WEBSERVICE", + "WEIBULL.DIST", + "XOR", + "Z.TEST", + ]; + const CONTENT_TYPES_FILE = "[Content_Types].xml"; + + /** + * Map of the different types of conversions warnings and their name in error messages + */ + var WarningTypes; + (function (WarningTypes) { + WarningTypes["DiagonalBorderNotSupported"] = "Diagonal Borders"; + WarningTypes["BorderStyleNotSupported"] = "Border style"; + WarningTypes["FillStyleNotSupported"] = "Fill Style"; + WarningTypes["FontNotSupported"] = "Font"; + WarningTypes["HorizontalAlignmentNotSupported"] = "Horizontal Alignment"; + WarningTypes["VerticalAlignmentNotSupported"] = "Vertical Alignments"; + WarningTypes["MultipleRulesCfNotSupported"] = "Multiple rules conditional formats"; + WarningTypes["CfTypeNotSupported"] = "Conditional format type"; + WarningTypes["CfFormatBorderNotSupported"] = "Borders in conditional formats"; + WarningTypes["CfFormatAlignmentNotSupported"] = "Alignment in conditional formats"; + WarningTypes["CfFormatNumFmtNotSupported"] = "Num formats in conditional formats"; + WarningTypes["CfIconSetEmptyIconNotSupported"] = "IconSets with empty icons"; + WarningTypes["BadlyFormattedHyperlink"] = "Badly formatted hyperlink"; + WarningTypes["NumFmtIdNotSupported"] = "Number format"; + })(WarningTypes || (WarningTypes = {})); + class XLSXImportWarningManager { + constructor() { + this._parsingWarnings = new Set(); + this._conversionWarnings = new Set(); + } + addParsingWarning(warning) { + this._parsingWarnings.add(warning); + } + addConversionWarning(warning) { + this._conversionWarnings.add(warning); + } + get warnings() { + return [...this._parsingWarnings, ...this._conversionWarnings]; + } + /** + * Add a warning "... is not supported" to the manager. + * + * @param type the type of the warning to add + * @param name optional, name of the element that was not supported + * @param supported optional, list of the supported elements + */ + generateNotSupportedWarning(type, name, supported) { + let warning = `${type} ${name ? '"' + name + '" is' : "are"} not yet supported. `; + if (supported) { + warning += `Only ${supported.join(", ")} are currently supported.`; + } + if (!this._conversionWarnings.has(warning)) { + this._conversionWarnings.add(warning); + } + } + } + + const SUPPORTED_BORDER_STYLES = ["thin"]; + const SUPPORTED_HORIZONTAL_ALIGNMENTS = ["general", "left", "center", "right"]; + const SUPPORTED_FONTS = ["Arial"]; + const SUPPORTED_FILL_PATTERNS = ["solid"]; + const SUPPORTED_CF_TYPES = [ + "expression", + "cellIs", + "colorScale", + "iconSet", + "containsText", + "notContainsText", + "beginsWith", + "endsWith", + "containsBlanks", + "notContainsBlanks", + ]; + /** Map between cell type in XLSX file and human readable cell type */ + const CELL_TYPE_CONVERSION_MAP = { + b: "boolean", + d: "date", + e: "error", + inlineStr: "inlineStr", + n: "number", + s: "sharedString", + str: "str", + }; + /** Conversion map Border Style in XLSX <=> Border style in o_spreadsheet*/ + const BORDER_STYLE_CONVERSION_MAP = { + dashDot: "thin", + dashDotDot: "thin", + dashed: "thin", + dotted: "thin", + double: "thin", + hair: "thin", + medium: "thin", + mediumDashDot: "thin", + mediumDashDotDot: "thin", + mediumDashed: "thin", + none: undefined, + slantDashDot: "thin", + thick: "thin", + thin: "thin", + }; + /** Conversion map Horizontal Alignment in XLSX <=> Horizontal Alignment in o_spreadsheet*/ + const H_ALIGNMENT_CONVERSION_MAP = { + general: undefined, + left: "left", + center: "center", + right: "right", + fill: "left", + justify: "left", + centerContinuous: "center", + distributed: "center", + }; + /** Convert the "CellIs" cf operator. + * We have all the operators that the xlsx have, but ours begin with a uppercase character */ + function convertCFCellIsOperator(xlsxCfOperator) { + return (xlsxCfOperator.slice(0, 1).toUpperCase() + + xlsxCfOperator.slice(1)); + } + /** Conversion map CF types in XLSX <=> Cf types in o_spreadsheet */ + const CF_TYPE_CONVERSION_MAP = { + aboveAverage: undefined, + expression: undefined, + cellIs: undefined, + colorScale: undefined, + dataBar: undefined, + iconSet: undefined, + top10: undefined, + uniqueValues: undefined, + duplicateValues: undefined, + containsText: "ContainsText", + notContainsText: "NotContains", + beginsWith: "BeginsWith", + endsWith: "EndsWith", + containsBlanks: "IsEmpty", + notContainsBlanks: "IsNotEmpty", + containsErrors: undefined, + notContainsErrors: undefined, + timePeriod: undefined, + }; + /** Conversion map CF thresholds types in XLSX <=> Cf thresholds types in o_spreadsheet */ + const CF_THRESHOLD_CONVERSION_MAP = { + num: "number", + percent: "percentage", + max: "value", + min: "value", + percentile: "percentile", + formula: "formula", + }; + /** + * Conversion map between Excels IconSets and our own IconSets. The string is the key of the iconset in the ICON_SETS constant. + * + * NoIcons is undefined instead of an empty string because we don't support it and need to mange it separately. + */ + const ICON_SET_CONVERSION_MAP = { + NoIcons: undefined, + "3Arrows": "arrows", + "3ArrowsGray": "arrows", + "3Symbols": "smiley", + "3Symbols2": "smiley", + "3Signs": "dots", + "3Flags": "dots", + "3TrafficLights1": "dots", + "3TrafficLights2": "dots", + "4Arrows": "arrows", + "4ArrowsGray": "arrows", + "4RedToBlack": "dots", + "4Rating": "smiley", + "4TrafficLights": "dots", + "5Arrows": "arrows", + "5ArrowsGray": "arrows", + "5Rating": "smiley", + "5Quarters": "dots", + "3Stars": "smiley", + "3Triangles": "arrows", + "5Boxes": "dots", + }; + /** Map between legend position in XLSX file and human readable position */ + const DRAWING_LEGEND_POSITION_CONVERSION_MAP = { + b: "bottom", + t: "top", + l: "left", + r: "right", + tr: "right", + }; + /** Conversion map chart types in XLSX <=> Cf chart types o_spreadsheet (undefined for unsupported chart types)*/ + const CHART_TYPE_CONVERSION_MAP = { + areaChart: undefined, + area3DChart: undefined, + lineChart: "line", + line3DChart: undefined, + stockChart: undefined, + radarChart: undefined, + scatterChart: undefined, + pieChart: "pie", + pie3DChart: undefined, + doughnutChart: "pie", + barChart: "bar", + bar3DChart: undefined, + ofPieChart: undefined, + surfaceChart: undefined, + surface3DChart: undefined, + bubbleChart: undefined, + }; + /** Conversion map for the SUBTOTAL(index, formula) function in xlsx, index <=> actual function*/ + const SUBTOTAL_FUNCTION_CONVERSION_MAP = { + "1": "AVERAGE", + "2": "COUNT", + "3": "COUNTA", + "4": "MAX", + "5": "MIN", + "6": "PRODUCT", + "7": "STDEV", + "8": "STDEVP", + "9": "SUM", + "10": "VAR", + "11": "VARP", + "101": "AVERAGE", + "102": "COUNT", + "103": "COUNTA", + "104": "MAX", + "105": "MIN", + "106": "PRODUCT", + "107": "STDEV", + "108": "STDEVP", + "109": "SUM", + "110": "VAR", + "111": "VARP", + }; + /** Mapping between Excel format indexes (see XLSX_FORMAT_MAP) and some supported formats */ + const XLSX_FORMATS_CONVERSION_MAP = { + 0: "", + 1: "0", + 2: "0.00", + 3: "#,#00", + 4: "#,##0.00", + 9: "0%", + 10: "0.00%", + 11: undefined, + 12: undefined, + 13: undefined, + 14: "m/d/yyyy", + 15: "m/d/yyyy", + 16: "m/d/yyyy", + 17: "m/d/yyyy", + 18: "hh:mm:ss a", + 19: "hh:mm:ss a", + 20: "hhhh:mm:ss", + 21: "hhhh:mm:ss", + 22: "m/d/yy h:mm", + 37: undefined, + 38: undefined, + 39: undefined, + 40: undefined, + 45: "hhhh:mm:ss", + 46: "hhhh:mm:ss", + 47: "hhhh:mm:ss", + 48: undefined, + 49: undefined, + }; + /** + * Mapping format index to format defined by default + * + * OpenXML $18.8.30 + * */ + const XLSX_FORMAT_MAP = { + "0": 1, + "0.00": 2, + "#,#00": 3, + "#,##0.00": 4, + "0%": 9, + "0.00%": 10, + "0.00E+00": 11, + "# ?/?": 12, + "# ??/??": 13, + "mm-dd-yy": 14, + "d-mm-yy": 15, + "mm-yy": 16, + "mmm-yy": 17, + "h:mm AM/PM": 18, + "h:mm:ss AM/PM": 19, + "h:mm": 20, + "h:mm:ss": 21, + "m/d/yy h:mm": 22, + "#,##0 ;(#,##0)": 37, + "#,##0 ;[Red](#,##0)": 38, + "#,##0.00;(#,##0.00)": 39, + "#,##0.00;[Red](#,##0.00)": 40, + "mm:ss": 45, + "[h]:mm:ss": 46, + "mmss.0": 47, + "##0.0E+0": 48, + "@": 49, + "hh:mm:ss a": 19, // TODO: discuss: this format is not recognized by excel for example (doesn't follow their guidelines I guess) + }; + /** OpenXML $18.8.27 */ + const XLSX_INDEXED_COLORS = { + 0: "000000", + 1: "FFFFFF", + 2: "FF0000", + 3: "00FF00", + 4: "0000FF", + 5: "FFFF00", + 6: "FF00FF", + 7: "00FFFF", + 8: "000000", + 9: "FFFFFF", + 10: "FF0000", + 11: "00FF00", + 12: "0000FF", + 13: "FFFF00", + 14: "FF00FF", + 15: "00FFFF", + 16: "800000", + 17: "008000", + 18: "000080", + 19: "808000", + 20: "800080", + 21: "008080", + 22: "C0C0C0", + 23: "808080", + 24: "9999FF", + 25: "993366", + 26: "FFFFCC", + 27: "CCFFFF", + 28: "660066", + 29: "FF8080", + 30: "0066CC", + 31: "CCCCFF", + 32: "000080", + 33: "FF00FF", + 34: "FFFF00", + 35: "00FFFF", + 36: "800080", + 37: "800000", + 38: "008080", + 39: "0000FF", + 40: "00CCFF", + 41: "CCFFFF", + 42: "CCFFCC", + 43: "FFFF99", + 44: "99CCFF", + 45: "FF99CC", + 46: "CC99FF", + 47: "FFCC99", + 48: "3366FF", + 49: "33CCCC", + 50: "99CC00", + 51: "FFCC00", + 52: "FF9900", + 53: "FF6600", + 54: "666699", + 55: "969696", + 56: "003366", + 57: "339966", + 58: "003300", + 59: "333300", + 60: "993300", + 61: "993366", + 62: "333399", + 63: "333333", + 64: "000000", + 65: "FFFFFF", // system background + }; + + /** + * Most of the functions could stay private, but are exported for testing purposes + */ + /** + * + * Extract the color referenced inside of an XML element and return it as an hex string #RRGGBBAA (or #RRGGBB + * if alpha = FF) + * + * The color is an attribute of the element that can be : + * - rgb : an rgb string + * - theme : a reference to a theme element + * - auto : automatic coloring. Return const AUTO_COLOR in constants.ts. + * - indexed : a legacy indexing scheme for colors. The only value that should be present in a xlsx is + * 64 = System Foreground, that we can replace with AUTO_COLOR. + */ + function convertColor(xlsxColor) { + if (!xlsxColor) { + return undefined; + } + let rgb; + if (xlsxColor.rgb) { + rgb = xlsxColor.rgb; + } + else if (xlsxColor.auto) { + rgb = AUTO_COLOR; + } + else if (xlsxColor.indexed) { + rgb = XLSX_INDEXED_COLORS[xlsxColor.indexed]; + } + else { + return undefined; + } + rgb = xlsxColorToHEXA(rgb); + if (xlsxColor.tint) { + rgb = applyTint(rgb, xlsxColor.tint); + } + rgb = rgb.toUpperCase(); + // Remove unnecessary alpha + if (rgb.length === 9 && rgb.endsWith("FF")) { + rgb = rgb.slice(0, 7); + } + return rgb; + } + /** + * Convert a hex color AARRGGBB (or RRGGBB)(representation inside XLSX Xmls) to a standard js color + * representation #RRGGBBAA + */ + function xlsxColorToHEXA(color) { + if (color.length === 6) + return "#" + color + "FF"; + return "#" + color.slice(2) + color.slice(0, 2); + } + /** + * Apply tint to a color (see OpenXml spec §18.3.1.15); + */ + function applyTint(color, tint) { + const rgba = colorToRGBA(color); + const hsla = rgbaToHSLA(rgba); + if (tint < 0) { + hsla.l = hsla.l * (1 + tint); + } + if (tint > 0) { + hsla.l = hsla.l * (1 - tint) + (100 - 100 * (1 - tint)); + } + return rgbaToHex(hslaToRGBA(hsla)); + } + /** + * Convert a hex + alpha color string to an integer representation. Also remove the alpha. + * + * eg. #FF0000FF => 4278190335 + */ + function hexaToInt(hex) { + if (hex.length === 9) { + hex = hex.slice(0, 7); + } + return parseInt(hex.replace("#", ""), 16); + } + + /** + * Get the relative path between two files + * + * Eg.: + * from "folder1/file1.txt" to "folder2/file2.txt" => "../folder2/file2.txt" + */ + function getRelativePath(from, to) { + const fromPathParts = from.split("/"); + const toPathParts = to.split("/"); + let relPath = ""; + let startIndex = 0; + for (let i = 0; i < fromPathParts.length - 1; i++) { + if (fromPathParts[i] === toPathParts[i]) { + startIndex++; + } + else { + relPath += "../"; + } + } + relPath += toPathParts.slice(startIndex).join("/"); + return relPath; + } + /** + * Convert an array of element into an object where the objects keys were the elements position in the array. + * Can give an offset as argument, and all the array indexes will we shifted by this offset in the returned object. + * + * eg. : ["a", "b"] => {0:"a", 1:"b"} + */ + function arrayToObject(array, indexOffset = 0) { + const obj = {}; + for (let i = 0; i < array.length; i++) { + if (array[i]) { + obj[i + indexOffset] = array[i]; + } + } + return obj; + } + /** + * Convert an object whose keys are numbers to an array were the element index was their key in the object. + * + * eg. : {0:"a", 2:"b"} => ["a", undefined, "b"] + */ + function objectToArray(obj) { + const arr = []; + for (let key of Object.keys(obj).map(Number)) { + arr[key] = obj[key]; + } + return arr; + } + /** + * In xlsx we can have string with unicode characters with the format _x00fa_. + * Replace with characters understandable by JS + */ + function fixXlsxUnicode(str) { + return str.replace(/_x([0-9a-zA-Z]{4})_/g, (match, code) => { + return String.fromCharCode(parseInt(code, 16)); + }); + } + + function convertBorders(data, warningManager) { + const borderArray = data.borders.map((border) => { + addBorderWarnings(border, warningManager); + const b = { + top: convertBorderDescr$1(border.top, warningManager), + bottom: convertBorderDescr$1(border.bottom, warningManager), + left: convertBorderDescr$1(border.left, warningManager), + right: convertBorderDescr$1(border.right, warningManager), + }; + Object.keys(b).forEach((key) => b[key] === undefined && delete b[key]); + return b; + }); + return arrayToObject(borderArray, 1); + } + function convertBorderDescr$1(borderDescr, warningManager) { + if (!borderDescr) + return undefined; + addBorderDescrWarnings(borderDescr, warningManager); + const style = BORDER_STYLE_CONVERSION_MAP[borderDescr.style]; + return style ? [style, convertColor(borderDescr.color)] : undefined; + } + function convertStyles(data, warningManager) { + const stylesArray = data.styles.map((style) => { + return convertStyle({ + fontStyle: data.fonts[style.fontId], + fillStyle: data.fills[style.fillId], + alignment: style.alignment, + }, warningManager); + }); + return arrayToObject(stylesArray, 1); + } + function convertStyle(styleStruct, warningManager) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; + addStyleWarnings(styleStruct === null || styleStruct === void 0 ? void 0 : styleStruct.fontStyle, styleStruct === null || styleStruct === void 0 ? void 0 : styleStruct.fillStyle, warningManager); + addHorizontalAlignmentWarnings((_a = styleStruct === null || styleStruct === void 0 ? void 0 : styleStruct.alignment) === null || _a === void 0 ? void 0 : _a.horizontal, warningManager); + addVerticalAlignmentWarnings((_b = styleStruct === null || styleStruct === void 0 ? void 0 : styleStruct.alignment) === null || _b === void 0 ? void 0 : _b.vertical, warningManager); + return { + bold: (_c = styleStruct.fontStyle) === null || _c === void 0 ? void 0 : _c.bold, + italic: (_d = styleStruct.fontStyle) === null || _d === void 0 ? void 0 : _d.italic, + strikethrough: (_e = styleStruct.fontStyle) === null || _e === void 0 ? void 0 : _e.strike, + underline: (_f = styleStruct.fontStyle) === null || _f === void 0 ? void 0 : _f.underline, + align: ((_g = styleStruct.alignment) === null || _g === void 0 ? void 0 : _g.horizontal) + ? H_ALIGNMENT_CONVERSION_MAP[styleStruct.alignment.horizontal] + : undefined, + // In xlsx fills, bgColor is the color of the fill, and fgColor is the color of the pattern above the background, except in solid fills + fillColor: ((_h = styleStruct.fillStyle) === null || _h === void 0 ? void 0 : _h.patternType) === "solid" + ? convertColor((_j = styleStruct.fillStyle) === null || _j === void 0 ? void 0 : _j.fgColor) + : convertColor((_k = styleStruct.fillStyle) === null || _k === void 0 ? void 0 : _k.bgColor), + textColor: convertColor((_l = styleStruct.fontStyle) === null || _l === void 0 ? void 0 : _l.color), + fontSize: ((_m = styleStruct.fontStyle) === null || _m === void 0 ? void 0 : _m.size) + ? getClosestFontSize(styleStruct.fontStyle.size) + : undefined, + }; + } + function convertFormats(data, warningManager) { + const formats = []; + for (let style of data.styles) { + const format = convertXlsxFormat(style.numFmtId, data.numFmts, warningManager); + if (format) { + formats[style.numFmtId] = format; + } + } + return arrayToObject(formats, 1); + } + /** + * Convert excel format to o_spreadsheet format + * + * Excel format are defined in openXML §18.8.31 + */ + function convertXlsxFormat(numFmtId, formats, warningManager) { + var _a, _b, _c; + if (numFmtId === 0) { + return undefined; + } + // Format is either defined in the imported data, or the formatId is defined in openXML §18.8.30 + let format = XLSX_FORMATS_CONVERSION_MAP[numFmtId] || ((_a = formats.find((f) => f.id === numFmtId)) === null || _a === void 0 ? void 0 : _a.format); + if (format) { + try { + let convertedFormat = format.replace(/(.*?);.*/, "$1"); // only take first part of multi-part format + convertedFormat = convertedFormat.replace(/\[(.*)-[A-Z0-9]{3}\]/g, "[$1]"); // remove currency and locale/date system/number system info (ECMA §18.8.31) + convertedFormat = convertedFormat.replace(/\[\$\]/g, ""); // remove empty bocks + // Quotes in format escape sequences of characters. ATM we only support [$...] blocks to escape characters, and only one of them per format + const numberOfQuotes = ((_b = convertedFormat.match(/"/g)) === null || _b === void 0 ? void 0 : _b.length) || 0; + const numberOfOpenBrackets = ((_c = convertedFormat.match(/\[/g)) === null || _c === void 0 ? void 0 : _c.length) || 0; + if (numberOfQuotes / 2 + numberOfOpenBrackets > 1) { + throw new Error("Multiple escaped blocks in format"); + } + convertedFormat = convertedFormat.replace(/"(.*)"/g, "[$$$1]"); // replace '"..."' by '[$...]' + convertedFormat = convertedFormat.replace(/_.{1}/g, ""); // _ == ignore with of next char for align purposes. Not supported ATM + convertedFormat = convertedFormat.replace(/\*.{1}/g, ""); // * == repeat next character enough to fill the line. Not supported ATM + convertedFormat = convertedFormat.replace(/\\ /g, " "); // unescape spaces + formatValue(0, convertedFormat); + return convertedFormat; + } + catch (e) { } + } + warningManager.generateNotSupportedWarning(WarningTypes.NumFmtIdNotSupported, format || `nmFmtId ${numFmtId}`); + return undefined; + } + /** + * We currently only support only a set of font sizes, we cannot define new font sizes. + * This function adapts an arbitrary font size to the closest supported font size. + */ + function getClosestFontSize(fontSize) { + const supportedSizes = Object.keys(fontSizeMap).map(Number); + const closest = supportedSizes.reduce((prev, curr) => Math.abs(curr - fontSize) < Math.abs(prev - fontSize) ? curr : prev); + return closest; + } + // --------------------------------------------------------------------------- + // Warnings + // --------------------------------------------------------------------------- + function addStyleWarnings(font, fill, warningManager) { + if (font && font.name && !SUPPORTED_FONTS.includes(font.name)) { + warningManager.generateNotSupportedWarning(WarningTypes.FontNotSupported, font.name, SUPPORTED_FONTS); + } + if (fill && fill.patternType && !SUPPORTED_FILL_PATTERNS.includes(fill.patternType)) { + warningManager.generateNotSupportedWarning(WarningTypes.FillStyleNotSupported, fill.patternType, SUPPORTED_FILL_PATTERNS); + } + } + function addBorderDescrWarnings(borderDescr, warningManager) { + if (!SUPPORTED_BORDER_STYLES.includes(borderDescr.style)) { + warningManager.generateNotSupportedWarning(WarningTypes.BorderStyleNotSupported, borderDescr.style, SUPPORTED_BORDER_STYLES); + } + } + function addBorderWarnings(border, warningManager) { + if (border.diagonal) { + warningManager.generateNotSupportedWarning(WarningTypes.DiagonalBorderNotSupported); + } + } + function addHorizontalAlignmentWarnings(alignment, warningManager) { + if (alignment && !SUPPORTED_HORIZONTAL_ALIGNMENTS.includes(alignment)) { + warningManager.generateNotSupportedWarning(WarningTypes.HorizontalAlignmentNotSupported, alignment, SUPPORTED_HORIZONTAL_ALIGNMENTS); + } + } + function addVerticalAlignmentWarnings(alignment, warningManager) { + if (alignment) { + warningManager.generateNotSupportedWarning(WarningTypes.VerticalAlignmentNotSupported); + } + } + + function convertConditionalFormats(xlsxCfs, dxfs, warningManager) { + const cfs = []; + let cfId = 1; + for (let cf of xlsxCfs) { + if (cf.cfRules.length === 0) + continue; + addCfConversionWarnings(cf, dxfs, warningManager); + const rule = cf.cfRules[0]; + let operator; + const values = []; + if (rule.dxfId === undefined && !(rule.type === "colorScale" || rule.type === "iconSet")) + continue; + switch (rule.type) { + case "aboveAverage": + case "containsErrors": + case "notContainsErrors": + case "dataBar": + case "duplicateValues": + case "expression": + case "top10": + case "uniqueValues": + case "timePeriod": + // Not supported + continue; + case "colorScale": + const colorScale = convertColorScale(cfId++, cf); + if (colorScale) { + cfs.push(colorScale); + } + continue; + case "iconSet": + const iconSet = convertIconSet(cfId++, cf, warningManager); + if (iconSet) { + cfs.push(iconSet); + } + continue; + case "containsText": + case "notContainsText": + case "beginsWith": + case "endsWith": + if (!rule.text) + continue; + operator = CF_TYPE_CONVERSION_MAP[rule.type]; + values.push(rule.text); + break; + case "containsBlanks": + case "notContainsBlanks": + operator = CF_TYPE_CONVERSION_MAP[rule.type]; + break; + case "cellIs": + if (!rule.operator || !rule.formula || rule.formula.length === 0) + continue; + operator = convertCFCellIsOperator(rule.operator); + values.push(rule.formula[0]); + if (rule.formula.length === 2) { + values.push(rule.formula[1]); + } + break; + } + if (operator && rule.dxfId !== undefined) { + cfs.push({ + id: (cfId++).toString(), + ranges: cf.sqref, + stopIfTrue: rule.stopIfTrue, + rule: { + type: "CellIsRule", + operator: operator, + values: values, + style: convertStyle({ fontStyle: dxfs[rule.dxfId].font, fillStyle: dxfs[rule.dxfId].fill }, warningManager), + }, + }); + } + } + return cfs; + } + function convertColorScale(id, xlsxCf) { + const scale = xlsxCf.cfRules[0].colorScale; + if (!scale || + scale.cfvos.length !== scale.colors.length || + scale.cfvos.length < 2 || + scale.cfvos.length > 3) { + return undefined; + } + const thresholds = []; + for (let i = 0; i < scale.cfvos.length; i++) { + thresholds.push({ + color: hexaToInt(convertColor(scale.colors[i]) || "#FFFFFF"), + type: CF_THRESHOLD_CONVERSION_MAP[scale.cfvos[i].type], + value: scale.cfvos[i].value, + }); + } + const minimum = thresholds[0]; + const maximum = thresholds.length === 2 ? thresholds[1] : thresholds[2]; + const midpoint = thresholds.length === 3 ? thresholds[1] : undefined; + return { + id: id.toString(), + stopIfTrue: xlsxCf.cfRules[0].stopIfTrue, + ranges: xlsxCf.sqref, + rule: { type: "ColorScaleRule", minimum, midpoint, maximum }, + }; + } + /** + * Convert Icons Sets. + * + * In the Xlsx extension of OpenXml, the IconSets can either be simply an IconSet, or a list of Icons + * (ie. their respective IconSet and their id in this set). + * + * In the case of a list of icons : + * - The order of the icons is lower => middle => upper + * - The their ids are : 0 : bad, 1 : neutral, 2 : good + */ + function convertIconSet(id, xlsxCf, warningManager) { + const xlsxIconSet = xlsxCf.cfRules[0].iconSet; + if (!xlsxIconSet) + return undefined; + let cfVos = xlsxIconSet.cfvos; + let cfIcons = xlsxIconSet.cfIcons; + if (cfVos.length < 3 || (cfIcons && cfIcons.length < 3)) { + return undefined; + } + // We don't support icon sets with more than 3 icons, so take the extrema and the middle. + if (cfVos.length > 3) { + cfVos = [cfVos[0], cfVos[Math.floor(cfVos.length / 2)], cfVos[cfVos.length - 1]]; + } + if (cfIcons && cfIcons.length > 3) { + cfIcons = [cfIcons[0], cfIcons[Math.floor(cfIcons.length / 2)], cfIcons[cfIcons.length - 1]]; + } + // In xlsx, the thresholds are NOT in the first cfVo, but on the second and third + const thresholds = []; + for (let i = 1; i <= 2; i++) { + const type = CF_THRESHOLD_CONVERSION_MAP[cfVos[i].type]; + if (type === "value") { + return undefined; + } + thresholds.push({ + value: cfVos[i].value || "", + operator: cfVos[i].gte ? "ge" : "gt", + type: type, + }); + } + let icons = { + lower: cfIcons + ? convertIcons(cfIcons[0].iconSet, cfIcons[0].iconId) + : convertIcons(xlsxIconSet.iconSet, 0), + middle: cfIcons + ? convertIcons(cfIcons[1].iconSet, cfIcons[1].iconId) + : convertIcons(xlsxIconSet.iconSet, 1), + upper: cfIcons + ? convertIcons(cfIcons[2].iconSet, cfIcons[2].iconId) + : convertIcons(xlsxIconSet.iconSet, 2), + }; + if (xlsxIconSet.reverse) { + icons = { upper: icons.lower, middle: icons.middle, lower: icons.upper }; + } + // We don't support empty icons in an IconSet, put a dot icon instead + for (let key of Object.keys(icons)) { + if (!icons[key]) { + warningManager.generateNotSupportedWarning(WarningTypes.CfIconSetEmptyIconNotSupported); + switch (key) { + case "upper": + icons[key] = ICON_SETS.dots.good; + break; + case "middle": + icons[key] = ICON_SETS.dots.neutral; + break; + case "lower": + icons[key] = ICON_SETS.dots.bad; + break; + } + } + } + return { + id: id.toString(), + stopIfTrue: xlsxCf.cfRules[0].stopIfTrue, + ranges: xlsxCf.sqref, + rule: { + type: "IconSetRule", + icons: icons, + upperInflectionPoint: thresholds[1], + lowerInflectionPoint: thresholds[0], + }, + }; + } + /** + * Convert an icon from a XLSX. + * + * The indexes are : 0 : bad, 1 : neutral, 2 : good + */ + function convertIcons(xlsxIconSet, index) { + const iconSet = ICON_SET_CONVERSION_MAP[xlsxIconSet]; + if (!iconSet) + return ""; + return index === 0 + ? ICON_SETS[iconSet].bad + : index === 1 + ? ICON_SETS[iconSet].neutral + : ICON_SETS[iconSet].good; + } + // --------------------------------------------------------------------------- + // Warnings + // --------------------------------------------------------------------------- + function addCfConversionWarnings(cf, dxfs, warningManager) { + if (cf.cfRules.length > 1) { + warningManager.generateNotSupportedWarning(WarningTypes.MultipleRulesCfNotSupported); + } + if (!SUPPORTED_CF_TYPES.includes(cf.cfRules[0].type)) { + warningManager.generateNotSupportedWarning(WarningTypes.CfTypeNotSupported, cf.cfRules[0].type); + } + if (cf.cfRules[0].dxfId) { + const dxf = dxfs[cf.cfRules[0].dxfId]; + if (dxf.border) { + warningManager.generateNotSupportedWarning(WarningTypes.CfFormatBorderNotSupported); + } + if (dxf.alignment) { + warningManager.generateNotSupportedWarning(WarningTypes.CfFormatAlignmentNotSupported); + } + if (dxf.numFmt) { + warningManager.generateNotSupportedWarning(WarningTypes.CfFormatNumFmtNotSupported); + } + } + } + + // ------------------------------------- + // CF HELPERS + // ------------------------------------- + /** + * Convert the conditional formatting o-spreadsheet operator to + * the corresponding excel operator. + * */ + function convertOperator(operator) { + switch (operator) { + case "IsNotEmpty": + return "notContainsBlanks"; + case "IsEmpty": + return "containsBlanks"; + case "NotContains": + return "notContainsBlanks"; + default: + return operator.charAt(0).toLowerCase() + operator.slice(1); + } + } + // ------------------------------------- + // WORKSHEET HELPERS + // ------------------------------------- + function getCellType(value) { + switch (typeof value) { + case "boolean": + return "b"; + case "string": + return "str"; + case "number": + return "n"; + } + } + /** + * For some reason, Excel will only take the devicePixelRatio (i.e. interface scale on Windows desktop) + * into account for the height. + */ + function convertHeightToExcel(height) { + return Math.round(HEIGHT_FACTOR * height * window.devicePixelRatio * 100) / 100; + } + function convertWidthToExcel(width) { + return Math.round(WIDTH_FACTOR * width * 100) / 100; + } + function convertHeightFromExcel(height) { + if (!height) + return height; + return Math.round((height / HEIGHT_FACTOR) * 100) / 100; + } + function convertWidthFromExcel(width) { + if (!width) + return width; + return Math.round((width / WIDTH_FACTOR) * 100) / 100; + } + function convertBorderDescr(descr) { + if (!descr) { + return undefined; + } + return { + style: descr[0], + color: { rgb: descr[1] }, + }; + } + function extractStyle(cell, data) { + let style = {}; + if (cell.style) { + style = data.styles[cell.style]; + } + const format = cell.format ? data.formats[cell.format] : undefined; + const exportedBorder = {}; + if (cell.border) { + const border = data.borders[cell.border]; + exportedBorder.left = convertBorderDescr(border.left); + exportedBorder.right = convertBorderDescr(border.right); + exportedBorder.bottom = convertBorderDescr(border.bottom); + exportedBorder.top = convertBorderDescr(border.top); + } + const styles = { + font: { + size: (style === null || style === void 0 ? void 0 : style.fontSize) || DEFAULT_FONT_SIZE, + color: { rgb: (style === null || style === void 0 ? void 0 : style.textColor) ? style.textColor : "000000" }, + family: 2, + name: "Arial", + }, + fill: (style === null || style === void 0 ? void 0 : style.fillColor) + ? { + fgColor: { rgb: style.fillColor }, + } + : { reservedAttribute: "none" }, + numFmt: format ? { format: format, id: 0 /* id not used for export */ } : undefined, + border: exportedBorder || {}, + alignment: { + vertical: "center", + horizontal: style.align, + }, + }; + styles.font["strike"] = !!(style === null || style === void 0 ? void 0 : style.strikethrough) || undefined; + styles.font["underline"] = !!(style === null || style === void 0 ? void 0 : style.underline) || undefined; + styles.font["bold"] = !!(style === null || style === void 0 ? void 0 : style.bold) || undefined; + styles.font["italic"] = !!(style === null || style === void 0 ? void 0 : style.italic) || undefined; + return styles; + } + function normalizeStyle(construct, styles) { + const { id: fontId } = pushElement(styles["font"], construct.fonts); + const { id: fillId } = pushElement(styles["fill"], construct.fills); + const { id: borderId } = pushElement(styles["border"], construct.borders); + // Normalize this + const numFmtId = convertFormat(styles["numFmt"], construct.numFmts); + const style = { + fontId, + fillId, + borderId, + numFmtId, + alignment: { + vertical: styles.alignment.vertical, + horizontal: styles.alignment.horizontal, + }, + }; + const { id } = pushElement(style, construct.styles); + return id; + } + function convertFormat(format, numFmtStructure) { + if (!format) { + return 0; + } + let formatId = XLSX_FORMAT_MAP[format.format]; + if (!formatId) { + const { id } = pushElement(format, numFmtStructure); + formatId = id + FIRST_NUMFMT_ID; + } + return formatId; + } + /** + * Add a relation to the given file and return its id. + */ + function addRelsToFile(relsFiles, path, rel) { + let relsFile = relsFiles.find((file) => file.path === path); + // the id is a one-based int casted as string + let id; + if (!relsFile) { + id = "rId1"; + relsFiles.push({ path, rels: [{ ...rel, id }] }); + } + else { + id = `rId${(relsFile.rels.length + 1).toString()}`; + relsFile.rels.push({ + ...rel, + id, + }); + } + return id; + } + function pushElement(property, propertyList) { + for (let [key, value] of Object.entries(propertyList)) { + if (JSON.stringify(value) === JSON.stringify(property)) { + return { id: parseInt(key, 10), list: propertyList }; + } + } + let elemId = propertyList.findIndex((elem) => JSON.stringify(elem) === JSON.stringify(property)); + if (elemId === -1) { + propertyList.push(property); + elemId = propertyList.length - 1; + } + return { + id: elemId, + list: propertyList, + }; + } + const chartIds = []; + /** + * Convert a chart o-spreadsheet id to a xlsx id which + * are unsigned integers (starting from 1). + */ + function convertChartId(chartId) { + const xlsxId = chartIds.findIndex((id) => id === chartId); + if (xlsxId === -1) { + chartIds.push(chartId); + return chartIds.length; + } + return xlsxId + 1; + } + /** + * Convert a value expressed in dot to EMU. + * EMU = English Metrical Unit + * There are 914400 EMU per inch. + * + * /!\ A value expressed in EMU cannot be fractional. + * See https://docs.microsoft.com/en-us/windows/win32/vml/msdn-online-vml-units#other-units-of-measurement + */ + function convertDotValueToEMU(value) { + const DPI = 96; + return Math.round((value * 914400) / DPI); + } + function getRangeSize(xc, defaultSheetIndex, data) { + const xcSplit = xc.split("!"); + let rangeSheetIndex; + if (xcSplit.length > 1) { + const index = data.sheets.findIndex((sheet) => sheet.name === xcSplit[0]); + if (index < 0) { + throw new Error("Unable to find a sheet with the name " + xcSplit[0]); + } + rangeSheetIndex = index; + xc = xcSplit[1]; + } + else { + rangeSheetIndex = Number(defaultSheetIndex); + } + const zone = toUnboundedZone(xc); + if (zone.right === undefined) { + zone.right = data.sheets[rangeSheetIndex].colNumber; + } + if (zone.bottom === undefined) { + zone.bottom = data.sheets[rangeSheetIndex].rowNumber; + } + return (zone.right - zone.left + 1) * (zone.bottom - zone.top + 1); + } + function convertEMUToDotValue(value) { + const DPI = 96; + return Math.round((value * DPI) / 914400); + } + /** + * Get the position of the start of a column in Excel (in px). + */ + function getColPosition(colIndex, sheetData) { + var _a; + let position = 0; + for (let i = 0; i < colIndex; i++) { + const colAtIndex = sheetData.cols.find((col) => i >= col.min && i <= col.max); + if (colAtIndex === null || colAtIndex === void 0 ? void 0 : colAtIndex.width) { + position += colAtIndex.width; + } + else if ((_a = sheetData.sheetFormat) === null || _a === void 0 ? void 0 : _a.defaultColWidth) { + position += sheetData.sheetFormat.defaultColWidth; + } + else { + position += EXCEL_DEFAULT_COL_WIDTH; + } + } + return position / WIDTH_FACTOR; + } + /** + * Get the position of the start of a row in Excel (in px). + */ + function getRowPosition(rowIndex, sheetData) { + var _a; + let position = 0; + for (let i = 0; i < rowIndex; i++) { + const rowAtIndex = sheetData.rows[i]; + if (rowAtIndex === null || rowAtIndex === void 0 ? void 0 : rowAtIndex.height) { + position += rowAtIndex.height; + } + else if ((_a = sheetData.sheetFormat) === null || _a === void 0 ? void 0 : _a.defaultRowHeight) { + position += sheetData.sheetFormat.defaultRowHeight; + } + else { + position += EXCEL_DEFAULT_ROW_HEIGHT; + } + } + return position / HEIGHT_FACTOR; + } + + function convertFigures(sheetData) { + let id = 1; + return sheetData.figures + .map((figure) => convertFigure(figure, (id++).toString(), sheetData)) + .filter(isDefined$1); + } + function convertFigure(figure, id, sheetData) { + const x1 = getColPosition(figure.anchors[0].col, sheetData) + + convertEMUToDotValue(figure.anchors[0].colOffset); + const x2 = getColPosition(figure.anchors[1].col, sheetData) + + convertEMUToDotValue(figure.anchors[1].colOffset); + const y1 = getRowPosition(figure.anchors[0].row, sheetData) + + convertEMUToDotValue(figure.anchors[0].rowOffset); + const y2 = getRowPosition(figure.anchors[1].row, sheetData) + + convertEMUToDotValue(figure.anchors[1].rowOffset); + const width = x2 - x1; + const height = y2 - y1; + const chartData = convertChartData(figure.data); + if (!chartData) + return undefined; + return { + id: id, + x: x1, + y: y1, + width: width, + height: height, + tag: "chart", + data: convertChartData(figure.data), + }; + } + function convertChartData(chartData) { + var _a; + const labelRange = (_a = chartData.dataSets[0].label) === null || _a === void 0 ? void 0 : _a.replace(/\$/g, ""); + let dataSets = chartData.dataSets.map((data) => data.range.replace(/\$/g, "")); + // For doughnut charts, in chartJS first dataset = outer dataset, in excel first dataset = inner dataset + if (chartData.type === "pie") { + dataSets.reverse(); + } + return { + dataSets, + dataSetsHaveTitle: false, + labelRange, + title: chartData.title || "", + type: chartData.type, + background: convertColor({ rgb: chartData.backgroundColor }) || "#FFFFFF", + verticalAxisPosition: chartData.verticalAxisPosition, + legendPosition: chartData.legendPosition, + stackedBar: chartData.stackedBar || false, + labelsAsText: false, + }; + } + + /** + * Match external reference (ex. '[1]Sheet 3'!$B$4) + * + * First match group is the external reference id + * Second match group is the sheet id + * Third match group is the reference of the cell + */ + const externalReferenceRegex = new RegExp(/'?\[([0-9]*)\](.*)'?!(\$?[a-zA-Z]*\$?[0-9]*)/g); + const subtotalRegex = new RegExp(/SUBTOTAL\(([0-9]*),/g); + const cellRegex = new RegExp(cellReference.source, "ig"); + function convertFormulasContent(sheet, data) { + const sfMap = getSharedFormulasMap(sheet); + for (let cell of sheet.rows.map((row) => row.cells).flat()) { + if (cell === null || cell === void 0 ? void 0 : cell.formula) { + cell.formula.content = + cell.formula.sharedIndex !== undefined && !cell.formula.content + ? "=" + adaptFormula(cell.xc, sfMap[cell.formula.sharedIndex]) + : "=" + cell.formula.content; + cell.formula.content = convertFormula(cell.formula.content, data); + } + } + } + function getSharedFormulasMap(sheet) { + const formulas = {}; + for (let row of sheet.rows) { + for (let cell of row.cells) { + if (cell.formula && cell.formula.sharedIndex !== undefined && cell.formula.content) { + formulas[cell.formula.sharedIndex] = { refCellXc: cell.xc, formula: cell.formula.content }; + } + } + } + return formulas; + } + /** + * Convert an XLSX formula into something we can evaluate. + * - remove _xlfn. flags before function names + * - convert the SUBTOTAL(index, formula) function to the function given by its index + * - change #REF! into #REF + * - convert external references into their value + */ + function convertFormula(formula, data) { + formula = formula.replace("_xlfn.", ""); + formula = formula.replace(/#REF!/g, "#REF"); + // SUBOTOTAL function, eg. =SUBTOTAL(3, {formula}) + formula = formula.replace(subtotalRegex, (match, functionId) => { + const convertedFunction = SUBTOTAL_FUNCTION_CONVERSION_MAP[functionId]; + return convertedFunction ? convertedFunction + "(" : match; + }); + // External references, eg. ='[1]Sheet 3'!$B$4 + formula = formula.replace(externalReferenceRegex, (match, externalRefId, sheetName, cellRef) => { + var _a; + externalRefId = Number(externalRefId) - 1; + cellRef = cellRef.replace(/\$/g, ""); + const sheetIndex = data.externalBooks[externalRefId].sheetNames.findIndex((name) => name === sheetName); + if (sheetIndex === -1) { + return match; + } + const externalDataset = (_a = data.externalBooks[externalRefId].datasets.find((dataset) => dataset.sheetId === sheetIndex)) === null || _a === void 0 ? void 0 : _a.data; + if (!externalDataset) { + return match; + } + const datasetValue = externalDataset && externalDataset[cellRef]; + const convertedValue = Number(datasetValue) ? datasetValue : `"${datasetValue}"`; + return convertedValue || match; + }); + return formula; + } + /** + * Transform a shared formula for the given target. + * + * This will compute the offset between the original cell of the shared formula and the target cell, + * then apply this offset to all the ranges in the formula (taking fixed references into account) + */ + function adaptFormula(targetCell, sf) { + const refPosition = toCartesian(sf.refCellXc); + let newFormula = sf.formula.slice(); + let match; + do { + match = cellRegex.exec(newFormula); + if (match) { + const formulaPosition = toCartesian(match[0].replace("$", "")); + const targetPosition = toCartesian(targetCell); + const rangePart = { + colFixed: match[0].startsWith("$"), + rowFixed: match[0].includes("$", 1), + }; + const offset = { + col: targetPosition.col - refPosition.col, + row: targetPosition.row - refPosition.row, + }; + const offsettedPosition = { + col: rangePart.colFixed ? formulaPosition.col : formulaPosition.col + offset.col, + row: rangePart.rowFixed ? formulaPosition.row : formulaPosition.row + offset.row, + }; + newFormula = + newFormula.slice(0, match.index) + + toXC(offsettedPosition.col, offsettedPosition.row, rangePart) + + newFormula.slice(match.index + match[0].length); + } + } while (match); + return newFormula; + } + + function convertSheets(data, warningManager) { + return data.sheets.map((sheet) => { + convertFormulasContent(sheet, data); + const sheetDims = getSheetDims(sheet); + const sheetOptions = sheet.sheetViews[0]; + return { + id: sheet.sheetName, + areGridLinesVisible: sheetOptions ? sheetOptions.showGridLines : true, + name: sheet.sheetName, + colNumber: sheetDims[0], + rowNumber: sheetDims[1], + cells: convertCells(sheet, data, sheetDims, warningManager), + merges: sheet.merges, + cols: convertCols(sheet, sheetDims[0]), + rows: convertRows(sheet, sheetDims[1]), + conditionalFormats: convertConditionalFormats(sheet.cfs, data.dxfs, warningManager), + figures: convertFigures(sheet), + isVisible: sheet.isVisible, + }; + }); + } + function convertCols(sheet, numberOfCols) { + var _a; + const cols = {}; + // Excel begins indexes at 1 + for (let i = 1; i < numberOfCols + 1; i++) { + const col = sheet.cols.find((col) => col.min <= i && i <= col.max); + let colSize; + if (col && col.width) + colSize = col.width; + else if ((_a = sheet.sheetFormat) === null || _a === void 0 ? void 0 : _a.defaultColWidth) + colSize = sheet.sheetFormat.defaultColWidth; + else + colSize = EXCEL_DEFAULT_COL_WIDTH; + cols[i - 1] = { size: convertWidthFromExcel(colSize), isHidden: col === null || col === void 0 ? void 0 : col.hidden }; + } + return cols; + } + function convertRows(sheet, numberOfRows) { + var _a; + const rows = {}; + // Excel begins indexes at 1 + for (let i = 1; i < numberOfRows + 1; i++) { + const row = sheet.rows.find((row) => row.index === i); + let rowSize; + if (row && row.height) + rowSize = row.height; + else if ((_a = sheet.sheetFormat) === null || _a === void 0 ? void 0 : _a.defaultRowHeight) + rowSize = sheet.sheetFormat.defaultRowHeight; + else + rowSize = EXCEL_DEFAULT_ROW_HEIGHT; + rows[i - 1] = { size: convertHeightFromExcel(rowSize), isHidden: row === null || row === void 0 ? void 0 : row.hidden }; + } + return rows; + } + /** Remove newlines (\n) in shared strings, We do not support them */ + function convertSharedStrings(xlsxSharedStrings) { + return xlsxSharedStrings.map((str) => str.replace(/\n/g, "")); + } + function convertCells(sheet, data, sheetDims, warningManager) { + const cells = {}; + const sharedStrings = convertSharedStrings(data.sharedStrings); + const hyperlinkMap = sheet.hyperlinks.reduce((map, link) => { + map[link.xc] = link; + return map; + }, {}); + for (let row of sheet.rows) { + for (let cell of row.cells) { + cells[cell.xc] = { + content: getCellValue(cell, hyperlinkMap, sharedStrings, warningManager), + // + 1 : our indexes for normalized values begin at 1 and not 0 + style: cell.styleIndex ? cell.styleIndex + 1 : undefined, + border: cell.styleIndex ? data.styles[cell.styleIndex].borderId + 1 : undefined, + format: cell.styleIndex ? data.styles[cell.styleIndex].numFmtId + 1 : undefined, + }; + } + } + // Apply row style + for (let row of sheet.rows.filter((row) => row.styleIndex)) { + for (let colIndex = 1; colIndex <= sheetDims[0]; colIndex++) { + const xc = toXC(colIndex - 1, row.index - 1); // Excel indexes start at 1 + let cell = cells[xc]; + if (!cell) { + cell = {}; + cells[xc] = cell; + } + cell.style = cell.style ? cell.style : row.styleIndex + 1; + cell.border = cell.border ? cell.border : data.styles[row.styleIndex].borderId + 1; + cell.format = cell.format ? cell.format : data.styles[row.styleIndex].numFmtId + 1; + } + } + // Apply col style + for (let col of sheet.cols.filter((col) => col.styleIndex)) { + for (let colIndex = col.min; colIndex <= Math.min(col.max, sheetDims[0]); colIndex++) { + for (let rowIndex = 1; rowIndex <= sheetDims[1]; rowIndex++) { + const xc = toXC(colIndex - 1, rowIndex - 1); // Excel indexes start at 1 + let cell = cells[xc]; + if (!cell) { + cell = {}; + cells[xc] = cell; + } + cell.style = cell.style ? cell.style : col.styleIndex + 1; + cell.border = cell.border ? cell.border : data.styles[col.styleIndex].borderId + 1; + cell.format = cell.format ? cell.format : data.styles[col.styleIndex].numFmtId + 1; + } + } + } + return cells; + } + function getCellValue(cell, hyperLinksMap, sharedStrings, warningManager) { + let cellValue; + switch (cell.type) { + case "sharedString": + const ssIndex = parseInt(cell.value, 10); + cellValue = sharedStrings[ssIndex]; + break; + case "boolean": + cellValue = Number(cell.value) ? "TRUE" : "FALSE"; + break; + case "date": // I'm not sure where this is used rather than a number with a format + case "error": // I don't think Excel really uses this + case "inlineStr": + case "number": + case "str": + cellValue = cell.value; + break; + } + if (cellValue && hyperLinksMap[cell.xc]) { + cellValue = convertHyperlink(hyperLinksMap[cell.xc], cellValue, warningManager); + } + if (cell.formula) { + cellValue = cell.formula.content; + } + return cellValue; + } + function convertHyperlink(link, cellValue, warningManager) { + const label = link.display || cellValue; + if (!link.relTarget && !link.location) { + warningManager.generateNotSupportedWarning(WarningTypes.BadlyFormattedHyperlink); + } + const url = link.relTarget ? link.relTarget : buildSheetLink(link.location.split("!")[0]); + return markdownLink(label, url); + } + function getSheetDims(sheet) { + const dims = [0, 0]; + for (let row of sheet.rows) { + dims[0] = Math.max(dims[0], ...row.cells.map((cell) => toCartesian(cell.xc).col)); + dims[1] = Math.max(dims[1], row.index); + } + dims[0] = Math.max(dims[0], EXCEL_IMPORT_DEFAULT_NUMBER_OF_COLS); + dims[1] = Math.max(dims[1], EXCEL_IMPORT_DEFAULT_NUMBER_OF_ROWS); + return dims; + } + + const TABLE_HEADER_STYLE = { + fillColor: "#000000", + textColor: "#ffffff", + bold: true, + }; + const TABLE_HIGHLIGHTED_CELL_STYLE = { + bold: true, + }; + const TABLE_BORDER_STYLE = ["thin", "#000000FF"]; + /** + * Convert the imported XLSX tables. + * + * As we don't support a concept similar to the XLSX tables, we will settle for applying a style in all + * the cells of the table and converting the table-specific formula references into standard reference. + * + * Change the converted data in-place. + */ + function convertTables(convertedData, xlsxData) { + applyTableStyle(convertedData, xlsxData); + convertTableFormulaReferences(convertedData.sheets, xlsxData.sheets); + } + /** + * Apply a style to all the cells that are in a table, and add the created styles in the converted data. + * + * In XLSXs, the style of the cells of a table are not directly in the sheet, but rather deduced from the style of + * the table that is defined in the table's XML file. The style of the table is a string referencing a standard style + * defined in the OpenXML specifications. As there are 80+ different styles, we won't implement every one of them but + * we will just define a style that will be used for all the imported tables. + */ + function applyTableStyle(convertedData, xlsxData) { + var _a, _b, _c, _d; + const styles = objectToArray(convertedData.styles); + const borders = objectToArray(convertedData.borders); + for (let xlsxSheet of xlsxData.sheets) { + for (let table of xlsxSheet.tables) { + const sheet = convertedData.sheets.find((sheet) => sheet.name === xlsxSheet.sheetName); + const tableZone = toZone(table.ref); + // Table style + for (let i = 0; i < table.headerRowCount; i++) { + applyStyleToZone(TABLE_HEADER_STYLE, { ...tableZone, bottom: tableZone.top + i }, sheet.cells, styles); + } + for (let i = 0; i < table.totalsRowCount; i++) { + applyStyleToZone(TABLE_HIGHLIGHTED_CELL_STYLE, { ...tableZone, top: tableZone.bottom - i }, sheet.cells, styles); + } + if ((_a = table.style) === null || _a === void 0 ? void 0 : _a.showFirstColumn) { + applyStyleToZone(TABLE_HIGHLIGHTED_CELL_STYLE, { ...tableZone, right: tableZone.left }, sheet.cells, styles); + } + if ((_b = table.style) === null || _b === void 0 ? void 0 : _b.showLastColumn) { + applyStyleToZone(TABLE_HIGHLIGHTED_CELL_STYLE, { ...tableZone, left: tableZone.right }, sheet.cells, styles); + } + // Table borders + // Borders at : table outline + col(/row) if showColumnStripes(/showRowStripes) + border above totalRow + for (let col = tableZone.left; col <= tableZone.right; col++) { + for (let row = tableZone.top; row <= tableZone.bottom; row++) { + const xc = toXC(col, row); + const cell = sheet.cells[xc]; + const border = { + left: col === tableZone.left || ((_c = table.style) === null || _c === void 0 ? void 0 : _c.showColumnStripes) + ? TABLE_BORDER_STYLE + : undefined, + right: col === tableZone.right ? TABLE_BORDER_STYLE : undefined, + top: row === tableZone.top || + ((_d = table.style) === null || _d === void 0 ? void 0 : _d.showRowStripes) || + row > tableZone.bottom - table.totalsRowCount + ? TABLE_BORDER_STYLE + : undefined, + bottom: row === tableZone.bottom ? TABLE_BORDER_STYLE : undefined, + }; + const newBorder = (cell === null || cell === void 0 ? void 0 : cell.border) ? { ...borders[cell.border], ...border } : border; + let borderIndex = borders.findIndex((border) => deepEquals(border, newBorder)); + if (borderIndex === -1) { + borderIndex = borders.length; + borders.push(newBorder); + } + if (cell) { + cell.border = borderIndex; + } + else { + sheet.cells[xc] = { border: borderIndex }; + } + } + } + } + } + convertedData.styles = arrayToObject(styles); + convertedData.borders = arrayToObject(borders); + } + /** + * Apply a style to all the cells in the zone. The applied style WILL NOT overwrite values in existing style of the cell. + * + * If a style that was not in the styles array was applied, push it into the style array. + */ + function applyStyleToZone(appliedStyle, zone, cells, styles) { + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + const xc = toXC(col, row); + const cell = cells[xc]; + const newStyle = (cell === null || cell === void 0 ? void 0 : cell.style) ? { ...styles[cell.style], ...appliedStyle } : appliedStyle; + let styleIndex = styles.findIndex((style) => deepEquals(style, newStyle)); + if (styleIndex === -1) { + styleIndex = styles.length; + styles.push(newStyle); + } + if (cell) { + cell.style = styleIndex; + } + else { + cells[xc] = { style: styleIndex }; + } + } + } + } + /** + * In all the sheets, replace the table-only references in the formula cells with standard references. + */ + function convertTableFormulaReferences(convertedSheets, xlsxSheets) { + for (let sheet of convertedSheets) { + const tables = xlsxSheets.find((s) => s.sheetName === sheet.name).tables; + for (let table of tables) { + const tabRef = table.name + "["; + for (let position of positions(toZone(table.ref))) { + const xc = toXC(position.col, position.row); + const cell = sheet.cells[xc]; + if (cell && cell.content && cell.content.startsWith("=")) { + let refIndex; + while ((refIndex = cell.content.indexOf(tabRef)) !== -1) { + let reference = cell.content.slice(refIndex + tabRef.length); + // Expression can either be tableName[colName] or tableName[[#This Row], [colName]] + let endIndex = reference.indexOf("]"); + if (reference.startsWith(`[`)) { + endIndex = reference.indexOf("]", endIndex + 1); + endIndex = reference.indexOf("]", endIndex + 1); + } + reference = reference.slice(0, endIndex); + const convertedRef = convertTableReference(reference, table, xc); + cell.content = + cell.content.slice(0, refIndex) + + convertedRef + + cell.content.slice(tabRef.length + refIndex + endIndex + 1); + } + } + } + } + } + } + /** + * Convert table-specific references in formulas into standard references. + * + * A reference in a table can have the form (only the part between brackets should be given to this function): + * - tableName[colName] : reference to the whole column "colName" + * - tableName[[#keyword], [colName]] : reference to some of the element(s) of the column colName + * + * The available keywords are : + * - #All : all the column (including totals) + * - #Data : only the column data (no headers/totals) + * - #Headers : only the header of the column + * - #Totals : only the totals of the column + * - #This Row : only the element in the same row as the cell + */ + function convertTableReference(expr, table, cellXc) { + const refElements = expr.split(","); + const tableZone = toZone(table.ref); + const refZone = { ...tableZone }; + let isReferencedZoneValid = true; + // Single column reference + if (refElements.length === 1) { + const colRelativeIndex = table.cols.findIndex((col) => col.name === refElements[0]); + refZone.left = refZone.right = colRelativeIndex + tableZone.left; + if (table.headerRowCount) { + refZone.top += table.headerRowCount; + } + if (table.totalsRowCount) { + refZone.bottom -= 1; + } + } + // Other references + else { + switch (refElements[0].slice(1, refElements[0].length - 1)) { + case "#All": + refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top; + refZone.bottom = tableZone.bottom; + break; + case "#Data": + refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top; + refZone.bottom = table.totalsRowCount ? tableZone.bottom + 1 : tableZone.bottom; + break; + case "#This Row": + refZone.top = refZone.bottom = toCartesian(cellXc).row; + break; + case "#Headers": + refZone.top = refZone.bottom = tableZone.top; + if (!table.headerRowCount) { + isReferencedZoneValid = false; + } + break; + case "#Totals": + refZone.top = refZone.bottom = tableZone.bottom; + if (!table.totalsRowCount) { + isReferencedZoneValid = false; + } + break; + } + const colRef = refElements[1].slice(1, refElements[1].length - 1); + const colRelativeIndex = table.cols.findIndex((col) => col.name === colRef); + refZone.left = refZone.right = colRelativeIndex + tableZone.left; + } + if (!isReferencedZoneValid) { + return INCORRECT_RANGE_STRING; + } + return refZone.top !== refZone.bottom ? zoneToXc(refZone) : toXC(refZone.left, refZone.top); + } + + // ------------------------------------- + // XML HELPERS + // ------------------------------------- + function createXMLFile(doc, path, contentType) { + return { + content: new XMLSerializer().serializeToString(doc), + path, + contentType, + }; + } + function xmlEscape(str) { + return String(str) + .replace(/\&/g, "&") + .replace(/\/g, ">") + .replace(/\"/g, """) + .replace(/\'/g, "'"); + } + function formatAttributes(attrs) { + return new XMLString(attrs.map(([key, val]) => `${key}="${xmlEscape(val)}"`).join(" ")); + } + function parseXML(xmlString) { + const document = new DOMParser().parseFromString(xmlString.toString(), "text/xml"); + const parserError = document.querySelector("parsererror"); + if (parserError) { + const errorString = parserError.innerHTML; + const lineNumber = parseInt(errorString.split(":")[0], 10); + const xmlStringArray = xmlString.toString().trim().split("\n"); + const xmlPreview = xmlStringArray + .slice(Math.max(lineNumber - 3, 0), Math.min(lineNumber + 2, xmlStringArray.length)) + .join("\n"); + throw new Error(`XML string could not be parsed: ${errorString}\n${xmlPreview}`); + } + return document; + } + function getDefaultXLSXStructure() { + return { + relsFiles: [], + sharedStrings: [], + // default Values that will always be part of the style sheet + styles: [ + { + fontId: 0, + fillId: 0, + numFmtId: 0, + borderId: 0, + alignment: { vertical: "center" }, + }, + ], + fonts: [ + { + size: DEFAULT_FONT_SIZE, + family: 2, + color: { rgb: "000000" }, + name: "Calibri", + }, + ], + fills: [{ reservedAttribute: "none" }, { reservedAttribute: "gray125" }], + borders: [{}], + numFmts: [], + dxfs: [], + }; + } + function createOverride(partName, contentType) { + return escapeXml /*xml*/ ` + + `; + } + function joinXmlNodes(xmlNodes) { + return new XMLString(xmlNodes.join("\n")); + } + /** + * Escape interpolated values except if the value is already + * a properly escaped XML string. + * + * ``` + * escapeXml`${"This will be escaped"}` + * ``` + */ + function escapeXml(strings, ...expressions) { + let str = [strings[0]]; + for (let i = 0; i < expressions.length; i++) { + const value = expressions[i] instanceof XMLString ? expressions[i] : xmlEscape(expressions[i]); + str.push(value + strings[i + 1]); + } + return new XMLString(concat(str)); + } + /** + * Removes the namespace of all the xml tags in the string. + * + * Eg. : "ns:test a" => "test a" + */ + function removeNamespaces(query) { + return query.replace(/[a-z0-9]+:(?=[a-z0-9]+)/gi, ""); + } + /** + * Escape the namespace's colons of all the xml tags in the string. + * + * Eg. : "ns:test a" => "ns\\:test a" + */ + function escapeNamespaces(query) { + return query.replace(/([a-z0-9]+):(?=[a-z0-9]+)/gi, "$1\\:"); + } + /** + * Return true if the querySelector ignores the namespaces when searching for a tag in the DOM. + * + * Should return true if it's running on a browser, and false if it's running on jest (jsdom). + */ + function areNamespaceIgnoredByQuerySelector() { + const doc = new DOMParser().parseFromString("", "text/xml"); + return doc.querySelector("test") !== null; + } + + class AttributeValue { + constructor(value) { + this.value = value; + } + asString() { + return fixXlsxUnicode(String(this.value)); + } + asBool() { + return Boolean(Number(this.value)); + } + asNum() { + return Number(this.value); + } + } + class XlsxBaseExtractor { + constructor(rootFile, xlsxStructure, warningManager) { + // The xml file we are currently parsing. We should have one Extractor class by XLSXImportFile, but + // the XLSXImportFile contains both the main .xml file, and the .rels file + this.currentFile = undefined; + this.rootFile = rootFile; + this.currentFile = rootFile.file.fileName; + this.xlsxFileStructure = xlsxStructure; + this.warningManager = warningManager; + this.areNamespaceIgnored = areNamespaceIgnoredByQuerySelector(); + this.relationships = {}; + if (rootFile.rels) { + this.extractRelationships(rootFile.rels).map((rel) => { + this.relationships[rel.id] = rel; + }); + } + } + /** + * Extract all the relationships inside a .xml.rels file + */ + extractRelationships(relFile) { + return this.mapOnElements({ parent: relFile.xml, query: "Relationship" }, (relationshipElement) => { + return { + id: this.extractAttr(relationshipElement, "Id", { required: true }).asString(), + target: this.extractAttr(relationshipElement, "Target", { required: true }).asString(), + type: this.extractAttr(relationshipElement, "Type", { required: true }).asString(), + }; + }); + } + /** + * Get the list of all the XLSX files in the XLSX file structure + */ + getListOfFiles() { + const files = Object.values(this.xlsxFileStructure).flat().filter(isDefined$1); + return files; + } + /** + * Return an array containing the return value of the given function applied to all the XML elements + * found using the MapOnElementArgs. + * + * The arguments contains : + * - query : a QuerySelector string to find the elements to apply the function to + * - parent : an XML element or XML document in which to find the queried elements + * - children : if true, the function is applied on the direct children of the queried element + * + * This method will also handle the errors thrown in the argument function. + */ + mapOnElements(args, fct) { + var _a; + const ret = []; + const oldWorkingDocument = this.currentFile; + let elements; + if (args.children) { + const children = (_a = this.querySelector(args.parent, args.query)) === null || _a === void 0 ? void 0 : _a.children; + elements = children ? children : []; + } + else { + elements = this.querySelectorAll(args.parent, args.query); + } + if (elements) { + for (let element of elements) { + try { + ret.push(fct(element)); + } + catch (e) { + this.catchErrorOnElement(e, element); + } + } + } + this.currentFile = oldWorkingDocument; + return ret; + } + /** + * Log an error caught when parsing an element in the warningManager. + */ + catchErrorOnElement(error, onElement) { + const errorMsg = onElement + ? `Error when parsing an element <${onElement.tagName}> of file ${this.currentFile}, skip this element. \n${error.stack}` + : `Error when parsing file ${this.currentFile}.`; + this.warningManager.addParsingWarning([errorMsg, error.message].join("\n")); + } + /** + * Extract an attribute from an Element. + * + * If the attribute is required but was not found, will add a warning in the warningManager if it was given a default + * value, and throw an error if no default value was given. + * + * Can only return undefined value for non-required attributes without default value. + */ + extractAttr(e, attName, optionalArgs) { + const attribute = e.attributes[attName]; + if (!attribute) + this.handleMissingValue(e, `attribute "${attName}"`, optionalArgs); + const value = (attribute === null || attribute === void 0 ? void 0 : attribute.value) ? attribute.value : optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default; + return (value === undefined ? undefined : new AttributeValue(value)); + } + /** + * Extract the text content of an Element. + * + * If the text content is required but was not found, will add a warning in the warningManager if it was given a default + * value, and throw an error if no default value was given. + * + * Can only return undefined value for non-required text content without default value. + */ + extractTextContent(element, optionalArgs) { + var _a; + if ((optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default) !== undefined && typeof optionalArgs.default !== "string") { + throw new Error("extractTextContent default value should be a string"); + } + const shouldPreserveSpaces = ((_a = element === null || element === void 0 ? void 0 : element.attributes["xml:space"]) === null || _a === void 0 ? void 0 : _a.value) === "preserve"; + let textContent = element === null || element === void 0 ? void 0 : element.textContent; + if (!element || textContent === null) { + this.handleMissingValue(element, `text content`, optionalArgs); + } + if (textContent) { + textContent = shouldPreserveSpaces ? textContent : textContent.trim(); + } + return (textContent ? fixXlsxUnicode(textContent) : optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default); + } + /** + * Extract an attribute of a child of the given element. + * + * The reference of a child can be a string (tag of the child) or an number (index in the list of children of the element) + * + * If the attribute is required but either the attribute or the referenced child element was not found, it will + * will add a warning in the warningManager if it was given a default value, and throw an error if no default value was given. + * + * Can only return undefined value for non-required attributes without default value. + */ + extractChildAttr(e, childRef, attName, optionalArgs) { + var _a; + let child; + if (typeof childRef === "number") { + child = e.children[childRef]; + } + else { + child = this.querySelector(e, childRef); + } + if (!child) { + this.handleMissingValue(e, typeof childRef === "number" ? `child at index ${childRef}` : `child <${childRef}>`, optionalArgs); + } + const value = child + ? (_a = this.extractAttr(child, attName, optionalArgs)) === null || _a === void 0 ? void 0 : _a.asString() + : optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default; + return (value !== undefined ? new AttributeValue(value) : undefined); + } + /** + * Extract the text content of a child of the given element. + * + * If the text content is required but either the text content or the referenced child element was not found, it will + * will add a warning in the warningManager if it was given a default value, and throw an error if no default value was given. + * + * Can only return undefined value for non-required text content without default value. + */ + extractChildTextContent(e, childRef, optionalArgs) { + if ((optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default) !== undefined && typeof optionalArgs.default !== "string") { + throw new Error("extractTextContent default value should be a string"); + } + let child = this.querySelector(e, childRef); + if (!child) { + this.handleMissingValue(e, `child <${childRef}>`, optionalArgs); + } + return (child ? this.extractTextContent(child, optionalArgs) : optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default); + } + /** + * Should be called if a extractAttr/extractTextContent doesn't find the element it needs to extract. + * + * If the extractable was required, this function will add a warning in the warningManager if there was a default value, + * and throw an error if no default value was given. + */ + handleMissingValue(parentElement, missingElementName, optionalArgs) { + if (optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.required) { + if (optionalArgs === null || optionalArgs === void 0 ? void 0 : optionalArgs.default) { + this.warningManager.addParsingWarning(`Missing required ${missingElementName} in element <${parentElement.tagName}> of ${this.currentFile}, replacing it by the default value ${optionalArgs.default}`); + } + else { + throw new Error(`Missing required ${missingElementName} in element <${parentElement.tagName}> of ${this.currentFile}, and no default value was set`); + } + } + } + /** + * Extract a color, extracting it from the theme if needed. + * + * Will throw an error if the element references a theme, but no theme was provided or the theme it doesn't contain the color. + */ + extractColor(colorElement, theme, defaultColor) { + var _a, _b, _c, _d, _e; + if (!colorElement) { + return defaultColor ? { rgb: defaultColor } : undefined; + } + const themeIndex = (_a = this.extractAttr(colorElement, "theme")) === null || _a === void 0 ? void 0 : _a.asString(); + let rgb; + if (themeIndex !== undefined) { + if (!theme || !theme.clrScheme) { + throw new Error("Color referencing a theme but no theme was provided"); + } + rgb = this.getThemeColor(themeIndex, theme.clrScheme); + } + else { + rgb = (_b = this.extractAttr(colorElement, "rgb")) === null || _b === void 0 ? void 0 : _b.asString(); + } + const color = { + rgb, + auto: (_c = this.extractAttr(colorElement, "auto")) === null || _c === void 0 ? void 0 : _c.asBool(), + indexed: (_d = this.extractAttr(colorElement, "indexed")) === null || _d === void 0 ? void 0 : _d.asNum(), + tint: (_e = this.extractAttr(colorElement, "tint")) === null || _e === void 0 ? void 0 : _e.asNum(), + }; + return color; + } + /** + * Returns the xlsx file targeted by a relationship. + */ + getTargetXmlFile(relationship) { + if (!relationship) + throw new Error("Undefined target file"); + let target = relationship.target; + target = target.replace("../", ""); + target = target.replace("./", ""); + // Use "endsWith" because targets are relative paths, and we know the files by their absolute path. + const f = this.getListOfFiles().find((f) => f.file.fileName.endsWith(target)); + if (!f || !f.file) + throw new Error("Cannot find target file"); + return f; + } + /** + * Wrapper of querySelector, but we'll remove the namespaces from the query if areNamespacesIgnored is true. + * + * Why we need to do this : + * - For an XML "" + * - on Jest(jsdom) : xml.querySelector("test") == null, xml.querySelector("t\\:test") == + * - on Browser : xml.querySelector("test") == , xml.querySelector("t\\:test") == null + */ + querySelector(element, query) { + query = this.areNamespaceIgnored ? removeNamespaces(query) : escapeNamespaces(query); + return element.querySelector(query); + } + /** + * Wrapper of querySelectorAll, but we'll remove the namespaces from the query if areNamespacesIgnored is true. + * + * Why we need to do this : + * - For an XML "" + * - on Jest(jsdom) : xml.querySelectorAll("test") == [], xml.querySelectorAll("t\\:test") == [] + * - on Browser : xml.querySelectorAll("test") == [], xml.querySelectorAll("t\\:test") == [] + */ + querySelectorAll(element, query) { + query = this.areNamespaceIgnored ? removeNamespaces(query) : escapeNamespaces(query); + return element.querySelectorAll(query); + } + /** + * Get a color from its id in the Theme's colorScheme. + * + * Note that Excel don't use the colors from the theme but from its own internal theme, so the displayed + * colors will be different in the import than in excel. + * . + */ + getThemeColor(colorId, clrScheme) { + switch (colorId) { + case "0": // 0 : sysColor window text + return "FFFFFF"; + case "1": // 1 : sysColor window background + return "000000"; + // Don't ask me why these 2 are inverted, I cannot find any documentation for it but everyone does it + case "2": + return clrScheme["3"].value; + case "3": + return clrScheme["2"].value; + default: + return clrScheme[colorId].value; + } + } + } + + /** + * XLSX Extractor class that can be used for either sharedString XML files or theme XML files. + * + * Since they both are quite simple, it make sense to make a single class to manage them all, to avoid unnecessary file + * cluttering. + */ + class XlsxMiscExtractor extends XlsxBaseExtractor { + getTheme() { + const clrScheme = this.mapOnElements({ query: "a:clrScheme", parent: this.rootFile.file.xml, children: true }, (element) => { + return { + name: element.tagName, + value: this.extractChildAttr(element, 0, "val", { + required: true, + default: AUTO_COLOR, + }).asString(), + lastClr: this.extractChildAttr(element, 0, "lastClr", { + default: AUTO_COLOR, + }).asString(), + }; + }); + return { clrScheme }; + } + /** + * Get the array of shared strings of the XLSX. + * + * Worth noting that running a prettier on the xml can mess up some strings, since there is an option in the + * xmls to keep the spacing and not trim the string. + */ + getSharedStrings() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "si" }, (ssElement) => { + // Shared string can either be a simple text, or a rich text (text with formatting, possibly in multiple parts) + if (ssElement.children[0].tagName === "t") { + return this.extractTextContent(ssElement) || ""; + } + // We don't support rich text formatting, we'll only extract the text + else { + return this.mapOnElements({ parent: ssElement, query: "t" }, (textElement) => { + return this.extractTextContent(textElement) || ""; + }).join(""); + } + }); + } + } + + class XlsxCfExtractor extends XlsxBaseExtractor { + constructor(sheetFile, xlsxStructure, warningManager, theme) { + super(sheetFile, xlsxStructure, warningManager); + this.theme = theme; + } + extractConditionalFormattings() { + const cfs = this.mapOnElements({ parent: this.rootFile.file.xml, query: "worksheet > conditionalFormatting" }, (cfElement) => { + var _a; + return { + // sqref = ranges on which the cf applies, separated by spaces + sqref: this.extractAttr(cfElement, "sqref", { required: true }).asString().split(" "), + pivot: (_a = this.extractAttr(cfElement, "pivot")) === null || _a === void 0 ? void 0 : _a.asBool(), + cfRules: this.extractCFRules(cfElement, this.theme), + }; + }); + // XLSX extension to OpenXml + cfs.push(...this.mapOnElements({ parent: this.rootFile.file.xml, query: "extLst x14:conditionalFormatting" }, (cfElement) => { + var _a; + return { + sqref: this.extractChildTextContent(cfElement, "xm:sqref", { required: true }).split(" "), + pivot: (_a = this.extractAttr(cfElement, "xm:pivot")) === null || _a === void 0 ? void 0 : _a.asBool(), + cfRules: this.extractCFRules(cfElement, this.theme), + }; + })); + return cfs; + } + extractCFRules(cfElement, theme) { + return this.mapOnElements({ parent: cfElement, query: "cfRule, x14:cfRule" }, (cfRuleElement) => { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; + const cfType = this.extractAttr(cfRuleElement, "type", { + required: true, + }).asString(); + if (cfType === "dataBar") { + // Databars are an extension to OpenXml and have a different format (XLSX §2.6.30). Do'nt bother + // extracting them as we don't support them. + throw new Error("Databars conditional formats are not supported."); + } + return { + type: cfType, + priority: this.extractAttr(cfRuleElement, "priority", { required: true }).asNum(), + colorScale: this.extractCfColorScale(cfRuleElement, theme), + formula: this.extractCfFormula(cfRuleElement), + iconSet: this.extractCfIconSet(cfRuleElement), + dxfId: (_a = this.extractAttr(cfRuleElement, "dxfId")) === null || _a === void 0 ? void 0 : _a.asNum(), + stopIfTrue: (_b = this.extractAttr(cfRuleElement, "stopIfTrue")) === null || _b === void 0 ? void 0 : _b.asBool(), + aboveAverage: (_c = this.extractAttr(cfRuleElement, "aboveAverage")) === null || _c === void 0 ? void 0 : _c.asBool(), + percent: (_d = this.extractAttr(cfRuleElement, "percent")) === null || _d === void 0 ? void 0 : _d.asBool(), + bottom: (_e = this.extractAttr(cfRuleElement, "bottom")) === null || _e === void 0 ? void 0 : _e.asBool(), + operator: (_f = this.extractAttr(cfRuleElement, "operator")) === null || _f === void 0 ? void 0 : _f.asString(), + text: (_g = this.extractAttr(cfRuleElement, "text")) === null || _g === void 0 ? void 0 : _g.asString(), + timePeriod: (_h = this.extractAttr(cfRuleElement, "timePeriod")) === null || _h === void 0 ? void 0 : _h.asString(), + rank: (_j = this.extractAttr(cfRuleElement, "rank")) === null || _j === void 0 ? void 0 : _j.asNum(), + stdDev: (_k = this.extractAttr(cfRuleElement, "stdDev")) === null || _k === void 0 ? void 0 : _k.asNum(), + equalAverage: (_l = this.extractAttr(cfRuleElement, "equalAverage")) === null || _l === void 0 ? void 0 : _l.asBool(), + }; + }); + } + extractCfFormula(cfRulesElement) { + return this.mapOnElements({ parent: cfRulesElement, query: "formula" }, (cfFormulaElements) => { + return this.extractTextContent(cfFormulaElements, { required: true }); + }); + } + extractCfColorScale(cfRulesElement, theme) { + const colorScaleElement = this.querySelector(cfRulesElement, "colorScale"); + if (!colorScaleElement) + return undefined; + return { + colors: this.mapOnElements({ parent: colorScaleElement, query: "color" }, (colorElement) => { + return this.extractColor(colorElement, theme, "ffffff"); + }), + cfvos: this.extractCFVos(colorScaleElement), + }; + } + extractCfIconSet(cfRulesElement) { + var _a, _b; + const iconSetElement = this.querySelector(cfRulesElement, "iconSet, x14:iconSet"); + if (!iconSetElement) + return undefined; + return { + iconSet: this.extractAttr(iconSetElement, "iconSet", { + default: "3TrafficLights1", + }).asString(), + showValue: this.extractAttr(iconSetElement, "showValue", { default: true }).asBool(), + percent: this.extractAttr(iconSetElement, "percent", { default: true }).asBool(), + reverse: (_a = this.extractAttr(iconSetElement, "reverse")) === null || _a === void 0 ? void 0 : _a.asBool(), + custom: (_b = this.extractAttr(iconSetElement, "custom")) === null || _b === void 0 ? void 0 : _b.asBool(), + cfvos: this.extractCFVos(iconSetElement), + cfIcons: this.extractCfIcons(iconSetElement), + }; + } + extractCfIcons(iconSetElement) { + const icons = this.mapOnElements({ parent: iconSetElement, query: "cfIcon, x14:cfIcon" }, (cfIconElement) => { + return { + iconSet: this.extractAttr(cfIconElement, "iconSet", { + required: true, + }).asString(), + iconId: this.extractAttr(cfIconElement, "iconId", { required: true }).asNum(), + }; + }); + return icons.length === 0 ? undefined : icons; + } + extractCFVos(parent) { + return this.mapOnElements({ parent, query: "cfvo, x14:cfvo" }, (cfVoElement) => { + var _a, _b; + return { + type: this.extractAttr(cfVoElement, "type", { + required: true, + }).asString(), + gte: (_a = this.extractAttr(cfVoElement, "gte", { default: true })) === null || _a === void 0 ? void 0 : _a.asBool(), + value: cfVoElement.attributes["val"] + ? (_b = this.extractAttr(cfVoElement, "val")) === null || _b === void 0 ? void 0 : _b.asString() + : this.extractChildTextContent(cfVoElement, "f, xm:f"), + }; + }); + } + } + + class XlsxChartExtractor extends XlsxBaseExtractor { + extractChart() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "c:chartSpace" }, (rootChartElement) => { + const chartType = this.getChartType(rootChartElement); + if (!CHART_TYPE_CONVERSION_MAP[chartType]) { + throw new Error(`Unsupported chart type ${chartType}`); + } + // Title can be separated into multiple xml elements (for styling and such), we only import the text + const chartTitle = this.mapOnElements({ parent: rootChartElement, query: "c:title a:t" }, (textElement) => { + return textElement.textContent || ""; + }).join(""); + const barChartGrouping = this.extractChildAttr(rootChartElement, "c:grouping", "val", { + default: "clustered", + }).asString(); + return { + title: chartTitle, + type: CHART_TYPE_CONVERSION_MAP[chartType], + dataSets: this.extractChartDatasets(this.querySelector(rootChartElement, `c:${chartType}`)), + backgroundColor: this.extractChildAttr(rootChartElement, "c:chartSpace > c:spPr a:srgbClr", "val", { + default: "ffffff", + }).asString(), + verticalAxisPosition: this.extractChildAttr(rootChartElement, "c:valAx > c:axPos", "val", { + default: "l", + }).asString() === "r" + ? "right" + : "left", + legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(rootChartElement, "c:legendPos", "val", { + default: "b", + }).asString()], + stackedBar: barChartGrouping === "stacked", + fontColor: "000000", + }; + })[0]; + } + extractChartDatasets(chartElement) { + return this.mapOnElements({ parent: chartElement, query: "c:ser" }, (chartDataElement) => { + return { + label: this.extractChildTextContent(chartDataElement, "c:cat c:f"), + range: this.extractChildTextContent(chartDataElement, "c:val c:f", { required: true }), + }; + }); + } + /** + * The chart type in the XML isn't explicitly defined, but there is an XML element that define the + * chart, and this element tag name tells us which type of chart it is. We just need to find this XML element. + */ + getChartType(chartElement) { + const plotAreaElement = this.querySelector(chartElement, "c:plotArea"); + if (!plotAreaElement) { + throw new Error("Missing plot area in the chart definition."); + } + for (let child of plotAreaElement.children) { + const tag = removeNamespaces(child.tagName); + if (XLSX_CHART_TYPES.some((chartType) => chartType === tag)) { + return tag; + } + } + throw new Error("Unknown chart type"); + } + } + + class XlsxFigureExtractor extends XlsxBaseExtractor { + extractFigures() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "xdr:wsDr", children: true }, (figureElement) => { + const anchorType = removeNamespaces(figureElement.tagName); + if (anchorType !== "twoCellAnchor") { + throw new Error("Only twoCellAnchor are supported for xlsx drawings."); + } + const chartElement = this.querySelector(figureElement, "c:chart"); + if (!chartElement) { + throw new Error("Only chart figures are currently supported."); + } + return { + anchors: [ + this.extractFigureAnchor("xdr:from", figureElement), + this.extractFigureAnchor("xdr:to", figureElement), + ], + data: this.extractChart(chartElement), + }; + }); + } + extractFigureAnchor(anchorTag, figureElement) { + const anchor = this.querySelector(figureElement, anchorTag); + if (!anchor) { + throw new Error(`Missing anchor element ${anchorTag}`); + } + return { + col: Number(this.extractChildTextContent(anchor, "xdr:col", { required: true })), + colOffset: Number(this.extractChildTextContent(anchor, "xdr:colOff", { required: true })), + row: Number(this.extractChildTextContent(anchor, "xdr:row", { required: true })), + rowOffset: Number(this.extractChildTextContent(anchor, "xdr:rowOff", { required: true })), + }; + } + extractChart(chartElement) { + const chartId = this.extractAttr(chartElement, "r:id", { required: true }).asString(); + const chartFile = this.getTargetXmlFile(this.relationships[chartId]); + const chartDefinition = new XlsxChartExtractor(chartFile, this.xlsxFileStructure, this.warningManager).extractChart(); + if (!chartDefinition) { + throw new Error("Unable to extract chart definition"); + } + return chartDefinition; + } + } + + /** + * We don't really support pivot tables, we'll just extract them as Tables. + */ + class XlsxPivotExtractor extends XlsxBaseExtractor { + getPivotTable() { + return this.mapOnElements( + // Use :root instead of "pivotTableDefinition" because others pivotTableDefinition elements are present inside the root + // pivotTableDefinition elements. + { query: ":root", parent: this.rootFile.file.xml }, (pivotElement) => { + return { + displayName: this.extractAttr(pivotElement, "name", { required: true }).asString(), + id: this.extractAttr(pivotElement, "name", { required: true }).asString(), + ref: this.extractChildAttr(pivotElement, "location", "ref", { + required: true, + }).asString(), + headerRowCount: this.extractChildAttr(pivotElement, "location", "firstDataRow", { + default: 0, + }).asNum(), + totalsRowCount: 1, + cols: [], + style: { + showFirstColumn: true, + showRowStripes: true, + }, + }; + })[0]; + } + } + + class XlsxTableExtractor extends XlsxBaseExtractor { + getTable() { + return this.mapOnElements({ query: "table", parent: this.rootFile.file.xml }, (tableElement) => { + var _a; + return { + displayName: this.extractAttr(tableElement, "displayName", { + required: true, + }).asString(), + name: (_a = this.extractAttr(tableElement, "name")) === null || _a === void 0 ? void 0 : _a.asString(), + id: this.extractAttr(tableElement, "id", { required: true }).asString(), + ref: this.extractAttr(tableElement, "ref", { required: true }).asString(), + headerRowCount: this.extractAttr(tableElement, "headerRowCount", { + default: 1, + }).asNum(), + totalsRowCount: this.extractAttr(tableElement, "totalsRowCount", { + default: 0, + }).asNum(), + cols: this.extractTableCols(tableElement), + style: this.extractTableStyleInfo(tableElement), + }; + })[0]; + } + extractTableCols(tableElement) { + return this.mapOnElements({ query: "tableColumn", parent: tableElement }, (tableColElement) => { + return { + id: this.extractAttr(tableColElement, "id", { required: true }).asString(), + name: this.extractAttr(tableColElement, "name", { required: true }).asString(), + colFormula: this.extractChildTextContent(tableColElement, "calculatedColumnFormula"), + }; + }); + } + extractTableStyleInfo(tableElement) { + return this.mapOnElements({ query: "tableStyleInfo", parent: tableElement }, (tableStyleElement) => { + var _a, _b, _c, _d, _e; + return { + name: (_a = this.extractAttr(tableStyleElement, "name")) === null || _a === void 0 ? void 0 : _a.asString(), + showFirstColumn: (_b = this.extractAttr(tableStyleElement, "showFirstColumn")) === null || _b === void 0 ? void 0 : _b.asBool(), + showLastColumn: (_c = this.extractAttr(tableStyleElement, "showLastColumn")) === null || _c === void 0 ? void 0 : _c.asBool(), + showRowStripes: (_d = this.extractAttr(tableStyleElement, "showRowStripes")) === null || _d === void 0 ? void 0 : _d.asBool(), + showColumnStripes: (_e = this.extractAttr(tableStyleElement, "showColumnStripes")) === null || _e === void 0 ? void 0 : _e.asBool(), + }; + })[0]; + } + } + + class XlsxSheetExtractor extends XlsxBaseExtractor { + constructor(sheetFile, xlsxStructure, warningManager, theme) { + super(sheetFile, xlsxStructure, warningManager); + this.theme = theme; + } + getSheet() { + return this.mapOnElements({ query: "worksheet", parent: this.rootFile.file.xml }, (sheetElement) => { + const sheetWorkbookInfo = this.getSheetWorkbookInfo(); + return { + sheetName: this.extractSheetName(), + sheetViews: this.extractSheetViews(sheetElement), + sheetFormat: this.extractSheetFormat(sheetElement), + cols: this.extractCols(sheetElement), + rows: this.extractRows(sheetElement), + sharedFormulas: this.extractSharedFormulas(sheetElement), + merges: this.extractMerges(sheetElement), + cfs: this.extractConditionalFormats(), + figures: this.extractFigures(sheetElement), + hyperlinks: this.extractHyperLinks(sheetElement), + tables: [...this.extractTables(sheetElement), ...this.extractPivotTables()], + isVisible: sheetWorkbookInfo.state === "visible" ? true : false, + }; + })[0]; + } + extractSheetViews(worksheet) { + return this.mapOnElements({ parent: worksheet, query: "sheetView" }, (sheetViewElement) => { + return { + tabSelected: this.extractAttr(sheetViewElement, "tabSelected", { + default: false, + }).asBool(), + showFormulas: this.extractAttr(sheetViewElement, "showFormulas", { + default: false, + }).asBool(), + showGridLines: this.extractAttr(sheetViewElement, "showGridLines", { + default: true, + }).asBool(), + showRowColHeaders: this.extractAttr(sheetViewElement, "showRowColHeaders", { + default: true, + }).asBool(), + }; + }); + } + extractSheetName() { + const relativePath = getRelativePath(this.xlsxFileStructure.workbook.file.fileName, this.rootFile.file.fileName); + const workbookRels = this.extractRelationships(this.xlsxFileStructure.workbook.rels); + const relId = workbookRels.find((rel) => rel.target === relativePath).id; + // Having a namespace in the attributes names mess with the querySelector, and the behavior is not the same + // for every XML parser. So we'll search manually instead of using a querySelector to search for an attribute value. + for (let sheetElement of this.querySelectorAll(this.xlsxFileStructure.workbook.file.xml, "sheet")) { + if (sheetElement.attributes["r:id"].value === relId) { + return sheetElement.attributes["name"].value; + } + } + throw new Error("Missing sheet name"); + } + getSheetWorkbookInfo() { + const relativePath = getRelativePath(this.xlsxFileStructure.workbook.file.fileName, this.rootFile.file.fileName); + const workbookRels = this.extractRelationships(this.xlsxFileStructure.workbook.rels); + const relId = workbookRels.find((rel) => rel.target === relativePath).id; + const workbookSheets = this.mapOnElements({ parent: this.xlsxFileStructure.workbook.file.xml, query: "sheet" }, (sheetElement) => { + return { + relationshipId: this.extractAttr(sheetElement, "r:id", { required: true }).asString(), + sheetId: this.extractAttr(sheetElement, "sheetId", { required: true }).asString(), + sheetName: this.extractAttr(sheetElement, "name", { required: true }).asString(), + state: this.extractAttr(sheetElement, "state", { + default: "visible", + }).asString(), + }; + }); + const info = workbookSheets.find((info) => info.relationshipId === relId); + if (!info) { + throw new Error("Cannot find corresponding workbook sheet"); + } + return info; + } + extractConditionalFormats() { + return new XlsxCfExtractor(this.rootFile, this.xlsxFileStructure, this.warningManager, this.theme).extractConditionalFormattings(); + } + extractFigures(worksheet) { + const figures = this.mapOnElements({ parent: worksheet, query: "drawing" }, (drawingElement) => { + var _a; + const drawingId = (_a = this.extractAttr(drawingElement, "r:id", { required: true })) === null || _a === void 0 ? void 0 : _a.asString(); + const drawingFile = this.getTargetXmlFile(this.relationships[drawingId]); + const figures = new XlsxFigureExtractor(drawingFile, this.xlsxFileStructure, this.warningManager).extractFigures(); + return figures; + })[0]; + return figures || []; + } + extractTables(worksheet) { + return this.mapOnElements({ query: "tablePart", parent: worksheet }, (tablePartElement) => { + var _a; + const tableId = (_a = this.extractAttr(tablePartElement, "r:id", { required: true })) === null || _a === void 0 ? void 0 : _a.asString(); + const tableFile = this.getTargetXmlFile(this.relationships[tableId]); + const tableExtractor = new XlsxTableExtractor(tableFile, this.xlsxFileStructure, this.warningManager); + return tableExtractor.getTable(); + }); + } + extractPivotTables() { + try { + return Object.values(this.relationships) + .filter((relationship) => relationship.type.endsWith("pivotTable")) + .map((pivotRelationship) => { + const pivotFile = this.getTargetXmlFile(pivotRelationship); + const pivot = new XlsxPivotExtractor(pivotFile, this.xlsxFileStructure, this.warningManager).getPivotTable(); + return pivot; + }); + } + catch (e) { + this.catchErrorOnElement(e); + return []; + } + } + extractMerges(worksheet) { + return this.mapOnElements({ parent: worksheet, query: "mergeCell" }, (mergeElement) => { + return this.extractAttr(mergeElement, "ref", { required: true }).asString(); + }); + } + extractSheetFormat(worksheet) { + const formatElement = this.querySelector(worksheet, "sheetFormatPr"); + if (!formatElement) + return undefined; + return { + defaultColWidth: this.extractAttr(formatElement, "defaultColWidth", { + default: EXCEL_DEFAULT_COL_WIDTH.toString(), + }).asNum(), + defaultRowHeight: this.extractAttr(formatElement, "defaultRowHeight", { + default: EXCEL_DEFAULT_ROW_HEIGHT.toString(), + }).asNum(), + }; + } + extractCols(worksheet) { + return this.mapOnElements({ parent: worksheet, query: "cols col" }, (colElement) => { + var _a, _b, _c, _d, _e, _f, _g; + return { + width: (_a = this.extractAttr(colElement, "width")) === null || _a === void 0 ? void 0 : _a.asNum(), + customWidth: (_b = this.extractAttr(colElement, "customWidth")) === null || _b === void 0 ? void 0 : _b.asBool(), + bestFit: (_c = this.extractAttr(colElement, "bestFit")) === null || _c === void 0 ? void 0 : _c.asBool(), + hidden: (_d = this.extractAttr(colElement, "hidden")) === null || _d === void 0 ? void 0 : _d.asBool(), + min: (_e = this.extractAttr(colElement, "min", { required: true })) === null || _e === void 0 ? void 0 : _e.asNum(), + max: (_f = this.extractAttr(colElement, "max", { required: true })) === null || _f === void 0 ? void 0 : _f.asNum(), + styleIndex: (_g = this.extractAttr(colElement, "style")) === null || _g === void 0 ? void 0 : _g.asNum(), + }; + }); + } + extractRows(worksheet) { + return this.mapOnElements({ parent: worksheet, query: "sheetData row" }, (rowElement) => { + var _a, _b, _c, _d, _e; + return { + index: (_a = this.extractAttr(rowElement, "r", { required: true })) === null || _a === void 0 ? void 0 : _a.asNum(), + cells: this.extractCells(rowElement), + height: (_b = this.extractAttr(rowElement, "ht")) === null || _b === void 0 ? void 0 : _b.asNum(), + customHeight: (_c = this.extractAttr(rowElement, "customHeight")) === null || _c === void 0 ? void 0 : _c.asBool(), + hidden: (_d = this.extractAttr(rowElement, "hidden")) === null || _d === void 0 ? void 0 : _d.asBool(), + styleIndex: (_e = this.extractAttr(rowElement, "s")) === null || _e === void 0 ? void 0 : _e.asNum(), + }; + }); + } + extractCells(row) { + return this.mapOnElements({ parent: row, query: "c" }, (cellElement) => { + var _a, _b, _c; + return { + xc: (_a = this.extractAttr(cellElement, "r", { required: true })) === null || _a === void 0 ? void 0 : _a.asString(), + styleIndex: (_b = this.extractAttr(cellElement, "s")) === null || _b === void 0 ? void 0 : _b.asNum(), + type: CELL_TYPE_CONVERSION_MAP[(_c = this.extractAttr(cellElement, "t", { default: "n" })) === null || _c === void 0 ? void 0 : _c.asString()], + value: this.extractChildTextContent(cellElement, "v"), + formula: this.extractCellFormula(cellElement), + }; + }); + } + extractCellFormula(cellElement) { + var _a, _b; + const formulaElement = this.querySelector(cellElement, "f"); + if (!formulaElement) + return undefined; + return { + content: this.extractTextContent(formulaElement), + sharedIndex: (_a = this.extractAttr(formulaElement, "si")) === null || _a === void 0 ? void 0 : _a.asNum(), + ref: (_b = this.extractAttr(formulaElement, "ref")) === null || _b === void 0 ? void 0 : _b.asString(), + }; + } + extractHyperLinks(worksheet) { + return this.mapOnElements({ parent: worksheet, query: "hyperlink" }, (linkElement) => { + var _a, _b, _c, _d; + const relId = (_a = this.extractAttr(linkElement, "r:id")) === null || _a === void 0 ? void 0 : _a.asString(); + return { + xc: (_b = this.extractAttr(linkElement, "ref", { required: true })) === null || _b === void 0 ? void 0 : _b.asString(), + location: (_c = this.extractAttr(linkElement, "location")) === null || _c === void 0 ? void 0 : _c.asString(), + display: (_d = this.extractAttr(linkElement, "display")) === null || _d === void 0 ? void 0 : _d.asString(), + relTarget: relId ? this.relationships[relId].target : undefined, + }; + }); + } + extractSharedFormulas(worksheet) { + const sfElements = this.querySelectorAll(worksheet, `f[si][ref]`); + const sfMap = {}; + for (let sfElement of sfElements) { + const index = this.extractAttr(sfElement, "si", { required: true }).asNum(); + const formula = this.extractTextContent(sfElement, { required: true }); + sfMap[index] = formula; + } + const sfs = []; + for (let i = 0; i < Object.keys(sfMap).length; i++) { + if (!sfMap[i]) { + this.warningManager.addParsingWarning(`Missing shared formula ${i}, replacing it by empty formula`); + sfs.push(""); + } + else { + sfs.push(sfMap[i]); + } + } + return sfs; + } + } + + class XlsxStyleExtractor extends XlsxBaseExtractor { + constructor(xlsxStructure, warningManager, theme) { + super(xlsxStructure.styles, xlsxStructure, warningManager); + this.theme = theme; + } + getNumFormats() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "numFmt" }, (numFmtElement) => { + return this.extractNumFormats(numFmtElement); + }); + } + extractNumFormats(numFmtElement) { + return { + id: this.extractAttr(numFmtElement, "numFmtId", { + required: true, + }).asNum(), + format: this.extractAttr(numFmtElement, "formatCode", { + required: true, + default: "", + }).asString(), + }; + } + getFonts() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "font" }, (font) => { + return this.extractFont(font); + }); + } + extractFont(fontElement) { + var _a, _b, _c, _d; + const name = this.extractChildAttr(fontElement, "name", "val", { + default: "Arial", + }).asString(); + const size = this.extractChildAttr(fontElement, "sz", "val", { + default: DEFAULT_FONT_SIZE.toString(), + }).asNum(); + const color = this.extractColor(this.querySelector(fontElement, `color`), this.theme); + // The behavior for these is kinda strange. The text is italic if there is either a "italic" tag with no "val" + // attribute, or a tag with a "val" attribute = "1" (boolean). + const italicElement = this.querySelector(fontElement, `i`) || undefined; + const italic = italicElement && ((_a = italicElement.attributes["val"]) === null || _a === void 0 ? void 0 : _a.value) !== "0"; + const boldElement = this.querySelector(fontElement, `b`) || undefined; + const bold = boldElement && ((_b = boldElement.attributes["val"]) === null || _b === void 0 ? void 0 : _b.value) !== "0"; + const strikeElement = this.querySelector(fontElement, `strike`) || undefined; + const strike = strikeElement && ((_c = strikeElement.attributes["val"]) === null || _c === void 0 ? void 0 : _c.value) !== "0"; + const underlineElement = this.querySelector(fontElement, `u`) || undefined; + const underline = underlineElement && ((_d = underlineElement.attributes["val"]) === null || _d === void 0 ? void 0 : _d.value) !== "none"; + return { name, size, color, italic, bold, underline, strike }; + } + getFills() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "fill" }, (fillElement) => { + return this.extractFill(fillElement); + }); + } + extractFill(fillElement) { + var _a; + // Fills are either patterns of gradients + const fillChild = fillElement.children[0]; + if (fillChild.tagName === "patternFill") { + return { + patternType: (_a = fillChild.attributes["patternType"]) === null || _a === void 0 ? void 0 : _a.value, + bgColor: this.extractColor(this.querySelector(fillChild, "bgColor"), this.theme), + fgColor: this.extractColor(this.querySelector(fillChild, "fgColor"), this.theme), + }; + } + else { + // We don't support gradients. Take the second gradient color as fill color + return { + patternType: "solid", + fgColor: this.extractColor(this.querySelectorAll(fillChild, "color")[1], this.theme), + }; + } + } + getBorders() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "border" }, (borderElement) => { + return this.extractBorder(borderElement); + }); + } + extractBorder(borderElement) { + var _a, _b; + const border = { + left: this.extractSingleBorder(borderElement, "left", this.theme), + right: this.extractSingleBorder(borderElement, "right", this.theme), + top: this.extractSingleBorder(borderElement, "top", this.theme), + bottom: this.extractSingleBorder(borderElement, "bottom", this.theme), + diagonal: this.extractSingleBorder(borderElement, "diagonal", this.theme), + }; + if (border.diagonal) { + border.diagonalUp = (_a = this.extractAttr(borderElement, "diagonalUp")) === null || _a === void 0 ? void 0 : _a.asBool(); + border.diagonalDown = (_b = this.extractAttr(borderElement, "diagonalDown")) === null || _b === void 0 ? void 0 : _b.asBool(); + } + return border; + } + extractSingleBorder(borderElement, direction, theme) { + const directionElement = this.querySelector(borderElement, direction); + if (!directionElement || !directionElement.attributes["style"]) + return undefined; + return { + style: this.extractAttr(directionElement, "style", { + required: true, + default: "thin", + }).asString(), + color: this.extractColor(directionElement.children[0], theme, "000000"), + }; + } + extractAlignment(alignmentElement) { + var _a, _b, _c, _d, _e, _f, _g; + return { + horizontal: this.extractAttr(alignmentElement, "horizontal", { + default: "general", + }).asString(), + vertical: this.extractAttr(alignmentElement, "vertical", { + default: "center", + }).asString(), + textRotation: (_a = this.extractAttr(alignmentElement, "textRotation")) === null || _a === void 0 ? void 0 : _a.asNum(), + wrapText: (_b = this.extractAttr(alignmentElement, "wrapText")) === null || _b === void 0 ? void 0 : _b.asBool(), + indent: (_c = this.extractAttr(alignmentElement, "indent")) === null || _c === void 0 ? void 0 : _c.asNum(), + relativeIndent: (_d = this.extractAttr(alignmentElement, "relativeIndent")) === null || _d === void 0 ? void 0 : _d.asNum(), + justifyLastLine: (_e = this.extractAttr(alignmentElement, "justifyLastLine")) === null || _e === void 0 ? void 0 : _e.asBool(), + shrinkToFit: (_f = this.extractAttr(alignmentElement, "shrinkToFit")) === null || _f === void 0 ? void 0 : _f.asBool(), + readingOrder: (_g = this.extractAttr(alignmentElement, "readingOrder")) === null || _g === void 0 ? void 0 : _g.asNum(), + }; + } + getDxfs() { + return this.mapOnElements({ query: "dxf", parent: this.rootFile.file.xml }, (dxfElement) => { + const fontElement = this.querySelector(dxfElement, "font"); + const fillElement = this.querySelector(dxfElement, "fill"); + const borderElement = this.querySelector(dxfElement, "border"); + const numFmtElement = this.querySelector(dxfElement, "numFmt"); + const alignmentElement = this.querySelector(dxfElement, "alignment"); + return { + font: fontElement ? this.extractFont(fontElement) : undefined, + fill: fillElement ? this.extractFill(fillElement) : undefined, + numFmt: numFmtElement ? this.extractNumFormats(numFmtElement) : undefined, + alignment: alignmentElement ? this.extractAlignment(alignmentElement) : undefined, + border: borderElement ? this.extractBorder(borderElement) : undefined, + }; + }); + } + getStyles() { + return this.mapOnElements({ query: "cellXfs xf", parent: this.rootFile.file.xml }, (styleElement) => { + const alignmentElement = this.querySelector(styleElement, "alignment"); + return { + fontId: this.extractAttr(styleElement, "fontId", { + required: true, + default: 0, + }).asNum(), + fillId: this.extractAttr(styleElement, "fillId", { + required: true, + default: 0, + }).asNum(), + borderId: this.extractAttr(styleElement, "borderId", { + required: true, + default: 0, + }).asNum(), + numFmtId: this.extractAttr(styleElement, "numFmtId", { + required: true, + default: 0, + }).asNum(), + alignment: alignmentElement ? this.extractAlignment(alignmentElement) : undefined, + }; + }); + } + } + + class XlsxExternalBookExtractor extends XlsxBaseExtractor { + getExternalBook() { + return this.mapOnElements({ parent: this.rootFile.file.xml, query: "externalBook" }, (bookElement) => { + return { + rId: this.extractAttr(bookElement, "r:id", { required: true }).asString(), + sheetNames: this.mapOnElements({ parent: bookElement, query: "sheetName" }, (sheetNameElement) => { + return this.extractAttr(sheetNameElement, "val", { required: true }).asString(); + }), + datasets: this.extractExternalSheetData(bookElement), + }; + })[0]; + } + extractExternalSheetData(externalBookElement) { + return this.mapOnElements({ parent: externalBookElement, query: "sheetData" }, (sheetDataElement) => { + const cellsData = this.mapOnElements({ parent: sheetDataElement, query: "cell" }, (cellElement) => { + return { + xc: this.extractAttr(cellElement, "r", { required: true }).asString(), + value: this.extractChildTextContent(cellElement, "v", { required: true }), + }; + }); + const dataMap = {}; + for (let cell of cellsData) { + dataMap[cell.xc] = cell.value; + } + return { + sheetId: this.extractAttr(sheetDataElement, "sheetId", { required: true }).asNum(), + data: dataMap, + }; + }); + } + } + + /** + * Return all the xmls converted to XLSXImportFile corresponding to the given content type. + */ + function getXLSXFilesOfType(contentType, xmls) { + const paths = getPathsOfContent(contentType, xmls); + return getXlsxFile(paths, xmls); + } + /** + * From an array of file path, return the equivalents XLSXFiles. An XLSX File is composed of an XML, + * and optionally of a relationships XML. + */ + function getXlsxFile(files, xmls) { + const ret = []; + for (let file of files) { + const rels = getRelationFile(file, xmls); + ret.push({ + file: { fileName: file, xml: xmls[file] }, + rels: rels ? { fileName: rels, xml: xmls[rels] } : undefined, + }); + } + return ret; + } + /** + * Return all the path of the files in a XLSX directory that have content of the given type. + */ + function getPathsOfContent(contentType, xmls) { + const xml = xmls[CONTENT_TYPES_FILE]; + const sheetItems = xml.querySelectorAll(`Override[ContentType="${contentType}"]`); + const paths = []; + for (let item of sheetItems) { + const file = item === null || item === void 0 ? void 0 : item.attributes["PartName"].value; + paths.push(file.substring(1)); // Remove the heading "/" + } + return paths; + } + /** + * Get the corresponding relationship file for a given xml file in a XLSX directory. + */ + function getRelationFile(file, xmls) { + if (file === CONTENT_TYPES_FILE) { + return "_rels/.rels"; + } + let relsFile = ""; + const pathParts = file.split("/"); + for (let i = 0; i < pathParts.length - 1; i++) { + relsFile += pathParts[i] + "/"; + } + relsFile += "_rels/"; + relsFile += pathParts[pathParts.length - 1] + ".rels"; + if (!xmls[relsFile]) { + relsFile = undefined; + } + return relsFile; + } + + const EXCEL_IMPORT_VERSION = 12; + class XlsxReader { + constructor(files) { + this.warningManager = new XLSXImportWarningManager(); + this.xmls = {}; + for (let key of Object.keys(files)) { + // Random files can be in xlsx (like a bin file for printer settings) + if (key.endsWith(".xml") || key.endsWith(".rels")) { + this.xmls[key] = parseXML(new XMLString(files[key])); + } + } + } + convertXlsx() { + const xlsxData = this.getXlsxData(); + const convertedData = this.convertImportedData(xlsxData); + return convertedData; + } + // --------------------------------------------------------------------------- + // Parsing XMLs + // --------------------------------------------------------------------------- + getXlsxData() { + const xlsxFileStructure = this.buildXlsxFileStructure(); + const theme = xlsxFileStructure.theme + ? new XlsxMiscExtractor(xlsxFileStructure.theme, xlsxFileStructure, this.warningManager).getTheme() + : undefined; + const sharedStrings = xlsxFileStructure.sharedStrings + ? new XlsxMiscExtractor(xlsxFileStructure.sharedStrings, xlsxFileStructure, this.warningManager).getSharedStrings() + : []; + // Sort sheets by file name : the sheets will always be named sheet1.xml, sheet2.xml, ... in order + const sheets = xlsxFileStructure.sheets + .sort((a, b) => a.file.fileName.localeCompare(b.file.fileName, undefined, { numeric: true })) + .map((sheetFile) => { + return new XlsxSheetExtractor(sheetFile, xlsxFileStructure, this.warningManager, theme).getSheet(); + }); + const externalBooks = xlsxFileStructure.externalLinks.map((externalLinkFile) => { + return new XlsxExternalBookExtractor(externalLinkFile, xlsxFileStructure, this.warningManager).getExternalBook(); + }); + const styleExtractor = new XlsxStyleExtractor(xlsxFileStructure, this.warningManager, theme); + return { + fonts: styleExtractor.getFonts(), + fills: styleExtractor.getFills(), + borders: styleExtractor.getBorders(), + dxfs: styleExtractor.getDxfs(), + numFmts: styleExtractor.getNumFormats(), + styles: styleExtractor.getStyles(), + sheets: sheets, + sharedStrings, + externalBooks, + }; + } + buildXlsxFileStructure() { + const xlsxFileStructure = { + sheets: getXLSXFilesOfType(CONTENT_TYPES.sheet, this.xmls), + workbook: getXLSXFilesOfType(CONTENT_TYPES.workbook, this.xmls)[0], + styles: getXLSXFilesOfType(CONTENT_TYPES.styles, this.xmls)[0], + sharedStrings: getXLSXFilesOfType(CONTENT_TYPES.sharedStrings, this.xmls)[0], + theme: getXLSXFilesOfType(CONTENT_TYPES.themes, this.xmls)[0], + charts: getXLSXFilesOfType(CONTENT_TYPES.chart, this.xmls), + figures: getXLSXFilesOfType(CONTENT_TYPES.drawing, this.xmls), + tables: getXLSXFilesOfType(CONTENT_TYPES.table, this.xmls), + pivots: getXLSXFilesOfType(CONTENT_TYPES.pivot, this.xmls), + externalLinks: getXLSXFilesOfType(CONTENT_TYPES.externalLink, this.xmls), + }; + if (!xlsxFileStructure.workbook.rels) { + throw Error(_lt("Cannot find workbook relations file")); + } + return xlsxFileStructure; + } + // --------------------------------------------------------------------------- + // Conversion + // --------------------------------------------------------------------------- + convertImportedData(data) { + const convertedData = { + version: EXCEL_IMPORT_VERSION, + sheets: convertSheets(data, this.warningManager), + styles: convertStyles(data, this.warningManager), + formats: convertFormats(data, this.warningManager), + borders: convertBorders(data, this.warningManager), + entities: {}, + revisionId: DEFAULT_REVISION_ID, + }; + convertTables(convertedData, data); + // Remove falsy attributes in styles. Not mandatory, but make objects more readable when debugging + Object.keys(data.styles).map((key) => { + data.styles[key] = removeFalsyAttributes(data.styles[key]); + }); + return convertedData; + } + } + + /** + * parses a formula (as a string) into the same formula, + * but with the references to other cells extracted + * + * =sum(a3:b1) + c3 --> =sum(|0|) + |1| + * + * @param formula + */ + function normalizeV9(formula) { + const tokens = rangeTokenize(formula); + let dependencies = []; + let noRefFormula = "".concat(...tokens.map((token) => { + if (token.type === "REFERENCE" && cellReference.test(token.value)) { + const value = token.value.trim(); + if (!dependencies.includes(value)) { + dependencies.push(value); + } + return `${FORMULA_REF_IDENTIFIER}${dependencies.indexOf(value)}${FORMULA_REF_IDENTIFIER}`; + } + else { + return token.value; + } + })); + return { text: noRefFormula, dependencies }; + } + + /** + * This is the current state version number. It should be incremented each time + * a breaking change is made in the way the state is handled, and an upgrade + * function should be defined + */ + const CURRENT_VERSION = 12; + const INITIAL_SHEET_ID = "Sheet1"; + /** + * This function tries to load anything that could look like a valid + * workbookData object. It applies any migrations, if needed, and return a + * current, complete workbookData object. + * + * It also ensures that there is at least one sheet. + */ + function load(data, verboseImport) { + if (!data) { + return createEmptyWorkbookData(); + } + if (data["[Content_Types].xml"]) { + const reader = new XlsxReader(data); + data = reader.convertXlsx(); + if (verboseImport) { + for (let parsingError of reader.warningManager.warnings.sort()) { + console.warn(parsingError); + } + } + } + data = JSON.parse(JSON.stringify(data)); + // apply migrations, if needed + if ("version" in data) { + if (data.version < CURRENT_VERSION) { + data = migrate(data); + } + } + // sanity check: try to fix missing fields/corrupted state by providing + // sensible default values + data = Object.assign(createEmptyWorkbookData(), data, { version: CURRENT_VERSION }); + data.sheets = data.sheets.map((s, i) => Object.assign(createEmptySheet(`Sheet${i + 1}`, `Sheet${i + 1}`), s)); + if (data.sheets.length === 0) { + data.sheets.push(createEmptySheet(INITIAL_SHEET_ID, "Sheet1")); + } + return data; + } + function migrate(data) { + const index = MIGRATIONS.findIndex((m) => m.from === data.version); + for (let i = index; i < MIGRATIONS.length; i++) { + data = MIGRATIONS[i].applyMigration(data); + } + return data; + } + const MIGRATIONS = [ + { + description: "add the `activeSheet` field on data", + from: 1, + to: 2, + applyMigration(data) { + if (data.sheets && data.sheets[0]) { + data.activeSheet = data.sheets[0].name; + } + return data; + }, + }, + { + description: "add an id field in each sheet", + from: 2, + to: 3, + applyMigration(data) { + if (data.sheets && data.sheets.length) { + for (let sheet of data.sheets) { + sheet.id = sheet.id || sheet.name; + } + } + return data; + }, + }, + { + description: "activeSheet is now an id, not the name of a sheet", + from: 3, + to: 4, + applyMigration(data) { + if (data.sheets && data.activeSheet) { + const activeSheet = data.sheets.find((s) => s.name === data.activeSheet); + data.activeSheet = activeSheet.id; + } + return data; + }, + }, + { + description: "add figures object in each sheets", + from: 4, + to: 5, + applyMigration(data) { + for (let sheet of data.sheets || []) { + sheet.figures = sheet.figures || []; + } + return data; + }, + }, + { + description: "normalize the content of the cell if it is a formula to avoid parsing all the formula that vary only by the cells they use", + from: 5, + to: 6, + applyMigration(data) { + for (let sheet of data.sheets || []) { + for (let xc in sheet.cells || []) { + const cell = sheet.cells[xc]; + if (cell.content && cell.content.startsWith("=")) { + cell.formula = normalizeV9(cell.content); + } + } + } + return data; + }, + }, + { + description: "transform chart data structure", + from: 6, + to: 7, + applyMigration(data) { + for (let sheet of data.sheets || []) { + for (let f in sheet.figures || []) { + const { dataSets, ...newData } = sheet.figures[f].data; + const newDataSets = []; + for (let ds of dataSets) { + if (ds.labelCell) { + const dataRange = toZone(ds.dataRange); + const newRange = ds.labelCell + ":" + toXC(dataRange.right, dataRange.bottom); + newDataSets.push(newRange); + } + else { + newDataSets.push(ds.dataRange); + } + } + newData.dataSetsHaveTitle = Boolean(dataSets[0].labelCell); + newData.dataSets = newDataSets; + sheet.figures[f].data = newData; + } + } + return data; + }, + }, + { + description: "remove single quotes in sheet names", + from: 7, + to: 8, + applyMigration(data) { + var _a; + const namesTaken = []; + const globalForbiddenInExcel = new RegExp(FORBIDDEN_IN_EXCEL_REGEX, "g"); + for (let sheet of data.sheets || []) { + if (!sheet.name) { + continue; + } + const oldName = sheet.name; + const escapedName = oldName.replace(globalForbiddenInExcel, "_"); + let i = 1; + let newName = escapedName; + while (namesTaken.includes(newName)) { + newName = `${escapedName}${i}`; + i++; + } + sheet.name = newName; + namesTaken.push(newName); + const replaceName = (str) => { + if (str === undefined) { + return str; + } + // replaceAll is only available in next Typescript version + let newString = str.replace(oldName, newName); + let currentString = str; + while (currentString !== newString) { + currentString = newString; + newString = currentString.replace(oldName, newName); + } + return currentString; + }; + //cells + for (let xc in sheet.cells) { + const cell = sheet.cells[xc]; + if (cell.formula) { + cell.formula.dependencies = cell.formula.dependencies.map(replaceName); + } + } + //charts + for (let figure of sheet.figures || []) { + if (figure.type === "chart") { + const dataSets = figure.data.dataSets.map(replaceName); + const labelRange = replaceName(figure.data.labelRange); + figure.data = { ...figure.data, dataSets, labelRange }; + } + } + //ConditionalFormats + for (let cf of sheet.conditionalFormats || []) { + cf.ranges = cf.ranges.map(replaceName); + for (const thresholdName of [ + "minimum", + "maximum", + "midpoint", + "upperInflectionPoint", + "lowerInflectionPoint", + ]) { + if (((_a = cf.rule[thresholdName]) === null || _a === void 0 ? void 0 : _a.type) === "formula") { + cf.rule[thresholdName].value = replaceName(cf.rule[thresholdName].value); + } + } + } + } + return data; + }, + }, + { + description: "transform chart data structure with design attributes", + from: 8, + to: 9, + applyMigration(data) { + for (const sheet of data.sheets || []) { + for (const chart of sheet.figures || []) { + chart.data.background = BACKGROUND_CHART_COLOR; + chart.data.verticalAxisPosition = "left"; + chart.data.legendPosition = "top"; + chart.data.stackedBar = false; + } + } + return data; + }, + }, + { + description: "de-normalize formula to reduce exported json size (~30%)", + from: 9, + to: 10, + applyMigration(data) { + for (let sheet of data.sheets || []) { + for (let xc in sheet.cells || []) { + const cell = sheet.cells[xc]; + if (cell.formula) { + let { text, dependencies } = cell.formula; + for (let [index, d] of Object.entries(dependencies)) { + const stringPosition = `\\${FORMULA_REF_IDENTIFIER}${index}\\${FORMULA_REF_IDENTIFIER}`; + text = text.replace(new RegExp(stringPosition, "g"), d); + } + cell.content = text; + delete cell.formula; + } + } + } + return data; + }, + }, + { + description: "normalize the formats of the cells", + from: 10, + to: 11, + applyMigration(data) { + const formats = {}; + for (let sheet of data.sheets || []) { + for (let xc in sheet.cells || []) { + const cell = sheet.cells[xc]; + if (cell.format) { + cell.format = getItemId(cell.format, formats); + } + } + } + data.formats = formats; + return data; + }, + }, + { + description: "Add isVisible to sheets", + from: 11, + to: 12, + applyMigration(data) { + for (let sheet of data.sheets || []) { + sheet.isVisible = true; + } + return data; + }, + }, + ]; + /** + * The goal of this function is to repair corrupted/wrong initial messages caused by + * a bug. + * The bug should obviously be fixed, but it's too late for existing spreadsheet. + */ + function repairInitialMessages(data, initialMessages) { + initialMessages = fixTranslatedSheetIds(data, initialMessages); + initialMessages = dropSortCommands(data, initialMessages); + return initialMessages; + } + /** + * When the workbook data is originally empty, a new one is generated on-the-fly. + * A bug caused the sheet id to be non-deterministic. The sheet id was propagated in + * commands. + * This function repairs initial commands with a wrong sheetId. + */ + function fixTranslatedSheetIds(data, initialMessages) { + // the fix is only needed when the workbook is generated on-the-fly + if (Object.keys(data).length !== 0) { + return initialMessages; + } + const sheetIds = []; + const messages = []; + const fixSheetId = (cmd) => { + if (cmd.type === "CREATE_SHEET") { + sheetIds.push(cmd.sheetId); + } + else if ("sheetId" in cmd && !sheetIds.includes(cmd.sheetId)) { + return { ...cmd, sheetId: INITIAL_SHEET_ID }; + } + return cmd; + }; + for (const message of initialMessages) { + if (message.type === "REMOTE_REVISION") { + messages.push({ + ...message, + commands: message.commands.map(fixSheetId), + }); + } + else { + messages.push(message); + } + } + return messages; + } + function dropSortCommands(data, initialMessages) { + const messages = []; + for (const message of initialMessages) { + if (message.type === "REMOTE_REVISION") { + messages.push({ + ...message, + // @ts-ignore + commands: message.commands.filter((command) => command.type !== "SORT_CELLS"), + }); + } + else { + messages.push(message); + } + } + return messages; + } + // ----------------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------------- + function createEmptySheet(sheetId, name) { + return { + id: sheetId, + name, + colNumber: 26, + rowNumber: 100, + cells: {}, + cols: {}, + rows: {}, + merges: [], + conditionalFormats: [], + figures: [], + isVisible: true, + }; + } + function createEmptyWorkbookData(sheetName = "Sheet1") { + const data = { + version: CURRENT_VERSION, + sheets: [createEmptySheet(INITIAL_SHEET_ID, sheetName)], + entities: {}, + styles: {}, + formats: {}, + borders: {}, + revisionId: DEFAULT_REVISION_ID, + }; + return data; + } + function createEmptyExcelSheet(sheetId, name) { + return { + ...createEmptySheet(sheetId, name), + charts: [], + }; + } + function createEmptyExcelWorkbookData() { + return { + ...createEmptyWorkbookData(), + sheets: [createEmptyExcelSheet(INITIAL_SHEET_ID, "Sheet1")], + }; + } + + /** + * Core plugins handle spreadsheet data. + * They are responsible to import, export and maintain the spreadsheet + * persisted state. + * They should not be concerned about UI parts or transient state. + */ + class CorePlugin extends BasePlugin { + constructor(getters, stateObserver, range, dispatch, config, uuidGenerator) { + super(stateObserver, dispatch, config); + this.dispatch = dispatch; + this.range = range; + range.addRangeProvider(this.adaptRanges.bind(this)); + this.getters = getters; + this.uuidGenerator = uuidGenerator; + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { } + export(data) { } + /** + * This method can be implemented in any plugin, to loop over the plugin's data structure and adapt the plugin's ranges. + * To adapt them, the implementation of the function must have a perfect knowledge of the data structure, thus + * implementing the loops over it makes sense in the plugin itself. + * When calling the method applyChange, the range will be adapted if necessary, then a copy will be returned along with + * the type of change that occurred. + * + * @param applyChange a function that, when called, will adapt the range according to the change on the grid + * @param sheetId an optional sheetId to adapt either range of that sheet specifically, or ranges pointing to that sheet + */ + adaptRanges(applyChange, sheetId) { } + } + + /** + * Formatting plugin. + * + * This plugin manages all things related to a cell look: + * - borders + */ + class BordersPlugin extends CorePlugin { + constructor() { + super(...arguments); + this.borders = {}; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + handle(cmd) { + switch (cmd.type) { + case "ADD_MERGE": + for (const zone of cmd.target) { + this.addBordersToMerge(cmd.sheetId, zone); + } + break; + case "DUPLICATE_SHEET": + const borders = this.borders[cmd.sheetId]; + if (borders) { + // borders is a sparse 2D array. + // map and slice preserve empty values and do not set `undefined` instead + const bordersCopy = borders + .slice() + .map((col) => col === null || col === void 0 ? void 0 : col.slice().map((border) => ({ ...border }))); + this.history.update("borders", cmd.sheetIdTo, bordersCopy); + } + break; + case "DELETE_SHEET": + const allBorders = { ...this.borders }; + delete allBorders[cmd.sheetId]; + this.history.update("borders", allBorders); + break; + case "SET_BORDER": + this.setBorder(cmd.sheetId, cmd.col, cmd.row, cmd.border); + break; + case "SET_FORMATTING": + if (cmd.border) { + const target = cmd.target.map((zone) => this.getters.expandZone(cmd.sheetId, zone)); + this.setBorders(cmd.sheetId, target, cmd.border); + } + break; + case "CLEAR_FORMATTING": + this.clearBorders(cmd.sheetId, cmd.target); + break; + case "REMOVE_COLUMNS_ROWS": + for (let el of cmd.elements) { + if (cmd.dimension === "COL") { + this.shiftBordersHorizontally(cmd.sheetId, el + 1, -1); + } + else { + this.shiftBordersVertically(cmd.sheetId, el + 1, -1); + } + } + break; + case "ADD_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.handleAddColumns(cmd); + } + else { + this.handleAddRows(cmd); + } + break; + } + } + /** + * Move borders according to the inserted columns. + * Ensure borders continuity. + */ + handleAddColumns(cmd) { + // The new columns have already been inserted in the sheet at this point. + let colLeftOfInsertion; + let colRightOfInsertion; + if (cmd.position === "before") { + this.shiftBordersHorizontally(cmd.sheetId, cmd.base, cmd.quantity, { + moveFirstLeftBorder: true, + }); + colLeftOfInsertion = cmd.base - 1; + colRightOfInsertion = cmd.base + cmd.quantity; + } + else { + this.shiftBordersHorizontally(cmd.sheetId, cmd.base + 1, cmd.quantity, { + moveFirstLeftBorder: false, + }); + colLeftOfInsertion = cmd.base; + colRightOfInsertion = cmd.base + cmd.quantity + 1; + } + this.ensureColumnBorderContinuity(cmd.sheetId, colLeftOfInsertion, colRightOfInsertion); + } + /** + * Move borders according to the inserted rows. + * Ensure borders continuity. + */ + handleAddRows(cmd) { + // The new rows have already been inserted at this point. + let rowAboveInsertion; + let rowBelowInsertion; + if (cmd.position === "before") { + this.shiftBordersVertically(cmd.sheetId, cmd.base, cmd.quantity, { + moveFirstTopBorder: true, + }); + rowAboveInsertion = cmd.base - 1; + rowBelowInsertion = cmd.base + cmd.quantity; + } + else { + this.shiftBordersVertically(cmd.sheetId, cmd.base + 1, cmd.quantity, { + moveFirstTopBorder: false, + }); + rowAboveInsertion = cmd.base; + rowBelowInsertion = cmd.base + cmd.quantity + 1; + } + this.ensureRowBorderContinuity(cmd.sheetId, rowAboveInsertion, rowBelowInsertion); + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getCellBorder(sheetId, col, row) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; + const border = { + top: (_c = (_b = (_a = this.borders[sheetId]) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b[row]) === null || _c === void 0 ? void 0 : _c.horizontal, + bottom: (_f = (_e = (_d = this.borders[sheetId]) === null || _d === void 0 ? void 0 : _d[col]) === null || _e === void 0 ? void 0 : _e[row + 1]) === null || _f === void 0 ? void 0 : _f.horizontal, + left: (_j = (_h = (_g = this.borders[sheetId]) === null || _g === void 0 ? void 0 : _g[col]) === null || _h === void 0 ? void 0 : _h[row]) === null || _j === void 0 ? void 0 : _j.vertical, + right: (_m = (_l = (_k = this.borders[sheetId]) === null || _k === void 0 ? void 0 : _k[col + 1]) === null || _l === void 0 ? void 0 : _l[row]) === null || _m === void 0 ? void 0 : _m.vertical, + }; + if (!border.bottom && !border.left && !border.right && !border.top) { + return null; + } + return border; + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + /** + * Ensure border continuity between two columns. + * If the two columns have the same borders (at each row respectively), + * the same borders are applied to each cell in between. + */ + ensureColumnBorderContinuity(sheetId, leftColumn, rightColumn) { + const targetCols = range(leftColumn + 1, rightColumn); + for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { + const leftBorder = this.getCellBorder(sheetId, leftColumn, row); + const rightBorder = this.getCellBorder(sheetId, rightColumn, row); + if (leftBorder && rightBorder) { + const commonSides = this.getCommonSides(leftBorder, rightBorder); + for (let col of targetCols) { + this.addBorder(sheetId, col, row, commonSides); + } + } + } + } + /** + * Ensure border continuity between two rows. + * If the two rows have the same borders (at each column respectively), + * the same borders are applied to each cell in between. + */ + ensureRowBorderContinuity(sheetId, topRow, bottomRow) { + const targetRows = range(topRow + 1, bottomRow); + for (let col = 0; col < this.getters.getNumberCols(sheetId); col++) { + const aboveBorder = this.getCellBorder(sheetId, col, topRow); + const belowBorder = this.getCellBorder(sheetId, col, bottomRow); + if (aboveBorder && belowBorder) { + const commonSides = this.getCommonSides(aboveBorder, belowBorder); + for (let row of targetRows) { + this.addBorder(sheetId, col, row, commonSides); + } + } + } + } + /** + * From two borders, return a new border with sides defined in both borders. + * i.e. the intersection of two borders. + */ + getCommonSides(border1, border2) { + const commonBorder = {}; + for (let side of ["top", "bottom", "left", "right"]) { + if (border1[side] && border1[side] === border2[side]) { + commonBorder[side] = border1[side]; + } + } + return commonBorder; + } + /** + * Get all the columns which contains at least a border + */ + getColumnsWithBorders(sheetId) { + const sheetBorders = this.borders[sheetId]; + if (!sheetBorders) + return []; + return Object.keys(sheetBorders).map((index) => parseInt(index, 10)); + } + /** + * Get the range of all the rows in the sheet + */ + getRowsRange(sheetId) { + const sheetBorders = this.borders[sheetId]; + if (!sheetBorders) + return []; + return range(0, this.getters.getNumberRows(sheetId) + 1); + } + /** + * Move borders of a sheet horizontally. + * @param sheetId + * @param start starting column (included) + * @param delta how much borders will be moved (negative if moved to the left) + */ + shiftBordersHorizontally(sheetId, start, delta, { moveFirstLeftBorder } = {}) { + const borders = this.borders[sheetId]; + if (!borders) + return; + if (delta < 0) { + this.moveBordersOfColumn(sheetId, start, delta, "vertical", { + destructive: false, + }); + } + this.getColumnsWithBorders(sheetId) + .filter((col) => col >= start) + .sort((a, b) => (delta < 0 ? a - b : b - a)) // start by the end when moving up + .forEach((col) => { + if ((col === start && moveFirstLeftBorder) || col !== start) { + this.moveBordersOfColumn(sheetId, col, delta, "vertical"); + } + this.moveBordersOfColumn(sheetId, col, delta, "horizontal"); + }); + } + /** + * Move borders of a sheet vertically. + * @param sheetId + * @param start starting row (included) + * @param delta how much borders will be moved (negative if moved to the above) + */ + shiftBordersVertically(sheetId, start, delta, { moveFirstTopBorder } = {}) { + const borders = this.borders[sheetId]; + if (!borders) + return; + if (delta < 0) { + this.moveBordersOfRow(sheetId, start, delta, "horizontal", { + destructive: false, + }); + } + this.getRowsRange(sheetId) + .filter((row) => row >= start) + .sort((a, b) => (delta < 0 ? a - b : b - a)) // start by the end when moving up + .forEach((row) => { + if ((row === start && moveFirstTopBorder) || row !== start) { + this.moveBordersOfRow(sheetId, row, delta, "horizontal"); + } + this.moveBordersOfRow(sheetId, row, delta, "vertical"); + }); + } + /** + * Moves the borders (left if `vertical` or top if `horizontal` depending on + * `borderDirection`) of all cells in an entire row `delta` rows to the right + * (`delta` > 0) or to the left (`delta` < 0). + * Note that as the left of a cell is the right of the cell-1, if the left is + * moved the right is also moved. However, if `horizontal`, the bottom border + * is not moved. + * It does it by replacing the target border by the moved border. If the + * argument `destructive` is given false, the target border is preserved if + * the moved border is empty + */ + moveBordersOfRow(sheetId, row, delta, borderDirection, { destructive } = { destructive: true }) { + const borders = this.borders[sheetId]; + if (!borders) + return; + this.getColumnsWithBorders(sheetId).forEach((col) => { + var _a, _b, _c, _d; + const targetBorder = (_b = (_a = borders[col]) === null || _a === void 0 ? void 0 : _a[row + delta]) === null || _b === void 0 ? void 0 : _b[borderDirection]; + const movedBorder = (_d = (_c = borders[col]) === null || _c === void 0 ? void 0 : _c[row]) === null || _d === void 0 ? void 0 : _d[borderDirection]; + this.history.update("borders", sheetId, col, row + delta, borderDirection, destructive ? movedBorder : movedBorder || targetBorder); + this.history.update("borders", sheetId, col, row, borderDirection, undefined); + }); + } + /** + * Moves the borders (left if `vertical` or top if `horizontal` depending on + * `borderDirection`) of all cells in an entire column `delta` columns below + * (`delta` > 0) or above (`delta` < 0). + * Note that as the top of a cell is the bottom of the cell-1, if the top is + * moved the bottom is also moved. However, if `vertical`, the right border + * is not moved. + * It does it by replacing the target border by the moved border. If the + * argument `destructive` is given false, the target border is preserved if + * the moved border is empty + */ + moveBordersOfColumn(sheetId, col, delta, borderDirection, { destructive } = { destructive: true }) { + const borders = this.borders[sheetId]; + if (!borders) + return; + this.getRowsRange(sheetId).forEach((row) => { + var _a, _b, _c, _d; + const targetBorder = (_b = (_a = borders[col + delta]) === null || _a === void 0 ? void 0 : _a[row]) === null || _b === void 0 ? void 0 : _b[borderDirection]; + const movedBorder = (_d = (_c = borders[col]) === null || _c === void 0 ? void 0 : _c[row]) === null || _d === void 0 ? void 0 : _d[borderDirection]; + this.history.update("borders", sheetId, col + delta, row, borderDirection, destructive ? movedBorder : movedBorder || targetBorder); + this.history.update("borders", sheetId, col, row, borderDirection, undefined); + }); + } + /** + * Set the borders of a cell. + * It overrides the current border if override == true. + */ + setBorder(sheetId, col, row, border, override = true) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r; + if (override || !((_d = (_c = (_b = (_a = this.borders) === null || _a === void 0 ? void 0 : _a[sheetId]) === null || _b === void 0 ? void 0 : _b[col]) === null || _c === void 0 ? void 0 : _c[row]) === null || _d === void 0 ? void 0 : _d.vertical)) { + this.history.update("borders", sheetId, col, row, "vertical", border === null || border === void 0 ? void 0 : border.left); + } + if (override || !((_h = (_g = (_f = (_e = this.borders) === null || _e === void 0 ? void 0 : _e[sheetId]) === null || _f === void 0 ? void 0 : _f[col]) === null || _g === void 0 ? void 0 : _g[row]) === null || _h === void 0 ? void 0 : _h.horizontal)) { + this.history.update("borders", sheetId, col, row, "horizontal", border === null || border === void 0 ? void 0 : border.top); + } + if (override || !((_m = (_l = (_k = (_j = this.borders) === null || _j === void 0 ? void 0 : _j[sheetId]) === null || _k === void 0 ? void 0 : _k[col + 1]) === null || _l === void 0 ? void 0 : _l[row]) === null || _m === void 0 ? void 0 : _m.vertical)) { + this.history.update("borders", sheetId, col + 1, row, "vertical", border === null || border === void 0 ? void 0 : border.right); + } + if (override || !((_r = (_q = (_p = (_o = this.borders) === null || _o === void 0 ? void 0 : _o[sheetId]) === null || _p === void 0 ? void 0 : _p[col]) === null || _q === void 0 ? void 0 : _q[row + 1]) === null || _r === void 0 ? void 0 : _r.horizontal)) { + this.history.update("borders", sheetId, col, row + 1, "horizontal", border === null || border === void 0 ? void 0 : border.bottom); + } + } + /** + * Remove the borders of a zone + */ + clearBorders(sheetId, zones) { + for (let zone of zones) { + for (let row = zone.top; row <= zone.bottom; row++) { + this.history.update("borders", sheetId, zone.right + 1, row, "vertical", undefined); + for (let col = zone.left; col <= zone.right; col++) { + this.history.update("borders", sheetId, col, row, undefined); + } + } + for (let col = zone.left; col <= zone.right; col++) { + this.history.update("borders", sheetId, col, zone.bottom + 1, "horizontal", undefined); + } + } + } + /** + * Add a border to the existing one to a cell + */ + addBorder(sheetId, col, row, border) { + this.setBorder(sheetId, col, row, { + ...this.getCellBorder(sheetId, col, row), + ...border, + }); + } + /** + * Set the borders of a zone by computing the borders to add from the given + * command + */ + setBorders(sheetId, zones, command) { + if (command === "clear") { + return this.clearBorders(sheetId, zones); + } + for (let zone of zones) { + if (command === "h" || command === "hv" || command === "all") { + for (let row = zone.top + 1; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + this.addBorder(sheetId, col, row, { top: DEFAULT_BORDER_DESC }); + } + } + } + if (command === "v" || command === "hv" || command === "all") { + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left + 1; col <= zone.right; col++) { + this.addBorder(sheetId, col, row, { left: DEFAULT_BORDER_DESC }); + } + } + } + if (command === "left" || command === "all" || command === "external") { + for (let row = zone.top; row <= zone.bottom; row++) { + this.addBorder(sheetId, zone.left, row, { left: DEFAULT_BORDER_DESC }); + } + } + if (command === "right" || command === "all" || command === "external") { + for (let row = zone.top; row <= zone.bottom; row++) { + this.addBorder(sheetId, zone.right + 1, row, { left: DEFAULT_BORDER_DESC }); + } + } + if (command === "top" || command === "all" || command === "external") { + for (let col = zone.left; col <= zone.right; col++) { + this.addBorder(sheetId, col, zone.top, { top: DEFAULT_BORDER_DESC }); + } + } + if (command === "bottom" || command === "all" || command === "external") { + for (let col = zone.left; col <= zone.right; col++) { + this.addBorder(sheetId, col, zone.bottom + 1, { top: DEFAULT_BORDER_DESC }); + } + } + } + } + /** + * Compute the borders to add to the given zone merged. + */ + addBordersToMerge(sheetId, zone) { + const { left, right, top, bottom } = zone; + const bordersTopLeft = this.getCellBorder(sheetId, left, top); + const bordersBottomRight = this.getCellBorder(sheetId, right, bottom); + this.clearBorders(sheetId, [zone]); + if (bordersTopLeft === null || bordersTopLeft === void 0 ? void 0 : bordersTopLeft.top) { + this.setBorders(sheetId, [{ ...zone, bottom: top }], "top"); + } + if (bordersTopLeft === null || bordersTopLeft === void 0 ? void 0 : bordersTopLeft.left) { + this.setBorders(sheetId, [{ ...zone, right: left }], "left"); + } + if ((bordersBottomRight === null || bordersBottomRight === void 0 ? void 0 : bordersBottomRight.bottom) || (bordersTopLeft === null || bordersTopLeft === void 0 ? void 0 : bordersTopLeft.bottom)) { + this.setBorders(sheetId, [{ ...zone, top: bottom }], "bottom"); + } + if ((bordersBottomRight === null || bordersBottomRight === void 0 ? void 0 : bordersBottomRight.right) || (bordersTopLeft === null || bordersTopLeft === void 0 ? void 0 : bordersTopLeft.right)) { + this.setBorders(sheetId, [{ ...zone, left: right }], "right"); + } + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { + // Borders + if (data.borders) { + for (let sheet of data.sheets) { + for (let [xc, cell] of Object.entries(sheet.cells)) { + if (cell === null || cell === void 0 ? void 0 : cell.border) { + const border = data.borders[cell.border]; + const { col, row } = toCartesian(xc); + this.setBorder(sheet.id, col, row, border, false); + } + } + } + } + // Merges + for (let sheetData of data.sheets) { + if (sheetData.merges) { + for (let merge of sheetData.merges) { + this.addBordersToMerge(sheetData.id, toZone(merge)); + } + } + } + } + export(data) { + // Borders + let borderId = 0; + const borders = {}; + /** + * Get the id of the given border. If the border does not exist, it creates + * one. + */ + function getBorderId(border) { + for (let [key, value] of Object.entries(borders)) { + if (stringify(value) === stringify(border)) { + return parseInt(key, 10); + } + } + borders[++borderId] = border; + return borderId; + } + for (let sheet of data.sheets) { + for (let col = 0; col < sheet.colNumber; col++) { + for (let row = 0; row < sheet.rowNumber; row++) { + const border = this.getCellBorder(sheet.id, col, row); + if (border) { + const xc = toXC(col, row); + const cell = sheet.cells[xc]; + const borderId = getBorderId(border); + if (cell) { + cell.border = borderId; + } + else { + sheet.cells[xc] = { border: borderId }; + } + } + } + } + } + data.borders = borders; + } + exportForExcel(data) { + this.export(data); + } + } + BordersPlugin.getters = ["getCellBorder"]; + + const nbspRegexp = new RegExp(String.fromCharCode(160), "g"); + /** + * Core Plugin + * + * This is the most fundamental of all plugins. It defines how to interact with + * cell and sheet content. + */ + class CellPlugin extends CorePlugin { + constructor() { + super(...arguments); + this.cells = {}; + this.createCell = cellFactory(this.getters); + } + adaptRanges(applyChange, sheetId) { + for (const sheet of Object.keys(this.cells)) { + for (const cell of Object.values(this.cells[sheet] || {})) { + if (cell.isFormula()) { + for (const range of cell.dependencies) { + if (!sheetId || range.sheetId === sheetId) { + const change = applyChange(range); + if (change.changeType !== "NONE") { + this.history.update("cells", sheet, cell.id, "dependencies", cell.dependencies.indexOf(range), change.range); + } + } + } + } + } + } + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "UPDATE_CELL": + return this.checkCellOutOfSheet(cmd.sheetId, cmd.col, cmd.row); + default: + return 0 /* Success */; + } + } + handle(cmd) { + switch (cmd.type) { + case "SET_FORMATTING": + if ("style" in cmd) { + this.setStyle(cmd.sheetId, cmd.target, cmd.style); + } + if ("format" in cmd && cmd.format !== undefined) { + this.setFormatter(cmd.sheetId, cmd.target, cmd.format); + } + break; + case "CLEAR_FORMATTING": + this.clearStyles(cmd.sheetId, cmd.target); + break; + case "ADD_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.handleAddColumnsRows(cmd, this.copyColumnStyle.bind(this)); + } + else { + this.handleAddColumnsRows(cmd, this.copyRowStyle.bind(this)); + } + break; + case "UPDATE_CELL": + this.updateCell(cmd.sheetId, cmd.col, cmd.row, cmd); + break; + case "CLEAR_CELL": + this.dispatch("UPDATE_CELL", { + sheetId: cmd.sheetId, + col: cmd.col, + row: cmd.row, + content: "", + style: null, + format: "", + }); + break; + } + } + /** + * Set a format to all the cells in a zone + */ + setFormatter(sheetId, zones, format) { + for (let zone of zones) { + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + format, + }); + } + } + } + } + /** + * Clear the styles of zones + */ + clearStyles(sheetId, zones) { + for (let zone of zones) { + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + // commandHelpers.updateCell(sheetId, col, row, { style: undefined}); + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + style: null, + }); + } + } + } + } + /** + * Copy the style of the reference column/row to the new columns/rows. + */ + handleAddColumnsRows(cmd, fn) { + // The new elements have already been inserted in the sheet at this point. + let insertedElements; + let styleReference; + if (cmd.position === "before") { + insertedElements = range(cmd.base, cmd.base + cmd.quantity); + styleReference = cmd.base + cmd.quantity; + } + else { + insertedElements = range(cmd.base + 1, cmd.base + cmd.quantity + 1); + styleReference = cmd.base; + } + fn(cmd.sheetId, styleReference, insertedElements); + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { + for (let sheet of data.sheets) { + // cells + for (let xc in sheet.cells) { + const cellData = sheet.cells[xc]; + const { col, row } = toCartesian(xc); + if ((cellData === null || cellData === void 0 ? void 0 : cellData.content) || (cellData === null || cellData === void 0 ? void 0 : cellData.format) || (cellData === null || cellData === void 0 ? void 0 : cellData.style)) { + const cell = this.importCell(sheet.id, cellData, data.styles, data.formats); + this.history.update("cells", sheet.id, cell.id, cell); + this.dispatch("UPDATE_CELL_POSITION", { + cellId: cell.id, + col, + row, + sheetId: sheet.id, + }); + } + } + } + } + export(data) { + const styles = {}; + const formats = {}; + for (let _sheet of data.sheets) { + const cells = {}; + const positions = Object.keys(this.cells[_sheet.id] || {}) + .map((cellId) => this.getters.getCellPosition(cellId)) + .sort((a, b) => (a.col === b.col ? a.row - b.row : a.col - b.col)); + for (const { col, row } of positions) { + const cell = this.getters.getCell(_sheet.id, col, row); + const xc = toXC(col, row); + cells[xc] = { + style: cell.style ? getItemId(cell.style, styles) : undefined, + format: cell.format ? getItemId(cell.format, formats) : undefined, + content: cell.content || undefined, + }; + } + _sheet.cells = cells; + } + data.styles = styles; + data.formats = formats; + } + importCell(sheetId, cellData, normalizedStyles, normalizedFormats) { + const style = (cellData.style && normalizedStyles[cellData.style]) || undefined; + const format = (cellData.format && normalizedFormats[cellData.format]) || undefined; + const cellId = this.uuidGenerator.uuidv4(); + const properties = { format, style }; + return this.createCell(cellId, (cellData === null || cellData === void 0 ? void 0 : cellData.content) || "", properties, sheetId); + } + exportForExcel(data) { + this.export(data); + for (let sheet of data.sheets) { + for (const xc in sheet.cells) { + const { col, row } = toCartesian(xc); + const cell = this.getters.getCell(sheet.id, col, row); + const exportedCellData = sheet.cells[xc]; + exportedCellData.value = cell.evaluated.value; + exportedCellData.isFormula = cell.isFormula(); + } + } + } + // --------------------------------------------------------------------------- + // GETTERS + // --------------------------------------------------------------------------- + getCells(sheetId) { + return this.cells[sheetId] || {}; + } + /** + * get a cell by ID. Used in evaluation when evaluating an async cell, we need to be able to find it back after + * starting an async evaluation even if it has been moved or re-allocated + */ + getCellById(cellId) { + // this must be as fast as possible + for (const sheetId in this.cells) { + const sheet = this.cells[sheetId]; + const cell = sheet[cellId]; + if (cell) { + return cell; + } + } + return undefined; + } + /* + * Reconstructs the original formula string based on a normalized form and its dependencies + */ + buildFormulaContent(sheetId, cell, dependencies) { + const ranges = dependencies || [...cell.dependencies]; + return concat(cell.compiledFormula.tokens.map((token) => { + if (token.type === "REFERENCE") { + const range = ranges.shift(); + return this.getters.getRangeString(range, sheetId); + } + return token.value; + })); + } + getFormulaCellContent(sheetId, cell) { + return this.buildFormulaContent(sheetId, cell); + } + getCellStyle(cell) { + return (cell && cell.style) || {}; + } + /** + * Converts a zone to a XC coordinate system + * + * The conversion also treats merges as one single cell + * + * Examples: + * {top:0,left:0,right:0,bottom:0} ==> A1 + * {top:0,left:0,right:1,bottom:1} ==> A1:B2 + * + * if A1:B2 is a merge: + * {top:0,left:0,right:1,bottom:1} ==> A1 + * {top:1,left:0,right:1,bottom:2} ==> A1:B3 + * + * if A1:B2 and A4:B5 are merges: + * {top:1,left:0,right:1,bottom:3} ==> A1:A5 + */ + zoneToXC(sheetId, zone, fixedParts = [{ colFixed: false, rowFixed: false }]) { + zone = this.getters.expandZone(sheetId, zone); + const topLeft = toXC(zone.left, zone.top, fixedParts[0]); + const botRight = toXC(zone.right, zone.bottom, fixedParts.length > 1 ? fixedParts[1] : fixedParts[0]); + const cellTopLeft = this.getters.getMainCellPosition(sheetId, zone.left, zone.top); + const cellBotRight = this.getters.getMainCellPosition(sheetId, zone.right, zone.bottom); + const sameCell = cellTopLeft.col === cellBotRight.col && cellTopLeft.row === cellBotRight.row; + if (topLeft != botRight && !sameCell) { + return topLeft + ":" + botRight; + } + return topLeft; + } + setStyle(sheetId, target, style) { + for (let zone of target) { + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + const cell = this.getters.getCell(sheetId, col, row); + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + style: style ? { ...cell === null || cell === void 0 ? void 0 : cell.style, ...style } : undefined, + }); + } + } + } + } + /** + * Copy the style of one column to other columns. + */ + copyColumnStyle(sheetId, refColumn, targetCols) { + for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { + const format = this.getFormat(sheetId, refColumn, row); + if (format.style || format.format) { + for (let col of targetCols) { + this.dispatch("UPDATE_CELL", { sheetId, col, row, ...format }); + } + } + } + } + /** + * Copy the style of one row to other rows. + */ + copyRowStyle(sheetId, refRow, targetRows) { + for (let col = 0; col < this.getters.getNumberCols(sheetId); col++) { + const format = this.getFormat(sheetId, col, refRow); + if (format.style || format.format) { + for (let row of targetRows) { + this.dispatch("UPDATE_CELL", { sheetId, col, row, ...format }); + } + } + } + } + /** + * gets the currently used style/border of a cell based on it's coordinates + */ + getFormat(sheetId, col, row) { + const format = {}; + const { col: mainCol, row: mainRow } = this.getters.getMainCellPosition(sheetId, col, row); + const cell = this.getters.getCell(sheetId, mainCol, mainRow); + if (cell) { + if (cell.style) { + format["style"] = cell.style; + } + if (cell.format) { + format["format"] = cell.format; + } + } + return format; + } + updateCell(sheetId, col, row, after) { + var _a; + const before = this.getters.getCell(sheetId, col, row); + const hasContent = "content" in after || "formula" in after; + // Compute the new cell properties + const afterContent = hasContent + ? ((_a = after.content) === null || _a === void 0 ? void 0 : _a.replace(nbspRegexp, "")) || "" + : (before === null || before === void 0 ? void 0 : before.content) || ""; + let style; + if (after.style !== undefined) { + style = after.style || undefined; + } + else { + style = before ? before.style : undefined; + } + let format = ("format" in after ? after.format : before && before.format) || NULL_FORMAT; + /* Read the following IF as: + * we need to remove the cell if it is completely empty, but we can know if it completely empty if: + * - the command says the new content is empty and has no border/format/style + * - the command has no content property, in this case + * - either there wasn't a cell at this place and the command says border/format/style is empty + * - or there was a cell at this place, but it's an empty cell and the command says border/format/style is empty + * */ + if (((hasContent && !afterContent && !after.formula) || + (!hasContent && (!before || before.isEmpty()))) && + !style && + !format) { + if (before) { + this.history.update("cells", sheetId, before.id, undefined); + this.dispatch("UPDATE_CELL_POSITION", { + cellId: undefined, + col, + row, + sheetId, + }); + } + return; + } + const cellId = (before === null || before === void 0 ? void 0 : before.id) || this.uuidGenerator.uuidv4(); + const didContentChange = hasContent; + const properties = { format, style }; + const cell = this.createCell(cellId, afterContent, properties, sheetId); + if (before && !didContentChange && cell.isFormula()) { + // content is not re-evaluated if the content did not change => reassign the value manually + // TODO this plugin should not care about evaluation + // and evaluation should not depend on implementation details here. + // Task 2813749 + cell.assignEvaluation(before.evaluated.value, before.evaluated.format); + if (before.evaluated.type === CellValueType.error) { + cell.assignError(before.evaluated.value, before.evaluated.error); + } + } + this.history.update("cells", sheetId, cell.id, cell); + this.dispatch("UPDATE_CELL_POSITION", { cellId: cell.id, col, row, sheetId }); + } + checkCellOutOfSheet(sheetId, col, row) { + const sheet = this.getters.tryGetSheet(sheetId); + if (!sheet) + return 25 /* InvalidSheetId */; + const sheetZone = this.getters.getSheetZone(sheetId); + return isInside(col, row, sheetZone) ? 0 /* Success */ : 16 /* TargetOutOfSheet */; + } + } + CellPlugin.getters = [ + "zoneToXC", + "getCells", + "getFormulaCellContent", + "getCellStyle", + "buildFormulaContent", + "getCellById", + ]; + + class ChartPlugin extends CorePlugin { + constructor() { + super(...arguments); + this.charts = {}; + this.nextId = 1; + this.createChart = chartFactory(this.getters); + this.validateChartDefinition = (definition) => validateChartDefinition(this, definition); + } + adaptRanges(applyChange) { + for (const [chartId, chart] of Object.entries(this.charts)) { + this.history.update("charts", chartId, chart === null || chart === void 0 ? void 0 : chart.updateRanges(applyChange)); + } + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "CREATE_CHART": + case "UPDATE_CHART": + return this.validateChartDefinition(cmd.definition); + default: + return 0 /* Success */; + } + } + handle(cmd) { + var _a; + switch (cmd.type) { + case "CREATE_CHART": + this.addFigure(cmd.id, cmd.sheetId, cmd.position, cmd.size); + this.addChart(cmd.id, cmd.sheetId, cmd.definition); + break; + case "UPDATE_CHART": { + this.addChart(cmd.id, cmd.sheetId, cmd.definition); + break; + } + case "DUPLICATE_SHEET": { + const sheetFiguresFrom = this.getters.getFigures(cmd.sheetId); + for (const fig of sheetFiguresFrom) { + if (fig.tag === "chart") { + const id = this.nextId.toString(); + this.history.update("nextId", this.nextId + 1); + const chart = (_a = this.charts[fig.id]) === null || _a === void 0 ? void 0 : _a.copyForSheetId(cmd.sheetIdTo); + // TODO: + // This is not really correct, it should be the role of figures to + // duplicate a figure. + this.addFigure(id, cmd.sheetIdTo, { x: fig.x, y: fig.y }); + this.history.update("charts", id, chart); + } + } + break; + } + case "DELETE_FIGURE": + this.history.update("charts", cmd.id, undefined); + break; + case "DELETE_SHEET": + for (let id of this.getChartIds(cmd.sheetId)) { + this.history.update("charts", id, undefined); + } + break; + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getContextCreationChart(figureId) { + var _a; + return (_a = this.charts[figureId]) === null || _a === void 0 ? void 0 : _a.getContextCreation(); + } + getChart(figureId) { + return this.charts[figureId]; + } + getChartType(figureId) { + var _a; + const type = (_a = this.charts[figureId]) === null || _a === void 0 ? void 0 : _a.type; + if (!type) { + throw new Error("Chart not defined."); + } + return type; + } + isChartDefined(figureId) { + return figureId in this.charts && this.charts !== undefined; + } + getChartIds(sheetId) { + return Object.entries(this.charts) + .filter(([, chart]) => (chart === null || chart === void 0 ? void 0 : chart.sheetId) === sheetId) + .map(([id]) => id); + } + getChartDefinition(figureId) { + var _a; + const definition = (_a = this.charts[figureId]) === null || _a === void 0 ? void 0 : _a.getDefinition(); + if (!definition) { + throw new Error(`There is no chart with the given figureId: ${figureId}`); + } + return definition; + } + getSheetIdsUsedInChartRanges(figureId) { + var _a; + return (_a = this.charts[figureId]) === null || _a === void 0 ? void 0 : _a.getSheetIdsUsedInChartRanges(); + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { + for (let sheet of data.sheets) { + if (sheet.figures) { + for (let figure of sheet.figures) { + // TODO: + // figure data should be external IMO => chart should be in sheet.chart + // instead of in figure.data + if (figure.tag === "chart") { + this.charts[figure.id] = this.createChart(figure.id, figure.data, sheet.id); + } + } + } + } + } + export(data) { + if (data.sheets) { + for (let sheet of data.sheets) { + // TODO This code is false, if two plugins want ot insert figures on the sheet, it will crash ! + const sheetFigures = this.getters.getFigures(sheet.id); + const figures = sheetFigures; + for (let figure of figures) { + if (figure && figure.tag === "chart") { + figure.data = this.getChartDefinition(figure.id); + } + } + sheet.figures = figures; + } + } + } + exportForExcel(data) { + var _a; + for (let sheet of data.sheets) { + const sheetFigures = this.getters.getFigures(sheet.id); + const figures = []; + for (let figure of sheetFigures) { + if (figure && figure.tag === "chart") { + const figureData = (_a = this.charts[figure.id]) === null || _a === void 0 ? void 0 : _a.getDefinitionForExcel(); + if (figureData) { + figures.push({ + ...figure, + data: figureData, + }); + } + } + } + sheet.charts = figures; + } + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + /** + * Add a figure with tag chart with the given id at the given position + */ + addFigure(id, sheetId, position = { x: 0, y: 0 }, size = { + width: DEFAULT_FIGURE_WIDTH, + height: DEFAULT_FIGURE_HEIGHT, + }) { + if (this.getters.getFigure(sheetId, id)) { + return; + } + const figure = { + id, + x: position.x, + y: position.y, + width: size.width, + height: size.height, + tag: "chart", + }; + this.dispatch("CREATE_FIGURE", { sheetId, figure }); + } + /** + * Add a chart in the local state. If a chart already exists, this chart is + * replaced + */ + addChart(id, sheetId, definition) { + this.history.update("charts", id, this.createChart(id, definition, sheetId)); + } + } + ChartPlugin.getters = [ + "isChartDefined", + "getChartDefinition", + "getChartType", + "getChartIds", + "getChart", + "getContextCreationChart", + "getSheetIdsUsedInChartRanges", + ]; + + // ----------------------------------------------------------------------------- + // Constants + // ----------------------------------------------------------------------------- + function stringToNumber(value) { + return value === "" ? NaN : Number(value); + } + class ConditionalFormatPlugin extends CorePlugin { + constructor() { + super(...arguments); + this.cfRules = {}; + } + loopThroughRangesOfSheet(sheetId, applyChange) { + for (const rule of this.cfRules[sheetId]) { + for (const range of rule.ranges) { + const change = applyChange(range); + switch (change.changeType) { + case "REMOVE": + let copy = rule.ranges.slice(); + copy.splice(rule.ranges.indexOf(range), 1); + if (copy.length >= 1) { + this.history.update("cfRules", sheetId, this.cfRules[sheetId].indexOf(rule), "ranges", copy); + } + else { + this.removeConditionalFormatting(rule.id, sheetId); + } + break; + case "RESIZE": + case "MOVE": + case "CHANGE": + this.history.update("cfRules", sheetId, this.cfRules[sheetId].indexOf(rule), "ranges", rule.ranges.indexOf(range), change.range); + break; + } + } + } + } + adaptRanges(applyChange, sheetId) { + if (sheetId) { + this.loopThroughRangesOfSheet(sheetId, applyChange); + } + else { + for (const sheetId of Object.keys(this.cfRules)) { + this.loopThroughRangesOfSheet(sheetId, applyChange); + } + } + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "ADD_CONDITIONAL_FORMAT": + return this.checkValidations(cmd, this.checkCFRule, this.checkEmptyRange); + case "MOVE_CONDITIONAL_FORMAT": + return this.checkValidReordering(cmd.cfId, cmd.direction, cmd.sheetId); + } + return 0 /* Success */; + } + handle(cmd) { + switch (cmd.type) { + case "CREATE_SHEET": + this.cfRules[cmd.sheetId] = []; + break; + case "DUPLICATE_SHEET": + this.history.update("cfRules", cmd.sheetIdTo, []); + for (const cf of this.getConditionalFormats(cmd.sheetId)) { + this.addConditionalFormatting(cf, cmd.sheetIdTo); + } + break; + case "DELETE_SHEET": + const cfRules = Object.assign({}, this.cfRules); + delete cfRules[cmd.sheetId]; + this.history.update("cfRules", cfRules); + break; + case "ADD_CONDITIONAL_FORMAT": + const cf = { + ...cmd.cf, + ranges: cmd.ranges.map((rangeData) => this.getters.getRangeString(this.getters.getRangeFromRangeData(rangeData), cmd.sheetId)), + }; + this.addConditionalFormatting(cf, cmd.sheetId); + break; + case "REMOVE_CONDITIONAL_FORMAT": + this.removeConditionalFormatting(cmd.id, cmd.sheetId); + break; + case "MOVE_CONDITIONAL_FORMAT": + this.reorderConditionalFormatting(cmd.cfId, cmd.direction, cmd.sheetId); + break; + } + } + import(data) { + for (let sheet of data.sheets) { + this.cfRules[sheet.id] = sheet.conditionalFormats.map((rule) => this.mapToConditionalFormatInternal(sheet.id, rule)); + } + } + export(data) { + if (data.sheets) { + for (let sheet of data.sheets) { + if (this.cfRules[sheet.id]) { + sheet.conditionalFormats = this.cfRules[sheet.id].map((rule) => this.mapToConditionalFormat(sheet.id, rule)); + } + } + } + } + exportForExcel(data) { + this.export(data); + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + /** + * Returns all the conditional format rules defined for the current sheet to display the user + */ + getConditionalFormats(sheetId) { + var _a; + return ((_a = this.cfRules[sheetId]) === null || _a === void 0 ? void 0 : _a.map((cf) => this.mapToConditionalFormat(sheetId, cf))) || []; + } + getRulesSelection(sheetId, selection) { + const ruleIds = new Set(); + selection.forEach((zone) => { + const zoneRuleId = this.getRulesByZone(sheetId, zone); + zoneRuleId.forEach((ruleId) => { + ruleIds.add(ruleId); + }); + }); + return Array.from(ruleIds); + } + getRulesByZone(sheetId, zone) { + const ruleIds = new Set(); + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + const cellRules = this.getRulesByCell(sheetId, col, row); + cellRules.forEach((rule) => { + ruleIds.add(rule.id); + }); + } + } + return ruleIds; + } + getRulesByCell(sheetId, cellCol, cellRow) { + const rules = []; + for (let cf of this.cfRules[sheetId]) { + for (let range of cf.ranges) { + if (isInside(cellCol, cellRow, range.zone)) { + rules.push(cf); + } + } + } + return new Set(rules.map((rule) => { + return this.mapToConditionalFormat(sheetId, rule); + })); + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + mapToConditionalFormat(sheetId, cf) { + return { + ...cf, + ranges: cf.ranges.map((range) => { + return this.getters.getRangeString(range, sheetId); + }), + }; + } + mapToConditionalFormatInternal(sheet, cf) { + const conditionalFormat = { + ...cf, + ranges: cf.ranges.map((range) => { + return this.getters.getRangeFromSheetXC(sheet, range); + }), + }; + return conditionalFormat; + } + /** + * Add or replace a conditional format rule + */ + addConditionalFormatting(cf, sheet) { + const currentCF = this.cfRules[sheet].slice(); + const replaceIndex = currentCF.findIndex((c) => c.id === cf.id); + const newCF = this.mapToConditionalFormatInternal(sheet, cf); + if (replaceIndex > -1) { + currentCF.splice(replaceIndex, 1, newCF); + } + else { + currentCF.push(newCF); + } + this.history.update("cfRules", sheet, currentCF); + } + checkValidReordering(cfId, direction, sheetId) { + if (!this.cfRules[sheetId]) + return 25 /* InvalidSheetId */; + const ruleIndex = this.cfRules[sheetId].findIndex((cf) => cf.id === cfId); + if (ruleIndex === -1) + return 68 /* InvalidConditionalFormatId */; + const cfIndex2 = direction === "up" ? ruleIndex - 1 : ruleIndex + 1; + if (cfIndex2 < 0 || cfIndex2 >= this.cfRules[sheetId].length) { + return 68 /* InvalidConditionalFormatId */; + } + return 0 /* Success */; + } + checkEmptyRange(cmd) { + return cmd.ranges.length ? 0 /* Success */ : 22 /* EmptyRange */; + } + checkCFRule(cmd) { + const rule = cmd.cf.rule; + switch (rule.type) { + case "CellIsRule": + return this.checkValidations(rule, this.checkOperatorArgsNumber(2, ["Between", "NotBetween"]), this.checkOperatorArgsNumber(1, [ + "BeginsWith", + "ContainsText", + "EndsWith", + "GreaterThan", + "GreaterThanOrEqual", + "LessThan", + "LessThanOrEqual", + "NotContains", + ]), this.checkOperatorArgsNumber(0, ["IsEmpty", "IsNotEmpty"])); + case "ColorScaleRule": { + return this.checkValidations(rule, this.chainValidations(this.checkThresholds(this.checkFormulaCompilation)), this.chainValidations(this.checkThresholds(this.checkNaN), this.batchValidations(this.checkMinBiggerThanMax, this.checkMinBiggerThanMid, this.checkMidBiggerThanMax + // Those three validations can be factorized further + ))); + } + case "IconSetRule": { + return this.checkValidations(rule, this.chainValidations(this.checkInflectionPoints(this.checkNaN), this.checkLowerBiggerThanUpper), this.chainValidations(this.checkInflectionPoints(this.checkFormulaCompilation))); + } + } + return 0 /* Success */; + } + checkOperatorArgsNumber(expectedNumber, operators) { + if (expectedNumber > 2) { + throw new Error("Checking more than 2 arguments is currently not supported. Add the appropriate CommandResult if you want to."); + } + return (rule) => { + if (operators.includes(rule.operator)) { + const errors = []; + const isEmpty = (value) => value === undefined || value === ""; + if (expectedNumber >= 1 && isEmpty(rule.values[0])) { + errors.push(48 /* FirstArgMissing */); + } + if (expectedNumber >= 2 && isEmpty(rule.values[1])) { + errors.push(49 /* SecondArgMissing */); + } + return errors.length ? errors : 0 /* Success */; + } + return 0 /* Success */; + }; + } + checkNaN(threshold, thresholdName) { + if (["number", "percentage", "percentile"].includes(threshold.type) && + (threshold.value === "" || isNaN(threshold.value))) { + switch (thresholdName) { + case "min": + return 50 /* MinNaN */; + case "max": + return 52 /* MaxNaN */; + case "mid": + return 51 /* MidNaN */; + case "upperInflectionPoint": + return 53 /* ValueUpperInflectionNaN */; + case "lowerInflectionPoint": + return 54 /* ValueLowerInflectionNaN */; + } + } + return 0 /* Success */; + } + checkFormulaCompilation(threshold, thresholdName) { + if (threshold.type !== "formula") + return 0 /* Success */; + try { + compile(threshold.value || ""); + } + catch (error) { + switch (thresholdName) { + case "min": + return 55 /* MinInvalidFormula */; + case "max": + return 57 /* MaxInvalidFormula */; + case "mid": + return 56 /* MidInvalidFormula */; + case "upperInflectionPoint": + return 58 /* ValueUpperInvalidFormula */; + case "lowerInflectionPoint": + return 59 /* ValueLowerInvalidFormula */; + } + } + return 0 /* Success */; + } + checkThresholds(check) { + return this.batchValidations((rule) => check(rule.minimum, "min"), (rule) => check(rule.maximum, "max"), (rule) => (rule.midpoint ? check(rule.midpoint, "mid") : 0 /* Success */)); + } + checkInflectionPoints(check) { + return this.batchValidations((rule) => check(rule.lowerInflectionPoint, "lowerInflectionPoint"), (rule) => check(rule.upperInflectionPoint, "upperInflectionPoint")); + } + checkLowerBiggerThanUpper(rule) { + const minValue = rule.lowerInflectionPoint.value; + const maxValue = rule.upperInflectionPoint.value; + if (["number", "percentage", "percentile"].includes(rule.lowerInflectionPoint.type) && + rule.lowerInflectionPoint.type === rule.upperInflectionPoint.type && + Number(minValue) > Number(maxValue)) { + return 45 /* LowerBiggerThanUpper */; + } + return 0 /* Success */; + } + checkMinBiggerThanMax(rule) { + const minValue = rule.minimum.value; + const maxValue = rule.maximum.value; + if (["number", "percentage", "percentile"].includes(rule.minimum.type) && + rule.minimum.type === rule.maximum.type && + stringToNumber(minValue) >= stringToNumber(maxValue)) { + return 44 /* MinBiggerThanMax */; + } + return 0 /* Success */; + } + checkMidBiggerThanMax(rule) { + var _a; + const midValue = (_a = rule.midpoint) === null || _a === void 0 ? void 0 : _a.value; + const maxValue = rule.maximum.value; + if (rule.midpoint && + ["number", "percentage", "percentile"].includes(rule.midpoint.type) && + rule.midpoint.type === rule.maximum.type && + stringToNumber(midValue) >= stringToNumber(maxValue)) { + return 46 /* MidBiggerThanMax */; + } + return 0 /* Success */; + } + checkMinBiggerThanMid(rule) { + var _a; + const minValue = rule.minimum.value; + const midValue = (_a = rule.midpoint) === null || _a === void 0 ? void 0 : _a.value; + if (rule.midpoint && + ["number", "percentage", "percentile"].includes(rule.midpoint.type) && + rule.minimum.type === rule.midpoint.type && + stringToNumber(minValue) >= stringToNumber(midValue)) { + return 47 /* MinBiggerThanMid */; + } + return 0 /* Success */; + } + removeConditionalFormatting(id, sheet) { + const cfIndex = this.cfRules[sheet].findIndex((s) => s.id === id); + if (cfIndex !== -1) { + const currentCF = this.cfRules[sheet].slice(); + currentCF.splice(cfIndex, 1); + this.history.update("cfRules", sheet, currentCF); + } + } + reorderConditionalFormatting(cfId, direction, sheetId) { + const cfIndex1 = this.cfRules[sheetId].findIndex((s) => s.id === cfId); + const cfIndex2 = direction === "up" ? cfIndex1 - 1 : cfIndex1 + 1; + if (cfIndex2 < 0 || cfIndex2 >= this.cfRules[sheetId].length) + return; + if (cfIndex1 !== -1 && cfIndex2 !== -1) { + const currentCF = [...this.cfRules[sheetId]]; + const tmp = currentCF[cfIndex1]; + currentCF[cfIndex1] = currentCF[cfIndex2]; + currentCF[cfIndex2] = tmp; + this.history.update("cfRules", sheetId, currentCF); + } + } + } + ConditionalFormatPlugin.getters = ["getConditionalFormats", "getRulesSelection", "getRulesByCell"]; + + class FigurePlugin extends CorePlugin { + constructor() { + super(...arguments); + this.figures = {}; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "UPDATE_FIGURE": + case "DELETE_FIGURE": + return this.checkFigureExists(cmd.sheetId, cmd.id); + default: + return 0 /* Success */; + } + } + handle(cmd) { + switch (cmd.type) { + case "CREATE_SHEET": + this.figures[cmd.sheetId] = {}; + break; + case "DELETE_SHEET": + this.deleteSheet(cmd.sheetId); + break; + case "CREATE_FIGURE": + this.addFigure(cmd.figure, cmd.sheetId); + break; + case "UPDATE_FIGURE": + const { type, sheetId, ...update } = cmd; + const figure = update; + this.updateFigure(sheetId, figure); + break; + case "DELETE_FIGURE": + this.removeFigure(cmd.id, cmd.sheetId); + break; + } + } + updateFigure(sheetId, figure) { + if (!("id" in figure)) { + return; + } + for (const [key, value] of Object.entries(figure)) { + switch (key) { + case "x": + case "y": + if (value !== undefined) { + this.history.update("figures", sheetId, figure.id, key, Math.max(value, 0)); + } + break; + case "width": + case "height": + if (value !== undefined) { + this.history.update("figures", sheetId, figure.id, key, value); + } + break; + } + } + } + addFigure(figure, sheetId) { + this.history.update("figures", sheetId, figure.id, figure); + } + deleteSheet(sheetId) { + this.history.update("figures", sheetId, undefined); + } + removeFigure(id, sheetId) { + this.history.update("figures", sheetId, id, undefined); + } + checkFigureExists(sheetId, figureId) { + var _a; + if (((_a = this.figures[sheetId]) === null || _a === void 0 ? void 0 : _a[figureId]) === undefined) { + return 67 /* FigureDoesNotExist */; + } + return 0 /* Success */; + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getFigures(sheetId) { + return Object.values(this.figures[sheetId] || {}).filter(isDefined$1); + } + getFigure(sheetId, figureId) { + var _a; + return (_a = this.figures[sheetId]) === null || _a === void 0 ? void 0 : _a[figureId]; + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { + for (let sheet of data.sheets) { + const figures = {}; + sheet.figures.forEach((figure) => { + figures[figure.id] = figure; + }); + this.figures[sheet.id] = figures; + } + } + export(data) { + for (const sheet of data.sheets) { + for (const figure of this.getFigures(sheet.id)) { + const data = undefined; + sheet.figures.push({ ...figure, data }); + } + } + } + exportForExcel(data) { + this.export(data); + } + } + FigurePlugin.getters = ["getFigures", "getFigure"]; + + class HeaderSizePlugin extends CorePlugin { + constructor() { + super(...arguments); + this.sizes = {}; + } + handle(cmd) { + var _a, _b, _c; + switch (cmd.type) { + case "CREATE_SHEET": { + const computedSizes = this.computeSheetSizes(cmd.sheetId); + const sizes = { + COL: computedSizes.COL.map((size) => ({ + manualSize: undefined, + computedSize: lazy(() => size), + })), + ROW: computedSizes.ROW.map((size) => ({ + manualSize: undefined, + computedSize: lazy(() => size), + })), + }; + this.history.update("sizes", cmd.sheetId, sizes); + break; + } + case "DUPLICATE_SHEET": + this.history.update("sizes", cmd.sheetIdTo, deepCopy(this.sizes[cmd.sheetId])); + break; + case "DELETE_SHEET": + const sizes = { ...this.sizes }; + delete sizes[cmd.sheetId]; + this.history.update("sizes", sizes); + break; + case "REMOVE_COLUMNS_ROWS": { + let sizes = [...this.sizes[cmd.sheetId][cmd.dimension]]; + for (let headerIndex of [...cmd.elements].sort((a, b) => b - a)) { + sizes.splice(headerIndex, 1); + } + const min = Math.min(...cmd.elements); + sizes = sizes.map((size, row) => { + if (cmd.dimension === "ROW" && row >= min) { + // invalidate sizes + return { + manualSize: size.manualSize, + computedSize: lazy(() => this.getRowTallestCellSize(cmd.sheetId, row)), + }; + } + return size; + }); + this.history.update("sizes", cmd.sheetId, cmd.dimension, sizes); + break; + } + case "ADD_COLUMNS_ROWS": { + let sizes = [...this.sizes[cmd.sheetId][cmd.dimension]]; + const addIndex = getAddHeaderStartIndex(cmd.position, cmd.base); + const baseSize = sizes[cmd.base]; + sizes.splice(addIndex, 0, ...Array(cmd.quantity).fill(baseSize)); + sizes = sizes.map((size, row) => { + if (cmd.dimension === "ROW" && row > cmd.base + cmd.quantity) { + // invalidate sizes + return { + manualSize: size.manualSize, + computedSize: lazy(() => this.getRowTallestCellSize(cmd.sheetId, row)), + }; + } + return size; + }); + this.history.update("sizes", cmd.sheetId, cmd.dimension, sizes); + break; + } + case "RESIZE_COLUMNS_ROWS": + for (let el of cmd.elements) { + if (cmd.dimension === "ROW") { + const height = this.getRowTallestCellSize(cmd.sheetId, el); + const size = height; + this.history.update("sizes", cmd.sheetId, cmd.dimension, el, { + manualSize: cmd.size || undefined, + computedSize: lazy(() => size), + }); + } + else { + this.history.update("sizes", cmd.sheetId, cmd.dimension, el, { + manualSize: cmd.size || undefined, + computedSize: lazy(() => cmd.size || DEFAULT_CELL_WIDTH), + }); + } + } + break; + case "UPDATE_CELL": + if (!((_c = (_b = (_a = this.sizes[cmd.sheetId]) === null || _a === void 0 ? void 0 : _a["ROW"]) === null || _b === void 0 ? void 0 : _b[cmd.row]) === null || _c === void 0 ? void 0 : _c.manualSize)) { + const { sheetId, row } = cmd; + this.history.update("sizes", sheetId, "ROW", row, "computedSize", lazy(() => this.getRowTallestCellSize(sheetId, row))); + } + break; + case "ADD_MERGE": + case "REMOVE_MERGE": + for (let target of cmd.target) { + for (let row of range(target.top, target.bottom + 1)) { + const rowHeight = this.getRowTallestCellSize(cmd.sheetId, row); + if (rowHeight !== this.getRowSize(cmd.sheetId, row)) { + this.history.update("sizes", cmd.sheetId, "ROW", row, "computedSize", lazy(() => rowHeight)); + } + } + } + break; + } + return; + } + getColSize(sheetId, index) { + return this.getHeaderSize(sheetId, "COL", index); + } + getRowSize(sheetId, index) { + return this.getHeaderSize(sheetId, "ROW", index); + } + getHeaderSize(sheetId, dimension, index) { + var _a, _b, _c, _d; + return (((_b = (_a = this.sizes[sheetId]) === null || _a === void 0 ? void 0 : _a[dimension][index]) === null || _b === void 0 ? void 0 : _b.manualSize) || + ((_d = (_c = this.sizes[sheetId]) === null || _c === void 0 ? void 0 : _c[dimension][index]) === null || _d === void 0 ? void 0 : _d.computedSize()) || + this.getDefaultHeaderSize(dimension)); + } + computeSheetSizes(sheetId) { + var _a, _b; + const sizes = { COL: [], ROW: [] }; + for (let col of range(0, this.getters.getNumberCols(sheetId))) { + sizes.COL.push(this.getHeaderSize(sheetId, "COL", col)); + } + for (let row of range(0, this.getters.getNumberRows(sheetId))) { + let rowSize = (_b = (_a = this.sizes[sheetId]) === null || _a === void 0 ? void 0 : _a["ROW"]) === null || _b === void 0 ? void 0 : _b[row].manualSize; + if (!rowSize) { + const height = this.getRowTallestCellSize(sheetId, row); + rowSize = height; + } + sizes.ROW.push(rowSize); + } + return sizes; + } + getDefaultHeaderSize(dimension) { + return dimension === "COL" ? DEFAULT_CELL_WIDTH : DEFAULT_CELL_HEIGHT; + } + /** + * Return the height the cell should have in the sheet, which is either DEFAULT_CELL_HEIGHT if the cell is in a multi-row + * merge, or the height of the cell computed based on its font size. + */ + getCellHeight(sheetId, col, row) { + const merge = this.getters.getMerge(sheetId, col, row); + if (merge && merge.bottom !== merge.top) { + return DEFAULT_CELL_HEIGHT; + } + const cell = this.getters.getCell(sheetId, col, row); + return getDefaultCellHeight(cell === null || cell === void 0 ? void 0 : cell.style); + } + /** + * Get the tallest cell of a row and its size. + * + * The tallest cell of the row correspond to the cell with the biggest font size, + * and that is not part of a multi-line merge. + */ + getRowTallestCellSize(sheetId, row) { + const cellIds = this.getters.getRowCells(sheetId, row); + let maxHeight = 0; + for (let i = 0; i < cellIds.length; i++) { + const cell = this.getters.getCellById(cellIds[i]); + if (!cell) + continue; + const { col, row } = this.getters.getCellPosition(cell.id); + const cellHeight = this.getCellHeight(sheetId, col, row); + if (cellHeight > maxHeight && cellHeight > DEFAULT_CELL_HEIGHT) { + maxHeight = cellHeight; + } + } + if (maxHeight <= DEFAULT_CELL_HEIGHT) { + return DEFAULT_CELL_HEIGHT; + } + return maxHeight; + } + import(data) { + for (let sheet of data.sheets) { + const manualSizes = { COL: [], ROW: [] }; + for (let [rowIndex, row] of Object.entries(sheet.rows)) { + if (row.size) { + manualSizes["ROW"][rowIndex] = row.size; + } + } + for (let [colIndex, col] of Object.entries(sheet.cols)) { + if (col.size) { + manualSizes["COL"][colIndex] = col.size; + } + } + const computedSizes = this.computeSheetSizes(sheet.id); + this.sizes[sheet.id] = { + COL: computedSizes.COL.map((size, i) => ({ + manualSize: manualSizes.COL[i], + computedSize: lazy(() => size), + })), + ROW: computedSizes.ROW.map((size, i) => ({ + manualSize: manualSizes.ROW[i], + computedSize: lazy(() => size), + })), + }; + } + return; + } + exportForExcel(data) { + this.exportData(data, true); + } + export(data) { + this.exportData(data); + } + /** + * Export the header sizes + * + * @param exportDefaults : if true, export column/row sizes even if they have the default size + */ + exportData(data, exportDefaults = false) { + var _a, _b; + for (let sheet of data.sheets) { + // Export row sizes + if (sheet.rows === undefined) { + sheet.rows = {}; + } + for (let row of range(0, this.getters.getNumberRows(sheet.id))) { + if (exportDefaults || ((_a = this.sizes[sheet.id]["ROW"][row]) === null || _a === void 0 ? void 0 : _a.manualSize)) { + sheet.rows[row] = { ...sheet.rows[row], size: this.getRowSize(sheet.id, row) }; + } + } + // Export col sizes + if (sheet.cols === undefined) { + sheet.cols = {}; + } + for (let col of range(0, this.getters.getNumberCols(sheet.id))) { + if (exportDefaults || ((_b = this.sizes[sheet.id]["COL"][col]) === null || _b === void 0 ? void 0 : _b.manualSize)) { + sheet.cols[col] = { ...sheet.cols[col], size: this.getColSize(sheet.id, col) }; + } + } + } + } + } + HeaderSizePlugin.getters = ["getRowSize", "getColSize"]; + + class HeaderVisibilityPlugin extends CorePlugin { + constructor() { + super(...arguments); + this.hiddenHeaders = {}; + } + allowDispatch(cmd) { + switch (cmd.type) { + case "HIDE_COLUMNS_ROWS": { + if (!this.hiddenHeaders[cmd.sheetId]) { + return 25 /* InvalidSheetId */; + } + const hiddenGroup = cmd.dimension === "COL" + ? this.getHiddenColsGroups(cmd.sheetId) + : this.getHiddenRowsGroups(cmd.sheetId); + const elements = cmd.dimension === "COL" + ? this.getters.getNumberCols(cmd.sheetId) + : this.getters.getNumberRows(cmd.sheetId); + return (hiddenGroup || []).flat().concat(cmd.elements).length < elements + ? 0 /* Success */ + : 63 /* TooManyHiddenElements */; + } + } + return 0 /* Success */; + } + handle(cmd) { + switch (cmd.type) { + case "CREATE_SHEET": + const hiddenHeaders = { + COL: Array(this.getters.getNumberCols(cmd.sheetId)).fill(false), + ROW: Array(this.getters.getNumberRows(cmd.sheetId)).fill(false), + }; + this.history.update("hiddenHeaders", cmd.sheetId, hiddenHeaders); + break; + case "DUPLICATE_SHEET": + this.history.update("hiddenHeaders", cmd.sheetIdTo, deepCopy(this.hiddenHeaders[cmd.sheetId])); + break; + case "DELETE_SHEET": + this.history.update("hiddenHeaders", cmd.sheetId, undefined); + break; + case "REMOVE_COLUMNS_ROWS": { + const hiddenHeaders = [...this.hiddenHeaders[cmd.sheetId][cmd.dimension]]; + for (let el of [...cmd.elements].sort((a, b) => b - a)) { + hiddenHeaders.splice(el, 1); + } + this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, hiddenHeaders); + break; + } + case "ADD_COLUMNS_ROWS": { + const hiddenHeaders = [...this.hiddenHeaders[cmd.sheetId][cmd.dimension]]; + const addIndex = getAddHeaderStartIndex(cmd.position, cmd.base); + hiddenHeaders.splice(addIndex, 0, ...Array(cmd.quantity).fill(false)); + this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, hiddenHeaders); + break; + } + case "HIDE_COLUMNS_ROWS": + for (let el of cmd.elements) { + this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, el, true); + } + break; + case "UNHIDE_COLUMNS_ROWS": + for (let el of cmd.elements) { + this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, el, false); + } + break; + } + return; + } + isRowHidden(sheetId, index) { + return this.hiddenHeaders[sheetId].ROW[index]; + } + isColHidden(sheetId, index) { + return this.hiddenHeaders[sheetId].COL[index]; + } + isHeaderHidden(sheetId, dimension, index) { + return dimension === "COL" + ? this.isColHidden(sheetId, index) + : this.isRowHidden(sheetId, index); + } + getHiddenColsGroups(sheetId) { + const consecutiveIndexes = [[]]; + const hiddenCols = this.hiddenHeaders[sheetId].COL; + for (let col = 0; col < hiddenCols.length; col++) { + const isColHidden = hiddenCols[col]; + if (isColHidden) { + consecutiveIndexes[consecutiveIndexes.length - 1].push(col); + } + else { + if (consecutiveIndexes[consecutiveIndexes.length - 1].length !== 0) { + consecutiveIndexes.push([]); + } + } + } + if (consecutiveIndexes[consecutiveIndexes.length - 1].length === 0) { + consecutiveIndexes.pop(); + } + return consecutiveIndexes; + } + getHiddenRowsGroups(sheetId) { + const consecutiveIndexes = [[]]; + const hiddenCols = this.hiddenHeaders[sheetId].ROW; + for (let row = 0; row < hiddenCols.length; row++) { + const isRowHidden = hiddenCols[row]; + if (isRowHidden) { + consecutiveIndexes[consecutiveIndexes.length - 1].push(row); + } + else { + if (consecutiveIndexes[consecutiveIndexes.length - 1].length !== 0) { + consecutiveIndexes.push([]); + } + } + } + if (consecutiveIndexes[consecutiveIndexes.length - 1].length === 0) { + consecutiveIndexes.pop(); + } + return consecutiveIndexes; + } + getNextVisibleCellPosition(sheetId, col, row) { + return { + col: this.findVisibleHeader(sheetId, "COL", range(col, this.getters.getNumberCols(sheetId))), + row: this.findVisibleHeader(sheetId, "ROW", range(row, this.getters.getNumberRows(sheetId))), + }; + } + findVisibleHeader(sheetId, dimension, indexes) { + return indexes.find((index) => this.getters.doesHeaderExist(sheetId, dimension, index) && + !this.isHeaderHidden(sheetId, dimension, index)); + } + findLastVisibleColRowIndex(sheetId, dimension) { + let lastIndex; + for (lastIndex = this.getters.getNumberHeaders(sheetId, dimension) - 1; lastIndex >= 0; lastIndex--) { + if (!this.isHeaderHidden(sheetId, dimension, lastIndex)) { + return lastIndex; + } + } + return lastIndex; + } + findFirstVisibleColRowIndex(sheetId, dimension) { + const numberOfHeaders = this.getters.getNumberHeaders(sheetId, dimension); + for (let i = 0; i < numberOfHeaders - 1; i++) { + if (dimension === "COL" && !this.isColHidden(sheetId, i)) { + return i; + } + if (dimension === "ROW" && !this.isRowHidden(sheetId, i)) { + return i; + } + } + return undefined; + } + import(data) { + var _a, _b; + for (let sheet of data.sheets) { + this.hiddenHeaders[sheet.id] = { COL: [], ROW: [] }; + for (let row = 0; row < sheet.rowNumber; row++) { + this.hiddenHeaders[sheet.id].ROW[row] = Boolean((_a = sheet.rows[row]) === null || _a === void 0 ? void 0 : _a.isHidden); + } + for (let col = 0; col < sheet.colNumber; col++) { + this.hiddenHeaders[sheet.id].COL[col] = Boolean((_b = sheet.cols[col]) === null || _b === void 0 ? void 0 : _b.isHidden); + } + } + return; + } + exportForExcel(data) { + this.exportData(data, true); + } + export(data) { + this.exportData(data); + } + exportData(data, exportDefaults = false) { + for (let sheet of data.sheets) { + if (sheet.rows === undefined) { + sheet.rows = {}; + } + for (let row = 0; row < this.getters.getNumberRows(sheet.id); row++) { + if (exportDefaults || this.hiddenHeaders[sheet.id]["ROW"][row]) { + if (sheet.rows[row] === undefined) { + sheet.rows[row] = {}; + } + sheet.rows[row].isHidden = this.hiddenHeaders[sheet.id]["ROW"][row]; + } + } + if (sheet.cols === undefined) { + sheet.cols = {}; + } + for (let col = 0; col < this.getters.getNumberCols(sheet.id); col++) { + if (exportDefaults || this.hiddenHeaders[sheet.id]["COL"][col]) { + if (sheet.cols[col] === undefined) { + sheet.cols[col] = {}; + } + sheet.cols[col].isHidden = this.hiddenHeaders[sheet.id]["COL"][col]; + } + } + } + } + } + HeaderVisibilityPlugin.getters = [ + "findFirstVisibleColRowIndex", + "findLastVisibleColRowIndex", + "findVisibleHeader", + "getHiddenColsGroups", + "getHiddenRowsGroups", + "getNextVisibleCellPosition", + "isRowHidden", + "isColHidden", + "isHeaderHidden", + ]; + + class MergePlugin extends CorePlugin { + constructor() { + super(...arguments); + this.nextId = 1; + this.merges = {}; + this.mergeCellMap = {}; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + const force = "force" in cmd ? !!cmd.force : false; + switch (cmd.type) { + case "ADD_MERGE": + if (force) { + return 0 /* Success */; + } + return this.checkValidations(cmd, this.checkDestructiveMerge, this.checkOverlap); + case "UPDATE_CELL": + return this.checkMergedContentUpdate(cmd); + default: + return 0 /* Success */; + } + } + handle(cmd) { + switch (cmd.type) { + case "CREATE_SHEET": + this.history.update("merges", cmd.sheetId, {}); + this.history.update("mergeCellMap", cmd.sheetId, {}); + break; + case "DELETE_SHEET": + this.history.update("merges", cmd.sheetId, {}); + this.history.update("mergeCellMap", cmd.sheetId, {}); + break; + case "DUPLICATE_SHEET": + const merges = this.merges[cmd.sheetId]; + if (!merges) + break; + for (const range of Object.values(merges).filter(isDefined$1)) { + this.addMerge(cmd.sheetIdTo, range.zone); + } + break; + case "ADD_MERGE": + for (const zone of cmd.target) { + this.addMerge(cmd.sheetId, zone); + } + break; + case "REMOVE_MERGE": + for (const zone of cmd.target) { + this.removeMerge(cmd.sheetId, zone); + } + break; + } + } + adaptRanges(applyChange, sheetId) { + const sheetIds = sheetId ? [sheetId] : Object.keys(this.merges); + for (const sheetId of sheetIds) { + this.applyRangeChangeOnSheet(sheetId, applyChange); + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getMerges(sheetId) { + return Object.keys(this.merges[sheetId] || {}) + .map((mergeId) => this.getMergeById(sheetId, parseInt(mergeId, 10))) + .filter(isDefined$1); + } + getMerge(sheetId, col, row) { + var _a; + const sheetMap = this.mergeCellMap[sheetId]; + const mergeId = sheetMap ? col in sheetMap && ((_a = sheetMap[col]) === null || _a === void 0 ? void 0 : _a[row]) : undefined; + return mergeId ? this.getMergeById(sheetId, mergeId) : undefined; + } + /** + * Return true if the zone intersects an existing merge: + * if they have at least a common cell + */ + doesIntersectMerge(sheetId, zone) { + const { left, right, top, bottom } = zone; + for (let row = top; row <= bottom; row++) { + for (let col = left; col <= right; col++) { + if (this.getMerge(sheetId, col, row)) { + return true; + } + } + } + return false; + } + /** + * Returns true if two columns have at least one merge in common + */ + doesColumnsHaveCommonMerges(sheetId, colA, colB) { + const sheet = this.getters.getSheet(sheetId); + for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { + if (this.isInSameMerge(sheet.id, colA, row, colB, row)) { + return true; + } + } + return false; + } + /** + * Returns true if two rows have at least one merge in common + */ + doesRowsHaveCommonMerges(sheetId, rowA, rowB) { + const sheet = this.getters.getSheet(sheetId); + for (let col = 0; col <= this.getters.getNumberCols(sheetId); col++) { + if (this.isInSameMerge(sheet.id, col, rowA, col, rowB)) { + return true; + } + } + return false; + } + /** + * Add all necessary merge to the current selection to make it valid + */ + expandZone(sheetId, zone) { + let { left, right, top, bottom } = zone; + let result = { left, right, top, bottom }; + for (let id in this.merges[sheetId]) { + const merge = this.getMergeById(sheetId, parseInt(id)); + if (merge && overlap(merge, result)) { + result = union(merge, result); + } + } + return isEqual(result, zone) ? result : this.expandZone(sheetId, result); + } + isInSameMerge(sheetId, colA, rowA, colB, rowB) { + const mergeA = this.getMerge(sheetId, colA, rowA); + const mergeB = this.getMerge(sheetId, colB, rowB); + if (!mergeA || !mergeB) { + return false; + } + return isEqual(mergeA, mergeB); + } + isInMerge(sheetId, col, row) { + var _a; + const sheetMap = this.mergeCellMap[sheetId]; + return sheetMap ? col in sheetMap && Boolean((_a = sheetMap[col]) === null || _a === void 0 ? void 0 : _a[row]) : false; + } + getMainCellPosition(sheetId, col, row) { + if (!this.isInMerge(sheetId, col, row)) { + return { col, row }; + } + const mergeTopLeftPos = this.getMerge(sheetId, col, row).topLeft; + return { col: mergeTopLeftPos.col, row: mergeTopLeftPos.row }; + } + getBottomLeftCell(sheetId, col, row) { + if (!this.isInMerge(sheetId, col, row)) { + return { col, row }; + } + const { bottom, left } = this.getMerge(sheetId, col, row); + return { col: left, row: bottom }; + } + isMergeHidden(sheetId, merge) { + const hiddenColsGroups = this.getters.getHiddenColsGroups(sheetId); + const hiddenRowsGroups = this.getters.getHiddenRowsGroups(sheetId); + for (let group of hiddenColsGroups) { + if (merge.left >= group[0] && merge.right <= group[group.length - 1]) { + return true; + } + } + for (let group of hiddenRowsGroups) { + if (merge.top >= group[0] && merge.bottom <= group[group.length - 1]) { + return true; + } + } + return false; + } + /** + * Check if the zone represents a single cell or a single merge. + */ + isSingleCellOrMerge(sheetId, zone) { + const merge = this.getMerge(sheetId, zone.left, zone.top); + if (merge) { + return isEqual(zone, merge); + } + const { width, height } = zoneToDimension(zone); + return width === 1 && height === 1; + } + // --------------------------------------------------------------------------- + // Merges + // --------------------------------------------------------------------------- + /** + * Return true if the current selection requires losing state if it is merged. + * This happens when there is some textual content in other cells than the + * top left. + */ + isMergeDestructive(sheetId, zone) { + let { left, right, top, bottom } = zone; + right = clip(right, 0, this.getters.getNumberCols(sheetId) - 1); + bottom = clip(bottom, 0, this.getters.getNumberRows(sheetId) - 1); + for (let row = top; row <= bottom; row++) { + for (let col = left; col <= right; col++) { + if (col !== left || row !== top) { + const cell = this.getters.getCell(sheetId, col, row); + if (cell && !cell.isEmpty()) { + return true; + } + } + } + } + return false; + } + getMergeById(sheetId, mergeId) { + var _a; + const range = (_a = this.merges[sheetId]) === null || _a === void 0 ? void 0 : _a[mergeId]; + return range !== undefined ? rangeToMerge(mergeId, range) : undefined; + } + checkDestructiveMerge({ sheetId, target }) { + const sheet = this.getters.tryGetSheet(sheetId); + if (!sheet) + return 0 /* Success */; + const isDestructive = target.some((zone) => this.isMergeDestructive(sheetId, zone)); + return isDestructive ? 3 /* MergeIsDestructive */ : 0 /* Success */; + } + checkOverlap({ target }) { + for (const zone of target) { + for (const zone2 of target) { + if (zone !== zone2 && overlap(zone, zone2)) { + return 62 /* MergeOverlap */; + } + } + } + return 0 /* Success */; + } + /** + * The content of a merged cell should always be empty. + * Except for the top-left cell. + */ + checkMergedContentUpdate(cmd) { + const { col, row, sheetId, content } = cmd; + if (content === undefined) { + return 0 /* Success */; + } + const { col: mainCol, row: mainRow } = this.getMainCellPosition(sheetId, col, row); + if (mainCol === col && mainRow === row) { + return 0 /* Success */; + } + return 4 /* CellIsMerged */; + } + /** + * Merge the current selection. Note that: + * - it assumes that we have a valid selection (no intersection with other + * merges) + * - it does nothing if the merge is trivial: A1:A1 + */ + addMerge(sheetId, zone) { + let { left, right, top, bottom } = zone; + right = clip(right, 0, this.getters.getNumberCols(sheetId) - 1); + bottom = clip(bottom, 0, this.getters.getNumberRows(sheetId) - 1); + const tl = toXC(left, top); + const br = toXC(right, bottom); + if (tl === br) { + return; + } + const topLeft = this.getters.getCell(sheetId, left, top); + let id = this.nextId++; + this.history.update("merges", sheetId, id, this.getters.getRangeFromSheetXC(sheetId, zoneToXc({ left, top, right, bottom }))); + let previousMerges = new Set(); + for (let row = top; row <= bottom; row++) { + for (let col = left; col <= right; col++) { + if (col !== left || row !== top) { + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + style: topLeft ? topLeft.style : null, + content: "", + }); + } + const merge = this.getMerge(sheetId, col, row); + if (merge) { + previousMerges.add(merge.id); + } + this.history.update("mergeCellMap", sheetId, col, row, id); + } + } + for (let mergeId of previousMerges) { + const { top, bottom, left, right } = this.getMergeById(sheetId, mergeId); + for (let r = top; r <= bottom; r++) { + for (let c = left; c <= right; c++) { + const merge = this.getMerge(sheetId, c, r); + if (!merge || merge.id !== id) { + this.history.update("mergeCellMap", sheetId, c, r, undefined); + this.dispatch("CLEAR_CELL", { + sheetId, + col: c, + row: r, + }); + } + } + } + this.history.update("merges", sheetId, mergeId, undefined); + } + } + removeMerge(sheetId, zone) { + const { left, top, bottom, right } = zone; + const merge = this.getMerge(sheetId, left, top); + if (merge === undefined || !isEqual(zone, merge)) { + throw new Error(_lt("Invalid merge zone")); + } + this.history.update("merges", sheetId, merge.id, undefined); + for (let r = top; r <= bottom; r++) { + for (let c = left; c <= right; c++) { + this.history.update("mergeCellMap", sheetId, c, r, undefined); + } + } + } + /** + * Apply a range change on merges of a particular sheet. + */ + applyRangeChangeOnSheet(sheetId, applyChange) { + const merges = Object.entries(this.merges[sheetId] || {}); + for (const [mergeId, range] of merges) { + if (range) { + const currentZone = range.zone; + const result = applyChange(range); + switch (result.changeType) { + case "NONE": + break; + case "REMOVE": + this.removeMerge(sheetId, currentZone); + break; + default: + const { width, height } = zoneToDimension(result.range.zone); + if (width === 1 && height === 1) { + this.removeMerge(sheetId, currentZone); + } + else { + this.history.update("merges", sheetId, parseInt(mergeId, 10), result.range); + } + break; + } + } + } + this.history.update("mergeCellMap", sheetId, {}); + for (const merge of this.getMerges(sheetId)) { + for (const { col, row } of positions(merge)) { + this.history.update("mergeCellMap", sheetId, col, row, merge.id); + } + } + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { + const sheets = data.sheets || []; + for (let sheetData of sheets) { + this.history.update("merges", sheetData.id, {}); + this.history.update("mergeCellMap", sheetData.id, {}); + if (sheetData.merges) { + this.importMerges(sheetData.id, sheetData.merges); + } + } + } + importMerges(sheetId, merges) { + for (let merge of merges) { + this.addMerge(sheetId, toZone(merge)); + } + } + export(data) { + for (let sheetData of data.sheets) { + const merges = this.merges[sheetData.id]; + if (merges) { + sheetData.merges.push(...exportMerges(merges)); + } + } + } + exportForExcel(data) { + this.export(data); + } + } + MergePlugin.getters = [ + "isInMerge", + "isInSameMerge", + "isMergeHidden", + "getMainCellPosition", + "getBottomLeftCell", + "expandZone", + "doesIntersectMerge", + "doesColumnsHaveCommonMerges", + "doesRowsHaveCommonMerges", + "getMerges", + "getMerge", + "isSingleCellOrMerge", + ]; + function exportMerges(merges) { + return Object.entries(merges) + .map(([mergeId, range]) => (range ? rangeToMerge(parseInt(mergeId, 10), range) : undefined)) + .filter(isDefined$1) + .map((merge) => toXC(merge.left, merge.top) + ":" + toXC(merge.right, merge.bottom)); + } + function rangeToMerge(mergeId, range) { + return { + ...range.zone, + topLeft: { col: range.zone.left, row: range.zone.top }, + id: mergeId, + }; + } + + class SheetPlugin extends CorePlugin { + constructor() { + super(...arguments); + this.sheetIdsMapName = {}; + this.orderedSheetIds = []; + this.sheets = {}; + this.cellPosition = {}; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + const genericChecks = this.chainValidations(this.checkSheetExists, this.checkZones)(cmd); + if (genericChecks !== 0 /* Success */) { + return genericChecks; + } + switch (cmd.type) { + case "HIDE_SHEET": { + if (this.getVisibleSheetIds().length === 1) { + return 8 /* NotEnoughSheets */; + } + return 0 /* Success */; + } + case "CREATE_SHEET": { + return this.checkValidations(cmd, this.checkSheetName, this.checkSheetPosition); + } + case "MOVE_SHEET": + const currentIndex = this.orderedSheetIds.indexOf(cmd.sheetId); + if (cmd.direction === "left") { + const leftSheets = this.orderedSheetIds + .slice(0, currentIndex) + .map((id) => !this.isSheetVisible(id)); + return leftSheets.every((isHidden) => isHidden) + ? 12 /* WrongSheetMove */ + : 0 /* Success */; + } + else { + const rightSheets = this.orderedSheetIds + .slice(currentIndex + 1) + .map((id) => !this.isSheetVisible(id)); + return rightSheets.every((isHidden) => isHidden) + ? 12 /* WrongSheetMove */ + : 0 /* Success */; + } + case "RENAME_SHEET": + return this.isRenameAllowed(cmd); + case "DELETE_SHEET": + return this.orderedSheetIds.length > 1 + ? 0 /* Success */ + : 8 /* NotEnoughSheets */; + case "REMOVE_COLUMNS_ROWS": + const length = cmd.dimension === "COL" + ? this.getNumberCols(cmd.sheetId) + : this.getNumberRows(cmd.sheetId); + return length > cmd.elements.length + ? 0 /* Success */ + : 7 /* NotEnoughElements */; + default: + return 0 /* Success */; + } + } + handle(cmd) { + switch (cmd.type) { + case "SET_GRID_LINES_VISIBILITY": + this.setGridLinesVisibility(cmd.sheetId, cmd.areGridLinesVisible); + break; + case "DELETE_CONTENT": + this.clearZones(cmd.sheetId, cmd.target); + break; + case "CREATE_SHEET": + const sheet = this.createSheet(cmd.sheetId, cmd.name || this.getNextSheetName(), cmd.cols || 26, cmd.rows || 100, cmd.position); + this.history.update("sheetIdsMapName", sheet.name, sheet.id); + break; + case "MOVE_SHEET": + this.moveSheet(cmd.sheetId, cmd.direction); + break; + case "RENAME_SHEET": + this.renameSheet(this.sheets[cmd.sheetId], cmd.name); + break; + case "HIDE_SHEET": + this.hideSheet(cmd.sheetId); + break; + case "SHOW_SHEET": + this.showSheet(cmd.sheetId); + break; + case "DUPLICATE_SHEET": + this.duplicateSheet(cmd.sheetId, cmd.sheetIdTo); + break; + case "DELETE_SHEET": + this.deleteSheet(this.sheets[cmd.sheetId]); + break; + case "REMOVE_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.removeColumns(this.sheets[cmd.sheetId], [...cmd.elements]); + } + else { + this.removeRows(this.sheets[cmd.sheetId], [...cmd.elements]); + } + break; + case "ADD_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.addColumns(this.sheets[cmd.sheetId], cmd.base, cmd.position, cmd.quantity); + } + else { + this.addRows(this.sheets[cmd.sheetId], cmd.base, cmd.position, cmd.quantity); + } + break; + case "UPDATE_CELL_POSITION": + this.updateCellPosition(cmd); + break; + } + } + // --------------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------------- + import(data) { + // we need to fill the sheetIds mapping first, because otherwise formulas + // that depends on a sheet not already imported will not be able to be + // compiled + for (let sheet of data.sheets) { + this.sheetIdsMapName[sheet.name] = sheet.id; + } + for (let sheetData of data.sheets) { + const name = sheetData.name || _t("Sheet") + (Object.keys(this.sheets).length + 1); + const { colNumber, rowNumber } = this.getImportedSheetSize(sheetData); + const sheet = { + id: sheetData.id, + name: name, + numberOfCols: colNumber, + rows: createDefaultRows(rowNumber), + areGridLinesVisible: sheetData.areGridLinesVisible === undefined ? true : sheetData.areGridLinesVisible, + isVisible: sheetData.isVisible, + }; + this.orderedSheetIds.push(sheet.id); + this.sheets[sheet.id] = sheet; + } + } + exportSheets(data) { + data.sheets = this.orderedSheetIds.filter(isDefined$1).map((id) => { + const sheet = this.sheets[id]; + return { + id: sheet.id, + name: sheet.name, + colNumber: sheet.numberOfCols, + rowNumber: this.getters.getNumberRows(sheet.id), + rows: {}, + cols: {}, + merges: [], + cells: {}, + conditionalFormats: [], + figures: [], + areGridLinesVisible: sheet.areGridLinesVisible === undefined ? true : sheet.areGridLinesVisible, + isVisible: sheet.isVisible, + }; + }); + } + export(data) { + this.exportSheets(data); + } + exportForExcel(data) { + this.exportSheets(data); + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getGridLinesVisibility(sheetId) { + return this.getSheet(sheetId).areGridLinesVisible; + } + tryGetSheet(sheetId) { + return this.sheets[sheetId]; + } + getSheet(sheetId) { + const sheet = this.sheets[sheetId]; + if (!sheet) { + throw new Error(`Sheet ${sheetId} not found.`); + } + return sheet; + } + isSheetVisible(sheetId) { + return this.getSheet(sheetId).isVisible; + } + /** + * Return the sheet name. Throw if the sheet is not found. + */ + getSheetName(sheetId) { + return this.getSheet(sheetId).name; + } + getCellsInZone(sheetId, zone) { + return positions(zone).map(({ col, row }) => this.getCell(sheetId, col, row)); + } + /** + * Return the sheet name or undefined if the sheet doesn't exist. + */ + tryGetSheetName(sheetId) { + var _a; + return (_a = this.tryGetSheet(sheetId)) === null || _a === void 0 ? void 0 : _a.name; + } + getSheetIdByName(name) { + if (name) { + const unquotedName = getUnquotedSheetName(name); + for (const key in this.sheetIdsMapName) { + if (key.toUpperCase() === unquotedName.toUpperCase()) { + return this.sheetIdsMapName[key]; + } + } + } + return undefined; + } + getSheetIds() { + return this.orderedSheetIds; + } + getVisibleSheetIds() { + return this.orderedSheetIds.filter(this.isSheetVisible.bind(this)); + } + getEvaluationSheets() { + return this.sheets; + } + doesHeaderExist(sheetId, dimension, index) { + return dimension === "COL" + ? index >= 0 && index < this.getNumberCols(sheetId) + : index >= 0 && index < this.getNumberRows(sheetId); + } + getRow(sheetId, index) { + const row = this.getSheet(sheetId).rows[index]; + if (!row) { + throw new Error(`Row ${row} not found.`); + } + return row; + } + getCell(sheetId, col, row) { + var _a; + const sheet = this.tryGetSheet(sheetId); + const cellId = (_a = sheet === null || sheet === void 0 ? void 0 : sheet.rows[row]) === null || _a === void 0 ? void 0 : _a.cells[col]; + if (cellId === undefined) { + return undefined; + } + return this.getters.getCellById(cellId); + } + /** + * Returns all the cells of a col + */ + getColCells(sheetId, col) { + return this.getSheet(sheetId) + .rows.map((row) => row.cells[col]) + .filter(isDefined$1) + .map((cellId) => this.getters.getCellById(cellId)) + .filter(isDefined$1); + } + getColsZone(sheetId, start, end) { + return { + top: 0, + bottom: this.getNumberRows(sheetId) - 1, + left: start, + right: end, + }; + } + getRowCells(sheetId, row) { + var _a; + return Object.values((_a = this.getSheet(sheetId).rows[row]) === null || _a === void 0 ? void 0 : _a.cells).filter(isDefined$1); + } + getRowsZone(sheetId, start, end) { + return { + top: start, + bottom: end, + left: 0, + right: this.getSheet(sheetId).numberOfCols - 1, + }; + } + getCellPosition(cellId) { + const cell = this.cellPosition[cellId]; + if (!cell) { + throw new Error(`asking for a cell position that doesn't exist, cell id: ${cellId}`); + } + return cell; + } + getNumberCols(sheetId) { + return this.getSheet(sheetId).numberOfCols; + } + getNumberRows(sheetId) { + return this.getSheet(sheetId).rows.length; + } + getNumberHeaders(sheetId, dimension) { + return dimension === "COL" ? this.getNumberCols(sheetId) : this.getNumberRows(sheetId); + } + getNextSheetName(baseName = "Sheet") { + let i = 1; + const names = this.orderedSheetIds.map(this.getSheetName.bind(this)); + let name = `${baseName}${i}`; + while (names.includes(name)) { + name = `${baseName}${i}`; + i++; + } + return name; + } + getSheetSize(sheetId) { + return { + height: this.getNumberRows(sheetId), + width: this.getNumberCols(sheetId), + }; + } + getSheetZone(sheetId) { + return { + top: 0, + left: 0, + bottom: this.getNumberRows(sheetId) - 1, + right: this.getNumberCols(sheetId) - 1, + }; + } + // --------------------------------------------------------------------------- + // Row/Col manipulation + // --------------------------------------------------------------------------- + /** + * Check if a zone only contains empty cells + */ + isEmpty(sheetId, zone) { + return this.getCellsInZone(sheetId, zone) + .flat() + .every((cell) => !cell || cell.isEmpty()); + } + updateCellPosition(cmd) { + const { sheetId, cellId, col, row } = cmd; + if (cellId) { + this.setNewPosition(cellId, sheetId, col, row); + } + else { + this.clearPosition(sheetId, col, row); + } + } + /** + * Set the cell at a new position and clear its previous position. + */ + setNewPosition(cellId, sheetId, col, row) { + const currentPosition = this.cellPosition[cellId]; + if (currentPosition) { + this.clearPosition(sheetId, currentPosition.col, currentPosition.row); + } + this.history.update("cellPosition", cellId, { + row: row, + col: col, + sheetId: sheetId, + }); + this.history.update("sheets", sheetId, "rows", row, "cells", col, cellId); + } + /** + * Remove the cell at the given position (if there's one) + */ + clearPosition(sheetId, col, row) { + var _a; + const cellId = (_a = this.sheets[sheetId]) === null || _a === void 0 ? void 0 : _a.rows[row].cells[col]; + if (cellId) { + this.history.update("cellPosition", cellId, undefined); + this.history.update("sheets", sheetId, "rows", row, "cells", col, undefined); + } + } + setGridLinesVisibility(sheetId, areGridLinesVisible) { + this.history.update("sheets", sheetId, "areGridLinesVisible", areGridLinesVisible); + } + clearZones(sheetId, zones) { + for (let zone of zones) { + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + const cell = this.sheets[sheetId].rows[row].cells[col]; + if (cell) { + this.dispatch("UPDATE_CELL", { + sheetId: sheetId, + content: "", + col, + row, + }); + } + } + } + } + } + createSheet(id, name, colNumber, rowNumber, position) { + const sheet = { + id, + name, + numberOfCols: colNumber, + rows: createDefaultRows(rowNumber), + areGridLinesVisible: true, + isVisible: true, + }; + const orderedSheetIds = this.orderedSheetIds.slice(); + orderedSheetIds.splice(position, 0, sheet.id); + const sheets = this.sheets; + this.history.update("orderedSheetIds", orderedSheetIds); + this.history.update("sheets", Object.assign({}, sheets, { [sheet.id]: sheet })); + return sheet; + } + moveSheet(sheetId, direction) { + const orderedSheetIds = this.orderedSheetIds.slice(); + const currentIndex = orderedSheetIds.findIndex((id) => id === sheetId); + const sheet = orderedSheetIds.splice(currentIndex, 1); + let index = direction === "left" + ? this.findIndexOfPreviousVisibleSheet(currentIndex - 1, orderedSheetIds) + : this.findIndexOfNextVisibleSheet(currentIndex + 1, orderedSheetIds); + if (index === undefined) { + index = orderedSheetIds.length; + } + orderedSheetIds.splice(index, 0, sheet[0]); + this.history.update("orderedSheetIds", orderedSheetIds); + } + findIndexOfPreviousVisibleSheet(current, orderedSheetIds) { + while (current >= 0 && !this.isSheetVisible(orderedSheetIds[current])) { + current--; + } + if (current === -1) { + throw new Error("There is no previous visible sheet"); + } + return current; + } + findIndexOfNextVisibleSheet(current, orderedSheetIds) { + while (current < orderedSheetIds.length && !this.isSheetVisible(orderedSheetIds[current])) { + current++; + } + if (current === orderedSheetIds.length - 1 && + !this.isSheetVisible(orderedSheetIds[current - 1])) { + return undefined; + } + return current; + } + checkSheetName(cmd) { + const { orderedSheetIds, sheets } = this; + const name = cmd.name && cmd.name.trim().toLowerCase(); + if (orderedSheetIds.find((id) => { var _a; return ((_a = sheets[id]) === null || _a === void 0 ? void 0 : _a.name.toLowerCase()) === name; })) { + return 10 /* DuplicatedSheetName */; + } + if (FORBIDDEN_IN_EXCEL_REGEX.test(name)) { + return 11 /* ForbiddenCharactersInSheetName */; + } + return 0 /* Success */; + } + checkSheetPosition(cmd) { + const { orderedSheetIds } = this; + if (cmd.position > orderedSheetIds.length || cmd.position < 0) { + return 13 /* WrongSheetPosition */; + } + return 0 /* Success */; + } + isRenameAllowed(cmd) { + const name = cmd.name && cmd.name.trim().toLowerCase(); + if (!name) { + return 9 /* MissingSheetName */; + } + return this.checkSheetName(cmd); + } + renameSheet(sheet, name) { + const oldName = sheet.name; + this.history.update("sheets", sheet.id, "name", name.trim()); + const sheetIdsMapName = Object.assign({}, this.sheetIdsMapName); + sheetIdsMapName[name] = sheet.id; + delete sheetIdsMapName[oldName]; + this.history.update("sheetIdsMapName", sheetIdsMapName); + } + hideSheet(sheetId) { + this.history.update("sheets", sheetId, "isVisible", false); + } + showSheet(sheetId) { + this.history.update("sheets", sheetId, "isVisible", true); + } + duplicateSheet(fromId, toId) { + const sheet = this.getSheet(fromId); + const toName = this.getDuplicateSheetName(sheet.name); + const newSheet = JSON.parse(JSON.stringify(sheet)); + newSheet.id = toId; + newSheet.name = toName; + for (let col = 0; col <= newSheet.numberOfCols; col++) { + for (let row = 0; row <= newSheet.rows.length; row++) { + if (newSheet.rows[row]) { + newSheet.rows[row].cells[col] = undefined; + } + } + } + const orderedSheetIds = this.orderedSheetIds.slice(); + const currentIndex = orderedSheetIds.indexOf(fromId); + orderedSheetIds.splice(currentIndex + 1, 0, newSheet.id); + this.history.update("orderedSheetIds", orderedSheetIds); + this.history.update("sheets", Object.assign({}, this.sheets, { [newSheet.id]: newSheet })); + for (const cell of Object.values(this.getters.getCells(fromId))) { + const { col, row } = this.getCellPosition(cell.id); + this.dispatch("UPDATE_CELL", { + sheetId: newSheet.id, + col, + row, + content: cell.content, + format: cell.format, + style: cell.style, + }); + } + const sheetIdsMapName = Object.assign({}, this.sheetIdsMapName); + sheetIdsMapName[newSheet.name] = newSheet.id; + this.history.update("sheetIdsMapName", sheetIdsMapName); + } + getDuplicateSheetName(sheetName) { + let i = 1; + const names = this.orderedSheetIds.map(this.getSheetName.bind(this)); + const baseName = _lt("Copy of %s", sheetName); + let name = baseName.toString(); + while (names.includes(name)) { + name = `${baseName} (${i})`; + i++; + } + return name; + } + deleteSheet(sheet) { + const name = sheet.name; + const sheets = Object.assign({}, this.sheets); + delete sheets[sheet.id]; + this.history.update("sheets", sheets); + const orderedSheetIds = this.orderedSheetIds.slice(); + const currentIndex = orderedSheetIds.indexOf(sheet.id); + orderedSheetIds.splice(currentIndex, 1); + this.history.update("orderedSheetIds", orderedSheetIds); + const sheetIdsMapName = Object.assign({}, this.sheetIdsMapName); + delete sheetIdsMapName[name]; + this.history.update("sheetIdsMapName", sheetIdsMapName); + } + /** + * Delete column. This requires a lot of handling: + * - Update all the formulas in all sheets + * - Move the cells + * - Update the cols/rows (size, number, (cells), ...) + * - Reevaluate the cells + * + * @param sheet ID of the sheet on which deletion should be applied + * @param columns Columns to delete + */ + removeColumns(sheet, columns) { + // This is necessary because we have to delete elements in correct order: + // begin with the end. + columns.sort((a, b) => b - a); + for (let column of columns) { + // Move the cells. + this.moveCellOnColumnsDeletion(sheet, column); + } + const numberOfCols = this.sheets[sheet.id].numberOfCols; + this.history.update("sheets", sheet.id, "numberOfCols", numberOfCols - columns.length); + } + /** + * Delete row. This requires a lot of handling: + * - Update the merges + * - Update all the formulas in all sheets + * - Move the cells + * - Update the cols/rows (size, number, (cells), ...) + * - Reevaluate the cells + * + * @param sheet ID of the sheet on which deletion should be applied + * @param rows Rows to delete + */ + removeRows(sheet, rows) { + // This is necessary because we have to delete elements in correct order: + // begin with the end. + rows.sort((a, b) => b - a); + for (let group of groupConsecutive(rows)) { + // Move the cells. + this.moveCellOnRowsDeletion(sheet, group[group.length - 1], group[0]); + // Effectively delete the element and recompute the left-right/top-bottom. + group.map((row) => this.updateRowsStructureOnDeletion(row, sheet)); + } + } + addColumns(sheet, column, position, quantity) { + // Move the cells. + this.moveCellsOnAddition(sheet, position === "before" ? column : column + 1, quantity, "columns"); + const numberOfCols = this.sheets[sheet.id].numberOfCols; + this.history.update("sheets", sheet.id, "numberOfCols", numberOfCols + quantity); + } + addRows(sheet, row, position, quantity) { + this.addEmptyRows(sheet, quantity); + // Move the cells. + this.moveCellsOnAddition(sheet, position === "before" ? row : row + 1, quantity, "rows"); + // Recompute the left-right/top-bottom. + this.updateRowsStructureOnAddition(sheet, row, quantity); + } + moveCellOnColumnsDeletion(sheet, deletedColumn) { + for (let [index, row] of Object.entries(sheet.rows)) { + const rowIndex = parseInt(index, 10); + for (let i in row.cells) { + const colIndex = parseInt(i, 10); + const cellId = row.cells[i]; + if (cellId) { + if (colIndex === deletedColumn) { + this.dispatch("CLEAR_CELL", { + sheetId: sheet.id, + col: colIndex, + row: rowIndex, + }); + } + if (colIndex > deletedColumn) { + this.dispatch("UPDATE_CELL_POSITION", { + sheetId: sheet.id, + cellId: cellId, + col: colIndex - 1, + row: rowIndex, + }); + } + } + } + } + } + /** + * Move the cells after a column or rows insertion + */ + moveCellsOnAddition(sheet, addedElement, quantity, dimension) { + const commands = []; + for (const [index, row] of Object.entries(sheet.rows)) { + const rowIndex = parseInt(index, 10); + if (dimension !== "rows" || rowIndex >= addedElement) { + for (let i in row.cells) { + const colIndex = parseInt(i, 10); + const cellId = row.cells[i]; + if (cellId) { + if (dimension === "rows" || colIndex >= addedElement) { + commands.push({ + type: "UPDATE_CELL_POSITION", + sheetId: sheet.id, + cellId: cellId, + col: colIndex + (dimension === "columns" ? quantity : 0), + row: rowIndex + (dimension === "rows" ? quantity : 0), + }); + } + } + } + } + } + for (let cmd of commands.reverse()) { + this.dispatch(cmd.type, cmd); + } + } + /** + * Move all the cells that are from the row under `deleteToRow` up to `deleteFromRow` + * + * b.e. + * move vertically with delete from 3 and delete to 5 will first clear all the cells from lines 3 to 5, + * then take all the row starting at index 6 and add them back at index 3 + * + */ + moveCellOnRowsDeletion(sheet, deleteFromRow, deleteToRow) { + const numberRows = deleteToRow - deleteFromRow + 1; + for (let [index, row] of Object.entries(sheet.rows)) { + const rowIndex = parseInt(index, 10); + if (rowIndex >= deleteFromRow && rowIndex <= deleteToRow) { + for (let i in row.cells) { + const colIndex = parseInt(i, 10); + const cellId = row.cells[i]; + if (cellId) { + this.dispatch("CLEAR_CELL", { + sheetId: sheet.id, + col: colIndex, + row: rowIndex, + }); + } + } + } + if (rowIndex > deleteToRow) { + for (let i in row.cells) { + const colIndex = parseInt(i, 10); + const cellId = row.cells[i]; + if (cellId) { + this.dispatch("UPDATE_CELL_POSITION", { + sheetId: sheet.id, + cellId: cellId, + col: colIndex, + row: rowIndex - numberRows, + }); + } + } + } + } + } + updateRowsStructureOnDeletion(index, sheet) { + const rows = []; + const cellsQueue = sheet.rows.map((row) => row.cells); + for (let i in sheet.rows) { + if (parseInt(i, 10) === index) { + continue; + } + rows.push({ + cells: cellsQueue.shift(), + }); + } + this.history.update("sheets", sheet.id, "rows", rows); + } + /** + * Update the rows of the sheet after an addition: + * - Rename the rows + * + * @param sheet Sheet on which the deletion occurs + * @param addedRow Index of the added row + * @param rowsToAdd Number of the rows to add + */ + updateRowsStructureOnAddition(sheet, addedRow, rowsToAdd) { + const rows = []; + const cellsQueue = sheet.rows.map((row) => row.cells); + sheet.rows.forEach(() => rows.push({ + cells: cellsQueue.shift(), + })); + this.history.update("sheets", sheet.id, "rows", rows); + } + /** + * Add empty rows at the end of the rows + * + * @param sheet Sheet + * @param quantity Number of rows to add + */ + addEmptyRows(sheet, quantity) { + const rows = sheet.rows.slice(); + for (let i = 0; i < quantity; i++) { + rows.push({ + cells: {}, + }); + } + this.history.update("sheets", sheet.id, "rows", rows); + } + getImportedSheetSize(data) { + const positions = Object.keys(data.cells).map(toCartesian); + let rowNumber = data.rowNumber; + let colNumber = data.colNumber; + for (let { col, row } of positions) { + rowNumber = Math.max(rowNumber, row + 1); + colNumber = Math.max(colNumber, col + 1); + } + return { rowNumber, colNumber }; + } + // ---------------------------------------------------- + // HIDE / SHOW + // ---------------------------------------------------- + /** + * Check that any "sheetId" in the command matches an existing + * sheet. + */ + checkSheetExists(cmd) { + if (cmd.type !== "CREATE_SHEET" && "sheetId" in cmd && this.sheets[cmd.sheetId] === undefined) { + return 25 /* InvalidSheetId */; + } + return 0 /* Success */; + } + /** + * Check if zones in the command are well formed and + * not outside the sheet. + */ + checkZones(cmd) { + const zones = []; + if ("zone" in cmd) { + zones.push(cmd.zone); + } + if ("target" in cmd && Array.isArray(cmd.target)) { + zones.push(...cmd.target); + } + if ("ranges" in cmd && Array.isArray(cmd.ranges)) { + zones.push(...cmd.ranges.map((rangeData) => this.getters.getRangeFromRangeData(rangeData).zone)); + } + if (!zones.every(isZoneValid)) { + return 23 /* InvalidRange */; + } + else if (zones.length && "sheetId" in cmd) { + const sheetZone = this.getSheetZone(cmd.sheetId); + return zones.every((zone) => isZoneInside(zone, sheetZone)) + ? 0 /* Success */ + : 16 /* TargetOutOfSheet */; + } + return 0 /* Success */; + } + } + SheetPlugin.getters = [ + "getSheetName", + "tryGetSheetName", + "getSheet", + "tryGetSheet", + "getSheetIdByName", + "getSheetIds", + "getVisibleSheetIds", + "isSheetVisible", + "getEvaluationSheets", + "doesHeaderExist", + "getCell", + "getCellsInZone", + "getCellPosition", + "getColCells", + "getColsZone", + "getRowCells", + "getRowsZone", + "getNumberCols", + "getNumberRows", + "getNumberHeaders", + "getGridLinesVisibility", + "getNextSheetName", + "isEmpty", + "getSheetSize", + "getSheetZone", + ]; + + /** + * This plugin manage the autofill. + * + * The way it works is the next one: + * For each line (row if the direction is left/right, col otherwise), we create + * a "AutofillGenerator" object which is used to compute the cells to + * autofill. + * + * When we need to autofill a cell, we compute the origin cell in the source. + * EX: from A1:A2, autofill A3->A6. + * Target | Origin cell + * A3 | A1 + * A4 | A2 + * A5 | A1 + * A6 | A2 + * When we have the origin, we take the associated cell in the AutofillGenerator + * and we apply the modifier (AutofillModifier) associated to the content of the + * cell. + */ + /** + * This class is used to generate the next values to autofill. + * It's done from a selection (the source) and describe how the next values + * should be computed. + */ + class AutofillGenerator { + constructor(cells, getters, direction) { + this.index = 0; + this.cells = cells; + this.getters = getters; + this.direction = direction; + } + /** + * Get the next value to autofill + */ + next() { + const genCell = this.cells[this.index++ % this.cells.length]; + const rule = genCell.rule; + const { cellData, tooltip } = autofillModifiersRegistry + .get(rule.type) + .apply(rule, genCell.data, this.getters, this.direction); + return { + cellData, + tooltip, + origin: { + col: genCell.data.col, + row: genCell.data.row, + }, + }; + } + } + /** + * Autofill Plugin + * + */ + class AutofillPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.lastCellSelected = {}; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "AUTOFILL_SELECT": + const sheetId = this.getters.getActiveSheetId(); + this.lastCellSelected.col = + cmd.col === -1 + ? this.lastCellSelected.col + : clip(cmd.col, 0, this.getters.getNumberCols(sheetId)); + this.lastCellSelected.row = + cmd.row === -1 + ? this.lastCellSelected.row + : clip(cmd.row, 0, this.getters.getNumberRows(sheetId)); + if (this.lastCellSelected.col !== undefined && this.lastCellSelected.row !== undefined) { + return 0 /* Success */; + } + return 42 /* InvalidAutofillSelection */; + case "AUTOFILL_AUTO": + const zone = this.getters.getSelectedZone(); + return zone.top === zone.bottom + ? 0 /* Success */ + : 1 /* CancelledForUnknownReason */; + } + return 0 /* Success */; + } + handle(cmd) { + switch (cmd.type) { + case "AUTOFILL": + this.autofill(true); + break; + case "AUTOFILL_SELECT": + this.select(cmd.col, cmd.row); + break; + case "AUTOFILL_AUTO": + this.autofillAuto(); + break; + case "AUTOFILL_CELL": + this.autoFillMerge(cmd.originCol, cmd.originRow, cmd.col, cmd.row); + const sheetId = this.getters.getActiveSheetId(); + this.dispatch("UPDATE_CELL", { + sheetId, + col: cmd.col, + row: cmd.row, + style: cmd.style || null, + content: cmd.content || "", + format: cmd.format || "", + }); + this.dispatch("SET_BORDER", { + sheetId, + col: cmd.col, + row: cmd.row, + border: cmd.border, + }); + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getAutofillTooltip() { + return this.tooltip; + } + // --------------------------------------------------------------------------- + // Private methods + // --------------------------------------------------------------------------- + /** + * Autofill the autofillZone from the current selection + * @param apply Flag set to true to apply the autofill in the model. It's + * useful to set it to false when we need to fill the tooltip + */ + autofill(apply) { + if (!this.autofillZone || this.direction === undefined) { + this.tooltip = undefined; + return; + } + const source = this.getters.getSelectedZone(); + const target = this.autofillZone; + switch (this.direction) { + case 1 /* DOWN */: + for (let col = source.left; col <= source.right; col++) { + const xcs = []; + for (let row = source.top; row <= source.bottom; row++) { + xcs.push(toXC(col, row)); + } + const generator = this.createGenerator(xcs); + for (let row = target.top; row <= target.bottom; row++) { + this.computeNewCell(generator, col, row, apply); + } + } + break; + case 0 /* UP */: + for (let col = source.left; col <= source.right; col++) { + const xcs = []; + for (let row = source.bottom; row >= source.top; row--) { + xcs.push(toXC(col, row)); + } + const generator = this.createGenerator(xcs); + for (let row = target.bottom; row >= target.top; row--) { + this.computeNewCell(generator, col, row, apply); + } + } + break; + case 2 /* LEFT */: + for (let row = source.top; row <= source.bottom; row++) { + const xcs = []; + for (let col = source.right; col >= source.left; col--) { + xcs.push(toXC(col, row)); + } + const generator = this.createGenerator(xcs); + for (let col = target.right; col >= target.left; col--) { + this.computeNewCell(generator, col, row, apply); + } + } + break; + case 3 /* RIGHT */: + for (let row = source.top; row <= source.bottom; row++) { + const xcs = []; + for (let col = source.left; col <= source.right; col++) { + xcs.push(toXC(col, row)); + } + const generator = this.createGenerator(xcs); + for (let col = target.left; col <= target.right; col++) { + this.computeNewCell(generator, col, row, apply); + } + } + break; + } + if (apply) { + const zone = union(this.getters.getSelectedZone(), this.autofillZone); + this.autofillZone = undefined; + this.lastCellSelected = {}; + this.direction = undefined; + this.tooltip = undefined; + this.selection.selectZone({ cell: { col: zone.left, row: zone.top }, zone }); + } + } + /** + * Select a cell which becomes the last cell of the autofillZone + */ + select(col, row) { + const source = this.getters.getSelectedZone(); + if (isInside(col, row, source)) { + this.autofillZone = undefined; + return; + } + this.direction = this.getDirection(col, row); + switch (this.direction) { + case 0 /* UP */: + this.saveZone(row, source.top - 1, source.left, source.right); + break; + case 1 /* DOWN */: + this.saveZone(source.bottom + 1, row, source.left, source.right); + break; + case 2 /* LEFT */: + this.saveZone(source.top, source.bottom, col, source.left - 1); + break; + case 3 /* RIGHT */: + this.saveZone(source.top, source.bottom, source.right + 1, col); + break; + } + this.autofill(false); + } + /** + * Computes the autofillZone to autofill when the user double click on the + * autofiller + */ + autofillAuto() { + const zone = this.getters.getSelectedZone(); + const sheetId = this.getters.getActiveSheetId(); + let col = zone.left; + let row = zone.bottom; + if (col > 0) { + let left = this.getters.getCell(sheetId, col - 1, row); + while (left && !left.isEmpty()) { + row += 1; + left = this.getters.getCell(sheetId, col - 1, row); + } + } + if (row === zone.bottom) { + col = zone.right; + if (col <= this.getters.getNumberCols(sheetId)) { + let right = this.getters.getCell(sheetId, col + 1, row); + while (right && !right.isEmpty()) { + row += 1; + right = this.getters.getCell(sheetId, col + 1, row); + } + } + } + if (row !== zone.bottom) { + this.select(zone.left, row - 1); + this.autofill(true); + } + } + /** + * Generate the next cell + */ + computeNewCell(generator, col, row, apply) { + const { cellData, tooltip, origin } = generator.next(); + const { content, style, border, format } = cellData; + this.tooltip = tooltip; + if (apply) { + this.dispatch("AUTOFILL_CELL", { + originCol: origin.col, + originRow: origin.row, + col, + row, + content, + style, + border, + format, + }); + } + } + /** + * Get the rule associated to the current cell + */ + getRule(cell, cells) { + const rules = autofillRulesRegistry.getAll().sort((a, b) => a.sequence - b.sequence); + const rule = rules.find((rule) => rule.condition(cell, cells)); + return rule && rule.generateRule(cell, cells); + } + /** + * Create the generator to be able to autofill the next cells. + */ + createGenerator(source) { + const nextCells = []; + const cellsData = []; + const sheetId = this.getters.getActiveSheetId(); + for (let xc of source) { + const { col, row } = toCartesian(xc); + const cell = this.getters.getCell(sheetId, col, row); + cellsData.push({ + col, + row, + cell, + sheetId, + }); + } + const cells = cellsData.map((cellData) => cellData.cell); + for (let cellData of cellsData) { + let rule = { type: "COPY_MODIFIER" }; + if (cellData && cellData.cell) { + const newRule = this.getRule(cellData.cell, cells); + rule = newRule || rule; + } + const { sheetId, col, row } = cellData; + const border = this.getters.getCellBorder(sheetId, col, row) || undefined; + nextCells.push({ + data: { ...cellData, border }, + rule, + }); + } + return new AutofillGenerator(nextCells, this.getters, this.direction); + } + saveZone(top, bottom, left, right) { + this.autofillZone = { top, bottom, left, right }; + } + /** + * Compute the direction of the autofill from the last selected zone and + * a given cell (col, row) + */ + getDirection(col, row) { + const source = this.getters.getSelectedZone(); + const position = { + up: { number: source.top - row, value: 0 /* UP */ }, + down: { number: row - source.bottom, value: 1 /* DOWN */ }, + left: { number: source.left - col, value: 2 /* LEFT */ }, + right: { number: col - source.right, value: 3 /* RIGHT */ }, + }; + if (Object.values(position) + .map((x) => (x.number > 0 ? 1 : 0)) + .reduce((acc, value) => acc + value) === 1) { + return Object.values(position).find((x) => (x.number > 0 ? 1 : 0)).value; + } + const first = position.up.number > 0 ? "up" : "down"; + const second = position.left.number > 0 ? "left" : "right"; + return Math.abs(position[first].number) >= Math.abs(position[second].number) + ? position[first].value + : position[second].value; + } + autoFillMerge(originCol, originRow, col, row) { + const activeSheet = this.getters.getActiveSheet(); + if (this.getters.isInMerge(activeSheet.id, col, row) && + !this.getters.isInMerge(activeSheet.id, originCol, originRow)) { + const zone = this.getters.getMerge(activeSheet.id, col, row); + if (zone) { + this.dispatch("REMOVE_MERGE", { + sheetId: activeSheet.id, + target: [zone], + }); + } + } + const originMerge = this.getters.getMerge(activeSheet.id, originCol, originRow); + if ((originMerge === null || originMerge === void 0 ? void 0 : originMerge.topLeft.col) === originCol && (originMerge === null || originMerge === void 0 ? void 0 : originMerge.topLeft.row) === originRow) { + this.dispatch("ADD_MERGE", { + sheetId: activeSheet.id, + target: [ + { + top: row, + bottom: row + originMerge.bottom - originMerge.top, + left: col, + right: col + originMerge.right - originMerge.left, + }, + ], + }); + } + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(renderingContext) { + if (!this.autofillZone) { + return; + } + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + const { x, y, width, height } = this.getters.getRect(this.autofillZone, viewport); + if (width > 0 && height > 0) { + ctx.strokeStyle = "black"; + ctx.lineWidth = thinLineWidth; + ctx.setLineDash([3]); + ctx.strokeRect(x, y, width, height); + ctx.setLineDash([]); + } + } + } + AutofillPlugin.layers = [6 /* Autofill */]; + AutofillPlugin.getters = ["getAutofillTooltip"]; + + class AutomaticSumPlugin extends UIPlugin { + handle(cmd) { + switch (cmd.type) { + case "SUM_SELECTION": + const sheetId = this.getters.getActiveSheetId(); + const { zones, anchor } = this.getters.getSelection(); + for (const zone of zones) { + const sums = this.getAutomaticSums(sheetId, zone, anchor.cell); + this.dispatchCellUpdates(sheetId, sums); + } + break; + } + } + getAutomaticSums(sheetId, zone, anchor) { + return this.shouldFindData(sheetId, zone) + ? this.sumAdjacentData(sheetId, zone, anchor) + : this.sumData(sheetId, zone); + } + // --------------------------------------------------------------------------- + // Private methods + // --------------------------------------------------------------------------- + sumData(sheetId, zone) { + const dimensions = this.dimensionsToSum(sheetId, zone); + const sums = this.sumDimensions(sheetId, zone, dimensions).filter(({ zone }) => !this.getters.isEmpty(sheetId, zone)); + if (dimensions.has("ROW") && dimensions.has("COL")) { + sums.push(this.sumTotal(zone)); + } + return sums; + } + sumAdjacentData(sheetId, zone, anchor) { + const { col, row } = isInside(anchor.col, anchor.row, zone) + ? anchor + : { col: zone.left, row: zone.top }; + const dataZone = this.findAdjacentData(sheetId, col, row); + if (!dataZone) { + return []; + } + if (this.getters.isSingleCellOrMerge(sheetId, zone) || + isOneDimensional(union(dataZone, zone))) { + return [{ position: { col, row }, zone: dataZone }]; + } + else { + return this.sumDimensions(sheetId, union(dataZone, zone), this.transpose(this.dimensionsToSum(sheetId, zone))); + } + } + /** + * Find a zone to automatically sum a column or row of numbers. + * + * We first decide which direction will be summed (column or row). + * Here is the strategy: + * 1. If the left cell is a number and the top cell is not: choose horizontal + * 2. Try to find a valid vertical zone. If it's valid: choose vertical + * 3. Try to find a valid horizontal zone. If it's valid: choose horizontal + * 4. Otherwise, no zone is returned + * + * Now, how to find a valid zone? + * The zone starts directly above or on the left of the starting point + * (depending on the direction). + * The zone ends where the first continuous sequence of numbers ends. + * Empty or text cells can be part of the zone while no number has been found. + * Other kind of cells (boolean, dates, etc.) are not valid in the zone and the + * search stops immediately if one is found. + * + * ------- ------- + * | 1 | | 1 | + * ------- ------- + * | | | | + * ------- <= end of the sequence, stop here ------- + * | 2 | | 2 | + * ------- ------- + * | 3 | <= start of the number sequence | 3 | + * ------- ------- + * | | <= ignored | FALSE | <= invalid, no zone is found + * ------- ------- + * | A | <= ignored | A | <= ignored + * ------- ------- + */ + findAdjacentData(sheetId, col, row) { + const sheet = this.getters.getSheet(sheetId); + const mainCellPosition = this.getters.getMainCellPosition(sheetId, col, row); + const zone = this.findSuitableZoneToSum(sheet, mainCellPosition.col, mainCellPosition.row); + if (zone) { + return this.getters.expandZone(sheetId, zone); + } + return undefined; + } + /** + * Return the zone to sum if a valid one is found. + * @see getAutomaticSumZone + */ + findSuitableZoneToSum(sheet, col, row) { + const topCell = this.getters.getCell(sheet.id, col, row - 1); + const leftCell = this.getters.getCell(sheet.id, col - 1, row); + if (this.isNumber(leftCell) && !this.isNumber(topCell)) { + return this.findHorizontalZone(sheet, col, row); + } + const verticalZone = this.findVerticalZone(sheet, col, row); + if (this.isZoneValid(verticalZone)) { + return verticalZone; + } + const horizontalZone = this.findHorizontalZone(sheet, col, row); + if (this.isZoneValid(horizontalZone)) { + return horizontalZone; + } + return undefined; + } + findVerticalZone(sheet, col, row) { + const zone = { + top: 0, + bottom: row - 1, + left: col, + right: col, + }; + const top = this.reduceZoneStart(sheet, zone, zone.bottom); + return { ...zone, top }; + } + findHorizontalZone(sheet, col, row) { + const zone = { + top: row, + bottom: row, + left: 0, + right: col - 1, + }; + const left = this.reduceZoneStart(sheet, zone, zone.right); + return { ...zone, left }; + } + /** + * Reduces a column or row zone to a valid zone for the automatic sum. + * @see getAutomaticSumZone + * @param sheet + * @param zone one dimensional zone (a single row or a single column). The zone is + * assumed to start at the beginning of the column (top=0) or the row (left=0) + * @param end end index of the zone (`bottom` or `right` depending on the dimension) + * @returns the starting position of the valid zone or Infinity if the zone is not valid. + */ + reduceZoneStart(sheet, zone, end) { + const cells = this.getters.getCellsInZone(sheet.id, zone); + const cellPositions = range(end, -1, -1); + const invalidCells = cellPositions.filter((position) => { var _a; return cells[position] && !((_a = cells[position]) === null || _a === void 0 ? void 0 : _a.isAutoSummable); }); + const maxValidPosition = Math.max(...invalidCells); + const numberSequences = groupConsecutive(cellPositions.filter((position) => this.isNumber(cells[position]))); + const firstSequence = numberSequences[0] || []; + if (Math.max(...firstSequence) < maxValidPosition) { + return Infinity; + } + return Math.min(...firstSequence); + } + shouldFindData(sheetId, zone) { + return this.getters.isEmpty(sheetId, zone) || this.getters.isSingleCellOrMerge(sheetId, zone); + } + isNumber(cell) { + var _a; + return (cell === null || cell === void 0 ? void 0 : cell.evaluated.type) === CellValueType.number && !((_a = cell.format) === null || _a === void 0 ? void 0 : _a.match(DATETIME_FORMAT)); + } + isZoneValid(zone) { + return zone.bottom >= zone.top && zone.right >= zone.left; + } + lastColIsEmpty(sheetId, zone) { + return this.getters.isEmpty(sheetId, { ...zone, left: zone.right }); + } + lastRowIsEmpty(sheetId, zone) { + return this.getters.isEmpty(sheetId, { ...zone, top: zone.bottom }); + } + /** + * Decides which dimensions (columns or rows) should be summed + * based on its shape and what's inside the zone. + */ + dimensionsToSum(sheetId, zone) { + const dimensions = new Set(); + if (isOneDimensional(zone)) { + dimensions.add(zoneToDimension(zone).width === 1 ? "COL" : "ROW"); + return dimensions; + } + if (this.lastColIsEmpty(sheetId, zone)) { + dimensions.add("ROW"); + } + if (this.lastRowIsEmpty(sheetId, zone)) { + dimensions.add("COL"); + } + if (dimensions.size === 0) { + dimensions.add("COL"); + } + return dimensions; + } + /** + * Sum each column and/or row in the zone in the appropriate cells, + * depending on the available space. + */ + sumDimensions(sheetId, zone, dimensions) { + return [ + ...(dimensions.has("COL") ? this.sumColumns(zone, sheetId) : []), + ...(dimensions.has("ROW") ? this.sumRows(zone, sheetId) : []), + ]; + } + /** + * Sum the total of the zone in the bottom right cell, assuming + * the last row contains summed columns. + */ + sumTotal(zone) { + const { bottom, right } = zone; + return { + position: { col: right, row: bottom }, + zone: { ...zone, top: bottom, right: right - 1 }, + }; + } + sumColumns(zone, sheetId) { + const target = this.nextEmptyRow(sheetId, { ...zone, bottom: zone.bottom - 1 }); + zone = { ...zone, bottom: Math.min(zone.bottom, target.bottom - 1) }; + return positions(target).map((position) => ({ + position, + zone: { ...zone, right: position.col, left: position.col }, + })); + } + sumRows(zone, sheetId) { + const target = this.nextEmptyCol(sheetId, { ...zone, right: zone.right - 1 }); + zone = { ...zone, right: Math.min(zone.right, target.right - 1) }; + return positions(target).map((position) => ({ + position, + zone: { ...zone, top: position.row, bottom: position.row }, + })); + } + dispatchCellUpdates(sheetId, sums) { + for (const sum of sums) { + const { col, row } = sum.position; + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + content: `=SUM(${this.getters.zoneToXC(sheetId, sum.zone)})`, + }); + } + } + /** + * Find the first row where all cells below the zone are empty. + */ + nextEmptyRow(sheetId, zone) { + let start = zone.bottom + 1; + const { left, right } = zone; + while (!this.getters.isEmpty(sheetId, { bottom: start, top: start, left, right })) { + start++; + } + return { + ...zone, + top: start, + bottom: start, + }; + } + /** + * Find the first column where all cells right of the zone are empty. + */ + nextEmptyCol(sheetId, zone) { + let start = zone.right + 1; + const { top, bottom } = zone; + while (!this.getters.isEmpty(sheetId, { left: start, right: start, top, bottom })) { + start++; + } + return { + ...zone, + left: start, + right: start, + }; + } + /** + * Transpose the given dimensions. + * COL becomes ROW + * ROW becomes COL + */ + transpose(dimensions) { + return new Set([...dimensions.values()].map((dimension) => (dimension === "COL" ? "ROW" : "COL"))); + } + } + AutomaticSumPlugin.getters = ["getAutomaticSums"]; + + /** + * Plugin managing the display of components next to cells. + */ + class CellPopoverPlugin extends UIPlugin { + allowDispatch(cmd) { + switch (cmd.type) { + case "OPEN_CELL_POPOVER": + try { + cellPopoverRegistry.get(cmd.popoverType); + } + catch (error) { + return 69 /* InvalidCellPopover */; + } + return 0 /* Success */; + default: + return 0 /* Success */; + } + } + handle(cmd) { + switch (cmd.type) { + case "ACTIVATE_SHEET": + this.persistentPopover = undefined; + break; + case "OPEN_CELL_POPOVER": + this.persistentPopover = { + col: cmd.col, + row: cmd.row, + type: cmd.popoverType, + }; + break; + case "CLOSE_CELL_POPOVER": + this.persistentPopover = undefined; + break; + } + } + getCellPopover({ col, row }) { + var _a, _b; + const sheetId = this.getters.getActiveSheetId(); + const viewport = this.getters.getActiveViewport(); + if (this.persistentPopover && + this.getters.isVisibleInViewport(this.persistentPopover.col, this.persistentPopover.row, viewport)) { + const mainPosition = this.getters.getMainCellPosition(sheetId, this.persistentPopover.col, this.persistentPopover.row); + const popover = (_b = (_a = cellPopoverRegistry + .get(this.persistentPopover.type)).onOpen) === null || _b === void 0 ? void 0 : _b.call(_a, mainPosition, this.getters); + return !(popover === null || popover === void 0 ? void 0 : popover.isOpen) + ? { isOpen: false } + : { + ...popover, + ...this.computePopoverProps(this.persistentPopover, popover.cellCorner), + }; + } + if (col === undefined || + row === undefined || + !this.getters.isVisibleInViewport(col, row, viewport)) { + return { isOpen: false }; + } + const mainPosition = this.getters.getMainCellPosition(sheetId, col, row); + const popover = cellPopoverRegistry + .getAll() + .map((matcher) => { var _a; return (_a = matcher.onHover) === null || _a === void 0 ? void 0 : _a.call(matcher, mainPosition, this.getters); }) + .find((popover) => popover === null || popover === void 0 ? void 0 : popover.isOpen); + return !(popover === null || popover === void 0 ? void 0 : popover.isOpen) + ? { isOpen: false } + : { + ...popover, + ...this.computePopoverProps(mainPosition, popover.cellCorner), + }; + } + computePopoverProps({ col, row }, corner) { + const viewport = this.getters.getActiveViewport(); + const { width, height } = this.getters.getRect(positionToZone({ col, row }), viewport); + return { + coordinates: this.computePopoverPosition({ col, row }, corner), + cellWidth: -width, + cellHeight: -height, + }; + } + computePopoverPosition({ col, row }, corner) { + const sheetId = this.getters.getActiveSheetId(); + const viewport = this.getters.getActiveViewport(); + const merge = this.getters.getMerge(sheetId, col, row); + if (merge) { + col = corner === "TopRight" ? merge.right : merge.left; + row = corner === "TopRight" ? merge.top : merge.bottom; + } + // x, y are relative to the canvas + const { x, y, width, height } = this.getters.getRect(positionToZone({ col, row }), viewport); + switch (corner) { + case "BottomLeft": + return { x, y: y + height }; + case "TopRight": + return { x: x + width, y: y }; + } + } + } + CellPopoverPlugin.getters = ["getCellPopover"]; + CellPopoverPlugin.modes = ["normal"]; + + /** Abstract state of the clipboard when copying/cutting content that is pasted in cells of the sheet */ + class ClipboardCellsAbstractState { + constructor(operation, getters, dispatch, selection) { + this.getters = getters; + this.dispatch = dispatch; + this.selection = selection; + this.operation = operation; + this.sheetId = getters.getActiveSheetId(); + } + isCutAllowed(target) { + return 0 /* Success */; + } + isPasteAllowed(target, clipboardOption) { + return 0 /* Success */; + } + /** + * Add columns and/or rows to ensure that col + width and row + height are still + * in the sheet + */ + addMissingDimensions(width, height, col, row) { + const sheetId = this.getters.getActiveSheetId(); + const missingRows = height + row - this.getters.getNumberRows(sheetId); + if (missingRows > 0) { + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: "ROW", + base: this.getters.getNumberRows(sheetId) - 1, + sheetId, + quantity: missingRows, + position: "after", + }); + } + const missingCols = width + col - this.getters.getNumberCols(sheetId); + if (missingCols > 0) { + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: "COL", + base: this.getters.getNumberCols(sheetId) - 1, + sheetId, + quantity: missingCols, + position: "after", + }); + } + } + isColRowDirtyingClipboard(position, dimension) { + return false; + } + drawClipboard(renderingContext) { } + } + + /** State of the clipboard when copying/cutting cells */ + class ClipboardCellsState extends ClipboardCellsAbstractState { + constructor(zones, operation, getters, dispatch, selection) { + super(operation, getters, dispatch, selection); + if (!zones.length) { + this.cells = [[]]; + this.zones = []; + return; + } + const lefts = new Set(zones.map((z) => z.left)); + const rights = new Set(zones.map((z) => z.right)); + const tops = new Set(zones.map((z) => z.top)); + const bottoms = new Set(zones.map((z) => z.bottom)); + const areZonesCompatible = (tops.size === 1 && bottoms.size === 1) || (lefts.size === 1 && rights.size === 1); + // In order to don't paste several times the same cells in intersected zones + // --> we merge zones that have common cells + const clippedZones = areZonesCompatible + ? mergeOverlappingZones(zones) + : [zones[zones.length - 1]]; + const cellsPosition = clippedZones.map((zone) => positions(zone)).flat(); + const columnsIndex = [...new Set(cellsPosition.map((p) => p.col))].sort((a, b) => a - b); + const rowsIndex = [...new Set(cellsPosition.map((p) => p.row))].sort((a, b) => a - b); + const cellsInClipboard = []; + const sheetId = getters.getActiveSheetId(); + for (let row of rowsIndex) { + let cellsInRow = []; + for (let col of columnsIndex) { + cellsInRow.push({ + cell: getters.getCell(sheetId, col, row), + border: getters.getCellBorder(sheetId, col, row) || undefined, + position: { col, row, sheetId }, + }); + } + cellsInClipboard.push(cellsInRow); + } + this.cells = cellsInClipboard; + this.zones = clippedZones; + } + isCutAllowed(target) { + if (target.length !== 1) { + return 17 /* WrongCutSelection */; + } + return 0 /* Success */; + } + isPasteAllowed(target, clipboardOption) { + const sheetId = this.getters.getActiveSheetId(); + if (this.operation === "CUT" && (clipboardOption === null || clipboardOption === void 0 ? void 0 : clipboardOption.pasteOption) !== undefined) { + // cannot paste only format or only value if the previous operation is a CUT + return 19 /* WrongPasteOption */; + } + if (target.length > 1) { + // cannot paste if we have a clipped zone larger than a cell and multiple + // zones selected + if (this.cells.length > 1 || this.cells[0].length > 1) { + return 18 /* WrongPasteSelection */; + } + } + const clipboardHeight = this.cells.length; + const clipboardWidth = this.cells[0].length; + for (let zone of this.getPasteZones(target)) { + if (this.getters.doesIntersectMerge(sheetId, zone)) { + if (target.length > 1 || + !this.getters.isSingleCellOrMerge(sheetId, target[0]) || + clipboardHeight * clipboardWidth !== 1) { + return 2 /* WillRemoveExistingMerge */; + } + } + } + return 0 /* Success */; + } + /** + * Paste the clipboard content in the given target + */ + paste(target, options) { + if (this.operation === "COPY") { + this.pasteFromCopy(target, options); + } + else { + this.pasteFromCut(target, options); + } + const height = this.cells.length; + const width = this.cells[0].length; + const isCutOperation = this.operation === "CUT"; + if (options === null || options === void 0 ? void 0 : options.selectTarget) { + this.selectPastedZone(width, height, isCutOperation, target); + } + } + pasteFromCopy(target, options) { + if (target.length === 1) { + // in this specific case, due to the isPasteAllowed function: + // state.cells can contains several cells. + // So if the target zone is larger than the copied zone, + // we duplicate each cells as many times as possible to fill the zone. + const height = this.cells.length; + const width = this.cells[0].length; + const pasteZones = this.pastedZones(target, width, height); + for (const zone of pasteZones) { + this.pasteZone(zone.left, zone.top, options); + } + } + else { + // in this case, due to the isPasteAllowed function: state.cells contains + // only one cell + for (const zone of target) { + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + this.pasteZone(col, row, options); + } + } + } + } + } + pasteFromCut(target, options) { + this.clearClippedZones(); + const selection = target[0]; + this.pasteZone(selection.left, selection.top, options); + this.dispatch("MOVE_RANGES", { + target: this.zones, + sheetId: this.sheetId, + targetSheetId: this.getters.getActiveSheetId(), + col: selection.left, + row: selection.top, + }); + } + /** + * The clipped zone is copied as many times as it fits in the target. + * This returns the list of zones where the clipped zone is copy-pasted. + */ + pastedZones(target, originWidth, originHeight) { + const selection = target[0]; + const repeatHorizontally = Math.max(1, Math.floor((selection.right + 1 - selection.left) / originWidth)); + const repeatVertically = Math.max(1, Math.floor((selection.bottom + 1 - selection.top) / originHeight)); + const zones = []; + for (let x = 0; x < repeatHorizontally; x++) { + for (let y = 0; y < repeatVertically; y++) { + const top = selection.top + y * originHeight; + const left = selection.left + x * originWidth; + zones.push({ + left, + top, + bottom: top + originHeight - 1, + right: left + originWidth - 1, + }); + } + } + return zones; + } + /** + * Compute the complete zones where to paste the current clipboard + */ + getPasteZones(target) { + const cells = this.cells; + if (!cells.length || !cells[0].length) { + return target; + } + const pasteZones = []; + const height = cells.length; + const width = cells[0].length; + const selection = target[target.length - 1]; + const col = selection.left; + const row = selection.top; + const repetitionCol = Math.max(1, Math.floor((selection.right + 1 - col) / width)); + const repetitionRow = Math.max(1, Math.floor((selection.bottom + 1 - row) / height)); + for (let x = 1; x <= repetitionCol; x++) { + for (let y = 1; y <= repetitionRow; y++) { + pasteZones.push({ + left: col, + top: row, + right: col - 1 + x * width, + bottom: row - 1 + y * height, + }); + } + } + return pasteZones; + } + /** + * Update the selection with the newly pasted zone + */ + selectPastedZone(width, height, isCutOperation, target) { + const selection = target[0]; + const col = selection.left; + const row = selection.top; + if (height > 1 || width > 1 || isCutOperation) { + const zones = this.pastedZones(target, width, height); + const newZone = isCutOperation ? zones[0] : union(...zones); + this.selection.selectZone({ cell: { col, row }, zone: newZone }); + } + } + /** + * Clear the clipped zones: remove the cells and clear the formatting + */ + clearClippedZones() { + for (const row of this.cells) { + for (const cell of row) { + if (cell.cell) { + this.dispatch("CLEAR_CELL", cell.position); + } + } + } + this.dispatch("CLEAR_FORMATTING", { + sheetId: this.sheetId, + target: this.zones, + }); + } + pasteZone(col, row, clipboardOptions) { + const height = this.cells.length; + const width = this.cells[0].length; + // This condition is used to determine if we have to paste the CF or not. + // We have to do it when the command handled is "PASTE", not "INSERT_CELL" + // or "DELETE_CELL". So, the state should be the local state + const shouldPasteCF = (clipboardOptions === null || clipboardOptions === void 0 ? void 0 : clipboardOptions.pasteOption) !== "onlyValue" && (clipboardOptions === null || clipboardOptions === void 0 ? void 0 : clipboardOptions.shouldPasteCF); + const sheetId = this.getters.getActiveSheetId(); + // first, add missing cols/rows if needed + this.addMissingDimensions(width, height, col, row); + // then, perform the actual paste operation + for (let r = 0; r < height; r++) { + const rowCells = this.cells[r]; + for (let c = 0; c < width; c++) { + const origin = rowCells[c]; + const position = { col: col + c, row: row + r, sheetId: sheetId }; + // TODO: refactor this part. the "Paste merge" action is also executed with + // MOVE_RANGES in pasteFromCut. Adding a condition on the operation type here + // is not appropriate + if (this.operation !== "CUT") { + this.pasteMergeIfExist(origin.position, position); + } + this.pasteCell(origin, position, this.operation, clipboardOptions); + if (shouldPasteCF) { + this.dispatch("PASTE_CONDITIONAL_FORMAT", { + origin: origin.position, + target: position, + operation: this.operation, + }); + } + } + } + } + /** + * Paste the cell at the given position to the target position + */ + pasteCell(origin, target, operation, clipboardOption) { + const { sheetId, col, row } = target; + const targetCell = this.getters.getCell(sheetId, col, row); + if ((clipboardOption === null || clipboardOption === void 0 ? void 0 : clipboardOption.pasteOption) !== "onlyValue") { + const targetBorders = this.getters.getCellBorder(sheetId, col, row); + const originBorders = origin.border; + const border = { + top: (targetBorders === null || targetBorders === void 0 ? void 0 : targetBorders.top) || (originBorders === null || originBorders === void 0 ? void 0 : originBorders.top), + bottom: (targetBorders === null || targetBorders === void 0 ? void 0 : targetBorders.bottom) || (originBorders === null || originBorders === void 0 ? void 0 : originBorders.bottom), + left: (targetBorders === null || targetBorders === void 0 ? void 0 : targetBorders.left) || (originBorders === null || originBorders === void 0 ? void 0 : originBorders.left), + right: (targetBorders === null || targetBorders === void 0 ? void 0 : targetBorders.right) || (originBorders === null || originBorders === void 0 ? void 0 : originBorders.right), + }; + this.dispatch("SET_BORDER", { sheetId, col, row, border }); + } + if (origin.cell) { + if ((clipboardOption === null || clipboardOption === void 0 ? void 0 : clipboardOption.pasteOption) === "onlyFormat") { + this.dispatch("UPDATE_CELL", { + ...target, + style: origin.cell.style, + format: origin.cell.evaluated.format || origin.cell.format, + }); + return; + } + if ((clipboardOption === null || clipboardOption === void 0 ? void 0 : clipboardOption.pasteOption) === "onlyValue") { + const content = formatValue(origin.cell.evaluated.value); + this.dispatch("UPDATE_CELL", { ...target, content }); + return; + } + let content = origin.cell.content; + if (origin.cell.isFormula() && operation === "COPY") { + const offsetX = col - origin.position.col; + const offsetY = row - origin.position.row; + content = this.getUpdatedContent(sheetId, origin.cell, offsetX, offsetY, operation); + } + this.dispatch("UPDATE_CELL", { + ...target, + content, + style: origin.cell.style || null, + format: origin.cell.format, + }); + } + else if (targetCell) { + if ((clipboardOption === null || clipboardOption === void 0 ? void 0 : clipboardOption.pasteOption) === "onlyValue") { + this.dispatch("UPDATE_CELL", { ...target, content: "" }); + } + else if ((clipboardOption === null || clipboardOption === void 0 ? void 0 : clipboardOption.pasteOption) === "onlyFormat") { + this.dispatch("UPDATE_CELL", { ...target, style: null, format: "" }); + } + else { + this.dispatch("CLEAR_CELL", target); + } + } + } + /** + * Get the newly updated formula, after applying offsets + */ + getUpdatedContent(sheetId, cell, offsetX, offsetY, operation) { + const ranges = this.getters.createAdaptedRanges(cell.dependencies, offsetX, offsetY, sheetId); + return this.getters.buildFormulaContent(sheetId, cell, ranges); + } + /** + * If the origin position given is the top left of a merge, merge the target + * position. + */ + pasteMergeIfExist(origin, target) { + let { sheetId, col, row } = origin; + const { col: mainCellColOrigin, row: mainCellRowOrigin } = this.getters.getMainCellPosition(sheetId, col, row); + if (mainCellColOrigin === col && mainCellRowOrigin === row) { + const merge = this.getters.getMerge(sheetId, col, row); + if (!merge) { + return; + } + ({ sheetId, col, row } = target); + this.dispatch("ADD_MERGE", { + sheetId, + force: true, + target: [ + { + left: col, + top: row, + right: col + merge.right - merge.left, + bottom: row + merge.bottom - merge.top, + }, + ], + }); + } + } + getClipboardContent() { + return (this.cells + .map((cells) => { + return cells + .map((c) => c.cell ? this.getters.getCellText(c.cell, this.getters.shouldShowFormulas()) : "") + .join("\t"); + }) + .join("\n") || "\t"); + } + isColRowDirtyingClipboard(position, dimension) { + if (!this.zones) + return false; + for (let zone of this.zones) { + if (dimension === "COL" && position <= zone.right) { + return true; + } + if (dimension === "ROW" && position <= zone.bottom) { + return true; + } + } + return false; + } + drawClipboard(renderingContext) { + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + if (this.sheetId !== this.getters.getActiveSheetId() || !this.zones || !this.zones.length) { + return; + } + ctx.setLineDash([8, 5]); + ctx.strokeStyle = SELECTION_BORDER_COLOR; + ctx.lineWidth = 3.3 * thinLineWidth; + for (const zone of this.zones) { + const { x, y, width, height } = this.getters.getRect(zone, viewport); + if (width > 0 && height > 0) { + ctx.strokeRect(x, y, width, height); + } + } + } + } + + /** State of the clipboard when copying/cutting figures */ + class ClipboardFigureState { + constructor(operation, getters, dispatch) { + this.getters = getters; + this.dispatch = dispatch; + this.sheetId = getters.getActiveSheetId(); + const copiedFigureId = getters.getSelectedFigureId(); + if (!copiedFigureId) { + throw new Error(`No figure selected`); + } + const figure = getters.getFigure(this.sheetId, copiedFigureId); + if (!figure) { + throw new Error(`No figure for the given id: ${copiedFigureId}`); + } + this.copiedFigure = { ...figure }; + const chart = getters.getChart(copiedFigureId); + if (!chart) { + throw new Error(`No chart for the given id: ${copiedFigureId}`); + } + this.copiedChart = chart.copyInSheetId(this.sheetId); + this.operation = operation; + } + isCutAllowed(target) { + return 0 /* Success */; + } + isPasteAllowed(target, option) { + if (target.length === 0) { + return 70 /* EmptyTarget */; + } + if ((option === null || option === void 0 ? void 0 : option.pasteOption) !== undefined) { + return 20 /* WrongFigurePasteOption */; + } + return 0 /* Success */; + } + /** + * Paste the clipboard content in the given target + */ + paste(target) { + const sheetId = this.getters.getActiveSheetId(); + const position = { + x: this.getters.getColDimensions(sheetId, target[0].left).start, + y: this.getters.getRowDimensions(sheetId, target[0].top).start, + }; + const newChart = this.copiedChart.copyInSheetId(sheetId); + const newId = new UuidGenerator().uuidv4(); + this.dispatch("CREATE_CHART", { + id: newId, + sheetId, + position, + size: { height: this.copiedFigure.height, width: this.copiedFigure.width }, + definition: newChart.getDefinition(), + }); + if (this.operation === "CUT") { + this.dispatch("DELETE_FIGURE", { + sheetId: this.copiedChart.sheetId, + id: this.copiedFigure.id, + }); + } + } + getClipboardContent() { + return "\t"; + } + isColRowDirtyingClipboard(position, dimension) { + return false; + } + drawClipboard(renderingContext) { } + } + + /** State of the clipboard when copying/cutting from the OS clipboard*/ + class ClipboardOsState extends ClipboardCellsAbstractState { + constructor(content, getters, dispatch, selection) { + super("COPY", getters, dispatch, selection); + this.values = content + .replace(/\r/g, "") + .split("\n") + .map((vals) => vals.split("\t")); + } + paste(target) { + const values = this.values; + const { left: activeCol, top: activeRow } = target[0]; + const width = Math.max.apply(Math, values.map((a) => a.length)); + const height = values.length; + const sheetId = this.getters.getActiveSheetId(); + this.addMissingDimensions(width, height, activeCol, activeRow); + for (let i = 0; i < values.length; i++) { + for (let j = 0; j < values[i].length; j++) { + this.dispatch("UPDATE_CELL", { + row: activeRow + i, + col: activeCol + j, + content: values[i][j], + sheetId, + }); + } + } + const zone = { + left: activeCol, + top: activeRow, + right: activeCol + width - 1, + bottom: activeRow + height - 1, + }; + this.selection.selectZone({ cell: { col: activeCol, row: activeRow }, zone }); + } + getClipboardContent() { + return this.values.map((values) => values.join("\t")).join("\n"); + } + } + + /** + * Clipboard Plugin + * + * This clipboard manages all cut/copy/paste interactions internal to the + * application, and with the OS clipboard as well. + */ + class ClipboardPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.status = "invisible"; + this._isPaintingFormat = false; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "CUT": + const zones = cmd.target || this.getters.getSelectedZones(); + const state = this.getClipboardState(zones, cmd.type); + return state.isCutAllowed(zones); + case "PASTE": + if (!this.state) { + return 21 /* EmptyClipboard */; + } + const pasteOption = cmd.pasteOption || (this._isPaintingFormat ? "onlyFormat" : undefined); + return this.state.isPasteAllowed(cmd.target, { pasteOption }); + case "INSERT_CELL": { + const { cut, paste } = this.getInsertCellsTargets(cmd.zone, cmd.shiftDimension); + const state = this.getClipboardStateForCopyCells(cut, "CUT"); + return state.isPasteAllowed(paste); + } + case "DELETE_CELL": { + const { cut, paste } = this.getDeleteCellsTargets(cmd.zone, cmd.shiftDimension); + const state = this.getClipboardStateForCopyCells(cut, "CUT"); + return state.isPasteAllowed(paste); + } + } + return 0 /* Success */; + } + handle(cmd) { + var _a, _b; + switch (cmd.type) { + case "COPY": + case "CUT": + const zones = ("target" in cmd && cmd.target) || this.getters.getSelectedZones(); + this.state = this.getClipboardState(zones, cmd.type); + this.status = "visible"; + break; + case "PASTE": + if (!this.state) { + break; + } + const pasteOption = cmd.pasteOption || (this._isPaintingFormat ? "onlyFormat" : undefined); + this._isPaintingFormat = false; + this.state.paste(cmd.target, { pasteOption, shouldPasteCF: true, selectTarget: true }); + if (this.state.operation === "CUT") { + this.state = undefined; + } + this.status = "invisible"; + break; + case "DELETE_CELL": { + const { cut, paste } = this.getDeleteCellsTargets(cmd.zone, cmd.shiftDimension); + const state = this.getClipboardStateForCopyCells(cut, "CUT"); + state.paste(paste); + break; + } + case "INSERT_CELL": { + const { cut, paste } = this.getInsertCellsTargets(cmd.zone, cmd.shiftDimension); + const state = this.getClipboardStateForCopyCells(cut, "CUT"); + state.paste(paste); + break; + } + case "ADD_COLUMNS_ROWS": { + this.status = "invisible"; + // If we add a col/row inside or before the cut area, we invalidate the clipboard + if (((_a = this.state) === null || _a === void 0 ? void 0 : _a.operation) !== "CUT") { + return; + } + const isClipboardDirty = this.state.isColRowDirtyingClipboard(cmd.position === "before" ? cmd.base : cmd.base + 1, cmd.dimension); + if (isClipboardDirty) { + this.state = undefined; + } + break; + } + case "REMOVE_COLUMNS_ROWS": { + this.status = "invisible"; + // If we remove a col/row inside or before the cut area, we invalidate the clipboard + if (((_b = this.state) === null || _b === void 0 ? void 0 : _b.operation) !== "CUT") { + return; + } + for (let el of cmd.elements) { + const isClipboardDirty = this.state.isColRowDirtyingClipboard(el, cmd.dimension); + if (isClipboardDirty) { + this.state = undefined; + break; + } + } + this.status = "invisible"; + break; + } + case "PASTE_FROM_OS_CLIPBOARD": + const state = new ClipboardOsState(cmd.text, this.getters, this.dispatch, this.selection); + state.paste(cmd.target); + this.status = "invisible"; + break; + case "ACTIVATE_PAINT_FORMAT": { + const zones = this.getters.getSelectedZones(); + this.state = this.getClipboardStateForCopyCells(zones, "COPY"); + this._isPaintingFormat = true; + this.status = "visible"; + break; + } + default: + if (isCoreCommand(cmd)) { + this.status = "invisible"; + } + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + /** + * Format the current clipboard to a string suitable for being pasted in other + * programs. + * + * - add a tab character between each consecutive cells + * - add a newline character between each line + * + * Note that it returns \t if the clipboard is empty. This is necessary for the + * clipboard copy event to add it as data, otherwise an empty string is not + * considered as a copy content. + */ + getClipboardContent() { + var _a; + return ((_a = this.state) === null || _a === void 0 ? void 0 : _a.getClipboardContent()) || "\t"; + } + isCutOperation() { + return this.state ? this.state.operation === "CUT" : false; + } + isPaintingFormat() { + return this._isPaintingFormat; + } + // --------------------------------------------------------------------------- + // Private methods + // --------------------------------------------------------------------------- + getDeleteCellsTargets(zone, dimension) { + const sheetId = this.getters.getActiveSheetId(); + let cut; + if (dimension === "COL") { + cut = { + ...zone, + left: zone.right + 1, + right: this.getters.getNumberCols(sheetId) - 1, + }; + } + else { + cut = { + ...zone, + top: zone.bottom + 1, + bottom: this.getters.getNumberRows(sheetId) - 1, + }; + } + return { cut: [cut], paste: [zone] }; + } + getInsertCellsTargets(zone, dimension) { + const sheetId = this.getters.getActiveSheetId(); + let cut; + let paste; + if (dimension === "COL") { + cut = { + ...zone, + right: this.getters.getNumberCols(sheetId) - 1, + }; + paste = { + ...zone, + left: zone.right + 1, + right: zone.right + 1, + }; + } + else { + cut = { + ...zone, + bottom: this.getters.getNumberRows(sheetId) - 1, + }; + paste = { ...zone, top: zone.bottom + 1, bottom: this.getters.getNumberRows(sheetId) - 1 }; + } + return { cut: [cut], paste: [paste] }; + } + getClipboardStateForCopyCells(zones, operation) { + return new ClipboardCellsState(zones, operation, this.getters, this.dispatch, this.selection); + } + /** + * Get the clipboard state from the given zones. + */ + getClipboardState(zones, operation) { + const selectedFigureId = this.getters.getSelectedFigureId(); + if (selectedFigureId) { + return new ClipboardFigureState(operation, this.getters, this.dispatch); + } + return new ClipboardCellsState(zones, operation, this.getters, this.dispatch, this.selection); + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(renderingContext) { + if (this.status !== "visible" || !this.state) { + return; + } + this.state.drawClipboard(renderingContext); + } + } + ClipboardPlugin.layers = [2 /* Clipboard */]; + ClipboardPlugin.getters = ["getClipboardContent", "isCutOperation", "isPaintingFormat"]; + + /** + * https://tomekdev.com/posts/sorting-colors-in-js + */ + function sortWithClusters(colorsToSort) { + const clusters = [ + { leadColor: rgba(255, 0, 0), colors: [] }, + { leadColor: rgba(255, 128, 0), colors: [] }, + { leadColor: rgba(128, 128, 0), colors: [] }, + { leadColor: rgba(128, 255, 0), colors: [] }, + { leadColor: rgba(0, 255, 0), colors: [] }, + { leadColor: rgba(0, 255, 128), colors: [] }, + { leadColor: rgba(0, 255, 255), colors: [] }, + { leadColor: rgba(0, 127, 255), colors: [] }, + { leadColor: rgba(0, 0, 255), colors: [] }, + { leadColor: rgba(127, 0, 255), colors: [] }, + { leadColor: rgba(128, 0, 128), colors: [] }, + { leadColor: rgba(255, 0, 128), colors: [] }, // rose + ]; + for (const color of colorsToSort.map(colorToRGBA)) { + let currentDistance = 500; //max distance is 441; + let currentIndex = 0; + clusters.forEach((cluster, clusterIndex) => { + const distance = colorDistance(color, cluster.leadColor); + if (currentDistance > distance) { + currentDistance = distance; + currentIndex = clusterIndex; + } + }); + clusters[currentIndex].colors.push(color); + } + return clusters + .map((cluster) => cluster.colors.sort((a, b) => rgbaToHSLA(a).s - rgbaToHSLA(b).s)) + .flat() + .map(rgbaToHex); + } + function colorDistance(color1, color2) { + return Math.sqrt(Math.pow(color1.r - color2.r, 2) + + Math.pow(color1.g - color2.g, 2) + + Math.pow(color1.b - color2.b, 2)); + } + /** + * CustomColors plugin + * This plugins aims to compute and keep to custom colors used in the + * current spreadsheet + */ + class CustomColorsPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.customColors = new Set(); + this.shouldUpdateColors = false; + } + handle(cmd) { + switch (cmd.type) { + case "UPDATE_CELL": + case "UPDATE_CHART": + case "CREATE_CHART": + case "ADD_CONDITIONAL_FORMAT": + this.shouldUpdateColors = true; + } + } + finalize() { + if (this.shouldUpdateColors) { + this.shouldUpdateColors = false; + for (const color of this.getCustomColors()) { + this.tryToAddColor(color); + } + } + } + getCustomColors() { + let usedColors = []; + for (const sheetId of this.getters.getSheetIds()) { + const cells = Object.values(this.getters.getCells(sheetId)); + usedColors = usedColors.concat(this.getColorsFromCells(cells), this.getFormattingColors(sheetId), this.getChartColors(sheetId)); + } + return sortWithClusters([ + ...new Set( + // remove duplicates first to check validity on a reduced + // set of colors, then normalize to HEX and remove duplicates + // again + [...new Set([...usedColors, ...this.customColors])] + .filter(isColorValid) + .map((c) => toHex(c).toLowerCase())), + ]).filter((color) => !COLORS.includes(color)); + } + getColorsFromCells(cells) { + var _a, _b; + const colors = new Set(); + for (const cell of cells) { + if ((_a = cell.style) === null || _a === void 0 ? void 0 : _a.textColor) { + colors.add(cell.style.textColor); + } + if ((_b = cell.style) === null || _b === void 0 ? void 0 : _b.fillColor) { + colors.add(cell.style.fillColor); + } + } + return [...colors]; + } + getFormattingColors(sheetId) { + const formats = this.getters.getConditionalFormats(sheetId); + const formatColors = []; + for (const format of formats) { + const rule = format.rule; + if (rule.type === "CellIsRule") { + formatColors.push(rule.style.textColor); + formatColors.push(rule.style.fillColor); + } + else if (rule.type === "ColorScaleRule") { + formatColors.push(colorNumberString(rule.minimum.color)); + formatColors.push(rule.midpoint ? colorNumberString(rule.midpoint.color) : undefined); + formatColors.push(colorNumberString(rule.maximum.color)); + } + } + return formatColors.filter(isDefined$1); + } + getChartColors(sheetId) { + const charts = this.getters.getChartIds(sheetId).map((cid) => this.getters.getChart(cid)); + let chartsColors = new Set(); + for (let chart of charts) { + if (chart === undefined) { + continue; + } + const background = chart.getDefinition().background; + if (background !== undefined) { + chartsColors.add(background); + } + switch (chart.type) { + case "gauge": + const colors = chart.sectionRule.colors; + chartsColors.add(colors.lowerColor); + chartsColors.add(colors.middleColor); + chartsColors.add(colors.upperColor); + break; + case "scorecard": + const scoreChart = chart; + chartsColors.add(scoreChart.baselineColorDown); + chartsColors.add(scoreChart.baselineColorUp); + break; + } + } + return [...chartsColors]; + } + tryToAddColor(color) { + const formattedColor = toHex(color).toLowerCase(); + if (color && !COLORS.includes(formattedColor)) { + this.customColors.add(formattedColor); + } + } + } + CustomColorsPlugin.getters = ["getCustomColors"]; + + const functionMap = functionRegistry.mapping; + class EvaluationPlugin extends UIPlugin { + constructor(getters, state, dispatch, config, selection) { + super(getters, state, dispatch, config, selection); + this.isUpToDate = new Set(); // Set + this.evalContext = config.evalContext; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + handle(cmd) { + if (invalidateEvaluationCommands.has(cmd.type)) { + this.isUpToDate.clear(); + } + switch (cmd.type) { + case "UPDATE_CELL": + if ("content" in cmd || "format" in cmd) { + this.isUpToDate.clear(); + } + break; + case "ACTIVATE_SHEET": { + this.evaluate(cmd.sheetIdTo); + this.isUpToDate.add(cmd.sheetIdTo); + break; + } + case "EVALUATE_CELLS": + this.evaluate(cmd.sheetId); + this.isUpToDate.add(cmd.sheetId); + break; + case "EVALUATE_ALL_SHEETS": + this.evaluateAllSheets(); + break; + } + } + finalize() { + const sheetId = this.getters.getActiveSheetId(); + if (!this.isUpToDate.has(sheetId)) { + this.evaluate(sheetId); + this.isUpToDate.add(sheetId); + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + evaluateFormula(formulaString, sheetId = this.getters.getActiveSheetId()) { + const compiledFormula = compile(formulaString); + const params = this.getCompilationParameters(() => { }); + const ranges = []; + for (let xc of compiledFormula.dependencies) { + ranges.push(this.getters.getRangeFromSheetXC(sheetId, xc)); + } + return compiledFormula.execute(ranges, ...params).value; + } + /** + * Return the value of each cell in the range as they are displayed in the grid. + */ + getRangeFormattedValues(range) { + const sheet = this.getters.tryGetSheet(range.sheetId); + if (sheet === undefined) + return []; + return this.getters + .getCellsInZone(sheet.id, range.zone) + .map((cell) => (cell === null || cell === void 0 ? void 0 : cell.formattedValue) || ""); + } + /** + * Return the value of each cell in the range. + */ + getRangeValues(range) { + const sheet = this.getters.tryGetSheet(range.sheetId); + if (sheet === undefined) + return []; + return this.getters.getCellsInZone(sheet.id, range.zone).map((cell) => cell === null || cell === void 0 ? void 0 : cell.evaluated.value); + } + // --------------------------------------------------------------------------- + // Evaluator + // --------------------------------------------------------------------------- + evaluate(sheetId) { + const cells = this.getters.getCells(sheetId); + const compilationParameters = this.getCompilationParameters(computeCell); + const visited = {}; + for (let cell of Object.values(cells)) { + computeCell(cell); + } + function handleError(e, cell) { + if (!(e instanceof Error)) { + e = new Error(e); + } + const msg = (e === null || e === void 0 ? void 0 : e.errorType) || CellErrorType.GenericError; + // apply function name + const __lastFnCalled = compilationParameters[2].__lastFnCalled || ""; + cell.assignError(msg, new EvaluationError(msg, e.message.replace("[[FUNCTION_NAME]]", __lastFnCalled), e.logLevel !== undefined ? e.logLevel : CellErrorLevel.error)); + } + function computeCell(cell) { + if (!cell.isFormula()) { + return; + } + const cellId = cell.id; + if (cellId in visited) { + if (visited[cellId] === null) { + cell.assignError(CellErrorType.CircularDependency, new CircularDependencyError()); + } + return; + } + visited[cellId] = null; + try { + compilationParameters[2].__originCellXC = () => { + // compute the value lazily for performance reasons + const position = compilationParameters[2].getters.getCellPosition(cellId); + return toXC(position.col, position.row); + }; + const computedCell = cell.compiledFormula.execute(cell.dependencies, ...compilationParameters); + cell.assignEvaluation(computedCell.value, cell.format || computedCell.format); + if (Array.isArray(cell.evaluated.value)) { + // if a value returns an array (like =A1:A3) + throw new Error(_lt("This formula depends on invalid values")); + } + } + catch (e) { + handleError(e, cell); + } + visited[cellId] = true; + } + } + /** + * Return all functions necessary to properly evaluate a formula: + * - a refFn function to read any reference, cell or range of a normalized formula + * - a range function to convert any reference to a proper value array + * - an evaluation context + */ + getCompilationParameters(computeCell) { + const evalContext = Object.assign(Object.create(functionMap), this.evalContext, { + getters: this.getters, + }); + const getters = this.getters; + function readCell(range) { + let cell; + if (!getters.tryGetSheet(range.sheetId)) { + throw new Error(_lt("Invalid sheet name")); + } + cell = getters.getCell(range.sheetId, range.zone.left, range.zone.top); + if (!cell || cell.isEmpty()) { + // magic "empty" value + // Returning {value: null} instead of undefined will ensure that we don't + // fall back on the default value of the argument provided to the formula's compute function + return { value: null }; + } + return getEvaluatedCell(cell); + } + function getEvaluatedCell(cell) { + computeCell(cell); + if (cell.evaluated.type === CellValueType.error) { + throw new EvaluationError(cell.evaluated.value, cell.evaluated.error.message, cell.evaluated.error.logLevel); + } + return cell.evaluated; + } + /** + * Return the values of the cell(s) used in reference, but always in the format of a range even + * if a single cell is referenced. It is a list of col values. This is useful for the formulas that describe parameters as + * range etc. + * + * Note that each col is possibly sparse: it only contain the values of cells + * that are actually present in the grid. + */ + function range(range) { + const sheetId = range.sheetId; + if (!isZoneValid(range.zone)) { + throw new InvalidReferenceError(); + } + // Performance issue: Avoid fetching data on positions that are out of the spreadsheet + // e.g. A1:ZZZ9999 in a sheet with 10 cols and 10 rows should ignore everything past J10 and return a 10x10 array + const sheetZone = getters.getSheetZone(sheetId); + const result = []; + const zone = intersection(range.zone, sheetZone); + if (!zone) { + result.push([]); + return result; + } + // Performance issue: nested loop is faster than a map here + for (let col = zone.left; col <= zone.right; col++) { + const rowValues = []; + for (let row = zone.top; row <= zone.bottom; row++) { + const cell = evalContext.getters.getCell(range.sheetId, col, row); + rowValues.push(cell ? getEvaluatedCell(cell) : undefined); + } + result.push(rowValues); + } + return result; + } + /** + * Returns the value of the cell(s) used in reference + * + * @param range the references used + * @param isMeta if a reference is supposed to be used in a `meta` parameter as described in the + * function for which this parameter is used, we just return the string of the parameter. + * The `compute` of the formula's function must process it completely + */ + function refFn(range, isMeta, functionName, paramNumber) { + if (isMeta) { + // Use zoneToXc of zone instead of getRangeString to avoid sending unbounded ranges + return { value: zoneToXc(range.zone) }; + } + if (!isZoneValid(range.zone)) { + throw new InvalidReferenceError(); + } + // if the formula definition could have accepted a range, we would pass through the _range function and not here + if (range.zone.bottom !== range.zone.top || range.zone.left !== range.zone.right) { + throw new Error(paramNumber + ? _lt("Function %s expects the parameter %s to be a single value or a single cell reference, not a range.", functionName.toString(), paramNumber.toString()) + : _lt("Function %s expects its parameters to be single values or single cell references, not ranges.", functionName.toString())); + } + if (range.invalidSheetName) { + throw new Error(_lt("Invalid sheet name: %s", range.invalidSheetName)); + } + return readCell(range); + } + return [refFn, range, evalContext]; + } + /** + * Triggers an evaluation of all cells on all sheets. + */ + evaluateAllSheets() { + for (const sheetId of this.getters.getSheetIds()) { + this.evaluate(sheetId); + this.isUpToDate.add(sheetId); + } + } + } + EvaluationPlugin.getters = ["evaluateFormula", "getRangeFormattedValues", "getRangeValues"]; + + class EvaluationChartPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.charts = {}; + this.createRuntimeChart = chartRuntimeFactory(this.getters); + } + handle(cmd) { + if (invalidateEvaluationCommands.has(cmd.type) || + invalidateCFEvaluationCommands.has(cmd.type) || + cmd.type === "EVALUATE_CELLS" || + cmd.type === "UPDATE_CELL") { + for (const chartId in this.charts) { + this.charts[chartId] = undefined; + } + } + switch (cmd.type) { + case "UPDATE_CHART": + case "CREATE_CHART": + case "DELETE_FIGURE": + this.charts[cmd.id] = undefined; + break; + case "REFRESH_CHART": + this.charts[cmd.id] = undefined; + this.evaluateUsedSheets([cmd.id]); //TODO Lazy evaluation for the win + break; + case "ACTIVATE_SHEET": + const chartsIds = this.getters.getChartIds(cmd.sheetIdTo); + this.evaluateUsedSheets(chartsIds); //TODO Lazy evaluation for the win + break; + case "DELETE_SHEET": + for (let chartId in this.charts) { + if (!this.getters.isChartDefined(chartId)) { + this.charts[chartId] = undefined; + } + } + break; + } + } + getChartRuntime(figureId) { + if (!this.charts[figureId]) { + const chart = this.getters.getChart(figureId); + if (!chart) { + throw new Error(`No chart for the given id: ${figureId}`); + } + this.charts[figureId] = this.createRuntimeChart(chart); + } + return this.charts[figureId]; + } + evaluateUsedSheets(chartsIds) { + const usedSheetsId = new Set(); + for (let chartId of chartsIds) { + const sheetIds = this.getters.getSheetIdsUsedInChartRanges(chartId) || []; + sheetIds.forEach((sheetId) => { + if (sheetId !== this.getters.getActiveSheetId()) { + usedSheetsId.add(sheetId); + } + }); + } + for (let sheetId of usedSheetsId) { + this.dispatch("EVALUATE_CELLS", { sheetId }); + } + } + /** + * Get the background color of a chart based on the color of the first cell of the main range + * of the chart. In order of priority, it will return : + * + * - the chart background color if one is defined + * - the fill color of the cell if one is defined + * - the fill color of the cell from conditional formats if one is defined + * - the default chart color if no other color is defined + */ + getBackgroundOfSingleCellChart(chartBackground, mainRange) { + if (chartBackground) + return chartBackground; + if (!mainRange) { + return BACKGROUND_CHART_COLOR; + } + const col = mainRange.zone.left; + const row = mainRange.zone.top; + const style = this.getters.getCellComputedStyle(mainRange.sheetId, col, row); + return style.fillColor || BACKGROUND_CHART_COLOR; + } + } + EvaluationChartPlugin.getters = ["getChartRuntime", "getBackgroundOfSingleCellChart"]; + + // ----------------------------------------------------------------------------- + // Constants + // ----------------------------------------------------------------------------- + class EvaluationConditionalFormatPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.isStale = true; + // stores the computed styles in the format of computedStyles.sheetName[col][row] = Style + this.computedStyles = {}; + this.computedIcons = {}; + /** + * Execute the predicate to know if a conditional formatting rule should be applied to a cell + */ + this.rulePredicate = { + CellIsRule: (cell, rule) => { + if (cell && cell.evaluated.type === CellValueType.error) { + return false; + } + const values = rule.values.map(parsePrimitiveContent); + switch (rule.operator) { + case "IsEmpty": + return !isDefined$1(cell) || cell.evaluated.value.toString().trim() === ""; + case "IsNotEmpty": + return isDefined$1(cell) && cell.evaluated.value.toString().trim() !== ""; + case "BeginsWith": + if (!cell && values[0] === "") { + return false; + } + return (isDefined$1(cell) && (cell === null || cell === void 0 ? void 0 : cell.evaluated.value.toString().startsWith(values[0].toString()))); + case "EndsWith": + if (!cell && values[0] === "") { + return false; + } + return isDefined$1(cell) && cell.evaluated.value.toString().endsWith(values[0].toString()); + case "Between": + return (isDefined$1(cell) && + cell.evaluated.value >= values[0] && + cell.evaluated.value <= values[1]); + case "NotBetween": + return !(isDefined$1(cell) && + cell.evaluated.value >= values[0] && + cell.evaluated.value <= values[1]); + case "ContainsText": + return (isDefined$1(cell) && cell.evaluated.value.toString().indexOf(values[0].toString()) > -1); + case "NotContains": + return (!isDefined$1(cell) || + !cell.evaluated.value || + cell.evaluated.value.toString().indexOf(values[0].toString()) == -1); + case "GreaterThan": + return isDefined$1(cell) && cell.evaluated.value > values[0]; + case "GreaterThanOrEqual": + return isDefined$1(cell) && cell.evaluated.value >= values[0]; + case "LessThan": + return isDefined$1(cell) && cell.evaluated.value < values[0]; + case "LessThanOrEqual": + return isDefined$1(cell) && cell.evaluated.value <= values[0]; + case "NotEqual": + if (!isDefined$1(cell) && values[0] === "") { + return false; + } + return isDefined$1(cell) && cell.evaluated.value !== values[0]; + case "Equal": + if (!cell && values[0] === "") { + return true; + } + return isDefined$1(cell) && cell.evaluated.value === values[0]; + default: + console.warn(_lt("Not implemented operator %s for kind of conditional formatting: %s", rule.operator, rule.type)); + } + return false; + }, + }; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + handle(cmd) { + if (invalidateCFEvaluationCommands.has(cmd.type) || + (cmd.type === "UPDATE_CELL" && "content" in cmd)) { + this.isStale = true; + } + switch (cmd.type) { + case "ACTIVATE_SHEET": + const activeSheet = cmd.sheetIdTo; + this.computedStyles[activeSheet] = this.computedStyles[activeSheet] || {}; + this.computedIcons[activeSheet] = this.computedIcons[activeSheet] || {}; + this.isStale = true; + break; + case "AUTOFILL_CELL": + const sheetId = this.getters.getActiveSheetId(); + const cfOrigin = this.getters.getRulesByCell(sheetId, cmd.originCol, cmd.originRow); + for (const cf of cfOrigin) { + this.adaptRules(sheetId, cf, [toXC(cmd.col, cmd.row)], []); + } + break; + case "PASTE_CONDITIONAL_FORMAT": + this.pasteCf(cmd.origin, cmd.target, cmd.operation); + break; + } + } + finalize() { + if (this.isStale) { + this.computeStyles(); + this.isStale = false; + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getCellComputedStyle(sheetId, col, row) { + var _a; + const cell = this.getters.getCell(sheetId, col, row); + const styles = this.computedStyles[sheetId]; + const cfStyle = styles && ((_a = styles[col]) === null || _a === void 0 ? void 0 : _a[row]); + return { + ...cell === null || cell === void 0 ? void 0 : cell.style, + ...cfStyle, + }; + } + getConditionalIcon(col, row) { + var _a; + const activeSheet = this.getters.getActiveSheetId(); + const icon = this.computedIcons[activeSheet]; + return icon && ((_a = icon[col]) === null || _a === void 0 ? void 0 : _a[row]); + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + /** + * Compute the styles according to the conditional formatting. + * This computation must happen after the cell values are computed if they change + * + * This result of the computation will be in the state.cell[XC].conditionalStyle and will be the union of all the style + * properties of the rules applied (in order). + * So if a cell has multiple conditional formatting applied to it, and each affect a different value of the style, + * the resulting style will have the combination of all those values. + * If multiple conditional formatting use the same style value, they will be applied in order so that the last applied wins + */ + computeStyles() { + var _a; + const activeSheetId = this.getters.getActiveSheetId(); + this.computedStyles[activeSheetId] = {}; + this.computedIcons[activeSheetId] = {}; + const computedStyle = this.computedStyles[activeSheetId]; + for (let cf of this.getters.getConditionalFormats(activeSheetId).reverse()) { + try { + switch (cf.rule.type) { + case "ColorScaleRule": + for (let range of cf.ranges) { + this.applyColorScale(range, cf.rule); + } + break; + case "IconSetRule": + for (let range of cf.ranges) { + this.applyIcon(range, cf.rule); + } + break; + default: + for (let ref of cf.ranges) { + const zone = this.getters.getRangeFromSheetXC(activeSheetId, ref).zone; + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + const pr = this.rulePredicate[cf.rule.type]; + let cell = this.getters.getCell(activeSheetId, col, row); + if (pr && pr(cell, cf.rule)) { + if (!computedStyle[col]) + computedStyle[col] = []; + // we must combine all the properties of all the CF rules applied to the given cell + computedStyle[col][row] = Object.assign(((_a = computedStyle[col]) === null || _a === void 0 ? void 0 : _a[row]) || {}, cf.rule.style); + } + } + } + } + break; + } + } + catch (_) { + // we don't care about the errors within the evaluation of a rule + } + } + } + parsePoint(range, threshold, functionName) { + const sheetId = this.getters.getActiveSheetId(); + const rangeValues = this.getters + .getRangeValues(this.getters.getRangeFromSheetXC(sheetId, range)) + .filter(this.isCellValueNumber); + switch (threshold.type) { + case "value": + const result = functionName === "max" ? Math.max(...rangeValues) : Math.min(...rangeValues); + return result; + case "number": + return Number(threshold.value); + case "percentage": + const min = Math.min(...rangeValues); + const max = Math.max(...rangeValues); + const delta = max - min; + return min + (delta * Number(threshold.value)) / 100; + case "percentile": + return percentile(rangeValues, Number(threshold.value) / 100, true); + case "formula": + const value = threshold.value && this.getters.evaluateFormula(threshold.value); + return !(value instanceof Promise) ? value : null; + default: + return null; + } + } + applyIcon(range, rule) { + const lowerInflectionPoint = this.parsePoint(range, rule.lowerInflectionPoint); + const upperInflectionPoint = this.parsePoint(range, rule.upperInflectionPoint); + if (lowerInflectionPoint === null || + upperInflectionPoint === null || + lowerInflectionPoint > upperInflectionPoint) { + return; + } + const activeSheetId = this.getters.getActiveSheetId(); + const zone = this.getters.getRangeFromSheetXC(activeSheetId, range).zone; + const computedIcons = this.computedIcons[activeSheetId]; + const iconSet = [rule.icons.upper, rule.icons.middle, rule.icons.lower]; + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + const cell = this.getters.getCell(activeSheetId, col, row); + if ((cell === null || cell === void 0 ? void 0 : cell.evaluated.type) !== CellValueType.number) { + continue; + } + const icon = this.computeIcon(cell.evaluated.value, upperInflectionPoint, rule.upperInflectionPoint.operator, lowerInflectionPoint, rule.lowerInflectionPoint.operator, iconSet); + if (!computedIcons[col]) { + computedIcons[col] = []; + } + computedIcons[col][row] = icon; + } + } + } + computeIcon(value, upperInflectionPoint, upperOperator, lowerInflectionPoint, lowerOperator, icons) { + if ((upperOperator === "ge" && value >= upperInflectionPoint) || + (upperOperator === "gt" && value > upperInflectionPoint)) { + return icons[0]; + } + else if ((lowerOperator === "ge" && value >= lowerInflectionPoint) || + (lowerOperator === "gt" && value > lowerInflectionPoint)) { + return icons[1]; + } + return icons[2]; + } + applyColorScale(range, rule) { + var _a; + const minValue = this.parsePoint(range, rule.minimum, "min"); + const midValue = rule.midpoint ? this.parsePoint(range, rule.midpoint) : null; + const maxValue = this.parsePoint(range, rule.maximum, "max"); + if (minValue === null || + maxValue === null || + minValue >= maxValue || + (midValue && (minValue >= midValue || midValue >= maxValue))) { + return; + } + const activeSheetId = this.getters.getActiveSheetId(); + const zone = this.getters.getRangeFromSheetXC(activeSheetId, range).zone; + const computedStyle = this.computedStyles[activeSheetId]; + const colorCellArgs = []; + if (rule.midpoint && midValue) { + colorCellArgs.push({ + minValue, + minColor: rule.minimum.color, + colorDiffUnit: this.computeColorDiffUnits(minValue, midValue, rule.minimum.color, rule.midpoint.color), + }); + colorCellArgs.push({ + minValue: midValue, + minColor: rule.midpoint.color, + colorDiffUnit: this.computeColorDiffUnits(midValue, maxValue, rule.midpoint.color, rule.maximum.color), + }); + } + else { + colorCellArgs.push({ + minValue, + minColor: rule.minimum.color, + colorDiffUnit: this.computeColorDiffUnits(minValue, maxValue, rule.minimum.color, rule.maximum.color), + }); + } + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + const cell = this.getters.getCell(activeSheetId, col, row); + if ((cell === null || cell === void 0 ? void 0 : cell.evaluated.type) === CellValueType.number) { + const value = clip(cell.evaluated.value, minValue, maxValue); + let color; + if (colorCellArgs.length === 2 && midValue) { + color = + value <= midValue + ? this.colorCell(value, colorCellArgs[0].minValue, colorCellArgs[0].minColor, colorCellArgs[0].colorDiffUnit) + : this.colorCell(value, colorCellArgs[1].minValue, colorCellArgs[1].minColor, colorCellArgs[1].colorDiffUnit); + } + else { + color = this.colorCell(value, colorCellArgs[0].minValue, colorCellArgs[0].minColor, colorCellArgs[0].colorDiffUnit); + } + if (!computedStyle[col]) + computedStyle[col] = []; + computedStyle[col][row] = ((_a = computedStyle[col]) === null || _a === void 0 ? void 0 : _a[row]) || {}; + computedStyle[col][row].fillColor = colorNumberString(color); + } + } + } + } + computeColorDiffUnits(minValue, maxValue, minColor, maxColor) { + const deltaValue = maxValue - minValue; + const deltaColorR = ((minColor >> 16) % 256) - ((maxColor >> 16) % 256); + const deltaColorG = ((minColor >> 8) % 256) - ((maxColor >> 8) % 256); + const deltaColorB = (minColor % 256) - (maxColor % 256); + const colorDiffUnitR = deltaColorR / deltaValue; + const colorDiffUnitG = deltaColorG / deltaValue; + const colorDiffUnitB = deltaColorB / deltaValue; + return [colorDiffUnitR, colorDiffUnitG, colorDiffUnitB]; + } + colorCell(value, minValue, minColor, colorDiffUnit) { + const [colorDiffUnitR, colorDiffUnitG, colorDiffUnitB] = colorDiffUnit; + const r = Math.round(((minColor >> 16) % 256) - colorDiffUnitR * (value - minValue)); + const g = Math.round(((minColor >> 8) % 256) - colorDiffUnitG * (value - minValue)); + const b = Math.round((minColor % 256) - colorDiffUnitB * (value - minValue)); + return (r << 16) | (g << 8) | b; + } + /** + * Add or remove cells to a given conditional formatting rule. + */ + adaptRules(sheetId, cf, toAdd, toRemove) { + if (toAdd.length === 0 && toRemove.length === 0) { + return; + } + const rules = this.getters.getConditionalFormats(sheetId); + const replaceIndex = rules.findIndex((c) => c.id === cf.id); + let currentRanges = []; + if (replaceIndex > -1) { + currentRanges = rules[replaceIndex].ranges; + } + currentRanges = currentRanges.concat(toAdd); + const newRangesXC = recomputeZones(currentRanges, toRemove); + this.dispatch("ADD_CONDITIONAL_FORMAT", { + cf: { + id: cf.id, + rule: cf.rule, + stopIfTrue: cf.stopIfTrue, + }, + ranges: newRangesXC.map((xc) => this.getters.getRangeDataFromXc(sheetId, xc)), + sheetId, + }); + } + pasteCf(origin, target, operation) { + const xc = toXC(target.col, target.row); + for (let rule of this.getters.getConditionalFormats(origin.sheetId)) { + for (let range of rule.ranges) { + if (isInside(origin.col, origin.row, this.getters.getRangeFromSheetXC(origin.sheetId, range).zone)) { + const cf = rule; + const toRemoveRange = []; + if (operation === "CUT") { + //remove from current rule + toRemoveRange.push(toXC(origin.col, origin.row)); + } + if (origin.sheetId === target.sheetId) { + this.adaptRules(origin.sheetId, cf, [xc], toRemoveRange); + } + else { + this.adaptRules(target.sheetId, cf, [xc], []); + this.adaptRules(origin.sheetId, cf, [], toRemoveRange); + } + } + } + } + } + isCellValueNumber(value) { + return typeof value === "number"; + } + } + EvaluationConditionalFormatPlugin.getters = ["getConditionalIcon", "getCellComputedStyle"]; + + const BORDER_COLOR = "#8B008B"; + const BACKGROUND_COLOR = "#8B008B33"; + var Direction; + (function (Direction) { + Direction[Direction["previous"] = -1] = "previous"; + Direction[Direction["current"] = 0] = "current"; + Direction[Direction["next"] = 1] = "next"; + })(Direction || (Direction = {})); + /** + * Find and Replace Plugin + * + * This plugin is used in combination with the find_and_replace sidePanel + * It is used to 'highlight' cells that match an input string according to + * the given searchOptions. The second part of this plugin makes it possible + * (again with the find_and_replace sidePanel), to replace the values that match + * the search with a new value. + */ + class FindAndReplacePlugin extends UIPlugin { + constructor() { + super(...arguments); + this.searchMatches = []; + this.selectedMatchIndex = null; + this.currentSearchRegex = null; + this.searchOptions = { + matchCase: false, + exactMatch: false, + searchFormulas: false, + }; + this.toSearch = ""; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + handle(cmd) { + switch (cmd.type) { + case "UPDATE_SEARCH": + this.updateSearch(cmd.toSearch, cmd.searchOptions); + break; + case "CLEAR_SEARCH": + this.clearSearch(); + break; + case "SELECT_SEARCH_PREVIOUS_MATCH": + this.selectNextCell(Direction.previous); + break; + case "SELECT_SEARCH_NEXT_MATCH": + this.selectNextCell(Direction.next); + break; + case "REPLACE_SEARCH": + this.replace(cmd.replaceWith); + break; + case "REPLACE_ALL_SEARCH": + this.replaceAll(cmd.replaceWith); + break; + case "UNDO": + case "REDO": + case "REMOVE_COLUMNS_ROWS": + case "ADD_COLUMNS_ROWS": + this.clearSearch(); + break; + case "ACTIVATE_SHEET": + case "REFRESH_SEARCH": + this.refreshSearch(); + break; + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getSearchMatches() { + return this.searchMatches; + } + getCurrentSelectedMatchIndex() { + return this.selectedMatchIndex; + } + // --------------------------------------------------------------------------- + // Search + // --------------------------------------------------------------------------- + /** + * Will update the current searchOptions and accordingly update the regex. + * It will then search for matches using the regex and store them. + */ + updateSearch(toSearch, searchOptions) { + this.searchOptions = searchOptions; + if (toSearch !== this.toSearch) { + this.selectedMatchIndex = 0; + } + this.toSearch = toSearch; + this.updateRegex(); + this.refreshSearch(); + } + /** + * refresh the matches according to the current search options + */ + refreshSearch() { + const matches = this.findMatches(); + this.searchMatches = matches; + this.selectNextCell(Direction.current); + } + /** + * Updates the regex based on the current searchOptions and + * the value toSearch + */ + updateRegex() { + let searchValue = this.toSearch; + const flags = !this.searchOptions.matchCase ? "i" : ""; + if (this.searchOptions.exactMatch) { + searchValue = `^${searchValue}$`; + } + this.currentSearchRegex = RegExp(searchValue, flags); + } + /** + * Find matches using the current regex + */ + findMatches() { + const activeSheetId = this.getters.getActiveSheetId(); + const cells = this.getters.getCells(activeSheetId); + const matches = []; + if (this.toSearch) { + for (const cell of Object.values(cells)) { + if (cell && + this.currentSearchRegex && + this.currentSearchRegex.test(this.searchOptions.searchFormulas + ? cell.isFormula() + ? cell.content + : String(cell.evaluated.value) + : String(cell.evaluated.value))) { + const position = this.getters.getCellPosition(cell.id); + const match = { col: position.col, row: position.row, selected: false }; + matches.push(match); + } + } + } + return matches.sort(this.sortByRowThenColumn); + } + sortByRowThenColumn(a, b) { + if (a.row === b.row) { + return a.col - b.col; + } + return a.row > b.row ? 1 : -1; + } + /** + * Changes the selected search cell. Given a direction it will + * Change the selection to the previous, current or nextCell, + * if it exists otherwise it will set the selectedMatchIndex to null. + * It will also reset the index to 0 if the search has changed. + * It is also used to keep coherence between the selected searchMatch + * and selectedMatchIndex. + */ + selectNextCell(indexChange) { + const matches = this.searchMatches; + if (!matches.length) { + this.selectedMatchIndex = null; + return; + } + let nextIndex; + if (this.selectedMatchIndex === null) { + nextIndex = 0; + } + else { + nextIndex = this.selectedMatchIndex + indexChange; + } + //modulo of negative value to be able to cycle in both directions with previous and next + nextIndex = ((nextIndex % matches.length) + matches.length) % matches.length; + if (this.selectedMatchIndex !== nextIndex) { + this.selectedMatchIndex = nextIndex; + this.selection.selectCell(matches[nextIndex].col, matches[nextIndex].row); + } + for (let index = 0; index < this.searchMatches.length; index++) { + this.searchMatches[index].selected = index === this.selectedMatchIndex; + } + } + clearSearch() { + this.toSearch = ""; + this.searchMatches = []; + this.selectedMatchIndex = null; + this.currentSearchRegex = null; + this.searchOptions = { + matchCase: false, + exactMatch: false, + searchFormulas: false, + }; + } + // --------------------------------------------------------------------------- + // Replace + // --------------------------------------------------------------------------- + /** + * Replace the value of the currently selected match + */ + replace(replaceWith) { + if (this.selectedMatchIndex === null || !this.currentSearchRegex) { + return; + } + const matches = this.searchMatches; + const selectedMatch = matches[this.selectedMatchIndex]; + const sheetId = this.getters.getActiveSheetId(); + const cellToReplace = this.getters.getCell(sheetId, selectedMatch.col, selectedMatch.row); + const toReplace = this.toReplace(cellToReplace, sheetId); + if (!cellToReplace || !toReplace) { + this.selectNextCell(Direction.next); + } + else { + const replaceRegex = new RegExp(this.currentSearchRegex.source, this.currentSearchRegex.flags + "g"); + const newContent = toReplace.toString().replace(replaceRegex, replaceWith); + this.dispatch("UPDATE_CELL", { + sheetId: this.getters.getActiveSheetId(), + col: selectedMatch.col, + row: selectedMatch.row, + content: newContent, + }); + this.searchMatches.splice(this.selectedMatchIndex, 1); + this.selectNextCell(Direction.current); + } + } + /** + * Apply the replace function to all the matches one time. + */ + replaceAll(replaceWith) { + const matchCount = this.searchMatches.length; + for (let i = 0; i < matchCount; i++) { + this.replace(replaceWith); + } + } + /** + * Determines if the content, the value or nothing should be replaced, + * based on the search and replace options + */ + toReplace(cell, sheetId) { + if (cell) { + if (this.searchOptions.searchFormulas && cell.isFormula()) { + return cell.content; + } + else if (this.searchOptions.searchFormulas || !cell.isFormula()) { + return cell.evaluated.value.toString(); + } + } + return null; + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(renderingContext) { + const { ctx } = renderingContext; + const sheetId = this.getters.getActiveSheetId(); + const viewport = this.getters.getActiveViewport(); + for (const match of this.searchMatches) { + const merge = this.getters.getMerge(sheetId, match.col, match.row); + const left = merge ? merge.left : match.col; + const right = merge ? merge.right : match.col; + const top = merge ? merge.top : match.row; + const bottom = merge ? merge.bottom : match.row; + const { x, y, width, height } = this.getters.getRect({ top, left, right, bottom }, viewport); + if (width > 0 && height > 0) { + ctx.fillStyle = BACKGROUND_COLOR; + ctx.fillRect(x, y, width, height); + if (match.selected) { + ctx.strokeStyle = BORDER_COLOR; + ctx.strokeRect(x, y, width, height); + } + } + } + } + } + FindAndReplacePlugin.layers = [3 /* Search */]; + FindAndReplacePlugin.getters = ["getSearchMatches", "getCurrentSelectedMatchIndex"]; + + class FormatPlugin extends UIPlugin { + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + handle(cmd) { + switch (cmd.type) { + case "SET_DECIMAL": + this.setDecimal(cmd.sheetId, cmd.target, cmd.step); + break; + } + } + /** + * This function allows to adjust the quantity of decimal places after a decimal + * point on cells containing number value. It does this by changing the cells + * format. Values aren't modified. + * + * The change of the decimal quantity is done one by one, the sign of the step + * variable indicates whether we are increasing or decreasing. + * + * If several cells are in the zone, the format resulting from the change of the + * first cell (with number type) will be applied to the whole zone. + */ + setDecimal(sheetId, zones, step) { + // Find the first cell with a number value and get the format + const numberFormat = this.searchNumberFormat(sheetId, zones); + if (numberFormat !== undefined) { + // Depending on the step sign, increase or decrease the decimal representation + // of the format + const newFormat = changeDecimalPlaces(numberFormat, step); + // Apply the new format on the whole zone + this.dispatch("SET_FORMATTING", { + sheetId, + target: zones, + format: newFormat, + }); + } + } + /** + * Take a range of cells and return the format of the first cell containing a + * number value. Returns a default format if the cell hasn't format. Returns + * undefined if no number value in the range. + */ + searchNumberFormat(sheetId, zones) { + var _a; + for (let zone of zones) { + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + const cell = this.getters.getCell(sheetId, col, row); + if ((cell === null || cell === void 0 ? void 0 : cell.evaluated.type) === CellValueType.number && + !((_a = cell.evaluated.format) === null || _a === void 0 ? void 0 : _a.match(DATETIME_FORMAT)) // reject dates + ) { + return cell.evaluated.format || createDefaultFormat(cell.evaluated.value); + } + } + } + } + return undefined; + } + } + FormatPlugin.modes = ["normal"]; + + /** + * HighlightPlugin + */ + class HighlightPlugin extends UIPlugin { + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getHighlights() { + return this.prepareHighlights(this.getters.getComposerHighlights().concat(this.getters.getSelectionInputHighlights())); + } + // --------------------------------------------------------------------------- + // Other + // --------------------------------------------------------------------------- + prepareHighlights(highlights) { + return highlights + .filter((x) => x.zone.top >= 0 && + x.zone.left >= 0 && + x.zone.bottom < this.getters.getNumberRows(x.sheetId) && + x.zone.right < this.getters.getNumberCols(x.sheetId)) + .map((highlight) => { + const { height, width } = zoneToDimension(highlight.zone); + const zone = height * width === 1 + ? this.getters.expandZone(highlight.sheetId, highlight.zone) + : highlight.zone; + return { + ...highlight, + zone, + }; + }); + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(renderingContext) { + // rendering selection highlights + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + const sheetId = this.getters.getActiveSheetId(); + const lineWidth = 3 * thinLineWidth; + ctx.lineWidth = lineWidth; + /** + * We only need to draw the highlights of the current sheet. + * + * Note that there can be several times the same highlight in 'this.highlights'. + * In order to avoid superposing the same color layer and modifying the final + * opacity, we filter highlights to remove duplicates. + */ + const highlights = this.getHighlights(); + for (let h of highlights.filter((highlight, index) => + // For every highlight in the sheet, deduplicated by zone + highlights.findIndex((h) => isEqual(h.zone, highlight.zone) && h.sheetId === sheetId) === + index)) { + const { x, y, width, height } = this.getters.getRect(h.zone, viewport); + if (width > 0 && height > 0) { + ctx.strokeStyle = h.color; + ctx.strokeRect(x + lineWidth / 2, y + lineWidth / 2, width - lineWidth, height - lineWidth); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = h.color + "20"; + ctx.fillRect(x + lineWidth, y + lineWidth, width - 2 * lineWidth, height - 2 * lineWidth); + } + } + } + } + HighlightPlugin.layers = [1 /* Highlights */]; + HighlightPlugin.getters = ["getHighlights"]; + + class RendererPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.boxes = []; + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + /** + * Returns the size, start and end coordinates of a column + */ + getColDimensions(sheetId, col) { + const start = this.getColRowOffset("COL", 0, col, sheetId); + const size = this.getters.getColSize(sheetId, col); + const isColHidden = this.getters.isColHidden(sheetId, col); + return { + start, + size, + end: start + (isColHidden ? 0 : size), + }; + } + /** + * Returns the size, start and end coordinates of a column relative to the left + * column of the current viewport + */ + getColDimensionsInViewport(sheetId, col) { + const { left } = this.getters.getActiveViewport(); + const start = this.getColRowOffset("COL", left, col, sheetId); + const size = this.getters.getColSize(sheetId, col); + const isColHidden = this.getters.isColHidden(sheetId, col); + return { + start, + size: size, + end: start + (isColHidden ? 0 : size), + }; + } + /** + * Returns the size, start and end coordinates of a row + */ + getRowDimensions(sheetId, row) { + const start = this.getColRowOffset("ROW", 0, row, sheetId); + const size = this.getters.getRowSize(sheetId, row); + const isRowHidden = this.getters.isRowHidden(sheetId, row); + return { + start, + size: size, + end: start + (isRowHidden ? 0 : size), + }; + } + /** + * Returns the size, start and end coordinates of a row relative to the top row + * of the current viewport + */ + getRowDimensionsInViewport(sheetId, row) { + const { top } = this.getters.getActiveViewport(); + const start = this.getColRowOffset("ROW", top, row, sheetId); + const size = this.getters.getRowSize(sheetId, row); + const isRowHidden = this.getters.isRowHidden(sheetId, row); + return { + start, + size: size, + end: start + (isRowHidden ? 0 : size), + }; + } + /** + * Returns the offset of a header (determined by the dimension) at the given index + * based on the referenceIndex given. If start === 0, this method will return + * the start attribute of the header. + */ + getColRowOffset(dimension, referenceIndex, index, sheetId = this.getters.getActiveSheetId()) { + if (index < referenceIndex) { + return -this.getColRowOffset(dimension, index, referenceIndex); + } + let offset = 0; + for (let i = referenceIndex; i < index; i++) { + if (this.getters.isHeaderHidden(sheetId, dimension, i)) { + continue; + } + offset += + dimension === "COL" + ? this.getters.getColSize(sheetId, i) + : this.getters.getRowSize(sheetId, i); + } + return offset; + } + /** + * Get the offset of a header (see getColRowOffset), adjusted with the header + * size (HEADER_HEIGHT and HEADER_WIDTH) + */ + getHeaderOffset(dimension, start, index) { + let size = this.getColRowOffset(dimension, start, index); + if (!this.getters.isDashboard()) { + size += dimension === "ROW" ? HEADER_HEIGHT : HEADER_WIDTH; + } + return size; + } + /** + * Get the actual size between two headers. + * The size from A to B is the distance between A.start and B.end + */ + getSizeBetweenHeaders(dimension, from, to) { + const sheetId = this.getters.getActiveSheetId(); + let size = 0; + for (let i = from; i <= to; i++) { + if (this.getters.isHeaderHidden(sheetId, dimension, i)) { + continue; + } + size += + dimension === "COL" + ? this.getters.getColSize(sheetId, i) + : this.getters.getRowSize(sheetId, i); + } + return size; + } + /** + * Computes the coordinates and size to draw the zone on the canvas + */ + getRect(zone, viewport) { + const { left, top } = viewport; + const x = this.getHeaderOffset("COL", left, zone.left); + const width = this.getSizeBetweenHeaders("COL", zone.left, zone.right); + const y = this.getHeaderOffset("ROW", top, zone.top); + const height = this.getSizeBetweenHeaders("ROW", zone.top, zone.bottom); + return { x, y, width, height }; + } + /** + * Check if a given position is visible in the viewport. + */ + isVisibleInViewport(col, row, viewport) { + const { right, left, top, bottom } = viewport; + return row <= bottom && row >= top && col >= left && col <= right; + } + getEdgeScrollCol(x) { + let canEdgeScroll = false; + let direction = 0; + let delay = 0; + const { width } = this.getters.getViewportDimension(); + const { width: gridWidth } = this.getters.getMaxViewportSize(this.getters.getActiveSheet()); + const { left, offsetX } = this.getters.getActiveViewport(); + if (x < 0 && left > 0) { + canEdgeScroll = true; + direction = -1; + delay = scrollDelay(-x); + } + else if (x > width && offsetX < gridWidth - width) { + canEdgeScroll = true; + direction = +1; + delay = scrollDelay(x - width); + } + return { canEdgeScroll, direction, delay }; + } + getEdgeScrollRow(y) { + let canEdgeScroll = false; + let direction = 0; + let delay = 0; + const { height } = this.getters.getViewportDimension(); + const { height: gridHeight } = this.getters.getMaxViewportSize(this.getters.getActiveSheet()); + const { top, offsetY } = this.getters.getActiveViewport(); + if (y < 0 && top > 0) { + canEdgeScroll = true; + direction = -1; + delay = scrollDelay(-y); + } + else if (y > height && offsetY < gridHeight - height) { + canEdgeScroll = true; + direction = +1; + delay = scrollDelay(y - height); + } + return { canEdgeScroll, direction, delay }; + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(renderingContext, layer) { + switch (layer) { + case 0 /* Background */: + this.boxes = this.getGridBoxes(renderingContext); + this.drawBackground(renderingContext); + this.drawCellBackground(renderingContext); + this.drawBorders(renderingContext); + this.drawTexts(renderingContext); + this.drawIcon(renderingContext); + break; + case 7 /* Headers */: + if (this.getters.isDashboard()) { + return; + } + this.drawHeaders(renderingContext); + break; + } + } + drawBackground(renderingContext) { + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + const { width, height } = this.getters.getViewportDimensionWithHeaders(); + const sheetId = this.getters.getActiveSheetId(); + // white background + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + // background grid + const { right, left, top, bottom } = viewport; + if (!this.getters.getGridLinesVisibility(sheetId) || this.getters.isDashboard()) { + return; + } + ctx.lineWidth = 2 * thinLineWidth; + ctx.strokeStyle = CELL_BORDER_COLOR; + ctx.beginPath(); + // vertical lines + for (let i = left; i <= right; i++) { + if (this.getters.isColHidden(sheetId, i)) { + continue; + } + const zone = { top, bottom, left: i, right: i }; + const { x, width: colWidth, height: colHeight } = this.getRect(zone, viewport); + ctx.moveTo(x + colWidth, 0); + ctx.lineTo(x + colWidth, Math.min(height, colHeight + (this.getters.isDashboard() ? 0 : HEADER_HEIGHT))); + } + // horizontal lines + for (let i = top; i <= bottom; i++) { + if (this.getters.isRowHidden(sheetId, i)) { + continue; + } + const zone = { left, right, top: i, bottom: i }; + const { y, width: rowWidth, height: rowHeight } = this.getRect(zone, viewport); + ctx.moveTo(0, y + rowHeight); + ctx.lineTo(Math.min(width, rowWidth + (this.getters.isDashboard() ? 0 : HEADER_WIDTH)), y + rowHeight); + } + ctx.stroke(); + } + drawCellBackground(renderingContext) { + const { ctx, thinLineWidth } = renderingContext; + const areGridLinesVisible = !this.getters.isDashboard() && + this.getters.getGridLinesVisibility(this.getters.getActiveSheetId()); + ctx.lineWidth = areGridLinesVisible ? 0.3 * thinLineWidth : thinLineWidth; + const inset = areGridLinesVisible ? 0.1 * thinLineWidth : 0; + ctx.strokeStyle = "#111"; + for (let box of this.boxes) { + // fill color + let style = box.style; + if ((style.fillColor && style.fillColor !== "#ffffff") || box.isMerge) { + ctx.fillStyle = style.fillColor || "#ffffff"; + if (areGridLinesVisible) { + ctx.fillRect(box.x, box.y, box.width, box.height); + ctx.strokeRect(box.x + inset, box.y + inset, box.width - 2 * inset, box.height - 2 * inset); + } + else { + ctx.fillRect(box.x - thinLineWidth, box.y - thinLineWidth, box.width + 2 * thinLineWidth, box.height + 2 * thinLineWidth); + } + } + if (box.error) { + ctx.fillStyle = "red"; + ctx.beginPath(); + ctx.moveTo(box.x + box.width - 5, box.y); + ctx.lineTo(box.x + box.width, box.y); + ctx.lineTo(box.x + box.width, box.y + 5); + ctx.fill(); + } + } + } + drawBorders(renderingContext) { + const { ctx, thinLineWidth } = renderingContext; + for (let box of this.boxes) { + // fill color + let border = box.border; + if (border) { + const { x, y, width, height } = box; + if (border.left) { + drawBorder(border.left, x, y, x, y + height); + } + if (border.top) { + drawBorder(border.top, x, y, x + width, y); + } + if (border.right) { + drawBorder(border.right, x + width, y, x + width, y + height); + } + if (border.bottom) { + drawBorder(border.bottom, x, y + height, x + width, y + height); + } + } + } + function drawBorder([style, color], x1, y1, x2, y2) { + ctx.strokeStyle = color; + ctx.lineWidth = (style === "thin" ? 2 : 3) * thinLineWidth; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + } + } + drawTexts(renderingContext) { + const { ctx, thinLineWidth } = renderingContext; + ctx.textBaseline = "middle"; + let currentFont; + for (let box of this.boxes) { + if (box.content) { + const style = box.style || {}; + const align = box.content.align || "left"; + const italic = style.italic ? "italic " : ""; + const weight = style.bold ? "bold" : DEFAULT_FONT_WEIGHT; + const sizeInPt = style.fontSize || DEFAULT_FONT_SIZE; + const size = fontSizeMap[sizeInPt]; + const font = `${italic}${weight} ${size}px ${DEFAULT_FONT}`; + if (font !== currentFont) { + currentFont = font; + ctx.font = font; + } + ctx.fillStyle = style.textColor || "#000"; + let x; + let y = box.y + box.height / 2 + 1; + if (align === "left") { + x = box.x + (box.image ? box.image.size + 2 * MIN_CF_ICON_MARGIN : MIN_CELL_TEXT_MARGIN); + } + else if (align === "right") { + x = box.x + box.width - MIN_CELL_TEXT_MARGIN; + } + else { + x = box.x + box.width / 2; + } + ctx.textAlign = align; + if (box.clipRect) { + ctx.save(); + ctx.beginPath(); + const { x, y, width, height } = box.clipRect; + ctx.rect(x, y, width, height); + ctx.clip(); + } + ctx.fillText(box.content.text, Math.round(x), Math.round(y)); + if (style.strikethrough || style.underline) { + if (align === "right") { + x = x - box.content.width; + } + else if (align === "center") { + x = x - box.content.width / 2; + } + if (style.strikethrough) { + ctx.fillRect(x, y, box.content.width, 2.6 * thinLineWidth); + } + if (style.underline) { + y = box.y + box.height / 2 + 1 + size / 2; + ctx.fillRect(x, y, box.content.width, 1.3 * thinLineWidth); + } + } + if (box.clipRect) { + ctx.restore(); + } + } + } + } + drawIcon(renderingContext) { + const { ctx } = renderingContext; + for (const box of this.boxes) { + if (box.image) { + const icon = box.image.image; + const size = box.image.size; + const margin = (box.height - size) / 2; + if (box.image.clipIcon) { + ctx.save(); + ctx.beginPath(); + const { x, y, width, height } = box.image.clipIcon; + ctx.rect(x, y, width, height); + ctx.clip(); + } + ctx.drawImage(icon, box.x + MIN_CF_ICON_MARGIN, box.y + margin, size, size); + if (box.image.clipIcon) { + ctx.restore(); + } + } + } + } + drawHeaders(renderingContext) { + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + const { right, left, top, bottom } = viewport; + const { width, height } = this.getters.getViewportDimensionWithHeaders(); + const selection = this.getters.getSelectedZones(); + const sheetId = this.getters.getActiveSheetId(); + const numberOfCols = this.getters.getNumberCols(sheetId); + const numberOfRows = this.getters.getNumberRows(sheetId); + const activeCols = this.getters.getActiveCols(); + const activeRows = this.getters.getActiveRows(); + ctx.fillStyle = BACKGROUND_HEADER_COLOR; + ctx.font = `400 ${HEADER_FONT_SIZE}px ${DEFAULT_FONT}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.lineWidth = thinLineWidth; + ctx.strokeStyle = "#333"; + // background + ctx.fillRect(0, 0, width, HEADER_HEIGHT); + ctx.fillRect(0, 0, HEADER_WIDTH, height); + // selection background + ctx.fillStyle = BACKGROUND_HEADER_SELECTED_COLOR; + for (let zone of selection) { + const colZone = intersection(zone, { ...viewport, top: 0, bottom: numberOfRows - 1 }); + if (colZone) { + const { x, width } = this.getRect(colZone, viewport); + ctx.fillStyle = activeCols.has(zone.left) + ? BACKGROUND_HEADER_ACTIVE_COLOR + : BACKGROUND_HEADER_SELECTED_COLOR; + ctx.fillRect(x, 0, width, HEADER_HEIGHT); + } + const rowZone = intersection(zone, { ...viewport, left: 0, right: numberOfCols - 1 }); + if (rowZone) { + const { y, height } = this.getRect(rowZone, viewport); + ctx.fillStyle = activeRows.has(zone.top) + ? BACKGROUND_HEADER_ACTIVE_COLOR + : BACKGROUND_HEADER_SELECTED_COLOR; + ctx.fillRect(0, y, HEADER_WIDTH, height); + } + } + // 2 main lines + ctx.beginPath(); + ctx.moveTo(HEADER_WIDTH, 0); + ctx.lineTo(HEADER_WIDTH, height); + ctx.moveTo(0, HEADER_HEIGHT); + ctx.lineTo(width, HEADER_HEIGHT); + ctx.strokeStyle = HEADER_BORDER_COLOR; + ctx.stroke(); + ctx.beginPath(); + // column text + separator + for (let i = left; i <= right; i++) { + const colSize = this.getters.getColSize(sheetId, i); + const isColHidden = this.getters.isColHidden(sheetId, i); + if (isColHidden) { + continue; + } + const colName = numberToLetters(i); + ctx.fillStyle = activeCols.has(i) ? "#fff" : TEXT_HEADER_COLOR; + let colStart = this.getHeaderOffset("COL", viewport.left, i); + ctx.fillText(colName, colStart + colSize / 2, HEADER_HEIGHT / 2); + ctx.moveTo(colStart + colSize, 0); + ctx.lineTo(colStart + colSize, HEADER_HEIGHT); + } + // row text + separator + for (let i = top; i <= bottom; i++) { + const rowSize = this.getters.getRowSize(sheetId, i); + const isRowHidden = this.getters.isRowHidden(sheetId, i); + if (isRowHidden) { + continue; + } + ctx.fillStyle = activeRows.has(i) ? "#fff" : TEXT_HEADER_COLOR; + let rowStart = this.getHeaderOffset("ROW", viewport.top, i); + ctx.fillText(String(i + 1), HEADER_WIDTH / 2, rowStart + rowSize / 2); + ctx.moveTo(0, rowStart + rowSize); + ctx.lineTo(HEADER_WIDTH, rowStart + rowSize); + } + ctx.stroke(); + } + hasContent(col, row) { + const sheetId = this.getters.getActiveSheetId(); + const cell = this.getters.getCell(sheetId, col, row); + return (cell && !cell.isEmpty()) || this.getters.isInMerge(sheetId, col, row); + } + findNextEmptyCol(base, max, row) { + let col = base; + while (col < max && !this.hasContent(col + 1, row)) { + col++; + } + return col; + } + findPreviousEmptyCol(base, min, row) { + let col = base; + while (col > min && !this.hasContent(col - 1, row)) { + col--; + } + return col; + } + computeCellAlignment(cell, isOverflowing) { + if (cell.isFormula() && this.getters.shouldShowFormulas()) { + return "left"; + } + const { align } = this.getters.getCellStyle(cell); + if (isOverflowing && cell.evaluated.type === CellValueType.number) { + return align !== "center" ? "left" : align; + } + return align || cell.defaultAlign; + } + createZoneBox(sheetId, zone, viewport) { + const { right, left } = viewport; + const col = zone.left; + const row = zone.top; + const cell = this.getters.getCell(sheetId, col, row); + const showFormula = this.getters.shouldShowFormulas(); + const { x, y, width, height } = this.getRect(zone, viewport); + const box = { + x, + y, + width, + height, + border: this.getters.getCellBorder(sheetId, col, row) || undefined, + style: this.getters.getCellComputedStyle(sheetId, col, row), + }; + if (!cell) { + return box; + } + /** Icon CF */ + const cfIcon = this.getters.getConditionalIcon(col, row); + const fontSize = box.style.fontSize || DEFAULT_FONT_SIZE; + const fontSizePX = fontSizeMap[fontSize]; + const iconBoxWidth = cfIcon ? 2 * MIN_CF_ICON_MARGIN + fontSizePX : 0; + if (cfIcon) { + box.image = { + type: "icon", + size: fontSizePX, + clipIcon: { x: box.x, y: box.y, width: Math.min(iconBoxWidth, width), height }, + image: ICONS[cfIcon].img, + }; + } + /** Content */ + const text = this.getters.getCellText(cell, showFormula); + const textWidth = this.getters.getTextWidth(cell); + const contentWidth = iconBoxWidth + textWidth; + const align = this.computeCellAlignment(cell, contentWidth > width); + box.content = { + text, + width: textWidth, + align, + }; + /** Error */ + if (cell.evaluated.type === CellValueType.error && + cell.evaluated.error.logLevel > CellErrorLevel.silent) { + box.error = cell.evaluated.error.message; + } + /** ClipRect */ + const isOverflowing = contentWidth > width || fontSizeMap[fontSize] > height; + if (cfIcon) { + box.clipRect = { + x: box.x + iconBoxWidth, + y: box.y, + width: Math.max(0, width - iconBoxWidth), + height, + }; + } + else if (isOverflowing) { + let nextColIndex, previousColIndex; + const isCellInMerge = this.getters.isInMerge(sheetId, col, row); + if (isCellInMerge) { + // Always clip merges + nextColIndex = this.getters.getMerge(sheetId, col, row).right; + previousColIndex = col; + } + else { + nextColIndex = this.findNextEmptyCol(col, right, row); + previousColIndex = this.findPreviousEmptyCol(col, left, row); + } + switch (align) { + case "left": { + const emptyZoneOnTheLeft = positionToZone({ col: nextColIndex, row }); + const { x, y, width, height } = this.getRect(union(zone, emptyZoneOnTheLeft), viewport); + if (width < textWidth || fontSizePX > height) { + box.clipRect = { x, y, width, height }; + } + break; + } + case "right": { + const emptyZoneOnTheRight = positionToZone({ col: previousColIndex, row }); + const { x, y, width, height } = this.getRect(union(zone, emptyZoneOnTheRight), viewport); + if (width < textWidth || fontSizePX > height) { + box.clipRect = { x, y, width, height }; + } + break; + } + case "center": { + const emptyZone = { + ...zone, + right: nextColIndex, + left: previousColIndex, + }; + const { x, y, width, height } = this.getRect(emptyZone, viewport); + if (width < textWidth || + previousColIndex === col || + nextColIndex === col || + fontSizePX > height) { + box.clipRect = { x, y, width, height }; + } + break; + } + } + } + return box; + } + getGridBoxes(renderingContext) { + const boxes = []; + const viewport = this.getters.getActiveViewport(); + const { right, left, top, bottom } = viewport; + const sheet = this.getters.getActiveSheet(); + const { id: sheetId } = sheet; + for (let rowNumber = top; rowNumber <= bottom; rowNumber++) { + if (this.getters.isRowHidden(sheetId, rowNumber)) { + continue; + } + for (let colNumber = left; colNumber <= right; colNumber++) { + if (this.getters.isColHidden(sheetId, colNumber)) { + continue; + } + if (this.getters.isInMerge(sheetId, colNumber, rowNumber)) { + continue; + } + boxes.push(this.createZoneBox(sheetId, positionToZone({ col: colNumber, row: rowNumber }), viewport)); + } + } + for (const merge of this.getters.getMerges(sheetId)) { + if (this.getters.isMergeHidden(sheetId, merge)) { + continue; + } + if (overlap(merge, viewport)) { + const box = this.createZoneBox(sheetId, merge, viewport); + const borderBottomRight = this.getters.getCellBorder(sheetId, merge.right, merge.bottom); + box.border = { + ...box.border, + bottom: borderBottomRight ? borderBottomRight.bottom : undefined, + right: borderBottomRight ? borderBottomRight.right : undefined, + }; + box.isMerge = true; + boxes.push(box); + } + } + return boxes; + } + } + RendererPlugin.layers = [0 /* Background */, 7 /* Headers */]; + RendererPlugin.getters = [ + "getColDimensions", + "getColDimensionsInViewport", + "getRowDimensions", + "getRowDimensionsInViewport", + "getRect", + "isVisibleInViewport", + "getEdgeScrollCol", + "getEdgeScrollRow", + ]; + + const selectionStatisticFunctions = [ + { + name: _lt("Sum"), + types: [CellValueType.number], + compute: (values) => SUM.compute([values]), + }, + { + name: _lt("Avg"), + types: [CellValueType.number], + compute: (values) => AVERAGE.compute([values]), + }, + { + name: _lt("Min"), + types: [CellValueType.number], + compute: (values) => MIN.compute([values]), + }, + { + name: _lt("Max"), + types: [CellValueType.number], + compute: (values) => MAX.compute([values]), + }, + { + name: _lt("Count"), + types: [CellValueType.number, CellValueType.text, CellValueType.boolean, CellValueType.error], + compute: (values) => COUNTA.compute([values]), + }, + { + name: _lt("Count Numbers"), + types: [CellValueType.number, CellValueType.text, CellValueType.boolean, CellValueType.error], + compute: (values) => COUNT.compute([values]), + }, + ]; + /** + * SelectionPlugin + */ + class GridSelectionPlugin extends UIPlugin { + constructor(getters, state, dispatch, config, selection) { + super(getters, state, dispatch, config, selection); + this.gridSelection = { + anchor: { + cell: { col: 0, row: 0 }, + zone: { top: 0, left: 0, bottom: 0, right: 0 }, + }, + zones: [{ top: 0, left: 0, bottom: 0, right: 0 }], + }; + this.selectedFigureId = null; + this.sheetsData = {}; + // This flag is used to avoid to historize the ACTIVE_SHEET command when it's + // the main command. + this.activeSheet = null; + this.moveClient = config.moveClient; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "ACTIVATE_SHEET": + try { + this.getters.getSheet(cmd.sheetIdTo); + break; + } + catch (error) { + return 25 /* InvalidSheetId */; + } + case "MOVE_COLUMNS_ROWS": + return this.isMoveElementAllowed(cmd); + } + return 0 /* Success */; + } + handleEvent(event) { + const anchor = event.anchor; + let zones = []; + switch (event.mode) { + case "overrideSelection": + zones = [anchor.zone]; + break; + case "updateAnchor": + zones = [...this.gridSelection.zones]; + const index = zones.findIndex((z) => isEqual(z, event.previousAnchor.zone)); + if (index >= 0) { + zones[index] = anchor.zone; + } + break; + case "newAnchor": + zones = [...this.gridSelection.zones, anchor.zone]; + break; + } + this.setSelectionMixin(event.anchor, zones); + const { col, row } = this.gridSelection.anchor.cell; + this.moveClient({ + sheetId: this.getters.getActiveSheetId(), + col, + row, + }); + this.selectedFigureId = null; + } + handle(cmd) { + switch (cmd.type) { + case "START_EDITION": + case "ACTIVATE_SHEET": + this.selectedFigureId = null; + break; + case "DELETE_FIGURE": + if (this.selectedFigureId === cmd.id) { + this.selectedFigureId = null; + } + break; + case "DELETE_SHEET": + if (this.selectedFigureId && this.getters.getFigure(cmd.sheetId, this.selectedFigureId)) { + this.selectedFigureId = null; + } + break; + } + switch (cmd.type) { + case "START": + const firstSheetId = this.getters.getVisibleSheetIds()[0]; + this.dispatch("ACTIVATE_SHEET", { + sheetIdTo: firstSheetId, + sheetIdFrom: firstSheetId, + }); + this.selection.registerAsDefault(this, this.gridSelection.anchor, { + handleEvent: this.handleEvent.bind(this), + }); + const { col, row } = this.getters.getNextVisibleCellPosition(firstSheetId, 0, 0); + this.selectCell(col, row); + this.moveClient({ sheetId: firstSheetId, col: 0, row: 0 }); + break; + case "ACTIVATE_SHEET": { + if (!this.getters.isSheetVisible(cmd.sheetIdTo)) { + this.dispatch("SHOW_SHEET", { sheetId: cmd.sheetIdTo }); + } + this.setActiveSheet(cmd.sheetIdTo); + const { col, row } = this.gridSelection.anchor.cell; + this.sheetsData[cmd.sheetIdFrom] = { + gridSelection: deepCopy(this.gridSelection), + activeCol: col, + activeRow: row, + }; + if (cmd.sheetIdTo in this.sheetsData) { + Object.assign(this, this.sheetsData[cmd.sheetIdTo]); + this.selection.resetDefaultAnchor(this, this.gridSelection.anchor); + } + else { + const { col, row } = this.getters.getNextVisibleCellPosition(cmd.sheetIdTo, 0, 0); + this.selectCell(col, row); + } + break; + } + case "REMOVE_COLUMNS_ROWS": { + const sheetId = this.getters.getActiveSheetId(); + if (cmd.sheetId === sheetId) { + if (cmd.dimension === "COL") { + this.onColumnsRemoved(cmd); + } + else { + this.onRowsRemoved(cmd); + } + const { col, row } = this.gridSelection.anchor.cell; + this.moveClient({ sheetId, col, row }); + } + break; + } + case "ADD_COLUMNS_ROWS": { + const sheetId = this.getters.getActiveSheetId(); + if (cmd.sheetId === sheetId) { + this.onAddElements(cmd); + const { col, row } = this.gridSelection.anchor.cell; + this.moveClient({ sheetId, col, row }); + } + break; + } + case "MOVE_COLUMNS_ROWS": + if (cmd.sheetId === this.getActiveSheetId()) { + this.onMoveElements(cmd); + } + break; + case "UPDATE_CHART": + case "SELECT_FIGURE": + this.selectedFigureId = cmd.id; + break; + case "ACTIVATE_NEXT_SHEET": + this.activateNextSheet("right"); + break; + case "ACTIVATE_PREVIOUS_SHEET": + this.activateNextSheet("left"); + break; + case "HIDE_SHEET": + if (cmd.sheetId === this.getActiveSheetId()) { + this.dispatch("ACTIVATE_SHEET", { + sheetIdFrom: cmd.sheetId, + sheetIdTo: this.getters.getVisibleSheetIds()[0], + }); + } + break; + case "UNDO": + case "REDO": + case "DELETE_SHEET": + if (!this.getters.tryGetSheet(this.getters.getActiveSheetId())) { + const currentSheets = this.getters.getVisibleSheetIds(); + this.activeSheet = this.getters.getSheet(currentSheets[0]); + this.selectCell(0, 0); + this.moveClient({ + sheetId: this.getters.getActiveSheetId(), + col: 0, + row: 0, + }); + } + const deletedSheetIds = Object.keys(this.sheetsData).filter((sheetId) => !this.getters.tryGetSheet(sheetId)); + for (const sheetId of deletedSheetIds) { + delete this.sheetsData[sheetId]; + } + for (const sheetId in this.sheetsData) { + const { anchor } = this.clipSelection(sheetId, this.sheetsData[sheetId].gridSelection); + this.sheetsData[sheetId] = { + gridSelection: this.gridSelection, + activeCol: anchor.cell.col, + activeRow: anchor.cell.row, + }; + } + const sheetId = this.getters.getActiveSheetId(); + this.gridSelection.zones = this.gridSelection.zones.map((z) => this.getters.expandZone(sheetId, z)); + this.gridSelection.anchor.zone = this.getters.expandZone(sheetId, this.gridSelection.anchor.zone); + this.ensureSelectionValidity(); + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getActiveSheet() { + return this.activeSheet; + } + getActiveSheetId() { + return this.activeSheet.id; + } + getActiveCell() { + const sheetId = this.getters.getActiveSheetId(); + const { col, row } = this.gridSelection.anchor.cell; + const { col: mainCol, row: mainRow } = this.getters.getMainCellPosition(sheetId, col, row); + return this.getters.getCell(sheetId, mainCol, mainRow); + } + getActiveCols() { + const activeCols = new Set(); + for (let zone of this.gridSelection.zones) { + if (zone.top === 0 && + zone.bottom === this.getters.getNumberRows(this.getters.getActiveSheetId()) - 1) { + for (let i = zone.left; i <= zone.right; i++) { + activeCols.add(i); + } + } + } + return activeCols; + } + getActiveRows() { + const activeRows = new Set(); + const sheetId = this.getters.getActiveSheetId(); + for (let zone of this.gridSelection.zones) { + if (zone.left === 0 && zone.right === this.getters.getNumberCols(sheetId) - 1) { + for (let i = zone.top; i <= zone.bottom; i++) { + activeRows.add(i); + } + } + } + return activeRows; + } + getCurrentStyle() { + const cell = this.getters.getActiveCell(); + return cell ? this.getters.getCellStyle(cell) : {}; + } + getSelectedZones() { + return deepCopy(this.gridSelection.zones); + } + getSelectedZone() { + return deepCopy(this.gridSelection.anchor.zone); + } + getSelection() { + return deepCopy(this.gridSelection); + } + getSelectedFigureId() { + return this.selectedFigureId; + } + getPosition() { + return { col: this.gridSelection.anchor.cell.col, row: this.gridSelection.anchor.cell.row }; + } + getSheetPosition(sheetId) { + if (sheetId === this.getters.getActiveSheetId()) { + return this.getPosition(); + } + else { + const sheetData = this.sheetsData[sheetId]; + return sheetData + ? { + col: sheetData.gridSelection.anchor.cell.col, + row: sheetData.gridSelection.anchor.cell.row, + } + : this.getters.getNextVisibleCellPosition(sheetId, 0, 0); + } + } + getStatisticFnResults() { + // get deduplicated cells in zones + const cells = new Set(this.gridSelection.zones + .map((zone) => this.getters.getCellsInZone(this.getters.getActiveSheetId(), zone)) + .flat() + .filter((cell) => cell !== undefined)); + let cellsTypes = new Set(); + let cellsValues = []; + for (let cell of cells) { + cellsTypes.add(cell.evaluated.type); + cellsValues.push(cell.evaluated.value); + } + let statisticFnResults = {}; + for (let fn of selectionStatisticFunctions) { + // We don't want to display statistical information when there is no interest: + // We set the statistical result to undefined if the data handled by the selection + // does not match the data handled by the function. + // Ex: if there are only texts in the selection, we prefer that the SUM result + // be displayed as undefined rather than 0. + let fnResult = undefined; + if (fn.types.some((t) => cellsTypes.has(t))) { + fnResult = fn.compute(cellsValues); + } + statisticFnResults[fn.name] = fnResult; + } + return statisticFnResults; + } + getAggregate() { + let aggregate = 0; + let n = 0; + const sheetId = this.getters.getActiveSheetId(); + const cellPositions = this.gridSelection.zones.map(positions).flat(); + for (const { col, row } of cellPositions) { + const cell = this.getters.getCell(sheetId, col, row); + if ((cell === null || cell === void 0 ? void 0 : cell.evaluated.type) === CellValueType.number) { + n++; + aggregate += cell.evaluated.value; + } + } + return n < 2 ? null : formatValue(aggregate); + } + isSelected(zone) { + return !!this.getters.getSelectedZones().find((z) => isEqual(z, zone)); + } + /** + * Returns a sorted array of indexes of all columns (respectively rows depending + * on the dimension parameter) intersected by the currently selected zones. + * + * example: + * assume selectedZones: [{left:0, right: 2, top :2, bottom: 4}, {left:5, right: 6, top :3, bottom: 5}] + * + * if dimension === "COL" => [0,1,2,5,6] + * if dimension === "ROW" => [2,3,4,5] + */ + getElementsFromSelection(dimension) { + if (dimension === "COL" && this.getters.getActiveCols().size === 0) { + return []; + } + if (dimension === "ROW" && this.getters.getActiveRows().size === 0) { + return []; + } + const zones = this.getters.getSelectedZones(); + let elements = []; + const start = dimension === "COL" ? "left" : "top"; + const end = dimension === "COL" ? "right" : "bottom"; + for (const zone of zones) { + const zoneRows = Array.from({ length: zone[end] - zone[start] + 1 }, (_, i) => zone[start] + i); + elements = elements.concat(zoneRows); + } + return [...new Set(elements)].sort(); + } + // --------------------------------------------------------------------------- + // Other + // --------------------------------------------------------------------------- + setSelectionMixin(anchor, zones) { + const { anchor: clippedAnchor, zones: clippedZones } = this.clipSelection(this.getters.getActiveSheetId(), { anchor, zones }); + this.gridSelection.anchor.cell.col = clippedAnchor.cell.col; + this.gridSelection.anchor.cell.row = clippedAnchor.cell.row; + this.gridSelection.anchor.zone = clippedAnchor.zone; + this.gridSelection.zones = uniqueZones(clippedZones); + } + /** + * Change the anchor of the selection active cell to an absolute col and row index. + * + * This is a non trivial task. We need to stop the editing process and update + * properly the current selection. Also, this method can optionally create a new + * range in the selection. + */ + selectCell(col, row) { + const sheetId = this.getters.getActiveSheetId(); + const zone = this.getters.expandZone(sheetId, { left: col, right: col, top: row, bottom: row }); + this.setSelectionMixin({ zone, cell: { col, row } }, [zone]); + } + setActiveSheet(id) { + const sheet = this.getters.getSheet(id); + this.activeSheet = sheet; + } + activateNextSheet(direction) { + const sheetIds = this.getters.getSheetIds(); + const oldSheetPosition = sheetIds.findIndex((id) => id === this.activeSheet.id); + const delta = direction === "left" ? sheetIds.length - 1 : 1; + const newPosition = (oldSheetPosition + delta) % sheetIds.length; + this.dispatch("ACTIVATE_SHEET", { + sheetIdFrom: this.getActiveSheetId(), + sheetIdTo: sheetIds[newPosition], + }); + } + onColumnsRemoved(cmd) { + const { cell, zone } = this.gridSelection.anchor; + const selectedZone = updateSelectionOnDeletion(zone, "left", [...cmd.elements]); + let anchorZone = { left: cell.col, right: cell.col, top: cell.row, bottom: cell.row }; + anchorZone = updateSelectionOnDeletion(anchorZone, "left", [...cmd.elements]); + const anchor = { + cell: { + col: anchorZone.left, + row: anchorZone.top, + }, + zone: selectedZone, + }; + this.setSelectionMixin(anchor, [selectedZone]); + this.ensureSelectionValidity(); + } + onRowsRemoved(cmd) { + const { cell, zone } = this.gridSelection.anchor; + const selectedZone = updateSelectionOnDeletion(zone, "top", [...cmd.elements]); + let anchorZone = { left: cell.col, right: cell.col, top: cell.row, bottom: cell.row }; + anchorZone = updateSelectionOnDeletion(anchorZone, "top", [...cmd.elements]); + const anchor = { + cell: { + col: anchorZone.left, + row: anchorZone.top, + }, + zone: selectedZone, + }; + this.setSelectionMixin(anchor, [selectedZone]); + this.ensureSelectionValidity(); + } + onAddElements(cmd) { + const selection = this.gridSelection.anchor.zone; + const zone = updateSelectionOnInsertion(selection, cmd.dimension === "COL" ? "left" : "top", cmd.base, cmd.position, cmd.quantity); + const anchor = { cell: { col: zone.left, row: zone.top }, zone }; + this.setSelectionMixin(anchor, [zone]); + } + onMoveElements(cmd) { + const thickness = cmd.elements.length; + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: cmd.dimension, + sheetId: cmd.sheetId, + base: cmd.base, + quantity: thickness, + position: "before", + }); + const isCol = cmd.dimension === "COL"; + const start = cmd.elements[0]; + const end = cmd.elements[thickness - 1]; + const isBasedBefore = cmd.base < start; + const deltaCol = isBasedBefore && isCol ? thickness : 0; + const deltaRow = isBasedBefore && !isCol ? thickness : 0; + this.dispatch("CUT", { + target: [ + { + left: isCol ? start + deltaCol : 0, + right: isCol ? end + deltaCol : this.getters.getNumberCols(cmd.sheetId) - 1, + top: !isCol ? start + deltaRow : 0, + bottom: !isCol ? end + deltaRow : this.getters.getNumberRows(cmd.sheetId) - 1, + }, + ], + }); + this.dispatch("PASTE", { + target: [ + { + left: isCol ? cmd.base : 0, + right: isCol ? cmd.base + thickness - 1 : this.getters.getNumberCols(cmd.sheetId) - 1, + top: !isCol ? cmd.base : 0, + bottom: !isCol ? cmd.base + thickness - 1 : this.getters.getNumberRows(cmd.sheetId) - 1, + }, + ], + }); + const toRemove = isBasedBefore ? cmd.elements.map((el) => el + thickness) : cmd.elements; + let currentIndex = cmd.base; + for (const element of toRemove) { + const size = cmd.dimension === "COL" + ? this.getters.getColSize(cmd.sheetId, element) + : this.getters.getRowSize(cmd.sheetId, element); + this.dispatch("RESIZE_COLUMNS_ROWS", { + dimension: cmd.dimension, + sheetId: cmd.sheetId, + size, + elements: [currentIndex], + }); + currentIndex += 1; + } + this.dispatch("REMOVE_COLUMNS_ROWS", { + dimension: cmd.dimension, + sheetId: cmd.sheetId, + elements: toRemove, + }); + } + isMoveElementAllowed(cmd) { + const isCol = cmd.dimension === "COL"; + const start = cmd.elements[0]; + const end = cmd.elements[cmd.elements.length - 1]; + const id = cmd.sheetId; + const doesElementsHaveCommonMerges = isCol + ? this.getters.doesColumnsHaveCommonMerges + : this.getters.doesRowsHaveCommonMerges; + if (doesElementsHaveCommonMerges(id, start - 1, start) || + doesElementsHaveCommonMerges(id, end, end + 1) || + doesElementsHaveCommonMerges(id, cmd.base - 1, cmd.base)) { + return 2 /* WillRemoveExistingMerge */; + } + return 0 /* Success */; + } + //------------------------------------------- + // Helpers for extensions + // ------------------------------------------ + /** + * Ensure selections are not outside sheet boundaries. + * They are clipped to fit inside the sheet if needed. + */ + ensureSelectionValidity() { + let { anchor, zones } = this.clipSelection(this.getters.getActiveSheetId(), this.gridSelection); + this.gridSelection.zones = uniqueZones(zones); + this.gridSelection.anchor.cell.col = anchor.cell.col; + this.gridSelection.anchor.cell.row = anchor.cell.row; + this.gridSelection.anchor.zone = anchor.zone; + } + /** + * Clip the selection if it spans outside the sheet + */ + clipSelection(sheetId, selection) { + const cols = this.getters.getNumberCols(sheetId) - 1; + const rows = this.getters.getNumberRows(sheetId) - 1; + const zones = selection.zones.map((z) => { + return { + left: clip(z.left, 0, cols), + right: clip(z.right, 0, cols), + top: clip(z.top, 0, rows), + bottom: clip(z.bottom, 0, rows), + }; + }); + const anchorCol = clip(selection.anchor.cell.col, 0, cols); + const anchorRow = clip(selection.anchor.cell.row, 0, rows); + const anchorZone = { + left: clip(selection.anchor.zone.left, 0, cols), + right: clip(selection.anchor.zone.right, 0, cols), + top: clip(selection.anchor.zone.top, 0, rows), + bottom: clip(selection.anchor.zone.bottom, 0, rows), + }; + return { + zones, + anchor: { + cell: { col: anchorCol, row: anchorRow }, + zone: anchorZone, + }, + }; + } + // --------------------------------------------------------------------------- + // Grid rendering + // --------------------------------------------------------------------------- + drawGrid(renderingContext) { + if (this.getters.isDashboard()) { + return; + } + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + // selection + const zones = this.getSelectedZones(); + ctx.fillStyle = "#f3f7fe"; + const onlyOneCell = zones.length === 1 && zones[0].left === zones[0].right && zones[0].top === zones[0].bottom; + ctx.fillStyle = onlyOneCell ? "#f3f7fe" : "#e9f0ff"; + ctx.strokeStyle = SELECTION_BORDER_COLOR; + ctx.lineWidth = 1.5 * thinLineWidth; + ctx.globalCompositeOperation = "multiply"; + for (const zone of zones) { + const { x, y, width, height } = this.getters.getRect(zone, viewport); + ctx.fillRect(x, y, width, height); + ctx.strokeRect(x, y, width, height); + } + ctx.globalCompositeOperation = "source-over"; + // active zone + const activeSheet = this.getters.getActiveSheetId(); + const { col, row } = this.getPosition(); + ctx.strokeStyle = SELECTION_BORDER_COLOR; + ctx.lineWidth = 3 * thinLineWidth; + let zone; + if (this.getters.isInMerge(activeSheet, col, row)) { + zone = this.getters.getMerge(activeSheet, col, row); + } + else { + zone = { + top: row, + bottom: row, + left: col, + right: col, + }; + } + const { x, y, width, height } = this.getters.getRect(zone, viewport); + if (width > 0 && height > 0) { + ctx.strokeRect(x, y, width, height); + } + } + } + GridSelectionPlugin.layers = [5 /* Selection */]; + GridSelectionPlugin.getters = [ + "getActiveSheet", + "getActiveSheetId", + "getActiveCell", + "getActiveCols", + "getActiveRows", + "getCurrentStyle", + "getSelectedZones", + "getSelectedZone", + "getStatisticFnResults", + "getAggregate", + "getSelectedFigureId", + "getSelection", + "getPosition", + "getSheetPosition", + "isSelected", + "getElementsFromSelection", + ]; + + const uuidGenerator = new UuidGenerator(); + /** + * Selection input Plugin + * + * The SelectionInput component input and output are both arrays of strings, but + * it requires an intermediary internal state to work. + * This plugin handles this internal state. + */ + class SelectionInputPlugin extends UIPlugin { + constructor(getters, state, dispatch, config, selection, initialRanges, inputHasSingleRange) { + super(getters, state, dispatch, config, selection); + this.inputHasSingleRange = inputHasSingleRange; + this.ranges = []; + this.focusedRangeIndex = null; + this.willAddNewRange = false; + this.insertNewRange(0, initialRanges); + this.activeSheet = this.getters.getActiveSheetId(); + if (this.ranges.length === 0) { + this.insertNewRange(this.ranges.length, [""]); + this.focusLast(); + } + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "ADD_EMPTY_RANGE": + if (this.inputHasSingleRange && this.ranges.length === 1) { + return 27 /* MaximumRangesReached */; + } + break; + } + return 0 /* Success */; + } + handleEvent(event) { + const xc = zoneToXc(event.anchor.zone); + const inputSheetId = this.activeSheet; + const sheetId = this.getters.getActiveSheetId(); + const sheetName = this.getters.getSheetName(sheetId); + this.add([sheetId === inputSheetId ? xc : `${getComposerSheetName(sheetName)}!${xc}`]); + } + handle(cmd) { + switch (cmd.type) { + case "UNFOCUS_SELECTION_INPUT": + this.unfocus(); + break; + case "FOCUS_RANGE": + this.focus(this.getIndex(cmd.rangeId)); + break; + case "CHANGE_RANGE": { + const index = this.getIndex(cmd.rangeId); + if (index !== null && this.focusedRangeIndex !== index) { + this.focus(index); + } + if (index !== null) { + const values = cmd.value.split(",").map((reference) => reference.trim()); + this.setRange(index, values); + } + break; + } + case "ADD_EMPTY_RANGE": + this.insertNewRange(this.ranges.length, [""]); + this.focusLast(); + break; + case "REMOVE_RANGE": + const index = this.getIndex(cmd.rangeId); + if (index !== null) { + this.removeRange(index); + } + break; + case "STOP_SELECTION_INPUT": + this.willAddNewRange = false; + break; + case "PREPARE_SELECTION_INPUT_EXPANSION": { + const index = this.focusedRangeIndex; + if (index !== null && !this.inputHasSingleRange) { + this.willAddNewRange = this.ranges[index].xc.trim() !== ""; + } + break; + } + case "ACTIVATE_SHEET": { + if (cmd.sheetIdFrom !== cmd.sheetIdTo) { + const { col, row } = this.getters.getNextVisibleCellPosition(cmd.sheetIdTo, 0, 0); + const zone = this.getters.expandZone(cmd.sheetIdTo, positionToZone({ col, row })); + this.selection.resetAnchor(this, { cell: { col, row }, zone }); + } + } + } + } + unsubscribe() { + this.unfocus(); + } + // --------------------------------------------------------------------------- + // Getters || only callable by the parent + // --------------------------------------------------------------------------- + getSelectionInputValue() { + return this.cleanInputs(this.ranges.map((range) => { + return range.xc ? range.xc : ""; + })); + } + getSelectionInputHighlights() { + return this.ranges.map((input) => this.inputToHighlights(input)).flat(); + } + // --------------------------------------------------------------------------- + // Other + // --------------------------------------------------------------------------- + /** + * Focus a given range or remove the focus. + */ + focus(index) { + this.focusedRangeIndex = index; + } + focusLast() { + this.focus(this.ranges.length - 1); + } + unfocus() { + this.focusedRangeIndex = null; + } + add(newRanges) { + if (this.focusedRangeIndex === null || newRanges.length === 0) { + return; + } + if (this.willAddNewRange) { + this.insertNewRange(this.ranges.length, newRanges); + this.focusLast(); + this.willAddNewRange = false; + } + else { + this.setRange(this.focusedRangeIndex, newRanges); + } + } + setContent(index, xc) { + this.ranges[index] = { + ...this.ranges[index], + id: uuidGenerator.uuidv4(), + xc, + }; + } + /** + * Insert new inputs after the given index. + */ + insertNewRange(index, values) { + this.ranges.splice(index, 0, ...values.map((xc, i) => ({ + xc, + id: (this.ranges.length + i + 1).toString(), + color: getNextColor(), + }))); + } + /** + * Set a new value in a given range input. If more than one value is provided, + * new inputs will be added. + */ + setRange(index, values) { + const [, ...additionalValues] = values; + this.setContent(index, values[0]); + this.insertNewRange(index + 1, additionalValues); + // focus the last newly added range + if (additionalValues.length) { + this.focus(index + additionalValues.length); + } + } + removeRange(index) { + this.ranges.splice(index, 1); + if (this.focusedRangeIndex !== null) { + this.focusLast(); + } + } + /** + * Convert highlights input format to the command format. + * The first xc in the input range will keep its color. + * Invalid ranges and ranges from other sheets than the active sheets + * are ignored. + */ + inputToHighlights({ xc, color }) { + const XCs = this.cleanInputs([xc]) + .filter((range) => this.getters.isRangeValid(range)) + .filter((reference) => this.shouldBeHighlighted(this.activeSheet, reference)); + return XCs.map((xc) => { + const [, sheetName] = xc.split("!").reverse(); + return { + zone: this.getters.getRangeFromSheetXC(this.activeSheet, xc).zone, + sheetId: (sheetName && this.getters.getSheetIdByName(sheetName)) || this.activeSheet, + color, + }; + }); + } + cleanInputs(ranges) { + return ranges + .map((xc) => xc.split(",")) + .flat() + .map((xc) => xc.trim()) + .filter((xc) => xc !== ""); + } + /** + * Check if a cell or range reference should be highlighted. + * It should be highlighted if it references the current active sheet. + * Note that if no sheet name is given in the reference ("A1"), it refers to the + * active sheet when the selection input was enabled which might be different from + * the current active sheet. + */ + shouldBeHighlighted(inputSheetId, reference) { + const sheetName = reference.split("!").reverse()[1]; + const sheetId = this.getters.getSheetIdByName(sheetName); + const activeSheetId = this.getters.getActiveSheet().id; + const valid = this.getters.isRangeValid(reference); + return (valid && + (sheetId === activeSheetId || (sheetId === undefined && activeSheetId === inputSheetId))); + } + /** + * Return the index of a range given its id + * or `null` if the range is not found. + */ + getIndex(rangeId) { + const index = this.ranges.findIndex((range) => range.id === rangeId); + return index >= 0 ? index : null; + } + } + SelectionInputPlugin.layers = [1 /* Highlights */]; + SelectionInputPlugin.getters = []; + + /** + * Selection input Plugin + * + * The SelectionInput component input and output are both arrays of strings, but + * it requires an intermediary internal state to work. + * This plugin handles this internal state. + */ + class SelectionInputsManagerPlugin extends UIPlugin { + constructor(getters, state, dispatch, config, selection) { + super(getters, state, dispatch, config, selection); + this.state = state; + this.config = config; + this.inputs = {}; + this.focusedInputId = null; + } + get currentInput() { + return this.focusedInputId ? this.inputs[this.focusedInputId] : null; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + var _a, _b; + switch (cmd.type) { + case "FOCUS_RANGE": + const index = (_a = this.currentInput) === null || _a === void 0 ? void 0 : _a.getIndex(cmd.rangeId); + if (this.focusedInputId === cmd.id && ((_b = this.currentInput) === null || _b === void 0 ? void 0 : _b.focusedRangeIndex) === index) { + return 26 /* InputAlreadyFocused */; + } + break; + } + if (this.currentInput) { + return this.currentInput.allowDispatch(cmd); + } + return 0 /* Success */; + } + handle(cmd) { + var _a; + switch (cmd.type) { + case "ENABLE_NEW_SELECTION_INPUT": + this.initInput(cmd.id, cmd.initialRanges || [], cmd.hasSingleRange); + break; + case "DISABLE_SELECTION_INPUT": + if (this.focusedInputId === cmd.id) { + this.unfocus(); + } + delete this.inputs[cmd.id]; + break; + case "UNFOCUS_SELECTION_INPUT": + this.unfocus(); + break; + case "ADD_EMPTY_RANGE": + case "REMOVE_RANGE": + case "FOCUS_RANGE": + case "CHANGE_RANGE": + if (cmd.id !== this.focusedInputId) { + const input = this.inputs[cmd.id]; + this.selection.capture(input, { cell: { col: 0, row: 0 }, zone: positionToZone({ col: 0, row: 0 }) }, { handleEvent: input.handleEvent.bind(input) }); + this.focusedInputId = cmd.id; + } + break; + } + (_a = this.currentInput) === null || _a === void 0 ? void 0 : _a.handle(cmd); + } + unsubscribe() { + this.unfocus(); + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + /** + * Return a list of all valid XCs. + * e.g. ["A1", "Sheet2!B3", "E12"] + */ + getSelectionInput(id) { + if (!this.inputs[id]) { + return []; + } + return this.inputs[id].ranges.map((input, index) => Object.assign({}, input, { + color: this.focusedInputId === id && + this.inputs[id].focusedRangeIndex !== null && + this.isRangeValid(input.xc) + ? input.color + : null, + isFocused: this.focusedInputId === id && this.inputs[id].focusedRangeIndex === index, + })); + } + isRangeValid(xc) { + if (!xc) { + return false; + } + const [rangeXc, sheetName] = xc.split("!").reverse(); + return (rangeXc.match(rangeReference) !== null && + (sheetName === undefined || this.getters.getSheetIdByName(sheetName) !== undefined)); + } + getSelectionInputValue(id) { + return this.inputs[id].getSelectionInputValue(); + } + getSelectionInputHighlights() { + if (!this.focusedInputId) { + return []; + } + return this.inputs[this.focusedInputId].getSelectionInputHighlights(); + } + // --------------------------------------------------------------------------- + // Other + // --------------------------------------------------------------------------- + initInput(id, initialRanges, inputHasSingleRange = false) { + this.inputs[id] = new SelectionInputPlugin(this.getters, this.state, this.dispatch, this.config, this.selection, initialRanges, inputHasSingleRange); + if (initialRanges.length === 0) { + const input = this.inputs[id]; + const anchor = { + zone: positionToZone({ col: 0, row: 0 }), + cell: { col: 0, row: 0 }, + }; + this.selection.capture(input, anchor, { handleEvent: input.handleEvent.bind(input) }); + this.focusedInputId = id; + } + } + unfocus() { + this.selection.release(this.currentInput); + this.focusedInputId = null; + } + } + SelectionInputsManagerPlugin.layers = [1 /* Highlights */]; + SelectionInputsManagerPlugin.getters = [ + "getSelectionInput", + "getSelectionInputValue", + "isRangeValid", + "getSelectionInputHighlights", + ]; + + /** + * This is a generic event bus based on the Owl event bus. + * This bus however ensures type safety across events and subscription callbacks. + */ + class EventBus { + constructor() { + this.subscriptions = {}; + } + /** + * Add a listener for the 'eventType' events. + * + * Note that the 'owner' of this event can be anything, but will more likely + * be a component or a class. The idea is that the callback will be called with + * the proper owner bound. + * + * Also, the owner should be kind of unique. This will be used to remove the + * listener. + */ + on(type, owner, callback) { + if (!callback) { + throw new Error("Missing callback"); + } + if (!this.subscriptions[type]) { + this.subscriptions[type] = []; + } + this.subscriptions[type].push({ + owner, + callback, + }); + } + /** + * Emit an event of type 'eventType'. Any extra arguments will be passed to + * the listeners callback. + */ + trigger(type, payload) { + const subs = this.subscriptions[type] || []; + for (let i = 0, iLen = subs.length; i < iLen; i++) { + const sub = subs[i]; + sub.callback.call(sub.owner, payload); + } + } + /** + * Remove a listener + */ + off(eventType, owner) { + const subs = this.subscriptions[eventType]; + if (subs) { + this.subscriptions[eventType] = subs.filter((s) => s.owner !== owner); + } + } + /** + * Remove all subscriptions. + */ + clear() { + this.subscriptions = {}; + } + } + + class Revision { + /** + * A revision represents a whole client action (Create a sheet, merge a Zone, Undo, ...). + * A revision contains the following information: + * - id: ID of the revision + * - commands: CoreCommands that are linked to the action, and should be + * dispatched in other clients + * - clientId: Client who initiated the action + * - changes: List of changes applied on the state. + */ + constructor(id, clientId, commands, changes) { + this._commands = []; + this._changes = []; + this.id = id; + this.clientId = clientId; + this._commands = [...commands]; + this._changes = changes ? [...changes] : []; + } + setChanges(changes) { + this._changes = changes; + } + get commands() { + return this._commands; + } + get changes() { + return this._changes; + } + } + + class ClientDisconnectedError extends Error { + } + class Session extends EventBus { + /** + * Manages the collaboration between multiple users on the same spreadsheet. + * It can forward local state changes to other users to ensure they all eventually + * reach the same state. + * It also manages the positions of each clients in the spreadsheet to provide + * a visual indication of what other users are doing in the spreadsheet. + * + * @param revisions + * @param transportService communication channel used to send and receive messages + * between all connected clients + * @param client the client connected locally + * @param serverRevisionId + */ + constructor(revisions, transportService, serverRevisionId = DEFAULT_REVISION_ID) { + super(); + this.revisions = revisions; + this.transportService = transportService; + this.serverRevisionId = serverRevisionId; + /** + * Positions of the others client. + */ + this.clients = {}; + this.clientId = "local"; + this.pendingMessages = []; + this.waitingAck = false; + this.processedRevisions = new Set(); + this.uuidGenerator = new UuidGenerator(); + this.debouncedMove = debounce(this._move.bind(this), DEBOUNCE_TIME); + } + /** + * Add a new revision to the collaborative session. + * It will be transmitted to all other connected clients. + */ + save(commands, changes) { + if (!commands.length || !changes.length) + return; + const revision = new Revision(this.uuidGenerator.uuidv4(), this.clientId, commands, changes); + this.revisions.append(revision.id, revision); + this.trigger("new-local-state-update", { id: revision.id }); + this.sendUpdateMessage({ + type: "REMOTE_REVISION", + version: MESSAGE_VERSION, + serverRevisionId: this.serverRevisionId, + nextRevisionId: revision.id, + clientId: revision.clientId, + commands: revision.commands, + }); + } + undo(revisionId) { + this.sendUpdateMessage({ + type: "REVISION_UNDONE", + version: MESSAGE_VERSION, + serverRevisionId: this.serverRevisionId, + nextRevisionId: this.uuidGenerator.uuidv4(), + undoneRevisionId: revisionId, + }); + } + redo(revisionId) { + this.sendUpdateMessage({ + type: "REVISION_REDONE", + version: MESSAGE_VERSION, + serverRevisionId: this.serverRevisionId, + nextRevisionId: this.uuidGenerator.uuidv4(), + redoneRevisionId: revisionId, + }); + } + /** + * Notify that the position of the client has changed + */ + move(position) { + this.debouncedMove(position); + } + join(client) { + if (client) { + this.clients[client.id] = client; + this.clientId = client.id; + } + else { + this.clients["local"] = { id: "local", name: "local" }; + this.clientId = "local"; + } + this.transportService.onNewMessage(this.clientId, this.onMessageReceived.bind(this)); + } + loadInitialMessages(messages) { + this.on("unexpected-revision-id", this, ({ revisionId }) => { + throw new Error(`The spreadsheet could not be loaded. Revision ${revisionId} is corrupted.`); + }); + for (const message of messages) { + this.onMessageReceived(message); + } + this.off("unexpected-revision-id", this); + } + /** + * Notify the server that the user client left the collaborative session + */ + leave() { + delete this.clients[this.clientId]; + this.transportService.leave(this.clientId); + this.transportService.sendMessage({ + type: "CLIENT_LEFT", + clientId: this.clientId, + version: MESSAGE_VERSION, + }); + } + /** + * Send a snapshot of the spreadsheet to the collaboration server + */ + snapshot(data) { + const snapshotId = this.uuidGenerator.uuidv4(); + this.transportService.sendMessage({ + type: "SNAPSHOT", + nextRevisionId: snapshotId, + serverRevisionId: this.serverRevisionId, + data: { ...data, revisionId: snapshotId }, + version: MESSAGE_VERSION, + }); + } + getClient() { + const client = this.clients[this.clientId]; + if (!client) { + throw new ClientDisconnectedError("The client left the session"); + } + return client; + } + getConnectedClients() { + return new Set(Object.values(this.clients).filter(isDefined$1)); + } + getRevisionId() { + return this.serverRevisionId; + } + isFullySynchronized() { + return this.pendingMessages.length === 0; + } + _move(position) { + var _a; + // this method is debounced and might be called after the client + // left the session. + if (!this.clients[this.clientId]) + return; + const currentPosition = (_a = this.clients[this.clientId]) === null || _a === void 0 ? void 0 : _a.position; + if ((currentPosition === null || currentPosition === void 0 ? void 0 : currentPosition.col) === position.col && + currentPosition.row === position.row && + currentPosition.sheetId === position.sheetId) { + return; + } + const type = currentPosition ? "CLIENT_MOVED" : "CLIENT_JOINED"; + const client = this.getClient(); + this.clients[this.clientId] = { ...client, position }; + this.transportService.sendMessage({ + type, + version: MESSAGE_VERSION, + client: { ...client, position }, + }); + } + /** + * Handles messages received from other clients in the collaborative + * session. + */ + onMessageReceived(message) { + if (this.isAlreadyProcessed(message)) + return; + switch (message.type) { + case "CLIENT_MOVED": + this.onClientMoved(message); + break; + case "CLIENT_JOINED": + this.onClientJoined(message); + break; + case "CLIENT_LEFT": + this.onClientLeft(message); + break; + case "REVISION_REDONE": { + this.waitingAck = false; + this.revisions.redo(message.redoneRevisionId, message.nextRevisionId, message.serverRevisionId); + this.trigger("revision-redone", { + revisionId: message.redoneRevisionId, + commands: this.revisions.get(message.redoneRevisionId).commands, + }); + break; + } + case "REVISION_UNDONE": + this.waitingAck = false; + this.revisions.undo(message.undoneRevisionId, message.nextRevisionId, message.serverRevisionId); + this.trigger("revision-undone", { + revisionId: message.undoneRevisionId, + commands: this.revisions.get(message.undoneRevisionId).commands, + }); + break; + case "REMOTE_REVISION": + this.waitingAck = false; + if (message.serverRevisionId !== this.serverRevisionId) { + this.trigger("unexpected-revision-id", { revisionId: message.serverRevisionId }); + return; + } + const { clientId, commands } = message; + const revision = new Revision(message.nextRevisionId, clientId, commands); + if (revision.clientId !== this.clientId) { + this.revisions.insert(revision.id, revision, message.serverRevisionId); + this.trigger("remote-revision-received", { commands }); + } + break; + case "SNAPSHOT_CREATED": { + this.waitingAck = false; + const revision = new Revision(message.nextRevisionId, "server", []); + this.revisions.insert(revision.id, revision, message.serverRevisionId); + this.dropPendingHistoryMessages(); + this.trigger("snapshot"); + break; + } + } + this.acknowledge(message); + this.trigger("collaborative-event-received"); + } + onClientMoved(message) { + if (message.client.id !== this.clientId) { + this.clients[message.client.id] = message.client; + } + } + /** + * Register the new client and send your + * own position back. + */ + onClientJoined(message) { + if (message.client.id !== this.clientId) { + this.clients[message.client.id] = message.client; + const client = this.clients[this.clientId]; + if (client) { + const { position } = client; + if (position) { + this.transportService.sendMessage({ + type: "CLIENT_MOVED", + version: MESSAGE_VERSION, + client: { ...client, position }, + }); + } + } + } + } + onClientLeft(message) { + if (message.clientId !== this.clientId) { + delete this.clients[message.clientId]; + } + } + sendUpdateMessage(message) { + this.pendingMessages.push(message); + if (this.waitingAck) { + return; + } + this.waitingAck = true; + this.sendPendingMessage(); + } + /** + * Send the next pending message + */ + sendPendingMessage() { + let message = this.pendingMessages[0]; + if (!message) + return; + if (message.type === "REMOTE_REVISION") { + const revision = this.revisions.get(message.nextRevisionId); + if (revision.commands.length === 0) { + /** + * The command is empty, we have to drop all the next local revisions + * to avoid issues with undo/redo + */ + this.revisions.drop(revision.id); + const revisionIds = this.pendingMessages + .filter((message) => message.type === "REMOTE_REVISION") + .map((message) => message.nextRevisionId); + this.trigger("pending-revisions-dropped", { revisionIds }); + this.pendingMessages = []; + return; + } + message = { + ...message, + clientId: revision.clientId, + commands: revision.commands, + }; + } + this.transportService.sendMessage({ + ...message, + serverRevisionId: this.serverRevisionId, + }); + } + acknowledge(message) { + switch (message.type) { + case "REMOTE_REVISION": + case "REVISION_REDONE": + case "REVISION_UNDONE": + case "SNAPSHOT_CREATED": + this.pendingMessages = this.pendingMessages.filter((msg) => msg.nextRevisionId !== message.nextRevisionId); + this.serverRevisionId = message.nextRevisionId; + this.processedRevisions.add(message.nextRevisionId); + this.sendPendingMessage(); + break; + } + } + isAlreadyProcessed(message) { + switch (message.type) { + case "REMOTE_REVISION": + case "REVISION_REDONE": + case "REVISION_UNDONE": + return this.processedRevisions.has(message.nextRevisionId); + default: + return false; + } + } + dropPendingHistoryMessages() { + this.pendingMessages = this.pendingMessages.filter(({ type }) => type !== "REVISION_REDONE" && type !== "REVISION_UNDONE"); + } + } + + function randomChoice(arr) { + return arr[Math.floor(Math.random() * arr.length)]; + } + const colors = [ + "#ff851b", + "#0074d9", + "#7fdbff", + "#b10dc9", + "#39cccc", + "#f012be", + "#3d9970", + "#111111", + "#ff4136", + "#aaaaaa", + "#85144b", + "#001f3f", + ]; + class SelectionMultiUserPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.availableColors = new Set(colors); + this.colors = {}; + } + isPositionValid(position) { + return (position.row < this.getters.getNumberRows(position.sheetId) && + position.col < this.getters.getNumberCols(position.sheetId)); + } + chooseNewColor() { + if (this.availableColors.size === 0) { + this.availableColors = new Set(colors); + } + const color = randomChoice([...this.availableColors.values()]); + this.availableColors.delete(color); + return color; + } + /** + * Get the list of others connected clients which are present in the same sheet + * and with a valid position + */ + getClientsToDisplay() { + try { + this.getters.getClient(); + } + catch (e) { + if (e instanceof ClientDisconnectedError) { + return []; + } + else { + throw e; + } + } + const sheetId = this.getters.getActiveSheetId(); + const clients = []; + for (const client of this.getters.getConnectedClients()) { + if (client.id !== this.getters.getClient().id && + client.position && + client.position.sheetId === sheetId && + this.isPositionValid(client.position)) { + const position = client.position; + if (!this.colors[client.id]) { + this.colors[client.id] = this.chooseNewColor(); + } + const color = this.colors[client.id]; + clients.push({ ...client, position, color }); + } + } + return clients; + } + drawGrid(renderingContext) { + if (this.getters.isDashboard()) { + return; + } + const { ctx, thinLineWidth } = renderingContext; + const viewport = this.getters.getActiveViewport(); + const activeSheetId = this.getters.getActiveSheetId(); + for (const client of this.getClientsToDisplay()) { + const { row, col } = client.position; + const zone = this.getters.expandZone(activeSheetId, { + top: row, + bottom: row, + left: col, + right: col, + }); + const { x, y, width, height } = this.getters.getRect(zone, viewport); + if (width <= 0 || height <= 0) { + continue; + } + const color = client.color; + /* Cell background */ + const cellBackgroundColor = `${color}10`; + ctx.fillStyle = cellBackgroundColor; + ctx.lineWidth = 4 * thinLineWidth; + ctx.strokeStyle = color; + ctx.globalCompositeOperation = "multiply"; + ctx.fillRect(x, y, width, height); + /* Cell border */ + ctx.globalCompositeOperation = "source-over"; + ctx.strokeRect(x, y, width, height); + /* client name background */ + ctx.font = `bold ${DEFAULT_FONT_SIZE + 1}px ${DEFAULT_FONT}`; + } + } + } + SelectionMultiUserPlugin.getters = ["getClientsToDisplay"]; + SelectionMultiUserPlugin.layers = [5 /* Selection */]; + + class SortPlugin extends UIPlugin { + allowDispatch(cmd) { + switch (cmd.type) { + case "SORT_CELLS": + if (!isInside(cmd.col, cmd.row, cmd.zone)) { + throw new Error(_lt("The anchor must be part of the provided zone")); + } + return this.checkValidations(cmd, this.checkMerge, this.checkMergeSizes); + } + return 0 /* Success */; + } + handle(cmd) { + switch (cmd.type) { + case "SORT_CELLS": + this.sortZone(cmd.sheetId, cmd, cmd.zone, cmd.sortDirection); + break; + } + } + checkMerge({ sheetId, zone }) { + if (!this.getters.doesIntersectMerge(sheetId, zone)) { + return 0 /* Success */; + } + /*Test the presence of single cells*/ + for (let row = zone.top; row <= zone.bottom; row++) { + for (let col = zone.left; col <= zone.right; col++) { + if (!this.getters.isInMerge(sheetId, col, row)) { + return 60 /* InvalidSortZone */; + } + } + } + return 0 /* Success */; + } + checkMergeSizes({ sheetId, zone }) { + if (!this.getters.doesIntersectMerge(sheetId, zone)) { + return 0 /* Success */; + } + const merges = this.getters.getMerges(sheetId).filter((merge) => overlap(merge, zone)); + /*Test the presence of merges of different sizes*/ + const mergeDimension = zoneToDimension(merges[0]); + let [widthFirst, heightFirst] = [mergeDimension.width, mergeDimension.height]; + if (!merges.every((merge) => { + let [widthCurrent, heightCurrent] = [ + merge.right - merge.left + 1, + merge.bottom - merge.top + 1, + ]; + return widthCurrent === widthFirst && heightCurrent === heightFirst; + })) { + return 60 /* InvalidSortZone */; + } + return 0 /* Success */; + } + // getContiguousZone helpers + /** + * safe-version of expandZone to make sure we don't get out of the grid + */ + expand(sheetId, z) { + const { left, right, top, bottom } = this.getters.expandZone(sheetId, z); + return { + left: Math.max(0, left), + right: Math.min(this.getters.getNumberCols(sheetId) - 1, right), + top: Math.max(0, top), + bottom: Math.min(this.getters.getNumberRows(sheetId) - 1, bottom), + }; + } + /** + * verifies the presence of at least one non-empty cell in the given zone + */ + checkExpandedValues(sheetId, z) { + const expandedZone = this.expand(sheetId, z); + let cell; + if (this.getters.doesIntersectMerge(sheetId, expandedZone)) { + const { left, right, top, bottom } = expandedZone; + for (let c = left; c <= right; c++) { + for (let r = top; r <= bottom; r++) { + const { col: mainCellCol, row: mainCellRow } = this.getters.getMainCellPosition(sheetId, c, r); + cell = this.getters.getCell(sheetId, mainCellCol, mainCellRow); + if (cell === null || cell === void 0 ? void 0 : cell.formattedValue) { + return true; + } + } + } + } + else { + for (let cell of this.getters.getCellsInZone(sheetId, expandedZone)) { + if (cell === null || cell === void 0 ? void 0 : cell.formattedValue) { + return true; + } + } + } + return false; + } + /** + * This function will expand the provided zone in directions (top, bottom, left, right) for which there + * are non-null cells on the external boundary of the zone in the given direction. + * + * Example: + * A B C D E + * ___ ___ ___ ___ ___ + * 1 | | D | | | | + * ___ ___ ___ ___ ___ + * 2 | 5 | | 1 | D | | + * ___ ___ ___ ___ ___ + * 3 | | | A | X | | + * ___ ___ ___ ___ ___ + * 4 | | | | | | + * ___ ___ ___ ___ ___ + * + * Let's consider a provided zone corresponding to (C2:D3) - (left:2, right: 3, top:1, bottom:2) + * - the top external boundary is (B1:E1) + * Since we have B1='D' != "", we expand to the top: => (C1:D3) + * The top boundary having reached the top of the grid, we cannot expand in that direction anymore + * + * - the left boundary is (B1:B4) + * since we have B1 again, we expand to the left => (B1:D3) + * + * - the right and bottom boundaries are a dead end for now as (E1:E4) and (A4:E4) are empty. + * + * - the left boundary is now (A1:A4) + * Since we have A2=5 != "", we can therefore expand to the left => (A1:D3) + * + * This will be the final zone as left and top have reached the boundaries of the grid and + * the other boundaries (E1:E4) and (A4:E4) are empty. + * + * @param sheetId UID of concerned sheet + * @param zone Zone + * + */ + getContiguousZone(sheetId, zone) { + let { top, bottom, left, right } = zone; + let canExpand; + let stop = false; + while (!stop) { + stop = true; + /** top row external boundary */ + if (top > 0) { + canExpand = this.checkExpandedValues(sheetId, { + left: left - 1, + right: right + 1, + top: top - 1, + bottom: top - 1, + }); + if (canExpand) { + stop = false; + top--; + } + } + /** left column external boundary */ + if (left > 0) { + canExpand = this.checkExpandedValues(sheetId, { + left: left - 1, + right: left - 1, + top: top - 1, + bottom: bottom + 1, + }); + if (canExpand) { + stop = false; + left--; + } + } + /** right column external boundary */ + if (right < this.getters.getNumberCols(sheetId) - 1) { + canExpand = this.checkExpandedValues(sheetId, { + left: right + 1, + right: right + 1, + top: top - 1, + bottom: bottom + 1, + }); + if (canExpand) { + stop = false; + right++; + } + } + /** bottom row external boundary */ + if (bottom < this.getters.getNumberRows(sheetId) - 1) { + canExpand = this.checkExpandedValues(sheetId, { + left: left - 1, + right: right + 1, + top: bottom + 1, + bottom: bottom + 1, + }); + if (canExpand) { + stop = false; + bottom++; + } + } + } + return { left, right, top, bottom }; + } + /** + * This function evaluates if the top row of a provided zone can be considered as a `header` + * by checking the following criteria: + * * If the left-most column top row value (topLeft) is empty, we ignore it while evaluating the criteria. + * 1 - Apart from the left-most column, every element of the top row must be non-empty, i.e. a cell should be present in the sheet. + * 2 - There should be at least one column in which the type (CellValueType) of the rop row cell differs from the type of the cell below. + * For the second criteria, we ignore columns on which the cell below is empty. + * + */ + hasHeader(items) { + if (items[0].length === 1) + return false; + let cells = items.map((col) => col.map((cell) => (cell === null || cell === void 0 ? void 0 : cell.evaluated.type) || CellValueType.empty)); + // ignore left-most column when topLeft cell is empty + const topLeft = cells[0][0]; + if (topLeft === CellValueType.empty) { + cells = cells.slice(1); + } + if (cells.some((item) => item[0] === CellValueType.empty)) { + return false; + } + else if (cells.some((item) => item[1] !== CellValueType.empty && item[0] !== item[1])) { + return true; + } + else { + return false; + } + } + sortZone(sheetId, anchor, zone, sortDirection) { + const [stepX, stepY] = this.mainCellsSteps(sheetId, zone); + let sortingCol = this.getters.getMainCellPosition(sheetId, anchor.col, anchor.row).col; // fetch anchor + let sortZone = Object.assign({}, zone); + // Update in case of merges in the zone + let cells = this.mainCells(sheetId, zone); + if (this.hasHeader(cells)) { + sortZone.top += stepY; + } + cells = this.mainCells(sheetId, sortZone); + const sortingCells = cells[sortingCol - sortZone.left]; + const sortedIndexOfSortTypeCells = sortCells(sortingCells, sortDirection); + const sortedIndex = sortedIndexOfSortTypeCells.map((x) => x.index); + const [width, height] = [cells.length, cells[0].length]; + for (let c = 0; c < width; c++) { + for (let r = 0; r < height; r++) { + let cell = cells[c][sortedIndex[r]]; + let newCol = sortZone.left + c * stepX; + let newRow = sortZone.top + r * stepY; + let newCellValues = { + sheetId: sheetId, + col: newCol, + row: newRow, + content: "", + value: "", + }; + if (cell) { + let content = cell.content; + if (cell.isFormula()) { + const position = this.getters.getCellPosition(cell.id); + const offsetY = newRow - position.row; + // we only have a vertical offset + const ranges = this.getters.createAdaptedRanges(cell.dependencies, 0, offsetY, sheetId); + content = this.getters.buildFormulaContent(sheetId, cell, ranges); + } + newCellValues.style = cell.style; + newCellValues.content = content; + newCellValues.format = cell.format; + newCellValues.value = cell.evaluated.value; + } + this.dispatch("UPDATE_CELL", newCellValues); + } + } + } + /** + * Return the distances between main merge cells in the zone. + * (1 if there are no merges). + * Note: it is assumed all merges are the same in the zone. + */ + mainCellsSteps(sheetId, zone) { + const merge = this.getters.getMerge(sheetId, zone.left, zone.top); + const stepX = merge ? merge.right - merge.left + 1 : 1; + const stepY = merge ? merge.bottom - merge.top + 1 : 1; + return [stepX, stepY]; + } + /** + * Return a 2D array of cells in the zone (main merge cells if there are merges) + */ + mainCells(sheetId, zone) { + const [stepX, stepY] = this.mainCellsSteps(sheetId, zone); + const cells = []; + const cols = range(zone.left, zone.right + 1, stepX); + const rows = range(zone.top, zone.bottom + 1, stepY); + for (const col of cols) { + const colCells = []; + cells.push(colCells); + for (const row of rows) { + colCells.push(this.getters.getCell(sheetId, col, row)); + } + } + return cells; + } + } + SortPlugin.getters = ["getContiguousZone"]; + + class UIOptionsPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.showFormulas = false; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + handle(cmd) { + switch (cmd.type) { + case "SET_FORMULA_VISIBILITY": + this.showFormulas = cmd.show; + break; + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + shouldShowFormulas() { + return this.showFormulas; + } + } + UIOptionsPlugin.getters = ["shouldShowFormulas"]; + + class SheetUIPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.ctx = document.createElement("canvas").getContext("2d"); + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "AUTORESIZE_ROWS": + case "AUTORESIZE_COLUMNS": + try { + this.getters.getSheet(cmd.sheetId); + break; + } + catch (error) { + return 25 /* InvalidSheetId */; + } + } + return 0 /* Success */; + } + handle(cmd) { + switch (cmd.type) { + case "AUTORESIZE_COLUMNS": + for (let col of cmd.cols) { + const size = this.getColMaxWidth(cmd.sheetId, col); + if (size !== 0) { + this.dispatch("RESIZE_COLUMNS_ROWS", { + elements: [col], + dimension: "COL", + size: size + 2 * PADDING_AUTORESIZE_HORIZONTAL, + sheetId: cmd.sheetId, + }); + } + } + break; + case "AUTORESIZE_ROWS": + for (let row of cmd.rows) { + this.dispatch("RESIZE_COLUMNS_ROWS", { + elements: [row], + dimension: "ROW", + size: null, + sheetId: cmd.sheetId, + }); + } + break; + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + getCellWidth(cell) { + let width = this.getTextWidth(cell); + const cellPosition = this.getters.getCellPosition(cell.id); + const icon = this.getters.getConditionalIcon(cellPosition.col, cellPosition.row); + if (icon) { + width += computeIconWidth(this.ctx, this.getters.getCellStyle(cell)); + } + return width; + } + getTextWidth(cell) { + const text = this.getters.getCellText(cell, this.getters.shouldShowFormulas()); + return computeTextWidth(this.ctx, text, this.getters.getCellStyle(cell)); + } + getCellText(cell, showFormula = false) { + if (showFormula && (cell.isFormula() || cell.evaluated.type === CellValueType.error)) { + return cell.content; + } + else { + return cell.formattedValue; + } + } + // --------------------------------------------------------------------------- + // Grid manipulation + // --------------------------------------------------------------------------- + getColMaxWidth(sheetId, index) { + const cells = this.getters.getColCells(sheetId, index); + const sizes = cells.map((cell) => this.getCellWidth(cell)); + return Math.max(0, ...sizes); + } + } + SheetUIPlugin.getters = ["getCellWidth", "getTextWidth", "getCellText"]; + + /** + * Viewport plugin. + * + * This plugin manages all things related to all viewport states. + * + */ + class ViewportPlugin extends UIPlugin { + constructor() { + super(...arguments); + this.viewports = {}; + /** + * The viewport dimensions are usually set by one of the components + * (i.e. when grid component is mounted) to properly reflect its state in the DOM. + * In the absence of a component (standalone model), is it mandatory to set reasonable default values + * to ensure the correct operation of this plugin. + */ + this.viewportWidth = 1000; + this.viewportHeight = 1000; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + switch (cmd.type) { + case "SET_VIEWPORT_OFFSET": + return this.checkOffsetValidity(cmd.offsetX, cmd.offsetY); + case "RESIZE_VIEWPORT": + if (cmd.width < 0 || cmd.height < 0) { + return 66 /* InvalidViewportSize */; + } + return 0 /* Success */; + default: + return 0 /* Success */; + } + } + handleEvent(event) { + switch (event.type) { + case "HeadersSelected": + case "AlterZoneCorner": + break; + case "ZonesSelected": + // altering a zone should not move the viewport + const sheetId = this.getters.getActiveSheetId(); + let { col, row } = findCellInNewZone(event.previousAnchor.zone, event.anchor.zone); + col = Math.min(col, this.getters.getNumberCols(sheetId) - 1); + row = Math.min(row, this.getters.getNumberRows(sheetId) - 1); + this.refreshViewport(this.getters.getActiveSheetId(), { col, row }); + break; + } + } + handle(cmd) { + switch (cmd.type) { + case "START": + this.selection.observe(this, { + handleEvent: this.handleEvent.bind(this), + }); + this.generateViewportState(this.getters.getActiveSheetId()); + break; + case "UNDO": + case "REDO": + this.cleanViewports(); + this.resetViewports(); + break; + case "RESIZE_VIEWPORT": + this.cleanViewports(); + this.resizeViewport(cmd.height, cmd.width); + break; + case "SET_VIEWPORT_OFFSET": + this.setViewportOffset(cmd.offsetX, cmd.offsetY); + break; + case "SHIFT_VIEWPORT_DOWN": + const { top } = this.getActiveViewport(); + const sheetId = this.getters.getActiveSheetId(); + const shiftedOffsetY = this.clipOffsetY(this.getters.getRowDimensions(sheetId, top).start + this.viewportHeight); + this.shiftVertically(shiftedOffsetY); + break; + case "SHIFT_VIEWPORT_UP": { + const { top } = this.getActiveViewport(); + const sheetId = this.getters.getActiveSheetId(); + const shiftedOffsetY = this.clipOffsetY(this.getters.getRowDimensions(sheetId, top).end - this.viewportHeight); + this.shiftVertically(shiftedOffsetY); + break; + } + case "REMOVE_COLUMNS_ROWS": + case "RESIZE_COLUMNS_ROWS": + case "HIDE_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.adjustViewportOffsetX(cmd.sheetId, this.getViewport(cmd.sheetId)); + } + else { + this.adjustViewportOffsetY(cmd.sheetId, this.getViewport(cmd.sheetId)); + } + break; + case "ADD_COLUMNS_ROWS": + case "UNHIDE_COLUMNS_ROWS": + if (cmd.dimension === "COL") { + this.adjustViewportZoneX(cmd.sheetId, this.getViewport(cmd.sheetId)); + } + else { + this.adjustViewportZoneY(cmd.sheetId, this.getViewport(cmd.sheetId)); + } + break; + case "ACTIVATE_SHEET": + this.refreshViewport(cmd.sheetIdTo); + break; + } + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + /** + * Return the index of a column given an offset x, based on the viewport left + * visible cell. + * It returns -1 if no column is found. + */ + getColIndex(x) { + if (x < 0) { + return -1; + } + const viewport = this.getActiveViewport(); + return this.searchHeaderIndex("COL", this.getters.getActiveSheetId(), x, viewport.left); + } + /** + * Return the index of a row given an offset y, based on the viewport top + * visible cell. + * It returns -1 if no row is found. + */ + getRowIndex(y) { + if (y < 0) { + return -1; + } + const viewport = this.getActiveViewport(); + return this.searchHeaderIndex("ROW", this.getters.getActiveSheetId(), y, viewport.top); + } + getViewportDimensionWithHeaders() { + return { + width: this.viewportWidth + (this.getters.isDashboard() ? 0 : HEADER_WIDTH), + height: this.viewportHeight + (this.getters.isDashboard() ? 0 : HEADER_HEIGHT), + }; + } + getViewportDimension() { + return { + width: this.viewportWidth, + height: this.viewportHeight, + }; + } + getActiveViewport() { + const sheetId = this.getters.getActiveSheetId(); + return this.getViewport(sheetId); + } + /** + * Return the maximum viewport size. That is the sheet dimension + * with some bottom and right padding. + */ + getMaxViewportSize(sheet) { + const sheetId = sheet.id; + const lastCol = this.getters.findLastVisibleColRowIndex(sheetId, "COL"); + const lastRow = this.getters.findLastVisibleColRowIndex(sheetId, "ROW"); + const { end: lastColEnd, size: lastColSize } = this.getters.getColDimensions(sheetId, lastCol); + const { end: lastRowEnd, size: lastRowSize } = this.getters.getRowDimensions(sheetId, lastRow); + const leftColIndex = this.searchHeaderIndex("COL", sheetId, lastColEnd - this.viewportWidth, 0); + const leftCol = this.getters.getColSize(sheetId, leftColIndex); + const leftRowIndex = this.searchHeaderIndex("ROW", sheetId, lastRowEnd - this.viewportHeight, 0); + const topRow = this.getters.getRowSize(sheetId, leftRowIndex); + const width = lastColEnd + + Math.max(DEFAULT_CELL_WIDTH, Math.min(leftCol, this.viewportWidth - lastColSize)); + const height = lastRowEnd + + Math.max(DEFAULT_CELL_HEIGHT + 5, Math.min(topRow, this.viewportHeight - lastRowSize)); + return { width, height }; + } + getMaximumViewportOffset(sheet) { + const { width, height } = this.getters.getMaxViewportSize(sheet); + return { + maxOffsetX: Math.max(0, width - this.viewportWidth + 1), + maxOffsetY: Math.max(0, height - this.viewportHeight + 1), + }; + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + searchHeaderIndex(dimension, sheetId, position, startIndex = 0) { + let size = 0; + const headers = this.getters.getNumberHeaders(sheetId, dimension); + for (let i = startIndex; i <= headers - 1; i++) { + if (this.getters.isHeaderHidden(sheetId, dimension, i)) { + continue; + } + size += + dimension === "COL" + ? this.getters.getColSize(sheetId, i) + : this.getters.getRowSize(sheetId, i); + if (size > position) { + return i; + } + } + return -1; + } + checkOffsetValidity(offsetX, offsetY) { + const sheet = this.getters.getActiveSheet(); + const { maxOffsetX, maxOffsetY } = this.getMaximumViewportOffset(sheet); + if (offsetX < 0 || offsetY < 0 || offsetY > maxOffsetY || offsetX > maxOffsetX) { + return 65 /* InvalidOffset */; + } + return 0 /* Success */; + } + getViewport(sheetId) { + if (!this.viewports[sheetId]) { + this.generateViewportState(sheetId); + } + return this.viewports[sheetId]; + } + /** gets rid of deprecated sheetIds */ + cleanViewports() { + const sheets = this.getters.getSheetIds(); + for (let sheetId of Object.keys(this.viewports)) { + if (!sheets.includes(sheetId)) { + delete this.viewports[sheetId]; + } + } + } + resetViewports() { + for (let [sheetId, viewport] of Object.entries(this.viewports)) { + const position = this.getters.getSheetPosition(sheetId); + this.adjustViewportOffsetX(sheetId, viewport); + this.adjustViewportOffsetY(sheetId, viewport); + this.adjustViewportsPosition(sheetId, position); + } + } + /** Corrects the viewport's horizontal offset based on the current structure + * To make sure that at least on column is visible inside the viewport. + */ + adjustViewportOffsetX(sheetId, viewport) { + const { offsetX } = viewport; + const { width: sheetWidth } = this.getMaxViewportSize(this.getters.getSheet(sheetId)); + if (this.viewportWidth + offsetX > sheetWidth) { + const diff = this.viewportWidth + offsetX - sheetWidth; + viewport.offsetScrollbarX = Math.max(0, offsetX - diff); + } + this.adjustViewportZoneX(sheetId, viewport); + } + /** Corrects the viewport's vertical offset based on the current structure + * To make sure that at least on row is visible inside the viewport. + */ + adjustViewportOffsetY(sheetId, viewport) { + const { offsetY } = viewport; + const { height: sheetHeight } = this.getMaxViewportSize(this.getters.getSheet(sheetId)); + if (this.viewportHeight + offsetY > sheetHeight) { + const diff = this.viewportHeight + offsetY - sheetHeight; + viewport.offsetScrollbarY = Math.max(0, offsetY - diff); + } + this.adjustViewportZoneY(sheetId, viewport); + } + resizeViewport(height, width) { + this.viewportHeight = height; + this.viewportWidth = width; + this.recomputeViewports(); + } + recomputeViewports() { + for (let sheetId of Object.keys(this.viewports)) { + this.adjustViewportOffsetX(sheetId, this.viewports[sheetId]); + this.adjustViewportOffsetY(sheetId, this.viewports[sheetId]); + } + } + setViewportOffset(offsetX, offsetY) { + const sheetId = this.getters.getActiveSheetId(); + this.getViewport(sheetId); + this.viewports[sheetId].offsetScrollbarX = offsetX; + this.viewports[sheetId].offsetScrollbarY = offsetY; + this.adjustViewportZone(sheetId, this.viewports[sheetId]); + } + /** + * Clip the vertical offset within the allowed range. + * Not above the sheet, nor below the sheet. + */ + clipOffsetY(offsetY) { + const { height } = this.getters.getMaxViewportSize(this.getters.getActiveSheet()); + const maxOffset = height - this.viewportHeight; + offsetY = Math.min(offsetY, maxOffset); + offsetY = Math.max(offsetY, 0); + return offsetY; + } + generateViewportState(sheetId) { + this.viewports[sheetId] = { + left: 0, + right: 0, + top: 0, + bottom: 0, + offsetX: 0, + offsetY: 0, + offsetScrollbarX: 0, + offsetScrollbarY: 0, + }; + this.adjustViewportZone(sheetId, this.viewports[sheetId]); + } + /** + * Adjust the viewport such that the anchor position is visible + */ + refreshViewport(sheetId, anchorPosition) { + const viewport = this.getViewport(sheetId); + this.adjustViewportZone(sheetId, viewport); + this.adjustViewportsPosition(sheetId, anchorPosition); + } + adjustViewportZone(sheetId, viewport) { + this.adjustViewportZoneX(sheetId, viewport); + this.adjustViewportZoneY(sheetId, viewport); + } + /** Updates the viewport zone based on its horizontal offset (will find Left) and its width (will find Right) */ + adjustViewportZoneX(sheetId, viewport) { + viewport.left = this.searchHeaderIndex("COL", sheetId, viewport.offsetScrollbarX); + viewport.right = this.searchHeaderIndex("COL", sheetId, this.viewportWidth, viewport.left); + if (viewport.right === -1) { + viewport.right = this.getters.getNumberCols(sheetId) - 1; + } + viewport.offsetX = this.getters.getColDimensions(sheetId, viewport.left).start; + } + /** Updates the viewport zone based on its vertical offset (will find Top) and its width (will find Bottom) */ + adjustViewportZoneY(sheetId, viewport) { + viewport.top = this.searchHeaderIndex("ROW", sheetId, viewport.offsetScrollbarY); + viewport.bottom = this.searchHeaderIndex("ROW", sheetId, this.viewportHeight, viewport.top); + if (viewport.bottom === -1) { + viewport.bottom = this.getters.getNumberRows(sheetId) - 1; + } + viewport.offsetY = this.getters.getRowDimensions(sheetId, viewport.top).start; + } + /** + * This function will make sure that the provided cell position (or current selected position) is part of + * the viewport that is actually displayed on the client. We therefore adjust the offset of the snapped + * viewport until it contains the cell completely. + */ + adjustViewportsPosition(sheetId, position) { + const sheet = this.getters.getSheet(sheetId); + const adjustedViewport = this.getViewport(sheetId); + if (!position) { + position = this.getters.getSheetPosition(sheetId); + } + const mainCellPosition = this.getters.getMainCellPosition(sheetId, position.col, position.row); + const { col, row } = this.getters.getNextVisibleCellPosition(sheet.id, mainCellPosition.col, mainCellPosition.row); + const { start, end } = this.getters.getColDimensions(sheetId, col); + while (end > adjustedViewport.offsetX + this.viewportWidth && + adjustedViewport.offsetX < start) { + adjustedViewport.offsetX = this.getters.getColDimensions(sheetId, adjustedViewport.left).end; + adjustedViewport.offsetScrollbarX = adjustedViewport.offsetX; + this.adjustViewportZoneX(sheetId, adjustedViewport); + } + while (col < adjustedViewport.left) { + let leftCol; + for (leftCol = adjustedViewport.left; leftCol >= 0; leftCol--) { + if (!this.getters.isColHidden(sheetId, leftCol)) { + break; + } + } + adjustedViewport.offsetX = this.getters.getColDimensions(sheetId, leftCol - 1).start; + adjustedViewport.offsetScrollbarX = adjustedViewport.offsetX; + this.adjustViewportZoneX(sheetId, adjustedViewport); + } + while (this.getters.getRowDimensions(sheetId, row).end > + adjustedViewport.offsetY + this.viewportHeight && + adjustedViewport.offsetY < this.getters.getRowDimensions(sheetId, row).start) { + adjustedViewport.offsetY = this.getters.getRowDimensions(sheetId, adjustedViewport.top).end; + adjustedViewport.offsetScrollbarY = adjustedViewport.offsetY; + this.adjustViewportZoneY(sheetId, adjustedViewport); + } + while (row < adjustedViewport.top) { + let topRow; + for (topRow = adjustedViewport.top; topRow >= 0; topRow--) { + if (!this.getters.isRowHidden(sheetId, topRow)) { + break; + } + } + adjustedViewport.offsetY = this.getters.getRowDimensions(sheetId, topRow - 1).start; + adjustedViewport.offsetScrollbarY = adjustedViewport.offsetY; + this.adjustViewportZoneY(sheetId, adjustedViewport); + } + } + /** + * Shift the viewport vertically and move the selection anchor + * such that it remains at the same place relative to the + * viewport top. + */ + shiftVertically(offset) { + const { top, offsetX } = this.getActiveViewport(); + this.setViewportOffset(offsetX, offset); + const { anchor } = this.getters.getSelection(); + const deltaRow = this.getActiveViewport().top - top; + this.selection.selectCell(anchor.cell.col, anchor.cell.row + deltaRow); + } + getVisibleFigures() { + const sheetId = this.getters.getActiveSheetId(); + const result = []; + const figures = this.getters.getFigures(sheetId); + const { offsetX, offsetY } = this.getters.getActiveViewport(); + const { width, height } = this.getters.getViewportDimensionWithHeaders(); + for (let figure of figures) { + if (figure.x >= offsetX + width || figure.x + figure.width <= offsetX) { + continue; + } + if (figure.y >= offsetY + height || figure.y + figure.height <= offsetY) { + continue; + } + result.push(figure); + } + return result; + } + } + ViewportPlugin.getters = [ + "getColIndex", + "getRowIndex", + "getActiveViewport", + "getViewportDimension", + "getViewportDimensionWithHeaders", + "getMaxViewportSize", + "getMaximumViewportOffset", + "getVisibleFigures", + ]; + + const corePluginRegistry = new Registry() + .add("sheet", SheetPlugin) + .add("header visibility", HeaderVisibilityPlugin) + .add("cell", CellPlugin) + .add("merge", MergePlugin) + .add("headerSize", HeaderSizePlugin) + .add("borders", BordersPlugin) + .add("conditional formatting", ConditionalFormatPlugin) + .add("figures", FigurePlugin) + .add("chart", ChartPlugin); + const uiPluginRegistry = new Registry() + .add("selection", GridSelectionPlugin) + .add("ui_sheet", SheetUIPlugin) + .add("ui_options", UIOptionsPlugin) + .add("evaluation", EvaluationPlugin) + .add("evaluation_cf", EvaluationConditionalFormatPlugin) + .add("evaluation_chart", EvaluationChartPlugin) + .add("clipboard", ClipboardPlugin) + .add("edition", EditionPlugin) + .add("selectionInputManager", SelectionInputsManagerPlugin) + .add("highlight", HighlightPlugin) + .add("viewport", ViewportPlugin) + .add("grid renderer", RendererPlugin) + .add("autofill", AutofillPlugin) + .add("find_and_replace", FindAndReplacePlugin) + .add("sort", SortPlugin) + .add("automatic_sum", AutomaticSumPlugin) + .add("format", FormatPlugin) + .add("cell_popovers", CellPopoverPlugin) + .add("selection_multiuser", SelectionMultiUserPlugin) + .add("custom_colors", CustomColorsPlugin); + + // ----------------------------------------------------------------------------- + // SpreadSheet + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-spreadsheet-bottom-bar { + background-color: ${BACKGROUND_GRAY_COLOR}; + padding-left: ${HEADER_WIDTH}px; + display: flex; + align-items: center; + font-size: 15px; + border-top: 1px solid lightgrey; + overflow: hidden; + + .o-add-sheet, + .o-list-sheets { + margin-right: 5px; + } + + .o-add-sheet.disabled { + cursor: not-allowed; + } + + .o-sheet-item { + display: flex; + align-items: center; + padding: 5px; + cursor: pointer; + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .o-all-sheets { + display: flex; + align-items: center; + max-width: 80%; + overflow: hidden; + } + + .o-sheet { + color: #666; + padding: 0 15px; + padding-right: 10px; + height: ${BOTTOMBAR_HEIGHT}px; + line-height: ${BOTTOMBAR_HEIGHT}px; + user-select: none; + white-space: nowrap; + border-left: 1px solid #c1c1c1; + + &:last-child { + border-right: 1px solid #c1c1c1; + } + + &.active { + color: #484; + background-color: #ffffff; + box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15); + } + + .o-sheet-icon { + margin-left: 5px; + + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } + } + + .o-selection-statistic { + background-color: #ffffff; + margin-left: auto; + font-size: 14px; + margin-right: 20px; + padding: 4px 8px; + color: #333; + border-radius: 3px; + box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15); + user-select: none; + cursor: pointer; + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .fade-enter-active { + transition: opacity 0.5s; + } + + .fade-enter { + opacity: 0; + } + } +`; + class BottomBar extends owl.Component { + constructor() { + super(...arguments); + this.bottomBarRef = owl.useRef("bottomBar"); + this.menuState = owl.useState({ isOpen: false, position: null, menuItems: [] }); + this.selectedStatisticFn = ""; + } + setup() { + owl.onMounted(() => this.focusSheet()); + owl.onPatched(() => this.focusSheet()); + } + focusSheet() { + const div = this.bottomBarRef.el.querySelector(`[data-id="${this.env.model.getters.getActiveSheetId()}"]`); + if (div && div.scrollIntoView) { + div.scrollIntoView(); + } + } + addSheet() { + const activeSheetId = this.env.model.getters.getActiveSheetId(); + const position = this.env.model.getters.getSheetIds().findIndex((sheetId) => sheetId === activeSheetId) + 1; + const sheetId = this.env.model.uuidGenerator.uuidv4(); + const name = this.env.model.getters.getNextSheetName(this.env._t("Sheet")); + this.env.model.dispatch("CREATE_SHEET", { sheetId, position, name }); + this.env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: sheetId }); + } + getVisibleSheets() { + return this.env.model.getters + .getVisibleSheetIds() + .map((sheetId) => this.env.model.getters.getSheet(sheetId)); + } + listSheets(ev) { + const registry = new MenuItemRegistry(); + const from = this.env.model.getters.getActiveSheetId(); + let i = 0; + for (const sheetId of this.env.model.getters.getSheetIds()) { + const sheet = this.env.model.getters.getSheet(sheetId); + registry.add(sheetId, { + name: sheet.name, + sequence: i, + isReadonlyAllowed: true, + textColor: sheet.isVisible ? undefined : "grey", + action: (env) => { + env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: from, sheetIdTo: sheetId }); + }, + }); + i++; + } + const target = ev.currentTarget; + const { top, left } = target.getBoundingClientRect(); + this.openContextMenu(left, top, registry); + } + activateSheet(name) { + this.env.model.dispatch("ACTIVATE_SHEET", { + sheetIdFrom: this.env.model.getters.getActiveSheetId(), + sheetIdTo: name, + }); + } + onDblClick(sheetId) { + interactiveRenameSheet(this.env, sheetId); + } + openContextMenu(x, y, registry) { + this.menuState.isOpen = true; + this.menuState.menuItems = registry.getAll().filter((x) => x.isVisible(this.env)); + this.menuState.position = { x, y }; + } + onIconClick(sheet, ev) { + if (this.env.model.getters.getActiveSheetId() !== sheet) { + this.activateSheet(sheet); + } + if (this.menuState.isOpen) { + this.menuState.isOpen = false; + } + else { + const target = ev.currentTarget.parentElement; + const { top, left } = target.getBoundingClientRect(); + this.openContextMenu(left, top, sheetMenuRegistry); + } + } + onContextMenu(sheet, ev) { + if (this.env.model.getters.getActiveSheetId() !== sheet) { + this.activateSheet(sheet); + } + const target = ev.currentTarget; + const { top, left } = target.getBoundingClientRect(); + this.openContextMenu(left, top, sheetMenuRegistry); + } + getSelectedStatistic() { + const statisticFnResults = this.env.model.getters.getStatisticFnResults(); + // don't display button if no function has a result + if (Object.values(statisticFnResults).every((result) => result === undefined)) { + return undefined; + } + if (this.selectedStatisticFn === "") { + this.selectedStatisticFn = Object.keys(statisticFnResults)[0]; + } + return this.getComposedFnName(this.selectedStatisticFn, statisticFnResults[this.selectedStatisticFn]); + } + listSelectionStatistics(ev) { + const registry = new MenuItemRegistry(); + let i = 0; + for (let [fnName, fnValue] of Object.entries(this.env.model.getters.getStatisticFnResults())) { + registry.add(fnName, { + name: this.getComposedFnName(fnName, fnValue), + sequence: i, + isReadonlyAllowed: true, + action: () => { + this.selectedStatisticFn = fnName; + }, + }); + i++; + } + const target = ev.currentTarget; + const { top, left, width } = target.getBoundingClientRect(); + this.openContextMenu(left + width, top, registry); + } + getComposedFnName(fnName, fnValue) { + return fnName + ": " + (fnValue !== undefined ? formatValue(fnValue) : "__"); + } + } + BottomBar.template = "o-spreadsheet-BottomBar"; + BottomBar.components = { Menu }; + + css /* scss */ ` + .o-sidePanel { + display: flex; + flex-direction: column; + overflow-x: hidden; + background-color: white; + border: 1px solid darkgray; + user-select: none; + .o-sidePanelHeader { + padding: 6px; + height: 30px; + background-color: ${BACKGROUND_HEADER_COLOR}; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid darkgray; + border-top: 1px solid darkgray; + font-weight: bold; + .o-sidePanelTitle { + font-weight: bold; + padding: 5px 10px; + color: dimgrey; + } + .o-sidePanelClose { + font-size: 1.5rem; + padding: 5px 10px; + cursor: pointer; + &:hover { + background-color: WhiteSmoke; + } + } + } + .o-sidePanelBody { + overflow: auto; + width: 100%; + height: 100%; + + .o-section { + padding: 16px; + + .o-section-title { + font-weight: bold; + color: dimgrey; + margin-bottom: 5px; + } + + .o-section-subtitle { + color: dimgrey; + font-weight: 500; + font-size: 12px; + line-height: 14px; + margin: 8px 0 4px 0; + } + + .o-subsection-left { + display: inline-block; + width: 47%; + margin-right: 3%; + } + + .o-subsection-right { + display: inline-block; + width: 47%; + margin-left: 3%; + } + } + } + + .o-sidepanel-error { + color: red; + margin-top: 10px; + } + + .o-sidePanelButtons { + padding: 16px; + text-align: right; + } + + .o-sidePanelButton { + border: 1px solid lightgrey; + padding: 0px 20px 0px 20px; + border-radius: 4px; + font-weight: 500; + font-size: 14px; + height: 30px; + line-height: 16px; + background: white; + margin-right: 8px; + &:hover:enabled { + background-color: rgba(0, 0, 0, 0.08); + } + } + .o-sidePanelButton:enabled { + cursor: pointer; + } + .o-sidePanelButton:last-child { + margin-right: 0px; + } + + .o-input { + color: #666666; + border-radius: 4px; + min-width: 0px; + padding: 4px 6px; + box-sizing: border-box; + line-height: 1; + width: 100%; + height: 28px; + .o-type-selector { + background-position: right 5px top 11px; + } + } + input.o-required, + select.o-required { + border-color: #4c4c4c; + } + input.o-optional, + select.o-optional { + border: 1px solid #a9a9a9; + } + input.o-invalid { + border-color: red; + } + select.o-input { + background-color: white; + text-align: left; + } + + .o-inflection { + .o-inflection-icon-button { + display: inline-block; + border: 1px solid #dadce0; + border-radius: 4px; + cursor: pointer; + padding: 1px 2px; + } + .o-inflection-icon-button:hover { + background-color: rgba(0, 0, 0, 0.08); + } + table { + table-layout: fixed; + margin-top: 2%; + display: table; + text-align: left; + font-size: 12px; + line-height: 18px; + width: 100%; + } + th.o-inflection-iconset-icons { + width: 8%; + } + th.o-inflection-iconset-text { + width: 28%; + } + th.o-inflection-iconset-operator { + width: 14%; + } + th.o-inflection-iconset-type { + width: 28%; + } + th.o-inflection-iconset-value { + width: 26%; + } + input, + select { + width: 100%; + height: 100%; + box-sizing: border-box; + } + } + + .o-dropdown { + position: relative; + .o-dropdown-content { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: ${ComponentsImportance.Dropdown}; + box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); + background-color: #f6f6f6; + + .o-dropdown-item { + padding: 7px 10px; + } + .o-dropdown-item:hover { + background-color: rgba(0, 0, 0, 0.08); + } + .o-dropdown-line { + display: flex; + padding: 3px 6px; + .o-line-item { + width: 16px; + height: 16px; + margin: 1px 3px; + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } + } + } + } + + .o-tools { + color: #333; + font-size: 13px; + cursor: default; + display: flex; + + .o-tool { + display: flex; + align-items: center; + margin: 2px; + padding: 0 3px; + border-radius: 2px; + } + + .o-tool.active, + .o-tool:not(.o-disabled):hover { + background-color: rgba(0, 0, 0, 0.08); + } + + .o-with-color > span { + border-bottom: 4px solid; + height: 16px; + margin-top: 2px; + } + .o-with-color { + .o-line-item:hover { + outline: 1px solid gray; + } + } + .o-border { + .o-line-item { + padding: 4px; + margin: 1px; + } + } + } + } +`; + class SidePanel extends owl.Component { + setup() { + this.state = owl.useState({ + panel: sidePanelRegistry.get(this.props.component), + }); + owl.onWillUpdateProps((nextProps) => (this.state.panel = sidePanelRegistry.get(nextProps.component))); + } + getTitle() { + return typeof this.state.panel.title === "function" + ? this.state.panel.title(this.env) + : this.state.panel.title; + } + } + SidePanel.template = "o-spreadsheet-SidePanel"; + + const AddMergeInteractiveContent = { + mergeIsDestructive: _lt("Merging these cells will only preserve the top-leftmost value. Merge anyway?"), + }; + function interactiveAddMerge(env, sheetId, target) { + const result = env.model.dispatch("ADD_MERGE", { sheetId, target }); + if (!result.isSuccessful) { + if (result.isCancelledBecause(3 /* MergeIsDestructive */)) { + env.askConfirmation(_lt(AddMergeInteractiveContent.mergeIsDestructive), () => { + env.model.dispatch("ADD_MERGE", { sheetId, target, force: true }); + }); + } + } + } + + const FORMATS = [ + { name: "automatic", text: NumberFormatTerms.Automatic }, + { name: "number", text: NumberFormatTerms.Number, description: "1,000.12", value: "#,##0.00" }, + { name: "percent", text: NumberFormatTerms.Percent, description: "10.12%", value: "0.00%" }, + { + name: "currency", + text: NumberFormatTerms.Currency, + description: "$1,000.12", + value: "[$$]#,##0.00", + }, + { + name: "currency_rounded", + text: NumberFormatTerms.CurrencyRounded, + description: "$1,000", + value: "[$$]#,##0", + }, + { name: "date", text: NumberFormatTerms.Date, description: "9/26/2008", value: "m/d/yyyy" }, + { name: "time", text: NumberFormatTerms.Time, description: "10:43:00 PM", value: "hh:mm:ss a" }, + { + name: "datetime", + text: NumberFormatTerms.DateTime, + description: "9/26/2008 22:43:00", + value: "m/d/yyyy hh:mm:ss", + }, + { + name: "duration", + text: NumberFormatTerms.Duration, + description: "27:51:38", + value: "hhhh:mm:ss", + }, + ]; + const CUSTOM_FORMATS = [ + { name: "custom_currency", text: NumberFormatTerms.CustomCurrency, sidePanel: "CustomCurrency" }, + ]; + // ----------------------------------------------------------------------------- + // TopBar + // ----------------------------------------------------------------------------- + css /* scss */ ` + .o-spreadsheet-topbar { + background-color: white; + line-height: 1.2; + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.2; + user-select: none; + + .o-topbar-top { + border-bottom: 1px solid #e0e2e4; + display: flex; + padding: 2px 10px; + justify-content: space-between; + + /* Menus */ + .o-topbar-topleft { + display: flex; + .o-topbar-menu { + padding: 4px 6px; + margin: 0 2px; + cursor: pointer; + } + + .o-topbar-menu:hover, + .o-topbar-menu-active { + background-color: #f1f3f4; + border-radius: 2px; + } + } + + .o-topbar-topright { + display: flex; + justify-content: flex-end; + } + } + /* Toolbar + Cell Content */ + .o-topbar-toolbar { + border-bottom: 1px solid #e0e2e4; + display: flex; + + .o-readonly-toolbar { + display: flex; + align-items: center; + background-color: ${BACKGROUND_HEADER_COLOR}; + padding-left: 18px; + padding-right: 18px; + } + .o-composer-container { + height: 34px; + border: 1px solid #e0e2e4; + margin-top: -1px; + margin-bottom: -1px; + } + + /* Toolbar */ + .o-toolbar-tools { + display: flex; + flex-shrink: 0; + margin-left: 16px; + color: #333; + cursor: default; + + .o-tool { + display: flex; + align-items: center; + margin: 2px; + padding: 0 3px; + border-radius: 2px; + cursor: pointer; + min-width: fit-content; + } + + .o-tool.active, + .o-tool:not(.o-disabled):hover { + background-color: #f1f3f4; + } + + .o-with-color > span { + border-bottom: 4px solid; + height: 16px; + margin-top: 2px; + } + + .o-with-color { + .o-line-item:hover { + outline: 1px solid gray; + } + } + + .o-border-dropdown { + padding: 4px; + } + + .o-divider { + display: inline-block; + border-right: 1px solid #e0e2e4; + width: 0; + margin: 0 6px; + } + + .o-disabled { + opacity: 0.6; + } + + .o-dropdown { + position: relative; + + .o-text-icon { + height: 100%; + line-height: 30px; + } + + .o-text-options > div { + line-height: 26px; + padding: 3px 12px; + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .o-dropdown-content { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: ${ComponentsImportance.Dropdown}; + box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); + background-color: white; + + .o-dropdown-item { + padding: 7px 10px; + } + + .o-dropdown-item:hover { + background-color: rgba(0, 0, 0, 0.08); + } + + .o-dropdown-line { + display: flex; + margin: 1px; + + .o-line-item { + padding: 4px; + + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } + } + + &.o-format-tool { + padding: 5px 0; + width: 250px; + font-size: 12px; + > div { + padding: 0 20px; + white-space: nowrap; + + &.active:before { + content: "✓"; + font-weight: bold; + position: absolute; + left: 5px; + } + } + } + } + } + } + + /* Cell Content */ + .o-toolbar-cell-content { + font-size: 12px; + font-weight: 500; + padding: 0 12px; + margin: 0; + line-height: 34px; + white-space: nowrap; + user-select: text; + } + } + } +`; + class TopBar extends owl.Component { + constructor() { + super(...arguments); + this.DEFAULT_FONT_SIZE = DEFAULT_FONT_SIZE; + this.commonFormats = FORMATS; + this.customFormats = CUSTOM_FORMATS; + this.currentFormatName = "automatic"; + this.fontSizes = fontSizes; + this.style = {}; + this.state = owl.useState({ + menuState: { isOpen: false, position: null, menuItems: [] }, + activeTool: "", + }); + this.isSelectingMenu = false; + this.openedEl = null; + this.inMerge = false; + this.cannotMerge = false; + this.undoTool = false; + this.redoTool = false; + this.paintFormatTool = false; + this.fillColor = "#ffffff"; + this.textColor = "#000000"; + this.menus = []; + this.composerStyle = ` + line-height: 34px; + padding-left: 8px; + height: 34px; + background-color: white; + `; + } + setup() { + owl.useExternalListener(window, "click", this.onClick); + owl.onWillStart(() => this.updateCellState()); + owl.onWillUpdateProps(() => this.updateCellState()); + } + get topbarComponents() { + return topbarComponentRegistry + .getAll() + .filter((item) => !item.isVisible || item.isVisible(this.env)); + } + onClick(ev) { + if (this.openedEl && isChildEvent(this.openedEl, ev)) { + return; + } + this.closeMenus(); + } + toogleStyle(style) { + setStyle(this.env, { [style]: !this.style[style] }); + } + toogleFormat(formatName) { + const formatter = FORMATS.find((f) => f.name === formatName); + const value = (formatter && formatter.value) || ""; + setFormatter(this.env, value); + } + toggleAlign(align) { + setStyle(this.env, { ["align"]: align }); + } + onMenuMouseOver(menu, ev) { + if (this.isSelectingMenu) { + this.toggleContextMenu(menu, ev); + } + } + toggleDropdownTool(tool, ev) { + const isOpen = this.state.activeTool === tool; + this.closeMenus(); + this.state.activeTool = isOpen ? "" : tool; + this.openedEl = isOpen ? null : ev.target; + } + toggleContextMenu(menu, ev) { + this.closeMenus(); + const { left, top, height } = ev.target.getBoundingClientRect(); + this.state.menuState.isOpen = true; + this.state.menuState.position = { x: left, y: top + height }; + this.state.menuState.menuItems = getMenuChildren(menu, this.env).filter((item) => !item.isVisible || item.isVisible(this.env)); + this.state.menuState.parentMenu = menu; + this.isSelectingMenu = true; + this.openedEl = ev.target; + } + closeMenus() { + this.state.activeTool = ""; + this.state.menuState.isOpen = false; + this.state.menuState.parentMenu = undefined; + this.isSelectingMenu = false; + this.openedEl = null; + } + updateCellState() { + const zones = this.env.model.getters.getSelectedZones(); + const { top, left, right, bottom } = zones[0]; + this.cannotMerge = zones.length > 1 || (top === bottom && left === right); + this.inMerge = false; + if (!this.cannotMerge) { + const { col, row } = this.env.model.getters.getPosition(); + const zone = this.env.model.getters.expandZone(this.env.model.getters.getActiveSheetId(), { + left: col, + right: col, + top: row, + bottom: row, + }); + this.inMerge = isEqual(zones[0], zone); + } + this.undoTool = this.env.model.getters.canUndo(); + this.redoTool = this.env.model.getters.canRedo(); + this.paintFormatTool = this.env.model.getters.isPaintingFormat(); + const cell = this.env.model.getters.getActiveCell(); + if (cell && cell.format) { + const currentFormat = this.commonFormats.find((f) => f.value === cell.format); + this.currentFormatName = currentFormat ? currentFormat.name : ""; + } + else { + this.currentFormatName = "automatic"; + } + this.style = { ...this.env.model.getters.getCurrentStyle() }; + this.style.align = this.style.align || (cell === null || cell === void 0 ? void 0 : cell.defaultAlign); + this.fillColor = this.style.fillColor || "#ffffff"; + this.textColor = this.style.textColor || "#000000"; + this.menus = topbarMenuRegistry + .getAll() + .filter((item) => !item.isVisible || item.isVisible(this.env)); + } + getMenuName(menu) { + return getMenuName(menu, this.env); + } + toggleMerge() { + const zones = this.env.model.getters.getSelectedZones(); + const target = [zones[zones.length - 1]]; + const sheetId = this.env.model.getters.getActiveSheetId(); + if (this.inMerge) { + this.env.model.dispatch("REMOVE_MERGE", { sheetId, target }); + } + else { + interactiveAddMerge(this.env, sheetId, target); + } + } + setColor(target, color) { + setStyle(this.env, { [target]: color }); + } + setBorder(command) { + this.env.model.dispatch("SET_FORMATTING", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + border: command, + }); + } + setFormat(ev) { + const format = ev.target.dataset.format; + if (format) { + this.toogleFormat(format); + return; + } + const custom = ev.target.dataset.custom; + if (custom) { + this.openCustomFormatSidePanel(custom); + } + } + openCustomFormatSidePanel(custom) { + const customFormatter = CUSTOM_FORMATS.find((c) => c.name === custom); + const sidePanel = (customFormatter && customFormatter.sidePanel) || ""; + this.env.openSidePanel(sidePanel); + } + setDecimal(step) { + this.env.model.dispatch("SET_DECIMAL", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + step: step, + }); + } + paintFormat() { + this.env.model.dispatch("ACTIVATE_PAINT_FORMAT", { + target: this.env.model.getters.getSelectedZones(), + }); + } + clearFormatting() { + this.env.model.dispatch("CLEAR_FORMATTING", { + sheetId: this.env.model.getters.getActiveSheetId(), + target: this.env.model.getters.getSelectedZones(), + }); + } + setSize(ev) { + const fontSize = parseFloat(ev.target.dataset.size); + setStyle(this.env, { fontSize }); + } + doAction(action) { + action(this.env); + this.closeMenus(); + } + undo() { + this.env.model.dispatch("REQUEST_UNDO"); + } + redo() { + this.env.model.dispatch("REQUEST_REDO"); + } + } + TopBar.template = "o-spreadsheet-TopBar"; + TopBar.components = { ColorPicker, Menu, Composer }; + + css /* scss */ ` + .o-spreadsheet { + position: relative; + display: grid; + grid-template-columns: auto 350px; + * { + font-family: "Roboto", "RobotoDraft", Helvetica, Arial, sans-serif; + } + &, + *, + *:before, + *:after { + box-sizing: content-box; + } + } + + .o-two-columns { + grid-column: 1 / 3; + } + + .o-icon { + width: ${ICON_EDGE_LENGTH}px; + height: ${ICON_EDGE_LENGTH}px; + opacity: 0.6; + vertical-align: middle; + } + + .o-cf-icon { + width: ${CF_ICON_EDGE_LENGTH}px; + height: ${CF_ICON_EDGE_LENGTH}px; + vertical-align: sub; + } +`; + const t = (s) => s; + class Spreadsheet extends owl.Component { + getStyle() { + if (this.env.isDashboard()) { + return `grid-template-rows: auto;`; + } + return `grid-template-rows: ${TOPBAR_HEIGHT}px auto ${BOTTOMBAR_HEIGHT + 1}px`; + } + setup() { + var _a, _b; + (_b = (_a = this.props).exposeSpreadsheet) === null || _b === void 0 ? void 0 : _b.call(_a, this); + this.model = this.props.model; + this.sidePanel = owl.useState({ isOpen: false, panelProps: {} }); + this.composer = owl.useState({ + topBarFocus: "inactive", + gridFocusMode: "inactive", + }); + this.keyDownMapping = { + "CTRL+H": () => this.toggleSidePanel("FindAndReplace", {}), + "CTRL+F": () => this.toggleSidePanel("FindAndReplace", {}), + }; + owl.useSubEnv({ + model: this.model, + isDashboard: () => this.model.getters.isDashboard(), + openSidePanel: this.openSidePanel.bind(this), + toggleSidePanel: this.toggleSidePanel.bind(this), + _t: Spreadsheet._t, + clipboard: navigator.clipboard, + }); + owl.useExternalListener(window, "resize", () => this.render(true)); + owl.useExternalListener(document.body, "keyup", this.onKeyup.bind(this)); + owl.useExternalListener(window, "beforeunload", this.unbindModelEvents.bind(this)); + owl.onMounted(() => this.bindModelEvents()); + owl.onWillUnmount(() => this.unbindModelEvents()); + } + get focusTopBarComposer() { + return this.model.getters.getEditionMode() === "inactive" + ? "inactive" + : this.composer.topBarFocus; + } + get focusGridComposer() { + return this.model.getters.getEditionMode() === "inactive" + ? "inactive" + : this.composer.gridFocusMode; + } + bindModelEvents() { + this.model.on("update", this, () => this.render(true)); + this.model.on("notify-ui", this, this.onNotifyUI); + } + unbindModelEvents() { + this.model.off("update", this); + this.model.off("notify-ui", this); + } + onNotifyUI(payload) { + switch (payload.type) { + case "NOTIFICATION": + this.env.notifyUser(payload.text); + break; + } + } + openSidePanel(panel, panelProps) { + this.sidePanel.component = panel; + this.sidePanel.panelProps = panelProps; + this.sidePanel.isOpen = true; + } + closeSidePanel() { + this.sidePanel.isOpen = false; + this.focusGrid(); + } + toggleSidePanel(panel, panelProps) { + if (this.sidePanel.isOpen && panel === this.sidePanel.component) { + this.sidePanel.isOpen = false; + this.focusGrid(); + } + else { + this.openSidePanel(panel, panelProps); + } + } + focusGrid() { + if (!this._focusGrid) { + throw new Error("_focusGrid should be exposed by the grid component"); + } + this._focusGrid(); + } + save() { + var _a, _b; + (_b = (_a = this.props).onContentSaved) === null || _b === void 0 ? void 0 : _b.call(_a, this.model.exportData()); + } + onKeyup(ev) { + if (ev.key === "Control") { + this.model.dispatch("STOP_SELECTION_INPUT"); + } + } + onKeydown(ev) { + if (ev.key === "Control" && !ev.repeat) { + this.model.dispatch("PREPARE_SELECTION_INPUT_EXPANSION"); + } + let keyDownString = ""; + if (ev.ctrlKey || ev.metaKey) { + keyDownString += "CTRL+"; + } + keyDownString += ev.key.toUpperCase(); + let handler = this.keyDownMapping[keyDownString]; + if (handler) { + ev.preventDefault(); + ev.stopPropagation(); + handler(); + return; + } + } + onTopBarComposerFocused(selection) { + if (this.model.getters.isReadonly()) { + return; + } + this.model.dispatch("UNFOCUS_SELECTION_INPUT"); + this.composer.topBarFocus = "contentFocus"; + this.composer.gridFocusMode = "inactive"; + this.setComposerContent({ selection } || {}); + } + onGridComposerContentFocused() { + if (this.model.getters.isReadonly()) { + return; + } + this.model.dispatch("UNFOCUS_SELECTION_INPUT"); + this.composer.topBarFocus = "inactive"; + this.composer.gridFocusMode = "contentFocus"; + this.setComposerContent({}); + } + onGridComposerCellFocused(content, selection) { + if (this.model.getters.isReadonly()) { + return; + } + this.model.dispatch("UNFOCUS_SELECTION_INPUT"); + this.composer.topBarFocus = "inactive"; + this.composer.gridFocusMode = "cellFocus"; + this.setComposerContent({ content, selection } || {}); + } + /** + * Start the edition or update the content if it's already started. + */ + setComposerContent({ content, selection, }) { + if (this.model.getters.getEditionMode() === "inactive") { + this.model.dispatch("START_EDITION", { text: content, selection }); + } + else if (content) { + this.model.dispatch("SET_CURRENT_CONTENT", { content, selection }); + } + } + } + Spreadsheet.template = "o-spreadsheet-Spreadsheet"; + Spreadsheet.components = { TopBar, Grid, BottomBar, SidePanel }; + Spreadsheet._t = t; + + class LocalTransportService { + constructor() { + this.listeners = []; + } + sendMessage(message) { + for (const { callback } of this.listeners) { + callback(message); + } + } + onNewMessage(id, callback) { + this.listeners.push({ id, callback }); + } + leave(id) { + this.listeners = this.listeners.filter((listener) => listener.id !== id); + } + } + + /* + * This file contains the specifics transformations + */ + otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["CREATE_CHART", "UPDATE_CHART"], updateChartRangesTransformation); + otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["CREATE_CHART", "UPDATE_CHART"], updateChartRangesTransformation); + otRegistry.addTransformation("DELETE_SHEET", ["MOVE_RANGES"], transformTargetSheetId); + otRegistry.addTransformation("DELETE_FIGURE", ["UPDATE_FIGURE", "UPDATE_CHART"], updateChartFigure); + otRegistry.addTransformation("CREATE_SHEET", ["CREATE_SHEET"], createSheetTransformation); + otRegistry.addTransformation("ADD_MERGE", ["ADD_MERGE", "REMOVE_MERGE"], mergeTransformation); + function transformTargetSheetId(cmd, executed) { + const deletedSheetId = executed.sheetId; + if (cmd.targetSheetId === deletedSheetId || cmd.sheetId === deletedSheetId) { + return undefined; + } + return cmd; + } + function updateChartFigure(toTransform, executed) { + if (toTransform.id === executed.id) { + return undefined; + } + return toTransform; + } + function updateChartRangesTransformation(toTransform, executed) { + return { + ...toTransform, + definition: transformDefinition(toTransform.definition, executed), + }; + } + function createSheetTransformation(cmd, executed) { + var _a; + if (cmd.name === executed.name) { + return { + ...cmd, + name: ((_a = cmd.name) === null || _a === void 0 ? void 0 : _a.match(/\d+/)) + ? cmd.name.replace(/\d+/, (n) => (parseInt(n) + 1).toString()) + : `${cmd.name}~`, + position: cmd.position + 1, + }; + } + return cmd; + } + function mergeTransformation(cmd, executed) { + if (cmd.sheetId !== executed.sheetId) { + return cmd; + } + const target = []; + for (const zone1 of cmd.target) { + for (const zone2 of executed.target) { + if (!overlap(zone1, zone2)) { + target.push({ ...zone1 }); + } + } + } + if (target.length) { + return { ...cmd, target }; + } + return undefined; + } + + const transformations = [ + { match: isSheetDependent, fn: transformSheetId }, + { match: isTargetDependent, fn: transformTarget }, + { match: isPositionDependent, fn: transformPosition }, + { match: isGridDependent, fn: transformDimension }, + { match: isRangeDependant, fn: transformRangeData }, + ]; + /** + * Get the result of applying the operation transformations on the given command + * to transform based on the executed command. + * Let's see a small example: + * Given + * - command A: set the content of C1 to "Hello" + * - command B: add a column after A + * + * If command B has been executed locally and not transmitted (yet) to + * other clients, and command A arrives from an other client to be executed locally. + * Command A is no longer valid and no longer reflects the user intention. + * It needs to be transformed knowing that command B is already executed. + * transform(A, B) => set the content of D1 to "Hello" + */ + function transform(toTransform, executed) { + const specificTransform = otRegistry.getTransformation(toTransform.type, executed.type); + return specificTransform + ? specificTransform(toTransform, executed) + : genericTransform(toTransform, executed); + } + /** + * Get the result of applying the operation transformations on all the given + * commands to transform for each executed commands. + */ + function transformAll(toTransform, executed) { + let transformedCommands = [...toTransform]; + for (const executedCommand of executed) { + transformedCommands = transformedCommands + .map((cmd) => transform(cmd, executedCommand)) + .filter(isDefined$1); + } + return transformedCommands; + } + /** + * Apply all generic transformation based on the characteristic of the given commands. + */ + function genericTransform(cmd, executed) { + for (const { match, fn } of transformations) { + if (match(cmd)) { + const result = fn(cmd, executed); + if (result === "SKIP_TRANSFORMATION") { + continue; + } + if (result === "IGNORE_COMMAND") { + return undefined; + } + cmd = result; + } + } + return cmd; + } + function transformSheetId(cmd, executed) { + const deleteSheet = executed.type === "DELETE_SHEET" && executed.sheetId; + if (cmd.sheetId === deleteSheet) { + return "IGNORE_COMMAND"; + } + else if (cmd.type === "CREATE_SHEET" || + executed.type === "CREATE_SHEET" || + cmd.sheetId !== executed.sheetId) { + return cmd; + } + return "SKIP_TRANSFORMATION"; + } + function transformTarget(cmd, executed) { + const transformSheetResult = transformSheetId(cmd, executed); + if (transformSheetResult !== "SKIP_TRANSFORMATION") { + return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : cmd; + } + const target = []; + for (const zone of cmd.target) { + const newZone = transformZone(zone, executed); + if (newZone) { + target.push(newZone); + } + } + if (!target.length) { + return "IGNORE_COMMAND"; + } + return { ...cmd, target }; + } + function transformRangeData(cmd, executed) { + const ranges = []; + const deletedSheet = executed.type === "DELETE_SHEET" && executed.sheetId; + for (const range of cmd.ranges) { + if (range._sheetId !== executed.sheetId) { + ranges.push({ ...range, _zone: range._zone }); + } + else { + const newZone = transformZone(range._zone, executed); + if (newZone && deletedSheet !== range._sheetId) { + ranges.push({ ...range, _zone: newZone }); + } + } + } + if (!ranges.length) { + return "IGNORE_COMMAND"; + } + return { ...cmd, ranges }; + } + function transformDimension(cmd, executed) { + const transformSheetResult = transformSheetId(cmd, executed); + if (transformSheetResult !== "SKIP_TRANSFORMATION") { + return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : cmd; + } + if (executed.type === "ADD_COLUMNS_ROWS" || executed.type === "REMOVE_COLUMNS_ROWS") { + const isUnique = cmd.type === "ADD_COLUMNS_ROWS"; + const field = isUnique ? "base" : "elements"; + let elements = isUnique ? [cmd[field]] : cmd[field]; + if (executed.type === "REMOVE_COLUMNS_ROWS") { + elements = elements + .map((element) => { + if (executed.elements.includes(element)) { + return undefined; + } + const executedElements = executed.elements.sort((a, b) => b - a); + for (let removedElement of executedElements) { + if (element > removedElement) { + element--; + } + } + return element; + }) + .filter(isDefined$1); + } + if (executed.type === "ADD_COLUMNS_ROWS") { + const base = executed.position === "before" ? executed.base - 1 : executed.base; + elements = elements.map((el) => (el > base ? el + executed.quantity : el)); + } + if (elements.length) { + let result = elements; + if (isUnique) { + result = elements[0]; + } + return { ...cmd, [field]: result }; + } + return "IGNORE_COMMAND"; + } + return "SKIP_TRANSFORMATION"; + } + /** + * Transform a PositionDependentCommand. It could be impacted by a grid command + * (Add/remove cols/rows) and a merge + */ + function transformPosition(cmd, executed) { + const transformSheetResult = transformSheetId(cmd, executed); + if (transformSheetResult !== "SKIP_TRANSFORMATION") { + return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : cmd; + } + if (executed.type === "ADD_COLUMNS_ROWS" || executed.type === "REMOVE_COLUMNS_ROWS") { + return transformPositionWithGrid(cmd, executed); + } + if (executed.type === "ADD_MERGE") { + return transformPositionWithMerge(cmd, executed); + } + return "SKIP_TRANSFORMATION"; + } + /** + * Transform a PositionDependentCommand after a grid shape modification. This + * transformation consists of updating the position. + */ + function transformPositionWithGrid(cmd, executed) { + const field = executed.dimension === "COL" ? "col" : "row"; + let base = cmd[field]; + if (executed.type === "REMOVE_COLUMNS_ROWS") { + const elements = [...executed.elements].sort((a, b) => b - a); + if (elements.includes(base)) { + return "IGNORE_COMMAND"; + } + for (let removedElement of elements) { + if (base >= removedElement) { + base--; + } + } + } + if (executed.type === "ADD_COLUMNS_ROWS") { + if (base > executed.base || (base === executed.base && executed.position === "before")) { + base = base + executed.quantity; + } + } + return { ...cmd, [field]: base }; + } + /** + * Transform a PositionDependentCommand after a merge. This transformation + * consists of checking that the position is not inside the merged zones + */ + function transformPositionWithMerge(cmd, executed) { + for (const zone of executed.target) { + const sameTopLeft = cmd.col === zone.left && cmd.row === zone.top; + if (!sameTopLeft && isInside(cmd.col, cmd.row, zone)) { + return "IGNORE_COMMAND"; + } + } + return cmd; + } + + function inverseCommand(cmd) { + return inverseCommandRegistry.get(cmd.type)(cmd); + } + + /** + * Create an empty structure according to the type of the node key: + * string: object + * number: array + */ + function createEmptyStructure(node) { + if (typeof node === "string") { + return {}; + } + else if (typeof node === "number") { + return []; + } + throw new Error(`Cannot create new node`); + } + + /** + * A branch holds a sequence of operations. + * It can be represented as "A - B - C - D" if A, B, C and D are executed one + * after the other. + * + * @param buildTransformation Factory to build transformations + * @param operations initial operations + */ + class Branch { + constructor(buildTransformation, operations = []) { + this.buildTransformation = buildTransformation; + this.operations = operations; + } + getOperations() { + return this.operations; + } + getOperation(operationId) { + const operation = this.operations.find((op) => op.id === operationId); + if (!operation) { + throw new Error(`Operation ${operationId} not found`); + } + return operation; + } + getLastOperationId() { + var _a; + return (_a = this.operations[this.operations.length - 1]) === null || _a === void 0 ? void 0 : _a.id; + } + /** + * Get the id of the operation appears first in the list of operations + */ + getFirstOperationAmong(op1, op2) { + for (const operation of this.operations) { + if (operation.id === op1) + return op1; + if (operation.id === op2) + return op2; + } + throw new Error(`Operation ${op1} and ${op2} not found`); + } + contains(operationId) { + return !!this.operations.find((operation) => operation.id === operationId); + } + /** + * Add the given operation as the first operation + */ + prepend(operation) { + const transformation = this.buildTransformation.with(operation.data); + this.operations = [ + operation, + ...this.operations.map((operation) => operation.transformed(transformation)), + ]; + } + /** + * add the given operation after the given predecessorOpId + */ + insert(newOperation, predecessorOpId) { + const transformation = this.buildTransformation.with(newOperation.data); + const { before, operation, after } = this.locateOperation(predecessorOpId); + this.operations = [ + ...before, + operation, + newOperation, + ...after.map((operation) => operation.transformed(transformation)), + ]; + } + /** + * Add the given operation as the last operation + */ + append(operation) { + this.operations.push(operation); + } + /** + * Append operations in the given branch to this branch. + */ + appendBranch(branch) { + this.operations = this.operations.concat(branch.operations); + } + /** + * Create and return a copy of this branch, starting after the given operationId + */ + fork(operationId) { + const { after } = this.locateOperation(operationId); + return new Branch(this.buildTransformation, after); + } + /** + * Transform all the operations in this branch with the given transformation + */ + transform(transformation) { + this.operations = this.operations.map((operation) => operation.transformed(transformation)); + } + /** + * Cut the branch before the operation, meaning the operation + * and all following operations are dropped. + */ + cutBefore(operationId) { + this.operations = this.locateOperation(operationId).before; + } + /** + * Cut the branch after the operation, meaning all following operations are dropped. + */ + cutAfter(operationId) { + const { before, operation } = this.locateOperation(operationId); + this.operations = before.concat([operation]); + } + /** + * Find an operation in this branch based on its id. + * This returns the operation itself, operations which comes before it + * and operation which comes after it. + */ + locateOperation(operationId) { + const operationIndex = this.operations.findIndex((step) => step.id === operationId); + if (operationIndex === -1) { + throw new Error(`Operation ${operationId} not found`); + } + return { + before: this.operations.slice(0, operationIndex), + operation: this.operations[operationIndex], + after: this.operations.slice(operationIndex + 1), + }; + } + } + + /** + * An Operation can be executed to change a data structure from state A + * to state B. + * It should hold the necessary data used to perform this transition. + * It should be possible to revert the changes made by this operation. + * + * In the context of o-spreadsheet, the data from an operation would + * be a revision (the commands are used to execute it, the `changes` are used + * to revert it). + */ + class Operation { + constructor(id, data) { + this.id = id; + this.data = data; + } + transformed(transformation) { + return new LazyOperation(this.id, lazy(() => transformation(this.data))); + } + } + class LazyOperation { + constructor(id, lazyData) { + this.id = id; + this.lazyData = lazyData; + } + get data() { + return this.lazyData(); + } + transformed(transformation) { + return new LazyOperation(this.id, this.lazyData.map(transformation)); + } + } + + /** + * An execution object is a sequence of executionSteps (each execution step is an operation in a branch). + * + * You can iterate over the steps of an execution + * ```js + * for (const operation of execution) { + * // ... do something + * } + * ``` + */ + class OperationSequence { + constructor(operations) { + this.operations = operations; + } + [Symbol.iterator]() { + return this.operations[Symbol.iterator](); + } + /** + * Stop the operation sequence at a given operation + * @param operationId included + */ + stopWith(operationId) { + function* filter(execution, operationId) { + for (const step of execution) { + yield step; + if (step.operation.id === operationId) { + return; + } + } + } + return new OperationSequence(filter(this.operations, operationId)); + } + /** + * Stop the operation sequence before a given operation + * @param operationId excluded + */ + stopBefore(operationId) { + function* filter(execution, operationId) { + for (const step of execution) { + if (step.operation.id === operationId) { + return; + } + yield step; + } + } + return new OperationSequence(filter(this.operations, operationId)); + } + /** + * Start the operation sequence at a given operation + * @param operationId excluded + */ + startAfter(operationId) { + function* filter(execution, operationId) { + let skip = true; + for (const step of execution) { + if (!skip) { + yield step; + } + if (step.operation.id === operationId) { + skip = false; + } + } + } + return new OperationSequence(filter(this.operations, operationId)); + } + } + + /** + * The tree is a data structure used to maintain the different branches of the + * SelectiveHistory. + * + * Branches can be "stacked" on each other and an execution path can be derived + * from any stack of branches. The rules to derive this path is explained below. + * + * An operation can be cancelled/undone by inserting a new branch below + * this operation. + * e.g + * Given the branch A B C + * To undo B, a new branching branch is inserted at operation B. + * ```txt + * A B C D + * > C' D' + * ``` + * A new execution path can now be derived. At each operation: + * - if there is a lower branch, don't execute it and go to the operation below + * - if not, execute it and go to the operation on the right. + * The execution path is A C' D' + * Operation C and D have been adapted (transformed) in the lower branch + * since operation B is not executed in this branch. + * + */ + class Tree { + constructor(buildTransformation, initialBranch) { + this.buildTransformation = buildTransformation; + this.branchingOperationIds = new Map(); + this.branches = [initialBranch]; + } + /** + * Return the last branch of the entire stack of branches. + */ + getLastBranch() { + return this.branches[this.branches.length - 1]; + } + /** + * Return the sequence of operations from this branch + * until the very last branch. + */ + execution(branch) { + return new OperationSequence(linkNext(this._execution(branch), this._execution(branch))); + } + /** + * Return the sequence of operations from this branch + * to the very first branch. + */ + revertedExecution(branch) { + return new OperationSequence(linkNext(this._revertedExecution(branch), this._revertedExecution(branch))); + } + /** + * Append an operation to the end of the tree. + * Also insert the (transformed) operation in all previous branches. + * + * Adding operation `D` to the last branch + * ```txt + * A1 B1 C1 + * > B2 C2 + * ``` + * will give + * ```txt + * A1 B1 C1 D' with D' = D transformed with A1 + * > B2 C2 D + * ``` + */ + insertOperationLast(branch, operation) { + var _a; + const insertAfter = branch.getLastOperationId() || ((_a = this.previousBranch(branch)) === null || _a === void 0 ? void 0 : _a.getLastOperationId()); + branch.append(operation); + if (insertAfter) { + this.insertPrevious(branch, operation, insertAfter); + } + } + /** + * Insert a new operation after an other operation. + * The operation will be inserted in this branch, in next branches (transformed) + * and in previous branches (also transformed). + * + * Given + * ```txt + * 1: A1 B1 C1 + * 2: > B2 C2 + * 3: > C3 + * ``` + * Inserting D to branch 2 gives + * ```txt + * 1: A1 B1 C1 D1 D1 = D transformed with A1 + * 2: > B2 C2 D with D = D + * 3: > C3 D2 D2 = D transformed without B2 (B2⁻¹) + * ``` + */ + insertOperationAfter(branch, operation, predecessorOpId) { + branch.insert(operation, predecessorOpId); + this.updateNextWith(branch, operation, predecessorOpId); + this.insertPrevious(branch, operation, predecessorOpId); + } + /** + * Create a new branching branch at the given operation. + * This cancels the operation from the execution path. + */ + undo(branch, operation) { + const transformation = this.buildTransformation.without(operation.data); + const branchingId = this.branchingOperationIds.get(branch); + this.branchingOperationIds.set(branch, operation.id); + const nextBranch = branch.fork(operation.id); + if (branchingId) { + this.branchingOperationIds.set(nextBranch, branchingId); + } + this.insertBranchAfter(branch, nextBranch); + this.transform(nextBranch, transformation); + } + /** + * Remove the branch just after this one. This un-cancels (redo) the branching + * operation. Lower branches will be transformed accordingly. + * + * Given + * ```txt + * 1: A1 B1 C1 + * 2: > B2 C2 + * 3: > C3 + * ``` + * removing the next branch of 1 gives + * + * ```txt + * 1: A1 B1 C1 + * 2: > C3' with C3' = C1 transformed without B1 (B1⁻¹) + * ``` + */ + redo(branch) { + const removedBranch = this.nextBranch(branch); + if (!removedBranch) + return; + const nextBranch = this.nextBranch(removedBranch); + this.removeBranchFromTree(removedBranch); + const undoBranchingId = this.branchingOperationIds.get(removedBranch); + if (undoBranchingId) { + this.branchingOperationIds.set(branch, undoBranchingId); + } + else { + this.branchingOperationIds.delete(branch); + } + if (nextBranch) { + this.rebaseUp(nextBranch); + } + } + /** + * Drop the operation and all following operations in every + * branch + */ + drop(operationId) { + for (const branch of this.branches) { + if (branch.contains(operationId)) { + branch.cutBefore(operationId); + } + } + } + /** + * Find the operation in the execution path. + */ + findOperation(branch, operationId) { + for (const operation of this.revertedExecution(branch)) { + if (operation.operation.id === operationId) { + return operation; + } + } + throw new Error(`Operation ${operationId} not found`); + } + /** + * Rebuild transformed operations of this branch based on the upper branch. + * + * Given the following structure: + * ```txt + * 1: A1 B1 C1 + * 2: > B2 C2 + * 3: > C3 + * ``` + * Rebasing branch "2" gives + * ```txt + * 1: A1 B1 C1 + * 2: > B2' C2' With B2' = B1 transformed without A1 and C2' = C1 transformed without A1 + * 3: > C3' C3' = C2' transformed without B2' + * ``` + */ + rebaseUp(branch) { + const { previousBranch, branchingOperation } = this.findPreviousBranchingOperation(branch); + if (!previousBranch || !branchingOperation) + return; + const rebaseTransformation = this.buildTransformation.without(branchingOperation.data); + const newBranch = previousBranch.fork(branchingOperation.id); + this.branchingOperationIds.set(newBranch, this.branchingOperationIds.get(branch)); + this.removeBranchFromTree(branch); + this.insertBranchAfter(previousBranch, newBranch); + newBranch.transform(rebaseTransformation); + const nextBranch = this.nextBranch(newBranch); + if (nextBranch) { + this.rebaseUp(nextBranch); + } + } + removeBranchFromTree(branch) { + const index = this.branches.findIndex((l) => l === branch); + this.branches.splice(index, 1); + } + insertBranchAfter(branch, toInsert) { + const index = this.branches.findIndex((l) => l === branch); + this.branches.splice(index + 1, 0, toInsert); + } + /** + * Update the branching branch of this branch, either by (1) inserting the new + * operation in it or (2) by transforming it. + * (1) If the operation is positioned before the branching branch, the branching + * branch should be transformed with this operation. + * (2) If it's positioned after, the operation should be inserted in the + * branching branch. + */ + updateNextWith(branch, operation, predecessorOpId) { + const branchingId = this.branchingOperationIds.get(branch); + const nextBranch = this.nextBranch(branch); + if (!branchingId || !nextBranch) { + return; + } + if (branch.getFirstOperationAmong(predecessorOpId, branchingId) === branchingId) { + const transformedOperation = this.addToNextBranch(branch, nextBranch, branchingId, operation, predecessorOpId); + this.updateNextWith(nextBranch, transformedOperation, predecessorOpId); + } + else { + const transformation = this.buildTransformation.with(operation.data); + this.transform(nextBranch, transformation); + } + } + addToNextBranch(branch, nextBranch, branchingId, operation, predecessorOpId) { + // If the operation is inserted after the branching operation, it should + // be positioned first. + let transformedOperation = operation; + if (predecessorOpId === branchingId) { + transformedOperation = this.getTransformedOperation(branch, branchingId, operation); + nextBranch.prepend(transformedOperation); + } + else if (nextBranch.contains(predecessorOpId)) { + transformedOperation = this.getTransformedOperation(branch, branchingId, operation); + nextBranch.insert(transformedOperation, predecessorOpId); + } + else { + nextBranch.append(operation); + } + return transformedOperation; + } + getTransformedOperation(branch, branchingId, operation) { + const branchingOperation = branch.getOperation(branchingId); + const branchingTransformation = this.buildTransformation.without(branchingOperation.data); + return operation.transformed(branchingTransformation); + } + /** + * Check if this branch should execute the given operation. + * i.e. If the operation is not cancelled by a branching branch. + */ + shouldExecute(branch, operation) { + return operation.id !== this.branchingOperationIds.get(branch); + } + transform(branch, transformation) { + branch.transform(transformation); + const nextBranch = this.nextBranch(branch); + if (nextBranch) { + this.transform(nextBranch, transformation); + } + } + /** + * Insert a new operation in previous branches. The operations which are + * positioned after the inserted operations are transformed with the newly + * inserted operations. This one is also transformed, with the branching + * operation. + */ + insertPrevious(branch, newOperation, insertAfter) { + const { previousBranch, branchingOperation } = this.findPreviousBranchingOperation(branch); + if (!previousBranch || !branchingOperation) + return; + const transformation = this.buildTransformation.with(branchingOperation.data); + const branchTail = branch.fork(insertAfter); + branchTail.transform(transformation); + previousBranch.cutAfter(insertAfter); + previousBranch.appendBranch(branchTail); + const operationToInsert = newOperation.transformed(transformation); + this.insertPrevious(previousBranch, operationToInsert, insertAfter); + } + findPreviousBranchingOperation(branch) { + const previousBranch = this.previousBranch(branch); + if (!previousBranch) + return { previousBranch: undefined, branchingOperation: undefined }; + const previousBranchingId = this.branchingOperationIds.get(previousBranch); + if (!previousBranchingId) + return { previousBranch: undefined, branchingOperation: undefined }; + return { + previousBranch, + branchingOperation: previousBranch.getOperation(previousBranchingId), + }; + } + /** + * Retrieve the next branch of the given branch + */ + nextBranch(branch) { + const index = this.branches.findIndex((l) => l === branch); + if (index === -1) { + return undefined; + } + return this.branches[index + 1]; + } + /** + * Retrieve the previous branch of the given branch + */ + previousBranch(branch) { + const index = this.branches.findIndex((l) => l === branch); + if (index === -1) { + return undefined; + } + return this.branches[index - 1]; + } + /** + * Yields the sequence of operations to execute, in reverse order. + */ + *_revertedExecution(branch) { + const branchingOperationId = this.branchingOperationIds.get(branch); + let afterBranchingPoint = !!branchingOperationId; + const operations = branch.getOperations(); + for (let i = operations.length - 1; i >= 0; i--) { + const operation = operations[i]; + if (operation.id === branchingOperationId) { + afterBranchingPoint = false; + } + if (!afterBranchingPoint) { + yield { + operation: operation, + branch: branch, + isCancelled: !this.shouldExecute(branch, operation), + }; + } + } + const previous = this.previousBranch(branch); + yield* previous ? this._revertedExecution(previous) : []; + } + /** + * Yields the sequence of operations to execute + */ + *_execution(branch) { + for (const operation of branch.getOperations()) { + yield { + operation: operation, + branch: branch, + isCancelled: !this.shouldExecute(branch, operation), + }; + if (operation.id === this.branchingOperationIds.get(branch)) { + const next = this.nextBranch(branch); + yield* next ? this._execution(next) : []; + return; + } + } + if (!this.branchingOperationIds.get(branch)) { + const next = this.nextBranch(branch); + yield* next ? this._execution(next) : []; + } + } + } + + class SelectiveHistory { + /** + * The selective history is a data structure used to register changes/updates of a state. + * Each change/update is called an "operation". + * The data structure allows to easily cancel (and redo) any operation individually. + * An operation can be represented by any data structure. It can be a "command", a "diff", etc. + * However it must have the following properties: + * - it can be applied to modify the state + * - it can be reverted on the state such that it was never executed. + * - it can be transformed given other operation (Operational Transformation) + * + * Since this data structure doesn't know anything about the state nor the structure of + * operations, the actual work must be performed by external functions given as parameters. + * @param initialOperationId + * @param applyOperation a function which can apply an operation to the state + * @param revertOperation a function which can revert an operation from the state + * @param buildEmpty a function returning an "empty" operation. + * i.e an operation that leaves the state unmodified once applied or reverted + * (used for internal implementation) + * @param buildTransformation Factory used to build transformations + */ + constructor(initialOperationId, applyOperation, revertOperation, buildEmpty, buildTransformation) { + this.applyOperation = applyOperation; + this.revertOperation = revertOperation; + this.buildEmpty = buildEmpty; + this.buildTransformation = buildTransformation; + this.HEAD_BRANCH = new Branch(this.buildTransformation); + this.tree = new Tree(buildTransformation, this.HEAD_BRANCH); + const initial = new Operation(initialOperationId, buildEmpty(initialOperationId)); + this.tree.insertOperationLast(this.HEAD_BRANCH, initial); + this.HEAD_OPERATION = initial; + } + /** + * Return the operation identified by its id. + */ + get(operationId) { + return this.tree.findOperation(this.HEAD_BRANCH, operationId).operation.data; + } + /** + * Append a new operation as the last one + */ + append(operationId, data) { + const operation = new Operation(operationId, data); + const branch = this.tree.getLastBranch(); + this.tree.insertOperationLast(branch, operation); + this.HEAD_BRANCH = branch; + this.HEAD_OPERATION = operation; + } + /** + * Insert a new operation after a specific operation (may not be the last operation). + * Following operations will be transformed according + * to the new operation. + */ + insert(operationId, data, insertAfter) { + const operation = new Operation(operationId, data); + this.revertTo(insertAfter); + this.tree.insertOperationAfter(this.HEAD_BRANCH, operation, insertAfter); + this.fastForward(); + } + /** + * @param operationId operation to undo + * @param undoId the id of the "undo operation" + * @param insertAfter the id of the operation after which to insert the undo + */ + undo(operationId, undoId, insertAfter) { + const { branch, operation } = this.tree.findOperation(this.HEAD_BRANCH, operationId); + this.revertBefore(operationId); + this.tree.undo(branch, operation); + this.fastForward(); + this.insert(undoId, this.buildEmpty(undoId), insertAfter); + } + /** + * @param operationId operation to redo + * @param redoId the if of the "redo operation" + * @param insertAfter the id of the operation after which to insert the redo + */ + redo(operationId, redoId, insertAfter) { + const { branch } = this.tree.findOperation(this.HEAD_BRANCH, operationId); + this.revertBefore(operationId); + this.tree.redo(branch); + this.fastForward(); + this.insert(redoId, this.buildEmpty(redoId), insertAfter); + } + drop(operationId) { + this.revertBefore(operationId); + this.tree.drop(operationId); + } + /** + * Revert the state as it was *before* the given operation was executed. + */ + revertBefore(operationId) { + const execution = this.tree.revertedExecution(this.HEAD_BRANCH).stopWith(operationId); + this.revert(execution); + } + /** + * Revert the state as it was *after* the given operation was executed. + */ + revertTo(operationId) { + const execution = operationId + ? this.tree.revertedExecution(this.HEAD_BRANCH).stopBefore(operationId) + : this.tree.revertedExecution(this.HEAD_BRANCH); + this.revert(execution); + } + /** + * Revert an execution + */ + revert(execution) { + for (const { next, operation, isCancelled } of execution) { + if (!isCancelled) { + this.revertOperation(operation.data); + } + if (next) { + this.HEAD_BRANCH = next.branch; + this.HEAD_OPERATION = next.operation; + } + } + } + /** + * Replay the operations between the current HEAD_BRANCH and the end of the tree + */ + fastForward() { + const operations = this.HEAD_OPERATION + ? this.tree.execution(this.HEAD_BRANCH).startAfter(this.HEAD_OPERATION.id) + : this.tree.execution(this.HEAD_BRANCH); + for (const { operation: operation, branch, isCancelled } of operations) { + if (!isCancelled) { + this.applyOperation(operation.data); + } + this.HEAD_OPERATION = operation; + this.HEAD_BRANCH = branch; + } + } + } + + function buildRevisionLog(initialRevisionId, recordChanges, dispatch) { + return new SelectiveHistory(initialRevisionId, (revision) => { + const commands = revision.commands.slice(); + const { changes } = recordChanges(() => { + for (const command of commands) { + dispatch(command); + } + }); + revision.setChanges(changes); + }, (revision) => revertChanges([revision]), (id) => new Revision(id, "empty", [], []), { + with: (revision) => (toTransform) => { + return new Revision(toTransform.id, toTransform.clientId, transformAll(toTransform.commands, revision.commands)); + }, + without: (revision) => (toTransform) => { + return new Revision(toTransform.id, toTransform.clientId, transformAll(toTransform.commands, revision.commands.map(inverseCommand).flat())); + }, + }); + } + /** + * Revert changes from the given revisions + */ + function revertChanges(revisions) { + for (const revision of revisions.slice().reverse()) { + for (let i = revision.changes.length - 1; i >= 0; i--) { + const change = revision.changes[i]; + applyChange(change, "before"); + } + } + } + /** + * Apply the changes of the given HistoryChange to the state + */ + function applyChange(change, target) { + let val = change.root; + let key = change.path[change.path.length - 1]; + for (let pathIndex = 0; pathIndex < change.path.slice(0, -1).length; pathIndex++) { + const p = change.path[pathIndex]; + if (val[p] === undefined) { + const nextPath = change.path[pathIndex + 1]; + val[p] = createEmptyStructure(nextPath); + } + val = val[p]; + } + if (change[target] === undefined) { + delete val[key]; + } + else { + val[key] = change[target]; + } + } + + /** + * Local History + * + * The local history is responsible of tracking the locally state updates + * It maintains the local undo and redo stack to allow to undo/redo only local + * changes + */ + class LocalHistory extends owl.EventBus { + constructor(dispatch, session) { + super(); + this.dispatch = dispatch; + this.session = session; + /** + * Ids of the revisions which can be undone + */ + this.undoStack = []; + /** + * Ids of the revisions which can be redone + */ + this.redoStack = []; + /** + * Flag used to block all commands when an undo or redo is triggered, until + * it is accepted on the server + */ + this.isWaitingForUndoRedo = false; + this.session.on("new-local-state-update", this, this.onNewLocalStateUpdate); + this.session.on("revision-undone", this, ({ commands }) => this.selectiveUndo(commands)); + this.session.on("revision-redone", this, ({ commands }) => this.selectiveRedo(commands)); + this.session.on("pending-revisions-dropped", this, ({ revisionIds }) => this.drop(revisionIds)); + this.session.on("snapshot", this, () => { + this.undoStack = []; + this.redoStack = []; + this.isWaitingForUndoRedo = false; + }); + } + allowDispatch(cmd) { + if (this.isWaitingForUndoRedo) { + return 61 /* WaitingSessionConfirmation */; + } + switch (cmd.type) { + case "REQUEST_UNDO": + if (!this.canUndo()) { + return 5 /* EmptyUndoStack */; + } + break; + case "REQUEST_REDO": + if (!this.canRedo()) { + return 6 /* EmptyRedoStack */; + } + break; + } + return 0 /* Success */; + } + beforeHandle(cmd) { } + handle(cmd) { + switch (cmd.type) { + case "REQUEST_UNDO": + case "REQUEST_REDO": + // History changes (undo & redo) are *not* applied optimistically on the local state. + // We wait a global confirmation from the server. The goal is to avoid handling concurrent + // history changes on multiple clients which are very hard to manage correctly. + this.requestHistoryChange(cmd.type === "REQUEST_UNDO" ? "UNDO" : "REDO"); + } + } + finalize() { } + requestHistoryChange(type) { + const id = type === "UNDO" ? this.undoStack.pop() : this.redoStack.pop(); + if (!id) { + return; + } + this.isWaitingForUndoRedo = true; + if (type === "UNDO") { + this.session.undo(id); + this.redoStack.push(id); + } + else { + this.session.redo(id); + this.undoStack.push(id); + } + } + canUndo() { + return this.undoStack.length > 0; + } + canRedo() { + return this.redoStack.length > 0; + } + drop(revisionIds) { + this.undoStack = this.undoStack.filter((id) => !revisionIds.includes(id)); + this.redoStack = []; + this.isWaitingForUndoRedo = false; + } + onNewLocalStateUpdate({ id }) { + this.undoStack.push(id); + this.redoStack = []; + if (this.undoStack.length > MAX_HISTORY_STEPS) { + this.undoStack.shift(); + } + } + selectiveUndo(commands) { + this.dispatch("UNDO", { commands }); + this.isWaitingForUndoRedo = false; + } + selectiveRedo(commands) { + this.dispatch("REDO", { commands }); + this.isWaitingForUndoRedo = false; + } + } + + class RangeAdapter { + constructor(getters) { + this.providers = []; + this.getters = getters; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + allowDispatch(cmd) { + if (cmd.type === "MOVE_RANGES") { + return cmd.target.length === 1 ? 0 /* Success */ : 24 /* InvalidZones */; + } + return 0 /* Success */; + } + beforeHandle(command) { } + handle(cmd) { + switch (cmd.type) { + case "REMOVE_COLUMNS_ROWS": { + let start = cmd.dimension === "COL" ? "left" : "top"; + let end = cmd.dimension === "COL" ? "right" : "bottom"; + let dimension = cmd.dimension === "COL" ? "columns" : "rows"; + const elements = [...cmd.elements]; + elements.sort((a, b) => b - a); + const groups = groupConsecutive(elements); + this.executeOnAllRanges((range) => { + if (range.sheetId !== cmd.sheetId) { + return { changeType: "NONE" }; + } + let newRange = range; + let changeType = "NONE"; + for (let group of groups) { + const min = Math.min(...group); + const max = Math.max(...group); + if (range.zone[start] <= min && min <= range.zone[end]) { + const toRemove = Math.min(range.zone[end], max) - min + 1; + changeType = "RESIZE"; + newRange = this.createAdaptedRange(newRange, dimension, changeType, -toRemove); + } + else if (range.zone[start] >= min && range.zone[end] <= max) { + changeType = "REMOVE"; + } + else if (range.zone[start] <= max && range.zone[end] >= max) { + const toRemove = max - range.zone[start] + 1; + changeType = "RESIZE"; + newRange = this.createAdaptedRange(newRange, dimension, changeType, -toRemove); + newRange = this.createAdaptedRange(newRange, dimension, "MOVE", -(range.zone[start] - min)); + } + else if (min < range.zone[start]) { + changeType = "MOVE"; + newRange = this.createAdaptedRange(newRange, dimension, changeType, -(max - min + 1)); + } + } + if (changeType !== "NONE") { + return { changeType, range: newRange }; + } + return { changeType: "NONE" }; + }, cmd.sheetId); + break; + } + case "ADD_COLUMNS_ROWS": { + let start = cmd.dimension === "COL" ? "left" : "top"; + let end = cmd.dimension === "COL" ? "right" : "bottom"; + let dimension = cmd.dimension === "COL" ? "columns" : "rows"; + this.executeOnAllRanges((range) => { + if (range.sheetId !== cmd.sheetId) { + return { changeType: "NONE" }; + } + if (cmd.position === "after") { + if (range.zone[start] <= cmd.base && cmd.base < range.zone[end]) { + return { + changeType: "RESIZE", + range: this.createAdaptedRange(range, dimension, "RESIZE", cmd.quantity), + }; + } + if (cmd.base < range.zone[start]) { + return { + changeType: "MOVE", + range: this.createAdaptedRange(range, dimension, "MOVE", cmd.quantity), + }; + } + } + else { + if (range.zone[start] < cmd.base && cmd.base <= range.zone[end]) { + return { + changeType: "RESIZE", + range: this.createAdaptedRange(range, dimension, "RESIZE", cmd.quantity), + }; + } + if (cmd.base <= range.zone[start]) { + return { + changeType: "MOVE", + range: this.createAdaptedRange(range, dimension, "MOVE", cmd.quantity), + }; + } + } + return { changeType: "NONE" }; + }, cmd.sheetId); + break; + } + case "DELETE_SHEET": { + this.executeOnAllRanges((range) => { + if (range.sheetId !== cmd.sheetId) { + return { changeType: "NONE" }; + } + const invalidSheetName = this.getters.getSheetName(cmd.sheetId); + const sheetId = ""; + range = range.clone({ sheetId, invalidSheetName }); + return { changeType: "REMOVE", range }; + }, cmd.sheetId); + break; + } + case "RENAME_SHEET": { + this.executeOnAllRanges((range) => { + if (range.sheetId === cmd.sheetId) { + return { changeType: "CHANGE", range }; + } + if (cmd.name && range.invalidSheetName === cmd.name) { + const invalidSheetName = undefined; + const sheetId = cmd.sheetId; + const newRange = range.clone({ sheetId, invalidSheetName }); + return { changeType: "CHANGE", range: newRange }; + } + return { changeType: "NONE" }; + }); + break; + } + case "MOVE_RANGES": { + const originZone = cmd.target[0]; + this.executeOnAllRanges((range) => { + if (range.sheetId !== cmd.sheetId || !isZoneInside(range.zone, originZone)) { + return { changeType: "NONE" }; + } + const targetSheetId = cmd.targetSheetId; + const offsetX = cmd.col - originZone.left; + const offsetY = cmd.row - originZone.top; + const adaptedRange = this.createAdaptedRange(range, "both", "MOVE", [offsetX, offsetY]); + const prefixSheet = cmd.sheetId === targetSheetId ? adaptedRange.prefixSheet : true; + return { + changeType: "MOVE", + range: adaptedRange.clone({ sheetId: targetSheetId, prefixSheet }), + }; + }); + break; + } + } + } + finalize() { } + /** + * Return a modified adapting function that verifies that after adapting a range, the range is still valid. + * Any range that gets adapted by the function adaptRange in parameter does so + * without caring if the start and end of the range in both row and column + * direction can be incorrect. This function ensure that an incorrect range gets removed. + */ + verifyRangeRemoved(adaptRange) { + return (range) => { + const result = adaptRange(range); + if (result.changeType !== "NONE" && !isZoneValid(result.range.zone)) { + return { range: result.range, changeType: "REMOVE" }; + } + return result; + }; + } + createAdaptedRange(range, dimension, operation, by) { + const zone = createAdaptedZone(range.unboundedZone, dimension, operation, by); + const adaptedRange = range.clone({ zone }); + return adaptedRange; + } + executeOnAllRanges(adaptRange, sheetId) { + const func = this.verifyRangeRemoved(adaptRange); + for (const provider of this.providers) { + provider(func, sheetId); + } + } + /** + * Stores the functions bound to each plugin to be able to iterate over all ranges of the application, + * without knowing any details of the internal data structure of the plugins and without storing ranges + * in the range adapter. + * + * @param provider a function bound to a plugin that will loop over its internal data structure to find + * all ranges + */ + addRangeProvider(provider) { + this.providers.push(provider); + } + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + createAdaptedRanges(ranges, offsetX, offsetY, sheetId) { + const rangesImpl = ranges.map((range) => RangeImpl.fromRange(range, this.getters)); + return rangesImpl.map((range) => { + if (!isZoneValid(range.zone)) { + return range; + } + const copySheetId = range.prefixSheet ? range.sheetId : sheetId; + const unboundZone = { + ...range.unboundedZone, + // Don't shift left if the range is a full row without header + left: range.isFullRow && !range.unboundedZone.hasHeader + ? range.unboundedZone.left + : range.unboundedZone.left + (range.parts[0].colFixed ? 0 : offsetX), + // Don't shift right if the range is a full row + right: range.isFullRow + ? range.unboundedZone.right + : range.unboundedZone.right + + ((range.parts[1] || range.parts[0]).colFixed ? 0 : offsetX), + // Don't shift up if the range is a column row without header + top: range.isFullCol && !range.unboundedZone.hasHeader + ? range.unboundedZone.top + : range.unboundedZone.top + (range.parts[0].rowFixed ? 0 : offsetY), + // Don't shift down if the range is a full column + bottom: range.isFullCol + ? range.unboundedZone.bottom + : range.unboundedZone.bottom + + ((range.parts[1] || range.parts[0]).rowFixed ? 0 : offsetY), + }; + range = range.clone({ sheetId: copySheetId, zone: unboundZone }); + range.orderZone(); + return range; + }); + } + /** + * Creates a range from a XC reference that can contain a sheet reference + * @param defaultSheetId the sheet to default to if the sheetXC parameter does not contain a sheet reference (usually the active sheet Id) + * @param sheetXC the string description of a range, in the form SheetName!XC:XC + */ + getRangeFromSheetXC(defaultSheetId, sheetXC) { + if (!rangeReference.test(sheetXC)) { + return new RangeImpl({ + sheetId: "", + zone: { left: -1, top: -1, right: -1, bottom: -1 }, + parts: [], + invalidXc: sheetXC, + prefixSheet: false, + }, this.getters.getSheetSize); + } + let sheetName = ""; + let prefixSheet = false; + if (sheetXC.includes("!")) { + [sheetName, sheetXC] = sheetXC.split("!"); + if (sheetName) { + prefixSheet = true; + } + } + const zone = toUnboundedZone(sheetXC); + const parts = RangeImpl.getRangeParts(sheetXC, zone); + const invalidSheetName = sheetName && !this.getters.getSheetIdByName(sheetName) ? sheetName : undefined; + const sheetId = this.getters.getSheetIdByName(sheetName) || defaultSheetId; + const rangeInterface = { prefixSheet, zone, sheetId, invalidSheetName, parts }; + const range = new RangeImpl(rangeInterface, this.getters.getSheetSize); + range.orderZone(); + return range; + } + /** + * Same as `getRangeString` but add all necessary merge to the range to make it a valid selection + */ + getSelectionRangeString(range, forSheetId) { + const rangeImpl = RangeImpl.fromRange(range, this.getters); + const expandedZone = this.getters.expandZone(rangeImpl.sheetId, rangeImpl.zone); + const expandedRange = rangeImpl.clone({ + zone: { + ...expandedZone, + bottom: rangeImpl.isFullCol ? undefined : expandedZone.bottom, + right: rangeImpl.isFullRow ? undefined : expandedZone.right, + }, + }); + return this.getRangeString(expandedRange, forSheetId); + } + /** + * Gets the string that represents the range as it is at the moment of the call. + * The string will be prefixed with the sheet name if the call specified a sheet id in `forSheetId` + * different than the sheet on which the range has been created. + * + * @param range the range (received from getRangeFromXC or getRangeFromZone) + * @param forSheetId the id of the sheet where the range string is supposed to be used. + */ + getRangeString(range, forSheetId) { + if (!range) { + return INCORRECT_RANGE_STRING; + } + if (range.invalidXc) { + return range.invalidXc; + } + if (range.zone.bottom - range.zone.top < 0 || range.zone.right - range.zone.left < 0) { + return INCORRECT_RANGE_STRING; + } + if (range.zone.left < 0 || range.zone.top < 0) { + return INCORRECT_RANGE_STRING; + } + const rangeImpl = RangeImpl.fromRange(range, this.getters); + let prefixSheet = rangeImpl.sheetId !== forSheetId || rangeImpl.invalidSheetName || rangeImpl.prefixSheet; + let sheetName = ""; + if (prefixSheet) { + if (rangeImpl.invalidSheetName) { + sheetName = rangeImpl.invalidSheetName; + } + else { + sheetName = getComposerSheetName(this.getters.getSheetName(rangeImpl.sheetId)); + } + } + if (prefixSheet && !sheetName) { + return INCORRECT_RANGE_STRING; + } + let rangeString = this.getRangePartString(rangeImpl, 0); + if (rangeImpl.parts && rangeImpl.parts.length === 2) { + // this if converts A2:A2 into A2 except if any part of the original range had fixed row or column (with $) + if (rangeImpl.zone.top !== rangeImpl.zone.bottom || + rangeImpl.zone.left !== rangeImpl.zone.right || + rangeImpl.parts[0].rowFixed || + rangeImpl.parts[0].colFixed || + rangeImpl.parts[1].rowFixed || + rangeImpl.parts[1].colFixed) { + rangeString += ":"; + rangeString += this.getRangePartString(rangeImpl, 1); + } + } + return `${prefixSheet ? sheetName + "!" : ""}${rangeString}`; + } + getRangeDataFromXc(sheetId, xc) { + return this.getters.getRangeFromSheetXC(sheetId, xc).rangeData; + } + getRangeDataFromZone(sheetId, zone) { + return { _sheetId: sheetId, _zone: zone }; + } + getRangeFromRangeData(data) { + const rangeInterface = { + prefixSheet: false, + zone: data._zone, + sheetId: data._sheetId, + invalidSheetName: undefined, + parts: [ + { colFixed: false, rowFixed: false }, + { colFixed: false, rowFixed: false }, + ], + }; + return new RangeImpl(rangeInterface, this.getters.getSheetSize); + } + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + /** + * Get a Xc string that represent a part of a range + */ + getRangePartString(range, part) { + const colFixed = range.parts && range.parts[part].colFixed ? "$" : ""; + const col = part === 0 ? numberToLetters(range.zone.left) : numberToLetters(range.zone.right); + const rowFixed = range.parts && range.parts[part].rowFixed ? "$" : ""; + const row = part === 0 ? String(range.zone.top + 1) : String(range.zone.bottom + 1); + let str = ""; + if (range.isFullCol) { + if (part === 0 && range.unboundedZone.hasHeader) { + str = colFixed + col + rowFixed + row; + } + else { + str = colFixed + col; + } + } + else if (range.isFullRow) { + if (part === 0 && range.unboundedZone.hasHeader) { + str = colFixed + col + rowFixed + row; + } + else { + str = rowFixed + row; + } + } + else { + str = colFixed + col + rowFixed + row; + } + return str; + } + } + RangeAdapter.getters = [ + "getRangeString", + "getSelectionRangeString", + "getRangeFromSheetXC", + "createAdaptedRanges", + "getRangeDataFromXc", + "getRangeDataFromZone", + "getRangeFromRangeData", + ]; + + /** + * Stateless sequence of events that can be processed by consumers. + * + * There are three kind of consumers: + * - the main consumer + * - the default consumer + * - observer consumers + * + * Main consumer + * ------------- + * Anyone can capture the event stream and become the main consumer. + * If there is already a main consumer, it is kicked off and it will no longer + * receive events. + * The main consumer can release the stream at any moment to stop listening + * events. + * + * Default consumer + * ---------------- + * When the main consumer releases the stream and until the stream is captured + * again, all events are transmitted to the default consumer. + * + * Observer consumers + * ------------------ + * Observers permanently receive events. + * + */ + class EventStream { + constructor() { + this.observers = []; + } + registerAsDefault(owner, callbacks) { + this.defaultSubscription = { owner, callbacks }; + if (!this.mainSubscription) { + this.mainSubscription = this.defaultSubscription; + } + } + /** + * Register callbacks to observe the stream + */ + observe(owner, callbacks) { + this.observers.push({ owner, callbacks }); + } + /** + * Capture the stream for yourself + */ + capture(owner, callbacks) { + var _a, _b, _c; + if (this.observers.find((sub) => sub.owner === owner)) { + throw new Error("You are already subscribed forever"); + } + if ((_a = this.mainSubscription) === null || _a === void 0 ? void 0 : _a.owner) { + (_c = (_b = this.mainSubscription.callbacks).release) === null || _c === void 0 ? void 0 : _c.call(_b); + } + this.mainSubscription = { owner, callbacks }; + } + release(owner) { + var _a, _b, _c, _d; + if (((_a = this.mainSubscription) === null || _a === void 0 ? void 0 : _a.owner) !== owner || + this.observers.find((sub) => sub.owner === owner)) { + return; + } + (_d = (_b = this.mainSubscription) === null || _b === void 0 ? void 0 : (_c = _b.callbacks).release) === null || _d === void 0 ? void 0 : _d.call(_c); + this.mainSubscription = this.defaultSubscription; + } + /** + * Check if you are currently the main stream consumer + */ + isListening(owner) { + var _a; + return ((_a = this.mainSubscription) === null || _a === void 0 ? void 0 : _a.owner) === owner; + } + /** + * Push an event to the stream and broadcast it to consumers + */ + send(event) { + var _a; + (_a = this.mainSubscription) === null || _a === void 0 ? void 0 : _a.callbacks.handleEvent(event); + [...this.observers].forEach((sub) => sub.callbacks.handleEvent(event)); + } + } + + /** + * Processes all selection updates (usually from user inputs) and emits an event + * with the new selected anchor + */ + class SelectionStreamProcessor { + constructor(getters) { + this.getters = getters; + this.stream = new EventStream(); + this.anchor = { cell: { col: 0, row: 0 }, zone: positionToZone({ col: 0, row: 0 }) }; + this.defaultAnchor = this.anchor; + } + capture(owner, anchor, callbacks) { + this.stream.capture(owner, callbacks); + this.anchor = anchor; + } + /** + * Register as default subscriber and capture the event stream. + */ + registerAsDefault(owner, anchor, callbacks) { + this.checkAnchorZoneOrThrow(anchor); + this.stream.registerAsDefault(owner, callbacks); + this.defaultAnchor = anchor; + this.capture(owner, anchor, callbacks); + } + resetDefaultAnchor(owner, anchor) { + this.checkAnchorZoneOrThrow(anchor); + if (this.stream.isListening(owner)) { + this.anchor = anchor; + } + this.defaultAnchor = anchor; + } + resetAnchor(owner, anchor) { + this.checkAnchorZoneOrThrow(anchor); + if (this.stream.isListening(owner)) { + this.anchor = anchor; + } + } + observe(owner, callbacks) { + this.stream.observe(owner, callbacks); + } + release(owner) { + if (this.stream.isListening(owner)) { + this.stream.release(owner); + this.anchor = this.defaultAnchor; + } + } + /** + * Select a new anchor + */ + selectZone(anchor) { + const sheetId = this.getters.getActiveSheetId(); + anchor = { + ...anchor, + zone: this.getters.expandZone(sheetId, anchor.zone), + }; + return this.processEvent({ + type: "ZonesSelected", + anchor, + mode: "overrideSelection", + }); + } + /** + * Select a single cell as the new anchor. + */ + selectCell(col, row) { + const zone = positionToZone({ col, row }); + return this.selectZone({ zone, cell: { col, row } }); + } + /** + * Set the selection to one of the cells adjacent to the current anchor cell. + */ + moveAnchorCell(direction, step = "one") { + const { col, row } = this.getNextAvailablePosition(direction, step); + return this.selectCell(col, row); + } + /** + * Update the current anchor such that it includes the given + * cell position. + */ + setAnchorCorner(col, row) { + const sheetId = this.getters.getActiveSheetId(); + const { col: anchorCol, row: anchorRow } = this.anchor.cell; + const zone = { + left: Math.min(anchorCol, col), + top: Math.min(anchorRow, row), + right: Math.max(anchorCol, col), + bottom: Math.max(anchorRow, row), + }; + const expandedZone = this.getters.expandZone(sheetId, zone); + const anchor = { zone: expandedZone, cell: { col: anchorCol, row: anchorRow } }; + return this.processEvent({ + type: "AlterZoneCorner", + mode: "updateAnchor", + anchor: anchor, + }); + } + /** + * Add a new cell to the current selection + */ + addCellToSelection(col, row) { + const sheetId = this.getters.getActiveSheetId(); + ({ col, row } = this.getters.getMainCellPosition(sheetId, col, row)); + const zone = positionToZone({ col, row }); + return this.processEvent({ + type: "ZonesSelected", + anchor: { zone, cell: { col, row } }, + mode: "newAnchor", + }); + } + /** + * Increase or decrease the size of the current anchor zone. + * The anchor cell remains where it is. It's the opposite side + * of the anchor zone which moves. + */ + resizeAnchorZone(direction, step = "one") { + const sheetId = this.getters.getActiveSheetId(); + const anchor = this.anchor; + const { col: anchorCol, row: anchorRow } = anchor.cell; + const { left, right, top, bottom } = anchor.zone; + const starting = this.getStartingPosition(direction); + let [deltaCol, deltaRow] = this.deltaToTarget(starting, direction, step); + if (deltaCol === 0 && deltaRow === 0) { + return DispatchResult.Success; + } + let result = anchor.zone; + const expand = (z) => { + z = organizeZone(z); + const { left, right, top, bottom } = this.getters.expandZone(sheetId, z); + return { + left: Math.max(0, left), + right: Math.min(this.getters.getNumberCols(sheetId) - 1, right), + top: Math.max(0, top), + bottom: Math.min(this.getters.getNumberRows(sheetId) - 1, bottom), + }; + }; + const { col: refCol, row: refRow } = this.getReferencePosition(); + // check if we can shrink selection + let n = 0; + while (result !== null) { + n++; + if (deltaCol < 0) { + const newRight = this.getNextAvailableCol(deltaCol, right - (n - 1), refRow); + result = refCol <= right - n ? expand({ top, left, bottom, right: newRight }) : null; + } + if (deltaCol > 0) { + const newLeft = this.getNextAvailableCol(deltaCol, left + (n - 1), refRow); + result = left + n <= refCol ? expand({ top, left: newLeft, bottom, right }) : null; + } + if (deltaRow < 0) { + const newBottom = this.getNextAvailableRow(deltaRow, refCol, bottom - (n - 1)); + result = refRow <= bottom - n ? expand({ top, left, bottom: newBottom, right }) : null; + } + if (deltaRow > 0) { + const newTop = this.getNextAvailableRow(deltaRow, refCol, top + (n - 1)); + result = top + n <= refRow ? expand({ top: newTop, left, bottom, right }) : null; + } + result = result ? organizeZone(result) : result; + if (result && !isEqual(result, anchor.zone)) { + return this.processEvent({ + type: "ZonesSelected", + mode: "updateAnchor", + anchor: { zone: result, cell: { col: anchorCol, row: anchorRow } }, + }); + } + } + const currentZone = { + top: anchorRow, + bottom: anchorRow, + left: anchorCol, + right: anchorCol, + }; + const zoneWithDelta = organizeZone({ + top: this.getNextAvailableRow(deltaRow, refCol, top), + left: this.getNextAvailableCol(deltaCol, left, refRow), + bottom: this.getNextAvailableRow(deltaRow, refCol, bottom), + right: this.getNextAvailableCol(deltaCol, right, refRow), + }); + result = expand(union(currentZone, zoneWithDelta)); + const newAnchor = { zone: result, cell: { col: anchorCol, row: anchorRow } }; + return this.processEvent({ + type: "ZonesSelected", + anchor: newAnchor, + mode: "updateAnchor", + }); + } + selectColumn(index, mode) { + const sheetId = this.getters.getActiveSheetId(); + const bottom = this.getters.getNumberRows(sheetId) - 1; + let zone = { left: index, right: index, top: 0, bottom }; + const top = this.getters.findFirstVisibleColRowIndex(sheetId, "ROW"); + let col, row; + switch (mode) { + case "overrideSelection": + case "newAnchor": + col = index; + row = top; + break; + case "updateAnchor": + ({ col, row } = this.anchor.cell); + zone = union(zone, { left: col, right: col, top, bottom }); + break; + } + return this.processEvent({ + type: "HeadersSelected", + anchor: { zone, cell: { col, row } }, + mode, + }); + } + selectRow(index, mode) { + const sheetId = this.getters.getActiveSheetId(); + const right = this.getters.getNumberCols(sheetId) - 1; + let zone = { top: index, bottom: index, left: 0, right }; + const left = this.getters.findFirstVisibleColRowIndex(sheetId, "COL"); + let col, row; + switch (mode) { + case "overrideSelection": + case "newAnchor": + col = left; + row = index; + break; + case "updateAnchor": + ({ col, row } = this.anchor.cell); + zone = union(zone, { left, right, top: row, bottom: row }); + break; + } + return this.processEvent({ + type: "HeadersSelected", + anchor: { zone, cell: { col, row } }, + mode, + }); + } + /** + * Loop the current selection while keeping the same anchor. The selection will loop through: + * 1) the smallest zone that contain the anchor and that have only empty cells bordering it + * 2) the whole sheet + * 3) the anchor cell + */ + loopSelection() { + const sheetId = this.getters.getActiveSheetId(); + const anchor = this.anchor; + /** Try to expand the zone by one col/row in any direction to include a new non-empty cell */ + const expandZone = (zone) => { + for (const col of range(zone.left, zone.right + 1)) { + if (!this.isCellEmpty({ col, row: zone.top - 1 })) { + return { ...zone, top: zone.top - 1 }; + } + if (!this.isCellEmpty({ col, row: zone.bottom + 1 })) { + return { ...zone, bottom: zone.bottom + 1 }; + } + } + for (const row of range(zone.top, zone.bottom + 1)) { + if (!this.isCellEmpty({ col: zone.left - 1, row })) { + return { ...zone, left: zone.left - 1 }; + } + if (!this.isCellEmpty({ col: zone.right + 1, row })) { + return { ...zone, right: zone.right + 1 }; + } + } + return zone; + }; + // The whole sheet is selected, select the anchor cell + if (isEqual(this.anchor.zone, this.getters.getSheetZone(sheetId))) { + return this.selectZone({ ...anchor, zone: positionToZone(anchor.cell) }); + } + let hasExpanded = false; + let hasExpandedOnce = false; + let zone = anchor.zone; + do { + hasExpandedOnce = hasExpandedOnce || hasExpanded; + hasExpanded = false; + const newZone = expandZone(zone); + if (!isEqual(zone, newZone)) { + hasExpanded = true; + zone = newZone; + continue; + } + } while (hasExpanded); + return hasExpandedOnce ? this.selectZone({ ...anchor, zone }) : this.selectAll(); + } + /** + * Select the entire sheet + */ + selectAll() { + const sheetId = this.getters.getActiveSheetId(); + const bottom = this.getters.getNumberRows(sheetId) - 1; + const right = this.getters.getNumberCols(sheetId) - 1; + const zone = { left: 0, top: 0, bottom, right }; + return this.processEvent({ + type: "HeadersSelected", + mode: "overrideSelection", + anchor: { zone, cell: this.anchor.cell }, + }); + } + /** + * Process a new anchor selection event. If the new anchor is inside + * the sheet boundaries, the event is pushed to the event stream to + * be processed. + */ + processEvent(newAnchorEvent) { + const event = { ...newAnchorEvent, previousAnchor: deepCopy(this.anchor) }; + const commandResult = this.checkEventAnchorZone(event); + if (commandResult !== 0 /* Success */) { + return new DispatchResult(commandResult); + } + this.anchor = event.anchor; + this.stream.send(event); + return DispatchResult.Success; + } + checkEventAnchorZone(event) { + return this.checkAnchorZone(event.anchor); + } + checkAnchorZone(anchor) { + const { cell, zone } = anchor; + if (!isInside(cell.col, cell.row, zone)) { + return 14 /* InvalidAnchorZone */; + } + const { left, right, top, bottom } = zone; + const sheetId = this.getters.getActiveSheetId(); + const refCol = this.getters.findVisibleHeader(sheetId, "COL", range(left, right + 1)); + const refRow = this.getters.findVisibleHeader(sheetId, "ROW", range(top, bottom + 1)); + if (refRow === undefined || refCol === undefined) { + return 15 /* SelectionOutOfBound */; + } + return 0 /* Success */; + } + checkAnchorZoneOrThrow(anchor) { + const result = this.checkAnchorZone(anchor); + if (result === 14 /* InvalidAnchorZone */) { + throw new Error(_t("The provided anchor is invalid. The cell must be part of the zone.")); + } + } + /** + * ---- PRIVATE ---- + */ + /** Computes the next cell position in the direction of deltaX and deltaY + * by crossing through merges and skipping hidden cells. + * Note that the resulting position might be out of the sheet, it needs to be validated. + */ + getNextAvailablePosition(direction, step = "one") { + const { col, row } = this.anchor.cell; + const delta = this.deltaToTarget({ col, row }, direction, step); + return { + col: this.getNextAvailableCol(delta[0], col, row), + row: this.getNextAvailableRow(delta[1], col, row), + }; + } + getNextAvailableCol(delta, colIndex, rowIndex) { + const sheetId = this.getters.getActiveSheetId(); + const position = { col: colIndex, row: rowIndex }; + const isInPositionMerge = (nextCol) => this.getters.isInSameMerge(sheetId, colIndex, rowIndex, nextCol, rowIndex); + return this.getNextAvailableHeader(delta, "COL", colIndex, position, isInPositionMerge); + } + getNextAvailableRow(delta, colIndex, rowIndex) { + const sheetId = this.getters.getActiveSheetId(); + const position = { col: colIndex, row: rowIndex }; + const isInPositionMerge = (nextRow) => this.getters.isInSameMerge(sheetId, colIndex, rowIndex, colIndex, nextRow); + return this.getNextAvailableHeader(delta, "ROW", rowIndex, position, isInPositionMerge); + } + getNextAvailableHeader(delta, dimension, startingHeaderIndex, position, isInPositionMerge) { + const sheetId = this.getters.getActiveSheetId(); + if (delta === 0) { + return startingHeaderIndex; + } + const step = Math.sign(delta); + let header = startingHeaderIndex + delta; + while (isInPositionMerge(header)) { + header += step; + } + while (this.getters.isHeaderHidden(sheetId, dimension, header)) { + header += step; + } + const outOfBound = header < 0 || header > this.getters.getNumberHeaders(sheetId, dimension) - 1; + if (outOfBound) { + if (this.getters.isHeaderHidden(sheetId, dimension, startingHeaderIndex)) { + return this.getNextAvailableHeader(-step, dimension, startingHeaderIndex, position, isInPositionMerge); + } + else { + return startingHeaderIndex; + } + } + return header; + } + /** + * Finds a visible cell in the currently selected zone starting with the anchor. + * If the anchor is hidden, browses from left to right and top to bottom to + * find a visible cell. + */ + getReferencePosition() { + const sheetId = this.getters.getActiveSheetId(); + const anchor = this.anchor; + const { left, right, top, bottom } = anchor.zone; + const { col: anchorCol, row: anchorRow } = anchor.cell; + return { + col: this.getters.isColHidden(sheetId, anchorCol) + ? this.getters.findVisibleHeader(sheetId, "COL", range(left, right + 1)) || anchorCol + : anchorCol, + row: this.getters.isRowHidden(sheetId, anchorRow) + ? this.getters.findVisibleHeader(sheetId, "ROW", range(top, bottom + 1)) || anchorRow + : anchorRow, + }; + } + deltaToTarget(position, direction, step) { + switch (direction) { + case "up": + return step === "one" + ? [0, -1] + : [0, this.getEndOfCluster(position, "rows", -1) - position.row]; + case "down": + return step === "one" + ? [0, 1] + : [0, this.getEndOfCluster(position, "rows", 1) - position.row]; + case "left": + return step === "one" + ? [-1, 0] + : [this.getEndOfCluster(position, "cols", -1) - position.col, 0]; + case "right": + return step === "one" + ? [1, 0] + : [this.getEndOfCluster(position, "cols", 1) - position.col, 0]; + } + } + // TODO rename this + getStartingPosition(direction) { + let { col, row } = this.getPosition(); + const zone = this.anchor.zone; + switch (direction) { + case "down": + case "up": + row = row === zone.top ? zone.bottom : zone.top; + break; + case "left": + case "right": + col = col === zone.right ? zone.left : zone.right; + break; + } + return { col, row }; + } + /** + * Given a starting position, compute the end of the cluster containing the position in the given + * direction or the start of the next cluster. We define cluster here as side-by-side cells that + * all have a content. + * + * We will return the end of the cluster if the given cell is inside a cluster, and the start of the + * next cluster if the given cell is outside a cluster or at the border of a cluster in the given direction. + */ + getEndOfCluster(startPosition, dim, dir) { + const sheet = this.getters.getActiveSheet(); + let currentPosition = startPosition; + // If both the current cell and the next cell are not empty, we want to go to the end of the cluster + const nextCellPosition = this.getNextCellPosition(startPosition, dim, dir); + let mode = !this.isCellEmpty(currentPosition, sheet.id) && !this.isCellEmpty(nextCellPosition, sheet.id) + ? "endOfCluster" + : "nextCluster"; + while (true) { + const nextCellPosition = this.getNextCellPosition(currentPosition, dim, dir); + // Break if nextPosition == currentPosition, which happens if there's no next valid position + if (currentPosition.col === nextCellPosition.col && + currentPosition.row === nextCellPosition.row) { + break; + } + const isNextCellEmpty = this.isCellEmpty(nextCellPosition, sheet.id); + if (mode === "endOfCluster" && isNextCellEmpty) { + break; + } + else if (mode === "nextCluster" && !isNextCellEmpty) { + // We want to return the start of the next cluster, not the end of the empty zone + currentPosition = nextCellPosition; + break; + } + currentPosition = nextCellPosition; + } + return dim === "cols" ? currentPosition.col : currentPosition.row; + } + /** + * Check if a cell is empty or undefined in the model. If the cell is part of a merge, + * check if the merge containing the cell is empty. + */ + isCellEmpty({ col, row }, sheetId = this.getters.getActiveSheetId()) { + const mainCellPosition = this.getters.getMainCellPosition(sheetId, col, row); + const cell = this.getters.getCell(sheetId, mainCellPosition.col, mainCellPosition.row); + return !cell || cell.isEmpty(); + } + /** Computes the next cell position in the given direction by crossing through merges and skipping hidden cells. + * + * This has the same behaviour as getNextAvailablePosition() for certain arguments, but use this method instead + * inside directionToDelta(), which is called in getNextAvailablePosition(), to avoid possible infinite + * recursion. + */ + getNextCellPosition(currentPosition, dimension, direction) { + const dimOfInterest = dimension === "cols" ? "col" : "row"; + const startingPosition = { ...currentPosition }; + const nextCoord = dimension === "cols" + ? this.getNextAvailableCol(direction, startingPosition.col, startingPosition.row) + : this.getNextAvailableRow(direction, startingPosition.col, startingPosition.row); + startingPosition[dimOfInterest] = nextCoord; + return { col: startingPosition.col, row: startingPosition.row }; + } + getPosition() { + return { ...this.anchor.cell }; + } + } + + class StateObserver { + constructor() { + this.changes = []; + this.commands = []; + } + /** + * Record the changes which could happen in the given callback, save them in a + * new revision with the given id and userId. + */ + recordChanges(callback) { + this.changes = []; + this.commands = []; + callback(); + return { changes: this.changes, commands: this.commands }; + } + addCommand(command) { + this.commands.push(command); + } + addChange(...args) { + const val = args.pop(); + const [root, ...path] = args; + let value = root; + let key = path[path.length - 1]; + for (let pathIndex = 0; pathIndex <= path.length - 2; pathIndex++) { + const p = path[pathIndex]; + if (value[p] === undefined) { + const nextPath = path[pathIndex + 1]; + value[p] = createEmptyStructure(nextPath); + } + value = value[p]; + } + if (value[key] === val) { + return; + } + this.changes.push({ + root, + path, + before: value[key], + after: val, + }); + if (val === undefined) { + delete value[key]; + } + else { + value[key] = val; + } + } + } + + /** + * Each axis present inside a graph needs to be identified by an unsigned integer + * The value does not matter, it can be hardcoded. + */ + const catAxId = 17781237; + const valAxId = 88853993; + function createChart(chart, chartSheetIndex, data) { + const namespaces = [ + ["xmlns:r", RELATIONSHIP_NSR], + ["xmlns:a", DRAWING_NS_A], + ["xmlns:c", DRAWING_NS_C], + ]; + const chartShapeProperty = shapeProperty({ + backgroundColor: chart.data.backgroundColor, + line: { color: "000000" }, + }); + // to manually position the chart in the figure container + let title = escapeXml ``; + if (chart.data.title) { + title = escapeXml /*xml*/ ` + + ${insertText(chart.data.title, chart.data.fontColor)} + + + `; + } + // switch on chart type + let plot = escapeXml ``; + switch (chart.data.type) { + case "bar": + plot = addBarChart(chart.data); + break; + case "line": + plot = addLineChart(chart.data); + break; + case "pie": + plot = addDoughnutChart(chart.data, chartSheetIndex, data, { holeSize: 0 }); + break; + } + let position = "t"; + switch (chart.data.legendPosition) { + case "bottom": + position = "b"; + break; + case "left": + position = "l"; + break; + case "right": + position = "r"; + break; + case "top": + position = "t"; + break; + } + const fontColor = chart.data.fontColor; + const xml = escapeXml /*xml*/ ` + + + + ${chartShapeProperty} + + ${title} + + + + + ${plot} + ${shapeProperty({ backgroundColor: chart.data.backgroundColor })} + + ${addLegend(position, fontColor)} + + + `; + return parseXML(xml); + } + function shapeProperty(params) { + return escapeXml /*xml*/ ` + + ${params.backgroundColor ? solidFill(params.backgroundColor) : ""} + ${params.line ? lineAttributes(params.line) : ""} + + `; + } + function solidFill(color) { + return escapeXml /*xml*/ ` + + + + `; + } + function lineAttributes(params) { + const attrs = [["cmpd", "sng"]]; + if (params.width) { + attrs.push(["w", convertDotValueToEMU(params.width)]); + } + const lineStyle = params.style ? escapeXml /*xml*/ `` : ""; + return escapeXml /*xml*/ ` + + ${solidFill(params.color)} + ${lineStyle} + + `; + } + function insertText(text, fontColor = "000000", fontsize = 22) { + return escapeXml /*xml*/ ` + + + + + + + + ${solidFill(fontColor)} + + + + + + ${text} + + + + + `; + } + function insertTextProperties(fontsize = 12, fontColor = "000000", bold = false, italic = false) { + const defPropertiesAttributes = [ + ["b", bold ? "1" : "0"], + ["i", italic ? "1" : "0"], + ["sz", fontsize * 100], + ]; + return escapeXml /*xml*/ ` + + + + + + + ${solidFill(fontColor)} + + + + + + `; + } + function addBarChart(chart) { + // gapWitdh and overlap that define the space between clusters (in %) and the overlap between datasets (from -100: completely scattered to 100, completely overlapped) + // see gapWidth : https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_gapWidth_topic_ID0EFVEQB.html#topic_ID0EFVEQB + // see overlap : https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_overlap_topic_ID0ELYQQB.html#topic_ID0ELYQQB + // + // overlap and gapWitdh seems to be by default at -20 and 20 in chart.js. + // See https://www.chartjs.org/docs/latest/charts/bar.html and https://www.chartjs.org/docs/latest/charts/bar.html#barpercentage-vs-categorypercentage + const colors = new ChartColors(); + const dataSetsNodes = []; + for (const [dsIndex, dataset] of Object.entries(chart.dataSets)) { + const color = toXlsxHexColor(colors.next()); + const dataShapeProperty = shapeProperty({ + backgroundColor: color, + line: { color }, + }); + dataSetsNodes.push(escapeXml /*xml*/ ` + + + + ${dataset.label ? escapeXml /*xml*/ `${stringRef(dataset.label)}` : ""} + ${dataShapeProperty} + ${chart.labelRange ? escapeXml /*xml*/ `${stringRef(chart.labelRange)}` : ""} + + ${numberRef(dataset.range)} + + + `); + } + // Excel does not support this feature + const axisPos = chart.verticalAxisPosition === "left" ? "l" : "r"; + const grouping = chart.stackedBar ? "stacked" : "clustered"; + const overlap = chart.stackedBar ? 100 : -20; + return escapeXml /*xml*/ ` + + + + + + + + ${joinXmlNodes(dataSetsNodes)} + + + + ${addAx("b", "c:catAx", catAxId, valAxId, { fontColor: chart.fontColor })} + ${addAx(axisPos, "c:valAx", valAxId, catAxId, { fontColor: chart.fontColor })} + `; + } + function addLineChart(chart) { + const colors = new ChartColors(); + const dataSetsNodes = []; + for (const [dsIndex, dataset] of Object.entries(chart.dataSets)) { + const dataShapeProperty = shapeProperty({ + line: { + width: 2.5, + style: "solid", + color: toXlsxHexColor(colors.next()), + }, + }); + dataSetsNodes.push(escapeXml /*xml*/ ` + + + + + + + + + ${dataset.label ? escapeXml `${stringRef(dataset.label)}` : ""} + ${dataShapeProperty} + ${chart.labelRange ? escapeXml `${stringRef(chart.labelRange)}` : ""} + + ${numberRef(dataset.range)} + + + `); + } + // Excel does not support this feature + const axisPos = chart.verticalAxisPosition === "left" ? "l" : "r"; + return escapeXml /*xml*/ ` + + + + ${joinXmlNodes(dataSetsNodes)} + + + + ${addAx("b", "c:catAx", catAxId, valAxId, { fontColor: chart.fontColor })} + ${addAx(axisPos, "c:valAx", valAxId, catAxId, { fontColor: chart.fontColor })} + `; + } + function addDoughnutChart(chart, chartSheetIndex, data, { holeSize } = { holeSize: 50 }) { + const colors = new ChartColors(); + const maxLength = Math.max(...chart.dataSets.map((ds) => getRangeSize(ds.range, chartSheetIndex, data))); + const doughnutColors = range(0, maxLength).map(() => toXlsxHexColor(colors.next())); + const dataSetsNodes = []; + for (const [dsIndex, dataset] of Object.entries(chart.dataSets).reverse()) { + //dataset slice labels + const dsSize = getRangeSize(dataset.range, chartSheetIndex, data); + const dataPoints = []; + for (const index of range(0, dsSize)) { + const pointShapeProperty = shapeProperty({ + backgroundColor: doughnutColors[index], + line: { color: "FFFFFF", width: 1.5 }, + }); + dataPoints.push(escapeXml /*xml*/ ` + + + ${pointShapeProperty} + + `); + } + dataSetsNodes.push(escapeXml /*xml*/ ` + + + + ${dataset.label ? escapeXml `${stringRef(dataset.label)}` : ""} + ${joinXmlNodes(dataPoints)} + ${insertDataLabels({ showLeaderLines: true })} + ${chart.labelRange ? escapeXml `${stringRef(chart.labelRange)}` : ""} + + ${numberRef(dataset.range)} + + + `); + } + return escapeXml /*xml*/ ` + + + + ${insertDataLabels()} + ${joinXmlNodes(dataSetsNodes)} + + `; + } + function insertDataLabels({ showLeaderLines } = { showLeaderLines: false }) { + return escapeXml /*xml*/ ` + + + + + + + + + + `; + } + function addAx(position, axisName, axId, crossAxId, { fontColor }) { + // Each Axis present inside a graph needs to be identified by an unsigned integer in order to be referenced by its crossAxis. + // I.e. x-axis, will reference y-axis and vice-versa. + return escapeXml /*xml*/ ` + <${axisName}> + + + + + + + + ${insertMajorGridLines()} + + + + + ${insertText("")} + + ${insertTextProperties(10, fontColor)} + ${axisName}> + + `; + } + function addLegend(position, fontColor) { + return escapeXml /*xml*/ ` + + + + ${insertTextProperties(10, fontColor)} + + `; + } + function insertMajorGridLines(color = "B7B7B7") { + return escapeXml /*xml*/ ` + + ${shapeProperty({ line: { color } })} + + `; + } + function stringRef(reference) { + return escapeXml /*xml*/ ` + + ${reference} + + `; + } + function numberRef(reference) { + return escapeXml /*xml*/ ` + + ${reference} + + + `; + } + + function addFormula(cell) { + const formula = cell.content; + const functions = functionRegistry.content; + const tokens = tokenize(formula); + const attrs = []; + let node = escapeXml ``; + const isExported = tokens + .filter((tk) => tk.type === "FUNCTION") + .every((tk) => functions[tk.value.toUpperCase()].isExported); + if (isExported) { + let cycle = escapeXml ``; + const XlsxFormula = adaptFormulaToExcel(formula); + // hack for cycles : if we don't set a value (be it 0 or #VALUE!), it will appear as invisible on excel, + // Making it very hard for the client to find where the recursion is. + if (cell.value === CellErrorType.CircularDependency) { + attrs.push(["t", "str"]); + cycle = escapeXml /*xml*/ `${cell.value}`; + } + node = escapeXml /*xml*/ ` + + ${XlsxFormula} + + ${cycle} + `; + return { attrs, node }; + } + else { + // Shouldn't we always output the value then ? + const value = cell.value; + // what if value = 0? Is this condition correct? + if (value) { + const type = getCellType(value); + attrs.push(["t", type]); + node = escapeXml /*xml*/ `${value}`; + } + return { attrs, node }; + } + } + function addContent(content, sharedStrings) { + let value = content; + const attrs = []; + if (["TRUE", "FALSE"].includes(value.trim())) { + value = value === "TRUE" ? "1" : "0"; + attrs.push(["t", "b"]); + } + else if (!isNumber(value)) { + const { id } = pushElement(content, sharedStrings); + value = id.toString(); + attrs.push(["t", "s"]); + } + return { attrs, node: escapeXml /*xml*/ `${value}` }; + } + function adaptFormulaToExcel(formulaText) { + if (formulaText[0] === "=") { + formulaText = formulaText.slice(1); + } + let ast; + try { + ast = parse(formulaText); + } + catch (error) { + return formulaText; + } + ast = convertAstNodes(ast, "STRING", convertDateFormat); + ast = convertAstNodes(ast, "FUNCALL", (ast) => { + ast = { ...ast, value: ast.value.toUpperCase() }; + ast = prependNonRetrocompatibleFunction(ast); + ast = addMissingRequiredArgs(ast); + return ast; + }); + return ast ? astToFormula(ast) : formulaText; + } + /** + * Some Excel function need required args that might not be mandatory in o-spreadsheet. + * This adds those missing args. + */ + function addMissingRequiredArgs(ast) { + const formulaName = ast.value.toUpperCase(); + const args = ast.args; + const exportDefaultArgs = FORCE_DEFAULT_ARGS_FUNCTIONS[formulaName]; + if (exportDefaultArgs) { + const requiredArgs = functionRegistry.content[formulaName].args.filter((el) => !el.optional); + const diffArgs = requiredArgs.length - ast.args.length; + if (diffArgs) { + // We know that we have at least 1 default Value missing + for (let i = ast.args.length; i < requiredArgs.length; i++) { + const currentDefaultArg = exportDefaultArgs[i - diffArgs]; + args.push({ type: currentDefaultArg.type, value: currentDefaultArg.value }); + } + } + } + return { ...ast, args }; + } + /** + * Prepend function names that are not compatible with Old Excel versions + */ + function prependNonRetrocompatibleFunction(ast) { + const formulaName = ast.value.toUpperCase(); + return { + ...ast, + value: NON_RETROCOMPATIBLE_FUNCTIONS.includes(formulaName) + ? `_xlfn.${formulaName}` + : formulaName, + }; + } + /** + * Convert strings that correspond to a date to the format YYYY-DD-MM + */ + function convertDateFormat(ast) { + const value = ast.value.replace(new RegExp('"', "g"), ""); + const internalDate = parseDateTime(value); + if (internalDate) { + let format = []; + if (value.match(mdyDateRegexp) || value.match(ymdDateRegexp)) { + format.push("yyyy-mm-dd"); + } + if (value.match(timeRegexp)) { + format.push("hh:mm:ss"); + } + return { + ...ast, + value: formatValue(internalDate.value, format.join(" ")), + }; + } + else { + return { ...ast, value: ast.value.replace(/\\"/g, `""`) }; + } + } + + function addConditionalFormatting(dxfs, conditionalFormats) { + // Conditional Formats + const cfNodes = []; + for (const cf of conditionalFormats) { + // Special case for each type of rule: might be better to extract that logic in dedicated functions + switch (cf.rule.type) { + case "CellIsRule": + cfNodes.push(addCellIsRule(cf, cf.rule, dxfs)); + break; + case "ColorScaleRule": + cfNodes.push(addColorScaleRule(cf, cf.rule)); + break; + case "IconSetRule": + cfNodes.push(addIconSetRule(cf, cf.rule)); + break; + default: + // @ts-ignore Typescript knows it will never happen at compile time + console.warn(`Conditional formatting ${cf.rule.type} not implemented`); + break; + } + } + return cfNodes; + } + // ---------------------- + // RULES + // ---------------------- + function addCellIsRule(cf, rule, dxfs) { + const ruleAttributes = commonCfAttributes(cf); + const operator = convertOperator(rule.operator); + ruleAttributes.push(...cellRuleTypeAttributes(rule), ["operator", operator]); + const formulas = cellRuleFormula(cf.ranges, rule).map((formula) => escapeXml /*xml*/ `${formula}`); + const dxf = { + font: { + color: { rgb: rule.style.textColor }, + bold: rule.style.bold, + italic: rule.style.italic, + strike: rule.style.strikethrough, + underline: rule.style.underline, + }, + }; + if (rule.style.fillColor) { + dxf.fill = { fgColor: { rgb: rule.style.fillColor } }; + } + const { id } = pushElement(dxf, dxfs); + ruleAttributes.push(["dxfId", id]); + return escapeXml /*xml*/ ` + + + ${joinXmlNodes(formulas)} + + + `; + } + function cellRuleFormula(ranges, rule) { + const firstCell = ranges[0].split(":")[0]; + const values = rule.values; + switch (rule.operator) { + case "ContainsText": + return [`NOT(ISERROR(SEARCH("${values[0]}",${firstCell})))`]; + case "NotContains": + return [`ISERROR(SEARCH("${values[0]}",${firstCell}))`]; + case "BeginsWith": + return [`LEFT(${firstCell},LEN("${values[0]}"))="${values[0]}"`]; + case "EndsWith": + return [`RIGHT(${firstCell},LEN("${values[0]}"))="${values[0]}"`]; + case "IsEmpty": + return [`LEN(TRIM(${firstCell}))=0`]; + case "IsNotEmpty": + return [`LEN(TRIM(${firstCell}))>0`]; + case "Equal": + case "NotEqual": + case "GreaterThan": + case "GreaterThanOrEqual": + case "LessThan": + case "LessThanOrEqual": + return [values[0]]; + case "Between": + case "NotBetween": + return [values[0], values[1]]; + } + } + function cellRuleTypeAttributes(rule) { + const operator = convertOperator(rule.operator); + switch (rule.operator) { + case "ContainsText": + case "NotContains": + case "BeginsWith": + case "EndsWith": + return [ + ["type", operator], + ["text", rule.values[0]], + ]; + case "IsEmpty": + case "IsNotEmpty": + return [["type", operator]]; + case "Equal": + case "NotEqual": + case "GreaterThan": + case "GreaterThanOrEqual": + case "LessThan": + case "LessThanOrEqual": + case "Between": + case "NotBetween": + return [["type", "cellIs"]]; + } + } + function addColorScaleRule(cf, rule) { + const ruleAttributes = commonCfAttributes(cf); + ruleAttributes.push(["type", "colorScale"]); + /** mimic our flow: + * for a given ColorScale CF, each range of the "ranges set" has its own behaviour. + */ + const conditionalFormats = []; + for (const range of cf.ranges) { + const cfValueObject = []; + const colors = []; + let canExport = true; + for (let position of ["minimum", "midpoint", "maximum"]) { + const threshold = rule[position]; + if (!threshold) { + // pass midpoint if not defined + continue; + } + if (threshold.type === "formula") { + canExport = false; + continue; + } + cfValueObject.push(thresholdAttributes(threshold, position)); + colors.push([["rgb", toXlsxHexColor(colorNumberString(threshold.color))]]); + } + if (!canExport) { + console.warn("Conditional formats with formula rules are not supported at the moment. The rule is therefore skipped."); + continue; + } + const cfValueObjectNodes = cfValueObject.map((attrs) => escapeXml /*xml*/ ``); + const cfColorNodes = colors.map((attrs) => escapeXml /*xml*/ ``); + conditionalFormats.push(escapeXml /*xml*/ ` + + + + ${joinXmlNodes(cfValueObjectNodes)} + ${joinXmlNodes(cfColorNodes)} + + + + `); + } + return joinXmlNodes(conditionalFormats); + } + function addIconSetRule(cf, rule) { + const ruleAttributes = commonCfAttributes(cf); + ruleAttributes.push(["type", "iconSet"]); + /** mimic our flow: + * for a given IconSet CF, each range of the "ranges set" has its own behaviour. + */ + const conditionalFormats = []; + for (const range of cf.ranges) { + const cfValueObject = [ + // It looks like they always want 3 cfvo and they add a dummy entry + [ + ["type", "percent"], + ["val", 0], + ], + ]; + let canExport = true; + for (let position of ["lowerInflectionPoint", "upperInflectionPoint"]) { + if (rule[position].type === "formula") { + canExport = false; + continue; + } + const threshold = rule[position]; + cfValueObject.push([ + ...thresholdAttributes(threshold, position), + ["gte", threshold.operator === "ge" ? "1" : "0"], + ]); + } + if (!canExport) { + console.warn("Conditional formats with formula rules are not supported at the moment. The rule is therefore skipped."); + continue; + } + const cfValueObjectNodes = cfValueObject.map((attrs) => escapeXml /*xml*/ ``); + conditionalFormats.push(escapeXml /*xml*/ ` + + + + ${joinXmlNodes(cfValueObjectNodes)} + + + + `); + } + return joinXmlNodes(conditionalFormats); + } + // ---------------------- + // MISC + // ---------------------- + function commonCfAttributes(cf) { + return [ + ["priority", 1], + ["stopIfTrue", cf.stopIfTrue ? 1 : 0], + ]; + } + function getIconSet(iconSet) { + return XLSX_ICONSET_MAP[Object.keys(XLSX_ICONSET_MAP).find((key) => iconSet.upper.toLowerCase().startsWith(key)) || + "dots"]; + } + function thresholdAttributes(threshold, position) { + const type = getExcelThresholdType(threshold.type, position); + const attrs = [["type", type]]; + if (type !== "min" && type !== "max") { + // what if the formula is not correct + // references cannot be relative :/ + let val = threshold.value; + if (type === "formula") { + try { + // Relative references are not supported in formula + val = adaptFormulaToExcel(threshold.value); + } + catch (error) { + val = threshold.value; + } + } + attrs.push(["val", val]); // value is undefined only for type="value") + } + return attrs; + } + /** + * This function adapts our Threshold types to their Excel equivalents. + * + * if type === "value" ,then we must replace it by min or max according to the position + * if type === "number", then it becomes num + * if type === "percentage", it becomes "percent" + * rest of the time, the type is unchanged + */ + function getExcelThresholdType(type, position) { + switch (type) { + case "value": + return position === "minimum" ? "min" : "max"; + case "number": + return "num"; + case "percentage": + return "percent"; + default: + return type; + } + } + + function createDrawing(chartRelIds, sheet, figures) { + const namespaces = [ + ["xmlns:xdr", NAMESPACE.drawing], + ["xmlns:r", RELATIONSHIP_NSR], + ["xmlns:a", DRAWING_NS_A], + ["xmlns:c", DRAWING_NS_C], + ]; + const figuresNodes = []; + for (const [figureIndex, figure] of Object.entries(figures)) { + // position + const { from, to } = convertFigureData(figure, sheet); + const chartId = convertChartId(figure.id); + const cNvPrAttrs = [ + ["id", chartId], + ["name", `Chart ${chartId}`], + ["title", "Chart"], + ]; + figuresNodes.push(escapeXml /*xml*/ ` + + + ${from.col} + ${from.colOff} + ${from.row} + ${from.rowOff} + + + ${to.col} + ${to.colOff} + ${to.row} + ${to.rowOff} + + + + + + + + + + + + + + + + + + + `); + } + const xml = escapeXml /*xml*/ ` + + ${joinXmlNodes(figuresNodes)} + + `; + return parseXML(xml); + } + /** + * Returns the coordinates of topLeft (from) and BottomRight (to) of the chart in English Metric Units (EMU) + */ + function convertFigureData(figure, sheet) { + const { x, y, height, width } = figure; + const cols = Object.values(sheet.cols); + const rows = Object.values(sheet.rows); + const { index: colFrom, offset: offsetColFrom } = figureCoordinates(cols, x); + const { index: colTo, offset: offsetColTo } = figureCoordinates(cols, x + width); + const { index: rowFrom, offset: offsetRowFrom } = figureCoordinates(rows, y); + const { index: rowTo, offset: offsetRowTo } = figureCoordinates(rows, y + height); + return { + from: { + col: colFrom, + colOff: offsetColFrom, + row: rowFrom, + rowOff: offsetRowFrom, + }, + to: { + col: colTo, + colOff: offsetColTo, + row: rowTo, + rowOff: offsetRowTo, + }, + }; + } + /** Returns figure coordinates in EMU for a specific header dimension + * See https://docs.microsoft.com/en-us/windows/win32/vml/msdn-online-vml-units#other-units-of-measurement + */ + function figureCoordinates(headers, position) { + let currentPosition = 0; + for (const [headerIndex, header] of Object.entries(headers)) { + if (currentPosition <= position && position < currentPosition + header.size) { + return { + index: parseInt(headerIndex), + offset: convertDotValueToEMU(position - currentPosition + FIGURE_BORDER_SIZE), + }; + } + else { + currentPosition += header.size; + } + } + return { + index: headers.length - 1, + offset: convertDotValueToEMU(position - currentPosition + FIGURE_BORDER_SIZE), + }; + } + + function addNumberFormats(numFmts) { + const numFmtNodes = []; + for (let [index, numFmt] of Object.entries(numFmts)) { + const numFmtAttrs = [ + ["numFmtId", parseInt(index) + FIRST_NUMFMT_ID], + ["formatCode", numFmt.format], + ]; + numFmtNodes.push(escapeXml /*xml*/ ` + + `); + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(numFmtNodes)} + + `; + } + function addFont(font) { + if (isObjectEmptyRecursive(font)) { + return escapeXml /*xml*/ ``; + } + return escapeXml /*xml*/ ` + + ${font.bold ? escapeXml /*xml*/ `` : ""} + ${font.italic ? escapeXml /*xml*/ `` : ""} + ${font.underline ? escapeXml /*xml*/ `` : ""} + ${font.strike ? escapeXml /*xml*/ `` : ""} + ${font.size ? escapeXml /*xml*/ `` : ""} + ${font.color && font.color.rgb + ? escapeXml /*xml*/ `` + : ""} + ${font.name ? escapeXml /*xml*/ `` : ""} + + `; + } + function addFonts(fonts) { + return escapeXml /*xml*/ ` + + ${joinXmlNodes(Object.values(fonts).map(addFont))} + + `; + } + function addFills(fills) { + const fillNodes = []; + for (let fill of Object.values(fills)) { + if (fill.reservedAttribute !== undefined) { + fillNodes.push(escapeXml /*xml*/ ` + + + + `); + } + else { + fillNodes.push(escapeXml /*xml*/ ` + + + + + + + `); + } + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(fillNodes)} + + `; + } + function addBorders(borders) { + const borderNodes = []; + for (let border of Object.values(borders)) { + borderNodes.push(escapeXml /*xml*/ ` + + + + + + + + `); + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(borderNodes)} + + `; + } + function formatBorderAttribute(description) { + if (!description) { + return escapeXml ``; + } + return formatAttributes([ + ["style", description.style], + ["color", toXlsxHexColor(description.color.rgb)], + ]); + } + function addStyles(styles) { + const styleNodes = []; + for (let style of styles) { + const attributes = [ + ["numFmtId", style.numFmtId], + ["fillId", style.fillId], + ["fontId", style.fontId], + ["borderId", style.borderId], + ]; + // Note: the apply${substyleName} does not seem to be required + const alignAttrs = []; + if (style.alignment && style.alignment.vertical) { + alignAttrs.push(["vertical", style.alignment.vertical]); + } + if (style.alignment && style.alignment.horizontal) { + alignAttrs.push(["horizontal", style.alignment.horizontal]); + } + styleNodes.push(escapeXml /*xml*/ ` + + ${alignAttrs ? escapeXml /*xml*/ `` : ""} + + `); + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(styleNodes)} + + `; + } + /** + * DXFS : Differential Formatting Records - Conditional formats + */ + function addCellWiseConditionalFormatting(dxfs // cell-wise CF + ) { + const dxfNodes = []; + for (const dxf of dxfs) { + let fontNode = escapeXml ``; + if (dxf.font) { + fontNode = addFont(dxf.font); + } + let fillNode = escapeXml ``; + if (dxf.fill) { + fillNode = escapeXml /*xml*/ ` + + + + + + `; + } + dxfNodes.push(escapeXml /*xml*/ ` + + ${fontNode} + ${fillNode} + + `); + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(dxfNodes)} + + `; + } + + function addColumns(cols) { + if (!Object.values(cols).length) { + return escapeXml ``; + } + const colNodes = []; + for (let [id, col] of Object.entries(cols)) { + // Always force our own col width + const attributes = [ + ["min", parseInt(id) + 1], + ["max", parseInt(id) + 1], + ["width", convertWidthToExcel(col.size || DEFAULT_CELL_WIDTH)], + ["customWidth", 1], + ["hidden", col.isHidden ? 1 : 0], + ]; + colNodes.push(escapeXml /*xml*/ ` + + `); + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(colNodes)} + + `; + } + function addRows(construct, data, sheet) { + const rowNodes = []; + for (let r = 0; r < sheet.rowNumber; r++) { + const rowAttrs = [["r", r + 1]]; + const row = sheet.rows[r] || {}; + // Always force our own row height + rowAttrs.push(["ht", convertHeightToExcel(row.size || DEFAULT_CELL_HEIGHT)], ["customHeight", 1], ["hidden", row.isHidden ? 1 : 0]); + const cellNodes = []; + for (let c = 0; c < sheet.colNumber; c++) { + const xc = toXC(c, r); + const cell = sheet.cells[xc]; + if (cell) { + const attributes = [["r", xc]]; + // style + const id = normalizeStyle(construct, extractStyle(cell, data)); + attributes.push(["s", id]); + let additionalAttrs = []; + let cellNode = escapeXml ``; + // Either formula or static value inside the cell + if (cell.isFormula) { + ({ attrs: additionalAttrs, node: cellNode } = addFormula(cell)); + } + else if (cell.content && isMarkdownLink(cell.content)) { + const { label } = parseMarkdownLink(cell.content); + ({ attrs: additionalAttrs, node: cellNode } = addContent(label, construct.sharedStrings)); + } + else if (cell.content && cell.content !== "") { + ({ attrs: additionalAttrs, node: cellNode } = addContent(cell.content, construct.sharedStrings)); + } + attributes.push(...additionalAttrs); + cellNodes.push(escapeXml /*xml*/ ` + + ${cellNode} + + `); + } + } + if (cellNodes.length || row.size !== DEFAULT_CELL_HEIGHT || row.isHidden) { + rowNodes.push(escapeXml /*xml*/ ` + + ${joinXmlNodes(cellNodes)} + + `); + } + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(rowNodes)} + + `; + } + function addHyperlinks(construct, data, sheetIndex) { + var _a; + const sheet = data.sheets[sheetIndex]; + const cells = sheet.cells; + const linkNodes = []; + for (const xc in cells) { + const content = (_a = cells[xc]) === null || _a === void 0 ? void 0 : _a.content; + if (content && isMarkdownLink(content)) { + const { label, url } = parseMarkdownLink(content); + if (isMarkdownSheetLink(content)) { + const sheetId = parseSheetLink(url); + const sheet = data.sheets.find((sheet) => sheet.id === sheetId); + const location = sheet ? `${sheet.name}!A1` : INCORRECT_RANGE_STRING; + linkNodes.push(escapeXml /*xml*/ ` + + `); + } + else { + const linkRelId = addRelsToFile(construct.relsFiles, `xl/worksheets/_rels/sheet${sheetIndex}.xml.rels`, { + target: url, + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + targetMode: "External", + }); + linkNodes.push(escapeXml /*xml*/ ` + + `); + } + } + } + if (!linkNodes.length) { + return escapeXml ``; + } + return escapeXml /*xml*/ ` + + ${joinXmlNodes(linkNodes)} + + `; + } + function addMerges(merges) { + if (merges.length) { + const mergeNodes = merges.map((merge) => escapeXml /*xml*/ ``); + return escapeXml /*xml*/ ` + + ${joinXmlNodes(mergeNodes)} + + `; + } + else + return escapeXml ``; + } + + /** + * Return the spreadsheet data in the Office Open XML file format. + * See ECMA-376 standard. + * https://www.ecma-international.org/publications-and-standards/standards/ecma-376/ + */ + function getXLSX(data) { + const files = []; + const construct = getDefaultXLSXStructure(); + files.push(createWorkbook(data, construct)); + files.push(...createWorksheets(data, construct)); + files.push(createStylesSheet(construct)); + files.push(createSharedStrings(construct.sharedStrings)); + files.push(...createRelsFiles(construct.relsFiles)); + files.push(createContentTypes(files)); + files.push(createRelRoot()); + return { + name: `my_spreadsheet.xlsx`, + files, + }; + } + function createWorkbook(data, construct) { + const namespaces = [ + ["xmlns", NAMESPACE["workbook"]], + ["xmlns:r", RELATIONSHIP_NSR], + ]; + const sheetNodes = []; + for (const [index, sheet] of Object.entries(data.sheets)) { + const attributes = [ + ["state", sheet.isVisible ? "visible" : "hidden"], + ["name", sheet.name], + ["sheetId", parseInt(index) + 1], + ["r:id", `rId${parseInt(index) + 1}`], + ]; + sheetNodes.push(escapeXml /*xml*/ ` + + `); + addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", { + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + target: `worksheets/sheet${index}.xml`, + }); + } + const xml = escapeXml /*xml*/ ` + + + ${joinXmlNodes(sheetNodes)} + + + `; + return createXMLFile(parseXML(xml), "xl/workbook.xml", "workbook"); + } + function createWorksheets(data, construct) { + const files = []; + for (const [sheetIndex, sheet] of Object.entries(data.sheets)) { + const namespaces = [ + ["xmlns", NAMESPACE["worksheet"]], + ["xmlns:r", RELATIONSHIP_NSR], + ]; + const sheetFormatAttributes = [ + ["defaultRowHeight", convertHeightToExcel(DEFAULT_CELL_HEIGHT)], + ["defaultColWidth", convertWidthToExcel(DEFAULT_CELL_WIDTH)], + ]; + // Figures and Charts + let drawingNode = escapeXml ``; + const charts = sheet.charts; + if (charts.length) { + const chartRelIds = []; + for (const chart of charts) { + const xlsxChartId = convertChartId(chart.id); + const chartRelId = addRelsToFile(construct.relsFiles, `xl/drawings/_rels/drawing${sheetIndex}.xml.rels`, { + target: `../charts/chart${xlsxChartId}.xml`, + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + }); + chartRelIds.push(chartRelId); + files.push(createXMLFile(createChart(chart, sheetIndex, data), `xl/charts/chart${xlsxChartId}.xml`, "chart")); + } + const drawingRelId = addRelsToFile(construct.relsFiles, `xl/worksheets/_rels/sheet${sheetIndex}.xml.rels`, { + target: `../drawings/drawing${sheetIndex}.xml`, + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing", + }); + files.push(createXMLFile(createDrawing(chartRelIds, sheet, charts), `xl/drawings/drawing${sheetIndex}.xml`, "drawing")); + drawingNode = escapeXml /*xml*/ ``; + } + const sheetXml = escapeXml /*xml*/ ` + + + ${addColumns(sheet.cols)} + ${addRows(construct, data, sheet)} + ${addMerges(sheet.merges)} + ${joinXmlNodes(addConditionalFormatting(construct.dxfs, sheet.conditionalFormats))} + ${addHyperlinks(construct, data, sheetIndex)} + ${drawingNode} + + `; + files.push(createXMLFile(parseXML(sheetXml), `xl/worksheets/sheet${sheetIndex}.xml`, "sheet")); + } + addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", { + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings", + target: "sharedStrings.xml", + }); + addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", { + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + target: "styles.xml", + }); + return files; + } + function createStylesSheet(construct) { + const namespaces = [ + ["xmlns", NAMESPACE["styleSheet"]], + ["xmlns:r", RELATIONSHIP_NSR], + ]; + const styleXml = escapeXml /*xml*/ ` + + ${addNumberFormats(construct.numFmts)} + ${addFonts(construct.fonts)} + ${addFills(construct.fills)} + ${addBorders(construct.borders)} + ${addStyles(construct.styles)} + ${addCellWiseConditionalFormatting(construct.dxfs)} + + `; + return createXMLFile(parseXML(styleXml), "xl/styles.xml", "styles"); + } + function createSharedStrings(strings) { + const namespaces = [ + ["xmlns", NAMESPACE["sst"]], + ["count", strings.length], + ["uniqueCount", strings.length], + ]; + const stringNodes = strings.map((string) => escapeXml /*xml*/ `${string}`); + const xml = escapeXml /*xml*/ ` + + ${joinXmlNodes(stringNodes)} + + `; + return createXMLFile(parseXML(xml), "xl/sharedStrings.xml", "sharedStrings"); + } + function createRelsFiles(relsFiles) { + const XMLRelsFiles = []; + for (const relFile of relsFiles) { + const relationNodes = []; + for (const rel of relFile.rels) { + const attributes = [ + ["Id", rel.id], + ["Target", rel.target], + ["Type", rel.type], + ]; + if (rel.targetMode) { + attributes.push(["TargetMode", rel.targetMode]); + } + relationNodes.push(escapeXml /*xml*/ ` + + `); + } + const xml = escapeXml /*xml*/ ` + + ${joinXmlNodes(relationNodes)} + + `; + XMLRelsFiles.push(createXMLFile(parseXML(xml), relFile.path)); + } + return XMLRelsFiles; + } + function createContentTypes(files) { + const overrideNodes = []; + for (const file of files) { + if (file.contentType) { + overrideNodes.push(createOverride("/" + file.path, CONTENT_TYPES[file.contentType])); + } + } + const xml = escapeXml /*xml*/ ` + + + + ${joinXmlNodes(overrideNodes)} + + `; + return createXMLFile(parseXML(xml), "[Content_Types].xml"); + } + function createRelRoot() { + const attributes = [ + ["Id", "rId1"], + ["Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"], + ["Target", "xl/workbook.xml"], + ]; + const xml = escapeXml /*xml*/ ` + + + + `; + return createXMLFile(parseXML(xml), "_rels/.rels"); + } + + var Status; + (function (Status) { + Status[Status["Ready"] = 0] = "Ready"; + Status[Status["Running"] = 1] = "Running"; + Status[Status["RunningCore"] = 2] = "RunningCore"; + Status[Status["Finalizing"] = 3] = "Finalizing"; + })(Status || (Status = {})); + class Model extends EventBus { + constructor(data = {}, config = {}, stateUpdateMessages = [], uuidGenerator = new UuidGenerator(), verboseImport = true) { + super(); + this.corePlugins = []; + this.uiPlugins = []; + /** + * A plugin can draw some contents on the canvas. But even better: it can do + * so multiple times. The order of the render calls will determine a list of + * "layers" (i.e., earlier calls will be obviously drawn below later calls). + * This list simply keeps the renderers+layer information so the drawing code + * can just iterate on it + */ + this.renderers = []; + /** + * Internal status of the model. Important for command handling coordination + */ + this.status = 0 /* Ready */; + /** + * The dispatch method is the only entry point to manipulate data in the model. + * This is through this method that commands are dispatched most of the time + * recursively until no plugin want to react anymore. + * + * CoreCommands dispatched from this function are saved in the history. + * + * Small technical detail: it is defined as an arrow function. There are two + * reasons for this: + * 1. this means that the dispatch method can be "detached" from the model, + * which is done when it is put in the environment (see the Spreadsheet + * component) + * 2. This allows us to define its type by using the interface CommandDispatcher + */ + this.dispatch = (type, payload) => { + const command = { type, ...payload }; + let status = this.status; + if (this.getters.isReadonly() && !canExecuteInReadonly(command)) { + return new DispatchResult(64 /* Readonly */); + } + switch (status) { + case 0 /* Ready */: + const result = this.checkDispatchAllowed(command); + if (!result.isSuccessful) { + return result; + } + this.status = 1 /* Running */; + const { changes, commands } = this.state.recordChanges(() => { + if (isCoreCommand(command)) { + this.state.addCommand(command); + } + this.dispatchToHandlers(this.handlers, command); + this.finalize(); + }); + this.session.save(commands, changes); + this.status = 0 /* Ready */; + this.trigger("update"); + break; + case 1 /* Running */: + if (isCoreCommand(command)) { + const dispatchResult = this.checkDispatchAllowed(command); + if (!dispatchResult.isSuccessful) { + return dispatchResult; + } + this.state.addCommand(command); + this.dispatchToHandlers(this.handlers, command); + } + else { + this.dispatchToHandlers(this.handlers, command); + } + break; + case 3 /* Finalizing */: + throw new Error(_lt("Cannot dispatch commands in the finalize state")); + case 2 /* RunningCore */: + throw new Error("A UI plugin cannot dispatch while handling a core command"); + } + return DispatchResult.Success; + }; + /** + * Dispatch a command from a Core Plugin (or the History). + * A command dispatched from this function is not added to the history. + */ + this.dispatchFromCorePlugin = (type, payload) => { + const command = { type, ...payload }; + const previousStatus = this.status; + this.status = 2 /* RunningCore */; + this.dispatchToHandlers(this.handlers, command); + this.status = previousStatus; + return DispatchResult.Success; + }; + stateUpdateMessages = repairInitialMessages(data, stateUpdateMessages); + const workbookData = load(data, verboseImport); + this.state = new StateObserver(); + this.uuidGenerator = uuidGenerator; + this.config = this.setupConfig(config); + this.session = this.setupSession(workbookData.revisionId); + this.config.moveClient = this.session.move.bind(this.session); + this.history = new LocalHistory(this.dispatchFromCorePlugin, this.session); + this.coreGetters = {}; + this.range = new RangeAdapter(this.coreGetters); + this.coreGetters.getRangeString = this.range.getRangeString.bind(this.range); + this.coreGetters.getRangeFromSheetXC = this.range.getRangeFromSheetXC.bind(this.range); + this.coreGetters.createAdaptedRanges = this.range.createAdaptedRanges.bind(this.range); + this.coreGetters.getRangeDataFromXc = this.range.getRangeDataFromXc.bind(this.range); + this.coreGetters.getRangeDataFromZone = this.range.getRangeDataFromZone.bind(this.range); + this.coreGetters.getRangeFromRangeData = this.range.getRangeFromRangeData.bind(this.range); + this.coreGetters.getSelectionRangeString = this.range.getSelectionRangeString.bind(this.range); + this.getters = { + isReadonly: () => this.config.mode === "readonly" || this.config.mode === "dashboard", + isDashboard: () => this.config.mode === "dashboard", + canUndo: this.history.canUndo.bind(this.history), + canRedo: this.history.canRedo.bind(this.history), + getClient: this.session.getClient.bind(this.session), + getConnectedClients: this.session.getConnectedClients.bind(this.session), + isFullySynchronized: this.session.isFullySynchronized.bind(this.session), + }; + this.uuidGenerator.setIsFastStrategy(true); + // Initiate stream processor + this.selection = new SelectionStreamProcessor(this.getters); + // registering plugins + for (let Plugin of corePluginRegistry.getAll()) { + this.setupCorePlugin(Plugin, workbookData); + } + Object.assign(this.getters, this.coreGetters); + for (let Plugin of uiPluginRegistry.getAll()) { + this.setupUiPlugin(Plugin); + } + this.uuidGenerator.setIsFastStrategy(false); + // starting plugins + this.dispatch("START"); + // Model should be the last permanent subscriber in the list since he should render + // after all changes have been applied to the other subscribers (plugins) + this.selection.observe(this, { + handleEvent: () => this.trigger("update"), + }); + // This should be done after construction of LocalHistory due to order of + // events + this.setupSessionEvents(); + // Load the initial revisions + this.session.loadInitialMessages(stateUpdateMessages); + this.joinSession(); + if (config.snapshotRequested) { + this.session.snapshot(this.exportData()); + } + // mark all models as "raw", so they will not be turned into reactive objects + // by owl, since we do not rely on reactivity + owl.markRaw(this); + } + get handlers() { + return [this.range, ...this.corePlugins, ...this.uiPlugins, this.history]; + } + joinSession() { + this.session.join(this.config.client); + } + leaveSession() { + this.session.leave(); + } + setupUiPlugin(Plugin) { + const plugin = new Plugin(this.getters, this.state, this.dispatch, this.config, this.selection); + for (let name of Plugin.getters) { + if (!(name in plugin)) { + throw new Error(`Invalid getter name: ${name} for plugin ${plugin.constructor}`); + } + this.getters[name] = plugin[name].bind(plugin); + } + this.uiPlugins.push(plugin); + const layers = Plugin.layers.map((l) => [plugin, l]); + this.renderers.push(...layers); + this.renderers.sort((p1, p2) => p1[1] - p2[1]); + } + /** + * Initialize and properly configure a plugin. + * + * This method is private for now, but if the need arise, there is no deep + * reason why the model could not add dynamically a plugin while it is running. + */ + setupCorePlugin(Plugin, data) { + const plugin = new Plugin(this.coreGetters, this.state, this.range, this.dispatchFromCorePlugin, this.config, this.uuidGenerator); + for (let name of Plugin.getters) { + if (!(name in plugin)) { + throw new Error(`Invalid getter name: ${name} for plugin ${plugin.constructor}`); + } + this.coreGetters[name] = plugin[name].bind(plugin); + } + plugin.import(data); + this.corePlugins.push(plugin); + } + onRemoteRevisionReceived({ commands }) { + for (let command of commands) { + this.dispatchToHandlers(this.uiPlugins, command); + } + this.finalize(); + } + setupSession(revisionId) { + const session = new Session(buildRevisionLog(revisionId, this.state.recordChanges.bind(this.state), (command) => this.dispatchToHandlers([this.range, ...this.corePlugins], command)), this.config.transportService, revisionId); + return session; + } + setupSessionEvents() { + this.session.on("remote-revision-received", this, this.onRemoteRevisionReceived); + this.session.on("revision-redone", this, this.finalize); + this.session.on("revision-undone", this, this.finalize); + // How could we improve communication between the session and UI? + // It feels weird to have the model piping specific session events to its own bus. + this.session.on("unexpected-revision-id", this, () => this.trigger("unexpected-revision-id")); + this.session.on("collaborative-event-received", this, () => { + this.trigger("update"); + }); + } + setupConfig(config) { + const client = config.client || { + id: this.uuidGenerator.uuidv4(), + name: _lt("Anonymous").toString(), + }; + const transportService = config.transportService || new LocalTransportService(); + return { + ...config, + mode: config.mode || "normal", + evalContext: config.evalContext || {}, + transportService, + client, + moveClient: () => { }, + snapshotRequested: false, + notifyUI: (payload) => this.trigger("notify-ui", payload), + }; + } + // --------------------------------------------------------------------------- + // Command Handling + // --------------------------------------------------------------------------- + /** + * Check if the given command is allowed by all the plugins and the history. + */ + checkDispatchAllowed(command) { + const results = this.handlers.map((handler) => handler.allowDispatch(command)); + return new DispatchResult(results.flat()); + } + finalize() { + this.status = 3 /* Finalizing */; + for (const h of this.handlers) { + h.finalize(); + } + this.status = 0 /* Ready */; + } + /** + * Dispatch the given command to the given handlers. + * It will call `beforeHandle` and `handle` + */ + dispatchToHandlers(handlers, command) { + command = deepCopy(command); + for (const handler of handlers) { + handler.beforeHandle(command); + } + for (const handler of handlers) { + handler.handle(command); + } + } + // --------------------------------------------------------------------------- + // Grid Rendering + // --------------------------------------------------------------------------- + /** + * When the Grid component is ready (= mounted), it has a reference to its + * canvas and need to draw the grid on it. This is then done by calling this + * method, which will dispatch the call to all registered plugins. + * + * Note that nothing prevent multiple grid components from calling this method + * each, or one grid component calling it multiple times with a different + * context. This is probably the way we should do if we want to be able to + * freeze a part of the grid (so, we would need to render different zones) + */ + drawGrid(context) { + // we make sure here that the viewport is properly positioned: the offsets + // correspond exactly to a cell + for (let [renderer, layer] of this.renderers) { + context.ctx.save(); + renderer.drawGrid(context, layer); + context.ctx.restore(); + } + } + // --------------------------------------------------------------------------- + // Data Export + // --------------------------------------------------------------------------- + /** + * As the name of this method strongly implies, it is useful when we need to + * export date out of the model. + */ + exportData() { + let data = createEmptyWorkbookData(); + for (let handler of this.handlers) { + if (handler instanceof CorePlugin) { + handler.export(data); + } + } + data.revisionId = this.session.getRevisionId() || DEFAULT_REVISION_ID; + data = JSON.parse(JSON.stringify(data)); + return data; + } + updateMode(mode) { + if (mode !== "normal") { + this.dispatch("STOP_EDITION", { cancel: true }); + } + this.config.mode = mode; + this.trigger("update"); + } + /** + * Exports the current model data into a list of serialized XML files + * to be zipped together as an *.xlsx file. + * + * We need to trigger a cell revaluation on every sheet and ensure that even + * async functions are evaluated. + * This prove to be necessary if the client did not trigger that evaluation in the first place + * (e.g. open a document with several sheet and click on download before visiting each sheet) + */ + exportXLSX() { + this.dispatch("EVALUATE_ALL_SHEETS"); + let data = createEmptyExcelWorkbookData(); + for (let handler of this.handlers) { + if (handler instanceof BasePlugin) { + handler.exportForExcel(data); + } + } + data = JSON.parse(JSON.stringify(data)); + return getXLSX(data); + } + } + + /** + * We export here all entities that needs to be accessed publicly by Odoo. + * + * Note that the __info__ key is actually completed by the build process (see + * the rollup.config.js file) + */ + const __info__ = {}; + const SPREADSHEET_DIMENSIONS = { + MIN_ROW_HEIGHT, + MIN_COL_WIDTH, + HEADER_HEIGHT, + HEADER_WIDTH, + TOPBAR_HEIGHT, + BOTTOMBAR_HEIGHT, + DEFAULT_CELL_WIDTH, + DEFAULT_CELL_HEIGHT, + SCROLLBAR_WIDTH: SCROLLBAR_WIDTH$1, + }; + const registries = { + autofillModifiersRegistry, + autofillRulesRegistry, + cellMenuRegistry, + colMenuRegistry, + dashboardMenuRegistry, + linkMenuRegistry, + functionRegistry, + uiPluginRegistry, + corePluginRegistry, + rowMenuRegistry, + sidePanelRegistry, + figureRegistry, + sheetMenuRegistry, + chartSidePanelComponentRegistry, + chartComponentRegistry, + chartRegistry, + topbarMenuRegistry, + topbarComponentRegistry, + otRegistry, + inverseCommandRegistry, + cellRegistry, + cellPopoverRegistry, + }; + const cellTypes = { + LinkCell, + }; + const helpers = { + args, + toBoolean, + toJsDate, + toNumber, + toString, + toXC, + toZone, + toCartesian, + numberToLetters, + createFullMenuItem, + UuidGenerator, + formatValue, + computeTextWidth, + isMarkdownLink, + parseMarkdownLink, + markdownLink, + createEmptyWorkbookData, + getDefaultChartJsRuntime, + chartFontColor, + getMenuChildren, + ChartColors, + EvaluationError, + CellErrorLevel, + }; + const components = { + ChartPanel, + ChartFigure, + ChartJsComponent, + Grid, + ScorecardChart, + LineConfigPanel, + LineBarPieDesignPanel, + BarConfigPanel, + LineBarPieConfigPanel, + GaugeChartConfigPanel, + GaugeChartDesignPanel, + ScorecardChartConfigPanel, + ScorecardChartDesignPanel, + }; + + exports.AbstractChart = AbstractChart; + exports.CorePlugin = CorePlugin; + exports.DATETIME_FORMAT = DATETIME_FORMAT; + exports.DispatchResult = DispatchResult; + exports.EvaluationError = EvaluationError; + exports.Model = Model; + exports.Registry = Registry; + exports.Revision = Revision; + exports.SPREADSHEET_DIMENSIONS = SPREADSHEET_DIMENSIONS; + exports.Spreadsheet = Spreadsheet; + exports.UIPlugin = UIPlugin; + exports.__info__ = __info__; + exports.astToFormula = astToFormula; + exports.cellTypes = cellTypes; + exports.compile = compile; + exports.components = components; + exports.convertAstNodes = convertAstNodes; + exports.coreTypes = coreTypes; + exports.findCellInNewZone = findCellInNewZone; + exports.functionCache = functionCache; + exports.helpers = helpers; + exports.invalidateEvaluationCommands = invalidateEvaluationCommands; + exports.load = load; + exports.parse = parse; + exports.readonlyAllowedCommands = readonlyAllowedCommands; + exports.registries = registries; + exports.setTranslationMethod = setTranslationMethod; + exports.tokenize = tokenize; + + Object.defineProperty(exports, '__esModule', { value: true }); + + exports.__info__.version = '2.0.0'; + exports.__info__.date = '2022-09-06T12:31:27.223Z'; + exports.__info__.hash = '5a48875'; + +})(this.o_spreadsheet = this.o_spreadsheet || {}, owl); +//# sourceMappingURL=o_spreadsheet.js.map diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml new file mode 100644 index 00000000000..68e1cbcff5c --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml @@ -0,0 +1,2951 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Standard + + + + ✔ + + + + + + Custom + + + + + + + + + ✔ + + + + + + + + + + + + Add + + + + + Reset + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ( + + , + + + [ + + , ... + ] + + + + ) + + + + ABOUT + + + + + + + + - [optional] + + + by default + + repeatable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /** Font Awesome by Dave Gandy + * http://fontawesome.io/ + * https://fontawesome.com/license + */ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Text + + + + + Link + + + + + + + + ✖ + + + + + + + + Cancel + + Confirm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ✖ + + + + + + Add range + + + Confirm + + + + + + + + + + + Stacked barchart + + + + Data Series + + + Data series include title + + + Categories / Labels + + + + + + + + + + + + + + Background color + + Select a color... + + + + + + + + Title + + + + Vertical axis position + + Left + Right + + + + Legend position + + None + Top + Bottom + Left + Right + + + + + + + + + Data range + + + + + + + + + + + + + + Background color + + Select a color... + + + + + + + + + Title + + + + + Range + + + + + + + + + + Thresholds + + + + + + + + + + + + + + + + + + + + Value + Type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Else + + + + + + + + + + + + + + + + + + + + + When value is below + + + + + + Number + Percentage + + + + + + + + + Data Series + + + Data series include title + + + Categories / Labels + + + + + + + + + + + + + + Background color + + Select a color... + + + + + + + + Title + + + + Legend position + + None + Top + Bottom + Left + Right + + + + + + + + + Data Series + + + Data series include title + + + Categories / Labels + + + + Treat labels as text + + + + + + + + + + + + + + Background color + + Select a color... + + + + + + + + Title + + + + Vertical axis position + + Left + Right + + + + Legend position + + None + Top + Bottom + Left + Right + + + + + + + + + + + Configuration + + + + Design + + + + + + + Chart type + + + + + + + + + + + + + + + + + Key value + + + + Baseline configuration + Baseline value + + Baseline format + + Absolute value + Value change from key value + Percentage change from key value + + + + + + + + + + + + + + Background color + + Select a color... + + + + + + + + + Title + + + + Baseline description + + + + + Baseline color + + Color on value increase + + + + + + + + Color on value decrease + + + + + + + + + + + + Preview text + + + + + Format cells if... + + + + + + + + + + + + Formatting style + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Preview text + + + + + + + + None + FixedNumber + Percentage + Percentile + Formula + + + + + Cell values + Number + Percentage + Percentile + Formula + + + + + + + + + + + + + + + + + + Preview + + Minpoint + + + + + MidPoint + + + + + MaxPoint + + + + + + + + + + + + + + + + + + + Add another rule + + + Reorder rules + + + + + Stop reordering rules + + + + + + + Apply to range + + + + Format rules + + + + Single color + + + + + Color scale + + + + + + Icon set + + + + + + + + + + + + + + + + Cancel + + + Save + + + + + + + + + + + + + + + + + + + + + + + + 123 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Icons + + + + + + + + + + + + + + + + + + + + + + + + + + + When value is + + + + > + + + ≥ + + + + + + + + + Number + Percentage + Percentile + Formula + + + + + + + + + + + + + Value + Type + + + + + + + + + + + + + + + + + + + + + + + Else + + + + + + + + + + + + + + + + Reverse icons + + + + + + + + Currency + + + + + + + + + Code + + + + Symbol + + + + + Format + + + + + + + + + Apply + + + + + + + + + Search + + + + + / + + + + + + + + Match case + + + + + + Match entire cell content + + + + + + Search in formulas + + + + + + + Previous + + + Next + + + + Replace + + + + + + + + Replace + + + Replace all + + + + + + + + + + × + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Readonly Access + + + + + + + + + + + + + + + + + + % + + + .0 + + + .00 + + + + 123 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.js b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.js new file mode 100644 index 00000000000..8687174c962 --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.js @@ -0,0 +1,10 @@ +/** @odoo-module */ + +const spreadsheet = window.o_spreadsheet; +export const initCallbackRegistry = new spreadsheet.Registry(); + +import { _t } from "@web/core/l10n/translation"; +window.o_spreadsheet.setTranslationMethod(_t); + +// export * from spreadsheet ? +export default spreadsheet; diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.scss b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.scss new file mode 100644 index 00000000000..029152d8ce4 --- /dev/null +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.scss @@ -0,0 +1,42 @@ +.o-spreadsheet { + height: 100%; + .fa { + font-family: FontAwesome; + } + .o-selection input { + display: initial; + } + + // Align spreadsheet menus and toolbar with the breadcrumb in Odoo + .o-spreadsheet-topbar { + .o_topbar_filter_icon { + padding: 2px 3px; + font-size: 20px; + cursor: pointer; + + &:hover { + background-color: #ebebeb; + } + } + } +} + +.o-sidePanel .o-sidePanelButtons .o-sidePanelButton { + color: #666; + + &.o_global_filter_save { + color: #008784; + border-color: #008784; + } + + &.o_delete_element { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; + + &:hover:enabled { + background-color: #c82333; + border-color: #bd2130; + } + } +} diff --git a/addons/spreadsheet/static/src/pivot/index.js b/addons/spreadsheet/static/src/pivot/index.js new file mode 100644 index 00000000000..9767be15159 --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/index.js @@ -0,0 +1,52 @@ +/** @odoo-module */ + +import { _lt } from "@web/core/l10n/translation"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +import PivotPlugin from "./plugins/pivot_plugin"; +import PivotStructurePlugin from "./plugins/pivot_structure_plugin"; + +import { SEE_RECORDS_PIVOT, SEE_RECORDS_PIVOT_VISIBLE } from "./pivot_actions"; + +const { + coreTypes, + readonlyAllowedCommands, + invalidateEvaluationCommands, +} = spreadsheet; +const { corePluginRegistry, uiPluginRegistry, cellMenuRegistry } = spreadsheet.registries; + +const { inverseCommandRegistry } = spreadsheet.registries; + +function identity(cmd) { + return [cmd]; +} + +corePluginRegistry.add("odooPivotPlugin", PivotPlugin); + +uiPluginRegistry.add("odooPivotStructurePlugin", PivotStructurePlugin); + +coreTypes.add("INSERT_PIVOT"); +coreTypes.add("RENAME_ODOO_PIVOT"); +coreTypes.add("REMOVE_PIVOT"); +coreTypes.add("RE_INSERT_PIVOT"); +coreTypes.add("UPDATE_ODOO_PIVOT_DOMAIN"); + +invalidateEvaluationCommands.add("UPDATE_ODOO_PIVOT_DOMAIN"); +invalidateEvaluationCommands.add("REMOVE_PIVOT"); + +readonlyAllowedCommands.add("ADD_PIVOT_DOMAIN"); + +cellMenuRegistry.add("pivot_see_records", { + name: _lt("See records"), + sequence: 175, + action: SEE_RECORDS_PIVOT, + isVisible: SEE_RECORDS_PIVOT_VISIBLE, +}); + +inverseCommandRegistry + .add("INSERT_PIVOT", identity) + .add("RENAME_ODOO_PIVOT", identity) + .add("REMOVE_PIVOT", identity) + .add("UPDATE_ODOO_PIVOT_DOMAIN", identity) + .add("RE_INSERT_PIVOT", identity); diff --git a/addons/spreadsheet/static/src/pivot/pivot_actions.js b/addons/spreadsheet/static/src/pivot/pivot_actions.js new file mode 100644 index 00000000000..2f92cdd3574 --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/pivot_actions.js @@ -0,0 +1,47 @@ +/** @odoo-module */ +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { getFirstPivotFunction, getNumberOfPivotFormulas } from "./pivot_helpers"; + +const { astToFormula } = spreadsheet; + +export const SEE_RECORDS_PIVOT = async (env) => { + const cell = env.model.getters.getActiveCell(); + const { col, row, sheetId } = env.model.getters.getCellPosition(cell.id); + const { args, functionName } = getFirstPivotFunction(cell.content); + const evaluatedArgs = args + .map(astToFormula) + .map((arg) => env.model.getters.evaluateFormula(arg)); + const pivotId = env.model.getters.getPivotIdFromPosition(sheetId, col, row); + const { model } = env.model.getters.getPivotDefinition(pivotId); + const dataSource = await env.model.getters.getAsyncPivotDataSource(pivotId); + const slice = functionName === "ODOO.PIVOT.HEADER" ? 1 : 2; + let argsDomain = evaluatedArgs.slice(slice); + if (argsDomain[argsDomain.length - 2] === "measure") { + // We have to remove the measure from the domain + argsDomain = argsDomain.slice(0, argsDomain.length - 2); + } + const domain = dataSource.getPivotCellDomain(argsDomain); + const name = await dataSource.getModelLabel(); + await env.services.action.doAction({ + type: "ir.actions.act_window", + name, + res_model: model, + view_mode: "list", + views: [ + [false, "list"], + [false, "form"], + ], + target: "current", + domain, + }); +}; + +export const SEE_RECORDS_PIVOT_VISIBLE = (env) => { + const cell = env.model.getters.getActiveCell(); + return ( + cell && + cell.evaluated.value !== "" && + !cell.evaluated.error && + getNumberOfPivotFormulas(cell.content) === 1 + ); +}; diff --git a/addons/spreadsheet/static/src/pivot/pivot_data_source.js b/addons/spreadsheet/static/src/pivot/pivot_data_source.js new file mode 100644 index 00000000000..808a28c094f --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/pivot_data_source.js @@ -0,0 +1,234 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import { OdooViewsDataSource } from "../data_sources/odoo_views_data_source"; +import { SpreadsheetPivotModel } from "./pivot_model"; + +export default class PivotDataSource extends OdooViewsDataSource { + /** + * + * @override + * @param {Object} services Services (see DataSource) + * @param {Object} params + * @param {import("./pivot_model").PivotMetaData} params.metaData + * @param {import("./pivot_model").PivotSearchParams} params.searchParams + */ + constructor(services, params) { + super(services, params); + } + + async _load() { + await super._load(); + /** @type {SpreadsheetPivotModel} */ + this._model = new SpreadsheetPivotModel( + { _t }, + { + metaData: this._metaData, + searchParams: this._searchParams, + }, + { + orm: this._orm, + metadataRepository: this._metadataRepository, + } + ); + await this._model.load(this._searchParams); + } + + async copyModelWithOriginalDomain() { + await this.loadMetadata(); + const model = new SpreadsheetPivotModel( + { _t }, + { + metaData: this._metaData, + searchParams: this._initialSearchParams, + }, + { + orm: this._orm, + metadataRepository: this._metadataRepository, + } + ); + await model.load(this._initialSearchParams); + return model; + } + + getReportMeasures() { + this._assertDataIsLoaded(); + return this._model.getReportMeasures(); + } + + /** + * @param {string} fieldName + */ + getPossibleValuesForGroupBy(fieldName) { + this._assertDataIsLoaded(); + return this._model.getPossibleValuesForGroupBy(fieldName); + } + + /** + * @param {string[]} domain + */ + getDisplayedPivotHeaderValue(domain) { + this._assertDataIsLoaded(); + return this._model.getDisplayedPivotHeaderValue(domain); + } + + /** + * @param {string[]} domain + */ + getPivotHeaderValue(domain) { + this._assertDataIsLoaded(); + return this._model.getPivotHeaderValue(domain); + } + + /** + * @param {string} measure Field name of the measures + * @param {string[]} domain + */ + markAsValueUsed(measure, domain) { + if (this._model) { + this._model.markAsValueUsed(measure, domain); + } + } + + /** + * @param {string[]} domain + */ + markAsHeaderUsed(domain) { + if (this._model) { + this._model.markAsHeaderUsed(domain); + } + } + + /** + * @param {string} measure Field name of the measures + * @param {string[]} domain + * @returns {boolean} + */ + isUsedValue(measure, domain) { + this._assertDataIsLoaded(); + return this._model.isUsedValue(measure, domain); + } + + /** + * @param {string[]} domain + * @returns {boolean} + */ + isUsedHeader(domain) { + this._assertDataIsLoaded(); + return this._model.isUsedHeader(domain); + } + + clearUsedValues() { + if (this._model) { + this._model.clearUsedValues(); + } + } + + getTableStructure() { + this._assertDataIsLoaded(); + return this._model.getTableStructure(); + } + + /** + * @param {string} measure Field name of the measures + * @param {string[]} domain + */ + getPivotCellValue(measure, domain) { + this._assertDataIsLoaded(); + return this._model.getPivotCellValue(measure, domain); + } + + /** + * @param {string[]} + */ + getPivotCellDomain(domain) { + this._assertDataIsLoaded(); + return this._model.getPivotCellDomain(domain); + } + + /** + * @param {string} fieldName + * @param {string} value raw string value + * @returns {string} + */ + getGroupByDisplayLabel(fieldName, value) { + this._assertDataIsLoaded(); + return this._model.getGroupByDisplayLabel(fieldName, value); + } + + /** + * @param {string} fieldName + * @returns {string} + */ + getFormattedGroupBy(fieldName) { + this._assertDataIsLoaded(); + return this._model.getFormattedGroupBy(fieldName); + } + + /** + * @param {string} groupFieldString + */ + parseGroupField(groupFieldString) { + this._assertDataIsLoaded(); + return this._model.parseGroupField(groupFieldString); + } + + /** + * @param {"COLUMN" | "ROW"} dimension + * @returns {boolean} + */ + isGroupedOnlyByOneDate(dimension) { + this._assertDataIsLoaded(); + return this._model.isGroupedOnlyByOneDate(dimension); + } + + /** + * @param {"COLUMN" | "ROW"} dimension + * @returns {string} + */ + getGroupOfFirstDate(dimension) { + this._assertDataIsLoaded(); + return this._model.getGroupOfFirstDate(dimension); + } + + /** + * @param {"COLUMN" | "ROW"} dimension + * @param {number} index + * @returns {string} + */ + getGroupByAtIndex(dimension, index) { + this._assertDataIsLoaded(); + return this._model.getGroupByAtIndex(dimension, index); + } + + /** + * @param {string} fieldName + * @returns {boolean} + */ + isColumnGroupBy(fieldName) { + this._assertDataIsLoaded(); + return this._model.isColumnGroupBy(fieldName); + } + + /** + * @param {string} fieldName + * @returns {boolean} + */ + isRowGroupBy(fieldName) { + this._assertDataIsLoaded(); + return this._model.isRowGroupBy(fieldName); + } + + /** + * @returns {number} + */ + getNumberOfColGroupBys() { + this._assertDataIsLoaded(); + return this._model.getNumberOfColGroupBys(); + } + + async prepareForTemplateGeneration() { + this._assertDataIsLoaded(); + await this._model.prepareForTemplateGeneration(); + } +} diff --git a/addons/spreadsheet/static/src/pivot/pivot_functions.js b/addons/spreadsheet/static/src/pivot/pivot_functions.js new file mode 100644 index 00000000000..eac839b1fa9 --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/pivot_functions.js @@ -0,0 +1,144 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { sprintf } from "@web/core/utils/strings"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +const { args, toString } = spreadsheet.helpers; +const { functionRegistry } = spreadsheet.registries; + +//-------------------------------------------------------------------------- +// Spreadsheet functions +//-------------------------------------------------------------------------- + +function assertPivotsExists(pivotId, getters) { + if (!getters.isExistingPivot(pivotId)) { + throw new Error(sprintf(_t('There is no pivot with id "%s"'), pivotId)); + } +} + +function assertMeasureExist(pivotId, measure, getters) { + const { measures } = getters.getPivotDefinition(pivotId); + if (!measures.includes(measure)) { + const validMeasures = `(${measures})`; + throw new Error( + sprintf( + _t("The argument %s is not a valid measure. Here are the measures: %s"), + measure, + validMeasures + ) + ); + } +} + +function assertDomainLength(domain) { + if (domain.length % 2 !== 0) { + throw new Error(_t("Function PIVOT takes an even number of arguments.")); + } +} + +functionRegistry + .add("ODOO.FILTER.VALUE", { + description: _t("Return the current value of a spreadsheet filter."), + args: args(` + filter_name (string) ${_t("The label of the filter whose value to return.")} + `), + compute: function (filterName) { + return this.getters.getFilterDisplayValue(filterName); + }, + returns: ["STRING"], + }) + .add("ODOO.PIVOT", { + description: _t("Get the value from a pivot."), + args: args(` + pivot_id (string) ${_t("ID of the pivot.")} + measure_name (string) ${_t("Name of the measure.")} + domain_field_name (string,optional,repeating) ${_t("Field name.")} + domain_value (string,optional,repeating) ${_t("Value.")} + `), + compute: function (pivotId, measureName, ...domain) { + pivotId = toString(pivotId); + const measure = toString(measureName); + const args = domain.map(toString); + assertPivotsExists(pivotId, this.getters); + assertMeasureExist(pivotId, measure, this.getters); + assertDomainLength(args); + return this.getters.getPivotCellValue(pivotId, measure, args); + }, + computeFormat: function (pivotId, measureName, ...domain) { + pivotId = toString(pivotId.value); + const measure = toString(measureName.value); + const field = this.getters.getPivotDataSource(pivotId).getReportMeasures()[measure]; + if (!field) { + return undefined; + } + switch (field.type) { + case "integer": + return "0"; + case "float": + return "#,##0.00"; + case "monetary": + return this.getters.getCompanyCurrencyFormat() || "#,##0.00"; + default: + return undefined; + } + }, + returns: ["NUMBER", "STRING"], + }) + .add("ODOO.PIVOT.HEADER", { + description: _t("Get the header of a pivot."), + args: args(` + pivot_id (string) ${_t("ID of the pivot.")} + domain_field_name (string,optional,repeating) ${_t("Field name.")} + domain_value (string,optional,repeating) ${_t("Value.")} + `), + compute: function (pivotId, ...domain) { + pivotId = toString(pivotId); + const args = domain.map(toString); + assertPivotsExists(pivotId, this.getters); + assertDomainLength(args); + return this.getters.getDisplayedPivotHeaderValue(pivotId, args); + }, + computeFormat: function (pivotId, ...domain) { + pivotId = toString(pivotId.value); + const pivot = this.getters.getPivotDataSource(pivotId); + const len = domain.length; + if (!len) { + return undefined; + } + const fieldName = toString(domain[len - 2].value); + const value = toString(domain[len - 1].value); + if (fieldName === "measure" || value === "false") { + return undefined; + } + const { aggregateOperator, field } = pivot.parseGroupField(fieldName); + switch (field.type) { + case "integer": + return "0"; + case "float": + case "monetary": + return "#,##0.00"; + case "date": + case "datetime": + if (aggregateOperator === "day") { + return "mm/dd/yyyy"; + } + return undefined; + default: + return undefined; + } + }, + returns: ["NUMBER", "STRING"], + }) + .add("ODOO.PIVOT.POSITION", { + description: _t("Get the absolute ID of an element in the pivot"), + args: args(` + pivot_id (string) ${_t("ID of the pivot.")} + field_name (string) ${_t("Name of the field.")} + position (number) ${_t("Position in the pivot")} + `), + compute: function () { + throw new Error(_t(`[[FUNCTION_NAME]] cannot be called from the spreadsheet.`)); + }, + returns: ["STRING"], + }); diff --git a/addons/spreadsheet/static/src/pivot/pivot_helpers.js b/addons/spreadsheet/static/src/pivot/pivot_helpers.js new file mode 100644 index 00000000000..855345803cf --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/pivot_helpers.js @@ -0,0 +1,77 @@ +/** @odoo-module **/ + +import { _t } from "web.core"; +import { FORMATS } from "../helpers/constants"; +import { getOdooFunctions } from "../helpers/odoo_functions_helpers"; + +export const pivotFormulaRegex = /^=.*PIVOT/; + +//-------------------------------------------------------------------------- +// Public +//-------------------------------------------------------------------------- + +/** + * Format a data + * + * @param {string} interval aggregate interval i.e. month, week, quarter, ... + * @param {string} value + */ +export function formatDate(interval, value) { + const output = FORMATS[interval].display; + const input = FORMATS[interval].out; + const date = moment(value, input); + return date.isValid() ? date.format(output) : _t("None"); +} + +/** + * Parse a spreadsheet formula and detect the number of PIVOT functions that are + * present in the given formula. + * + * @param {string} formula + * + * @returns {number} + */ +export function getNumberOfPivotFormulas(formula) { + return getOdooFunctions(formula, (functionName) => + ["ODOO.PIVOT", "ODOO.PIVOT.HEADER", "ODOO.PIVOT.POSITION"].includes(functionName) + ).filter((fn) => fn.isMatched).length; +} + +/** + * Get the first Pivot function description of the given formula. + * + * @param {string} formula + * + * @returns {import("../helpers/odoo_functions_helpers").OdooFunctionDescription|undefined} + */ +export function getFirstPivotFunction(formula) { + return getOdooFunctions(formula, (functionName) => + ["ODOO.PIVOT", "ODOO.PIVOT.HEADER", "ODOO.PIVOT.POSITION"].includes(functionName) + ).find((fn) => fn.isMatched); +} + +/** + * Build a pivot formula expression + * + * @param {string} formula formula to be used (PIVOT or PIVOT.HEADER) + * @param {*} args arguments of the formula + * + * @returns {string} + */ +export function makePivotFormula(formula, args) { + return `=${formula}(${args + .map((arg) => + typeof arg == "number" || (typeof arg == "string" && !isNaN(arg)) + ? `${arg}` + : `"${arg.toString().replace(/"/g, '\\"')}"` + ) + .join(",")})`; +} + +export const PERIODS = { + day: _t("Day"), + week: _t("Week"), + month: _t("Month"), + quarter: _t("Quarter"), + year: _t("Year"), +}; diff --git a/addons/spreadsheet/static/src/pivot/pivot_model.js b/addons/spreadsheet/static/src/pivot/pivot_model.js new file mode 100644 index 00000000000..d7009444dac --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/pivot_model.js @@ -0,0 +1,723 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import { Domain } from "@web/core/domain"; +import { sprintf } from "@web/core/utils/strings"; +import { PivotModel } from "@web/views/pivot/pivot_model"; +import { computeReportMeasures } from "@web/views/utils"; + +import { FORMATS } from "../helpers/constants"; + +import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; +import { formatDate } from "./pivot_helpers"; +import { PERIODS } from "@spreadsheet/pivot/pivot_helpers"; +import { SpreadsheetPivotTable } from "@spreadsheet/pivot/pivot_table"; + +const { toString, toNumber, toBoolean } = spreadsheet.helpers; + +/** + * @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field + * + * @typedef {Object} PivotMetaData + * @property {Array} colGroupBys + * @property {Array} rowGroupBys + * @property {Array} activeMeasures + * @property {string} resModel + * @property {Record} fields + * @property {string|undefined} modelLabel + * + * @typedef {Object} PivotSearchParams + * @property {Array} groupBy + * @property {Array} orderBy + * @property {Object} domain + * @property {Object} context + */ + +/** + * Parses the positional char (#), the field and operator string of pivot group. + * e.g. "create_date:month" + * @param {Record} allFields + * @param {string} groupFieldString + * @returns {{field: Field, aggregateOperator: string, isPositional: boolean}} + */ +function parseGroupField(allFields, groupFieldString) { + let [fieldName, aggregateOperator] = groupFieldString.split(":"); + const isPositional = fieldName.startsWith("#"); + fieldName = isPositional ? fieldName.substring(1) : fieldName; + const field = allFields[fieldName]; + if (field === undefined) { + throw new Error(sprintf(_t("Field %s does not exist"), fieldName)); + } + if (["date", "datetime"].includes(field.type)) { + aggregateOperator = aggregateOperator || "month"; + } + return { + isPositional, + field, + aggregateOperator, + }; +} + +const UNSUPPORTED_FIELD_TYPES = ["one2many", "binary", "html"]; +const NO_RECORD_AT_THIS_POSITION = Symbol("NO_RECORD_AT_THIS_POSITION"); + +function isNotSupported(fieldType) { + return UNSUPPORTED_FIELD_TYPES.includes(fieldType); +} + +function throwUnsupportedFieldError(field) { + throw new Error( + sprintf(_t("Field %s is not supported because of its type (%s)"), field.string, field.type) + ); +} + +/** + * Parses the value defining a pivot group in a PIVOT formula + * e.g. given the following formula PIVOT("1", "stage_id", "42", "status", "won"), + * the two group values are "42" and "won". + * @param {object} field + * @param {number | boolean | string} groupValue + * @returns {number | boolean | string} + */ +export function parsePivotFormulaFieldValue(field, groupValue) { + const groupValueString = + typeof groupValue === "boolean" + ? toString(groupValue).toLocaleLowerCase() + : toString(groupValue); + if (isNotSupported(field.type)) { + throwUnsupportedFieldError(field); + } + // represents a field which is not set (=False server side) + if (groupValueString === "false") { + return false; + } + switch (field.type) { + case "datetime": + case "date": + return toString(groupValueString); + case "selection": + case "char": + case "text": + return toString(groupValueString); + case "boolean": + return toBoolean(groupValueString); + case "float": + case "integer": + case "monetary": + case "many2one": + case "many2many": + return toNumber(groupValueString); + default: + throwUnsupportedFieldError(field); + } +} + +/** + * This class is an extension of PivotModel with some additional information + * that we need in spreadsheet (name_get, isUsedInSheet, ...) + */ +export class SpreadsheetPivotModel extends PivotModel { + /** + * @param {Object} params + * @param {PivotMetaData} params.metaData + * @param {PivotSearchParams} params.searchParams + * @param {Object} services + * @param {import("../data_sources/metadata_repository").MetadataRepository} services.metadataRepository + */ + setup(params, services) { + // fieldAttrs is required, but not needed in Spreadsheet, so we define it as empty + (params.metaData.fieldAttrs = {}), super.setup(params); + + this.metadataRepository = services.metadataRepository; + + /** + * Contains the possible values for each group by of the pivot. This attribute is used *only* for templates, + * so it's computed only in prepareForTemplateGeneration + */ + this._fieldsValue = {}; + + /** + * Contains the domain of the values used during the evaluation of the formula =Pivot(...) + * Is used to know if a pivot cell is missing or not + * */ + + this._usedValueDomains = new Set(); + /** + * Contains the domain of the headers used during the evaluation of the formula =Pivot.header(...) + * Is used to know if a pivot cell is missing or not + * */ + this._usedHeaderDomains = new Set(); + + /** + * Display name of the model + */ + this._modelLabel = params.metaData.modelLabel; + } + + //-------------------------------------------------------------------------- + // Metadata getters + //-------------------------------------------------------------------------- + + /** + * Return true if the given field name is part of the col group bys + * @param {string} fieldName + * @returns {boolean} + */ + isColumnGroupBy(fieldName) { + try { + const { field } = this.parseGroupField(fieldName); + return this._isCol(field); + } catch (_) { + false; + } + } + + /** + * Return true if the given field name is part of the row group bys + * @param {string} fieldName + * @returns {boolean} + */ + isRowGroupBy(fieldName) { + try { + const { field } = this.parseGroupField(fieldName); + return this._isRow(field); + } catch (_) { + return false; + } + } + + /** + * Get the display name of a group by + * @param {string} fieldName + * @returns {string} + */ + getFormattedGroupBy(fieldName) { + const { field, aggregateOperator } = this.parseGroupField(fieldName); + return field.string + (aggregateOperator ? ` (${PERIODS[aggregateOperator]})` : ""); + } + + getReportMeasures() { + return computeReportMeasures(this.metaData.fields, this.metaData.fieldAttrs, []); + } + + //-------------------------------------------------------------------------- + // Cell missing + //-------------------------------------------------------------------------- + + /** + * Reset the used values and headers + */ + clearUsedValues() { + this._usedHeaderDomains.clear(); + this._usedValueDomains.clear(); + } + + /** + * Check if the given domain with the given measure has been used + */ + isUsedValue(domain, measure) { + const tag = [measure, ...domain]; + return this._usedValueDomains.has(tag.join()); + } + + /** + * Check if the given domain has been used + */ + isUsedHeader(domain) { + return this._usedHeaderDomains.has(domain.join()); + } + + /** + * Indicate that the given domain has been used with the given measure + */ + markAsValueUsed(domain, measure) { + const toTag = [measure, ...domain]; + this._usedValueDomains.add(toTag.join()); + } + + /** + * Indicate that the given domain has been used + */ + markAsHeaderUsed(domain) { + this._usedHeaderDomains.add(domain.join()); + } + + //-------------------------------------------------------------------------- + // Template + //-------------------------------------------------------------------------- + + /** + * Get the possible values for the given groupBy + */ + getPossibleValuesForGroupBy(groupBy) { + return this._fieldsValue[groupBy]; + } + + /** + * This method is used to compute the possible values for each group bys. + * It should be run before using templates + */ + async prepareForTemplateGeneration() { + const colValues = []; + const rowValues = []; + + function collectValues(tree, collector) { + const group = tree.root; + if (!tree.directSubTrees.size) { + //It's a leaf, we can fill the cols + collector.push([...group.values]); + } + [...tree.directSubTrees.values()].forEach((subTree) => { + collectValues(subTree, collector); + }); + } + + collectValues(this.data.colGroupTree, colValues); + collectValues(this.data.rowGroupTree, rowValues); + + for (let i = 0; i < this.metaData.fullRowGroupBys.length; i++) { + let vals = [...new Set(rowValues.map((array) => array[i]))]; + if (i !== 0) { + vals = await this._orderValues(vals, this.metaData.fullRowGroupBys[i]); + } + this._fieldsValue[this.metaData.fullRowGroupBys[i]] = vals; + } + for (let i = 0; i < this.metaData.fullColGroupBys.length; i++) { + let vals; + if (i !== 0) { + vals = await this._orderValues(vals, this.metaData.fullColGroupBys[i]); + } else { + vals = colValues.map((array) => array[i]); + vals = [...new Set(vals)]; + } + this._fieldsValue[this.metaData.fullColGroupBys[i]] = vals; + } + } + + /** + * Order the given values for the given groupBy. This is done by executing a + * search_read + */ + async _orderValues(values, groupBy) { + const field = this.parseGroupField(groupBy).field; + const model = this.metaData.resModel; + const context = this.searchParams.context; + const baseDomain = this.searchParams.domain; + const requestField = field.relation ? "id" : field.name; + const domain = Domain.and([ + field.relation ? [] : baseDomain, + [[requestField, "in", values]], + ]).toList(); + // orderby is omitted for relational fields on purpose to have the default order of the model + const records = await this.orm.searchRead( + field.relation ? field.relation : model, + domain, + [requestField], + { + order: field.relation ? undefined : [{ name: field.name, asc: true }], + context: { ...context, active_test: false }, + } + ); + return [...new Set(records.map((record) => record[requestField].toString()))]; + } + + //-------------------------------------------------------------------------- + // Autofill + //-------------------------------------------------------------------------- + + /** + * @param {string} dimension COLUMN | ROW + */ + isGroupedOnlyByOneDate(dimension) { + const groupBys = + dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys; + return groupBys.length === 1 && this._isDateField(this.parseGroupField(groupBys[0]).field); + } + /** + * @param {string} dimension COLUMN | ROW + */ + getGroupOfFirstDate(dimension) { + if (!this.isGroupedOnlyByOneDate(dimension)) { + return undefined; + } + const groupBys = + dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys; + return this.parseGroupField(groupBys[0]).aggregateOperator; + } + + /** + * @param {string} dimension COLUMN | ROW + * @param {number} index + */ + getGroupByAtIndex(dimension, index) { + const groupBys = + dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys; + return groupBys[index]; + } + + getNumberOfColGroupBys() { + return this.metaData.fullColGroupBys.length; + } + + //-------------------------------------------------------------------------- + // Evaluation + //-------------------------------------------------------------------------- + + /** + * Get the value of the given domain for the given measure + */ + getPivotCellValue(measure, domain) { + const { cols, rows } = this._getColsRowsValuesFromDomain(domain); + const group = JSON.stringify([rows, cols]); + const values = this.data.measurements[group]; + return (values && values[0][measure]) || ""; + } + + /** + * Get the label the given field-value + * + * @param {string} groupFieldString Name of the field + * @param {string} groupValueString Value of the group by + * @returns {string} + */ + getGroupByDisplayLabel(groupFieldString, groupValueString) { + if (groupValueString === NO_RECORD_AT_THIS_POSITION) { + return ""; + } + if (groupFieldString === "measure") { + if (groupValueString === "__count") { + return _t("Count"); + } + // the value is actually the measure field name + return this.parseGroupField(groupValueString).field.string; + } + const { field, aggregateOperator } = this.parseGroupField(groupFieldString); + const value = parsePivotFormulaFieldValue(field, groupValueString); + const undef = _t("None"); + if (this._isDateField(field)) { + if (value && aggregateOperator === "day") { + return toNumber(value); + } + return formatDate(aggregateOperator, value); + } + if (field.relation) { + const label = this.metadataRepository.getRecordDisplayName(field.relation, value); + if (!label) { + return undef; + } + return label; + } + const label = this.metadataRepository.getLabel(this.metaData.resModel, field.name, value); + if (!label) { + return undef; + } + return label; + } + + /** + * Get the label of the last group by of the domain + * + * @param {string[]} domain Domain of the formula + */ + getPivotHeaderValue(domain) { + const groupFieldString = domain[domain.length - 2]; + if (groupFieldString.startsWith("#")) { + const { field } = this.parseGroupField(groupFieldString); + const { cols, rows } = this._getColsRowsValuesFromDomain(domain); + return this._isCol(field) ? cols[cols.length - 1] : rows[rows.length - 1]; + } + const groupValueString = domain[domain.length - 1]; + return groupValueString; + } + + /** + * Get the displayed label of the last group by of the domain + * + * @param {string[]} domain Domain of the formula + * @returns {string} + */ + getDisplayedPivotHeaderValue(domain) { + const groupFieldString = domain[domain.length - 2]; + return this.getGroupByDisplayLabel(groupFieldString, this.getPivotHeaderValue(domain)); + } + + //-------------------------------------------------------------------------- + // Misc + //-------------------------------------------------------------------------- + + /** + * Get the Odoo domain corresponding to the given domain + */ + getPivotCellDomain(domain) { + const { cols, rows } = this._getColsRowsValuesFromDomain(domain); + const key = JSON.stringify([rows, cols]); + const domains = this.data.groupDomains[key]; + return domains ? domains[0] : Domain.FALSE.toList(); + } + + /** + * @returns {SpreadsheetPivotTable} + */ + getTableStructure() { + const cols = this._getSpreadsheetCols(); + const rows = this._getSpreadsheetRows(this.data.rowGroupTree); + rows.push(rows.shift()); //Put the Total row at the end. + const measures = this.metaData.activeMeasures; + return new SpreadsheetPivotTable(cols, rows, measures); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _loadData(config) { + /** @type {(groupFieldString: string) => ReturnType} */ + this.parseGroupField = parseGroupField.bind(null, this.metaData.fields); + /* + * prune is manually set to false in order to expand all the groups + * automatically + */ + const prune = false; + await super._loadData(config, prune); + + const metadataRepository = this.metadataRepository; + + const registerLabels = (tree, groupBys) => { + const group = tree.root; + if (!tree.directSubTrees.size) { + for (let i = 0; i < group.values.length; i++) { + const { field } = this.parseGroupField(groupBys[i]); + if (!field.relation) { + metadataRepository.registerLabel( + config.metaData.resModel, + field.name, + group.values[i], + group.labels[i] + ); + } + } + } + [...tree.directSubTrees.values()].forEach((subTree) => { + registerLabels(subTree, groupBys); + }); + }; + + registerLabels(this.data.colGroupTree, this.metaData.fullColGroupBys); + registerLabels(this.data.rowGroupTree, this.metaData.fullRowGroupBys); + } + + /** + * Determines if the given field is a date or datetime field. + * + * @param {Field} field Field description + * @private + * @returns {boolean} True if the type of the field is date or datetime + */ + _isDateField(field) { + return ["date", "datetime"].includes(field.type); + } + + /** + * @override + */ + _getGroupValues(group, groupBys) { + return groupBys.map((groupBy) => { + return this._sanitizeValue(group[groupBy], groupBy); + }); + } + + /** + * @override + */ + _sanitizeValue(value, groupBy) { + const { aggregateOperator, field } = this.parseGroupField(groupBy); + if (this._isDateField(field)) { + const fIn = FORMATS[aggregateOperator]["in"]; + const fOut = FORMATS[aggregateOperator]["out"]; + // eslint-disable-next-line no-undef + const date = moment(value, fIn); + return date.isValid() ? date.format(fOut) : false; + } + return super._sanitizeValue(value); + } + + /** + * Check if the given field is used as col group by + */ + _isCol(field) { + return this.metaData.fullColGroupBys + .map(this.parseGroupField) + .map(({ field }) => field.name) + .includes(field.name); + } + + /** + * Check if the given field is used as row group by + */ + _isRow(field) { + return this.metaData.fullRowGroupBys + .map(this.parseGroupField) + .map(({ field }) => field.name) + .includes(field.name); + } + + /** + * Get the value of a field-value for a positional group by + * + * @param {object} field Field of the group by + * @param {unknown} groupValueString Value of the group by + * @param {(number | boolean | string)[]} rows Values for the previous row group bys + * @param {(number | boolean | string)[]} cols Values for the previous col group bys + * + * @private + * @returns {number | boolean | string} + */ + _parsePivotFormulaWithPosition(field, groupValueString, cols, rows) { + const position = toNumber(groupValueString) - 1; + let tree; + if (this._isCol(field)) { + tree = this.data.colGroupTree; + for (const col of cols) { + tree = tree && tree.directSubTrees.get(col); + } + } else { + tree = this.data.rowGroupTree; + for (const row of rows) { + tree = tree && tree.directSubTrees.get(row); + } + } + if (tree) { + const treeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; + const sortedKey = treeKeys[position]; + return sortedKey !== undefined ? sortedKey : NO_RECORD_AT_THIS_POSITION; + } + return NO_RECORD_AT_THIS_POSITION; + } + + /** + * Transform the given domain in the structure used in this class + * + * @param {(number | boolean | string)[]} domain Domain + * + * @private + */ + _getColsRowsValuesFromDomain(domain) { + const rows = []; + const cols = []; + let i = 0; + while (i < domain.length) { + const groupFieldString = domain[i]; + const groupValue = domain[i + 1]; + const { field, isPositional } = this.parseGroupField(groupFieldString); + let value; + if (isPositional) { + value = this._parsePivotFormulaWithPosition(field, groupValue, cols, rows); + } else { + value = parsePivotFormulaFieldValue(field, groupValue); + } + if (this._isCol(field)) { + cols.push(value); + } else if (this._isRow(field)) { + rows.push(value); + } + i += 2; + } + return { rows, cols }; + } + + /** + * Get the row structure + */ + _getSpreadsheetRows(tree) { + let rows = []; + const group = tree.root; + const indent = group.labels.length; + const rowGroupBys = this.metaData.fullRowGroupBys; + + rows.push({ + fields: rowGroupBys.slice(0, indent), + values: [...group.values], + indent, + }); + + const subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; + subTreeKeys.forEach((subTreeKey) => { + const subTree = tree.directSubTrees.get(subTreeKey); + rows = rows.concat(this._getSpreadsheetRows(subTree)); + }); + return rows; + } + + /** + * Get the col structure + */ + _getSpreadsheetCols() { + const colGroupBys = this.metaData.fullColGroupBys; + const height = colGroupBys.length; + const measureCount = this.metaData.activeMeasures.length; + const leafCounts = this._getLeafCounts(this.data.colGroupTree); + + const headers = new Array(height).fill(0).map(() => []); + + function generateTreeHeaders(tree, fields) { + const group = tree.root; + const rowIndex = group.values.length; + if (rowIndex !== 0) { + const row = headers[rowIndex - 1]; + const leafCount = leafCounts[JSON.stringify(tree.root.values)]; + const cell = { + fields: colGroupBys.slice(0, rowIndex), + values: [...group.values], + width: leafCount * measureCount, + }; + row.push(cell); + } + + [...tree.directSubTrees.values()].forEach((subTree) => { + generateTreeHeaders(subTree, fields); + }); + } + + generateTreeHeaders(this.data.colGroupTree, this.metaData.fields); + const hasColGroupBys = this.metaData.colGroupBys.length; + + // 2) generate measures row + const measureRow = []; + + if (hasColGroupBys) { + headers[headers.length - 1].forEach((cell) => { + this.metaData.activeMeasures.forEach((measureName) => { + const measureCell = { + fields: [...cell.fields, "measure"], + values: [...cell.values, measureName], + width: 1, + }; + measureRow.push(measureCell); + }); + }); + } + this.metaData.activeMeasures.forEach((measureName) => { + const measureCell = { + fields: ["measure"], + values: [measureName], + width: 1, + }; + measureRow.push(measureCell); + }); + headers.push(measureRow); + // 3) Add the total cell + if (headers.length === 1) { + headers.unshift([]); // Will add the total there + } + headers[headers.length - 2].push({ + fields: [], + values: [], + width: this.metaData.activeMeasures.length, + }); + + return headers; + } +} diff --git a/addons/spreadsheet/static/src/pivot/pivot_table.js b/addons/spreadsheet/static/src/pivot/pivot_table.js new file mode 100644 index 00000000000..69129c8961b --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/pivot_table.js @@ -0,0 +1,171 @@ +/** @odoo-module */ + +/** + * @typedef {Object} Column + * @property {string[]} fields + * @property {string[]} values + * @property {number} width + * + * @typedef {Object} Row + * @property {string[]} fields + * @property {string[]} values + * @property {number} intend + * + * @typedef {Object} SpreadsheetTableData + * @property {Column[][]} cols + * @property {Row[]} rows + * @property {string[]} measures + */ + +/** + * Class used to ease the construction of a pivot table. + * Let's consider the following example, with: + * - columns groupBy: [sales_team, create_date] + * - rows groupBy: [continent, city] + * - measures: [revenues] + * _____________________________________________________________________________________| ----| + * | | Sale Team 1 | Sale Team 2 | | | + * | |___________________________|_________________________|_____________| | + * | | May 2020 | June 2020 | May 2020 | June 2020 | Total | |<---- `cols` + * | |______________|____________|____________|____________|_____________| | ----| + * | | Revenues | Revenues | Revenues | Revenues | Revenues | | |<--- `measureRow` + * |________________|______________|____________|____________|____________|_____________| ----| ----| + * |Europe | 25 | 35 | 40 | 30 | 65 | ----| + * | Brussels | 0 | 15 | 30 | 30 | 30 | | + * | Paris | 25 | 20 | 10 | 0 | 35 | | + * |North America | 60 | 75 | | | 60 | |<---- `body` + * | Washington | 60 | 75 | | | 60 | | + * |Total | 85 | 110 | 40 | 30 | 125 | | + * |________________|______________|____________|____________|____________|_____________| ----| + * + * | | + * |----------------| + * | + * | + * `rows` + * + * `rows` is an array of cells, each cells contains the indent level, the fields used for the group by and the values for theses fields. + * For example: + * `Europe`: { indent: 1, fields: ["continent"], values: ["id_of_Europe"]} + * `Brussels`: { indent: 2, fields: ["continent", "city"], values: ["id_of_Europe", "id_of_Brussels"]} + * `Total`: { indent: 0, fields: [], values: []} + * + * `columns` is an double array, first by row and then by cell. So, in this example, it looks like: + * [[row1], [row2], [measureRow]] + * Each cell of a column's row contains the width (span) of the cells, the fields used for the group by and the values for theses fields. + * For example: + * `Sale Team 1`: { width: 2, fields: ["sales_team"], values: ["id_of_SaleTeam1"]} + * `May 2020` (the one under Sale Team 2): { width: 1, fields: ["sales_team", "create_date"], values: ["id_of_SaleTeam2", "May 2020"]} + * `Revenues` (the one under Total): { width: 1, fields: ["measure"], values: ["revenues"]} + * + */ +export class SpreadsheetPivotTable { + /** + * @param {Column[][]} cols + * @param {Row[]} rows + * @param {string[]} measures + */ + constructor(cols, rows, measures) { + this._cols = cols; + this._rows = rows; + this._measures = measures; + } + + /** + * @returns {number} + */ + getNumberOfMeasures() { + return this._measures.length; + } + + /** + * @returns {Column[][]} + */ + getColHeaders() { + return this._cols; + } + + /** + * Get the last row of the columns (i.e. the one with the measures) + * @returns {Column[]} + */ + getMeasureHeaders() { + return this._cols[this._cols.length - 1]; + } + + /** + * Get the number of columns leafs (i.e. the number of the last row of columns) + * @returns {number} + */ + getColWidth() { + return this._cols[this._cols.length - 1].length; + } + + /** + * Get the number of row in each columns + * @return {number} + */ + getColHeight() { + return this._cols.length; + } + + /** + * @returns {Row[]} + */ + getRowHeaders() { + return this._rows; + } + + /** + * Get the number of rows + * + * @returns {number} + */ + getRowHeight() { + return this._rows.length; + } + + getCellFromMeasureRowWithDomain(values) { + const vals = JSON.stringify(values).slice(0, -1); //Remove the last "]" + return this.getMeasureHeaders().find((cell) => + JSON.stringify(cell.values).startsWith(vals) + ); + } + + /** + * Get the index of the cell in the measure row (i.e. the last one) which + * correspond to the given values + * + * @returns {number} + */ + getColMeasureIndex(values) { + const vals = JSON.stringify(values); + return this.getMeasureHeaders().findIndex((cell) => JSON.stringify(cell.values) === vals); + } + + getRowIndex(values) { + const vals = JSON.stringify(values); + return this._rows.findIndex( + (cell) => JSON.stringify(cell.values.map((val) => val.toString())) === vals + ); + } + + getCellFromMeasureRowAtIndex(index) { + return this.getMeasureHeaders()[index]; + } + + getCellsFromRowAtIndex(index) { + return this._rows[index]; + } + + /** + * @returns {SpreadsheetTableData} + */ + export() { + return { + cols: this._cols, + rows: this._rows, + measures: this._measures, + }; + } +} diff --git a/addons/spreadsheet/static/src/pivot/plugins/pivot_plugin.js b/addons/spreadsheet/static/src/pivot/plugins/pivot_plugin.js new file mode 100644 index 00000000000..416edc072f3 --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/plugins/pivot_plugin.js @@ -0,0 +1,481 @@ +/** @odoo-module */ + +/** + * @typedef {Object} PivotDefinition + * @property {Array} colGroupBys + * @property {Array} rowGroupBys + * @property {Array} measures + * @property {string} model + * @property {Array} domain + * @property {Object} context + * @property {string} name + * @property {string} id + * @property {Object | null} sortedColumn + * + * @typedef {Object} Pivot + * @property {number} id + * @property {string} dataSourceId + * @property {PivotDefinition} definition + */ +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { makePivotFormula } from "../pivot_helpers"; +import { getMaxObjectId } from "@spreadsheet/helpers/helpers"; +import { HEADER_STYLE, TOP_LEVEL_STYLE, MEASURE_STYLE } from "@spreadsheet/helpers/constants"; +import PivotDataSource from "../pivot_data_source"; +import { SpreadsheetPivotTable } from "../pivot_table"; +import CommandResult from "../../o_spreadsheet/cancelled_reason"; + +const { CorePlugin } = spreadsheet; + +export default class PivotPlugin extends CorePlugin { + constructor(getters, history, range, dispatch, config, uuidGenerator) { + super(getters, history, range, dispatch, config, uuidGenerator); + this.dataSources = config.dataSources; + + this.nextId = 1; + /** @type {Object.} */ + this.pivots = {}; + } + + allowDispatch(cmd) { + switch (cmd.type) { + case "RENAME_ODOO_PIVOT": + if (!(cmd.pivotId in this.pivots)) { + return CommandResult.PivotIdNotFound; + } + if (cmd.name === "") { + return CommandResult.EmptyName; + } + break; + case "INSERT_PIVOT": + if (cmd.id !== this.nextId.toString()) { + return CommandResult.InvalidNextId; + } + break; + } + return CommandResult.Success; + } + + /** + * Handle a spreadsheet command + * + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "INSERT_PIVOT": { + const { sheetId, col, row, id, definition, dataSourceId } = cmd; + /** @type [number,number] */ + const anchor = [col, row]; + const { cols, rows, measures } = cmd.table; + const table = new SpreadsheetPivotTable(cols, rows, measures); + this._addPivot(id, definition, dataSourceId); + this._insertPivot(sheetId, anchor, id, table); + this.nextId = parseInt(id, 10) + 1; + break; + } + case "RE_INSERT_PIVOT": { + const { sheetId, col, row, id } = cmd; + /** @type [number,number] */ + const anchor = [col, row]; + const { cols, rows, measures } = cmd.table; + const table = new SpreadsheetPivotTable(cols, rows, measures); + this._insertPivot(sheetId, anchor, id, table); + break; + } + case "RENAME_ODOO_PIVOT": { + this.history.update("pivots", cmd.pivotId, "definition", "name", cmd.name); + break; + } + case "REMOVE_PIVOT": { + const pivots = { ...this.pivots }; + delete pivots[cmd.pivotId]; + this.history.update("pivots", pivots); + break; + } + case "UPDATE_ODOO_PIVOT_DOMAIN": { + this.history.update( + "pivots", + cmd.pivotId, + "definition", + "searchParams", + "domain", + cmd.domain + ); + const pivot = this.pivots[cmd.pivotId]; + this.dataSources.add(pivot.dataSourceId, PivotDataSource, pivot.definition); + break; + } + case "UNDO": + case "REDO": { + const domainEditionCommands = cmd.commands.filter( + (cmd) => cmd.type === "UPDATE_ODOO_PIVOT_DOMAIN" + ); + for (const cmd of domainEditionCommands) { + const pivot = this.pivots[cmd.pivotId]; + this.dataSources.add(pivot.dataSourceId, PivotDataSource, pivot.definition); + } + break; + } + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + /** + * @param {number} id + * @returns {PivotDataSource|undefined} + */ + getPivotDataSource(id) { + const dataSourceId = this.pivots[id].dataSourceId; + return this.dataSources.get(dataSourceId); + } + + /** + * @param {number} id + * @returns {string} + */ + getPivotDisplayName(id) { + return `(#${id}) ${this.getPivotName(id)}`; + } + + /** + * @param {number} id + * @returns {string} + */ + getPivotName(id) { + return this.pivots[id].definition.name; + } + + /** + * @param {number} id + * @returns {Promise} + */ + async getAsyncPivotDataSource(id) { + const dataSourceId = this.pivots[id].dataSourceId; + await this.dataSources.load(dataSourceId); + return this.getPivotDataSource(id); + } + + /** + * Retrieve the next available id for a new pivot + * + * @returns {string} id + */ + getNextPivotId() { + return this.nextId.toString(); + } + + /** + * @param {number} id Id of the pivot + * + * @returns {PivotDefinition} + */ + getPivotDefinition(id) { + const def = this.pivots[id].definition; + return { + colGroupBys: [...def.metaData.colGroupBys], + context: { ...def.searchParams.context }, + domain: [...def.searchParams.domain], + id, + measures: [...def.metaData.activeMeasures], + model: def.metaData.resModel, + rowGroupBys: [...def.metaData.rowGroupBys], + name: def.name, + sortedColumn: def.metaData.sortedColumn ? { ...def.metaData.sortedColumn } : null, + }; + } + + /** + * Retrieve all the pivot ids + * + * @returns {Array} + */ + getPivotIds() { + return Object.keys(this.pivots); + } + + /** + * Check if an id is an id of an existing pivot + * + * @param {number} pivotId Id of the pivot + * + * @returns {boolean} + */ + isExistingPivot(pivotId) { + return pivotId in this.pivots; + } + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + /** + * @param {number} id + * @param {PivotDefinition} definition + * @param {string} dataSourceId + */ + _addPivot(id, definition, dataSourceId) { + const pivots = { ...this.pivots }; + pivots[id] = { + id, + definition, + dataSourceId, + }; + + if (!this.dataSources.contains(dataSourceId)) { + this.dataSources.add(dataSourceId, PivotDataSource, definition); + } + this.history.update("pivots", pivots); + } + + /** + * @param {string} sheetId + * @param {[number, number]} anchor + * @param {number} id + * @param {SpreadsheetPivotTable} table + */ + _insertPivot(sheetId, anchor, id, table) { + this._resizeSheet(sheetId, anchor, table); + this._insertColumns(sheetId, anchor, id, table); + this._insertRows(sheetId, anchor, id, table); + this._insertBody(sheetId, anchor, id, table); + } + + /** + * @param {string} sheetId + * @param {[number, number]} anchor + * @param {number} id + * @param {SpreadsheetPivotTable} table + */ + _insertColumns(sheetId, anchor, id, table) { + let anchorLeft = anchor[0] + 1; + let anchorTop = anchor[1]; + for (const _row of table.getColHeaders()) { + anchorLeft = anchor[0] + 1; + for (const cell of _row) { + const args = [id]; + for (let i = 0; i < cell.fields.length; i++) { + args.push(cell.fields[i]); + args.push(cell.values[i]); + } + if (cell.width > 1) { + this._merge(sheetId, { + top: anchorTop, + bottom: anchorTop, + left: anchorLeft, + right: anchorLeft + cell.width - 1, + }); + } + this._addPivotFormula(sheetId, anchorLeft, anchorTop, "ODOO.PIVOT.HEADER", args); + anchorLeft += cell.width; + } + anchorTop++; + } + const colHeight = table.getColHeight(); + const colWidth = table.getColWidth(); + const lastRowBeforeMeasureRow = anchor[1] + colHeight - 2; + const right = anchor[0] + colWidth; + const left = right - table.getNumberOfMeasures() + 1; + for (let anchorTop = anchor[1]; anchorTop < lastRowBeforeMeasureRow; anchorTop++) { + this._merge(sheetId, { top: anchorTop, bottom: anchorTop, left, right }); + } + const headersZone = { + top: anchor[1], + bottom: lastRowBeforeMeasureRow, + left: anchor[0], + right: anchor[0] + colWidth, + }; + const measuresZone = { + top: anchor[1] + colHeight - 1, + bottom: anchor[1] + colHeight - 1, + left: anchor[0], + right: anchor[0] + colWidth, + }; + this.dispatch("SET_FORMATTING", { sheetId, target: [headersZone], style: TOP_LEVEL_STYLE }); + this.dispatch("SET_FORMATTING", { sheetId, target: [measuresZone], style: MEASURE_STYLE }); + } + + /** + * Merge a zone + * + * @param {string} sheetId + * @param {Object} zone + * + * @private + */ + _merge(sheetId, zone) { + this.dispatch("ADD_MERGE", { sheetId, target: [zone] }); + } + + /** + * @param {string} sheetId + * @param {[number,number]} anchor + * @param {SpreadsheetPivotTable} table + */ + _resizeSheet(sheetId, anchor, table) { + const colLimit = table.getColWidth() + 1; // +1 for the Top-Left + const numberCols = this.getters.getNumberCols(sheetId); + const deltaCol = numberCols - anchor[0]; + if (deltaCol < colLimit) { + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: "COL", + base: numberCols - 1, + sheetId: sheetId, + quantity: colLimit - deltaCol, + position: "after", + }); + } + const rowLimit = table.getColHeight() + table.getRowHeight(); + const numberRows = this.getters.getNumberRows(sheetId); + const deltaRow = numberRows - anchor[1]; + if (deltaRow < rowLimit) { + this.dispatch("ADD_COLUMNS_ROWS", { + dimension: "ROW", + base: numberRows - 1, + sheetId: sheetId, + quantity: rowLimit - deltaRow, + position: "after", + }); + } + } + + /** + * @param {string} sheetId + * @param {[number, number]} anchor + * @param {number} id + * @param {SpreadsheetPivotTable} table + */ + _insertRows(sheetId, anchor, id, table) { + let y = anchor[1] + table.getColHeight(); + const x = anchor[0]; + for (const row of table.getRowHeaders()) { + const args = [id]; + for (let i = 0; i < row.fields.length; i++) { + args.push(row.fields[i]); + args.push(row.values[i]); + } + this._addPivotFormula(sheetId, x, y, "ODOO.PIVOT.HEADER", args); + if (row.indent <= 2) { + const target = [{ top: y, bottom: y, left: x, right: x }]; + const style = row.indent === 2 ? HEADER_STYLE : TOP_LEVEL_STYLE; + this.dispatch("SET_FORMATTING", { sheetId, target, style }); + } + y++; + } + } + + /** + * @param {string} sheetId + * @param {[number, number]} anchor + * @param {number} id + * @param {SpreadsheetPivotTable} table + */ + _insertBody(sheetId, anchor, id, table) { + let x = anchor[0] + 1; + for (const col of table.getMeasureHeaders()) { + let y = anchor[1] + table.getColHeight(); + const measure = col.values[col.values.length - 1]; + for (const row of table.getRowHeaders()) { + const args = [id, measure]; + for (let i = 0; i < row.fields.length; i++) { + args.push(row.fields[i]); + args.push(row.values[i]); + } + for (let i = 0; i < col.fields.length - 1; i++) { + args.push(col.fields[i]); + args.push(col.values[i]); + } + this._addPivotFormula(sheetId, x, y, "ODOO.PIVOT", args); + y++; + } + x++; + } + } + + /** + * @param {string} sheetId + * @param {number} col + * @param {number} row + * @param {string} formula + * @param {Array} args + */ + _addPivotFormula(sheetId, col, row, formula, args) { + this.dispatch("UPDATE_CELL", { + sheetId, + col, + row, + content: makePivotFormula(formula, args), + }); + } + + // --------------------------------------------------------------------- + // Import/Export + // --------------------------------------------------------------------- + + /** + * Import the pivots + * + * @param {Object} data + */ + import(data) { + if (data.pivots) { + for (const [id, pivot] of Object.entries(data.pivots)) { + const definition = { + metaData: { + colGroupBys: pivot.colGroupBys, + rowGroupBys: pivot.rowGroupBys, + activeMeasures: pivot.measures.map((elt) => elt.field), + resModel: pivot.model, + sortedColumn: !pivot.sortedColumn + ? undefined + : { + groupId: pivot.sortedColumn.groupId, + measure: pivot.sortedColumn.measure, + order: pivot.sortedColumn.order, + }, + }, + searchParams: { + groupBy: [], + orderBy: [], + domain: pivot.domain, + context: pivot.context, + }, + /** + * As we are not able to migrate the json, we have to fallback + * in the case where the name is not yet present + */ + name: pivot.name || pivot.model, + }; + this._addPivot(id, definition, this.uuidGenerator.uuidv4()); + } + } + this.nextId = data.pivotNextId || getMaxObjectId(this.pivots) + 1; + } + /** + * Export the pivots + * + * @param {Object} data + */ + export(data) { + data.pivots = {}; + for (const id in this.pivots) { + data.pivots[id] = JSON.parse(JSON.stringify(this.getPivotDefinition(id))); + data.pivots[id].measures = data.pivots[id].measures.map((elt) => ({ field: elt })); + } + data.pivotNextId = this.nextId; + } +} + +PivotPlugin.getters = [ + "getNextPivotId", + "getPivotDefinition", + "getPivotDisplayName", + "getPivotIds", + "getPivotName", + "getAsyncPivotDataSource", + "isExistingPivot", + "getPivotDataSource", +]; diff --git a/addons/spreadsheet/static/src/pivot/plugins/pivot_structure_plugin.js b/addons/spreadsheet/static/src/pivot/plugins/pivot_structure_plugin.js new file mode 100644 index 00000000000..99f819ee199 --- /dev/null +++ b/addons/spreadsheet/static/src/pivot/plugins/pivot_structure_plugin.js @@ -0,0 +1,286 @@ +/** @odoo-module */ + +import { _t } from "web.core"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { getFirstPivotFunction } from "../pivot_helpers"; +import { FILTER_DATE_OPTION, monthsOptions } from "@spreadsheet/assets_backend/constants"; + +const { astToFormula } = spreadsheet; +const { DateTime } = luxon; + +/** + * Convert pivot period to the related filter value + * + * @param {import("@spreadsheet/global_filters/plugins/filters_plugin").RangeType} timeRange + * @param {string} value + * @returns {object} + */ +function pivotPeriodToFilterValue(timeRange, value) { + // reuse the same logic as in `parseAccountingDate`? + const yearOffset = (value.split("/").pop() | 0) - DateTime.now().year; + switch (timeRange) { + case "year": + return { + yearOffset, + }; + case "month": { + const month = value.split("/")[0] | 0; + return { + yearOffset, + period: monthsOptions[month - 1].id, + }; + } + case "quarter": { + const quarter = value.split("/")[0] | 0; + return { + yearOffset, + period: FILTER_DATE_OPTION.quarter[quarter - 1], + }; + } + } +} + +export default class PivotStructurePlugin extends spreadsheet.UIPlugin { + constructor() { + super(...arguments); + /** @type {string} */ + this.selectedPivotId = undefined; + this.selection.observe(this, { + handleEvent: this.handleEvent.bind(this), + }); + } + + handleEvent(event) { + if (!this.getters.isDashboard()) { + return; + } + switch (event.type) { + case "ZonesSelected": { + const sheetId = this.getters.getActiveSheetId(); + const { col, row } = event.anchor.cell; + const cell = this.getters.getCell(sheetId, col, row); + if (cell !== undefined && cell.content.startsWith("=ODOO.PIVOT.HEADER(")) { + const filters = this.getFiltersMatchingPivot(cell.content); + this.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { filters }); + } + break; + } + } + } + + /** + * Handle a spreadsheet command + * @param {Object} cmd Command + */ + handle(cmd) { + switch (cmd.type) { + case "SELECT_PIVOT": + this.selectedPivotId = cmd.pivotId; + break; + case "ADD_PIVOT_DOMAIN": + this._addDomain(cmd.id, cmd.domain); + break; + case "REFRESH_PIVOT": + this._refreshOdooPivot(cmd.id); + break; + case "REFRESH_ALL_DATA_SOURCES": + this._refreshOdooPivots(); + break; + } + } + + // --------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------- + + /** + * Retrieve the pivotId of the current selected cell + * + * @returns {string} + */ + getSelectedPivotId() { + return this.selectedPivotId; + } + + /** + * Get the id of the pivot at the given position. Returns undefined if there + * is no pivot at this position + * + * @param {string} sheetId Id of the sheet + * @param {number} col Index of the col + * @param {number} row Index of the row + * + * @returns {string|undefined} + */ + getPivotIdFromPosition(sheetId, col, row) { + const cell = this.getters.getCell(sheetId, col, row); + if (cell && cell.isFormula()) { + const pivotFunction = getFirstPivotFunction(cell.content); + if (pivotFunction) { + const content = astToFormula(pivotFunction.args[0]); + return this.getters.evaluateFormula(content).toString(); + } + } + return undefined; + } + + /** + * Get the computed domain of a pivot + * CLEAN ME not used outside of tests + * @param {string} pivotId Id of the pivot + * @returns {Array} + */ + getPivotComputedDomain(pivotId) { + return this.getters.getPivotDataSource(pivotId).getComputedDomain(); + } + + /** + * Return all possible values in the pivot for a given field. + * + * @param {string} pivotId Id of the pivot + * @param {string} fieldName + * @returns {Array} + */ + getPivotGroupByValues(pivotId, fieldName) { + return this.getters.getPivotDataSource(pivotId).getPossibleValuesForGroupBy(fieldName); + } + + /** + * Get the value of a pivot header + * + * @param {string} pivotId Id of a pivot + * @param {Array} domain Domain + */ + getDisplayedPivotHeaderValue(pivotId, domain) { + const dataSource = this.getters.getPivotDataSource(pivotId); + dataSource.markAsHeaderUsed(domain); + const len = domain.length; + if (len === 0) { + return _t("Total"); + } + return dataSource.getDisplayedPivotHeaderValue(domain); + } + + /** + * Get the value for a pivot cell + * + * @param {string} pivotId Id of a pivot + * @param {string} measure Field name of the measures + * @param {Array} domain Domain + * + * @returns {string|number|undefined} + */ + getPivotCellValue(pivotId, measure, domain) { + const dataSource = this.getters.getPivotDataSource(pivotId); + dataSource.markAsValueUsed(domain, measure); + return dataSource.getPivotCellValue(measure, domain); + } + + /** + * Get the filter impacted by a pivot formula's argument + * + * @param {string} formula Formula of the pivot cell + * + * @returns {Array} + */ + getFiltersMatchingPivot(formula) { + const functionDescription = getFirstPivotFunction(formula); + if (!functionDescription) { + return []; + } + const { args } = functionDescription; + const evaluatedArgs = args + .map(astToFormula) + .map((arg) => this.getters.evaluateFormula(arg)); + const pivotId = evaluatedArgs[0]; + const argField = evaluatedArgs[evaluatedArgs.length - 2]; + const filters = this.getters.getGlobalFilters(); + const matchingFilters = []; + + for (const filter of filters) { + const dataSource = this.getters.getPivotDataSource(pivotId); + const { field, aggregateOperator: time } = dataSource.parseGroupField(argField); + if (filter.pivotFields[pivotId].field === field.name) { + let value = dataSource.getPivotHeaderValue(evaluatedArgs.slice(1)); + let transformedValue; + const currentValue = this.getters.getGlobalFilterValue(filter.id); + switch (filter.type) { + case "date": + if (time === filter.rangeType) { + transformedValue = pivotPeriodToFilterValue(time, value); + if (JSON.stringify(transformedValue) === JSON.stringify(currentValue)) { + transformedValue = undefined; + } + } else { + continue; + } + break; + case "relation": + if (typeof value == "string") { + value = Number(value); + if (Number.isNaN(value)) { + break; + } + } + if (JSON.stringify(currentValue) !== `[${value}]`) { + transformedValue = [value]; + } + break; + case "text": + if (currentValue !== value) { + transformedValue = value; + } + break; + } + matchingFilters.push({ filterId: filter.id, value: transformedValue }); + } + } + return matchingFilters; + } + + // --------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------- + + /** + * Refresh the cache of a pivot + * + * @param {string} pivotId Id of the pivot + */ + _refreshOdooPivot(pivotId) { + const dataSource = this.getters.getPivotDataSource(pivotId); + dataSource.clearUsedValues(); + dataSource.load({ reload: true }); + } + + /** + * Refresh the cache of all the lists + */ + _refreshOdooPivots() { + for (const pivotId of this.getters.getPivotIds()) { + this._refreshOdooPivot(pivotId, false); + } + } + + /** + * Add an additional domain to a pivot + * + * @private + * + * @param {string} pivotId pivot id + * @param {Array>} domain + */ + _addDomain(pivotId, domain) { + this.getters.getPivotDataSource(pivotId).addDomain(domain); + } +} + +PivotStructurePlugin.getters = [ + "getSelectedPivotId", + "getPivotComputedDomain", + "getDisplayedPivotHeaderValue", + "getPivotIdFromPosition", + "getPivotCellValue", + "getPivotGroupByValues", + "getFiltersMatchingPivot", +]; diff --git a/addons/spreadsheet/static/tests/charts/model/link_chart_plugin_test.js b/addons/spreadsheet/static/tests/charts/model/link_chart_plugin_test.js new file mode 100644 index 00000000000..d405d4c3b7a --- /dev/null +++ b/addons/spreadsheet/static/tests/charts/model/link_chart_plugin_test.js @@ -0,0 +1,159 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { getBasicData } from "@spreadsheet/../tests/utils/data"; +import { createBasicChart } from "@spreadsheet/../tests/utils/commands"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { registry } from "@web/core/registry"; +import { menuService } from "@web/webclient/menus/menu_service"; +import { actionService } from "@web/webclient/actions/action_service"; +import { ormService } from "@web/core/orm_service"; +import { viewService } from "@web/views/view_service"; + +const { Model } = spreadsheet; + +const chartId = "uuid1"; + +QUnit.module( + "spreadsheet > ir.ui.menu chart plugin", + { + beforeEach: function () { + this.serverData = {}; + this.serverData.menus = { + root: { + id: "root", + children: [1, 2], + name: "root", + appID: "root", + }, + 1: { + id: 1, + children: [], + name: "test menu 1", + xmlid: "documents_spreadsheet.test.menu", + appID: 1, + actionID: "menuAction", + }, + 2: { + id: 2, + children: [], + name: "test menu 2", + xmlid: "documents_spreadsheet.test.menu2", + appID: 1, + actionID: "menuAction2", + }, + }; + this.serverData.actions = { + menuAction: { + id: 99, + xml_id: "ir.ui.menu", + name: "menuAction", + res_model: "ir.ui.menu", + type: "ir.actions.act_window", + views: [[false, "list"]], + }, + menuAction2: { + id: 100, + xml_id: "ir.ui.menu", + name: "menuAction2", + res_model: "ir.ui.menu", + type: "ir.actions.act_window", + views: [[false, "list"]], + }, + }; + this.serverData.views = {}; + this.serverData.views["ir.ui.menu,false,list"] = ``; + this.serverData.views["ir.ui.menu,false,search"] = ``; + this.serverData.models = { + ...getBasicData(), + "ir.ui.menu": { + fields: { + name: { string: "Name", type: "char" }, + action: { string: "Action", type: "char" }, + groups_id: { + string: "Groups", + type: "many2many", + relation: "res.group", + }, + }, + records: [ + { + id: 1, + name: "test menu 1", + action: "action1", + groups_id: [10], + }, + { + id: 2, + name: "test menu 2", + action: "action2", + groups_id: [10], + }, + ], + }, + "res.users": { + fields: { + name: { string: "Name", type: "char" }, + groups_id: { + string: "Groups", + type: "many2many", + relation: "res.group", + }, + }, + records: [{ id: 1, name: "Raoul", groups_id: [10] }], + }, + "ir.actions": { + fields: { + name: { string: "Name", type: "char" }, + }, + records: [{ id: 1 }], + }, + "res.group": { + fields: { name: { string: "Name", type: "char" } }, + records: [{ id: 10, name: "test group" }], + }, + }; + registry.category("services").add("menu", menuService).add("action", actionService); + registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class + registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class + }, + }, + + () => { + QUnit.test( + "Links between charts and ir.menus are correctly imported/exported", + async function (assert) { + const env = await makeTestEnv({ serverData: this.serverData }); + const model = new Model({}, { evalContext: { env } }); + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: 1, + }); + const exportedData = model.exportData(); + assert.equal( + exportedData.chartOdooMenusReferences[chartId], + 1, + "Link to odoo menu is exported" + ); + const importedModel = new Model(exportedData, { evalContext: { env } }); + const chartMenu = importedModel.getters.getChartOdooMenu(chartId); + assert.equal(chartMenu.id, 1, "Link to odoo menu is imported"); + } + ); + + QUnit.test("Can undo-redo a LINK_ODOO_MENU_TO_CHART", async function (assert) { + const env = await makeTestEnv({ serverData: this.serverData }); + const model = new Model({}, { evalContext: { env } }); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: 1, + }); + assert.equal(model.getters.getChartOdooMenu(chartId).id, 1); + model.dispatch("REQUEST_UNDO"); + assert.equal(model.getters.getChartOdooMenu(chartId), undefined); + model.dispatch("REQUEST_REDO"); + assert.equal(model.getters.getChartOdooMenu(chartId).id, 1); + }); + } +); diff --git a/addons/spreadsheet/static/tests/charts/model/odoo_chart_plugin_test.js b/addons/spreadsheet/static/tests/charts/model/odoo_chart_plugin_test.js new file mode 100644 index 00000000000..8550a6c2c32 --- /dev/null +++ b/addons/spreadsheet/static/tests/charts/model/odoo_chart_plugin_test.js @@ -0,0 +1,269 @@ +/** @odoo-module */ + +import { OdooBarChart } from "@spreadsheet/chart/odoo_chart/odoo_bar_chart"; +import { OdooChart } from "@spreadsheet/chart/odoo_chart/odoo_chart"; +import { OdooLineChart } from "@spreadsheet/chart/odoo_chart/odoo_line_chart"; +import { nextTick } from "@web/../tests/helpers/utils"; +import { createSpreadsheetWithGraph, insertGraphInSpreadsheet } from "../../utils/chart"; +import { createModelWithDataSource } from "../../utils/model"; + +QUnit.module("spreadsheet > odoo chart plugin", {}, () => { + QUnit.test("Can add an Odoo Bar chart", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ type: "odoo_bar" }); + const sheetId = model.getters.getActiveSheetId(); + assert.strictEqual(model.getters.getChartIds(sheetId).length, 1); + const chartId = model.getters.getChartIds(sheetId)[0]; + const chart = model.getters.getChart(chartId); + assert.ok(chart instanceof OdooBarChart); + assert.strictEqual(chart.getDefinitionForExcel(), undefined); + assert.strictEqual(model.getters.getChartRuntime(chartId).chartJsConfig.type, "bar"); + }); + + QUnit.test("Can add an Odoo Line chart", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ type: "odoo_line" }); + const sheetId = model.getters.getActiveSheetId(); + assert.strictEqual(model.getters.getChartIds(sheetId).length, 1); + const chartId = model.getters.getChartIds(sheetId)[0]; + const chart = model.getters.getChart(chartId); + assert.ok(chart instanceof OdooLineChart); + assert.strictEqual(chart.getDefinitionForExcel(), undefined); + assert.strictEqual(model.getters.getChartRuntime(chartId).chartJsConfig.type, "line"); + }); + + QUnit.test("Can add an Odoo Pie chart", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ type: "odoo_pie" }); + const sheetId = model.getters.getActiveSheetId(); + assert.strictEqual(model.getters.getChartIds(sheetId).length, 1); + const chartId = model.getters.getChartIds(sheetId)[0]; + const chart = model.getters.getChart(chartId); + assert.ok(chart instanceof OdooChart); + assert.strictEqual(chart.getDefinitionForExcel(), undefined); + assert.strictEqual(model.getters.getChartRuntime(chartId).chartJsConfig.type, "pie"); + }); + + QUnit.test("A data source is added after a chart creation", async (assert) => { + const { model } = await createSpreadsheetWithGraph(); + const sheetId = model.getters.getActiveSheetId(); + const chartId = model.getters.getChartIds(sheetId)[0]; + assert.ok(model.getters.getGraphDataSource(chartId)); + }); + + QUnit.test("Odoo bar chart runtime loads the data", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ + type: "odoo_bar", + mockRPC: async function (route, args) { + if (args.method === "web_read_group") { + assert.step("web_read_group"); + } + }, + }); + const sheetId = model.getters.getActiveSheetId(); + const chartId = model.getters.getChartIds(sheetId)[0]; + assert.verifySteps([], "it should not be loaded eagerly"); + assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, { + datasets: [], + labels: [], + }); + await nextTick(); + assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, { + datasets: [ + { + backgroundColor: "rgb(31,119,180)", + borderColor: "rgb(31,119,180)", + data: [1, 3], + label: "Count", + }, + ], + labels: ["false", "true"], + }); + assert.verifySteps(["web_read_group"], "it should have loaded the data"); + }); + + QUnit.test("Odoo pie chart runtime loads the data", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ + type: "odoo_pie", + mockRPC: async function (route, args) { + if (args.method === "web_read_group") { + assert.step("web_read_group"); + } + }, + }); + const sheetId = model.getters.getActiveSheetId(); + const chartId = model.getters.getChartIds(sheetId)[0]; + assert.verifySteps([], "it should not be loaded eagerly"); + assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, { + datasets: [], + labels: [], + }); + await nextTick(); + assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, { + datasets: [ + { + backgroundColor: ["rgb(31,119,180)", "rgb(255,127,14)", "rgb(174,199,232)"], + borderColor: "#FFFFFF", + data: [1, 3], + label: "", + }, + ], + labels: ["false", "true"], + }); + assert.verifySteps(["web_read_group"], "it should have loaded the data"); + }); + + QUnit.test("Odoo line chart runtime loads the data", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ + type: "odoo_line", + mockRPC: async function (route, args) { + if (args.method === "web_read_group") { + assert.step("web_read_group"); + } + }, + }); + const sheetId = model.getters.getActiveSheetId(); + const chartId = model.getters.getChartIds(sheetId)[0]; + assert.verifySteps([], "it should not be loaded eagerly"); + assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, { + datasets: [], + labels: [], + }); + await nextTick(); + assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, { + datasets: [ + { + backgroundColor: "rgb(31,119,180)", + borderColor: "rgb(31,119,180)", + data: [1, 3], + label: "Count", + lineTension: 0, + }, + ], + labels: ["false", "true"], + }); + assert.verifySteps(["web_read_group"], "it should have loaded the data"); + }); + + QUnit.test("Can import/export an Odoo chart", async (assert) => { + const model = await createModelWithDataSource(); + insertGraphInSpreadsheet(model, "odoo_line"); + const data = model.exportData(); + const figures = data.sheets[0].figures; + assert.strictEqual(figures.length, 1); + const figure = figures[0]; + assert.strictEqual(figure.tag, "chart"); + assert.strictEqual(figure.data.type, "odoo_line"); + const m1 = await createModelWithDataSource({ spreadsheetData: data }); + const sheetId = m1.getters.getActiveSheetId(); + assert.strictEqual(m1.getters.getChartIds(sheetId).length, 1); + const chartId = m1.getters.getChartIds(sheetId)[0]; + assert.ok(m1.getters.getGraphDataSource(chartId)); + assert.strictEqual(m1.getters.getChartRuntime(chartId).chartJsConfig.type, "line"); + }); + + QUnit.test("Can undo/redo an Odoo chart creation", async (assert) => { + const model = await createModelWithDataSource(); + insertGraphInSpreadsheet(model, "odoo_line"); + const sheetId = model.getters.getActiveSheetId(); + const chartId = model.getters.getChartIds(sheetId)[0]; + assert.ok(model.getters.getGraphDataSource(chartId)); + model.dispatch("REQUEST_UNDO"); + assert.notOk(model.getters.getGraphDataSource(chartId)); + assert.strictEqual(model.getters.getChartIds(sheetId).length, 0); + model.dispatch("REQUEST_REDO"); + assert.ok(model.getters.getGraphDataSource(chartId)); + assert.strictEqual(model.getters.getChartIds(sheetId).length, 1); + }); + + QUnit.test("charts with no legend", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ type: "odoo_pie" }); + insertGraphInSpreadsheet(model, "odoo_bar"); + insertGraphInSpreadsheet(model, "odoo_line"); + const sheetId = model.getters.getActiveSheetId(); + const [pieChartId, barChartId, lineChartId] = model.getters.getChartIds(sheetId); + const pie = model.getters.getChartDefinition(pieChartId); + const bar = model.getters.getChartDefinition(barChartId); + const line = model.getters.getChartDefinition(lineChartId); + assert.strictEqual( + model.getters.getChartRuntime(pieChartId).chartJsConfig.options.legend.display, + true + ); + assert.strictEqual( + model.getters.getChartRuntime(barChartId).chartJsConfig.options.legend.display, + true + ); + assert.strictEqual( + model.getters.getChartRuntime(lineChartId).chartJsConfig.options.legend.display, + true + ); + model.dispatch("UPDATE_CHART", { + definition: { + ...pie, + legendPosition: "none", + }, + id: pieChartId, + sheetId, + }); + model.dispatch("UPDATE_CHART", { + definition: { + ...bar, + legendPosition: "none", + }, + id: barChartId, + sheetId, + }); + model.dispatch("UPDATE_CHART", { + definition: { + ...line, + legendPosition: "none", + }, + id: lineChartId, + sheetId, + }); + assert.strictEqual( + model.getters.getChartRuntime(pieChartId).chartJsConfig.options.legend.display, + false + ); + assert.strictEqual( + model.getters.getChartRuntime(barChartId).chartJsConfig.options.legend.display, + false + ); + assert.strictEqual( + model.getters.getChartRuntime(lineChartId).chartJsConfig.options.legend.display, + false + ); + }); + + QUnit.test("Bar chart with stacked attribute is supported", async (assert) => { + const { model } = await createSpreadsheetWithGraph({ type: "odoo_bar" }); + const sheetId = model.getters.getActiveSheetId(); + const chartId = model.getters.getChartIds(sheetId)[0]; + const definition = model.getters.getChartDefinition(chartId); + model.dispatch("UPDATE_CHART", { + definition: { + ...definition, + stacked: true, + }, + id: chartId, + sheetId, + }); + assert.ok( + model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.xAxes[0].stacked + ); + assert.ok( + model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.yAxes[0].stacked + ); + model.dispatch("UPDATE_CHART", { + definition: { + ...definition, + stacked: false, + }, + id: chartId, + sheetId, + }); + assert.notOk( + model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.xAxes[0].stacked + ); + assert.notOk( + model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.yAxes[0].stacked + ); + }); +}); diff --git a/addons/spreadsheet/static/tests/charts/ui/link_chart_figure_test.js b/addons/spreadsheet/static/tests/charts/ui/link_chart_figure_test.js new file mode 100644 index 00000000000..1fc3dd013bd --- /dev/null +++ b/addons/spreadsheet/static/tests/charts/ui/link_chart_figure_test.js @@ -0,0 +1,336 @@ +/** @odoo-module */ + +import { click, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { session } from "@web/session"; +import { getBasicData } from "@spreadsheet/../tests/utils/data"; +import { createBasicChart } from "@spreadsheet/../tests/utils/commands"; +import { registry } from "@web/core/registry"; +import { menuService } from "@web/webclient/menus/menu_service"; +import { actionService } from "@web/webclient/actions/action_service"; +import { ormService } from "@web/core/orm_service"; +import { viewService } from "@web/views/view_service"; +import { mountSpreadsheet } from "@spreadsheet/../tests/utils/ui"; +import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model"; + +const chartId = "uuid1"; + +/** + * The chart menu is hidden by default, and visible on :hover, but this property + * can't be triggered programmatically, so we artificially make it visible to be + * able to interact with it. + */ +async function showChartMenu(fixture) { + const chartMenu = fixture.querySelector(".o-chart-menu"); + chartMenu.style.display = "flex"; + await nextTick(); +} + +/** Click on external link of the first chart found in the page*/ +async function clickChartExternalLink(fixture) { + await showChartMenu(fixture); + const chartMenuItem = fixture.querySelector(".o-chart-menu-item.o-chart-external-link"); + await click(chartMenuItem); +} + +function mockActionService(assert, doActionStep) { + const serviceRegistry = registry.category("services"); + serviceRegistry.add("actionMain", actionService); + const fakeActionService = { + dependencies: ["actionMain"], + start(env, { actionMain }) { + return { + ...actionMain, + doAction: (actionRequest, options = {}) => { + if (actionRequest === "menuAction2") { + assert.step(doActionStep); + } + return actionMain.doAction(actionRequest, options); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { + force: true, + }); +} + +QUnit.module( + "spreadsheet > ir.ui.menu chart figure", + { + beforeEach: function () { + this.serverData = {}; + this.serverData.menus = { + root: { + id: "root", + children: [1, 2], + name: "root", + appID: "root", + }, + 1: { + id: 1, + children: [], + name: "test menu 1", + xmlid: "documents_spreadsheet.test.menu", + appID: 1, + actionID: "menuAction", + }, + 2: { + id: 2, + children: [], + name: "test menu 2", + xmlid: "documents_spreadsheet.test.menu2", + appID: 1, + actionID: "menuAction2", + }, + }; + this.serverData.actions = { + menuAction: { + id: 99, + xml_id: "ir.ui.menu", + name: "menuAction", + res_model: "ir.ui.menu", + type: "ir.actions.act_window", + views: [[false, "list"]], + }, + menuAction2: { + id: 100, + xml_id: "ir.ui.menu", + name: "menuAction2", + res_model: "ir.ui.menu", + type: "ir.actions.act_window", + views: [[false, "list"]], + }, + }; + this.serverData.views = {}; + this.serverData.views["ir.ui.menu,false,list"] = ``; + this.serverData.views["ir.ui.menu,false,search"] = ``; + this.serverData.models = { + ...getBasicData(), + "ir.ui.menu": { + fields: { + name: { string: "Name", type: "char" }, + action: { string: "Action", type: "char" }, + groups_id: { + string: "Groups", + type: "many2many", + relation: "res.group", + }, + }, + records: [ + { + id: 1, + name: "test menu 1", + action: "action1", + groups_id: [10], + }, + { + id: 2, + name: "test menu 2", + action: "action2", + groups_id: [10], + }, + ], + }, + "res.users": { + fields: { + name: { string: "Name", type: "char" }, + groups_id: { + string: "Groups", + type: "many2many", + relation: "res.group", + }, + }, + records: [{ id: 1, name: "Raoul", groups_id: [10] }], + }, + "ir.actions": { + fields: { + name: { string: "Name", type: "char" }, + }, + records: [{ id: 1 }], + }, + "res.group": { + fields: { name: { string: "Name", type: "char" } }, + records: [{ id: 10, name: "test group" }], + }, + }; + patchWithCleanup(session, { uid: 1 }); + registry.category("services").add("menu", menuService).add("action", actionService); + registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class + registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class + }, + }, + + () => { + QUnit.test( + "icon external link isn't on the chart when its not linked to an odoo menu", + async function (assert) { + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + createBasicChart(model, chartId); + await nextTick(); + const odooMenu = model.getters.getChartOdooMenu(chartId); + assert.equal(odooMenu, undefined, "No menu linked with the chart"); + + const externalRefIcon = fixture.querySelector(".o-chart-external-link"); + assert.equal(externalRefIcon, null); + } + ); + + QUnit.test( + "icon external link is on the chart when its linked to an odoo menu", + async function (assert) { + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: 1, + }); + const chartMenu = model.getters.getChartOdooMenu(chartId); + assert.equal(chartMenu.id, 1, "Odoo menu is linked to chart"); + await nextTick(); + const externalRefIcon = fixture.querySelector(".o-chart-external-link"); + assert.ok(externalRefIcon); + } + ); + + QUnit.test( + "icon external link is not on the chart when its linked to a wrong odoo menu", + async function (assert) { + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: "menu which does not exist", + }); + const chartMenu = model.getters.getChartOdooMenu(chartId); + assert.equal(chartMenu, undefined, "cannot get a wrong menu"); + await nextTick(); + assert.containsNone(fixture, ".o-chart-external-link"); + } + ); + + QUnit.test( + "icon external link isn't on the chart in dashboard mode", + async function (assert) { + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: 1, + }); + const chartMenu = model.getters.getChartOdooMenu(chartId); + assert.equal(chartMenu.id, 1, "Odoo menu is linked to chart"); + model.updateMode("dashboard"); + await nextTick(); + assert.containsNone(fixture, ".o-chart-external-link", "No link icon in dashboard"); + } + ); + + QUnit.test( + "click on icon external link on chart redirect to the odoo menu", + async function (assert) { + const doActionStep = "doAction"; + mockActionService(assert, doActionStep); + + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: 2, + }); + const chartMenu = model.getters.getChartOdooMenu(chartId); + assert.equal(chartMenu.id, 2, "Odoo menu is linked to chart"); + await nextTick(); + + await clickChartExternalLink(fixture); + + assert.verifySteps([doActionStep]); + } + ); + + QUnit.test( + "Click on chart in dashboard mode redirect to the odoo menu", + async function (assert) { + const doActionStep = "doAction"; + mockActionService(assert, doActionStep); + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: 2, + }); + const chartMenu = model.getters.getChartOdooMenu(chartId); + assert.equal(chartMenu.id, 2, "Odoo menu is linked to chart"); + await nextTick(); + + await click(fixture, ".o-chart-container"); + assert.verifySteps([], "Clicking on a chart while not dashboard mode do nothing"); + + model.updateMode("dashboard"); + await nextTick(); + await click(fixture, ".o-chart-container"); + assert.verifySteps( + [doActionStep], + "Clicking on a chart while on dashboard mode redirect to the odoo menu" + ); + } + ); + + QUnit.test("can use menus xmlIds instead of menu ids", async function (assert) { + const serviceRegistry = registry.category("services"); + serviceRegistry.add("actionMain", actionService); + const fakeActionService = { + dependencies: ["actionMain"], + start(env, { actionMain }) { + return { + ...actionMain, + doAction: (actionRequest, options = {}) => { + if (actionRequest === "menuAction2") { + assert.step("doAction"); + } + return actionMain.doAction(actionRequest, options); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { + force: true, + }); + + const model = await createModelWithDataSource({ + serverData: this.serverData, + }); + const fixture = await mountSpreadsheet(model); + + createBasicChart(model, chartId); + model.dispatch("LINK_ODOO_MENU_TO_CHART", { + chartId, + odooMenuId: "documents_spreadsheet.test.menu2", + }); + await nextTick(); + + await clickChartExternalLink(fixture); + + assert.verifySteps(["doAction"]); + }); + } +); diff --git a/addons/spreadsheet/static/tests/currency/currency_test.js b/addons/spreadsheet/static/tests/currency/currency_test.js new file mode 100644 index 00000000000..377aca265ac --- /dev/null +++ b/addons/spreadsheet/static/tests/currency/currency_test.js @@ -0,0 +1,75 @@ +/** @odoo-module */ + +import { setCellContent } from "@spreadsheet/../tests/utils/commands"; +import { getCell, getCellValue } from "@spreadsheet/../tests/utils/getters"; +import { nextTick } from "@web/../tests/helpers/utils"; +import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model"; + +QUnit.module("spreadsheet > Currency"); + +QUnit.test("Basic exchange formula", async (assert) => { + const model = await createModelWithDataSource({ + mockRPC: async function (route, args) { + if (args.method === "get_rates_for_spreadsheet") { + const info = args.args[0][0]; + return [{ ...info, rate: 0.9 }]; + } + }, + }); + setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`); + assert.strictEqual(getCellValue(model, "A1"), "Loading..."); + await nextTick(); + assert.strictEqual(getCellValue(model, "A1"), 0.9); +}); + +QUnit.test("Currency rate throw with unknown currency", async (assert) => { + const model = await createModelWithDataSource({ + mockRPC: async function (route, args) { + if (args.method === "get_rates_for_spreadsheet") { + const info = args.args[0][0]; + return [{ ...info, rate: false }]; + } + }, + }); + setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("INVALID","USD")`); + await nextTick(); + assert.strictEqual(getCell(model, "A1").evaluated.error.message, "Currency rate unavailable."); +}); + +QUnit.test("Currency rates are only loaded once", async (assert) => { + const model = await createModelWithDataSource({ + mockRPC: async function (route, args) { + if (args.method === "get_rates_for_spreadsheet") { + assert.step("FETCH"); + const info = args.args[0][0]; + return [{ ...info, rate: 0.9 }]; + } + }, + }); + setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`); + await nextTick(); + assert.verifySteps(["FETCH"]); + setCellContent(model, "A2", `=ODOO.CURRENCY.RATE("EUR","USD")`); + await nextTick(); + assert.verifySteps([]); +}); + +QUnit.test("Currency rates are loaded once by clock", async (assert) => { + const model = await createModelWithDataSource({ + mockRPC: async function (route, args) { + if (args.method === "get_rates_for_spreadsheet") { + assert.step("FETCH:" + args.args[0].length); + const info1 = args.args[0][0]; + const info2 = args.args[0][1]; + return [ + { ...info1, rate: 0.9 }, + { ...info2, rate: 1 }, + ]; + } + }, + }); + setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`); + setCellContent(model, "A2", `=ODOO.CURRENCY.RATE("EUR","SEK")`); + await nextTick(); + assert.verifySteps(["FETCH:2"]); +}); diff --git a/addons/spreadsheet/static/tests/data_fetching/metadata_repository_test.js b/addons/spreadsheet/static/tests/data_fetching/metadata_repository_test.js new file mode 100644 index 00000000000..bdbd69532b8 --- /dev/null +++ b/addons/spreadsheet/static/tests/data_fetching/metadata_repository_test.js @@ -0,0 +1,169 @@ +/** @odoo-module */ + +import { nextTick } from "@web/../tests/helpers/utils"; +import { MetadataRepository } from "@spreadsheet/data_sources/metadata_repository"; + +QUnit.module("spreadsheet > Metadata Repository", {}, () => { + QUnit.test("Fields_get are only loaded once", async function (assert) { + assert.expect(6); + + const orm = { + silent: { + call: async (model, method) => { + assert.step(`${method}-${model}`); + return model; + }, + }, + }; + + const metadataRepository = new MetadataRepository(orm); + + const first = await metadataRepository.fieldsGet("A"); + const second = await metadataRepository.fieldsGet("A"); + const third = await metadataRepository.fieldsGet("B"); + + assert.strictEqual(first, "A"); + assert.strictEqual(second, "A"); + assert.strictEqual(third, "B"); + + assert.verifySteps(["fields_get-A", "fields_get-B"]); + }); + + QUnit.test("display_name_for on ir.model are only loaded once", async function (assert) { + assert.expect(6); + + const orm = { + silent: { + call: async (model, method, args) => { + if (method === "display_name_for" && model === "ir.model") { + const [modelName] = args[0]; + assert.step(`${modelName}`); + return [{ display_name: modelName, model: modelName }]; + } + }, + }, + }; + + const metadataRepository = new MetadataRepository(orm); + + const first = await metadataRepository.modelDisplayName("A"); + const second = await metadataRepository.modelDisplayName("A"); + const third = await metadataRepository.modelDisplayName("B"); + + assert.strictEqual(first, "A"); + assert.strictEqual(second, "A"); + assert.strictEqual(third, "B"); + + assert.verifySteps(["A", "B"]); + }); + + QUnit.test("Register label correctly memorize labels", function (assert) { + assert.expect(2); + + const metadataRepository = new MetadataRepository({ silent: {} }); + + assert.strictEqual(metadataRepository.getLabel("model", "field", "value"), undefined); + const label = "label"; + metadataRepository.registerLabel("model", "field", "value", label); + assert.strictEqual(metadataRepository.getLabel("model", "field", "value"), label); + }); + + QUnit.test("Name_get are collected and executed once by clock", async function (assert) { + const orm = { + silent: { + call: async (model, method, args) => { + const ids = args[0]; + assert.step(`${method}-${model}-[${ids.join(",")}]`); + return ids.map((id) => [id, id.toString()]); + }, + }, + }; + + const metadataRepository = new MetadataRepository(orm); + metadataRepository.addEventListener("labels-fetched", () => { + assert.step("labels-fetched"); + }); + + assert.throws(() => metadataRepository.getRecordDisplayName("A", 1), /Data is loading/); + assert.throws(() => metadataRepository.getRecordDisplayName("A", 1), /Data is loading/); + assert.throws(() => metadataRepository.getRecordDisplayName("A", 2), /Data is loading/); + assert.throws(() => metadataRepository.getRecordDisplayName("B", 1), /Data is loading/); + assert.verifySteps([]); + + await nextTick(); + assert.verifySteps([ + "name_get-A-[1,2]", + "name_get-B-[1]", + "labels-fetched", + "labels-fetched", + ]); + + assert.strictEqual(metadataRepository.getRecordDisplayName("A", 1), "1"); + assert.strictEqual(metadataRepository.getRecordDisplayName("A", 2), "2"); + assert.strictEqual(metadataRepository.getRecordDisplayName("B", 1), "1"); + }); + + QUnit.test("Name_get to fetch are cleared after being fetched", async function (assert) { + const orm = { + silent: { + call: async (model, method, args) => { + const ids = args[0]; + assert.step(`${method}-${model}-[${ids.join(",")}]`); + return ids.map((id) => [id, id.toString()]); + }, + }, + }; + + const metadataRepository = new MetadataRepository(orm); + + assert.throws(() => metadataRepository.getRecordDisplayName("A", 1)); + assert.verifySteps([]); + + await nextTick(); + assert.verifySteps(["name_get-A-[1]"]); + + assert.throws(() => metadataRepository.getRecordDisplayName("A", 2)); + await nextTick(); + assert.verifySteps(["name_get-A-[2]"]); + }); + + QUnit.test( + "Name_get will retry with one id by request in case of failure", + async function (assert) { + const orm = { + silent: { + call: async (model, method, args) => { + const ids = args[0]; + assert.step(`${method}-${model}-[${ids.join(",")}]`); + if (model === "B" && ids.includes(1)) { + throw new Error("Missing"); + } + return ids.map((id) => [id, id.toString()]); + }, + }, + }; + + const metadataRepository = new MetadataRepository(orm); + + assert.throws(() => metadataRepository.getRecordDisplayName("A", 1), /Data is loading/); + assert.throws(() => metadataRepository.getRecordDisplayName("B", 1), /Data is loading/); + assert.throws(() => metadataRepository.getRecordDisplayName("B", 2), /Data is loading/); + assert.verifySteps([]); + + await nextTick(); + assert.verifySteps([ + "name_get-A-[1]", + "name_get-B-[1,2]", + "name_get-B-[1]", + "name_get-B-[2]", + ]); + + assert.strictEqual(metadataRepository.getRecordDisplayName("A", 1), "1"); + assert.throws( + () => metadataRepository.getRecordDisplayName("B", 1), + /Unable to fetch the label of 1 of model B/ + ); + assert.strictEqual(metadataRepository.getRecordDisplayName("B", 2), "2"); + } + ); +}); diff --git a/addons/spreadsheet/static/tests/data_fetching/server_data_test.js b/addons/spreadsheet/static/tests/data_fetching/server_data_test.js new file mode 100644 index 00000000000..8cebe2ba008 --- /dev/null +++ b/addons/spreadsheet/static/tests/data_fetching/server_data_test.js @@ -0,0 +1,282 @@ +/** @odoo-module */ + +import { nextTick } from "@web/../tests/helpers/utils"; +import { ServerData } from "@spreadsheet/data_sources/server_data"; +import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors"; + +QUnit.module("spreadsheet server data", {}, () => { + QUnit.test("simple synchronous get", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws( + () => serverData.get("partner", "get_something", [5]), + LoadingDataError, + "it should throw when it's not loaded" + ); + await nextTick(); + assert.verifySteps(["partner/get_something", "data-fetched-notification"]); + assert.deepEqual(serverData.get("partner", "get_something", [5]), 5); + assert.verifySteps([]); + }); + + QUnit.test("synchronous get which returns an error", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + throw new Error("error while fetching data"); + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws( + () => serverData.get("partner", "get_something", [5]), + LoadingDataError, + "it should throw when it's not loaded" + ); + await nextTick(); + assert.verifySteps(["partner/get_something", "data-fetched-notification"]); + assert.throws(() => serverData.get("partner", "get_something", [5]), Error); + assert.verifySteps([]); + }); + + QUnit.test("simple async fetch", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + const result = await serverData.fetch("partner", "get_something", [5]); + assert.deepEqual(result, 5); + assert.verifySteps(["partner/get_something"]); + assert.deepEqual(await serverData.fetch("partner", "get_something", [5]), 5); + assert.verifySteps([]); + }); + + QUnit.test("async fetch which throws an error", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + throw new Error("error while fetching data"); + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.rejects(serverData.fetch("partner", "get_something", [5])); + assert.verifySteps(["partner/get_something"]); + assert.rejects(serverData.fetch("partner", "get_something", [5])); + assert.verifySteps([]); + }); + + QUnit.test("two identical concurrent async fetch", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + const [result1, result2] = await Promise.all([ + serverData.fetch("partner", "get_something", [5]), + serverData.fetch("partner", "get_something", [5]), + ]); + assert.verifySteps(["partner/get_something"], "it should have fetch the data once"); + assert.deepEqual(result1, 5); + assert.deepEqual(result2, 5); + assert.verifySteps([]); + }); + + QUnit.test("batch get with a single item", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws( + () => serverData.batch.get("partner", "get_something_in_batch", 5), + LoadingDataError, + "it should throw when it's not loaded" + ); + await nextTick(); + assert.verifySteps(["partner/get_something_in_batch", "data-fetched-notification"]); + assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 5), 5); + assert.verifySteps([]); + }); + + QUnit.test("batch get with multiple items", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws( + () => serverData.batch.get("partner", "get_something_in_batch", 5), + LoadingDataError, + "it should throw when it's not loaded" + ); + assert.throws( + () => serverData.batch.get("partner", "get_something_in_batch", 6), + LoadingDataError, + "it should throw when it's not loaded" + ); + await nextTick(); + assert.verifySteps(["partner/get_something_in_batch", "data-fetched-notification"]); + assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 5), 5); + assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 6), 6); + assert.verifySteps([]); + }); + + QUnit.test("batch get with one error", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + if (args[0].includes(5)) { + throw new Error("error while fetching data"); + } + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws( + () => serverData.batch.get("partner", "get_something_in_batch", 4), + LoadingDataError, + "it should throw when it's not loaded" + ); + assert.throws( + () => serverData.batch.get("partner", "get_something_in_batch", 5), + LoadingDataError, + "it should throw when it's not loaded" + ); + assert.throws( + () => serverData.batch.get("partner", "get_something_in_batch", 6), + LoadingDataError, + "it should throw when it's not loaded" + ); + await nextTick(); + assert.verifySteps([ + // one call for the batch + "partner/get_something_in_batch", + // retries one by one + "partner/get_something_in_batch", + "partner/get_something_in_batch", + "partner/get_something_in_batch", + "data-fetched-notification", + ]); + assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 4), 4); + assert.throws(() => serverData.batch.get("partner", "get_something_in_batch", 5), Error); + assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 6), 6); + assert.verifySteps([]); + }); + + QUnit.test("concurrently fetch then get the same request", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + const promise = serverData.fetch("partner", "get_something", [5]); + assert.throws(() => serverData.get("partner", "get_something", [5]), LoadingDataError); + const result = await promise; + await nextTick(); + assert.verifySteps( + ["partner/get_something", "partner/get_something", "data-fetched-notification"], + "it loads the data independently" + ); + assert.deepEqual(result, 5); + assert.deepEqual(serverData.get("partner", "get_something", [5]), 5); + assert.verifySteps([]); + }); + + QUnit.test("concurrently get then fetch the same request", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws(() => serverData.get("partner", "get_something", [5]), LoadingDataError); + const result = await serverData.fetch("partner", "get_something", [5]); + assert.verifySteps( + ["partner/get_something", "partner/get_something", "data-fetched-notification"], + "it should have fetch the data once" + ); + assert.deepEqual(result, 5); + assert.deepEqual(serverData.get("partner", "get_something", [5]), 5); + assert.verifySteps([]); + }); + + QUnit.test("concurrently batch get then fetch the same request", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws(() => serverData.batch.get("partner", "get_something", 5), LoadingDataError); + const result = await serverData.fetch("partner", "get_something", [5]); + await nextTick(); + assert.verifySteps( + ["partner/get_something", "partner/get_something", "data-fetched-notification"], + "it should have fetch the data once" + ); + assert.deepEqual(result, 5); + assert.deepEqual(serverData.batch.get("partner", "get_something", 5), 5); + assert.verifySteps([]); + }); + + QUnit.test("concurrently get and batch get the same request", async (assert) => { + const orm = { + call: async (model, method, args) => { + assert.step(`${model}/${method}`); + return args[0]; + }, + }; + const serverData = new ServerData(orm, { + whenDataIsFetched: () => assert.step("data-fetched-notification"), + }); + assert.throws(() => serverData.batch.get("partner", "get_something", 5), LoadingDataError); + assert.throws(() => serverData.get("partner", "get_something", [5]), LoadingDataError); + await nextTick(); + assert.verifySteps( + ["partner/get_something", "data-fetched-notification"], + "it should have fetch the data once" + ); + assert.deepEqual(serverData.get("partner", "get_something", [5]), 5); + assert.deepEqual(serverData.batch.get("partner", "get_something", 5), 5); + assert.verifySteps([]); + }); +}); diff --git a/addons/spreadsheet/static/tests/global_filters/global_filter_helper_test.js b/addons/spreadsheet/static/tests/global_filters/global_filter_helper_test.js new file mode 100644 index 00000000000..1fd147d11cc --- /dev/null +++ b/addons/spreadsheet/static/tests/global_filters/global_filter_helper_test.js @@ -0,0 +1,146 @@ +/** @odoo-module */ +import { getRelativeDateDomain } from "@spreadsheet/global_filters/helpers"; +import { + getDateDomainDurationInDays, + assertDateDomainEqual, +} from "@spreadsheet/../tests/utils/date_domain"; + +const { DateTime } = luxon; + +QUnit.module("spreadsheet > Global filters helpers", {}, () => { + QUnit.test("getRelativeDateDomain > last_week (last 7 days)", async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, 0, "last_week", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual(assert, "field", "2022-05-09", "2022-05-15", domain); + }); + + QUnit.test("getRelativeDateDomain > last_month (last 30 days)", async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, 0, "last_month", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 30); + assertDateDomainEqual(assert, "field", "2022-04-16", "2022-05-15", domain); + }); + + QUnit.test("getRelativeDateDomain > last_year (last 365 days)", async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, 0, "last_year", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 365); + assertDateDomainEqual(assert, "field", "2021-05-16", "2022-05-15", domain); + }); + + QUnit.test( + "getRelativeDateDomain > last_three_years (last 3 * 365 days)", + async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, 0, "last_three_years", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 3 * 365); + assertDateDomainEqual(assert, "field", "2019-05-17", "2022-05-15", domain); + } + ); + + QUnit.test("getRelativeDateDomain > simple date time", async function (assert) { + const now = DateTime.fromISO("2022-05-16T00:00:00+00:00", { zone: "utc" }); + const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual( + assert, + "field", + "2022-05-09 00:00:00", + "2022-05-15 23:59:59", + domain + ); + }); + + QUnit.test("getRelativeDateDomain > date time from middle of day", async function (assert) { + const now = DateTime.fromISO("2022-05-16T13:59:00+00:00", { zone: "utc" }); + const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual( + assert, + "field", + "2022-05-09 00:00:00", + "2022-05-15 23:59:59", + domain + ); + }); + + QUnit.test("getRelativeDateDomain > date time with timezone", async function (assert) { + const now = DateTime.fromISO("2022-05-16T12:00:00+02:00", { zone: "UTC+2" }); + const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual( + assert, + "field", + "2022-05-08 22:00:00", + "2022-05-15 21:59:59", + domain + ); + }); + + QUnit.test( + "getRelativeDateDomain > date time with timezone on different day than UTC", + async function (assert) { + const now = DateTime.fromISO("2022-05-16T01:00:00+02:00", { zone: "UTC+2" }); + const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual( + assert, + "field", + "2022-05-08 22:00:00", + "2022-05-15 21:59:59", + domain + ); + } + ); + + QUnit.test( + "getRelativeDateDomain > with offset > last_week (last 7 days)", + async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, -1, "last_week", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual(assert, "field", "2022-05-02", "2022-05-08", domain); + } + ); + + QUnit.test("getRelativeDateDomain > with offset (last 30 days)", async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, -2, "last_month", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 30); + assertDateDomainEqual(assert, "field", "2022-02-15", "2022-03-16", domain); + }); + + QUnit.test( + "getRelativeDateDomain > with offset > last_year (last 365 days)", + async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, 1, "last_year", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 365); + assertDateDomainEqual(assert, "field", "2022-05-16", "2023-05-15", domain); + } + ); + + QUnit.test( + "getRelativeDateDomain > with offset > last_three_years (last 3 * 365 days)", + async function (assert) { + const now = DateTime.fromISO("2022-05-16"); + const domain = getRelativeDateDomain(now, -1, "last_three_years", "field", "date"); + assert.equal(getDateDomainDurationInDays(domain), 3 * 365); + assertDateDomainEqual(assert, "field", "2016-05-17", "2019-05-16", domain); + } + ); + + QUnit.test("getRelativeDateDomain > with offset > simple date time", async function (assert) { + const now = DateTime.fromISO("2022-05-16T00:00:00+00:00", { zone: "utc" }); + const domain = getRelativeDateDomain(now, -1, "last_week", "field", "datetime"); + assert.equal(getDateDomainDurationInDays(domain), 7); + assertDateDomainEqual( + assert, + "field", + "2022-05-02 00:00:00", + "2022-05-08 23:59:59", + domain + ); + }); +}); diff --git a/addons/spreadsheet/static/tests/global_filters/global_filters_chart_test.js b/addons/spreadsheet/static/tests/global_filters/global_filters_chart_test.js new file mode 100644 index 00000000000..f928b92a5b7 --- /dev/null +++ b/addons/spreadsheet/static/tests/global_filters/global_filters_chart_test.js @@ -0,0 +1,45 @@ +/** @odoo-module */ + +import { createSpreadsheetWithGraph } from "../utils/chart"; +import { addGlobalFilter } from "../utils/commands"; + +async function addChartGlobalFilter(model) { + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; + const filter = { + id: "42", + type: "date", + label: "Last Year", + rangeType: "year", + defaultValue: { yearOffset: -1 }, + graphFields: { [chartId]: { field: "date", type: "date" } }, + }; + await addGlobalFilter(model, { filter }); +} + +QUnit.module("spreadsheet > Global filters chart", {}, () => { + QUnit.test("Can add a chart global filter", async function (assert) { + const { model } = await createSpreadsheetWithGraph(); + assert.equal(model.getters.getGlobalFilters().length, 0); + await addChartGlobalFilter(model); + assert.equal(model.getters.getGlobalFilters().length, 1); + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; + const computedDomain = model.getters.getGraphDataSource(chartId).getComputedDomain(); + assert.equal(computedDomain.length, 3); + assert.equal(computedDomain[0], "&"); + }); + + QUnit.test("Chart is loaded with computed domain", async function (assert) { + assert.expect(3); + + const { model } = await createSpreadsheetWithGraph({ + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "web_read_group") { + assert.strictEqual(kwargs.domain.length, 3); + assert.strictEqual(kwargs.domain[0], "&"); + assert.strictEqual(kwargs.domain[1][0], "date"); + } + }, + }); + await addChartGlobalFilter(model); + }); +}); diff --git a/addons/spreadsheet/static/tests/global_filters/global_filters_model_test.js b/addons/spreadsheet/static/tests/global_filters/global_filters_model_test.js new file mode 100644 index 00000000000..48df859bab9 --- /dev/null +++ b/addons/spreadsheet/static/tests/global_filters/global_filters_model_test.js @@ -0,0 +1,1509 @@ +/** @odoo-module */ + +import { nextTick, patchDate } from "@web/../tests/helpers/utils"; +import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { + createModelWithDataSource, + setupDataSourceEvaluation, +} from "@spreadsheet/../tests/utils/model"; +import { createSpreadsheetWithPivotAndList } from "@spreadsheet/../tests/utils/pivot_list"; + +import { getCellFormula, getCellValue } from "@spreadsheet/../tests/utils/getters"; +import { + addGlobalFilter, + editGlobalFilter, + removeGlobalFilter, + setCellContent, + setGlobalFilterValue, +} from "@spreadsheet/../tests/utils/commands"; +import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot"; +import { + createSpreadsheetWithGraph, + insertGraphInSpreadsheet, +} from "@spreadsheet/../tests/utils/chart"; +import { createSpreadsheetWithList } from "@spreadsheet/../tests/utils/list"; +import { DataSources } from "@spreadsheet/data_sources/data_sources"; +import { FILTER_DATE_OPTION } from "@spreadsheet/assets_backend/constants"; +import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants"; +import { + assertDateDomainEqual, + getDateDomainDurationInDays, +} from "@spreadsheet/../tests/utils/date_domain"; +import FiltersEvaluationPlugin from "@spreadsheet/global_filters/plugins/filters_evaluation_plugin"; +import { migrate } from "@spreadsheet/o_spreadsheet/migration"; + +const { Model, DispatchResult } = spreadsheet; + +const LAST_YEAR_FILTER = { + filter: { + id: "42", + type: "date", + label: "Last Year", + rangeType: "year", + defaultValue: { yearOffset: -1 }, + pivotFields: { 1: { field: "date", type: "date" } }, + listFields: { 1: { field: "date", type: "date" } }, + }, +}; +const LAST_YEAR_LEGACY_FILTER = { + filter: { + id: "41", + type: "date", + rangeType: "year", + label: "Legacy Last Year", + defaultValue: { year: "last_year" }, + pivotFields: { 1: { field: "date", type: "date" } }, + listFields: { 1: { field: "date", type: "date" } }, + }, +}; + +const THIS_YEAR_FILTER = { + filter: { + type: "date", + label: "This Year", + rangeType: "year", + defaultValue: { yearOffset: 0 }, + pivotFields: { 1: { field: "date", type: "date" } }, + listFields: { 1: { field: "date", type: "date" } }, + }, +}; + +QUnit.module("spreadsheet > Global filters model", {}, () => { + QUnit.test("Can add a global filter", async function (assert) { + assert.expect(4); + + const { model } = await createSpreadsheetWithPivotAndList(); + assert.equal(model.getters.getGlobalFilters().length, 0); + await addGlobalFilter(model, LAST_YEAR_FILTER); + assert.equal(model.getters.getGlobalFilters().length, 1); + const computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(computedDomain.length, 3); + assert.equal(computedDomain[0], "&"); + }); + + QUnit.test("Can delete a global filter", async function (assert) { + assert.expect(4); + + const { model } = await createSpreadsheetWithPivotAndList(); + let result = await removeGlobalFilter(model, 1); + assert.deepEqual(result.reasons, [CommandResult.FilterNotFound]); + await addGlobalFilter(model, LAST_YEAR_FILTER); + const gf = model.getters.getGlobalFilters()[0]; + result = await removeGlobalFilter(model, gf.id); + assert.deepEqual(result, DispatchResult.Success); + assert.equal(model.getters.getGlobalFilters().length, 0); + const computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(computedDomain.length, 0); + }); + + QUnit.test("Can edit a global filter", async function (assert) { + assert.expect(4); + + const { model } = await createSpreadsheetWithPivotAndList(); + const gfDef = { ...THIS_YEAR_FILTER, id: 1 }; + let result = await editGlobalFilter(model, gfDef); + assert.deepEqual(result.reasons, [CommandResult.FilterNotFound]); + await addGlobalFilter(model, LAST_YEAR_FILTER); + const gf = model.getters.getGlobalFilters()[0]; + gfDef.id = gf.id; + result = await editGlobalFilter(model, gfDef); + assert.deepEqual(result, DispatchResult.Success); + assert.equal(model.getters.getGlobalFilters().length, 1); + assert.deepEqual(model.getters.getGlobalFilters()[0].defaultValue.yearOffset, 0); + }); + + QUnit.test("A global filter with an empty field can be evaluated", async function (assert) { + const { model } = await createSpreadsheetWithPivotAndList(); + const fields = { 1: {} }; + const filter = { + ...THIS_YEAR_FILTER.filter, + pivotFields: fields, + listFields: fields, + }; + await addGlobalFilter(model, { filter }); + const domain = model.getters.getPivotComputedDomain(1); + assert.deepEqual(domain, []); + }); + + QUnit.test("Cannot have duplicated names", async function (assert) { + assert.expect(6); + + const { model } = await createSpreadsheetWithPivotAndList(); + const filter = { ...THIS_YEAR_FILTER.filter, label: "Hello" }; + await addGlobalFilter(model, { filter }); + assert.equal(model.getters.getGlobalFilters().length, 1); + + // Add filter with same name + let result = await addGlobalFilter(model, { filter: { ...filter, id: "456" } }); + assert.deepEqual(result.reasons, [CommandResult.DuplicatedFilterLabel]); + assert.equal(model.getters.getGlobalFilters().length, 1); + + // Edit to set same name as other filter + await addGlobalFilter(model, { + filter: { ...filter, id: "789", label: "Other name" }, + }); + assert.equal(model.getters.getGlobalFilters().length, 2); + result = await editGlobalFilter(model, { + id: "789", + filter: { ...filter, label: "Hello" }, + }); + assert.deepEqual(result.reasons, [CommandResult.DuplicatedFilterLabel]); + + // Edit to set same name + result = await editGlobalFilter(model, { + id: "789", + filter: { ...filter, label: "Other name" }, + }); + assert.deepEqual(result, DispatchResult.Success); + }); + + QUnit.test("Can name/rename filters with special characters", async function (assert) { + assert.expect(5); + const { model } = await createSpreadsheetWithPivot(); + const filter = Object.assign({}, THIS_YEAR_FILTER.filter, { + label: "{my} We)ird. |*ab(el []", + }); + let result = model.dispatch("ADD_GLOBAL_FILTER", { filter }); + assert.deepEqual(result, DispatchResult.Success); + assert.equal(model.getters.getGlobalFilters().length, 1); + + const filterId = model.getters.getGlobalFilters()[0].id; + + // Edit to set another name with special characters + result = model.dispatch("EDIT_PIVOT_FILTER", { + id: filterId, + filter: Object.assign({}, filter, { label: "+Othe^ we?rd name+$" }), + }); + assert.deepEqual(result, DispatchResult.Success); + + result = model.dispatch("EDIT_PIVOT_FILTER", { + id: filterId, + filter: Object.assign({}, filter, { label: "normal name" }), + }); + assert.deepEqual(result, DispatchResult.Success); + + result = model.dispatch("EDIT_PIVOT_FILTER", { + id: filterId, + filter: Object.assign({}, filter, { label: "?ack +.* to {my} We)ird. |*ab(el []" }), + }); + assert.deepEqual(result, DispatchResult.Success); + }); + + QUnit.test("Can save a value to an existing global filter", async function (assert) { + assert.expect(8); + + const { model } = await createSpreadsheetWithPivotAndList(); + await addGlobalFilter(model, { + filter: { ...LAST_YEAR_FILTER.filter, rangeType: "month" }, + }); + const gf = model.getters.getGlobalFilters()[0]; + let result = await setGlobalFilterValue(model, { + id: gf.id, + value: { period: "february" }, + }); + assert.deepEqual(result, DispatchResult.Success); + assert.equal(model.getters.getGlobalFilters().length, 1); + assert.deepEqual(model.getters.getGlobalFilterDefaultValue(gf.id).yearOffset, -1); + assert.deepEqual(model.getters.getGlobalFilterValue(gf.id).period, "february"); + result = await setGlobalFilterValue(model, { + id: gf.id, + value: { period: "march" }, + }); + assert.deepEqual(result, DispatchResult.Success); + assert.deepEqual(model.getters.getGlobalFilterValue(gf.id).period, "march"); + const computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(computedDomain.length, 3); + const listDomain = model.getters.getListComputedDomain("1"); + assert.equal(listDomain.length, 3); + }); + + QUnit.test("Domain of simple date filter", async function (assert) { + patchDate(2022, 6, 14, 0, 0, 0); + const { model } = await createSpreadsheetWithPivotAndList(); + insertGraphInSpreadsheet(model); + const chartId = model.getters.getOdooChartIds()[0]; + await addGlobalFilter(model, { + filter: { + ...LAST_YEAR_FILTER.filter, + graphFields: { [chartId]: { field: "date", type: "date" } }, + }, + }); + const pivotDomain = model.getters.getPivotComputedDomain("1"); + assert.deepEqual(pivotDomain[0], "&"); + assert.deepEqual(pivotDomain[1], ["date", ">=", "2021-01-01"]); + assert.deepEqual(pivotDomain[2], ["date", "<=", "2021-12-31"]); + const listDomain = model.getters.getListComputedDomain("1"); + assert.deepEqual(listDomain[0], "&"); + assert.deepEqual(listDomain[1], ["date", ">=", "2021-01-01"]); + assert.deepEqual(listDomain[2], ["date", "<=", "2021-12-31"]); + const graphDomain = model.getters.getGraphDataSource(chartId).getComputedDomain(); + assert.deepEqual(graphDomain[0], "&"); + assert.deepEqual(graphDomain[1], ["date", ">=", "2021-01-01"]); + assert.deepEqual(graphDomain[2], ["date", "<=", "2021-12-31"]); + }); + + QUnit.test("Domain of date filter with year offset on pivot field", async function (assert) { + patchDate(2022, 6, 14, 0, 0, 0); + const { model } = await createSpreadsheetWithPivot(); + const filter = { + ...THIS_YEAR_FILTER.filter, + pivotFields: { 1: { field: "date", type: "date", offset: 1 } }, + }; + await addGlobalFilter(model, { filter }); + const pivotDomain = model.getters.getPivotComputedDomain("1"); + assert.deepEqual(pivotDomain[0], "&"); + assert.deepEqual(pivotDomain[1], ["date", ">=", "2023-01-01"]); + assert.deepEqual(pivotDomain[2], ["date", "<=", "2023-12-31"]); + }); + + QUnit.test("Domain of date filter with quarter offset on list field", async function (assert) { + patchDate(2022, 6, 14, 0, 0, 0); + const { model } = await createSpreadsheetWithList(); + const filter = { + ...THIS_YEAR_FILTER.filter, + rangeType: "quarter", + defaultValue: { yearOffset: 0, period: "third_quarter" }, + listFields: { 1: { field: "date", type: "date", offset: 2 } }, + }; + await addGlobalFilter(model, { filter }); + const listDomain = model.getters.getListComputedDomain("1"); + assert.deepEqual(listDomain[0], "&"); + assert.deepEqual(listDomain[1], ["date", ">=", "2023-01-01"]); + assert.deepEqual(listDomain[2], ["date", "<=", "2023-03-31"]); + }); + + QUnit.test("Domain of date filter with month offset on graph field", async function (assert) { + patchDate(2022, 6, 14, 0, 0, 0); + const { model } = await createSpreadsheetWithGraph(); + const chartId = model.getters.getOdooChartIds()[0]; + const filter = { + ...THIS_YEAR_FILTER.filter, + rangeType: "month", + defaultValue: { yearOffset: 0, period: "july" }, + graphFields: { [chartId]: { field: "date", type: "date", offset: -2 } }, + }; + await addGlobalFilter(model, { filter }); + const graphDomain = model.getters.getGraphDataSource(chartId).getComputedDomain(); + assert.deepEqual(graphDomain[0], "&"); + assert.deepEqual(graphDomain[1], ["date", ">=", "2022-05-01"]); + assert.deepEqual(graphDomain[2], ["date", "<=", "2022-05-31"]); + }); + + QUnit.test("Can import/export filters", async function (assert) { + const spreadsheetData = migrate({ + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + }, + lists: { + 1: { + id: 1, + columns: ["foo", "contact_name"], + domain: [], + model: "partner", + orderBy: [], + context: {}, + }, + }, + globalFilters: [LAST_YEAR_LEGACY_FILTER.filter, LAST_YEAR_FILTER.filter], + }); + const model = await createModelWithDataSource({ spreadsheetData }); + + assert.equal(model.getters.getGlobalFilters().length, 2); + let [filter1, filter2] = model.getters.getGlobalFilters(); + assert.deepEqual(filter1.defaultValue.yearOffset, -1); + assert.deepEqual( + model.getters.getGlobalFilterValue(filter1.id).yearOffset, + -1, + "it should have applied the default value" + ); + assert.deepEqual(filter2.defaultValue.yearOffset, -1); + assert.deepEqual( + model.getters.getGlobalFilterValue(filter2.id).yearOffset, + -1, + "it should have applied the default value" + ); + + let computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(computedDomain.length, 7, "it should have updated the pivot domain"); + let listDomain = model.getters.getListComputedDomain("1"); + assert.equal(listDomain.length, 7, "it should have updated the list domain"); + + const newModel = new Model(model.exportData(), { + evalContext: model.config.evalContext, + dataSources: model.config.dataSources, + }); + + assert.equal(newModel.getters.getGlobalFilters().length, 2); + [filter1, filter2] = newModel.getters.getGlobalFilters(); + assert.deepEqual(filter1.defaultValue.yearOffset, -1); + assert.deepEqual( + newModel.getters.getGlobalFilterValue(filter1.id).yearOffset, + -1, + "it should have applied the default value" + ); + assert.deepEqual(filter2.defaultValue.yearOffset, -1); + assert.deepEqual( + newModel.getters.getGlobalFilterValue(filter2.id).yearOffset, + -1, + "it should have applied the default value" + ); + + computedDomain = newModel.getters.getPivotComputedDomain("1"); + assert.equal(computedDomain.length, 7, "it should have updated the pivot domain"); + listDomain = newModel.getters.getListComputedDomain("1"); + assert.equal(listDomain.length, 7, "it should have updated the list domain"); + }); + + QUnit.test("Relational filter with undefined value", async function (assert) { + assert.expect(1); + + const { model } = await createSpreadsheetWithPivot(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + label: "Relation Filter", + pivotFields: { + 1: { + field: "foo", + type: "char", + }, + }, + }, + }); + const [filter] = model.getters.getGlobalFilters(); + await setGlobalFilterValue(model, { + id: filter.id, + value: undefined, + }); + const computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(computedDomain.length, 0, "it should not have updated the pivot domain"); + }); + + QUnit.test("Get active filters with multiple filters", async function (assert) { + assert.expect(2); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Text Filter", + }, + }); + await addGlobalFilter(model, { + filter: { + id: "43", + type: "date", + label: "Date Filter", + rangeType: "quarter", + }, + }); + await addGlobalFilter(model, { + filter: { + id: "44", + type: "relation", + label: "Relation Filter", + }, + }); + const [text] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getActiveFilterCount(), false); + await setGlobalFilterValue(model, { + id: text.id, + value: "Hello", + }); + assert.equal(model.getters.getActiveFilterCount(), true); + }); + + QUnit.test("Get active filters with text filter enabled", async function (assert) { + assert.expect(2); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Text Filter", + }, + }); + const [filter] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getActiveFilterCount(), false); + await setGlobalFilterValue(model, { + id: filter.id, + value: "Hello", + }); + assert.equal(model.getters.getActiveFilterCount(), true); + }); + + QUnit.test("Get active filters with relation filter enabled", async function (assert) { + assert.expect(2); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + label: "Relation Filter", + }, + }); + const [filter] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getActiveFilterCount(), false); + await setGlobalFilterValue(model, { + id: filter.id, + value: [1], + }); + assert.equal(model.getters.getActiveFilterCount(), true); + }); + + QUnit.test("Get active filters with date filter enabled", async function (assert) { + assert.expect(4); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "date", + label: "Date Filter", + rangeType: "quarter", + }, + }); + const [filter] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getActiveFilterCount(), false); + await setGlobalFilterValue(model, { + id: filter.id, + value: { + yearOffset: 0, + period: undefined, + }, + }); + assert.equal(model.getters.getActiveFilterCount(), true); + await setGlobalFilterValue(model, { + id: filter.id, + value: { + period: "first_quarter", + }, + }); + assert.equal(model.getters.getActiveFilterCount(), true); + await setGlobalFilterValue(model, { + id: filter.id, + value: { + yearOffset: 0, + period: "first_quarter", + }, + }); + assert.equal(model.getters.getActiveFilterCount(), true); + }); + + QUnit.test("ODOO.FILTER.VALUE text filter", async function (assert) { + assert.expect(3); + + const model = await createModelWithDataSource(); + setCellContent(model, "A10", `=ODOO.FILTER.VALUE("Text Filter")`); + await nextTick(); + assert.equal(getCellValue(model, "A10"), "#ERROR"); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Text Filter", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), ""); + const [filter] = model.getters.getGlobalFilters(); + await setGlobalFilterValue(model, { + id: filter.id, + value: "Hello", + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), "Hello"); + }); + + QUnit.test("ODOO.FILTER.VALUE date filter", async function (assert) { + assert.expect(4); + + const model = await createModelWithDataSource(); + setCellContent(model, "A10", `=ODOO.FILTER.VALUE("Date Filter")`); + await nextTick(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "date", + label: "Date Filter", + pivotFields: { + 1: { + field: "name", + type: "date", + }, + }, + }, + }); + await nextTick(); + const [filter] = model.getters.getGlobalFilters(); + await setGlobalFilterValue(model, { + id: filter.id, + rangeType: "quarter", + value: { + yearOffset: 0, + period: "first_quarter", + }, + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), `Q1/${moment().year()}`); + await setGlobalFilterValue(model, { + id: filter.id, + rangeType: "year", + value: { + yearOffset: 0, + }, + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), `${moment().year()}`); + await setGlobalFilterValue(model, { + id: filter.id, + rangeType: "year", + value: { + period: "january", + yearOffset: 0, + }, + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), `01/${moment().year()}`); + await setGlobalFilterValue(model, { + id: filter.id, + rangeType: "year", + value: {}, + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), ``); + }); + + QUnit.test("ODOO.FILTER.VALUE relation filter", async function (assert) { + assert.expect(6); + + const orm = { + call: async (model, method, args) => { + const resId = args[0][0]; + assert.step(`name_get_${resId}`); + return resId === 1 ? [[1, "Jean-Jacques"]] : [[2, "Raoul Grosbedon"]]; + }, + }; + const model = new Model( + {}, + { + dataSources: new DataSources({ ...orm, silent: orm }), + evalContext: { env: { services: { orm } } }, + } + ); + setupDataSourceEvaluation(model); + setCellContent(model, "A10", `=ODOO.FILTER.VALUE("Relation Filter")`); + await nextTick(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + label: "Relation Filter", + modelName: "partner", + }, + }); + await nextTick(); + const [filter] = model.getters.getGlobalFilters(); + + // One record; displayNames not defined => rpc + await setGlobalFilterValue(model, { + id: filter.id, + value: [1], + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), "Jean-Jacques"); + + // Two records; displayNames defined => no rpc + await setGlobalFilterValue(model, { + id: filter.id, + value: [1, 2], + displayNames: ["Jean-Jacques", "Raoul Grosbedon"], + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), "Jean-Jacques, Raoul Grosbedon"); + + // another record; displayNames not defined => rpc + await setGlobalFilterValue(model, { + id: filter.id, + value: [2], + }); + await nextTick(); + assert.equal(getCellValue(model, "A10"), "Raoul Grosbedon"); + assert.verifySteps(["name_get_1", "name_get_2"]); + }); + + QUnit.test( + "ODOO.FILTER.VALUE formulas are updated when filter label is changed", + async function (assert) { + assert.expect(1); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "date", + label: "Cuillère", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + setCellContent( + model, + "A10", + `=ODOO.FILTER.VALUE("Cuillère") & ODOO.FILTER.VALUE( "Cuillère" )` + ); + const [filter] = model.getters.getGlobalFilters(); + const newFilter = { + type: "date", + label: "Interprete", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }; + await editGlobalFilter(model, { id: filter.id, filter: newFilter }); + assert.equal( + getCellFormula(model, "A10"), + `=ODOO.FILTER.VALUE("Interprete") & ODOO.FILTER.VALUE("Interprete")` + ); + } + ); + + QUnit.test("Exporting data does not remove value from model", async function (assert) { + assert.expect(2); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Cuillère", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + await setGlobalFilterValue(model, { + id: "42", + value: "Hello export bug", + }); + const [filter] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getGlobalFilterValue(filter.id), "Hello export bug"); + model.exportData(); + assert.equal(model.getters.getGlobalFilterValue(filter.id), "Hello export bug"); + }); + + QUnit.test("Can undo-redo a ADD_GLOBAL_FILTER", async function (assert) { + assert.expect(3); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Cuillère", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + assert.equal(model.getters.getGlobalFilters().length, 1); + model.dispatch("REQUEST_UNDO"); + assert.equal(model.getters.getGlobalFilters().length, 0); + model.dispatch("REQUEST_REDO"); + assert.equal(model.getters.getGlobalFilters().length, 1); + }); + + QUnit.test("Can undo-redo a REMOVE_GLOBAL_FILTER", async function (assert) { + assert.expect(3); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Cuillère", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + await removeGlobalFilter(model, "42"); + assert.equal(model.getters.getGlobalFilters().length, 0); + model.dispatch("REQUEST_UNDO"); + assert.equal(model.getters.getGlobalFilters().length, 1); + model.dispatch("REQUEST_REDO"); + assert.equal(model.getters.getGlobalFilters().length, 0); + }); + + QUnit.test("Can undo-redo a EDIT_GLOBAL_FILTER", async function (assert) { + assert.expect(3); + + const model = await createModelWithDataSource(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "text", + label: "Cuillère", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + await editGlobalFilter(model, { + id: "42", + filter: { + id: "42", + type: "text", + label: "Arthouuuuuur", + pivotFields: { + 1: { + field: "name", + type: "char", + }, + }, + }, + }); + assert.equal(model.getters.getGlobalFilters()[0].label, "Arthouuuuuur"); + model.dispatch("REQUEST_UNDO"); + assert.equal(model.getters.getGlobalFilters()[0].label, "Cuillère"); + model.dispatch("REQUEST_REDO"); + assert.equal(model.getters.getGlobalFilters()[0].label, "Arthouuuuuur"); + }); + + QUnit.test("pivot headers won't change when adding a filter ", async function (assert) { + assert.expect(6); + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + `, + }); + assert.equal(getCellValue(model, "A3"), "xphone"); + assert.equal(getCellValue(model, "A4"), "xpad"); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + label: "Relation Filter", + modelName: "product", + defaultValue: [41], + pivotFields: { 1: { field: "product_id", type: "many2one" } }, + }, + }); + assert.equal(getCellValue(model, "A3"), "xphone"); + assert.equal(getCellValue(model, "B3"), ""); + assert.equal(getCellValue(model, "A4"), "xpad"); + assert.equal(getCellValue(model, "B4"), "121"); + }); + + QUnit.test( + "load data only once if filter is not active (without default value)", + async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + }, + globalFilters: [ + { + id: "filterId", + type: "date", + label: "my filter", + defaultValue: {}, + rangeType: "year", + pivotFields: { + 1: { field: "date", type: "date" }, + }, + listFields: {}, + fields: { + 1: { field: "date", type: "date" }, + }, + }, + ], + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "read_group") { + assert.step(`${model}/${method}`); + } + }, + }); + assert.verifySteps([ + "partner/read_group", + "partner/read_group", + "partner/read_group", + "partner/read_group", + ]); + assert.equal(getCellValue(model, "A1"), 131); + } + ); + + QUnit.test( + "load data only once if filter is active (with a default value)", + async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + }, + globalFilters: [ + { + id: "filterId", + type: "date", + label: "my filter", + defaultValue: { yearOffset: 0 }, + rangeType: "year", + pivotFields: { + 1: { field: "date", type: "date" }, + }, + listFields: {}, + fields: { + 1: { field: "date", type: "date" }, + }, + }, + ], + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "read_group") { + assert.step(`${model}/${method}`); + } + }, + }); + assert.verifySteps(["partner/read_group"]); + assert.equal(getCellValue(model, "A1"), ""); + } + ); + + QUnit.test("don't reload data if an empty filter is added", async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + }, + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "read_group") { + assert.step(`${model}/${method}`); + } + }, + }); + assert.verifySteps([ + "partner/read_group", + "partner/read_group", + "partner/read_group", + "partner/read_group", + ]); + model.dispatch("ADD_GLOBAL_FILTER", { + filter: { + id: "42", + type: "date", + rangeType: "month", + label: "This month", + pivotFields: { + 1: { field: "date", type: "date" }, + }, + defaultValue: {}, // no default value! + }, + }); + assert.verifySteps([]); + }); + + QUnit.test( + "don't load data if a filter is added but the data is not needed", + async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + }, + { + id: "sheet2", + cells: { + A1: { content: `=ODOO.PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + }, + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "read_group") { + assert.step(`${model}/${method}`); + } + }, + }); + assert.verifySteps([]); + model.dispatch("ADD_GLOBAL_FILTER", { + filter: { + id: "42", + type: "date", + rangeType: "month", + label: "This month", + pivotFields: { + 1: { field: "date", type: "date" }, + }, + defaultValue: { period: "january" }, + }, + }); + assert.verifySteps([]); + model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "sheet1", sheetIdTo: "sheet2" }); + assert.equal(getCellValue(model, "A1"), "Loading..."); + await nextTick(); + assert.equal(getCellValue(model, "A1"), ""); + assert.verifySteps(["partner/read_group"]); + } + ); + + QUnit.test( + "don't load data if a filter is activated but the data is not needed", + async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + }, + { + id: "sheet2", + cells: { + A1: { content: `=ODOO.PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + }, + globalFilters: [ + { + id: "filterId", + type: "date", + label: "my filter", + defaultValue: {}, + rangeType: "year", + pivotFields: { + 1: { field: "date", type: "date" }, + }, + listFields: {}, + fields: { + 1: { field: "date", type: "date" }, + }, + }, + ], + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "read_group") { + assert.step(`${model}/${method}`); + } + }, + }); + assert.verifySteps([]); + model.dispatch("SET_GLOBAL_FILTER_VALUE", { + id: "filterId", + value: { yearOffset: 0 }, + }); + assert.verifySteps([]); + model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "sheet1", sheetIdTo: "sheet2" }); + assert.equal(getCellValue(model, "A1"), "Loading..."); + await nextTick(); + assert.equal(getCellValue(model, "A1"), ""); + assert.verifySteps(["partner/read_group"]); + } + ); + + QUnit.test("Default value defines value", async function (assert) { + assert.expect(1); + + const { model } = await createSpreadsheetWithPivot(); + const label = "This year"; + const defaultValue = "value"; + await addGlobalFilter(model, { + filter: { id: "42", type: "text", label, defaultValue, pivotFields: {} }, + }); + const [filter] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getGlobalFilterValue(filter.id), defaultValue); + }); + + QUnit.test("Default value defines value at model loading", async function (assert) { + assert.expect(1); + const label = "This year"; + const defaultValue = "value"; + const model = new Model({ + globalFilters: [{ type: "text", label, defaultValue, fields: {}, id: "1" }], + }); + const [filter] = model.getters.getGlobalFilters(); + assert.equal(model.getters.getGlobalFilterValue(filter.id), defaultValue); + }); + + QUnit.test("filter display value of year filter is a string", async function (assert) { + const { model } = await createSpreadsheetWithPivotAndList(); + await addGlobalFilter(model, THIS_YEAR_FILTER); + const [filter] = model.getters.getGlobalFilters(); + assert.strictEqual( + model.getters.getFilterDisplayValue(filter.label), + String(new Date().getFullYear()) + ); + }); + + QUnit.test("Export global filters for excel", async function (assert) { + const { model } = await createSpreadsheetWithPivotAndList(); + await addGlobalFilter(model, THIS_YEAR_FILTER); + const [filter] = model.getters.getGlobalFilters(); + const filterPlugin = model["handlers"].find((handler) => handler instanceof FiltersEvaluationPlugin); + const exportData = { styles: [], sheets: [] }; + filterPlugin.exportForExcel(exportData); + const filterSheet = exportData.sheets[0]; + assert.ok(filterSheet, "A sheet to export global filters was created"); + assert.equal(filterSheet.cells["A1"].content, "Filter"); + assert.equal(filterSheet.cells["A2"].content, filter.label); + assert.equal(filterSheet.cells["B1"].content, "Value"); + assert.equal( + filterSheet.cells["B2"].content, + model.getters.getFilterDisplayValue(filter.label) + ); + }); + + QUnit.test("Date filter automatic default value for years filter", async function (assert) { + const label = "This year"; + const { model } = await createSpreadsheetWithPivot(); + await addGlobalFilter(model, { + filter: { + id: "1", + type: "date", + label, + pivotFields: {}, + defaultsToCurrentPeriod: true, + rangeType: "year", + }, + }); + assert.deepEqual(model.getters.getGlobalFilterValue("1"), { + yearOffset: 0, + }); + }); + + QUnit.test("Date filter automatic default value for month filter", async function (assert) { + patchDate(2022, 2, 10, 0, 0, 0); + const label = "This month"; + const { model } = await createSpreadsheetWithPivot(); + await addGlobalFilter(model, { + filter: { + id: "1", + type: "date", + label, + pivotFields: {}, + defaultsToCurrentPeriod: true, + rangeType: "month", + }, + }); + assert.deepEqual(model.getters.getGlobalFilterValue("1"), { + yearOffset: 0, + period: "march", + }); + }); + + QUnit.test("Date filter automatic default value for quarter filter", async function (assert) { + patchDate(2022, 11, 10, 0, 0, 0); + const label = "This quarter"; + const { model } = await createSpreadsheetWithPivot(); + await addGlobalFilter(model, { + filter: { + id: "1", + type: "date", + label, + pivotFields: {}, + defaultsToCurrentPeriod: true, + rangeType: "quarter", + }, + }); + assert.deepEqual(model.getters.getGlobalFilterValue("1"), { + yearOffset: 0, + period: FILTER_DATE_OPTION.quarter[3], + }); + }); + + QUnit.test("Date filter automatic default value at model loading", async function (assert) { + const label = "This year"; + const model = new Model({ + globalFilters: [ + { + type: "date", + label, + defaultsToCurrentPeriod: true, + defaultValue: {}, + fields: {}, + id: "1", + rangeType: "year", + }, + ], + }); + assert.deepEqual(model.getters.getGlobalFilterValue("1"), { + yearOffset: 0, + }); + }); + + QUnit.test("Relative date filter at model loading", async function (assert) { + const label = "Last Month"; + const defaultValue = RELATIVE_DATE_RANGE_TYPES[1].type; + const model = new Model({ + globalFilters: [ + { type: "date", rangeType: "relative", label, defaultValue, fields: {}, id: "1" }, + ], + }); + assert.equal(model.getters.getGlobalFilterValue("1"), defaultValue); + }); + + QUnit.test("Relative date filter display value", async function (assert) { + patchDate(2022, 4, 16, 0, 0, 0); + const label = "Last Month"; + const defaultValue = RELATIVE_DATE_RANGE_TYPES[1].type; + const { model } = await createSpreadsheetWithPivot(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "date", + label, + defaultValue, + pivotFields: {}, + rangeType: "relative", + }, + }); + assert.equal( + model.getters.getFilterDisplayValue(label), + RELATIVE_DATE_RANGE_TYPES[1].description + ); + }); + + QUnit.test("Relative date filter domain value", async function (assert) { + patchDate(2022, 4, 16, 0, 0, 0); + const label = "Last Month"; + const { model } = await createSpreadsheetWithPivot(); + const filter = { + id: "42", + type: "date", + label, + defaultValue: "last_week", + pivotFields: { 1: { field: "date", type: "date" } }, + rangeType: "relative", + }; + await addGlobalFilter(model, { filter }); + let computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 7); + assertDateDomainEqual(assert, "date", "2022-05-09", "2022-05-15", computedDomain); + + await setGlobalFilterValue(model, { id: "42", value: "last_month" }); + computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 30); + assertDateDomainEqual(assert, "date", "2022-04-16", "2022-05-15", computedDomain); + + await setGlobalFilterValue(model, { id: "42", value: "last_year" }); + computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 365); + assertDateDomainEqual(assert, "date", "2021-05-16", "2022-05-15", computedDomain); + + await setGlobalFilterValue(model, { id: "42", value: "last_three_years" }); + computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 3 * 365); + assertDateDomainEqual(assert, "date", "2019-05-17", "2022-05-15", computedDomain); + }); + + QUnit.test("Relative date filter with offset domain value", async function (assert) { + patchDate(2022, 4, 16, 0, 0, 0); + const label = "Last Month"; + const { model } = await createSpreadsheetWithPivot(); + const filter = { + id: "42", + type: "date", + label, + defaultValue: "last_week", + pivotFields: { 1: { field: "date", type: "date", offset: -1 } }, + rangeType: "relative", + }; + await addGlobalFilter(model, { filter }); + let computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 7); + assertDateDomainEqual(assert, "date", "2022-05-02", "2022-05-08", computedDomain); + + await setGlobalFilterValue(model, { id: "42", value: "last_month" }); + computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 30); + assertDateDomainEqual(assert, "date", "2022-03-17", "2022-04-15", computedDomain); + + await setGlobalFilterValue(model, { id: "42", value: "last_year" }); + computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 365); + assertDateDomainEqual(assert, "date", "2020-05-16", "2021-05-15", computedDomain); + + await setGlobalFilterValue(model, { id: "42", value: "last_three_years" }); + computedDomain = model.getters.getPivotComputedDomain("1"); + assert.equal(getDateDomainDurationInDays(computedDomain), 3 * 365); + assertDateDomainEqual(assert, "date", "2016-05-17", "2019-05-16", computedDomain); + }); + + QUnit.test( + "Can set a value to a relation filter from the SET_MANY_GLOBAL_FILTER_VALUE command", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + `, + }); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + pivotFields: { 1: { field: "product_id", type: "many2one" } }, + }, + }); + model.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { + filters: [{ filterId: "42", value: [31] }], + }); + assert.deepEqual(model.getters.getGlobalFilterValue("42"), [31]); + model.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { filters: [{ filterId: "42" }] }); + assert.deepEqual(model.getters.getGlobalFilterValue("42"), []); + } + ); + + QUnit.test( + "Can set a value to a date filter from the SET_MANY_GLOBAL_FILTER_VALUE command", + async function (assert) { + patchDate(2022, 6, 14, 0, 0, 0); + const { model } = await createSpreadsheetWithPivot(); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "date", + pivotFields: { 1: { field: "date", type: "date" } }, + rangeType: "month", + }, + }); + const newValue = { yearOffset: -6, period: "may" }; + model.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { + filters: [{ filterId: "42", value: newValue }], + }); + assert.deepEqual(model.getters.getGlobalFilterValue("42"), newValue); + model.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { filters: [{ filterId: "42" }] }); + assert.deepEqual(model.getters.getGlobalFilterValue("42"), { yearOffset: undefined }); + } + ); + + QUnit.test( + "getFiltersMatchingPivot return correctly matching filter according to cell formula", + async function (assert) { + patchDate(2022, 6, 14, 0, 0, 0); + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + + `, + }); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + label: "relational filter", + pivotFields: { 1: { field: "product_id", type: "many2one" } }, + }, + }); + await addGlobalFilter(model, { + filter: { + id: "43", + type: "date", + label: "date filter 1", + pivotFields: { 1: { field: "date", type: "date" } }, + rangeType: "month", + }, + }); + await addGlobalFilter(model, { + filter: { + id: "44", + type: "date", + label: "date filter 2", + pivotFields: { 1: { field: "date", type: "date" } }, + rangeType: "year", + }, + }); + const relationalFilters1 = model.getters.getFiltersMatchingPivot( + '=ODOO.PIVOT.HEADER(1,"product_id",37)' + ); + assert.deepEqual(relationalFilters1, [{ filterId: "42", value: [37] }]); + const relationalFilters2 = model.getters.getFiltersMatchingPivot( + '=ODOO.PIVOT.HEADER(1,"product_id","41")' + ); + assert.deepEqual(relationalFilters2, [{ filterId: "42", value: [41] }]); + const dateFilters1 = model.getters.getFiltersMatchingPivot( + '=ODOO.PIVOT.HEADER(1,"date:month","08/2016")' + ); + assert.deepEqual(dateFilters1, [ + { filterId: "43", value: { yearOffset: -6, period: "august" } }, + ]); + const dateFilters2 = model.getters.getFiltersMatchingPivot( + '=ODOO.PIVOT.HEADER(1,"date:year","2016")' + ); + assert.deepEqual(dateFilters2, [{ filterId: "44", value: { yearOffset: -6 } }]); + } + ); + + QUnit.test( + "getFiltersMatchingPivot return an empty array if there is no pivot formula", + async function (assert) { + const model = await createModelWithDataSource(); + const result = model.getters.getFiltersMatchingPivot("=1"); + assert.deepEqual(result, []); + } + ); + + QUnit.test( + "getFiltersMatchingPivot return correctly matching filter according to cell formula with multi-levels grouping", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + + `, + }); + await addGlobalFilter(model, { + filter: { + id: "42", + type: "relation", + label: "relational filter", + pivotFields: { 1: { field: "product_id", type: "many2one" } }, + }, + }); + await addGlobalFilter(model, { + filter: { + id: "43", + type: "date", + label: "date filter 1", + pivotFields: { 1: { field: "date", type: "date" } }, + rangeType: "month", + }, + }); + const filters = model.getters.getFiltersMatchingPivot( + '=ODOO.PIVOT.HEADER(1,"date:month","08/2016","product_id","41")' + ); + assert.deepEqual(filters, [{ filterId: "42", value: [41] }]); + } + ); +}); diff --git a/addons/spreadsheet/static/tests/links/menu_data_utils.js b/addons/spreadsheet/static/tests/links/menu_data_utils.js new file mode 100644 index 00000000000..846bd8b8487 --- /dev/null +++ b/addons/spreadsheet/static/tests/links/menu_data_utils.js @@ -0,0 +1,62 @@ +/** @odoo-module */ + +import { getBasicData } from "@spreadsheet/../tests/utils/data"; + + +export function getMenuServerData() { + const serverData = {}; + serverData.menus = { + root: { id: "root", children: [1, 2], name: "root", appID: "root" }, + 1: { + id: 1, + children: [], + name: "menu with xmlid", + appID: 1, + xmlid: "test_menu", + actionID: "action1", + }, + 2: { id: 2, children: [], name: "menu without xmlid", appID: 2 }, + }; + serverData.actions = { + action1: { + id: 99, + xml_id: "action1", + name: "action1", + res_model: "ir.ui.menu", + type: "ir.actions.act_window", + views: [[false, "list"]], + }, + }; + serverData.views = {}; + serverData.views["ir.ui.menu,false,list"] = ``; + serverData.views["ir.ui.menu,false,search"] = ``; + serverData.models = { + ...getBasicData(), + "ir.ui.menu": { + fields: { + name: { string: "Name", type: "char" }, + action: { string: "Action", type: "char" }, + groups_id: { string: "Groups", type: "many2many", relation: "res.group" }, + }, + records: [ + { id: 1, name: "menu with xmlid", action: "action1", groups_id: [10] }, + { id: 2, name: "menu without xmlid", action: "action2", groups_id: [10] }, + ], + }, + "res.users": { + fields: { + name: { string: "Name", type: "char" }, + groups_id: { string: "Groups", type: "many2many", relation: "res.group" }, + }, + records: [ + { id: 1, name: "Raoul", groups_id: [10] }, + { id: 2, name: "Joseph", groups_id: [] }, + ], + }, + "res.group": { + fields: { name: { string: "Name", type: "char" } }, + records: [{ id: 10, name: "test group" }], + }, + }; + return serverData; +} diff --git a/addons/spreadsheet/static/tests/links/model/link_menu_cells_test.js b/addons/spreadsheet/static/tests/links/model/link_menu_cells_test.js new file mode 100644 index 00000000000..7f2677652f2 --- /dev/null +++ b/addons/spreadsheet/static/tests/links/model/link_menu_cells_test.js @@ -0,0 +1,115 @@ +/** @odoo-module */ +import { spreadsheetLinkMenuCellService } from "@spreadsheet/ir_ui_menu/index"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { registry } from "@web/core/registry"; +import { actionService } from "@web/webclient/actions/action_service"; +import { ormService } from "@web/core/orm_service"; +import { viewService } from "@web/views/view_service"; +import { menuService } from "@web/webclient/menus/menu_service"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { setCellContent } from "@spreadsheet/../tests/utils/commands"; +import { getCell } from "@spreadsheet/../tests/utils/getters"; +import { getMenuServerData } from "../menu_data_utils"; + +const { Model } = spreadsheet; + +function beforeEach() { + registry + .category("services") + .add("menu", menuService) + .add("action", actionService) + .add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService); + registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class + registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class +} + +QUnit.module("spreadsheet > menu link cells", { beforeEach }, () => { + QUnit.test("ir.menu linked based on xml id", async function (assert) { + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const model = new Model({}, { evalContext: { env } }); + setCellContent(model, "A1", "[label](odoo://ir_menu_xml_id/test_menu)"); + const cell = getCell(model, "A1"); + assert.equal(cell.evaluated.value, "label", "The value should be the menu name"); + assert.equal( + cell.content, + "[label](odoo://ir_menu_xml_id/test_menu)", + "The content should be the complete markdown link" + ); + assert.equal(cell.link.label, "label", "The link label should be the menu name"); + assert.equal( + cell.link.url, + "odoo://ir_menu_xml_id/test_menu", + "The link url should reference the correct menu" + ); + }); + + QUnit.test("ir.menu linked based on record id", async function (assert) { + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const model = new Model({}, { evalContext: { env } }); + setCellContent(model, "A1", "[label](odoo://ir_menu_id/2)"); + const cell = getCell(model, "A1"); + assert.equal(cell.evaluated.value, "label", "The value should be the menu name"); + assert.equal( + cell.content, + "[label](odoo://ir_menu_id/2)", + "The content should be the complete markdown link" + ); + assert.equal(cell.link.label, "label", "The link label should be the menu name"); + assert.equal( + cell.link.url, + "odoo://ir_menu_id/2", + "The link url should reference the correct menu" + ); + }); + + QUnit.test("ir.menu linked based on xml id which does not exists", async function (assert) { + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const model = new Model({}, { evalContext: { env } }); + setCellContent(model, "A1", "[label](odoo://ir_menu_xml_id/does_not_exists)"); + const cell = getCell(model, "A1"); + assert.equal(cell.content, "[label](odoo://ir_menu_xml_id/does_not_exists)"); + assert.equal(cell.evaluated.value, "#BAD_EXPR"); + }); + + QUnit.test("ir.menu linked based on record id which does not exists", async function (assert) { + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const model = new Model({}, { evalContext: { env } }); + setCellContent(model, "A1", "[label](odoo://ir_menu_id/9999)"); + const cell = getCell(model, "A1"); + assert.equal(cell.content, "[label](odoo://ir_menu_id/9999)"); + assert.equal(cell.evaluated.value, "#BAD_EXPR"); + }); + + QUnit.test("Odoo link cells can be imported/exported", async function (assert) { + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const model = new Model({}, { evalContext: { env } }); + setCellContent(model, "A1", "[label](odoo://ir_menu_id/2)"); + let cell = getCell(model, "A1"); + assert.equal(cell.evaluated.value, "label", "The value should be the menu name"); + assert.equal( + cell.content, + "[label](odoo://ir_menu_id/2)", + "The content should be the complete markdown link" + ); + assert.equal(cell.link.label, "label", "The link label should be the menu name"); + assert.equal( + cell.link.url, + "odoo://ir_menu_id/2", + "The link url should reference the correct menu" + ); + const model2 = new Model(model.exportData(), { evalContext: { env } }); + cell = getCell(model2, "A1"); + assert.equal(cell.evaluated.value, "label", "The value should be the menu name"); + assert.equal( + cell.content, + "[label](odoo://ir_menu_id/2)", + "The content should be the complete markdown link" + ); + assert.equal(cell.link.label, "label", "The link label should be the menu name"); + assert.equal( + cell.link.url, + "odoo://ir_menu_id/2", + "The link url should reference the correct menu" + ); + }); +}); diff --git a/addons/spreadsheet/static/tests/lists/list_plugin_test.js b/addons/spreadsheet/static/tests/lists/list_plugin_test.js new file mode 100644 index 00000000000..b3c4485d9f4 --- /dev/null +++ b/addons/spreadsheet/static/tests/lists/list_plugin_test.js @@ -0,0 +1,448 @@ +/** @odoo-module */ + +import { session } from "@web/session"; +import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; + +import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason"; +import { createModelWithDataSource, waitForDataSourcesLoaded } from "../utils/model"; +import { selectCell, setCellContent } from "../utils/commands"; +import { getCell, getCellContent, getCellFormula, getCells, getCellValue } from "../utils/getters"; +import { createSpreadsheetWithList } from "../utils/list"; +import { registry } from "@web/core/registry"; + +QUnit.module("spreadsheet > list plugin", {}, () => { + QUnit.test("List export", async (assert) => { + const { model } = await createSpreadsheetWithList(); + const total = 4 + 10 * 4; // 4 Headers + 10 lines + assert.strictEqual(Object.values(getCells(model)).length, total); + assert.strictEqual(getCellFormula(model, "A1"), `=ODOO.LIST.HEADER(1,"foo")`); + assert.strictEqual(getCellFormula(model, "B1"), `=ODOO.LIST.HEADER(1,"bar")`); + assert.strictEqual(getCellFormula(model, "C1"), `=ODOO.LIST.HEADER(1,"date")`); + assert.strictEqual(getCellFormula(model, "D1"), `=ODOO.LIST.HEADER(1,"product_id")`); + assert.strictEqual(getCellFormula(model, "A2"), `=ODOO.LIST(1,1,"foo")`); + assert.strictEqual(getCellFormula(model, "B2"), `=ODOO.LIST(1,1,"bar")`); + assert.strictEqual(getCellFormula(model, "C2"), `=ODOO.LIST(1,1,"date")`); + assert.strictEqual(getCellFormula(model, "D2"), `=ODOO.LIST(1,1,"product_id")`); + assert.strictEqual(getCellFormula(model, "A3"), `=ODOO.LIST(1,2,"foo")`); + assert.strictEqual(getCellFormula(model, "A11"), `=ODOO.LIST(1,10,"foo")`); + assert.strictEqual(getCellFormula(model, "A12"), ""); + }); + + QUnit.test("Return display name of selection field", async (assert) => { + const { model } = await createSpreadsheetWithList({ + model: "documents.document", + columns: ["handler"], + }); + assert.strictEqual(getCellValue(model, "A2", "Spreadsheet")); + }); + + QUnit.test("Return name_get of many2one field", async (assert) => { + const { model } = await createSpreadsheetWithList({ columns: ["product_id"] }); + assert.strictEqual(getCellValue(model, "A2"), "xphone"); + }); + + QUnit.test("Boolean fields are correctly formatted", async (assert) => { + const { model } = await createSpreadsheetWithList({ columns: ["bar"] }); + assert.strictEqual(getCellValue(model, "A2"), "TRUE"); + assert.strictEqual(getCellValue(model, "A5"), "FALSE"); + }); + + QUnit.test("Can display a field which is not in the columns", async function (assert) { + const { model } = await createSpreadsheetWithList(); + setCellContent(model, "A1", `=ODOO.LIST(1,1,"active")`); + assert.strictEqual(getCellValue(model, "A1"), "Loading..."); + await nextTick(); // Await for batching collection of missing fields + assert.strictEqual(getCellValue(model, "A1"), true); + }); + + QUnit.test("Can remove a list with undo after editing a cell", async function (assert) { + const { model } = await createSpreadsheetWithList(); + assert.ok(getCellContent(model, "B1").startsWith("=ODOO.LIST.HEADER")); + setCellContent(model, "G10", "should be undoable"); + model.dispatch("REQUEST_UNDO"); + assert.equal(getCellContent(model, "G10"), ""); + model.dispatch("REQUEST_UNDO"); + assert.equal(getCellContent(model, "B1"), ""); + assert.equal(model.getters.getListIds().length, 0); + }); + + QUnit.test("List formulas are correctly formatted at evaluation", async function (assert) { + const { model } = await createSpreadsheetWithList({ + columns: ["foo", "probability", "bar", "date", "create_date", "product_id", "pognon"], + linesNumber: 2, + }); + await waitForDataSourcesLoaded(model); + assert.strictEqual(getCell(model, "A2").format, undefined); + assert.strictEqual(getCell(model, "B2").format, undefined); + assert.strictEqual(getCell(model, "C2").format, undefined); + assert.strictEqual(getCell(model, "D2").format, undefined); + assert.strictEqual(getCell(model, "E2").format, undefined); + assert.strictEqual(getCell(model, "F2").format, undefined); + assert.strictEqual(getCell(model, "G2").format, undefined); + assert.strictEqual(getCell(model, "G3").format, undefined); + + assert.strictEqual(getCell(model, "A2").evaluated.format, "0"); + assert.strictEqual(getCell(model, "B2").evaluated.format, "#,##0.00"); + assert.strictEqual(getCell(model, "C2").evaluated.format, undefined); + assert.strictEqual(getCell(model, "D2").evaluated.format, "m/d/yyyy"); + assert.strictEqual(getCell(model, "E2").evaluated.format, "m/d/yyyy hh:mm:ss"); + assert.strictEqual(getCell(model, "F2").evaluated.format, undefined); + assert.strictEqual(getCell(model, "G2").evaluated.format, "#,##0.00[$€]"); + assert.strictEqual(getCell(model, "G3").evaluated.format, "[$$]#,##0.00"); + }); + + QUnit.test("can select a List from cell formula", async function (assert) { + const { model } = await createSpreadsheetWithList(); + const sheetId = model.getters.getActiveSheetId(); + const listId = model.getters.getListIdFromPosition(sheetId, 0, 0); + model.dispatch("SELECT_ODOO_LIST", { listId }); + const selectedListId = model.getters.getSelectedListId(); + assert.strictEqual(selectedListId, "1"); + }); + + QUnit.test( + "can select a List from cell formula with '-' before the formula", + async function (assert) { + const { model } = await createSpreadsheetWithList(); + setCellContent(model, "A1", `=-ODOO.LIST("1","1","foo")`); + const sheetId = model.getters.getActiveSheetId(); + const listId = model.getters.getListIdFromPosition(sheetId, 0, 0); + model.dispatch("SELECT_ODOO_LIST", { listId }); + const selectedListId = model.getters.getSelectedListId(); + assert.strictEqual(selectedListId, "1"); + } + ); + QUnit.test( + "can select a List from cell formula with other numerical values", + async function (assert) { + const { model } = await createSpreadsheetWithList(); + setCellContent(model, "A1", `=3*ODOO.LIST("1","1","foo")`); + const sheetId = model.getters.getActiveSheetId(); + const listId = model.getters.getListIdFromPosition(sheetId, 0, 0); + model.dispatch("SELECT_ODOO_LIST", { listId }); + const selectedListId = model.getters.getSelectedListId(); + assert.strictEqual(selectedListId, "1"); + } + ); + + QUnit.test("can select a List from cell formula within a formula", async function (assert) { + const { model } = await createSpreadsheetWithList(); + setCellContent(model, "A1", `=SUM(ODOO.LIST("1","1","foo"),1)`); + const sheetId = model.getters.getActiveSheetId(); + const listId = model.getters.getListIdFromPosition(sheetId, 0, 0); + model.dispatch("SELECT_ODOO_LIST", { listId }); + const selectedListId = model.getters.getSelectedListId(); + assert.strictEqual(selectedListId, "1"); + }); + + QUnit.test( + "can select a List from cell formula where the id is a reference", + async function (assert) { + const { model } = await createSpreadsheetWithList(); + setCellContent(model, "A1", `=ODOO.LIST(G10,"1","foo")`); + setCellContent(model, "G10", "1"); + const sheetId = model.getters.getActiveSheetId(); + const listId = model.getters.getListIdFromPosition(sheetId, 0, 0); + model.dispatch("SELECT_ODOO_LIST", { listId }); + const selectedListId = model.getters.getSelectedListId(); + assert.strictEqual(selectedListId, "1"); + } + ); + + QUnit.test("Referencing non-existing fields does not crash", async function (assert) { + assert.expect(4); + const forbiddenFieldName = "product_id"; + let spreadsheetLoaded = false; + const { model } = await createSpreadsheetWithList({ + columns: ["bar", "product_id"], + mockRPC: async function (route, args, performRPC) { + if ( + spreadsheetLoaded && + args.method === "search_read" && + args.model === "partner" && + args.kwargs.fields && + args.kwargs.fields.includes(forbiddenFieldName) + ) { + // We should not go through this condition if the forbidden fields is properly filtered + assert.ok(false, `${forbiddenFieldName} should have been ignored`); + } + if (this) { + // @ts-ignore + return this._super.apply(this, arguments); + } + }, + }); + const listId = model.getters.getListIds()[0]; + // remove forbidden field from the fields of the list. + delete model.getters.getListDataSource(listId).getFields()[forbiddenFieldName]; + spreadsheetLoaded = true; + model.dispatch("REFRESH_ALL_DATA_SOURCES"); + await nextTick(); + setCellContent(model, "A1", `=ODOO.LIST.HEADER("1", "${forbiddenFieldName}")`); + setCellContent(model, "A2", `=ODOO.LIST("1","1","${forbiddenFieldName}")`); + + assert.equal( + model.getters.getListDataSource(listId).getFields()[forbiddenFieldName], + undefined + ); + assert.strictEqual(getCellValue(model, "A1"), forbiddenFieldName); + const A2 = getCell(model, "A2"); + assert.equal(A2.evaluated.type, "error"); + assert.equal( + A2.evaluated.error.message, + `The field ${forbiddenFieldName} does not exist or you do not have access to that field` + ); + }); + + QUnit.test("don't fetch list data if no formula use it", async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + }, + { + id: "sheet2", + cells: { + A1: { content: `=ODOO.LIST("1", "1", "foo")` }, + }, + }, + ], + lists: { + 1: { + id: 1, + columns: ["foo", "contact_name"], + domain: [], + model: "partner", + orderBy: [], + context: {}, + }, + }, + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (_, { model, method }) { + if (!["partner", "ir.model"].includes(model)) { + return; + } + assert.step(`${model}/${method}`); + }, + }); + assert.verifySteps([]); + model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "sheet1", sheetIdTo: "sheet2" }); + assert.equal(getCellValue(model, "A1"), "Loading..."); + await nextTick(); + assert.equal(getCellValue(model, "A1"), 12); + assert.verifySteps(["partner/fields_get", "partner/search_read"]); + }); + + QUnit.test("user context is combined with list context to fetch data", async function (assert) { + const context = { + allowed_company_ids: [15], + default_stage_id: 5, + search_default_stage_id: 5, + tz: "bx", + lang: "FR", + uid: 4, + }; + const testSession = { + uid: 4, + user_companies: { + allowed_companies: { + 15: { id: 15, name: "Hermit" }, + 16: { id: 16, name: "Craft" }, + }, + current_company: 15, + }, + user_context: context, + }; + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.LIST("1", "1", "name")` }, + }, + }, + ], + lists: { + 1: { + id: 1, + columns: ["name", "contact_name"], + domain: [], + model: "partner", + orderBy: [], + context: { + allowed_company_ids: [16], + default_stage_id: 9, + search_default_stage_id: 90, + tz: "nz", + lang: "EN", + uid: 40, + }, + }, + }, + }; + const expectedFetchContext = { + allowed_company_ids: [15], + default_stage_id: 9, + search_default_stage_id: 90, + tz: "bx", + lang: "FR", + uid: 4, + }; + patchWithCleanup(session, testSession); + await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model !== "partner") { + return; + } + switch (method) { + case "search_read": + assert.step("search_read"); + assert.deepEqual( + kwargs.context, + expectedFetchContext, + "search_read context" + ); + break; + } + }, + }); + assert.verifySteps(["search_read"]); + }); + + QUnit.test("rename list with empty name is refused", async (assert) => { + const { model } = await createSpreadsheetWithList(); + const result = model.dispatch("RENAME_ODOO_LIST", { + listId: "1", + name: "", + }); + assert.deepEqual(result.reasons, [CommandResult.EmptyName]); + }); + + QUnit.test("rename list with incorrect id is refused", async (assert) => { + const { model } = await createSpreadsheetWithList(); + const result = model.dispatch("RENAME_ODOO_LIST", { + listId: "invalid", + name: "name", + }); + assert.deepEqual(result.reasons, [CommandResult.ListIdNotFound]); + }); + + QUnit.test("Undo/Redo for RENAME_ODOO_LIST", async function (assert) { + assert.expect(4); + const { model } = await createSpreadsheetWithList(); + assert.equal(model.getters.getListName("1"), "List"); + model.dispatch("RENAME_ODOO_LIST", { listId: "1", name: "test" }); + assert.equal(model.getters.getListName("1"), "test"); + model.dispatch("REQUEST_UNDO"); + assert.equal(model.getters.getListName("1"), "List"); + model.dispatch("REQUEST_REDO"); + assert.equal(model.getters.getListName("1"), "test"); + }); + + QUnit.test("Can delete list", async function (assert) { + const { model } = await createSpreadsheetWithList(); + model.dispatch("REMOVE_ODOO_LIST", { listId: "1" }); + assert.strictEqual(model.getters.getListIds().length, 0); + const B4 = getCell(model, "B4"); + assert.equal(B4.evaluated.error.message, `There is no list with id "1"`); + assert.equal(B4.evaluated.value, `#ERROR`); + }); + + QUnit.test("Can undo/redo a delete list", async function (assert) { + const { model } = await createSpreadsheetWithList(); + const value = getCell(model, "B4").evaluated.value; + model.dispatch("REMOVE_ODOO_LIST", { listId: "1" }); + model.dispatch("REQUEST_UNDO"); + assert.strictEqual(model.getters.getListIds().length, 1); + let B4 = getCell(model, "B4"); + assert.equal(B4.evaluated.error, undefined); + assert.equal(B4.evaluated.value, value); + model.dispatch("REQUEST_REDO"); + assert.strictEqual(model.getters.getListIds().length, 0); + B4 = getCell(model, "B4"); + assert.equal(B4.evaluated.error.message, `There is no list with id "1"`); + assert.equal(B4.evaluated.value, `#ERROR`); + }); + + QUnit.test("can edit list domain", async (assert) => { + const { model } = await createSpreadsheetWithList(); + const [listId] = model.getters.getListIds(); + assert.deepEqual(model.getters.getListDefinition(listId).domain, []); + assert.strictEqual(getCellValue(model, "B2"), "TRUE"); + model.dispatch("UPDATE_ODOO_LIST_DOMAIN", { + listId, + domain: [["foo", "in", [55]]], + }); + assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]); + await nextTick(); + assert.strictEqual(getCellValue(model, "B2"), ""); + model.dispatch("REQUEST_UNDO"); + await nextTick(); + assert.deepEqual(model.getters.getListDefinition(listId).domain, []); + await nextTick(); + assert.strictEqual(getCellValue(model, "B2"), "TRUE"); + model.dispatch("REQUEST_REDO"); + assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]); + await nextTick(); + assert.strictEqual(getCellValue(model, "B2"), ""); + }); + + QUnit.test("edited domain is exported", async (assert) => { + const { model } = await createSpreadsheetWithList(); + const [listId] = model.getters.getListIds(); + model.dispatch("UPDATE_ODOO_LIST_DOMAIN", { + listId, + domain: [["foo", "in", [55]]], + }); + assert.deepEqual(model.exportData().lists["1"].domain, [["foo", "in", [55]]]); + }); + + QUnit.test("Can see record of a list in dashboard mode", async function (assert) { + const fakeActionService = { + dependencies: [], + start: (env) => ({ + doAction: (params) => { + assert.step(params.res_model); + assert.step(params.res_id.toString()); + }, + }), + }; + registry.category("services").add("action", fakeActionService); + const { model } = await createSpreadsheetWithList(); + model.updateMode("dashboard"); + const listId = model.getters.getListIds()[0]; + const dataSource = model.getters.getListDataSource(listId); + selectCell(model, "A2"); + assert.verifySteps(["partner", dataSource.getIdFromPosition(0).toString()]); + }); + + QUnit.test( + "Cannot see record of a list in dashboard mode if wrong list formula", + async function (assert) { + const fakeActionService = { + dependencies: [], + start: (env) => ({ + doAction: (params) => { + assert.step(params.res_model); + assert.step(params.res_id.toString()); + }, + }), + }; + registry.category("services").add("action", fakeActionService); + const { model } = await createSpreadsheetWithList(); + const sheetId = model.getters.getActiveSheetId(); + model.dispatch("UPDATE_CELL", { + col: 0, + row: 1, + sheetId, + content: "=ODOO.LIST()", + }); + model.updateMode("dashboard"); + selectCell(model, "A2"); + assert.verifySteps([]); + } + ); +}); diff --git a/addons/spreadsheet/static/tests/migrations/migrations.js b/addons/spreadsheet/static/tests/migrations/migrations.js new file mode 100644 index 00000000000..1d47f8bde84 --- /dev/null +++ b/addons/spreadsheet/static/tests/migrations/migrations.js @@ -0,0 +1,170 @@ +/** @odoo-module */ + +import { migrate, ODOO_VERSION } from "@spreadsheet/o_spreadsheet/migration"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { Model } = spreadsheet; + +QUnit.module("spreadsheet > migrations"); + +QUnit.test("Odoo formulas are migrated", (assert) => { + const data = { + sheets: [ + { + cells: { + A1: { content: `=PIVOT("1")` }, + A2: { content: `=PIVOT.HEADER("1")` }, + A3: { content: `=FILTER.VALUE("1")` }, + A4: { content: `=LIST("1")` }, + A5: { content: `=LIST.HEADER("1")` }, + A6: { content: `=PIVOT.POSITION("1")` }, + A7: { content: `=pivot("1")` }, + }, + }, + ], + }; + const migratedData = migrate(data); + assert.strictEqual(migratedData.sheets[0].cells.A1.content, `=ODOO.PIVOT("1")`); + assert.strictEqual(migratedData.sheets[0].cells.A2.content, `=ODOO.PIVOT.HEADER("1")`); + assert.strictEqual(migratedData.sheets[0].cells.A3.content, `=ODOO.FILTER.VALUE("1")`); + assert.strictEqual(migratedData.sheets[0].cells.A4.content, `=ODOO.LIST("1")`); + assert.strictEqual(migratedData.sheets[0].cells.A5.content, `=ODOO.LIST.HEADER("1")`); + assert.strictEqual(migratedData.sheets[0].cells.A6.content, `=ODOO.PIVOT.POSITION("1")`); + assert.strictEqual(migratedData.sheets[0].cells.A7.content, `=ODOO.PIVOT("1")`); +}); + +QUnit.test("Pivot 'day' arguments are migrated", (assert) => { + const data = { + odooVersion: 1, + sheets: [ + { + cells: { + A1: { content: `=ODOO.PIVOT("1","21/07/2022")` }, + A2: { content: `=ODOO.PIVOT.HEADER("1","11/12/2022")` }, + A3: { content: `=odoo.pivot("1","21/07/2021")` }, + A4: { content: `=ODOO.PIVOT("1","test")` }, + A5: { content: `=odoo.pivot("1","21/07/2021")+"21/07/2021"` }, + A6: { content: `=BAD_FORMULA(` }, + }, + }, + ], + }; + const migratedData = migrate(data); + assert.strictEqual(migratedData.sheets[0].cells.A1.content, `=ODOO.PIVOT("1","07/21/2022")`); + assert.strictEqual( + migratedData.sheets[0].cells.A2.content, + `=ODOO.PIVOT.HEADER("1","12/11/2022")` + ); + assert.strictEqual(migratedData.sheets[0].cells.A3.content, `=odoo.pivot("1","07/21/2021")`); + assert.strictEqual(migratedData.sheets[0].cells.A4.content, `=ODOO.PIVOT("1","test")`); + assert.strictEqual( + migratedData.sheets[0].cells.A5.content, + `=odoo.pivot("1","07/21/2021")+"21/07/2021"` + ); + assert.strictEqual(migratedData.sheets[0].cells.A6.content, `=BAD_FORMULA(`); +}); + +QUnit.test("Global filters: pivot fields is correctly added", (assert) => { + const data = { + globalFilters: [ + { + id: "1", + type: "relation", + label: "Relation Filter", + fields: { + 1: { + field: "foo", + type: "char", + }, + }, + }, + ], + }; + const migratedData = migrate(data); + const filter = migratedData.globalFilters[0]; + assert.deepEqual(filter.pivotFields, { + 1: { + field: "foo", + type: "char", + }, + }); + assert.strictEqual(filter.fields, undefined); +}); + +QUnit.test("Global filters: date is correctly migrated", (assert) => { + const data = { + globalFilters: [ + { + id: "1", + type: "date", + rangeType: "year", + defaultValue: { year: "last_year" }, + }, + { + id: "2", + type: "date", + rangeType: "year", + defaultValue: { year: "antepenultimate_year" }, + }, + { + id: "3", + type: "date", + rangeType: "year", + defaultValue: { year: "this_year" }, + }, + ], + }; + const migratedData = migrate(data); + const [f1, f2, f3] = migratedData.globalFilters; + assert.deepEqual(f1.defaultValue, { yearOffset: -1 }); + assert.deepEqual(f2.defaultValue, { yearOffset: -2 }); + assert.deepEqual(f3.defaultValue, { yearOffset: 0 }); +}); + +QUnit.test("Global filters: list and graph fields are added", (assert) => { + const data = { + globalFilters: [ + { + id: "1", + type: "relation", + }, + { + id: "1", + type: "relation", + listFields: { + 1: { + field: "foo", + type: "char", + }, + }, + graphFields: { + 1: { + field: "foo", + type: "char", + }, + }, + }, + ], + }; + const migratedData = migrate(data); + const [f1, f2] = migratedData.globalFilters; + assert.deepEqual(f1.listFields, {}); + assert.deepEqual(f1.graphFields, {}); + assert.deepEqual(f2.listFields, { + 1: { + field: "foo", + type: "char", + }, + }); + assert.deepEqual(f2.graphFields, { + 1: { + field: "foo", + type: "char", + }, + }); +}); + +QUnit.test("Odoo version is exported", (assert) => { + const model = new Model(); + assert.strictEqual(model.exportData().odooVersion, ODOO_VERSION); +}); diff --git a/addons/spreadsheet/static/tests/pivots/model/pivot_plugin_test.js b/addons/spreadsheet/static/tests/pivots/model/pivot_plugin_test.js new file mode 100644 index 00000000000..d8ab8dc8524 --- /dev/null +++ b/addons/spreadsheet/static/tests/pivots/model/pivot_plugin_test.js @@ -0,0 +1,675 @@ +/** @odoo-module */ + +import { + getCell, + getCellContent, + getCellFormula, + getCellValue, +} from "@spreadsheet/../tests/utils/getters"; +import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot"; +import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason"; +import { setCellContent } from "@spreadsheet/../tests/utils/commands"; +import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model"; +import { makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { session } from "@web/session"; + +QUnit.module("spreadsheet > pivot plugin", {}, () => { + QUnit.test("can select a Pivot from cell formula", async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + const sheetId = model.getters.getActiveSheetId(); + const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2); + model.dispatch("SELECT_PIVOT", { pivotId }); + const selectedPivotId = model.getters.getSelectedPivotId(); + assert.strictEqual(selectedPivotId, "1"); + }); + + QUnit.test( + "can select a Pivot from cell formula with '-' before the formula", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + model.dispatch("SET_VALUE", { + xc: "C3", + text: `=-PIVOT("1","probability","bar","false","foo","2")`, + }); + const sheetId = model.getters.getActiveSheetId(); + const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2); + model.dispatch("SELECT_PIVOT", { pivotId }); + const selectedPivotId = model.getters.getSelectedPivotId(); + assert.strictEqual(selectedPivotId, "1"); + } + ); + + QUnit.test( + "can select a Pivot from cell formula with other numerical values", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + model.dispatch("SET_VALUE", { + xc: "C3", + text: `=3*PIVOT("1","probability","bar","false","foo","2")+2`, + }); + const sheetId = model.getters.getActiveSheetId(); + const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2); + model.dispatch("SELECT_PIVOT", { pivotId }); + const selectedPivotId = model.getters.getSelectedPivotId(); + assert.strictEqual(selectedPivotId, "1"); + } + ); + + QUnit.test( + "can select a Pivot from cell formula where pivot is in a function call", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + model.dispatch("SET_VALUE", { + xc: "C3", + text: `=SUM(PIVOT("1","probability","bar","false","foo","2"),PIVOT("1","probability","bar","false","foo","2"))`, + }); + const sheetId = model.getters.getActiveSheetId(); + const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2); + model.dispatch("SELECT_PIVOT", { pivotId }); + const selectedPivotId = model.getters.getSelectedPivotId(); + assert.strictEqual(selectedPivotId, "1"); + } + ); + + QUnit.test( + "can select a Pivot from cell formula where the id is a reference", + async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + setCellContent(model, "C3", `=ODOO.PIVOT(G10,"probability","bar","false","foo","2")+2`); + setCellContent(model, "G10", "1"); + const sheetId = model.getters.getActiveSheetId(); + const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2); + model.dispatch("SELECT_PIVOT", { pivotId }); + const selectedPivotId = model.getters.getSelectedPivotId(); + assert.strictEqual(selectedPivotId, "1"); + } + ); + + QUnit.test( + "can select a Pivot from cell formula (Mix of test scenarios above)", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + + `, + }); + model.dispatch("SET_VALUE", { + xc: "C3", + text: `=3*SUM(PIVOT("1","probability","bar","false","foo","2"),PIVOT("1","probability","bar","false","foo","2"))+2*PIVOT("1","probability","bar","false","foo","2")`, + }); + const sheetId = model.getters.getActiveSheetId(); + const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2); + model.dispatch("SELECT_PIVOT", { pivotId }); + const selectedPivotId = model.getters.getSelectedPivotId(); + assert.strictEqual(selectedPivotId, "1"); + } + ); + + QUnit.test("Can remove a pivot with undo after editing a cell", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + assert.ok(getCellContent(model, "B1").startsWith("=ODOO.PIVOT.HEADER")); + setCellContent(model, "G10", "should be undoable"); + model.dispatch("REQUEST_UNDO"); + assert.equal(getCellContent(model, "G10"), ""); + // 2 REQUEST_UNDO because of the AUTORESIZE feature + model.dispatch("REQUEST_UNDO"); + model.dispatch("REQUEST_UNDO"); + assert.equal(getCellContent(model, "B1"), ""); + assert.equal(model.getters.getPivotIds().length, 0); + }); + + QUnit.test("rename pivot with empty name is refused", async (assert) => { + const { model } = await createSpreadsheetWithPivot(); + const result = model.dispatch("RENAME_ODOO_PIVOT", { + pivotId: "1", + name: "", + }); + assert.deepEqual(result.reasons, [CommandResult.EmptyName]); + }); + + QUnit.test("rename pivot with incorrect id is refused", async (assert) => { + const { model } = await createSpreadsheetWithPivot(); + const result = model.dispatch("RENAME_ODOO_PIVOT", { + pivotId: "invalid", + name: "name", + }); + assert.deepEqual(result.reasons, [CommandResult.PivotIdNotFound]); + }); + + QUnit.test("Undo/Redo for RENAME_ODOO_PIVOT", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + assert.equal(model.getters.getPivotName("1"), "Partner Pivot"); + model.dispatch("RENAME_ODOO_PIVOT", { pivotId: "1", name: "test" }); + assert.equal(model.getters.getPivotName("1"), "test"); + model.dispatch("REQUEST_UNDO"); + assert.equal(model.getters.getPivotName("1"), "Partner Pivot"); + model.dispatch("REQUEST_REDO"); + assert.equal(model.getters.getPivotName("1"), "test"); + }); + + QUnit.test("Can delete pivot", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + model.dispatch("REMOVE_PIVOT", { pivotId: "1" }); + assert.strictEqual(model.getters.getPivotIds().length, 0); + const B4 = getCell(model, "B4"); + assert.equal(B4.evaluated.error.message, `There is no pivot with id "1"`); + assert.equal(B4.evaluated.value, `#ERROR`); + }); + + QUnit.test("Can undo/redo a delete pivot", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + const value = getCell(model, "B4").evaluated.value; + model.dispatch("REMOVE_PIVOT", { pivotId: "1" }); + model.dispatch("REQUEST_UNDO"); + assert.strictEqual(model.getters.getPivotIds().length, 1); + let B4 = getCell(model, "B4"); + assert.equal(B4.evaluated.error, undefined); + assert.equal(B4.evaluated.value, value); + model.dispatch("REQUEST_REDO"); + assert.strictEqual(model.getters.getPivotIds().length, 0); + B4 = getCell(model, "B4"); + assert.equal(B4.evaluated.error.message, `There is no pivot with id "1"`); + assert.equal(B4.evaluated.value, `#ERROR`); + }); + + QUnit.test("Format header displays an error for non-existing field", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + setCellContent(model, "G10", `=ODOO.PIVOT.HEADER("1", "measure", "non-existing")`); + setCellContent(model, "G11", `=ODOO.PIVOT.HEADER("1", "non-existing", "bla")`); + await nextTick(); + assert.equal(getCellValue(model, "G10"), "#ERROR"); + assert.equal(getCellValue(model, "G11"), "#ERROR"); + assert.equal( + getCell(model, "G10").evaluated.error.message, + "Field non-existing does not exist" + ); + assert.equal( + getCell(model, "G11").evaluated.error.message, + "Field non-existing does not exist" + ); + }); + + QUnit.test( + "user context is combined with pivot context to fetch data", + async function (assert) { + const context = { + allowed_company_ids: [15], + default_stage_id: 5, + search_default_stage_id: 5, + tz: "bx", + lang: "FR", + uid: 4, + }; + const testSession = { + uid: 4, + user_companies: { + allowed_companies: { + 15: { id: 15, name: "Hermit" }, + 16: { id: 16, name: "Craft" }, + }, + current_company: 15, + }, + user_context: context, + }; + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.PIVOT(1, "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: { + allowed_company_ids: [16], + default_stage_id: 9, + search_default_stage_id: 90, + tz: "nz", + lang: "EN", + uid: 40, + }, + }, + }, + }; + const expectedFetchContext = { + allowed_company_ids: [15], + default_stage_id: 9, + search_default_stage_id: 90, + tz: "bx", + lang: "FR", + uid: 4, + }; + patchWithCleanup(session, testSession); + await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model !== "partner") { + return; + } + switch (method) { + case "read_group": + assert.step("read_group"); + assert.deepEqual(kwargs.context, expectedFetchContext, "read_group"); + break; + } + }, + }); + assert.verifySteps(["read_group", "read_group", "read_group", "read_group"]); + } + ); + + QUnit.test("fetch metadata only once per model", async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.PIVOT(1, "probability")` }, + A2: { content: `=ODOO.PIVOT(2, "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + context: {}, + }, + 2: { + id: 2, + colGroupBys: ["bar"], + domain: [], + measures: [{ field: "probability", operator: "max" }], + model: "partner", + rowGroupBys: ["foo"], + context: {}, + }, + }, + }; + await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (model === "partner" && method === "fields_get") { + assert.step(`${model}/${method}`); + } else if (model === "ir.model" && method === "search_read") { + assert.step(`${model}/${method}`); + } + }, + }); + assert.verifySteps(["partner/fields_get"]); + }); + + QUnit.test("don't fetch pivot data if no formula use it", async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + }, + { + id: "sheet2", + cells: { + A1: { content: `=ODOO.PIVOT("1", "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + }, + }, + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: function (route, { model, method, kwargs }) { + if (!["partner", "ir.model"].includes(model)) { + return; + } + assert.step(`${model}/${method}`); + }, + }); + assert.verifySteps([]); + model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "sheet1", sheetIdTo: "sheet2" }); + assert.equal(getCellValue(model, "A1"), "Loading..."); + await nextTick(); + assert.verifySteps([ + "partner/fields_get", + "partner/read_group", + "partner/read_group", + "partner/read_group", + "partner/read_group", + ]); + assert.equal(getCellValue(model, "A1"), 131); + }); + + QUnit.test("display loading while data is not fully available", async function (assert) { + const metadataPromise = makeDeferred(); + const dataPromise = makeDeferred(); + const namePromise = makeDeferred(); + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.PIVOT.HEADER(1, "measure", "probability")` }, + A2: { content: `=ODOO.PIVOT.HEADER(1, "product_id", 37)` }, + A3: { content: `=ODOO.PIVOT(1, "probability")` }, + }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["product_id"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: [], + }, + }, + }; + const model = await createModelWithDataSource({ + spreadsheetData, + mockRPC: async function (route, args, performRPC) { + const { model, method, kwargs } = args; + const result = await performRPC(route, args); + if (model === "partner" && method === "fields_get") { + assert.step(`${model}/${method}`); + await metadataPromise; + } + if ( + model === "partner" && + method === "read_group" && + kwargs.groupby[0] === "product_id" + ) { + assert.step(`${model}/${method}`); + await dataPromise; + } + if (model === "product" && method === "name_get") { + assert.step(`${model}/${method}`); + await namePromise; + } + return result; + }, + }); + assert.strictEqual(getCellValue(model, "A1"), "Loading..."); + assert.strictEqual(getCellValue(model, "A2"), "Loading..."); + assert.strictEqual(getCellValue(model, "A3"), "Loading..."); + metadataPromise.resolve(); + await nextTick(); + setCellContent(model, "A10", "1"); // trigger a new evaluation (might also be caused by other async formulas resolving) + assert.strictEqual(getCellValue(model, "A1"), "Loading..."); + assert.strictEqual(getCellValue(model, "A2"), "Loading..."); + assert.strictEqual(getCellValue(model, "A3"), "Loading..."); + dataPromise.resolve(); + await nextTick(); + setCellContent(model, "A10", "2"); + assert.strictEqual(getCellValue(model, "A1"), "Probability"); + assert.strictEqual(getCellValue(model, "A2"), "Loading..."); + assert.strictEqual(getCellValue(model, "A3"), 131); + namePromise.resolve(); + await nextTick(); + setCellContent(model, "A10", "3"); + assert.strictEqual(getCellValue(model, "A1"), "Probability"); + assert.strictEqual(getCellValue(model, "A2"), "xphone"); + assert.strictEqual(getCellValue(model, "A3"), 131); + assert.verifySteps(["partner/fields_get", "partner/read_group", "product/name_get"]); + }); + + QUnit.test("relational PIVOT.HEADER with missing id", async function (assert) { + assert.expect(1); + + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + + `, + }); + const sheetId = model.getters.getActiveSheetId(); + model.dispatch("UPDATE_CELL", { + col: 4, + row: 9, + content: `=ODOO.PIVOT.HEADER("1", "product_id", "1111111")`, + sheetId, + }); + await nextTick(); + assert.equal( + getCell(model, "E10").evaluated.error.message, + "Unable to fetch the label of 1111111 of model product" + ); + }); + + QUnit.test("relational PIVOT.HEADER with undefined id", async function (assert) { + assert.expect(2); + + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + + `, + }); + setCellContent(model, "F10", `=ODOO.PIVOT.HEADER("1", "product_id", A25)`); + assert.equal(getCell(model, "A25"), null, "the cell should be empty"); + await nextTick(); + assert.equal(getCellValue(model, "F10"), "None"); + }); + + QUnit.test("Verify pivot measures are correctly computed :)", async function (assert) { + assert.expect(4); + + const { model } = await createSpreadsheetWithPivot(); + assert.equal(getCellValue(model, "B4"), 11); + assert.equal(getCellValue(model, "C3"), 15); + assert.equal(getCellValue(model, "D4"), 10); + assert.equal(getCellValue(model, "E4"), 95); + }); + + QUnit.test("can import/export sorted pivot", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + id: "1", + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability" }], + model: "partner", + rowGroupBys: ["bar"], + sortedColumn: { + measure: "probability", + order: "asc", + groupId: [[], [1]], + }, + name: "A pivot", + context: {}, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + assert.deepEqual(model.getters.getPivotDefinition(1).sortedColumn, { + measure: "probability", + order: "asc", + groupId: [[], [1]], + }); + assert.deepEqual(model.exportData().pivots, spreadsheetData.pivots); + }); + + QUnit.test("Can group by many2many field ", async (assert) => { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + assert.equal(getCellFormula(model, "A3"), '=ODOO.PIVOT.HEADER(1,"tag_ids","false")'); + assert.equal(getCellFormula(model, "A4"), '=ODOO.PIVOT.HEADER(1,"tag_ids",42)'); + assert.equal(getCellFormula(model, "A5"), '=ODOO.PIVOT.HEADER(1,"tag_ids",67)'); + + assert.equal( + getCellFormula(model, "B3"), + '=ODOO.PIVOT(1,"probability","tag_ids","false","foo",1)' + ); + assert.equal( + getCellFormula(model, "B4"), + '=ODOO.PIVOT(1,"probability","tag_ids",42,"foo",1)' + ); + assert.equal( + getCellFormula(model, "B5"), + '=ODOO.PIVOT(1,"probability","tag_ids",67,"foo",1)' + ); + + assert.equal( + getCellFormula(model, "C3"), + '=ODOO.PIVOT(1,"probability","tag_ids","false","foo",2)' + ); + assert.equal( + getCellFormula(model, "C4"), + '=ODOO.PIVOT(1,"probability","tag_ids",42,"foo",2)' + ); + assert.equal( + getCellFormula(model, "C5"), + '=ODOO.PIVOT(1,"probability","tag_ids",67,"foo",2)' + ); + + assert.equal(getCellValue(model, "A3"), "None"); + assert.equal(getCellValue(model, "A4"), "isCool"); + assert.equal(getCellValue(model, "A5"), "Growing"); + assert.equal(getCellValue(model, "B3"), ""); + assert.equal(getCellValue(model, "B4"), "11"); + assert.equal(getCellValue(model, "B5"), "11"); + assert.equal(getCellValue(model, "C3"), ""); + assert.equal(getCellValue(model, "C4"), "15"); + assert.equal(getCellValue(model, "C5"), ""); + }); + + QUnit.test("PIVOT formulas are correctly formatted at evaluation", async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + + `, + }); + assert.strictEqual(getCell(model, "B3").evaluated.format, "0"); + assert.strictEqual(getCell(model, "C3").evaluated.format, "#,##0.00"); + }); + + QUnit.test( + "PIVOT formulas with monetary measure are correctly formatted at evaluation", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + assert.strictEqual(getCell(model, "B3").evaluated.format, "#,##0.00[$€]"); + } + ); + + QUnit.test( + "PIVOT.HEADER formulas are correctly formatted at evaluation", + async function (assert) { + const { model } = await createSpreadsheetWithPivot({ + arch: /* xml */ ` + + + + + `, + }); + assert.strictEqual(getCell(model, "A3").evaluated.format, "#,##0.00"); + assert.strictEqual(getCell(model, "B1").evaluated.format, "mm/dd/yyyy"); + assert.strictEqual(getCell(model, "B2").evaluated.format, undefined); + } + ); + + QUnit.test("can edit pivot domain", async (assert) => { + const { model } = await createSpreadsheetWithPivot(); + const [pivotId] = model.getters.getPivotIds(); + assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, []); + assert.strictEqual(getCellValue(model, "B4"), 11); + model.dispatch("UPDATE_ODOO_PIVOT_DOMAIN", { + pivotId, + domain: [["foo", "in", [55]]], + }); + assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, [["foo", "in", [55]]]); + await nextTick(); + assert.strictEqual(getCellValue(model, "B4"), ""); + model.dispatch("REQUEST_UNDO"); + await nextTick(); + assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, []); + await nextTick(); + assert.strictEqual(getCellValue(model, "B4"), 11); + model.dispatch("REQUEST_REDO"); + assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, [["foo", "in", [55]]]); + await nextTick(); + assert.strictEqual(getCellValue(model, "B4"), ""); + }); + + QUnit.test("edited domain is exported", async (assert) => { + const { model } = await createSpreadsheetWithPivot(); + const [pivotId] = model.getters.getPivotIds(); + model.dispatch("UPDATE_ODOO_PIVOT_DOMAIN", { + pivotId, + domain: [["foo", "in", [55]]], + }); + assert.deepEqual(model.exportData().pivots["1"].domain, [["foo", "in", [55]]]); + }); +}); diff --git a/addons/spreadsheet/static/tests/pivots/model/pivot_positional_formula_test.js b/addons/spreadsheet/static/tests/pivots/model/pivot_positional_formula_test.js new file mode 100644 index 00000000000..5c8d5a28e5f --- /dev/null +++ b/addons/spreadsheet/static/tests/pivots/model/pivot_positional_formula_test.js @@ -0,0 +1,341 @@ +/** @odoo-module */ + +import { nextTick } from "@web/../tests/helpers/utils"; +import { setCellContent } from "@spreadsheet/../tests/utils/commands"; +import { getCell, getCellValue } from "@spreadsheet/../tests/utils/getters"; +import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot"; +import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model"; + +QUnit.module("spreadsheet > positional pivot formula", {}, () => { + QUnit.test("Can have positional args in pivot formula", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + + // Columns + setCellContent(model, "H1", `=ODOO.PIVOT(1,"probability","#foo", 1)`); + setCellContent(model, "H2", `=ODOO.PIVOT(1,"probability","#foo", 2)`); + setCellContent(model, "H3", `=ODOO.PIVOT(1,"probability","#foo", 3)`); + setCellContent(model, "H4", `=ODOO.PIVOT(1,"probability","#foo", 4)`); + setCellContent(model, "H5", `=ODOO.PIVOT(1,"probability","#foo", 5)`); + assert.strictEqual(getCellValue(model, "H1"), 11); + assert.strictEqual(getCellValue(model, "H2"), 15); + assert.strictEqual(getCellValue(model, "H3"), 10); + assert.strictEqual(getCellValue(model, "H4"), 95); + assert.strictEqual(getCellValue(model, "H5"), ""); + + // Rows + setCellContent(model, "I1", `=ODOO.PIVOT(1,"probability","#bar", 1)`); + setCellContent(model, "I2", `=ODOO.PIVOT(1,"probability","#bar", 2)`); + setCellContent(model, "I3", `=ODOO.PIVOT(1,"probability","#bar", 3)`); + assert.strictEqual(getCellValue(model, "I1"), 15); + assert.strictEqual(getCellValue(model, "I2"), 116); + assert.strictEqual(getCellValue(model, "I3"), ""); + }); + + QUnit.test("Can have positional args in pivot headers formula", async function (assert) { + const { model } = await createSpreadsheetWithPivot(); + // Columns + setCellContent(model, "H1", `=ODOO.PIVOT.HEADER(1,"#foo",1)`); + setCellContent(model, "H2", `=ODOO.PIVOT.HEADER(1,"#foo",2)`); + setCellContent(model, "H3", `=ODOO.PIVOT.HEADER(1,"#foo",3)`); + setCellContent(model, "H4", `=ODOO.PIVOT.HEADER(1,"#foo",4)`); + setCellContent(model, "H5", `=ODOO.PIVOT.HEADER(1,"#foo",5)`); + setCellContent(model, "H6", `=ODOO.PIVOT.HEADER(1,"#foo",5, "measure", "probability")`); + assert.strictEqual(getCellValue(model, "H1"), 1); + assert.strictEqual(getCellValue(model, "H2"), 2); + assert.strictEqual(getCellValue(model, "H3"), 12); + assert.strictEqual(getCellValue(model, "H4"), 17); + assert.strictEqual(getCellValue(model, "H5"), ""); + assert.strictEqual(getCellValue(model, "H6"), "Probability"); + + // Rows + setCellContent(model, "I1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`); + setCellContent(model, "I2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`); + setCellContent(model, "I3", `=ODOO.PIVOT.HEADER(1,"#bar",3)`); + setCellContent(model, "I4", `=ODOO.PIVOT.HEADER(1,"#bar",3, "measure", "probability")`); + assert.strictEqual(getCellValue(model, "I1"), "No"); + assert.strictEqual(getCellValue(model, "I2"), "Yes"); + assert.strictEqual(getCellValue(model, "I3"), ""); + assert.strictEqual(getCellValue(model, "I4"), "Probability"); + }); + + QUnit.test("pivot positional with two levels of group bys in rows", async (assert) => { + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + + + `, + }); + // Rows Headers + setCellContent(model, "H1", `=ODOO.PIVOT.HEADER(1,"bar","false","#product_id",1)`); + setCellContent(model, "H2", `=ODOO.PIVOT.HEADER(1,"bar","true","#product_id",1)`); + setCellContent(model, "H3", `=ODOO.PIVOT.HEADER(1,"#bar",1,"#product_id",1)`); + setCellContent(model, "H4", `=ODOO.PIVOT.HEADER(1,"#bar",2,"#product_id",1)`); + setCellContent(model, "H5", `=ODOO.PIVOT.HEADER(1,"#bar",3,"#product_id",1)`); + assert.strictEqual(getCellValue(model, "H1"), "xpad"); + assert.strictEqual(getCellValue(model, "H2"), "xphone"); + assert.strictEqual(getCellValue(model, "H3"), "xpad"); + assert.strictEqual(getCellValue(model, "H4"), "xphone"); + assert.strictEqual(getCellValue(model, "H5"), ""); + + // Cells + setCellContent( + model, + "H1", + `=ODOO.PIVOT(1,"probability","#bar",1,"#product_id",1,"#foo",2)` + ); + setCellContent( + model, + "H2", + `=ODOO.PIVOT(1,"probability","#bar",1,"#product_id",2,"#foo",2)` + ); + assert.strictEqual(getCellValue(model, "H1"), 15); + assert.strictEqual(getCellValue(model, "H2"), ""); + }); + + QUnit.test("Positional argument without a number should crash", async (assert) => { + const { model } = await createSpreadsheetWithPivot(); + setCellContent(model, "A10", `=ODOO.PIVOT.HEADER(1,"#bar","this is not a number")`); + assert.strictEqual(getCellValue(model, "A10"), "#ERROR"); + assert.strictEqual( + getCell(model, "A10").evaluated.error.message, + "The function ODOO.PIVOT.HEADER expects a number value, but 'this is not a number' is a string, and cannot be coerced to a number." + ); + }); + + QUnit.test("sort first pivot column (ascending)", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + colGroupBys: ["foo"], + rowGroupBys: ["bar"], + domain: [], + id: "1", + measures: [{ field: "probability" }], + model: "partner", + sortedColumn: { + groupId: [[], [1]], + measure: "probability", + order: "asc", + }, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`); + setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`); + setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`); + setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`); + setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`); + setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`); + setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`); + setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`); + await nextTick(); + assert.strictEqual(getCellValue(model, "A1"), "No"); + assert.strictEqual(getCellValue(model, "A2"), "Yes"); + assert.strictEqual(getCellValue(model, "B1"), ""); + assert.strictEqual(getCellValue(model, "B2"), 11); + assert.strictEqual(getCellValue(model, "C1"), 15); + assert.strictEqual(getCellValue(model, "C2"), ""); + assert.strictEqual(getCellValue(model, "D1"), 15); + assert.strictEqual(getCellValue(model, "D2"), 116); + }); + + QUnit.test("sort first pivot column (descending)", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + colGroupBys: ["foo"], + rowGroupBys: ["bar"], + domain: [], + id: "1", + measures: [{ field: "probability" }], + model: "partner", + sortedColumn: { + groupId: [[], [1]], + measure: "probability", + order: "desc", + }, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`); + setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`); + setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`); + setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`); + setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`); + setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`); + setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`); + setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`); + await nextTick(); + assert.strictEqual(getCellValue(model, "A1"), "Yes"); + assert.strictEqual(getCellValue(model, "A2"), "No"); + assert.strictEqual(getCellValue(model, "B1"), 11); + assert.strictEqual(getCellValue(model, "B2"), ""); + assert.strictEqual(getCellValue(model, "C1"), ""); + assert.strictEqual(getCellValue(model, "C2"), 15); + assert.strictEqual(getCellValue(model, "D1"), 116); + assert.strictEqual(getCellValue(model, "D2"), 15); + }); + + QUnit.test("sort second pivot column (ascending)", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + colGroupBys: ["foo"], + domain: [], + id: "1", + measures: [{ field: "probability" }], + model: "partner", + rowGroupBys: ["bar"], + name: "Partners by Foo", + sortedColumn: { + groupId: [[], [2]], + measure: "probability", + order: "asc", + }, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`); + setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`); + setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`); + setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`); + setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`); + setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`); + setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`); + setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`); + await nextTick(); + assert.strictEqual(getCellValue(model, "A1"), "Yes"); + assert.strictEqual(getCellValue(model, "A2"), "No"); + assert.strictEqual(getCellValue(model, "B1"), 11); + assert.strictEqual(getCellValue(model, "B2"), ""); + assert.strictEqual(getCellValue(model, "C1"), ""); + assert.strictEqual(getCellValue(model, "C2"), 15); + assert.strictEqual(getCellValue(model, "D1"), 116); + assert.strictEqual(getCellValue(model, "D2"), 15); + }); + + QUnit.test("sort second pivot column (descending)", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + colGroupBys: ["foo"], + domain: [], + id: "1", + measures: [{ field: "probability" }], + model: "partner", + rowGroupBys: ["bar"], + name: "Partners by Foo", + sortedColumn: { + groupId: [[], [2]], + measure: "probability", + order: "desc", + }, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`); + setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`); + setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`); + setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`); + setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`); + setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`); + setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`); + setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`); + await nextTick(); + assert.strictEqual(getCellValue(model, "A1"), "No"); + assert.strictEqual(getCellValue(model, "A2"), "Yes"); + assert.strictEqual(getCellValue(model, "B1"), ""); + assert.strictEqual(getCellValue(model, "B2"), 11); + assert.strictEqual(getCellValue(model, "C1"), 15); + assert.strictEqual(getCellValue(model, "C2"), ""); + assert.strictEqual(getCellValue(model, "D1"), 15); + assert.strictEqual(getCellValue(model, "D2"), 116); + }); + + QUnit.test("sort second pivot measure (ascending)", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + rowGroupBys: ["product_id"], + colGroupBys: [], + domain: [], + id: "1", + measures: [{ field: "probability" }, { field: "foo" }], + model: "partner", + sortedColumn: { + groupId: [[], []], + measure: "foo", + order: "asc", + }, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + setCellContent(model, "A10", `=ODOO.PIVOT.HEADER(1,"#product_id",1)`); + setCellContent(model, "A11", `=ODOO.PIVOT.HEADER(1,"#product_id",2)`); + setCellContent(model, "B10", `=ODOO.PIVOT(1,"probability","#product_id",1)`); + setCellContent(model, "B11", `=ODOO.PIVOT(1,"probability","#product_id",2)`); + setCellContent(model, "C10", `=ODOO.PIVOT(1,"foo","#product_id",1)`); + setCellContent(model, "C11", `=ODOO.PIVOT(1,"foo","#product_id",2)`); + await nextTick(); + assert.strictEqual(getCellValue(model, "A10"), "xphone"); + assert.strictEqual(getCellValue(model, "A11"), "xpad"); + assert.strictEqual(getCellValue(model, "B10"), 10); + assert.strictEqual(getCellValue(model, "B11"), 121); + assert.strictEqual(getCellValue(model, "C10"), 12); + assert.strictEqual(getCellValue(model, "C11"), 20); + }); + + QUnit.test("sort second pivot measure (descending)", async (assert) => { + const spreadsheetData = { + pivots: { + 1: { + colGroupBys: [], + domain: [], + id: "1", + measures: [{ field: "probability" }, { field: "foo" }], + model: "partner", + rowGroupBys: ["product_id"], + sortedColumn: { + groupId: [[], []], + measure: "foo", + order: "desc", + }, + }, + }, + }; + const model = await createModelWithDataSource({ spreadsheetData }); + setCellContent(model, "A10", `=ODOO.PIVOT.HEADER(1,"#product_id",1)`); + setCellContent(model, "A11", `=ODOO.PIVOT.HEADER(1,"#product_id",2)`); + setCellContent(model, "B10", `=ODOO.PIVOT(1,"probability","#product_id",1)`); + setCellContent(model, "B11", `=ODOO.PIVOT(1,"probability","#product_id",2)`); + setCellContent(model, "C10", `=ODOO.PIVOT(1,"foo","#product_id",1)`); + setCellContent(model, "C11", `=ODOO.PIVOT(1,"foo","#product_id",2)`); + await nextTick(); + assert.strictEqual(getCellValue(model, "A10"), "xpad"); + assert.strictEqual(getCellValue(model, "A11"), "xphone"); + assert.strictEqual(getCellValue(model, "B10"), 121); + assert.strictEqual(getCellValue(model, "B11"), 10); + assert.strictEqual(getCellValue(model, "C10"), 20); + assert.strictEqual(getCellValue(model, "C11"), 12); + }); + + QUnit.test("Formatting a pivot positional preserves the interval", async (assert) => { + const { model } = await createSpreadsheetWithPivot({ + arch: /*xml*/ ` + + + + `, + }); + setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#date:day",1)`); + assert.strictEqual(getCell(model, "A1").formattedValue, "01/20/2016"); + }); +}); diff --git a/addons/spreadsheet/static/tests/pivots/pivot_helpers_test.js b/addons/spreadsheet/static/tests/pivots/pivot_helpers_test.js new file mode 100644 index 00000000000..386632dcae9 --- /dev/null +++ b/addons/spreadsheet/static/tests/pivots/pivot_helpers_test.js @@ -0,0 +1,154 @@ +/** @odoo-module */ +import { getFirstPivotFunction, getNumberOfPivotFormulas } from "@spreadsheet/pivot/pivot_helpers"; +import { getFirstListFunction, getNumberOfListFormulas } from "@spreadsheet/list/list_helpers"; +import { parsePivotFormulaFieldValue } from "@spreadsheet/pivot/pivot_model"; + +function stringArg(value) { + return { type: "STRING", value: `${value}` }; +} + +QUnit.module("spreadsheet > pivot_helpers", {}, () => { + QUnit.test("Basic formula extractor", async function (assert) { + const formula = `=ODOO.PIVOT("1", "test") + ODOO.LIST("2", "hello", "bla")`; + let functionName; + let args; + ({ functionName, args } = getFirstPivotFunction(formula)); + assert.strictEqual(functionName, "ODOO.PIVOT"); + assert.strictEqual(args.length, 2); + assert.deepEqual(args[0], stringArg("1")); + assert.deepEqual(args[1], stringArg("test")); + ({ functionName, args } = getFirstListFunction(formula)); + assert.strictEqual(functionName, "ODOO.LIST"); + assert.strictEqual(args.length, 3); + assert.deepEqual(args[0], stringArg("2")); + assert.deepEqual(args[1], stringArg("hello")); + assert.deepEqual(args[2], stringArg("bla")); + }); + + QUnit.test("Extraction with two PIVOT formulas", async function (assert) { + const formula = `=ODOO.PIVOT("1", "test") + ODOO.PIVOT("2", "hello", "bla")`; + let functionName; + let args; + ({ functionName, args } = getFirstPivotFunction(formula)); + assert.strictEqual(functionName, "ODOO.PIVOT"); + assert.strictEqual(args.length, 2); + assert.deepEqual(args[0], stringArg("1")); + assert.deepEqual(args[1], stringArg("test")); + assert.strictEqual(getFirstListFunction(formula), undefined); + }); + + QUnit.test("Number of formulas", async function (assert) { + const formula = `=ODOO.PIVOT("1", "test") + ODOO.PIVOT("2", "hello", "bla") + ODOO.LIST("1", "bla")`; + assert.strictEqual(getNumberOfPivotFormulas(formula), 2); + assert.strictEqual(getNumberOfListFormulas(formula), 1); + assert.strictEqual(getNumberOfPivotFormulas("=1+1"), 0); + assert.strictEqual(getNumberOfListFormulas("=1+1"), 0); + assert.strictEqual(getNumberOfPivotFormulas("=bla"), 0); + assert.strictEqual(getNumberOfListFormulas("=bla"), 0); + }); + + QUnit.test("getFirstPivotFunction does not crash when given crap", async function (assert) { + assert.strictEqual(getFirstListFunction("=SUM(A1)"), undefined); + assert.strictEqual(getFirstPivotFunction("=SUM(A1)"), undefined); + assert.strictEqual(getFirstListFunction("=1+1"), undefined); + assert.strictEqual(getFirstPivotFunction("=1+1"), undefined); + assert.strictEqual(getFirstListFunction("=bla"), undefined); + assert.strictEqual(getFirstPivotFunction("=bla"), undefined); + assert.strictEqual(getFirstListFunction("bla"), undefined); + assert.strictEqual(getFirstPivotFunction("bla"), undefined); + }); +}); + +QUnit.module("spreadsheet > parsePivotFormulaFieldValue", {}, () => { + QUnit.test("parse values of a selection, char or text field", (assert) => { + for (const fieldType of ["selection", "text", "char"]) { + const field = { + type: fieldType, + string: "A field", + }; + assert.strictEqual(parsePivotFormulaFieldValue(field, "won"), "won"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "1"), "1"); + assert.strictEqual(parsePivotFormulaFieldValue(field, 1), "1"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "11/2020"), "11/2020"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "2020"), "2020"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "01/11/2020"), "01/11/2020"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, false), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, "true"), "true"); + } + }); + + QUnit.test("parse values of time fields", (assert) => { + for (const fieldType of ["date", "datetime"]) { + const field = { + type: fieldType, + string: "A field", + }; + assert.strictEqual(parsePivotFormulaFieldValue(field, "11/2020"), "11/2020"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "2020"), "2020"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "01/11/2020"), "01/11/2020"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "1"), "1"); + assert.strictEqual(parsePivotFormulaFieldValue(field, 1), "1"); + assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, false), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, "true"), "true"); // this should throw because it's not a valid date + assert.strictEqual(parsePivotFormulaFieldValue(field, true), "true"); // this should throw because it's not a valid date + assert.strictEqual(parsePivotFormulaFieldValue(field, "won"), "won"); // this should throw because it's not a valid date + } + }); + + QUnit.test("parse values of boolean field", (assert) => { + const field = { + type: "boolean", + string: "A field", + }; + assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, false), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, "true"), true); + assert.strictEqual(parsePivotFormulaFieldValue(field, true), true); + assert.throws(() => parsePivotFormulaFieldValue(field, "11/2020")); + assert.throws(() => parsePivotFormulaFieldValue(field, "2020")); + assert.throws(() => parsePivotFormulaFieldValue(field, "01/11/2020")); + assert.throws(() => parsePivotFormulaFieldValue(field, "1")); + assert.throws(() => parsePivotFormulaFieldValue(field, 1)); + assert.throws(() => parsePivotFormulaFieldValue(field, "won")); + }); + + QUnit.test("parse values of numeric fields", (assert) => { + for (const fieldType of ["float", "integer", "monetary", "many2one", "many2many"]) { + const field = { + type: fieldType, + string: "A field", + }; + assert.strictEqual(parsePivotFormulaFieldValue(field, "2020"), 2020); + assert.strictEqual(parsePivotFormulaFieldValue(field, "01/11/2020"), 43841); // a date is actually a number in a spreadsheet + assert.strictEqual(parsePivotFormulaFieldValue(field, "1"), 1); + assert.strictEqual(parsePivotFormulaFieldValue(field, 1), 1); + assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false); + assert.strictEqual(parsePivotFormulaFieldValue(field, false), false); + assert.throws(() => parsePivotFormulaFieldValue(field, "true")); + assert.throws(() => parsePivotFormulaFieldValue(field, true)); + assert.throws(() => parsePivotFormulaFieldValue(field, "won")); + assert.throws(() => parsePivotFormulaFieldValue(field, "11/2020")); + } + }); + + QUnit.test("parse values of unsupported fields", (assert) => { + for (const fieldType of ["one2many", "binary", "html"]) { + const field = { + type: fieldType, + string: "A field", + }; + assert.throws(() => parsePivotFormulaFieldValue(field, "false")); + assert.throws(() => parsePivotFormulaFieldValue(field, false)); + assert.throws(() => parsePivotFormulaFieldValue(field, "true")); + assert.throws(() => parsePivotFormulaFieldValue(field, true)); + assert.throws(() => parsePivotFormulaFieldValue(field, "11/2020")); + assert.throws(() => parsePivotFormulaFieldValue(field, "2020")); + assert.throws(() => parsePivotFormulaFieldValue(field, "01/11/2020")); + assert.throws(() => parsePivotFormulaFieldValue(field, "1")); + assert.throws(() => parsePivotFormulaFieldValue(field, 1)); + assert.throws(() => parsePivotFormulaFieldValue(field, "won")); + } + }); +}); diff --git a/addons/spreadsheet/static/tests/pivots/pivot_see_records_test.js b/addons/spreadsheet/static/tests/pivots/pivot_see_records_test.js new file mode 100644 index 00000000000..4a9a265054f --- /dev/null +++ b/addons/spreadsheet/static/tests/pivots/pivot_see_records_test.js @@ -0,0 +1,92 @@ +/** @odoo-module */ +import { nextTick } from "@web/../tests/helpers/utils"; + +import { selectCell } from "@spreadsheet/../tests/utils/commands"; +import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { registry } from "@web/core/registry"; + +const { cellMenuRegistry } = spreadsheet.registries; + +QUnit.module("spreadsheet > see pivot records"); + +const basicListAction = { + type: "ir.actions.act_window", + name: "Partner", + res_model: "partner", + view_mode: "list", + views: [ + [false, "list"], + [false, "form"], + ], + target: "current", + domain: [], +}; + +QUnit.test("Can open see records on headers col", async function (assert) { + const fakeActionService = { + dependencies: [], + start: (env) => ({ + doAction: (actionRequest, options = {}) => { + assert.step("doAction"); + assert.deepEqual(actionRequest, { + ...basicListAction, + domain: [["foo", "=", 1]], + }); + }, + }), + }; + registry.category("services").add("action", fakeActionService); + const { env, model } = await createSpreadsheetWithPivot(); + selectCell(model, "B1"); + await nextTick(); + const root = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records"); + await root.action(env); + assert.verifySteps(["doAction"]); +}); + +QUnit.test("Can open see records on headers row", async function (assert) { + const fakeActionService = { + dependencies: [], + start: (env) => ({ + doAction: (actionRequest, options = {}) => { + assert.step("doAction"); + assert.deepEqual(actionRequest, { + ...basicListAction, + domain: [["bar", "=", false]], + }); + }, + }), + }; + registry.category("services").add("action", fakeActionService); + const { env, model } = await createSpreadsheetWithPivot(); + selectCell(model, "A3"); + await nextTick(); + const root = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records"); + await root.action(env); + assert.verifySteps(["doAction"]); +}); + +QUnit.test("Can open see records on measure headers", async function (assert) { + const fakeActionService = { + dependencies: [], + start: (env) => ({ + doAction: (actionRequest, options = {}) => { + assert.step("doAction"); + assert.deepEqual(actionRequest, { + ...basicListAction, + domain: [["foo", "=", 1]], + }); + }, + }), + }; + registry.category("services").add("action", fakeActionService); + const { env, model } = await createSpreadsheetWithPivot(); + selectCell(model, "B2"); + await nextTick(); + const root = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records"); + await root.action(env); + assert.verifySteps(["doAction"]); +}); + diff --git a/addons/spreadsheet/static/tests/utils/chart.js b/addons/spreadsheet/static/tests/utils/chart.js new file mode 100644 index 00000000000..ff925a4297d --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/chart.js @@ -0,0 +1,71 @@ +/** @odoo-module */ + +import { nextTick } from "@web/../tests/helpers/utils"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { createModelWithDataSource } from "./model"; +const uuidGenerator = new spreadsheet.helpers.UuidGenerator(); + +/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */ + +/** + * + * @param {Model} model + */ +export function insertGraphInSpreadsheet(model, type = "odoo_bar") { + const definition = getChartDefinition(type); + model.dispatch("CREATE_CHART", { + sheetId: model.getters.getActiveSheetId(), + id: definition.id, + position: { + x: 10, + y: 10, + }, + definition, + }); +} +/** + * + * @param {Object} params + * @param {function} [params.mockRPC] + * @param {string} [params.type] + * + * @returns { Promise<{ model: Model, env: Object }>} + */ +export async function createSpreadsheetWithGraph(params = {}) { + const model = await createModelWithDataSource({ + mockRPC: params.mockRPC, + }); + + insertGraphInSpreadsheet(model, params.type); + + const env = model.config.evalContext.env; + env.model = model; + await nextTick(); + return { model, env }; +} + +function getChartDefinition(type) { + return { + metaData: { + groupBy: ["foo", "bar"], + measure: "__count", + order: null, + resModel: "partner", + }, + searchParams: { + comparison: null, + context: {}, + domain: [], + groupBy: [], + orderBy: [], + }, + stacked: true, + title: "Partners", + background: "#FFFFFF", + legendPosition: "top", + verticalAxisPosition: "left", + dataSourceId: uuidGenerator.uuidv4(), + id: uuidGenerator.uuidv4(), + type, + }; +} diff --git a/addons/spreadsheet/static/tests/utils/commands.js b/addons/spreadsheet/static/tests/utils/commands.js new file mode 100644 index 00000000000..f107b22755b --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/commands.js @@ -0,0 +1,143 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { waitForDataSourcesLoaded } from "@spreadsheet/../tests/utils/model"; + +const { toCartesian, toZone } = spreadsheet.helpers; + +/** + * Select a cell + */ +export function selectCell(model, xc) { + const { col, row } = toCartesian(xc); + return model.selection.selectCell(col, row); +} + +/** + * Add a global filter and ensure the data sources are completely reloaded + */ +export async function addGlobalFilter(model, filter) { + const result = model.dispatch("ADD_GLOBAL_FILTER", filter); + await waitForDataSourcesLoaded(model); + return result; +} + +/** + * Remove a global filter and ensure the data sources are completely reloaded + */ +export async function removeGlobalFilter(model, id) { + const result = model.dispatch("REMOVE_GLOBAL_FILTER", { id }); + await waitForDataSourcesLoaded(model); + return result; +} + +/** + * Edit a global filter and ensure the data sources are completely reloaded + */ +export async function editGlobalFilter(model, filter) { + const result = model.dispatch("EDIT_GLOBAL_FILTER", filter); + await waitForDataSourcesLoaded(model); + return result; +} + +/** + * Set the value of a global filter and ensure the data sources are completely + * reloaded + */ +export async function setGlobalFilterValue(model, payload) { + const result = model.dispatch("SET_GLOBAL_FILTER_VALUE", payload); + await waitForDataSourcesLoaded(model); + return result; +} + +/** + * Set the selection + */ +export function setSelection(model, xc) { + const zone = toZone(xc); + model.selection.selectZone({ cell: { col: zone.left, row: zone.top }, zone }); +} + +/** + * Autofill from a zone to a cell + */ +export function autofill(model, from, to) { + setSelection(model, from); + model.dispatch("AUTOFILL_SELECT", toCartesian(to)); + model.dispatch("AUTOFILL"); +} + +/** + * Set the content of a cell + */ +export function setCellContent(model, xc, content, sheetId = model.getters.getActiveSheetId()) { + model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, content }); +} + +/** Create a test chart in the active sheet*/ +export function createBasicChart(model, chartId, sheetId = model.getters.getActiveSheetId()) { + model.dispatch("CREATE_CHART", { + id: chartId, + position: { x: 0, y: 0 }, + sheetId: sheetId, + definition: { + title: "test", + dataSets: ["A1"], + type: "bar", + background: "#fff", + verticalAxisPosition: "left", + legendPosition: "top", + stackedBar: false, + }, + }); +} + +/** Create a test scorecard chart in the active sheet*/ +export function createScorecardChart(model, chartId, sheetId = model.getters.getActiveSheetId()) { + model.dispatch("CREATE_CHART", { + id: chartId, + position: { x: 0, y: 0 }, + sheetId: sheetId, + definition: { + title: "test", + keyValue: "A1", + type: "scorecard", + background: "#fff", + baselineColorDown: "#DC6965", + baselineColorUp: "#00A04A", + baselineMode: "absolute", + }, + }); +} + +/** Create a test scorecard chart in the active sheet*/ +export function createGaugeChart(model, chartId, sheetId = model.getters.getActiveSheetId()) { + model.dispatch("CREATE_CHART", { + id: chartId, + position: { x: 0, y: 0 }, + sheetId: sheetId, + definition: { + title: "test", + type: "gauge", + background: "#fff", + dataRange: "A1", + sectionRule: { + rangeMin: "0", + rangeMax: "100", + colors: { + lowerColor: "#112233", + middleColor: "#445566", + upperColor: "#778899", + }, + lowerInflectionPoint: { + type: "number", + value: "25", + }, + upperInflectionPoint: { + type: "number", + value: "85", + }, + }, + }, + }); +} diff --git a/addons/spreadsheet/static/tests/utils/data.js b/addons/spreadsheet/static/tests/utils/data.js new file mode 100644 index 00000000000..1b1c7466b42 --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/data.js @@ -0,0 +1,408 @@ +/** @odoo-module */ + +/** + * @typedef {object} ServerData + * @property {object} models + * @property {object} views + */ + +/** + * Get a basic arch for a pivot, which is compatible with the data given by + * getBasicData(). + * + * Here is the pivot created: + * A B C D E F + * 1 1 2 12 17 Total + * 2 Proba Proba Proba Proba Proba + * 3 false 15 15 + * 4 true 11 10 95 116 + * 5 Total 11 15 10 95 131 + */ +export function getBasicPivotArch() { + return /* xml */ ` + + + + + `; +} + +/** + * Get a basic arch for a list, which is compatible with the data given by + * getBasicData(). + * + * Here is the list created: + * A B C D + * 1 Foo bar Date Product + * 2 12 True 2016-04-14 xphone + * 3 1 True 2016-10-26 xpad + * 4 17 True 2016-12-15 xpad + * 5 2 False 2016-12-11 xpad + */ +export function getBasicListArch() { + return /* xml */ ` + + + + + + + `; +} + +export function getBasicGraphArch() { + return /* xml */ ` + + + + `; +} + +/** + * @returns {ServerData} + */ +export function getBasicServerData() { + return { + models: getBasicData(), + views: { + "partner,false,list": getBasicListArch(), + "partner,false,pivot": getBasicPivotArch(), + "partner,false,graph": getBasicGraphArch(), + "partner,false,form": /* xml */ ``, + "partner,false,search": /* xml */ ``, + }, + }; +} + +/** + * + * @param {string} model + * @param {Array} columns + * @param {Object} data + * + * @returns { {definition: Object, columns: Array}} + */ +export function generateListDefinition(model, columns, data = getBasicData()) { + const cols = []; + for (const name of columns) { + cols.push({ + name, + type: data[model].fields[name].type, + }); + } + return { + definition: { + metaData: { + resModel: model, + columns, + }, + searchParams: { + domain: [], + context: {}, + orderBy: [], + }, + name: "List", + }, + columns: cols, + }; +} + +export function getBasicListArchs() { + return { + "partner,false,list": getBasicListArch(), + "partner,false,search": /* xml */ ``, + }; +} + +export function getBasicData() { + return { + "documents.document": { + fields: { + name: { string: "Name", type: "char" }, + raw: { string: "Data", type: "text" }, + thumbnail: { string: "Thumbnail", type: "text" }, + favorited_ids: { string: "Name", type: "many2many" }, + is_favorited: { string: "Name", type: "boolean" }, + mimetype: { string: "Mimetype", type: "char" }, + partner_id: { string: "Related partner", type: "many2one", relation: "partner" }, + owner_id: { string: "Owner", type: "many2one", relation: "partner" }, + handler: { + string: "Handler", + type: "selection", + selection: [["spreadsheet", "Spreadsheet"]], + }, + previous_attachment_ids: { + string: "History", + type: "many2many", + relation: "ir.attachment", + }, + tag_ids: { string: "Tags", type: "many2many", relation: "documents.tag" }, + folder_id: { string: "Workspaces", type: "many2one", relation: "documents.folder" }, + res_model: { string: "Model (technical)", type: "char" }, + available_rule_ids: { + string: "Rules", + type: "many2many", + relation: "documents.workflow.rule", + }, + }, + records: [ + { + id: 1, + name: "My spreadsheet", + raw: "{}", + is_favorited: false, + folder_id: 1, + handler: "spreadsheet", + }, + { + id: 2, + name: "", + raw: "{}", + is_favorited: true, + folder_id: 1, + handler: "spreadsheet", + }, + ], + }, + "ir.model": { + fields: { + name: { string: "Model Name", type: "char" }, + model: { string: "Model", type: "char" }, + }, + records: [ + { + id: 37, + name: "Product", + model: "product", + }, + { + id: 40, + name: "Partner", + model: "partner", + }, + ], + }, + "documents.folder": { + fields: { + name: { string: "Name", type: "char" }, + parent_folder_id: { + string: "Parent Workspace", + type: "many2one", + relation: "documents.folder", + }, + description: { string: "Description", type: "text" }, + }, + records: [ + { + id: 1, + name: "Workspace1", + description: "Workspace", + parent_folder_id: false, + }, + ], + }, + "documents.tag": { + fields: {}, + records: [], + get_tags: () => [], + }, + "documents.workflow.rule": { + fields: {}, + records: [], + }, + "documents.share": { + fields: {}, + records: [], + }, + "spreadsheet.template": { + fields: { + name: { string: "Name", type: "char" }, + data: { string: "Data", type: "binary" }, + thumbnail: { string: "Thumbnail", type: "binary" }, + }, + records: [ + { id: 1, name: "Template 1", data: btoa("{}") }, + { id: 2, name: "Template 2", data: btoa("{}") }, + ], + }, + "res.currency": { + fields: { + name: { string: "Code", type: "char" }, + symbol: { string: "Symbol", type: "char" }, + position: { + string: "Position", + type: "selection", + selection: [ + ["after", "A"], + ["before", "B"], + ], + }, + decimal_places: { string: "decimal", type: "integer" }, + }, + records: [ + { + id: 1, + name: "EUR", + symbol: "€", + position: "after", + decimal_places: 2, + }, + { + id: 2, + name: "USD", + symbol: "$", + position: "before", + decimal_places: 2, + }, + ], + }, + partner: { + fields: { + foo: { + string: "Foo", + type: "integer", + store: true, + searchable: true, + group_operator: "sum", + }, + bar: { string: "Bar", type: "boolean", store: true, sortable: true }, + name: { string: "name", type: "char", store: true, sortable: true }, + date: { string: "Date", type: "date", store: true, sortable: true }, + create_date: { + string: "Creation Date", + type: "datetime", + store: true, + sortable: true, + }, + active: { string: "Active", type: "bool", default: true }, + product_id: { + string: "Product", + type: "many2one", + relation: "product", + store: true, + sortable: true, + }, + tag_ids: { + string: "Tags", + type: "many2many", + relation: "tag", + store: true, + sortable: true, + }, + probability: { + string: "Probability", + type: "float", + searchable: true, + store: true, + group_operator: "avg", + }, + field_with_array_agg: { + string: "field_with_array_agg", + type: "integer", + searchable: true, + group_operator: "array_agg", + }, + currency_id: { + string: "Currency", + type: "many2one", + relation: "res.currency", + store: true, + sortable: true, + }, + pognon: { + string: "Money!", + type: "monetary", + currency_field: "currency_id", + store: true, + sortable: true, + group_operator: "avg", + }, + }, + records: [ + { + id: 1, + foo: 12, + bar: true, + date: "2016-04-14", + create_date: "2016-04-03 00:00:00", + product_id: 37, + probability: 10, + field_with_array_agg: 1, + tag_ids: [42, 67], + currency_id: 1, + pognon: 74.4, + }, + { + id: 2, + foo: 1, + bar: true, + date: "2016-10-26", + create_date: "2014-04-03 00:05:32", + product_id: 41, + probability: 11, + field_with_array_agg: 2, + tag_ids: [42, 67], + currency_id: 2, + pognon: 74.8, + }, + { + id: 3, + foo: 17, + bar: true, + date: "2016-12-15", + create_date: "2006-01-03 11:30:50", + product_id: 41, + probability: 95, + field_with_array_agg: 3, + tag_ids: [], + currency_id: 1, + pognon: 4, + }, + { + id: 4, + foo: 2, + bar: false, + date: "2016-12-11", + create_date: "2016-12-10 21:59:59", + product_id: 41, + probability: 15, + field_with_array_agg: 4, + tag_ids: [42], + currency_id: 2, + pognon: 1000, + }, + ], + }, + product: { + fields: { + name: { string: "Product Name", type: "char" }, + active: { string: "Active", type: "bool", default: true }, + }, + records: [ + { + id: 37, + display_name: "xphone", + }, + { + id: 41, + display_name: "xpad", + }, + ], + }, + tag: { + fields: { + name: { string: "Tag Name", type: "char" }, + }, + records: [ + { + id: 42, + display_name: "isCool", + }, + { + id: 67, + display_name: "Growing", + }, + ], + }, + }; +} diff --git a/addons/spreadsheet/static/tests/utils/date_domain.js b/addons/spreadsheet/static/tests/utils/date_domain.js new file mode 100644 index 00000000000..df1967e2821 --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/date_domain.js @@ -0,0 +1,52 @@ +/** @odoo-module */ + +const { DateTime } = luxon; +import { Domain } from "@web/core/domain"; + +function getDateDomainBounds(domain) { + const startDateStr = domain[1][2]; + const endDateStr = domain[2][2]; + + const isDateTime = startDateStr.includes(":"); + + if (isDateTime) { + const dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; + const start = DateTime.fromFormat(startDateStr, dateTimeFormat); + const end = DateTime.fromFormat(endDateStr, dateTimeFormat); + return { start, end }; + } + + const start = DateTime.fromISO(startDateStr); + const end = DateTime.fromISO(endDateStr); + const startIsIncluded = domain[1][1] === ">="; + const endIsIncluded = domain[2][1] === "<="; + return { + start: startIsIncluded ? start.startOf("day") : start.endOf("day"), + end: endIsIncluded ? end.endOf("day") : end.startOf("day"), + }; +} + +/** + * @param {object} assert + * @param {string} field + * @param {string} start + * @param {string} end + * @param {import("@web/core/domain").DomainRepr} domain + */ +export function assertDateDomainEqual(assert, field, start, end, domain) { + domain = new Domain(domain).toList(); + assert.deepEqual(domain[0], "&"); + assert.deepEqual(domain[1], [field, ">=", start]); + assert.deepEqual(domain[2], [field, "<=", end]); +} + +/** + * @param {import("@web/core/domain").DomainRepr} domain + * @returns {number} + */ +export function getDateDomainDurationInDays(domain) { + domain = new Domain(domain).toList(); + const bounds = getDateDomainBounds(domain); + const diff = bounds.end.diff(bounds.start, ["days"]); + return Math.round(diff.days); +} diff --git a/addons/spreadsheet/static/tests/utils/getters.js b/addons/spreadsheet/static/tests/utils/getters.js new file mode 100644 index 00000000000..9041a41de68 --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/getters.js @@ -0,0 +1,55 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { toCartesian } = spreadsheet.helpers; + +/** + * Get the value of the given cell + */ +export function getCellValue(model, xc, sheetId = model.getters.getActiveSheetId()) { + const { col, row } = toCartesian(xc); + const cell = model.getters.getCell(sheetId, col, row); + if (!cell) { + return undefined; + } + return cell.evaluated.value; +} + +/** + * Get the cell of the given xc + */ +export function getCell(model, xc, sheetId = model.getters.getActiveSheetId()) { + const { col, row } = toCartesian(xc); + return model.getters.getCell(sheetId, col, row); +} + +/** + * Get the cells of the given sheet (or active sheet if not provided) + */ +export function getCells(model, sheetId = model.getters.getActiveSheetId()) { + return model.getters.getCells(sheetId); +} + +/** + * Get the formula of the given xc + */ +export function getCellFormula(model, xc, sheetId = model.getters.getActiveSheetId()) { + const cell = getCell(model, xc, sheetId); + return cell && cell.isFormula() ? model.getters.getFormulaCellContent(sheetId, cell) : ""; +} + +/** + * Get the content of the given xc + */ +export function getCellContent(model, xc, sheetId = model.getters.getActiveSheetId()) { + const cell = getCell(model, xc, sheetId); + return cell ? model.getters.getCellText(cell, sheetId, true) : ""; +} + +/** + * Get the list of the merges (["A1:A2"]) of the sheet + */ +export function getMerges(model, sheetId = model.getters.getActiveSheetId()) { + return model.exportData().sheets.find((sheet) => sheet.id === sheetId).merges; +} diff --git a/addons/spreadsheet/static/tests/utils/list.js b/addons/spreadsheet/static/tests/utils/list.js new file mode 100644 index 00000000000..dffb50faf3b --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/list.js @@ -0,0 +1,71 @@ +/** @odoo-module */ + +import { nextTick } from "@web/../tests/helpers/utils"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { generateListDefinition } from "./data"; +import { createModelWithDataSource } from "./model"; + +const uuidGenerator = new spreadsheet.helpers.UuidGenerator(); + +/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */ + +/** + * Insert a list in a spreadsheet model. + * + * @param {Model} model + * @param {Object} params + * @param {string} params.model + * @param {Array} params.columns + * @param {number} [params.linesNumber] + * @param {[number, number]} [params.position] + * @param {string} [params.sheetId] + */ +export function insertListInSpreadsheet(model, params) { + const { definition, columns } = generateListDefinition(params.model, params.columns); + const [col, row] = params.position || [0, 0]; + + model.dispatch("INSERT_ODOO_LIST", { + sheetId: params.sheetId || model.getters.getActiveSheetId(), + definition, + linesNumber: params.linesNumber || 10, + columns, + id: model.getters.getNextListId(), + col, + row, + dataSourceId: uuidGenerator.uuidv4(), + }); +} + +/** + * + * @param {Object} params + * @param {string} [params.model] + * @param {Array} [params.columns] + * @param {Object} [params.serverData] + * @param {function} [params.mockRPC] + * @param {number} [params.linesNumber] + * @param {[number, number]} [params.position] + * @param {string} [params.sheetId] + * + * @returns { Promise<{ model: Model, env: Object }>} + */ +export async function createSpreadsheetWithList(params = {}) { + const model = await createModelWithDataSource({ + mockRPC: params.mockRPC, + serverData: params.serverData, + }); + + insertListInSpreadsheet(model, { + columns: params.columns || ["foo", "bar", "date", "product_id"], + model: params.model || "partner", + linesNumber: params.linesNumber, + position: params.position, + sheetId: params.sheetId, + }); + + const env = model.config.evalContext.env; + env.model = model; + await nextTick(); + return { model, env }; +} diff --git a/addons/spreadsheet/static/tests/utils/mock_server.js b/addons/spreadsheet/static/tests/utils/mock_server.js new file mode 100644 index 00000000000..6a2de0342be --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/mock_server.js @@ -0,0 +1,31 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; + +registry + .category("mock_server") + .add("res.currency/get_currencies_for_spreadsheet", function (route, args) { + const currencyNames = args.args[0]; + const result = []; + for (let currencyName of currencyNames) { + const curr = this.models["res.currency"].records.find( + (curr) => curr.name === currencyName + ); + + result.push({ + code: curr.name, + symbol: curr.symbol, + decimalPlaces: curr.decimal_places || 2, + position: curr.position || "after", + }); + } + return result; + }) + .add("res.currency/get_company_currency_for_spreadsheet", function (route, args) { + return { + code: "EUR", + symbol: "€", + position: "after", + decimalPlaces: 2, + }; + }); diff --git a/addons/spreadsheet/static/tests/utils/model.js b/addons/spreadsheet/static/tests/utils/model.js new file mode 100644 index 00000000000..a19c767cdc5 --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/model.js @@ -0,0 +1,55 @@ +/** @odoo-module */ + +import { ormService } from "@web/core/orm_service"; +import { registry } from "@web/core/registry"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { nextTick } from "@web/../tests/helpers/utils"; + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { DataSources } from "@spreadsheet/data_sources/data_sources"; +import { getBasicServerData } from "./data"; + +const { Model } = spreadsheet; + +/** + * @typedef {import("@spreadsheet/../tests/utils/data").ServerData} ServerData + */ + +export function setupDataSourceEvaluation(model) { + model.config.dataSources.addEventListener("data-source-updated", () => { + const sheetId = model.getters.getActiveSheetId(); + model.dispatch("EVALUATE_CELLS", { sheetId }); + }); +} + +/** + * Create a spreadsheet model with a mocked server environnement + * + * @param {object} params + * @param {object} [params.spreadsheetData] Spreadsheet data to import + * @param {ServerData} [params.serverData] Data to be injected in the mock server + * @param {function} [params.mockRPC] Mock rpc function + */ +export async function createModelWithDataSource(params = {}) { + registry.category("services").add("orm", ormService, { force: true }); + const env = await makeTestEnv({ + serverData: params.serverData || getBasicServerData(), + mockRPC: params.mockRPC, + }); + const model = new Model(params.spreadsheetData, { + evalContext: { env }, + //@ts-ignore + dataSources: new DataSources(env.services.orm.silent), + }); + setupDataSourceEvaluation(model); + await nextTick(); // initial async formulas loading + return model; +} + +/** + * @param {Model} model + */ +export async function waitForDataSourcesLoaded(model) { + //@ts-ignore + await model.config.dataSources.waitForAllLoaded(); +} diff --git a/addons/spreadsheet/static/tests/utils/pivot.js b/addons/spreadsheet/static/tests/utils/pivot.js new file mode 100644 index 00000000000..15958320c84 --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/pivot.js @@ -0,0 +1,74 @@ +/** @odoo-module */ + +import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser"; +import { nextTick } from "@web/../tests/helpers/utils"; + +import PivotDataSource from "@spreadsheet/pivot/pivot_data_source"; +import { getBasicServerData } from "./data"; +import { createModelWithDataSource } from "./model"; + +/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */ + +/** + * @param {Model} model + * @param {object} params + * @param {string} params.arch + * @param {[number, number]} [params.anchor] + */ +export async function insertPivotInSpreadsheet(model, params) { + const archInfo = new PivotArchParser().parse(params.arch); + const definition = { + metaData: { + colGroupBys: archInfo.colGroupBys, + rowGroupBys: archInfo.rowGroupBys, + activeMeasures: archInfo.activeMeasures, + resModel: "partner", + }, + searchParams: { + domain: [], + context: {}, + groupBy: [], + orderBy: [], + }, + name: "Partner Pivot", + }; + const dataSource = model.config.dataSources.create(PivotDataSource, definition); + await dataSource.load(); + const { cols, rows, measures } = dataSource.getTableStructure().export(); + const table = { + cols, + rows, + measures, + }; + const [col, row] = params.anchor || [0, 0]; + model.dispatch("INSERT_PIVOT", { + id: model.getters.getNextPivotId(), + sheetId: model.getters.getActiveSheetId(), + col, + row, + table, + dataSourceId: "pivotData1", + definition, + }); + await nextTick(); +} + +/** + * @param {object} params + * @param {string} [params.arch] + * @param {object} [params.serverData] + * @param {function} [params.mockRPC] + * @returns {Promise<{ model: Model, env: object}>} + */ +export async function createSpreadsheetWithPivot(params = {}) { + const serverData = params.serverData || getBasicServerData(); + const model = await createModelWithDataSource({ + mockRPC: params.mockRPC, + serverData: params.serverData, + }); + const arch = params.arch || serverData.views["partner,false,pivot"]; + await insertPivotInSpreadsheet(model, { arch }); + const env = model.config.evalContext.env; + env.model = model; + return { model, env }; +} diff --git a/addons/spreadsheet/static/tests/utils/pivot_list.js b/addons/spreadsheet/static/tests/utils/pivot_list.js new file mode 100644 index 00000000000..1e114e49dd8 --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/pivot_list.js @@ -0,0 +1,15 @@ +/** @odoo-module */ + +import { nextTick } from "@web/../tests/helpers/utils"; +import { createSpreadsheetWithPivot } from "./pivot"; +import { insertListInSpreadsheet } from "./list"; + +export async function createSpreadsheetWithPivotAndList() { + const { model, env } = await createSpreadsheetWithPivot(); + insertListInSpreadsheet(model, { + model: "partner", + columns: ["foo", "bar", "date", "product_id"], + }); + await nextTick(); + return { env, model }; +} diff --git a/addons/spreadsheet/static/tests/utils/ui.js b/addons/spreadsheet/static/tests/utils/ui.js new file mode 100644 index 00000000000..d296f1f547b --- /dev/null +++ b/addons/spreadsheet/static/tests/utils/ui.js @@ -0,0 +1,44 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { registerCleanup } from "@web/../tests/helpers/cleanup"; +import { getFixture } from "@web/../tests/helpers/utils"; +import { loadJS } from "@web/core/assets"; + +const { App } = owl; +const { Spreadsheet } = spreadsheet; +const { getMenuChildren } = spreadsheet.helpers; + +/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */ + +/** + * Mount o-spreadsheet component with the given spreadsheet model + * @param {Model} model + * @returns {Promise} + */ +export async function mountSpreadsheet(model) { + await loadJS("/web/static/lib/Chart/Chart.js"); + const app = new App(Spreadsheet, { + props: { model }, + templates: window.__OWL_TEMPLATES__, + env: model.config.evalContext.env, + test: true, + }); + registerCleanup(() => app.destroy()); + const fixture = getFixture(); + await app.mount(fixture); + return fixture; +} + +export async function doMenuAction(registry, path, env) { + const root = path[0]; + let node = registry.get(root); + for (const p of path.slice(1)) { + const children = getMenuChildren(node, env); + node = children.find((child) => child.id === p); + } + if (!node) { + throw new Error(`Cannot find menu with path "${path.join("/")}"`); + } + await node.action(env); +} diff --git a/addons/spreadsheet/tests/__init__.py b/addons/spreadsheet/tests/__init__.py new file mode 100644 index 00000000000..28165b50638 --- /dev/null +++ b/addons/spreadsheet/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_currency +from . import test_currency_rate diff --git a/addons/spreadsheet/tests/test_currency.py b/addons/spreadsheet/tests/test_currency.py new file mode 100644 index 00000000000..26192470441 --- /dev/null +++ b/addons/spreadsheet/tests/test_currency.py @@ -0,0 +1,85 @@ +from odoo.tests.common import TransactionCase + +class TestCurrencyRates(TransactionCase): + @classmethod + def setUpClass(cls): + super(TestCurrencyRates, cls).setUpClass() + cls.env["res.currency"].create( + [ + { + "name": "MC1", + "symbol": ":D", + "rounding": 0.001, + }, + { + "name": "MC2", + "symbol": "§", + }, + ] + ) + eur_company = cls.env["res.company"].create( + {"name": "Company with EUR", "currency_id": cls.env.ref("base.EUR").id} + ) + usd_company = cls.env["res.company"].create( + {"name": "Company with USD", "currency_id": cls.env.ref("base.USD").id} + ) + cls.env.user.company_ids |= eur_company + cls.env.user.company_ids |= usd_company + cls.env.user.company_id = eur_company + cls.usd_company_id = usd_company.id + + def test_get_currencies_for_spreadsheet(self): + self.assertEqual( + self.env["res.currency"].get_currencies_for_spreadsheet(["MC1", "MC2"]), + [ + { + "code": "MC1", + "symbol": ":D", + "decimalPlaces": 3, + "position": "after", + }, + { + "code": "MC2", + "symbol": "§", + "decimalPlaces": 2, + "position": "after", + }, + ], + ) + + self.assertEqual( + self.env["res.currency"].get_currencies_for_spreadsheet(["ProbablyNotACurrencyName?", "MC2"]), + [ + None, + { + "code": "MC2", + "symbol": "§", + "decimalPlaces": 2, + "position": "after", + }, + ], + ) + + def test_get_company_currency_for_spreadsheet(self): + self.assertEqual( + self.env["res.currency"].get_company_currency_for_spreadsheet(), + { + "code": "EUR", + "symbol": "€", + "decimalPlaces": 2, + "position": "after", + } + ) + self.assertEqual( + self.env["res.currency"].get_company_currency_for_spreadsheet(self.usd_company_id), + { + "code": "USD", + "symbol": "$", + "decimalPlaces": 2, + "position": "before", + } + ) + self.assertEqual( + self.env["res.currency"].get_company_currency_for_spreadsheet(123456), + False + ) diff --git a/addons/spreadsheet/tests/test_currency_rate.py b/addons/spreadsheet/tests/test_currency_rate.py new file mode 100644 index 00000000000..239f71f93e0 --- /dev/null +++ b/addons/spreadsheet/tests/test_currency_rate.py @@ -0,0 +1,136 @@ +from freezegun import freeze_time + +from odoo.tests.common import TransactionCase + +CURRENT_USD = 1.5 +CURRENT_EUR = 1 +CURRENT_CAD = 1.2 +USD_11 = 1.8 +CAD_11 = 1.9 +CAD_UTC = 1.3 +CAD_AUS = 2 + +fake_now_utc = "2020-01-01 21:00:00" + + +class TestCurrencyRates(TransactionCase): + @classmethod + def setUpClass(cls): + super(TestCurrencyRates, cls).setUpClass() + usd = cls.env.ref("base.USD") + eur = cls.env.ref("base.EUR") + cad = cls.env.ref("base.CAD") + new_company = cls.env["res.company"].create( + {"name": "Test Currency Company", "currency_id": eur.id} + ) + cls.env.user.company_ids |= new_company + cls.env.user.company_id = new_company + + cls.env["res.currency.rate"].create( + [ + { + "currency_id": usd.id, + "rate": CURRENT_USD, + }, + { + "currency_id": cad.id, + "rate": CURRENT_CAD, + }, + { + "name": "2021-11-11", + "currency_id": usd.id, + "rate": USD_11, + }, + { + "name": "2021-11-11", + "currency_id": cad.id, + "rate": CAD_11, + }, + ] + ) + + def test_currency_without_date(self): + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("USD", "EUR"), + CURRENT_EUR / CURRENT_USD, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("EUR", "USD"), + CURRENT_USD, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("USD", "CAD"), + CURRENT_CAD / CURRENT_USD, + ) + + def test_currency_with_date(self): + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet( + "USD", "EUR", "2021-11-11" + ), + CURRENT_EUR / USD_11, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet( + "EUR", "USD", "2021-11-11" + ), + USD_11, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet( + "USD", "CAD", "2021-11-11" + ), + CAD_11 / USD_11, + ) + + def test_currency_invalid_args(self): + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("INVALID", "EUR"), + False, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("EUR", "INVALID"), + False, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("INVALID", "USD"), + False, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("USD", "INVALID"), + False, + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet(False, "EUR"), False + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("EUR", False), False + ) + + @freeze_time(fake_now_utc) + def test_rate_by_tz(self): + cad = self.env.ref("base.CAD") + self.env.user.tz = "UTC" + self.env["res.currency.rate"].create( + { + "currency_id": cad.id, + "rate": CAD_UTC, + } + ) + self.env.user.tz = "Australia/ACT" + self.env["res.currency.rate"].create( + { + "currency_id": cad.id, + "rate": CAD_AUS, + } + ) + self.assertEqual( + self.env["res.currency.rate"]._get_rate_for_spreadsheet("CAD", "EUR"), + CURRENT_EUR / CAD_AUS, + ) + self.assertEqual( + self.env["res.currency.rate"] + .with_context(tz="UTC") + ._get_rate_for_spreadsheet("CAD", "EUR"), + CURRENT_EUR / CAD_UTC, + ) diff --git a/addons/web/tooling/_eslintignore b/addons/web/tooling/_eslintignore index e4c80ea87db..38620c5f287 100644 --- a/addons/web/tooling/_eslintignore +++ b/addons/web/tooling/_eslintignore @@ -80,11 +80,11 @@ web_map/static/tests/legacy/**/* !documents_spreadsheet/**/* # Whitelist spreadsheet -!spreadsheet -!spreadsheet/**/* +!addons/spreadsheet +!addons/spreadsheet/**/* # blacklist o-spreadsheet lib -spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js +addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js # Whitelist spreadsheet_edition !spreadsheet_edition diff --git a/addons/web/tooling/_prettierignore b/addons/web/tooling/_prettierignore index 85356fc90ef..3d0447e2b5e 100644 --- a/addons/web/tooling/_prettierignore +++ b/addons/web/tooling/_prettierignore @@ -80,11 +80,11 @@ web_map/static/tests/legacy/**/* !documents_spreadsheet/**/* # Whitelist spreadsheet -!spreadsheet -!spreadsheet/**/* +!addons/spreadsheet +!addons/spreadsheet/**/* # blacklist o-spreadsheet lib -spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js +addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js # Whitelist spreadsheet_edition !spreadsheet_edition