This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new d2e3c4efe38 [v3-4-test] UI: Render Arabic durations with the correct
plural form (#72604) (#75323)
d2e3c4efe38 is described below
commit d2e3c4efe3802e030ec668d7c3285163a850bec3
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 22 15:25:05 2026 +0530
[v3-4-test] UI: Render Arabic durations with the correct plural form
(#72604) (#75323)
* [v3-4-test] UI: Render Arabic durations with the correct plural form
(#72604)
Arabic inflects a counted noun four ways, but dayjs's `ar` locale stores a
single template per unit, so every count above one took the 3-10 form: a
two-hour deadline read "2 ساعات" where the dual "ساعتان" is required, and an
eleven-day one read "11 أيام" instead of "11 يومًا".
Locales with comparable plural systems -- Hebrew, Russian, Polish -- already
pass a function instead of a template and render correctly at every count;
Arabic was simply never migrated. This applies the same treatment locally
until the fix can be made upstream.
Two assertions from the previous change pinned the uninflected output and
are
updated here.
(cherry picked from commit 80702895deb1f549147aab6562e46998bc497bd9)
Co-authored-by: Baha Bouali <[email protected]>
* UI: Drop Arabic plural test cases that no longer go through dayjs (#75330)
(cherry picked from commit a3a4c138a081b7cf1c6e3113c5253c8cadbb1ebc)
---------
Co-authored-by: Baha Bouali <[email protected]>
Co-authored-by: Yuseok Jo <[email protected]>
---
.../airflow/ui/src/i18n/dayjsArabicPlurals.test.ts | 126 +++++++++++++++++++++
.../src/airflow/ui/src/i18n/dayjsArabicPlurals.ts | 82 ++++++++++++++
.../src/airflow/ui/src/i18n/dayjsLocale.test.ts | 5 +-
.../src/airflow/ui/src/i18n/dayjsLocale.ts | 4 +
4 files changed, 215 insertions(+), 2 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/i18n/dayjsArabicPlurals.test.ts
b/airflow-core/src/airflow/ui/src/i18n/dayjsArabicPlurals.test.ts
new file mode 100644
index 00000000000..9691e4e815f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/dayjsArabicPlurals.test.ts
@@ -0,0 +1,126 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import dayjs from "dayjs";
+import "dayjs/locale/ar";
+import dayjsDuration from "dayjs/plugin/duration";
+import relativeTime from "dayjs/plugin/relativeTime";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { applyArabicPluralForms } from "./dayjsArabicPlurals";
+
+dayjs.extend(dayjsDuration);
+dayjs.extend(relativeTime);
+
+// TypeScript's ES2022 lib does not declare Intl.DurationFormat, and browser
support
+// is still too new to ship against. It is used here only as a test oracle, so
it is
+// declared locally rather than widening the project's `lib`, and the suite
skips the
+// oracle checks on runtimes that lack it.
+type DurationFormatConstructor = new (
+ locales: string,
+ options: { style: "long" },
+) => { format: (duration: Record<string, number>) => string };
+
+const durationFormat = (Intl as unknown as { DurationFormat?:
DurationFormatConstructor }).DurationFormat;
+
+// Counts stay inside each unit's humanize threshold so `humanize()` reaches
the key
+// under test instead of rolling up to the next unit.
+const UNIT_WINDOWS = [
+ { build: (count: number) => dayjs.duration(count, "minutes"), intlUnit:
"minutes", max: 44 },
+ { build: (count: number) => dayjs.duration(count, "hours"), intlUnit:
"hours", max: 21 },
+ { build: (count: number) => dayjs.duration(count, "days"), intlUnit: "days",
max: 25 },
+ { build: (count: number) => dayjs.duration(count, "months"), intlUnit:
"months", max: 10 },
+];
+
+// The dual is the only form whose case is spelled out rather than carried by
an unwritten
+// vowel, so it is also the only count where these forms and Intl's can
legitimately differ.
+const FIRST_CASE_INVARIANT_COUNT = 3;
+
+beforeAll(() => {
+ applyArabicPluralForms();
+ dayjs.locale("ar");
+});
+
+afterAll(() => {
+ dayjs.locale("en");
+});
+
+describe("Arabic plural forms", () => {
+ // Intl cannot arbitrate the dual, so every unit's is pinned here: it is the
form the
+ // oblique decision changes, and the one a future edit could silently put
back into the
+ // nominative.
+ it.each([
+ { expected: "دقيقتين", unit: "minutes" },
+ { expected: "ساعتين", unit: "hours" },
+ { expected: "يومين", unit: "days" },
+ { expected: "شهرين", unit: "months" },
+ { expected: "عامين", unit: "years" },
+ ] as const)("inflects two $unit as the oblique dual $expected", ({ expected,
unit }) => {
+ expect(dayjs.duration(2, unit).humanize()).toBe(expected);
+ });
+
+ it.each([
+ { count: 3, expected: "3 ساعات" },
+ { count: 11, expected: "11 ساعة" },
+ { count: 21, expected: "21 ساعة" },
+ ])("inflects $count hours as $expected", ({ count, expected }) => {
+ expect(dayjs.duration(count, "hours").humanize()).toBe(expected);
+ });
+
+ // Masculine nouns take tanwīn in the 11-99 form, which the feminine units
hide.
+ it.each([
+ { count: 3, expected: "3 أيام" },
+ { count: 11, expected: "11 يومًا" },
+ ])("inflects $count days as $expected", ({ count, expected }) => {
+ expect(dayjs.duration(count, "days").humanize()).toBe(expected);
+ });
+
+ // dayjs's `ar` locale words a year as عام where CLDR uses سنة. That
vocabulary is a
+ // choice the locale already made and is left alone; only the inflection is
fixed,
+ // so years are pinned to an explicit table rather than compared against
Intl.
+ it.each([
+ { count: 3, expected: "3 أعوام" },
+ { count: 11, expected: "11 عامًا" },
+ { count: 100, expected: "100 عام" },
+ ])("inflects $count years as $expected", ({ count, expected }) => {
+ expect(dayjs.duration(count, "years").humanize()).toBe(expected);
+ });
+
+ // The oblique dual is chosen for the governed position, and dayjs's own
past/future
+ // wrappers put every relative time in one, so the wrapped form is what to
assert.
+ it.each([
+ { expected: "منذ ساعتين", render: () => dayjs().subtract(2,
"hour").fromNow() },
+ { expected: "بعد يومين", render: () => dayjs().add(2, "day").fromNow() },
+ ])("renders a relative time as $expected", ({ expected, render }) => {
+ expect(render()).toBe(expected);
+ });
+
+ // Intl reads the same CLDR data these templates were transcribed from, so
it is the
+ // authority on which form each count takes. It formats a standalone
duration and so
+ // yields the nominative dual, which the cases above deliberately override
with the
+ // oblique; the sweep therefore starts at the first count where the two
agree.
+ describe.skipIf(durationFormat === undefined)("matches Intl.DurationFormat",
() => {
+ it.each(UNIT_WINDOWS)("for every count of $intlUnit in range", ({ build,
intlUnit, max }) => {
+ const format = new (durationFormat as DurationFormatConstructor)("ar", {
style: "long" });
+
+ for (let count = FIRST_CASE_INVARIANT_COUNT; count <= max; count += 1) {
+ expect(build(count).humanize()).toBe(format.format({ [intlUnit]: count
}));
+ }
+ });
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/i18n/dayjsArabicPlurals.ts
b/airflow-core/src/airflow/ui/src/i18n/dayjsArabicPlurals.ts
new file mode 100644
index 00000000000..8838bac6f1b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/dayjsArabicPlurals.ts
@@ -0,0 +1,82 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import dayjs from "dayjs";
+import arLocale from "dayjs/locale/ar";
+import updateLocale from "dayjs/plugin/updateLocale";
+
+dayjs.extend(updateLocale);
+
+// Arabic inflects a counted noun four ways, but dayjs's `ar` locale stores a
single
+// template per unit ("%d ساعات") and so renders the 3-10 form for every count
above
+// one: "2 ساعات" where the dual "ساعتين" is required, "11 ساعات" where the
accusative
+// "11 ساعة" is. Hebrew, Russian and Polish have comparable plural systems and
already
+// pass a function instead of a template; Arabic was never migrated. Delete
this module
+// once dayjs ships the same treatment and the dependency is bumped.
+//
+// Forms are [two, few (n%100 = 3-10), many (n%100 = 11-99), other]. "many"
takes the
+// accusative, written with tanwīn on masculine nouns (يومًا) and identical to
the
+// nominative on feminine ones (ساعة). Counts 3 and above are verified against
+// Intl.DurationFormat, which reads the same CLDR data these templates were
+// transcribed from.
+//
+// The dual deliberately departs from Intl, which formats a standalone
duration and so
+// yields the nominative (ساعتان). Every humanized duration in this UI is
governed by a
+// preposition -- "خلال {{interval}}" in the deadline rule, and dayjs's own
past/future
+// wrappers ("منذ %s", "بعد %s") around every `fromNow()` -- which requires
the oblique
+// (خلال ساعتين). The dual is the only form that spells its case out; on the
others the
+// ending is an unwritten vowel, and the 11-99 form is تمييز, invariably
accusative
+// whatever governs it. Revisit if a caller ever renders a duration standing
alone.
+const ARABIC_UNIT_FORMS = {
+ dd: ["يومين", "أيام", "يومًا", "يوم"],
+ hh: ["ساعتين", "ساعات", "ساعة", "ساعة"],
+ mm: ["دقيقتين", "دقائق", "دقيقة", "دقيقة"],
+ MM: ["شهرين", "أشهر", "شهرًا", "شهر"],
+ yy: ["عامين", "أعوام", "عامًا", "عام"],
+} as const;
+
+const formatArabicUnit = (count: number, forms: readonly [string, string,
string, string]): string => {
+ const [two, few, many, other] = forms;
+
+ if (count === 2) {
+ return two;
+ }
+
+ const remainder = count % 100;
+
+ if (remainder >= 3 && remainder <= 10) {
+ return `${count} ${few}`;
+ }
+
+ return remainder >= 11 && remainder <= 99 ? `${count} ${many}` : `${count}
${other}`;
+};
+
+// updateLocale merges with Object.assign, so relativeTime is spread whole —
passing
+// only the overridden keys would drop future/past and every singular form.
+export const applyArabicPluralForms = (): void => {
+ dayjs.updateLocale("ar", {
+ relativeTime: {
+ ...arLocale.relativeTime,
+ dd: (count: number) => formatArabicUnit(count, ARABIC_UNIT_FORMS.dd),
+ hh: (count: number) => formatArabicUnit(count, ARABIC_UNIT_FORMS.hh),
+ mm: (count: number) => formatArabicUnit(count, ARABIC_UNIT_FORMS.mm),
+ MM: (count: number) => formatArabicUnit(count, ARABIC_UNIT_FORMS.MM),
+ yy: (count: number) => formatArabicUnit(count, ARABIC_UNIT_FORMS.yy),
+ },
+ });
+};
diff --git a/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
index 892f189a852..bbd63313682 100644
--- a/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
@@ -52,10 +52,11 @@ describe("dayjs locale", () => {
},
);
+ // The dual form comes from the `ar` plural patch applied when this module
loads.
it("renders the reported Arabic case", () => {
syncDayjsLocale("ar");
- expect(humanizeTwoHours()).toBe("2 ساعات");
+ expect(humanizeTwoHours()).toBe("ساعتين");
});
it("falls back to English for a language dayjs does not ship", () => {
@@ -71,7 +72,7 @@ describe("dayjs locale", () => {
registerDayjsLocaleSync(instance);
await instance.init({ ...i18nBaseOptions, lng: "ar", resources: {} });
- expect(humanizeTwoHours()).toBe("2 ساعات");
+ expect(humanizeTwoHours()).toBe("ساعتين");
});
it("follows a later language switch", async () => {
diff --git a/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
index 223606a3fb5..4eef9d2365f 100644
--- a/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
@@ -39,6 +39,10 @@ import "dayjs/locale/zh-cn";
import "dayjs/locale/zh-tw";
import type { i18n as I18nInstance } from "i18next";
+import { applyArabicPluralForms } from "./dayjsArabicPlurals";
+
+applyArabicPluralForms();
+
// dayjs holds a single global locale and bundles only `en`, so `.humanize()`
and
// `.fromNow()` rendered English durations ("2 hours") inside otherwise
translated
// sentences. The locale data is registered eagerly rather than fetched per
language: