From 6ff643b045fe6965e7c02571edcb67abda1e698a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:24:18 -0700 Subject: [PATCH] fix(i18n): add zh_TW snooze parser locale (#13822) --- .../helper/snoozeDateParser/localization.js | 11 +- .../helper/snoozeDateParser/parser.js | 14 +++ .../helper/specs/snoozeDateParser.spec.js | 49 ++++++++ .../dashboard/i18n/locale/zh_TW/index.js | 2 + .../dashboard/i18n/locale/zh_TW/snooze.json | 106 +++++++++--------- 5 files changed, 127 insertions(+), 55 deletions(-) diff --git a/app/javascript/dashboard/helper/snoozeDateParser/localization.js b/app/javascript/dashboard/helper/snoozeDateParser/localization.js index 461fc96e9..8ffabe05a 100644 --- a/app/javascript/dashboard/helper/snoozeDateParser/localization.js +++ b/app/javascript/dashboard/helper/snoozeDateParser/localization.js @@ -166,6 +166,8 @@ const TOD_TO_MERIDIEM = { evening: 'pm', night: 'pm', }; +const CJK_CHAR_RE = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; // ─── Translation Cache ────────────────────────────────────────────────────── @@ -278,8 +280,13 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const substituteLocalTokens = (text, pairs) => { let r = text; pairs.forEach(([local, en]) => { - const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g'); - r = r.replace(re, en); + if (CJK_CHAR_RE.test(local)) { + const re = new RegExp(escapeRegex(local), 'g'); + r = r.replace(re, ` ${en} `); + } else { + const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g'); + r = r.replace(re, en); + } }); return r; }; diff --git a/app/javascript/dashboard/helper/snoozeDateParser/parser.js b/app/javascript/dashboard/helper/snoozeDateParser/parser.js index 43d401cca..e076da712 100644 --- a/app/javascript/dashboard/helper/snoozeDateParser/parser.js +++ b/app/javascript/dashboard/helper/snoozeDateParser/parser.js @@ -82,6 +82,9 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`; const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/; const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`); +const RELATIVE_DURATION_AFTER_RE = new RegExp( + `^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$` +); const DURATION_FROM_NOW_RE = new RegExp( `^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$` ); @@ -89,6 +92,9 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`); const RELATIVE_DAY_TOD_RE = new RegExp( `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$` ); +const RELATIVE_DAY_MERIDIEM_RE = new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$` +); const RELATIVE_DAY_TOD_TIME_RE = new RegExp( `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$` ); @@ -245,6 +251,7 @@ const matchDuration = (text, now) => { return ( parseDuration(text.match(DURATION_FROM_NOW_RE), now) || + parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) || parseDuration(text.match(RELATIVE_DURATION_RE), now) ); }; @@ -303,6 +310,13 @@ const matchRelativeDay = (text, now) => { ); } + const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE); + if (dayMeridiemMatch) { + const [, dayKey, meridiem] = dayMeridiemMatch; + const hours = meridiem === 'am' ? 9 : 14; + return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now); + } + const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE); if (dayAtTimeMatch) { const [, dayKey, timeRaw] = dayAtTimeMatch; diff --git a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js index 1ddde6fed..0c0bab385 100644 --- a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js @@ -1626,6 +1626,24 @@ describe('generateDateSuggestions — localized input regressions', () => { }, }; + const zhTWSnoozeTranslations = { + UNITS: { + HOUR: '小時', + HOURS: '小時', + DAY: '天', + DAYS: '天', + }, + HALF: '半', + RELATIVE: { + TOMORROW: '明天', + }, + MERIDIEM: { + AM: '上午', + PM: '下午', + }, + AFTER: '後', + }; + describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => { it('Arabic "غد" does not produce half-duration suggestions', () => { const results = generateDateSuggestions('غد', now, { @@ -1721,6 +1739,37 @@ describe('generateDateSuggestions — localized input regressions', () => { expect(results[0].date.getHours()).toBe(6); }); }); + + describe('zh_TW compact CJK inputs', () => { + const options = { + translations: zhTWSnoozeTranslations, + locale: 'zh-TW', + }; + + it('parses "2小時後" (2 hours from now) without spaces', () => { + const results = generateDateSuggestions('2小時後', now, options); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(16); + expect(results[0].date.getHours()).toBe(12); + expect(results[0].date.getMinutes()).toBe(0); + }); + + it('parses "半天" (half day) without spaces', () => { + const results = generateDateSuggestions('半天', now, options); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(16); + expect(results[0].date.getHours()).toBe(22); + expect(results[0].date.getMinutes()).toBe(0); + }); + + it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => { + const results = generateDateSuggestions('明天 上午', now, options); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + expect(results[0].date.getHours()).toBe(9); + expect(results[0].date.getMinutes()).toBe(0); + }); + }); }); describe('no-space duration suggestions', () => { diff --git a/app/javascript/dashboard/i18n/locale/zh_TW/index.js b/app/javascript/dashboard/i18n/locale/zh_TW/index.js index 33e7851b0..1e5b0b2cd 100644 --- a/app/javascript/dashboard/i18n/locale/zh_TW/index.js +++ b/app/javascript/dashboard/i18n/locale/zh_TW/index.js @@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; @@ -74,6 +75,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, }; diff --git a/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json b/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json index 4b380fdef..8c631716a 100644 --- a/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json +++ b/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json @@ -1,72 +1,72 @@ { "SNOOZE_PARSER": { "UNITS": { - "MINUTE": "minute", - "MINUTES": "minutes", - "HOUR": "hour", + "MINUTE": "分鐘", + "MINUTES": "分鐘", + "HOUR": "小時", "HOURS": "小時", - "DAY": "day", - "DAYS": "days", - "WEEK": "week", - "WEEKS": "weeks", - "MONTH": "month", - "MONTHS": "months", - "YEAR": "month", - "YEARS": "years" + "DAY": "天", + "DAYS": "天", + "WEEK": "週", + "WEEKS": "週", + "MONTH": "月", + "MONTHS": "月", + "YEAR": "年", + "YEARS": "年" }, - "HALF": "half", - "NEXT": "next", - "THIS": "this", - "AT": "at", - "IN": "in", - "FROM_NOW": "from now", - "NEXT_YEAR": "next year", + "HALF": "半", + "NEXT": "下一個", + "THIS": "這個", + "AT": "在", + "IN": "在", + "FROM_NOW": "之後", + "NEXT_YEAR": "明年", "MERIDIEM": { - "AM": "am", - "PM": "pm" + "AM": "上午", + "PM": "下午" }, "RELATIVE": { "TOMORROW": "明天", - "DAY_AFTER_TOMORROW": "day after tomorrow", + "DAY_AFTER_TOMORROW": "後天", "NEXT_WEEK": "下週", - "NEXT_MONTH": "next month", - "THIS_WEEKEND": "this weekend", - "NEXT_WEEKEND": "next weekend" + "NEXT_MONTH": "下個月", + "THIS_WEEKEND": "這個週末", + "NEXT_WEEKEND": "下個週末" }, "TIME_OF_DAY": { - "MORNING": "morning", - "AFTERNOON": "afternoon", - "EVENING": "evening", - "NIGHT": "night", - "NOON": "noon", - "MIDNIGHT": "midnight" + "MORNING": "早上", + "AFTERNOON": "下午", + "EVENING": "晚上", + "NIGHT": "夜晚", + "NOON": "中午", + "MIDNIGHT": "午夜" }, "WORD_NUMBERS": { - "ONE": "one", - "TWO": "two", - "THREE": "three", - "FOUR": "four", - "FIVE": "five", - "SIX": "six", - "SEVEN": "seven", - "EIGHT": "eight", - "NINE": "nine", - "TEN": "ten", - "TWELVE": "twelve", - "FIFTEEN": "fifteen", - "TWENTY": "twenty", - "THIRTY": "thirty" + "ONE": "一", + "TWO": "二", + "THREE": "三", + "FOUR": "四", + "FIVE": "五", + "SIX": "六", + "SEVEN": "七", + "EIGHT": "八", + "NINE": "九", + "TEN": "十", + "TWELVE": "十二", + "FIFTEEN": "十五", + "TWENTY": "二十", + "THIRTY": "三十" }, "ORDINALS": { - "FIRST": "first", - "SECOND": "second", - "THIRD": "third", - "FOURTH": "fourth", - "FIFTH": "fifth" + "FIRST": "第一", + "SECOND": "第二", + "THIRD": "第三", + "FOURTH": "第四", + "FIFTH": "第五" }, - "OF": "of", - "AFTER": "after", - "WEEK": "week", - "DAY": "day" + "OF": "的", + "AFTER": "後", + "WEEK": "週", + "DAY": "天" } }