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/ui/data.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { groupsOf } from '../config.js';
|
|
4
|
+
import { listFiles } from '../engine/sources.js';
|
|
5
|
+
import { detectFormat, flatten, isArbMetadata, parseDoc, readText, serializeDoc, stringLeaves, writeText, } from '../files.js';
|
|
6
|
+
import { Scope } from '../scope.js';
|
|
7
|
+
import { reviewId, State } from '../state.js';
|
|
8
|
+
import { withLock } from '../lock.js';
|
|
9
|
+
import { hash, today } from '../util.js';
|
|
10
|
+
const stateOf = (config) => new State(path.join(config.root, config.stateDir));
|
|
11
|
+
const groupByName = (config, name) => {
|
|
12
|
+
const groups = groupsOf(config);
|
|
13
|
+
const group = name ? groups.find(g => g.name === name) : groups[0];
|
|
14
|
+
if (!group)
|
|
15
|
+
throw new Error(`Unknown group "${name}".`);
|
|
16
|
+
return group;
|
|
17
|
+
};
|
|
18
|
+
const readFlat = (root, rel) => {
|
|
19
|
+
const text = readText(path.join(root, rel));
|
|
20
|
+
if (text === null)
|
|
21
|
+
return new Map();
|
|
22
|
+
try {
|
|
23
|
+
return flatten(parseDoc(rel, text));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return new Map();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function sourceStrings(group, file) {
|
|
30
|
+
const rel = file.pathFor(group.sourceLanguage);
|
|
31
|
+
const text = readText(path.join(group.root, rel));
|
|
32
|
+
if (text === null)
|
|
33
|
+
return [];
|
|
34
|
+
try {
|
|
35
|
+
return stringLeaves(parseDoc(rel, text));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Per group and language: strings, translated strings and hand edits waiting for review. */
|
|
42
|
+
export function status(config, plugins) {
|
|
43
|
+
const state = stateOf(config);
|
|
44
|
+
const pending = Object.entries(state.review).filter(([, e]) => e.status === 'pending').map(([id]) => id);
|
|
45
|
+
const groups = groupsOf(config).map(group => {
|
|
46
|
+
const scope = new Scope(group);
|
|
47
|
+
const files = listFiles(group, scope, plugins);
|
|
48
|
+
const languages = group.targetLanguages.map(lang => {
|
|
49
|
+
let strings = 0;
|
|
50
|
+
let translated = 0;
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
const leaves = sourceStrings(group, file).filter(l => l.value.trim() !== '' && !scope.isLiteral(l.key, l.value));
|
|
53
|
+
const target = readFlat(group.root, file.pathFor(lang));
|
|
54
|
+
strings += leaves.length;
|
|
55
|
+
translated += leaves.filter(l => (target.get(l.key) ?? '').trim() !== '').length;
|
|
56
|
+
}
|
|
57
|
+
const pendingReview = pending.filter(id => id.startsWith(`${lang}|`) && files.some(f => id.includes(`|${f.id}#`))).length;
|
|
58
|
+
return { lang, strings, translated, pendingReview };
|
|
59
|
+
});
|
|
60
|
+
return { name: group.name, files: files.length, sourceLanguage: group.sourceLanguage, languages };
|
|
61
|
+
});
|
|
62
|
+
let usage = null;
|
|
63
|
+
try {
|
|
64
|
+
const data = JSON.parse(fs.readFileSync(path.join(config.root, config.stateDir, 'usage.json'), 'utf8'));
|
|
65
|
+
// Usage is per UTC day: yesterday's numbers are not today's.
|
|
66
|
+
usage = data?.date === today() ? data : { date: today(), tokens: 0, requests: 0 };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// no usage recorded yet
|
|
70
|
+
}
|
|
71
|
+
return { groups, usage, limits: { maxTokensPerRun: config.maxTokensPerRun, dailyTokenBudget: config.dailyTokenBudget ?? null } };
|
|
72
|
+
}
|
|
73
|
+
/** Strings of a group in one language, filtered by a search text (key, source or translation). */
|
|
74
|
+
export function strings(config, plugins, query) {
|
|
75
|
+
const group = groupByName(config, query.group);
|
|
76
|
+
if (!group.targetLanguages.includes(query.lang))
|
|
77
|
+
throw new Error(`"${query.lang}" is not a target language of this group.`);
|
|
78
|
+
const scope = new Scope(group);
|
|
79
|
+
const state = stateOf(config);
|
|
80
|
+
const needle = query.q?.toLowerCase().trim();
|
|
81
|
+
const rows = [];
|
|
82
|
+
for (const file of listFiles(group, scope, plugins)) {
|
|
83
|
+
const target = readFlat(group.root, file.pathFor(query.lang));
|
|
84
|
+
const targetRel = file.pathFor(query.lang);
|
|
85
|
+
for (const leaf of sourceStrings(group, file)) {
|
|
86
|
+
if (leaf.value.trim() === '')
|
|
87
|
+
continue;
|
|
88
|
+
const text = target.get(leaf.key) ?? null;
|
|
89
|
+
const review = state.review[reviewId(query.lang, file.id, leaf.key)];
|
|
90
|
+
const status = isArbMetadata(targetRel, leaf.key) || scope.isLiteral(leaf.key, leaf.value)
|
|
91
|
+
? 'not-text'
|
|
92
|
+
: text === null || text.trim() === ''
|
|
93
|
+
? 'missing'
|
|
94
|
+
: review?.status === 'pending'
|
|
95
|
+
? 'pending-review'
|
|
96
|
+
: review?.status === 'approved'
|
|
97
|
+
? 'approved'
|
|
98
|
+
: 'translated';
|
|
99
|
+
if (query.only && status !== query.only)
|
|
100
|
+
continue;
|
|
101
|
+
if (needle && ![leaf.key, leaf.value, text ?? ''].some(v => v.toLowerCase().includes(needle)))
|
|
102
|
+
continue;
|
|
103
|
+
rows.push({ group: group.name, file: file.id, key: leaf.key, source: leaf.value, text, status });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const limit = Math.min(Math.max(query.limit ?? 200, 1), 1000);
|
|
107
|
+
return { rows: rows.slice(0, limit), total: rows.length };
|
|
108
|
+
}
|
|
109
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
110
|
+
/**
|
|
111
|
+
* Sets one string in a target document. Missing objects are created empty; missing arrays
|
|
112
|
+
* and array items are filled from the source document (as a run would), never with nulls.
|
|
113
|
+
*/
|
|
114
|
+
function setPath(doc, segments, value, source) {
|
|
115
|
+
if (segments.some(s => typeof s === 'string' && UNSAFE_KEYS.has(s)))
|
|
116
|
+
throw new Error('Unsupported key.');
|
|
117
|
+
let node = doc;
|
|
118
|
+
let src = source;
|
|
119
|
+
for (const [i, segment] of segments.slice(0, -1).entries()) {
|
|
120
|
+
const srcChild = src && typeof src === 'object' ? src[segment] : undefined;
|
|
121
|
+
const current = node[segment];
|
|
122
|
+
if (current === undefined || current === null || typeof current !== 'object') {
|
|
123
|
+
node[segment] = Array.isArray(srcChild) ? structuredClone(srcChild) : typeof segments[i + 1] === 'number' ? [] : {};
|
|
124
|
+
}
|
|
125
|
+
else if (Array.isArray(current) && Array.isArray(srcChild)) {
|
|
126
|
+
for (let j = 0; j < srcChild.length; j++)
|
|
127
|
+
if (current[j] === undefined || current[j] === null)
|
|
128
|
+
current[j] = structuredClone(srcChild[j]);
|
|
129
|
+
}
|
|
130
|
+
node = node[segment];
|
|
131
|
+
src = srcChild;
|
|
132
|
+
}
|
|
133
|
+
node[segments[segments.length - 1]] = value;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Saves a translation edited in the interface. It counts as checked by a person: it is
|
|
137
|
+
* approved on the review list, so runs leave it alone and the check skips its warnings.
|
|
138
|
+
*/
|
|
139
|
+
export function editString(config, plugins, edit) {
|
|
140
|
+
const group = groupByName(config, edit.group);
|
|
141
|
+
if (!group.targetLanguages.includes(edit.lang))
|
|
142
|
+
throw new Error(`"${edit.lang}" is not a target language of this group.`);
|
|
143
|
+
if (typeof edit.value !== 'string')
|
|
144
|
+
throw new Error('value must be a string.');
|
|
145
|
+
// Only files of this group: the interface can never write anywhere else.
|
|
146
|
+
const file = listFiles(group, new Scope(group), plugins).find(f => f.id === edit.file);
|
|
147
|
+
if (!file)
|
|
148
|
+
throw new Error(`Unknown file "${edit.file}".`);
|
|
149
|
+
const leaf = sourceStrings(group, file).find(l => l.key === edit.key);
|
|
150
|
+
if (!leaf)
|
|
151
|
+
throw new Error(`Unknown key "${edit.key}".`);
|
|
152
|
+
return withLock(path.join(config.root, config.stateDir), () => writeEdit(config, group, file, leaf, edit));
|
|
153
|
+
}
|
|
154
|
+
function writeEdit(config, group, file, leaf, edit) {
|
|
155
|
+
const rel = file.pathFor(edit.lang);
|
|
156
|
+
const full = path.join(group.root, rel);
|
|
157
|
+
if (!path.resolve(full).startsWith(path.resolve(group.root) + path.sep))
|
|
158
|
+
throw new Error('Path outside the project.');
|
|
159
|
+
const text = readText(full);
|
|
160
|
+
const sourceText = readText(path.join(group.root, file.pathFor(group.sourceLanguage)));
|
|
161
|
+
const sourceDoc = sourceText === null ? {} : parseDoc(file.pathFor(group.sourceLanguage), sourceText);
|
|
162
|
+
const doc = text === null ? {} : parseDoc(rel, text);
|
|
163
|
+
setPath(doc, leaf.path, edit.value, sourceDoc);
|
|
164
|
+
const out = serializeDoc(rel, doc, detectFormat(text ?? sourceText));
|
|
165
|
+
if (out !== null)
|
|
166
|
+
writeText(full, out);
|
|
167
|
+
const state = stateOf(config);
|
|
168
|
+
state.set(edit.lang, file.id, edit.key, leaf.value, edit.value, false);
|
|
169
|
+
state.review[reviewId(edit.lang, file.id, edit.key)] = {
|
|
170
|
+
status: 'approved',
|
|
171
|
+
reason: 'approved-by-hand',
|
|
172
|
+
file: rel,
|
|
173
|
+
since: today(),
|
|
174
|
+
valueHash: hash(edit.value),
|
|
175
|
+
};
|
|
176
|
+
state.save();
|
|
177
|
+
return { group: group.name, file: file.id, key: edit.key, source: leaf.value, text: edit.value, status: 'approved' };
|
|
178
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface page: one HTML document with inline CSS and JS, no dependencies. All data is
|
|
3
|
+
* inserted with textContent (translations contain HTML; it must never be interpreted here).
|
|
4
|
+
*/
|
|
5
|
+
export declare const PAGE = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>localewarden</title>\n<style>\n:root { --bg:#f7f7f5; --panel:#fff; --text:#1d1f23; --muted:#646a73; --line:#e3e3df; --accent:#1f7a52; --accent-weak:#e4f3eb; --warn:#9a6700; --warn-weak:#fff4d6; --err:#b42318; --err-weak:#fde8e7; }\n@media (prefers-color-scheme: dark) { :root { --bg:#111316; --panel:#181b20; --text:#e6e8eb; --muted:#9aa1ab; --line:#2a2f37; --accent:#4cc38a; --accent-weak:#163325; --warn:#e3b341; --warn-weak:#33290f; --err:#ff7b72; --err-weak:#3a1717; } }\n* { box-sizing: border-box; }\nbody { margin:0; font:14px/1.45 -apple-system, system-ui, \"Segoe UI\", sans-serif; background:var(--bg); color:var(--text); }\nheader { display:flex; align-items:center; gap:24px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); position:sticky; top:0; z-index:2; flex-wrap:wrap; }\nheader h1 { font-size:16px; margin:0; }\nnav { display:flex; gap:4px; flex-wrap:wrap; }\nnav button { border:0; background:none; color:var(--muted); padding:6px 10px; border-radius:6px; cursor:pointer; font:inherit; }\nnav button[aria-selected=true] { background:var(--accent-weak); color:var(--accent); font-weight:600; }\nmain { padding:20px; max-width:1200px; margin:0 auto; }\nsection[hidden] { display:none; }\n.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; margin-bottom:16px; }\n.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:12px; }\nselect, input, textarea, .btn { font:inherit; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:6px 8px; }\n.btn { cursor:pointer; }\n.btn.primary { background:var(--accent); color:#fff; border-color:var(--accent); }\n.btn:disabled { opacity:.5; cursor:default; }\ntable { width:100%; border-collapse:collapse; }\nth, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); vertical-align:top; }\nth { color:var(--muted); font-weight:500; font-size:12px; }\ntd.num { text-align:right; font-variant-numeric:tabular-nums; }\n.bar { height:6px; background:var(--line); border-radius:3px; overflow:hidden; min-width:80px; }\n.bar > i { display:block; height:100%; background:var(--accent); }\n.tag { display:inline-block; padding:1px 7px; border-radius:10px; font-size:12px; background:var(--line); color:var(--muted); white-space:nowrap; }\n.tag.missing, .tag.error { background:var(--err-weak); color:var(--err); }\n.tag.pending-review, .tag.warning { background:var(--warn-weak); color:var(--warn); }\n.tag.approved, .tag.translated { background:var(--accent-weak); color:var(--accent); }\n.text { white-space:pre-wrap; word-break:break-word; max-width:460px; }\n.muted { color:var(--muted); }\ntextarea { width:100%; min-height:70px; }\npre.log { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; max-height:420px; overflow:auto; white-space:pre-wrap; font-size:12px; }\n.error-box { color:var(--err); }\ndetails.panel > summary { cursor:pointer; font-weight:600; display:flex; gap:12px; align-items:center; list-style:none; }\ndetails.panel > summary::-webkit-details-marker { display:none; }\ndetails.panel > summary::before { content:'\u25B8'; color:var(--muted); }\ndetails.panel[open] > summary::before { content:'\u25BE'; }\ndetails.panel > summary .bar { flex:0 0 160px; }\ndetails.panel table { margin-top:12px; }\ncol.c-lang { width:90px; } col.c-num { width:130px; } col.c-review { width:170px; }\n</style>\n</head>\n<body>\n<header>\n <h1>localewarden</h1>\n <nav role=\"tablist\">\n <button role=\"tab\" data-tab=\"overview\" aria-selected=\"true\">Overview</button>\n <button role=\"tab\" data-tab=\"strings\" aria-selected=\"false\">Strings</button>\n <button role=\"tab\" data-tab=\"findings\" aria-selected=\"false\">Check</button>\n <button role=\"tab\" data-tab=\"review\" aria-selected=\"false\">Review</button>\n <button role=\"tab\" data-tab=\"run\" aria-selected=\"false\">Run</button>\n </nav>\n <span id=\"busy\" class=\"muted\"></span>\n</header>\n<main>\n <section id=\"overview\">\n <div class=\"panel\"><div id=\"budget\" class=\"muted\"></div></div>\n <div id=\"groups\"></div>\n </section>\n\n <section id=\"strings\" hidden>\n <div class=\"panel\">\n <div class=\"row\">\n <select id=\"s-group\" aria-label=\"Group\"></select>\n <select id=\"s-lang\" aria-label=\"Language\"></select>\n <select id=\"s-only\" aria-label=\"Show\">\n <option value=\"\">All strings</option><option value=\"missing\">Missing</option><option value=\"pending-review\">Hand edits to review</option>\n </select>\n <input id=\"s-q\" placeholder=\"Search key, source or translation\" size=\"32\">\n <button class=\"btn\" id=\"s-load\">Show</button>\n <span id=\"s-count\" class=\"muted\"></span>\n </div>\n <table><thead><tr><th>Key</th><th>Source</th><th>Translation</th><th>Status</th><th></th></tr></thead><tbody id=\"s-rows\"></tbody></table>\n </div>\n </section>\n\n <section id=\"findings\" hidden>\n <div class=\"panel\">\n <div class=\"row\">\n <button class=\"btn primary\" id=\"f-run\">Run check</button>\n <select id=\"f-lang\" aria-label=\"Language\"><option value=\"\">All languages</option></select>\n <select id=\"f-check\" aria-label=\"Check\"><option value=\"\">All checks</option></select>\n <span id=\"f-count\" class=\"muted\"></span>\n </div>\n <table><thead><tr><th>Lang</th><th>Check</th><th>Key</th><th>Translation</th><th>Note</th></tr></thead><tbody id=\"f-rows\"></tbody></table>\n </div>\n </section>\n\n <section id=\"review\" hidden>\n <div class=\"panel\">\n <div class=\"row\"><label><input type=\"checkbox\" id=\"r-all\"> include approved</label><span id=\"r-count\" class=\"muted\"></span></div>\n <table><thead><tr><th>Lang</th><th>Key</th><th>Translation</th><th>Reason</th><th></th></tr></thead><tbody id=\"r-rows\"></tbody></table>\n </div>\n </section>\n\n <section id=\"run\" hidden>\n <div class=\"panel\">\n <div class=\"row\">\n <select id=\"j-mode\" aria-label=\"Mode\">\n <option value=\"dry-run\">Dry run (no API calls)</option>\n <option value=\"translate\">Translate new and changed strings</option>\n <option value=\"fix-flagged\">Fix what the check flags</option>\n </select>\n <select id=\"j-group\" aria-label=\"Group\"><option value=\"\">All groups</option></select>\n <input id=\"j-langs\" placeholder=\"Languages, e.g. de,fr (empty = all)\" size=\"28\">\n <button class=\"btn primary\" id=\"j-start\">Start</button>\n </div>\n <p class=\"muted\">Translating uses your API key and costs tokens. Start with a dry run.</p>\n <pre class=\"log\" id=\"j-log\"></pre>\n <div id=\"j-summary\"></div>\n </div>\n </section>\n</main>\n<script>\n(() => {\n const token = new URLSearchParams(location.search).get('t') || '';\n const $ = id => document.getElementById(id);\n const el = (tag, props = {}, ...children) => {\n const node = document.createElement(tag);\n for (const [k, v] of Object.entries(props)) {\n if (k === 'class') node.className = v; else if (k === 'text') node.textContent = v; else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v);\n }\n for (const c of children) node.append(c);\n return node;\n };\n async function api(path, body) {\n const res = await fetch(path, { method: body ? 'POST' : 'GET', headers: { 'X-Localewarden-Token': token, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });\n const data = await res.json();\n if (!res.ok) throw new Error(data && data.error || res.statusText);\n return data;\n }\n const report = e => alert(e.message);\n\n // Tabs\n for (const b of document.querySelectorAll('nav button')) b.addEventListener('click', () => {\n for (const o of document.querySelectorAll('nav button')) o.setAttribute('aria-selected', String(o === b));\n for (const s of document.querySelectorAll('main section')) s.hidden = s.id !== b.dataset.tab;\n if (b.dataset.tab === 'review') loadReview().catch(report);\n });\n\n let status = null;\n async function loadStatus() {\n status = await api('/api/status');\n const u = status.usage, l = status.limits;\n $('budget').textContent = 'Tokens today: ' + (u ? u.tokens.toLocaleString('en') : 0) + (l.dailyTokenBudget ? ' of ' + l.dailyTokenBudget.toLocaleString('en') + ' (daily budget)' : '') + ' \u00B7 per run: ' + l.maxTokensPerRun.toLocaleString('en');\n const box = $('groups'); box.replaceChildren();\n for (const g of status.groups) {\n const rows = g.languages.map(x => el('tr', {},\n el('td', { text: x.lang }),\n el('td', { class: 'num', text: x.translated + ' / ' + x.strings }),\n el('td', {}, (() => { const b = el('div', { class: 'bar' }); b.append(el('i', { style: 'width:' + (x.strings ? Math.round(100 * x.translated / x.strings) : 100) + '%' })); return b; })()),\n el('td', { class: 'num', text: x.pendingReview ? String(x.pendingReview) : '' })));\n const total = g.languages.reduce((a, x) => a + x.strings, 0), done = g.languages.reduce((a, x) => a + x.translated, 0);\n const pct = total ? Math.floor(1000 * done / total) / 10 : 100;\n const pending = g.languages.reduce((a, x) => a + x.pendingReview, 0);\n const bar = el('div', { class: 'bar' }); bar.append(el('i', { style: 'width:' + pct + '%' }));\n const details = el('details', { class: 'panel' },\n el('summary', {}, el('span', { text: (g.name || 'Project') }), bar, el('span', { class: 'muted', text: pct + '% \u00B7 ' + g.languages.length + ' languages \u00B7 ' + g.files + ' file(s) \u00B7 source ' + g.sourceLanguage + (pending ? ' \u00B7 ' + pending + ' to review' : '') })),\n el('table', {}, el('colgroup', {}, el('col', { class: 'c-lang' }), el('col', { class: 'c-num' }), el('col', {}), el('col', { class: 'c-review' })),\n el('thead', {}, el('tr', {}, el('th', { text: 'Language' }), el('th', { text: 'Translated' }), el('th', { text: '' }), el('th', { text: 'Hand edits to review' }))), el('tbody', {}, ...rows)));\n if (status.groups.length === 1) details.open = true;\n box.append(details);\n }\n const groupOptions = sel => { sel.replaceChildren(...status.groups.map((g, i) => el('option', { value: g.name || '', text: g.name || 'Project' }))); };\n groupOptions($('s-group'));\n $('j-group').replaceChildren(el('option', { value: '', text: 'All groups' }), ...status.groups.filter(g => g.name).map(g => el('option', { value: g.name, text: g.name })));\n fillLangs();\n const langs = [...new Set(status.groups.flatMap(g => g.languages.map(x => x.lang)))];\n $('f-lang').replaceChildren(el('option', { value: '', text: 'All languages' }), ...langs.map(x => el('option', { value: x, text: x })));\n $('busy').textContent = status.job && status.job.running ? 'Running: ' + status.job.kind : '';\n }\n function fillLangs() {\n const g = status.groups.find(x => (x.name || '') === $('s-group').value) || status.groups[0];\n $('s-lang').replaceChildren(...g.languages.map(x => el('option', { value: x.lang, text: x.lang })));\n }\n $('s-group').addEventListener('change', fillLangs);\n\n // Strings\n async function loadStrings() {\n const p = new URLSearchParams({ group: $('s-group').value, lang: $('s-lang').value, q: $('s-q').value, only: $('s-only').value, limit: '300' });\n const data = await api('/api/strings?' + p);\n $('s-count').textContent = data.rows.length + ' of ' + data.total + ' shown';\n $('s-rows').replaceChildren(...data.rows.map(r => {\n const cell = el('td', { class: 'text', text: r.text ?? '' });\n const edit = el('button', { class: 'btn', text: 'Edit' });\n if (r.status === 'not-text') edit.disabled = true;\n edit.addEventListener('click', () => {\n const area = el('textarea', {}); area.value = r.text ?? '';\n const save = el('button', { class: 'btn primary', text: 'Save' });\n save.addEventListener('click', async () => {\n try { const out = await api('/api/strings', { group: r.group, lang: $('s-lang').value, file: r.file, key: r.key, value: area.value }); cell.replaceChildren(document.createTextNode(out.text)); tag.className = 'tag approved'; tag.textContent = 'approved'; edit.disabled = false; }\n catch (e) { report(e); }\n });\n cell.replaceChildren(area, save); edit.disabled = true;\n });\n const tag = el('span', { class: 'tag ' + r.status, text: r.status });\n return el('tr', {}, el('td', { class: 'muted', text: r.key }), el('td', { class: 'text', text: r.source }), cell, el('td', {}, tag), el('td', {}, edit));\n }));\n }\n $('s-load').addEventListener('click', () => loadStrings().catch(report));\n $('s-q').addEventListener('keydown', e => { if (e.key === 'Enter') loadStrings().catch(report); });\n\n // Findings\n async function loadFindings() {\n const p = new URLSearchParams({ lang: $('f-lang').value, check: $('f-check').value, limit: '300' });\n const data = await api('/api/findings?' + p);\n if (!data.checked) { $('f-count').textContent = 'Not checked yet.'; return; }\n $('f-count').textContent = data.total + ' finding(s)' + (data.total > data.rows.length ? ', first ' + data.rows.length + ' shown' : '');\n const checks = new Set([...$('f-check').options].map(o => o.value));\n for (const r of data.rows) if (!checks.has(r.check)) { checks.add(r.check); $('f-check').append(el('option', { value: r.check, text: r.check })); }\n $('f-rows').replaceChildren(...data.rows.map(r => el('tr', {}, el('td', { text: r.lang }), el('td', {}, el('span', { class: 'tag ' + r.severity, text: r.check })), el('td', { class: 'muted', text: r.key }), el('td', { class: 'text', text: r.text }), el('td', { class: 'text muted', text: r.note || '' }))));\n }\n $('f-run').addEventListener('click', async () => { try { await api('/api/check', {}); await follow(); await loadFindings(); } catch (e) { report(e); } });\n $('f-lang').addEventListener('change', () => loadFindings().catch(report));\n $('f-check').addEventListener('change', () => loadFindings().catch(report));\n\n // Review\n async function loadReview() {\n const rows = await api('/api/review' + ($('r-all').checked ? '?all=1' : ''));\n $('r-count').textContent = rows.length + ' entr' + (rows.length === 1 ? 'y' : 'ies');\n $('r-rows').replaceChildren(...rows.map(r => {\n const sel = r.lang + ':' + r.key;\n const act = (action, label) => { const b = el('button', { class: 'btn', text: label }); b.addEventListener('click', async () => { try { await api('/api/review', { action, selectors: [sel] }); await loadReview(); } catch (e) { report(e); } }); return b; };\n return el('tr', {}, el('td', { text: r.lang }), el('td', { class: 'muted', text: r.key }), el('td', { class: 'text', text: r.value ?? '' }),\n el('td', {}, el('span', { class: 'tag ' + (r.status === 'pending' ? 'pending-review' : 'approved'), text: r.status + ' \u00B7 ' + r.reason })),\n el('td', {}, ...(r.status === 'pending' ? [act('approve', 'Approve'), ' '] : []), act('release', 'Hand back')));\n }));\n }\n $('r-all').addEventListener('change', () => loadReview().catch(report));\n\n // Runs\n async function follow() {\n let from = 0;\n $('j-log').textContent = '';\n for (;;) {\n const job = await api('/api/job?from=' + from);\n if (!job) return;\n if (job.lines.length) { $('j-log').textContent += job.lines.join('\\n') + '\\n'; from = job.lineCount; $('j-log').scrollTop = 1e9; }\n $('busy').textContent = job.running ? 'Running: ' + job.kind : '';\n if (!job.running) {\n if (job.error) $('j-summary').replaceChildren(el('p', { class: 'error-box', text: job.error }));\n else if (job.summary) {\n const s = job.summary;\n const done = Object.entries(s.languages).map(([l, x]) => l + ': ' + (s.dryRun ? x.planned + ' to translate' : x.translated + ' translated, ' + x.revised + ' revised, ' + x.failed + ' failed')).join(' \u00B7 ');\n $('j-summary').replaceChildren(el('p', { text: (s.dryRun ? 'Dry run. ' : '') + s.requests + ' request(s), ' + s.tokens.toLocaleString('en') + ' tokens, ' + s.filesWritten.length + ' file(s) written.' + (s.stopReason ? ' Stopped: ' + s.stopReason : '') }), el('p', { class: 'muted', text: done }));\n }\n await loadStatus();\n return;\n }\n await new Promise(r => setTimeout(r, 1000));\n }\n }\n $('j-start').addEventListener('click', async () => {\n const mode = $('j-mode').value;\n if (mode !== 'dry-run' && !confirm('This calls the API and costs tokens. Start?')) return;\n try {\n $('j-summary').replaceChildren();\n await api('/api/run', { mode, groups: $('j-group').value ? [$('j-group').value] : [], languages: $('j-langs').value.split(',').map(s => s.trim()).filter(Boolean) });\n await follow();\n } catch (e) { report(e); }\n });\n\n loadStatus().catch(report);\n})();\n</script>\n</body>\n</html>";
|
package/dist/ui/page.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface page: one HTML document with inline CSS and JS, no dependencies. All data is
|
|
3
|
+
* inserted with textContent (translations contain HTML; it must never be interpreted here).
|
|
4
|
+
*/
|
|
5
|
+
export const PAGE = `<!doctype html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10
|
+
<title>localewarden</title>
|
|
11
|
+
<style>
|
|
12
|
+
:root { --bg:#f7f7f5; --panel:#fff; --text:#1d1f23; --muted:#646a73; --line:#e3e3df; --accent:#1f7a52; --accent-weak:#e4f3eb; --warn:#9a6700; --warn-weak:#fff4d6; --err:#b42318; --err-weak:#fde8e7; }
|
|
13
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#111316; --panel:#181b20; --text:#e6e8eb; --muted:#9aa1ab; --line:#2a2f37; --accent:#4cc38a; --accent-weak:#163325; --warn:#e3b341; --warn-weak:#33290f; --err:#ff7b72; --err-weak:#3a1717; } }
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin:0; font:14px/1.45 -apple-system, system-ui, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
|
|
16
|
+
header { display:flex; align-items:center; gap:24px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); position:sticky; top:0; z-index:2; flex-wrap:wrap; }
|
|
17
|
+
header h1 { font-size:16px; margin:0; }
|
|
18
|
+
nav { display:flex; gap:4px; flex-wrap:wrap; }
|
|
19
|
+
nav button { border:0; background:none; color:var(--muted); padding:6px 10px; border-radius:6px; cursor:pointer; font:inherit; }
|
|
20
|
+
nav button[aria-selected=true] { background:var(--accent-weak); color:var(--accent); font-weight:600; }
|
|
21
|
+
main { padding:20px; max-width:1200px; margin:0 auto; }
|
|
22
|
+
section[hidden] { display:none; }
|
|
23
|
+
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; margin-bottom:16px; }
|
|
24
|
+
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:12px; }
|
|
25
|
+
select, input, textarea, .btn { font:inherit; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:6px 8px; }
|
|
26
|
+
.btn { cursor:pointer; }
|
|
27
|
+
.btn.primary { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
28
|
+
.btn:disabled { opacity:.5; cursor:default; }
|
|
29
|
+
table { width:100%; border-collapse:collapse; }
|
|
30
|
+
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
31
|
+
th { color:var(--muted); font-weight:500; font-size:12px; }
|
|
32
|
+
td.num { text-align:right; font-variant-numeric:tabular-nums; }
|
|
33
|
+
.bar { height:6px; background:var(--line); border-radius:3px; overflow:hidden; min-width:80px; }
|
|
34
|
+
.bar > i { display:block; height:100%; background:var(--accent); }
|
|
35
|
+
.tag { display:inline-block; padding:1px 7px; border-radius:10px; font-size:12px; background:var(--line); color:var(--muted); white-space:nowrap; }
|
|
36
|
+
.tag.missing, .tag.error { background:var(--err-weak); color:var(--err); }
|
|
37
|
+
.tag.pending-review, .tag.warning { background:var(--warn-weak); color:var(--warn); }
|
|
38
|
+
.tag.approved, .tag.translated { background:var(--accent-weak); color:var(--accent); }
|
|
39
|
+
.text { white-space:pre-wrap; word-break:break-word; max-width:460px; }
|
|
40
|
+
.muted { color:var(--muted); }
|
|
41
|
+
textarea { width:100%; min-height:70px; }
|
|
42
|
+
pre.log { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; max-height:420px; overflow:auto; white-space:pre-wrap; font-size:12px; }
|
|
43
|
+
.error-box { color:var(--err); }
|
|
44
|
+
details.panel > summary { cursor:pointer; font-weight:600; display:flex; gap:12px; align-items:center; list-style:none; }
|
|
45
|
+
details.panel > summary::-webkit-details-marker { display:none; }
|
|
46
|
+
details.panel > summary::before { content:'▸'; color:var(--muted); }
|
|
47
|
+
details.panel[open] > summary::before { content:'▾'; }
|
|
48
|
+
details.panel > summary .bar { flex:0 0 160px; }
|
|
49
|
+
details.panel table { margin-top:12px; }
|
|
50
|
+
col.c-lang { width:90px; } col.c-num { width:130px; } col.c-review { width:170px; }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<header>
|
|
55
|
+
<h1>localewarden</h1>
|
|
56
|
+
<nav role="tablist">
|
|
57
|
+
<button role="tab" data-tab="overview" aria-selected="true">Overview</button>
|
|
58
|
+
<button role="tab" data-tab="strings" aria-selected="false">Strings</button>
|
|
59
|
+
<button role="tab" data-tab="findings" aria-selected="false">Check</button>
|
|
60
|
+
<button role="tab" data-tab="review" aria-selected="false">Review</button>
|
|
61
|
+
<button role="tab" data-tab="run" aria-selected="false">Run</button>
|
|
62
|
+
</nav>
|
|
63
|
+
<span id="busy" class="muted"></span>
|
|
64
|
+
</header>
|
|
65
|
+
<main>
|
|
66
|
+
<section id="overview">
|
|
67
|
+
<div class="panel"><div id="budget" class="muted"></div></div>
|
|
68
|
+
<div id="groups"></div>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
71
|
+
<section id="strings" hidden>
|
|
72
|
+
<div class="panel">
|
|
73
|
+
<div class="row">
|
|
74
|
+
<select id="s-group" aria-label="Group"></select>
|
|
75
|
+
<select id="s-lang" aria-label="Language"></select>
|
|
76
|
+
<select id="s-only" aria-label="Show">
|
|
77
|
+
<option value="">All strings</option><option value="missing">Missing</option><option value="pending-review">Hand edits to review</option>
|
|
78
|
+
</select>
|
|
79
|
+
<input id="s-q" placeholder="Search key, source or translation" size="32">
|
|
80
|
+
<button class="btn" id="s-load">Show</button>
|
|
81
|
+
<span id="s-count" class="muted"></span>
|
|
82
|
+
</div>
|
|
83
|
+
<table><thead><tr><th>Key</th><th>Source</th><th>Translation</th><th>Status</th><th></th></tr></thead><tbody id="s-rows"></tbody></table>
|
|
84
|
+
</div>
|
|
85
|
+
</section>
|
|
86
|
+
|
|
87
|
+
<section id="findings" hidden>
|
|
88
|
+
<div class="panel">
|
|
89
|
+
<div class="row">
|
|
90
|
+
<button class="btn primary" id="f-run">Run check</button>
|
|
91
|
+
<select id="f-lang" aria-label="Language"><option value="">All languages</option></select>
|
|
92
|
+
<select id="f-check" aria-label="Check"><option value="">All checks</option></select>
|
|
93
|
+
<span id="f-count" class="muted"></span>
|
|
94
|
+
</div>
|
|
95
|
+
<table><thead><tr><th>Lang</th><th>Check</th><th>Key</th><th>Translation</th><th>Note</th></tr></thead><tbody id="f-rows"></tbody></table>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section id="review" hidden>
|
|
100
|
+
<div class="panel">
|
|
101
|
+
<div class="row"><label><input type="checkbox" id="r-all"> include approved</label><span id="r-count" class="muted"></span></div>
|
|
102
|
+
<table><thead><tr><th>Lang</th><th>Key</th><th>Translation</th><th>Reason</th><th></th></tr></thead><tbody id="r-rows"></tbody></table>
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section id="run" hidden>
|
|
107
|
+
<div class="panel">
|
|
108
|
+
<div class="row">
|
|
109
|
+
<select id="j-mode" aria-label="Mode">
|
|
110
|
+
<option value="dry-run">Dry run (no API calls)</option>
|
|
111
|
+
<option value="translate">Translate new and changed strings</option>
|
|
112
|
+
<option value="fix-flagged">Fix what the check flags</option>
|
|
113
|
+
</select>
|
|
114
|
+
<select id="j-group" aria-label="Group"><option value="">All groups</option></select>
|
|
115
|
+
<input id="j-langs" placeholder="Languages, e.g. de,fr (empty = all)" size="28">
|
|
116
|
+
<button class="btn primary" id="j-start">Start</button>
|
|
117
|
+
</div>
|
|
118
|
+
<p class="muted">Translating uses your API key and costs tokens. Start with a dry run.</p>
|
|
119
|
+
<pre class="log" id="j-log"></pre>
|
|
120
|
+
<div id="j-summary"></div>
|
|
121
|
+
</div>
|
|
122
|
+
</section>
|
|
123
|
+
</main>
|
|
124
|
+
<script>
|
|
125
|
+
(() => {
|
|
126
|
+
const token = new URLSearchParams(location.search).get('t') || '';
|
|
127
|
+
const $ = id => document.getElementById(id);
|
|
128
|
+
const el = (tag, props = {}, ...children) => {
|
|
129
|
+
const node = document.createElement(tag);
|
|
130
|
+
for (const [k, v] of Object.entries(props)) {
|
|
131
|
+
if (k === 'class') node.className = v; else if (k === 'text') node.textContent = v; else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v);
|
|
132
|
+
}
|
|
133
|
+
for (const c of children) node.append(c);
|
|
134
|
+
return node;
|
|
135
|
+
};
|
|
136
|
+
async function api(path, body) {
|
|
137
|
+
const res = await fetch(path, { method: body ? 'POST' : 'GET', headers: { 'X-Localewarden-Token': token, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
|
138
|
+
const data = await res.json();
|
|
139
|
+
if (!res.ok) throw new Error(data && data.error || res.statusText);
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
const report = e => alert(e.message);
|
|
143
|
+
|
|
144
|
+
// Tabs
|
|
145
|
+
for (const b of document.querySelectorAll('nav button')) b.addEventListener('click', () => {
|
|
146
|
+
for (const o of document.querySelectorAll('nav button')) o.setAttribute('aria-selected', String(o === b));
|
|
147
|
+
for (const s of document.querySelectorAll('main section')) s.hidden = s.id !== b.dataset.tab;
|
|
148
|
+
if (b.dataset.tab === 'review') loadReview().catch(report);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let status = null;
|
|
152
|
+
async function loadStatus() {
|
|
153
|
+
status = await api('/api/status');
|
|
154
|
+
const u = status.usage, l = status.limits;
|
|
155
|
+
$('budget').textContent = 'Tokens today: ' + (u ? u.tokens.toLocaleString('en') : 0) + (l.dailyTokenBudget ? ' of ' + l.dailyTokenBudget.toLocaleString('en') + ' (daily budget)' : '') + ' · per run: ' + l.maxTokensPerRun.toLocaleString('en');
|
|
156
|
+
const box = $('groups'); box.replaceChildren();
|
|
157
|
+
for (const g of status.groups) {
|
|
158
|
+
const rows = g.languages.map(x => el('tr', {},
|
|
159
|
+
el('td', { text: x.lang }),
|
|
160
|
+
el('td', { class: 'num', text: x.translated + ' / ' + x.strings }),
|
|
161
|
+
el('td', {}, (() => { const b = el('div', { class: 'bar' }); b.append(el('i', { style: 'width:' + (x.strings ? Math.round(100 * x.translated / x.strings) : 100) + '%' })); return b; })()),
|
|
162
|
+
el('td', { class: 'num', text: x.pendingReview ? String(x.pendingReview) : '' })));
|
|
163
|
+
const total = g.languages.reduce((a, x) => a + x.strings, 0), done = g.languages.reduce((a, x) => a + x.translated, 0);
|
|
164
|
+
const pct = total ? Math.floor(1000 * done / total) / 10 : 100;
|
|
165
|
+
const pending = g.languages.reduce((a, x) => a + x.pendingReview, 0);
|
|
166
|
+
const bar = el('div', { class: 'bar' }); bar.append(el('i', { style: 'width:' + pct + '%' }));
|
|
167
|
+
const details = el('details', { class: 'panel' },
|
|
168
|
+
el('summary', {}, el('span', { text: (g.name || 'Project') }), bar, el('span', { class: 'muted', text: pct + '% · ' + g.languages.length + ' languages · ' + g.files + ' file(s) · source ' + g.sourceLanguage + (pending ? ' · ' + pending + ' to review' : '') })),
|
|
169
|
+
el('table', {}, el('colgroup', {}, el('col', { class: 'c-lang' }), el('col', { class: 'c-num' }), el('col', {}), el('col', { class: 'c-review' })),
|
|
170
|
+
el('thead', {}, el('tr', {}, el('th', { text: 'Language' }), el('th', { text: 'Translated' }), el('th', { text: '' }), el('th', { text: 'Hand edits to review' }))), el('tbody', {}, ...rows)));
|
|
171
|
+
if (status.groups.length === 1) details.open = true;
|
|
172
|
+
box.append(details);
|
|
173
|
+
}
|
|
174
|
+
const groupOptions = sel => { sel.replaceChildren(...status.groups.map((g, i) => el('option', { value: g.name || '', text: g.name || 'Project' }))); };
|
|
175
|
+
groupOptions($('s-group'));
|
|
176
|
+
$('j-group').replaceChildren(el('option', { value: '', text: 'All groups' }), ...status.groups.filter(g => g.name).map(g => el('option', { value: g.name, text: g.name })));
|
|
177
|
+
fillLangs();
|
|
178
|
+
const langs = [...new Set(status.groups.flatMap(g => g.languages.map(x => x.lang)))];
|
|
179
|
+
$('f-lang').replaceChildren(el('option', { value: '', text: 'All languages' }), ...langs.map(x => el('option', { value: x, text: x })));
|
|
180
|
+
$('busy').textContent = status.job && status.job.running ? 'Running: ' + status.job.kind : '';
|
|
181
|
+
}
|
|
182
|
+
function fillLangs() {
|
|
183
|
+
const g = status.groups.find(x => (x.name || '') === $('s-group').value) || status.groups[0];
|
|
184
|
+
$('s-lang').replaceChildren(...g.languages.map(x => el('option', { value: x.lang, text: x.lang })));
|
|
185
|
+
}
|
|
186
|
+
$('s-group').addEventListener('change', fillLangs);
|
|
187
|
+
|
|
188
|
+
// Strings
|
|
189
|
+
async function loadStrings() {
|
|
190
|
+
const p = new URLSearchParams({ group: $('s-group').value, lang: $('s-lang').value, q: $('s-q').value, only: $('s-only').value, limit: '300' });
|
|
191
|
+
const data = await api('/api/strings?' + p);
|
|
192
|
+
$('s-count').textContent = data.rows.length + ' of ' + data.total + ' shown';
|
|
193
|
+
$('s-rows').replaceChildren(...data.rows.map(r => {
|
|
194
|
+
const cell = el('td', { class: 'text', text: r.text ?? '' });
|
|
195
|
+
const edit = el('button', { class: 'btn', text: 'Edit' });
|
|
196
|
+
if (r.status === 'not-text') edit.disabled = true;
|
|
197
|
+
edit.addEventListener('click', () => {
|
|
198
|
+
const area = el('textarea', {}); area.value = r.text ?? '';
|
|
199
|
+
const save = el('button', { class: 'btn primary', text: 'Save' });
|
|
200
|
+
save.addEventListener('click', async () => {
|
|
201
|
+
try { const out = await api('/api/strings', { group: r.group, lang: $('s-lang').value, file: r.file, key: r.key, value: area.value }); cell.replaceChildren(document.createTextNode(out.text)); tag.className = 'tag approved'; tag.textContent = 'approved'; edit.disabled = false; }
|
|
202
|
+
catch (e) { report(e); }
|
|
203
|
+
});
|
|
204
|
+
cell.replaceChildren(area, save); edit.disabled = true;
|
|
205
|
+
});
|
|
206
|
+
const tag = el('span', { class: 'tag ' + r.status, text: r.status });
|
|
207
|
+
return el('tr', {}, el('td', { class: 'muted', text: r.key }), el('td', { class: 'text', text: r.source }), cell, el('td', {}, tag), el('td', {}, edit));
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
$('s-load').addEventListener('click', () => loadStrings().catch(report));
|
|
211
|
+
$('s-q').addEventListener('keydown', e => { if (e.key === 'Enter') loadStrings().catch(report); });
|
|
212
|
+
|
|
213
|
+
// Findings
|
|
214
|
+
async function loadFindings() {
|
|
215
|
+
const p = new URLSearchParams({ lang: $('f-lang').value, check: $('f-check').value, limit: '300' });
|
|
216
|
+
const data = await api('/api/findings?' + p);
|
|
217
|
+
if (!data.checked) { $('f-count').textContent = 'Not checked yet.'; return; }
|
|
218
|
+
$('f-count').textContent = data.total + ' finding(s)' + (data.total > data.rows.length ? ', first ' + data.rows.length + ' shown' : '');
|
|
219
|
+
const checks = new Set([...$('f-check').options].map(o => o.value));
|
|
220
|
+
for (const r of data.rows) if (!checks.has(r.check)) { checks.add(r.check); $('f-check').append(el('option', { value: r.check, text: r.check })); }
|
|
221
|
+
$('f-rows').replaceChildren(...data.rows.map(r => el('tr', {}, el('td', { text: r.lang }), el('td', {}, el('span', { class: 'tag ' + r.severity, text: r.check })), el('td', { class: 'muted', text: r.key }), el('td', { class: 'text', text: r.text }), el('td', { class: 'text muted', text: r.note || '' }))));
|
|
222
|
+
}
|
|
223
|
+
$('f-run').addEventListener('click', async () => { try { await api('/api/check', {}); await follow(); await loadFindings(); } catch (e) { report(e); } });
|
|
224
|
+
$('f-lang').addEventListener('change', () => loadFindings().catch(report));
|
|
225
|
+
$('f-check').addEventListener('change', () => loadFindings().catch(report));
|
|
226
|
+
|
|
227
|
+
// Review
|
|
228
|
+
async function loadReview() {
|
|
229
|
+
const rows = await api('/api/review' + ($('r-all').checked ? '?all=1' : ''));
|
|
230
|
+
$('r-count').textContent = rows.length + ' entr' + (rows.length === 1 ? 'y' : 'ies');
|
|
231
|
+
$('r-rows').replaceChildren(...rows.map(r => {
|
|
232
|
+
const sel = r.lang + ':' + r.key;
|
|
233
|
+
const act = (action, label) => { const b = el('button', { class: 'btn', text: label }); b.addEventListener('click', async () => { try { await api('/api/review', { action, selectors: [sel] }); await loadReview(); } catch (e) { report(e); } }); return b; };
|
|
234
|
+
return el('tr', {}, el('td', { text: r.lang }), el('td', { class: 'muted', text: r.key }), el('td', { class: 'text', text: r.value ?? '' }),
|
|
235
|
+
el('td', {}, el('span', { class: 'tag ' + (r.status === 'pending' ? 'pending-review' : 'approved'), text: r.status + ' · ' + r.reason })),
|
|
236
|
+
el('td', {}, ...(r.status === 'pending' ? [act('approve', 'Approve'), ' '] : []), act('release', 'Hand back')));
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
$('r-all').addEventListener('change', () => loadReview().catch(report));
|
|
240
|
+
|
|
241
|
+
// Runs
|
|
242
|
+
async function follow() {
|
|
243
|
+
let from = 0;
|
|
244
|
+
$('j-log').textContent = '';
|
|
245
|
+
for (;;) {
|
|
246
|
+
const job = await api('/api/job?from=' + from);
|
|
247
|
+
if (!job) return;
|
|
248
|
+
if (job.lines.length) { $('j-log').textContent += job.lines.join('\\n') + '\\n'; from = job.lineCount; $('j-log').scrollTop = 1e9; }
|
|
249
|
+
$('busy').textContent = job.running ? 'Running: ' + job.kind : '';
|
|
250
|
+
if (!job.running) {
|
|
251
|
+
if (job.error) $('j-summary').replaceChildren(el('p', { class: 'error-box', text: job.error }));
|
|
252
|
+
else if (job.summary) {
|
|
253
|
+
const s = job.summary;
|
|
254
|
+
const done = Object.entries(s.languages).map(([l, x]) => l + ': ' + (s.dryRun ? x.planned + ' to translate' : x.translated + ' translated, ' + x.revised + ' revised, ' + x.failed + ' failed')).join(' · ');
|
|
255
|
+
$('j-summary').replaceChildren(el('p', { text: (s.dryRun ? 'Dry run. ' : '') + s.requests + ' request(s), ' + s.tokens.toLocaleString('en') + ' tokens, ' + s.filesWritten.length + ' file(s) written.' + (s.stopReason ? ' Stopped: ' + s.stopReason : '') }), el('p', { class: 'muted', text: done }));
|
|
256
|
+
}
|
|
257
|
+
await loadStatus();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
$('j-start').addEventListener('click', async () => {
|
|
264
|
+
const mode = $('j-mode').value;
|
|
265
|
+
if (mode !== 'dry-run' && !confirm('This calls the API and costs tokens. Start?')) return;
|
|
266
|
+
try {
|
|
267
|
+
$('j-summary').replaceChildren();
|
|
268
|
+
await api('/api/run', { mode, groups: $('j-group').value ? [$('j-group').value] : [], languages: $('j-langs').value.split(',').map(s => s.trim()).filter(Boolean) });
|
|
269
|
+
await follow();
|
|
270
|
+
} catch (e) { report(e); }
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
loadStatus().catch(report);
|
|
274
|
+
})();
|
|
275
|
+
</script>
|
|
276
|
+
</body>
|
|
277
|
+
</html>`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import type { Model } from '../llm.js';
|
|
3
|
+
import { type Plugin } from '../plugins.js';
|
|
4
|
+
export interface UiOptions {
|
|
5
|
+
/** Port on 127.0.0.1; 0 picks a free one. Default 4848. */
|
|
6
|
+
port?: number;
|
|
7
|
+
plugins?: Plugin[];
|
|
8
|
+
/** Model for runs started from the interface (tests). */
|
|
9
|
+
model?: Model;
|
|
10
|
+
/** Access token; random by default. */
|
|
11
|
+
token?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UiServer {
|
|
14
|
+
url: string;
|
|
15
|
+
token: string;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Local web interface: overview, strings with inline editing, check findings, review list,
|
|
20
|
+
* and runs with live log. Listens on 127.0.0.1 only; every request needs the random token
|
|
21
|
+
* from the printed URL (page) or the X-Localewarden-Token header (API), and the Host header
|
|
22
|
+
* must be the local address, which blocks other websites and DNS rebinding.
|
|
23
|
+
*/
|
|
24
|
+
export declare function startUi(config: Config, options?: UiOptions): Promise<UiServer>;
|