diff --git a/conformance/core/functions.yaml b/conformance/core/functions.yaml index b9843329e7..5d3e1ba19f 100644 --- a/conformance/core/functions.yaml +++ b/conformance/core/functions.yaml @@ -710,6 +710,33 @@ format: "yyyy-MM-dd HH:mm" expect: "2026-09-04 23:30" +- name: test_evaluate_function_format_date_quoted_literal + description: Verifies formatDate copies text in single quotes through as a literal, so letters inside it are not read as pattern tokens. + action: evaluate_function + function: formatDate + args: + value: "2025-12-15T12:00:00Z" + format: "EEEE, MMM d 'at' yyyy" + expect: "Monday, Dec 15 at 2025" + +- name: test_evaluate_function_format_date_escaped_apostrophe + description: Verifies formatDate renders two single quotes as one apostrophe, both inside and outside a quoted literal. + action: evaluate_function + function: formatDate + args: + value: "2025-12-15T12:00:00Z" + format: "d 'o''clock', MMM''yy" + expect: "15 o'clock, Dec'25" + +- name: test_evaluate_function_format_date_short_weekday + description: Verifies formatDate reads EEE and EE as the abbreviated weekday, the same as E. + action: evaluate_function + function: formatDate + args: + value: "2026-02-02T12:00:00Z" + format: "EEE, MMM d (EE, E)" + expect: "Mon, Feb 2 (Mon, Mon)" + # --- FormatStringFunction (D18, D28) --- - name: test_evaluate_function_format_string_basic description: Verifies formatString evaluates static template string. diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 376ae9931a..39c12af222 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -127,6 +127,11 @@ `DateTimeInput` now validates; previously it matched all three branches of the `oneOf` that tells them apart and was always rejected ([#3123](https://github.com/a2ui-project/a2ui/issues/3123)). +- `formatDate` copies text in single quotes through as a literal and renders + `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` + instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the + abbreviated weekday instead of repeating it + ([#2937](https://github.com/a2ui-project/a2ui/issues/2937), [#3787](https://github.com/a2ui-project/a2ui/pull/3787)). ## 0.4.0 diff --git a/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart b/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart index e066e8fded..077421d233 100644 --- a/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart +++ b/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart @@ -109,8 +109,12 @@ String formatCurrency( return format.format(number); } +/// TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text +/// in single quotes (with `''` inside it standing for an apostrophe) is copied +/// through. An unclosed quote runs to the end of the pattern, as in web_core. final RegExp _dateTokens = RegExp( - 'yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a', + r"''|'((?:''|[^'])+)(?:'|$)" + '|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a', ); /// The offset at the end of a timestamp's time part: `Z`, `+hh`, `+hhmm` or @@ -212,8 +216,9 @@ DateSymbols _dateSymbolsFor(String locale) { /// Formats an ISO 8601 timestamp [value] with a TR35 [pattern]. /// -/// Supports the tokens `yyyy yy MMMM MMM MM M EEEE E dd d HH H hh h mm ss a`; -/// other text is copied through. A timestamp with an offset keeps its +/// Supports the tokens +/// `yyyy yy MMMM MMM MM M EEEE EEE EE E dd d HH H hh h mm ss a` and quoted +/// literals; other text is copied through. A timestamp with an offset keeps its /// wall-clock fields, so `2026-09-04T23:30:00-05:00` formats as `2026-09-04`. /// The pattern `ISO` emits the UTC instant as `YYYY-MM-DDTHH:mm:ss.sssZ`. /// Returns an empty string for a missing or unparseable [value]. @@ -238,6 +243,8 @@ String formatDate( return (format.isEmpty ? 'yyyy-MM-dd' : format).replaceAllMapped( _dateTokens, (match) => switch (match[0]) { + _ when match[1] != null => match[1]!.replaceAll("''", "'"), + "''" => "'", 'yyyy' => '${shifted.year}', 'yy' => pad(shifted.year % 100), 'MMMM' => names.STANDALONEMONTHS[shifted.month - 1], @@ -245,7 +252,7 @@ String formatDate( 'MM' => pad(shifted.month), 'M' => '${shifted.month}', 'EEEE' => names.STANDALONEWEEKDAYS[weekday], - 'E' => names.STANDALONESHORTWEEKDAYS[weekday], + 'EEE' || 'EE' || 'E' => names.STANDALONESHORTWEEKDAYS[weekday], 'dd' => pad(shifted.day), 'd' => '${shifted.day}', 'HH' => pad(shifted.hour), diff --git a/dart/a2ui_core/test/basic_catalog_test.dart b/dart/a2ui_core/test/basic_catalog_test.dart index d56b82cb57..898c3f0bab 100644 --- a/dart/a2ui_core/test/basic_catalog_test.dart +++ b/dart/a2ui_core/test/basic_catalog_test.dart @@ -808,6 +808,16 @@ void main() { ); }); + test('copies quoted literals and reads EEE as the short weekday', () { + const value = '2026-02-02T15:17:09Z'; + expect(format(value, "MMM d 'at' h:mm a"), 'Feb 2 at 3:17 PM'); + expect(format(value, "h 'o''clock'"), "3 o'clock"); + expect(format(value, "h''mm"), "3'17"); + expect(format(value, 'EEE, EE, E'), 'Mon, Mon, Mon'); + // An unclosed quote runs to the end of the pattern. + expect(format(value, "h 'at end"), '3 at end'); + }); + test('keeps the wall-clock time of an offset timestamp', () { expect(format('2026-09-04T23:30:00-05:00', 'yyyy-MM-dd'), '2026-09-04'); expect(format('2026-09-04T23:30:00-05:00', 'HH:mm'), '23:30'); diff --git a/python/a2ui_core/CHANGELOG.md b/python/a2ui_core/CHANGELOG.md index e6ddcc23b2..550f390de8 100644 --- a/python/a2ui_core/CHANGELOG.md +++ b/python/a2ui_core/CHANGELOG.md @@ -193,6 +193,11 @@ and the bare `protocolVersion` (for example `'1.0'`) on each inline catalog even when the source catalog document omits them. [#3382](https://github.com/a2ui-project/a2ui/pull/3382) +- `formatDate` (v0.9 and v1.0) copies text in single quotes through as a + literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives + `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and + `EE` as the abbreviated weekday instead of repeating it. + [#2937](https://github.com/a2ui-project/a2ui/issues/2937), [#3787](https://github.com/a2ui-project/a2ui/pull/3787) ## 0.4.0 (2026-10-17) diff --git a/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py b/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py index 7c8994f961..60d62d081b 100644 --- a/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py +++ b/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py @@ -277,7 +277,13 @@ def _format_currency( FormatCurrencyImplementation = create_format_currency_implementation(None) -_DATE_TOKENS = re.compile(r"yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a|%") +# TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text in +# single quotes (with `''` inside it standing for an apostrophe) is copied +# through. An unclosed quote runs to the end of the pattern, as in web_core. +_DATE_TOKENS = re.compile( + r"''|'((?:''|[^'])+)(?:'|$)" + r"|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a|%" +) def create_format_date_implementation( @@ -306,7 +312,12 @@ def _format_date( loc = get_locale(locale) def _sub(m: re.Match[str]) -> str: + literal = m.group(1) + if literal is not None: + return literal.replace("''", "'") tok = m.group(0) + if tok == "''": + return "'" if tok == "yyyy": return str(dt.year) if tok == "yy": @@ -321,7 +332,7 @@ def _sub(m: re.Match[str]) -> str: return str(dt.month) if tok == "EEEE": return str(loc.days["format"]["wide"][dt.weekday()]) - if tok == "E": + if tok in ("EEE", "EE", "E"): return str(loc.days["format"]["abbreviated"][dt.weekday()]) if tok == "dd": return f"{dt.day:02d}" diff --git a/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py b/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py index 4e4eaf04e8..1b7d26f9c1 100644 --- a/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py +++ b/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py @@ -323,7 +323,13 @@ def _format_currency( FormatCurrencyImplementation = create_format_currency_implementation(None) -_DATE_TOKENS = re.compile(r"yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a|%") +# TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text in +# single quotes (with `''` inside it standing for an apostrophe) is copied +# through. An unclosed quote runs to the end of the pattern, as in web_core. +_DATE_TOKENS = re.compile( + r"''|'((?:''|[^'])+)(?:'|$)" + r"|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a|%" +) def create_format_date_implementation( @@ -356,7 +362,12 @@ def _format_date( loc = get_locale(locale) def _sub(m: re.Match[str]) -> str: + literal = m.group(1) + if literal is not None: + return literal.replace("''", "'") tok = m.group(0) + if tok == "''": + return "'" if tok == "yyyy": return str(dt.year) if tok == "yy": @@ -371,7 +382,7 @@ def _sub(m: re.Match[str]) -> str: return str(dt.month) if tok == "EEEE": return str(loc.days["format"]["wide"][dt.weekday()]) - if tok == "E": + if tok in ("EEE", "EE", "E"): return str(loc.days["format"]["abbreviated"][dt.weekday()]) if tok == "dd": return f"{dt.day:02d}" diff --git a/python/a2ui_core/tests/test_functions.py b/python/a2ui_core/tests/test_functions.py index 7e2bb7c536..7adc263d28 100644 --- a/python/a2ui_core/tests/test_functions.py +++ b/python/a2ui_core/tests/test_functions.py @@ -610,6 +610,32 @@ def test_formatting_falls_back_for_unusable_locale(version, locale): ) +@pytest.mark.parametrize("version", ["v0_9", "v1_0"]) +@pytest.mark.parametrize( + ("pattern", "expected"), + [ + ("MMM d 'at' h:mm a", "Feb 2 at 3:17 PM"), + ("h 'o''clock'", "3 o'clock"), + ("h''mm", "3'17"), + ("EEE, EE, E", "Mon, Mon, Mon"), + # An unclosed quote runs to the end of the pattern. + ("h 'at end", "3 at end"), + ], +) +def test_format_date_quoted_literals_and_short_weekday(version, pattern, expected): + import importlib + + module = importlib.import_module( + f"a2ui.core.basic_catalog.{version}.function_impls" + ) + format_date = module.create_format_date_implementation(None) + + assert ( + format_date.execute({"value": "2026-02-02T15:17:09Z", "format": pattern}) + == expected + ) + + def test_validation_return_types_v09_vs_v10(): from a2ui.core.basic_catalog import v0_9, v1_0 diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index de94d49137..835b13d2f9 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -55,6 +55,7 @@ - (v1_0) `metadata.extensions` keys containing characters outside UAX #31 identifier syntax (such as `:` or `-`) are now rejected during message and component validation ([#2931](https://github.com/a2ui-project/a2ui/pull/2931)). - (v0_8) Children attached via `Card.child`, `Modal.entryPointChild` or `Modal.contentChild` on v0.8 surfaces validated against a catalog loaded with `Catalog.fromJson` are now recognized as reachable from `root` instead of failing topology validation as orphaned components ([#2884](https://github.com/a2ui-project/a2ui/pull/2884)). - `Catalog.toJson()` and `Catalog.validationSchema` now include `args` in the outer function call schema's `required` list when a function's argument schema is a union (`oneOf` or `anyOf`) whose branches all require arguments, an `allOf` composition with a branch that requires arguments, or a local `$ref` to a definition that requires arguments ([#3363](https://github.com/a2ui-project/a2ui/issues/3363)). +- `formatDate` copies text in single quotes through as a literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the abbreviated weekday instead of repeating it. [#2937](https://github.com/a2ui-project/a2ui/issues/2937), [#3787](https://github.com/a2ui-project/a2ui/pull/3787) ## 0.14.0 diff --git a/typescript/core/web_core/src/common/basic_functions.test.ts b/typescript/core/web_core/src/common/basic_functions.test.ts index 826cdd3aec..b3d66097ca 100644 --- a/typescript/core/web_core/src/common/basic_functions.test.ts +++ b/typescript/core/web_core/src/common/basic_functions.test.ts @@ -154,6 +154,16 @@ describe('Common Basic Functions', () => { assert.strictEqual(executeFormatDate('invalid-date'), ''); }); + it('executeFormatDate copies quoted literals and reads EEE as the short weekday', () => { + const value = '2026-02-02T15:17:09Z'; + assert.strictEqual(executeFormatDate(value, "MMM d 'at' h:mm a"), 'Feb 2 at 3:17 PM'); + assert.strictEqual(executeFormatDate(value, "h 'o''clock'"), "3 o'clock"); + assert.strictEqual(executeFormatDate(value, "h''mm"), "3'17"); + assert.strictEqual(executeFormatDate(value, 'EEE, EE, E'), 'Mon, Mon, Mon'); + // An unclosed quote runs to the end of the pattern. + assert.strictEqual(executeFormatDate(value, "h 'at end"), '3 at end'); + }); + it('parseTimestamp', () => { const parsed = parseTimestamp('2025-01-01T12:00:00+02:00'); assert.ok(parsed); diff --git a/typescript/core/web_core/src/common/basic_functions.ts b/typescript/core/web_core/src/common/basic_functions.ts index efa215d926..1f7cdc0821 100644 --- a/typescript/core/web_core/src/common/basic_functions.ts +++ b/typescript/core/web_core/src/common/basic_functions.ts @@ -312,7 +312,13 @@ export function getDateNames(locale: string): DateNames { return names; } -export const DATE_TOKENS = /yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a/g; +/** + * TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text in + * single quotes (with `''` inside it standing for an apostrophe) is copied + * through. As in date-fns, an unclosed quote runs to the end of the pattern. + */ +export const DATE_TOKENS = + /''|'((?:''|[^'])+)(?:'|$)|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a/g; export const ISO_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/; /** @@ -530,8 +536,11 @@ export function executeFormatDate( const hours = shifted.getUTCHours(); const hours12 = hours % 12 || 12; - return pattern.replace(DATE_TOKENS, (token: string) => { + return pattern.replace(DATE_TOKENS, (token: string, literal?: string) => { + if (literal !== undefined) return literal.replace(/''/g, "'"); switch (token) { + case "''": + return "'"; case 'yyyy': return String(shifted.getUTCFullYear()); case 'yy': @@ -546,6 +555,8 @@ export function executeFormatDate( return String(shifted.getUTCMonth() + 1); case 'EEEE': return names.weekdaysLong[weekdayIndex]; + case 'EEE': + case 'EE': case 'E': return names.weekdaysShort[weekdayIndex]; case 'dd':