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:

Reply via email to