diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e08b5fcb..f16a7d215 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] +### Added + +- Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) + ### Fixed - Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index dd9777523..eb8428e18 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -502,6 +502,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SUBSTITUTE | Returns string where occurrences of Old_text are replaced by New_text. Replaces only specific occurrence if last parameter is provided. | SUBSTITUTE(Text, Old_text, New_text, [Occurrence]) | | T | Returns text if given value is text, empty string otherwise. | T(Value) | | TEXT | Converts a number into text according to a given format.
By default, accepts the same formats that can be passed to the [`dateFormats`](../api/interfaces/configparams.md#dateformats) option, but can be further customized with the [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) option. | TEXT(Number, Format) | +| TEXTJOIN | Joins text from multiple strings and/or ranges with a delimiter. Supports array/range delimiters that cycle through gaps. When ignore_empty is TRUE, empty strings are skipped. Returns #VALUE! if result exceeds 32,767 characters. | TEXTJOIN(Delimiter, Ignore_empty, Text1, [Text2, ...]) | | TRIM | Strips extra spaces from text. | TRIM("Text") | | UNICHAR | Returns the character created by using provided code point. | UNICHAR(Number) | | UNICODE | Returns the Unicode code point of a first character of a text. | UNICODE(Text) | diff --git a/src/error-message.ts b/src/error-message.ts index d9fded74c..5e3afdbea 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -73,6 +73,7 @@ export class ErrorMessage { public static ComplexNumberExpected = 'Complex number expected.' public static ShouldBeIorJ = 'Should be \'i\' or \'j\'.' public static SizeMismatch = 'Array dimensions mismatched.' + public static ResultTooLong = 'Result exceeds the maximum allowed length.' 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}.` diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index d0ed619fb..d3f0deaf9 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLPRICE', TBILLYIELD: 'TBILLYIELD', TEXT: 'HODNOTA.NA.TEXT', + TEXTJOIN: 'TEXTJOIN', TIME: 'ČAS', TIMEVALUE: 'ČASHODN', TODAY: 'DNES', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index d8838ff19..12607c126 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'STATSOBLIGATION.KURS', TBILLYIELD: 'STATSOBLIGATION.AFKAST', TEXT: 'TEKST', + TEXTJOIN: 'TEKST.KOMBINER', TIME: 'TID', TIMEVALUE: 'TIDSVÆRDI', TODAY: 'IDAG', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index d05f7dcdd..025fd81d1 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLKURS', TBILLYIELD: 'TBILLRENDITE', TEXT: 'TEXT', + TEXTJOIN: 'TEXTVERKETTEN', TIME: 'ZEIT', TIMEVALUE: 'ZEITWERT', TODAY: 'HEUTE', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 233a354da..aa70001a3 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -217,6 +217,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLPRICE', TBILLYIELD: 'TBILLYIELD', TEXT: 'TEXT', + TEXTJOIN: 'TEXTJOIN', TIME: 'TIME', TIMEVALUE: 'TIMEVALUE', TODAY: 'TODAY', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 7593a79ed..a15326f25 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -215,6 +215,7 @@ export const dictionary: RawTranslationPackage = { TBILLPRICE: 'LETRA.DE.TES.PRECIO', TBILLYIELD: 'LETRA.DE.TES.RENDTO', TEXT: 'TEXTO', + TEXTJOIN: 'UNIRCADENAS', TIME: 'NSHORA', TIMEVALUE: 'HORANUMERO', TODAY: 'HOY', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 25d54032a..9deeed016 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'OBLIG.HINTA', TBILLYIELD: 'OBLIG.TUOTTO', TEXT: 'TEKSTI', + TEXTJOIN: 'TEKSTI.YHDISTÄ', TIME: 'AIKA', TIMEVALUE: 'AIKA_ARVO', TODAY: 'TÄMÄ.PÄIVÄ', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 7733d20b4..dd467e7e4 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'PRIX.BON.TRESOR', TBILLYIELD: 'RENDEMENT.BON.TRESOR', TEXT: 'TEXTE', + TEXTJOIN: 'JOINDRE.TEXTE', TIME: 'TEMPS', TIMEVALUE: 'TEMPSVAL', TODAY: 'AUJOURDHUI', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index d1341fb21..915420c49 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'KJEGY.ÁR', TBILLYIELD: 'KJEGY.HOZAM', TEXT: 'SZÖVEG', + TEXTJOIN: 'SZÖVEGÖSSZEFŰZÉS', TIME: 'IDŐ', TIMEVALUE: 'IDŐÉRTÉK', TODAY: 'MA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 28f4ece50..000f45a1f 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'BOT.PREZZO', TBILLYIELD: 'BOT.REND', TEXT: 'TESTO', + TEXTJOIN: 'UNISCI.TESTO', TIME: 'ORARIO', TIMEVALUE: 'ORARIO.VALORE', TODAY: 'OGGI', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 61ec76ccf..d521aead4 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLPRIS', TBILLYIELD: 'TBILLAVKASTNING', TEXT: 'TEKST', + TEXTJOIN: 'TEKST.KOMBINER', TIME: 'TID', TIMEVALUE: 'TIDSVERDI', TODAY: 'IDAG', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 4de49b7a4..1536ea5a5 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'SCHATK.PRIJS', TBILLYIELD: 'SCHATK.REND', TEXT: 'TEKST', + TEXTJOIN: 'TEKST.KOPPELEN', TIME: 'TIJD', TIMEVALUE: 'TIJDWAARDE', TODAY: 'VANDAAG', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 7c0a08756..d5651c77d 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'CENA.BS', TBILLYIELD: 'RENT.BS', TEXT: 'TEKST', + TEXTJOIN: 'POŁĄCZ.TEKSTY', TIME: 'CZAS', TIMEVALUE: 'CZAS.WARTOŚĆ', TODAY: 'DZIŚ', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index cd3fc715d..ee5d9597e 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'OTNVALOR', TBILLYIELD: 'OTNLUCRO', TEXT: 'TEXTO', + TEXTJOIN: 'UNIRTEXTO', TIME: 'TEMPO', TIMEVALUE: 'VALOR.TEMPO', TODAY: 'HOJE', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 66032d3cd..d11284169 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'ЦЕНАКЧЕК', TBILLYIELD: 'ДОХОДКЧЕК', TEXT: 'ТЕКСТ', + TEXTJOIN: 'ОБЪЕДИНИТЬ', TIME: 'ВРЕМЯ', TIMEVALUE: 'ВРЕМЗНАЧ', TODAY: 'СЕГОДНЯ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index d4741d6fc..4bc4f46c7 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'SSVXPRIS', TBILLYIELD: 'SSVXRÄNTA', TEXT: 'TEXT', + TEXTJOIN: 'TEXTJOIN', TIME: 'KLOCKSLAG', TIMEVALUE: 'TIDVÄRDE', TODAY: 'IDAG', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 38507c24b..d23e8f2f3 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'HTAHDEĞER', TBILLYIELD: 'HTAHÖDEME', TEXT: 'METNEÇEVİR', + TEXTJOIN: 'METİNBİRLEŞTİR', TIME: 'ZAMAN', TIMEVALUE: 'ZAMANSAYISI', TODAY: 'BUGÜN', diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 4bb5832df..4326a57d6 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -7,6 +7,7 @@ import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' import {Maybe} from '../../Maybe' import {ProcedureAst} from '../../parser' +import {coerceScalarToString} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' import {SimpleRangeValue} from '../../SimpleRangeValue' import {ExtendedNumber, InterpreterValue, isExtendedNumber, RawScalarValue, InternalScalarValue} from '../InterpreterValue' @@ -20,7 +21,7 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec 'CONCATENATE': { method: 'concatenate', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ], repeatLastArgs: 1, expandRanges: true, @@ -28,134 +29,143 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec 'EXACT': { method: 'exact', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING } ] }, 'SPLIT': { method: 'split', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, ] }, 'LEN': { method: 'len', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'LOWER': { method: 'lower', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'MID': { method: 'mid', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.NUMBER }, ] }, 'TRIM': { method: 'trim', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'T': { method: 't', parameters: [ - {argumentType: FunctionArgumentType.SCALAR} + { argumentType: FunctionArgumentType.SCALAR } ] }, 'N': { method: 'n', parameters: [ - {argumentType: FunctionArgumentType.ANY} + { argumentType: FunctionArgumentType.ANY } ] }, 'PROPER': { method: 'proper', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'CLEAN': { method: 'clean', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'REPT': { method: 'rept', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, ] }, 'RIGHT': { method: 'right', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'LEFT': { method: 'left', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'REPLACE': { method: 'replace', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.STRING } ] }, 'SEARCH': { method: 'search', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'SUBSTITUTE': { method: 'substitute', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: true} + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, optionalArg: true } ] }, 'FIND': { method: 'find', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'UPPER': { method: 'upper', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'VALUE': { method: 'value', parameters: [ - {argumentType: FunctionArgumentType.SCALAR} + { argumentType: FunctionArgumentType.SCALAR } ] }, + 'TEXTJOIN': { + method: 'textjoin', + repeatLastArgs: 1, + parameters: [ + {argumentType: FunctionArgumentType.ANY}, + {argumentType: FunctionArgumentType.BOOLEAN}, + {argumentType: FunctionArgumentType.ANY}, + ], + }, } /** @@ -424,6 +434,78 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec }) } + /** + * Corresponds to TEXTJOIN(delimiter, ignore_empty, text1, [text2], …) + * + * Joins text from multiple strings/ranges with a configurable delimiter. + * Supports array/range delimiters that cycle through gaps between text values. + * When ignore_empty is TRUE, empty strings are skipped. + * Returns #VALUE! if the result exceeds 32,767 characters (Excel cell content limit). + * + * @param {ProcedureAst} ast - The procedure AST node + * @param {InterpreterState} state - The interpreter state + */ + public textjoin(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('TEXTJOIN'), + (delimiterArg: InternalScalarValue | SimpleRangeValue, + ignoreEmpty: boolean, + ...textArgs: (InternalScalarValue | SimpleRangeValue)[]) => { + + const delimiters = this.flattenArgToStrings(delimiterArg) + if (delimiters instanceof CellError) { + return delimiters + } + + const texts: string[] = [] + for (const arg of textArgs) { + const coerced = this.flattenArgToStrings(arg) + if (coerced instanceof CellError) { + return coerced + } + texts.push(...coerced) + } + + const parts = ignoreEmpty ? texts.filter((t) => t !== '') : texts + + if (parts.length === 0) { + return '' + } + + const result = parts.reduce((acc, part, i) => + i === 0 ? part : acc + delimiters[(i - 1) % delimiters.length] + part + , '') + + if (result.length > 32767) { + return new CellError(ErrorType.VALUE, ErrorMessage.ResultTooLong) + } + return result + } + ) + } + + /** + * Flattens a scalar or range argument into an array of coerced strings. + * Returns a CellError immediately if any value in the argument is an error or cannot be coerced. + * + * @param {InternalScalarValue | SimpleRangeValue} arg - Scalar or range to flatten + * @returns {string[] | CellError} - Array of string values, or the first error encountered + */ + private flattenArgToStrings(arg: InternalScalarValue | SimpleRangeValue): string[] | CellError { + const values = arg instanceof SimpleRangeValue ? arg.valuesFromTopLeftCorner() : [arg] + const result: string[] = [] + for (const val of values) { + if (val instanceof CellError) { + return val + } + const coerced = coerceScalarToString(val as InternalScalarValue) + if (coerced instanceof CellError) { + return coerced + } + result.push(coerced) + } + return result + } + /** * Parses a string to a numeric value, handling whitespace trimming and empty string validation. * @@ -443,4 +525,5 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec private escapeRegExpSpecialCharacters(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } + }