localewarden 0.2.0 → 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 +108 -4
- package/dist/budget.d.ts +25 -0
- package/dist/budget.js +74 -0
- package/dist/checks.d.ts +15 -4
- package/dist/checks.js +17 -3
- package/dist/cli.js +44 -9
- package/dist/config.d.ts +27 -1
- package/dist/config.js +105 -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 +9 -0
- package/dist/files.js +76 -14
- 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/plugins.d.ts +72 -0
- package/dist/plugins.js +71 -0
- package/dist/project.d.ts +18 -5
- package/dist/project.js +70 -57
- package/dist/prompt.d.ts +3 -1
- package/dist/prompt.js +2 -2
- package/dist/review.d.ts +1 -1
- package/dist/review.js +41 -35
- package/dist/state.d.ts +10 -3
- package/dist/state.js +30 -11
- package/dist/translate.d.ts +9 -49
- package/dist/translate.js +118 -429
- 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 +1 -1
package/dist/config.js
CHANGED
|
@@ -20,7 +20,12 @@ export const DEFAULTS = {
|
|
|
20
20
|
concurrency: 4,
|
|
21
21
|
batchSize: 20,
|
|
22
22
|
stateDir: '.localewarden',
|
|
23
|
+
copies: {},
|
|
24
|
+
chunkChars: 8000,
|
|
25
|
+
plugins: [],
|
|
23
26
|
};
|
|
27
|
+
/** Settings that apply to the whole run and cannot differ per group. */
|
|
28
|
+
const GLOBAL_ONLY = ['groups', 'stateDir', 'plugins', 'dailyTokenBudget', 'maxTokensPerRun', 'concurrency'];
|
|
24
29
|
export class ConfigError extends Error {
|
|
25
30
|
}
|
|
26
31
|
const isStringRecord = (value) => Boolean(value) &&
|
|
@@ -32,7 +37,88 @@ export function resolveConfig(raw, root) {
|
|
|
32
37
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
33
38
|
throw new ConfigError('The config must be a JSON object.');
|
|
34
39
|
}
|
|
35
|
-
const
|
|
40
|
+
const { groups, ...rest } = raw;
|
|
41
|
+
if (groups === undefined)
|
|
42
|
+
return resolveSingle(rest, root);
|
|
43
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
44
|
+
throw new ConfigError('"groups" must be a non-empty list of { "name", "files", ... } objects.');
|
|
45
|
+
}
|
|
46
|
+
const names = new Set();
|
|
47
|
+
const resolved = groups.map((group, i) => {
|
|
48
|
+
if (!group || typeof group !== 'object' || Array.isArray(group))
|
|
49
|
+
throw new ConfigError(`groups[${i}] must be an object.`);
|
|
50
|
+
const g = group;
|
|
51
|
+
if (typeof g.name !== 'string' || g.name === '')
|
|
52
|
+
throw new ConfigError(`groups[${i}] needs a "name".`);
|
|
53
|
+
if (names.has(g.name))
|
|
54
|
+
throw new ConfigError(`Group name "${g.name}" is used twice.`);
|
|
55
|
+
names.add(g.name);
|
|
56
|
+
const global = Object.keys(g).filter(key => GLOBAL_ONLY.includes(key));
|
|
57
|
+
if (global.length > 0)
|
|
58
|
+
throw new ConfigError(`groups[${i}] ("${g.name}"): ${global.join(', ')} can only be set at the top level.`);
|
|
59
|
+
try {
|
|
60
|
+
return resolveSingle(mergeGroup(rest, g), root);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new ConfigError(`Group "${g.name}": ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
return { ...resolved[0], name: undefined, groups: resolved };
|
|
67
|
+
}
|
|
68
|
+
/** Settings a group merges with the top level instead of replacing. */
|
|
69
|
+
const MERGED_OBJECTS = ['formality', 'termNotes', 'instructions', 'maxLength'];
|
|
70
|
+
const MERGED_LISTS = ['doNotTranslate', 'ignoreKeys', 'exclude'];
|
|
71
|
+
/**
|
|
72
|
+
* A group's settings on top of the top-level ones: maps (formality, glossary per language,
|
|
73
|
+
* termNotes, instructions, maxLength) are merged, lists (doNotTranslate, ignoreKeys, exclude)
|
|
74
|
+
* extended, everything else replaced. `copies` belong to the locales they name: a group with
|
|
75
|
+
* its own languages does not inherit them.
|
|
76
|
+
*/
|
|
77
|
+
function mergeGroup(base, group) {
|
|
78
|
+
const merged = { ...base, ...group };
|
|
79
|
+
const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
80
|
+
for (const key of MERGED_OBJECTS) {
|
|
81
|
+
if (key in group && key in base)
|
|
82
|
+
merged[key] = { ...asObject(base[key]), ...asObject(group[key]) };
|
|
83
|
+
}
|
|
84
|
+
if ('glossary' in group && 'glossary' in base) {
|
|
85
|
+
const glossary = { ...asObject(base.glossary) };
|
|
86
|
+
for (const [lang, terms] of Object.entries(asObject(group.glossary)))
|
|
87
|
+
glossary[lang] = { ...asObject(glossary[lang]), ...asObject(terms) };
|
|
88
|
+
merged.glossary = glossary;
|
|
89
|
+
}
|
|
90
|
+
for (const key of MERGED_LISTS) {
|
|
91
|
+
if (Array.isArray(group[key]) && Array.isArray(base[key]))
|
|
92
|
+
merged[key] = [...new Set([...base[key], ...group[key]])];
|
|
93
|
+
}
|
|
94
|
+
if (!('copies' in group) && ('targetLanguages' in group || 'sourceLanguage' in group))
|
|
95
|
+
merged.copies = {};
|
|
96
|
+
return merged;
|
|
97
|
+
}
|
|
98
|
+
/** The groups of a config, or the config itself as its only group. */
|
|
99
|
+
export const groupsOf = (config) => config.groups ?? [config];
|
|
100
|
+
/**
|
|
101
|
+
* The groups to work on, in config order, limited by name; unknown group or language names
|
|
102
|
+
* are an error (a typo must not turn into "nothing to do" or a green check).
|
|
103
|
+
*/
|
|
104
|
+
export function selectGroups(config, names, languages) {
|
|
105
|
+
const groups = groupsOf(config);
|
|
106
|
+
let selected = groups;
|
|
107
|
+
if (names?.length) {
|
|
108
|
+
const unknown = names.filter(name => !groups.some(g => g.name === name));
|
|
109
|
+
if (unknown.length > 0) {
|
|
110
|
+
throw new ConfigError(`Unknown group(s): ${unknown.join(', ')}. Groups: ${groups.map(g => g.name ?? '(none)').join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
selected = groups.filter(g => g.name !== undefined && names.includes(g.name));
|
|
113
|
+
}
|
|
114
|
+
const targets = new Set(selected.flatMap(g => g.targetLanguages));
|
|
115
|
+
const unknownLanguages = (languages ?? []).filter(lang => !targets.has(lang));
|
|
116
|
+
if (unknownLanguages.length > 0) {
|
|
117
|
+
throw new ConfigError(`Not in targetLanguages: ${unknownLanguages.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
return selected;
|
|
120
|
+
}
|
|
121
|
+
function resolveSingle(input, root) {
|
|
36
122
|
const config = { ...DEFAULTS, ...input, root };
|
|
37
123
|
const fail = (message) => {
|
|
38
124
|
throw new ConfigError(message);
|
|
@@ -94,7 +180,23 @@ export function resolveConfig(raw, root) {
|
|
|
94
180
|
}
|
|
95
181
|
}
|
|
96
182
|
}
|
|
97
|
-
|
|
183
|
+
if (!isStringRecord(config.copies))
|
|
184
|
+
fail('"copies" must map locales to the locale they copy, e.g. { "en-GB": "en-US" }.');
|
|
185
|
+
for (const [target, from] of Object.entries(config.copies)) {
|
|
186
|
+
if (!isLanguageCode(target))
|
|
187
|
+
fail(`"copies": "${target}" is not a language code.`);
|
|
188
|
+
if (config.targetLanguages.includes(target))
|
|
189
|
+
fail(`"copies": "${target}" is also in targetLanguages.`);
|
|
190
|
+
if (from !== config.sourceLanguage && !config.targetLanguages.includes(from)) {
|
|
191
|
+
fail(`"copies.${target}": "${from}" must be the source language or one of targetLanguages.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(config.plugins))
|
|
195
|
+
fail('"plugins" must be a list of module paths.');
|
|
196
|
+
if (config.dailyTokenBudget !== undefined && !(Number.isFinite(config.dailyTokenBudget) && config.dailyTokenBudget > 0)) {
|
|
197
|
+
fail('"dailyTokenBudget" must be a positive number.');
|
|
198
|
+
}
|
|
199
|
+
for (const key of ['maxTokensPerRun', 'concurrency', 'batchSize', 'chunkChars']) {
|
|
98
200
|
if (!Number.isFinite(config[key]) || config[key] < 1)
|
|
99
201
|
fail(`"${key}" must be a positive number.`);
|
|
100
202
|
}
|
|
@@ -102,7 +204,7 @@ export function resolveConfig(raw, root) {
|
|
|
102
204
|
if (typeof config[key] !== 'string' || config[key] === '')
|
|
103
205
|
fail(`"${key}" must be a string.`);
|
|
104
206
|
}
|
|
105
|
-
const known = new Set([...Object.keys(DEFAULTS), 'targetLanguages', 'files', 'context', 'tone', 'temperature', 'reasoningEffort', 'placeholders', '$schema']);
|
|
207
|
+
const known = new Set([...Object.keys(DEFAULTS), 'targetLanguages', 'files', 'context', 'tone', 'temperature', 'reasoningEffort', 'placeholders', 'dailyTokenBudget', 'name', '$schema']);
|
|
106
208
|
const unknown = Object.keys(input).filter(key => !known.has(key));
|
|
107
209
|
if (unknown.length > 0)
|
|
108
210
|
fail(`Unknown config option(s): ${unknown.join(', ')}`);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Budget, BudgetExceededError } from '../budget.js';
|
|
2
|
+
import { type Model } from '../llm.js';
|
|
3
|
+
import type { Plugin } from '../plugins.js';
|
|
4
|
+
import { PluginHost } from '../plugins.js';
|
|
5
|
+
import type { State } from '../state.js';
|
|
6
|
+
export interface Logger {
|
|
7
|
+
info(message: string): void;
|
|
8
|
+
warn(message: string): void;
|
|
9
|
+
error(message: string): void;
|
|
10
|
+
debug?(message: string): void;
|
|
11
|
+
}
|
|
12
|
+
export declare const consoleLogger: Logger;
|
|
13
|
+
export interface RunOptions {
|
|
14
|
+
/** Subset of the configured target languages. */
|
|
15
|
+
languages?: string[];
|
|
16
|
+
/** Only these groups (by name); default all, in config order. */
|
|
17
|
+
groups?: string[];
|
|
18
|
+
/** Show what would be translated; no API calls, no writes. */
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
/** Re-translate every string, not only new and changed ones. Hand edits stay protected. */
|
|
21
|
+
retranslateAll?: boolean;
|
|
22
|
+
/** Re-translate every string of files matching these path patterns (hand edits stay protected). */
|
|
23
|
+
retranslateFiles?: string[];
|
|
24
|
+
/** Re-translate strings localewarden last wrote before this day (YYYY-MM-DD), or adopted. */
|
|
25
|
+
refreshBefore?: string;
|
|
26
|
+
/** Also replace hand-edited translations. */
|
|
27
|
+
overwriteManual?: boolean;
|
|
28
|
+
/** Ask the model to fix strings the quality check flags, changing as little as possible. */
|
|
29
|
+
fixFlagged?: boolean;
|
|
30
|
+
/** Overrides maxTokensPerRun from the config. */
|
|
31
|
+
maxTokens?: number;
|
|
32
|
+
logger?: Logger;
|
|
33
|
+
/** Model to use instead of the configured OpenAI-compatible endpoint (tests, other SDKs). */
|
|
34
|
+
model?: Model;
|
|
35
|
+
/** Plugins to use instead of loading the ones listed in the config. */
|
|
36
|
+
plugins?: Plugin[];
|
|
37
|
+
}
|
|
38
|
+
export interface LanguageSummary {
|
|
39
|
+
translated: number;
|
|
40
|
+
revised: number;
|
|
41
|
+
repaired: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
protected: number;
|
|
44
|
+
removed: number;
|
|
45
|
+
planned: number;
|
|
46
|
+
plannedChars: number;
|
|
47
|
+
}
|
|
48
|
+
export interface RunSummary {
|
|
49
|
+
languages: Record<string, LanguageSummary>;
|
|
50
|
+
filesWritten: string[];
|
|
51
|
+
/** Copies written (see `copies` in the config). */
|
|
52
|
+
filesCopied: string[];
|
|
53
|
+
tokens: number;
|
|
54
|
+
requests: number;
|
|
55
|
+
stoppedByBudget: boolean;
|
|
56
|
+
/** Why the run stopped early, when it did. */
|
|
57
|
+
stopReason?: string;
|
|
58
|
+
pendingReview: number;
|
|
59
|
+
dryRun: boolean;
|
|
60
|
+
}
|
|
61
|
+
export declare const emptyLanguageSummary: () => LanguageSummary;
|
|
62
|
+
/** What every part of a run shares: options, state, budget, plugins and the summary. */
|
|
63
|
+
export declare class RunContext {
|
|
64
|
+
readonly options: RunOptions;
|
|
65
|
+
readonly log: Logger;
|
|
66
|
+
readonly state: State;
|
|
67
|
+
readonly budget: Budget;
|
|
68
|
+
readonly plugins: PluginHost;
|
|
69
|
+
readonly summary: RunSummary;
|
|
70
|
+
private fatal;
|
|
71
|
+
constructor(options: RunOptions, log: Logger, state: State, budget: Budget, plugins: PluginHost);
|
|
72
|
+
get dryRun(): boolean;
|
|
73
|
+
counts(lang: string): LanguageSummary;
|
|
74
|
+
/** True once the budget is used up or the API refused the requests for good. */
|
|
75
|
+
get stopped(): boolean;
|
|
76
|
+
/** Records an API error: budget and fatal errors stop the run, others are logged. */
|
|
77
|
+
handleError(error: unknown, lang: string): void;
|
|
78
|
+
/** Throws the fatal API error, if there was one. */
|
|
79
|
+
rethrow(): void;
|
|
80
|
+
}
|
|
81
|
+
export { BudgetExceededError };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BudgetExceededError } from '../budget.js';
|
|
2
|
+
import { FatalModelError } from '../llm.js';
|
|
3
|
+
export const consoleLogger = {
|
|
4
|
+
info: message => console.log(message),
|
|
5
|
+
warn: message => console.warn(`warning: ${message}`),
|
|
6
|
+
error: message => console.error(`error: ${message}`),
|
|
7
|
+
};
|
|
8
|
+
export const emptyLanguageSummary = () => ({
|
|
9
|
+
translated: 0,
|
|
10
|
+
revised: 0,
|
|
11
|
+
repaired: 0,
|
|
12
|
+
failed: 0,
|
|
13
|
+
protected: 0,
|
|
14
|
+
removed: 0,
|
|
15
|
+
planned: 0,
|
|
16
|
+
plannedChars: 0,
|
|
17
|
+
});
|
|
18
|
+
/** What every part of a run shares: options, state, budget, plugins and the summary. */
|
|
19
|
+
export class RunContext {
|
|
20
|
+
options;
|
|
21
|
+
log;
|
|
22
|
+
state;
|
|
23
|
+
budget;
|
|
24
|
+
plugins;
|
|
25
|
+
summary;
|
|
26
|
+
fatal = null;
|
|
27
|
+
constructor(options, log, state, budget, plugins) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.log = log;
|
|
30
|
+
this.state = state;
|
|
31
|
+
this.budget = budget;
|
|
32
|
+
this.plugins = plugins;
|
|
33
|
+
this.summary = {
|
|
34
|
+
languages: {},
|
|
35
|
+
filesWritten: [],
|
|
36
|
+
filesCopied: [],
|
|
37
|
+
tokens: 0,
|
|
38
|
+
requests: 0,
|
|
39
|
+
stoppedByBudget: false,
|
|
40
|
+
pendingReview: 0,
|
|
41
|
+
dryRun: Boolean(options.dryRun),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
get dryRun() {
|
|
45
|
+
return this.summary.dryRun;
|
|
46
|
+
}
|
|
47
|
+
counts(lang) {
|
|
48
|
+
return (this.summary.languages[lang] ??= emptyLanguageSummary());
|
|
49
|
+
}
|
|
50
|
+
/** True once the budget is used up or the API refused the requests for good. */
|
|
51
|
+
get stopped() {
|
|
52
|
+
return this.fatal !== null || this.summary.stoppedByBudget;
|
|
53
|
+
}
|
|
54
|
+
/** Records an API error: budget and fatal errors stop the run, others are logged. */
|
|
55
|
+
handleError(error, lang) {
|
|
56
|
+
if (error instanceof BudgetExceededError) {
|
|
57
|
+
if (!this.summary.stoppedByBudget)
|
|
58
|
+
this.log.warn(`${error.message}; stopping. Run again to continue.`);
|
|
59
|
+
this.summary.stoppedByBudget = true;
|
|
60
|
+
this.summary.stopReason = error.message;
|
|
61
|
+
}
|
|
62
|
+
else if (error instanceof FatalModelError) {
|
|
63
|
+
this.fatal ??= error;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this.log.warn(`[${lang}] ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Throws the fatal API error, if there was one. */
|
|
70
|
+
rethrow() {
|
|
71
|
+
if (this.fatal)
|
|
72
|
+
throw this.fatal;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export { BudgetExceededError };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { type LocaleFile } from '../files.js';
|
|
3
|
+
import type { RunContext } from './context.js';
|
|
4
|
+
/**
|
|
5
|
+
* Writes the locales that are copies of another one (`copies` in the config), e.g. store
|
|
6
|
+
* listings where en-GB is the en-US text. Runs after translating, so copies of target
|
|
7
|
+
* languages get the new translations. A copy is only written when its content differs.
|
|
8
|
+
*/
|
|
9
|
+
export declare function applyCopies(ctx: RunContext, config: Config, files: LocaleFile[]): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readText, writeText } from '../files.js';
|
|
3
|
+
/**
|
|
4
|
+
* Writes the locales that are copies of another one (`copies` in the config), e.g. store
|
|
5
|
+
* listings where en-GB is the en-US text. Runs after translating, so copies of target
|
|
6
|
+
* languages get the new translations. A copy is only written when its content differs.
|
|
7
|
+
*/
|
|
8
|
+
export function applyCopies(ctx, config, files) {
|
|
9
|
+
const only = ctx.options.languages;
|
|
10
|
+
for (const [target, from] of Object.entries(config.copies)) {
|
|
11
|
+
// With --lang, only copies of (or into) the selected languages.
|
|
12
|
+
if (only?.length && !only.includes(target) && !only.includes(from))
|
|
13
|
+
continue;
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const fromRel = file.pathFor(from);
|
|
16
|
+
const toRel = file.pathFor(target);
|
|
17
|
+
const toFile = path.join(config.root, toRel);
|
|
18
|
+
if (!path.resolve(toFile).startsWith(path.resolve(config.root) + path.sep))
|
|
19
|
+
continue;
|
|
20
|
+
const text = readText(path.join(config.root, fromRel));
|
|
21
|
+
if (text === null || readText(toFile) === text)
|
|
22
|
+
continue;
|
|
23
|
+
if (ctx.dryRun) {
|
|
24
|
+
ctx.log.info(`[${target}] ${toRel}: would copy from ${from}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
writeText(toFile, text);
|
|
28
|
+
ctx.summary.filesCopied.push(toRel);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Checker, Issue } from '../checks.js';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
import { type JsonValue, type Leaf, type LocaleFile } from '../files.js';
|
|
4
|
+
import type { PromptItem } from '../prompt.js';
|
|
5
|
+
import type { Scope } from '../scope.js';
|
|
6
|
+
import type { LanguageSummary, RunContext } from './context.js';
|
|
7
|
+
/** A parsed source-language file. */
|
|
8
|
+
export interface SourceFile {
|
|
9
|
+
file: LocaleFile;
|
|
10
|
+
doc: JsonValue;
|
|
11
|
+
text: string;
|
|
12
|
+
leaves: Leaf[];
|
|
13
|
+
}
|
|
14
|
+
export interface Repair {
|
|
15
|
+
key: string;
|
|
16
|
+
source: string;
|
|
17
|
+
issues: Issue[];
|
|
18
|
+
}
|
|
19
|
+
/** What to do with one file in one language. */
|
|
20
|
+
export interface Plan {
|
|
21
|
+
file: LocaleFile;
|
|
22
|
+
lang: string;
|
|
23
|
+
counts: LanguageSummary;
|
|
24
|
+
sourceDoc: JsonValue;
|
|
25
|
+
sourceText: string;
|
|
26
|
+
targetRel: string;
|
|
27
|
+
targetFile: string;
|
|
28
|
+
targetText: string | null;
|
|
29
|
+
targetDoc: JsonValue | null;
|
|
30
|
+
/** Current values by key, updated as translations come in. */
|
|
31
|
+
values: Map<string, string>;
|
|
32
|
+
/** Strings to translate (new) or revise (source changed). */
|
|
33
|
+
work: PromptItem[];
|
|
34
|
+
repairs: Repair[];
|
|
35
|
+
removed: string[];
|
|
36
|
+
pluralExtras: Leaf[];
|
|
37
|
+
/**
|
|
38
|
+
* Translations taken into `values` but not yet recorded in the state. They are recorded
|
|
39
|
+
* only after the file was written, so a failed write is retried, not taken for a hand edit.
|
|
40
|
+
*/
|
|
41
|
+
pending: {
|
|
42
|
+
key: string;
|
|
43
|
+
source: string;
|
|
44
|
+
value: string;
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
/** Everything the planner needs about the group being translated. */
|
|
48
|
+
export interface GroupTools {
|
|
49
|
+
config: Config;
|
|
50
|
+
scope: Scope;
|
|
51
|
+
checker: Checker;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Decides, per string, whether to translate, revise, repair, adopt or protect it:
|
|
55
|
+
*
|
|
56
|
+
* no translation yet -> translate
|
|
57
|
+
* translation exists, never seen -> adopt (unless it is the source copied over)
|
|
58
|
+
* source changed since the last write -> revise from the existing translation
|
|
59
|
+
* value differs from what we wrote -> hand edit: protect, list for review
|
|
60
|
+
* --retranslate-all / --retranslate-files -> translate again (hand edits stay protected)
|
|
61
|
+
* --refresh-before DATE, written earlier -> translate again
|
|
62
|
+
* --fix-flagged, check finds a problem -> targeted repair (or revise if content is missing)
|
|
63
|
+
*
|
|
64
|
+
* Returns null when the target file cannot be used (outside the project, invalid JSON).
|
|
65
|
+
*/
|
|
66
|
+
export declare function planFile(ctx: RunContext, group: GroupTools, source: SourceFile, lang: string): Plan | null;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isFixable, isUnchangedProse, muchShorter } from '../checks.js';
|
|
3
|
+
import { flatten, isArbMetadata, missingPluralLeaves, parseDoc, pathPattern, readText, } from '../files.js';
|
|
4
|
+
import { reviewId } from '../state.js';
|
|
5
|
+
import { hash } from '../util.js';
|
|
6
|
+
const matchesAny = (patterns, file, sourceLanguage) => patterns.some(re => re.test(file.id) || re.test(file.pathFor(sourceLanguage)));
|
|
7
|
+
/**
|
|
8
|
+
* Decides, per string, whether to translate, revise, repair, adopt or protect it:
|
|
9
|
+
*
|
|
10
|
+
* no translation yet -> translate
|
|
11
|
+
* translation exists, never seen -> adopt (unless it is the source copied over)
|
|
12
|
+
* source changed since the last write -> revise from the existing translation
|
|
13
|
+
* value differs from what we wrote -> hand edit: protect, list for review
|
|
14
|
+
* --retranslate-all / --retranslate-files -> translate again (hand edits stay protected)
|
|
15
|
+
* --refresh-before DATE, written earlier -> translate again
|
|
16
|
+
* --fix-flagged, check finds a problem -> targeted repair (or revise if content is missing)
|
|
17
|
+
*
|
|
18
|
+
* Returns null when the target file cannot be used (outside the project, invalid JSON).
|
|
19
|
+
*/
|
|
20
|
+
export function planFile(ctx, group, source, lang) {
|
|
21
|
+
const { config, scope, checker } = group;
|
|
22
|
+
const { state, options, log } = ctx;
|
|
23
|
+
const { file } = source;
|
|
24
|
+
const counts = ctx.counts(lang);
|
|
25
|
+
const pluralExtras = missingPluralLeaves(source.leaves, lang);
|
|
26
|
+
const leaves = [...source.leaves, ...pluralExtras];
|
|
27
|
+
const sourceKeys = new Set(leaves.map(leaf => leaf.key));
|
|
28
|
+
const targetRel = file.pathFor(lang);
|
|
29
|
+
const targetFile = path.join(config.root, targetRel);
|
|
30
|
+
if (!path.resolve(targetFile).startsWith(path.resolve(config.root) + path.sep)) {
|
|
31
|
+
log.error(`${targetRel} is outside the project; skipped.`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const targetText = readText(targetFile);
|
|
35
|
+
let targetDoc = null;
|
|
36
|
+
if (targetText !== null) {
|
|
37
|
+
try {
|
|
38
|
+
targetDoc = parseDoc(targetRel, targetText);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
log.error(`${targetRel} is not valid JSON (${error.message}); skipped. Fix it by hand.`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const current = targetDoc === null ? new Map() : flatten(targetDoc);
|
|
46
|
+
const values = new Map(current);
|
|
47
|
+
const fresh = [];
|
|
48
|
+
const revise = [];
|
|
49
|
+
const repairs = [];
|
|
50
|
+
const forceFile = matchesAny((options.retranslateFiles ?? []).map(pathPattern), file, config.sourceLanguage);
|
|
51
|
+
const item = (key, sourceText, extra = {}) => ({
|
|
52
|
+
key,
|
|
53
|
+
source: sourceText,
|
|
54
|
+
maxLength: scope.maxLength(key),
|
|
55
|
+
file: file.id,
|
|
56
|
+
...extra,
|
|
57
|
+
});
|
|
58
|
+
for (const { key, value: src } of leaves) {
|
|
59
|
+
const cur = current.get(key);
|
|
60
|
+
const entry = state.get(lang, file.id, key);
|
|
61
|
+
const sourceHash = hash(src);
|
|
62
|
+
if (src.trim() === '') {
|
|
63
|
+
values.set(key, src);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (isArbMetadata(targetRel, key)) {
|
|
67
|
+
// Flutter ARB: "@@locale" names the target language; other metadata is copied.
|
|
68
|
+
values.set(key, key === '@@locale' ? lang : cur ?? src);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (scope.isLiteral(key, src)) {
|
|
72
|
+
// Not text: keep a localized value someone set (e.g. a /de/ URL), else copy the source.
|
|
73
|
+
if (cur === undefined || cur.trim() === '')
|
|
74
|
+
values.set(key, src);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const hasValue = cur !== undefined && cur.trim() !== '';
|
|
78
|
+
if (hasValue) {
|
|
79
|
+
const id = reviewId(lang, file.id, key);
|
|
80
|
+
const review = state.review[id];
|
|
81
|
+
const curHash = hash(cur);
|
|
82
|
+
if (options.overwriteManual && (review || (entry && entry.value !== curHash))) {
|
|
83
|
+
delete state.review[id];
|
|
84
|
+
fresh.push(item(key, src));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (review) {
|
|
88
|
+
// Counted only when something new needs a person's attention, not on every run.
|
|
89
|
+
if (review.status === 'approved' && review.valueHash !== curHash) {
|
|
90
|
+
state.addReview(lang, file.id, key, 'edited-after-approval', cur);
|
|
91
|
+
counts.protected++;
|
|
92
|
+
}
|
|
93
|
+
else if (entry && entry.source !== sourceHash) {
|
|
94
|
+
state.addReview(lang, file.id, key, 'source-changed', cur);
|
|
95
|
+
log.warn(`[${lang}] ${key}: source changed, but the translation was edited by hand; kept and listed for review`);
|
|
96
|
+
counts.protected++;
|
|
97
|
+
}
|
|
98
|
+
state.set(lang, file.id, key, src, cur, false);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (entry && entry.value !== curHash) {
|
|
102
|
+
const sourceChanged = entry.source !== sourceHash;
|
|
103
|
+
state.addReview(lang, file.id, key, sourceChanged ? 'source-changed' : 'manual-edit', cur);
|
|
104
|
+
state.set(lang, file.id, key, src, cur, false);
|
|
105
|
+
log.info(`[${lang}] ${key}: edited by hand; protected and listed for review`);
|
|
106
|
+
counts.protected++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const stale = options.refreshBefore !== undefined && hasValue && (!entry || entry.date === '' || entry.date < options.refreshBefore);
|
|
111
|
+
if (!hasValue || options.retranslateAll || forceFile || stale) {
|
|
112
|
+
fresh.push(item(key, src));
|
|
113
|
+
}
|
|
114
|
+
else if (!entry) {
|
|
115
|
+
// First time localewarden sees this string: adopt an existing translation, unless
|
|
116
|
+
// it is just the source text copied over.
|
|
117
|
+
if (isUnchangedProse(src, cur, checker.placeholderRe))
|
|
118
|
+
fresh.push(item(key, src));
|
|
119
|
+
else
|
|
120
|
+
state.set(lang, file.id, key, src, cur, false);
|
|
121
|
+
}
|
|
122
|
+
else if (entry.source !== sourceHash) {
|
|
123
|
+
revise.push(item(key, src, { previous: cur }));
|
|
124
|
+
}
|
|
125
|
+
else if (options.fixFlagged) {
|
|
126
|
+
const failed = state.repairFailures[reviewId(lang, file.id, key)];
|
|
127
|
+
if (failed?.valueHash === hash(cur))
|
|
128
|
+
continue; // this value could not be fixed before
|
|
129
|
+
// Missing content needs more than a minimal correction: revise from the existing text.
|
|
130
|
+
if (muchShorter(lang, src, cur)) {
|
|
131
|
+
revise.push(item(key, src, { previous: cur, completing: true }));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const issues = checker.checkString(lang, key, src, cur, file.id).filter(isFixable);
|
|
135
|
+
if (issues.length > 0)
|
|
136
|
+
repairs.push({ key, source: src, issues });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Strings removed from the source.
|
|
141
|
+
const removed = [...current.keys()].filter(key => !sourceKeys.has(key));
|
|
142
|
+
for (const key of new Set([...removed, ...state.keys(lang, file.id).filter(key => !sourceKeys.has(key))])) {
|
|
143
|
+
state.delete(lang, file.id, key);
|
|
144
|
+
delete state.review[reviewId(lang, file.id, key)];
|
|
145
|
+
}
|
|
146
|
+
const work = [...fresh, ...revise];
|
|
147
|
+
if (work.length + repairs.length + removed.length > 0) {
|
|
148
|
+
log.info(`[${lang}] ${targetRel}: ${fresh.length} new, ${revise.length} changed${repairs.length ? `, ${repairs.length} to repair` : ''}${removed.length ? `, ${removed.length} to remove` : ''}`);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
file,
|
|
152
|
+
lang,
|
|
153
|
+
counts,
|
|
154
|
+
sourceDoc: source.doc,
|
|
155
|
+
sourceText: source.text,
|
|
156
|
+
targetRel,
|
|
157
|
+
targetFile,
|
|
158
|
+
targetText,
|
|
159
|
+
targetDoc,
|
|
160
|
+
values,
|
|
161
|
+
work,
|
|
162
|
+
repairs,
|
|
163
|
+
removed,
|
|
164
|
+
pluralExtras,
|
|
165
|
+
pending: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Checker } from '../checks.js';
|
|
2
|
+
import type { Scope } from '../scope.js';
|
|
3
|
+
import type { RunContext } from './context.js';
|
|
4
|
+
import type { Plan } from './planner.js';
|
|
5
|
+
import type { Translator } from './translator.js';
|
|
6
|
+
/** Words not shared by both texts, counted on the longer side (word-level LCS). */
|
|
7
|
+
export declare function changedWords(before: string, after: string): number;
|
|
8
|
+
/** A targeted repair may change a few words of a short string or a quarter of a long one. */
|
|
9
|
+
export declare function tooManyChanges(before: string, after: string): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Targeted repairs (--fix-flagged): the model gets the existing translation and the
|
|
12
|
+
* findings, and must change only what fixes them. A repair is written only if the same
|
|
13
|
+
* checks pass afterwards, nothing hard broke and few words changed; a rejected one is
|
|
14
|
+
* recorded and not retried until the translation changes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runRepairs(ctx: RunContext, plan: Plan, checker: Checker, scope: Scope, translator: Translator): Promise<void>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { repairInstruction } from '../prompt.js';
|
|
2
|
+
import { reviewId } from '../state.js';
|
|
3
|
+
import { hash, today } from '../util.js';
|
|
4
|
+
/** Words not shared by both texts, counted on the longer side (word-level LCS). */
|
|
5
|
+
export function changedWords(before, after) {
|
|
6
|
+
const split = (text) => text.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).map(w => w.toLocaleLowerCase());
|
|
7
|
+
const a = split(before);
|
|
8
|
+
const b = split(after);
|
|
9
|
+
let prev = new Array(b.length + 1).fill(0);
|
|
10
|
+
for (let i = 1; i <= a.length; i++) {
|
|
11
|
+
const row = new Array(b.length + 1).fill(0);
|
|
12
|
+
for (let j = 1; j <= b.length; j++)
|
|
13
|
+
row[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], row[j - 1]);
|
|
14
|
+
prev = row;
|
|
15
|
+
}
|
|
16
|
+
return Math.max(a.length, b.length) - prev[b.length];
|
|
17
|
+
}
|
|
18
|
+
/** A targeted repair may change a few words of a short string or a quarter of a long one. */
|
|
19
|
+
export function tooManyChanges(before, after) {
|
|
20
|
+
const changed = changedWords(before, after);
|
|
21
|
+
const allowed = Math.max(6, Math.ceil(before.split(/\s+/).filter(Boolean).length * 0.25));
|
|
22
|
+
return changed > allowed ? `changed ${changed} words (allowed ${allowed})` : null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Targeted repairs (--fix-flagged): the model gets the existing translation and the
|
|
26
|
+
* findings, and must change only what fixes them. A repair is written only if the same
|
|
27
|
+
* checks pass afterwards, nothing hard broke and few words changed; a rejected one is
|
|
28
|
+
* recorded and not retried until the translation changes.
|
|
29
|
+
*/
|
|
30
|
+
export async function runRepairs(ctx, plan, checker, scope, translator) {
|
|
31
|
+
const { state, log } = ctx;
|
|
32
|
+
const { lang, file, values, counts } = plan;
|
|
33
|
+
for (const { key, source, issues } of plan.repairs) {
|
|
34
|
+
if (ctx.stopped)
|
|
35
|
+
break;
|
|
36
|
+
const before = values.get(key) ?? '';
|
|
37
|
+
const problems = issues.map(issue => (issue.note ? `${issue.check}: ${issue.note}` : issue.check));
|
|
38
|
+
const after = await translator.translateOne(lang, { key, source, maxLength: scope.maxLength(key), file: file.id }, repairInstruction(before, problems));
|
|
39
|
+
const id = reviewId(lang, file.id, key);
|
|
40
|
+
let reason = after === null ? 'no answer' : null;
|
|
41
|
+
if (after !== null) {
|
|
42
|
+
const checks = new Set(issues.map(issue => issue.check));
|
|
43
|
+
const remaining = checker.checkString(lang, key, source, after, file.id).filter(issue => checks.has(issue.check));
|
|
44
|
+
reason =
|
|
45
|
+
checker.defect(lang, key, source, after, file.id).hard ??
|
|
46
|
+
(remaining.length > 0 ? `still flagged: ${remaining.map(r => r.note ?? r.check).join('; ')}` : null) ??
|
|
47
|
+
tooManyChanges(before, after);
|
|
48
|
+
}
|
|
49
|
+
if (reason || after === null) {
|
|
50
|
+
if (after !== null || !ctx.stopped) {
|
|
51
|
+
state.repairFailures[id] = { valueHash: hash(before), date: today(), problems, reason: reason ?? 'no answer', ...(after ? { attempted: after } : {}) };
|
|
52
|
+
log.warn(`[${lang}] ${key}: repair rejected (${reason})`);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
delete state.repairFailures[id];
|
|
57
|
+
values.set(key, after);
|
|
58
|
+
plan.pending.push({ key, source, value: after });
|
|
59
|
+
counts.repaired++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { type LocaleFile } from '../files.js';
|
|
3
|
+
import type { PluginHost } from '../plugins.js';
|
|
4
|
+
import type { Scope } from '../scope.js';
|
|
5
|
+
import type { Logger } from './context.js';
|
|
6
|
+
import type { SourceFile } from './planner.js';
|
|
7
|
+
/**
|
|
8
|
+
* The source files of a group, without excluded ones, in the order plugins choose (they may
|
|
9
|
+
* also leave files out, e.g. blog posts scheduled for a later date).
|
|
10
|
+
*/
|
|
11
|
+
export declare function listFiles(config: Config, scope: Scope, plugins: PluginHost): LocaleFile[];
|
|
12
|
+
/** Reads and parses the source files; unreadable ones are reported and skipped. */
|
|
13
|
+
export declare function loadSources(config: Config, files: LocaleFile[], log: Logger): SourceFile[];
|