From 61ead7306277cfa963c9d38b1e10e0ad8f203493 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Mon, 10 Aug 2026 18:19:00 +0200 Subject: [PATCH 01/18] Minor change to the text showed by the release script --- script/release/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release.sh b/script/release/release.sh index e447a70f79..cebdc65fd9 100644 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -1187,10 +1187,10 @@ manual_checklist < Date: Thu, 27 Aug 2026 17:58:56 +0200 Subject: [PATCH 02/18] Improve the docs search with frontmatter tags (HF-353) (#1750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context [HF-353](https://app.clickup.com/t/9015210959/HF-353) started from one symptom: the official HyperFormula skill for Claude Code was documented only on the [Set up your coding agent](https://hyperformula.handsontable.com/docs/guide/setup-coding-agent.html) page, and searching the docs for `skill` returned nothing. The cause is general, not specific to that page. The docs search box builds its match domain from a page's **title + `tags` frontmatter + `h2`/`h3` heading titles** only ([`docs/.vuepress/plugins/search-box/match-query.js`](docs/.vuepress/plugins/search-box/match-query.js)) — body text is never indexed. Until now **no page in the repository used `tags`**, so every page was findable only by the words it happened to put in a heading. A reader who types the Excel term, the error value, the method name or the abbreviation found nothing. Changes: - **Every guide page** (55 of them) now carries a `tags` list of the words a reader really types: the Excel or Google Sheets term (`GSheets`, `MS Excel`, `defined names`), the literal error value (`#REF!`, `#DIV/0!`), the API method (`buildFromArray`, `setRowOrder`, `registerFunctionPlugin`), the abbreviation (`CLA`, `GPL`, `CRUD`, `SSR`, `IE11`, `UDF`), the third-party name (`ExcelJS`, `Pinia`, `Chevrotain`, `Context7`) and the plain-language word for the problem (`slow`, `memory`, `division by zero`, `quickstart`). - **`docs/index.md`** and **`README.md`** — one paragraph at the end of "Installation and usage" mentioning the skill and linking to the coding-agent guide. The two files are hand-maintained mirrors of one page, so the paragraph went into both. - **`docs/guide/client-side-installation.md`** and **`docs/guide/server-side-installation.md`** — both installation guides now close with a short "Set up your coding agent" section pointing at the same guide. These are the pages a reader actually lands on when installing, and neither repeats the skill's setup commands. - **`README.md`** (same page, drive-by) — its 22 documentation links, and the one in `.github/pull_request_template.md`, still used the pre-Cloudflare `hyperformula.handsontable.com/guide/.html` form. The docs are built under `/docs/` and served from `https://hyperformula.handsontable.com/docs/` (`docs/README.md`, `docs/.vuepress/build.config.js`) — the form `src/interpreter/functionMetadata/**` already uses — so all of them now carry the `/docs/` prefix. - **`DOCS_CONTENT_GUIDE.md`** — documents `tags` in "VuePress conventions": what it is for, the 10-tag limit, why a tag ten pages claim is worthless, and that it must be a YAML **list** (a plain string makes `match-query.js` call `.join()` on a string and breaks the search box site-wide). The tags follow four rules: 1. **At most 10 per page**, fewer where fewer will do — the range is 2 to 10, 353 in total. 2. **Never a word the page's own title or headings already match** — those match anyway, so such a tag buys nothing. This is why `setup-coding-agent.md` lost `Cursor`, `Copilot` and `Claude Code` (all in its own headings) and `LLM` (a substring of `llms.txt`) when it was trimmed to the same limit. 3. **Never a generic word the page is not the best destination for**, remembering that a multi-word tag is matched word by word, so `rules file` would drag the page into results for `file` and `rules`. 4. **Only what the page truly answers** — nothing tags a feature it does not document. `timezone`, for instance, is tagged nowhere, because no page covers time zones. `tags` has exactly one consumer in the repo (that search plugin), and frontmatter is stripped from the `.md` companions and `llms-full.txt`, so nothing outside the search index changes. Tags on `built-in-functions.tmpl.md` reach the generated built-in functions page, which `script/generate-builtin-functions-doc.ts` splices from that template. ### How did you test your changes? **The search behaviour.** `npm run docs:build` cannot run in the authoring environment (no `node_modules`, no registry access), so this was tested by **executing the shipped search code** — `match-query.js` and `SearchBox.vue`'s `suggestions` / `suggestionsFromCategory` logic, loaded through `vm` with `lodash/get` stubbed — against page objects built from the real Markdown sources the way `@vuepress/core` 1.9.10 does (title from frontmatter or the first `H1`, headers from `h2`/`h3` only, frontmatter from the YAML block). Results over the whole tag set: | check | result | | --- | --- | | tag queries run (every tag of every page) | 353 | | tags that fail to surface their own page | **0** | | tags returning more than 6 rows (limit is 10, unranked) | **0** | | `skill` → the coding-agent page | `Guides: Set up your coding agent` (was: no results) | Spot checks land where they should, page-level or deep-linked: `#REF!` → `Cell references > The #REF! error` and `Types of errors`; `CLA` → `Contributing`; `GPL` → `License key`, `Licensing > GPLv3 license`; `IE11` → `Supported browsers`; `buildFromArray` → `Basic usage`; `Pinia` → `Integration with Vue`; `modulo` → `Types of operators`; `trace precedents` → `Dependency graph`; `division by zero` → `Types of errors`. The error values are quoted in YAML (`- "#REF!"`) on purpose: unquoted, `#` starts a comment, and the unprefixed `REF!` would miss the `#REF!` a reader actually types. The quoted form matches `ref`, `REF!` and `#REF!` alike. **The rest.** - Every frontmatter block parses with a real YAML parser — `tags` reads back as a list of strings on all 56 pages, and no page exceeds 10. - The applier refuses to write a tag list that breaks an invariant (over 10, duplicates, a tag that is a substring of another on the same page, an unquotable value), so none of those can slip in. - `build-docs` (a real `npm run docs:build`) and the Cloudflare preview deployment both passed on the commit carrying all 55 tagged pages, which is the authoritative check that VuePress accepts this frontmatter. - The new sections on the two installation guides keep `skill` pointed at the coding-agent page (still a single row for that query); `coding agent` now returns three rows — the guide itself plus the two new deep links. - Every rewritten README link was checked against the docs sources: all 21 distinct URLs resolve to an existing page, and the two anchors in use (`#install-with-npm-or-yarn`, `#demo`) exist. - README.md and docs/index.md were diffed under the link-form normalisation and are line-for-line identical apart from the docs page's frontmatter and its leading `
` spacing. - The diff is Markdown and YAML only, so no lint, unit test or build step is affected; each edited page keeps its own line-wrapping convention, no trailing whitespace, no CRLF. Worth a reviewer's eye: 1. `Codex` and `Windsurf` are tagged on the coding-agent page although it never names them — they fall under its "Cursor, Copilot & other agents" section, and pointing such a user at `llms-full.txt` is exactly the advice they need. 2. Four pages share `SSR` and four share `breaking changes`; three share the AI-integration vocabulary (`AI agents`, `LLM`, `tool calling`). Each genuinely answers those queries, and the row counts stay well under the limit, but it is the kind of thing to keep an eye on as more tags are added. 3. `docs/index.md` is deliberately untagged: it has no `H1`, so VuePress infers no title for it and its search rows would be labelled by path. ### Types of changes - [ ] Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore) - [ ] New feature or improvement (a non-breaking change that adds functionality) - [ ] Bug fix (a non-breaking change that fixes an issue) - [ ] Additional language file, or a change to an existing language file (translations) - [x] Change to the documentation ### Related issues: 1. [HF-353](https://app.clickup.com/t/9015210959/HF-353) ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/docs/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [ ] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [ ] My change is compatible with Microsoft Excel. - [ ] My change is compatible with Google Sheets. - [ ] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [x] My changes require a documentation update. - [ ] My changes require a migration guide. Deliberately left out: no `CHANGELOG.md` / release-notes entry (documentation-only change, and the 3.4.0 mention of the coding-agent page was dropped from this PR on request). Four links of the old `/guide/` form survive in `CHANGELOG.md` (3) and `docs/guide/release-notes.md` (1) — those two files were off limits here, so they are worth a separate one-line fix. --------- Co-authored-by: Claude Opus 5 --- .github/pull_request_template.md | 2 +- DOCS_CONTENT_GUIDE.md | 7 +++ README.md | 45 ++++++++++--------- docs/guide/advanced-usage.md | 8 ++++ docs/guide/ai-sdk.md | 11 +++++ docs/guide/arrays.md | 10 +++++ docs/guide/basic-operations.md | 13 ++++++ docs/guide/basic-usage.md | 9 ++++ docs/guide/batch-operations.md | 9 ++++ docs/guide/branding.md | 8 ++++ docs/guide/building.md | 11 +++++ docs/guide/built-in-functions.tmpl.md | 8 ++++ docs/guide/cell-references.md | 11 +++++ docs/guide/client-side-installation.md | 14 ++++++ docs/guide/clipboard-operations.md | 7 +++ docs/guide/code-of-conduct.md | 10 +++++ .../guide/compatibility-with-google-sheets.md | 9 ++++ .../compatibility-with-microsoft-excel.md | 12 +++++ docs/guide/configuration-options.md | 8 ++++ docs/guide/contact.md | 11 +++++ docs/guide/contributing.md | 10 +++++ docs/guide/currency-handling.md | 11 +++++ docs/guide/custom-functions.md | 12 +++++ docs/guide/date-and-time-handling.md | 12 +++++ docs/guide/demo.md | 7 +++ docs/guide/dependencies.md | 11 +++++ docs/guide/dependency-graph.md | 11 +++++ docs/guide/file-import.md | 9 ++++ docs/guide/i18n-features.md | 10 +++++ docs/guide/integration-with-angular.md | 13 ++++++ docs/guide/integration-with-langchain.md | 9 ++++ docs/guide/integration-with-react.md | 12 +++++ docs/guide/integration-with-svelte.md | 9 ++++ docs/guide/integration-with-vue.md | 12 +++++ docs/guide/key-concepts.md | 10 +++++ docs/guide/known-limitations.md | 10 +++++ docs/guide/license-key.md | 11 +++++ docs/guide/licensing.md | 10 +++++ docs/guide/list-of-differences.md | 10 +++++ docs/guide/localizing-functions.md | 8 ++++ docs/guide/mcp-server.md | 10 +++++ docs/guide/migration-from-0.6-to-1.0.md | 10 +++++ docs/guide/migration-from-1.x-to-2.0.md | 11 +++++ docs/guide/migration-from-2.x-to-3.0.md | 14 ++++++ docs/guide/named-expressions.md | 12 +++++ docs/guide/order-of-precendece.md | 8 ++++ docs/guide/performance.md | 12 +++++ docs/guide/quality.md | 10 +++++ docs/guide/release-notes.md | 9 ++++ docs/guide/server-side-installation.md | 17 +++++++ docs/guide/setup-coding-agent.md | 15 +++++++ docs/guide/sorting-data.md | 11 +++++ docs/guide/specifications-and-limits.md | 12 +++++ docs/guide/supported-browsers.md | 10 +++++ docs/guide/types-of-errors.md | 14 ++++++ docs/guide/types-of-operators.md | 14 ++++++ docs/guide/types-of-values.md | 14 ++++++ docs/guide/undo-redo.md | 9 ++++ docs/guide/volatile-functions.md | 11 +++++ docs/index.md | 2 + 60 files changed, 632 insertions(+), 23 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 98f8337238..9898efe1b2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,7 +20,7 @@ ### Checklist: -- [ ] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/guide/contributing.html) and I confirm that my code follows the code style of this project. +- [ ] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/docs/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [ ] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [ ] My change is compatible with Microsoft Excel. diff --git a/DOCS_CONTENT_GUIDE.md b/DOCS_CONTENT_GUIDE.md index 899c402fe8..08d160f128 100644 --- a/DOCS_CONTENT_GUIDE.md +++ b/DOCS_CONTENT_GUIDE.md @@ -220,6 +220,13 @@ and hallucinate the parts you omit. description: Register and use your own functions in HyperFormula. --- ``` +- Add a `tags` frontmatter **list** to make a page findable by words it does not use in +its title or its `##`/`###` headings — the search box matches those three things only, +never the body text (see `docs/guide/setup-coding-agent.md`, tagged `skills`, `Cursor`, +`MCP`). Keep it to at most 10 tags a page, each one a word a reader would really type, and +leave a query to the page that answers it best — a tag ten pages claim is decided by file +order, not relevance, once the 10-result limit bites. A plain string instead of a list breaks +the search box site-wide. - Use containers for asides: `::: tip`, `::: warning`, `::: danger` … `:::`. Put essential steps in the body, not hidden inside a tip. - Use **relative links** between guide pages (`./named-expressions.md`) and link to diff --git a/README.md b/README.md index f1a7b08467..8d3d14707b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ --- - HyperFormula is a headless spreadsheet built in TypeScript, serving as both a parser and evaluator of spreadsheet formulas. It can be integrated into your browser or utilized as a service with Node.js as your back-end technology. ## What HyperFormula can be used for? @@ -39,36 +38,36 @@ HyperFormula doesn't assume any existing user interface, making it a general-pur ## Features -- [Function syntax compatible with Microsoft Excel](https://hyperformula.handsontable.com/guide/compatibility-with-microsoft-excel.html) and [Google Sheets](https://hyperformula.handsontable.com/guide/compatibility-with-google-sheets.html) +- [Function syntax compatible with Microsoft Excel](https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html) and [Google Sheets](https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html) - High-speed parsing and evaluation of spreadsheet formulas -- [A library of ~400 built-in functions](https://hyperformula.handsontable.com/guide/built-in-functions.html) -- [Support for custom functions](https://hyperformula.handsontable.com/guide/custom-functions.html) -- [Support for Node.js](https://hyperformula.handsontable.com/guide/server-side-installation.html#install-with-npm-or-yarn) -- [Support for undo/redo](https://hyperformula.handsontable.com/guide/undo-redo.html) -- [Support for CRUD operations](https://hyperformula.handsontable.com/guide/basic-operations.html) -- [Support for clipboard](https://hyperformula.handsontable.com/guide/clipboard-operations.html) -- [Support for named expressions](https://hyperformula.handsontable.com/guide/named-expressions.html) -- [Support for data sorting](https://hyperformula.handsontable.com/guide/sorting-data.html) -- [Support for formula localization with 17 built-in languages](https://hyperformula.handsontable.com/guide/i18n-features.html) +- [A library of ~400 built-in functions](https://hyperformula.handsontable.com/docs/guide/built-in-functions.html) +- [Support for custom functions](https://hyperformula.handsontable.com/docs/guide/custom-functions.html) +- [Support for Node.js](https://hyperformula.handsontable.com/docs/guide/server-side-installation.html#install-with-npm-or-yarn) +- [Support for undo/redo](https://hyperformula.handsontable.com/docs/guide/undo-redo.html) +- [Support for CRUD operations](https://hyperformula.handsontable.com/docs/guide/basic-operations.html) +- [Support for clipboard](https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html) +- [Support for named expressions](https://hyperformula.handsontable.com/docs/guide/named-expressions.html) +- [Support for data sorting](https://hyperformula.handsontable.com/docs/guide/sorting-data.html) +- [Support for formula localization with 17 built-in languages](https://hyperformula.handsontable.com/docs/guide/i18n-features.html) - Easy integration with any front-end or back-end application - GPLv3 or a [commercial license](https://handsontable.com/get-a-quote) - Maintained by the team that stands behind the [Handsontable](https://handsontable.com/) data grid ## Documentation -- [Client-side installation](https://hyperformula.handsontable.com/guide/client-side-installation.html) -- [Server-side installation](https://hyperformula.handsontable.com/guide/server-side-installation.html) -- [Basic usage](https://hyperformula.handsontable.com/guide/basic-usage.html) -- [Configuration options](https://hyperformula.handsontable.com/guide/configuration-options.html) -- [List of built-in functions](https://hyperformula.handsontable.com/guide/built-in-functions.html) -- [API Reference](https://hyperformula.handsontable.com/api/) +- [Client-side installation](https://hyperformula.handsontable.com/docs/guide/client-side-installation.html) +- [Server-side installation](https://hyperformula.handsontable.com/docs/guide/server-side-installation.html) +- [Basic usage](https://hyperformula.handsontable.com/docs/guide/basic-usage.html) +- [Configuration options](https://hyperformula.handsontable.com/docs/guide/configuration-options.html) +- [List of built-in functions](https://hyperformula.handsontable.com/docs/guide/built-in-functions.html) +- [API Reference](https://hyperformula.handsontable.com/docs/api/) ## Integrations -- [Integration with React](https://hyperformula.handsontable.com/guide/integration-with-react.html#demo) -- [Integration with Angular](https://hyperformula.handsontable.com/guide/integration-with-angular.html#demo) -- [Integration with Vue](https://hyperformula.handsontable.com/guide/integration-with-vue.html#demo) -- [Integration with Svelte](https://hyperformula.handsontable.com/guide/integration-with-svelte.html#demo) +- [Integration with React](https://hyperformula.handsontable.com/docs/guide/integration-with-react.html#demo) +- [Integration with Angular](https://hyperformula.handsontable.com/docs/guide/integration-with-angular.html#demo) +- [Integration with Vue](https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html#demo) +- [Integration with Svelte](https://hyperformula.handsontable.com/docs/guide/integration-with-svelte.html#demo) ## Installation and usage @@ -104,9 +103,11 @@ console.log(`${hf.getCellValue({ sheet: sheetId, row: 0, col: 0 })}: ${hf.getCel [Run this code in StackBlitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.4.x/mortgage-calculator) +HyperFormula ships an official Claude skill and machine-readable docs, so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. To install the skill in Claude Code, or to point Cursor, GitHub Copilot, or another agent at the docs, see [Set up your coding agent](https://hyperformula.handsontable.com/docs/guide/setup-coding-agent.html). + ## Contributing -Contributions are welcome, but before you make them, please read the [Contributing Guide](https://hyperformula.handsontable.com/guide/contributing.html) and accept the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). +Contributions are welcome, but before you make them, please read the [Contributing Guide](https://hyperformula.handsontable.com/docs/guide/contributing.html) and accept the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). ## License diff --git a/docs/guide/advanced-usage.md b/docs/guide/advanced-usage.md index 7ccb1f64c1..21934c647e 100644 --- a/docs/guide/advanced-usage.md +++ b/docs/guide/advanced-usage.md @@ -1,3 +1,11 @@ +--- +tags: + - buildEmpty + - simpleCellAddressFromString + - getSheetValues + - cross-sheet +--- + # Advanced usage ::: tip diff --git a/docs/guide/ai-sdk.md b/docs/guide/ai-sdk.md index e38f89c524..9ede398ec2 100644 --- a/docs/guide/ai-sdk.md +++ b/docs/guide/ai-sdk.md @@ -1,3 +1,14 @@ +--- +tags: + - AI agents + - LLM + - tool calling + - deterministic + - createSpreadsheetTools + - OpenAI + - what-if analysis +--- + # HyperFormula AI SDK for Vercel A [Vercel AI SDK](https://sdk.vercel.ai/docs) tool that gives your agents deterministic spreadsheet and formula computation — backed by HyperFormula's Excel-compatible engine. diff --git a/docs/guide/arrays.md b/docs/guide/arrays.md index dc02b87b60..a049ad2283 100644 --- a/docs/guide/arrays.md +++ b/docs/guide/arrays.md @@ -1,3 +1,13 @@ +--- +tags: + - ARRAYFORMULA + - useArrayArithmetic + - ARRAY_CONSTRAIN + - spilling + - arrayRowSeparator + - arrayColumnSeparator +--- + # Array formulas Use array formulas to perform an operation (or call a function) on multiple cells at a time. diff --git a/docs/guide/basic-operations.md b/docs/guide/basic-operations.md index f6c25fd7cb..966bd96116 100644 --- a/docs/guide/basic-operations.md +++ b/docs/guide/basic-operations.md @@ -1,3 +1,16 @@ +--- +tags: + - CRUD + - insert rows + - delete rows + - setCellContents + - SimpleCellAddress + - A1 notation + - workbook + - sheet tabs + - NoSheetWithIdError +--- + # Basic operations HyperFormula can perform efficient **CRUD** operations on the workbook. diff --git a/docs/guide/basic-usage.md b/docs/guide/basic-usage.md index d1b392c9ec..b3cd196604 100644 --- a/docs/guide/basic-usage.md +++ b/docs/guide/basic-usage.md @@ -1,3 +1,12 @@ +--- +tags: + - getting started + - quickstart + - tutorial + - buildFromArray + - buildFromSheets +--- + # Basic usage ::: tip diff --git a/docs/guide/batch-operations.md b/docs/guide/batch-operations.md index 54596320ae..87799085f2 100644 --- a/docs/guide/batch-operations.md +++ b/docs/guide/batch-operations.md @@ -1,3 +1,12 @@ +--- +tags: + - batching + - isEvaluationSuspended + - EvaluationSuspendedError + - bulk updates + - recalculation +--- + # Batch operations HyperFormula offers a built-in feature for doing batch operations. diff --git a/docs/guide/branding.md b/docs/guide/branding.md index 159219f7f8..0271cc46e9 100644 --- a/docs/guide/branding.md +++ b/docs/guide/branding.md @@ -1,3 +1,11 @@ +--- +tags: + - brand book + - brand guidelines + - brand assets + - press kit +--- + # Branding ## Our logo diff --git a/docs/guide/building.md b/docs/guide/building.md index 7d20b71a27..bbe9a0b5d8 100644 --- a/docs/guide/building.md +++ b/docs/guide/building.md @@ -1,3 +1,14 @@ +--- +tags: + - UMD + - ESM + - bundles + - TypeScript typings + - Jest + - Karma + - ESLint +--- + # Building The build process uses Webpack and Babel, as well as npm tasks diff --git a/docs/guide/built-in-functions.tmpl.md b/docs/guide/built-in-functions.tmpl.md index 2507820769..f801ad044e 100644 --- a/docs/guide/built-in-functions.tmpl.md +++ b/docs/guide/built-in-functions.tmpl.md @@ -1,3 +1,11 @@ +--- +tags: + - supported functions + - function syntax + - function categories + - statistics +--- + # Built-in functions --- > [!NOTE] > [Cursor Bugbot](https://cursor.com/bugbot) is generating a summary for commit fac749e62e91a688e40c052e5211ec20f0e2e53d. Configure [here](https://www.cursor.com/dashboard/bugbot). Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 6 +++--- docs/guide/release-notes.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ecffc1c4..5a16d04698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -242,7 +242,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Removed - Removed all polyfills from the CommonJS build and the ES modules build. In the UMD build, kept only the polyfills - required by the [supported browsers](https://hyperformula.handsontable.com/guide/supported-browsers.html). + required by the [supported browsers](https://hyperformula.handsontable.com/docs/guide/supported-browsers.html). [#1011](https://github.com/handsontable/hyperformula/issues/1011) ## [2.0.1] - 2022-06-14 @@ -261,9 +261,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), For more information on this release, see: -- [Release notes](https://hyperformula.handsontable.com/guide/release-notes.html) +- [Release notes](https://hyperformula.handsontable.com/docs/guide/release-notes.html) - [Blog post](https://handsontable.com/blog/articles/2022/04/whats-new-in-hyperformula-2.0.0) -- [Migration guide](https://hyperformula.handsontable.com/guide/migration-from-1.0-to-2.0.html) +- [Migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-1.x-to-2.0.html) ### Added diff --git a/docs/guide/release-notes.md b/docs/guide/release-notes.md index af054d6a50..bab635f173 100644 --- a/docs/guide/release-notes.md +++ b/docs/guide/release-notes.md @@ -291,7 +291,7 @@ HyperFormula adheres to ### Removed - Removed all polyfills from the CommonJS build and the ES modules build. In the UMD build, kept only the polyfills - required by the [supported browsers](https://hyperformula.handsontable.com/guide/supported-browsers.html). + required by the [supported browsers](https://hyperformula.handsontable.com/docs/guide/supported-browsers.html). [#1011](https://github.com/handsontable/hyperformula/issues/1011) ## 2.0.1 From 3195e005856d5a264349c8eb80ad84d49d6f4b39 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Fri, 28 Aug 2026 14:45:19 +0200 Subject: [PATCH 04/18] Fix MOD to return the remainder with the sign of the divisor (HF-357) (#1752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context `MOD` returned the result of JavaScript's `%` operator, which is the **truncated** remainder and takes the sign of the **dividend**. Excel, Google Sheets and the OpenDocument specification define `MOD` as the **floored** remainder, which takes the sign of the **divisor**. The two agree whenever the arguments share a sign and differ by exactly one divisor when they do not, so every mixed-sign call was wrong: | Formula | Excel / Google Sheets | Before | After | |---|---|---|---| | `=MOD(-3, 12)` | `9` | `-3` | `9` | | `=MOD(5, -3)` | `-1` | `2` | `-1` | | `=MOD(7, 3)` | `1` | `1` | `1` | | `=MOD(-7, -3)` | `-1` | `-1` | `-1` | Same-sign arguments and `=MOD(x, 0)` → `#DIV/0!` were already correct and are unchanged. **Why `%` plus a correction, and not a one-liner.** Both textbook formulas are too lossy for a calculation engine, so `ModuloPlugin` shifts the remainder given by `%` (which is exact for IEEE 754 doubles) only when its sign disagrees with the divisor's. The rejected alternatives, with the concrete failures now pinned by tests: - `dividend - divisor * Math.floor(dividend / divisor)` rounds twice and the multiplication scales the division's error back up — `MOD(1e308, 3)` returns `0` instead of `2`, and a dividend of `Number.MAX_VALUE` overflows to `-Infinity`. - `((dividend % divisor) + divisor) % divisor` loses a remainder that is negligible next to the divisor — `MOD(1e-20, 3)` returns `0` instead of `1e-20` — and overflows to `NaN` once the intermediate sum exceeds `Number.MAX_VALUE`. The reasoning is recorded in the JSDoc on `flooredRemainder` so the next reader does not "simplify" it back. **Docs and metadata.** `MOD` is removed from the list of differences (row and root-cause bullet) and from the deviations listed in `DEV_DOCS.md`, which both asserted the old behaviour. The sign contract moves out of the `divisor` parameter description and into `shortDescription`, because `script/renderBuiltinFunctionsTable.ts` renders only name, short description and syntax — parameter descriptions never reach the generated guide page, so the contract was invisible there before. ### How did you test your changes? Tests live in the private suite, per `DEV_DOCS.md`: **handsontable/hyperformula-tests#46**, on a branch of this same name, so `fetch-tests.sh` pairs the two and this PR's CI runs them. That PR takes `unit/interpreter/function-modulo.spec.ts` from 4 cases to 38 (one assertion each) and corrects one stale golden value in `unit/function-metadata-api.spec.ts` — see it for the full breakdown. The old spec covered only same-sign arguments (`5,2`, `36,6`, `10.5,3`), which is why this went unnoticed. The new cases add opposite signs in both positions, exact division in all four sign combinations, zero and fractional arguments, coercion, and the precision boundaries that separate the correct implementation from the plausible-but-wrong ones. Mutation-checked — the suite is not merely green, it discriminates: | Implementation | Failures out of 38 | |---|---| | The fix | **0** | | Old `dividend % divisor` | 13 | | `dividend - divisor * Math.floor(dividend / divisor)` | 5 | | `((dividend % divisor) + divisor) % divisor` | 3 | | The fix without its exact-division guard | 4 | Run locally in the exact CI arrangement (this branch, with the tests repo checked out at `test/hyperformula-tests`): ``` Test Suites: 502 passed, 502 total Tests: 3 skipped, 6214 passed, 6217 total ``` Other gates: `npx tsc --noEmit` clean; `eslint . --ext .js,.ts` 0 errors, and 0 new warnings in `src/` (the file's 2 warnings for a missing class/method JSDoc are pre-existing and shared with every sibling plugin). `npm run docs:generate-function-docs` regenerates cleanly, and the resulting `MOD` row reads *"Returns the remainder when one number is divided by another. The result has the same sign as divisor. | MOD(dividend, divisor)"*. ### Types of changes - [ ] Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore) - [ ] New feature or improvement (a non-breaking change that adds functionality) - [x] Bug fix (a non-breaking change that fixes an issue) - [ ] Additional language file, or a change to an existing language file (translations) - [x] Change to the documentation ### Related issues: 1. Fixes #1747 2. HF-357 3. Tests: handsontable/hyperformula-tests#46 ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/docs/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [x] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [x] My change is compatible with Microsoft Excel. - [x] My change is compatible with Google Sheets. - [x] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [x] My changes require a documentation update. - [ ] My changes require a migration guide. ### Notes for the reviewer Three judgement calls to confirm, none of them blocking: 1. **"Breaking change" left unticked.** Returned values do change for anyone relying on mixed-sign `MOD`, and there is no config escape hatch (only a custom-function reimplementation). I followed the closest precedent instead: the empty-cell `MATCH`/`VLOOKUP` fix shipped in 3.4.0 as a plain `### Fixed` while also editing the list of differences. Ticking the box would mean creating a migration guide, and no `migration-from-3.x` guide exists yet. Happy to reclassify. 2. **A knock-on change at an unrepresentable divisor.** A divisor that overflows to infinity is reachable through string coercion, and `=MOD(-10, "1e400")` now returns `#NUM!` where it used to return `-10`. That follows from the definition — for a positive divisor the floored result must lie in `[0, divisor)`, so the only answer here is unrepresentable — and `-10` was exactly the truncated-remainder bug. Pinned by two tests rather than left to chance; say the word if you would rather special-case non-finite divisors. 3. **`QUOTIENT` is deliberately untouched.** The identity `a = b * QUOTIENT(a, b) + MOD(a, b)` no longer holds for mixed signs, because `QUOTIENT` truncates. Excel has exactly the same inconsistency, so changing it would break Excel parity. Separately, while testing this I found a **pre-existing, unrelated bug**, now filed as [HF-358](https://app.clickup.com/t/86cbb9ufr): negative zero leaks through the array/range path for `MOD`, `ROUND`, `INT` and `*` alike (`Interpreter.evaluateAst` applies `fixNegativeZero` only when `isExtendedNumber(val)`, which is false for a `SimpleRangeValue`). It predates this change and affects the same inputs before and after it. --- > [!NOTE] > **Medium Risk** > Mixed-sign `MOD` results change for any workbook that depended on the old dividend-sign semantics; impact is limited to that function but can alter live spreadsheet calculations. > > **Overview** > **`MOD`** no longer uses JavaScript’s `%` operator. It now returns the **floored** remainder whose sign matches the **divisor**, matching Excel, Google Sheets, and OpenDocument (e.g. `=MOD(-3, 12)` is **9**, not **-3**). Same-sign inputs and `#DIV/0!` for a zero divisor are unchanged. > > `ModuloPlugin` routes non-zero divisors through a new **`flooredRemainder`** helper that adjusts `%` only when the truncated remainder’s sign disagrees with the divisor’s, avoiding less stable one-liner formulas at extreme magnitudes. JSDoc on that helper records why. > > Docs follow the behaviour change: **CHANGELOG** entry, **`MOD`** removed from the Excel/Sheets differences table and related prose in **`DEV_DOCS.md`**, and function catalogue **`shortDescription`** updated so the divisor-sign rule appears in generated built-in function docs. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9f423a699a0e2823ccec5e77dc3c681d61fe1f68. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude --- CHANGELOG.md | 4 ++ DEV_DOCS.md | 2 +- docs/guide/list-of-differences.md | 2 - .../categories/math-and-trigonometry.ts | 4 +- src/interpreter/plugin/ModuloPlugin.ts | 39 ++++++++++++++++++- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a16d04698..562e6feb3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) + ## [3.4.0] - 2026-08-10 ### Added diff --git a/DEV_DOCS.md b/DEV_DOCS.md index 13630fd4cb..78fe3f49c0 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -153,7 +153,7 @@ It does **not** turn ordinary English into identifiers. A parameter's own descri Note what the drift warning does **not** cover: **optionality is not cross-checked.** The catalogue authors no optionality of its own — a parameter's `optional` flag is derived entirely from `optionalArg`/`defaultValue` in `implementedFunctions` — so a description that calls an argument optional can sit next to `optional: false` with nothing failing. When a function accepts a call that arity alone does not express (`SHEET()`, `ROW()`, and anything else served by `runFunctionWithReferenceArgument`'s zero-argument path), the plugin must declare `optionalArg: true` explicitly, or the public API will advertise the argument as required. `ROW`, `COLUMN`, `SHEET` and `SHEETS` all declare it; `ISFORMULA` takes the same path and correctly does not, because its zero-argument call is an error rather than a shorthand. -Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of the catalogue was seeded from a hand-written page that documented Excel, and HyperFormula deliberately deviates in places (`INT` truncates toward zero, `MOD` takes the sign of the dividend, `ISEVEN`/`ISODD` do not truncate, `CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1). Verify a claim against the implementation before authoring it, and record any deviation in [the list of differences](docs/guide/list-of-differences.md). +Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of the catalogue was seeded from a hand-written page that documented Excel, and HyperFormula deliberately deviates in places (`INT` truncates toward zero, `ISEVEN`/`ISODD` do not truncate, `CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1). Verify a claim against the implementation before authoring it, and record any deviation in [the list of differences](docs/guide/list-of-differences.md). ## Internationalization and function translations diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 81a3962dd1..a109f82616 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -117,7 +117,6 @@ To remove the differences, create [custom implementations](custom-functions.md) | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | INT | =INT(-8.9) | -8 | -9 | -9 | -| MOD | =MOD(-10, 3) | -1 | 2 | 2 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | | ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE | | CEILING.MATH | =CEILING.MATH(-4.3, 2, 2) | -4 | -6 | -6 | @@ -126,6 +125,5 @@ To remove the differences, create [custom implementations](custom-functions.md) A few of the rows above share a root cause worth stating once: - **Rounding toward zero, not down.** `INT` discards the fractional part rather than rounding toward negative infinity, so it differs from Excel and Google Sheets for negative input only. `ROUNDDOWN`/`ROUNDUP` are unaffected — they are defined in terms of zero in all three. -- **`MOD` takes the sign of the dividend.** Excel and Google Sheets return a result with the sign of the *divisor*. - **`ISEVEN`/`ISODD` do not truncate.** They test the remainder of the value as given, so a value with a fractional part returns `FALSE` from *both*. Excel and Google Sheets truncate to an integer first, so exactly one of the two is always `TRUE`. - **`CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1.** Excel and Google Sheets switch the negative-number rounding direction for any non-zero `mode`. diff --git a/src/interpreter/functionMetadata/categories/math-and-trigonometry.ts b/src/interpreter/functionMetadata/categories/math-and-trigonometry.ts index 4252a3df05..e48408bcd1 100644 --- a/src/interpreter/functionMetadata/categories/math-and-trigonometry.ts +++ b/src/interpreter/functionMetadata/categories/math-and-trigonometry.ts @@ -278,8 +278,8 @@ export const MATH_AND_TRIGONOMETRY_DOCS: Record = { }, MOD: { category: 'Math and trigonometry', - shortDescription: 'Returns the remainder when one number is divided by another.', - parameters: [{name: 'dividend', description: 'The number to be divided.'}, {name: 'divisor', description: 'The non-zero number to divide by. The result has the same sign as the dividend.'}], + shortDescription: 'Returns the remainder when one number is divided by another. The result has the same sign as divisor.', + parameters: [{name: 'dividend', description: 'The number to be divided.'}, {name: 'divisor', description: 'The non-zero number to divide by.'}], documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=MOD(10, 3)', '=MOD(-7, 2)'], }, diff --git a/src/interpreter/plugin/ModuloPlugin.ts b/src/interpreter/plugin/ModuloPlugin.ts index 9c76dba4e8..428f31e598 100644 --- a/src/interpreter/plugin/ModuloPlugin.ts +++ b/src/interpreter/plugin/ModuloPlugin.ts @@ -24,9 +24,44 @@ export class ModuloPlugin extends FunctionPlugin implements FunctionPluginTypech return this.runFunction(ast.args, state, this.metadata('MOD'), (dividend: number, divisor: number) => { if (divisor === 0) { return new CellError(ErrorType.DIV_BY_ZERO) - } else { - return dividend % divisor } + + return flooredRemainder(dividend, divisor) }) } } + +/** + * Computes the remainder of a division, taking the sign of the divisor. + * + * This is the floored remainder, i.e. the one left by a division rounded towards negative infinity. + * It is what Excel, Google Sheets and the OpenDocument specification define MOD to return. The `%` + * operator computes the truncated remainder instead, which takes the sign of the dividend: the two + * agree whenever the arguments share a sign, and differ by exactly one divisor when they do not. + * + * Correcting the remainder given by `%` is more verbose than the two textbook one-liners, but neither + * of those is accurate enough for a calculation engine: + * - `dividend - divisor * Math.floor(dividend / divisor)` rounds twice, and the multiplication scales + * the error of the division back up. It returns 0 instead of 2 for a dividend of 1e308 and a divisor + * of 3, and overflows to -Infinity for a dividend of Number.MAX_VALUE. + * - `((dividend % divisor) + divisor) % divisor` loses the remainder entirely when it is negligible + * next to the divisor, returning 0 instead of 1e-20 for a divisor of 3, and overflows to NaN when the + * intermediate sum exceeds Number.MAX_VALUE. + * + * `%` on its own is exact for IEEE 754 doubles, so applying the correction only where it is needed + * keeps every already-correct result untouched. + * + * @param {number} dividend - the number being divided + * @param {number} divisor - the number to divide by, must not be 0 + */ +function flooredRemainder(dividend: number, divisor: number): number { + const truncatedRemainder = dividend % divisor + const isDivisibleExactly = truncatedRemainder === 0 + const hasSignOfDivisor = (truncatedRemainder < 0) === (divisor < 0) + + if (isDivisibleExactly || hasSignOfDivisor) { + return truncatedRemainder + } + + return truncatedRemainder + divisor +} From 419028aa7e3a25379d84c3fd0dcddf1bbcea1ed3 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Fri, 28 Aug 2026 17:50:32 +0200 Subject: [PATCH 05/18] Fix MAXPOOL and MEDIANPOOL throwing on non-tiling dimensions (#1718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context `MAXPOOL` and `MEDIANPOOL` computed the output array size without checking that the pooling window actually tiles the input range. When the range dimensions, reduced by the window size, were not whole multiples of the stride, the kernel read past the last row of the input, and an uncaught `TypeError` escaped the interpreter and the public API: ```ts const hf = HyperFormula.buildEmpty({licenseKey: 'gpl-v3'}) const id = hf.getSheetId(hf.addSheet('S')) hf.setSheetContent(id, [[3, 1, 2], [9, 7, 8], [5, 4, 6]]) // 3x3 hf.calculateFormula('=MAXPOOL(A1:C3, 2)', id) // TypeError: Cannot read properties of undefined (reading '0') hf.calculateFormula('=MEDIANPOOL(A1:C3, 2)', id) // same ``` The same exception escaped `setCellContents()`. `maxpoolArraySize()` already rejected such arguments, but `ArraySize.error()` is `(1, 1, isRef: true)`, which `isScalar()` reports as scalar, so the formula was evaluated as an ordinary scalar formula and the invalid arguments reached the kernel anyway. Related failure modes found while reproducing: a window larger than the range, a stride that makes the last window overshoot, and a zero, negative or fractional window size or stride all threw as well (`TypeError` or `RangeError: Invalid array length`). ### Changes - `MatrixPlugin.maxpool()` / `MatrixPlugin.medianpool()` now validate the window size and the stride against the range dimensions and return `#VALUE!` with a new `ErrorMessage.PoolDimensions` (`'Range dimensions are not compatible with the window size and the stride.'`) instead of running the kernel out of bounds. `#VALUE!` matches how `MMULT` reports incompatible dimensions. - The window size and the stride are declared as `FunctionArgumentType.INTEGER` with `minValue: 1`, so a zero, negative or fractional value returns `#NUM!` (`Value too small.` / `Value needs to be an integer.`) instead of throwing. - The new `isPoolWindowFittingInputArray()` predicate is shared with `maxpoolArraySize()`, keeping the predicted array size and the runtime validation in sync. It also carries the positive-integer requirement, which fixes a second defect: `maxpoolArraySize()` predicted a **negative** array size for a negative stride, because `(4 - 2) % -1 === 0` passes a naive divisibility check. The cell then reported `#SPILL!` instead of the `#NUM!` the function actually returns. - The dimension constraint is documented in the `range` / `window_size` / `stride` entries of `src/interpreter/functionMetadata/categories/matrix-functions.ts` — the single source of truth from which `docs/guide/built-in-functions.md` is generated — and in the JSDoc of both methods. ### Tests In the private suite: **handsontable/hyperformula-tests#48**, on the matching `claude/vigilant-gates-3dkoua` branch so `fetch-tests.sh` pairs the two and this PR's CI runs against it. New cases in `unit/interpreter/matrix-plugin.spec.ts` cover dimensions that are not a whole multiple of the window size (in a cell and via `calculateFormula()`), only one dimension divisible, a window larger than the range, an overshooting stride, a zero/negative/fractional window size and stride, and a window covering the whole range. The existing `matrix maxpool`, `matrix maxpool, custom stride` and `matrix medianpool on even/odd square` tests already guard the working cases. Verified locally against the full private suite: **502 suites, 6230 tests passed, 3 skipped**. `npm run compile` passes. ### Types of changes - [ ] Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore) - [ ] New feature or improvement (a non-breaking change that adds functionality) - [x] Bug fix (a non-breaking change that fixes an issue) - [ ] Additional language file, or a change to an existing language file (translations) - [x] Change to the documentation ### Related issues: 1. Fixes #... ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [ ] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. — `MAXPOOL` and `MEDIANPOOL` are HyperFormula-specific functions, not covered by the standard. - [ ] My change is compatible with Microsoft Excel. — not applicable, these functions do not exist in Excel. - [ ] My change is compatible with Google Sheets. — not applicable, these functions do not exist in Google Sheets. - [x] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [x] My changes require a documentation update. - [ ] My changes require a migration guide. --- > [!NOTE] > **Low Risk** > Localized bug fix to HyperFormula-specific pooling functions with stricter validation and clearer errors; no auth, data, or broad API contract changes beyond replacing throws with spreadsheet errors. > > **Overview** > **MAXPOOL** and **MEDIANPOOL** no longer throw uncaught `TypeError` (or related runtime errors) when the range does not tile cleanly with the window and stride. They now validate arguments up front and return **#VALUE!** with `ErrorMessage.PoolDimensions`, consistent with how incompatible dimensions are handled elsewhere (e.g. **MMULT**). > > Shared helpers `isPoolWindowFittingInputArray()` and `poolFunctionArgumentsError()` enforce positive integer window size and stride, window fit inside the range, and divisibility of `(dimension - window_size)` by stride. The same predicate is used in `maxpoolArraySize()` so array-size prediction matches runtime behavior (including cases that previously mis-predicted size for invalid strides). > > `window_size` and optional `stride` are typed as **INTEGER** with `minValue: 1` instead of generic numbers, so zero, negative, or fractional values surface as **#NUM!** via argument coercion rather than reaching the kernel. Function metadata and the changelog document the tiling rules and the fix. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 60606ca6ed773aa43e5090af067c56d2b91a4f89. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + src/error-message.ts | 1 + .../categories/matrix-functions.ts | 8 +- src/interpreter/plugin/MatrixPlugin.ts | 78 ++++++++++++++++--- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 562e6feb3c..7e535ebc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) - Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) ## [3.4.0] - 2026-08-10 diff --git a/src/error-message.ts b/src/error-message.ts index 5e3afdbeab..4b80ee06fa 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -12,6 +12,7 @@ export class ErrorMessage { public static EmptyArg = 'Empty function argument.' public static EmptyArray = 'Empty array not allowed.' public static ArrayDimensions = 'Array dimensions are not compatible.' + public static PoolDimensions = 'Range dimensions are not compatible with the window size and the stride.' public static NoSpaceForArrayResult = 'No space for array result.' public static ValueSmall = 'Value too small.' public static ValueLarge = 'Value too large.' diff --git a/src/interpreter/functionMetadata/categories/matrix-functions.ts b/src/interpreter/functionMetadata/categories/matrix-functions.ts index 940c3b1038..d4a9b73ad3 100644 --- a/src/interpreter/functionMetadata/categories/matrix-functions.ts +++ b/src/interpreter/functionMetadata/categories/matrix-functions.ts @@ -12,15 +12,15 @@ import {FunctionDoc} from '../FunctionDescription' export const MATRIX_FUNCTIONS_DOCS: Record = { MAXPOOL: { category: 'Matrix functions', - shortDescription: 'Calculates a smaller range which is a maximum of a window_size, in a given range, for every stride element.', - parameters: [{name: 'range', description: 'The range of numeric values to pool; must contain only numbers.'}, {name: 'window_size', description: 'The width and height, in cells, of the square window whose maximum is taken at each step.'}, {name: 'stride', description: 'The number of cells the window moves between steps; defaults to window_size when omitted.'}], + shortDescription: 'Calculates a smaller range which is a maximum of a window_size, in a given range, for every stride element.
window_size and stride must be positive integers, and the window must tile the range exactly: window_size cannot exceed either dimension of range, and both dimensions, reduced by window_size, must be whole multiples of stride. Otherwise the function returns the #VALUE! error.', + parameters: [{name: 'range', description: 'The range of numeric values to pool; must contain only numbers, and its dimensions must fit a whole number of windows (see window_size and stride).'}, {name: 'window_size', description: 'The width and height, in cells, of the square window whose maximum is taken at each step; a positive integer that is not greater than either dimension of range.'}, {name: 'stride', description: 'The number of cells the window moves between steps; a positive integer that defaults to window_size when omitted. Both dimensions of range, reduced by window_size, must be whole multiples of it, otherwise the function returns the #VALUE! error.'}], documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=MAXPOOL(A1:D4, 2)', '=MAXPOOL(A1:D4, 2, 1)'], }, MEDIANPOOL: { category: 'Matrix functions', - shortDescription: 'Calculates a smaller range which is a median of a window_size, in a given range, for every stride element.', - parameters: [{name: 'range', description: 'The range of numeric values to pool; must contain only numbers.'}, {name: 'window_size', description: 'The width and height, in cells, of the square window whose median is taken at each step.'}, {name: 'stride', description: 'The number of cells the window moves between steps; defaults to window_size when omitted.'}], + shortDescription: 'Calculates a smaller range which is a median of a window_size, in a given range, for every stride element.
window_size and stride must be positive integers, and the window must tile the range exactly: window_size cannot exceed either dimension of range, and both dimensions, reduced by window_size, must be whole multiples of stride. Otherwise the function returns the #VALUE! error.', + parameters: [{name: 'range', description: 'The range of numeric values to pool; must contain only numbers, and its dimensions must fit a whole number of windows (see window_size and stride).'}, {name: 'window_size', description: 'The width and height, in cells, of the square window whose median is taken at each step; a positive integer that is not greater than either dimension of range.'}, {name: 'stride', description: 'The number of cells the window moves between steps; a positive integer that defaults to window_size when omitted. Both dimensions of range, reduced by window_size, must be whole multiples of it, otherwise the function returns the #VALUE! error.'}], documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=MEDIANPOOL(A1:D4, 2)', '=MEDIANPOOL(A1:D4, 2, 1)'], }, diff --git a/src/interpreter/plugin/MatrixPlugin.ts b/src/interpreter/plugin/MatrixPlugin.ts index b2d329d95e..8b384c2d44 100644 --- a/src/interpreter/plugin/MatrixPlugin.ts +++ b/src/interpreter/plugin/MatrixPlugin.ts @@ -9,6 +9,7 @@ import {ErrorMessage} from '../../error-message' import {AstNodeType, ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' import {InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {Maybe} from '../../Maybe' import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' @@ -37,6 +38,45 @@ function arraySizeForPoolFunction(inputArray: ArraySize, windowSize: number, str ) } +/** + * Checks whether a square pooling window tiles the input array exactly, so that no window reaches outside of it. + * + * The window size and the stride have to be positive integers, the window has to fit inside the input array, + * and both of its dimensions, reduced by the window size, have to be whole multiples of the stride. + * + * @param inputArray - dimensions of the pooled input array + * @param windowSize - side length of the square pooling window + * @param stride - distance between the top-left corners of two consecutive windows + */ +function isPoolWindowFittingInputArray(inputArray: ArraySize, windowSize: number, stride: number): boolean { + return Number.isInteger(windowSize) && windowSize >= 1 + && Number.isInteger(stride) && stride >= 1 + && windowSize <= inputArray.width + && windowSize <= inputArray.height + && (inputArray.width - windowSize) % stride === 0 + && (inputArray.height - windowSize) % stride === 0 +} + +/** + * Validates the arguments shared by the pooling functions (MAXPOOL, MEDIANPOOL). + * + * @param matrix - the pooled input range + * @param windowSize - side length of the square pooling window + * @param stride - distance between the top-left corners of two consecutive windows + * @returns a {@link CellError} describing the violated constraint, or `undefined` when the arguments are valid + */ +function poolFunctionArgumentsError(matrix: SimpleRangeValue, windowSize: number, stride: number): Maybe { + if (!matrix.hasOnlyNumbers()) { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberRange) + } + + if (!isPoolWindowFittingInputArray(matrix.size, windowSize, stride)) { + return new CellError(ErrorType.VALUE, ErrorMessage.PoolDimensions) + } + + return undefined +} + export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { 'MMULT': { @@ -61,8 +101,8 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech sizeOfResultArrayMethod: 'maxpoolArraySize', parameters: [ {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: true}, + {argumentType: FunctionArgumentType.INTEGER, minValue: 1}, + {argumentType: FunctionArgumentType.INTEGER, minValue: 1, optionalArg: true}, ], vectorizationForbidden: true, }, @@ -71,8 +111,8 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech sizeOfResultArrayMethod: 'medianpoolArraySize', parameters: [ {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: true}, + {argumentType: FunctionArgumentType.INTEGER, minValue: 1}, + {argumentType: FunctionArgumentType.INTEGER, minValue: 1, optionalArg: true}, ], vectorizationForbidden: true, }, @@ -110,10 +150,19 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech return arraySizeForMultiplication(left, right) } + /** + * Corresponds to MAXPOOL(Range, Window_size, Stride). + * + * Reduces the input range to the maximum value of every window of `Window_size` x `Window_size` cells, + * moving the window by `Stride` cells. The window has to fit inside the range and the range dimensions, + * reduced by the window size, have to be whole multiples of the stride. Otherwise, the function + * returns the #VALUE! error. + */ public maxpool(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('MAXPOOL'), (matrix: SimpleRangeValue, windowSize: number, stride: number = windowSize) => { - if (!matrix.hasOnlyNumbers()) { - return new CellError(ErrorType.VALUE, ErrorMessage.NumberRange) + const argumentsError = poolFunctionArgumentsError(matrix, windowSize, stride) + if (argumentsError !== undefined) { + return argumentsError } const outputSize = arraySizeForPoolFunction(matrix.size, windowSize, stride) @@ -133,10 +182,19 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech }) } + /** + * Corresponds to MEDIANPOOL(Range, Window_size, Stride). + * + * Reduces the input range to the median value of every window of `Window_size` x `Window_size` cells, + * moving the window by `Stride` cells. The window has to fit inside the range and the range dimensions, + * reduced by the window size, have to be whole multiples of the stride. Otherwise, the function + * returns the #VALUE! error. + */ public medianpool(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('MEDIANPOOL'), (matrix: SimpleRangeValue, windowSize: number, stride: number = windowSize) => { - if (!matrix.hasOnlyNumbers()) { - return new CellError(ErrorType.VALUE, ErrorMessage.NumberRange) + const argumentsError = poolFunctionArgumentsError(matrix, windowSize, stride) + if (argumentsError !== undefined) { + return argumentsError } const outputSize = arraySizeForPoolFunction(matrix.size, windowSize, stride) @@ -227,9 +285,7 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech } } - if (window > array.width || window > array.height - || stride > window - || (array.width - window) % stride !== 0 || (array.height - window) % stride !== 0) { + if (!isPoolWindowFittingInputArray(array, window, stride)) { return ArraySize.error() } From 114fd5d3e6523a52a19eef4d076cfecc244601b5 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Fri, 28 Aug 2026 19:14:22 +0200 Subject: [PATCH 06/18] Update AGENTS.md --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 99b25536ca..c81e681c12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,19 @@ Instructions for AI coding agents (Cursor, Claude Code, Codex, Aider, and any ot Whatever you do, start by reading entire [DEV_DOCS.md](DEV_DOCS.md). Only then proceed to your task. +## Never publish sensitive information + +Never write any of the following into a commit message, branch name, pull request title or description, GitHub issue or comment, code comment, changelog entry, or documentation page: + +- client, customer, and partner names, or details that identify them indirectly (their domains, deployments, or the wording of their reports) +- personal data of any kind — names, e-mail addresses, phone numbers, user accounts, IP addresses +- credentials and secrets — API keys, tokens, passwords, license keys, private URLs +- internal-only material — contents of private repositories and internal tickets, unreleased plans, contract and pricing details + +Describe the change on its own technical terms instead: write "fix an off-by-one error in `SUMIFS` when the criteria range is empty", not "fix the bug reported by \". An internal ticket identifier such as `HF-123` is fine on its own; the contents of that ticket are not. + +If a change cannot be described without such information, stop and ask the user how to proceed. + ## Other important resources - the repository [README.md](README.md) — high-level project description and quick install/usage From 286a7311ca9cd86fc27d90540c62320ae169cbaa Mon Sep 17 00:00:00 2001 From: Aleksandra Budnik Date: Mon, 31 Aug 2026 13:36:47 +0200 Subject: [PATCH 07/18] docs(guide): named columns vs structured references (SU-637) (#1753) https://app.clickup.com/t/9015210959/SU-637 Goal: Improve docs-assistant answer depth on HyperFormula named ranges / structured references Change: Named-columns section: no Excel structured refs, why SUM(Name1:Name5) fails, $A:$A workaround. ### Context Adds the missing named-column recipe to the named-expressions guide so docs-assistant can retrieve it. Cross-links the existing range-restraint on cell-references. ### How did you test your changes? Read-through against naming rules (`Name1` is A1) and range restraints (`:` cannot take named endpoints). Docs-only; no engine change. ### Types of changes - [x] Change to the documentation ### Related issues: 1. https://app.clickup.com/t/9015210959/SU-637 ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/docs/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [x] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [x] My change is compatible with Microsoft Excel. - [x] My change is compatible with Google Sheets. - [ ] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [ ] My changes require a documentation update. - [ ] My changes require a migration guide. Docs-only; no CHANGELOG Made with [Cursor](https://cursor.com) and Ola :) --------- Co-authored-by: Cursor Co-authored-by: Kuba Sekowski Co-authored-by: Kuba Sekowski --- docs/guide/cell-references.md | 2 +- docs/guide/named-expressions.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/guide/cell-references.md b/docs/guide/cell-references.md index edd88ebdf3..5fcd811e18 100644 --- a/docs/guide/cell-references.md +++ b/docs/guide/cell-references.md @@ -204,7 +204,7 @@ You can reference ranges: The following restraints apply: - You can't mix two different types of range references together (=A1:B). -- Range expressions can't contain [named expressions](/guide/named-expressions.md). +- Range expressions can't contain [named expressions](/guide/named-expressions.md) (`=Name_1:Name_5` is a parse error). To name a whole column, see [Named columns](/guide/named-expressions.md#named-columns). - At the moment, HyperFormula doesn't support multi-cell range references (=A1:B2:C3). ::: tip diff --git a/docs/guide/named-expressions.md b/docs/guide/named-expressions.md index 580fad691f..99249cb25b 100644 --- a/docs/guide/named-expressions.md +++ b/docs/guide/named-expressions.md @@ -4,6 +4,7 @@ tags: - global scope - variables - named constants + - structured references - addNamedExpression - changeNamedExpression - removeNamedExpression @@ -83,6 +84,7 @@ For examples of valid and invalid expression names, see the following table: | ASP.NET | Valid | | A1 | Invalid | | $A$1 | Invalid | +| Name1 | Invalid | | RC | Invalid | ## Using named expressions in formulas @@ -114,6 +116,28 @@ When array arithmetic is enabled (`useArrayArithmetic: true`), named ranges stil - A bare `=myRange + 1` does not spill — it returns a `#VALUE!` error rather than producing one result per element. - Inside an aggregate the operator becomes element-wise. `=SUM(myRange + 1)` adds 1 to every element and then sums, so for `myRange` covering values `1..5` it returns `20` (`SUM(2, 3, 4, 5, 6)`), not the single reduced value of the default mode. +## Named columns + +To address a column by name, register a named expression that points at the column (or at the data range), then use that name in the formula: + +```javascript +hfInstance.addNamedExpression('ColSales', '=Sheet1!$A:$A'); +hfInstance.setCellContents({ sheet: 0, col: 2, row: 0 }, [['=SUM(ColSales)']]); +``` + +- The address inside the named expression must be **absolute** (`$A:$A` or `Sheet1!$A:$A`). Relative `A:A` is not allowed. +- If row 1 is a header, `$A:$A` includes it. For data only, use `$A$2:$A`. +- Do not put header text in the formula. Map each header to a named expression in application code. + +### Why SUM(Name1:Name5) does not work + +HyperFormula does not support Excel-style structured references such as `Table[Column]`, and it does not treat column headers as formula addresses. A formula like `=SUM(Name1:Name5)` is not a reference to columns named Name1 and Name5. + +Two separate problems often get stacked in that example: + +1. **Illegal name.** `Name1` matches A1 notation (column NAME, row 1), so it cannot be registered as a named expression. Use `ColSales` or `Name_1` instead. See [Naming rules](#naming-rules). +2. **Range operator.** `:` does not accept named expressions as endpoints. Even with legal names, `Name_1:Name_5` is a parse error. See [Range restraints](cell-references.md#range-restraints). + ## Available methods These are the basic methods that can be used to add and manipulate named From 50e7170cded45a739db3fa142b8d402a0b563669 Mon Sep 17 00:00:00 2001 From: Oluwatobi Adefami <48369656+Tobiadefami@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:03:14 +0100 Subject: [PATCH 08/18] Fix localized VSTACK and HSTACK names (#1748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context HyperFormula 3.4.0 shipped `VSTACK` and `HSTACK` with English placeholder names in language packs where Microsoft Excel uses localized names. This PR moves the previously reviewed localization fix out of the unrelated TAKE PR and into a dedicated change. This extracts the VSTACK/HSTACK portion of commit `b902d53bc`, originally authored by Kuba Sekowski. ### Changes | Language | VSTACK | HSTACK | |---|---|---| | Czech | `SROVNAT.SVISLE` | `SROVNAT.VODOROVNĚ` | | Danish | `VSTAK` | `HSTAK` | | German | `VSTAPELN` | `HSTAPELN` | | Spanish | `APILARV` | `APILARH` | | Finnish | `VPINO` | `HPINO` | | French | `ASSEMB.V` | `ASSEMB.H` | | Hungarian | `FÜGG.HALMOZÁS` | `VÍZSZ.HALMOZÁS` | | Italian | `STACK.VERT` | `STACK.ORIZ` | | Norwegian | `VSTAKK` | `HSTAKK` | | Dutch | `VERT.STAPELEN` | `HOR.STAPELEN` | | Polish | `STOS.PION` | `STOS.POZ` | | Portuguese | `JUNTARV` | `JUNTARH` | | Russian | `ВСТОЛБИК` | `ГСТОЛБИК` | | Turkish | `DÜŞEYYIĞ` | `YATAYYIĞ` | Indonesian and Swedish remain in English, matching Excel. The PR also adds source-verification guidance to `DEV_DOCS.md` and an Unreleased changelog entry. ### Source verification All 14 localized pairs and the unchanged Indonesian and Swedish pairs were checked on 2026-08-25 against Microsoft Support's current individual VSTACK/HSTACK pages, using each page title and displayed formula syntax. This caught one mismatch in the original table: Danish is [`VSTAK`](https://support.microsoft.com/da-dk/excel/functions/vstack-function) / [`HSTAK`](https://support.microsoft.com/da-dk/excel/functions/hstack-function). Microsoft's Danish alphabetical function index currently conflicts with those individual pages, so `DEV_DOCS.md` now requires checking the linked individual page rather than copying a name from the index alone. ### Migration note This is a breaking change for formulas created with HyperFormula 3.4.0 that use English `VSTACK` or `HSTACK` while one of the affected language packs is active. Those formulas must switch to the names listed above. ### Validation Companion tests: handsontable/hyperformula-tests#44 - 34/34 focused i18n tests passed. - Full browser suite passed: 12,388 assertions in Chrome and Firefox. - TypeScript compilation passed. - Targeted lint completed with zero errors. - `git diff --check` passed. ### Types of changes - [x] Breaking change - [ ] New feature or improvement - [x] Bug fix - [x] Additional language file or translation change - [x] Documentation change --- > [!NOTE] > **Medium Risk** > Breaking change to parsed function names in 14 locales; no calculation logic changes, but existing localized formulas using English `VSTACK`/`HSTACK` will stop resolving until renamed. > > **Overview** > Replaces English placeholder names for **`VSTACK`** and **`HSTACK`** in **14 built-in language packs** (Czech, Danish, German, Spanish, Finnish, French, Hungarian, Italian, Norwegian, Dutch, Polish, Portuguese, Russian, Turkish) with the localized identifiers Microsoft Excel uses—for example German `VSTAPELN`/`HSTAPELN` and French `ASSEMB.V`/`ASSEMB.H`. Locales where Excel keeps English (e.g. `enGB`, Indonesian, Swedish) are unchanged. > > **`DEV_DOCS.md`** now requires Excel-verified names only (no inferred translations), documents Microsoft’s per-locale alphabetical function list for newer functions, and says to confirm names on individual function pages when the index disagrees. **`CHANGELOG.md`** records the fix under Unreleased. > > **Migration:** With an affected language pack active, formulas that still call `VSTACK`/`HSTACK` in English must use the new localized names; this is treated as a breaking change for 3.4.0-era workbooks. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e2c90f10daccda30e48ea2c18d3a66b45d6d38b8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor Agent Co-authored-by: Kuba Sekowski Co-authored-by: Kuba Sekowski --- CHANGELOG.md | 1 + DEV_DOCS.md | 10 ++++++++++ src/i18n/languages/csCZ.ts | 4 ++-- src/i18n/languages/daDK.ts | 4 ++-- src/i18n/languages/deDE.ts | 4 ++-- src/i18n/languages/esES.ts | 4 ++-- src/i18n/languages/fiFI.ts | 4 ++-- src/i18n/languages/frFR.ts | 4 ++-- src/i18n/languages/huHU.ts | 4 ++-- src/i18n/languages/itIT.ts | 4 ++-- src/i18n/languages/nbNO.ts | 4 ++-- src/i18n/languages/nlNL.ts | 4 ++-- src/i18n/languages/plPL.ts | 4 ++-- src/i18n/languages/ptPT.ts | 4 ++-- src/i18n/languages/ruRU.ts | 4 ++-- src/i18n/languages/trTR.ts | 4 ++-- 16 files changed, 39 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e535ebc20..4256fd3b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) - Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) diff --git a/DEV_DOCS.md b/DEV_DOCS.md index 78fe3f49c0..95444dc80d 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -159,11 +159,21 @@ Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of th HyperFormula supports internationalization and provides localized function names for all built-in languages. Translation files live in `src/i18n/languages/`. New functions must include translations for all built-in languages. +Only add a localized function name after confirming that Microsoft Excel ships that exact name. If an authoritative source does not provide a localized name, keep the English name instead of translating or inferring one. + When looking for the valid translations for new functions, try these sources: - https://support.microsoft.com/en-us/office/excel-functions-translator-f262d0c0-991c-485b-89b6-32cc8d326889 - http://dolf.trieschnigg.nl/excel/index.php +For recent Excel functions that are absent from those sources, use Microsoft's localized alphabetical function list: + +```text +https://support.microsoft.com//office/excel-functions-alphabetical-b3944572-255d-4efb-bb96-c6d90033e188 +``` + +Find the link whose URL contains `functions/-function`, then follow it and confirm that the individual function page uses the same localized name in its title and formula syntax. Check each locale independently because Excel keeps some function names in English. If the alphabetical list and the individual page disagree, do not copy the list entry without additional product verification. + For languages not officially supported by Microsoft Excel, the two sources above do not apply. For these languages, use Google Sheets as the reference. Switch the `hl` query parameter to the target locale, for example: - https://support.google.com/docs/table/25273?hl=id (Indonesian) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 830b1c1968..fea286083c 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'SROVNAT.SVISLE', + HSTACK: 'SROVNAT.VODOROVNĚ', ADDRESS: 'ODKAZ', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 90296fd420..9264551249 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAK', + HSTACK: 'HSTAK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index daaaa0bf70..4d3d39319b 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAPELN', + HSTACK: 'HSTAPELN', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 6fea52e4e2..9c6b817b98 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -19,8 +19,8 @@ export const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'APILARV', + HSTACK: 'APILARH', ADDRESS: 'DIRECCION', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index a3b319af2a..99ff05708f 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VPINO', + HSTACK: 'HPINO', ADDRESS: 'OSOITE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index a29109053c..f7b9be45f7 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'ASSEMB.V', + HSTACK: 'ASSEMB.H', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index fafc2c4b9b..e40219553a 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'FÜGG.HALMOZÁS', + HSTACK: 'VÍZSZ.HALMOZÁS', ADDRESS: 'CÍM', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 40ec9cf414..1a494628f8 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'STACK.VERT', + HSTACK: 'STACK.ORIZ', ADDRESS: 'INDIRIZZO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 89c0f85f76..ff15415a98 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAKK', + HSTACK: 'HSTAKK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 73ef99687b..c491e2f44b 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VERT.STAPELEN', + HSTACK: 'HOR.STAPELEN', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 003b0fd648..6fa4f016b4 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'STOS.PION', + HSTACK: 'STOS.POZ', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 408d7f7722..6c1e68c328 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'JUNTARV', + HSTACK: 'JUNTARH', ADDRESS: 'ENDEREÇO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 657b0db373..6b35c1ce2f 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'ВСТОЛБИК', + HSTACK: 'ГСТОЛБИК', ADDRESS: 'АДРЕС', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index dc243600d5..93c2176d37 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'DÜŞEYYIĞ', + HSTACK: 'YATAYYIĞ', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', From f9c50c1bea3f658a9e072eeb1e2a8ef3ba2b6d6b Mon Sep 17 00:00:00 2001 From: Oluwatobi Adefami <48369656+Tobiadefami@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:43:28 +0100 Subject: [PATCH 09/18] fix: preserve zero results from AVERAGEIF (#1733) ### Context `AVERAGEIF` returned a `#DIV/0!` error when matching values produced a valid average of `0`. This happened because the result used a logical OR fallback, which treated `0` as if no average had been calculated. This change uses nullish coalescing so that only an absent result produces `#DIV/0!`, while a valid zero is returned normally. ### How did you test your changes? Added a regression test where the matching values are `-1` and `1`, producing an average of `0`. The test was verified to fail with `#DIV/0!` before the fix and pass with `0` after the fix. I also ran: - The focused `AVERAGEIF` test suite: all 14 tests passed. - `npm run bundle:cjs`: TypeScript and the CommonJS build completed successfully. - Targeted linting: no errors. ### Types of changes - [ ] Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore) - [ ] New feature or improvement (a non-breaking change that adds functionality) - [x] Bug fix (a non-breaking change that fixes an issue) - [ ] Additional language file, or a change to an existing language file (translations) - [x] Change to the documentation ### Related issues: None. ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https:// hyperformula.handsontable.com/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [x] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/ os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [x] My change is compatible with Microsoft Excel. - [x] My change is compatible with Google Sheets. - [x] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/ CHANGELOG.md) file. - [ ] My changes require a documentation update. - [ ] My changes require a migration guide. --- > [!NOTE] > **Low Risk** > Single-operator change in AVERAGEIF result handling with no impact on auth, data, or broader aggregation APIs. > > **Overview** > **`AVERAGEIF` now returns `0` when the conditional average is legitimately zero**, instead of incorrectly surfacing `#DIV/0!`. > > The bug came from using logical OR (`||`) after `averageValue()`: a computed average of `0` was treated like a missing result. **`ConditionalAggregationPlugin`** switches that fallback to nullish coalescing (`??`), so only `undefined` (no matching numeric cells / zero count) still maps to `#DIV/0!`. **CHANGELOG** documents the fix under Unreleased. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2eb85e9775e3ea2970ba6fbdbd31c20d92ab3ef4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Kuba Sekowski --- CHANGELOG.md | 1 + src/interpreter/plugin/ConditionalAggregationPlugin.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4256fd3b5c..aeea8dd511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) - Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) diff --git a/src/interpreter/plugin/ConditionalAggregationPlugin.ts b/src/interpreter/plugin/ConditionalAggregationPlugin.ts index f2e74e83cf..34e0fc7db6 100644 --- a/src/interpreter/plugin/ConditionalAggregationPlugin.ts +++ b/src/interpreter/plugin/ConditionalAggregationPlugin.ts @@ -200,7 +200,7 @@ export class ConditionalAggregationPlugin extends FunctionPlugin implements Func if (averageResult instanceof CellError) { return averageResult } else { - return averageResult.averageValue() || new CellError(ErrorType.DIV_BY_ZERO) + return averageResult.averageValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } } From c920375cbb1e6a138fb846cc224756eb41240595 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 3 Sep 2026 02:54:20 +0800 Subject: [PATCH 10/18] docs(HF-282): auto-derive function & language counts in docs (#1715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why The docs stated a **hardcoded** count of built-in functions ("~400"/"400+") and languages ("17"/"18") that drifts from reality. The function count already auto-derived via `{{ $page.functionsCount }}`; this adds the parallel `{{ $page.languagesCount }}` and swaps the remaining hardcoded counts to the interpolated variables. ## How - `docs/.vuepress/config.js`: derive `languagesCount` once at config load from the i18n export barrel `src/i18n/languages/index.ts` (whitespace-tolerant regex + fail-loud guard so a barrel reformat can never silently publish "0 languages"); inject `$page.languagesCount`. - Docs: `~400`/`400+` → `{{ $page.functionsCount }}` (index, ai-sdk, mcp-server, langchain); `17`/`18` → `{{ $page.languagesCount }}` (index, built-in-functions, i18n-features, localizing-functions). - `README.md` (not a VuePress page, so no interpolation possible): manual `over 400` + `18`, with the language count now asserted against the same i18n barrel at config load (`3b4fda30f`) so it fails the docs build instead of rotting silently. - `docs/.vuepress/plugins/md-companions/index.js`: register `languagesCount` with the companion `{{ $page.* }}` resolver (`3ef3fdddc`). The resolver arrived with #1703 and substitutes an allowlist of injected keys, so without this the built `.md` companions and `llms-full.txt` shipped the raw mustache while the HTML rendered the number. ## Verification `docs:build` renders the real counts (functions / languages), including inside markdown link text, with **no un-rendered `{{ }}`** in the built output. Docs-only + docs-build-config → no CHANGELOG per DEV_DOCS DoD. ## Known nuance `README.md` counts stay hand-edited — it is rendered by GitHub and npm, neither of which runs VuePress, so `{{ $page.* }}` would publish literally. The language count is therefore guarded rather than interpolated: the docs build fails if README disagrees with the barrel, or if the ` built-in languages` phrase disappears. Verified both failure modes. The function count needs no guard — "over 400" is chosen so the line does not restale as functions grow. The `localizing-functions.md` language *table* rows remain hand-maintained. The guard couples the docs build to `README.md`, which is a deliberate trade: the alternative was generating README as a build product, and it is a file people edit directly. ClickUp task: https://app.clickup.com/t/9015210959/HF-282 **Update (28.08 rebase):** `develop` replaced `docs/guide/built-in-functions.md` with a generated page (template `docs/guide/built-in-functions.tmpl.md` + generator, HF-249/#1692). This PR's one-line change to that page now lives in the **template** — the generator copies prose verbatim, and `npm run docs:build` renders `{{ $page.languagesCount }}` correctly in the generated output (verified: zero unrendered mustaches in `dist/`). Commit SHAs in this description refer to the rebased branch. --- > [!NOTE] > **Low Risk** > Docs and VuePress build configuration only; no runtime library or API behavior changes. > > **Overview** > Stops hardcoded **function** and **language** totals in docs and the root README from drifting when the catalogue or i18n packs change. > > **VuePress** now derives `languagesCount` at config load from `src/i18n/languages` (with a barrel re-export check) and injects `{{ $page.languagesCount }}` alongside the existing function count. The function total is computed once via `getAvailableFunctions()` on a default-config engine—the same approach as `script/generate-builtin-functions-doc.ts`—instead of `getRegisteredFunctionNames`, so the headline number matches the generated built-in-functions table. Guide pages, index, and integration previews swap `~400` / `400+` and `17` / `18` for `{{ $page.functionsCount }}` and `{{ $page.languagesCount }}`; the **md-companions** allowlist resolves `languagesCount` in shipped `.md` / `llms-full.txt`. > > **README.md** (GitHub/npm, no VuePress) is updated manually to **over 400** functions and **18** languages; the docs build **fails** if the i18n features bullet’s language number disagrees with the derived count. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ea8e6a4291bea311fab9b034ff269598c3c3c568. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Kuba Sekowski --- README.md | 4 +- docs/.vuepress/config.js | 79 +++++++++++++++++-- docs/.vuepress/plugins/md-companions/index.js | 2 +- docs/guide/ai-sdk.md | 2 +- docs/guide/built-in-functions.tmpl.md | 2 +- docs/guide/i18n-features.md | 2 +- docs/guide/integration-with-langchain.md | 2 +- docs/guide/localizing-functions.md | 2 +- docs/guide/mcp-server.md | 2 +- docs/index.md | 4 +- script/generate-builtin-functions-doc.ts | 7 +- script/renderBuiltinFunctionsTable.ts | 2 +- 12 files changed, 90 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8d3d14707b..0d2b9c2fc2 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ HyperFormula doesn't assume any existing user interface, making it a general-pur - [Function syntax compatible with Microsoft Excel](https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html) and [Google Sheets](https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html) - High-speed parsing and evaluation of spreadsheet formulas -- [A library of ~400 built-in functions](https://hyperformula.handsontable.com/docs/guide/built-in-functions.html) +- [A library of over 400 built-in functions](https://hyperformula.handsontable.com/docs/guide/built-in-functions.html) - [Support for custom functions](https://hyperformula.handsontable.com/docs/guide/custom-functions.html) - [Support for Node.js](https://hyperformula.handsontable.com/docs/guide/server-side-installation.html#install-with-npm-or-yarn) - [Support for undo/redo](https://hyperformula.handsontable.com/docs/guide/undo-redo.html) @@ -48,7 +48,7 @@ HyperFormula doesn't assume any existing user interface, making it a general-pur - [Support for clipboard](https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html) - [Support for named expressions](https://hyperformula.handsontable.com/docs/guide/named-expressions.html) - [Support for data sorting](https://hyperformula.handsontable.com/docs/guide/sorting-data.html) -- [Support for formula localization with 17 built-in languages](https://hyperformula.handsontable.com/docs/guide/i18n-features.html) +- [Support for formula localization with 18 built-in languages](https://hyperformula.handsontable.com/docs/guide/i18n-features.html) - Easy integration with any front-end or back-end application - GPLv3 or a [commercial license](https://handsontable.com/get-a-quote) - Maintained by the team that stands behind the [Handsontable](https://handsontable.com/) data grid diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 3f6b6864d1..5caadb41a7 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -4,6 +4,77 @@ const footnotePlugin = require('markdown-it-footnote'); const searchBoxPlugin = require('./plugins/search-box'); const examples = require('./plugins/examples/examples'); const HyperFormula = require('../../dist/hyperformula.full'); +const fs = require('fs'); +const path = require('path'); + +// HF-282: count HF built-in languages from `src/i18n/languages` (source of truth): one module +// per shipped language, next to the `index.ts` export barrel. Counted by listing the directory +// rather than by matching `export {default as xxYY}` lines in the barrel, so the total cannot +// depend on how those lines are punctuated: `export { default as ukUA }` is the same export to +// TypeScript, but a regex anchored on the brace spacing misses it and then quietly publishes a +// stale total with every check below still green. Read once at config load — no dist/ +// dependency, so it resolves identically in `docs:dev` and `docs:build`. +const languagesDir = path.resolve(__dirname, '../../src/i18n/languages'); +const languageCodes = fs.readdirSync(languagesDir) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') + .map((file) => path.basename(file, '.ts')); +const languagesCount = languageCodes.length; +if (!languagesCount) { + throw new Error(`HF-282: derived languagesCount is 0 — no language modules found in ${languagesDir}; check the path in docs/.vuepress/config.js.`); +} +// Listing the directory counts what is *present*, and only the barrel decides what actually +// ships, so a module nobody re-exported would overstate the total. Cross-check by language code +// (no brace matching, so barrel formatting stays irrelevant) and fail loudly on the mismatch. +const languagesBarrel = fs.readFileSync(path.join(languagesDir, 'index.ts'), 'utf8'); +const unexportedLanguages = languageCodes.filter((code) => !new RegExp(`\\bdefault as ${code}\\b`).test(languagesBarrel)); +if (unexportedLanguages.length) { + throw new Error(`HF-282: language modules present in src/i18n/languages/ but not re-exported from index.ts, so they do not ship and must not be counted: ${unexportedLanguages.join(', ')} — add them to the barrel, or remove the files.`); +} + +// HF-282: the root README.md is rendered by GitHub and npm, not VuePress, so it cannot +// use the `{{ $page.languagesCount }}` interpolation and states the count literally. +// Assert it against the count derived above so it cannot rot unnoticed — it already did once, when +// the Indonesian pack (#1674) left the README saying 17. The function count needs no +// such check: "over 400" stays true as functions are added. +// +// The claim under test is one specific line: the features bullet linking to the i18n guide. Matched +// there rather than loose against the whole file, because `String.match` without /g returns the +// first hit anywhere — inside a fenced example, or in a sentence about an older release — and would +// then report a number from a line that was never the claim, sending the reader to correct text +// that is already right. Fenced blocks are skipped for the same reason. +const readmeLanguagesClaims = []; +let inReadmeFence = false; +for (const line of fs.readFileSync(path.resolve(__dirname, '../../README.md'), 'utf8').split('\n')) { + if (/^\s*(```|~~~)/.test(line)) { + inReadmeFence = !inReadmeFence; + continue; + } + if (inReadmeFence || !line.startsWith('- ') || !line.includes('guide/i18n-features')) { + continue; + } + const match = line.match(/(\d+) built-in languages/); + if (match) { + readmeLanguagesClaims.push(match[1]); + } +} +if (readmeLanguagesClaims.length !== 1) { + throw new Error(`HF-282: expected exactly one README.md features bullet linking to the i18n guide and stating " built-in languages", found ${readmeLanguagesClaims.length} — if the wording changed on purpose, update this check in docs/.vuepress/config.js.`); +} +if (Number(readmeLanguagesClaims[0]) !== languagesCount) { + throw new Error(`HF-282: README.md says ${readmeLanguagesClaims[0]} built-in languages but src/i18n/languages/ ships ${languagesCount} — update README.md.`); +} + +// HF-282: the function total. Derived from `getAvailableFunctions` on a default-config engine — +// the same API, and the same engine options, as `script/generate-builtin-functions-doc.ts`, so the +// total and the rows of the page it heads cannot describe different function sets. Default-config +// is the point: `functionPlugins` restrictions would give a narrower count than the generated +// table. The GPLv3 key only keeps the build quiet — a keyless engine logs a missing-key warning — +// but see the LICENSE_KEY note in that script for why it has to stay the fully-entitled one. +// Built once here, not per page: `extendPageData` runs for every page and the count is invariant. +const functionsCount = HyperFormula + .buildEmpty({language: 'enGB', licenseKey: 'gpl-v3'}) + .getAvailableFunctions().length; + const includeCodeSnippet = require('./plugins/markdown-it-include-code-snippet'); const mdCompanions = require('./plugins/md-companions'); @@ -112,11 +183,9 @@ module.exports = { // inject current HF releaseDate as {{ $page.releaseDate }} variable $page.releaseDate = HyperFormula.releaseDate // inject current HF function count as {{ $page.functionsCount }} variable - // This total and the rows of the built-in functions page come from two different sources: the count below, - // and getAvailableFunctions via script/renderBuiltinFunctionsTable.ts. They agree today (423 each) and must - // be kept in step by hand, or the page prints a total that contradicts the number of rows under it. The - // renderer throws on the one divergence it can see from its side; this side cannot detect any. - $page.functionsCount = HyperFormula.getRegisteredFunctionNames('enGB').length + $page.functionsCount = functionsCount + // inject current HF built-in language count as {{ $page.languagesCount }} variable + $page.languagesCount = languagesCount if (searchPattern.test($page.path) || generatedPagePattern.test($page.path)) { $page.frontmatter.editLink = false diff --git a/docs/.vuepress/plugins/md-companions/index.js b/docs/.vuepress/plugins/md-companions/index.js index a1a89362df..35d5ba0c04 100644 --- a/docs/.vuepress/plugins/md-companions/index.js +++ b/docs/.vuepress/plugins/md-companions/index.js @@ -13,7 +13,7 @@ const { stripVuePressSyntax } = require('./strip'); */ function resolvePageVars(md, page) { return md.replace( - /\{\{\s*\$page\.(version|buildDate|buildDateURIEncoded|releaseDate|functionsCount)\s*\}\}/g, + /\{\{\s*\$page\.(version|buildDate|buildDateURIEncoded|releaseDate|functionsCount|languagesCount)\s*\}\}/g, (m, key) => (page && page[key] != null ? String(page[key]) : m) ); } diff --git a/docs/guide/ai-sdk.md b/docs/guide/ai-sdk.md index 9ede398ec2..4ef063cb25 100644 --- a/docs/guide/ai-sdk.md +++ b/docs/guide/ai-sdk.md @@ -24,7 +24,7 @@ If you'd like to try it, [join the early access list](https://2fmjvg.share-eu1.h - **Evaluate formulas deterministically** — your agent runs any Excel-compatible formula through HyperFormula instead of asking the LLM to do math. Results are exact, reproducible, and auditable. - **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. - **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how every value was derived. -- **400+ built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. +- **{{ $page.functionsCount }} built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. ## Example diff --git a/docs/guide/built-in-functions.tmpl.md b/docs/guide/built-in-functions.tmpl.md index f801ad044e..7d03cf4df1 100644 --- a/docs/guide/built-in-functions.tmpl.md +++ b/docs/guide/built-in-functions.tmpl.md @@ -36,7 +36,7 @@ spreadsheet software. That is because a spreadsheet is probably the most universal software ever created. We wanted the same flexibility for HyperFormula but without the constraints of the spreadsheet UI. -Each of HyperFormula's built-in function names is available in [17 languages](localizing-functions.md#list-of-supported-languages) and [custom language packs](localizing-functions.md) can be added. +Each of HyperFormula's built-in function names is available in [{{ $page.languagesCount }} languages](localizing-functions.md#list-of-supported-languages) and [custom language packs](localizing-functions.md) can be added. The latest version of HyperFormula has an extensive collection of **{{ $page.functionsCount }}** functions grouped into categories: diff --git a/docs/guide/i18n-features.md b/docs/guide/i18n-features.md index 07db8d49f5..183e5e0432 100644 --- a/docs/guide/i18n-features.md +++ b/docs/guide/i18n-features.md @@ -17,7 +17,7 @@ Configure HyperFormula to match the languages and regions of your users. ## Function names and errors -Each of HyperFormula's [built-in functions](built-in-functions.md) and [errors](types-of-errors.md) is available in [18 languages](localizing-functions.md#list-of-supported-languages). +Each of HyperFormula's [built-in functions](built-in-functions.md) and [errors](types-of-errors.md) is available in [{{ $page.languagesCount }} languages](localizing-functions.md#list-of-supported-languages). You can easily [switch between languages](localizing-functions.md) ([`language`](../api/interfaces/configparams.md#language)). diff --git a/docs/guide/integration-with-langchain.md b/docs/guide/integration-with-langchain.md index e1b207ba27..1be8f3f99a 100644 --- a/docs/guide/integration-with-langchain.md +++ b/docs/guide/integration-with-langchain.md @@ -22,7 +22,7 @@ If you'd like to try it, [join the early access list](https://2fmjvg.share-eu1.h - **Evaluate formulas deterministically** — your agent runs any Excel-compatible formula through HyperFormula instead of asking the LLM to do math. Results are exact, reproducible, and auditable. - **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. - **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how every value was derived. -- **400+ built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. +- **{{ $page.functionsCount }} built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. ## Example diff --git a/docs/guide/localizing-functions.md b/docs/guide/localizing-functions.md index 9603e0cb7d..39b6d1dcb8 100644 --- a/docs/guide/localizing-functions.md +++ b/docs/guide/localizing-functions.md @@ -9,7 +9,7 @@ tags: # Localizing functions You can localize a function's ID and error -messages. Currently, HyperFormula supports 18 languages, with British English +messages. Currently, HyperFormula supports {{ $page.languagesCount }} languages, with British English as the default. To change the language all you need to do is import and diff --git a/docs/guide/mcp-server.md b/docs/guide/mcp-server.md index 9a1ac29bd9..51e387c1cc 100644 --- a/docs/guide/mcp-server.md +++ b/docs/guide/mcp-server.md @@ -23,7 +23,7 @@ If you'd like to try it, [join the early access list](https://2fmjvg.share-eu1.h - **Evaluate formulas deterministically** — your agent runs any Excel-compatible formula through HyperFormula instead of asking the LLM to do math. Results are exact, reproducible, and auditable. - **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. - **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how every value was derived. -- **400+ built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. +- **{{ $page.functionsCount }} built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. ## Example diff --git a/docs/index.md b/docs/index.md index ec82856b1f..0e0367fe2a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,7 +45,7 @@ HyperFormula doesn't assume any existing user interface, making it a general-pur - [Function syntax compatible with Microsoft Excel](guide/compatibility-with-microsoft-excel.md) and [Google Sheets](guide/compatibility-with-google-sheets.md) - High-speed parsing and evaluation of spreadsheet formulas -- [A library of ~400 built-in functions](guide/built-in-functions.md) +- [A library of {{ $page.functionsCount }} built-in functions](guide/built-in-functions.md) - [Support for custom functions](guide/custom-functions.md) - [Support for Node.js](guide/server-side-installation.md#install-with-npm-or-yarn) - [Support for undo/redo](guide/undo-redo.md) @@ -53,7 +53,7 @@ HyperFormula doesn't assume any existing user interface, making it a general-pur - [Support for clipboard](guide/clipboard-operations.md) - [Support for named expressions](guide/named-expressions.md) - [Support for data sorting](guide/sorting-data.md) -- [Support for formula localization with 17 built-in languages](guide/i18n-features.md) +- [Support for formula localization with {{ $page.languagesCount }} built-in languages](guide/i18n-features.md) - Easy integration with any front-end or back-end application - GPLv3 or a [commercial license](https://handsontable.com/get-a-quote) - Maintained by the team that stands behind the [Handsontable](https://handsontable.com/) data grid diff --git a/script/generate-builtin-functions-doc.ts b/script/generate-builtin-functions-doc.ts index 5b611deba2..6281a10cc5 100644 --- a/script/generate-builtin-functions-doc.ts +++ b/script/generate-builtin-functions-doc.ts @@ -38,9 +38,10 @@ const LICENSE_KEY = 'gpl-v3' /** Reads the committed template and returns the page with both generated regions spliced in. */ function buildUpdatedFile(): string { - // Deliberately a default-config engine: its registry must stay the global one, because the function total printed - // on the page is computed separately, from the global registry, in docs/.vuepress/config.js. Restricting this - // engine with `functionPlugins` would give the table a different function set from the total above it. + // Deliberately a default-config engine, matching the one `docs/.vuepress/config.js` builds for the function total + // printed above the table: same `getAvailableFunctions` call, same language, same license key. Restricting this + // engine with `functionPlugins` would give the table a different function set from that total. The two engines are + // still separate call sites — keep the options here and there in step. const engine = HyperFormula.buildEmpty({language: LANGUAGE, licenseKey: LICENSE_KEY}) const entries = engine.getAvailableFunctions() const detailsFor = (canonicalName: string) => engine.getFunctionDetails(canonicalName) diff --git a/script/renderBuiltinFunctionsTable.ts b/script/renderBuiltinFunctionsTable.ts index 2119e5609f..4f9fc528b3 100644 --- a/script/renderBuiltinFunctionsTable.ts +++ b/script/renderBuiltinFunctionsTable.ts @@ -83,7 +83,7 @@ function escapeCell(text: string): string { * Only the documented categories get a section, so every entry passed in must declare one: the page is generated from * the built-in catalogue, where `category` is a [[DocumentedFunctionCategory]] by type. A `'Custom'` entry is rejected * rather than skipped — silently dropping it would leave the page short of a row while the printed function total, - * which is computed independently in `docs/.vuepress/config.js`, still claimed it. + * which `docs/.vuepress/config.js` computes from its own engine, still claimed it. * * @param {FunctionListEntry[]} entries - the function set to document (e.g. an engine's `getAvailableFunctions`) * @param {(canonicalName: string) => FunctionDetails | undefined} detailsFor - resolves a function's details From 5abbbd9a8bb7966a5d2175dbfda3ea658d3b6d12 Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:18:45 -0400 Subject: [PATCH 11/18] Promote the first-party docs MCP server in the agent docs (#1775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The first-party docs MCP server is live at `https://docs-assistant.handsontable.com/mcp` — semantic search over the full HyperFormula + Handsontable knowledge base (docs guides, API reference, code recipes, release notes, blog posts, and GitHub issues), always current with the latest release. This PR makes it the recommended MCP route in the agent-facing docs: - **Set up your coding agent** guide: the "Live docs via MCP" section now leads with the first-party server (GitMCP and Context7 stay as alternatives); added to the page tags and the Resources list. - **Wizard**: the Cursor path now hands out a `.cursor/mcp.json` config for the server (Cursor supports MCP natively; the rules-file corpus pointer stays as the noted alternative), and the Other/API path mentions the MCP URL. - **README**: one sentence pointing MCP-capable agents at the server. The endpoint was verified live before this PR: initialize, `tools/list`, and a real `tools/call` all pass, serving the current knowledge-base pair (HT 18.1.0 / HF 3.4.0). ## Test plan - Docs-only change: `docs/guide/setup-coding-agent.md` renders with the existing anchors unchanged (the `#live-docs-via-mcp-any-agent` sidebar link still resolves) - Wizard JSON snippet is valid `.cursor/mcp.json` content 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Low Risk** > Documentation-only updates to README, docs pages, and the coding-agent wizard; no runtime or library code changes. > > **Overview** > This PR updates agent-facing documentation to **recommend the first-party docs MCP server** at `https://docs-assistant.handsontable.com/mcp` for live, semantic search over HyperFormula and Handsontable docs. > > In **Set up your coding agent**, the “Live docs via MCP” section now leads with that server (GitMCP and Context7 remain alternatives), and the Resources list adds a link to the Docs MCP Server guide. The **CodingAgentWizard** Cursor path now copies a `.cursor/mcp.json` snippet for the server, with the `llms-full.txt` rules-file approach noted as a fallback; the Other/API option also mentions the MCP URL. **README** and **docs index** gain a short paragraph pointing MCP-capable agents at the same server. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b0a65202daaab6274cdf4e5541042ce7d6fd6529. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Fable 5 --- README.md | 2 +- docs/.vuepress/components/CodingAgentWizard.vue | 6 +++--- docs/guide/setup-coding-agent.md | 4 +++- docs/index.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d2b9c2fc2..1dc6c1c715 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ console.log(`${hf.getCellValue({ sheet: sheetId, row: 0, col: 0 })}: ${hf.getCel [Run this code in StackBlitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.4.x/mortgage-calculator) -HyperFormula ships an official Claude skill and machine-readable docs, so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. To install the skill in Claude Code, or to point Cursor, GitHub Copilot, or another agent at the docs, see [Set up your coding agent](https://hyperformula.handsontable.com/docs/guide/setup-coding-agent.html). +HyperFormula ships an official Claude skill and machine-readable docs, so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. To install the skill in Claude Code, or to point Cursor, GitHub Copilot, or another agent at the docs, see [Set up your coding agent](https://hyperformula.handsontable.com/docs/guide/setup-coding-agent.html). You can also connect any MCP-capable agent to the first-party [docs MCP server](https://handsontable.com/docs/javascript-data-grid/docs-mcp-server/) — semantic search over the full HyperFormula and Handsontable knowledge base: docs guides, API reference, code recipes, release notes, blog posts, and GitHub issues, always current with the latest release. ## Contributing diff --git a/docs/.vuepress/components/CodingAgentWizard.vue b/docs/.vuepress/components/CodingAgentWizard.vue index 6eb24284a4..a91d3ba87d 100644 --- a/docs/.vuepress/components/CodingAgentWizard.vue +++ b/docs/.vuepress/components/CodingAgentWizard.vue @@ -40,8 +40,8 @@ export default { { id: 'cursor', label: 'Cursor', - snippet: 'Add to your AGENTS.md / rules file:\nHyperFormula docs (LLM-friendly): https://hyperformula.handsontable.com/docs/llms-full.txt', - note: 'Cursor has no Claude-skill installer yet — point it at the full docs corpus instead.', + snippet: 'Add to .cursor/mcp.json:\n{\n "mcpServers": {\n "handsontable-docs": { "url": "https://docs-assistant.handsontable.com/mcp" }\n }\n}', + note: 'Connects Cursor to the first-party docs MCP server (docs guides, API reference, code recipes, release notes, blog posts, and GitHub issues). Alternatively, point a rules file at the full docs corpus: https://hyperformula.handsontable.com/docs/llms-full.txt.', }, { id: 'copilot', @@ -53,7 +53,7 @@ export default { id: 'other', label: 'Other / API', snippet: 'curl -s https://hyperformula.handsontable.com/docs/llms-full.txt', - note: 'Fetch the full corpus, or upload the skill folder from handsontable/handsontable-skills to the Claude API.', + note: 'Fetch the full corpus, upload the skill folder from handsontable/handsontable-skills to the Claude API, or connect any MCP-capable agent to https://docs-assistant.handsontable.com/mcp.', }, ], }; diff --git a/docs/guide/setup-coding-agent.md b/docs/guide/setup-coding-agent.md index 406845499f..37909fbc71 100644 --- a/docs/guide/setup-coding-agent.md +++ b/docs/guide/setup-coding-agent.md @@ -41,8 +41,9 @@ For agents that read a rules file (e.g. Cursor's `AGENTS.md`), add a line pointi ## Live docs via MCP (any agent) -Two zero-setup ways to let an agent pull authoritative HyperFormula docs on demand: +Zero-setup ways to let an agent pull authoritative HyperFormula docs on demand: +- **Docs MCP server** (first-party, recommended) — add `https://docs-assistant.handsontable.com/mcp` to your agent (e.g. `claude mcp add --transport http handsontable-docs https://docs-assistant.handsontable.com/mcp`). Semantic search over the full HyperFormula and Handsontable knowledge base: docs guides, API reference, code recipes, release notes, blog posts, and GitHub issues — always current with the latest release. No install, no auth. Full guide: [Docs MCP Server](https://handsontable.com/docs/javascript-data-grid/docs-mcp-server/). - **GitMCP** — add the MCP server `https://gitmcp.io/handsontable/hyperformula` to your agent (e.g. `claude mcp add --transport http hyperformula https://gitmcp.io/handsontable/hyperformula`). It serves this GitHub repository's docs. No install, no auth. - **Context7** — run `npx -y @upstash/context7-mcp` (or use the Context7 skill / `ctx7` CLI) and ask for the `hyperformula` library. Context7 indexes the repository's `docs` folder (see `context7.json` in the repo root). @@ -56,5 +57,6 @@ cp -r handsontable-skills/skills/hyperformula ~/.claude/skills/ ## Resources - [Official skill repository](https://github.com/handsontable/handsontable-skills) +- [Docs MCP Server guide](https://handsontable.com/docs/javascript-data-grid/docs-mcp-server/) - [`llms-full.txt`](../llms-full.txt) - [API reference](/api/) diff --git a/docs/index.md b/docs/index.md index 0e0367fe2a..2933864f65 100644 --- a/docs/index.md +++ b/docs/index.md @@ -108,7 +108,7 @@ console.log(`${hf.getCellValue({ sheet: sheetId, row: 0, col: 0 })}: ${hf.getCel [Run this code in StackBlitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.4.x/mortgage-calculator) -HyperFormula ships an official Claude skill and machine-readable docs, so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. To install the skill in Claude Code, or to point Cursor, GitHub Copilot, or another agent at the docs, see [Set up your coding agent](guide/setup-coding-agent.md). +HyperFormula ships an official Claude skill and machine-readable docs, so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. To install the skill in Claude Code, or to point Cursor, GitHub Copilot, or another agent at the docs, see [Set up your coding agent](guide/setup-coding-agent.md). You can also connect any MCP-capable agent to the first-party [docs MCP server](https://handsontable.com/docs/javascript-data-grid/docs-mcp-server/) — semantic search over the full HyperFormula and Handsontable knowledge base: docs guides, API reference, code recipes, release notes, blog posts, and GitHub issues, always current with the latest release. ## Contributing From 3a9c34b44dd84f1c62c6c942be3ec16761ba2363 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 8 Oct 2026 00:34:20 +0800 Subject: [PATCH 12/18] =?UTF-8?q?HF-307:=20license-key=20entitlement=20gat?= =?UTF-8?q?ing=20=E2=80=94=20capability=20model,=20key=20reader,=20API=20g?= =?UTF-8?q?uards=20(#1728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What this is HF-307, feature packages and add-ons. A proprietary license key can now grant a *subset* of the library instead of only answering yes/no. The key carries a flat list of capability tokens, and the engine grants the union of what those tokens name. This started as a stack of four PRs and is now one. #1729, #1730 and #1743 were merged into this branch; #1731, #1736, #1737, #1740 and #1741 were closed once their content landed here. Paired tests: handsontable/hyperformula-tests#30. Twenty-four files are new; 22 of those live under `src/license/`. > The earlier revisions of this description, written while the stack still existed, are in this description's edit history. ### Two gates - **Gate A** — is the key valid? Classic keys are validated and messaged as before, except that the build release date and the expiry date in the console message are now read in UTC (see below); entitlement keys are validated too, with their own console messages. A key that is missing, invalid, or expired in a way that blocks evaluation (a classic key, or a trial past its grace period) blocks the library: - every function except `VERSION` and `OFFSET` evaluates to `#LIC!`, as before; - new: every gated public-API method throws `LicenseCapabilityMissingError` naming the key's state (`License key is missing. Feature crud is not available.`), and so does building an engine with named expressions. Before, such a key stopped only function calls: operator formulas, plain data and the whole API kept working. Code that builds an engine without a valid key now gets this error from its first gated call. The changelog lists it under Changed. - **Gate B** — what does the key grant? New. Functions and public-API areas, resolved on two axes (`functions`, `features`), each either a set or `'all'`. One invariant binds them: **only a key that lets the build evaluate restricts anything through gate B**: a valid key, or an expired subscription or perpetual key, which keeps its own grants. A missing, invalid or blocking expired key resolves to an entitlement that restricts nothing, and gate A alone stops it. Its errors therefore always name the key's state (`License key is invalid.`), never a missing grant (`… is not included in your license`), and `getAvailableFunctions()` still describes the whole catalog under it. ### What a restricted key does - A function outside the grant evaluates to `#LIC!` rather than disappearing, in its own cell: an array function the license stops reserves no spill range, so it never shows `#SPILL!` instead. - A gated public-API method throws `LicenseCapabilityMissingError`. Its `feature` property names the area the call needed, as a value of `FeatureId`, which the package now exports. - The matching `isItPossibleTo*` predicates (`isItPossibleToAddRows()` and the rest) answer `false` for a call that would throw it, and so do `isThereSomethingToUndo()` and `isThereSomethingToRedo()` without `undo_redo`. - `getAvailableFunctions()` and `getFunctionDetails()` describe only what the instance can actually evaluate. `getRegisteredFunctionNames()` still lists every registered function. - Valid classic 25-character keys, `gpl-v3`, `internal-use-in-handsontable` and `hftrial-0168e-1f2b7-47158-70b05-0842f` are unaffected: they resolve to an unrestricted entitlement and never consult the capability table. An invalid or expired classic key gets gate A's behavior above. ### Capability tokens Function grants use the packaging vocabulary: `fun:all`, `fun:.` and per-function `fun:`. The callable operator forms (`HF.ADD` and friends) sit under `fun:operator.A`; infix operators work under any key. Token names are matched case-insensitively. Gated API areas use `feat:*` tokens, one area each. A key is granted **exactly the areas it names**, and `feat:all` is how it names all of them; a key naming none is granted none. The key generator (`license-key` 5.1.1) cannot write `fun:*` or `feat:*` tokens yet, so a key it issues today grants no gated function and no gated API area. An earlier revision read the absence of a `feat:*` token as "this key does not talk about features" and granted all five areas; that rule was removed deliberately. The engine knows no packages and no add-ons. A key may carry a word like `spreadsheet` or `functions_1`; it is read as any other word this version does not know: it grants nothing, nothing reports it, and it never subtracts anything. ### The key reader `src/license/handsontable-license-key-parser/` is a copy of the reader upstream publishes for products (`license-key`'s `vendor/entitlement-key-reader/`, tag 5.1.1). Twelve of its thirteen files are byte-identical; the thirteenth differs by one cast, explained below. The engine calls its `readEntitlementLicense`, as upstream's README prescribes: the reader verifies the key, picks the `hyperformula` entry, places it in its lifecycle window and reads its flags. `licenseResolution.ts` keeps what the guide leaves to the product: the meaning of the capability tokens, the console messages, and which lifecycle states block evaluation. A non-trial key past its grace period, or past the build its maintenance covers, reports EXPIRED and prints a console error but keeps evaluating with its own grants. A trial past its grace still gives `#LIC!`. Classic 25-character keys are read as before, with one fix: the build release date they are compared with is parsed in UTC, and the expiry date in their console message is printed in UTC. Previously, east of UTC, a classic key that expired the day before the build was released was still accepted, and west of UTC, the console printed an expiry date one day too early. The changelog has a Fixed entry for it. Since 5.x, keys are in format version 2: the payload carries a digest of the prose, so a key whose prose was edited or removed (the bare `[...]` block), or that has text after its block, is invalid (M7). Whitespace and line breaks saved as text (`\n`) are ignored anywhere in the key, inside the block too. Following that, the license key guide tells users to pass the whole key as issued. The spec's state table has the matching rows (S31 changed, S39 added). Console messages for entitlement keys are the specification's text, per lifecycle state, the same table Handsontable prints (`handsontable/src/helpers/mixed.ts`): the date exactly as the key carries it, a warning while the key works and an error once it has run out (the grace period included). They print every time a key is resolved: unlike classic keys, which keep their once-per-page flag, entitlement keys keep no record of what they already printed, so one key can print the same message more than once on a page. The cast in `extractKeyData.ts` stays. Following the reader changed three things: - A key that grants other products but not HyperFormula is INVALID and restricts nothing. An earlier revision had it VALID with nothing granted. - A malformed date in another product's entry invalidates the whole key again, as upstream does. - Only `no-console-warns` silences the console. `silent-console` and `silent` are unknown flags; the generator emits neither. `upstream.json` pins the tag. `npm run check:license-key-parser-drift` compares every file git tracks in the directory against the pinned commit and runs in CI (`vendored-parser.yml`; currently red: the `LICENSE_KEY_REPO_TOKEN` secret exists but cannot read the upstream repository). One declared divergence, a single cast for TypeScript 4.0, is recorded there with its expiry. ### Also in this PR - **Moving or pasting a formula with an undefined name to another sheet** no longer adds that name as an empty global named expression. Before, the formula's `#NAME?` turned into an empty value, and the workbook serialized a named expression nobody added, which a key without `named_expressions` then could not build again. The bug predates this PR; the license gate turned it into a hard failure. Only a name the source sheet defines locally is copied to the global scope now (`Operations.updateNamedExpressionsForTargetAddress`). - **One rule for stopped function calls.** `FunctionCallLicenseGate` decides which calls the license stops and with which `#LIC!` error. The interpreter and the array size predictor both use it, which is what keeps a stopped array function to one cell. - **API reference examples.** Every `@example` block in `HyperFormula` and its events, and the API reference landing page, passes `licenseKey: 'gpl-v3'`. Four examples that threw for their own reasons (`moveColumns`, `getNamedExpressionValue`, `changeNamedExpression`, `getAllNamedExpressionsSerialized`) are fixed. ### `VERSION()` `VERSION()` returns only the version (`HyperFormula v3.4.0`), with no license status, under every key. The function's catalog description and the changelog say so. ### Docs - Every engine built in a `docs/guide` example passes `licenseKey: 'gpl-v3'`: without a key, the gated methods those examples call now throw. - `DEV_DOCS.md` describes `src/license/` and the vendored reader, and the new-function checklist has a step for `functionCapabilities.ts`: a built-in missing from it is not gated at all. - The changelog has a Changed entry for what a blocking key does to the gated API. It is not marked as breaking, for the reason under "Two gates". ### Not built here, on purpose - The shared conformance fixtures both products would run in CI. Needs the other side to exist. ### Review Reviewed on 2026-09-22, 2026-09-23, 2026-09-24, 2026-09-28 and 2026-09-30, with the 2026-09-29 rulings in the thread. The `feat:import_export` token is gone. Merge order: this PR before hyperformula-tests#30. Open: - The drift-check workflow is red: its secret cannot read the upstream repository. - The key generator cannot write `fun:*` or `feat:*` tokens yet, so no key issued today grants a gated function or API area. - Spec rows S32–S35 and S38 have no tests yet. - Two places where the engine and the spec disagree: a custom function evaluates to `#LIC!` under a blocking key, and `getAvailableFunctions()` lists the whole catalog under a blocking key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **High Risk** > Changes core licensing behavior for formula evaluation and most mutating public API paths; incorrect gating or key parsing could block paying customers or expose paid features. > > **Overview** > Adds **feature-package licensing**: proprietary keys can grant subsets of functions (`fun:*` tokens) and API areas (`feat:*` tokens). Restricted functions evaluate to **`#LIC!`**; gated API methods throw **`LicenseCapabilityMissingError`**, with matching **`isItPossibleTo*`** / undo-redo predicates returning **`false`**. **`getAvailableFunctions()`** / **`getFunctionDetails()`** now list only what the instance’s key allows. > > **Gate A** still blocks evaluation for missing/invalid keys and blocking expirations (classic or trial past grace): almost all functions become **`#LIC!`** (except **`VERSION`** / **`OFFSET`**), and gated API—including building with **named expressions**—throws instead of silently allowing edits. **`VERSION()`** no longer embeds license status. > > Integrates a **vendored** `handsontable/license-key` entitlement reader (pinned tag, drift CI, one TS 4.0 cast shim), **`licenseResolution`**, and capability tables in **`functionCapabilities.ts`** / **`featureCapabilities.ts`**. Classic 25-char keys get a **UTC** fix for release vs expiry comparison and console dates. > > Also fixes **move/paste** of formulas referencing undefined names on another sheet (no spurious global named expression), updates docs/examples with **`licenseKey: 'gpl-v3'`**, and excludes the vendored tree from ESLint/Codecov. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f9819f7ac32d14c6db6cfc93be2a3485125d1987. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Kuba Sekowski Co-authored-by: Kuba Sekowski Co-authored-by: Cursor Agent Co-authored-by: Kuba Sekowski --- .eslintignore | 5 + .github/workflows/vendored-parser.yml | 42 ++ .typedoc.ts | 1 + CHANGELOG.md | 11 + DEV_DOCS.md | 24 +- codecov.yml | 7 + docs/api-ref-readme.md | 2 +- docs/guide/ai-sdk.md | 2 +- docs/guide/basic-operations.md | 4 +- docs/guide/batch-operations.md | 6 +- docs/guide/cell-references.md | 2 +- docs/guide/clipboard-operations.md | 6 +- .../guide/compatibility-with-google-sheets.md | 1 + .../compatibility-with-microsoft-excel.md | 1 + docs/guide/currency-handling.md | 8 +- docs/guide/custom-functions.md | 2 +- docs/guide/dependency-graph.md | 4 +- docs/guide/integration-with-langchain.md | 2 +- docs/guide/known-limitations.md | 2 +- docs/guide/license-key.md | 47 +- docs/guide/localizing-functions.md | 1 + docs/guide/migration-from-0.6-to-1.0.md | 8 +- docs/guide/migration-from-2.x-to-3.0.md | 1 + docs/guide/named-expressions.md | 6 +- docs/guide/sorting-data.md | 12 +- docs/guide/types-of-errors.md | 2 +- docs/guide/types-of-values.md | 6 +- package.json | 1 + script/check-license-key-parser-drift.js | 335 +++++++++++ script/generate-builtin-functions-doc.ts | 13 +- src/ArraySize.ts | 10 + src/BuildEngineFactory.ts | 23 +- src/Config.ts | 67 ++- src/Emitter.ts | 16 +- src/HyperFormula.ts | 561 +++++++++++++----- src/Operations.ts | 9 +- src/error-message.ts | 1 + src/errors.ts | 64 ++ src/helpers/licenseKeyValidator.ts | 202 ++++++- src/index.ts | 6 + src/interpreter/FunctionRegistry.ts | 12 + src/interpreter/Interpreter.ts | 11 +- .../categories/information.ts | 2 +- src/interpreter/plugin/VersionPlugin.ts | 24 +- src/license/CapabilityRegistry.ts | 167 ++++++ src/license/FunctionCallLicenseGate.ts | 80 +++ src/license/LicenseEntitlement.ts | 94 +++ src/license/capabilities.ts | 82 +++ src/license/ensureFeatureAllowed.ts | 44 ++ src/license/featureCapabilities.ts | 38 ++ src/license/functionCapabilities.ts | 140 +++++ .../handsontable-license-key-parser/AGENTS.md | 21 + .../PROVENANCE.md | 62 ++ .../handsontable-license-key-parser/README.md | 415 +++++++++++++ .../buildDate.ts | 69 +++ .../classify.ts | 157 +++++ .../constants.ts | 79 +++ .../detectFormat.ts | 78 +++ .../encoding.ts | 226 +++++++ .../extractKeyData.ts | 519 ++++++++++++++++ .../handsontable-license-key-parser/grants.ts | 101 ++++ .../handsontable-license-key-parser/index.ts | 70 +++ .../readLicense.ts | 132 +++++ .../handsontable-license-key-parser/sha512.ts | 211 +++++++ .../handsontable-license-key-parser/types.ts | 211 +++++++ .../upstream.json | 33 ++ src/license/licenseResolution.ts | 211 +++++++ 67 files changed, 4558 insertions(+), 254 deletions(-) create mode 100644 .github/workflows/vendored-parser.yml create mode 100755 script/check-license-key-parser-drift.js create mode 100644 src/license/CapabilityRegistry.ts create mode 100644 src/license/FunctionCallLicenseGate.ts create mode 100644 src/license/LicenseEntitlement.ts create mode 100644 src/license/capabilities.ts create mode 100644 src/license/ensureFeatureAllowed.ts create mode 100644 src/license/featureCapabilities.ts create mode 100644 src/license/functionCapabilities.ts create mode 100644 src/license/handsontable-license-key-parser/AGENTS.md create mode 100644 src/license/handsontable-license-key-parser/PROVENANCE.md create mode 100644 src/license/handsontable-license-key-parser/README.md create mode 100644 src/license/handsontable-license-key-parser/buildDate.ts create mode 100644 src/license/handsontable-license-key-parser/classify.ts create mode 100644 src/license/handsontable-license-key-parser/constants.ts create mode 100644 src/license/handsontable-license-key-parser/detectFormat.ts create mode 100644 src/license/handsontable-license-key-parser/encoding.ts create mode 100644 src/license/handsontable-license-key-parser/extractKeyData.ts create mode 100644 src/license/handsontable-license-key-parser/grants.ts create mode 100644 src/license/handsontable-license-key-parser/index.ts create mode 100644 src/license/handsontable-license-key-parser/readLicense.ts create mode 100644 src/license/handsontable-license-key-parser/sha512.ts create mode 100644 src/license/handsontable-license-key-parser/types.ts create mode 100644 src/license/handsontable-license-key-parser/upstream.json create mode 100644 src/license/licenseResolution.ts diff --git a/.eslintignore b/.eslintignore index 03546876e2..7b1522852f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,11 @@ docs/examples/ # 3rd party src/interpreter/plugin/3rdparty +# A byte-identical copy of handsontable/license-key's vendor/entitlement-key-reader - see +# PROVENANCE.md in that directory. Linting it would mean editing it, and editing it is the one +# thing it must not have; upstream runs strict tsc on it instead. +src/license/handsontable-license-key-parser/*.ts + # Configurations *.config.js karma.* diff --git a/.github/workflows/vendored-parser.yml b/.github/workflows/vendored-parser.yml new file mode 100644 index 0000000000..7202b0e694 --- /dev/null +++ b/.github/workflows/vendored-parser.yml @@ -0,0 +1,42 @@ +name: Vendored parser + +# The entitlement-key reader under src/license/handsontable-license-key-parser/ is a verbatim +# copy of a private upstream directory. This job is the drift check that copy's integration guide +# asks for: it fails when any byte differs from the pinned tag, when a file shadows the copy, or +# when upstream has released a newer tag. See PROVENANCE.md in that directory. +# +# It needs LICENSE_KEY_REPO_TOKEN - a read-only token for handsontable/license-key - as a repository +# secret. Without it the check fails on purpose ("could not verify" is not "verified"), so this job +# stays red until the secret is set. Pull requests from forks never receive secrets and are skipped. + +on: + pull_request: + types: [ opened, reopened, synchronize ] + paths: + - 'src/license/handsontable-license-key-parser/**' + - 'script/check-license-key-parser-drift.js' + - '.github/workflows/vendored-parser.yml' + push: + branches: [ master, develop ] + schedule: + - cron: '17 6 * * 1-5' # weekday mornings: a newer upstream tag is news even when nothing here changed + +permissions: + contents: read + +jobs: + drift: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + - name: Setup Node.js 22 + uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 + with: + node-version: '22' + - name: Compare the vendored reader with upstream + run: node script/check-license-key-parser-drift.js + env: + LICENSE_KEY_REPO_TOKEN: ${{ secrets.LICENSE_KEY_REPO_TOKEN }} diff --git a/.typedoc.ts b/.typedoc.ts index 0001ced876..d7d1f28152 100644 --- a/.typedoc.ts +++ b/.typedoc.ts @@ -7,6 +7,7 @@ module.exports = { "./src/dependencyTransformers/**", "./src/DependencyGraph/**", "./src/ColumnSearch/**", + "./src/license/handsontable-license-key-parser/**", ], "mode": "file", "out": "./typedoc", diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..787c642ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added support for the new license key format. A proprietary key can now grant a subset of the library: a function your key does not include evaluates to a `#LIC!` error, and the matching parts of the API throw a `LicenseCapabilityMissingError`. `getAvailableFunctions()` and `getFunctionDetails()` describe only the functions your key includes. [#1728](https://github.com/handsontable/hyperformula/pull/1728) + +### Changed + +- Changed the `VERSION` function to return only the HyperFormula version (e.g. `HyperFormula v3.4.0`), without the license key status. [#1728](https://github.com/handsontable/hyperformula/pull/1728) +- Changed the API methods gated by the license key to throw a `LicenseCapabilityMissingError` when the key is missing or invalid, when a classic key has expired, or when a trial key is past its grace period. The gated methods are the ones that edit cells, rows, columns, and sheets, `copy()`, `cut()`, `paste()`, `undo()`, `redo()`, `batch()`, `suspendEvaluation()`, and the methods that add, change, or remove named expressions. Building an engine with named expressions throws the same error, and the matching `isItPossibleTo*()` methods, `isThereSomethingToUndo()`, and `isThereSomethingToRedo()` return `false`. [#1728](https://github.com/handsontable/hyperformula/pull/1728) + ### Fixed +- Fixed the validation of classic (25-character) license keys depending on the time zone: east of UTC, a key that expired the day before the build was released was still accepted, and west of UTC, the console message printed an expiry date one day too early. [#1728](https://github.com/handsontable/hyperformula/pull/1728) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) - Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) +- Fixed a bug where moving or pasting a formula with an undefined name to another sheet incorrectly added an empty global named expression. [#1728](https://github.com/handsontable/hyperformula/pull/1728) ## [3.4.0] - 2026-08-10 diff --git a/DEV_DOCS.md b/DEV_DOCS.md index 95444dc80d..daa15138f5 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -23,7 +23,9 @@ Canonical reference for everyone working on the HyperFormula source code: mainta │ │ └── plugin/ # Built-in spreadsheet function plugins │ ├── DependencyGraph/ # Cell dependency tracking and recalculation order │ ├── CrudOperations.ts # Create/read/update/delete operations on sheets and cells -│ └── i18n/ # Function-name translations per language +│ ├── i18n/ # Function-name translations per language +│ └── license/ # License key reading, capability tables, and license gating +│ └── handsontable-license-key-parser/ # Vendored key reader; do not edit ├── test/ # Test suite ├── docs/ # Public documentation portal (VuePress) │ ├── guide/ # Markdown guides (building, contributing, usage…) @@ -51,6 +53,13 @@ Canonical reference for everyone working on the HyperFormula source code: mainta - `src/interpreter/` — formula evaluation engine - `src/DependencyGraph/` — cell dependency tracking and recalculation order - `src/CrudOperations.ts` — create/read/update/delete operations on sheets and cells +- `src/license/` — license key handling: reads the key, maps its capability tokens to functions (`functionCapabilities.ts`) and public API features (`featureCapabilities.ts`), and gates function calls and API methods + +### License key reader (`src/license/handsontable-license-key-parser/`) + +A copy of the entitlement-key reader published by the `handsontable/license-key` repository, taken whole from a tagged release. Don't edit it: fix the problem upstream and take the copy again, as described in `PROVENANCE.md` in that directory. ESLint ignores the directory. + +`npm run check:license-key-parser-drift` compares the copy with the pinned upstream commit and fails on any difference or on a newer upstream tag. It needs read access to the upstream repository, so it runs in its own CI workflow rather than in `npm run test` or `npm run lint`. ### Function plugins (`src/interpreter/plugin/`) @@ -125,8 +134,9 @@ Adding a built-in function is similar to adding a [custom function](docs/guide/c 2. Add function metadata to `implementedFunctions`. 3. Implement the function method. 4. Add a catalogue entry to `src/interpreter/functionMetadata/categories/.ts` (see below). -5. Add translations to all language files in `src/i18n/languages/`. -6. Add tests in `test/unit/interpreter/`. +5. Add the function to the license capability table in `src/license/functionCapabilities.ts` (see below). +6. Add translations to all language files in `src/i18n/languages/`. +7. Add tests in `test/unit/interpreter/`. ### The function metadata catalogue @@ -155,6 +165,14 @@ Note what the drift warning does **not** cover: **optionality is not cross-check Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of the catalogue was seeded from a hand-written page that documented Excel, and HyperFormula deliberately deviates in places (`INT` truncates toward zero, `ISEVEN`/`ISODD` do not truncate, `CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1). Verify a claim against the implementation before authoring it, and record any deviation in [the list of differences](docs/guide/list-of-differences.md). +### The license capability table + +`src/license/functionCapabilities.ts` decides which license keys can call a built-in function. Add every new built-in either to its group in `FUNCTION_GROUPS` or, if it belongs to no group, to `UNGROUPED_FUNCTIONS`. An ungrouped function is granted only by `fun:all` and by its own `fun:` token. Both lists use canonical names: an alias needs no entry, because it is gated as its canonical function. + +A built-in missing from the table escapes the entitlement check: every key that lets formulas evaluate can call it, including a key that grants no functions. The completeness check in the private test suite (`unit/license/capability-registry.spec.ts`) fails on such a function, but the public smoke tests do not. + +Group names are part of the license key format, so never rename a group or remove a function from one. Adding a function to a group grants it to every key that already carries that group's token. + ## Internationalization and function translations HyperFormula supports internationalization and provides localized function names for all built-in languages. Translation files live in `src/i18n/languages/`. New functions must include translations for all built-in languages. diff --git a/codecov.yml b/codecov.yml index acc230a501..9610511d8d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,6 +6,13 @@ coverage: round: down precision: 2 +# src/license/handsontable-license-key-parser/ is a byte-identical copy of handsontable/license-key's +# vendor/entitlement-key-reader (see PROVENANCE.md there). Upstream tests it; HyperFormula only calls +# part of it and may not edit it, so its coverage says nothing about this repository. It is left in the +# local jest report on purpose - only the codecov statuses ignore it. +ignore: + - "src/license/handsontable-license-key-parser/**" + comment: layout: "reach, diff, flags, files" behavior: new diff --git a/docs/api-ref-readme.md b/docs/api-ref-readme.md index 69f252437f..dc647c341c 100644 --- a/docs/api-ref-readme.md +++ b/docs/api-ref-readme.md @@ -43,7 +43,7 @@ For example, subscribing to `sheetAdded` event: const hfInstance = HyperFormula.buildFromSheets({ MySheet1: [ ['1'] ], MySheet2: [ ['10'] ], -}); +}, { licenseKey: 'gpl-v3' }); const handler = ( ) => { console.log('baz') } diff --git a/docs/guide/ai-sdk.md b/docs/guide/ai-sdk.md index 4ef063cb25..7eb0b7c593 100644 --- a/docs/guide/ai-sdk.md +++ b/docs/guide/ai-sdk.md @@ -41,7 +41,7 @@ const hf = HyperFormula.buildFromArray([ ['Revenue', 100], ['Cost', 60], ['Profit', '=B1-B2'], -]); +], { licenseKey: 'gpl-v3' }); // Pass the spreadsheet tools straight into generateText. const result = await generateText({ diff --git a/docs/guide/basic-operations.md b/docs/guide/basic-operations.md index 966bd96116..b49f000bc7 100644 --- a/docs/guide/basic-operations.md +++ b/docs/guide/basic-operations.md @@ -356,7 +356,7 @@ easily check if that action is allowed, and if it is not, throw an error. // an instance with some example data const hfInstance = HyperFormula.buildFromArray([ ['1', '2'], -]); +], { licenseKey: 'gpl-v3' }); // a variable used to carry the message for the user let messageUsedInUI; @@ -395,7 +395,7 @@ const hf = HyperFormula.buildFromArray([ [1], ['=SUM(A1:A2)'], ['=COUNTBLANK(A1:A3)'], -]); +], { licenseKey: 'gpl-v3' }); // insert an empty row between the row 0 and the row 1 const changes = hf.addRows(0, [1, 1]); diff --git a/docs/guide/batch-operations.md b/docs/guide/batch-operations.md index 87799085f2..c113c7af8d 100644 --- a/docs/guide/batch-operations.md +++ b/docs/guide/batch-operations.md @@ -30,7 +30,7 @@ operation together with their absolute addresses and new values. const hfInstance = HyperFormula.buildFromSheets({ MySheet1: [ ['1'] ], MySheet2: [ ['10'] ], -}); +}, { licenseKey: 'gpl-v3' }); // multiple operations in a single callback will trigger evaluation only once // and only one set of changes will be returned as a combined result of all @@ -58,7 +58,7 @@ operation together with their absolute addresses and new values. const hfInstance = HyperFormula.buildFromSheets({ MySheet1: [ ['1'] ], MySheet2: [ ['10'] ], -}); +}, { licenseKey: 'gpl-v3' }); // suspend the evaluation hfInstance.suspendEvaluation(); @@ -82,7 +82,7 @@ When you need to check if the evaluation is suspended you can call the [`isEvaluationSuspended`](../api/classes/hyperformula.md#isevaluationsuspended) method. ```javascript -const hfInstance = HyperFormula.buildEmpty(); +const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); // suspend the evaluation hfInstance.suspendEvaluation(); diff --git a/docs/guide/cell-references.md b/docs/guide/cell-references.md index 5fcd811e18..e707e00871 100644 --- a/docs/guide/cell-references.md +++ b/docs/guide/cell-references.md @@ -132,7 +132,7 @@ This example shows the change after the move operation was done: // build with a simple dataset const hfInstance = HyperFormula.buildFromArray([ ['=B2', '=A1', ''], -]); +], { licenseKey: 'gpl-v3' }); // these are the coordinates for a move operation const source = { sheet: 0, col: 1, row: 0 }; diff --git a/docs/guide/clipboard-operations.md b/docs/guide/clipboard-operations.md index 0c13991c3b..6eaec8933c 100644 --- a/docs/guide/clipboard-operations.md +++ b/docs/guide/clipboard-operations.md @@ -20,7 +20,7 @@ To copy the contents of a cell or range, use the [`copy()`](../api/classes/hyper ```javascript const hfInstance = HyperFormula.buildFromArray([ ['1', '2'], -]); +], { licenseKey: 'gpl-v3' }); // copy [ [ 2 ] ] const clipboardContent = hfInstance.copy({ @@ -40,7 +40,7 @@ Any CRUD operation called after the [`cut()`](../api/classes/hyperformula.md#cut ```javascript const hfInstance = HyperFormula.buildFromArray([ ['1', '2'], -]); +], { licenseKey: 'gpl-v3' }); // returns the values that were cut: [ [ 1 ] ] const clipboardContent = hfInstance.cut({ @@ -58,7 +58,7 @@ To paste the contents of a cell or range, use the [`paste()`](../api/classes/hyp ```javascript const hfInstance = HyperFormula.buildFromArray([ ['1', '2'], -]); +], { licenseKey: 'gpl-v3' }); // [ [ 2 ] ] was copied const clipboardContent = hfInstance.copy({ diff --git a/docs/guide/compatibility-with-google-sheets.md b/docs/guide/compatibility-with-google-sheets.md index d4d39ff528..6ac2aa1dc5 100644 --- a/docs/guide/compatibility-with-google-sheets.md +++ b/docs/guide/compatibility-with-google-sheets.md @@ -107,6 +107,7 @@ This configuration aligns HyperFormula with the default behavior of Google Sheet ```js // define options const options = { + licenseKey: 'gpl-v3', dateFormats: ['MM/DD/YYYY', 'MM/DD/YY', 'YYYY/MM/DD'], timeFormats: ['hh:mm', 'hh:mm:ss.sss'], // set by default currencySymbol: ['$', 'USD'], diff --git a/docs/guide/compatibility-with-microsoft-excel.md b/docs/guide/compatibility-with-microsoft-excel.md index c0e86520bc..7309548158 100644 --- a/docs/guide/compatibility-with-microsoft-excel.md +++ b/docs/guide/compatibility-with-microsoft-excel.md @@ -179,6 +179,7 @@ This configuration aligns HyperFormula with the default behavior of Microsoft Ex ```js // define options const options = { + licenseKey: 'gpl-v3', dateFormats: ['MM/DD/YYYY', 'MM/DD/YY', 'YYYY/MM/DD'], timeFormats: ['hh:mm', 'hh:mm:ss.sss'], // set by default currencySymbol: ['$', 'USD'], diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index 25e415b82b..0d3917a361 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -25,7 +25,7 @@ By default, HyperFormula recognizes `$` as a currency symbol in cell input. To a ```javascript const hf = HyperFormula.buildFromArray( [['100 zł', '=A1 * 1.23']], - { currencySymbol: ['$', 'zł'] } + { licenseKey: 'gpl-v3', currencySymbol: ['$', 'zł'] } ); console.log(hf.getCellValue({ sheet: 0, col: 0, row: 0 })); // 100 @@ -53,7 +53,7 @@ With no `stringifyCurrency` configured, the built-in formatter handles simple `$ const hf = HyperFormula.buildFromArray([ [1234.5, '=TEXT(A1, "$0.00")'], [1234.5, '=TEXT(A2, "$#.00")'], -]); +], { licenseKey: 'gpl-v3' }); console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })); // "$1234.50" @@ -62,7 +62,7 @@ console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })); // "$1234.50" A non-`$` symbol used purely as a suffix (no thousands grouping, no decimal-comma) also passes through unchanged: ```javascript -const hf = HyperFormula.buildFromArray([[1234.5, '=TEXT(A1, "0.00 zł")']]); +const hf = HyperFormula.buildFromArray([[1234.5, '=TEXT(A1, "0.00 zł")']], { licenseKey: 'gpl-v3' }); console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "1234.50 zł" ``` @@ -92,7 +92,7 @@ const stringifyCurrency = (value, fmt) => const hf = HyperFormula.buildFromArray([ [1234.5, '=TEXT(A1, "$#,##0.00")'], -], { stringifyCurrency }); +], { licenseKey: 'gpl-v3', stringifyCurrency }); console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" ``` diff --git a/docs/guide/custom-functions.md b/docs/guide/custom-functions.md index 5c68d8c114..903e30d45e 100644 --- a/docs/guide/custom-functions.md +++ b/docs/guide/custom-functions.md @@ -160,7 +160,7 @@ Now, you're ready to use your GREET function in a formula. ```js // build a HyperFormula instance where you can use your function directly -const hfInstance = HyperFormula.buildFromArray([['Anthony', '=GREET(A1)']]); +const hfInstance = HyperFormula.buildFromArray([['Anthony', '=GREET(A1)']], { licenseKey: 'gpl-v3' }); // read the value of cell B1 const result = hfInstance.getCellValue({ sheet: 0, col: 1, row: 0 }); diff --git a/docs/guide/dependency-graph.md b/docs/guide/dependency-graph.md index dbf7b3af0a..55ce99713d 100644 --- a/docs/guide/dependency-graph.md +++ b/docs/guide/dependency-graph.md @@ -87,7 +87,7 @@ node and avoid duplicating the work during computation. To get the immediate precedents of a cell or a range (the in-neighbors of the cell node or the range node), use the [`getCellPrecedents()`](../api/classes/hyperformula.html#getcellprecedents) method: ```js -const hfInstance = HyperFormula.buildFromArray([[ '1', '2', '=A1', '=B1+C1' ]]); +const hfInstance = HyperFormula.buildFromArray([[ '1', '2', '=A1', '=B1+C1' ]], { licenseKey: 'gpl-v3' }); hfInstance.getCellPrecedents({ sheet: 0, col: 3, row: 0 }); // returns [{ sheet: 0, col: 1, row: 0 }, { sheet: 0, col: 2, row: 0 }] @@ -98,7 +98,7 @@ hfInstance.getCellPrecedents({ sheet: 0, col: 3, row: 0 }); To get the immediate dependents of a cell or a range (the out-neighbors of the cell node or the range node), use the [`getCellDependents()`](../api/classes/hyperformula.html#getcelldependents) method: ```js -const hfInstance = HyperFormula.buildFromArray([[ '1', '=A1', '=A1+B1', '=B1+C1' ]]) +const hfInstance = HyperFormula.buildFromArray([[ '1', '=A1', '=A1+B1', '=B1+C1' ]], { licenseKey: 'gpl-v3' }) hfInstance.getCellDependents({ sheet: 0, col: 0, row: 0 }) // returns [{ sheet: 0, col: 1, row: 0 }, { sheet: 0, col: 2, row: 0 }] diff --git a/docs/guide/integration-with-langchain.md b/docs/guide/integration-with-langchain.md index 1be8f3f99a..807699b59d 100644 --- a/docs/guide/integration-with-langchain.md +++ b/docs/guide/integration-with-langchain.md @@ -38,7 +38,7 @@ const hf = HyperFormula.buildFromArray([ ['Revenue', 100], ['Cost', 60], ['Profit', '=B1-B2'], -]); +], { licenseKey: 'gpl-v3' }); const agent = createReactAgent({ llm: new ChatOpenAI({ model: 'gpt-4o' }), diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 91b161d94a..d78cac018d 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -91,6 +91,6 @@ HyperFormula resolves the OFFSET function at parse time rather than during evalu * OFFSET is resolved at parse time, so `getCellFormula` returns the computed reference, not the original `OFFSET` call. ```js - const hf = HyperFormula.buildFromArray([[1, 45, '=OFFSET(A1, 0, 1)']]); + const hf = HyperFormula.buildFromArray([[1, 45, '=OFFSET(A1, 0, 1)']], { licenseKey: 'gpl-v3' }); hf.getCellFormula({ sheet: 0, row: 0, col: 2 }); // '=B1' ``` diff --git a/docs/guide/license-key.md b/docs/guide/license-key.md index 731c5cd08a..3e8ae8d905 100644 --- a/docs/guide/license-key.md +++ b/docs/guide/license-key.md @@ -40,25 +40,60 @@ const options = { } ``` +If your key is a few sentences of text followed by a block in square brackets (`[...]`), pass the +whole key, exactly as you received it. The key covers its text, so a key cut down to the bracketed +block, or with any word changed, is invalid. Whitespace and line breaks don't matter, including a +line break saved as `\n` in a `.env` file, but keeping the key on one line is the safest choice. + ### Proprietary license key validation ::: tip HyperFormula doesn't use an internet connection to validate your proprietary license key. ::: -To determine whether a user is still entitled to use a particular -version of the software, HyperFormula compares the time between -two dates: -* The HyperFormula build date -* The date in your proprietary license key +Which versions of HyperFormula a key covers, and for how long, follows from the +terms of your contract. Your key carries those terms, and HyperFormula applies +them locally, without any connection to a server. + +## Feature packages and add-ons + +A proprietary license key may grant the whole library, or only part of it. -This process doesn't require any connection to the server. +If your key grants only part of the library, then: + +* A function your key doesn't include evaluates to a `#LIC!` error, in the same way as any other + [error value](types-of-errors.md). Everything else in the sheet keeps calculating. +* An API method your key doesn't include throws a `LicenseCapabilityMissingError` when you call + it. Getters never throw; `copy()` and `cut()` do, because they belong to the clipboard feature. + The matching `isItPossibleTo*()` methods, such as `isItPossibleToAddRows()`, return `false`, + and so do `isThereSomethingToUndo()` and `isThereSomethingToRedo()` when your key doesn't + include undo and redo. +* [`getAvailableFunctions()`](../api/classes/hyperformula.md#getavailablefunctions) and + [`getFunctionDetails()`](../api/classes/hyperformula.md#getfunctiondetails) describe only the + functions your key includes, so a function picker built from them never offers a function that + then fails. ## License key notifications If your license key is missing, invalid, or expired, you see a corresponding notification in the console. +A missing or invalid key blocks the library: + +* Every function call evaluates to a `#LIC!` error, except `VERSION()` and `OFFSET()`. +* Every license-gated API method throws a `LicenseCapabilityMissingError` that names the key's + state, for example: `License key is missing. Feature crud is not available.` The gated methods + are the ones that edit cells, rows, columns, and sheets, `copy()`, `cut()`, `paste()`, `undo()`, + `redo()`, `batch()`, `suspendEvaluation()`, and the methods that add, change, or remove named + expressions. Building an engine with named expressions throws the same error. +* The `isItPossibleTo*()` methods return `false` for every gated method, and + `isThereSomethingToUndo()` and `isThereSomethingToRedo()` return `false`. +* Getters and clean-up methods, such as `clearClipboard()` and `resumeEvaluation()`, keep working, + and `getAvailableFunctions()` still describes the full set of functions. + +Depending on your license terms, an expired key either blocks the library in the same way, or keeps +working with what it grants and prints an error in the console. + ## License key support If you have any issues with your license key, [contact our team](contact.md). \ No newline at end of file diff --git a/docs/guide/localizing-functions.md b/docs/guide/localizing-functions.md index 39b6d1dcb8..68cab2c623 100644 --- a/docs/guide/localizing-functions.md +++ b/docs/guide/localizing-functions.md @@ -104,6 +104,7 @@ HyperFormula.registerLanguage('es', spanish); // Use it in your configuration const hf = HyperFormula.buildEmpty({ + licenseKey: 'gpl-v3', language: 'es' }); ``` diff --git a/docs/guide/migration-from-0.6-to-1.0.md b/docs/guide/migration-from-0.6-to-1.0.md index 4444c557c1..402448c05c 100644 --- a/docs/guide/migration-from-0.6-to-1.0.md +++ b/docs/guide/migration-from-0.6-to-1.0.md @@ -54,7 +54,7 @@ Before: const hfInstance = HyperFormula.buildFromSheets({ MySheet1: [ ['=SUM(MySheet2!A1:A2)'] ], MySheet2: [ ['10'] ], -}); +}, { licenseKey: 'gpl-v3' }); const changes = hfInstance.clearSheet('MySheet2'); ``` @@ -64,7 +64,7 @@ After: const hfInstance = HyperFormula.buildFromSheets({ MySheet1: [ ['=SUM(MySheet2!A1:A2)'] ], MySheet2: [ ['10'] ], -}); +}, { licenseKey: 'gpl-v3' }); // use `sheetId` instead of `sheetName` const changes = hfInstance.clearSheet(1); @@ -170,7 +170,7 @@ Before: ```js const hfInstance = HyperFormula.buildFromArray([ ['1', '2'], -]); +], { licenseKey: 'gpl-v3' }); // takes `simpleCellAddress`, `width`, and `height` // returns: [ [ 2 ] ] @@ -181,7 +181,7 @@ After: ```js const hfInstance = HyperFormula.buildFromArray([ ['1', '2'], -]); +], { licenseKey: 'gpl-v3' }); // takes `simpleCellRange` // returns: [ [ 2 ] ] diff --git a/docs/guide/migration-from-2.x-to-3.0.md b/docs/guide/migration-from-2.x-to-3.0.md index 26ddf035e6..dd1130487b 100644 --- a/docs/guide/migration-from-2.x-to-3.0.md +++ b/docs/guide/migration-from-2.x-to-3.0.md @@ -104,6 +104,7 @@ HyperFormula 3.0.0 introduces a change in the default value of the `precisionRou ```javascript const hf = HyperFormula.buildEmpty({ + licenseKey: 'gpl-v3', precisionRounding: 14 }); ``` \ No newline at end of file diff --git a/docs/guide/named-expressions.md b/docs/guide/named-expressions.md index 99249cb25b..1f4e7cd96a 100644 --- a/docs/guide/named-expressions.md +++ b/docs/guide/named-expressions.md @@ -166,11 +166,11 @@ const namedExpressions = [ ]; // Create engine with named expressions -const hfInstance = HyperFormula.buildEmpty({}, namedExpressions); +const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }, namedExpressions); // or -const hfInstance = HyperFormula.buildFromArray(sheetData, {}, namedExpressions); +const hfInstance = HyperFormula.buildFromArray(sheetData, { licenseKey: 'gpl-v3' }, namedExpressions); // or -const hfInstance = HyperFormula.buildFromSheets(sheetsData, {}, namedExpressions); +const hfInstance = HyperFormula.buildFromSheets(sheetsData, { licenseKey: 'gpl-v3' }, namedExpressions); ``` **After engine creation**: You can add a named expression by using the `addNamedExpression` method. It accepts name for the expression, the expression as a raw cell content, and optionally the scope. If you do not define the scope it will be set to global, meaning the expression name will be valid for the whole workbook. If you want to add many of them, it is advised to do so in a [batch](batch-operations.md). This method returns [an array of changed cells](basic-operations.md#changes-array). diff --git a/docs/guide/sorting-data.md b/docs/guide/sorting-data.md index f69468720c..8fffb0087a 100644 --- a/docs/guide/sorting-data.md +++ b/docs/guide/sorting-data.md @@ -36,7 +36,7 @@ const hfInstance = HyperFormula.buildFromArray([ ['A'], ['B'], ['C'], -]); +], { licenseKey: 'gpl-v3' }); // we'll set the row order to [1, 2, 0] in the next steps // the resulting sheet will be: [['C'], ['A'], ['B']] @@ -65,7 +65,7 @@ const hfInstance = HyperFormula.buildFromArray([ ['A'], ['B'], ['C'], -]); +], { licenseKey: 'gpl-v3' }); // a variable to carry the user message let messageUsedInUI; @@ -88,7 +88,7 @@ const hfInstance = HyperFormula.buildFromArray([ ['A'], ['B'], ['C'], -]); +], { licenseKey: 'gpl-v3' }); let messageUsedInUI; @@ -132,7 +132,7 @@ For example, if you want to move the last column to the front of a 3-column shee // a HyperFormula instance with example data const hfInstance = HyperFormula.buildFromArray([ ['A', 'B', 'C'] -]); +], { licenseKey: 'gpl-v3' }); // we'll set the column order to [1, 2, 0] in the next steps // the resulting sheet will be: [['C', 'A', 'B']] @@ -157,7 +157,7 @@ Use the [`isItPossibleToSetColumnOrder`](../api/classes/hyperformula.md#isitposs ```js const hfInstance = HyperFormula.buildFromArray([ ['A', 'B', 'C'] -]); +], { licenseKey: 'gpl-v3' }); // a variable to carry the user message let messageUsedInUI; @@ -178,7 +178,7 @@ If your specified column number permutation is valid, change the column order: ```js const hfInstance = HyperFormula.buildFromArray([ ['A', 'B', 'C'] -]); +], { licenseKey: 'gpl-v3' }); let messageUsedInUI; diff --git a/docs/guide/types-of-errors.md b/docs/guide/types-of-errors.md index 4c524877ee..8a7ba22c4a 100644 --- a/docs/guide/types-of-errors.md +++ b/docs/guide/types-of-errors.md @@ -37,4 +37,4 @@ according to the language settings. | #VALUE! | Wrong type of argument | It occurs when a formula tries to improperly use different types of data. For example, you will see this error when you will try to add a string to a number. | | #CYCLE! | Circular reference | It occurs when a formula refers to its own cell, both directly and indirectly. | | #ERROR! | An error occurred | It indicates that there is an unknown error in a formula. | -| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | \ No newline at end of file +| #LIC! | License key problem | It occurs when the license key is missing or invalid, when it has expired and your [license terms](license-key.md#license-key-notifications) stop it from working after that, or when the function is not included in the [feature package](license-key.md#feature-packages-and-add-ons) your license key grants. | \ No newline at end of file diff --git a/docs/guide/types-of-values.md b/docs/guide/types-of-values.md index e433164fdf..b4f483c963 100644 --- a/docs/guide/types-of-values.md +++ b/docs/guide/types-of-values.md @@ -48,7 +48,7 @@ const hf = HyperFormula.buildFromArray([ [true], // Logical [new Date()], // Date/DateTime [null], // Empty -]); +], { licenseKey: 'gpl-v3' }); ``` ### For string values @@ -76,7 +76,7 @@ const hf = HyperFormula.buildFromArray([ ["22/06/2022"], // Date ["10:40:16"], // Time ["Hello"], // Text -]); +], { licenseKey: 'gpl-v3' }); ``` ### Forcing the text value type @@ -92,7 +92,7 @@ const hf = HyperFormula.buildFromArray([ ["'11201"], // a string: "11201" ["22/06/2022"], // a date: June 22nd 2022 ["'22/06/2022"], // a string: "22/06/2022" -]); +], { licenseKey: 'gpl-v3' }); // a formula: SUM(B1,B2) hf.setCellContents({ col: 0, row: 4, sheet: 0 }, [["=SUM(B1,B2)"]]); diff --git a/package.json b/package.json index ca901db20b..94c7e70223 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "audit": "npm audit --omit=dev", "clean": "rimraf coverage/ commonjs/ dist/ es/ languages/ lib/ typings/ test-jasmine/", "compile": "tsc", + "check:license-key-parser-drift": "node script/check-license-key-parser-drift.js", "check:licenses": "license-checker --production --excludePackages=\"hyperformula@3.1.1\" --onlyAllow=\"MIT; Apache-2.0; BSD-3-Clause; BSD-2-Clause; ISC; BSD; Unlicense\"", "tsnode": "ts-node --transpile-only -O {\\\"module\\\":\\\"commonjs\\\"}", "release": "bash script/release/release.sh" diff --git a/script/check-license-key-parser-drift.js b/script/check-license-key-parser-drift.js new file mode 100755 index 0000000000..5a64d371d9 --- /dev/null +++ b/script/check-license-key-parser-drift.js @@ -0,0 +1,335 @@ +#!/usr/bin/env node +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/* + * Holds `src/license/handsontable-license-key-parser/` to the upstream directory it is a copy of. + * + * The directory is `handsontable/license-key`'s `vendor/entitlement-key-reader/`, taken whole from + * a tagged release, as upstream's own integration guide prescribes ("copy the whole directory, + * from a tagged release, and do not edit the copy ... add a drift check to CI"). This is that check. + * + * What it does, per `upstream.json`: + * - lists the upstream directory at the pinned `commit` and compares EVERY file byte for byte + * against the local one as git tracks it - upstream's `diff -r`, without needing a clone; + * - fails on a tracked local file upstream does not have (other than `own_files`) - a shadowing + * `foo.ts` beside a copied `foo.ts` would otherwise win module resolution silently; + * - fails on an upstream file that is missing locally; + * - checks that the pinned `tag` still points at the pinned `commit`, so a moved tag or an edited + * pin does not pass as the copy it is not; + * - applies `allowed_divergences` - each a single exact line swap with a stated reason and expiry - + * to the fetched text before comparing. Tags are immutable, so this cannot see upstream fix the + * line on a branch; the next tag fails the check (newer-tag rule), and at the re-take the swap + * stops matching once upstream has changed the line, so an upstream fix cannot be missed. A tag + * that leaves the line alone carries the shim forward; `until` is a note, not a check; + * - fails when upstream has a NEWER tag than the pin: the copy is taken from releases, and a + * release nobody has looked at is exactly what the reviewer asked to be told about. + * + * Usage: npm run check:license-key-parser-drift + * + * Needs read access to a private repository: LICENSE_KEY_REPO_TOKEN, GH_TOKEN, GITHUB_TOKEN, or + * a logged-in `gh`. Without one it FAILS - "could not verify" is not "verified" - and a 401/403/404 + * is reported as an access problem, not as drift. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const https = require('https') +const {execFileSync} = require('child_process') + +const REPO_ROOT = path.resolve(__dirname, '..') +const DIR = path.resolve(REPO_ROOT, 'src/license/handsontable-license-key-parser') +const PIN = path.join(DIR, 'upstream.json') + +function token() { + const fromEnv = process.env.LICENSE_KEY_REPO_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN + + if (fromEnv) { + return fromEnv + } + try { + return execFileSync('gh', ['auth', 'token'], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}).trim() + } catch (error) { + return null + } +} + +/** How long a request may wait for the API before the check gives up on it. */ +const REQUEST_TIMEOUT_MS = 30000 + +function get(apiPath, accept, auth) { + return new Promise((resolve, reject) => { + const request = https.get({ + hostname: 'api.github.com', + path: apiPath, + headers: {'Accept': accept, 'User-Agent': 'hyperformula-vendored-parser-check', 'Authorization': `Bearer ${auth}`}, + timeout: REQUEST_TIMEOUT_MS, + }, (response) => { + const chunks = [] + + response.on('data', (chunk) => chunks.push(chunk)) + response.on('end', () => { + const body = Buffer.concat(chunks) + + if (response.statusCode === 200) { + resolve(body) + } else { + const error = new Error(`${response.statusCode}`) + + error.statusCode = response.statusCode + reject(error) + } + }) + }) + + request.on('timeout', () => request.destroy(new Error(`no response within ${REQUEST_TIMEOUT_MS / 1000} s`))) + // Anything the socket reports - a timeout, a refused or dropped connection, a failed DNS lookup - + // is a network problem; describeFailure tells it apart from an HTTP status or a bad response. + request.on('error', (error) => { + error.network = true + reject(error) + }) + }) +} + +/** HTTP statuses that mean the token cannot see the repository, the ref or the path. */ +const ACCESS_STATUSES = [401, 403, 404] + +/** + * Why a request failed, worded so that it is never mistaken for drift: a 301 is a renamed or moved + * repository (a configuration problem), 401/403/404 are access problems, any other HTTP status is a + * problem on GitHub's side (an outage or a rate limit), a socket error is a network problem, and + * anything else is a response this check could not read. + */ +function describeFailure(error) { + if (error.statusCode === 301) { + return 'HTTP 301: the repository moved or was renamed - update `repository` in upstream.json. A configuration problem, not drift' + } + if (ACCESS_STATUSES.indexOf(error.statusCode) !== -1) { + return `HTTP ${error.statusCode} - access, not drift` + } + if (error.statusCode !== undefined) { + return `HTTP ${error.statusCode} - a GitHub API problem (an outage or a rate limit), not drift` + } + if (error.network === true) { + return `${error.message} - a network problem, not drift` + } + + return `${error.message} - an unexpected response from the GitHub API, not drift` +} + +const sha256 = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex') + +/** + * The `[major, minor, patch]` a tag names, or `null` for a tag that is not a version. A leading `v` + * is ignored, and so is a prerelease suffix (`-rc.1`): a prerelease counts as newer than the pin + * only when the version it leads up to is newer. + */ +function baseVersionOf(tag) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(tag) + + return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])] +} + +/** Whether version `a` (`[major, minor, patch]`) is greater than version `b`. */ +function versionGreater(a, b) { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return a[i] > b[i] + } + } + + return false +} + +/** Every tag of an upstream repository, following the pagination, 100 per page. */ +async function listTags(repo, auth) { + const tags = [] + + for (let page = 1; ; page++) { + const batch = JSON.parse((await get(`/repos/${repo}/tags?per_page=100&page=${page}`, 'application/vnd.github+json', auth)).toString('utf8')) + + tags.push(...batch) + if (batch.length < 100) { + return tags + } + } +} + +/** Every file under an upstream directory at a ref, recursively, as `relative path -> api path`. */ +async function listUpstream(repo, dir, ref, auth) { + const out = new Map() + const walk = async(sub) => { + const listing = JSON.parse((await get(`/repos/${repo}/contents/${sub}?ref=${encodeURIComponent(ref)}`, 'application/vnd.github+json', auth)).toString('utf8')) + + for (const entry of listing) { + const rel = entry.path.slice(dir.length + 1) + + if (entry.type === 'dir') { + await walk(entry.path) + } else { + out.set(rel, entry.path) + } + } + } + + await walk(dir) + + return out +} + +/** A path under the repository root as git spells it: relative, with forward slashes. */ +const gitPath = (absolute) => path.relative(REPO_ROOT, absolute).split(path.sep).join('/') + +/** + * Every file git tracks under the local directory, recursively, relative to it. Untracked files + * (`.DS_Store`, editor leftovers) are not part of the copy, so they are not compared. + */ +function listTracked(root) { + const prefix = `${gitPath(root)}/` + + return execFileSync('git', ['ls-files', '-z', '--', prefix], {cwd: REPO_ROOT, encoding: 'utf8'}) + .split('\0') + .filter((file) => file !== '') + .map((file) => file.slice(prefix.length)) +} + +/** + * A tracked file's content as git stores it (the index), not as it was checked out, so a checkout + * that converts line endings to CRLF does not read as drift. An edit is compared once it is staged. + */ +function readTracked(root, rel) { + // `:./` resolves the path from REPO_ROOT, as `git ls-files` does above; a bare `:` would resolve + // it from the top of the work tree, which is not REPO_ROOT when this repository sits inside another. + return execFileSync('git', ['show', `:./${gitPath(path.join(root, rel))}`], {cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024}) +} + +async function main() { + const pin = JSON.parse(fs.readFileSync(PIN, 'utf8')) + const auth = token() + + console.log(`vendored reader: ${pin.repository}/${pin.directory} @ ${pin.tag} (${pin.commit.slice(0, 9)})`) + + if (auth === null) { + console.error('\nFAIL no credentials for a private repository.') + console.error(' Set LICENSE_KEY_REPO_TOKEN (or GH_TOKEN / GITHUB_TOKEN), or run `gh auth login`.') + console.error(' Failing rather than skipping: an unverified copy is not a verified one.') + process.exit(1) + } + + const problems = [] + + // The pin first: a tag that moved, or a pin edited by hand, is named as that - before any + // listing at the tag can fail for a different reason and blame access. + try { + const tags = await listTags(pin.repository, auth) + const pinned = tags.find((t) => t.name === pin.tag) + + if (pinned === undefined) { + problems.push(`tag ${pin.tag} is not among upstream's tags - the pin names a release that does not exist`) + } else if (pinned.commit.sha !== pin.commit) { + problems.push(`tag ${pin.tag} points at ${pinned.commit.sha.slice(0, 9)}, the pin says ${pin.commit.slice(0, 9)} - the tag moved or the pin was edited`) + } + + const pinVersion = baseVersionOf(pin.tag) + const newer = pinVersion === null + ? [] + : tags.map((t) => t.name).filter((name) => { + const version = baseVersionOf(name) + + return version !== null && versionGreater(version, pinVersion) + }) + + if (newer.length > 0) { + problems.push(`upstream has released ${newer.join(', ')} after ${pin.tag}. Review the change and re-take the copy from the newest tag.`) + } + } catch (error) { + problems.push(`could not list upstream tags: ${describeFailure(error)}`) + } + + + let upstream + + try { + // At the pinned commit, not the tag: a tag moved after the check above cannot change what is compared. + upstream = await listUpstream(pin.repository, pin.directory, pin.commit, auth) + } catch (error) { + // Whatever the pin check already found is the more likely cause of this failure - say it first. + problems.forEach((p) => console.error(`\nFAIL ${p}`)) + console.error(`\nFAIL could not list ${pin.repository}/${pin.directory} at commit ${pin.commit.slice(0, 9)}: ${describeFailure(error)}.`) + if (error.statusCode === 404) { + console.error(' 404 from a private repository means the token has no access to it, the pinned commit does not exist upstream, or the directory does not exist at that commit.') + } + process.exit(1) + } + + const divergences = new Map((pin.allowed_divergences || []).map((d) => [d.file, d])) + const own = new Set(pin.own_files || []) + const tracked = new Set(listTracked(DIR)) + const local = Array.from(tracked).filter((rel) => !own.has(rel)) + + for (const [rel, apiPath] of upstream) { + if (!tracked.has(rel)) { + problems.push(`${rel}: in upstream at ${pin.tag} (${pin.commit.slice(0, 9)}), missing here`) + console.log(` GONE ${rel}`) + continue + } + + let theirs + + try { + theirs = (await get(`/repos/${pin.repository}/contents/${apiPath}?ref=${encodeURIComponent(pin.commit)}`, 'application/vnd.github.raw', auth)).toString('utf8') + } catch (error) { + problems.push(`${rel}: upstream could not be read: ${describeFailure(error)}`) + console.log(` ???? ${rel}`) + continue + } + + const shim = divergences.get(rel) + + if (shim) { + const occurrences = theirs.split(shim.upstream).length - 1 + + if (occurrences !== 1) { + problems.push(`${rel}: the allowed divergence no longer matches upstream (line found ${occurrences} times). Upstream moved - drop the shim and re-take the file.`) + console.log(` SHIM? ${rel}`) + continue + } + theirs = theirs.replace(shim.upstream, shim.local) + } + + const ours = readTracked(DIR, rel) + + if (sha256(Buffer.from(ours)) === sha256(Buffer.from(theirs))) { + console.log(` ok ${rel}${shim ? ' (1 allowed divergence applied)' : ''}`) + } else { + problems.push(`${rel}: differs from upstream at ${pin.tag} (${pin.commit.slice(0, 9)})`) + console.log(` DIFF ${rel}`) + } + } + + local.filter((rel) => !upstream.has(rel)).forEach((rel) => { + problems.push(`${rel}: exists here and not in upstream - a local file in this directory shadows or extends the copy; move it out`) + console.log(` EXTRA ${rel}`) + }) + + if (problems.length === 0) { + console.log(`\nOK - the directory is upstream's ${pin.directory} at ${pin.tag} (${pin.commit.slice(0, 9)}), byte for byte${divergences.size ? `, with ${divergences.size} declared divergence(s)` : ''}`) + + return + } + + console.error(`\n${problems.length} problem(s):`) + problems.forEach((p) => console.error(` ${p}`)) + process.exit(1) +} + +main().catch((error) => { + console.error(`check failed: ${error.message}`) + process.exit(1) +}) diff --git a/script/generate-builtin-functions-doc.ts b/script/generate-builtin-functions-doc.ts index 6281a10cc5..93a6296403 100644 --- a/script/generate-builtin-functions-doc.ts +++ b/script/generate-builtin-functions-doc.ts @@ -24,15 +24,10 @@ const TEMPLATE_PATH = path.join(REPO_ROOT, 'docs/guide/built-in-functions.tmpl.m const DOC_PATH = path.join(REPO_ROOT, 'docs/guide/built-in-functions.md') const LANGUAGE = 'enGB' /** - * The GPLv3 key. Two reasons to name a key here, one current and one not yet: - * - * - Today it only keeps the build quiet. Function availability is **not** license-gated: every key, and no key at - * all, yields the same function set. But the metadata API is instance-scoped, so this page is now generated from - * an engine, and constructing one without a key logs "The license key for HyperFormula is missing." — noise the - * static path never produced, because it built no engine. - * - Once entitlement lands (HF-307), the engine's key *will* decide which functions the metadata API reports. - * Naming the fully-entitled key now means that change cannot silently narrow the published reference to one tier; - * the page must always document the complete function set (HF-349). + * The GPLv3 key, which restricts nothing. The metadata API is instance-scoped and an engine reports only the + * functions its key grants, so this page has to be generated with a fully entitled key: the published reference + * must always document the complete function set. The key also keeps the build quiet, because constructing an + * engine without one logs "The license key for HyperFormula is missing." */ const LICENSE_KEY = 'gpl-v3' diff --git a/src/ArraySize.ts b/src/ArraySize.ts index dc658ee29a..1b41d1b9b6 100644 --- a/src/ArraySize.ts +++ b/src/ArraySize.ts @@ -9,6 +9,7 @@ import {Config} from './Config' import {FunctionRegistry} from './interpreter/FunctionRegistry' import {InterpreterState} from './interpreter/InterpreterState' import {FunctionArgumentType} from './interpreter' +import {FunctionCallLicenseGate} from './license/FunctionCallLicenseGate' import {Ast, AstNodeType, ProcedureAst} from './parser' export class ArraySize { @@ -40,10 +41,13 @@ function arraySizeForUnaryOp(arraySize: ArraySize): ArraySize { } export class ArraySizePredictor { + private readonly functionCallLicenseGate: FunctionCallLicenseGate + constructor( private config: Config, private functionRegistry: FunctionRegistry, ) { + this.functionCallLicenseGate = new FunctionCallLicenseGate(config, functionRegistry) } public checkArraySize(ast: Ast, formulaAddress: SimpleCellAddress): ArraySize { @@ -123,6 +127,12 @@ export class ArraySizePredictor { } private checkArraySizeForFunction(ast: ProcedureAst, state: InterpreterState): ArraySize { + // A call the license stops evaluates to a single #LIC! error. Sized as one cell, it reserves + // no spill range, so a non-empty cell below it cannot turn that error into #SPILL!. + if (this.functionCallLicenseGate.stopsCall(ast.procedureName)) { + return ArraySize.scalar() + } + const pluginArraySizeFunction = this.functionRegistry.getArraySizeFunction(ast.procedureName) if (pluginArraySizeFunction !== undefined) { diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 62202a78c1..10336fd4d8 100644 --- a/src/BuildEngineFactory.ts +++ b/src/BuildEngineFactory.ts @@ -19,6 +19,8 @@ import {ArithmeticHelper} from './interpreter/ArithmeticHelper' import {FunctionRegistry} from './interpreter/FunctionRegistry' import {Interpreter} from './interpreter/Interpreter' import {LazilyTransformingAstService} from './LazilyTransformingAstService' +import {ensureFeatureAllowed} from './license/ensureFeatureAllowed' +import {FeatureId} from './license/LicenseEntitlement' import {buildColumnSearchStrategy, ColumnSearchStrategy} from './Lookup/SearchStrategy' import {NamedExpressions} from './NamedExpressions' import {NumberLiteralHelper} from './NumberLiteralHelper' @@ -50,23 +52,42 @@ export type EngineState = { export class BuildEngineFactory { public static buildFromSheets(sheets: Sheets, configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) return this.buildEngine(config, sheets, namedExpressions) } public static buildFromSheet(sheet: Sheet, configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) const newsheetprefix = config.translationPackage.getUITranslation(UIElement.NEW_SHEET_PREFIX) + '1' return this.buildEngine(config, {[newsheetprefix]: sheet}, namedExpressions) } public static buildEmpty(configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { - return this.buildEngine(new Config(configInput), {}, namedExpressions) + const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) + return this.buildEngine(config, {}, namedExpressions) } public static rebuildWithConfig(config: Config, sheets: Sheets, namedExpressions: SerializedNamedExpression[], stats: Statistics): EngineState { return this.buildEngine(config, sheets, namedExpressions, stats) } + /** + * Throws if `namedExpressions` is non-empty and `config`'s license does not allow + * {@link FeatureId.NamedExpressions} (the build-time counterpart of + * {@link HyperFormula.ensureCapability}). An empty list is never checked: building an engine + * with no named expressions never touches the feature. Deliberately not called from + * {@link rebuildWithConfig}, which re-serializes named expressions an already-built instance + * created (and was allowed to create) rather than accepting them fresh from a caller. + */ + private static ensureNamedExpressionsCapability(config: Config, namedExpressions: SerializedNamedExpression[]): void { + if (namedExpressions.length === 0) { + return + } + ensureFeatureAllowed(config, FeatureId.NamedExpressions) + } + private static buildEngine(config: Config, sheets: Sheets = {}, inputNamedExpressions: SerializedNamedExpression[] = [], stats: Statistics = config.useStats ? new Statistics() : new EmptyStatistics()): EngineState { stats.start(StatType.BUILD_ENGINE_TOTAL) diff --git a/src/Config.ts b/src/Config.ts index 8e8a924eee..fbb48d72ec 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -16,15 +16,28 @@ import {DateTime, instanceOfSimpleDate, SimpleDate, SimpleDateTime, SimpleTime} import {AlwaysDense, ChooseAddressMapping} from './DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {ConfigValueEmpty, ExpectedValueOfTypeError} from './errors' import {defaultStringifyCurrency, defaultStringifyDateTime, defaultStringifyDuration} from './format/format' -import {checkLicenseKeyValidity, LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' import {HyperFormula} from './HyperFormula' import {TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' +import {CapabilityRegistry, ResolvedCapabilities} from './license/CapabilityRegistry' +import {resolveLicense} from './license/licenseResolution' import {Maybe} from './Maybe' import {ParserConfig} from './parser/ParserConfig' import {ConfigParams, ConfigParamsList} from './ConfigParams' -const privatePool: WeakMap = new WeakMap() +/** + * The license-derived state kept off the public `ConfigParams` surface — see + * {@link Config.licenseCapabilities}. + */ +interface LicensePrivateState { + licenseKeyValidityState: LicenseKeyValidityState, + licenseBlocksEvaluation: boolean, + licenseCapabilities: ResolvedCapabilities, + capabilityRegistry: CapabilityRegistry, +} + +const privatePool: WeakMap = new WeakMap() export class Config implements ConfigParams, ParserConfig { @@ -167,7 +180,7 @@ export class Config implements ConfigParams, ParserConfig { /** @inheritDoc */ public readonly matchWholeCell: boolean - constructor(options: Partial = {}, showDeprecatedWarns: boolean = true) { + constructor(options: Partial = {}, showDeprecatedWarns: boolean = true, notifyLicenseMessages: boolean = true) { const { accentSensitive, caseSensitive, @@ -266,8 +279,15 @@ export class Config implements ConfigParams, ParserConfig { validateNumberToBeAtLeast(this.maxColumns, 'maxColumns', 1) this.context = context + const {validityState: licenseKeyValidityState, blocksEvaluation: licenseBlocksEvaluation, entitlement} = resolveLicense(this.licenseKey, notifyLicenseMessages) + const capabilityRegistry = new CapabilityRegistry() + const licenseCapabilities = capabilityRegistry.resolve(entitlement) + privatePool.set(this, { - licenseKeyValidityState: checkLicenseKeyValidity(this.licenseKey) + licenseKeyValidityState, + licenseBlocksEvaluation, + licenseCapabilities, + capabilityRegistry, }) configCheckIfParametersNotInConflict( @@ -305,19 +325,52 @@ export class Config implements ConfigParams, ParserConfig { * @internal */ public get licenseKeyValidityState(): LicenseKeyValidityState { - return (privatePool.get(this) as Config).licenseKeyValidityState + return (privatePool.get(this) as LicensePrivateState).licenseKeyValidityState + } + + /** + * Gate A: `true` when function calls must evaluate to `#LIC!`. Proxied to its private + * counterpart for the same reason as {@link licenseKeyValidityState}. + * + * @internal + */ + public get licenseBlocksEvaluation(): boolean { + return (privatePool.get(this) as LicensePrivateState).licenseBlocksEvaluation + } + + /** + * The functions and features this config's license entitles it to, already resolved from + * whatever tokens the license key carries. Proxied to its private counterpart for the same + * reason as {@link licenseKeyValidityState}: it must never become part of {@link getConfig}. + * + * @internal + */ + public get licenseCapabilities(): ResolvedCapabilities { + return (privatePool.get(this) as LicensePrivateState).licenseCapabilities + } + + + /** + * The registry used to resolve this config's entitlement into {@link licenseCapabilities}. + * Exposed so the interpreter can tell a custom, instance-registered function apart from a + * built-in outside the capability table without constructing a second registry. + * + * @internal + */ + public get capabilityRegistry(): CapabilityRegistry { + return (privatePool.get(this) as LicensePrivateState).capabilityRegistry } public getConfig(): ConfigParams { return getFullConfigFromPartial(this) } - public mergeConfig(init: Partial): Config { + public mergeConfig(init: Partial, notifyLicenseMessages: boolean = true): Config { const mergedConfig: ConfigParams = Object.assign({}, this.getConfig(), init) Config.warnDeprecatedOptions(init) - return new Config(mergedConfig, false) + return new Config(mergedConfig, false, notifyLicenseMessages) } private static warnDeprecatedOptions(options: Partial) { diff --git a/src/Emitter.ts b/src/Emitter.ts index b2f1d6fff0..ce079ec634 100644 --- a/src/Emitter.ts +++ b/src/Emitter.ts @@ -27,7 +27,7 @@ export interface Listeners { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (addedSheetDisplayName) => { console.log('baz') } @@ -64,7 +64,7 @@ export interface Listeners { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['=SUM(MySheet2!A1:A2)'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (removedSheetDisplayName, changes) => { console.log('baz') } @@ -101,7 +101,7 @@ export interface Listeners { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['=SUM(MySheet2!A1:A2)'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (oldName, newName) => { console.log(`Sheet ${oldName} was renamed to ${newName}`) } @@ -137,7 +137,7 @@ export interface Listeners { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (namedExpressionName, changes) => { console.log('baz') } @@ -173,7 +173,7 @@ export interface Listeners { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (namedExpressionName, changes) => { console.log('baz') } @@ -212,7 +212,7 @@ export interface Listeners { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '=A1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (changes) => { console.log('baz') } @@ -246,7 +246,7 @@ export interface Listeners { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ] - * }); + * }, { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = ( ) => { console.log('baz') } @@ -285,7 +285,7 @@ export interface Listeners { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ] - * }); + * }, { licenseKey: 'gpl-v3' }); * * // define a function to be called when the event occurs * const handler = (changes) => { console.log('baz') } diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 522d7b1509..ac5313eeaa 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -43,6 +43,9 @@ import { import {Evaluator} from './Evaluator' import {ExportedChange, Exporter} from './Exporter' import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {licenseAllowsFunction} from './license/CapabilityRegistry' +import {ensureFeatureAllowed, isFeatureAllowed} from './license/ensureFeatureAllowed' +import {FeatureId} from './license/LicenseEntitlement' import {buildTranslationPackage, RawTranslationPackage, TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' import {FUNCTION_DOCS} from './interpreter/functionMetadata' @@ -253,6 +256,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[SheetSizeLimitExceededError]] when sheet size exceeds the limits * @throws [[InvalidArgumentsError]] when sheet is not an array of arrays * @throws [[FunctionPluginValidationError]] when plugin class definition is not consistent with metadata + * @throws [[LicenseCapabilityMissingError]] if namedExpressions is non-empty and the license key is missing or invalid, has expired and blocks evaluation, or does not grant the NamedExpressions feature * * @example * ```js @@ -271,7 +275,7 @@ export class HyperFormula implements TypedEmitter { * ]; * * // method with optional config parameter maxColumns - * const hfInstance = HyperFormula.buildFromArray(sheetData, { maxColumns: 1000 }, namedExpressions); + * const hfInstance = HyperFormula.buildFromArray(sheetData, { licenseKey: 'gpl-v3', maxColumns: 1000 }, namedExpressions); * ``` * * @category Factories @@ -293,6 +297,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[SheetSizeLimitExceededError]] when sheet size exceeds the limits * @throws [[InvalidArgumentsError]] when any sheet is not an array of arrays * @throws [[FunctionPluginValidationError]] when plugin class definition is not consistent with metadata + * @throws [[LicenseCapabilityMissingError]] if namedExpressions is non-empty and the license key is missing or invalid, has expired and blocks evaluation, or does not grant the NamedExpressions feature * * @example * ```js @@ -318,7 +323,7 @@ export class HyperFormula implements TypedEmitter { * ]; * * // method with optional config parameter useColumnIndex - * const hfInstance = HyperFormula.buildFromSheets(sheetData, { useColumnIndex: true }, namedExpressions); + * const hfInstance = HyperFormula.buildFromSheets(sheetData, { licenseKey: 'gpl-v3', useColumnIndex: true }, namedExpressions); * ``` * * @category Factories @@ -335,6 +340,8 @@ export class HyperFormula implements TypedEmitter { * @param {Partial} configInput - engine configuration * @param {SerializedNamedExpression[]} namedExpressions - starting named expressions * + * @throws [[LicenseCapabilityMissingError]] if namedExpressions is non-empty and the license key is missing or invalid, has expired and blocks evaluation, or does not grant the NamedExpressions feature + * * @example * ```js * const namedExpressions = [ @@ -345,7 +352,7 @@ export class HyperFormula implements TypedEmitter { * ]; * * // build with no initial data and with optional config parameter maxColumns - * const hfInstance = HyperFormula.buildEmpty({ maxColumns: 1000 }, namedExpressions); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3', maxColumns: 1000 }, namedExpressions); * ``` * * @category Factories @@ -398,7 +405,7 @@ export class HyperFormula implements TypedEmitter { * ```js * // return registered language * HyperFormula.registerLanguage('enUS', enUS); - * const engine = HyperFormula.buildEmpty({language: 'enUS'}); + * const engine = HyperFormula.buildEmpty({licenseKey: 'gpl-v3', language: 'enUS'}); * ``` * * @category Static Methods @@ -589,7 +596,17 @@ export class HyperFormula implements TypedEmitter { } /** - * Returns translated names of all registered functions for a given language + * Returns translated names of all registered functions for a given language. + * + * Answers for the GLOBAL function registry, because a static method has no engine, and therefore + * no configuration, in scope. An engine configured with its own `functionPlugins` registers only + * those, so this method can list functions that engine cannot evaluate at all. + * + * The two forms answer different questions and neither replaces the other: this one translates + * into any registered language without building an engine, while the instance method of the same + * name answers for the engine you actually hold, in that instance's own language. Neither form + * looks at the license key: to list only the functions an instance can evaluate, use + * [[getAvailableFunctions]]. * * @param {string} code - language code * @@ -699,26 +716,58 @@ export class HyperFormula implements TypedEmitter { return {doc, metadata, aliasOf: metadataKey !== functionId ? metadataKey : undefined} } + /** + * Whether an instance's license lets it evaluate the given function id, and therefore whether the + * metadata API may describe it. Mirrors the gate-B branch the interpreter runs per function call + * (`Interpreter.evaluateAstWithoutPostprocessing`, the `FUNCTION_CALL` case), through the same + * [[licenseAllowsFunction]] rule and the same alias canonicalization, so a listed function is + * always one that actually evaluates. + * + * Gate B only, deliberately — never the license key's validity state. A key that blocks + * evaluation (a missing or invalid key, an expired classic key, or a trial past its grace period) + * resolves to an unrestricted entitlement (the invariant `resolveLicense` documents), so it + * reaches this method with both `licenseCapabilities` axes set to `'all'` and every function + * stays listed. + * That is the intended answer: a key problem is reported on the console and by `#LIC!` in cells, + * and narrowing the catalog to the two protected built-ins would leave an integrator who has not + * wired up their key yet with an empty function picker and no clue why. The list narrows only for + * a key that evaluates (a valid one, or an expired one that keeps working with its own grants) and + * genuinely does not include a function — the case where the answer is useful. + * + * @param {string} functionId - the id as registered, which may be an alias + * @param {FunctionRegistry} functionRegistry - the engine's registry, which resolves the alias map + * @param {Config} config - the instance's config, holding its resolved entitlement + */ + private static licenseListsFunction(functionId: string, functionRegistry: FunctionRegistry, config: Config): boolean { + if (FunctionRegistry.functionIsProtected(functionId)) { + return true + } + return licenseAllowsFunction(config.capabilityRegistry, config.licenseCapabilities, functionRegistry.getCanonicalFunctionId(functionId)) + } + /** * Builds the function list for every id registered in an engine's own registry. Documented functions use their * catalogue entry; custom functions are listed with their name only. Sorted by localized name with * `localeCompare`, so the order follows the host's collation rules, with the language-independent canonical name * as a stable tiebreaker for entries that share a localized name. * - * Takes the [[TranslationPackage]] rather than deriving it from a language code: an instance must describe its - * functions under the package its own evaluator uses (`Config.translationPackage`), which is a snapshot taken + * Takes the instance's whole [[Config]] rather than a language code: an instance must describe its functions + * under the translation package its own evaluator uses (`Config.translationPackage`), which is a snapshot taken * when the instance was built and can differ from whatever is registered globally for the same code today. - * Deriving it here instead would let this method report a localized name the instance refuses to evaluate. + * Deriving it here instead would let this method report a localized name the instance refuses to evaluate. The + * config also carries the resolved entitlement, for the same reason — see [[licenseListsFunction]]. * * @param {FunctionRegistry} functionRegistry - the engine's registry, the source of both the ids and their plugins - * @param {TranslationPackage} language - the translation package to translate the names under + * @param {Config} config - the instance's config: the translation package and the resolved license entitlement */ - private static buildAvailableFunctions(functionRegistry: FunctionRegistry, language: TranslationPackage): FunctionListEntry[] { + private static buildAvailableFunctions(functionRegistry: FunctionRegistry, config: Config): FunctionListEntry[] { + const language = config.translationPackage const translate = (id: string) => language.getMaybeFunctionTranslation(id) return functionRegistry.getListableFunctionIds() // The interpreter refuses to evaluate ids the active language has no translation entry for // (FunctionRegistry.getFunction), so an untranslated function would be advertised but uncallable. .filter(id => language.isFunctionTranslated(id)) + .filter(id => HyperFormula.licenseListsFunction(id, functionRegistry, config)) .map(id => { const resolved = HyperFormula.resolveFunctionMetadata(id, functionRegistry.getFunctionPlugin(id)) if (resolved === undefined) { @@ -742,14 +791,19 @@ export class HyperFormula implements TypedEmitter { * * @param {string} functionId - the language-independent function id (canonical id or alias) * @param {FunctionRegistry} functionRegistry - the engine's registry, which resolves the id to its plugin - * @param {TranslationPackage} language - the translation package to translate the names under + * @param {Config} config - the instance's config: the translation package and the resolved license entitlement */ - private static buildFunctionDetailsFor(functionId: string, functionRegistry: FunctionRegistry, language: TranslationPackage): FunctionDetails | undefined { - // Mirrors the filter in buildAvailableFunctions: an id the active language cannot evaluate - // (no translation entry) gets no details either, so the list and the details always agree. + private static buildFunctionDetailsFor(functionId: string, functionRegistry: FunctionRegistry, config: Config): FunctionDetails | undefined { + const language = config.translationPackage + // Mirrors the filters in buildAvailableFunctions: an id the active language cannot evaluate + // (no translation entry), or one this instance's license does not grant, gets no details + // either, so the list and the details always agree. if (!language.isFunctionTranslated(functionId)) { return undefined } + if (!HyperFormula.licenseListsFunction(functionId, functionRegistry, config)) { + return undefined + } const resolved = HyperFormula.resolveFunctionMetadata(functionId, functionRegistry.getFunctionPlugin(functionId)) if (resolved === undefined) { return undefined @@ -798,7 +852,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(1, 2, 3)', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // get value of A1 cell, should be '6' * const A1Value = hfInstance.getCellValue({ sheet: 0, col: 0, row: 0 }); @@ -829,7 +883,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(1, 2, 3)', '0'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return a normalized A1 cell formula: '=SUM(1, 2, 3)' * const A1Formula = hfInstance.getCellFormula({ sheet: 0, col: 0, row: 0 }); @@ -859,7 +913,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=HYPERLINK("https://hyperformula.handsontable.com/", "HyperFormula")', '0'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return url of 'HYPERLINK': https://hyperformula.handsontable.com/ * const A1Hyperlink = hfInstance.getCellHyperlink({ sheet: 0, col: 0, row: 0 }); @@ -891,7 +945,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(1, 2, 3)', '0'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return serialized content of A1 cell: '=SUM(1, 2, 3)' * const cellA1Serialized = hfInstance.getCellSerialized({ sheet: 0, col: 0, row: 0 }); @@ -926,7 +980,7 @@ export class HyperFormula implements TypedEmitter { * ['0', '=SUM(1, 2, 3)', '=A1'], * ['1', '=TEXT(A2, "0.0%")', '=C1'], * ['2', '=SUM(A1:C1)', '=C1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return all values of a sheet: [[0, 6, 0], [1, '1.0%', 0], [2, 6, 0]] * const sheetValues = hfInstance.getSheetValues(0); @@ -954,7 +1008,7 @@ export class HyperFormula implements TypedEmitter { * ['0', '=SUM(1, 2, 3)', '=A1'], * ['1', '=TEXT(A2, "0.0%")', '=C1'], * ['2', '=SUM(A1:C1)', '=C1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return all formulas of a sheet: * // [ @@ -987,7 +1041,7 @@ export class HyperFormula implements TypedEmitter { * ['0', '=SUM(1, 2, 3)', '=A1'], * ['1', '=TEXT(A2, "0.0%")', '=C1'], * ['2', '=SUM(A1:C1)', '=C1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return: * // [ @@ -1021,7 +1075,7 @@ export class HyperFormula implements TypedEmitter { * ['3'], * ['4'], * ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return the dimensions of all sheets: * // { Sheet1: { width: 3, height: 1 }, Sheet2: { width: 1, height: 2 } } @@ -1049,7 +1103,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '=Sheet2!$A1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return provided sheet's dimensions: { width: 3, height: 1 } * const sheetDimensions = hfInstance.getSheetDimensions(0); @@ -1074,7 +1128,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '=A1+10', '3'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return all sheets values: { Sheet1: [ [ 1, 11, 3 ] ] } * const allSheetsValues = hfInstance.getAllSheetsValues(); @@ -1094,7 +1148,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '=A1+10'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return only formulas: { Sheet1: [ [ undefined, undefined, '=A1+10' ] ] } * const allSheetsFormulas = hfInstance.getAllSheetsFormulas(); @@ -1117,7 +1171,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', 2, '=A1+10'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return all sheets serialized content: { Sheet1: [ [ '1', 2, '=A1+10' ] ] } * // note: the string '1' stays a string and the number 2 stays a number @@ -1145,7 +1199,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a config param, for example maxColumns, * // you can check the configuration with getConfig method @@ -1219,13 +1273,14 @@ export class HyperFormula implements TypedEmitter { * @fires [[valuesUpdated]] if recalculation was triggered by this change * * @throws [[NoOperationToUndoError]] when there is no operation running that can be undone + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the UndoRedo feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], * ['3', ''], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // perform CRUD operation, for example remove the second row * hfInstance.removeRows(0, [1, 1]); @@ -1237,6 +1292,7 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public undo(): ExportedChange[] { + this.ensureCapability(FeatureId.UndoRedo) this._crudOperations.undo() return this.recomputeIfDependencyGraphNeedsIt() } @@ -1253,6 +1309,7 @@ export class HyperFormula implements TypedEmitter { * @fires [[valuesUpdated]] if recalculation was triggered by this change * * @throws [[NoOperationToRedoError]] when there is no operation running that can be re-done + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the UndoRedo feature * * @example * ```js @@ -1260,7 +1317,7 @@ export class HyperFormula implements TypedEmitter { * ['1'], * ['2'], * ['3'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // perform CRUD operation, for example remove the second row * hfInstance.removeRows(0, [1, 1]); @@ -1275,12 +1332,14 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public redo(): ExportedChange[] { + this.ensureCapability(FeatureId.UndoRedo) this._crudOperations.redo() return this.recomputeIfDependencyGraphNeedsIt() } /** * Checks if there is at least one operation that can be undone. + * Returns `false` also when the license key does not allow the UndoRedo feature (see [[LicenseCapabilityMissingError]]). * * For more information, see the [Undo-Redo guide](/guide/undo-redo.md). * @@ -1290,7 +1349,7 @@ export class HyperFormula implements TypedEmitter { * ['1'], * ['2'], * ['3'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // perform CRUD operation, for example remove the second row * hfInstance.removeRows(0, [1, 1]); @@ -1303,11 +1362,15 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public isThereSomethingToUndo(): boolean { + if (!this.isCapabilityAllowed(FeatureId.UndoRedo)) { + return false + } return this._crudOperations.isThereSomethingToUndo() } /** * Checks if there is at least one operation that can be re-done. + * Returns `false` also when the license key does not allow the UndoRedo feature (see [[LicenseCapabilityMissingError]]). * * For more information, see the [Undo-Redo guide](/guide/undo-redo.md). * @@ -1322,6 +1385,9 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public isThereSomethingToRedo(): boolean { + if (!this.isCapabilityAllowed(FeatureId.UndoRedo)) { + return false + } return this._crudOperations.isThereSomethingToRedo() } @@ -1329,6 +1395,7 @@ export class HyperFormula implements TypedEmitter { * Returns information whether it is possible to change the content in a rectangular area bounded by the box. * If returns `true`, doing [[setCellContents]] operation won't throw any errors. * Returns `false` if the address is invalid or the sheet does not exist. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {SimpleCellAddress | SimpleCellRange} address - single cell or block of cells to check * @@ -1339,7 +1406,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // top left corner * const address1 = { col: 0, row: 0, sheet: 0 }; @@ -1354,6 +1421,9 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public isItPossibleToSetCellContents(address: SimpleCellAddress | SimpleCellRange): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } let range if (isSimpleCellAddress(address)) { range = new AbsoluteCellRange(address, address) @@ -1389,12 +1459,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[InvalidArgumentsError]] when the value is not an array of arrays or a raw cell value * @throws [[SheetSizeLimitExceededError]] when performing this operation would result in sheet size limits exceeding * @throws [[ExpectedValueOfTypeError]] if topLeftCornerAddress argument is of wrong type + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '=A1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should set the content, returns: * // [{ @@ -1407,6 +1478,7 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public setCellContents(topLeftCornerAddress: SimpleCellAddress, cellContents: RawCellContent[][] | RawCellContent): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) this._crudOperations.setCellContents(topLeftCornerAddress, cellContents) return this.recomputeIfDependencyGraphNeedsIt() } @@ -1427,6 +1499,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when rowMapping does not define correct row permutation for some subset of rows of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js @@ -1434,7 +1507,7 @@ export class HyperFormula implements TypedEmitter { * [1], * [2], * [4, 5], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should set swap rows 0 and 2 in place, returns: * // [{ @@ -1459,6 +1532,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public swapRowIndexes(sheetId: number, rowMapping: [number, number][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setRowOrder(sheetId, rowMapping) return this.recomputeIfDependencyGraphNeedsIt() @@ -1466,6 +1540,7 @@ export class HyperFormula implements TypedEmitter { /** * Checks if it is possible to reorder rows of a sheet according to a source-target mapping. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - ID of a sheet to operate on * @param {[number, number][]} rowMapping - array mapping original positions to final positions of rows @@ -1478,7 +1553,7 @@ export class HyperFormula implements TypedEmitter { * [1], * [2], * [4, 5], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns true * const isSwappable = hfInstance.isItPossibleToSwapRowIndexes(0, [[0, 2], [2, 0]]); @@ -1490,6 +1565,9 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public isItPossibleToSwapRowIndexes(sheetId: number, rowMapping: [number, number][]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { this._crudOperations.validateSwapRowIndexes(sheetId, rowMapping) @@ -1522,6 +1600,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when rowMapping does not define correct row permutation for some subset of rows of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js @@ -1529,7 +1608,7 @@ export class HyperFormula implements TypedEmitter { * ['A'], * ['B'], * ['C'] - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // Move 'A' to index 1, 'B' to index 2, and 'C' to index 0. * const newRowOrder = [1, 2, 0]; // [ newPosForA, newPosForB, newPosForC ] @@ -1542,6 +1621,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public setRowOrder(sheetId: number, newRowOrder: number[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const mapping = this._crudOperations.mappingFromOrder(sheetId, newRowOrder, 'row') return this.swapRowIndexes(sheetId, mapping) @@ -1549,6 +1629,7 @@ export class HyperFormula implements TypedEmitter { /** * Checks if it is possible to reorder rows of a sheet according to a permutation. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * Parameter `newRowOrder` should have the form `[ newPositionForRow0, newPositionForRow1, newPositionForRow2, ... ]`, * i.e. the value at index `i` is the new position for the row that is currently at index `i`. @@ -1565,7 +1646,7 @@ export class HyperFormula implements TypedEmitter { * ['A'], * ['B'], * ['C'] - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns true * hfInstance.isItPossibleToSetRowOrder(0, [1, 2, 0]); @@ -1577,6 +1658,9 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public isItPossibleToSetRowOrder(sheetId: number, newRowOrder: number[]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { const rowMapping = this._crudOperations.mappingFromOrder(sheetId, newRowOrder, 'row') @@ -1604,13 +1688,14 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when columnMapping does not define correct column permutation for some subset of columns of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * [1, 2, 4], * [5] - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should set swap columns 0 and 2 in place, returns: * // [{ @@ -1635,6 +1720,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public swapColumnIndexes(sheetId: number, columnMapping: [number, number][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setColumnOrder(sheetId, columnMapping) return this.recomputeIfDependencyGraphNeedsIt() @@ -1642,6 +1728,7 @@ export class HyperFormula implements TypedEmitter { /** * Checks if it is possible to reorder columns of a sheet according to a source-target mapping. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @fires [[valuesUpdated]] if recalculation was triggered by this change * @@ -1651,7 +1738,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromArray([ * [1, 2, 4], * [5] - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns true * hfInstance.isItPossibleToSwapColumnIndexes(0, [[0, 2], [2, 0]]); @@ -1663,6 +1750,9 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public isItPossibleToSwapColumnIndexes(sheetId: number, columnMapping: [number, number][]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { this._crudOperations.validateSwapColumnIndexes(sheetId, columnMapping) @@ -1695,12 +1785,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when columnMapping does not define correct column permutation for some subset of columns of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['A', 'B', 'C'] - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // Move 'A' to index 1, 'B' to index 2, and 'C' to index 0. * const newColumnOrder = [1, 2, 0]; // [ newPosForA, newPosForB, newPosForC ] @@ -1713,6 +1804,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public setColumnOrder(sheetId: number, newColumnOrder: number[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const mapping = this._crudOperations.mappingFromOrder(sheetId, newColumnOrder, 'column') return this.swapColumnIndexes(sheetId, mapping) @@ -1720,6 +1812,7 @@ export class HyperFormula implements TypedEmitter { /** * Checks if it is possible to reorder columns of a sheet according to a permutation. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * Parameter `newColumnOrder` should have the form `[ newPositionForColumn0, newPositionForColumn1, newPositionForColumn2, ... ]`, * i.e. the value at index `i` is the new position for the column that is currently at index `i`. @@ -1734,7 +1827,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['A', 'B', 'C'] - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns true * hfInstance.isItPossibleToSetColumnOrder(0, [1, 2, 0]); @@ -1746,6 +1839,9 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public isItPossibleToSetColumnOrder(sheetId: number, newColumnOrder: number[]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { const columnMapping = this._crudOperations.mappingFromOrder(sheetId, newColumnOrder, 'column') @@ -1762,6 +1858,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that addRows can be called. * If returns `true`, doing [[addRows]] operation won't throw any errors. * Returns `false` if adding rows would exceed the sheet size limit or given arguments are invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID in which rows will be added * @param {ColumnRowIndex[]} indexes - non-contiguous indexes with format [row, amount], where row is a row number above which the rows will be added @@ -1772,7 +1869,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '3'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example, * // it is possible to add one row in the second row of sheet 0 @@ -1782,6 +1879,9 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public isItPossibleToAddRows(sheetId: number, ...indexes: ColumnRowIndex[]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') const normalizedIndexes = normalizeAddedIndexes(indexes) try { @@ -1808,13 +1908,14 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[SheetSizeLimitExceededError]] when performing this operation would result in sheet size limits exceeding + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1'], * ['2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values @@ -1824,6 +1925,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public addRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.addRows(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1834,6 +1936,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that removeRows can be called. * If returns `true`, doing [[removeRows]] operation won't throw any errors. * Returns `false` if given arguments are invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID from which rows will be removed * @param {ColumnRowIndex[]} indexes - non-contiguous indexes with format: [row, amount] @@ -1845,7 +1948,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromArray([ * ['1'], * ['2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example * // it is possible to remove one row from row 1 of sheet 0 @@ -1855,6 +1958,9 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public isItPossibleToRemoveRows(sheetId: number, ...indexes: ColumnRowIndex[]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') const normalizedIndexes = normalizeRemovedIndexes(indexes) try { @@ -1881,13 +1987,14 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1'], * ['2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return: [{ sheet: 0, col: 1, row: 2, value: null }] for this example * const changes = hfInstance.removeRows(0, [1, 1]); @@ -1896,6 +2003,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public removeRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.removeRows(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1906,6 +2014,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that addColumns can be called. * If returns `true`, doing [[addColumns]] operation won't throw any errors. * Returns `false` if adding columns would exceed the sheet size limit or given arguments are invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID in which columns will be added * @param {ColumnRowIndex[]} indexes - non-contiguous indexes with format: [column, amount], where column is a column number from which new columns will be added @@ -1916,7 +2025,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example, * // it is possible to add 1 column in sheet 0, at column 1 @@ -1926,6 +2035,9 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public isItPossibleToAddColumns(sheetId: number, ...indexes: ColumnRowIndex[]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') const normalizedIndexes = normalizeAddedIndexes(indexes) try { @@ -1953,12 +2065,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[SheetSizeLimitExceededError]] when performing this operation would result in sheet size limits exceeding + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=RAND()', '42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values, for this example: @@ -1972,6 +2085,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public addColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.addColumns(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1982,6 +2096,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that removeColumns can be called. * If returns `true`, doing [[removeColumns]] operation won't throw any errors. * Returns `false` if given arguments are invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID from which columns will be removed * @param {ColumnRowIndex[]} indexes - non-contiguous indexes with format [column, amount] @@ -1992,7 +2107,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example * // it is possible to remove one column, in place of the second column of sheet 0 @@ -2002,6 +2117,9 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public isItPossibleToRemoveColumns(sheetId: number, ...indexes: ColumnRowIndex[]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') const normalizedIndexes = normalizeRemovedIndexes(indexes) try { @@ -2028,12 +2146,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when the given arguments are invalid + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['0', '=SUM(1, 2, 3)', '=A1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values, in this example it will return: @@ -2047,6 +2166,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public removeColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.removeColumns(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -2057,6 +2177,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that moveCells can be called. * If returns `true`, doing [[moveCells]] operation won't throw any errors. * Returns `false` if the operation might be disrupted and causes side effects by the fact that there is an array inside the selected columns, the target location includes an array or the provided address is invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {SimpleCellRange} source - range for a moved block * @param {SimpleCellAddress} destinationLeftCorner - upper left address of the target cell block @@ -2068,7 +2189,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // choose the coordinates and assign them to variables * const source = { sheet: 0, col: 1, row: 0 }; @@ -2083,6 +2204,9 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public isItPossibleToMoveCells(source: SimpleCellRange, destinationLeftCorner: SimpleCellAddress): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } if (!isSimpleCellAddress(destinationLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'destinationLeftCorner') } @@ -2117,12 +2241,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[SourceLocationHasArrayError]] when the source location has array inside - array cannot be moved * @throws [[TargetLocationHasArrayError]] when the target location has array inside - cells cannot be replaced by the array * @throws [[SheetsNotEqual]] if range provided has distinct sheet numbers for start and end + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=RAND()', '42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // choose the coordinates and assign them to variables * const source = { sheet: 0, col: 1, row: 0 }; @@ -2140,6 +2265,7 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public moveCells(source: SimpleCellRange, destinationLeftCorner: SimpleCellAddress): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) if (!isSimpleCellAddress(destinationLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'destinationLeftCorner') } @@ -2156,6 +2282,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that moveRows can be called. * If returns `true`, doing [[moveRows]] operation won't throw any errors. * Returns `false` if the operation might be disrupted and causes side effects by the fact that there is an array inside the selected rows, the target location includes an array or the provided address is invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - a sheet number in which the operation will be performed * @param {number} startRow - number of the first row to move @@ -2169,7 +2296,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromArray([ * ['1'], * ['2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example * // it is possible to move one row from row 0 into row 2 @@ -2179,6 +2306,9 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public isItPossibleToMoveRows(sheetId: number, startRow: number, numberOfRows: number, targetRow: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startRow, 'number', 'startRow') validateArgToType(numberOfRows, 'number', 'numberOfRows') @@ -2210,13 +2340,14 @@ export class HyperFormula implements TypedEmitter { * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[SourceLocationHasArrayError]] when the source location has array inside - array cannot be moved * @throws [[TargetLocationHasArrayError]] when the target location has array inside - cells cannot be replaced by the array + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1'], * ['2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values @@ -2226,6 +2357,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public moveRows(sheetId: number, startRow: number, numberOfRows: number, targetRow: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startRow, 'number', 'startRow') validateArgToType(numberOfRows, 'number', 'numberOfRows') @@ -2239,6 +2371,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that moveColumns can be called. * If returns `true`, doing [[moveColumns]] operation won't throw any errors. * Returns `false` if the operation might be disrupted and causes side effects by the fact that there is an array inside the selected columns, the target location includes an array or the provided address is invalid. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - a sheet number in which the operation will be performed * @param {number} startColumn - number of the first column to move @@ -2251,7 +2384,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example * // it is possible to move one column from column 1 into column 2 of sheet 0 @@ -2261,6 +2394,9 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public isItPossibleToMoveColumns(sheetId: number, startColumn: number, numberOfColumns: number, targetColumn: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startColumn, 'number', 'startColumn') validateArgToType(numberOfColumns, 'number', 'numberOfColumns') @@ -2292,28 +2428,31 @@ export class HyperFormula implements TypedEmitter { * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[SourceLocationHasArrayError]] when the source location has array inside - array cannot be moved * @throws [[TargetLocationHasArrayError]] when the target location has array inside - cells cannot be replaced by the array + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '3', '=RAND()', '=SUM(A1:C1)'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * + * // move column B before column D; the SUM range follows its cells and becomes A1:B1 * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values, for this example: * // [{ - * // address: { sheet: 0, col: 1, row: 0 }, - * // newValue: 0.16210054671639, - * // }, { * // address: { sheet: 0, col: 4, row: 0 }, - * // newValue: 6.16210054671639, + * // newValue: 4, + * // }, { + * // address: { sheet: 0, col: 3, row: 0 }, + * // newValue: 0.16210054671639, * // }] - * const changes = hfInstance.moveColumns(0, 1, 1, 2); + * const changes = hfInstance.moveColumns(0, 1, 1, 3); * ``` * * @category Columns */ public moveColumns(sheetId: number, startColumn: number, numberOfColumns: number, targetColumn: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startColumn, 'number', 'startColumn') validateArgToType(numberOfColumns, 'number', 'numberOfColumns') @@ -2333,12 +2472,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[ExpectedValueOfTypeError]] if source is of wrong type * @throws [[SheetsNotEqual]] if range provided has distinct sheet numbers for start and end + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Clipboard feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // it copies [ [ 2 ] ] * const clipboardContent = hfInstance.copy({ @@ -2352,6 +2492,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public copy(source: SimpleCellRange): CellValue[][] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellRange(source)) { throw new ExpectedValueOfTypeError('SimpleCellRange', 'source') } @@ -2373,12 +2514,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if source is of wrong type * @throws [[SheetsNotEqual]] if range provided has distinct sheet numbers for start and end * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Clipboard feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns the values that were cut: [ [ 1 ] ] * const clipboardContent = hfInstance.cut({ @@ -2392,6 +2534,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public cut(source: SimpleCellRange): CellValue[][] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellRange(source)) { throw new ExpectedValueOfTypeError('SimpleCellRange', 'source') } @@ -2421,12 +2564,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[NothingToPasteError]] when clipboard is empty * @throws [[TargetLocationHasArrayError]] when the selected target area has array inside * @throws [[ExpectedValueOfTypeError]] if targetLeftCorner is of wrong type + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Clipboard feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // [ [ 2 ] ] was copied * const clipboardContent = hfInstance.copy({ @@ -2443,6 +2587,10 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public paste(targetLeftCorner: SimpleCellAddress): ExportedChange[] { + // Clipboard alone is enough, including for pasting a CUT - which relocates cells, the same + // mutation the public moveCells() requires Crud for. Granting the clipboard is taken to grant + // what the clipboard does, so this route is deliberately not gated on Crud as well. + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellAddress(targetLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'targetLeftCorner') } @@ -2458,7 +2606,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // copy desired content * const clipboardContent = hfInstance.copy({ @@ -2504,7 +2652,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '3'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // do an operation, for example remove columns * hfInstance.removeColumns(0, [0, 1]); @@ -2534,7 +2682,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2', '3'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // do an operation, for example remove columns * hfInstance.removeColumns(0, [0, 1]); @@ -2567,7 +2715,7 @@ export class HyperFormula implements TypedEmitter { * ['=SUM(1, 2)', '2', '10'], * ['5', '6', '7'], * ['40', '30', '20'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * * // returns calculated cells content: [ [ 3, 2 ], [ 5, 6 ] ] @@ -2603,7 +2751,7 @@ export class HyperFormula implements TypedEmitter { * ['=SUM(1, 2)', '2', '10'], * ['5', '6', '7'], * ['40', '30', '20'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns cell formulas of a given range only: * // [ [ '=SUM(1, 2)', undefined ], [ undefined, undefined ] ] @@ -2642,7 +2790,7 @@ export class HyperFormula implements TypedEmitter { * ['=SUM(1, 2)', 2, 10], * [5, 6, 7], * [40, 30, 20], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return serialized cell content for the given range: * // [ [ '=SUM(1, 2)', 2 ], [ 5, 6 ] ] @@ -2676,7 +2824,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildFromArray([[1, '=A1'], ['=$A$1', '2']]); + * const hfInstance = HyperFormula.buildFromArray([[1, '=A1'], ['=$A$1', '2']], { licenseKey: 'gpl-v3' }); * * // should return [['2', '=$A$1', '2'], ['=A3', 1, '=C3'], ['2', '=$A$1', '2']] * hfInstance.getFillRangeData( {start: {sheet: 0, row: 0, col: 0}, end: {sheet: 0, row: 1, col: 1}}, @@ -2711,6 +2859,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that addSheet can be called. * If returns `true`, doing [[addSheet]] operation won't throw any errors, and it is possible to add sheet with provided name. * Returns `false` if the chosen name is already used. + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {string} sheetName - sheet name, case-insensitive * @@ -2721,7 +2870,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'false' because 'MySheet2' already exists * const isAddable = hfInstance.isItPossibleToAddSheet('MySheet2'); @@ -2730,6 +2879,9 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public isItPossibleToAddSheet(sheetName: string): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetName, 'string', 'sheetName') try { this._crudOperations.ensureItIsPossibleToAddSheet(sheetName) @@ -2750,13 +2902,14 @@ export class HyperFormula implements TypedEmitter { * * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[SheetNameAlreadyTakenError]] when sheet with a given name already exists + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'MySheet3' * const nameProvided = hfInstance.addSheet('MySheet3'); @@ -2769,6 +2922,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public addSheet(sheetName?: string): string { + this.ensureCapability(FeatureId.Crud) if (sheetName !== undefined) { validateArgToType(sheetName, 'string', 'sheetName') } @@ -2782,6 +2936,7 @@ export class HyperFormula implements TypedEmitter { * Returns information whether it is possible to remove sheet for the engine. * Returns `true` if the provided sheet exists, and therefore it can be removed, doing [[removeSheet]] operation won't throw any errors. * Returns `false` otherwise + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID. * @@ -2792,7 +2947,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'true' because sheet with ID 1 exists and is removable * const isRemovable = hfInstance.isItPossibleToRemoveSheet(1); @@ -2801,6 +2956,9 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public isItPossibleToRemoveSheet(sheetId: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { this._crudOperations.ensureScopeIdIsValid(sheetId) @@ -2824,13 +2982,14 @@ export class HyperFormula implements TypedEmitter { * * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['=SUM(MySheet2!A1:A2)'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values, in this example it will return: @@ -2844,6 +3003,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public removeSheet(sheetId: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const displayName = this.sheetMapping.getSheetName(sheetId) as string this._crudOperations.removeSheet(sheetId) @@ -2856,6 +3016,7 @@ export class HyperFormula implements TypedEmitter { * Returns information whether it is possible to clear a specified sheet. * If returns `true`, doing [[clearSheet]] operation won't throw any errors, provided sheet exists and its content can be cleared. * Returns `false` otherwise + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID. * @@ -2866,7 +3027,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'true' because 'MySheet2' exists and can be cleared * const isClearable = hfInstance.isItPossibleToClearSheet(1); @@ -2875,6 +3036,9 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public isItPossibleToClearSheet(sheetId: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { this._crudOperations.ensureScopeIdIsValid(sheetId) @@ -2897,13 +3061,14 @@ export class HyperFormula implements TypedEmitter { * * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['=SUM(MySheet2!A1:A2)'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values, in this example it will return: @@ -2917,6 +3082,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public clearSheet(sheetId: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.clearSheet(sheetId) return this.recomputeIfDependencyGraphNeedsIt() @@ -2926,6 +3092,7 @@ export class HyperFormula implements TypedEmitter { * Returns information whether it is possible to replace the sheet content. * If returns `true`, doing [[setSheetContent]] operation won't throw any errors, the provided sheet exists and then its content can be replaced. * Returns `false` otherwise + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - sheet ID. * @param {RawCellContent[][]} values - array of new values @@ -2937,7 +3104,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'true' because sheet of ID 0 exists * // and the provided content can be placed in this sheet @@ -2947,6 +3114,9 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public isItPossibleToReplaceSheetContent(sheetId: number, values: RawCellContent[][]): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') try { this._crudOperations.ensureScopeIdIsValid(sheetId) @@ -2968,13 +3138,14 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when values argument is not an array of arrays + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return a list of cells which values changed after the operation, * // their absolute addresses and new values @@ -2984,6 +3155,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public setSheetContent(sheetId: number, values: RawCellContent[][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setSheetContent(sheetId, values) return this.recomputeIfDependencyGraphNeedsIt() @@ -3003,7 +3175,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * hfInstance.addSheet('Sheet0'); //sheetId = 0 * * // returns { sheet: 42, col: 0, row: 0 } @@ -3041,7 +3213,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * hfInstance.addSheet('Sheet0'); //sheetId = 0 * * // should return { start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 0 } } @@ -3068,7 +3240,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * hfInstance.addSheet('Sheet0'); //sheetId = 0 * const addr = { sheet: 0, col: 1, row: 1 }; * @@ -3121,7 +3293,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * hfInstance.addSheet('Sheet0'); //sheetId = 0 * const range = { start: { sheet: 0, col: 1, row: 1 }, end: { sheet: 0, col: 2, row: 1 } }; * @@ -3172,7 +3344,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildFromArray( [ ['1', '=A1', '=A1+B1'] ] ); + * const hfInstance = HyperFormula.buildFromArray( [ ['1', '=A1', '=A1+B1'] ] , { licenseKey: 'gpl-v3' }); * * hfInstance.getCellDependents({ sheet: 0, col: 0, row: 0}); * // returns [{ sheet: 0, col: 1, row: 0}, { sheet: 0, col: 2, row: 0}] @@ -3210,7 +3382,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildFromArray( [ ['1', '=A1', '=A1+B1'] ] ); + * const hfInstance = HyperFormula.buildFromArray( [ ['1', '=A1', '=A1+B1'] ] , { licenseKey: 'gpl-v3' }); * * hfInstance.getCellPrecedents({ sheet: 0, col: 2, row: 0}); * // returns [{ sheet: 0, col: 0, row: 0}, { sheet: 0, col: 1, row: 0}] @@ -3245,7 +3417,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'MySheet2' as this sheet is the second one * const sheetName = hfInstance.getSheetName(1); @@ -3267,7 +3439,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return all sheets names: ['MySheet1', 'MySheet2'] * const sheetNames = hfInstance.getSheetNames(); @@ -3291,7 +3463,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return '0' because 'MySheet1' is of ID '0' * const sheetID = hfInstance.getSheetId('MySheet1'); @@ -3316,7 +3488,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // should return 'true' since 'MySheet1' exists * const sheetExist = hfInstance.doesSheetExist('MySheet1'); @@ -3342,7 +3514,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(A2:A3)', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'FORMULA', the cell of given coordinates is of this type * const cellA1Type = hfInstance.getCellType({ sheet: 0, col: 0, row: 0 }); @@ -3374,7 +3546,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(A2:A3)', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' since the selected cell contains a simple value * const isA1Simple = hfInstance.doesCellHaveSimpleValue({ sheet: 0, col: 0, row: 0 }); @@ -3405,7 +3577,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(A2:A3)', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' since the A1 cell contains a formula * const A1Formula = hfInstance.doesCellHaveFormula({ sheet: 0, col: 0, row: 0 }); @@ -3437,7 +3609,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * [null, '1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true', cell of provided coordinates is empty * const isEmpty = hfInstance.isCellEmpty({ sheet: 0, col: 0, row: 0 }); @@ -3468,7 +3640,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['{=TRANSPOSE(B1:B1)}'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true', cell of provided coordinates is a part of an array * const isPartOfArray = hfInstance.isCellPartOfArray({ sheet: 0, col: 0, row: 0 }); @@ -3500,7 +3672,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['=SUM(1, 2, 3)', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'NUMBER', cell value type of provided coordinates is a number * const cellValue = hfInstance.getCellValueType({ sheet: 0, col: 1, row: 0 }); @@ -3536,7 +3708,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1%', '1$'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'NUMBER_PERCENT', cell value type of provided coordinates is a number with a format inference percent. * const cellType = hfInstance.getCellValueDetailedType({ sheet: 0, col: 0, row: 0 }); @@ -3570,7 +3742,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1$', '1'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return '$', cell value type of provided coordinates is a number with a format inference currency, parsed as using '$' as currency. * const cellFormat = hfInstance.getCellValueFormat({ sheet: 0, col: 0, row: 0 }); @@ -3597,7 +3769,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['1', '2'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return the number of sheets which is '1' * const sheetsCount = hfInstance.countSheets(); @@ -3613,6 +3785,7 @@ export class HyperFormula implements TypedEmitter { * Returns information whether it is possible to rename sheet. * Returns `true` if the sheet with provided id exists and new name is available * Returns `false` if sheet cannot be renamed + * Returns `false` also when the license key does not allow the Crud feature (see [[LicenseCapabilityMissingError]]). * * @param {number} sheetId - a sheet number * @param {string} newName - a name of the sheet to be given @@ -3624,7 +3797,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // returns true * hfInstance.isItPossibleToRenameSheet(0, 'MySheet0'); @@ -3633,6 +3806,9 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public isItPossibleToRenameSheet(sheetId: number, newName: string): boolean { + if (!this.isCapabilityAllowed(FeatureId.Crud)) { + return false + } validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(newName, 'string', 'newName') try { @@ -3656,13 +3832,14 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[SheetNameAlreadyTakenError]] when the provided sheet name already exists + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Crud feature * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // renames the sheet 'MySheet1' * hfInstance.renameSheet(0, 'MySheet0'); @@ -3671,6 +3848,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public renameSheet(sheetId: number, newName: string): void { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(newName, 'string', 'newName') const oldName = this._crudOperations.renameSheet(sheetId, newName) @@ -3693,12 +3871,14 @@ export class HyperFormula implements TypedEmitter { * @fires [[evaluationSuspended]] always * @fires [[evaluationResumed]] after the recomputation of necessary values * + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Batching feature + * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // multiple operations in a single callback will trigger evaluation only once * // and only one set of changes is returned as a combined result of all @@ -3712,6 +3892,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public batch(batchOperations: () => void): ExportedChange[] { + this.ensureCapability(FeatureId.Batching) this.suspendEvaluation() this._crudOperations.beginUndoRedoBatchMode() try { @@ -3734,12 +3915,14 @@ export class HyperFormula implements TypedEmitter { * * @fires [[evaluationSuspended]] always * + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the Batching feature + * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // similar to batch() but operations are not within a callback, * // one method suspends the recalculation @@ -3759,6 +3942,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public suspendEvaluation(): void { + this.ensureCapability(FeatureId.Batching) this._evaluationSuspended = true this._emitter.emit(Events.EvaluationSuspended) } @@ -3775,7 +3959,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * MySheet1: [ ['1'] ], * MySheet2: [ ['10'] ], - * }); + * }, { licenseKey: 'gpl-v3' }); * * // similar to batch() but operations are not within a callback, * // one method suspends the recalculation @@ -3795,6 +3979,15 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public resumeEvaluation(): ExportedChange[] { + // Deliberately NOT gated, unlike suspendEvaluation and batch. This is the only exit from + // a suspended engine, and _evaluationSuspended survives rebuildWithConfig: an instance + // suspended while Batching was granted, whose entitlement then loses Batching via + // updateConfig, would be stuck suspended forever - every read throws + // EvaluationSuspendedError and the sole recovery path would throw + // LicenseCapabilityMissingError. Gating the two entry points is what makes the feature + // licensable; gating the release valve only strands the caller, which is the same reason + // teardown (clearClipboard, clearUndoStack, clearRedoStack) is ungated. See the note on + // ensureCapability. this._evaluationSuspended = false const changes = this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.EvaluationResumed, changes) @@ -3806,7 +3999,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // suspend the evaluation * hfInstance.suspendEvaluation(); @@ -3829,6 +4022,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that addNamedExpression can be called. * If returns `true`, doing [[addNamedExpression]] operation won't throw any errors. * Returns `false` if the operation might be disrupted. + * Returns `false` also when the license key does not allow the NamedExpressions feature (see [[LicenseCapabilityMissingError]]). * * @param {string} expressionName - a name of the expression to be added * @param {RawCellContent} expression - the expression @@ -3840,7 +4034,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // should return 'true' for this example, * // it is possible to add named expression to global scope @@ -3850,6 +4044,9 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public isItPossibleToAddNamedExpression(expressionName: string, expression: RawCellContent, scope?: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.NamedExpressions)) { + return false + } validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -3882,12 +4079,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[NamedExpressionNameIsInvalidError]] when the named-expression name is not valid * @throws [[NoRelativeAddressesAllowedError]] when the named-expression formula contains relative references * @throws [[NoSheetWithIdError]] if no sheet with given sheetId exists + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the NamedExpressions feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add own expression, scope limited to 'Sheet1' (sheetId=0), the method should return a list of cells which values * // changed after the operation, their absolute addresses and new values @@ -3902,6 +4100,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public addNamedExpression(expressionName: string, expression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -3928,13 +4127,13 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression, only 'Sheet1' (sheetId=0) considered as it is the scope - * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100', 'Sheet1'); + * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100', 0); * - * // returns the calculated value of a passed named expression, '142' for this example - * const myFormula = hfInstance.getNamedExpressionValue('prettyName', 'Sheet1'); + * // returns the calculated value of a passed named expression, 142 for this example + * const myFormula = hfInstance.getNamedExpressionValue('prettyName', 0); * ``` * * @category Named Expressions @@ -3969,7 +4168,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression in 'Sheet1' (sheetId=0) * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100', 0); @@ -4010,7 +4209,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression in 'Sheet1' (sheetId=0) * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100', 0); @@ -4052,6 +4251,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that changeNamedExpression can be called. * If returns `true`, doing [[changeNamedExpression]] operation won't throw any errors. * Returns `false` if the operation might be disrupted. + * Returns `false` also when the license key does not allow the NamedExpressions feature (see [[LicenseCapabilityMissingError]]). * * @param {string} expressionName - an expression name, case-insensitive. * @param {RawCellContent} newExpression - a new expression @@ -4063,7 +4263,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100'); @@ -4076,6 +4276,9 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public isItPossibleToChangeNamedExpression(expressionName: string, newExpression: RawCellContent, scope?: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.NamedExpressions)) { + return false + } validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4107,23 +4310,25 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] if no sheet with given sheetId exists * @throws [[ArrayFormulasNotSupportedError]] when the named expression formula is an array formula * @throws [[NoRelativeAddressesAllowedError]] when the named expression formula contains relative references + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the NamedExpressions feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression, scope limited to 'Sheet1' (sheetId=0) * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100', 0); * - * // change the named expression - * const changes = hfInstance.changeNamedExpression('prettyName', '=Sheet1!$A$1+200'); + * // change the named expression in the same scope + * const changes = hfInstance.changeNamedExpression('prettyName', '=Sheet1!$A$1+200', 0); * ``` * * @category Named Expressions */ public changeNamedExpression(expressionName: string, newExpression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4137,6 +4342,7 @@ export class HyperFormula implements TypedEmitter { * Checks against particular rules to ascertain that removeNamedExpression can be called. * If returns `true`, doing [[removeNamedExpression]] operation won't throw any errors. * Returns `false` if the operation might be disrupted. + * Returns `false` also when the license key does not allow the NamedExpressions feature (see [[LicenseCapabilityMissingError]]). * * @param {string} expressionName - an expression name, case-insensitive. * @param {number?} scope - scope definition, `sheetId` for local scope or `undefined` for global scope @@ -4147,7 +4353,7 @@ export class HyperFormula implements TypedEmitter { * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100'); @@ -4160,6 +4366,9 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public isItPossibleToRemoveNamedExpression(expressionName: string, scope?: number): boolean { + if (!this.isCapabilityAllowed(FeatureId.NamedExpressions)) { + return false + } validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4188,12 +4397,13 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NamedExpressionDoesNotExistError]] when the given expression does not exist. * @throws [[NoSheetWithIdError]] if no sheet with given sheetId exists + * @throws [[LicenseCapabilityMissingError]] if the license key is missing or invalid, has expired and blocks evaluation, or does not grant the NamedExpressions feature * * @example * ```js * const hfInstance = HyperFormula.buildFromArray([ * ['42'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add a named expression * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100', 0); @@ -4205,6 +4415,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public removeNamedExpression(expressionName: string, scope?: number): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4237,7 +4448,7 @@ export class HyperFormula implements TypedEmitter { * ['42'], * ['50'], * ['60'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add two named expressions and one scoped * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100'); @@ -4272,12 +4483,12 @@ export class HyperFormula implements TypedEmitter { * ['42'], * ['50'], * ['60'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // add two named expressions and one scoped * hfInstance.addNamedExpression('prettyName', '=Sheet1!$A$1+100'); * hfInstance.addNamedExpression('anotherPrettyName', '=Sheet1!$A$2+100'); - * hfInstance.addNamedExpression('prettyName3', '=Sheet1!$A$3+100', 0); + * hfInstance.addNamedExpression('alsoPrettyName', '=Sheet1!$A$3+100', 0); * * // get all expressions serialized * // should return: @@ -4309,7 +4520,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromArray([ * ['42'], * ['50'], - * ]); + * ], { licenseKey: 'gpl-v3' }); * * // returns '=Sheet1!$A$1+10' * const normalizedFormula = hfInstance.normalizeFormula('=SHEET1!$A$1+10'); @@ -4344,7 +4555,7 @@ export class HyperFormula implements TypedEmitter { * const hfInstance = HyperFormula.buildFromSheets({ * Sheet1: [['58']], * Sheet2: [['1', '2', '3'], ['4', '5', '6']] - * }); + * }, { licenseKey: 'gpl-v3' }); * * // returns the calculated formula's value * // for this example, returns `68` @@ -4378,7 +4589,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // returns a list of named expressions used by a formula * // for this example, returns ['foo', 'bar'] @@ -4432,9 +4643,14 @@ export class HyperFormula implements TypedEmitter { * Returns translated names of all functions registered in this instance of HyperFormula * according to the language set in the configuration * + * Answers for the instance's function REGISTRY — what is registered, not what the license key + * lets it evaluate — so it lists every registered function whatever the key grants. To build a + * function picker that never offers a function evaluating to `#LIC!`, use + * [[getAvailableFunctions]], which answers about availability. + * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // return translated names of all functions, assign to a variable * const allNames = hfInstance.getRegisteredFunctionNames(); @@ -4461,7 +4677,7 @@ export class HyperFormula implements TypedEmitter { * // import your own plugin * import { MyExamplePlugin } from './file_with_your_plugin'; * - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // register a plugin * HyperFormula.registerFunctionPlugin(MyExamplePlugin); @@ -4482,7 +4698,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // return classes of all plugins registered, assign to a variable * const allNames = hfInstance.getAllFunctionPlugins(); @@ -4517,9 +4733,24 @@ export class HyperFormula implements TypedEmitter { * plugin registered without translations for that language. A translation set to an empty string is not a missing * entry: it falls back to the canonical id, so the function stays listed under its canonical name. * + * A function the instance's license key does not include is omitted for the same reason: it would evaluate to a + * `#LIC!` error. The list therefore answers "what can this engine compute", not "what does this package contain". + * Two consequences worth knowing: + * - A key that blocks evaluation (a missing or invalid key, an expired classic key, or a trial past its grace + * period) does **not** shorten the list. Such a key restricts nothing by entitlement — it is reported on the + * console, and every license-gated function call evaluates to `#LIC!` — so the full catalog is still described. + * `VERSION()` and `OFFSET()` are protected built-ins outside the license system, so they keep evaluating. Use it to + * build a function picker before a key is configured. An expired key that keeps evaluating (a subscription past + * its grace period, or a perpetual key whose maintenance doesn't cover this build) keeps its own grants, so the + * list stays exactly what it was while the key was current. + * - A custom (user-registered) function is omitted only if it took a built-in id the key excludes. The rule is + * "not covered by the capability table", not "not user-registered", so a plugin registered under an id the + * built-in catalog already uses is treated as that built-in. Registered under an id of its own, a custom + * function is never omitted. See {@link getFunctionDetails}, which states the same exception. + * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // get the list of available functions, translated for the configured language * const functions = hfInstance.getAvailableFunctions(); @@ -4530,9 +4761,9 @@ export class HyperFormula implements TypedEmitter { public getAvailableFunctions(): FunctionListEntry[] { return HyperFormula.buildAvailableFunctions( this._functionRegistry, - // The instance's own package, the one its evaluator uses — not a fresh global lookup, which could describe - // the functions under a package this instance never adopted. - this._config.translationPackage, + // The instance's own config: its translation package (not a fresh global lookup, which could describe the + // functions under a package this instance never adopted) and its resolved license entitlement. + this._config, ) } @@ -4543,9 +4774,10 @@ export class HyperFormula implements TypedEmitter { * documentation link (`documentationUrl`) and usage examples (`examples`) — every built-in authors both. * Resolves both built-in and custom (user-registered) functions, as well as aliases. An alias reports its * target's metadata (including examples, which spell the target's name) under the alias id, with the target id - * exposed as `aliasOf`. Returns `undefined` when the function id is unknown, not registered in this instance, or - * has no translation entry for the configured language (an untranslated id cannot be evaluated, so it is not - * described either, which keeps this method consistent with [[getAvailableFunctions]]). + * exposed as `aliasOf`. Returns `undefined` when the function id is unknown, not registered in this instance, has + * no translation entry for the configured language, or is not included in this instance's license key (neither an + * untranslated nor an unlicensed id can be evaluated, so neither is described — which keeps this method consistent + * with [[getAvailableFunctions]], including its behavior for a key that blocks evaluation). * For a custom function, `category` is `'Custom'`, there is no `shortDescription`, `documentationUrl` or * `examples`, and parameters are reported positionally (`Arg1`, `Arg2`, ...). A custom plugin registered over a * built-in id is the exception: the catalogue is keyed by function id, so it reports that built-in's authored @@ -4564,7 +4796,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // get the details of the SUMIF function, translated for the configured language * const details = hfInstance.getFunctionDetails('SUMIF'); @@ -4574,8 +4806,8 @@ export class HyperFormula implements TypedEmitter { */ public getFunctionDetails(canonicalName: string): FunctionDetails | undefined { validateArgToType(canonicalName, 'string', 'canonicalName') - // The instance's own package, the one its evaluator uses — see getAvailableFunctions. - return HyperFormula.buildFunctionDetailsFor(canonicalName, this._functionRegistry, this._config.translationPackage) + // The instance's own config, for the same reasons as getAvailableFunctions. + return HyperFormula.buildFunctionDetailsFor(canonicalName, this._functionRegistry, this._config) } /** @@ -4589,7 +4821,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // pass the number of days since nullDate * // the method should return formatted date and time, for this example: @@ -4616,7 +4848,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // pass the number of days since nullDate * // the method should return formatted date, for this example: @@ -4642,7 +4874,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // pass a number to be interpreted as a time * // should return {hours: 26, minutes: 24} for this example @@ -4665,7 +4897,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // subscribe to a 'sheetAdded', pass a simple handler * hfInstance.on('sheetAdded', ( ) => { console.log('foo') }); @@ -4690,7 +4922,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // subscribe to a 'sheetAdded', pass a simple handler * hfInstance.once('sheetAdded', ( ) => { console.log('foo') }); @@ -4716,7 +4948,7 @@ export class HyperFormula implements TypedEmitter { * * @example * ```js - * const hfInstance = HyperFormula.buildEmpty(); + * const hfInstance = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); * * // define a simple function to be called upon emitting an event * const handler = ( ) => { console.log('baz') } @@ -4767,6 +4999,48 @@ export class HyperFormula implements TypedEmitter { } } + /** + * Throws an error if the current license does not allow the given feature: either the key's + * state blocks every gated feature (a missing or invalid key, an expired classic key, or a trial + * past its grace period), or the key does not grant it. + * + * Where the line is drawn, so a later change does not move it by accident: + * - **Gated:** methods that create value by mutating the sheet, the clipboard, the undo + * history, or the named-expression set. + * - **Not gated:** reads (`getCellValue`, `listNamedExpressions`, + * `getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates and + * `isThereSomethingToUndo`/`isThereSomethingToRedo`, which answer `false` instead of throwing + * when the method they ask about is not allowed) and teardown or + * cleanup that only ever removes state (`clearClipboard`, `clearUndoStack`, + * `clearRedoStack`, `destroy`). Gating cleanup would let a restricted entitlement strand + * an integration mid-teardown while giving a licensee nothing, and mirrors gate B, which + * blocks *calling* a function rather than *reading* an already-computed value. + * - **Not gated, for the same reason:** `resumeEvaluation`, the sole exit from a suspended + * engine. Gate the entry points (`suspendEvaluation`, `batch`) and the feature is + * licensable; gate the release valve too and an entitlement change mid-suspension leaves + * the instance permanently unusable. A capability check must never be reachable only on + * the way out of a state it let the caller into. + * + * Must stay the first statement of every gated method, so a blocking key is reported before + * any argument validation. + * + * @internal + */ + private ensureCapability(feature: FeatureId): void { + ensureFeatureAllowed(this._config, feature) + } + + /** + * Whether the current license allows the given feature: the answer [[ensureCapability]] acts on, + * for the `isItPossibleTo*` predicates and `isThereSomethingToUndo`/`isThereSomethingToRedo`, + * which answer `false` instead of throwing. + * + * @internal + */ + private isCapabilityAllowed(feature: FeatureId): boolean { + return isFeatureAllowed(this._config, feature) + } + /** * Parses a formula string and extracts its AST and dependencies. * @@ -4793,7 +5067,10 @@ export class HyperFormula implements TypedEmitter { */ private rebuildWithConfig(newParams: Partial): void { const newConfig = this._config.mergeConfig(newParams) - const configNewLanguage = this._config.mergeConfig({language: newParams.language}) + // The second argument silences license console messages for this transient Config: it is + // built from the OUTGOING config purely to reserialize sheets, and must not print an expiry + // notice for the key the caller may be replacing in this very call. + const configNewLanguage = this._config.mergeConfig({language: newParams.language}, false) const serializedSheets = this._serialization.withNewConfig(configNewLanguage, this._namedExpressions).getAllSheetsSerialized() const serializedNamedExpressions = this._serialization.getAllNamedExpressionsSerialized() diff --git a/src/Operations.ts b/src/Operations.ts index 6a0e5ccdd4..df6d41264f 100644 --- a/src/Operations.ts +++ b/src/Operations.ts @@ -958,10 +958,17 @@ export class Operations { const expressionName = namedExpressionDependency.name const sourceVertex = this.dependencyGraph.fetchNamedExpressionVertex(expressionName, sourceSheet).vertex const namedExpressionInTargetScope = this.namedExpressions.isExpressionInScope(expressionName, targetAddress.sheet) + const namedExpressionInSourceScope = this.namedExpressions.isExpressionInScope(expressionName, sourceSheet) + // A name the source sheet does not define locally resolves to the same workbook-level vertex + // from either sheet: a global named expression, or the placeholder of a name defined nowhere. + // There is nothing to copy then, and copying would turn an undefined name into an empty + // global named expression, changing the formula's #NAME? into an empty value. const targetScopeExpressionVertex = namedExpressionInTargetScope ? this.dependencyGraph.fetchNamedExpressionVertex(expressionName, targetAddress.sheet).vertex - : this.copyOrFetchGlobalNamedExpressionVertex(expressionName, sourceVertex, addedGlobalNamedExpressions) + : namedExpressionInSourceScope + ? this.copyOrFetchGlobalNamedExpressionVertex(expressionName, sourceVertex, addedGlobalNamedExpressions) + : sourceVertex if (targetScopeExpressionVertex !== sourceVertex) { this.dependencyGraph.graph.removeEdgeIfExists(sourceVertex, vertex) diff --git a/src/error-message.ts b/src/error-message.ts index 4b80ee06fa..36bc5231af 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -78,4 +78,5 @@ export class ErrorMessage { public static FunctionName = (arg: string) => `Function name ${arg} not recognized.` public static NamedExpressionName = (arg: string) => `Named expression ${arg} not recognized.` public static LicenseKey = (arg: string) => `License key is ${arg}.` + public static LicenseCapability = (functionName: string) => `Function ${functionName} is not included in your license.` } diff --git a/src/errors.ts b/src/errors.ts index 66a73a2add..16baf2b889 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,6 +4,8 @@ */ import {SimpleCellAddress} from './Cell' +import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {FeatureId} from './license/LicenseEntitlement' /** * Error thrown when the sheet of a given ID does not exist. @@ -392,3 +394,65 @@ export class AliasAlreadyExisting extends Error { super(`Alias id ${name} in plugin ${pluginName} already defined as a function or alias.`) } } + +/** + * Error thrown when a public API method is called for a {@link FeatureId} that the current + * license entitlement does not grant, or when the license key itself blocks every gated feature + * (a missing or invalid key, an expired classic key, or a trial past its grace period); the + * message then names the key's state instead. Mirrors gate B's `ErrorMessage.LicenseCapability`, but + * this one guards the API surface itself rather than a formula evaluation, so it + * is thrown synchronously instead of surfacing as a cell error. + * + * This list names every method that can throw it - `resumeEvaluation` is deliberately NOT among + * them: it is the sole exit from a suspended engine, so gating it could strand an instance + * permanently if the entitlement changes mid-suspension (see the note on `HyperFormula. + * ensureCapability`). + * + * @see [[HyperFormula.buildFromArray]] + * @see [[HyperFormula.buildFromSheets]] + * @see [[HyperFormula.buildEmpty]] + * @see [[addNamedExpression]] + * @see [[changeNamedExpression]] + * @see [[removeNamedExpression]] + * @see [[copy]] + * @see [[cut]] + * @see [[paste]] + * @see [[setCellContents]] + * @see [[addRows]] + * @see [[removeRows]] + * @see [[addColumns]] + * @see [[removeColumns]] + * @see [[moveCells]] + * @see [[moveRows]] + * @see [[moveColumns]] + * @see [[swapRowIndexes]] + * @see [[setRowOrder]] + * @see [[swapColumnIndexes]] + * @see [[setColumnOrder]] + * @see [[addSheet]] + * @see [[removeSheet]] + * @see [[clearSheet]] + * @see [[setSheetContent]] + * @see [[renameSheet]] + * @see [[undo]] + * @see [[redo]] + * @see [[batch]] + * @see [[suspendEvaluation]] + */ +export class LicenseCapabilityMissingError extends Error { + /** The gated feature the call needed. */ + public readonly feature: FeatureId + + /** + * @param {FeatureId} feature - the gated feature that was called + * @param {LicenseKeyValidityState} [blockingState] - the key's state when the key itself blocks + * every gated feature (a missing or invalid key, an expired classic key, or a trial past its grace + * period); omit when the key evaluates but does not grant `feature` + */ + constructor(feature: FeatureId, blockingState?: LicenseKeyValidityState) { + super(blockingState === undefined + ? `Feature ${feature} is not included in your license.` + : `License key is ${blockingState}. Feature ${feature} is not available.`) + this.feature = feature + } +} diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 72ae003241..7ea39eb5ca 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -3,6 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import {LicenseState, UnlicensedReason} from '../license/handsontable-license-key-parser' import {checkKeySchema, extractTime} from './licenseKeyHelper' /** @@ -27,7 +28,7 @@ type ConsoleMessages = { type MessageDescriptor = { template: LicenseKeyValidityState, - vars: TemplateVars, + expiryDate?: Date, } /** @@ -42,6 +43,179 @@ const consoleMessages: ConsoleMessages = { let _notified = false +/** + * What an entitlement-key lifecycle message is built from: the date exactly as the key carries it + * (never rebuilt from a timestamp) and the whole UTC days left until it. + */ +export interface EntitlementMessageParams { + licensedUntil: string, + /** `null` for a `release_until` key, which is compared with the build date and reads no clock. */ + daysRemaining: number | null, +} + +/** + * One console notification for an entitlement key: its severity and its text. Kept as one record + * so a state cannot get a text without a severity. A warning while the license still works, an + * error once it has run out. + */ +interface EntitlementConsoleNotification { + severity: 'warn' | 'error', + message: (params: Params) => string, +} + +const PURCHASE_LICENSE_TEXT = 'To continue using HyperFormula, you need to purchase a license.' + +/** + * Formats a `usage_until` date for a message. It is compared against the clock in UTC, so it is + * printed with the marker; a `release_until` date involves no clock and is printed without one. + * + * @param {string} isoDate - the date as the key carries it, `YYYY-MM-DD` + */ +function utcDay(isoDate: string): string { + return `${isoDate} (UTC)` +} + +/** + * The countdown of a trial notice: `expires today`, `expires in 1 day` or `expires in N days`. + * + * @param {number} days - the whole UTC days left until the last licensed day + */ +function expiryClause(days: number): string { + return days === 0 ? 'expires today' : `expires in ${days} ${days === 1 ? 'day' : 'days'}` +} + +/** + * The message of a subscription past its `usage_until` date, inside its grace period or after it. + * + * @param {EntitlementMessageParams} params - the key's own date + */ +function subscriptionExpiredMessage({licensedUntil}: EntitlementMessageParams): string { + return `Your HyperFormula subscription license expired on ${utcDay(licensedUntil)}. To continue using the software, contact sales@handsontable.com to purchase a valid license key.` +} + +/** + * The console message for each entitlement-key lifecycle state that talks to the developer: the + * specification's text (as the vendored reader's README carries it), the same + * table Handsontable prints (`handsontable/src/helpers/mixed.ts`, `entitlementConsoleNotifications`), + * so one key reads the same in both products. Silent states (inside the term, a build covered by its + * maintenance date) have no entry. A non-trial key past its grace keeps the soft-stop message: it + * never blocks a paying customer. + */ +const ENTITLEMENT_CONSOLE_NOTIFICATIONS: Partial>> = { + trial_notice: { + severity: 'warn', + // A trial in its notice window is always a `usage_until` key, so the reader counted its days. + message: ({daysRemaining}) => `Your HyperFormula license key ${expiryClause(daysRemaining as number)}. ${PURCHASE_LICENSE_TEXT}`, + }, + trial_soft_stop: { + severity: 'error', + message: ({licensedUntil}) => `Your HyperFormula trial license key expired on ${utcDay(licensedUntil)}. ${PURCHASE_LICENSE_TEXT}`, + }, + trial_hard_stop: { + severity: 'error', + message: ({licensedUntil}) => `Your HyperFormula trial license key expired on ${utcDay(licensedUntil)}. You may no longer use HyperFormula under the trial license. To continue using the software, contact sales@handsontable.com to purchase a valid license.`, + }, + usage_notice: { + severity: 'warn', + message: ({licensedUntil}) => `Your HyperFormula subscription license expires on ${utcDay(licensedUntil)}. To renew your license, contact sales@handsontable.com.`, + }, + usage_soft_stop: {severity: 'error', message: subscriptionExpiredMessage}, + usage_hard_stop: {severity: 'error', message: subscriptionExpiredMessage}, + release_expired: { + severity: 'error', + message: ({licensedUntil}) => `The license key for HyperFormula expired on ${licensedUntil}, and is not valid for the installed version ${process.env.HT_VERSION as string}. Renew your license key or downgrade to a version released on or before ${licensedUntil}. If you need any help, contact us at sales@handsontable.com.`, + }, +} + +/** + * The console message for each reason an entitlement key does not license HyperFormula. Both are + * errors: neither key evaluates formulas. + */ +const UNLICENSED_CONSOLE_NOTIFICATIONS: Record> = { + unreadable: { + severity: 'error', + message: () => 'The license key for HyperFormula is invalid. If you need any help, contact us at support@handsontable.com.', + }, + product_missing: { + severity: 'error', + message: () => 'The license key does not include a license for HyperFormula. To purchase one, contact sales@handsontable.com.', + }, +} + +/** + * Clears the once-per-page-load flag {@link notifyLicenseKeyState} keeps. + * + * Exists for tests only. The flag is module-level and never otherwise reset, so without this the + * classic-key message path is unobservable: the first spec to build any engine consumes the + * message and every later assertion sees silence regardless of what the code does. Under Karma + * every spec shares one browser context, so spec-order tricks do not work there at all. + * + * @internal + */ +export function resetLicenseKeyNotificationForTests(): void { + _notified = false +} + +/** + * Prints the console message for a classic 25-character key's non-valid state, at most once per + * page load. Entitlement keys do not go through this function. + * + * @param {LicenseKeyValidityState} state - the state to report; `VALID` prints nothing + * @param {Date} [keyValidityDate] - the day the key stopped being valid, used by the `expired` message + */ +export function notifyLicenseKeyState(state: LicenseKeyValidityState, keyValidityDate?: Date): void { + if (_notified || state === LicenseKeyValidityState.VALID) { + return + } + + const vars: TemplateVars = keyValidityDate === undefined ? {} : {keyValidityDate: formatDate(keyValidityDate)} + + console.warn(consoleMessages[state](vars)) + _notified = true +} + +/** + * Prints the console message for an entitlement key's lifecycle state, every time a key is + * resolved: unlike classic keys, entitlement keys keep no record of what they already printed. + * States inside the term print nothing. + * + * @param {LicenseState} state - the reader's lifecycle state + * @param {EntitlementMessageParams} params - the key's own date and days remaining + */ +export function notifyEntitlementKey(state: LicenseState, params: EntitlementMessageParams): void { + const notification = ENTITLEMENT_CONSOLE_NOTIFICATIONS[state] + + if (notification !== undefined) { + printNotification(notification.severity, notification.message(params)) + } +} + +/** + * Prints the console message for an entitlement key that does not license HyperFormula, every + * time such a key is resolved. + * + * @param {UnlicensedReason} reason - why the reader does not license HyperFormula with the key + */ +export function notifyUnlicensedEntitlementKey(reason: UnlicensedReason): void { + const notification = UNLICENSED_CONSOLE_NOTIFICATIONS[reason] + + printNotification(notification.severity, notification.message()) +} + +/** + * Prints `text` on the console channel that matches `severity`. + * + * @param {'warn' | 'error'} severity - `warn` while the license still works, `error` once it does not + * @param {string} text - the message + */ +function printNotification(severity: 'warn' | 'error', text: string): void { + if (severity === 'error') { + console.error(text) + } else { + console.warn(text) + } +} + /** * Checks if the provided license key is grammatically valid or not expired. * @@ -51,7 +225,6 @@ let _notified = false export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityState { const messageDescriptor: MessageDescriptor = { template: LicenseKeyValidityState.MISSING, - vars: {}, } if (licenseKey === 'gpl-v3' || licenseKey === 'internal-use-in-handsontable' || licenseKey === 'hftrial-0168e-1f2b7-47158-70b05-0842f') { @@ -59,10 +232,11 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS } else if (typeof licenseKey === 'string' && checkKeySchema(licenseKey)) { const [day, month, year] = (process.env.HT_RELEASE_DATE || '').split('/') - const releaseDays = Math.floor(new Date(`${month}/${day}/${year}`).getTime() / 8.64e7) + // UTC, not `new Date('MM/DD/YYYY')`: local parsing puts the release day one day early east of UTC. + const releaseDays = Math.floor(Date.UTC(Number(year), Number(month) - 1, Number(day)) / 8.64e7) const keyValidityDays = extractTime(licenseKey) - messageDescriptor.vars.keyValidityDate = formatDate(new Date((keyValidityDays + 1) * 8.64e7)) + messageDescriptor.expiryDate = new Date((keyValidityDays + 1) * 8.64e7) if (releaseDays > keyValidityDays) { messageDescriptor.template = LicenseKeyValidityState.EXPIRED @@ -74,10 +248,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS messageDescriptor.template = LicenseKeyValidityState.INVALID } - if (!_notified && messageDescriptor.template !== LicenseKeyValidityState.VALID) { - console.warn(consoleMessages[messageDescriptor.template](messageDescriptor.vars)) - _notified = true - } + notifyLicenseKeyState(messageDescriptor.template, messageDescriptor.expiryDate) return messageDescriptor.template } @@ -85,16 +256,21 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS /** * Formats a Date instance to hard-coded format MMMM DD, YYYY. * - * @param {Date} date The date to format. - * @returns {string} + * Read in UTC, not local time. Every date reaching this function is built at UTC midnight — the + * legacy path from a whole number of days since the epoch, the entitlement-key path from a calendar + * date in the payload — so local getters shifted the day backwards for anyone west of UTC and + * printed an expiry one day earlier than the one the key actually carries. + * + * @param {Date} date The date to format, at UTC midnight. + * @returns {string} The date as `MMMM DD, YYYY`. */ function formatDate(date: Date): string { const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ] - const month = monthNames[date.getMonth()] - const day = date.getDate() - const year = date.getFullYear() + const month = monthNames[date.getUTCMonth()] + const day = date.getUTCDate() + const year = date.getUTCFullYear() return `${month} ${day}, ${year}` } diff --git a/src/index.ts b/src/index.ts index 23b060cf49..f48715c9f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { InvalidArgumentsError, LanguageAlreadyRegisteredError, LanguageNotRegisteredError, + LicenseCapabilityMissingError, MissingTranslationError, NamedExpressionDoesNotExistError, NamedExpressionNameIsAlreadyTakenError, @@ -51,6 +52,7 @@ import enGB from './i18n/languages/enGB' import {FunctionArgument, FunctionPlugin, FunctionPluginDefinition, FunctionArgumentType, ImplementedFunctions, FunctionMetadata, EmptyValue} from './interpreter' import {FunctionCategory, FunctionDetails, FunctionListEntry, FunctionParameterDescription} from './interpreter/functionMetadata/FunctionDescription' import {FormatInfo} from './interpreter/InterpreterValue' +import {FeatureId} from './license/LicenseEntitlement' import * as plugins from './interpreter/plugin' import {SimpleRangeValue} from './SimpleRangeValue' import {NamedExpression, NamedExpressionOptions} from './NamedExpressions' @@ -64,6 +66,7 @@ import {ConfigParams} from './ConfigParams' class HyperFormulaNS extends HyperFormula { public static HyperFormula = HyperFormula public static ErrorType = ErrorType + public static FeatureId = FeatureId public static CellError = CellError public static CellType = CellType public static CellValueType = CellValueType @@ -86,6 +89,7 @@ class HyperFormulaNS extends HyperFormula { public static InvalidArgumentsError = InvalidArgumentsError public static LanguageNotRegisteredError = LanguageNotRegisteredError public static LanguageAlreadyRegisteredError = LanguageAlreadyRegisteredError + public static LicenseCapabilityMissingError = LicenseCapabilityMissingError public static MissingTranslationError = MissingTranslationError public static NamedExpressionDoesNotExistError = NamedExpressionDoesNotExistError public static NamedExpressionNameIsAlreadyTakenError = NamedExpressionNameIsAlreadyTakenError @@ -150,6 +154,7 @@ export { CellValueType, CellValueDetailedType, ErrorType, + FeatureId, ExportedCellChange, ExportedNamedExpressionChange, DetailedCellError, @@ -169,6 +174,7 @@ export { InvalidArgumentsError, LanguageAlreadyRegisteredError, LanguageNotRegisteredError, + LicenseCapabilityMissingError, MissingTranslationError, NamedExpressionDoesNotExistError, NamedExpressionNameIsAlreadyTakenError, diff --git a/src/interpreter/FunctionRegistry.ts b/src/interpreter/FunctionRegistry.ts index 5038fec654..4a38ed70a1 100644 --- a/src/interpreter/FunctionRegistry.ts +++ b/src/interpreter/FunctionRegistry.ts @@ -233,6 +233,18 @@ export class FunctionRegistry { return this.instancePlugins.get(functionId) } + /** + * Returns the id a function is registered as an alias of (`plugin.aliases`), or the id itself + * when it is not an alias. The license capability table lists canonical names only, so both the + * interpreter and the function metadata API resolve an id through this before consulting it: + * calling a gated function through its alias must be gated exactly like calling it by name. + * + * @param {string} functionId - the id as registered, which may be an alias + */ + public getCanonicalFunctionId(functionId: string): string { + return this.getFunctionPlugin(functionId)?.aliases?.[functionId] ?? functionId + } + /** * Returns the ids of all functions the function-metadata API (`getAvailableFunctions`/`getFunctionDetails`) * should describe: every function registered in this instance (aliases and any custom/user-registered functions diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index 8edf08f0a9..6444332537 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -12,7 +12,7 @@ import {DateTimeHelper} from '../DateTimeHelper' import {DependencyGraph} from '../DependencyGraph' import {FormulaVertex} from '../DependencyGraph/FormulaVertex' import {ErrorMessage} from '../error-message' -import {LicenseKeyValidityState} from '../helpers/licenseKeyValidator' +import {FunctionCallLicenseGate} from '../license/FunctionCallLicenseGate' import {ColumnSearchStrategy} from '../Lookup/SearchStrategy' import {Maybe} from '../Maybe' import {NamedExpressions} from '../NamedExpressions' @@ -45,6 +45,9 @@ import { AddressWithSheet } from '../parser/Address' export class Interpreter { public readonly criterionBuilder: CriterionBuilder + /** Decides which function calls the license stops; see {@link FunctionCallLicenseGate}. */ + private readonly functionCallLicenseGate: FunctionCallLicenseGate + constructor( public readonly config: Config, public readonly dependencyGraph: DependencyGraph, @@ -59,6 +62,7 @@ export class Interpreter { ) { this.functionRegistry.initializePlugins(this) this.criterionBuilder = new CriterionBuilder(config) + this.functionCallLicenseGate = new FunctionCallLicenseGate(config, functionRegistry) } public evaluateAst(ast: Ast, state: InterpreterState): InterpreterValue { @@ -177,8 +181,9 @@ export class Interpreter { return this.unaryRangeWrapper(this.percentOp, result, state) } case AstNodeType.FUNCTION_CALL: { - if (this.config.licenseKeyValidityState !== LicenseKeyValidityState.VALID && !FunctionRegistry.functionIsProtected(ast.procedureName)) { - return new CellError(ErrorType.LIC, ErrorMessage.LicenseKey(this.config.licenseKeyValidityState)) + const licenseError = this.functionCallLicenseGate.stoppedCallError(ast.procedureName) + if (licenseError !== undefined) { + return licenseError } const pluginFunction = this.functionRegistry.getFunction(ast.procedureName) if (pluginFunction !== undefined) { diff --git a/src/interpreter/functionMetadata/categories/information.ts b/src/interpreter/functionMetadata/categories/information.ts index 56fb78adba..fd18cd79b6 100644 --- a/src/interpreter/functionMetadata/categories/information.ts +++ b/src/interpreter/functionMetadata/categories/information.ts @@ -135,7 +135,7 @@ export const INFORMATION_DOCS: Record = { // repeatLastArgs) is authored separately in `PROTECTED_FUNCTION_METADATA`. VERSION: { category: 'Information', - shortDescription: 'Returns the HyperFormula version and the license key status as a single text value, e.g. "HyperFormula v3.0.0, 1" (a status code, or the last five characters of the license key).', + shortDescription: 'Returns the HyperFormula version as a text value, e.g. "HyperFormula v3.0.0".', parameters: [], documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=VERSION()'], diff --git a/src/interpreter/plugin/VersionPlugin.ts b/src/interpreter/plugin/VersionPlugin.ts index 2a9275a354..da65919a20 100644 --- a/src/interpreter/plugin/VersionPlugin.ts +++ b/src/interpreter/plugin/VersionPlugin.ts @@ -3,20 +3,12 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {LicenseKeyValidityState} from '../../helpers/licenseKeyValidator' import {HyperFormula} from '../../HyperFormula' import {ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' import {InterpreterValue} from '../InterpreterValue' import {FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' -const LICENSE_STATUS_MAP = new Map([ - ['gpl-v3', 1], - [LicenseKeyValidityState.MISSING, 2], - [LicenseKeyValidityState.INVALID, 3], - [LicenseKeyValidityState.EXPIRED, 4], -]) - export class VersionPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { 'VERSION': { @@ -27,21 +19,7 @@ export class VersionPlugin extends FunctionPlugin implements FunctionPluginTypec public version(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('VERSION'), () => { - const { - licenseKeyValidityState: validityState, - licenseKey, - } = this.config - let status - - if (LICENSE_STATUS_MAP.has(licenseKey)) { - status = LICENSE_STATUS_MAP.get(licenseKey) - } else if (LICENSE_STATUS_MAP.has(validityState)) { - status = LICENSE_STATUS_MAP.get(validityState) - } else if (validityState === LicenseKeyValidityState.VALID) { - status = licenseKey.slice(-5) - } - - return `HyperFormula v${HyperFormula.version}, ${status}` + return `HyperFormula v${HyperFormula.version}` }) } } diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts new file mode 100644 index 0000000000..698798adb4 --- /dev/null +++ b/src/license/CapabilityRegistry.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {FeatureId, LicenseEntitlement} from './LicenseEntitlement' +import {CAPABILITY_TABLE, CapabilityGrant, normalizeCapabilityToken} from './capabilities' + +/** + * The capabilities a resolved {@link LicenseEntitlement} grants, ready for gate B (the + * interpreter) and `ensureCapability` to query through {@link allowsFunction} and + * {@link allowsFeature}. + */ +export interface ResolvedCapabilities { + /** + * `'all'` short-circuits {@link allowsFunction} to `true` for every function id, independently + * of {@link features}: a key can cover every function without covering every feature, or the + * other way round. A single `unrestricted: boolean` could not express that combination — it + * could only grant both axes together or neither. + */ + functions: ReadonlySet | 'all', + /** `'all'` short-circuits {@link allowsFeature} to `true` for every {@link FeatureId}. */ + features: ReadonlySet | 'all', +} + +/** + * Expands a {@link LicenseEntitlement}'s capability tokens against a table of + * {@link CapabilityGrant}s into the concrete functions and features they grant, and answers + * which token, if any, covers a given function id. + */ +export class CapabilityRegistry { + private readonly table: ReadonlyMap + private readonly reverseIndex: ReadonlyMap + + /** + * @param {ReadonlyMap} [table] - capability table to resolve + * against. Omit to use the production {@link CAPABILITY_TABLE}; tests inject their own so the + * suite does not depend on its placeholder content. + */ + constructor(table?: ReadonlyMap) { + this.table = table ?? CAPABILITY_TABLE + this.reverseIndex = CapabilityRegistry.buildReverseIndex(this.table) + } + + /** + * Inverts a capability table from token → grant into function id → token, so + * {@link capabilityOf} is a single lookup instead of a scan. The first token that lists a + * given function id wins, in table iteration order. + * + * @param {ReadonlyMap} table - the table to invert + */ + private static buildReverseIndex(table: ReadonlyMap): ReadonlyMap { + const index = new Map() + for (const [token, grant] of table) { + for (const functionId of grant.functions) { + if (!index.has(functionId)) { + index.set(functionId, token) + } + } + } + return index + } + + /** + * Expands an entitlement's capability tokens into the concrete functions and features they + * grant. An `unrestricted` entitlement short-circuits to `'all'` on BOTH axes without + * consulting the table at all. Tokens are matched case-insensitively (the table is keyed by + * the normalized spelling — see {@link normalizeCapabilityToken}). Every grant stands on its + * own — a token never refers to another — so this is a flat pass over the entitlement's own + * tokens; an unrecognized token is skipped without an error, and a repeated one adds nothing. + * + * Setting `'all'` on both axes here, in the same object literal, is deliberate: this is the + * only place `entitlement.unrestricted` is read, so a future edit that touches one axis and + * not the other has nowhere else to be caught except the per-axis fail-open tests in + * `unit/license/capability-registry.spec.ts`. The axis a change forgets fails CLOSED, not + * open — silently turning a working gpl-v3/legacy install into a partial denial — which is why + * both are pinned separately rather than with one combined assertion. + * + * @param {LicenseEntitlement} entitlement - the entitlement to resolve, e.g. one built by + * hand in a test or produced by the license-key payload adapter + */ + public resolve(entitlement: LicenseEntitlement): ResolvedCapabilities { + if (entitlement.unrestricted) { + return {functions: 'all', features: 'all'} + } + + const functions = new Set() + const features = new Set() + // A key may carry a great many tokens - the format sets no size limit - and a repeated one + // grants nothing new, so each distinct spelling is expanded once. Expansion itself is a single + // pass: no grant refers to another, so there is nothing to walk. + const visited = new Set() + + for (const rawToken of entitlement.capabilities) { + const token = normalizeCapabilityToken(rawToken) + if (visited.has(token)) { + continue + } + visited.add(token) + + const grant = this.table.get(token) + if (grant === undefined) { + continue + } + grant.functions.forEach((functionId) => functions.add(functionId)) + grant.features.forEach((feature) => features.add(feature)) + } + + return {functions, features} + } + + /** + * Returns the capability token a function id is covered by, or `undefined` if this registry's + * table does not cover it. The completeness invariant in the paired `hyperformula-tests` suite + * (`unit/license/capability-registry.spec.ts`) guarantees every built-in registered in the + * static function registry is covered by the table or the protected list — + * so `undefined` for a function known to the current instance's function registry means it is + * a custom, instance-registered function rather than an unlisted built-in. + */ + public capabilityOf(functionId: string): string | undefined { + return this.reverseIndex.get(functionId) + } +} + +/** + * Whether a resolved entitlement allows calling the given function. + */ +export function allowsFunction(resolved: ResolvedCapabilities, functionId: string): boolean { + return resolved.functions === 'all' || resolved.functions.has(functionId) +} + +/** + * Whether a resolved entitlement allows using the given feature area of the public API. + */ +export function allowsFeature(resolved: ResolvedCapabilities, feature: FeatureId): boolean { + return resolved.features === 'all' || resolved.features.has(feature) +} + +/** + * Whether the license lets an instance evaluate — and therefore describe — the given function. + * + * The rule both gate-B function call sites share: a function the capability table does not cover + * at all is allowed. {@link CapabilityRegistry.capabilityOf} returns `undefined` only for an id no + * token lists, which the completeness invariant in `unit/license/capability-registry.spec.ts` + * guarantees is not an unlisted built-in but a custom, instance-registered function — exempt from + * gate B, because custom functions are never gated. Everything the table does cover has to be granted by the entitlement. + * + * Extracted so the interpreter and the function metadata API cannot drift apart. The metadata API + * exists to describe the functions an instance can actually evaluate, so a second spelling of this + * rule would eventually let it advertise a function that then returns `#LIC!`. + * + * Note this is gate B only: it says nothing about {@link LicenseKeyValidityState}. Callers that + * also need gate A check it separately, because the two gates have different answers for the same + * key — see the comment on `resolveLicense`. + * + * @param {CapabilityRegistry} registry - the registry the capabilities were resolved against + * @param {ResolvedCapabilities} resolved - the instance's resolved capabilities + * @param {string} canonicalFunctionId - the function id, already resolved through the alias map + */ +export function licenseAllowsFunction( + registry: CapabilityRegistry, + resolved: ResolvedCapabilities, + canonicalFunctionId: string, +): boolean { + return registry.capabilityOf(canonicalFunctionId) === undefined + || allowsFunction(resolved, canonicalFunctionId) +} diff --git a/src/license/FunctionCallLicenseGate.ts b/src/license/FunctionCallLicenseGate.ts new file mode 100644 index 0000000000..a29ae4e053 --- /dev/null +++ b/src/license/FunctionCallLicenseGate.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {CellError, ErrorType} from '../Cell' +import {Config} from '../Config' +import {ErrorMessage} from '../error-message' +import {LicenseKeyValidityState} from '../helpers/licenseKeyValidator' +import {FunctionRegistry} from '../interpreter/FunctionRegistry' +import {CapabilityRegistry, licenseAllowsFunction, ResolvedCapabilities} from './CapabilityRegistry' + +/** + * Decides whether the license stops a function call, and with which `#LIC!` error. + * + * The one rule behind two consumers: the interpreter, which evaluates a stopped call to that + * error, and the array size predictor, which sizes a stopped call as a single cell. A stopped call + * therefore reserves no spill range, so its `#LIC!` appears in its own cell only and a non-empty + * cell below it cannot turn it into `#SPILL!`. Sharing one object keeps the two from disagreeing + * about the same call. + * + * Built from an engine's config, once per consumer. The license decisions are read off the config + * here rather than through its getters (a WeakMap lookup each), because the interpreter asks on the + * hottest path of evaluation. They cannot go stale: a Config never changes once built, and + * `updateConfig` builds a new engine, and with it new consumers. + */ +export class FunctionCallLicenseGate { + private readonly blocksEvaluation: boolean + private readonly validityState: LicenseKeyValidityState + private readonly capabilityRegistry: CapabilityRegistry + private readonly capabilities: ResolvedCapabilities + /** `false` for a key that grants every function, which lets a call skip the alias and table lookups. */ + private readonly restrictsFunctions: boolean + + /** + * @param {Config} config - the engine's config, holding its resolved license + * @param {FunctionRegistry} functionRegistry - the engine's registry, which resolves aliases + */ + constructor(config: Config, private readonly functionRegistry: FunctionRegistry) { + this.blocksEvaluation = config.licenseBlocksEvaluation + this.validityState = config.licenseKeyValidityState + this.capabilityRegistry = config.capabilityRegistry + this.capabilities = config.licenseCapabilities + this.restrictsFunctions = config.licenseCapabilities.functions !== 'all' + } + + /** + * Returns the `#LIC!` error a call to the function evaluates to, or `undefined` when the license + * lets the call run. The checks run in the specification's order: a protected function always + * runs; a key that blocks evaluation stops every other call (C1); a key that evaluates stops a + * call to a function it does not grant (C2), checking an alias as its canonical function. + * + * @param {string} procedureName - the function id as written in the formula + */ + public stoppedCallError(procedureName: string): CellError | undefined { + if (FunctionRegistry.functionIsProtected(procedureName)) { + return undefined + } + + if (this.blocksEvaluation) { + return new CellError(ErrorType.LIC, ErrorMessage.LicenseKey(this.validityState)) + } + + if (this.restrictsFunctions + && !licenseAllowsFunction(this.capabilityRegistry, this.capabilities, this.functionRegistry.getCanonicalFunctionId(procedureName))) { + return new CellError(ErrorType.LIC, ErrorMessage.LicenseCapability(procedureName)) + } + + return undefined + } + + /** + * Whether the license stops a call to the function, so that the call evaluates to `#LIC!`. + * + * @param {string} procedureName - the function id as written in the formula + */ + public stopsCall(procedureName: string): boolean { + return this.stoppedCallError(procedureName) !== undefined + } +} diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts new file mode 100644 index 0000000000..d067943dfd --- /dev/null +++ b/src/license/LicenseEntitlement.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Identifies a feature area of the public API that a license entitlement can gate. + * + * Public, as the type of [[LicenseCapabilityMissingError]]'s `feature`, so that a caller can tell + * which feature a call needed without parsing the error message. + */ +export enum FeatureId { + NamedExpressions = 'named_expressions', + Clipboard = 'clipboard', + Crud = 'crud', + UndoRedo = 'undo_redo', + Batching = 'batching', +} + +/** + * Describes when a license entitlement stops being valid. + * + * `date` is kept as a calendar string rather than an epoch, and is INCLUSIVE of its last valid + * day: + * - `kind === 'usage'`: compared against the current instant in **UTC**, not the client's LOCAL + * calendar date, because the offline check and a future online check have to return the same verdict for the same key at + * the same instant, and any rule that reads a local clock breaks that parity. The practical + * cost is that a customer far west of UTC loses the tail of their last local day. + * - `kind === 'release'`: compared against the library's build date; no clock is involved, which + * is what keeps an air-gapped install with a wrong system clock working. + * - `kind === 'none'`: the entitlement does not expire. + */ +export interface LicenseExpiry { + kind: 'usage' | 'release' | 'none', + /** ISO 'YYYY-MM-DD', or `null` when `kind` is `'none'`. */ + date: string | null, + noticeDays: number, + graceDays: number, +} + +/** + * The resolved set of things a license grants, independent of how the underlying license key + * was parsed. + * + * {@link CapabilityRegistry} turns it into a `ResolvedCapabilities` set, gate B in the + * interpreter reads that set, and `ensureCapability` reads it for the public API. `resolveLicense` builds it from the + * configured key. + */ +export interface LicenseEntitlement { + /** + * `true` for every key that restricts nothing: classic keys, `gpl-v3`, and any key that blocks + * evaluation (a missing or invalid key, an expired classic key, or a trial past its grace + * period). An entitlement key that has expired but keeps evaluating is not one of them: it keeps + * its own grants. + */ + unrestricted: boolean, + /** + * The capability tokens the key carries, spelled as the key spells them, recognized or not. Only + * the ones this library version recognizes grant anything. + */ + capabilities: ReadonlySet, + expiry: LicenseExpiry, + /** + * When `true`, resolving this entitlement must not print a console message of any kind. + * + * Set from the key's own `no-console-warns` flag ONLY, as the vendored reader reads it (its + * `channels.console`). An unrecognized token does NOT + * set it: an unknown token makes the *grant* silent (it grants nothing, and nothing reports it), + * which is a different thing from muting the key's console output. + * Coupling them would suppress expiry notices as a side effect of a vocabulary mismatch. + */ + silent: boolean, + isTrial: boolean, +} + +/** + * The unrestricted entitlement: classic keys, `gpl-v3`, and every key that blocks evaluation (a + * missing or invalid key, an expired classic key, or a trial past its grace period) resolve to + * this. An entitlement key that has expired but keeps evaluating does not: it keeps its own grants. + * + * Unrecognized tokens fail closed and silently, so an entitlement key whose tokens this library + * version does not recognize at all does not map here — it resolves to an entitlement with an empty, + * silent capability set instead of falling back to unrestricted access. Do not reuse this + * function for that case. + */ +export function unrestrictedEntitlement(): LicenseEntitlement { + return { + unrestricted: true, + capabilities: new Set(), + expiry: {kind: 'none', date: null, noticeDays: 0, graceDays: 0}, + silent: false, + isTrial: false, + } +} diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts new file mode 100644 index 0000000000..577699fa73 --- /dev/null +++ b/src/license/capabilities.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {FeatureId} from './LicenseEntitlement' +import {FEATURE_CAPABILITY_TABLE} from './featureCapabilities' +import {FUNCTION_CAPABILITY_TABLE} from './functionCapabilities' + +// The engine reads CAPABILITIES, never packages. A license key carries a list of capability +// tokens and the engine grants the union of what those tokens name; which tokens make up which +// commercial package is decided where keys are minted, not here. In the words of the packaging +// design: "Nothing else about packaging exists at the technical layer." +// +// The vocabulary itself lives in two halves — `./featureCapabilities` and +// `./functionCapabilities` — and this module is where they meet: it owns the grant shape both +// halves are read through, and joins them into the single table the engine reads. That table is +// what consumers want; neither half is worth importing directly unless you need one vocabulary +// without the other. + +/** + * Describes what a capability token grants: a set of function ids and a set of {@link FeatureId} + * values. A grant never refers to another token — every one stands alone, so + * `CapabilityRegistry.resolve` reads the table in a single flat pass. + */ +export interface CapabilityGrant { + functions: string[], + features: FeatureId[], +} + +/** + * The production capability table the engine reads: {@link FEATURE_CAPABILITY_TABLE} and + * {@link FUNCTION_CAPABILITY_TABLE} under one key space, keyed by NORMALIZED token spelling — + * look up through {@link normalizeCapabilityToken}, never with a raw key string. + * + * The two halves share no token (one vocabulary is prefixed `feat:`, the other `fun:`), so the + * merge cannot lose an entry to a collision. Features come first so that the function half keeps + * its own iteration order, which is what decides the winner in + * `CapabilityRegistry`'s reverse index: `fun:all` covers every gatable function and therefore + * names every one of them there. + * + * Both halves are copied into fresh {@link CapabilityGrant} objects rather than referenced, so + * that a consumer holding a grant cannot reach back into a sub-table's arrays. + * + * No token here names a package, and no grant refers to another token. Which tokens a commercial + * package consists of is the generator's knowledge, expressed by the bigger license simply + * listing more tokens — so a key's function set is the union of everything it names that this + * table recognizes, and an unrecognized token is inert. + * Legacy keys resolve to the unrestricted entitlement and never consult this table at all. + * + * There is no entry for any add-on. An add-on is a commercial wrapper, and which capabilities it + * bundles is decided where keys are minted; the engine only ever reads the capabilities the key + * actually names. That is what lets pricing rename or re-bundle an add-on without a release here. + */ +export const CAPABILITY_TABLE: ReadonlyMap = new Map([ + ...Array.from(FEATURE_CAPABILITY_TABLE, ([token, features]): [string, CapabilityGrant] => [ + token, + {functions: [], features: [...features]}, + ]), + ...Array.from(FUNCTION_CAPABILITY_TABLE, ([token, functions]): [string, CapabilityGrant] => [ + token, + {functions: [...functions], features: []}, + ]), +]) + +/** + * The canonical spelling of a capability token for table lookups. + * + * Token names are case-insensitive — the packaging doc states it outright for its `fun:*` + * vocabulary, and tolerating case on the other tokens costs nothing since none of them collide + * under lowercasing. Surrounding whitespace is trimmed because a key's token list is text a human + * edited somewhere upstream: `'feat:crud '` is the token its author meant, and a padded spelling + * that silently grants nothing is a support ticket, not a license restriction. + * + * Normalization happens at LOOKUP, never at storage: an entitlement carries the key's own + * spellings (they are diagnostics), and {@link CAPABILITY_TABLE} is keyed by the normalized form. + * + * @param {string} token - a capability token as the key spells it + */ +export function normalizeCapabilityToken(token: string): string { + return token.trim().toLowerCase() +} diff --git a/src/license/ensureFeatureAllowed.ts b/src/license/ensureFeatureAllowed.ts new file mode 100644 index 0000000000..f03e9001b9 --- /dev/null +++ b/src/license/ensureFeatureAllowed.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {Config} from '../Config' +import {LicenseCapabilityMissingError} from '../errors' +import {allowsFeature} from './CapabilityRegistry' +import {FeatureId} from './LicenseEntitlement' + +/** + * Whether the license lets the caller use `feature`. Checks both gates, in the same order the + * interpreter does for functions: + * - gate A first: a key whose state blocks evaluation (a missing or invalid key, an expired classic + * key, or a trial past its grace period) blocks every gated feature, whatever the entitlement says; + * - then gate B: a key that evaluates must grant `feature`. + * + * The one rule behind {@link ensureFeatureAllowed}, the `isItPossibleTo*` predicates and + * `isThereSomethingToUndo`/`isThereSomethingToRedo`, so a predicate never answers `true` for a call + * that then throws a license error. + * + * @param {Config} config - the config whose resolved license is checked + * @param {FeatureId} feature - the gated feature being asked about + */ +export function isFeatureAllowed(config: Config, feature: FeatureId): boolean { + return !config.licenseBlocksEvaluation && allowsFeature(config.licenseCapabilities, feature) +} + +/** + * Throws {@link LicenseCapabilityMissingError} unless {@link isFeatureAllowed}. When the key itself + * blocks evaluation, the error names the key's state. + * + * Shared by the build-time named-expressions check and `HyperFormula.ensureCapability`, so the two + * cannot disagree about the same key. + * + * @param {Config} config - the config whose resolved license is checked + * @param {FeatureId} feature - the gated feature being called + */ +export function ensureFeatureAllowed(config: Config, feature: FeatureId): void { + if (isFeatureAllowed(config, feature)) { + return + } + throw new LicenseCapabilityMissingError(feature, config.licenseBlocksEvaluation ? config.licenseKeyValidityState : undefined) +} diff --git a/src/license/featureCapabilities.ts b/src/license/featureCapabilities.ts new file mode 100644 index 0000000000..73fa5d5c96 --- /dev/null +++ b/src/license/featureCapabilities.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {FeatureId} from './LicenseEntitlement' + +/** + * One entry per single-area feature token. `feat:all` is derived from this list rather than + * spelled out beside it, so a new gated area reaches it by being added here and nowhere else. + */ +const singleFeatureEntries: [string, readonly FeatureId[]][] = [ + ['feat:crud', [FeatureId.Crud]], + ['feat:undo_redo', [FeatureId.UndoRedo]], + ['feat:clipboard', [FeatureId.Clipboard]], + ['feat:named_expressions', [FeatureId.NamedExpressions]], + ['feat:batching', [FeatureId.Batching]], +] + +/** Every gated API area: what `feat:all` grants. */ +const ALL_FEATURES = singleFeatureEntries.reduce( + (features, [, granted]) => features.concat(granted), + [], +) + +/** + * The `feat:*` half of the vocabulary, keyed by NORMALIZED token spelling: one token per gated + * area of the public API, plus `feat:all` for all of them at once. + * + * Kept apart from `FUNCTION_CAPABILITY_TABLE` because the two halves are maintained by different + * forces. This one grows when a public API area becomes gated — an engine decision, one entry + * hand-written per area — while the function half is a transcription of the packaging document's + * group membership. `CAPABILITY_TABLE` in `./capabilities` merges them for the consumers. + */ +export const FEATURE_CAPABILITY_TABLE: ReadonlyMap = new Map([ + ...singleFeatureEntries, + ['feat:all', ALL_FEATURES] as [string, readonly FeatureId[]], +]) diff --git a/src/license/functionCapabilities.ts b/src/license/functionCapabilities.ts new file mode 100644 index 0000000000..a26733ec5f --- /dev/null +++ b/src/license/functionCapabilities.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * The 21 function groups of the packaging doc, keyed by their group tokens in normalized + * (lowercase) spelling — the doc writes them `fun:.` and declares all token names + * case-insensitive. + * + * Transcribed 1:1 from section 6 of the internal packaging design document ("HF function groups + * and packages"), so that a re-transcription is a reviewable diff against the doc's published + * counts. The table below is the only thing production code reads it through. + * + * `fun:info.a` and `fun:lookup.a` name nothing but the two protected built-ins, `VERSION` and + * `OFFSET` (see `FunctionRegistry._protectedPlugins`). The doc calls that a "technical + * limitation" on both: the interpreter never gate-checks a protected function, so those two + * evaluate under every key no matter which tokens name them, and the groups that carry them are + * bookkeeping identifiers for functionality every key already has. + * + * The doc freezes group names as API surface: once shipped inside license keys, a rename is a + * breaking change. + */ +const FUNCTION_GROUPS: ReadonlyMap = new Map([ + ['fun:math.a', ['ABS', 'LOG', 'MOD', 'POWER', 'PRODUCT', 'ROUND', 'ROUNDDOWN', 'ROUNDUP', 'SQRT', 'SUM']], + ['fun:stat.a', ['AVERAGE', 'COUNT', 'MAX', 'MIN']], + ['fun:logic.a', ['IF']], + ['fun:operator.a', ['HF.ADD', 'HF.CONCAT', 'HF.DIVIDE', 'HF.EQ', 'HF.GT', 'HF.GTE', 'HF.LT', 'HF.LTE', 'HF.MINUS', + 'HF.MULTIPLY', 'HF.NE', 'HF.POW', 'HF.UMINUS', 'HF.UNARY_PERCENT', 'HF.UPLUS']], + ['fun:info.a', ['VERSION']], + ['fun:lookup.a', ['OFFSET']], + ['fun:time.b', [ + 'DATE', 'DATEDIF', 'DATEVALUE', 'DAY', 'DAYS', 'EOMONTH', 'HOUR', 'ISOWEEKNUM', 'MINUTE', 'MONTH', + 'NETWORKDAYS', 'SECOND', 'TODAY', 'WEEKDAY', 'WEEKNUM', 'WORKDAY', 'YEAR', + ]], + ['fun:text.b', [ + 'CONCATENATE', 'EXACT', 'LEFT', 'LEN', 'LOWER', 'MID', 'REPLACE', 'REPT', 'RIGHT', 'SEARCH', + 'SUBSTITUTE', 'TEXT', 'TRIM', 'UPPER', 'VALUE', + ]], + ['fun:logic.b', ['AND', 'FALSE', 'IFS', 'NOT', 'OR', 'SWITCH', 'TRUE', 'XOR']], + ['fun:math.b', ['RAND', 'RANDBETWEEN', 'SUMIF', 'SUMIFS']], + ['fun:stat.b', ['AVERAGEIF', 'COUNTIF', 'STDEV.S']], + ['fun:lookup.c', [ + 'ADDRESS', 'CHOOSE', 'COLUMN', 'COLUMNS', 'FILTER', 'HLOOKUP', 'HSTACK', 'HYPERLINK', 'INDEX', 'MATCH', + 'ROW', 'ROWS', 'SORT', 'TRANSPOSE', 'UNIQUE', 'VLOOKUP', 'VSTACK', 'XLOOKUP', + ]], + ['fun:math.c', [ + 'ACOS', 'ASIN', 'ATAN', 'ATAN2', 'CEILING', 'COS', 'EVEN', 'EXP', 'FLOOR', 'INT', 'LN', 'MROUND', 'ODD', + 'PI', 'QUOTIENT', 'SEQUENCE', 'SIGN', 'SIN', 'SUBTOTAL', 'SUMPRODUCT', 'SUMSQ', 'SUMXMY2', 'TAN', + ]], + ['fun:stat.c', [ + 'AVERAGEA', 'COUNTA', 'COUNTBLANK', 'COUNTIFS', 'LARGE', 'MAXIFS', 'MEDIAN', 'MINIFS', 'PERCENTILE.INC', + 'SMALL', 'STDEV.P', 'STDEVA', 'STDEVPA', 'VAR.P', 'VAR.S', + ]], + ['fun:time.c', ['DAYS360', 'EDATE', 'NOW', 'TIME', 'YEARFRAC']], + ['fun:text.c', ['CHAR', 'CLEAN', 'CODE', 'FIND', 'PROPER', 'T', 'TEXTJOIN', 'UNICHAR']], + ['fun:info.c', [ + 'ISBLANK', 'ISERR', 'ISERROR', 'ISEVEN', 'ISLOGICAL', 'ISNA', 'ISNUMBER', 'ISODD', 'ISTEXT', 'N', 'NA', + ]], + ['fun:logic.c', ['IFERROR', 'IFNA']], + ['fun:finance.c', ['FV', 'IPMT', 'IRR', 'NPV', 'PMT', 'PPMT', 'PV', 'RATE', 'SLN', 'XIRR', 'XNPV']], + ['fun:engineer.c', ['DEC2HEX', 'HEX2DEC']], + ['fun:array.c', ['ARRAYFORMULA', 'ARRAY_CONSTRAIN']], +]) + +/** + * The implemented functions no group names — the packaging design's niche tail, reachable only + * through `fun:all` or their own single-function token. + * + * Enumerated rather than taken from the function registry at run time, even though "everything + * not in a group" would be the shorter way to say it. Reading the registry would sweep in + * functions registered through `HyperFormula.registerFunctionPlugin`, putting a user's OWN custom + * function under a license token and returning `#LIC!` for it, while custom functions must never + * be gated. A function this table does not list is not + * gated at all, which is exactly the treatment a custom function should get. + */ +const UNGROUPED_FUNCTIONS = [ + 'ACOSH', 'ACOT', 'ACOTH', 'ARABIC', 'ASINH', 'ATANH', 'AVEDEV', 'BASE', 'BESSELI', 'BESSELJ', 'BESSELK', + 'BESSELY', 'BETA.DIST', 'BETA.INV', 'BIN2DEC', 'BIN2HEX', 'BIN2OCT', 'BINOM.DIST', 'BINOM.INV', 'BITAND', + 'BITLSHIFT', 'BITOR', 'BITRSHIFT', 'BITXOR', 'CEILING.MATH', 'CEILING.PRECISE', 'CHISQ.DIST', + 'CHISQ.DIST.RT', 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'COMBIN', 'COMBINA', 'COMPLEX', + 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COVARIANCE.P', + 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', 'DCOUNT', 'DCOUNTA', 'DDB', + 'DEC2BIN', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', 'DGET', 'DMAX', 'DMIN', 'DOLLARDE', + 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', + 'EXPON.DIST', 'F.DIST', 'F.DIST.RT', 'F.INV', 'F.INV.RT', 'F.TEST', 'FACT', 'FACTDOUBLE', 'FISHER', + 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FORMULATEXT', 'FVSCHEDULE', 'GAMMA', 'GAMMA.DIST', + 'GAMMA.INV', 'GAMMALN', 'GAUSS', 'GCD', 'GEOMEAN', 'HARMEAN', 'HEX2BIN', 'HEX2OCT', 'HYPGEOM.DIST', + 'IMABS', 'IMAGINARY', 'IMARGUMENT', 'IMCONJUGATE', 'IMCOS', 'IMCOSH', 'IMCOT', 'IMCSC', 'IMCSCH', 'IMDIV', + 'IMEXP', 'IMLN', 'IMLOG10', 'IMLOG2', 'IMPOWER', 'IMPRODUCT', 'IMREAL', 'IMSEC', 'IMSECH', 'IMSIN', + 'IMSINH', 'IMSQRT', 'IMSUB', 'IMSUM', 'IMTAN', 'INTERVAL', 'ISBINARY', 'ISFORMULA', 'ISNONTEXT', 'ISPMT', + 'ISREF', 'LCM', 'LOG10', 'LOGNORM.DIST', 'LOGNORM.INV', 'MAXA', 'MAXPOOL', 'MEDIANPOOL', 'MINA', 'MIRR', + 'MMULT', 'MULTINOMIAL', 'NEGBINOM.DIST', 'NETWORKDAYS.INTL', 'NOMINAL', 'NORM.DIST', 'NORM.INV', + 'NORM.S.DIST', 'NORM.S.INV', 'NPER', 'OCT2BIN', 'OCT2DEC', 'OCT2HEX', 'PDURATION', 'PERCENTILE.EXC', + 'PHI', 'POISSON.DIST', 'QUARTILE.EXC', 'QUARTILE.INC', 'RADIANS', 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', + 'SERIESSUM', 'SHEET', 'SHEETS', 'SINH', 'SKEW', 'SKEW.P', 'SLOPE', 'SPLIT', 'SQRTPI', 'STANDARDIZE', + 'STEYX', 'SUMX2MY2', 'SUMX2PY2', 'SYD', 'T.DIST', 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', + 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'UNICODE', 'VARA', 'VARPA', + 'WEIBULL.DIST', 'WORKDAY.INTL', 'Z.TEST', +] + +/** + * The whole catalog: what `fun:all` grants. + * + * Flattened by `reduce` rather than `Array.prototype.flat`, which is ES2019 and so sits above the + * `lib` ceiling this package compiles against. + */ +const ALL_FUNCTIONS = Array.from(FUNCTION_GROUPS.values()) + .reduce((functions, members) => functions.concat(members), []) + .concat(UNGROUPED_FUNCTIONS) + +/** + * One table entry per canonical function name: the packaging doc's single-function tokens + * (`fun:`), "for surgical grants: custom deals, previews, per-function + * exceptions". One exists for EVERY canonical name — the operator callable forms and the + * protected built-ins included. Alias names get no token of their own: tokens reference canonical + * names, and an alias travels with its canonical function because the gates canonicalize before + * consulting the table. + */ +const singleFunctionEntries: [string, readonly string[]][] = ALL_FUNCTIONS.map((name) => [ + `fun:${name.trim().toLowerCase()}`, + [name], +]) + +/** + * The `fun:*` half of the vocabulary, keyed by NORMALIZED token spelling, as the packaging design + * defines it: `fun:all`, the group tokens `fun:.`, and one + * `fun:` per canonical function. + * + * Every grant is STATIC. Nothing here is derived from the function registry at run time, so a + * function registered by a user through `HyperFormula.registerFunctionPlugin` can never be gated + * — see the note on `UNGROUPED_FUNCTIONS`. The cost is that a newly implemented built-in is + * ungated until it is added here, which the completeness invariant in + * `unit/license/capability-registry.spec.ts` fails on. + */ +export const FUNCTION_CAPABILITY_TABLE: ReadonlyMap = new Map([ + ['fun:all', ALL_FUNCTIONS] as [string, readonly string[]], + ...FUNCTION_GROUPS, + ...singleFunctionEntries, +]) diff --git a/src/license/handsontable-license-key-parser/AGENTS.md b/src/license/handsontable-license-key-parser/AGENTS.md new file mode 100644 index 0000000000..dc81b54a53 --- /dev/null +++ b/src/license/handsontable-license-key-parser/AGENTS.md @@ -0,0 +1,21 @@ +# Entitlement key reader - rules for coding agents + +`README.md` here is the full integration guide; read it before changing anything that calls this code. Which repository you are in decides the first rule: + +- **In `handsontable/license-key`** (this directory is `vendor/entitlement-key-reader/` next to `src/`), this is the **original**. Edit it together with `src/entitlement-key/` - a change to the key format is a change to both, in the same PR (see the root `CLAUDE.md`). Keep the tests in `test/entitlement-key-reader/` passing and extend them for anything new. +- **Anywhere else** (a product such as Handsontable or HyperFormula), this directory is a **copy**. **Do not edit files in it.** Fix the original in `license-key` and copy the directory again. A local change makes two products disagree about the same key, and CI drift checks will fail. + +In both places: + +- **Every import stays inside this directory.** No npm dependencies, and no `crypto`, `Buffer`, `TextEncoder`, `crypto.subtle` or DOM APIs - the reader must run on plain `http://` pages and in any bundler. +- **Product-specific code lives outside this directory**: the product name, the build date, the literal keys, the messages and the capability-token gates. +- **Call `readEntitlementLicense(key, { product, buildDate })`** for a key that `detectLicenseKeyFormat` reports as `'entitlement'`. Legacy and literal keys are the product's own path. +- **Never convert a license date through `Date`.** Print `lifecycle.licensedUntil` as it is. Pass the build date as bare `YYYY-MM-DD` text (`toIsoBuildDate` converts `DD/MM/YYYY`). +- **Tell users to keep the key on one line** (in `.env`, CI secrets, YAML or code). The reader ignores whitespace and a line break saved as `\n`, but a shell or `.env` value can be cut short by a quote inside the key. The README's "Where users keep the key" table lists what works. +- **Pass the whole key, and do not "repair" it.** A version 2 key covers the prose too (a digest in the payload), so never cut a key down to its `[...]` block or strip characters from it. Trimming the whole key is fine. A version 1 key (no `v`) never covered its prose - do not treat a readable key as proof its sentences are unedited; check `version`. +- **Every format version keeps the payload checksum and the prose digest.** That is what lets a product that only knows an older version read a newer key. In `license-key`, a change that bumps `v` may add fields, never change those two - a change that must is a new format, not a new version. +- **An unlicensed key unlocks everything** (`UNRESTRICTED_GRANTS`). Never gate features away from a key that failed to read. +- **`no-ui-warns` silences UI warnings, not the trial hard-stop block.** +- **Everything the reader returns is frozen** and typed read-only. Copy an array before sorting or changing it. +- **A build date in the wrong format throws**; only a missing one fails open. +- **Tests pin the clock.** Pass `now`, or mock `Date.now`. Fixture keys come from the `license-key` generator CLI; forged keys come from a test-only helper that is never imported from product source. diff --git a/src/license/handsontable-license-key-parser/PROVENANCE.md b/src/license/handsontable-license-key-parser/PROVENANCE.md new file mode 100644 index 0000000000..36aa544e85 --- /dev/null +++ b/src/license/handsontable-license-key-parser/PROVENANCE.md @@ -0,0 +1,62 @@ +# Vendored entitlement-key reader + +This directory is a copy of `handsontable/license-key`'s **`vendor/entitlement-key-reader/`**, +the TypeScript reader that repository publishes for products to copy. It is taken whole, from a +tagged release, and edited here in one declared line only (below), which the HyperFormula owner +accepted on 2026-10-01 ("Let's keep this solution for now", #1728). Anything else is fixed upstream +and re-taken: a local fix makes two products disagree about the same key. + +| | | +|---|---| +| Repository | `handsontable/license-key` (private) | +| Directory | `vendor/entitlement-key-reader` | +| Tag | `5.1.1` (`124660718`, 2026-10-06) | +| Pin | `upstream.json` | + +## One declared divergence + +`extractKeyData.ts`, one line: `Object.keys(current)` → `Object.keys(current as object)`. HyperFormula +compiles with TypeScript 4.0.8, which does not narrow `unknown` through `!== null && typeof === +'object'`; upstream compiles under 5.9, and TypeScript 4.5 is where the line starts to compile +uncast. The cast changes no behavior. It is recorded in `upstream.json` with its reason, and the +check below applies it before comparing. A tag is immutable, so the check does not see upstream fix +the line on a branch; it sees the next tag - a newer tag fails the check - and at the re-take the swap +stops matching once upstream has changed the line, so the fix cannot be missed. A tag that leaves the +line alone carries the shim forward; the `until` field is a note for the person re-taking, not a check. + +## Checking it + +```bash +npm run check:license-key-parser-drift +``` + +Checks that the pinned tag still resolves to the pinned commit, lists the upstream directory at that +commit and compares every file git tracks here with it byte for byte. Fails on a moved tag or an edited pin, on any difference, +on a file here that upstream does not have (a shadowing `.ts` would win module +resolution silently), on an upstream file missing here, and on a **newer upstream tag** than the +pin. Without credentials to the private repository it fails rather than skips. + +## What HyperFormula uses from it + +`readEntitlementLicense`, the single entry point upstream prescribes, plus `detectLicenseKeyFormat`, +`toIsoBuildDate` and the types - from +`src/license/licenseResolution.ts` and `src/helpers/licenseKeyValidator.ts`, which sit outside the +copy, as upstream's guide prescribes. The reader verifies the key (the checksum and, from format version 2, the prose +digest), picks HyperFormula's entry, places it in its lifecycle window and reads its flags. +HyperFormula keeps what the guide leaves to the product: the meaning of the capability tokens +(`src/license/capabilities.ts`) and the console messages. The test suite mints keys with the test-only exports `canonicalizeProse`, +`computeProseDigest`, `computePayloadChecksum` and `stringToBase64Url`, as upstream's README shows. + +Upstream's `README.md` in this directory is the integration guide; `AGENTS.md` is theirs too. + +## Lint and types + +The directory is excluded from ESLint (upstream style; the same treatment as +`src/interpreter/plugin/3rdparty`) and type-checked with the rest of `src`. Published typings are +emitted from these sources by `tsc`, as for any other `.ts` here. + +## Related + +- `src/license/licenseResolution.ts` - the consumer: routes on `detectLicenseKeyFormat`, reads the + key with `readEntitlementLicense` and turns the result into an entitlement. +- `src/license/capabilities.ts` - the capability table the payload's tokens are resolved against. diff --git a/src/license/handsontable-license-key-parser/README.md b/src/license/handsontable-license-key-parser/README.md new file mode 100644 index 0000000000..c94788a67b --- /dev/null +++ b/src/license/handsontable-license-key-parser/README.md @@ -0,0 +1,415 @@ +# Entitlement key reader + +The part of [`handsontable/license-key`](https://github.com/handsontable/license-key) that a product copies into its own source tree to read **entitlement license keys**. It verifies a key, picks the entry for your product, tells you which lifecycle window the license is in, which warning channels the key keeps open, and which capabilities it unlocks. + +It is plain TypeScript with **no dependencies**. It does not use `crypto`, `Buffer`, `TextEncoder`, the Web Crypto API or the DOM, so it runs in any browser (including plain `http://` intranet pages), in Node, and in a worker. It type-checks under `strict` against the ES2015 library only. + +It is **general**. Nothing in it names Handsontable, HyperFormula or any other product. Your product passes in what is specific to it: its product name, its build date and the literal keys it accepts. + +What it does **not** do: + +- generate keys (that is the `license-key` package, used by my.handsontable.com), +- read the legacy 25-character keys (each product keeps its own legacy validator), +- print messages or render UI (the wording and the surfaces are the product's; the table in [What to do in each state](#what-to-do-in-each-state) gives the specification's text). + +## Contents + +- [Copy it into a product](#copy-it-into-a-product) +- [The key in 60 seconds](#the-key-in-60-seconds) +- [Where users keep the key](#where-users-keep-the-key) +- [Integrate it in five steps](#integrate-it-in-five-steps) +- [What to do in each state](#what-to-do-in-each-state) +- [Rules you must not break](#rules-you-must-not-break) +- [What each product supplies](#what-each-product-supplies) +- [Test it in your product](#test-it-in-your-product) +- [API reference](#api-reference) +- [Files](#files) + +## Copy it into a product + +Copy the **whole directory**, from a tagged release of `license-key`, and do not edit the copy: + +```bash +# from the product repository +LICENSE_KEY_TAG=4.1.0 # the license-key release you copy from - an example, use a real tag +git clone --depth 1 --branch "$LICENSE_KEY_TAG" git@github.com:handsontable/license-key.git /tmp/license-key +rm -rf src/utils/entitlementKeyReader +cp -R /tmp/license-key/vendor/entitlement-key-reader src/utils/entitlementKeyReader +``` + +The target path is yours to choose. Keep the files together: every import inside the directory is relative to it, and nothing imports from outside it. + +Treat the copy as read-only: + +- **Fix bugs in `license-key`, then copy again.** A local fix makes the products disagree about the same key, and a customer sees one product accept a key another rejects. +- **Add a drift check to CI.** Record the tag you copied from, and fail the build when the copy no longer matches it: + + ```bash + git clone --depth 1 --branch "$LICENSE_KEY_TAG" git@github.com:handsontable/license-key.git /tmp/license-key + diff -r /tmp/license-key/vendor/entitlement-key-reader src/utils/entitlementKeyReader + ``` + +- **Put your own code next to the copy, not inside it.** A `license.ts` beside it that calls `readEntitlementLicense` and holds your messages is the usual shape. + +A TypeScript product imports it directly. A JavaScript product needs a build step that strips types (Babel's `@babel/preset-typescript`, `esbuild`, `swc` or `tsc`) - the files use nothing beyond type annotations. + +**Do not install `license-key` as a dependency instead.** The package carries the generator, which products must not ship, and a product with a GPL distribution (HyperFormula) cannot depend on a private package without breaking `npm install` for its open-source users. Copying the reader is the chosen delivery form (specification §7.1). + +## The key in 60 seconds + +A key is plain-English text for humans, then a block for the library: + +``` +This is a Handsontable license key for Acme Corp, issued on 2026-08-12. It includes 1 license: + +> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. + +[eyJwcm9kdWN0cyI6eyJoYW5k......<128 hex characters of SHA-512>] +``` + +- **The prose is never parsed, but it is covered** (version 2, every key issued now). The payload carries a digest of it, so a key whose sentences were edited or removed is invalid - the `[...]` block alone is **not** a key. A version 1 key (`license-key` 4.x, trial keys in production) never covered its prose: its bare block, an edited prose and text after its block all read, as they did when it was issued. Only the whitespace and the Unicode composition of the prose are ignored: a key still works after a mail client rewraps it (also inside a word or between two CJK characters), collapses the blank line, stores it decomposed, or after it is put on one line. +- **The block** is `[`, a base64url (URL-safe, unpadded) JSON payload, the checksum (128 lowercase hex characters), and `]`. The block is the **last** `[` in the string and the first `]` after it. Whitespace inside the block is ignored, so a block a mail client wrapped still reads. In a version 2 key only whitespace (or a line break saved as `\n`) may follow it. +- **The checksum** is the SHA-512 hex of the UTF-8 bytes of the encoded payload. +- **The prose digest** (`prose` in the payload) is the first 64 characters of the SHA-512 hex of the UTF-8 bytes of `canonical(prose)`, where `prose` is everything in front of the `[` and `canonical` is the prose with every line break or tab saved as text (`\n`, `\r`, `\t`) and every whitespace character removed, then put in Unicode NFC. The characters are listed exactly, as `PROSE_WHITESPACE` in `extractKeyData.ts`: TAB, LF, VT, FF, CR, SPACE, U+00A0, U+1680, U+2000-U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and U+FEFF. A version 2 key with an empty prose is invalid. +- **The format version** is `v` in the payload. The reader accepts two kinds of key and returns the version with the data: + + | Version | `v` | Checksum | Prose checked? | Issued by | + | --- | --- | --- | --- | --- | + | 1 | absent | SHA-512 of the payload | no | `license-key` 4.x | + | 2 | `2` | SHA-512 of the payload | yes, by the digest | `license-key` from DEV-3286 on | + + A newer `v` than the reader knows is accepted: later versions only add fields and keep the payload checksum and the digest. That is also why a version 2 key reads in a product that only knows version 1 (Handsontable 18.1.x) - such a product does not check the prose. +- **The payload** lists what is licensed, per product: + + ```json + { + "products": { + "hyperformula": { + "capabilities": ["functions_1", "functions_2", "spreadsheet"], + "release_until": "2027-03-31", + "notice": 0, + "grace": 0, + "flags": ["no-console-warns", "no-ui-warns"] + } + }, + "v": 2, + "prose": "<64 hex characters>" + } + ``` + +| Field | Meaning | +| --- | --- | +| `products` | Keyed by product name. **Presence means licensed.** A key may grant several products; each has its own dates and flags. | +| `v` | The format version. Absent in the first keys (version 1). | +| `prose` | The digest of the prose, from version 2. The reader checks it; your product never needs it. | +| `capabilities` | Opaque tokens. The key says *what is granted*; your product decides *what each token unlocks*. | +| `usage_until` | The last licensed day, inclusive, in UTC. Measured against the clock. A subscription or a trial. | +| `release_until` | Builds released on or before this day may run forever. Measured against your build date, never the clock. A perpetual license. | +| `notice` | Days of advance warning before `usage_until`. `0` means no warning. | +| `grace` | Days of soft stop after `usage_until` before the hard stop. | +| `flags` | `trial`, `no-console-warns`, `no-ui-warns`, `custom`. Present or absent - there is no `false`. | + +Exactly **one** of `usage_until` / `release_until` is present. There is no contract type, tier, package name or holder in the payload - "subscription" or "perpetual" is only visible as which date is present. + +## Where users keep the key + +Users paste the key into code, `.env` files, CI secrets, container settings and YAML. The reader ignores every whitespace character and a line break or tab saved as text (`\n`, `\r`, `\t`), so most of these work. What breaks a key is a store that **loses part of it** or **changes a character** - most often a quote. The prose can contain `"` (around the project name) and `'` (in a company name such as `O'Brien`). + +Tell your users to keep the key **on one line**. The `license-key` generator prints that form, and it is the same key: the reader ignores the line breaks it no longer has. + +Checked by hand on 2026-10-05, with real keys pushed through the real parsers (`dotenv` 16.6.1, `js-yaml` 3.14, `bash`). This is not part of CI, so check again before relying on a row if those tools change: + +| Where the key is kept | Valid | +| --- | --- | +| Code: a string with real line breaks, or the one-line form | yes | +| A JSON config, parsed (`\n` in the JSON) | yes | +| YAML: `\|` block, `>` folded block | yes | +| YAML: a double-quoted string | only with the key's own `"` escaped - the prose contains `"` around the project name | +| `.env`, the one-line form - unquoted, `"..."` or `'...'` | yes | +| `.env`, line breaks saved as `\n` - unquoted, `"..."` or `'...'` | yes | +| `.env`, real line breaks inside `"..."` or `'...'` | only if the key has no `"` / `'` of that kind | +| `.env`, real line breaks, **unquoted** | **no** - only the first line is kept | +| Docker `--env-file`, CI secret fields that store `\n` as text | yes | +| A shell script: `KEY="$(cat key.txt)"` | yes | +| A shell script: the key pasted inside `"..."` or `'...'` | **no** if the key contains that quote - the shell ends the value there | +| JSON text used as it is, without parsing it | **no** - the quotes and the escapes stay in | +| Line breaks saved twice-escaped (`\\n`) | **no** - a stray backslash is left | +| A word processor that turned `"` into `“` `”` (curly quotes) | **no** - that is a changed character | + +Do not "fix" a key on the way in - see rule 5 below. If a user's key reads as `unreadable`, the message should ask them to paste the key exactly as it was issued, on one line. + +## Integrate it in five steps + +```ts +import { + detectLicenseKeyFormat, + readEntitlementLicense, + toIsoBuildDate, + hasCapability, + UNRESTRICTED_GRANTS, +} from './entitlementKeyReader'; +import type { LicenseGrants } from './entitlementKeyReader'; + +const PRODUCT = 'hyperformula'; // 1. your product name, as it appears in the payload +const LITERAL_KEYS = ['gpl-v3', 'internal-use-in-handsontable', 'hftrial-0168e-1f2b7-47158-70b05-0842f']; +const BUILD_DATE = toIsoBuildDate(process.env.HT_RELEASE_DATE); // "DD/MM/YYYY" -> "YYYY-MM-DD" + +// Returns what the key unlocks. Every path returns grants - never a report's +// result - because the feature gates read them. +export function checkLicense(rawKey: unknown): LicenseGrants { + const key = typeof rawKey === 'string' ? rawKey.trim() : ''; + + // 2. Route the key. Only "entitlement" goes to this reader. + switch (detectLicenseKeyFormat(key, LITERAL_KEYS)) { + case 'literal': + handleLiteralKey(key); // your existing behaviour + return UNRESTRICTED_GRANTS; + case 'legacy': + handleLegacyKey(key); // your existing legacy validator + return UNRESTRICTED_GRANTS; + case 'unknown': + if (key === '') { + reportMissingKey(); + } else { + reportInvalidKey(); + } + return UNRESTRICTED_GRANTS; + case 'entitlement': + break; + } + + // 3. Read it for your product. + const license = readEntitlementLicense(key, { product: PRODUCT, buildDate: BUILD_DATE }); + + if (!license.licensed) { + // The key is broken, edited (version 2), or it licenses other products only. + // license.reason is 'unreadable' or 'product_missing' - both are an invalid key to the user. + reportInvalidKey(); + + return license.grants; // UNRESTRICTED_GRANTS - an invalid key nags, it never strips features + } + + // 4. Act on the lifecycle state, through the channels the key leaves open. + notify(license.lifecycle, license.channels); + + // 5. Gate features by capability token. + return license.grants; +} + +// Anywhere a feature is gated: +const grants = checkLicense(settings.licenseKey); + +if (!hasCapability(grants, PRODUCT, 'spreadsheet')) { + // not unlocked by this key +} +``` + +Notes on each step: + +1. **The product name** is the key of `products` in the payload. Names are append-only and never renamed, so a constant is right. +2. **Literal keys** are the plain words your product accepts as a key. They are your decision, so the reader knows none unless you pass them. Check them before anything else, as the example does. Trim the key once, at the start, and pass the trimmed value everywhere - an untrimmed legacy key fails its checksum. A blank entry in the list is ignored. +3. **The build date** is the release date of the installed build as a bare `YYYY-MM-DD`. `toIsoBuildDate` converts the `DD/MM/YYYY` the Handsoncode build pipelines inject, by reordering the text - never through a `Date`. A **missing** build date (`undefined`, `null`, `''`) fails open; a date in **any other format** - the raw `DD/MM/YYYY` included - throws a `TypeError`, whatever the key, so the mistake shows in your first test instead of silently switching off every `release_until` check. `now` is optional and defaults to `Date.now()`; pass it in tests. +4. **Read the key once per start-up** and share the result between your console message and your UI. The reader caches the last key it read, so a second read is cheap, but one result is what keeps two surfaces from disagreeing. +5. **An unlicensed key unlocks everything.** When `licensed` is `false`, `grants` is `UNRESTRICTED_GRANTS` and `hasCapability` answers `true` for every token. An invalid key nags; it never takes features away. Do the same for legacy, literal, missing and unknown keys - return `UNRESTRICTED_GRANTS` to your gates, as the example does - so adding capability gating can never break an existing customer. +6. **Everything the reader returns is frozen**, and typed read-only. Copy an array (`capabilities.slice()`) before sorting or changing it; `getProductCapabilities` already returns a copy. + +## What to do in each state + +`license.lifecycle.state` is one of ten values. The windows are measured the same way for a trial and a subscription; the `trial` flag changes only the wording and whether the hard stop blocks. + +| State | When | Console (if `channels.console`) | UI (if `channels.ui`) | +| --- | --- | --- | --- | +| `usage_valid` | before the notice window | nothing | nothing | +| `usage_notice` | the last `notice` days, up to and including `usage_until` | warning | nothing | +| `usage_soft_stop` | after `usage_until`, through `grace` days | error | nothing | +| `usage_hard_stop` | after the grace period | error - the soft-stop message persists (18.1 never blocks a paying customer) | nothing | +| `trial_valid` | as `usage_valid`, on a trial | nothing | optional trial badge | +| `trial_notice` | as `usage_notice`, on a trial | warning | optional trial badge | +| `trial_soft_stop` | as `usage_soft_stop`, on a trial | error | a banner or a modal | +| `trial_hard_stop` | as `usage_hard_stop`, on a trial | error | **block the product** - applied even when `channels.ui` is `false` | +| `release_valid` | the build is covered by `release_until` | nothing | nothing | +| `release_expired` | the build was released after `release_until` | error | a banner | + +`lifecycle.daysRemaining` is the whole UTC days until `usage_until` (`0` on the last licensed day, negative after it, `null` for `release_until`). `lifecycle.licensedUntil` is the date exactly as the key carries it - print that string, never a date rebuilt from a timestamp. + +The two flags close channels, per product: + +- `no-console-warns` - nothing reaches the console. +- `no-ui-warns` - no **warning** is rendered in the UI. It does **not** lift the trial hard-stop block: the block is enforcement, not a warning (Handsontable decision DEV-2709; the specification's §4.1 table header does not record the split yet). + +Both are set on keys issued for external, end-user-facing use, so your license messages never reach your customer's own users. + +The specification's message text (§4.1, §4.2). Replace `{PRODUCT}` with your product's display name. Print `usage_until` dates with the ` (UTC)` marker and `release_until` dates without it: + +| State | Message | +| --- | --- | +| `trial_notice` | `Your {PRODUCT} license key expires in {N} days. To continue using {PRODUCT}, you need to purchase a license.` | +| `trial_soft_stop` | `Your {PRODUCT} trial license key expired on {DATE} (UTC). To continue using {PRODUCT}, you need to purchase a license.` | +| `trial_hard_stop` | `Your {PRODUCT} trial license key expired on {DATE} (UTC). You may no longer use {PRODUCT} under the trial license. To continue using the software, contact sales@handsontable.com to purchase a valid license.` | +| `usage_notice` | `Your {PRODUCT} subscription license expires on {DATE} (UTC). To renew your license, contact sales@handsontable.com.` | +| `usage_soft_stop`, `usage_hard_stop` | `Your {PRODUCT} subscription license expired on {DATE} (UTC). To continue using the software, contact sales@handsontable.com to purchase a valid license key.` | +| `release_expired` | `The license key for {PRODUCT} expired on {DATE}, and is not valid for the installed version {VERSION}. Renew your license key or downgrade to a version released on or before {DATE}. If you need any help, contact us at sales@handsontable.com.` | + +Two edges the specification leaves open, as Handsontable words them: `{N}` of `1` reads "expires in 1 day", and `0` reads "expires today" (the last licensed day is licensed in full). Show each distinct message once per key per page, not once per instance. + +The message for an **invalid or missing** key is not in the specification yet (§4.5). Handsontable reuses its legacy messages and points at support rather than sales, because both are install faults. + +## Rules you must not break + +Each of these has broken a real implementation, or a specification fixture exists for it. + +1. **Never convert a date through `Date`.** Compare `usage_until` in UTC, and `release_until` against the build date **as text** (`'2027-08-12' >= '2027-08-11'` is exact for `YYYY-MM-DD`). `new Date('07/14/2025')` parses in local time; a `toISOString()` round trip moves a date by a day east of UTC. +2. **`usage_until` is inclusive.** The license is valid until the UTC midnight that *follows* it. The hard stop starts at `usage_until + 1 + grace` days, `00:00:00Z`. Day counts are calendar days (UTC midnight to UTC midnight), never milliseconds divided and floored. `notice: 0` means no warning window at all. +3. **A `release_until` license never reads the clock.** A perpetual key must read the same with the clock set to 1999 or 2035, or on an offline machine. +4. **Fail open on a missing build date, and only on a missing one.** If the build date is missing (`undefined`, `null`, empty), a `release_until` license reads as `release_valid` - a broken build must never tell a paying customer their license lapsed. A build date in the wrong format is your bug and throws; convert with `toIsoBuildDate`. Read the build constant exactly as your bundler inlines it - do not wrap `process.env.X` in a `typeof process` guard, which the bundler does not inline and which then blanks the date. +5. **Pass the whole key, and do not repair it.** Surrounding whitespace is fine to trim, and so is nothing else - the reader already ignores whitespace and a line break saved as `\n`, in the prose and inside the block (see [Where users keep the key](#where-users-keep-the-key)). Do not cut the key down to its `[...]` block, and do not strip quotes or other characters from it: a version 2 key covers its prose, so the block alone or a changed sentence is invalid. Do not treat a readable key as proof that its sentences are unedited, either - a version 1 key never covered them (check `version`). +6. **Be strict about shape, lenient about vocabulary.** Unknown products, capability tokens, flags and extra fields are kept and ignored. A key your build does not fully understand must still read. Only a malformed shape (both dates, no date, a bad date, a negative window) makes a key invalid. +7. **Never branch on a contract type or a package name.** The payload has neither. Decide by which date is present and by the `trial` flag - which is what the state names already encode. +8. **The capability-token meaning lives in your product.** Keep the list of tokens your build understands next to your feature gates. Tokens are only ever added to a product, never removed, so a gate on an existing token stays valid. +9. **Keep the copy identical to `license-key`.** See [Copy it into a product](#copy-it-into-a-product). + +## What each product supplies + +| | Handsontable | HyperFormula | +| --- | --- | --- | +| `product` | `'handsontable'` | `'hyperformula'` | +| Build date | `process.env.HOT_RELEASE_DATE` (`DD/MM/YYYY`) | `process.env.HT_RELEASE_DATE` (`DD/MM/YYYY`) | +| Literal keys | `non-commercial-and-evaluation`, `ht68e-1f2b7-47158-70b05-0842f` | `gpl-v3`, `internal-use-in-handsontable`, `hftrial-0168e-1f2b7-47158-70b05-0842f` | +| Capability tokens | `core` | `functions_1` ... `functions_4`, `spreadsheet`, `import_export` | +| Legacy keys | own obfuscated validator | own obfuscated validator | + +Pass **every** literal key the product accepts today, including the ones shaped like a legacy key: checked first, they never reach the legacy validator. The lists above are what each product accepted when this reader was written - check the product's own validator before copying them. + +A new product needs the same five things: a product name agreed with my.handsontable.com, a build date, its literal keys (maybe none), its capability tokens, and a decision about its legacy keys (maybe none). + +Handsontable already ships an earlier port of this reader in `handsontable/src/utils/entitlementLicenseKey/` (DEV-2562). This directory is that port made general, with the same state names and window rules. The differences, when switching Handsontable over: + +- **the prose is covered and the key has a version** (`license-key` 5.0.0, DEV-3253; DEV-3286) - the 18.1.x port checks the payload checksum only and accepts the bare `[...]` block. It reads 4.x keys and version 2 keys, without checking their prose. A port of the unreleased 5.0.0 rule (Handsontable `develop` after DEV-3254) rejects both and has to be replaced by this reader before it ships, +- whitespace inside the block is ignored - the port rejects a wrapped block, +- the data carries `version`, +- the product name is passed in (`readEntitlementLicense(key, { product })`) instead of being built in, +- `detectLicenseKeyFormat(key, literalKeys)` takes the literal keys and returns `'literal'` for them, where the port returns `'non-commercial-and-evaluation'` (Handsontable only calls `isEntitlementKey`, which is unchanged), +- a date that is not a string, such as `["2027-08-12"]`, makes the key invalid - the port still accepts it, +- `extractEntitlementKeyData` returns `null` for a value that is not a string, +- every result is frozen and typed read-only - code that sorts or pushes into one must copy it first, +- a build date in the wrong format throws; the port fails open on it, +- `classifyEntitlement` throws for an entry without exactly one valid date, +- the clock is read once per `readEntitlementLicense` call, and never for a `release_until` entry. + +## Test it in your product + +The windows are evaluated in this directory and are already covered by `license-key`'s own tests (the specification's Date semantics fixtures J1-J10). Your product still needs to test **its own wiring**: + +- each literal key, a legacy key, a missing key and an unreadable key reach the right path, +- each of the ten states produces the right message on the right surface, and nothing when the matching channel is closed, +- the trial hard stop blocks even with `no-ui-warns`, +- a key for another product only reports an invalid key, +- the build date constant reaches `readEntitlementLicense` in your real bundle, not only in tests, +- pin `now` (or mock `Date.now`) in every test - never rely on the real clock. + +For fixture keys, generate real ones with the `license-key` CLI, so a misunderstanding shared by your test and your code cannot pass: + +```bash +# in a checkout of license-key +npm install && npm run build +npm run generate-entitlement-key --record='{"holder":"Test Fixture","issued":"2026-08-12","licenses":[{"product":"hyperformula","contractType":"perpetual","agreement":"perpetual-2.0","package":"Pro","mode":"internal","date":"2027-03-31","notice":0,"grace":0}]}' +``` + +For the shapes the generator refuses (both dates, a bad date, an unknown token), build the key yourself in a test-only helper - kept in a `__tests__/` directory beside the copy, and never imported from your source: + +```ts +import { canonicalizeProse, computePayloadChecksum, computeProseDigest } from '../entitlementKeyReader/extractKeyData'; +import { stringToBase64Url } from '../entitlementKeyReader/encoding'; + +// A version 2 payload carries a digest of the prose, so a test key needs one. +const PROSE = 'This is a test license key.'; + +export function buildTestKey(payload: { products: object }): string { + const encoded = stringToBase64Url(JSON.stringify({ + ...payload, + v: 2, + prose: computeProseDigest(canonicalizeProse(PROSE)), + })); + + return `${PROSE}\n\n[${encoded}${computePayloadChecksum(encoded)}]`; +} +``` + +This is not a secret: the checksum recipe ships in every product bundle by design. The key protects integrity (a typo, a broken paste or - in a version 2 key - an edited sentence cannot pass), not authenticity. + +## API reference + +### `readEntitlementLicense(licenseKey, { product, buildDate, now? })` -> `EntitlementLicense` + +The one call a product needs. Verifies the block, picks the product's entry, classifies it, reads its flags and resolves its grants. + +```text +licensed: { licensed: true, reason: null, version, entitlement, lifecycle, channels, grants } +not licensed: { licensed: false, reason: 'unreadable' | 'product_missing', version, entitlement: null, + lifecycle: null, channels: { console: true, ui: true }, grants: UNRESTRICTED_GRANTS } +``` + +`version` is the format version of the key - `1` for a key without `v` (its prose is not checked), `2` for one issued now - so a product can treat an older key differently. It is `null` only for an unreadable key. + +What to do with `version === 1`: **accept it.** Trial keys in that format are in production. Do not treat `version` as a security signal either: anyone can turn a version 2 key into a version 1 key by dropping `v` and `prose` and recomputing the checksum - the checksum is not a signature. `version` only says what was checked, for example to log it or to word a support message. + +The result is frozen all the way down, in both cases. + +Throws a `TypeError` - a wrong argument is your bug, and a silent "invalid" would hide it - when: + +- the options object is missing, +- `product` is not a non-empty string, +- `now` is given and is not a finite number of milliseconds, +- `buildDate` is present but not a real `YYYY-MM-DD` (a missing one - `undefined`, `null`, `''` - fails open instead). + +The argument checks run before the key is read, so they fail whatever key a test uses. + +### `toIsoBuildDate(releaseDate)` -> `string` + +`'14/07/2025'` -> `'2025-07-14'`. Also accepts an already-bare `YYYY-MM-DD`. Returns `''` for anything that is not a real date, so its output is always safe to pass on: a real date, or a missing one (fails open). + +### `detectLicenseKeyFormat(licenseKey, literalKeys?)` -> `'entitlement' | 'legacy' | 'literal' | 'unknown'` + +Tells the shape of a key without validating it. `'entitlement'` means "a `[...]` block is present", not "valid". `literalKeys` are compared trimmed and case-insensitively; a blank entry, and a missing or non-array list, are ignored. `isEntitlementKey(licenseKey)` is the one-line form of the entitlement check. + +### `extractEntitlementKeyData(licenseKey)` -> `{ version, products } | null` + +The verified, frozen payload, or `null` for any unreadable key. `version` is the format version (1 for a key without `v`). `validateEntitlementKey(licenseKey)` returns the same as a boolean. `getProductEntitlement(data, product)` returns one product's entry or `null`, reading own properties only. + +### `classifyEntitlement(entitlement, { now, buildDate })` -> `LicenseLifecycle` + +`{ state, isTrial, daysRemaining, licensedUntil }` for one verified entry. It checks `buildDate` as `readEntitlementLicense` does, and throws for an entry that does not carry exactly one valid date - pass an entry read by `extractEntitlementKeyData`, not one you built. + +### `resolveChannels(entitlement)` -> `{ console, ui }` + +Which channels the entry's flags leave open. + +### Grants + +- `getLicenseGrants(data)` -> `{ unrestricted: false, products: { [name]: { capabilities } } }` +- `UNRESTRICTED_GRANTS` - frozen; every query answers "granted". +- `hasProductGrant(grants, product)` -> `boolean` +- `hasCapability(grants, product, token)` -> `boolean` +- `getProductCapabilities(grants, product)` -> `string[] | null` (a copy; `null` when unrestricted or not granted) + +### Constants + +`TRIAL_FLAG`, `NO_CONSOLE_WARNS_FLAG`, `NO_UI_WARNS_FLAG`, `CUSTOM_FLAG`. + +## Files + +| File | What it holds | +| --- | --- | +| `index.ts` | the public exports | +| `readLicense.ts` | `readEntitlementLicense` | +| `detectFormat.ts` | `detectLicenseKeyFormat`, `isEntitlementKey` | +| `extractKeyData.ts` | the key reader: the checksum, the format version and the prose digest, decode, shape checks, the one-entry cache | +| `classify.ts` | the lifecycle windows and the channels | +| `grants.ts` | capability queries | +| `buildDate.ts` | `toIsoBuildDate` | +| `encoding.ts` | UTF-8, base64 and `YYYY-MM-DD` parsing, without `TextEncoder` or `Buffer` | +| `sha512.ts` | a pure-JS SHA-512 - the Web Crypto API is unavailable on plain `http://` pages | +| `constants.ts`, `types.ts` | names and shapes | +| `AGENTS.md` | the rules above, for AI coding agents working near the copy | + +The full format, and the reasons behind it, are in `license-key`: `.ai/ENTITLEMENT-KEY-FORMAT.md` and `.ai/DESIGN-DECISIONS.md`. The specification is [License Keys - part 1 (18.1) rev 6](https://app.clickup.com/9015210959/v/dc/8cnjcyf-31675/8cnjcyf-46715), with its [Date semantics fixtures](https://app.clickup.com/9015210959/v/dc/8cnjcyf-31675/8cnjcyf-48255) and [Examples](https://app.clickup.com/9015210959/v/dc/8cnjcyf-31675/8cnjcyf-48235). diff --git a/src/license/handsontable-license-key-parser/buildDate.ts b/src/license/handsontable-license-key-parser/buildDate.ts new file mode 100644 index 0000000000..a2b9ceb3f6 --- /dev/null +++ b/src/license/handsontable-license-key-parser/buildDate.ts @@ -0,0 +1,69 @@ +import { parseIsoDateToTimestamp } from './encoding'; + +/** + * Turns a build release date into the bare "YYYY-MM-DD" text that + * `release_until` is compared against. + * + * Accepts the "DD/MM/YYYY" form the Handsoncode build pipelines inject (for + * example `process.env.HOT_RELEASE_DATE` or `process.env.HT_RELEASE_DATE`) and + * an already-bare "YYYY-MM-DD". The parts are REORDERED as text and never + * routed through a `Date`: `new Date('07/14/2025')` parses in the machine's + * local timezone, which is exactly the kind of shift the date rules exist to + * prevent. + * + * Returns an empty string when the value is missing or not a real date. Passed + * on to `readEntitlementLicense`, an empty build date fails OPEN - a broken + * build must never tell a paying customer that their license lapsed. + * + * @param {*} releaseDate The build release date, "DD/MM/YYYY" or "YYYY-MM-DD". + * @returns {string} The date as "YYYY-MM-DD", or "" when it cannot be read. + */ +export function toIsoBuildDate(releaseDate: unknown): string { + if (typeof releaseDate !== 'string') { + return ''; + } + + const value = releaseDate.trim(); + const dayMonthYear = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value); + const isoDate = dayMonthYear === null + ? value + : `${dayMonthYear[3]}-${`0${dayMonthYear[2]}`.slice(-2)}-${`0${dayMonthYear[1]}`.slice(-2)}`; + + return parseIsoDateToTimestamp(isoDate) === null ? '' : isoDate; +} + +/** + * Checks the build date a caller passed in, and returns it ready to compare. + * + * Two different failures, two different answers: + * + * - MISSING (`undefined`, `null`, an empty or blank string) fails OPEN and + * returns "". That is a broken build - a bundler that did not inline the + * release date constant - and a paying customer must never be told their + * license lapsed because of it. + * - PRESENT BUT WRONG (anything else that is not a bare, real "YYYY-MM-DD", + * such as the raw "DD/MM/YYYY" a build pipeline injects) throws. That is + * the product's bug, it is the same on every run, and failing open on it + * would silently turn off every `release_until` check. The product's own + * tests see the error on the first key they read. + * + * @param {*} buildDate The build date the caller passed. + * @param {string} caller The public function name, for the error message. + * @returns {string} The build date, or "" when it is missing. + */ +export function resolveBuildDate(buildDate: unknown, caller: string): string { + if (buildDate === undefined || buildDate === null) { + return ''; + } + if (typeof buildDate === 'string' && buildDate.trim() === '') { + return ''; + } + if (parseIsoDateToTimestamp(buildDate) === null) { + const got = typeof buildDate === 'string' ? `"${buildDate}"` : `a ${typeof buildDate}`; + + throw new TypeError(`${caller}: "buildDate" has to be a "YYYY-MM-DD" date, got ${got}. ` + + 'Convert a "DD/MM/YYYY" release date with toIsoBuildDate().'); + } + + return buildDate as string; +} diff --git a/src/license/handsontable-license-key-parser/classify.ts b/src/license/handsontable-license-key-parser/classify.ts new file mode 100644 index 0000000000..ce158e2295 --- /dev/null +++ b/src/license/handsontable-license-key-parser/classify.ts @@ -0,0 +1,157 @@ +import type { + ProductEntitlement, + LicenseLifecycle, + LicenseState, + LicenseChannels, + LicenseTimeReference, +} from './types'; +import { parseIsoDateToTimestamp } from './encoding'; +import { resolveBuildDate } from './buildDate'; +import { + TRIAL_FLAG, + NO_CONSOLE_WARNS_FLAG, + NO_UI_WARNS_FLAG, + MILLISECONDS_PER_DAY, +} from './constants'; + +/** + * The window a `usage_until` license is in, before the trial flag decides how + * it is worded. + */ +type UsageWindow = 'valid' | 'notice' | 'soft_stop' | 'hard_stop'; + +/** + * The UTC midnight of the day an instant falls on. Every window boundary is a + * UTC midnight, so both sides of every comparison are snapped to one - the + * local calendar, the locale and DST are inputs the classification must not + * read at all. + * + * @param {number} timestamp The instant, in epoch milliseconds. + * @returns {number} + */ +function utcMidnightOf(timestamp: number): number { + return Math.floor(timestamp / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY; +} + +/** + * Whole UTC days from today until the last licensed day. `0` on the last + * licensed day (which is still licensed in full) and negative once it has + * passed. Both sides are UTC midnights, so the count is a whole number of + * calendar days and never a rounded fraction. + * + * @param {number} expiryTimestamp The UTC midnight of the last licensed day. + * @param {number} now The current instant, in epoch milliseconds. + * @returns {number} + */ +function daysUntil(expiryTimestamp: number, now: number): number { + return (expiryTimestamp - utcMidnightOf(now)) / MILLISECONDS_PER_DAY; +} + +/** + * Places a `usage_until` license in its window. + * + * The named day is licensed in full: the license runs until the UTC midnight + * that FOLLOWS it, and the grace period is measured from there. A `notice` of + * `0` means no advance warning at all, so the notice window is empty rather + * than one day long. + * + * @param {number} expiryTimestamp The UTC midnight of the last licensed day. + * @param {ProductEntitlement} entitlement The product entry the windows come from. + * @param {number} now The current instant, in epoch milliseconds. + * @returns {UsageWindow} + */ +function resolveUsageWindow( + expiryTimestamp: number, + entitlement: ProductEntitlement, + now: number, +): UsageWindow { + const expiryBoundary = expiryTimestamp + MILLISECONDS_PER_DAY; + + if (now >= expiryBoundary) { + return now < expiryBoundary + (entitlement.grace * MILLISECONDS_PER_DAY) ? 'soft_stop' : 'hard_stop'; + } + + const daysRemaining = daysUntil(expiryTimestamp, now); + + return entitlement.notice > 0 && daysRemaining <= entitlement.notice ? 'notice' : 'valid'; +} + +/** + * Classifies one product entitlement into its lifecycle facet. + * + * Which of the two dates the entry carries decides how it is measured; the + * `trial` flag decides only what the user is told. Nothing here branches on a + * contract type, because the payload does not carry one. + * + * A `release_until` entry never reads `time.now`, and a `usage_until` entry + * never compares against `time.buildDate`. The build date is checked either + * way, so a wrong one fails on the first key a product's tests read, whatever + * its kind - see `resolveBuildDate`. + * + * Pass an entry from `extractEntitlementKeyData` / `getProductEntitlement`. An + * entry that is not a verified one - no date, both dates, a date that is not a + * real "YYYY-MM-DD" string - throws rather than being guessed at. + * + * @param {ProductEntitlement} entitlement The verified product entry. + * @param {LicenseTimeReference} time The time references to measure against. + * @returns {LicenseLifecycle} + */ +export function classifyEntitlement( + entitlement: ProductEntitlement, + time: LicenseTimeReference, +): LicenseLifecycle { + const buildDate = resolveBuildDate(time.buildDate, 'classifyEntitlement'); + const isTrial = entitlement.flags.indexOf(TRIAL_FLAG) !== -1; + const releaseUntil = entitlement.release_until; + const usageUntil = entitlement.usage_until; + // Read without turning the value into text first: `['2027-08-12']` would + // stringify to a valid date. + const releaseTimestamp = parseIsoDateToTimestamp(releaseUntil); + const usageTimestamp = parseIsoDateToTimestamp(usageUntil); + + if ((releaseTimestamp === null) === (usageTimestamp === null)) { + throw new TypeError('classifyEntitlement: the entry has to carry exactly one valid date. ' + + 'Pass an entry read by extractEntitlementKeyData().'); + } + + if (releaseTimestamp !== null) { + // Fail OPEN when the build release date is missing (a bundler consuming + // the source without the build-time define step, a broken build): a paying + // customer must never be told their license lapsed because of a build + // defect. The comparison as text is exact because both sides are + // "YYYY-MM-DD". + const covered = buildDate === '' || (releaseUntil as string) >= buildDate; + + return Object.freeze({ + state: covered ? 'release_valid' : 'release_expired', + isTrial, + daysRemaining: null, + licensedUntil: releaseUntil as string, + }); + } + + const expiryTimestamp = usageTimestamp as number; + const window = resolveUsageWindow(expiryTimestamp, entitlement, time.now); + + return Object.freeze({ + state: `${isTrial ? 'trial' : 'usage'}_${window}` as LicenseState, + isTrial, + daysRemaining: daysUntil(expiryTimestamp, time.now), + licensedUntil: usageUntil as string, + }); +} + +/** + * Reads which notification channels a product entitlement leaves open. Both + * flags are per product and default to open; a key issued for external, + * end-user-facing use carries both. + * + * @param {ProductEntitlement} entitlement The verified product entry. + * @returns {LicenseChannels} + */ +export function resolveChannels(entitlement: ProductEntitlement): LicenseChannels { + return Object.freeze({ + console: entitlement.flags.indexOf(NO_CONSOLE_WARNS_FLAG) === -1, + ui: entitlement.flags.indexOf(NO_UI_WARNS_FLAG) === -1, + }); +} diff --git a/src/license/handsontable-license-key-parser/constants.ts b/src/license/handsontable-license-key-parser/constants.ts new file mode 100644 index 0000000000..71064b1f91 --- /dev/null +++ b/src/license/handsontable-license-key-parser/constants.ts @@ -0,0 +1,79 @@ +/** + * The length of the checksum (SHA-512 as hex) that closes the machine-readable + * block of every entitlement license key. + * + * @type {number} + */ +export const CHECKSUM_LENGTH = 128; + +/** + * The length of the prose digest (the first 64 hex characters of a SHA-512, + * 256 bits) that a version 2 payload carries as `prose`. + * + * @type {number} + */ +export const PROSE_DIGEST_LENGTH = 64; + +/** + * The two mutually exclusive date fields of a product entry. Exactly one of + * them is present: + * + * - "usage_until" the last licensed day (inclusive, compared in UTC), + * - "release_until" builds released on or before that day may be used + * forever (compared against the build release date as + * text, no clock involved). + * + * The pair replaces the contract type - nothing in the payload says + * "subscription" or "perpetual". + * + * @type {string[]} + */ +export const DATE_FIELDS = ['usage_until', 'release_until']; + +/** + * Marks a license as an evaluation one. It changes how a license is WORDED and + * whether the hard stop blocks the product - never how its dates are measured. + * A flag is present or absent - there is no `false` value - and an + * unrecognized flag is ignored, for the same reason an unrecognized capability + * token is. + * + * @type {string} + */ +export const TRIAL_FLAG = 'trial'; + +/** + * Closes the console channel: nothing the license has to say reaches it. + * + * @type {string} + */ +export const NO_CONSOLE_WARNS_FLAG = 'no-console-warns'; + +/** + * Closes the UI WARNING surfaces (a banner, a badge, a popover). Both this flag + * and the one above are the default for a key issued for external, + * end-user-facing use. + * + * It does NOT close the trial hard-stop block. The block is enforcement rather + * than a warning. This is a product decision (DEV-2709, Handsontable); the + * specification's S4.1 table header does not yet record the split, so do not + * "restore" the other reading from the spec alone. + * + * @type {string} + */ +export const NO_UI_WARNS_FLAG = 'no-ui-warns'; + +/** + * Marks an individually negotiated key. Reserved - it changes nothing a reader + * does today, and is listed only so a reader knows the name is taken. + * + * @type {string} + */ +export const CUSTOM_FLAG = 'custom'; + +/** + * The number of milliseconds in a day, used to walk from one UTC midnight to + * the next. + * + * @type {number} + */ +export const MILLISECONDS_PER_DAY = 86400000; diff --git a/src/license/handsontable-license-key-parser/detectFormat.ts b/src/license/handsontable-license-key-parser/detectFormat.ts new file mode 100644 index 0000000000..2bb4230fa4 --- /dev/null +++ b/src/license/handsontable-license-key-parser/detectFormat.ts @@ -0,0 +1,78 @@ +import type { LicenseKeyFormat } from './types'; + +/** + * The classic 25-character key, once its dashes are stripped. + * + * @type {RegExp} + */ +const LEGACY_KEY = /^[0-9a-fA-F]{25}$/; + +/** + * Tells which license key format a string is in, without validating it. + * + * The entitlement key format has no leading type tag, so a key no longer + * announces itself in its first characters - it ends with the bracketed + * machine-readable block instead. A product that accepts several formats needs + * one place that makes the distinction, and this is it. + * + * The answer is about SHAPE only. A returned "entitlement" means "route this to + * `readEntitlementLicense`", not "this key is valid". A returned "legacy" means + * "route this to the product's own legacy validator" - this module does not + * read the legacy format. + * + * Surrounding whitespace is ignored: a key pasted out of an email or a chat + * window commonly carries a trailing space or newline. `extractEntitlementKeyData` + * ignores whitespace inside the brackets too, so a block a mail client + * wrapped still reads. + * + * @param {*} licenseKey The license key to inspect. + * @param {string[]} [literalKeys] The plain words this product accepts as a key + * (for example "non-commercial-and-evaluation"). Compared trimmed and + * case-insensitively. Which words are accepted is the product's decision, so + * none is built in. A blank entry is ignored - it would otherwise turn a + * missing key into a literal one. + * @returns {LicenseKeyFormat} + */ +export function detectLicenseKeyFormat( + licenseKey: unknown, + literalKeys?: readonly string[] | null, +): LicenseKeyFormat { + if (typeof licenseKey !== 'string') { + return 'unknown'; + } + + const key = licenseKey.trim(); + const lowerCaseKey = key.toLowerCase(); + const isLiteral = Array.isArray(literalKeys) && literalKeys.some((literalKey: unknown) => ( + typeof literalKey === 'string' && literalKey.trim() !== '' && literalKey.trim().toLowerCase() === lowerCaseKey + )); + + if (isLiteral) { + return 'literal'; + } + + // The bracketed block closes an entitlement key. Its presence is what + // separates the new format from everything else, so it is checked before the + // shape-based ones. + const blockStart = key.lastIndexOf('['); + + if (blockStart !== -1 && key.indexOf(']', blockStart) !== -1) { + return 'entitlement'; + } + if (LEGACY_KEY.test(key.replace(/-/g, ''))) { + return 'legacy'; + } + + return 'unknown'; +} + +/** + * Tells whether a license key has the shape of an entitlement key - the one + * check a product needs to route a key away from its legacy path. + * + * @param {*} licenseKey The license key to inspect. + * @returns {boolean} + */ +export function isEntitlementKey(licenseKey: unknown): boolean { + return detectLicenseKeyFormat(licenseKey) === 'entitlement'; +} diff --git a/src/license/handsontable-license-key-parser/encoding.ts b/src/license/handsontable-license-key-parser/encoding.ts new file mode 100644 index 0000000000..d29fa6cf0d --- /dev/null +++ b/src/license/handsontable-license-key-parser/encoding.ts @@ -0,0 +1,226 @@ +/* eslint-disable no-bitwise */ + +/** + * The base64 alphabet. + * + * @type {string} + */ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** + * Encodes the string as UTF-8 bytes. The plain implementation is used on purpose. + * It does not depend on `TextEncoder` or `Buffer`, so the same code works in + * Node.js and in every browser, including plain http:// pages. + * + * @param {string} string The string to encode. + * @returns {number[]} + */ +export function stringToUtf8Bytes(string: string): number[] { + const bytes = []; + + for (let i = 0; i < string.length; i += 1) { + let codePoint = string.charCodeAt(i); + + // Combine a surrogate pair into a single code point. + if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < string.length) { + const lowSurrogate = string.charCodeAt(i + 1); + + if (lowSurrogate >= 0xdc00 && lowSurrogate <= 0xdfff) { + codePoint = ((codePoint - 0xd800) * 0x400) + (lowSurrogate - 0xdc00) + 0x10000; + i += 1; + } + } + + if (codePoint < 0x80) { + bytes.push(codePoint); + } else if (codePoint < 0x800) { + bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)); + } else if (codePoint < 0x10000) { + bytes.push( + 0xe0 | (codePoint >> 12), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ); + } else { + bytes.push( + 0xf0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3f), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ); + } + } + + return bytes; +} + +/** + * Decodes UTF-8 bytes back into a string. + * + * @param {number[]} bytes The bytes to decode. + * @returns {string} + */ +export function utf8BytesToString(bytes: number[]): string { + let string = ''; + let i = 0; + + while (i < bytes.length) { + const byte = bytes[i]; + let codePoint; + + if (byte < 0x80) { + codePoint = byte; + i += 1; + } else if (byte < 0xe0) { + codePoint = ((byte & 0x1f) << 6) | (bytes[i + 1] & 0x3f); + i += 2; + } else if (byte < 0xf0) { + codePoint = ((byte & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f); + i += 3; + } else { + codePoint = ((byte & 0x07) << 18) | ((bytes[i + 1] & 0x3f) << 12) + | ((bytes[i + 2] & 0x3f) << 6) | (bytes[i + 3] & 0x3f); + i += 4; + } + + if (codePoint >= 0x10000) { + // Split the code point back into a surrogate pair. + codePoint -= 0x10000; + string += String.fromCharCode(0xd800 + (codePoint >> 10), 0xdc00 + (codePoint & 0x3ff)); + } else { + string += String.fromCharCode(codePoint); + } + } + + return string; +} + +/** + * Encodes the bytes as a base64 string (standard alphabet, with padding). + * + * @param {number[]} bytes The bytes to encode. + * @returns {string} + */ +export function bytesToBase64(bytes: number[]): string { + let base64 = ''; + + for (let i = 0; i < bytes.length; i += 3) { + const byte1 = bytes[i]; + const byte2 = bytes[i + 1]; + const byte3 = bytes[i + 2]; + + base64 += BASE64_ALPHABET.charAt(byte1 >> 2); + base64 += BASE64_ALPHABET.charAt(((byte1 & 0x03) << 4) | (byte2 === undefined ? 0 : byte2 >> 4)); + base64 += byte2 === undefined + ? '=' : BASE64_ALPHABET.charAt(((byte2 & 0x0f) << 2) | (byte3 === undefined ? 0 : byte3 >> 6)); + base64 += byte3 === undefined ? '=' : BASE64_ALPHABET.charAt(byte3 & 0x3f); + } + + return base64; +} + +/** + * Decodes a base64 string (standard or URL-safe alphabet, padding optional) + * back into bytes. Returns `null` when the string is not valid base64. + * + * @param {string} base64 The base64 string to decode. + * @returns {number[]|null} + */ +export function base64ToBytes(base64: string): number[] | null { + const normalized = `${base64}`.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, ''); + + if (!/^[A-Za-z0-9+/]*$/.test(normalized) || normalized.length % 4 === 1) { + return null; + } + + const bytes = []; + + for (let i = 0; i < normalized.length; i += 4) { + const chunk = [0, 1, 2, 3].map((offset) => { + const char = normalized.charAt(i + offset); + + // `indexOf('')` would return 0, so the missing characters of the last + // chunk have to be mapped to -1 explicitly. + return char === '' ? -1 : BASE64_ALPHABET.indexOf(char); + }); + + bytes.push((chunk[0] << 2) | (chunk[1] >> 4)); + + if (chunk[2] !== -1) { + bytes.push(((chunk[1] & 0x0f) << 4) | (chunk[2] >> 2)); + } + if (chunk[3] !== -1) { + bytes.push(((chunk[2] & 0x03) << 6) | chunk[3]); + } + } + + return bytes; +} + +/** + * Encodes the string as a URL-safe base64 string without padding (the same + * format as the JWT payload segment). + * + * @param {string} string The string to encode. + * @returns {string} + */ +export function stringToBase64Url(string: string): string { + return bytesToBase64(stringToUtf8Bytes(string)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Decodes a base64 (standard or URL-safe) string back into a string. + * Returns `null` when the input is not valid base64. + * + * @param {string} base64 The base64 string to decode. + * @returns {string|null} + */ +export function base64ToString(base64: string): string | null { + const bytes = base64ToBytes(base64); + + return bytes === null ? null : utf8BytesToString(bytes); +} + +/** + * Parses a date in the "YYYY-MM-DD" format into the epoch milliseconds of its + * UTC midnight. Returns `null` when the date is malformed or does not exist in + * the calendar (for example "2027-02-30"). Unlike the generator side, the + * reader never throws on a bad date - a broken payload simply makes the key + * unreadable. + * + * @param {*} isoDate The date to parse. Anything but a string is not a date. + * @returns {number|null} + */ +export function parseIsoDateToTimestamp(isoDate: unknown): number | null { + // Only a string is a date. Checked before the text test on purpose: an array + // like `['2027-08-12']` stringifies to a valid date and would otherwise pass. + const match = typeof isoDate === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate) : null; + + if (match === null) { + return null; + } + + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + + // Date.UTC maps years 0-99 to 1900-1999, which would make the round-trip + // check below report a "not a valid calendar date" lie. + if (year < 100) { + return null; + } + + const timestamp = Date.UTC(year, month - 1, day); + const date = new Date(timestamp); + + // An impossible date (e.g. "2027-02-30") makes `Date.UTC` roll over to + // the next month, so a round-trip comparison catches it. + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + return null; + } + + return timestamp; +} diff --git a/src/license/handsontable-license-key-parser/extractKeyData.ts b/src/license/handsontable-license-key-parser/extractKeyData.ts new file mode 100644 index 0000000000..0316e3cc57 --- /dev/null +++ b/src/license/handsontable-license-key-parser/extractKeyData.ts @@ -0,0 +1,519 @@ +import type { EntitlementKeyData, ProductEntitlement } from './types'; +import { CHECKSUM_LENGTH, DATE_FIELDS, PROSE_DIGEST_LENGTH } from './constants'; +import { sha512 } from './sha512'; +import { base64ToString, stringToUtf8Bytes, parseIsoDateToTimestamp } from './encoding'; + +/** + * The alphabet of the encoded payload - URL-safe base64 without padding. The + * checksum (lowercase hex) is a subset of it, which is what lets the two be + * split by a fixed length from the right. + * + * @type {RegExp} + */ +const ENCODED_PAYLOAD = /^[A-Za-z0-9\-_]+$/; +const CHECKSUM = /^[0-9a-f]+$/; + +/** + * The whitespace removed from the prose before its digest is taken: TAB, LF, + * VT, FF, CR, SPACE, NO-BREAK SPACE, OGHAM SPACE MARK, the U+2000-U+200A spaces, + * LINE SEPARATOR, PARAGRAPH SEPARATOR, NARROW NO-BREAK SPACE, MEDIUM + * MATHEMATICAL SPACE, IDEOGRAPHIC SPACE and the BOM. + * + * Listed explicitly instead of `\s`, whose set has changed between JavaScript + * engines (U+180E) and differs in other languages (U+0085), so a reader written + * anywhere can reproduce it exactly. + * + * @type {RegExp} + */ +const PROSE_WHITESPACE = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g; + +/** + * A line break or tab that was saved as text - a backslash followed by "n", + * "r" or "t" - also removed before the digest of the prose is taken. + * + * Several places a key is stored keep a line break that way rather than as a + * real one: a single-quoted or unquoted `.env` value, Docker's `--env-file`, + * and many CI secret fields. The block survives that intact, so without this + * a genuine key would fail only because of how it was stored. The generator + * strips backslashes from every free-text field, so a key it issues never + * carries one of its own. + * + * @type {RegExp} + */ +const ESCAPED_WHITESPACE = /\\[nrt]/g; + +/** + * Removes every escaped line break or tab (`\n`, `\r`, `\t` saved as text) + * and then every whitespace character - the first two steps of + * `canonicalizeProse`, and all that is done to the content of the block. + * + * The block is one long word, so a mail client or an editor that wraps the key + * can break it across lines - and a `.env` file can save that line break as + * the text `\n`. Neither base64url nor hex contains whitespace or a backslash, + * so removing them cannot change a genuine block. The block is not put in NFC: + * it is ASCII, and anything else in it fails the alphabet check anyway. + * + * The escapes go first, so a backslash, a space and "n" stay three characters. + * + * @param {string} text The text to strip. + * @returns {string} + */ +function removeWhitespace(text: string): string { + return text.replace(ESCAPED_WHITESPACE, '').replace(PROSE_WHITESPACE, ''); +} + +/** + * Brings the human-readable text of a key to the form the prose digest covers: + * every escaped line break or tab (`\n`, `\r`, `\t` saved as text) and every + * whitespace character removed, then Unicode NFC. + * + * Only the whitespace, its escaped forms and the Unicode composition are + * ignored. A mail client that rewraps the text - also between two CJK + * characters or inside a word - collapses a blank line, a `.env` file that + * saves a line break as `\n`, or a system that stores "u" + U+0308 for "u" + * leaves the key valid. A changed, added or removed letter, digit or symbol + * does not. + * + * Exported for test helpers; products call `extractEntitlementKeyData`. + * + * @param {string} prose The text in front of the machine-readable block. + * @returns {string} + */ +export function canonicalizeProse(prose: string): string { + // NFC runs last: a line break between a letter and its combining mark (an + // NFD copy rewrapped there) has to be gone before the two can compose. + return removeWhitespace(prose).normalize('NFC'); +} + +/** + * Computes the checksum that closes the block of every key: the SHA-512 + * (lowercase hex) of the UTF-8 bytes of the encoded payload. It is the same in + * every format version, which is what lets a reader that only knows version 1 + * read a newer key. + * + * Named apart from the 5.0.0 `computeChecksum(canonicalProse, encodedPayload)` + * on purpose: a test helper written for that one fails to import instead of + * hashing the wrong input. + * + * Exported for test helpers; products call `extractEntitlementKeyData`. + * + * @param {string} encodedPayload The base64url payload. + * @returns {string} + */ +export function computePayloadChecksum(encodedPayload: string): string { + return sha512(stringToUtf8Bytes(encodedPayload)); +} + +/** + * Computes the prose digest a version 2 payload carries as `prose`: the first + * 64 hex characters of the SHA-512 of the UTF-8 bytes of the canonical prose. + * + * Exported for test helpers; products call `extractEntitlementKeyData`. + * + * @param {string} canonicalProse The prose, already passed through + * `canonicalizeProse`. + * @returns {string} + */ +export function computeProseDigest(canonicalProse: string): string { + return sha512(stringToUtf8Bytes(canonicalProse)).slice(0, PROSE_DIGEST_LENGTH); +} + +/** + * Reports own-property presence without trusting a payload's inherited or + * overridden `hasOwnProperty`. + * + * @param {object} object The object to inspect. + * @param {string} key The property name to look up. + * @returns {boolean} + */ +function hasOwn(object: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key); +} + +/** + * Narrows an unknown value to a plain (non-null, non-array) object. + * + * @param {*} value The value to check. + * @returns {boolean} + */ +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Returns `true` when the value is a non-negative integer. `Number.isFinite` + * also rejects `Infinity` (JSON `1e999` parses to it), which would otherwise + * turn a window size into a non-finite date later on. + * + * @param {*} value The value to check. + * @returns {boolean} + */ +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && Math.floor(value) === value && value >= 0; +} + +/** + * Returns `true` when the value is an array of strings. + * + * @param {*} value The value to check. + * @returns {boolean} + */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === 'string'); +} + +/** + * Freezes the value and everything nested in it. The read result is shared + * between callers (see the memo below), so a caller that sorted or pushed into + * it would silently rewrite what the next caller reads. + * + * It walks with an explicit stack, not by recursion. An unknown extra field is + * kept as it is, and a key can nest one thousands of levels deep - recursion + * would then overflow the call stack and throw out of the reader, where a + * key is only ever allowed to read as data or as `null`. + * + * @param {*} value The value to freeze. + * @returns {*} + */ +function deepFreeze(value: T): T { + const pending: unknown[] = [value]; + + while (pending.length > 0) { + const current = pending.pop(); + + if (current !== null && typeof current === 'object' && !Object.isFrozen(current)) { + Object.freeze(current); + Object.keys(current as object).forEach(key => pending.push((current as Record)[key])); + } + } + + return value; +} + +/** + * Adds an own, ordinary property. + * + * Both the product names and the field names of a product entry come from + * JSON, so "__proto__" is a name an attacker can put in a key. A plain + * assignment would go through the `Object.prototype` setter: the value would + * vanish from `Object.keys` while still resolving through the chain. Exported + * because every payload-keyed write in this module must use it - `grants.ts` + * re-keys the same product names. + * + * @param {object} target The object to add the property to. + * @param {string} key The property name. + * @param {*} value The property value. + * @returns {void} + */ +export function defineOwn(target: object, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, enumerable: true, writable: true, configurable: true, + }); +} + +/** + * Verifies and normalizes one product entry. + * + * Strict about SHAPE: exactly one of the two dates, a real calendar date, and + * the two window sizes. A key that gets this wrong is malformed, not merely + * unknown, and reading it would mean guessing what was licensed. + * + * Lenient about VOCABULARY: an unrecognized capability token, an unrecognized + * flag and an unrecognized extra field are all kept and ignored. Without that + * leniency every token added on the issuing side would break every build + * already deployed in the field. + * + * Returns `null` when the entry is malformed. + * + * @param {*} entry The product entry of the payload. + * @returns {ProductEntitlement|null} + */ +function normalizeProductEntry(entry: unknown): ProductEntitlement | null { + if (!isPlainObject(entry)) { + return null; + } + if (!isStringArray(entry.capabilities)) { + return null; + } + + const presentDateFields = DATE_FIELDS.filter(field => entry[field] !== undefined); + + // Exactly one date per product. "Both" and "neither" are each a different + // commercial shape that the format cannot express, so neither may be silently + // resolved by whichever field the reader happens to look at first. + if (presentDateFields.length !== 1) { + return null; + } + if (parseIsoDateToTimestamp(entry[presentDateFields[0]]) === null) { + return null; + } + if (!isNonNegativeInteger(entry.notice) || !isNonNegativeInteger(entry.grace)) { + return null; + } + if (entry.flags !== undefined && !isStringArray(entry.flags)) { + return null; + } + + // Start from everything the entry carries, so a field this version does not + // know survives into the result instead of being silently dropped. A field + // added to the format later is exactly the case a vendored reader has to + // survive, and one that quietly discards it makes the field invisible to the + // layers above. + const normalized = {} as ProductEntitlement; + + Object.keys(entry).forEach(field => defineOwn(normalized, field, entry[field])); + + defineOwn(normalized, 'capabilities', entry.capabilities.slice()); + defineOwn(normalized, 'notice', entry.notice); + defineOwn(normalized, 'grace', entry.grace); + // An absent array and an empty one mean the same thing. Normalizing here + // keeps `flags.indexOf('trial')` safe at every call site. + defineOwn(normalized, 'flags', entry.flags === undefined ? [] : entry.flags.slice()); + defineOwn(normalized, presentDateFields[0], entry[presentDateFields[0]]); + + return normalized; +} + +/** + * Reads the format version of a payload: 1 when it has no `v` (the keys issued + * before the field existed), the value of `v` otherwise. Returns `null` when + * `v` is there but is not a whole number of at least 2 - no generator ever + * wrote such a key. + * + * A version newer than this reader knows is accepted. A later version may add + * fields, but it keeps the block checksum and the prose digest, so this reader + * still verifies everything it knows about. + * + * Only an own `v` counts. Read through the prototype chain, one property + * another script set on `Object.prototype` would decide how every key reads. + * + * @param {Record} payload The decoded payload. + * @returns {number|null} + */ +function readFormatVersion(payload: Record): number | null { + if (!hasOwn(payload, 'v')) { + return 1; + } + if (!isNonNegativeInteger(payload.v) || payload.v < 2) { + return null; + } + + return payload.v; +} + +/** + * Tells whether a version 2 key covers its prose: nothing but whitespace + * follows the block, the prose is not empty, and its digest is the one the + * payload carries. + * + * Text after the block would be words nothing covers. A key always states its + * terms, so the bare block is rejected even with a digest computed over empty + * prose. + * + * @param {string} licenseKey The whole key. + * @param {number} blockStart The index of the block's "[". + * @param {number} blockEnd The index of the block's "]". + * @param {Record} payload The decoded payload. + * @returns {boolean} + */ +function coversProse( + licenseKey: string, + blockStart: number, + blockEnd: number, + payload: Record, +): boolean { + // Judged by the same rule as the prose, so a trailing line break saved as + // text ("\n") is allowed too. + if (canonicalizeProse(licenseKey.slice(blockEnd + 1)) !== '') { + return false; + } + + const canonicalProse = canonicalizeProse(licenseKey.slice(0, blockStart)); + + return canonicalProse !== '' + && hasOwn(payload, 'prose') + && typeof payload.prose === 'string' + && computeProseDigest(canonicalProse) === payload.prose; +} + +/** + * Reads and verifies one key. Split out from the memoized public entry point so + * the memo can wrap every exit path uniformly. + * + * @param {string} licenseKey The license key to read. + * @returns {EntitlementKeyData|null} + */ +function readEntitlementKeyData(licenseKey: string): EntitlementKeyData | null { + // The machine-readable block closes the key. Searching backwards means a + // bracket inside the prose cannot shadow it. + const blockStart = licenseKey.lastIndexOf('['); + + if (blockStart === -1) { + return null; + } + + const blockEnd = licenseKey.indexOf(']', blockStart); + + if (blockEnd === -1) { + return null; + } + + const content = removeWhitespace(licenseKey.slice(blockStart + 1, blockEnd)); + + if (content.length <= CHECKSUM_LENGTH) { + return null; + } + + const encodedPayload = content.slice(0, -CHECKSUM_LENGTH); + const checksum = content.slice(-CHECKSUM_LENGTH); + + if (!ENCODED_PAYLOAD.test(encodedPayload) || !CHECKSUM.test(checksum)) { + return null; + } + + if (computePayloadChecksum(encodedPayload) !== checksum) { + return null; + } + + const payloadJson = base64ToString(encodedPayload); + + if (payloadJson === null) { + return null; + } + + let payload: unknown; + + try { + payload = JSON.parse(payloadJson); + } catch (error) { + return null; + } + + if (!isPlainObject(payload) || !isPlainObject(payload.products)) { + return null; + } + + const version = readFormatVersion(payload); + + if (version === null) { + return null; + } + // The prose is canonicalized only here, after the checksum passed: a version + // 1 key and a broken one never pay for the NFC pass. + if (version >= 2 && !coversProse(licenseKey, blockStart, blockEnd, payload)) { + return null; + } + + const products = {} as EntitlementKeyData['products']; + const entries = payload.products; + let malformed = false; + + Object.keys(entries).forEach((name) => { + const entry = normalizeProductEntry(entries[name]); + + if (entry === null) { + malformed = true; + + return; + } + + defineOwn(products, name, entry); + }); + + if (malformed) { + return null; + } + + return deepFreeze({ version, products }); +} + +// A product typically reads its key more than once per start-up (a console +// message and a UI surface each resolve the license state), and reading runs +// the full SHA-512 + base64 + JSON parse. A one-entry memo on the key makes the +// second read free. The returned data is frozen, so sharing one object is safe. +let memoizedKey: string | null = null; +let memoizedData: EntitlementKeyData | null = null; + +/** + * Extracts the machine-readable data from an entitlement license key. + * + * The checksum is verified first, so the returned data is guaranteed to belong + * to an intact key. A malformed or tampered key reads as `null` - reporting an + * invalid key is the caller's job, not this function's. + * + * The checksum is the SHA-512 of the encoded payload in every version. The + * payload's `v` decides what else is checked: + * + * - version 2 and later (`v` in the payload): the payload's `prose` digest + * has to match the text in front of the block, and only whitespace may + * follow the block. A key whose prose was edited or removed (the bare + * `[...]` block) reads as `null`. + * - version 1 (no `v`), as license-key 4.x issued it: neither is checked, + * exactly as 4.x did not - the bare block, an edited prose and text after + * the block all read. Trial keys in that format are in production. + * + * The caller passes the whole key. The prose is never parsed, and its + * whitespace and Unicode composition are ignored, so rewrapped or re-pasted + * prose still validates. Whitespace inside the block is ignored too, so a + * block wrapped by a mail client still validates. + * + * Unknown products, capability tokens and flags are all tolerated, so nothing + * about reading a key depends on the commercial vocabulary. + * + * The result is frozen. Copy an array before sorting or changing it. + * + * @param {string} licenseKey The license key to extract the data from. + * @returns {EntitlementKeyData|null} + */ +export function extractEntitlementKeyData(licenseKey: string): EntitlementKeyData | null { + if (typeof licenseKey !== 'string') { + return null; + } + if (licenseKey !== memoizedKey) { + // Read first, remember second. Were the key remembered before the read, + // a read that throws would leave the new key paired with the previous + // key's data, and the next read of the new key would return it. + const data = readEntitlementKeyData(licenseKey); + + memoizedKey = licenseKey; + memoizedData = data; + } + + return memoizedData; +} + +/** + * Tells whether a license key is a well-formed, untampered entitlement key. + * + * The check is about integrity, not about entitlement: a key that is valid + * here may still be past its `usage_until` date, or may grant a product the + * caller does not care about. `readEntitlementLicense` answers those. + * + * @param {string} licenseKey The license key to check. + * @returns {boolean} + */ +export function validateEntitlementKey(licenseKey: string): boolean { + return extractEntitlementKeyData(licenseKey) !== null; +} + +/** + * Returns the entitlement of one product, but only when it is present as an own + * property. Used by the lifecycle and grants layers to read one product without + * trusting the prototype chain. + * + * @param {EntitlementKeyData} keyData The verified key data. + * @param {string} productName The product to read. + * @returns {ProductEntitlement|null} + */ +export function getProductEntitlement( + keyData: EntitlementKeyData, + productName: string, +): ProductEntitlement | null { + if (!hasOwn(keyData.products, productName)) { + return null; + } + + const product = keyData.products[productName]; + + return isPlainObject(product) ? product as ProductEntitlement : null; +} diff --git a/src/license/handsontable-license-key-parser/grants.ts b/src/license/handsontable-license-key-parser/grants.ts new file mode 100644 index 0000000000..7af5dfd39c --- /dev/null +++ b/src/license/handsontable-license-key-parser/grants.ts @@ -0,0 +1,101 @@ +import type { EntitlementKeyData, LicenseGrants } from './types'; +import { getProductEntitlement, defineOwn } from './extractKeyData'; + +/** + * The grants shared by every non-entitlement license state: a valid legacy + * commercial key, a legacy expired key, a literal key, a missing key, + * an invalid key, or an unreadable entitlement key. Every query short-circuits + * on `unrestricted`, so these keys unlock everything - introducing capability + * gating can never take a feature away from an existing customer. Frozen so it + * cannot be mutated by a consumer. + * + * @type {LicenseGrants} + */ +export const UNRESTRICTED_GRANTS: LicenseGrants = Object.freeze({ + unrestricted: true, + products: Object.freeze({}), +}); + +/** + * Builds the grants facet from a verified entitlement key: exactly the + * capability tokens the payload lists, per product. Entitlement keys are never + * unrestricted - they unlock only what they name. Frozen, like every result of + * this module, so the grants a product resolved once cannot be rewritten later. + * + * @param {EntitlementKeyData} keyData The verified key data. + * @returns {LicenseGrants} + */ +export function getLicenseGrants(keyData: EntitlementKeyData): LicenseGrants { + const products: LicenseGrants['products'] = {}; + + Object.keys(keyData.products).forEach((productName) => { + const entitlement = getProductEntitlement(keyData, productName); + + if (entitlement !== null) { + // Defined, not assigned: the product name comes from the key's JSON, and a plain assignment + // of "__proto__" would hit the `Object.prototype` setter - replacing the prototype of + // `products` instead of adding the grant, which then reads back as not granted. + defineOwn(products, productName, Object.freeze({ + capabilities: Object.freeze(entitlement.capabilities.slice()), + })); + } + }); + + return Object.freeze({ + unrestricted: false, + products: Object.freeze(products), + }); +} + +/** + * Tells whether the license grants a product. An unrestricted license grants + * every product; an entitlement license grants a product only when its payload + * lists it. + * + * @param {LicenseGrants} grants The resolved grants. + * @param {string} productName The product to check. + * @returns {boolean} + */ +export function hasProductGrant(grants: LicenseGrants, productName: string): boolean { + return grants.unrestricted || Object.prototype.hasOwnProperty.call(grants.products, productName); +} + +/** + * Returns the capability tokens a license grants for a product, or `null` when + * the product is not granted. An unrestricted license reports no token list - + * it answers "granted" to every capability query instead, so a caller that + * needs a yes/no answer asks `hasCapability`. + * + * @param {LicenseGrants} grants The resolved grants. + * @param {string} productName The product to read. + * @returns {string[]|null} + */ +export function getProductCapabilities(grants: LicenseGrants, productName: string): string[] | null { + if (grants.unrestricted || !hasProductGrant(grants, productName)) { + return null; + } + + // A copy: the grants are frozen, and a caller asking for a plain token list + // usually wants one it can sort or extend. + return grants.products[productName].capabilities.slice(); +} + +/** + * Tells whether the license unlocks a capability for a product. An unrestricted + * license unlocks every capability - even tokens that do not exist yet. An + * entitlement license unlocks a capability only when its payload lists the + * token for that product. + * + * @param {LicenseGrants} grants The resolved grants. + * @param {string} productName The product the capability belongs to. + * @param {string} capability The capability token to check. + * @returns {boolean} + */ +export function hasCapability(grants: LicenseGrants, productName: string, capability: string): boolean { + if (grants.unrestricted) { + return true; + } + + return hasProductGrant(grants, productName) && + grants.products[productName].capabilities.indexOf(capability) !== -1; +} diff --git a/src/license/handsontable-license-key-parser/index.ts b/src/license/handsontable-license-key-parser/index.ts new file mode 100644 index 0000000000..e1f4129690 --- /dev/null +++ b/src/license/handsontable-license-key-parser/index.ts @@ -0,0 +1,70 @@ +/** + * The entitlement license key reader - the part of `handsontable/license-key` + * that products copy into their own source tree. + * + * Copy this whole directory. Do not edit the copy: change the original in + * `handsontable/license-key` (`vendor/entitlement-key-reader/`) and copy it + * again. See `README.md` in this directory. + */ +import { detectLicenseKeyFormat, isEntitlementKey } from './detectFormat'; +import { + extractEntitlementKeyData, + validateEntitlementKey, + getProductEntitlement, +} from './extractKeyData'; +import { classifyEntitlement, resolveChannels } from './classify'; +import { + UNRESTRICTED_GRANTS, + getLicenseGrants, + hasProductGrant, + getProductCapabilities, + hasCapability, +} from './grants'; +import { readEntitlementLicense } from './readLicense'; +import { toIsoBuildDate } from './buildDate'; +import { + TRIAL_FLAG, + NO_CONSOLE_WARNS_FLAG, + NO_UI_WARNS_FLAG, + CUSTOM_FLAG, +} from './constants'; + +export type { + LicenseKeyFormat, + ProductEntitlement, + EntitlementKeyData, + LicenseState, + LicenseLifecycle, + LicenseChannels, + LicenseGrants, + LicenseTimeReference, + UnlicensedReason, + EntitlementLicense, + ReadEntitlementLicenseOptions, +} from './types'; + +export { + // The one call a product needs. + readEntitlementLicense, + toIsoBuildDate, + // Routing a key to the right validator. + detectLicenseKeyFormat, + isEntitlementKey, + // The building blocks `readEntitlementLicense` is made of. + extractEntitlementKeyData, + validateEntitlementKey, + getProductEntitlement, + classifyEntitlement, + resolveChannels, + // Capability gating. + UNRESTRICTED_GRANTS, + getLicenseGrants, + hasProductGrant, + getProductCapabilities, + hasCapability, + // The flag names, for a product that reads one directly. + TRIAL_FLAG, + NO_CONSOLE_WARNS_FLAG, + NO_UI_WARNS_FLAG, + CUSTOM_FLAG, +}; diff --git a/src/license/handsontable-license-key-parser/readLicense.ts b/src/license/handsontable-license-key-parser/readLicense.ts new file mode 100644 index 0000000000..26a14dee43 --- /dev/null +++ b/src/license/handsontable-license-key-parser/readLicense.ts @@ -0,0 +1,132 @@ +import type { + EntitlementLicense, + LicenseChannels, + ReadEntitlementLicenseOptions, + UnlicensedReason, +} from './types'; +import { extractEntitlementKeyData, getProductEntitlement } from './extractKeyData'; +import { classifyEntitlement, resolveChannels } from './classify'; +import { UNRESTRICTED_GRANTS, getLicenseGrants } from './grants'; +import { resolveBuildDate } from './buildDate'; + +/** + * Both notification channels open - what a key gets when its flags could not + * be read. + * + * @type {LicenseChannels} + */ +const OPEN_CHANNELS: LicenseChannels = Object.freeze({ console: true, ui: true }); + +/** + * Builds the result for a key that does not license the product. The + * overloads tie the version to the reason: a key that grants other products + * was read, so it has one; an unreadable key has none. + * + * @param {UnlicensedReason} reason Why the key does not license the product. + * @param {number|null} version The format version of the key, or `null` when + * it could not be read. + * @returns {EntitlementLicense} + */ +function unlicensed(reason: 'unreadable', version: null): EntitlementLicense; +function unlicensed(reason: 'product_missing', version: number): EntitlementLicense; +function unlicensed(reason: UnlicensedReason, version: number | null): EntitlementLicense { + return Object.freeze({ + licensed: false, + reason, + version, + entitlement: null, + lifecycle: null, + channels: OPEN_CHANNELS, + grants: UNRESTRICTED_GRANTS, + }) as EntitlementLicense; +} + +/** + * Reads an entitlement license key for one product, in one call: verifies the + * key (the checksum, and from version 2 the prose digest), picks the product's + * entry, places it in its lifecycle window, reads + * its silencing flags and resolves what it unlocks. + * + * This is the single entry point a product needs. Route a key here only when + * `detectLicenseKeyFormat` says "entitlement" - legacy and literal keys are + * the product's own path. + * + * A key that grants other products but not this one is NOT a license for this + * product, however many others it grants. It reads as `licensed: false` with + * the reason `product_missing`, and the product reports it as an invalid key. + * Another product's entry never invalidates the key on its own: one install + * can be licensed for one product and not for another. + * + * The grants of an unlicensed key stay UNRESTRICTED on purpose. An invalid key + * nags - it does not strip features - so introducing capability gating can + * never take a feature away from a customer whose key failed to read. + * + * The clock is read only for a `usage_until` entry, and only when `now` is not + * passed. A `release_until` entry never reads it. + * + * A missing build date fails OPEN; a build date that is present but not a + * bare "YYYY-MM-DD" throws - see `resolveBuildDate`. Both are checked before + * the key is read, so the error shows whatever key a product's tests use. + * + * The result is frozen, whichever way it goes. + * + * @param {string} licenseKey The license key, as the user supplied it. + * @param {ReadEntitlementLicenseOptions} options What the product tells the reader. + * @param {string} options.product The product name this build reads its license from. + * @param {string} options.buildDate The build release date, "YYYY-MM-DD". + * @param {number} [options.now] The current instant, in epoch milliseconds. + * @returns {EntitlementLicense} + */ +export function readEntitlementLicense( + licenseKey: string, + options: ReadEntitlementLicenseOptions, +): EntitlementLicense { + // A wrong argument is the product's bug, not the customer's, so it throws - + // a silent "invalid" would make every key look broken with no clue why. + if (options === null || typeof options !== 'object') { + throw new TypeError('readEntitlementLicense: pass the options - { product, buildDate, now? }.'); + } + + const { product, now } = options; + const buildDate = resolveBuildDate(options.buildDate, 'readEntitlementLicense'); + + if (typeof product !== 'string' || product === '') { + throw new TypeError('readEntitlementLicense: "product" has to be a non-empty product name.'); + } + if (now !== undefined && (typeof now !== 'number' || !Number.isFinite(now))) { + throw new TypeError('readEntitlementLicense: "now" has to be epoch milliseconds.'); + } + + const keyData = extractEntitlementKeyData(licenseKey); + + if (keyData === null) { + return unlicensed('unreadable', null); + } + + const entitlement = getProductEntitlement(keyData, product); + + if (entitlement === null) { + return unlicensed('product_missing', keyData.version); + } + + // The clock is read once, so every window boundary is measured against the + // same instant, and only for a `usage_until` entry - a `release_until` one + // never reads it (fixture J9), so it gets a value nothing looks at. + let currentTime = NaN; + + if (entitlement.usage_until !== undefined) { + currentTime = now === undefined ? Date.now() : now; + } + + const lifecycle = classifyEntitlement(entitlement, { now: currentTime, buildDate }); + + return Object.freeze({ + licensed: true, + reason: null, + version: keyData.version, + entitlement, + lifecycle, + channels: resolveChannels(entitlement), + grants: getLicenseGrants(keyData), + }); +} diff --git a/src/license/handsontable-license-key-parser/sha512.ts b/src/license/handsontable-license-key-parser/sha512.ts new file mode 100644 index 0000000000..3e479d7790 --- /dev/null +++ b/src/license/handsontable-license-key-parser/sha512.ts @@ -0,0 +1,211 @@ +/* eslint-disable no-bitwise */ + +/** + * The SHA-512 round constants. Each 64-bit constant is stored as a pair + * of 32-bit integers (high word first, low word second). + * + * @type {number[]} + */ +const K = [ + 0x428a2f98, 0xd728ae22, 0x71374491, 0x23ef65cd, + 0xb5c0fbcf, 0xec4d3b2f, 0xe9b5dba5, 0x8189dbbc, + 0x3956c25b, 0xf348b538, 0x59f111f1, 0xb605d019, + 0x923f82a4, 0xaf194f9b, 0xab1c5ed5, 0xda6d8118, + 0xd807aa98, 0xa3030242, 0x12835b01, 0x45706fbe, + 0x243185be, 0x4ee4b28c, 0x550c7dc3, 0xd5ffb4e2, + 0x72be5d74, 0xf27b896f, 0x80deb1fe, 0x3b1696b1, + 0x9bdc06a7, 0x25c71235, 0xc19bf174, 0xcf692694, + 0xe49b69c1, 0x9ef14ad2, 0xefbe4786, 0x384f25e3, + 0x0fc19dc6, 0x8b8cd5b5, 0x240ca1cc, 0x77ac9c65, + 0x2de92c6f, 0x592b0275, 0x4a7484aa, 0x6ea6e483, + 0x5cb0a9dc, 0xbd41fbd4, 0x76f988da, 0x831153b5, + 0x983e5152, 0xee66dfab, 0xa831c66d, 0x2db43210, + 0xb00327c8, 0x98fb213f, 0xbf597fc7, 0xbeef0ee4, + 0xc6e00bf3, 0x3da88fc2, 0xd5a79147, 0x930aa725, + 0x06ca6351, 0xe003826f, 0x14292967, 0x0a0e6e70, + 0x27b70a85, 0x46d22ffc, 0x2e1b2138, 0x5c26c926, + 0x4d2c6dfc, 0x5ac42aed, 0x53380d13, 0x9d95b3df, + 0x650a7354, 0x8baf63de, 0x766a0abb, 0x3c77b2a8, + 0x81c2c92e, 0x47edaee6, 0x92722c85, 0x1482353b, + 0xa2bfe8a1, 0x4cf10364, 0xa81a664b, 0xbc423001, + 0xc24b8b70, 0xd0f89791, 0xc76c51a3, 0x0654be30, + 0xd192e819, 0xd6ef5218, 0xd6990624, 0x5565a910, + 0xf40e3585, 0x5771202a, 0x106aa070, 0x32bbd1b8, + 0x19a4c116, 0xb8d2d0c8, 0x1e376c08, 0x5141ab53, + 0x2748774c, 0xdf8eeb99, 0x34b0bcb5, 0xe19b48a8, + 0x391c0cb3, 0xc5c95a63, 0x4ed8aa4a, 0xe3418acb, + 0x5b9cca4f, 0x7763e373, 0x682e6ff3, 0xd6b2b8a3, + 0x748f82ee, 0x5defb2fc, 0x78a5636f, 0x43172f60, + 0x84c87814, 0xa1f0ab72, 0x8cc70208, 0x1a6439ec, + 0x90befffa, 0x23631e28, 0xa4506ceb, 0xde82bde9, + 0xbef9a3f7, 0xb2c67915, 0xc67178f2, 0xe372532b, + 0xca273ece, 0xea26619c, 0xd186b8c7, 0x21c0c207, + 0xeada7dd6, 0xcde0eb1e, 0xf57d4f7f, 0xee6ed178, + 0x06f067aa, 0x72176fba, 0x0a637dc5, 0xa2c898a6, + 0x113f9804, 0xbef90dae, 0x1b710b35, 0x131c471b, + 0x28db77f5, 0x23047d84, 0x32caab7b, 0x40c72493, + 0x3c9ebe0a, 0x15c9bebc, 0x431d67c4, 0x9c100d4c, + 0x4cc5d4be, 0xcb3e42b6, 0x597f299c, 0xfc657e2a, + 0x5fcb6fab, 0x3ad6faec, 0x6c44198c, 0x4a475817, +]; + +/** + * Converts a 32-bit integer to a zero-padded 8-character hex string. + * + * @param {number} value The 32-bit integer value. + * @returns {string} + */ +function toHex32(value: number): string { + return `00000000${(value >>> 0).toString(16)}`.slice(-8); +} + +/** + * Calculates the SHA-512 checksum of the passed bytes. The implementation is + * a plain (pure JS) one on purpose. It does not depend on the Web Crypto API + * (`crypto.subtle`), which browsers expose only on secure origins (https). + * Thanks to that, the checksum can be verified on plain http:// pages, + * for example, intranets of big companies. + * + * @param {number[]|Uint8Array} bytes The bytes to calculate the checksum from. + * @returns {string} The checksum as a 128-character hex string. + */ +export function sha512(bytes: number[] | Uint8Array): string { + const byteLength = bytes.length; + // The message is padded with the 0x80 byte, zeros, and the 128-bit big-endian + // bit length so the total length is a multiple of 128 bytes. + const blockCount = Math.ceil((byteLength + 17) / 128); + const buffer = new Uint8Array(blockCount * 128); + + buffer.set(bytes); + buffer[byteLength] = 0x80; + + const bitLength = byteLength * 8; + const bufferLength = buffer.length; + + // The supported message sizes fit well within 2^53 bits, so only the two + // lowest 32-bit words of the 128-bit length field are ever non-zero. + buffer[bufferLength - 7] = Math.floor(bitLength / 0x1000000000000) & 0xff; // bits 48-55 + buffer[bufferLength - 6] = Math.floor(bitLength / 0x10000000000) & 0xff; // bits 40-47 + buffer[bufferLength - 5] = Math.floor(bitLength / 0x100000000) & 0xff; // bits 32-39 + buffer[bufferLength - 4] = (bitLength >>> 24) & 0xff; // bits 24-31 + buffer[bufferLength - 3] = (bitLength >>> 16) & 0xff; // bits 16-23 + buffer[bufferLength - 2] = (bitLength >>> 8) & 0xff; // bits 8-15 + buffer[bufferLength - 1] = bitLength & 0xff; // bits 0-7 + + // The initial hash values, stored as [high, low] 32-bit pairs. + const H = [ + 0x6a09e667, 0xf3bcc908, 0xbb67ae85, 0x84caa73b, + 0x3c6ef372, 0xfe94f82b, 0xa54ff53a, 0x5f1d36f1, + 0x510e527f, 0xade682d1, 0x9b05688c, 0x2b3e6c1f, + 0x1f83d9ab, 0xfb41bd6b, 0x5be0cd19, 0x137e2179, + ]; + const wh: number[] = new Array(80); + const wl: number[] = new Array(80); + + for (let block = 0; block < blockCount; block += 1) { + const offset = block * 128; + + // Prepare the message schedule. + for (let i = 0; i < 16; i += 1) { + const o = offset + (i * 8); + + wh[i] = ((buffer[o] << 24) | (buffer[o + 1] << 16) | (buffer[o + 2] << 8) | buffer[o + 3]) >>> 0; + wl[i] = ((buffer[o + 4] << 24) | (buffer[o + 5] << 16) | (buffer[o + 6] << 8) | buffer[o + 7]) >>> 0; + } + + for (let i = 16; i < 80; i += 1) { + const x2h = wh[i - 2]; + const x2l = wl[i - 2]; + const x15h = wh[i - 15]; + const x15l = wl[i - 15]; + // smallSigma1 = ROTR^19(x) XOR ROTR^61(x) XOR SHR^6(x) + const s1h = ((x2h >>> 19) | (x2l << 13)) ^ ((x2l >>> 29) | (x2h << 3)) ^ (x2h >>> 6); + const s1l = ((x2l >>> 19) | (x2h << 13)) ^ ((x2h >>> 29) | (x2l << 3)) ^ ((x2l >>> 6) | (x2h << 26)); + // smallSigma0 = ROTR^1(x) XOR ROTR^8(x) XOR SHR^7(x) + const s0h = ((x15h >>> 1) | (x15l << 31)) ^ ((x15h >>> 8) | (x15l << 24)) ^ (x15h >>> 7); + const s0l = ((x15l >>> 1) | (x15h << 31)) ^ ((x15l >>> 8) | (x15h << 24)) ^ ((x15l >>> 7) | (x15h << 25)); + + const lowSum = (s1l >>> 0) + (wl[i - 7] >>> 0) + (s0l >>> 0) + (wl[i - 16] >>> 0); + + wl[i] = lowSum >>> 0; + wh[i] = ((s1h >>> 0) + (wh[i - 7] >>> 0) + (s0h >>> 0) + (wh[i - 16] >>> 0) + + Math.floor(lowSum / 0x100000000)) >>> 0; + } + + let ah = H[0]; + let al = H[1]; + let bh = H[2]; + let bl = H[3]; + let ch = H[4]; + let cl = H[5]; + let dh = H[6]; + let dl = H[7]; + let eh = H[8]; + let el = H[9]; + let fh = H[10]; + let fl = H[11]; + let gh = H[12]; + let gl = H[13]; + let hh = H[14]; + let hl = H[15]; + + for (let i = 0; i < 80; i += 1) { + // bigSigma1 = ROTR^14(e) XOR ROTR^18(e) XOR ROTR^41(e) + const bs1h = ((eh >>> 14) | (el << 18)) ^ ((eh >>> 18) | (el << 14)) ^ ((el >>> 9) | (eh << 23)); + const bs1l = ((el >>> 14) | (eh << 18)) ^ ((el >>> 18) | (eh << 14)) ^ ((eh >>> 9) | (el << 23)); + // bigSigma0 = ROTR^28(a) XOR ROTR^34(a) XOR ROTR^39(a) + const bs0h = ((ah >>> 28) | (al << 4)) ^ ((al >>> 2) | (ah << 30)) ^ ((al >>> 7) | (ah << 25)); + const bs0l = ((al >>> 28) | (ah << 4)) ^ ((ah >>> 2) | (al << 30)) ^ ((ah >>> 7) | (al << 25)); + // ch = (e AND f) XOR (NOT e AND g) + const chh = (eh & fh) ^ (~eh & gh); + const chl = (el & fl) ^ (~el & gl); + // maj = (a AND b) XOR (a AND c) XOR (b AND c) + const majh = (ah & bh) ^ (ah & ch) ^ (bh & ch); + const majl = (al & bl) ^ (al & cl) ^ (bl & cl); + + const t1LowSum = (hl >>> 0) + (bs1l >>> 0) + (chl >>> 0) + (K[(i * 2) + 1] >>> 0) + (wl[i] >>> 0); + const t1l = t1LowSum >>> 0; + const t1h = ((hh >>> 0) + (bs1h >>> 0) + (chh >>> 0) + (K[i * 2] >>> 0) + + (wh[i] >>> 0) + Math.floor(t1LowSum / 0x100000000)) >>> 0; + + const t2LowSum = (bs0l >>> 0) + (majl >>> 0); + const t2l = t2LowSum >>> 0; + const t2h = ((bs0h >>> 0) + (majh >>> 0) + Math.floor(t2LowSum / 0x100000000)) >>> 0; + + hh = gh; + hl = gl; + gh = fh; + gl = fl; + fh = eh; + fl = el; + + const eLowSum = (dl >>> 0) + t1l; + + el = eLowSum >>> 0; + eh = ((dh >>> 0) + t1h + Math.floor(eLowSum / 0x100000000)) >>> 0; + + dh = ch; + dl = cl; + ch = bh; + cl = bl; + bh = ah; + bl = al; + + const aLowSum = t1l + t2l; + + al = aLowSum >>> 0; + ah = (t1h + t2h + Math.floor(aLowSum / 0x100000000)) >>> 0; + } + + const stateWords = [ah, al, bh, bl, ch, cl, dh, dl, eh, el, fh, fl, gh, gl, hh, hl]; + + for (let i = 0; i < 16; i += 2) { + const stateLowSum = (H[i + 1] >>> 0) + (stateWords[i + 1] >>> 0); + + H[i + 1] = stateLowSum >>> 0; + H[i] = ((H[i] >>> 0) + (stateWords[i] >>> 0) + Math.floor(stateLowSum / 0x100000000)) >>> 0; + } + } + + return H.map(toHex32).join(''); +} diff --git a/src/license/handsontable-license-key-parser/types.ts b/src/license/handsontable-license-key-parser/types.ts new file mode 100644 index 0000000000..961a12c04c --- /dev/null +++ b/src/license/handsontable-license-key-parser/types.ts @@ -0,0 +1,211 @@ +/** + * The shapes a license key string can have. `literal` is one of the plain words + * the calling product accepts as a key (for example + * "non-commercial-and-evaluation" or "gpl-v3") - which words those are is the + * product's decision, so the reader only recognizes the ones it is handed. + * Only "entitlement" is read by this module; every other format is the + * product's own path. + */ +export type LicenseKeyFormat = + | 'entitlement' + | 'legacy' + | 'literal' + | 'unknown'; + +/** + * What one product entry of a verified key grants. Exactly one of + * `usage_until` / `release_until` is present - the pair replaces the contract + * type, which the payload does not carry. `notice` and `grace` are the warning + * and soft-stop windows, in days, and arrive in the key rather than living in + * the library. Unknown extra fields survive verification untouched, so a field + * added to the format later stays visible to the layers above. + * + * Read-only: the verified data is frozen and shared between callers. Copy an + * array (`capabilities.slice()`) before sorting or changing it. + */ +export interface ProductEntitlement { + readonly capabilities: readonly string[]; + readonly usage_until?: string; + readonly release_until?: string; + readonly notice: number; + readonly grace: number; + readonly flags: readonly string[]; + readonly [field: string]: unknown; +} + +/** + * The machine-readable data of a verified entitlement license key: what it + * grants, keyed by product name. Presence means licensed. A product the reader + * does not know is kept and ignored - one install can be licensed for one + * product and not for another, and the unknown one must not take down the + * known ones. + */ +export interface EntitlementKeyData { + /** + * The format version of the key: 1 for a key without `v` in its payload + * (the first keys), the value of `v` otherwise. The checksum and the shape + * are verified for every version. The prose is verified only from version + * 2: a version 1 key reads with an edited prose, as its bare block, or with + * text after the block, exactly as it did when it was issued. A product may + * use the number to decide how to treat such a key. + */ + readonly version: number; + readonly products: { readonly [productName: string]: ProductEntitlement }; +} + +/** + * The lifecycle state of a license, derived from the governing date of the + * product entry: + * + * - `usage_until` runs the notice -> soft stop -> hard stop windows against + * the current UTC instant. The `trial_*` states are the same windows on a + * key carrying the `trial` flag - they differ in what the user is told and + * shown, not in how they are measured. + * - `release_until` compares the build release date against the maintenance + * date as text. No clock takes part, so the verdict never changes. + */ +export type LicenseState = + | 'usage_valid' + | 'usage_notice' + | 'usage_soft_stop' + | 'usage_hard_stop' + | 'trial_valid' + | 'trial_notice' + | 'trial_soft_stop' + | 'trial_hard_stop' + | 'release_valid' + | 'release_expired'; + +/** + * The lifecycle facet of a license: the state, whether the key is a trial, the + * whole UTC days left until the last licensed day (`null` when no clock is + * involved), and the governing date as the bare "YYYY-MM-DD" string the + * payload carries. The date is never re-derived from a timestamp - the string + * in the key is what the messages print. + */ +export interface LicenseLifecycle { + readonly state: LicenseState; + readonly isTrial: boolean; + readonly daysRemaining: number | null; + readonly licensedUntil: string | null; +} + +/** + * Which notification channels the license leaves open. A product entry may + * carry `no-console-warns` (nothing reaches the console) and `no-ui-warns` (no + * WARNING is rendered in the UI); both are the default for a key issued for + * external, end-user-facing use. + * + * `ui` governs warnings only. A trial hard-stop block is enforcement and is + * applied regardless - see `NO_UI_WARNS_FLAG` in `./constants`. A new surface + * reading this field has to decide which of the two it is before honoring it. + */ +export interface LicenseChannels { + readonly console: boolean; + readonly ui: boolean; +} + +/** + * What a license unlocks. `unrestricted` is the fallback shortcut - every query + * answers "granted" for it, so the same API serves keys outside the + * entitlement format (unlock everything) and entitlement keys (unlock exactly + * the tokens the payload lists) without the caller branching on the key + * family. + */ +export interface LicenseGrants { + readonly unrestricted: boolean; + readonly products: { readonly [productName: string]: { readonly capabilities: readonly string[] } }; +} + +/** + * The time references a license is measured against: the current instant for a + * `usage_until` entitlement, and the build release date (as the bare + * "YYYY-MM-DD" text it is compared to) for a `release_until` one. The build + * date is text on purpose - the maintenance check is static against static, so + * it holds on a machine with no clock and cannot disagree between two + * timezones. + */ +export interface LicenseTimeReference { + now: number; + buildDate: string | null | undefined; +} + +/** + * Why an entitlement key does not license the product that reads it: + * + * - `unreadable` the key fails verification: the block is missing, + * tampered with or malformed, or - for a version 2 + * key - the prose was edited or removed (the prose + * digest covers it) or text other than whitespace + * follows the block. A version 1 key never covered + * its prose, so neither makes it unreadable, + * - `product_missing` the key is intact but grants other products only. + * + * Both are reported to the user as an invalid key; the split exists so a + * product can log which one it was. + */ +export type UnlicensedReason = 'unreadable' | 'product_missing'; + +/** + * The result of reading an entitlement key for one product. + * + * When `licensed` is `true`, `entitlement`, `lifecycle` and `channels` describe + * that product and `grants` lists exactly what the key unlocks. + * + * `version` is the format version of the key (1 for a key without `v`, whose + * prose is not checked) whenever the key could be read - also for + * `product_missing`. It is `null` only for an unreadable key, and the union + * narrows it: check `reason` and `version` is a `number` or `null`. + * + * When `licensed` is `false`, the product must report an invalid key. + * `entitlement` and `lifecycle` are `null`, both channels are open (the flags + * that close them could not be read), and `grants` is unrestricted - an + * invalid key nags, it never takes features away. + * + * The whole result is frozen, in both cases. + */ +export type EntitlementLicense = + | { + readonly licensed: true; + readonly reason: null; + readonly version: number; + readonly entitlement: ProductEntitlement; + readonly lifecycle: LicenseLifecycle; + readonly channels: LicenseChannels; + readonly grants: LicenseGrants; + } + | { + readonly licensed: false; + readonly reason: 'product_missing'; + readonly version: number; + readonly entitlement: null; + readonly lifecycle: null; + readonly channels: LicenseChannels; + readonly grants: LicenseGrants; + } + | { + readonly licensed: false; + readonly reason: 'unreadable'; + readonly version: null; + readonly entitlement: null; + readonly lifecycle: null; + readonly channels: LicenseChannels; + readonly grants: LicenseGrants; + }; + +/** + * What the calling product tells `readEntitlementLicense`. + */ +export interface ReadEntitlementLicenseOptions { + /** The product name this build reads its own license from (e.g. "hyperformula"). */ + product: string; + /** + * The build release date, as a bare "YYYY-MM-DD" (`toIsoBuildDate` converts + * "DD/MM/YYYY"). Compared against `release_until` keys only. A missing value + * (`undefined`, `null`, "") fails OPEN; any other value that is not a real + * "YYYY-MM-DD" throws - see `resolveBuildDate`. + */ + buildDate: string | null | undefined; + /** The current instant, in epoch milliseconds. Defaults to `Date.now()`. */ + now?: number; +} diff --git a/src/license/handsontable-license-key-parser/upstream.json b/src/license/handsontable-license-key-parser/upstream.json new file mode 100644 index 0000000000..05188fce14 --- /dev/null +++ b/src/license/handsontable-license-key-parser/upstream.json @@ -0,0 +1,33 @@ +{ + "_comment": [ + "Where this directory comes from, machine-readable so `npm run check:license-key-parser-drift` can hold it", + "to upstream: it fetches every file of the upstream directory at `tag`, checks that `tag` still", + "points at `commit`, and compares bytes. Nothing else may live here except `own_files`; a", + "local-only file would shadow a copied one.", + "", + "`allowed_divergences` is the ONE place a local edit is permitted, and each entry has to say why", + "and when it goes away. The checker substitutes `local` for `upstream` in the fetched file before", + "comparing. A tag is immutable, so this does not notice upstream fixing the line on its branches;", + "what it notices is a NEWER TAG (the check fails on one), and at the re-take the swap stops matching", + "once upstream has changed the line. A tag that leaves the line alone carries the shim forward;", + "`until` is a note for whoever re-takes, not something the checker evaluates." + ], + "repository": "handsontable/license-key", + "directory": "vendor/entitlement-key-reader", + "tag": "5.1.1", + "commit": "12466071875bfb3b807e8321d9d5e9c9a8551b09", + "taken_on": "2026-10-07", + "own_files": [ + "PROVENANCE.md", + "upstream.json" + ], + "allowed_divergences": [ + { + "file": "extractKeyData.ts", + "why": "TypeScript 4.0.8 (HyperFormula's) does not narrow `unknown` through `!== null && typeof === 'object'`; upstream compiles under ^5.9.3. One cast makes the file compile here. Semantics unchanged.", + "until": "upstream ships the cast (or HyperFormula moves to TypeScript >= 4.5, measured: 4.4.4 fails, 4.5.5 passes) - then delete this entry and re-take the file", + "upstream": " Object.keys(current).forEach(key => pending.push((current as Record)[key]));", + "local": " Object.keys(current as object).forEach(key => pending.push((current as Record)[key]));" + } + ] +} diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts new file mode 100644 index 0000000000..fad5804b2c --- /dev/null +++ b/src/license/licenseResolution.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import { + checkLicenseKeyValidity, + LicenseKeyValidityState, + notifyEntitlementKey, + notifyUnlicensedEntitlementKey, +} from '../helpers/licenseKeyValidator' +import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' +import {detectLicenseKeyFormat} from './handsontable-license-key-parser/detectFormat' +import {readEntitlementLicense} from './handsontable-license-key-parser/readLicense' +import {toIsoBuildDate} from './handsontable-license-key-parser/buildDate' +import {LicenseState, ProductEntitlement} from './handsontable-license-key-parser/types' + +/** + * The name of HyperFormula's own product entry in an entitlement key payload. A key that grants + * other products but not this one is not a license for HyperFormula (the reader returns + * `product_missing`), however many other products it grants. + */ +export const HYPERFORMULA_PRODUCT_NAME = 'hyperformula' + +/** + * What one lifecycle state of a licensed entitlement key means for this build: the validity state it + * reports, and whether it blocks evaluation. + */ +interface LifecycleVerdict { + validityState: LicenseKeyValidityState.VALID | LicenseKeyValidityState.EXPIRED, + blocksEvaluation: boolean, +} + +/** + * The verdict for every lifecycle state the vendored reader reports. A `Record` over + * {@link LicenseState}, so a state added upstream fails compilation here until it is classified, + * instead of falling into a default. + * + * - The valid and notice states, and `release_valid`, report VALID and evaluate. + * - The soft-stop states report VALID and evaluate on purpose: the grace period keeps working and + * prints the specification's error (see `notifyEntitlementKey`). + * - A subscription past its grace period (`usage_hard_stop`) and a key whose `release_until` is + * before the build (`release_expired`) report EXPIRED but keep evaluating, printing an error to + * the console instead. An expired license never blocks a paying customer, as the reader's guide + * and the key specification both say. Such a key keeps its own grants: the reader reports it as + * licensed, so it is never granted more than the same key was granted while it was current. + * - A trial past its grace period (`trial_hard_stop`) reports EXPIRED and blocks. + */ +const LIFECYCLE_VERDICTS: Record = { + usage_valid: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + usage_notice: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + usage_soft_stop: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + usage_hard_stop: {validityState: LicenseKeyValidityState.EXPIRED, blocksEvaluation: false}, + trial_valid: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + trial_notice: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + trial_soft_stop: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + trial_hard_stop: {validityState: LicenseKeyValidityState.EXPIRED, blocksEvaluation: true}, + release_valid: {validityState: LicenseKeyValidityState.VALID, blocksEvaluation: false}, + release_expired: {validityState: LicenseKeyValidityState.EXPIRED, blocksEvaluation: false}, +} + +/** + * Both halves of the license decision, resolved from one reading of the key. + * + * They are deliberately produced together: the two gates ask different questions of the same + * string, and parsing it twice would let them disagree about what it says. + */ +export interface ResolvedLicense { + /** The key's state, as the console messages and the `#LIC!` and E3 error messages report it. */ + validityState: LicenseKeyValidityState, + /** + * Gate A — `true` when function calls must return `#LIC!`. Usually `validityState !== VALID`; + * the exception is an entitlement key whose {@link LIFECYCLE_VERDICTS} entry reports `EXPIRED` + * but keeps evaluating. + */ + blocksEvaluation: boolean, + /** Gate B — which functions and API features the key grants. */ + entitlement: LicenseEntitlement, +} + +/** + * The expiry details an entitlement records, read off HyperFormula's own entry. + * + * A `release_until` date has no grace period: it is compared with the build date, which never + * moves, so there is no window to be inside of. + * + * @param {ProductEntitlement} entry - HyperFormula's entry of an intact key + */ +function expiryOf(entry: ProductEntitlement): LicenseExpiry { + const comparedAgainstReleaseDate = entry.release_until !== undefined + + return { + kind: comparedAgainstReleaseDate ? 'release' : 'usage', + date: (comparedAgainstReleaseDate ? entry.release_until : entry.usage_until) as string, + noticeDays: entry.notice, + graceDays: comparedAgainstReleaseDate ? 0 : entry.grace, + } +} + +/** + * Turns HyperFormula's entry of a valid entitlement key into the entitlement it grants. + * + * This is fail-closed and silent: a token this version does not recognize grants nothing, without + * a warning, a message, or anything public to read it back from. "Silent" there means the *grant* is silent — whether the + * key's console messages are suppressed is decided solely by its `no-console-warns` flag, never + * by the presence of an unrecognized token; coupling the two would suppress expiry notices as a + * side effect of a vocabulary mismatch. + * + * @param {ProductEntitlement} entry - HyperFormula's entry of a valid key + * @param {boolean} isTrial - whether the key carries the `trial` flag + * @param {boolean} silent - whether the key closes the console channel + */ +function entitlementOf(entry: ProductEntitlement, isTrial: boolean, silent: boolean): LicenseEntitlement { + return { + unrestricted: false, + // Never spread into a call (`push(...entry.capabilities)`): the array comes from an + // attacker-influenced payload with no size limit, and a spread puts one argument per stack + // slot - measured, a checksum-valid key carrying 125 000 tokens threw `RangeError: Maximum + // call stack size exceeded` out of `HyperFormula.buildFromArray`. + capabilities: new Set(entry.capabilities), + expiry: expiryOf(entry), + silent, + isTrial, + } +} + +/** + * Resolves a license key into both gates' inputs. + * + * Routing follows the vendored {@link detectLicenseKeyFormat}, whose test order is normative: the + * literals, then the trailing bracketed block that marks an + * entitlement key, then the legacy 25-character shape. Everything that is not an entitlement key + * — `gpl-v3`, a legacy key, an empty string — falls through to {@link checkLicenseKeyValidity} + * completely unchanged, which is what keeps this from touching existing behavior. A string that + * carries a bracketed block routes here even when the block is garbage: such a key is INVALID, + * not a legacy key that happens to contain brackets. + * + * An entitlement key is read by the vendored {@link readEntitlementLicense}, the single entry + * point upstream prescribes for products: it verifies the key (the checksum and the prose + * digest), picks HyperFormula's entry, places it in its lifecycle window and reads its flags. Only + * the meaning of the capability tokens and the console messages live here. + * + * **The invariant this function exists to protect.** Only an entitlement key that lets this build + * evaluate — a valid one, or an expired one whose {@link LIFECYCLE_VERDICTS} entry does not + * block — resolves to a restricted entitlement, and an expired one keeps exactly the grants it had + * while current. Every key that blocks evaluation (a missing or invalid key, an expired classic key, + * or a trial past its grace period) resolves to + * {@link unrestrictedEntitlement}, and so does every classic key. A key that blocks is stopped by + * gate A alone, through `blocksEvaluation`: formulas yield `#LIC!` and every gated API feature throws + * with the key's state (see `ensureFeatureAllowed`). Gate B never reports such a key, so its "not + * included in your license" error is reserved for a key that evaluates but lacks the grant. The fail-closed rule governs unrecognized tokens INSIDE an otherwise valid key; it is not + * a rule about invalid keys. + * + * A checksum-valid key whose payload shape cannot be read is INVALID, not a crash and not a free + * pass: every payload field is untrusted, so nothing here may assume a shape the vendored reader + * has not verified. + * + * @param {string} licenseKey - the raw `licenseKey` config value + * @param {boolean} notifyConsole - pass `false` for a resolution whose result exists only to be + * thrown away (e.g. the transient serialization-only `Config` that `rebuildWithConfig` builds + * from the OUTGOING config) — such a resolution must not print notices for a key the caller is + * in the middle of replacing. Legacy keys notify inside {@link checkLicenseKeyValidity} behind a + * once-per-page-load flag, so they cannot double-print regardless of this parameter. + */ +export function resolveLicense(licenseKey: string, notifyConsole: boolean = true): ResolvedLicense { + if (detectLicenseKeyFormat(licenseKey) !== 'entitlement') { + const validityState = checkLicenseKeyValidity(licenseKey) + + return { + validityState, + blocksEvaluation: validityState !== LicenseKeyValidityState.VALID, + entitlement: unrestrictedEntitlement(), + } + } + + // Read exactly as the bundler inlines it - see the reader's README, rule 4. A missing or + // malformed `HT_RELEASE_DATE` becomes '', which the reader treats as "build date unknown" and + // fails open on, as the legacy validator does. + const license = readEntitlementLicense(licenseKey, { + product: HYPERFORMULA_PRODUCT_NAME, + buildDate: toIsoBuildDate(process.env.HT_RELEASE_DATE), + }) + + if (!license.licensed) { + // `unreadable` (a broken block, or edited or missing prose) and `product_missing` (a key for other products only) both + // resolve to an invalid key that restricts nothing; only their console messages differ. + if (notifyConsole) { + notifyUnlicensedEntitlementKey(license.reason) + } + + return {validityState: LicenseKeyValidityState.INVALID, blocksEvaluation: true, entitlement: unrestrictedEntitlement()} + } + + const {entitlement: entry, lifecycle, channels} = license + const {validityState, blocksEvaluation} = LIFECYCLE_VERDICTS[lifecycle.state] + + // The message is chosen by the reader's state and prints the key's own date (the key + // specification's text, the same table Handsontable uses). The `no-console-warns` flag closes the channel. + if (notifyConsole && channels.console) { + // The reader sets `licensedUntil` for every key it licenses: the entry's own governing date. + notifyEntitlementKey(lifecycle.state, {licensedUntil: lifecycle.licensedUntil as string, daysRemaining: lifecycle.daysRemaining}) + } + + return { + validityState, + blocksEvaluation, + entitlement: blocksEvaluation + ? unrestrictedEntitlement() + : entitlementOf(entry, lifecycle.isTrial, !channels.console), + } +} From eff792857e27c132b8ff999a131ff134914d417c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 8 Oct 2026 16:26:09 +0800 Subject: [PATCH 13/18] Fix precision loss in variance, standard deviation and related statistics (HF-491) (#1784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context The variance functions compute `sum(x^2) - sum(x)^2 / n` in one pass. When the mean is large relative to the spread (typical of measurement data), the two terms are nearly equal and cancel, so every significant digit is lost: | Formula | Before | Exact (stored doubles) | After | |---|---|---|---| | `=STDEV.S({10000000.000,10000000.001,10000000.002})` | `#NUM!` | 0.0010000001639127731 | 0.0010000001639127731 | | `=VAR.S(` same `)` | `-0.03125` (negative, no error) | 1.0000003278255731e-06 | 1.0000003278255731e-06 | | `=STDEV.S({100000000000.003,100000000000.004,100000000000.005})` | `0` | 0.0009994603901554338 | 0.0009994603901554338 | `smartRounding: false` and `precisionEpsilon: 0` do not change the old results. `DEVSQ`, `COVARIANCE.P`, `COVARIANCE.S`, `SLOPE`, `STEYX` and the database functions `DSTDEV`, `DSTDEVP`, `DVAR`, `DVARP` compute the mean first and lose digits in the same situation when the values use all 15 significant digits. For example, for three values near `100000000000`, `STEYX` returned `0.0187` instead of `0.00623` and `SLOPE` returned `1000.37` instead of `1000.53`. Affected: - `VAR.S`, `VAR.P`, `STDEV.S`, `STDEV.P`, `VARA`, `VARPA`, `STDEVA`, `STDEVPA`, their legacy names (`VAR`, `VARP`, `VARS`, `STDEV`, `STDEVP`, `STDEVS`), and `SUBTOTAL` modes 7, 8, 10 and 11 (and 107, 108, 110, 111), all backed by `MomentsAggregate` - `DEVSQ`, `COVARIANCE.P`, `COVARIANCE.S` (and `COVAR`, `COVARIANCEP`, `COVARIANCES`), `SLOPE`, `STEYX`, `DSTDEV`, `DSTDEVP`, `DVAR`, `DVARP` ### The change **Double-double helpers** (`src/interpreter/doubleDouble.ts`): TwoSum (in the Fast2Sum form, taking the error from the larger addend, so it cannot overflow while the sum is finite) and Dekker's TwoProduct with Veltkamp's split (no FMA needed), plus addition, subtraction, multiplication and division of double-doubles, and rounding to a double. Near the top of the double range they rescale by exact powers of two: `twoProduct` scales the larger factor by 2^-53 when a factor exceeds 2^996 or the product exceeds 2^1023, so its error term is exact for every finite product above 2^-969, and the divisions scale dividends above 2^1000 by 2^-64. **`MomentsAggregate`** (`src/interpreter/plugin/MomentsAggregate.ts`) stays composable, so the per-range cache keeps working. Besides the count, it keeps the sums of deviations `S1` and of squared deviations `S2` from a `shift` (the first value added), stored as double-double, and rebases them when two aggregates compose. The deviations are stored multiplied by `2^-exponent`, an exact power of two kept in the aggregate, so the shifted sums cannot overflow: the exponent is 0 until the scaled sum of squares exceeds 2^960 (deviations of about 1e144 and above), and two aggregates compose at the larger exponent, raised further when the difference of their shifts needs it. Adding one value, the common case when a range is folded cell by cell, takes a fast path. The sum of squared deviations `S2 - S1^2 / n` is evaluated in double-double as `S2 - S1 * (S1 / n)`, so the `S1^2` term cannot overflow, the variance is rounded once, and it is then multiplied by `2^(2 * exponent)`. The variance is `#NUM!` only when it exceeds the largest double, and it does not depend on the order of the arguments: `=VAR.S(0, 1e154, 1e154)` and `=VAR.S(1e154, 1e154, 0)` both return 3.33e307 (develop returns `#NUM!` for both), and `=VAR.S(-1.2e154, 0, 1.2e154)` returns 1.44e308 although the sum of squared deviations exceeds the largest double. The exponent can also go negative (down to -600) when tiny differences follow equal values, and the standard deviation is taken at the scale of the sums before scaling back, so `STDEV.*` is finite when only the variance overflows and non-zero for spreads below about 1e-154 (`=STDEV.S(1e-170, 2e-170)` returns 7.07e-171). **Deviation sums** (`src/interpreter/deviationSums.ts`): `DEVSQ`, `COVARIANCE.*`, `SLOPE` and `STEYX` compute the mean in double-double, then the deviations and the sums of their products in double-double, and round once. Each array is measured at its own power-of-two scale and the rounded result is scaled back: an array whose largest magnitude is below 2^-400 is scaled up to it (exact), so squared deviations cannot underflow and the sum of squared x deviations is 0 only when all the x values are equal; an array above 2^400 is scaled down to it only when the unscaled sums overflow, so on ordinary data the results are bit-identical to the unscaled computation. `DEVSQ` is only scaled up, because when its unscaled sum overflows, its result does too. `SLOPE` and `STEYX` share `regressionSums` and the check for equal x values. `STEYX` does not use `Syy - Sxy^2 / Sxx`, which cancels when the points lie almost on a line: it computes each residual `dy - slope * dx` in double-double, centers the residuals on their mean (which removes the shift that the rounding errors of the two means add to every residual), and sums their squares. `SLOPE` skips the residuals. **D-variance functions**: `DVAR`, `DVARP`, `DSTDEV` and `DSTDEVP` fold their values through `MomentsAggregate`, so they return the same results as `VAR.S`, `VAR.P`, `STDEV.S` and `STDEV.P`, including when the sum of squared deviations overflows. Behaviour changes besides precision: `STEYX` returns the standard error instead of `#NUM!` or `0` for points that lie almost on a line (`=STEYX({0.25,0.4,0.5499999999999999},{0.1,0.2,0.3})` returns 2.83e-17, the exact value for the stored doubles, which are not evenly spaced, where develop returns `#NUM!`; `=STEYX({100000000,200000000,300000000.00000024},{1,2,3})` returns 9.733e-8 where develop returns `0`); `SLOPE` and `STEYX` return `#DIV/0!` with the message "All x values are equal." (the new `ErrorMessage.EqualXValues`) when all the x values are equal, as Excel and Google Sheets return `#DIV/0!` (develop returns `#NUM!` or an arbitrary number), and finite results where the sum of squared deviations of the x values overflows or underflows (`=SLOPE({1,2},{1e-170,2e-170})` returns 1e170; develop returns `#NUM!`). `DVAR`, `DSTDEV` and `COVARIANCE` return finite results where only an intermediate sum overflows (`=DSTDEV` of `-1.2e154, 0, 1.2e154` returns 1.2e154, like `STDEV.S`). `AVERAGE`, `AVERAGEA` and `SUBTOTAL` 1/101 no longer use `MomentsAggregate`: they reduce an `AverageResult` (a plain sum and a count, moved from `ConditionalAggregationPlugin` to its own module) under their own cache keys (`_AVERAGE` and `_AVERAGE_A`), so they do not compute the double-double sums. Each argument is evaluated once, and the results are the same as before. ### Accuracy The reference is the exact result for the stored doubles, reported as LRE (digits of agreement, McCullough 1998). Exact references: BigInt rational arithmetic, Python's `statistics` (exact fractions) and mpmath at 60 digits. The data is 24 datasets, including all 9 NIST StRD univariate sets. Measured for `VAR`/`STDEV` at 7a88902 and not re-measured since; the variance algorithm is the same apart from the `S1 * (S1 / n)` evaluation and the power-of-two exponent, which is exact scaling and is 0 for all 24 datasets. | | min LRE over 24 datasets | |---|---| | this PR | **15** | | Gnumeric 1.12.56 | 15 | | R 4.3.3 `sd()` | 4.7 | | NumPy 2.5.3 / SciPy 1.18.1 | 0 | | plain two-pass | 0 | | Excel (two-pass; 50 identical values → 7.5e-8, exact 0) | 0 | | Welford / Chan pairwise | 2.4 | | before (one-pass) | −1 (negative variance) | Double-double arithmetic does not guarantee correct rounding, so the JSDoc states the accuracy as about one unit in the last place. Split arguments, `=STDEV.S(A1:A50, A51:A100)` in either order, hold the same accuracy. When a range reuses the cached aggregate of a smaller range or the values come in several arguments, partial aggregates are composed in a different order than the left-to-right fold of the D-functions, so the results can differ from them in the last bits. Compared with Excel: near 1e7 the results are equal, but near 1e11 Excel's two-pass result differs from the exact value in the 4th significant digit (0.000999538 vs 0.000999460), and for 50 identical values whose mean is not exactly representable Excel returns 7.5e-8 instead of 0. This PR returns the exact values. ### Alternatives measured - **Plain two-pass** for the `MomentsAggregate` functions: mean first, then deviations, which needs every value again. It cannot use the range cache, and a running `=STDEV.S(A$1:An)` over 5,000 rows became 10x slower to build and 38x slower to recalculate after one edit. The functions that already take whole arrays (`DEVSQ`, `COVARIANCE.*`, `SLOPE`, `STEYX`, D-functions) use two passes, in double-double. - **Shifted sums plus a term that reproduced Excel's rounding of the mean**: composable, but it inherited Excel's error (min LRE 0) and depended on argument order. - **Compensated (Neumaier) shifted sums**: min LRE 13.1 at about the same cost as double-double. ### How did you test your changes? - The tests PR (handsontable/hyperformula-tests#70) adds 118 tests to the spec of each affected function; 63 of them fail on develop. Expected values are the exact results for the stored doubles, cross-checked with Python's `statistics`, compared as a ratio within 1e-14 with `smartRounding` off. They cover every affected function, the legacy `STDEV` name, `SUBTOTAL` 7 and 11, two ranges as separate arguments, range + scalar, a range extending a cached smaller range, two cached ranges kept at different scales (tiny spreads of different sizes, and tiny values with huge ones) combined in either order, recalculation after an edit, the NIST NumAcc4 dataset, identical values (also near 1e300), variances near 1e300, `STEYX` on points exactly and almost on a line, `SLOPE`/`STEYX` with equal x values (including the error message), and overflow: a squared deviation close to the largest double in either order, shifted sums that overflow, two cached ranges whose combination overflows, a running total that overflows, a value equal to the largest double, products of deviations near the largest double that cancel, the sum of squared x deviations overflowing or underflowing in `SLOPE`/`STEYX`, the D-functions and `COVARIANCE` matching `VAR`/`STDEV` when the sum of squared deviations overflows, tiny spreads in `STDEV.S` and `DSTDEV`, products of large and tiny values that cancel in `COVARIANCE` and `SLOPE`, an overflowing slope in `STEYX`, the same variance for the values in any order and for a range split at any point when the squared deviations from the first value overflow, a finite variance whose sum of squared deviations overflows, `#NUM!` when the variance exceeds the largest double, `AVERAGE` and `VAR.S` and `AVERAGEA` and `VARA` over the same range, and `AVERAGE`, `AVERAGEA` and `SUBTOTAL` 1 evaluating each argument once. - Full suite (after merging develop): 6733 passed, 0 failed, 3 skipped. - `STEYX` on points almost on a line was checked against exact rational results: 4,000 datasets (n from 3 to 12, intercepts, slopes and x values over 30 to 40 orders of magnitude, residual sums of squares down to 1e-37 of the sum of squared y deviations), largest relative error 1.8e-15, plus 60k datasets of the general `COVARIANCE`/`SLOPE`/`STEYX` fuzz with no `STEYX` result off by more than 1e-12. - The double-double helpers were checked against exact BigInt arithmetic: about 31M cases each for `twoSum` and `twoProduct` (boundary grids around 2^996, 2^1023, the largest double, subnormals, signed zeros, infinities and NaN, plus random bit patterns) with no inexact result where an exact one is representable, and about 5M cases each for the two divisions. - An engine differential test against develop and exact results (about 2.9M formula evaluations over ordinary data, large means with small spreads, extreme magnitudes, and the same data as one range, two ranges, scalars and a cached range extended) found no case where develop is finite and this PR returns an error, apart from the ones listed under "Not in this PR". - A differential test of the power-of-two exponent against exact BigInt rational results: 24,000 datasets (magnitudes from 1e140 to the largest double mixed with zeros and subnormals, opposite-sign values near the largest double, large means with small spreads at every exponent, identical values, variances near the overflow threshold; n from 2 to 200), each evaluated in about 20 forms (argument orders, split ranges, range + scalar, cached prefix ranges), 10.8M checks in all, for `VAR.*`, `STDEV.*`, `VARA`, `VARPA`, `STDEVA`, `STDEVPA` and `SUBTOTAL` 7, 8, 10 and 11. Where the exact variance is a normal double, the largest error is 1 ulp (3 results at 2 ulp just above 2^-1022; largest relative error 3.75e-16), all forms agree within 2 ulp, and `#NUM!` is returned exactly when the exact variance rounds to infinity. - Benchmark against develop at 92ebfbd4 (Apple M4 Pro, Node 24; fresh process per run, 7 alternating runs, warm median): one `STDEV.S` over 100k cells 14.5 → 18.5 ms (1.28x), `AVERAGE` plus `VAR.S` over the same 20k-cell range 5.6 → 7.2 ms (1.29x), recalculation of a running `STDEV.S(A$1:An)` over 5k rows after one edit 12.9 → 13.9 ms (1.08x), building a running `STDEV.S(A$1:An)` or `AVERAGE(A$1:An)` over 5k rows 1.00x and 0.99x. Data that needs the exponent (values near 1e150) adds about 6% to the 100k `STDEV.S`. - The power-of-two scaling of the D-functions, `COVARIANCE`, `SLOPE` and `STEYX` was checked against exact BigInt results and against the previous head (92ebfbd4): about 18k random datasets with 23 formulas each, 18k more from an independent generator, an 80k-dataset `MomentsAggregate` composition fuzz and 19,664 equal-x cases. No result differs from 92ebfbd4 on ordinary data, the D-functions equal `VAR`/`STDEV` in every regime, and equal x values always give `#DIV/0!`. On 100k ordinary values, every affected function is within about ±3% of 92ebfbd4, and `DVAR`/`DSTDEV` are about 5% faster. - Heap after building a running formula over 5k rows: `STDEV.S` 10.49 → 11.82 MB, `AVERAGE` 10.48 → 10.96 MB. A cached `MomentsAggregate` is larger than on develop (two double-doubles, a shift and an exponent); a cached `AverageResult` is smaller than develop's aggregate. ### Not in this PR - `VAR.*` results below 2^-1022 (spreads below about 1e-154) are subnormal numbers with few significant bits; they are rounded at the scale of the sums and then scaled back, so they can be 1 subnormal ulp off. - `CORREL`, `PEARSON`, `RSQ`, `Z.TEST`, `T.TEST`, `F.TEST`, `SKEW` and `SKEW.P` still compute their sums of deviations in plain doubles (jStat), so they keep the precision loss on data with a large mean. - When an intermediate sum overflows, `COVARIANCE` and `SLOPE` scale large arrays down, which rounds away values about 2^1400 times smaller than the largest one. On extremely ill-conditioned data (values near the largest double that cancel, or data spanning about 600 orders of magnitude) they can then return a finite but inaccurate result where develop returned `#NUM!`; no double-double method can resolve these. - `STEYX` rounds twice (division, then square root), so it can be about 1 ulp off. - Double-double edge cases that predate this change: `divideDoubleDoubles` with a dividend below about 2^-950 can be a few ulps less accurate than plain division, and sums or quotients within half an ulp of the largest double return infinity instead of the largest double. - The precision of `AVERAGE`: `=AVERAGE(1e16, 1, -1e16)` returns `0`, as on develop and in Excel. - `INTERCEPT` is not implemented in HyperFormula. ### Types of changes - [ ] Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore) - [ ] New feature or improvement (a non-breaking change that adds functionality) - [x] Bug fix (a non-breaking change that fixes an issue) - [ ] Additional language file, or a change to an existing language file (translations) - [ ] Change to the documentation ### Related issues: 1. Tests: handsontable/hyperformula-tests#70 2. Merged into this branch: #1798 (`DEVSQ`, `COVARIANCE`, `SLOPE`, `STEYX` and the D-variance functions) ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/docs/guide/contributing.html) and I confirm that my code follows the code style of this project. - [x] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [ ] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [ ] My change is compatible with Microsoft Excel. - [ ] My change is compatible with Google Sheets. - [x] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [ ] My changes require a documentation update. - [ ] My changes require a migration guide. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Medium Risk** > Touches core numeric paths for many spreadsheet functions; behavior changes (errors, edge magnitudes) are intentional but affect formula results users may rely on. > > **Overview** > Fixes **catastrophic cancellation** in variance, standard deviation, and related statistics when the mean is large relative to the spread (e.g. `STDEV.S` on values near `1e7` with tiny differences). > > Introduces **double-double arithmetic** (`doubleDouble.ts`) and two aggregation layers: **`MomentsAggregate`** for composable `VAR`/`STDEV`/`SUBTOTAL`/database `DVAR`/`DSTDEV` (shifted sums with power-of-two scaling), and **`deviationSums.ts`** for `DEVSQ`, `COVARIANCE`, `SLOPE`, and `STEYX`. **`STEYX`** now sums squared residuals per point (with centered residuals) instead of a formula that cancels on nearly collinear data. > > **`SLOPE`** and **`STEYX`** return **`#DIV/0!`** with *"All x values are equal."* when all x values match. **`AVERAGE`** / **`AVERAGEA`** use a lightweight **`AverageResult`** (sum + count) instead of the variance aggregate. Release script step 3 reads **`CURRENT_VERSION`** after the step label. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 91456eb95d4efa958b8bdc55a54d64a38f2434ae. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Kuba Sekowski --- CHANGELOG.md | 4 + script/release/release.sh | 2 +- src/error-message.ts | 1 + src/interpreter/deviationSums.ts | 306 +++++++++++++++ src/interpreter/doubleDouble.ts | 235 ++++++++++++ src/interpreter/plugin/AverageResult.ts | 56 +++ .../plugin/ConditionalAggregationPlugin.ts | 26 +- src/interpreter/plugin/DatabasePlugin.ts | 31 +- src/interpreter/plugin/MomentsAggregate.ts | 354 ++++++++++++++++++ .../plugin/NumericAggregationPlugin.ts | 96 ++--- .../plugin/StatisticalAggregationPlugin.ts | 43 ++- 11 files changed, 1030 insertions(+), 124 deletions(-) create mode 100644 src/interpreter/deviationSums.ts create mode 100644 src/interpreter/doubleDouble.ts create mode 100644 src/interpreter/plugin/AverageResult.ts create mode 100644 src/interpreter/plugin/MomentsAggregate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 787c642ad9..4bdba941dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) +- Fixed the `VAR`, `STDEV`, `DEVSQ`, `COVARIANCE`, `SLOPE`, `STEYX`, `DVAR` and `DSTDEV` functions, their variants, and the matching `SUBTOTAL` modes losing precision on data with a large mean and a small spread. [#1784](https://github.com/handsontable/hyperformula/pull/1784) +- Fixed a bug where `SLOPE` and `STEYX` returned `#NUM!` or an arbitrary number instead of `#DIV/0!` when all the x values are equal. [#1784](https://github.com/handsontable/hyperformula/pull/1784) +- Fixed a bug where `STEYX` returned `#NUM!` or `0` instead of the standard error for points that lie almost on a line. [#1784](https://github.com/handsontable/hyperformula/pull/1784) +- Fixed a bug where `VAR`, `STDEV`, `DVAR`, `DSTDEV`, `COVARIANCE`, `SLOPE`, `STEYX`, their variants, and the matching `SUBTOTAL` modes returned an error or `0` for very large or very small values although the result was within the range of numbers. [#1784](https://github.com/handsontable/hyperformula/pull/1784) - Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) - Fixed a bug where moving or pasting a formula with an undefined name to another sheet incorrectly added an empty global named expression. [#1728](https://github.com/handsontable/hyperformula/pull/1728) diff --git a/script/release/release.sh b/script/release/release.sh index cebdc65fd9..50d7f0051d 100644 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -593,9 +593,9 @@ step "2. Create release/$VERSION in hyperformula-tests, then sync the suite" run npm run test:setup-private # 3. Bump version + release date (each half skipped when already correct, so a -CURRENT_VERSION="$(node -e 'process.stdout.write(require("./package.json").version||"")' 2>/dev/null || true)" # re-run after a later failure does not rewrite files it already wrote) step "3. Bump version + HT_RELEASE_DATE" +CURRENT_VERSION="$(node -e 'process.stdout.write(require("./package.json").version||"")' 2>/dev/null || true)" if [[ "$CURRENT_VERSION" == "$VERSION" ]]; then skip "package.json already at $VERSION" elif $DRY_RUN; then diff --git a/src/error-message.ts b/src/error-message.ts index 36bc5231af..d42040901c 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -48,6 +48,7 @@ export class ErrorMessage { public static OneValue = 'Needs at least one value.' public static TwoValues = 'Range needs to contain at least two elements.' public static ThreeValues = 'Range needs to contain at least three elements.' + public static EqualXValues = 'All x values are equal.' public static IndexBounds = 'Index out of bounds.' public static IndexLarge = 'Index too large.' public static Formula = 'Expected formula.' diff --git a/src/interpreter/deviationSums.ts b/src/interpreter/deviationSums.ts new file mode 100644 index 0000000000..f5c16c10ed --- /dev/null +++ b/src/interpreter/deviationSums.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import { + addDoubleDouble, + divideDoubleDouble, + divideDoubleDoubles, + DOUBLE_DOUBLE_ZERO, + DoubleDouble, + multiplyByPowerOfTwo, + multiplyByTwoToThe, + multiplyDoubleDouble, + roundDoubleDouble, + subtractDoubleDouble, +} from './doubleDouble' + +/** + * Sums of squared deviations and of products of deviations from the mean, for the functions built + * on them (DEVSQ, COVARIANCE, SLOPE and STEYX). + * + * The mean, the deviations and their sums are all kept in double-double and rounded once at the end, + * so the result is accurate to the last digits of the stored values even when the mean is large + * relative to the spread. + * + * Each array is measured at its own power-of-two scale, and the rounded results are scaled back, so + * that a result is not lost to an intermediate sum that overflows or underflows: + * - an array whose largest magnitude is below `MIN_UNSCALED_MAGNITUDE` is scaled up to it, which is + * exact, so that its squared deviations cannot underflow; + * - an array whose largest magnitude is above `MAX_UNSCALED_MAGNITUDE` is scaled down to it only when + * the sums overflow without that. Scaling down rounds away the low-order bits of values about 2^1400 + * times smaller than the largest one, which matter when they pair with large deviations of the other + * array, so it is used only where the sums could not be computed at all otherwise. + */ + +/** + * The binary exponent of the magnitudes that the arrays are scaled to (400). + */ +const SCALED_MAGNITUDE_EXPONENT = 400 + +/** + * The smallest largest magnitude of an array that is used without scaling up (2^-400). Distinct + * doubles near the largest magnitude differ by at least 2^-53 of it, so unless all the values are + * equal, the largest deviation from their mean is at least about 2^-454 and its square, at least + * 2^-908, is computed exactly (`twoProduct` is exact above 2^-969). So the sum of squared deviations + * is 0 only when all the values are equal. + */ +const MIN_UNSCALED_MAGNITUDE = 2 ** -SCALED_MAGNITUDE_EXPONENT + +/** + * The largest magnitude of an array that is used without scaling down when the sums overflow (2^400). + * Every deviation is then at most 2^402, and every product of two deviations at most 2^804, so no + * sum can overflow. + */ +const MAX_UNSCALED_MAGNITUDE = 2 ** SCALED_MAGNITUDE_EXPONENT + +/** + * Deviations from the mean of an array of values, multiplied by `2^-exponent`. + */ +interface ScaledDeviations { + /** `(value - mean) * 2^-exponent` for each value, without rounding */ + readonly deviations: DoubleDouble[], + /** the exponent of the power of two the deviations are scaled by */ + readonly exponent: number, +} + +/** + * Sums of products of the deviations of two paired arrays, each array at its own scale. + */ +interface PairedSums { + /** the sums, as returned by the function that computed them */ + readonly sums: DoubleDouble[], + /** the deviations of the first array are multiplied by `2^-firstExponent` */ + readonly firstExponent: number, + /** the deviations of the second array are multiplied by `2^-secondExponent` */ + readonly secondExponent: number, +} + +/** + * The sums a simple linear regression of `y` on `x` is computed from. + */ +export interface RegressionSums { + /** `sum((x - mean(x))^2) * 2^(-2 * xExponent)` */ + readonly xSumOfSquares: DoubleDouble, + /** `sum((x - mean(x)) * (y - mean(y))) * 2^(-xExponent - yExponent)` */ + readonly productsSum: DoubleDouble, + /** + * `sum((y - mean(y) - slope * (x - mean(x)))^2) * 2^(-2 * yExponent)`, the residual sum of squares, + * when requested + */ + readonly residualSumOfSquares: DoubleDouble, + /** the exponent of the power of two the x deviations are scaled by */ + readonly xExponent: number, + /** the exponent of the power of two the y deviations are scaled by */ + readonly yExponent: number, +} + +/** + * The sum of the squared deviations of the values from their mean. + * + * Only a tiny array is scaled: when the unscaled sum overflows, the sum itself exceeds the largest + * double. + * + * @param {number[]} values - a non-empty array of numbers + * @returns {number} `sum((x - mean)^2)`, rounded once to a double + */ +export function sumOfSquaredDeviations(values: number[]): number { + const {deviations, exponent} = scaledDeviationsFromMean(values, false) + return multiplyByTwoToThe(roundDoubleDouble(sumOfProducts(deviations, deviations)), 2 * exponent) +} + +/** + * The covariance of two arrays: the sum of products of deviations divided by `n - deltaDegreesOfFreedom`. + * + * The quotient is rounded at the scale of the deviations and scaled back, so it is infinite only when + * the covariance exceeds the largest double. + * + * @param {number[]} first - a non-empty array of numbers + * @param {number[]} second - an array of the same length as `first` + * @param {number} deltaDegreesOfFreedom - 0 for the population covariance, 1 for the sample covariance + * @returns {number} the covariance, rounded once to a double + */ +export function covariance(first: number[], second: number[], deltaDegreesOfFreedom: number): number { + const {sums: [productsSum], firstExponent, secondExponent} = pairedSums(first, second, (x, y) => [sumOfProducts(x, y)]) + const scaledCovariance = roundDoubleDouble(divideDoubleDouble(productsSum, first.length - deltaDegreesOfFreedom)) + return multiplyByTwoToThe(scaledCovariance, firstExponent + secondExponent) +} + +/** + * The sums of squared deviations and of products of deviations of a simple linear regression, each + * array at its own scale. + * + * Unless all the x values are equal, the scaled sum of squares of x is at least about 2^-908, so the + * scaled slope `productsSum / xSumOfSquares` is finite whenever the sum of squared y deviations is, + * and so is every residual, whose square is at most the residual sum of squares. + * + * @param {number[]} knownYs - a non-empty array of the dependent values + * @param {number[]} knownXs - an array of the independent values, of the same length as `knownYs` + * @param {boolean} withResidualSumOfSquares - whether to compute `residualSumOfSquares`; otherwise it is 0 + * @returns {RegressionSums} the scaled sums and the exponents of the scales + */ +export function regressionSums(knownYs: number[], knownXs: number[], withResidualSumOfSquares: boolean): RegressionSums { + const {sums: [xSumOfSquares, productsSum, residualSumOfSquares], firstExponent, secondExponent} = pairedSums(knownXs, knownYs, + (x, y) => { + const xSquaresSum = sumOfProducts(x, x) + const xyProductsSum = sumOfProducts(y, x) + return [xSquaresSum, xyProductsSum, withResidualSumOfSquares ? sumOfSquaredResiduals(y, x, xSquaresSum, xyProductsSum) : DOUBLE_DOUBLE_ZERO] + }, + ) + return {xSumOfSquares, productsSum, residualSumOfSquares, xExponent: firstExponent, yExponent: secondExponent} +} + +/** + * The sum of squared residuals of a simple linear regression, from the deviations of y and x. + * + * The residuals are computed one by one rather than as `sum(dy^2) - productsSum^2 / xSumOfSquares`, + * which cancels when the points lie almost on a line: each residual is then the small difference of + * a deviation and its fitted value, which double-double keeps to about 2^-106 of the deviation. + * + * The rounding errors of the two means shift every residual by the same amount, the error of the y + * mean minus the slope times the error of the x mean, which can exceed the residuals themselves when + * the slope is large. The exact residuals sum to 0, so they are centered on their mean, which + * removes that shift, before they are squared. + * + * @param {DoubleDouble[]} yDeviations - the deviations of y from its mean + * @param {DoubleDouble[]} xDeviations - the deviations of x from its mean, at the same scale as the sums + * @param {DoubleDouble} xSumOfSquares - `sum(dx^2)` + * @param {DoubleDouble} productsSum - `sum(dy * dx)` + * @returns {DoubleDouble} `sum((dy - slope * dx)^2)`, or 0 when all the x values are equal + */ +function sumOfSquaredResiduals(yDeviations: DoubleDouble[], xDeviations: DoubleDouble[], xSumOfSquares: DoubleDouble, productsSum: DoubleDouble): DoubleDouble { + if (xSumOfSquares.hi === 0) { + return DOUBLE_DOUBLE_ZERO + } + const slope = divideDoubleDoubles(productsSum, xSumOfSquares) + const residuals = yDeviations.map((deviation, index) => subtractDoubleDouble(deviation, multiplyDoubleDouble(slope, xDeviations[index]))) + const residualsSum = residuals.reduce((total, residual) => addDoubleDouble(total, residual), DOUBLE_DOUBLE_ZERO) + const residualsMean = divideDoubleDouble(residualsSum, residuals.length) + const centeredResiduals = residuals.map((residual) => subtractDoubleDouble(residual, residualsMean)) + return sumOfProducts(centeredResiduals, centeredResiduals) +} + +/** + * Sums of products of the deviations of two paired arrays from their means. + * + * The sums are computed first with only tiny arrays scaled, which gives the same results as no scaling + * at all for arrays of ordinary magnitude. Only when a sum is not finite, they are computed again with + * the arrays whose largest magnitude exceeds `MAX_UNSCALED_MAGNITUDE` scaled down. + * + * @param {number[]} first - a non-empty array of numbers + * @param {number[]} second - an array of the same length as `first` + * @param {Function} computeSums - computes the sums from the scaled deviations of `first` and `second` + * @returns {PairedSums} the scaled sums and the exponents of the scales + */ +function pairedSums(first: number[], second: number[], computeSums: (first: DoubleDouble[], second: DoubleDouble[]) => DoubleDouble[]): PairedSums { + let firstDeviations = scaledDeviationsFromMean(first, false) + let secondDeviations = scaledDeviationsFromMean(second, false) + let sums = computeSums(firstDeviations.deviations, secondDeviations.deviations) + if (sums.some((sum) => !Number.isFinite(sum.hi))) { + firstDeviations = scaledDeviationsFromMean(first, true) + secondDeviations = scaledDeviationsFromMean(second, true) + sums = computeSums(firstDeviations.deviations, secondDeviations.deviations) + } + return {sums, firstExponent: firstDeviations.exponent, secondExponent: secondDeviations.exponent} +} + +/** + * The deviations of the values from their mean, measured at a power-of-two scale. + * + * The values are first multiplied by `2^-exponent`, which brings their largest magnitude up to + * `MIN_UNSCALED_MAGNITUDE` when it is below it, and, when `scaleDown` is set, down to + * `MAX_UNSCALED_MAGNITUDE` when it is above it. Scaling up is exact. Scaling down is exact except for + * values that become subnormal, at least 2^1400 times smaller than the largest one. + * + * @param {number[]} values - a non-empty array of numbers + * @param {boolean} scaleDown - whether to scale down values above `MAX_UNSCALED_MAGNITUDE` + * @returns {ScaledDeviations} the scaled deviations, without rounding, and the exponent of the scale + */ +function scaledDeviationsFromMean(values: number[], scaleDown: boolean): ScaledDeviations { + const exponent = scalingExponent(largestMagnitude(values), scaleDown) + const scale = 2 ** -exponent + const scaledValues = exponent === 0 ? values : values.map((value) => value * scale) + const center = mean(scaledValues) + return { + deviations: scaledValues.map((value) => subtractDoubleDouble({hi: value, lo: 0}, center)), + exponent, + } +} + +/** + * The exponent `scaledDeviationsFromMean` scales the values by. + * + * @param {number} magnitude - the largest magnitude of the values + * @param {boolean} scaleDown - whether to scale down magnitudes above `MAX_UNSCALED_MAGNITUDE` + * @returns {number} an exponent between -674 and 624: 0 when the values are not scaled, otherwise the + * binary exponent of `magnitude` minus that of the magnitude it is scaled to + */ +function scalingExponent(magnitude: number, scaleDown: boolean): number { + if (magnitude > 0 && magnitude < MIN_UNSCALED_MAGNITUDE) { + return Math.floor(Math.log2(magnitude)) + SCALED_MAGNITUDE_EXPONENT + } + if (scaleDown && magnitude > MAX_UNSCALED_MAGNITUDE && Number.isFinite(magnitude)) { + return Math.floor(Math.log2(magnitude)) - SCALED_MAGNITUDE_EXPONENT + } + return 0 +} + +/** + * The largest absolute value of the values. + * + * A plain loop, which is several times faster than `reduce` with `Math.max` on long arrays. + * + * @param {number[]} values - an array of numbers + * @returns {number} the largest `|value|`, or 0 for an empty array + */ +function largestMagnitude(values: number[]): number { + let largest = 0 + for (const value of values) { + const magnitude = Math.abs(value) + if (magnitude > largest) { + largest = magnitude + } + } + return largest +} + +/** + * The mean of the values. + * + * When the running total overflows, the values are summed scaled down by a power of two no smaller + * than their count, so that no partial sum can exceed the largest double, and the mean is scaled back. + * + * @param {number[]} values - a non-empty array of numbers + * @returns {DoubleDouble} the mean, without rounding to a double + */ +function mean(values: number[]): DoubleDouble { + const total = sum(values) + if (Number.isFinite(total.hi)) { + return divideDoubleDouble(total, values.length) + } + const scale = 2 ** Math.ceil(Math.log2(values.length)) + const scaledTotal = sum(values.map((value) => value / scale)) + return multiplyByPowerOfTwo(divideDoubleDouble(scaledTotal, values.length), scale) +} + +/** + * The sum of the values. + * + * @param {number[]} values - an array of numbers + * @returns {DoubleDouble} the sum, without rounding to a double + */ +function sum(values: number[]): DoubleDouble { + return values.reduce((total, value) => addDoubleDouble(total, {hi: value, lo: 0}), DOUBLE_DOUBLE_ZERO) +} + +/** + * The sum of the products of paired double-doubles. + * + * @param {DoubleDouble[]} first - the first factors + * @param {DoubleDouble[]} second - the second factors, of the same length + * @returns {DoubleDouble} `sum(first[i] * second[i])` + */ +function sumOfProducts(first: DoubleDouble[], second: DoubleDouble[]): DoubleDouble { + return first.reduce((sum, deviation, index) => addDoubleDouble(sum, multiplyDoubleDouble(deviation, second[index])), DOUBLE_DOUBLE_ZERO) +} diff --git a/src/interpreter/doubleDouble.ts b/src/interpreter/doubleDouble.ts new file mode 100644 index 0000000000..c2497a1fb9 --- /dev/null +++ b/src/interpreter/doubleDouble.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * A number represented as the unevaluated sum `hi + lo` of two doubles (double-double arithmetic). + * It carries about 106 bits of significand instead of 53, so sums and products keep the low-order + * digits that plain doubles round away. After `twoSum`, `twoProduct`, `addDoubleDouble` and + * `divideDoubleDouble`, `|lo|` is at most half an ulp of `hi`; products of double-doubles are left + * unnormalized. + * + * Used where a result is the small difference of large accumulated quantities, such as a sum of + * squared deviations. The building blocks are the error-free transformations TwoSum and TwoProduct + * (splitting a factor by 2^27 + 1), which need no fused multiply-add. + */ +export interface DoubleDouble { + readonly hi: number, + readonly lo: number, +} + +export const DOUBLE_DOUBLE_ZERO: DoubleDouble = {hi: 0, lo: 0} + +/** Splitting constant for binary64: 2^27 + 1. */ +const SPLITTER = 134217729 + +/** The largest magnitude that can be multiplied by `SPLITTER` without overflowing (2^996). */ +const SPLIT_LIMIT = 2 ** 996 + +/** + * The largest product magnitude for which the product of the split high halves (at most `|a * b|` + * times (1 + 2^-26)^2) cannot overflow (2^1023). + */ +const PRODUCT_LIMIT = 2 ** 1023 + +/** + * An exact power-of-two scale (2^53) for the larger factor of a product above `PRODUCT_LIMIT` or with + * a factor above `SPLIT_LIMIT`, so that splitting it cannot overflow. + */ +const FACTOR_SCALE = 2 ** 53 + +/** + * The largest dividend magnitude that `divideDoubleDouble` and `divideDoubleDoubles` divide directly + * (2^1000). Above it, the quotient times the divisor can round to infinity, so the dividend is scaled + * down by `DIVIDEND_SCALE` first. + */ +const DIVIDEND_LIMIT = 2 ** 1000 + +/** An exact power-of-two scale for dividends above `DIVIDEND_LIMIT` (2^64). */ +const DIVIDEND_SCALE = 2 ** 64 + +/** + * The exact sum of two doubles, as a double-double (TwoSum). + * + * The rounding error is computed from the addend of larger magnitude (Fast2Sum), which is exact and, + * unlike the branch-free form, cannot overflow while the sum is finite. + * + * @param {number} a - first addend + * @param {number} b - second addend + * @returns {DoubleDouble} `a + b` with no rounding error + */ +export function twoSum(a: number, b: number): DoubleDouble { + const hi = a + b + return {hi, lo: Math.abs(a) >= Math.abs(b) ? b - (hi - a) : a - (hi - b)} +} + +/** + * The exact product of two doubles, as a double-double (TwoProduct). + * + * When a factor exceeds 2^996 or the product exceeds 2^1023, the larger factor is scaled by 2^-53 + * (exactly) so the splitting cannot overflow, and the error term is scaled back. + * + * @param {number} a - first factor + * @param {number} b - second factor + * @returns {DoubleDouble} `a * b`, with no rounding error whenever the product is finite and at least + * 2^-969 in magnitude; an infinite or NaN product has `lo` 0 + */ +export function twoProduct(a: number, b: number): DoubleDouble { + const hi = a * b + if (Math.abs(a) <= SPLIT_LIMIT && Math.abs(b) <= SPLIT_LIMIT && Math.abs(hi) <= PRODUCT_LIMIT) { + return {hi, lo: productError(a, b, hi)} + } + if (!Number.isFinite(hi)) { + return {hi, lo: 0} + } + const [larger, smaller] = Math.abs(a) >= Math.abs(b) ? [a, b] : [b, a] + const largerScaled = larger / FACTOR_SCALE + return {hi, lo: productError(largerScaled, smaller, largerScaled * smaller) * FACTOR_SCALE} +} + +/** + * The rounding error of `a * b`, where `hi` is `a * b` rounded (Dekker's product with Veltkamp + * splitting). Exact when both factors are at most `SPLIT_LIMIT` and `|hi|` is at most `PRODUCT_LIMIT`. + * + * @param {number} a - first factor + * @param {number} b - second factor + * @param {number} hi - `a * b` rounded to a double + * @returns {number} `a * b - hi` + */ +function productError(a: number, b: number, hi: number): number { + const aScaled = SPLITTER * a + const aHigh = aScaled - (aScaled - a) + const aLow = a - aHigh + const bScaled = SPLITTER * b + const bHigh = bScaled - (bScaled - b) + const bLow = b - bHigh + return ((aHigh * bHigh - hi) + aHigh * bLow + aLow * bHigh) + aLow * bLow +} + +/** + * Sum of two double-doubles. + * + * @param {DoubleDouble} x - first addend + * @param {DoubleDouble} y - second addend + * @returns {DoubleDouble} `x + y`; a sum that is infinite or NaN is the plain double sum + */ +export function addDoubleDouble(x: DoubleDouble, y: DoubleDouble): DoubleDouble { + const sum = twoSum(x.hi, y.hi) + if (!Number.isFinite(sum.hi)) { + return {hi: sum.hi, lo: 0} + } + const lo = sum.lo + x.lo + y.lo + const hi = sum.hi + lo + return {hi, lo: lo - (hi - sum.hi)} +} + +/** + * Difference of two double-doubles. + * + * @param {DoubleDouble} x - minuend + * @param {DoubleDouble} y - subtrahend + * @returns {DoubleDouble} `x - y`; a difference that is infinite or NaN is the plain double difference + */ +export function subtractDoubleDouble(x: DoubleDouble, y: DoubleDouble): DoubleDouble { + return addDoubleDouble(x, {hi: -y.hi, lo: -y.lo}) +} + +/** + * Product of two double-doubles. + * + * @param {DoubleDouble} x - first factor + * @param {DoubleDouble} y - second factor + * @returns {DoubleDouble} `x * y` + */ +export function multiplyDoubleDouble(x: DoubleDouble, y: DoubleDouble): DoubleDouble { + const product = twoProduct(x.hi, y.hi) + return {hi: product.hi, lo: product.lo + x.hi * y.lo + x.lo * y.hi} +} + +/** + * Product of a double-double and a double. + * + * @param {DoubleDouble} x - double-double factor + * @param {number} k - double factor + * @returns {DoubleDouble} `x * k` + */ +export function scaleDoubleDouble(x: DoubleDouble, k: number): DoubleDouble { + const product = twoProduct(x.hi, k) + return {hi: product.hi, lo: product.lo + x.lo * k} +} + +/** + * Quotient of a double-double and a double. + * + * @param {DoubleDouble} x - dividend + * @param {number} k - divisor + * @returns {DoubleDouble} `x / k` + */ +export function divideDoubleDouble(x: DoubleDouble, k: number): DoubleDouble { + if (Math.abs(x.hi) > DIVIDEND_LIMIT && Number.isFinite(x.hi)) { + return multiplyByPowerOfTwo(divideDoubleDouble(multiplyByPowerOfTwo(x, 1 / DIVIDEND_SCALE), k), DIVIDEND_SCALE) + } + const quotient = x.hi / k + const product = twoProduct(quotient, k) + return twoSum(quotient, ((x.hi - product.hi) - product.lo + x.lo) / k) +} + +/** + * Quotient of two double-doubles. + * + * A zero or non-finite divisor, or a non-finite quotient, gives the plain double quotient, so infinities, + * zeros and NaNs are the same as in ordinary arithmetic. + * + * @param {DoubleDouble} x - dividend + * @param {DoubleDouble} y - divisor + * @returns {DoubleDouble} `x / y` + */ +export function divideDoubleDoubles(x: DoubleDouble, y: DoubleDouble): DoubleDouble { + const quotient = x.hi / y.hi + if (!Number.isFinite(quotient) || !Number.isFinite(y.hi)) { + return {hi: quotient, lo: 0} + } + if (Math.abs(x.hi) > DIVIDEND_LIMIT) { + return multiplyByPowerOfTwo(divideDoubleDoubles(multiplyByPowerOfTwo(x, 1 / DIVIDEND_SCALE), y), DIVIDEND_SCALE) + } + const remainder = subtractDoubleDouble(x, scaleDoubleDouble(y, quotient)) + return twoSum(quotient, remainder.hi / y.hi) +} + +/** + * Product of a double-double and a power of two, which is exact unless it overflows or underflows. + * + * @param {DoubleDouble} x - double-double factor + * @param {number} powerOfTwo - a power of two + * @returns {DoubleDouble} `x * powerOfTwo` + */ +export function multiplyByPowerOfTwo(x: DoubleDouble, powerOfTwo: number): DoubleDouble { + return {hi: x.hi * powerOfTwo, lo: x.lo * powerOfTwo} +} + +/** + * Product of a double and `2^exponent`, for exponents beyond the range of a finite power of two. + * + * The power is applied in two halves of the same sign, so that neither half overflows or underflows + * and an intermediate product overflows only when the result does. The product is exact unless it + * overflows or is subnormal; a subnormal product can be rounded twice. + * + * @param {number} value - the double factor + * @param {number} exponent - an integer, at most 2046 in magnitude + * @returns {number} `value * 2^exponent` + */ +export function multiplyByTwoToThe(value: number, exponent: number): number { + const half = Math.trunc(exponent / 2) + return value * 2 ** half * 2 ** (exponent - half) +} + +/** + * Rounds a double-double to the nearest double. + * + * @param {DoubleDouble} x - the value to round + * @returns {number} `x` as a double + */ +export function roundDoubleDouble(x: DoubleDouble): number { + return x.hi + x.lo +} diff --git a/src/interpreter/plugin/AverageResult.ts b/src/interpreter/plugin/AverageResult.ts new file mode 100644 index 0000000000..fefceae88d --- /dev/null +++ b/src/interpreter/plugin/AverageResult.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {Maybe} from '../../Maybe' + +/** + * The sum and the count of a set of numbers, composable so that the value of a range can be cached + * and reused for a larger range. + */ +export class AverageResult { + public static empty = new AverageResult(0, 0) + + /** + * @param {number} sum - the sum of the values + * @param {number} count - the number of values + */ + constructor( + public readonly sum: number, + public readonly count: number, + ) {} + + /** + * The sum and the count of one value. + * + * @param {number} arg - the value + * @returns {AverageResult} an aggregate of the single value + */ + public static single(arg: number): AverageResult { + return new AverageResult(arg, 1) + } + + /** + * Combines two aggregates by adding their sums and their counts. + * + * @param {AverageResult} other - the aggregate to add + * @returns {AverageResult} the aggregate of the values of both + */ + public compose(other: AverageResult) { + return new AverageResult(this.sum + other.sum, this.count + other.count) + } + + /** + * The average: the sum divided by the count. + * + * @returns {Maybe} the average, or `undefined` for no values + */ + public averageValue(): Maybe { + if (this.count > 0) { + return this.sum / this.count + } else { + return undefined + } + } +} diff --git a/src/interpreter/plugin/ConditionalAggregationPlugin.ts b/src/interpreter/plugin/ConditionalAggregationPlugin.ts index 34e0fc7db6..774f418f6f 100644 --- a/src/interpreter/plugin/ConditionalAggregationPlugin.ts +++ b/src/interpreter/plugin/ConditionalAggregationPlugin.ts @@ -18,33 +18,9 @@ import { RawScalarValue } from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' +import {AverageResult} from './AverageResult' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' -class AverageResult { - public static empty = new AverageResult(0, 0) - - constructor( - public readonly sum: number, - public readonly count: number, - ) {} - - public static single(arg: number): AverageResult { - return new AverageResult(arg, 1) - } - - public compose(other: AverageResult) { - return new AverageResult(this.sum + other.sum, this.count + other.count) - } - - public averageValue(): Maybe { - if (this.count > 0) { - return this.sum / this.count - } else { - return undefined - } - } -} - /** Computes key for criterion function cache */ function conditionalAggregationFunctionCacheKey(functionName: string): (conditions: Condition[]) => string { return (conditions: Condition[]): string => { diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 1208a0f17f..b91c802b13 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -11,6 +11,7 @@ import {EmptyValue, getRawValue, InternalScalarValue, InterpreterValue, isExtend import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' import {CriterionLambda} from '../Criterion' +import {MomentsAggregate} from './MomentsAggregate' /** * Parsed criterion for a single cell in the criteria range. @@ -337,13 +338,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return values } - if (values.length <= 1) { - return new CellError(ErrorType.DIV_BY_ZERO) - } - - const mean = values.reduce((a, b) => a + b, 0) / values.length - const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) - return Math.sqrt(variance) + return MomentsAggregate.of(values).stdevSValue() ?? new CellError(ErrorType.DIV_BY_ZERO) }) } @@ -362,13 +357,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return values } - if (values.length === 0) { - return new CellError(ErrorType.DIV_BY_ZERO) - } - - const mean = values.reduce((a, b) => a + b, 0) / values.length - const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length - return Math.sqrt(variance) + return MomentsAggregate.of(values).stdevPValue() ?? new CellError(ErrorType.DIV_BY_ZERO) }) } @@ -386,12 +375,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return values } - if (values.length <= 1) { - return new CellError(ErrorType.DIV_BY_ZERO) - } - - const mean = values.reduce((a, b) => a + b, 0) / values.length - return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + return MomentsAggregate.of(values).varSValue() ?? new CellError(ErrorType.DIV_BY_ZERO) }) } @@ -410,12 +394,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return values } - if (values.length === 0) { - return new CellError(ErrorType.DIV_BY_ZERO) - } - - const mean = values.reduce((a, b) => a + b, 0) / values.length - return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + return MomentsAggregate.of(values).varPValue() ?? new CellError(ErrorType.DIV_BY_ZERO) }) } diff --git a/src/interpreter/plugin/MomentsAggregate.ts b/src/interpreter/plugin/MomentsAggregate.ts new file mode 100644 index 0000000000..b962c3b485 --- /dev/null +++ b/src/interpreter/plugin/MomentsAggregate.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {Maybe} from '../../Maybe' +import { + addDoubleDouble, + divideDoubleDouble, + DOUBLE_DOUBLE_ZERO, + DoubleDouble, + multiplyByPowerOfTwo, + multiplyByTwoToThe, + multiplyDoubleDouble, + roundDoubleDouble, + scaleDoubleDouble, + subtractDoubleDouble, + twoSum, +} from '../doubleDouble' + +/** + * The largest scaled sum of squared deviations a `MomentsAggregate` keeps (2^960). Above it, the + * aggregate raises its exponent. Every scaled deviation is then at most 2^480, so rebasing the sums of + * two aggregates cannot overflow. + */ +const MAX_SCALED_SUM_OF_SQUARES = 2 ** 960 + +/** + * The largest scaled difference of shifts that two aggregates are rebased with (2^470). Above it, the + * exponent is raised first. + */ +const MAX_SCALED_SHIFT_DIFFERENCE = 2 ** 470 + +/** + * The smallest non-zero scaled difference of shifts that is added to an aggregate whose sum of squares + * is 0 without lowering its exponent first (2^-450). Its square, at least 2^-900, is then computed + * exactly (`twoProduct` is exact above 2^-969). + */ +const MIN_SCALED_SHIFT_DIFFERENCE = 2 ** -450 + +/** + * The lowest exponent of an aggregate (-600). Scaled by `2^600`, even the smallest difference of two + * doubles, 2^-1074, has a square above 2^-969, while `2^600` and `2^-600` are finite. + */ +const MIN_EXPONENT = -600 + +/** + * The smallest exponent `e >= 0` (up to one) for which `magnitude * 2^-e` is at most `limit`. + * + * @param {number} magnitude - a non-negative finite number + * @param {number} limit - a positive power of two + * @returns {number} the exponent + */ +function exponentToFit(magnitude: number, limit: number): number { + return magnitude <= limit ? 0 : Math.ceil(Math.log2(magnitude / limit)) +} + +/** + * The exponent at which two shifts can be subtracted and their scaled difference squared: 0 when the + * difference is within [`MIN_SCALED_SHIFT_DIFFERENCE`, `MAX_SCALED_SHIFT_DIFFERENCE`], higher when it + * is larger, lower (down to `MIN_EXPONENT`) when it is smaller, and `-Infinity` when it is 0. + * + * @param {number} shift - the first shift + * @param {number} otherShift - the second shift + * @returns {number} the exponent + */ +function shiftDifferenceExponent(shift: number, otherShift: number): number { + const difference = Math.abs(otherShift - shift) + if (difference === 0) { + return -Infinity + } + if (difference < MIN_SCALED_SHIFT_DIFFERENCE) { + return Math.max(MIN_EXPONENT, Math.floor(Math.log2(difference))) + } + // halving the shifts first keeps their difference finite + return exponentToFit(Math.abs(otherShift / 2 - shift / 2), MAX_SCALED_SHIFT_DIFFERENCE / 2) +} + +/** + * Moments of a set of numbers, composable so that the value of a range can be cached and reused + * for a larger range. + * + * Besides the count, it keeps the sums of deviations `S1` and of squared deviations `S2` from a + * `shift`: the first value added. Both sums are double-double. Measured from a nearby value, the + * deviations stay small, which avoids the cancellation of the textbook one-pass form + * `sum(x^2) - sum(x)^2 / n` for data with a large mean and a small spread (for example 10000000.001, + * 10000000.002, ...). The variance and the standard deviation are accurate to about one unit in the + * last place of the exact values for the stored numbers. Double-double arithmetic does not guarantee + * correct rounding. + * + * The deviations are stored multiplied by `2^-exponent`, which is exact, so that the sums can neither + * overflow nor underflow. The exponent is 0 until the scaled sum of squares exceeds + * `MAX_SCALED_SUM_OF_SQUARES` (deviations of about 1e144 and above), and grows as needed. When all the + * values so far are equal and the next one differs from them by less than `MIN_SCALED_SHIFT_DIFFERENCE` + * (about 1e-135), the exponent is lowered instead, so that the squared difference does not underflow. + * An aggregate whose sum of squares is 0 has exponent 0. The variance and the standard deviation are + * computed at the scale of the sums and scaled back, so each is `#NUM!` only when it exceeds the + * largest double, and 0 only when it is below the smallest one, whatever the order of the values. + */ +export class MomentsAggregate { + + public static empty = new MomentsAggregate(0, 0, 0, DOUBLE_DOUBLE_ZERO, DOUBLE_DOUBLE_ZERO) + + /** + * @param {number} count - the number of values + * @param {number} shift - the value the deviations are measured from + * @param {number} exponent - the deviations are stored multiplied by `2^-exponent` + * @param {DoubleDouble} shiftedSum - `S1`, the sum of `(x - shift) * 2^-exponent` + * @param {DoubleDouble} shiftedSumOfSquares - `S2`, the sum of `((x - shift) * 2^-exponent)^2` + */ + constructor( + public readonly count: number, + public readonly shift: number, + public readonly exponent: number, + public readonly shiftedSum: DoubleDouble, + public readonly shiftedSumOfSquares: DoubleDouble, + ) { + } + + /** + * The moments of one value, which is also the shift. + * + * @param {number} arg - the value + * @returns {MomentsAggregate} an aggregate of the single value + */ + public static single(arg: number): MomentsAggregate { + return new MomentsAggregate(1, arg, 0, DOUBLE_DOUBLE_ZERO, DOUBLE_DOUBLE_ZERO) + } + + /** + * The moments of an array of numbers, added one by one in order, with the first value as the shift. + * + * VAR.S, VAR.P, STDEV.S and STDEV.P fold a single uncached range the same way. When they reuse the + * cached aggregate of a smaller range or get several arguments, they compose partial aggregates in a + * different order, so their results can differ from this one in the last bits. + * + * @param {number[]} values - the values + * @returns {MomentsAggregate} an aggregate of the values + */ + public static of(values: number[]): MomentsAggregate { + return values.reduce((aggregate, value) => aggregate.compose(MomentsAggregate.single(value)), MomentsAggregate.empty) + } + + /** + * An aggregate whose exponent is raised, if needed, so that its scaled sum of squares is at most + * `MAX_SCALED_SUM_OF_SQUARES`. + * + * @param {number} count - the number of values + * @param {number} shift - the value the deviations are measured from + * @param {number} exponent - the exponent of the given sums + * @param {DoubleDouble} shiftedSum - the scaled sum of deviations + * @param {DoubleDouble} shiftedSumOfSquares - the scaled sum of squared deviations + * @returns {MomentsAggregate} the aggregate + */ + private static normalized(count: number, shift: number, exponent: number, shiftedSum: DoubleDouble, shiftedSumOfSquares: DoubleDouble): MomentsAggregate { + const excess = exponentToFit(Math.abs(shiftedSumOfSquares.hi), MAX_SCALED_SUM_OF_SQUARES) + if (excess === 0) { + return new MomentsAggregate(count, shift, exponent, shiftedSum, shiftedSumOfSquares) + } + const raise = Math.ceil(excess / 2) + return new MomentsAggregate(count, shift, exponent + raise, + multiplyByPowerOfTwo(shiftedSum, 2 ** -raise), + multiplyByPowerOfTwo(shiftedSumOfSquares, 2 ** (-2 * raise)), + ) + } + + /** + * Combines two aggregates. The result keeps this aggregate's `shift`; the other one's shifted sums + * are re-expressed relative to it, at the larger exponent of the two (raised further when the + * difference of the shifts needs it). The exponent of an aggregate whose sum of squares is 0 does not + * count, so the exponent is lowered when all the values are equal but for a tiny difference of the + * shifts. An empty aggregate is the identity: composing with it returns the other aggregate + * unchanged, with its own shift. + * + * @param {MomentsAggregate} other - the aggregate to add + * @returns {MomentsAggregate} the aggregate of the values of both + */ + public compose(other: MomentsAggregate): MomentsAggregate { + if (this.count === 0) { + return other + } + if (other.count === 0) { + return this + } + + // the common case: no rescaling, because both aggregates have the same exponent or the other one + // is a single value, whose shifted sums are 0 at any exponent + if (other.exponent === this.exponent || other.count === 1) { + const exponent = this.exponent + const shiftDifference = exponent === 0 + ? twoSum(other.shift, -this.shift) + : twoSum(other.shift * 2 ** -exponent, -this.shift * 2 ** -exponent) + const magnitude = Math.abs(shiftDifference.hi) + // a difference below MIN_SCALED_SHIFT_DIFFERENCE would lose precision when squared, which matters + // only when it is not 0 and no non-zero sum of squares outweighs it; the exponent is then lowered + const squaresPrecisely = magnitude >= MIN_SCALED_SHIFT_DIFFERENCE || magnitude === 0 + || this.shiftedSumOfSquares.hi !== 0 || other.shiftedSumOfSquares.hi !== 0 + if (magnitude <= MAX_SCALED_SHIFT_DIFFERENCE && squaresPrecisely) { + return this.composeScaled(other, exponent, this.shiftedSum, this.shiftedSumOfSquares, other.shiftedSum, other.shiftedSumOfSquares, shiftDifference) + } + } + + // an aggregate whose sum of squares is 0 does not constrain the exponent + const exponent = Math.max( + this.shiftedSumOfSquares.hi === 0 ? -Infinity : this.exponent, + other.shiftedSumOfSquares.hi === 0 ? -Infinity : other.exponent, + shiftDifferenceExponent(this.shift, other.shift), + ) + const thisScale = 2 ** (this.exponent - exponent) + const otherScale = 2 ** (other.exponent - exponent) + const scale = 2 ** -exponent + return this.composeScaled(other, exponent, + multiplyByPowerOfTwo(this.shiftedSum, thisScale), + multiplyByPowerOfTwo(multiplyByPowerOfTwo(this.shiftedSumOfSquares, thisScale), thisScale), + multiplyByPowerOfTwo(other.shiftedSum, otherScale), + multiplyByPowerOfTwo(multiplyByPowerOfTwo(other.shiftedSumOfSquares, otherScale), otherScale), + twoSum(other.shift * scale, -this.shift * scale), + ) + } + + /** + * The sample variance, as in VAR.S. + * + * @returns {Maybe} the variance, or `undefined` for fewer than two values + */ + public varSValue(): Maybe { + if (this.count > 1) { + return this.variance(this.count - 1) + } else { + return undefined + } + } + + /** + * The population variance, as in VAR.P. + * + * @returns {Maybe} the variance, or `undefined` for no values + */ + public varPValue(): Maybe { + if (this.count > 0) { + return this.variance(this.count) + } else { + return undefined + } + } + + /** + * The sample standard deviation, as in STDEV.S. + * + * @returns {Maybe} the standard deviation, or `undefined` for fewer than two values + */ + public stdevSValue(): Maybe { + if (this.count > 1) { + return this.standardDeviation(this.count - 1) + } else { + return undefined + } + } + + /** + * The population standard deviation, as in STDEV.P. + * + * @returns {Maybe} the standard deviation, or `undefined` for no values + */ + public stdevPValue(): Maybe { + if (this.count > 0) { + return this.standardDeviation(this.count) + } else { + return undefined + } + } + + /** + * Adds the other aggregate's sums, already scaled to `exponent`, to this aggregate's sums, also + * scaled to `exponent`. + * + * @param {MomentsAggregate} other - the aggregate to add + * @param {number} exponent - the common exponent + * @param {DoubleDouble} shiftedSum - this aggregate's `S1` at `exponent` + * @param {DoubleDouble} shiftedSumOfSquares - this aggregate's `S2` at `exponent` + * @param {DoubleDouble} otherShiftedSum - the other aggregate's `S1` at `exponent` + * @param {DoubleDouble} otherShiftedSumOfSquares - the other aggregate's `S2` at `exponent` + * @param {DoubleDouble} shiftDifference - `(other.shift - this.shift) * 2^-exponent` + * @returns {MomentsAggregate} the aggregate of the values of both + */ + private composeScaled( + other: MomentsAggregate, + exponent: number, + shiftedSum: DoubleDouble, + shiftedSumOfSquares: DoubleDouble, + otherShiftedSum: DoubleDouble, + otherShiftedSumOfSquares: DoubleDouble, + shiftDifference: DoubleDouble, + ): MomentsAggregate { + const count = this.count + other.count + if (other.count === 1) { + // the common case of adding one value: its deviation is the shift difference itself + return MomentsAggregate.normalized(count, this.shift, exponent, + addDoubleDouble(shiftedSum, shiftDifference), + addDoubleDouble(shiftedSumOfSquares, multiplyDoubleDouble(shiftDifference, shiftDifference)), + ) + } + + // other's sums rebased: S1 + n*d and S2 + 2*d*S1 + n*d^2 + const rebasedSum = addDoubleDouble(otherShiftedSum, scaleDoubleDouble(shiftDifference, other.count)) + const rebasedSumOfSquares = addDoubleDouble( + addDoubleDouble(otherShiftedSumOfSquares, scaleDoubleDouble(multiplyDoubleDouble(shiftDifference, otherShiftedSum), 2)), + scaleDoubleDouble(multiplyDoubleDouble(shiftDifference, shiftDifference), other.count), + ) + return MomentsAggregate.normalized(count, this.shift, exponent, + addDoubleDouble(shiftedSum, rebasedSum), + addDoubleDouble(shiftedSumOfSquares, rebasedSumOfSquares), + ) + } + + /** + * The variance: the sum of squared deviations from the mean divided by `divisor`. + * + * The scaled variance is multiplied by `2^(2 * exponent)` in two steps, so that the power of two + * itself cannot overflow; the result overflows only when the variance exceeds the largest double. + * + * @param {number} divisor - `n - 1` for the sample variance, `n` for the population variance + * @returns {number} the variance + */ + private variance(divisor: number): number { + return multiplyByTwoToThe(this.scaledVariance(divisor), 2 * this.exponent) + } + + /** + * The standard deviation: the square root of the variance, taken at the scale of the sums, so that + * it is finite and non-zero whenever the exact standard deviation is, even when the variance is not. + * + * @param {number} divisor - `n - 1` for the sample standard deviation, `n` for the population one + * @returns {number} the standard deviation + */ + private standardDeviation(divisor: number): number { + return Math.sqrt(this.scaledVariance(divisor)) * 2 ** this.exponent + } + + /** + * The sum of squared deviations from the mean at the scale of the sums, `S2 - S1^2 / n` with `S1`, + * `S2` the shifted sums and `n` the count, divided by `divisor` and rounded once. + * + * `S1^2 / n` is evaluated as `S1 * (S1 / n)`, which cannot overflow because `S1^2 / n <= S2`. + * + * @param {number} divisor - `n - 1` for the sample variance, `n` for the population variance + * @returns {number} the variance multiplied by `2^(-2 * exponent)` + */ + private scaledVariance(divisor: number): number { + const squaredSumOverCount = multiplyDoubleDouble(this.shiftedSum, divideDoubleDouble(this.shiftedSum, this.count)) + const sumOfSquaredDeviations = subtractDoubleDouble(this.shiftedSumOfSquares, squaredSumOverCount) + return roundDoubleDouble(divideDoubleDouble(sumOfSquaredDeviations, divisor)) + } +} diff --git a/src/interpreter/plugin/NumericAggregationPlugin.ts b/src/interpreter/plugin/NumericAggregationPlugin.ts index 5a675e9360..6f426c6080 100644 --- a/src/interpreter/plugin/NumericAggregationPlugin.ts +++ b/src/interpreter/plugin/NumericAggregationPlugin.ts @@ -14,6 +14,8 @@ import {coerceBooleanToNumber} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' import {EmptyValue, ExtendedNumber, getRawValue, InternalScalarValue, isExtendedNumber} from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' +import {AverageResult} from './AverageResult' +import {MomentsAggregate} from './MomentsAggregate' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' import {RangeVertex} from '../../DependencyGraph' @@ -31,50 +33,6 @@ function zeroForInfinite(value: InternalScalarValue) { } } -class MomentsAggregate { - - public static empty = new MomentsAggregate(0, 0, 0) - - constructor( - public readonly sumsq: number, - public readonly sum: number, - public readonly count: number, - ) { - } - - public static single(arg: number): MomentsAggregate { - return new MomentsAggregate(arg * arg, arg, 1) - } - - public compose(other: MomentsAggregate) { - return new MomentsAggregate(this.sumsq + other.sumsq, this.sum + other.sum, this.count + other.count) - } - - public averageValue(): Maybe { - if (this.count > 0) { - return this.sum / this.count - } else { - return undefined - } - } - - public varSValue(): Maybe { - if (this.count > 1) { - return (this.sumsq - (this.sum * this.sum) / this.count) / (this.count - 1) - } else { - return undefined - } - } - - public varPValue(): Maybe { - if (this.count > 0) { - return (this.sumsq - (this.sum * this.sum) / this.count) / this.count - } else { - return undefined - } - } -} - export class NumericAggregationPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { 'SUM': { @@ -298,17 +256,7 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function } public averagea(ast: ProcedureAst, state: InterpreterState): InternalScalarValue { - const result = this.reduce(ast.args, state, MomentsAggregate.empty, '_AGGREGATE_A', - (left, right) => left.compose(right), - (arg): MomentsAggregate => MomentsAggregate.single(getRawValue(arg)), - numbersBooleans - ) - - if (result instanceof CellError) { - return result - } else { - return result.averageValue() ?? new CellError(ErrorType.DIV_BY_ZERO) - } + return this.doAverageA(ast.args, state) } public vars(ast: ProcedureAst, state: InterpreterState): InternalScalarValue { @@ -353,8 +301,7 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function if (result instanceof CellError) { return result } else { - const val = result.varSValue() - return val === undefined ? new CellError(ErrorType.DIV_BY_ZERO) : Math.sqrt(val) + return result.stdevSValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } } @@ -364,8 +311,7 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function if (result instanceof CellError) { return result } else { - const val = result.varPValue() - return val === undefined ? new CellError(ErrorType.DIV_BY_ZERO) : Math.sqrt(val) + return result.stdevPValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } } @@ -439,13 +385,33 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function } private doAverage(args: Ast[], state: InterpreterState): InternalScalarValue { - const result = this.reduceAggregate(args, state) + return this.averageOf(args, state, '_AVERAGE', strictlyNumbers) + } + + private doAverageA(args: Ast[], state: InterpreterState): InternalScalarValue { + return this.averageOf(args, state, '_AVERAGE_A', numbersBooleans) + } + /** + * The plain sum of the values divided by their count, as in AVERAGE and AVERAGEA. The sum and the + * count are folded in one pass and cached per range, so AVERAGE does not compute the variance sums. + * + * @param {Ast[]} args - the function arguments + * @param {InterpreterState} state - interpreter state + * @param {string} cacheKey - the range cache key + * @param {coercionOperation} coercion - which values count + * @returns {InternalScalarValue} the average, `#DIV/0!` when there are no values, or the first error + */ + private averageOf(args: Ast[], state: InterpreterState, cacheKey: string, coercion: coercionOperation): InternalScalarValue { + const result = this.reduce(args, state, AverageResult.empty, cacheKey, + (left, right) => left.compose(right), + (arg) => AverageResult.single(getRawValue(arg)), + coercion, + ) if (result instanceof CellError) { return result - } else { - return result.averageValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } + return result.averageValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } private doVarS(args: Ast[], state: InterpreterState): InternalScalarValue { @@ -474,8 +440,7 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function if (result instanceof CellError) { return result } else { - const val = result.varSValue() - return val === undefined ? new CellError(ErrorType.DIV_BY_ZERO) : Math.sqrt(val) + return result.stdevSValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } } @@ -485,8 +450,7 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function if (result instanceof CellError) { return result } else { - const val = result.varPValue() - return val === undefined ? new CellError(ErrorType.DIV_BY_ZERO) : Math.sqrt(val) + return result.stdevPValue() ?? new CellError(ErrorType.DIV_BY_ZERO) } } diff --git a/src/interpreter/plugin/StatisticalAggregationPlugin.ts b/src/interpreter/plugin/StatisticalAggregationPlugin.ts index 75b8c820b7..c363bc1351 100644 --- a/src/interpreter/plugin/StatisticalAggregationPlugin.ts +++ b/src/interpreter/plugin/StatisticalAggregationPlugin.ts @@ -19,7 +19,6 @@ import { centralF, chisquare, corrcoeff, - covariance, geomean, mean, normal, @@ -28,6 +27,12 @@ import { sumsqerr, variance } from './3rdparty/jstat/jstat' +import {covariance, regressionSums, RegressionSums, sumOfSquaredDeviations} from '../deviationSums' +import { + divideDoubleDoubles, + multiplyByTwoToThe, + roundDoubleDouble, +} from '../doubleDouble' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' export class StatisticalAggregationPlugin extends FunctionPlugin implements FunctionPluginTypecheck { @@ -185,7 +190,7 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func if (coerced.length === 0) { return 0 } - return sumsqerr(coerced) + return sumOfSquaredDeviations(coerced) }) } @@ -282,7 +287,7 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func if (n === 1) { return 0 } - return covariance(ret[0], ret[1]) * (n - 1) / n + return covariance(ret[0], ret[1], 0) }) } @@ -301,7 +306,7 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func if (n <= 1) { return new CellError(ErrorType.DIV_BY_ZERO, ErrorMessage.TwoValues) } - return covariance(ret[0], ret[1]) + return covariance(ret[0], ret[1], 1) }) } @@ -370,7 +375,11 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func if (n <= 2) { return new CellError(ErrorType.DIV_BY_ZERO, ErrorMessage.ThreeValues) } - return Math.sqrt((sumsqerr(ret[0]) - Math.pow(covariance(ret[0], ret[1]) * (n - 1), 2) / sumsqerr(ret[1])) / (n - 2)) + const sums = nonDegenerateRegressionSums(ret[0], ret[1], true) + if (sums instanceof CellError) { + return sums + } + return multiplyByTwoToThe(Math.sqrt(roundDoubleDouble(sums.residualSumOfSquares) / (n - 2)), sums.yExponent) }) } @@ -389,7 +398,12 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func if (n <= 1) { return new CellError(ErrorType.DIV_BY_ZERO, ErrorMessage.TwoValues) } - return covariance(ret[0], ret[1]) * (n - 1) / sumsqerr(ret[1]) + const sums = nonDegenerateRegressionSums(ret[0], ret[1], false) + if (sums instanceof CellError) { + return sums + } + const scaledSlope = roundDoubleDouble(divideDoubleDoubles(sums.productsSum, sums.xSumOfSquares)) + return multiplyByTwoToThe(scaledSlope, sums.yExponent - sums.xExponent) }) } @@ -520,6 +534,23 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func } } +/** + * The sums of a simple linear regression of `knownYs` on `knownXs`, or `#DIV/0!` when all the x values + * are equal, so that the slope is undefined. + * + * @param {number[]} knownYs - a non-empty array of the dependent values + * @param {number[]} knownXs - an array of the independent values, of the same length as `knownYs` + * @param {boolean} withResidualSumOfSquares - whether to compute `residualSumOfSquares` + * @returns {RegressionSums | CellError} the scaled sums, or the error + */ +function nonDegenerateRegressionSums(knownYs: number[], knownXs: number[], withResidualSumOfSquares: boolean): RegressionSums | CellError { + const sums = regressionSums(knownYs, knownXs, withResidualSumOfSquares) + if (sums.xSumOfSquares.hi === 0) { + return new CellError(ErrorType.DIV_BY_ZERO, ErrorMessage.EqualXValues) + } + return sums +} + function parseTwoArrays(dataX: SimpleRangeValue, dataY: SimpleRangeValue): CellError | [number[], number[]] { const xit = dataX.iterateValuesFromTopLeftCorner() const yit = dataY.iterateValuesFromTopLeftCorner() From a7041720d6012f8d3e79128ea4abe9b0f219a248 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Thu, 8 Oct 2026 10:35:02 +0200 Subject: [PATCH 14/18] 3.5.0 --- CHANGELOG.md | 2 + docs/guide/custom-functions.md | 2 +- docs/guide/file-import.md | 2 +- docs/guide/integration-with-angular.md | 2 +- docs/guide/integration-with-react.md | 2 +- docs/guide/integration-with-svelte.md | 2 +- docs/guide/integration-with-vue.md | 2 +- docs/guide/release-notes.md | 26 + docs/index.md | 2 +- ht.config.js | 2 +- package-lock.json | 5734 ++++++++++++++++++++++-- package.json | 2 +- 12 files changed, 5482 insertions(+), 298 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdba941dd..199495eaf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [3.5.0] - 2026-10-09 + ### Added - Added support for the new license key format. A proprietary key can now grant a subset of the library: a function your key does not include evaluates to a `#LIC!` error, and the matching parts of the API throw a `LicenseCapabilityMissingError`. `getAvailableFunctions()` and `getFunctionDetails()` describe only the functions your key includes. [#1728](https://github.com/handsontable/hyperformula/pull/1728) diff --git a/docs/guide/custom-functions.md b/docs/guide/custom-functions.md index 903e30d45e..5a6021ef2d 100644 --- a/docs/guide/custom-functions.md +++ b/docs/guide/custom-functions.md @@ -370,7 +370,7 @@ it('returns a VALUE error if the range argument contains a string', () => { ## Working demo -Explore the full working example on Stackblitz. +Explore the full working example on Stackblitz. This demo contains the implementation of both the [`GREET`](#add-a-simple-custom-function) and diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index 470383da9e..a937071807 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -27,7 +27,7 @@ To import XLSX files, use a third-party [XLSX parser](https://www.npmjs.com/sear This example uses [ExcelJS](https://www.npmjs.com/package/exceljs) to import XLSX files into HyperFormula. -See full example on [GitHub](https://github.com/handsontable/hyperformula-demos/tree/3.4.x/read-excel-file). +See full example on [GitHub](https://github.com/handsontable/hyperformula-demos/tree/3.5.x/read-excel-file). ```js const ExcelJS = require('exceljs'); diff --git a/docs/guide/integration-with-angular.md b/docs/guide/integration-with-angular.md index 535fddcfe6..6b2f36d7c7 100644 --- a/docs/guide/integration-with-angular.md +++ b/docs/guide/integration-with-angular.md @@ -259,4 +259,4 @@ The service above is already SSR-safe — HyperFormula has no browser-only API d ## Demo -For a more advanced example, check out the Angular demo on Stackblitz. +For a more advanced example, check out the Angular demo on Stackblitz. diff --git a/docs/guide/integration-with-react.md b/docs/guide/integration-with-react.md index 1cb3f1a328..d26cef4fef 100644 --- a/docs/guide/integration-with-react.md +++ b/docs/guide/integration-with-react.md @@ -128,4 +128,4 @@ In the Pages Router, the same `dynamic(..., { ssr: false })` call works directly ## Demo -For a more advanced example, check out the React demo on Stackblitz. +For a more advanced example, check out the React demo on Stackblitz. diff --git a/docs/guide/integration-with-svelte.md b/docs/guide/integration-with-svelte.md index 5ca5b86fea..e88e6f55a1 100644 --- a/docs/guide/integration-with-svelte.md +++ b/docs/guide/integration-with-svelte.md @@ -132,4 +132,4 @@ In SvelteKit, top-level statements in `