Compare commits

..
Author SHA1 Message Date
Coffey Labs 17d98748c4 Merge pull request #272 from Coffey-Labs/i18n-missing-plurals
Add seven plural forms no catalogue ever had
2026-09-03 14:47:00 -07:00
jcoffey-dev 1070ee13bc Add seven plural forms no catalogue ever had
Found by widening the coverage check to plural() forms in every file rather
than the two being worked on. Seven counted strings in the Files view and the
event editor had never been in any of the nine catalogues, so they rendered in
English whatever language was chosen.

Not a regression from the recent work -- they have been missing since the
features landed, and every earlier scan looked at t("literal") sites and the
plurals of whichever file was in hand.

All nine languages, one commit rather than nine: this is a single gap in a
check rather than a translation pass, and splitting it per language would
suggest nine decisions where there is one.
2026-09-03 14:44:23 -07:00
Coffey Labs 71827a2d04 Merge pull request #270 from Coffey-Labs/i18n-ukrainian-rule-sentences
Translate the rule sentences into Ukrainian
2026-09-03 14:41:45 -07:00
Coffey Labs 5dd0a56732 Merge pull request #269 from Coffey-Labs/i18n-russian-rule-sentences
Translate the rule sentences into Russian
2026-09-03 14:41:39 -07:00
Coffey Labs b55c8b13bc Merge pull request #268 from Coffey-Labs/i18n-chinese-rule-sentences
Translate the rule sentences into Simplified Chinese
2026-09-03 14:41:35 -07:00
Coffey Labs 0cf9b81444 Merge pull request #267 from Coffey-Labs/i18n-japanese-rule-sentences
Translate the rule sentences into Japanese
2026-09-03 14:41:31 -07:00
Coffey Labs 8386444ac7 Merge pull request #266 from Coffey-Labs/i18n-portuguese-rule-sentences
Translate the rule sentences into Brazilian Portuguese
2026-09-03 14:41:26 -07:00
Coffey Labs e1ae97139c Merge pull request #265 from Coffey-Labs/i18n-dutch-rule-sentences
Translate the rule sentences into Dutch
2026-09-03 14:41:21 -07:00
Coffey Labs 503eaf17ec Merge pull request #264 from Coffey-Labs/i18n-french-rule-sentences
Translate the rule sentences into French
2026-09-03 14:41:16 -07:00
Coffey Labs 1e02d9ebba Merge pull request #263 from Coffey-Labs/i18n-spanish-rule-sentences
Translate the rule sentences into Spanish
2026-09-03 14:41:11 -07:00
Coffey Labs d40dbf04b8 Merge pull request #262 from Coffey-Labs/i18n-german-rule-sentences
Translate the rule sentences into German
2026-09-03 14:41:06 -07:00
Coffey Labs 2a9e18f04c Merge pull request #271 from Coffey-Labs/fix/shortcuts-after-checkbox
Keep shortcuts working after a checkbox is clicked
2026-09-03 14:41:00 -07:00
Coffey Labs 4d89f5e672 Merge pull request #261 from Coffey-Labs/i18n-describe-rules
Build the two rule descriptions as sentences, not fragments
2026-09-03 14:40:55 -07:00
jcoffey-dev 95e5c69e8f Keep shortcuts working after a checkbox is clicked
Ticking "select all" disabled every keyboard shortcut until the reader clicked
somewhere else (#260). Same for the per-message checkboxes, so selecting a few
messages and pressing e to archive them did nothing.

The guard that stops "a" archiving while you are typing into the search box
tested `tagName === "INPUT"`. That is also true of a checkbox, and a checkbox
keeps focus after a click -- correctly, since space should toggle it again.
So the guard was suppressing shortcuts for an element that swallows no
keystroke: space is handled by the browser before this listener runs.

The question is not "is this an input" but "does this input take text", which
is what isTextEntry now asks. A <select> counts, in the sense that matters
here: typing a letter jumps to the option starting with it, and a shortcut
would steal that.

Thirteen checkboxes and seven file inputs across the app were affected, not
just the one reported.

The regression test was checked against the old guard first: it fails there
and passes here, which is the only thing that makes it a regression test.
2026-09-03 14:38:15 -07:00
jcoffey-dev 50d08a18e4 Translate the rule sentences into Ukrainian
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Ukrainian half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:24:12 -07:00
jcoffey-dev 9a634311b2 Translate the rule sentences into Russian
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Russian half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:24:08 -07:00
jcoffey-dev b811c84b12 Translate the rule sentences into Simplified Chinese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Simplified Chinese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:32 -07:00
jcoffey-dev b9b01ce02c Translate the rule sentences into Japanese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Japanese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:28 -07:00
jcoffey-dev 104e3c7ba0 Translate the rule sentences into Brazilian Portuguese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Brazilian Portuguese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:01 -07:00
jcoffey-dev 0a03c64ff3 Translate the rule sentences into Dutch
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Dutch half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:58 -07:00
jcoffey-dev 4c430ea995 Translate the rule sentences into French
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the French half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:28 -07:00
jcoffey-dev 112b3ea52f Translate the rule sentences into Spanish
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Spanish half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:24 -07:00
jcoffey-dev 31edf33839 Translate the rule sentences into German
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the German half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:21:53 -07:00
jcoffey-dev 1a842d8d14 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.
2026-09-03 14:20:50 -07:00
16 changed files with 949 additions and 81 deletions
@@ -0,0 +1,93 @@
/**
* The two sentence builders, which had no tests while they were building
* English by concatenation -- and no test would have caught the thing wrong
* with them, since the English output was correct. These pin the two
* properties that matter now: every fragment goes through the catalogue, and
* the joining is Intl's rather than a hardcoded " and ".
*/
import { describe, expect, it } from "vitest";
import { describeRule as describeSieve } from "../sieve";
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
describe("sieve describeRule", () => {
it("names the header and operator through the catalogue", () => {
const s = describeSieve({
id: "1", name: "r", join: "allof", enabled: true,
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
actions: [{ type: "fileinto", mailbox: "Work" }],
} as never);
expect(s).toContain("Subject");
expect(s).toContain("contains");
expect(s).toContain("invoice");
expect(s).toContain("Work");
});
it("joins an allof rule as a conjunction and anyof as a disjunction", () => {
const base = {
id: "1", name: "r", enabled: true,
tests: [
{ type: "header", header: "from", op: "is", value: "a@b" },
{ type: "header", header: "to", op: "is", value: "c@d" },
],
actions: [{ type: "keep" }],
};
expect(describeSieve({ ...base, join: "allof" } as never)).toContain(" and ");
expect(describeSieve({ ...base, join: "anyof" } as never)).toContain(" or ");
});
it("says 'always' when a rule has no tests", () => {
const s = describeSieve({ id: "1", name: "r", join: "allof", enabled: true, tests: [], actions: [{ type: "stop" }] } as never);
expect(s).toContain("always");
});
});
describe("recurrence describeRule", () => {
it("describes the simple frequencies", () => {
expect(describeRecurrence(undefined)).toBe("Does not repeat");
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Daily");
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
});
it("recognises Monday to Friday as every weekday", () => {
const rule = {
"@type": "RecurrenceRule", frequency: "weekly",
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
};
expect(describeRecurrence(rule as never)).toBe("Every weekday");
});
it("uses a word, not a suffix, for the nth weekday of a month", () => {
const s = describeRecurrence({
"@type": "RecurrenceRule", frequency: "monthly",
byDay: [{ "@type": "NDay", day: "tu", nthOfPeriod: 2 }],
} as never);
expect(s).toContain("second");
expect(s).not.toContain("2nd");
});
it("wraps the sentence for count and until rather than appending to it", () => {
const s = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", count: 5 } as never);
expect(s).toBe("Daily, 5 times");
const u = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", until: "2026-05-03T00:00:00" } as never);
expect(u).toBe("Daily, until 2026-05-03");
});
it("takes its weekday names from the locale, not a table of English", () => {
setUiLanguageForFormatting("de-DE");
const names = weekdayOptions().map((w) => w.label);
expect(names[0]).toBe("Montag");
expect(names).toHaveLength(7);
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
// which is why they cannot be catalogue keys and come from Intl instead.
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
setUiLanguageForFormatting(null);
});
it("renders a translated rule through the catalogue", () => {
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
setCatalog("en", { strings: {}, plurals: {} });
});
});
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isTextEntry, keyboard } from "@/lib/keyboard";
/*
* Shortcuts after a click on a checkbox (#260).
*
* The guard that stops "a" archiving while you are typing into the search box
* tested `tagName === "INPUT"`, which is also true of a checkbox. A checkbox
* keeps focus after a click, so ticking "select all" disabled every shortcut
* until the reader clicked somewhere else — and nothing about a checkbox
* swallows a keystroke in the first place.
*/
const pressFrom = (el: Element, key: string) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e;
};
let pop: (() => void) | null = null;
afterEach(() => {
pop?.();
pop = null;
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("isTextEntry", () => {
const input = (type?: string) => {
const el = document.createElement("input");
if (type) el.setAttribute("type", type);
return el;
};
it("is false for the inputs you cannot type into", () => {
for (const type of ["checkbox", "radio", "button", "submit", "reset", "file", "color", "range"]) {
expect(isTextEntry(input(type)), type).toBe(false);
}
});
it("is true for the ones you can", () => {
for (const type of ["text", "search", "email", "url", "tel", "password", "number", "date", "time"]) {
expect(isTextEntry(input(type)), type).toBe(true);
}
});
it("treats an input with no type as text, which is what the browser does", () => {
expect(isTextEntry(input())).toBe(true);
});
it("covers textarea, select and contenteditable", () => {
expect(isTextEntry(document.createElement("textarea"))).toBe(true);
// A select takes letters too: typing jumps to the matching option, and a
// shortcut would steal that.
expect(isTextEntry(document.createElement("select"))).toBe(true);
const div = document.createElement("div");
div.contentEditable = "true";
Object.defineProperty(div, "isContentEditable", { value: true });
expect(isTextEntry(div)).toBe(true);
});
it("is false for a button and for nothing at all", () => {
expect(isTextEntry(document.createElement("button"))).toBe(false);
expect(isTextEntry(null)).toBe(false);
});
});
describe("shortcuts with a checkbox focused", () => {
it("still fire — the reported bug", () => {
const handler = vi.fn();
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
const box = document.createElement("input");
box.type = "checkbox";
document.body.appendChild(box);
box.focus();
pressFrom(box, "e");
expect(handler).toHaveBeenCalledTimes(1);
});
it("still do not fire from a text field", () => {
const handler = vi.fn();
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
const field = document.createElement("input");
field.type = "search";
document.body.appendChild(field);
field.focus();
pressFrom(field, "e");
expect(handler).not.toHaveBeenCalled();
});
});
+45
View File
@@ -557,3 +557,48 @@ export function localeOptions(): LocaleOption[] {
optionsExtras = extras;
return list;
}
/**
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
*
* These used to be a table of English strings with a `short` of "M", "T", "W"…
* which could not become catalogue entries at all: "T" is both Tuesday and
* Thursday and "S" is both Saturday and Sunday, so the key collides with
* itself. A catalogue cannot hold two translations under one key, and no
* amount of translating fixes that — the data was wrong, not the wiring.
*
* Intl has the names already, in every locale, in three widths, and gets the
* plural and capitalisation conventions right without anybody maintaining a
* list. 2026-06-01 is a Monday; the rest follow from it.
*/
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
const WEEKDAY_ORDER: WeekdayKey[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const WEEKDAY_BASE = Date.UTC(2026, 5, 1); // a Monday
export function weekdayName(day: WeekdayKey, width: "long" | "short" | "narrow" = "long"): string {
const i = WEEKDAY_ORDER.indexOf(day);
if (i < 0) return day;
return intl({ weekday: width, timeZone: "UTC" }).format(new Date(WEEKDAY_BASE + i * 86_400_000));
}
/** Every weekday, Monday first, for pickers that show all seven. */
export function weekdayNames(width: "long" | "short" | "narrow" = "long"): Array<{ key: WeekdayKey; name: string }> {
return WEEKDAY_ORDER.map((key) => ({ key, name: weekdayName(key, width) }));
}
/**
* "A, B and C" — or "A, B oder C", or the comma the locale actually uses.
*
* Joining with a translated " and " does not work: Japanese does not separate
* list items with a word, and the last separator differs from the others in
* English. Intl.ListFormat knows all of that.
*/
export function formatList(items: string[], type: "conjunction" | "disjunction" = "conjunction"): string {
if (items.length < 2) return items[0] ?? "";
try {
return new Intl.ListFormat(resolvedLocale(), { style: "long", type }).format(items);
} catch {
return items.join(", ");
}
}
+33 -3
View File
@@ -54,9 +54,7 @@ class Keyboard {
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
if (document.querySelector(".dialog-backdrop, .popover")) return;
const target = e.target as HTMLElement | null;
const inInput =
!!target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);
const inInput = isTextEntry(target);
const combo = comboOf(e);
if (!combo) return;
@@ -102,6 +100,38 @@ class Keyboard {
}
}
/**
* Is the focused element somewhere the reader is typing?
*
* This guard exists so that pressing "a" in the search box searches for "a"
* rather than archiving the message behind it. The test used to be
* `tagName === "INPUT"`, which is true of a checkbox — and a checkbox keeps
* focus after you click it, so ticking "select all" silently disabled every
* shortcut until the reader clicked somewhere else (#260). Nothing about a
* checkbox swallows a keystroke: space toggles it and the browser handles
* that before this listener ever runs.
*
* So the question is not "is this an input" but "does this input take text".
* A `<select>` does, in the sense that matters here: typing a letter jumps to
* the option beginning with it, which a shortcut would steal.
*/
const TEXT_ENTRY_TYPES = new Set([
"text", "search", "email", "url", "tel", "password", "number",
"date", "datetime-local", "month", "time", "week",
]);
export function isTextEntry(el: Element | null): boolean {
if (!el) return false;
const node = el as HTMLElement;
if (node.isContentEditable) return true;
const tag = node.tagName;
if (tag === "TEXTAREA" || tag === "SELECT") return true;
if (tag !== "INPUT") return false;
// An <input> with no type attribute is a text field.
const type = (node as HTMLInputElement).type?.toLowerCase() || "text";
return TEXT_ENTRY_TYPES.has(type);
}
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
export function comboOf(e: KeyboardEvent): string | null {
+101 -31
View File
@@ -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);
}
}
+58 -44
View File
@@ -6,6 +6,9 @@
* Sieve below each comment is what the server actually runs.
*/
import { formatList } from "./datetime";
import { t } from "@/lib/i18n";
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
export type SieveTest =
@@ -318,49 +321,60 @@ export function reorderRules(rules: SieveRule[], fromId: string, toId: string, b
return [...rest.slice(0, at), moved, ...rest.slice(at)];
}
/**
* A filter rule as a sentence, for the rule list.
*
* Rebuilt as whole sentences with placeholders. The old version concatenated
* fragments -- a header name, an operator, a quoted value, joined by " and "
* -- which no catalogue could fix: German puts the verb last, Japanese does
* not separate list items with a word at all, and a translator handed " and "
* on its own cannot move anything. Reported by a native speaker reviewing the
* German catalogue (#247).
*
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
* for an anyof rule, the disjunction the language actually uses.
*/
export function describeRule(r: SieveRule): string {
const tests = r.tests
.map((t) => {
switch (t.type) {
case "header":
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "address":
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "size":
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
case "body":
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
case "true":
return "always";
}
})
.join(r.join === "allof" ? " and " : " or ");
const actions = r.actions
.map((a) => {
switch (a.type) {
case "fileinto":
return `move to ${a.mailbox}`;
case "redirect":
return `forward to ${a.address}`;
case "discard":
return "delete";
case "keep":
return "keep";
case "reject":
return "reject";
case "markread":
return "mark read";
case "flag":
return "star";
case "addflag":
case "setflag":
return `add ${a.flag}`;
case "removeflag":
return `remove ${a.flag}`;
case "stop":
return "stop";
}
})
.join(", ");
return `${tests || "always"}${actions}`;
const headerLabel = (h: string): string => t(HEADER_CHOICES.find((c) => c.value === h)?.label ?? h);
const opLabel = (op: string): string => t(HEADER_OPS.find((o) => o.value === op)?.label ?? op);
const tests = r.tests.map((test) => {
switch (test.type) {
case "header":
return t('{header} {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
case "address":
return t('{header} address {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
case "size":
return test.op === "over"
? t("size is over {n} KB", { n: Math.round(test.value / 1024) })
: t("size is under {n} KB", { n: Math.round(test.value / 1024) });
case "body":
return test.op === "contains"
? t('body contains "{value}"', { value: test.value })
: t('body does not contain "{value}"', { value: test.value });
case "true":
return t("always");
}
});
const actions = r.actions.map((a) => {
switch (a.type) {
case "fileinto": return t("move to {folder}", { folder: a.mailbox });
case "redirect": return t("forward to {address}", { address: a.address });
case "discard": return t("delete it");
case "keep": return t("keep it");
case "reject": return t("reject it");
case "markread": return t("mark it read");
case "flag": return t("star it");
case "addflag":
case "setflag": return t("add {flag}", { flag: a.flag });
case "removeflag": return t("remove {flag}", { flag: a.flag });
case "stop": return t("stop");
}
});
return t("{tests} → {actions}", {
tests: tests.length ? formatList(tests, r.join === "allof" ? "conjunction" : "disjunction") : t("always"),
actions: formatList(actions, "conjunction"),
});
}
+58
View File
@@ -1282,8 +1282,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Diese Nachricht wurde automatisch versendet, daher wird keine Lesebestätigung angeboten.",
"This server will not hold a message longer than {span}.": "Dieser Server hält eine Nachricht nicht länger als {span} zurück.",
"Upload failed": "Hochladen fehlgeschlagen",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Alle {n} {frequency}",
"Monthly": "Monatlich",
"Monthly on day {days}": "Monatlich am Tag {days}",
"Monthly on the {ordinal} {weekday}": "Monatlich am {ordinal} {weekday}",
"Monthly on {weekday}": "Monatlich am {weekday}",
"Weekly": "Wöchentlich",
"Weekly on {days}": "Wöchentlich am {days}",
"add {flag}": "{flag} hinzufügen",
"always": "immer",
"body contains \"{value}\"": "Text enthält \"{value}\"",
"body does not contain \"{value}\"": "Text enthält nicht \"{value}\"",
"delete it": "löschen",
"fifth": "fünften",
"first": "ersten",
"forward to {address}": "weiterleiten an {address}",
"fourth": "vierten",
"keep it": "behalten",
"last": "letzten",
"mark it read": "als gelesen markieren",
"move to {folder}": "verschieben nach {folder}",
"reject it": "abweisen",
"remove {flag}": "{flag} entfernen",
"second": "zweiten",
"size is over {n} KB": "Größe über {n} KB",
"size is under {n} KB": "Größe unter {n} KB",
"star it": "markieren",
"stop": "anhalten",
"third": "dritten",
"{header} address {op} \"{value}\"": "{header}-Adresse {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, bis {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "{n} Element löschen", other: "{n} Elemente löschen" },
"Delete {n} items?": { one: "{n} Element löschen?", other: "{n} Elemente löschen?" },
"Move {n} items": { one: "{n} Element verschieben", other: "{n} Elemente verschieben" },
"Move {n} items…": { one: "{n} Element verschieben…", other: "{n} Elemente verschieben…" },
"The event runs {n} days longer than this shows.": { one: "Der Termin dauert {n} Tag länger, als hier angezeigt wird.", other: "Der Termin dauert {n} Tage länger, als hier angezeigt wird." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} Gast ist nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen.", other: "{n} Gäste sind nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen." },
"{n} items selected": { one: "{n} Element ausgewählt", other: "{n} Elemente ausgewählt" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Jeden Tag", other: "Alle {n} Tage" },
"Every {n} months": { one: "Jeden Monat", other: "Alle {n} Monate" },
"Every {n} months on day {days}": { one: "Jeden Monat am Tag {days}", other: "Alle {n} Monate am Tag {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Jeden Monat am {ordinal} {weekday}", other: "Alle {n} Monate am {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Jeden Monat am {weekday}", other: "Alle {n} Monate am {weekday}" },
"Every {n} weeks": { one: "Jede Woche", other: "Alle {n} Wochen" },
"Every {n} weeks on {days}": { one: "Jede Woche am {days}", other: "Alle {n} Wochen am {days}" },
"Every {n} years": { one: "Jedes Jahr", other: "Alle {n} Jahre" },
"{rule}, {n} times": { one: "{rule}, {n}-mal", other: "{rule}, {n}-mal" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "{n} Nachricht in den Papierkorb verschieben?", other: "{n} Nachrichten in den Papierkorb verschieben?" },
"{n} days": { one: "{n} Tag", other: "{n} Tage" },
+58
View File
@@ -1255,8 +1255,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Este mensaje se envió automáticamente, así que no se ofrece confirmación de lectura.",
"This server will not hold a message longer than {span}.": "Este servidor no retiene un mensaje más de {span}.",
"Upload failed": "Error al subir",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Cada {n} {frequency}",
"Monthly": "Mensualmente",
"Monthly on day {days}": "Mensualmente el día {days}",
"Monthly on the {ordinal} {weekday}": "Mensualmente el {ordinal} {weekday}",
"Monthly on {weekday}": "Mensualmente el {weekday}",
"Weekly": "Semanalmente",
"Weekly on {days}": "Semanalmente los {days}",
"add {flag}": "añadir {flag}",
"always": "siempre",
"body contains \"{value}\"": "el cuerpo contiene \"{value}\"",
"body does not contain \"{value}\"": "el cuerpo no contiene \"{value}\"",
"delete it": "eliminarlo",
"fifth": "quinto",
"first": "primer",
"forward to {address}": "reenviar a {address}",
"fourth": "cuarto",
"keep it": "conservarlo",
"last": "último",
"mark it read": "marcarlo como leído",
"move to {folder}": "mover a {folder}",
"reject it": "rechazarlo",
"remove {flag}": "quitar {flag}",
"second": "segundo",
"size is over {n} KB": "el tamaño supera {n} KB",
"size is under {n} KB": "el tamaño es inferior a {n} KB",
"star it": "destacarlo",
"stop": "detener",
"third": "tercer",
"{header} address {op} \"{value}\"": "la dirección de {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, hasta el {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Eliminar {n} elemento", other: "Eliminar {n} elementos" },
"Delete {n} items?": { one: "¿Eliminar {n} elemento?", other: "¿Eliminar {n} elementos?" },
"Move {n} items": { one: "Mover {n} elemento", other: "Mover {n} elementos" },
"Move {n} items…": { one: "Mover {n} elemento…", other: "Mover {n} elementos…" },
"The event runs {n} days longer than this shows.": { one: "El evento dura {n} día más de lo que se muestra aquí.", other: "El evento dura {n} días más de lo que se muestra aquí." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invitado no está en este servidor, así que no hay información de libre/ocupado para él.", other: "{n} invitados no están en este servidor, así que no hay información de libre/ocupado para ellos." },
"{n} items selected": { one: "{n} elemento seleccionado", other: "{n} elementos seleccionados" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Cada día", other: "Cada {n} días" },
"Every {n} months": { one: "Cada mes", other: "Cada {n} meses" },
"Every {n} months on day {days}": { one: "Cada mes el día {days}", other: "Cada {n} meses el día {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Cada mes el {ordinal} {weekday}", other: "Cada {n} meses el {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Cada mes el {weekday}", other: "Cada {n} meses el {weekday}" },
"Every {n} weeks": { one: "Cada semana", other: "Cada {n} semanas" },
"Every {n} weeks on {days}": { one: "Cada semana los {days}", other: "Cada {n} semanas los {days}" },
"Every {n} years": { one: "Cada año", other: "Cada {n} años" },
"{rule}, {n} times": { one: "{rule}, {n} vez", other: "{rule}, {n} veces" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "¿Mover {n} mensaje a la Papelera?", other: "¿Mover {n} mensajes a la Papelera?" },
"{n} days": { one: "{n} día", other: "{n} días" },
+58
View File
@@ -1260,8 +1260,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Ce message a été envoyé automatiquement, aucun accusé de lecture n'est donc proposé.",
"This server will not hold a message longer than {span}.": "Ce serveur ne retient pas un message plus de {span}.",
"Upload failed": "Échec de l'envoi",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Tous les {n} {frequency}",
"Monthly": "Chaque mois",
"Monthly on day {days}": "Chaque mois le {days}",
"Monthly on the {ordinal} {weekday}": "Chaque mois le {ordinal} {weekday}",
"Monthly on {weekday}": "Chaque mois le {weekday}",
"Weekly": "Chaque semaine",
"Weekly on {days}": "Chaque semaine le {days}",
"add {flag}": "ajouter {flag}",
"always": "toujours",
"body contains \"{value}\"": "le corps contient \"{value}\"",
"body does not contain \"{value}\"": "le corps ne contient pas \"{value}\"",
"delete it": "le supprimer",
"fifth": "cinquième",
"first": "premier",
"forward to {address}": "transférer à {address}",
"fourth": "quatrième",
"keep it": "le conserver",
"last": "dernier",
"mark it read": "le marquer comme lu",
"move to {folder}": "déplacer vers {folder}",
"reject it": "le rejeter",
"remove {flag}": "retirer {flag}",
"second": "deuxième",
"size is over {n} KB": "la taille dépasse {n} Ko",
"size is under {n} KB": "la taille est inférieure à {n} Ko",
"star it": "le suivre",
"stop": "arrêter",
"third": "troisième",
"{header} address {op} \"{value}\"": "l'adresse {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, jusqu'au {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Supprimer {n} élément", other: "Supprimer {n} éléments" },
"Delete {n} items?": { one: "Supprimer {n} élément ?", other: "Supprimer {n} éléments ?" },
"Move {n} items": { one: "Déplacer {n} élément", other: "Déplacer {n} éléments" },
"Move {n} items…": { one: "Déplacer {n} élément…", other: "Déplacer {n} éléments…" },
"The event runs {n} days longer than this shows.": { one: "L'événement dure {n} jour de plus que ce qui est affiché ici.", other: "L'événement dure {n} jours de plus que ce qui est affiché ici." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invité n'est pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour lui.", other: "{n} invités ne sont pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour eux." },
"{n} items selected": { one: "{n} élément sélectionné", other: "{n} éléments sélectionnés" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Chaque jour", other: "Tous les {n} jours" },
"Every {n} months": { one: "Chaque mois", other: "Tous les {n} mois" },
"Every {n} months on day {days}": { one: "Chaque mois le {days}", other: "Tous les {n} mois le {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Chaque mois le {ordinal} {weekday}", other: "Tous les {n} mois le {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Chaque mois le {weekday}", other: "Tous les {n} mois le {weekday}" },
"Every {n} weeks": { one: "Chaque semaine", other: "Toutes les {n} semaines" },
"Every {n} weeks on {days}": { one: "Chaque semaine le {days}", other: "Toutes les {n} semaines le {days}" },
"Every {n} years": { one: "Chaque année", other: "Tous les {n} ans" },
"{rule}, {n} times": { one: "{rule}, {n} fois", other: "{rule}, {n} fois" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Déplacer {n} message vers la Corbeille ?", other: "Déplacer {n} messages vers la Corbeille ?" },
"{n} days": { one: "{n} jour", other: "{n} jours" },
+58
View File
@@ -1263,8 +1263,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "このメールは自動送信されたため、開封確認は行いません。",
"This server will not hold a message longer than {span}.": "このサーバーはメールを {span} を超えて保留しません。",
"Upload failed": "アップロードに失敗しました",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "{n} {frequency} ごと",
"Monthly": "毎月",
"Monthly on day {days}": "毎月 {days} 日",
"Monthly on the {ordinal} {weekday}": "毎月第{ordinal} {weekday}",
"Monthly on {weekday}": "毎月 {weekday}",
"Weekly": "毎週",
"Weekly on {days}": "毎週 {days}",
"add {flag}": "{flag} を付ける",
"always": "常に",
"body contains \"{value}\"": "本文が \"{value}\" を含む",
"body does not contain \"{value}\"": "本文が \"{value}\" を含まない",
"delete it": "削除",
"fifth": "5",
"first": "1",
"forward to {address}": "{address} に転送",
"fourth": "4",
"keep it": "保持",
"last": "最終",
"mark it read": "既読にする",
"move to {folder}": "{folder} に移動",
"reject it": "拒否",
"remove {flag}": "{flag} を外す",
"second": "2",
"size is over {n} KB": "サイズが {n} KB を超える",
"size is under {n} KB": "サイズが {n} KB 未満",
"star it": "スターを付ける",
"stop": "停止",
"third": "3",
"{header} address {op} \"{value}\"": "{header} のアドレスが \"{value}\" を{op}",
"{header} {op} \"{value}\"": "{header} が \"{value}\" を{op}",
"{rule}, until {date}": "{rule}{date} まで)",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { other: "{n} 件を削除" },
"Delete {n} items?": { other: "{n} 件を削除しますか?" },
"Move {n} items": { other: "{n} 件を移動" },
"Move {n} items…": { other: "{n} 件を移動…" },
"The event runs {n} days longer than this shows.": { other: "この予定は表示よりも {n} 日長く続きます。" },
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "{n} 名の参加者はこのサーバーにいないため、空き情報を取得できません。" },
"{n} items selected": { other: "{n} 件を選択中" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { other: "{n} 日ごと" },
"Every {n} months": { other: "{n} か月ごと" },
"Every {n} months on day {days}": { other: "{n} か月ごと {days} 日" },
"Every {n} months on the {ordinal} {weekday}": { other: "{n} か月ごと第{ordinal} {weekday}" },
"Every {n} months on {weekday}": { other: "{n} か月ごと {weekday}" },
"Every {n} weeks": { other: "{n} 週ごと" },
"Every {n} weeks on {days}": { other: "{n} 週ごと {days}" },
"Every {n} years": { other: "{n} 年ごと" },
"{rule}, {n} times": { other: "{rule}{n} 回)" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { other: "{n} 通のメールをゴミ箱に移動しますか?" },
"{n} days": { other: "{n} 日" },
+58
View File
@@ -1251,8 +1251,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Dit bericht is automatisch verzonden, dus er wordt geen leesbevestiging aangeboden.",
"This server will not hold a message longer than {span}.": "Deze server houdt een bericht niet langer dan {span} vast.",
"Upload failed": "Uploaden mislukt",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Elke {n} {frequency}",
"Monthly": "Maandelijks",
"Monthly on day {days}": "Maandelijks op dag {days}",
"Monthly on the {ordinal} {weekday}": "Maandelijks op de {ordinal} {weekday}",
"Monthly on {weekday}": "Maandelijks op {weekday}",
"Weekly": "Wekelijks",
"Weekly on {days}": "Wekelijks op {days}",
"add {flag}": "{flag} toevoegen",
"always": "altijd",
"body contains \"{value}\"": "tekst bevat \"{value}\"",
"body does not contain \"{value}\"": "tekst bevat niet \"{value}\"",
"delete it": "verwijderen",
"fifth": "vijfde",
"first": "eerste",
"forward to {address}": "doorsturen naar {address}",
"fourth": "vierde",
"keep it": "behouden",
"last": "laatste",
"mark it read": "als gelezen markeren",
"move to {folder}": "verplaatsen naar {folder}",
"reject it": "weigeren",
"remove {flag}": "{flag} verwijderen",
"second": "tweede",
"size is over {n} KB": "grootte boven {n} KB",
"size is under {n} KB": "grootte onder {n} KB",
"star it": "een ster geven",
"stop": "stoppen",
"third": "derde",
"{header} address {op} \"{value}\"": "{header}-adres {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, tot {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "{n} item verwijderen", other: "{n} items verwijderen" },
"Delete {n} items?": { one: "{n} item verwijderen?", other: "{n} items verwijderen?" },
"Move {n} items": { one: "{n} item verplaatsen", other: "{n} items verplaatsen" },
"Move {n} items…": { one: "{n} item verplaatsen…", other: "{n} items verplaatsen…" },
"The event runs {n} days longer than this shows.": { one: "De gebeurtenis duurt {n} dag langer dan hier wordt getoond.", other: "De gebeurtenis duurt {n} dagen langer dan hier wordt getoond." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} gast zit niet op deze server, dus er is geen vrij/bezet voor die persoon te lezen.", other: "{n} gasten zitten niet op deze server, dus er is geen vrij/bezet voor hen te lezen." },
"{n} items selected": { one: "{n} item geselecteerd", other: "{n} items geselecteerd" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Elke dag", other: "Elke {n} dagen" },
"Every {n} months": { one: "Elke maand", other: "Elke {n} maanden" },
"Every {n} months on day {days}": { one: "Elke maand op dag {days}", other: "Elke {n} maanden op dag {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Elke maand op de {ordinal} {weekday}", other: "Elke {n} maanden op de {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Elke maand op {weekday}", other: "Elke {n} maanden op {weekday}" },
"Every {n} weeks": { one: "Elke week", other: "Elke {n} weken" },
"Every {n} weeks on {days}": { one: "Elke week op {days}", other: "Elke {n} weken op {days}" },
"Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" },
"{rule}, {n} times": { one: "{rule}, {n} keer", other: "{rule}, {n} keer" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "{n} bericht naar de Prullenbak verplaatsen?", other: "{n} berichten naar de Prullenbak verplaatsen?" },
"{n} days": { one: "{n} dag", other: "{n} dagen" },
+58
View File
@@ -1258,8 +1258,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Esta mensagem foi enviada automaticamente, então não há confirmação de leitura a oferecer.",
"This server will not hold a message longer than {span}.": "Este servidor não retém uma mensagem por mais de {span}.",
"Upload failed": "Falha no envio",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "A cada {n} {frequency}",
"Monthly": "Mensalmente",
"Monthly on day {days}": "Mensalmente no dia {days}",
"Monthly on the {ordinal} {weekday}": "Mensalmente na {ordinal} {weekday}",
"Monthly on {weekday}": "Mensalmente em {weekday}",
"Weekly": "Semanalmente",
"Weekly on {days}": "Semanalmente em {days}",
"add {flag}": "adicionar {flag}",
"always": "sempre",
"body contains \"{value}\"": "o corpo contém \"{value}\"",
"body does not contain \"{value}\"": "o corpo não contém \"{value}\"",
"delete it": "excluir",
"fifth": "quinta",
"first": "primeira",
"forward to {address}": "encaminhar para {address}",
"fourth": "quarta",
"keep it": "manter",
"last": "última",
"mark it read": "marcar como lida",
"move to {folder}": "mover para {folder}",
"reject it": "rejeitar",
"remove {flag}": "remover {flag}",
"second": "segunda",
"size is over {n} KB": "o tamanho passa de {n} KB",
"size is under {n} KB": "o tamanho é menor que {n} KB",
"star it": "favoritar",
"stop": "parar",
"third": "terceira",
"{header} address {op} \"{value}\"": "o endereço de {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, até {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Excluir {n} item", other: "Excluir {n} itens" },
"Delete {n} items?": { one: "Excluir {n} item?", other: "Excluir {n} itens?" },
"Move {n} items": { one: "Mover {n} item", other: "Mover {n} itens" },
"Move {n} items…": { one: "Mover {n} item…", other: "Mover {n} itens…" },
"The event runs {n} days longer than this shows.": { one: "O evento dura {n} dia a mais do que é exibido aqui.", other: "O evento dura {n} dias a mais do que é exibido aqui." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} convidado não está neste servidor, então não há livre/ocupado a consultar para ele.", other: "{n} convidados não estão neste servidor, então não há livre/ocupado a consultar para eles." },
"{n} items selected": { one: "{n} item selecionado", other: "{n} itens selecionados" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Todo dia", other: "A cada {n} dias" },
"Every {n} months": { one: "Todo mês", other: "A cada {n} meses" },
"Every {n} months on day {days}": { one: "Todo mês no dia {days}", other: "A cada {n} meses no dia {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Todo mês na {ordinal} {weekday}", other: "A cada {n} meses na {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Todo mês em {weekday}", other: "A cada {n} meses em {weekday}" },
"Every {n} weeks": { one: "Toda semana", other: "A cada {n} semanas" },
"Every {n} weeks on {days}": { one: "Toda semana em {days}", other: "A cada {n} semanas em {days}" },
"Every {n} years": { one: "Todo ano", other: "A cada {n} anos" },
"{rule}, {n} times": { one: "{rule}, {n} vez", other: "{rule}, {n} vezes" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Mover {n} mensagem para a Lixeira?", other: "Mover {n} mensagens para a Lixeira?" },
"{n} days": { one: "{n} dia", other: "{n} dias" },
+58
View File
@@ -1257,8 +1257,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Это письмо отправлено автоматически, поэтому уведомление о прочтении не предлагается.",
"This server will not hold a message longer than {span}.": "Этот сервер не удерживает письмо дольше чем {span}.",
"Upload failed": "Не удалось загрузить",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Каждые {n} {frequency}",
"Monthly": "Ежемесячно",
"Monthly on day {days}": "Ежемесячно {days}-го числа",
"Monthly on the {ordinal} {weekday}": "Ежемесячно в {ordinal} {weekday}",
"Monthly on {weekday}": "Ежемесячно в {weekday}",
"Weekly": "Еженедельно",
"Weekly on {days}": "Еженедельно в {days}",
"add {flag}": "добавить {flag}",
"always": "всегда",
"body contains \"{value}\"": "текст содержит \"{value}\"",
"body does not contain \"{value}\"": "текст не содержит \"{value}\"",
"delete it": "удалить",
"fifth": "пятый",
"first": "первый",
"forward to {address}": "переслать на {address}",
"fourth": "четвёртый",
"keep it": "оставить",
"last": "последний",
"mark it read": "пометить прочитанным",
"move to {folder}": "переместить в {folder}",
"reject it": "отклонить",
"remove {flag}": "убрать {flag}",
"second": "второй",
"size is over {n} KB": "размер больше {n} КБ",
"size is under {n} KB": "размер меньше {n} КБ",
"star it": "отметить",
"stop": "остановить",
"third": "третий",
"{header} address {op} \"{value}\"": "адрес {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, до {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Удалить {n} объект", few: "Удалить {n} объекта", many: "Удалить {n} объектов", other: "Удалить {n} объекта" },
"Delete {n} items?": { one: "Удалить {n} объект?", few: "Удалить {n} объекта?", many: "Удалить {n} объектов?", other: "Удалить {n} объекта?" },
"Move {n} items": { one: "Переместить {n} объект", few: "Переместить {n} объекта", many: "Переместить {n} объектов", other: "Переместить {n} объекта" },
"Move {n} items…": { one: "Переместить {n} объект…", few: "Переместить {n} объекта…", many: "Переместить {n} объектов…", other: "Переместить {n} объекта…" },
"The event runs {n} days longer than this shows.": { one: "Событие длится на {n} день дольше, чем показано здесь.", few: "Событие длится на {n} дня дольше, чем показано здесь.", many: "Событие длится на {n} дней дольше, чем показано здесь.", other: "Событие длится на {n} дня дольше, чем показано здесь." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} участник не на этом сервере, поэтому сведений о занятости для него нет.", few: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет.", many: "{n} участников не на этом сервере, поэтому сведений о занятости для них нет.", other: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет." },
"{n} items selected": { one: "Выбран {n} объект", few: "Выбрано {n} объекта", many: "Выбрано {n} объектов", other: "Выбрано {n} объекта" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Каждый день", few: "Каждые {n} дня", many: "Каждые {n} дней", other: "Каждые {n} дня" },
"Every {n} months": { one: "Каждый месяц", few: "Каждые {n} месяца", many: "Каждые {n} месяцев", other: "Каждые {n} месяца" },
"Every {n} months on day {days}": { one: "Каждый месяц {days}-го числа", few: "Каждые {n} месяца {days}-го числа", many: "Каждые {n} месяцев {days}-го числа", other: "Каждые {n} месяца {days}-го числа" },
"Every {n} months on the {ordinal} {weekday}": { one: "Каждый месяц в {ordinal} {weekday}", few: "Каждые {n} месяца в {ordinal} {weekday}", many: "Каждые {n} месяцев в {ordinal} {weekday}", other: "Каждые {n} месяца в {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Каждый месяц в {weekday}", few: "Каждые {n} месяца в {weekday}", many: "Каждые {n} месяцев в {weekday}", other: "Каждые {n} месяца в {weekday}" },
"Every {n} weeks": { one: "Каждую неделю", few: "Каждые {n} недели", many: "Каждые {n} недель", other: "Каждые {n} недели" },
"Every {n} weeks on {days}": { one: "Каждую неделю в {days}", few: "Каждые {n} недели в {days}", many: "Каждые {n} недель в {days}", other: "Каждые {n} недели в {days}" },
"Every {n} years": { one: "Каждый год", few: "Каждые {n} года", many: "Каждые {n} лет", other: "Каждые {n} года" },
"{rule}, {n} times": { one: "{rule}, {n} раз", few: "{rule}, {n} раза", many: "{rule}, {n} раз", other: "{rule}, {n} раза" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Переместить {n} письмо в корзину?", few: "Переместить {n} письма в корзину?", many: "Переместить {n} писем в корзину?", other: "Переместить {n} письма в корзину?" },
"{n} days": { one: "{n} день", few: "{n} дня", many: "{n} дней", other: "{n} дня" },
+58
View File
@@ -1251,8 +1251,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Цей лист надіслано автоматично, тому сповіщення про прочитання не пропонується.",
"This server will not hold a message longer than {span}.": "Цей сервер не утримує лист довше ніж {span}.",
"Upload failed": "Не вдалося завантажити",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Кожні {n} {frequency}",
"Monthly": "Щомісяця",
"Monthly on day {days}": "Щомісяця {days}-го числа",
"Monthly on the {ordinal} {weekday}": "Щомісяця у {ordinal} {weekday}",
"Monthly on {weekday}": "Щомісяця у {weekday}",
"Weekly": "Щотижня",
"Weekly on {days}": "Щотижня у {days}",
"add {flag}": "додати {flag}",
"always": "завжди",
"body contains \"{value}\"": "текст містить \"{value}\"",
"body does not contain \"{value}\"": "текст не містить \"{value}\"",
"delete it": "видалити",
"fifth": "п’ятий",
"first": "перший",
"forward to {address}": "переслати на {address}",
"fourth": "четвертий",
"keep it": "залишити",
"last": "останній",
"mark it read": "позначити прочитаним",
"move to {folder}": "перемістити до {folder}",
"reject it": "відхилити",
"remove {flag}": "прибрати {flag}",
"second": "другий",
"size is over {n} KB": "розмір більший за {n} КБ",
"size is under {n} KB": "розмір менший за {n} КБ",
"star it": "позначити",
"stop": "зупинити",
"third": "третій",
"{header} address {op} \"{value}\"": "адреса {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, до {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Видалити {n} об’єкт", few: "Видалити {n} об’єкти", many: "Видалити {n} об’єктів", other: "Видалити {n} об’єкта" },
"Delete {n} items?": { one: "Видалити {n} об’єкт?", few: "Видалити {n} об’єкти?", many: "Видалити {n} об’єктів?", other: "Видалити {n} об’єкта?" },
"Move {n} items": { one: "Перемістити {n} об’єкт", few: "Перемістити {n} об’єкти", many: "Перемістити {n} об’єктів", other: "Перемістити {n} об’єкта" },
"Move {n} items…": { one: "Перемістити {n} об’єкт…", few: "Перемістити {n} об’єкти…", many: "Перемістити {n} об’єктів…", other: "Перемістити {n} об’єкта…" },
"The event runs {n} days longer than this shows.": { one: "Подія триває на {n} день довше, ніж показано тут.", few: "Подія триває на {n} дні довше, ніж показано тут.", many: "Подія триває на {n} днів довше, ніж показано тут.", other: "Подія триває на {n} дня довше, ніж показано тут." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} гість не на цьому сервері, тому відомостей про зайнятість для нього немає.", few: "{n} гості не на цьому сервері, тому відомостей про зайнятість для них немає.", many: "{n} гостей не на цьому сервері, тому відомостей про зайнятість для них немає.", other: "{n} гостя не на цьому сервері, тому відомостей про зайнятість для них немає." },
"{n} items selected": { one: "Вибрано {n} об’єкт", few: "Вибрано {n} об’єкти", many: "Вибрано {n} об’єктів", other: "Вибрано {n} об’єкта" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Щодня", few: "Кожні {n} дні", many: "Кожні {n} днів", other: "Кожні {n} дня" },
"Every {n} months": { one: "Щомісяця", few: "Кожні {n} місяці", many: "Кожні {n} місяців", other: "Кожні {n} місяця" },
"Every {n} months on day {days}": { one: "Щомісяця {days}-го числа", few: "Кожні {n} місяці {days}-го числа", many: "Кожні {n} місяців {days}-го числа", other: "Кожні {n} місяця {days}-го числа" },
"Every {n} months on the {ordinal} {weekday}": { one: "Щомісяця у {ordinal} {weekday}", few: "Кожні {n} місяці у {ordinal} {weekday}", many: "Кожні {n} місяців у {ordinal} {weekday}", other: "Кожні {n} місяця у {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Щомісяця у {weekday}", few: "Кожні {n} місяці у {weekday}", many: "Кожні {n} місяців у {weekday}", other: "Кожні {n} місяця у {weekday}" },
"Every {n} weeks": { one: "Щотижня", few: "Кожні {n} тижні", many: "Кожні {n} тижнів", other: "Кожні {n} тижня" },
"Every {n} weeks on {days}": { one: "Щотижня у {days}", few: "Кожні {n} тижні у {days}", many: "Кожні {n} тижнів у {days}", other: "Кожні {n} тижня у {days}" },
"Every {n} years": { one: "Щороку", few: "Кожні {n} роки", many: "Кожні {n} років", other: "Кожні {n} року" },
"{rule}, {n} times": { one: "{rule}, {n} раз", few: "{rule}, {n} рази", many: "{rule}, {n} разів", other: "{rule}, {n} раза" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Перемістити {n} лист до кошика?", few: "Перемістити {n} листи до кошика?", many: "Перемістити {n} листів до кошика?", other: "Перемістити {n} листа до кошика?" },
"{n} days": { one: "{n} день", few: "{n} дні", many: "{n} днів", other: "{n} дня" },
+58
View File
@@ -1262,8 +1262,66 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "此邮件为自动发送,因此不提供已读回执。",
"This server will not hold a message longer than {span}.": "此服务器保留邮件的时间不会超过 {span}。",
"Upload failed": "上传失败",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "每 {n} {frequency}",
"Monthly": "每月",
"Monthly on day {days}": "每月 {days} 日",
"Monthly on the {ordinal} {weekday}": "每月第{ordinal}个{weekday}",
"Monthly on {weekday}": "每月 {weekday}",
"Weekly": "每周",
"Weekly on {days}": "每周 {days}",
"add {flag}": "添加 {flag}",
"always": "始终",
"body contains \"{value}\"": "正文包含 \"{value}\"",
"body does not contain \"{value}\"": "正文不包含 \"{value}\"",
"delete it": "删除",
"fifth": "五",
"first": "一",
"forward to {address}": "转发到 {address}",
"fourth": "四",
"keep it": "保留",
"last": "最后一",
"mark it read": "标记为已读",
"move to {folder}": "移动到 {folder}",
"reject it": "拒收",
"remove {flag}": "移除 {flag}",
"second": "二",
"size is over {n} KB": "大小超过 {n} KB",
"size is under {n} KB": "大小小于 {n} KB",
"star it": "加星标",
"stop": "停止",
"third": "三",
"{header} address {op} \"{value}\"": "{header} 地址 {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule},直到 {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { other: "删除 {n} 个项目" },
"Delete {n} items?": { other: "要删除 {n} 个项目吗?" },
"Move {n} items": { other: "移动 {n} 个项目" },
"Move {n} items…": { other: "移动 {n} 个项目…" },
"The event runs {n} days longer than this shows.": { other: "此活动比这里显示的时间长 {n} 天。" },
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "有 {n} 位与会者不在此服务器上,因此无法读取他们的空闲/忙碌信息。" },
"{n} items selected": { other: "已选择 {n} 个项目" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { other: "每 {n} 天" },
"Every {n} months": { other: "每 {n} 个月" },
"Every {n} months on day {days}": { other: "每 {n} 个月的 {days} 日" },
"Every {n} months on the {ordinal} {weekday}": { other: "每 {n} 个月的第{ordinal}个{weekday}" },
"Every {n} months on {weekday}": { other: "每 {n} 个月的 {weekday}" },
"Every {n} weeks": { other: "每 {n} 周" },
"Every {n} weeks on {days}": { other: "每 {n} 周的 {days}" },
"Every {n} years": { other: "每 {n} 年" },
"{rule}, {n} times": { other: "{rule},共 {n} 次" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { other: "要将 {n} 封邮件移到已删除邮件吗?" },
"{n} days": { other: "{n} 天" },
+3 -3
View File
@@ -13,7 +13,7 @@ import { RecipientInput } from "../compose/RecipientInput";
import { DateField, DateTimeField } from "@/ui/datefield";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { formatClock, formatNumericDate, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { WEEKDAY_KEYS, weekdayOptions, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
import { availabilityWindow } from "@/lib/availabilityWindow";
import { askEditScope, droppedMessage, runScoped } from "./scope";
@@ -359,7 +359,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</select>
)}
{!oneDate && (
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAY_KEYS[(start.getDay() + 6) % 7]! }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">{translate("Does not repeat")}</option>
<option value="daily">{translate("Daily")}</option>
<option value="weekly">{translate("Weekly on {weekday}", { weekday: formatWeekday(start, "long") })}</option>
@@ -381,7 +381,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</div>
{customRule.frequency === "weekly" && (
<div className="row" style={{ gap: 4, marginTop: 8 }}>
{WEEKDAYS.map((w) => {
{weekdayOptions().map((w) => {
const on = customRule.byDay?.some((d) => d.day === w.key);
return <button key={w.key} type="button" className={`btn btn-sm btn-pill ${on ? "btn-primary" : ""}`} style={{ width: 36, padding: 0 }} title={w.label} onClick={() => { const cur = customRule.byDay ?? []; const next: JSCalendarNDay[] = on ? cur.filter((d) => d.day !== w.key) : [...cur, { "@type": "NDay", day: w.key }]; setRule({ ...customRule, byDay: next.length ? next : undefined }); }}>{w.short}</button>;
})}