-
-
Notifications
You must be signed in to change notification settings - Fork 84
Expand file tree
/
Copy pathuploadLocale.js
More file actions
231 lines (212 loc) · 9.33 KB
/
Copy pathuploadLocale.js
File metadata and controls
231 lines (212 loc) · 9.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
// Push the enUS phrase list to CurseForge before the packager runs.
//
// CurseForge holds the master phrase list that translators work against. It
// only learns about a new or changed string when someone uploads it, so a
// release could ship strings the translators had never been offered. This runs
// as a pre-build step so every packaged build is preceded by the phrase list
// that produced it.
//
// Source of truth is GSE/Localization/ModL_enUS.lua, parsed rather than
// regexed: 36 of its entries wrap across lines, one key carries an escaped
// quote, and several values contain \n. A line-based match mangles all three.
//
// AceLocale writes `L["Phrase"] = true` to mean "the value IS the key".
// CurseForge's TableAdditions format wants a real value, so `true` is expanded
// to the key itself on the way out.
const fs = require('fs');
const LOCALE_FILE = 'GSE/Localization/ModL_enUS.lua';
const TOC_FILE = 'GSE/GSE.toc';
// legacy.curseforge.com is where the upload API actually lives; the docs page
// still says wow.curseforge.com. Overridable so the request can be pointed at
// a local echo server to verify its shape.
const API = process.env.CF_API_BASE || 'https://legacy.curseforge.com/api';
// ── Lua literal parsing ─────────────────────────────────────────────────────
const ESCAPES = { n: '\n', r: '\r', t: '\t', a: '\x07', b: '\b', f: '\f', v: '\v', '\\': '\\', '"': '"', "'": "'" };
// The file is read as latin1 so one JS char == one byte, and parsed results are
// decoded back to UTF-8 at the end. Lua's \ddd escapes are BYTES: an em dash is
// written \226\128\148, three escapes that only mean "—" once recombined.
// Decoding per-escape with fromCharCode produces "â" plus two strays instead.
function decodeBytes(s) {
return Buffer.from(s, 'latin1').toString('utf8');
}
function skipSpace(src, i) {
while (i < src.length) {
if (/\s/.test(src[i])) { i++; continue; }
if (src.startsWith('--', i)) { // comment to end of line
const nl = src.indexOf('\n', i);
i = nl < 0 ? src.length : nl + 1;
continue;
}
break;
}
return i;
}
// Reads a quoted or [[long]] Lua string starting at i. Returns {value, end} or null.
function readLuaString(src, i) {
const q = src[i];
if (q === '"' || q === "'") {
let out = '';
let p = i + 1;
while (p < src.length) {
const c = src[p];
if (c === '\\') {
const e = src[p + 1];
if (e === 'x') { out += String.fromCharCode(parseInt(src.substr(p + 2, 2), 16)); p += 4; continue; }
if (/[0-9]/.test(e)) { // \ddd — a single byte
const m = /^[0-9]{1,3}/.exec(src.slice(p + 1))[0];
out += String.fromCharCode(parseInt(m, 10) & 0xff); p += 1 + m.length; continue;
}
if (e === '\r' || e === '\n') { // escaped line break
p += 2;
if (e === '\r' && src[p] === '\n') p++; // the file is CRLF
out += '\n';
continue;
}
out += ESCAPES[e] !== undefined ? ESCAPES[e] : e;
p += 2; continue;
}
if (c === q) return { value: decodeBytes(out), end: p + 1 };
out += c; p++;
}
return null; // unterminated
}
const long = /^\[(=*)\[/.exec(src.slice(i));
if (long) {
const close = ']' + long[1] + ']';
const start = i + long[0].length;
const end = src.indexOf(close, start);
if (end < 0) return null;
// A long string starting with a newline drops it, per Lua.
let value = src.slice(start, end);
if (value.startsWith('\r\n')) value = value.slice(2);
else if (value.startsWith('\n')) value = value.slice(1);
// Lua's lexer folds any end-of-line sequence inside a long string to a
// single newline. Without this the CRLF checkout leaks a stray CR into
// every line of the multi-line phrases.
value = value.replace(/\r\n?/g, '\n');
return { value: decodeBytes(value), end: end + close.length };
}
return null;
}
// Walks `L[<string>] = <string|true>` statements.
function parseAceLocale(src) {
const entries = [];
let i = 0;
for (;;) {
const at = src.indexOf('L[', i);
if (at < 0) break;
// Must be a statement, not a substring of another identifier (e.g. VAL[).
const prev = at > 0 ? src[at - 1] : '\n';
if (/[A-Za-z0-9_.]/.test(prev)) { i = at + 2; continue; }
let p = skipSpace(src, at + 2);
const key = readLuaString(src, p);
if (!key) { i = at + 2; continue; }
p = skipSpace(src, key.end);
if (src[p] !== ']') { i = at + 2; continue; }
p = skipSpace(src, p + 1);
if (src[p] !== '=') { i = at + 2; continue; }
p = skipSpace(src, p + 1);
if (src.startsWith('true', p)) {
entries.push([key.value, key.value]); // AceLocale shorthand
i = p + 4;
continue;
}
const val = readLuaString(src, p);
if (!val) { i = at + 2; continue; }
entries.push([key.value, val.value]);
i = val.end;
}
return entries;
}
function luaQuote(s) {
return '"' + String(s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"';
}
function projectIdFromToc() {
const toc = fs.readFileSync(TOC_FILE, 'utf8');
const m = /##\s*X-Curse-Project-ID:\s*(\d+)/i.exec(toc);
return m ? m[1] : null;
}
async function main() {
// --dry-run parses and builds the payload, prints what would be sent, and
// stops. Lets the phrase list be checked without a key and without touching
// the live project.
const dryRun = process.argv.includes('--dry-run');
const token = process.env.CF_API_KEY;
if (!token && !dryRun) {
console.log('[locale] CF_API_KEY not set, skipping');
return;
}
const projectId = process.env.CF_PROJECT_ID || projectIdFromToc();
if (!projectId) {
console.error(`[locale] no X-Curse-Project-ID in ${TOC_FILE} and CF_PROJECT_ID unset`);
process.exitCode = 1;
return;
}
const src = fs.readFileSync(LOCALE_FILE, 'latin1'); // byte-wise; see decodeBytes
const entries = parseAceLocale(src);
if (!entries.length) {
console.error(`[locale] parsed 0 phrases from ${LOCALE_FILE} — refusing to upload an empty list`);
process.exitCode = 1;
return;
}
const seen = new Map();
for (const [k, v] of entries) seen.set(k, v); // last wins, as Lua would
// Sanity floor, and it earns its keep because the upload deletes phrases the
// payload omits. Count `L[` statement starts with a crude scan that shares no
// logic with the parser above: if the parser ever regresses — a quoting style
// it does not understand, a refactor of the locale file — the two numbers
// diverge and we stop, rather than quietly telling CurseForge that the addon
// has three phrases and to delete the rest.
const rawStarts = (src.match(/(^|[^A-Za-z0-9_.])L\[/g) || []).length;
if (rawStarts && seen.size < rawStarts * 0.5) {
console.error(`[locale] parsed only ${seen.size} phrases from ~${rawStarts} candidates in `
+ `${LOCALE_FILE} — refusing to upload a list this incomplete`);
process.exitCode = 1;
return;
}
const blob = [...seen.entries()].map(([k, v]) => `L[${luaQuote(k)}] = ${luaQuote(v)}`).join('\n');
// DeletePhrase: the addon's enUS file is the master list, so a phrase that is
// no longer in it should not linger on CurseForge collecting translations for
// a string nothing displays. That makes this upload DESTRUCTIVE — anything
// absent from `blob` is removed over there, translations included — which is
// why the sanity check above exists. Override per-run with
// CF_LOCALE_MISSING_HANDLING=DoNothing.
const missingHandling = process.env.CF_LOCALE_MISSING_HANDLING || 'DeletePhrase';
// multipart/form-data with two named parts, NOT the raw JSON body the docs
// describe — posting JSON returns HTTP 500 "unhandled exception" from their
// side (observed 2026-08-21). metadata rides as a JSON *string* part.
// formatType is left off entirely: TableAdditions is the default and the
// known-good client omits it.
const metadata = { language: 'enUS', 'missing-phrase-handling': missingHandling };
const form = new FormData();
form.append('metadata', JSON.stringify(metadata));
form.append('localizations', blob);
if (dryRun) {
console.log(`[locale] DRY RUN — would POST ${blob.length} bytes to project ${projectId}`);
console.log('[locale] metadata:', metadata);
console.log(`[locale] ${seen.size} phrases; first three lines:`);
for (const line of blob.split('\n').slice(0, 3)) console.log(' ' + line.slice(0, 120));
return;
}
console.log(`[locale] uploading ${seen.size} enUS phrases to project ${projectId} (${missingHandling})`);
// No explicit Content-Type — fetch sets multipart/form-data with the boundary.
const res = await fetch(`${API}/projects/${projectId}/localization/import`, {
method: 'POST',
headers: { 'X-Api-Token': token },
body: form,
});
const text = await res.text().catch(() => '');
if (!res.ok) {
console.error(`[locale] upload failed: HTTP ${res.status} ${text.slice(0, 300)}`);
process.exitCode = 1;
return;
}
console.log(`[locale] uploaded ${seen.size} phrases${text ? ' — ' + text.slice(0, 200) : ''}`);
}
if (require.main === module) main();
module.exports = { parseAceLocale, luaQuote };