Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no catalogue could fix. A translator handed " and " or " on " in isolation cannot move it: German puts the verb last, Japanese does not separate list items with a word at all, and the fragments arrive in an order the English sentence chose. Reported by a native speaker reviewing the German catalogue (#247), whose "the summaries" item is the Sieve one. Every branch is now one whole sentence with placeholders, so a translator rewrites the sentence including its word order. Joining is Intl.ListFormat, which gives "A, B und C" for an allof rule and the language's own disjunction for anyof, rather than a hardcoded " and " that would be wrong twice over. The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03" wrap the sentence they qualify, so a language that puts the limit first can. Ordinals become words. The old suffix table -- st, nd, rd, th, picked by arithmetic -- is English spelling rules in code, and no catalogue can reach a suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5 or -1 in practice, so five words and "last" cover it. WEEKDAYS is gone. Its long names could have been catalogue entries but its short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and Sunday, and a catalogue cannot hold two translations under one key. That was bad data rather than missing translation, and Intl has every name in every locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a language question. Adds the first tests either function has had. Neither had any, and no test would have caught what was wrong with them, since the English output was correct -- so these pin the two properties that actually matter: fragments go through the catalogue, and the joining is Intl's. 32 strings and 9 plural forms are new and land with each language. Verified: typecheck clean, 1009 tests pass.
This commit is contained in:
+101
-31
@@ -1,14 +1,25 @@
|
||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { formatList, weekdayName, weekdayNames } from "./datetime";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
export const WEEKDAYS: Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> = [
|
||||
{ key: "mo", label: "Monday", short: "M" },
|
||||
{ key: "tu", label: "Tuesday", short: "T" },
|
||||
{ key: "we", label: "Wednesday", short: "W" },
|
||||
{ key: "th", label: "Thursday", short: "T" },
|
||||
{ key: "fr", label: "Friday", short: "F" },
|
||||
{ key: "sa", label: "Saturday", short: "S" },
|
||||
{ key: "su", label: "Sunday", short: "S" },
|
||||
];
|
||||
/**
|
||||
* The seven days, Monday first, named in the reader's locale.
|
||||
*
|
||||
* This was a table of English strings carrying `label: "Monday"` and
|
||||
* `short: "M"`, rendered straight into the picker. The long names could have
|
||||
* become catalogue entries; the short ones could not, because "T" is both
|
||||
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalogue
|
||||
* cannot hold two translations under one key. Intl knows all of them.
|
||||
*/
|
||||
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||
|
||||
export function weekdayOptions(): Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> {
|
||||
return weekdayNames("long").map(({ key, name }) => ({
|
||||
key: key as JSCalendarNDay["day"],
|
||||
label: name,
|
||||
short: weekdayName(key, "narrow"),
|
||||
}));
|
||||
}
|
||||
|
||||
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
|
||||
|
||||
@@ -28,7 +39,7 @@ export function presetFor(rule: JSCalendarRecurrenceRule | undefined): Recurrenc
|
||||
}
|
||||
|
||||
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
|
||||
const dow = WEEKDAYS[(start.getDay() + 6) % 7]!.key;
|
||||
const dow = WEEKDAY_KEYS[(start.getDay() + 6) % 7]!;
|
||||
switch (preset) {
|
||||
case "daily":
|
||||
return { "@type": "RecurrenceRule", frequency: "daily" };
|
||||
@@ -45,48 +56,107 @@ export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalenda
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A recurrence rule as a sentence.
|
||||
*
|
||||
* Built as whole sentences with placeholders rather than by concatenation.
|
||||
* The old version appended fragments -- `base += " on " + names` -- which is
|
||||
* untranslatable however complete the catalogue is: German puts the weekday
|
||||
* list somewhere else in the clause, and a translator handed " on " alone
|
||||
* cannot move it. Every branch below is one key a translator can rewrite in
|
||||
* full, including the word order.
|
||||
*/
|
||||
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
|
||||
if (!rule) return "Does not repeat";
|
||||
if (!rule) return t("Does not repeat");
|
||||
const n = rule.interval ?? 1;
|
||||
const every = n !== 1;
|
||||
let base: string;
|
||||
|
||||
switch (rule.frequency) {
|
||||
case "daily":
|
||||
base = n === 1 ? "Daily" : `Every ${n} days`;
|
||||
base = every ? plural(n, { one: "Every {n} day", other: "Every {n} days" }) : t("Daily");
|
||||
break;
|
||||
|
||||
case "weekly": {
|
||||
base = n === 1 ? "Weekly" : `Every ${n} weeks`;
|
||||
if (rule.byDay?.length) {
|
||||
const names = rule.byDay.map((d) => WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day);
|
||||
const set = rule.byDay.map((d) => d.day).sort().join(",");
|
||||
if (set === ["mo", "tu", "we", "th", "fr"].sort().join(",") && n === 1) base = "Every weekday";
|
||||
else base += ` on ${names.join(", ")}`;
|
||||
const days = rule.byDay?.length ? rule.byDay.map((d) => d.day) : [];
|
||||
const weekdaysOnly =
|
||||
days.length === 5 && ["mo", "tu", "we", "th", "fr"].every((d) => days.includes(d as JSCalendarNDay["day"]));
|
||||
if (weekdaysOnly && !every) {
|
||||
base = t("Every weekday");
|
||||
} else if (days.length) {
|
||||
const list = formatList(days.map((d) => weekdayName(d as never)));
|
||||
base = every
|
||||
? plural(n, { one: "Every {n} week on {days}", other: "Every {n} weeks on {days}" }, { days: list })
|
||||
: t("Weekly on {days}", { days: list });
|
||||
} else {
|
||||
base = every ? plural(n, { one: "Every {n} week", other: "Every {n} weeks" }) : t("Weekly");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "monthly": {
|
||||
base = n === 1 ? "Monthly" : `Every ${n} months`;
|
||||
if (rule.byMonthDay?.length) base += ` on day ${rule.byMonthDay.join(", ")}`;
|
||||
else if (rule.byDay?.length) {
|
||||
if (rule.byMonthDay?.length) {
|
||||
const list = formatList(rule.byMonthDay.map(String));
|
||||
base = every
|
||||
? plural(n, { one: "Every {n} month on day {days}", other: "Every {n} months on day {days}" }, { days: list })
|
||||
: t("Monthly on day {days}", { days: list });
|
||||
} else if (rule.byDay?.length) {
|
||||
const d = rule.byDay[0]!;
|
||||
const ord = d.nthOfPeriod ? ordinal(d.nthOfPeriod) + " " : "";
|
||||
base += ` on the ${ord}${WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day}`;
|
||||
const weekday = weekdayName(d.day as never);
|
||||
if (d.nthOfPeriod) {
|
||||
const ord = ordinal(d.nthOfPeriod);
|
||||
base = every
|
||||
? plural(n, { one: "Every {n} month on the {ordinal} {weekday}", other: "Every {n} months on the {ordinal} {weekday}" }, { ordinal: ord, weekday })
|
||||
: t("Monthly on the {ordinal} {weekday}", { ordinal: ord, weekday });
|
||||
} else {
|
||||
base = every
|
||||
? plural(n, { one: "Every {n} month on {weekday}", other: "Every {n} months on {weekday}" }, { weekday })
|
||||
: t("Monthly on {weekday}", { weekday });
|
||||
}
|
||||
} else {
|
||||
base = every ? plural(n, { one: "Every {n} month", other: "Every {n} months" }) : t("Monthly");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "yearly":
|
||||
base = n === 1 ? "Yearly" : `Every ${n} years`;
|
||||
base = every ? plural(n, { one: "Every {n} year", other: "Every {n} years" }) : t("Yearly");
|
||||
break;
|
||||
|
||||
default:
|
||||
base = `Every ${n} ${rule.frequency}`;
|
||||
// An RFC frequency this build has no sentence for. The frequency word
|
||||
// itself stays as the server sent it rather than being invented.
|
||||
base = t("Every {n} {frequency}", { n, frequency: rule.frequency });
|
||||
}
|
||||
|
||||
// The tail wraps the sentence rather than being glued to its end, so a
|
||||
// translator can put "until 3 May" first if that is what the language does.
|
||||
if (rule.count) {
|
||||
base = plural(rule.count, { one: "{rule}, {n} time", other: "{rule}, {n} times" }, { rule: base });
|
||||
}
|
||||
if (rule.until) {
|
||||
base = t("{rule}, until {date}", { rule: base, date: rule.until.slice(0, 10) });
|
||||
}
|
||||
if (rule.count) base += `, ${rule.count} times`;
|
||||
if (rule.until) base += `, until ${rule.until.slice(0, 10)}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* "first", "second", "last" -- words, not "1st".
|
||||
*
|
||||
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
|
||||
* rules in code: German writes "1.", Japanese "第1", and no catalogue can
|
||||
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
|
||||
* in practice, so five words and "last" cover it; anything else falls back to
|
||||
* the bare number, which is wrong in no language.
|
||||
*/
|
||||
function ordinal(n: number): string {
|
||||
if (n === -1) return "last";
|
||||
const s = ["th", "st", "nd", "rd"];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!);
|
||||
switch (n) {
|
||||
case -1: return t("last");
|
||||
case 1: return t("first");
|
||||
case 2: return t("second");
|
||||
case 3: return t("third");
|
||||
case 4: return t("fourth");
|
||||
case 5: return t("fifth");
|
||||
default: return String(n);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user