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.
Files changed (53) hide show
  1. package/README.md +108 -4
  2. package/dist/budget.d.ts +25 -0
  3. package/dist/budget.js +74 -0
  4. package/dist/checks.d.ts +15 -4
  5. package/dist/checks.js +17 -3
  6. package/dist/cli.js +44 -9
  7. package/dist/config.d.ts +27 -1
  8. package/dist/config.js +105 -3
  9. package/dist/engine/context.d.ts +81 -0
  10. package/dist/engine/context.js +75 -0
  11. package/dist/engine/copies.d.ts +9 -0
  12. package/dist/engine/copies.js +31 -0
  13. package/dist/engine/planner.d.ts +66 -0
  14. package/dist/engine/planner.js +167 -0
  15. package/dist/engine/repair.d.ts +16 -0
  16. package/dist/engine/repair.js +61 -0
  17. package/dist/engine/sources.d.ts +13 -0
  18. package/dist/engine/sources.js +40 -0
  19. package/dist/engine/translator.d.ts +29 -0
  20. package/dist/engine/translator.js +156 -0
  21. package/dist/engine/writer.d.ts +13 -0
  22. package/dist/engine/writer.js +63 -0
  23. package/dist/files.d.ts +9 -0
  24. package/dist/files.js +76 -14
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.js +6 -3
  27. package/dist/llm.d.ts +5 -8
  28. package/dist/llm.js +7 -15
  29. package/dist/lock.d.ts +13 -0
  30. package/dist/lock.js +91 -0
  31. package/dist/output.d.ts +21 -0
  32. package/dist/output.js +76 -0
  33. package/dist/plugins.d.ts +72 -0
  34. package/dist/plugins.js +71 -0
  35. package/dist/project.d.ts +18 -5
  36. package/dist/project.js +70 -57
  37. package/dist/prompt.d.ts +3 -1
  38. package/dist/prompt.js +2 -2
  39. package/dist/review.d.ts +1 -1
  40. package/dist/review.js +41 -35
  41. package/dist/state.d.ts +10 -3
  42. package/dist/state.js +30 -11
  43. package/dist/translate.d.ts +9 -49
  44. package/dist/translate.js +118 -429
  45. package/dist/ui/data.d.ts +50 -0
  46. package/dist/ui/data.js +178 -0
  47. package/dist/ui/page.d.ts +5 -0
  48. package/dist/ui/page.js +277 -0
  49. package/dist/ui/server.d.ts +24 -0
  50. package/dist/ui/server.js +194 -0
  51. package/dist/util.d.ts +6 -2
  52. package/dist/util.js +17 -5
  53. package/package.json +1 -1
package/dist/review.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
+ import { groupsOf } from './config.js';
2
3
  import { findSourceFiles, flatten, parseDoc, readText } from './files.js';
3
4
  import { parseReviewId, reviewId, State } from './state.js';
5
+ import { withLock } from './lock.js';
4
6
  import { hash, today } from './util.js';
5
7
  function currentValue(config, lang, fileId, key) {
6
8
  const rel = fileId.split('{lang}').join(lang);
@@ -41,44 +43,48 @@ function matches(selector, lang, key) {
41
43
  * Returns the number of entries changed.
42
44
  */
43
45
  export function updateReview(config, action, selectors) {
44
- const state = new State(path.join(config.root, config.stateDir));
45
- let changed = 0;
46
- for (const [id, entry] of Object.entries(state.review)) {
47
- const { lang, file, key } = parseReviewId(id);
48
- if (!selectors.some(selector => matches(selector, lang, key)))
49
- continue;
50
- if (action === 'approve') {
51
- const value = currentValue(config, lang, file, key);
52
- if (value === undefined)
46
+ // Under the run lock: a run in progress would otherwise overwrite these changes.
47
+ return withLock(path.join(config.root, config.stateDir), () => {
48
+ const state = new State(path.join(config.root, config.stateDir));
49
+ let changed = 0;
50
+ for (const [id, entry] of Object.entries(state.review)) {
51
+ const { lang, file, key } = parseReviewId(id);
52
+ if (!selectors.some(selector => matches(selector, lang, key)))
53
53
  continue;
54
- state.review[id] = { ...entry, status: 'approved', valueHash: hash(value) };
55
- }
56
- else {
57
- delete state.review[id];
58
- state.invalidate(lang, file, key);
59
- }
60
- changed++;
61
- }
62
- // Approving a string that is not on the list marks it as checked by a person: it is then
63
- // protected like a hand edit and the quality check no longer reports warnings for it.
64
- if (action === 'approve') {
65
- const exact = selectors.filter(sel => sel !== 'all' && !sel.endsWith(':*') && sel.includes(':'));
66
- for (const selector of exact) {
67
- const colon = selector.indexOf(':');
68
- const [lang, key] = [selector.slice(0, colon), selector.slice(colon + 1)];
69
- for (const file of findSourceFiles(config.root, config.files, config.sourceLanguage)) {
70
- const id = reviewId(lang, file.id, key);
71
- if (state.review[id])
72
- continue;
73
- const value = currentValue(config, lang, file.id, key);
54
+ if (action === 'approve') {
55
+ const value = currentValue(config, lang, file, key);
74
56
  if (value === undefined)
75
57
  continue;
76
- state.review[id] = { status: 'approved', reason: 'approved-by-hand', file: file.pathFor(lang), since: today(), valueHash: hash(value) };
77
- changed++;
58
+ state.review[id] = { ...entry, status: 'approved', valueHash: hash(value) };
59
+ }
60
+ else {
61
+ delete state.review[id];
62
+ state.invalidate(lang, file, key);
78
63
  }
64
+ changed++;
79
65
  }
80
- }
81
- if (changed > 0)
82
- state.save();
83
- return changed;
66
+ // Approving a string that is not on the list marks it as checked by a person: it is then
67
+ // protected like a hand edit and the quality check no longer reports warnings for it.
68
+ if (action === 'approve') {
69
+ const exact = selectors.filter(sel => sel !== 'all' && !sel.endsWith(':*') && sel.includes(':'));
70
+ for (const selector of exact) {
71
+ const colon = selector.indexOf(':');
72
+ const [lang, key] = [selector.slice(0, colon), selector.slice(colon + 1)];
73
+ const files = groupsOf(config).flatMap(group => findSourceFiles(group.root, group.files, group.sourceLanguage));
74
+ for (const file of files) {
75
+ const id = reviewId(lang, file.id, key);
76
+ if (state.review[id])
77
+ continue;
78
+ const value = currentValue(config, lang, file.id, key);
79
+ if (value === undefined)
80
+ continue;
81
+ state.review[id] = { status: 'approved', reason: 'approved-by-hand', file: file.pathFor(lang), since: today(), valueHash: hash(value) };
82
+ changed++;
83
+ }
84
+ }
85
+ }
86
+ if (changed > 0)
87
+ state.save();
88
+ return changed;
89
+ });
84
90
  }
package/dist/state.d.ts CHANGED
@@ -39,13 +39,20 @@ export declare class State {
39
39
  review: Record<string, ReviewEntry>;
40
40
  repairFailures: Record<string, RepairFailure>;
41
41
  constructor(dir: string);
42
- /** Hashes recorded for a string, or undefined if localewarden has not seen it yet. */
42
+ /**
43
+ * Hashes recorded for a string, or undefined if localewarden has not seen it yet. `date` is
44
+ * the UTC day (YYYY-MM-DD) localewarden wrote it; empty for adopted existing translations.
45
+ */
43
46
  get(lang: string, fileId: string, key: string): {
44
47
  source: string;
45
48
  value: string;
49
+ date: string;
46
50
  } | undefined;
47
- /** Records that `value` is the current translation of `source`. */
48
- set(lang: string, fileId: string, key: string, source: string, value: string): void;
51
+ /**
52
+ * Records that `value` is the current translation of `source`, written today; pass
53
+ * `written: false` for a translation that was adopted or protected, not written by us.
54
+ */
55
+ set(lang: string, fileId: string, key: string, source: string, value: string, written?: boolean): void;
49
56
  /** Marks a recorded string as needing re-translation (source hash cleared). */
50
57
  invalidate(lang: string, fileId: string, key: string): void;
51
58
  delete(lang: string, fileId: string, key: string): void;
package/dist/state.js CHANGED
@@ -1,28 +1,37 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { writeText } from './files.js';
3
4
  import { hash, sortObject, today } from './util.js';
4
5
  /** "<file id>#<key>" */
5
6
  export const stringId = (fileId, key) => `${fileId}#${key}`;
6
7
  /** "<lang>|<file id>#<key>" */
7
8
  export const reviewId = (lang, fileId, key) => `${lang}|${stringId(fileId, key)}`;
8
9
  export function parseReviewId(id) {
10
+ // File ids are paths and contain no "#"; keys may ("faq.#1"), so split at the first one.
9
11
  const bar = id.indexOf('|');
10
- const hashMark = id.lastIndexOf('#');
12
+ const hashMark = id.indexOf('#', bar + 1);
11
13
  return { lang: id.slice(0, bar), file: id.slice(bar + 1, hashMark), key: id.slice(hashMark + 1) };
12
14
  }
13
15
  function readJson(file, fallback) {
16
+ let text;
14
17
  try {
15
- return JSON.parse(fs.readFileSync(file, 'utf8'));
18
+ text = fs.readFileSync(file, 'utf8');
16
19
  }
17
20
  catch (error) {
18
21
  if (error.code === 'ENOENT')
19
22
  return fallback;
20
- throw new Error(`Could not read ${file}: ${error.message}`);
23
+ throw error;
24
+ }
25
+ try {
26
+ return JSON.parse(text.replace(/^\uFEFF/, ''));
27
+ }
28
+ catch (error) {
29
+ throw new Error(`${file} is not valid JSON (${error.message}). It is localewarden's own bookkeeping: ` +
30
+ 'restore it from git, or delete it (translations are kept and adopted again; hand edits made since are then not detected).');
21
31
  }
22
32
  }
23
33
  function writeJson(file, value) {
24
- fs.mkdirSync(path.dirname(file), { recursive: true });
25
- fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8');
34
+ writeText(file, JSON.stringify(value, null, 2) + '\n');
26
35
  }
27
36
  export class State {
28
37
  dir;
@@ -36,17 +45,27 @@ export class State {
36
45
  this.review = readJson(path.join(dir, 'review.json'), {});
37
46
  this.repairFailures = readJson(path.join(dir, 'repair-failures.json'), {});
38
47
  }
39
- /** Hashes recorded for a string, or undefined if localewarden has not seen it yet. */
48
+ /**
49
+ * Hashes recorded for a string, or undefined if localewarden has not seen it yet. `date` is
50
+ * the UTC day (YYYY-MM-DD) localewarden wrote it; empty for adopted existing translations.
51
+ */
40
52
  get(lang, fileId, key) {
41
53
  const raw = this.entries[lang]?.[stringId(fileId, key)];
42
54
  if (!raw)
43
55
  return undefined;
44
- const [source, value] = raw.split(':');
45
- return { source, value };
56
+ const [source, value, date = ''] = raw.split(':');
57
+ return { source, value, date: date ? `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}` : '' };
46
58
  }
47
- /** Records that `value` is the current translation of `source`. */
48
- set(lang, fileId, key, source, value) {
49
- (this.entries[lang] ??= {})[stringId(fileId, key)] = `${hash(source)}:${hash(value)}`;
59
+ /**
60
+ * Records that `value` is the current translation of `source`, written today; pass
61
+ * `written: false` for a translation that was adopted or protected, not written by us.
62
+ */
63
+ set(lang, fileId, key, source, value, written = true) {
64
+ const id = stringId(fileId, key);
65
+ const previous = this.get(lang, fileId, key);
66
+ const keepDate = !written && previous && previous.value === hash(value) ? previous.date.replace(/-/g, '') : '';
67
+ const date = written ? today().replace(/-/g, '') : keepDate;
68
+ (this.entries[lang] ??= {})[id] = `${hash(source)}:${hash(value)}${date ? `:${date}` : ''}`;
50
69
  }
51
70
  /** Marks a recorded string as needing re-translation (source hash cleared). */
52
71
  invalidate(lang, fileId, key) {
@@ -1,50 +1,10 @@
1
- import type { Config } from './config.js';
2
- import { type Model } from './llm.js';
3
- export interface Logger {
4
- info(message: string): void;
5
- warn(message: string): void;
6
- error(message: string): void;
7
- debug?(message: string): void;
8
- }
9
- export declare const consoleLogger: Logger;
10
- export interface RunOptions {
11
- /** Subset of the configured target languages. */
12
- languages?: string[];
13
- /** Show what would be translated; no API calls, no writes. */
14
- dryRun?: boolean;
15
- /** Re-translate every string, not only new and changed ones. Hand edits stay protected. */
16
- retranslateAll?: boolean;
17
- /** Also replace hand-edited translations. */
18
- overwriteManual?: boolean;
19
- /** Ask the model to fix strings the quality check flags, changing as little as possible. */
20
- fixFlagged?: boolean;
21
- /** Overrides maxTokensPerRun from the config. */
22
- maxTokens?: number;
23
- logger?: Logger;
24
- /** Model to use instead of the configured OpenAI-compatible endpoint (tests, other SDKs). */
25
- model?: Model;
26
- }
27
- export interface LanguageSummary {
28
- translated: number;
29
- revised: number;
30
- repaired: number;
31
- failed: number;
32
- protected: number;
33
- removed: number;
34
- planned: number;
35
- plannedChars: number;
36
- }
37
- export interface RunSummary {
38
- languages: Record<string, LanguageSummary>;
39
- filesWritten: string[];
40
- tokens: number;
41
- requests: number;
42
- stoppedByBudget: boolean;
43
- pendingReview: number;
44
- dryRun: boolean;
45
- }
46
- /** Words not shared by both texts, counted on the longer side (word-level LCS). */
47
- export declare function changedWords(before: string, after: string): number;
48
- /** A targeted repair may change a few words of a short string or a quarter of a long one. */
49
- export declare function tooManyChanges(before: string, after: string): string | null;
1
+ import { type Config } from './config.js';
2
+ import { type RunOptions, type RunSummary } from './engine/context.js';
3
+ export { consoleLogger, type Logger, type LanguageSummary, type RunOptions, type RunSummary } from './engine/context.js';
4
+ export { changedWords, tooManyChanges } from './engine/repair.js';
5
+ /**
6
+ * Translates new and changed strings, group by group (in config order), language by language.
7
+ * Within a language, all files of a group share requests, so many small files do not cost a
8
+ * request each. See engine/planner.ts for what is translated, revised, adopted or protected.
9
+ */
50
10
  export declare function run(config: Config, options?: RunOptions): Promise<RunSummary>;