Skip to content

Commit 674cb89

Browse files
committed
Language
1 parent e26b432 commit 674cb89

63 files changed

Lines changed: 2711 additions & 1211 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

check.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const m = require('./i18n/locales/manifest.json');
2+
const v = m['stats.engagementTotalsBody'];
3+
console.log('has real newline char:', v.includes(String.fromCharCode(10)));
4+
console.log('length', v.length);

i18n/locales/manifest.json

Lines changed: 766 additions & 3 deletions
Large diffs are not rendered by default.

schema/guild-config.meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"generatedAt": "2026-09-14T19:16:38.387Z",
3+
"generatedAt": "2026-09-15T08:36:20.007Z",
44
"templatePath": "config/default.server.yaml",
55
"schemaPath": "schema/guild-config.schema.json",
66
"categories": [

scripts/extractI18nKeys.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,40 @@ function readStringLiteral(src: string, i: number): { raw: string; next: number
7575
return null;
7676
}
7777

78+
/** Standard JS escape sequences a string/template literal can contain. `${...}` inside a
79+
* template literal is deliberately left as literal text (see header) — only backslash escapes
80+
* are resolved here. Unrecognized escapes (e.g. `ሴ`, `\x41`) are left as-is; none of our
81+
* translation strings use them. */
82+
const ESCAPES: Record<string, string> = {
83+
n: "\n",
84+
t: "\t",
85+
r: "\r",
86+
"\\": "\\",
87+
"'": "'",
88+
'"': '"',
89+
"`": "`",
90+
b: "\b",
91+
f: "\f",
92+
v: "\v",
93+
"0": "\0",
94+
};
95+
7896
function literalValue(raw: string): string {
79-
// Strip the surrounding quote/backtick; leave escapes and ${...} as-is (best effort — see header).
80-
return raw.slice(1, -1);
97+
// Strip the surrounding quote/backtick, then resolve backslash escapes so the manifest holds
98+
// the actual runtime string (a literal `\n` in source must become a real newline here) — not
99+
// the raw source text, which is what a translator/machine-translation call actually needs.
100+
const inner = raw.slice(1, -1);
101+
let out = "";
102+
for (let i = 0; i < inner.length; i++) {
103+
if (inner[i] === "\\" && i + 1 < inner.length) {
104+
const next = inner[i + 1]!;
105+
out += next in ESCAPES ? ESCAPES[next] : `\\${next}`;
106+
i += 1;
107+
} else {
108+
out += inner[i];
109+
}
110+
}
111+
return out;
81112
}
82113

83114
const CALL_PATTERN = /(?<![\w$])t\(/g;

src/bridge/dashboardBridge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ export function startDashboardBridge(client: Client, configManager: ConfigManage
603603

604604
if (req.method === "GET" && url.pathname === "/bridge/languages") {
605605
const { listLanguagesForWeb } = await import("./webLanguage.js");
606-
sendJson(res, 200, { ok: true, languages: listLanguagesForWeb() });
606+
sendJson(res, 200, { ok: true, languages: await listLanguagesForWeb() });
607607
return;
608608
}
609609

src/bridge/webLanguage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import {
1616
type LanguageRecord,
1717
} from "../i18n/index.js";
1818

19-
export type LanguageOptionForWeb = { id: string; label: string };
19+
export type LanguageOptionForWeb = { id: string; label: string; flag: string };
2020

2121
export function listLanguagesForWeb(): Promise<LanguageOptionForWeb[]> {
22-
return listEnabledLanguages().then((langs) => langs.map((l) => ({ id: l.code, label: l.name })));
22+
return listEnabledLanguages().then((langs) => langs.map((l) => ({ id: l.code, label: l.name, flag: l.flag })));
2323
}
2424

2525
export async function getLanguageForWeb(discordId: string): Promise<{ locale: string }> {

src/i18n/bulkTranslate.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -61,42 +61,52 @@ export function startBulkTranslate(locale: string): BulkTranslateStatus {
6161
return status;
6262
}
6363

64-
const PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
64+
/** Matches everything that must survive machine translation byte-for-byte: a `{varName}`
65+
* placeholder, a literal backtick (Discord code-span markdown, e.g. `` `{count}` `` — left
66+
* alone, Google Translate tends to swap straight backticks/quotes for locale-specific curly
67+
* quote characters, e.g. „79") , or a real newline (multi-line fields like the edits/deletes/
68+
* reactions/attachments summary — translating the whole blob as one string is fine, but a
69+
* newline getting reflowed or dropped isn't). */
70+
const GUARDED_PATTERN = /\{(\w+)\}|`|\n/g;
6571
/** Meaningless token (not a real word in any language, so Google Translate has nothing to
66-
* translate) standing in for each `{varName}` while the surrounding text gets translated. */
72+
* translate) standing in for each guarded piece while the surrounding text gets translated. */
6773
const guardToken = (index: number) => `qxkz${index}qxkz`;
6874
const GUARD_PATTERN = /qxkz\s*(\d+)\s*qxkz/gi;
6975

70-
/** Swaps every `{varName}` for a guard token so machine translation can't mangle it, then swaps
71-
* the guard tokens back to their original `{varName}` placeholders afterward — translators
76+
type GuardedPiece = { varName: string } | "backtick" | "newline";
77+
78+
/** Swaps every placeholder/backtick/newline for a guard token so machine translation can't
79+
* mangle them, then swaps the guard tokens back to the original text afterward — translators
7280
* regularly reorder/adjust spacing around words but leave a meaningless alphanumeric token
73-
* alone, which plain `{varName}` (real words, real brackets) doesn't survive nearly as well. */
74-
function guardPlaceholders(text: string): { guarded: string; names: string[] } {
75-
const names: string[] = [];
76-
const guarded = text.replace(PLACEHOLDER_PATTERN, (_match, name: string) => {
77-
const token = guardToken(names.length);
78-
names.push(name);
81+
* alone, which real punctuation/whitespace doesn't survive nearly as well. */
82+
function guardText(text: string): { guarded: string; pieces: GuardedPiece[] } {
83+
const pieces: GuardedPiece[] = [];
84+
const guarded = text.replace(GUARDED_PATTERN, (match, name: string | undefined) => {
85+
const token = guardToken(pieces.length);
86+
pieces.push(name !== undefined ? { varName: name } : match === "`" ? "backtick" : "newline");
7987
return token;
8088
});
81-
return { guarded, names };
89+
return { guarded, pieces };
8290
}
8391

84-
function restorePlaceholders(text: string, names: string[]): string {
92+
function restoreGuardedText(text: string, pieces: GuardedPiece[]): string {
8593
return text.replace(GUARD_PATTERN, (match, indexRaw: string) => {
86-
const index = Number(indexRaw);
87-
const name = names[index];
88-
return name !== undefined ? `{${name}}` : match;
94+
const piece = pieces[Number(indexRaw)];
95+
if (piece === undefined) return match;
96+
if (piece === "backtick") return "`";
97+
if (piece === "newline") return "\n";
98+
return `{${piece.varName}}`;
8999
});
90100
}
91101

92102
async function translateOne(locale: string, englishText: string): Promise<string | null> {
93103
const trimmed = englishText.trim();
94104
if (!trimmed) return "";
95-
const { guarded, names } = guardPlaceholders(trimmed);
105+
const { guarded, pieces } = guardText(trimmed);
96106
try {
97107
const { translateText } = await import("../plugins/translation/functions/translate.js");
98108
const result = await translateText(guarded, locale, "en");
99-
return names.length ? restorePlaceholders(result.text, names) : result.text;
109+
return pieces.length ? restoreGuardedText(result.text, pieces) : result.text;
100110
} catch (error) {
101111
log.warn(`[i18n] Machine translation failed for "${locale}":`, error);
102112
return null;

src/plugins/autothreads/functions/handlers.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,21 @@ import {
1313
normalizeAutothreadRules,
1414
} from "./rules.js";
1515
import { shouldTriggerAutothreadByCadence } from "./state.js";
16+
import { translatorFor } from "../../../i18n/index.js";
1617

1718
const ALL_CHANNELS = "*";
1819
const THREAD_NAME_MAX = 100;
1920

20-
function threadNameFromRule(message: Message, nameTemplate: string): string {
21+
async function threadNameFromRule(message: Message, nameTemplate: string): Promise<string> {
2122
const rendered = renderTemplate(nameTemplate, {
2223
guild: message.guild,
2324
channel: message.channel as TextChannel,
2425
user: message.author,
2526
member: message.member as GuildMember | null,
2627
}).slice(0, THREAD_NAME_MAX);
27-
return rendered.length > 0 ? rendered : "Thread";
28+
if (rendered.length > 0) return rendered;
29+
const { t } = await translatorFor(message.author.id);
30+
return t("autothreads.defaultThreadName", "Thread");
2831
}
2932

3033
export async function handleAutothreadMessage(message: Message): Promise<void> {
@@ -79,7 +82,7 @@ export async function handleAutothreadMessage(message: Message): Promise<void> {
7982

8083
const thread = await message
8184
.startThread({
82-
name: threadNameFromRule(message, rule.thread_name),
85+
name: await threadNameFromRule(message, rule.thread_name),
8386
autoArchiveDuration: rule.auto_archive_minutes as ThreadAutoArchiveDuration,
8487
...(rule.thread_slowmode_seconds ? { rateLimitPerUser: rule.thread_slowmode_seconds } : {}),
8588
})

0 commit comments

Comments
 (0)