localewarden 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -12
- package/dist/budget.d.ts +25 -0
- package/dist/budget.js +74 -0
- package/dist/checks.d.ts +34 -6
- package/dist/checks.js +111 -21
- package/dist/cli.js +66 -10
- package/dist/config.d.ts +37 -1
- package/dist/config.js +118 -3
- package/dist/engine/context.d.ts +81 -0
- package/dist/engine/context.js +75 -0
- package/dist/engine/copies.d.ts +9 -0
- package/dist/engine/copies.js +31 -0
- package/dist/engine/planner.d.ts +66 -0
- package/dist/engine/planner.js +167 -0
- package/dist/engine/repair.d.ts +16 -0
- package/dist/engine/repair.js +61 -0
- package/dist/engine/sources.d.ts +13 -0
- package/dist/engine/sources.js +40 -0
- package/dist/engine/translator.d.ts +29 -0
- package/dist/engine/translator.js +156 -0
- package/dist/engine/writer.d.ts +13 -0
- package/dist/engine/writer.js +63 -0
- package/dist/files.d.ts +30 -0
- package/dist/files.js +125 -12
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -3
- package/dist/llm.d.ts +5 -8
- package/dist/llm.js +7 -15
- package/dist/lock.d.ts +13 -0
- package/dist/lock.js +91 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +76 -0
- package/dist/placeholders.js +2 -1
- package/dist/plugins.d.ts +72 -0
- package/dist/plugins.js +71 -0
- package/dist/project.d.ts +33 -5
- package/dist/project.js +135 -42
- package/dist/prompt.d.ts +7 -1
- package/dist/prompt.js +10 -3
- package/dist/review.d.ts +1 -1
- package/dist/review.js +49 -23
- package/dist/scope.d.ts +15 -0
- package/dist/scope.js +26 -0
- package/dist/state.d.ts +11 -4
- package/dist/state.js +30 -11
- package/dist/translate.d.ts +9 -49
- package/dist/translate.js +119 -383
- package/dist/ui/data.d.ts +50 -0
- package/dist/ui/data.js +178 -0
- package/dist/ui/page.d.ts +5 -0
- package/dist/ui/page.js +277 -0
- package/dist/ui/server.d.ts +24 -0
- package/dist/ui/server.js +194 -0
- package/dist/util.d.ts +6 -2
- package/dist/util.js +17 -5
- package/package.json +12 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { LockError } from '../lock.js';
|
|
4
|
+
import { listReview, updateReview } from '../review.js';
|
|
5
|
+
import { checkProject } from '../project.js';
|
|
6
|
+
import { loadPlugins, PluginHost } from '../plugins.js';
|
|
7
|
+
import { run } from '../translate.js';
|
|
8
|
+
import { editString, status, strings } from './data.js';
|
|
9
|
+
import { PAGE } from './page.js';
|
|
10
|
+
const MAX_BODY = 1_000_000;
|
|
11
|
+
function readBody(req) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let size = 0;
|
|
14
|
+
const chunks = [];
|
|
15
|
+
req.on('data', (chunk) => {
|
|
16
|
+
size += chunk.length;
|
|
17
|
+
if (size > MAX_BODY) {
|
|
18
|
+
reject(new Error('Request too large.'));
|
|
19
|
+
req.destroy();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
});
|
|
24
|
+
req.on('end', () => {
|
|
25
|
+
try {
|
|
26
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
reject(new Error('Body must be JSON.'));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
req.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const sameToken = (a, b) => {
|
|
36
|
+
const x = Buffer.from(a);
|
|
37
|
+
const y = Buffer.from(b);
|
|
38
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Local web interface: overview, strings with inline editing, check findings, review list,
|
|
42
|
+
* and runs with live log. Listens on 127.0.0.1 only; every request needs the random token
|
|
43
|
+
* from the printed URL (page) or the X-Localewarden-Token header (API), and the Host header
|
|
44
|
+
* must be the local address, which blocks other websites and DNS rebinding.
|
|
45
|
+
*/
|
|
46
|
+
export async function startUi(config, options = {}) {
|
|
47
|
+
const token = options.token ?? randomBytes(18).toString('base64url');
|
|
48
|
+
const plugins = new PluginHost(options.plugins ?? (await loadPlugins(config)));
|
|
49
|
+
let job = null;
|
|
50
|
+
let lastCheck = null;
|
|
51
|
+
let port = 0;
|
|
52
|
+
const send = (res, code, body, type = 'application/json') => {
|
|
53
|
+
res.writeHead(code, {
|
|
54
|
+
'Content-Type': `${type}; charset=utf-8`,
|
|
55
|
+
'Cache-Control': 'no-store',
|
|
56
|
+
'X-Content-Type-Options': 'nosniff',
|
|
57
|
+
'Referrer-Policy': 'no-referrer',
|
|
58
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
|
|
59
|
+
});
|
|
60
|
+
res.end(type === 'application/json' ? JSON.stringify(body) : String(body));
|
|
61
|
+
};
|
|
62
|
+
const startJob = (kind, work) => {
|
|
63
|
+
job = { kind, running: true, lines: [], dropped: 0, started: new Date().toISOString() };
|
|
64
|
+
const current = job;
|
|
65
|
+
const log = (line) => {
|
|
66
|
+
current.lines.push(line);
|
|
67
|
+
if (current.lines.length > 5000) {
|
|
68
|
+
const excess = current.lines.length - 5000;
|
|
69
|
+
current.lines.splice(0, excess);
|
|
70
|
+
current.dropped += excess;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
work(log)
|
|
74
|
+
.then(result => Object.assign(current, result))
|
|
75
|
+
.catch((error) => {
|
|
76
|
+
current.error = error.message;
|
|
77
|
+
})
|
|
78
|
+
.finally(() => {
|
|
79
|
+
current.running = false;
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const server = http.createServer(async (req, res) => {
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
85
|
+
const host = req.headers.host ?? '';
|
|
86
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)
|
|
87
|
+
return send(res, 403, { error: 'Wrong host.' });
|
|
88
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
89
|
+
if (!sameToken(url.searchParams.get('t') ?? '', token))
|
|
90
|
+
return send(res, 403, 'Open the URL printed by "localewarden ui" (it contains the access token).', 'text/plain');
|
|
91
|
+
return send(res, 200, PAGE, 'text/html');
|
|
92
|
+
}
|
|
93
|
+
if (!url.pathname.startsWith('/api/'))
|
|
94
|
+
return send(res, 404, { error: 'Not found.' });
|
|
95
|
+
if (!sameToken(String(req.headers['x-localewarden-token'] ?? ''), token))
|
|
96
|
+
return send(res, 403, { error: 'Missing or wrong token.' });
|
|
97
|
+
const route = `${req.method} ${url.pathname}`;
|
|
98
|
+
const q = url.searchParams;
|
|
99
|
+
switch (route) {
|
|
100
|
+
case 'GET /api/status':
|
|
101
|
+
return send(res, 200, { ...status(config, plugins), job: job && { kind: job.kind, running: job.running } });
|
|
102
|
+
case 'GET /api/strings':
|
|
103
|
+
return send(res, 200, strings(config, plugins, {
|
|
104
|
+
group: q.get('group') || undefined,
|
|
105
|
+
lang: q.get('lang') ?? '',
|
|
106
|
+
q: q.get('q') || undefined,
|
|
107
|
+
only: (q.get('only') || undefined),
|
|
108
|
+
limit: Number(q.get('limit') ?? 200),
|
|
109
|
+
}));
|
|
110
|
+
case 'POST /api/strings': {
|
|
111
|
+
if (job?.running && job.kind === 'run')
|
|
112
|
+
return send(res, 409, { error: 'A run is in progress; edit after it finished.' });
|
|
113
|
+
const body = (await readBody(req));
|
|
114
|
+
return send(res, 200, editString(config, plugins, body));
|
|
115
|
+
}
|
|
116
|
+
case 'GET /api/review':
|
|
117
|
+
return send(res, 200, listReview(config, q.get('all') === '1'));
|
|
118
|
+
case 'POST /api/review': {
|
|
119
|
+
const body = (await readBody(req));
|
|
120
|
+
if (body.action !== 'approve' && body.action !== 'release')
|
|
121
|
+
return send(res, 400, { error: 'action must be approve or release.' });
|
|
122
|
+
if (!Array.isArray(body.selectors) || !body.selectors.every(s => typeof s === 'string'))
|
|
123
|
+
return send(res, 400, { error: 'selectors must be a list.' });
|
|
124
|
+
return send(res, 200, { changed: updateReview(config, body.action, body.selectors) });
|
|
125
|
+
}
|
|
126
|
+
case 'GET /api/findings': {
|
|
127
|
+
const filter = (f) => (!q.get('lang') || f.lang === q.get('lang')) && (!q.get('check') || f.check === q.get('check')) && (!q.get('group') || f.group === q.get('group'));
|
|
128
|
+
const all = lastCheck ?? [];
|
|
129
|
+
const rows = all.filter(filter);
|
|
130
|
+
return send(res, 200, { checked: lastCheck !== null, total: rows.length, rows: rows.slice(0, Number(q.get('limit') ?? 300)) });
|
|
131
|
+
}
|
|
132
|
+
case 'POST /api/check': {
|
|
133
|
+
if (job?.running)
|
|
134
|
+
return send(res, 409, { error: 'Something is already running.' });
|
|
135
|
+
startJob('check', async (log) => {
|
|
136
|
+
log('Checking all translations…');
|
|
137
|
+
const findings = checkProject(config, { plugins: plugins.plugins });
|
|
138
|
+
lastCheck = findings;
|
|
139
|
+
log(`${findings.length} finding(s).`);
|
|
140
|
+
return { findings: [] };
|
|
141
|
+
});
|
|
142
|
+
return send(res, 202, { started: true });
|
|
143
|
+
}
|
|
144
|
+
case 'POST /api/run': {
|
|
145
|
+
if (job?.running)
|
|
146
|
+
return send(res, 409, { error: 'Something is already running.' });
|
|
147
|
+
const body = (await readBody(req));
|
|
148
|
+
const mode = body.mode ?? 'dry-run';
|
|
149
|
+
if (!['dry-run', 'translate', 'fix-flagged'].includes(mode))
|
|
150
|
+
return send(res, 400, { error: 'mode must be dry-run, translate or fix-flagged.' });
|
|
151
|
+
startJob('run', async (log) => {
|
|
152
|
+
const summary = await run(config, {
|
|
153
|
+
dryRun: mode === 'dry-run',
|
|
154
|
+
fixFlagged: mode === 'fix-flagged',
|
|
155
|
+
groups: body.groups?.length ? body.groups : undefined,
|
|
156
|
+
languages: body.languages?.length ? body.languages : undefined,
|
|
157
|
+
plugins: plugins.plugins,
|
|
158
|
+
model: options.model,
|
|
159
|
+
logger: { info: log, warn: m => log(`warning: ${m}`), error: m => log(`error: ${m}`) },
|
|
160
|
+
});
|
|
161
|
+
return { summary };
|
|
162
|
+
});
|
|
163
|
+
return send(res, 202, { started: true });
|
|
164
|
+
}
|
|
165
|
+
case 'GET /api/job':
|
|
166
|
+
if (!job)
|
|
167
|
+
return send(res, 200, null);
|
|
168
|
+
{
|
|
169
|
+
// `from` counts all lines ever logged; the oldest may have been dropped.
|
|
170
|
+
const from = Math.max(0, Number(q.get('from') ?? 0) - job.dropped);
|
|
171
|
+
return send(res, 200, { ...job, findings: undefined, lines: job.lines.slice(from), lineCount: job.dropped + job.lines.length });
|
|
172
|
+
}
|
|
173
|
+
default:
|
|
174
|
+
return send(res, 404, { error: 'Not found.' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
// A run (here or in another process) holds the lock: try again when it is done.
|
|
179
|
+
if (error instanceof LockError)
|
|
180
|
+
return send(res, 409, { error: error.message });
|
|
181
|
+
return send(res, 400, { error: error.message });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
await new Promise((resolve, reject) => {
|
|
185
|
+
server.once('error', reject);
|
|
186
|
+
server.listen(options.port ?? 4848, '127.0.0.1', () => resolve());
|
|
187
|
+
});
|
|
188
|
+
port = server.address().port;
|
|
189
|
+
return {
|
|
190
|
+
url: `http://127.0.0.1:${port}/?t=${token}`,
|
|
191
|
+
token,
|
|
192
|
+
close: () => new Promise(resolve => server.close(() => resolve())),
|
|
193
|
+
};
|
|
194
|
+
}
|
package/dist/util.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Short content hash
|
|
1
|
+
/** Short content hash. NFC-normalized, so an editor that stores "é" decomposed is no edit. */
|
|
2
2
|
export declare const hash: (value: string) => string;
|
|
3
3
|
export declare const today: () => string;
|
|
4
4
|
export declare const sleep: (ms: number) => Promise<void>;
|
|
@@ -10,5 +10,9 @@ export declare const isTraditionalChinese: (lang: string) => boolean;
|
|
|
10
10
|
/** English name of a language code ("de" -> "German", "pt-BR" -> "Brazilian Portuguese"). */
|
|
11
11
|
export declare function languageName(lang: string): string;
|
|
12
12
|
export declare function sortObject<T>(obj: Record<string, T>): Record<string, T>;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Runs `worker` over `items` with at most `concurrency` in flight. After the first error no
|
|
15
|
+
* new item starts, and the error is thrown only once every running worker has finished, so
|
|
16
|
+
* nothing keeps writing after the caller has cleaned up (released its lock).
|
|
17
|
+
*/
|
|
14
18
|
export declare function inParallel<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void>;
|
package/dist/util.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
/** Short content hash
|
|
3
|
-
export const hash = (value) => createHash('sha256').update(value.trim()).digest('hex').slice(0, 12);
|
|
2
|
+
/** Short content hash. NFC-normalized, so an editor that stores "é" decomposed is no edit. */
|
|
3
|
+
export const hash = (value) => createHash('sha256').update(value.normalize('NFC').trim()).digest('hex').slice(0, 12);
|
|
4
4
|
export const today = () => new Date().toISOString().slice(0, 10);
|
|
5
5
|
export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
6
6
|
export const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -21,14 +21,26 @@ export function languageName(lang) {
|
|
|
21
21
|
export function sortObject(obj) {
|
|
22
22
|
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Runs `worker` over `items` with at most `concurrency` in flight. After the first error no
|
|
26
|
+
* new item starts, and the error is thrown only once every running worker has finished, so
|
|
27
|
+
* nothing keeps writing after the caller has cleaned up (released its lock).
|
|
28
|
+
*/
|
|
25
29
|
export async function inParallel(items, concurrency, worker) {
|
|
26
30
|
let next = 0;
|
|
31
|
+
let failure = null;
|
|
27
32
|
const run = async () => {
|
|
28
|
-
while (next < items.length) {
|
|
33
|
+
while (!failure && next < items.length) {
|
|
29
34
|
const item = items[next++];
|
|
30
|
-
|
|
35
|
+
try {
|
|
36
|
+
await worker(item);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
failure ??= { error };
|
|
40
|
+
}
|
|
31
41
|
}
|
|
32
42
|
};
|
|
33
43
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, run));
|
|
44
|
+
if (failure)
|
|
45
|
+
throw failure.error;
|
|
34
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localewarden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Incremental AI translation for JSON locale files: translates only what changed, never overwrites hand edits, and checks every result before writing it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,17 @@
|
|
|
39
39
|
"json",
|
|
40
40
|
"locales",
|
|
41
41
|
"i18next",
|
|
42
|
+
"react-intl",
|
|
43
|
+
"vue-i18n",
|
|
44
|
+
"next-intl",
|
|
45
|
+
"fastlane",
|
|
46
|
+
"flutter",
|
|
47
|
+
"arb",
|
|
48
|
+
"app-store",
|
|
49
|
+
"ai-translation",
|
|
50
|
+
"gpt",
|
|
51
|
+
"chatgpt",
|
|
52
|
+
"translation-quality",
|
|
42
53
|
"cli"
|
|
43
54
|
],
|
|
44
55
|
"repository": {
|