altalandi 0.6.0 → 0.6.2

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/batch.mjs CHANGED
@@ -1,620 +1,769 @@
1
- // `altalandi <folder | url>`: refine a whole folder of documents, one web page
2
- // or a site in one command, and write the Icelandic next to the original.
3
- //
4
- // The order of things is the safety story:
5
- // 1. plan read every document and cut it into passages (segment.mjs,
6
- // web.mjs); nothing is sent yet
7
- // 2. estimate files, passages, characters and billing units, printed before
8
- // anything is sent
9
- // 3. consent a y/N question in a terminal, or --yes; with neither, nothing
10
- // is sent (--dry-run stops here on purpose)
11
- // 4. refine one call per passage, 2 at a time by default; a 429 waits for
12
- // Retry-After, a 402 stops the run cleanly
13
- // 5. write each file once all its passages are back; a passage whose
14
- // meaning check did not pass keeps its original text; nothing is
15
- // ever written inside the input
16
- // 6. report one Markdown summary of every file and every change
17
- //
18
- // Every side effect is injectable (io), so the tests need no network, terminal
19
- // or API key.
20
-
21
- import { existsSync, statSync } from "node:fs";
22
- import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
23
- import path from "node:path";
24
-
25
- import { refine } from "./client.mjs";
26
- import { writeBatchReport } from "./report.mjs";
27
- import { assemble, FORMATS, isConfigJson, repairCodeSpans, segment, segmentMarkdown, splitForApi } from "./segment.mjs";
28
- import { pagePath, readPage, readSite, safeSegment, userAgent } from "./web.mjs";
29
-
30
- export const EXIT_OK = 0;
31
- export const EXIT_ERROR = 1;
32
- export const EXIT_KEPT = 2; // finished, but some passages kept their original text
33
- export const EXIT_CREDIT = 4; // stopped: out of credit or allowance (as for a single text)
34
-
35
- // altalandi-reports: the reports of earlier runs are not documents to refine.
36
- const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "altalandi-reports"]);
37
- const MAX_FILE_BYTES = 5 * 1024 * 1024;
38
- const MAX_RETRY_WAIT_MS = 5 * 60_000; // a longer Retry-After means "come back later"
39
- const MAX_RATE_LIMIT_RETRIES = 8;
40
- const MAX_TRANSIENT_RETRIES = 2;
41
- const ERRORS_IN_A_ROW_TO_STOP = 3;
42
- const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
43
- const REGISTERS = ["formal", "neutral", "warm"];
44
-
45
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
- const fmt = (n) => Math.round(n).toLocaleString("en-US");
47
- const plural = (n, one, many) => `${fmt(n)} ${n === 1 ? one : many}`;
48
- const fold = (p) => (process.platform === "win32" || process.platform === "darwin" ? p.toLowerCase() : p);
49
- const samePath = (a, b) => fold(path.resolve(a)) === fold(path.resolve(b));
50
- const isInside = (child, parent) => {
51
- const rel = path.relative(fold(path.resolve(parent)), fold(path.resolve(child)));
52
- return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel);
53
- };
54
- const display = (p, cwd) => {
55
- const rel = path.relative(cwd, p);
56
- return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : p;
57
- };
58
- const preview = (s, n = 72) => {
59
- const one = String(s).replace(/\s+/g, " ").trim();
60
- return one.length > n ? `${one.slice(0, n - 1)}…` : one;
61
- };
62
-
63
- /** Every whitespace token is at least one billing unit; a long unbroken token
64
- * is one unit per 20 characters (the server's rule, docs/api/README.md). */
65
- const billingTokens = (text) => text.split(/\s+/).filter(Boolean)
66
- .reduce((n, t) => n + Math.max(1, Math.ceil(t.length / 20)), 0);
67
-
68
- /** The real path of p, or of its deepest existing ancestor plus the rest, so a
69
- * symlinked --out is compared where it really points. */
70
- async function realish(p) {
71
- const abs = path.resolve(p);
72
- const rest = [];
73
- let probe = abs;
74
- for (;;) {
75
- try {
76
- return path.join(await realpath(probe), ...rest.reverse());
77
- } catch {
78
- const parent = path.dirname(probe);
79
- if (parent === probe) return abs;
80
- rest.push(path.basename(probe));
81
- probe = parent;
82
- }
83
- }
84
- }
85
-
86
- async function walk(root) {
87
- const files = [];
88
- const visit = async (dir, rel) => {
89
- const entries = await readdir(dir, { withFileTypes: true });
90
- entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
91
- for (const e of entries) {
92
- // Hidden files and folders, and symlinks (no loops, no escaping the input).
93
- if (e.name.startsWith(".") || e.isSymbolicLink()) continue;
94
- const childRel = rel ? `${rel}/${e.name}` : e.name;
95
- if (e.isDirectory()) {
96
- if (!SKIP_DIRS.has(e.name)) await visit(path.join(dir, e.name), childRel);
97
- } else if (e.isFile() && FORMATS[path.extname(e.name).toLowerCase()]) {
98
- files.push(childRel);
99
- }
100
- }
101
- };
102
- await visit(root, "");
103
- return files;
104
- }
105
-
106
- function usage(message) {
107
- const e = new Error(message);
108
- e.usage = true;
109
- return e;
110
- }
111
-
112
- // ── plans ───────────────────────────────────────────────────────────────────
113
-
114
- async function planFolder(opts, io) {
115
- const cwd = io.cwd || process.cwd();
116
- const given = path.resolve(cwd, opts.target);
117
- let input;
118
- try { input = await realpath(given); } catch { throw usage(`No such folder: ${opts.target}`); }
119
- if (!statSync(input).isDirectory()) throw usage(`${opts.target} is not a folder.`);
120
- if (!path.basename(input)) throw usage("Give the output folder with --out.");
121
- const output = path.resolve(cwd, opts.out || path.join(path.dirname(given), `${path.basename(given)}-is`));
122
- const outputReal = await realish(output);
123
- if (samePath(outputReal, input) || isInside(outputReal, input) || isInside(input, outputReal)) {
124
- throw usage(`--out must be a separate folder, not the input folder, inside it or around it: ${output}`);
125
- }
126
- if (existsSync(output) && !statSync(output).isDirectory()) throw usage(`--out ${output} is a file; give a folder.`);
127
-
128
- const files = [];
129
- for (const rel of await walk(input)) {
130
- const source = path.join(input, ...rel.split("/"));
131
- const file = {
132
- path: rel, source, target: path.join(output, ...rel.split("/")), format: FORMATS[path.extname(rel).toLowerCase()],
133
- };
134
- file.bytes = await readFile(source);
135
- let text;
136
- if (file.bytes.length > MAX_FILE_BYTES) file.note = "larger than 5 MB";
137
- else {
138
- try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.bytes); } catch { file.note = "not UTF-8 text"; }
139
- }
140
- if (text !== undefined) {
141
- if (file.format === "json" && isConfigJson(path.basename(rel))) file.note = "configuration, not a string catalog";
142
- else {
143
- const seg = segment(text, file.format);
144
- file.text = text;
145
- file.units = seg.units;
146
- file.note = seg.note || (seg.units.length ? undefined : "nothing to refine");
147
- }
148
- }
149
- file.status = file.note ? "copy" : "refine";
150
- files.push(file);
151
- }
152
- return { mode: "folder", label: opts.target, input, output, outputIsFolder: true, files };
153
- }
154
-
155
- const withSource = (page) => `<!-- Source: ${page.url} -->\n\n${page.markdown}\n`;
156
-
157
- function pageFile(page, rel, target) {
158
- const text = withSource(page);
159
- const { units } = segmentMarkdown(text);
160
- return {
161
- path: rel, source: page.url, target, format: "md", text, units,
162
- status: units.length ? "refine" : "copy", note: units.length ? undefined : "nothing to refine",
163
- };
164
- }
165
-
166
- async function planWeb(opts, io, log) {
167
- const cwd = io.cwd || process.cwd();
168
- const web = { fetch: io.fetch, sleep: io.sleep, userAgent: userAgent(io.version || "0") };
169
- if (!opts.site) {
170
- const page = await readPage(opts.target, web);
171
- const last = pagePath(page.url).replace(/\.md$/, "").split("/").pop();
172
- const name = `${last === "index" ? safeSegment(new URL(page.url).host) : last}-is.md`;
173
- let target = path.resolve(cwd, opts.out || name);
174
- if (existsSync(target) && statSync(target).isDirectory()) target = path.join(target, name);
175
- return { mode: "page", label: opts.target, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
176
- }
177
- log(`Reading ${opts.target} (up to ${plural(opts.maxPages, "page", "pages")}) …`);
178
- const { pages, skipped, source } = await readSite(opts.target, {
179
- maxPages: opts.maxPages, io: web, onPage: (p) => log(` read ${p.url}`),
180
- });
181
- const output = path.resolve(cwd, opts.out || `${safeSegment(new URL(pages[0].url).host)}-is`);
182
- if (existsSync(output) && !statSync(output).isDirectory()) throw usage(`--out ${output} is a file; --site writes a folder.`);
183
- const taken = new Set();
184
- const files = pages.map((page) => {
185
- let rel = pagePath(page.url);
186
- for (let k = 2; taken.has(rel.toLowerCase()); k++) rel = pagePath(page.url).replace(/\.md$/, `-${k}.md`);
187
- taken.add(rel.toLowerCase());
188
- return pageFile(page, rel, path.join(output, ...rel.split("/")));
189
- });
190
- return { mode: "site", label: opts.target, output, outputIsFolder: true, files, skipped, discovery: source };
191
- }
192
-
193
- // ── calls ───────────────────────────────────────────────────────────────────
194
-
195
- /** One call per distinct piece of text: a passage seen twice is paid once. A
196
- * passage over the API limit becomes several pieces cut at sentences. */
197
- function buildCalls(files, maxChars) {
198
- const calls = new Map();
199
- let budgetUsed = 0;
200
- let overBudget = false;
201
- for (const file of files) {
202
- if (file.status !== "refine") continue;
203
- const fresh = new Map();
204
- for (const unit of file.units) {
205
- const { pieces, joins } = splitForApi(unit.text);
206
- unit.joins = joins;
207
- unit.calls = pieces.map((text) => calls.get(text) || fresh.get(text) || fresh.set(text, { text, units: [] }).get(text));
208
- }
209
- const added = [...fresh.values()].reduce((n, c) => n + c.text.length, 0);
210
- if (overBudget || (maxChars && budgetUsed + added > maxChars)) {
211
- overBudget = true;
212
- file.status = "over-budget";
213
- continue;
214
- }
215
- budgetUsed += added;
216
- for (const [text, call] of fresh) calls.set(text, call);
217
- for (const unit of file.units) {
218
- unit.file = file;
219
- for (const call of new Set(unit.calls)) call.units.push(unit);
220
- }
221
- }
222
- return [...calls.values()];
223
- }
224
-
225
- async function fetchPricing(baseUrl, fetchImpl) {
226
- const controller = new AbortController();
227
- const timer = setTimeout(() => controller.abort(), 5000);
228
- try {
229
- const res = await fetchImpl(`${baseUrl}/v1/pricing`, { headers: { accept: "application/json" }, signal: controller.signal });
230
- if (!res.ok) return null;
231
- const p = JSON.parse(await res.text());
232
- const lane = p?.tiers?.standard?.fast;
233
- if (!Number.isFinite(p?.minimumBillableUnits) || !lane) return null;
234
- return {
235
- minUnits: p.minimumBillableUnits,
236
- multiplier: Number(lane.multiplier) || 1,
237
- iskPer1000: Number(lane.iskPer1000Units),
238
- freeDaily: Number(p.accountFreeDailyUnits) || 0,
239
- };
240
- } catch {
241
- return null;
242
- } finally {
243
- clearTimeout(timer);
244
- }
245
- }
246
-
247
- function estimateLines(plan, calls, pricing, opts) {
248
- const refining = plan.files.filter((f) => f.status === "refine");
249
- const passages = refining.reduce((n, f) => n + f.units.length, 0);
250
- const chars = calls.reduce((n, c) => n + c.text.length, 0);
251
- const lines = [
252
- `${plural(refining.length, "file", "files")}, ${plural(passages, "passage", "passages")}, about ${fmt(chars)} characters`
253
- + `${calls.length !== passages ? ` (${plural(calls.length, "call", "calls")}: repeated passages are sent once)` : ""}.`,
254
- ];
255
- const copied = plan.files.filter((f) => f.status === "copy").length;
256
- if (copied) lines.push(`${plural(copied, "more file has", "more files have")} nothing to refine and ${copied === 1 ? "is" : "are"} copied unchanged.`);
257
- const over = plan.files.filter((f) => f.status === "over-budget");
258
- if (over.length) {
259
- lines.push(`--max-chars ${fmt(opts.maxChars)}: ${plural(over.length, "file does", "files do")} not fit and will not be sent `
260
- + `(from ${over[0].path} on).`);
261
- }
262
- if (pricing && calls.length) {
263
- const units = calls.reduce((n, c) => n + Math.max(pricing.minUnits, billingTokens(c.text)), 0) * pricing.multiplier;
264
- const isk = Number.isFinite(pricing.iskPer1000) ? `, about ${fmt((units * pricing.iskPer1000) / 1000)} kr if paid from credit` : "";
265
- lines.push(`Billing: about ${fmt(units)} units (at least ${fmt(pricing.minUnits)} a call)${isk}.`
266
- + `${pricing.freeDaily ? ` The free allowance is ${fmt(pricing.freeDaily)} units a day.` : ""}`);
267
- }
268
- return lines;
269
- }
270
-
271
- // ── the run ─────────────────────────────────────────────────────────────────
272
-
273
- const VERDICT = {
274
- passed: "meaning verified",
275
- changed: "the meaning may have changed",
276
- reverted: "rewrite refused; original returned",
277
- skipped: "meaning not checked",
278
- unavailable: "the meaning check did not finish",
279
- };
280
-
281
- /** Decide a passage once all its calls are back. */
282
- function settle(unit) {
283
- const results = unit.calls.map((c) => c.result);
284
- const failed = results.find((r) => r.error);
285
- if (failed) return Object.assign(unit, { status: "failed", reason: failed.error });
286
- const data = results.map((r) => r.data);
287
- const refined = data.map((d) => String(d.output ?? "").trim())
288
- .reduce((acc, piece, k) => (k ? acc + (unit.joins[k - 1] ?? " ") + piece : piece), "");
289
- const verified = data.every((d) => d.meaningCheck === "passed" && d.meaningPreserved === true);
290
- const notPassed = data.find((d) => d.meaningCheck !== "passed");
291
- const check = notPassed ? (notPassed.meaningCheck ?? null) : "passed";
292
- Object.assign(unit, {
293
- after: refined,
294
- meaningCheck: check,
295
- meaningPreserved: data.every((d) => d.meaningPreserved === true) ? true : data.some((d) => d.meaningPreserved === false) ? false : null,
296
- ids: data.map((d) => d.id).filter(Boolean),
297
- });
298
- const unverified = check !== "passed"
299
- ? VERDICT[check] || `meaning check: ${check ?? "missing"}`
300
- : `meaning not verified (meaningPreserved: ${unit.meaningPreserved})`;
301
- if (refined.replace(/\s+/g, " ") === unit.text.replace(/\s+/g, " ")) {
302
- // The engine kept the text. That is a refusal of the meaning check only
303
- // when it says so; "already fine" and its own quality veto are not.
304
- const refused = data.find((d) => !d.candidateVetoFired && (d.meaningCheck === "reverted" || d.rejectionReason));
305
- return Object.assign(unit, refused
306
- ? { status: "kept", reason: refused.rejectionReason || VERDICT.reverted }
307
- : { status: "unchanged" });
308
- }
309
- const mojibake = data.find((d) => d.quality?.encodingArtifacts?.length);
310
- if (mojibake) return Object.assign(unit, { status: "kept", reason: "encoding artifacts in the output" });
311
- if (!verified) return Object.assign(unit, { status: "kept", reason: unverified });
312
- const repaired = repairCodeSpans(unit.text, refined);
313
- const placed = unit.place(repaired);
314
- if (!placed.ok) return Object.assign(unit, { status: "kept", reason: placed.reason });
315
- return Object.assign(unit, { status: "changed", value: placed.value, after: repaired });
316
- }
317
-
318
- function passageSummary(unit, n) {
319
- return {
320
- n,
321
- status: unit.status || "pending",
322
- before: unit.text,
323
- ...(unit.after !== undefined ? { after: unit.after } : {}),
324
- ...(unit.meaningCheck !== undefined ? { meaningCheck: unit.meaningCheck, meaningPreserved: unit.meaningPreserved } : {}),
325
- ...(unit.reason ? { reason: unit.reason } : {}),
326
- ...(unit.ids?.length ? { ids: unit.ids } : {}),
327
- };
328
- }
329
-
330
- /**
331
- * The whole command. Returns an exit code:
332
- * 0 done · 2 done, some passages kept their original · 4 out of credit · 1 error
333
- */
334
- export async function runBatch(opts, io = {}) {
335
- const log = io.log || ((m) => process.stderr.write(`${m}\n`));
336
- const print = io.print || ((m) => process.stdout.write(`${m}\n`));
337
- const wait = io.sleep || sleep;
338
- const now = io.now || Date.now;
339
- const cwd = io.cwd || process.cwd();
340
- const fetchImpl = io.fetch || globalThis.fetch;
341
- const isUrl = /^https?:\/\//i.test(opts.target);
342
-
343
- if (opts.fast) {
344
- log("--fast skips the meaning check, and a folder or site run only writes verified changes. Leave --fast out.");
345
- return EXIT_ERROR;
346
- }
347
- if (opts.review) {
348
- log("--review is for one text. A folder or site run lists every change in its report: run it with --dry-run first, then --yes.");
349
- return EXIT_ERROR;
350
- }
351
- const concurrency = opts.concurrency ?? 2;
352
- if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) { log("--concurrency must be 1, 2, 3 or 4."); return EXIT_ERROR; }
353
- if (opts.maxChars !== undefined && !(Number.isInteger(opts.maxChars) && opts.maxChars > 0)) { log("--max-chars must be a positive whole number."); return EXIT_ERROR; }
354
- const maxPages = opts.maxPages ?? 20;
355
- if (!Number.isInteger(maxPages) || maxPages < 1) { log("--max-pages must be a positive whole number."); return EXIT_ERROR; }
356
- if (opts.site && !isUrl) { log("--site needs a URL, such as https://example.com"); return EXIT_ERROR; }
357
- // Checked here, not by the API: a bad value would otherwise fail every call.
358
- if (!DOMAINS.includes(opts.domain ?? "general")) { log(`--domain must be one of: ${DOMAINS.join(", ")}`); return EXIT_ERROR; }
359
- if (!REGISTERS.includes(opts.register ?? "neutral")) { log(`--register must be one of: ${REGISTERS.join(", ")}`); return EXIT_ERROR; }
360
- // Before reading anything, so a site is not crawled for nothing. --dry-run
361
- // sends nothing and needs no key.
362
- if (!opts.dryRun && !opts.key) { log("Not logged in. Run once: npx altalandi login (or set ALTALANDI_API_KEY)"); return EXIT_ERROR; }
363
-
364
- let plan;
365
- try {
366
- plan = isUrl ? await planWeb({ ...opts, maxPages }, { ...io, fetch: fetchImpl, cwd }, log) : await planFolder(opts, { cwd });
367
- } catch (e) {
368
- log(e.usage ? e.message : `Could not read ${opts.target}: ${e.message}`);
369
- return EXIT_ERROR;
370
- }
371
- const calls = buildCalls(plan.files, opts.maxChars);
372
- const baseUrl = (opts.baseUrl || "https://altalandi.is/api").replace(/\/+$/, "");
373
- const where = `${plan.label} → ${display(plan.output, cwd)}`;
374
- const refining = () => plan.files.filter((f) => f.status === "refine");
375
-
376
- if (opts.maxChars && !refining().length && plan.files.some((f) => f.status === "over-budget")) {
377
- log(`Nothing fits in --max-chars ${fmt(opts.maxChars)}: ${plan.files.find((f) => f.status === "over-budget").path} alone is more.`);
378
- return EXIT_ERROR;
379
- }
380
- // The public tariff: no key, no text sent, nothing charged.
381
- const pricing = calls.length ? await fetchPricing(baseUrl, fetchImpl) : null;
382
- const estimate = estimateLines(plan, calls, pricing, opts);
383
-
384
- if (opts.dryRun) {
385
- if (opts.json) {
386
- print(JSON.stringify({
387
- dryRun: true, input: plan.label, output: plan.output, estimate,
388
- files: plan.files.map((f) => ({
389
- path: f.path, source: f.source, status: f.status, ...(f.note ? { note: f.note } : {}),
390
- passages: (f.units || []).map((u) => u.text),
391
- })),
392
- }, null, 2));
393
- return EXIT_OK;
394
- }
395
- print(`Dry run: nothing is sent and nothing is written.\n${where}\n`);
396
- for (const f of plan.files) {
397
- if (f.status === "copy") { print(`${f.path}: copied unchanged (${f.note})`); continue; }
398
- const chars = f.units.reduce((n, u) => n + u.text.length, 0);
399
- print(`${f.path}: ${plural(f.units.length, "passage", "passages")}, ${fmt(chars)} characters${f.status === "over-budget" ? " (over --max-chars, not sent)" : ""}`);
400
- f.units.forEach((u, k) => print(` ${k + 1}. ${preview(u.text)}`));
401
- }
402
- print(`\n${estimate.join("\n")}`);
403
- return EXIT_OK;
404
- }
405
-
406
- if (!refining().length) {
407
- log(`${where}\nNothing to refine: no passages in ${plural(plan.files.length, "file", "files")}.`);
408
- } else {
409
- log(`${where}\n${estimate.join("\n")}`);
410
- if (!opts.yes) {
411
- const interactive = io.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
412
- if (!interactive) {
413
- log("Nothing was sent. Without a terminal to ask in, pass --yes to confirm (see the estimate above, or run with --dry-run first).");
414
- return EXIT_ERROR;
415
- }
416
- const confirm = io.confirm || (async (q) => (await import("./update.mjs")).ask(q));
417
- const n = refining().reduce((k, f) => k + f.units.length, 0);
418
- if (!(await confirm(`Send ${plural(n, "passage", "passages")} to Altalandi?`))) {
419
- log("Nothing was sent.");
420
- return EXIT_ERROR;
421
- }
422
- }
423
- }
424
-
425
- // Never write inside the input, whatever the paths say.
426
- const write = async (target, data) => {
427
- if (plan.input && (samePath(target, plan.input) || isInside(target, plan.input))) {
428
- throw new Error(`refusing to write inside the input: ${target}`);
429
- }
430
- if (plan.outputIsFolder ? !isInside(target, plan.output) : !samePath(target, plan.output)) {
431
- throw new Error(`refusing to write outside the output: ${target}`);
432
- }
433
- await mkdir(path.dirname(target), { recursive: true });
434
- await writeFile(target, data);
435
- };
436
-
437
- const state = { stop: null, pausedUntil: 0, errorsInARow: 0 };
438
- const stop = (reason, message) => { if (!state.stop) state.stop = { reason, message }; };
439
-
440
- for (const f of plan.files.filter((x) => x.status === "copy")) {
441
- try { await write(f.target, f.bytes ?? f.text); } catch (e) { stop("error", `Could not write ${f.target}: ${e.message}`); }
442
- }
443
-
444
- const requestOptions = {
445
- apiKey: opts.key, baseUrl, domain: opts.domain, register: opts.register,
446
- // Only verified changes are written, so ask for the check explicitly.
447
- preserveMeaning: true,
448
- ...(opts.noStore ? { contribute: false } : {}),
449
- ...(io.fetch ? { fetch: io.fetch } : {}),
450
- };
451
-
452
- const pause = async () => {
453
- for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
454
- };
455
-
456
- const callOnce = async (call) => {
457
- let rateLimited = 0;
458
- let transient = 0;
459
- for (;;) {
460
- await pause();
461
- if (state.stop) return { skipped: true };
462
- try {
463
- return { data: await refine(call.text, requestOptions) };
464
- } catch (e) {
465
- if (e.status === 429) {
466
- rateLimited++;
467
- const ms = Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : Math.min(60_000, 2000 * 2 ** (rateLimited - 1));
468
- if (ms > MAX_RETRY_WAIT_MS) {
469
- stop("credit", `${e.message} Try again in about ${Math.ceil(ms / 60_000)} min.`);
470
- return { skipped: true };
471
- }
472
- if (rateLimited > MAX_RATE_LIMIT_RETRIES) {
473
- stop("error", `Still rate limited after ${MAX_RATE_LIMIT_RETRIES} retries: ${e.message}`);
474
- return { skipped: true };
475
- }
476
- if (now() + ms > state.pausedUntil) {
477
- state.pausedUntil = now() + ms;
478
- log(`Rate limited (429): waiting ${Math.ceil(ms / 1000)} s, as the server asked.`);
479
- }
480
- continue;
481
- }
482
- if (e.status === 402) { stop("credit", e.message); return { skipped: true }; }
483
- if (e.status === 401 || e.status === 403) { stop("error", `${e.message} → The key is invalid or revoked. Run: npx altalandi login`); return { skipped: true }; }
484
- // Network errors, timeouts and server errors get two more tries.
485
- if ((!e.status || e.status === 408 || e.status >= 500) && transient < MAX_TRANSIENT_RETRIES) {
486
- transient++;
487
- await wait(1000 * 2 ** transient);
488
- continue;
489
- }
490
- return { error: e.message };
491
- }
492
- }
493
- };
494
-
495
- const progress = (file) => {
496
- const u = file.units;
497
- const changed = u.filter((x) => x.status === "changed").length;
498
- const kept = u.filter((x) => x.status === "kept").length;
499
- const failed = u.filter((x) => x.status === "failed").length;
500
- const extra = [kept && `${kept} kept as they were (check did not pass)`, failed && `${failed} failed`].filter(Boolean).join(", ");
501
- log(`${kept || failed ? "!" : "✓"} ${file.path}: ${changed} of ${plural(u.length, "passage", "passages")} changed${extra ? `; ${extra}` : ""}`);
502
- };
503
-
504
- const finish = async (file) => {
505
- file.status = "writing"; // before the first await, so no other worker writes it too
506
- const values = new Map(file.units.filter((u) => u.status === "changed").map((u) => [u, u.value]));
507
- try {
508
- await write(file.target, values.size ? assemble(file.text, file.units, values) : (file.bytes ?? file.text));
509
- file.status = values.size ? "written" : "unchanged";
510
- progress(file);
511
- } catch (e) {
512
- file.status = "failed";
513
- file.note = e.message;
514
- stop("error", `Could not write ${file.target}: ${e.message}`);
515
- }
516
- };
517
-
518
- const done = async (call) => {
519
- if (call.result.skipped) return;
520
- if (call.result.error) {
521
- state.errorsInARow++;
522
- if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${call.result.error}`);
523
- } else {
524
- state.errorsInARow = 0;
525
- }
526
- for (const unit of call.units) {
527
- if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
528
- settle(unit);
529
- const file = unit.file;
530
- if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
531
- }
532
- };
533
-
534
- const hadWork = refining().length > 0;
535
- const queue = [...calls];
536
- const worker = async () => {
537
- while (queue.length && !state.stop) {
538
- const call = queue.shift();
539
- call.result = await callOnce(call);
540
- await done(call);
541
- }
542
- };
543
- await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, calls.length)) }, worker));
544
-
545
- for (const f of plan.files) if (f.status === "refine") f.status = "unfinished";
546
-
547
- // ── results ───────────────────────────────────────────────────────────────
548
- const files = plan.files.map((f) => ({
549
- path: f.path,
550
- ...(f.source && isUrl ? { source: f.source } : {}),
551
- output: display(f.target, cwd),
552
- status: f.status,
553
- ...(f.note ? { note: f.note } : {}),
554
- passages: (f.units || []).map((u, k) => passageSummary(u, k + 1)),
555
- }));
556
- const passages = files.flatMap((f) => f.passages);
557
- const count = (s) => passages.filter((p) => p.status === s).length;
558
- const summary = {
559
- input: plan.label,
560
- output: display(plan.output, cwd),
561
- mode: plan.mode,
562
- domain: opts.domain,
563
- register: opts.register,
564
- finishedAt: new Date(now()).toISOString(),
565
- stopped: state.stop,
566
- totals: {
567
- files: files.length,
568
- written: files.filter((f) => f.status === "written").length,
569
- unchanged: files.filter((f) => f.status === "unchanged").length,
570
- copied: files.filter((f) => f.status === "copy").length,
571
- unfinished: files.filter((f) => f.status === "unfinished").length,
572
- overBudget: files.filter((f) => f.status === "over-budget").length,
573
- passages: passages.length,
574
- changed: count("changed"),
575
- unchangedPassages: count("unchanged"),
576
- kept: count("kept"),
577
- failed: count("failed"),
578
- characters: calls.filter((c) => c.result && !c.result.skipped).reduce((n, c) => n + c.text.length, 0),
579
- },
580
- ...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
581
- files,
582
- };
583
-
584
- let exit = EXIT_OK;
585
- if (summary.totals.kept) exit = EXIT_KEPT;
586
- if (summary.totals.failed || summary.totals.overBudget || state.stop) exit = EXIT_ERROR;
587
- if (state.stop?.reason === "credit") exit = EXIT_CREDIT;
588
- summary.exitCode = exit;
589
-
590
- if (passages.length) {
591
- // Next to the output by default (./altalandi-reports for `altalandi ./docs`),
592
- // and never inside the input, where the next run would pick it up.
593
- const beside = path.join(path.dirname(plan.output), "altalandi-reports");
594
- let reportDir = path.resolve(cwd, opts.reportDir || process.env.ALTALANDI_REPORT_DIR || beside);
595
- if (plan.input && (samePath(reportDir, plan.input) || isInside(reportDir, plan.input))) reportDir = beside;
596
- try {
597
- summary.report = await writeBatchReport(summary, reportDir);
598
- } catch (e) {
599
- log(`Could not write the report: ${e.message}`);
600
- }
601
- }
602
-
603
- const t = summary.totals;
604
- if (state.stop) {
605
- log(`\nStopped: ${state.stop.message}`);
606
- if (state.stop.reason === "credit") log("→ Add credit at https://altalandi.is/portal, then run the same command for the rest.");
607
- const finished = files.filter((f) => ["written", "unchanged"].includes(f.status)).map((f) => f.path);
608
- const unfinished = files.filter((f) => f.status === "unfinished").map((f) => f.path);
609
- log(`Finished (${finished.length}): ${finished.join(", ") || "none"}`);
610
- log(`Not finished, not written (${unfinished.length}): ${unfinished.join(", ") || "none"}`);
611
- }
612
- log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
613
- + `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
614
- + (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
615
- if (t.kept) log(`${plural(t.kept, "passage keeps", "passages keep")} the original text because a check did not pass; the report lists ${t.kept === 1 ? "it" : "them"}.`);
616
- if (t.overBudget) log(`${plural(t.overBudget, "file was", "files were")} left out for --max-chars.`);
617
- if (summary.report) log(`Report: ${display(summary.report, cwd)}`);
618
- if (opts.json) print(JSON.stringify(summary, null, 2));
619
- return exit;
620
- }
1
+ // `altalandi <folder | url>`: refine a whole folder of documents, one web page
2
+ // or a site in one command, and write the Icelandic next to the original.
3
+ //
4
+ // The order of things is the safety story:
5
+ // 1. plan read every document and cut it into passages (segment.mjs,
6
+ // web.mjs); nothing is sent yet
7
+ // 2. estimate files, passages and the characters billed, with the price,
8
+ // printed before anything is sent
9
+ // 3. consent a y/N question in a terminal, or --yes; with neither, nothing
10
+ // is sent (--dry-run stops here on purpose)
11
+ // 4. refine short passages go together in batch requests (/v1/refine/batch),
12
+ // longer ones one call each; 2 requests at a time by default; a
13
+ // 429 waits for Retry-After, a 402 stops the run cleanly
14
+ // 5. write each file once all its passages are back; a passage whose
15
+ // meaning check did not pass keeps its original text; nothing is
16
+ // ever written inside the input
17
+ // 6. report one Markdown summary of every file and every change
18
+ //
19
+ // Every side effect is injectable (io), so the tests need no network, terminal
20
+ // or API key.
21
+
22
+ import { existsSync, statSync } from "node:fs";
23
+ import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
24
+ import path from "node:path";
25
+
26
+ import { refine, refineBatch } from "./client.mjs";
27
+ import { CHARS_PER_WORD, charsEquivalent, formatChars, formatIsk, writeBatchReport } from "./report.mjs";
28
+ import { assemble, FORMATS, isConfigJson, repairCodeSpans, segment, segmentMarkdown, splitForApi } from "./segment.mjs";
29
+ import { pagePath, readPage, readSite, safeSegment, userAgent } from "./web.mjs";
30
+
31
+ export const EXIT_OK = 0;
32
+ export const EXIT_ERROR = 1;
33
+ export const EXIT_KEPT = 2; // finished, but some passages kept their original text
34
+ export const EXIT_CREDIT = 4; // stopped: out of credit or allowance (as for a single text)
35
+
36
+ // altalandi-reports: the reports of earlier runs are not documents to refine.
37
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "altalandi-reports"]);
38
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
39
+ const MAX_RETRY_WAIT_MS = 5 * 60_000; // a longer Retry-After means "come back later"
40
+ const MAX_RATE_LIMIT_RETRIES = 8;
41
+ const MAX_TRANSIENT_RETRIES = 2;
42
+ const ERRORS_IN_A_ROW_TO_STOP = 3;
43
+ // Short passages go to /v1/refine/batch: each is billed at least 130 characters
44
+ // there instead of the 250-character call minimum, which is paid once per
45
+ // request. A request
46
+ // stays small enough to come back well inside the web proxy's ~300 s timeout
47
+ // (the server refines 4 at a time: ~40 short items take a minute or two).
48
+ export const BATCH_PASSAGE_MAX_CHARS = 1500;
49
+ export const BATCH_MAX_ITEMS = 40;
50
+ export const BATCH_MAX_CHARS = 6000;
51
+ // When /v1/pricing says neither (minimumBillableChars / batchItemMinimumChars)
52
+ // nor the word units of an older engine.
53
+ const MIN_BILLABLE_CHARS = 250;
54
+ const BATCH_ITEM_MIN_CHARS = 130;
55
+ const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
56
+ const REGISTERS = ["formal", "neutral", "warm"];
57
+
58
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
+ const fmt = (n) => Math.round(n).toLocaleString("en-US");
60
+ const plural = (n, one, many) => `${fmt(n)} ${n === 1 ? one : many}`;
61
+ const fold = (p) => (process.platform === "win32" || process.platform === "darwin" ? p.toLowerCase() : p);
62
+ const samePath = (a, b) => fold(path.resolve(a)) === fold(path.resolve(b));
63
+ const isInside = (child, parent) => {
64
+ const rel = path.relative(fold(path.resolve(parent)), fold(path.resolve(child)));
65
+ return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel);
66
+ };
67
+ const display = (p, cwd) => {
68
+ const rel = path.relative(cwd, p);
69
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : p;
70
+ };
71
+ const preview = (s, n = 72) => {
72
+ const one = String(s).replace(/\s+/g, " ").trim();
73
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one;
74
+ };
75
+
76
+ /** Billable characters: every Unicode code point of the trimmed text, spaces,
77
+ * punctuation and line breaks included (the server's rule). */
78
+ export const billableChars = (text) => [...String(text).trim()].length;
79
+
80
+ /** The real path of p, or of its deepest existing ancestor plus the rest, so a
81
+ * symlinked --out is compared where it really points. */
82
+ async function realish(p) {
83
+ const abs = path.resolve(p);
84
+ const rest = [];
85
+ let probe = abs;
86
+ for (;;) {
87
+ try {
88
+ return path.join(await realpath(probe), ...rest.reverse());
89
+ } catch {
90
+ const parent = path.dirname(probe);
91
+ if (parent === probe) return abs;
92
+ rest.push(path.basename(probe));
93
+ probe = parent;
94
+ }
95
+ }
96
+ }
97
+
98
+ async function walk(root) {
99
+ const files = [];
100
+ const visit = async (dir, rel) => {
101
+ const entries = await readdir(dir, { withFileTypes: true });
102
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
103
+ for (const e of entries) {
104
+ // Hidden files and folders, and symlinks (no loops, no escaping the input).
105
+ if (e.name.startsWith(".") || e.isSymbolicLink()) continue;
106
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
107
+ if (e.isDirectory()) {
108
+ if (!SKIP_DIRS.has(e.name)) await visit(path.join(dir, e.name), childRel);
109
+ } else if (e.isFile() && FORMATS[path.extname(e.name).toLowerCase()]) {
110
+ files.push(childRel);
111
+ }
112
+ }
113
+ };
114
+ await visit(root, "");
115
+ return files;
116
+ }
117
+
118
+ function usage(message) {
119
+ const e = new Error(message);
120
+ e.usage = true;
121
+ return e;
122
+ }
123
+
124
+ // ── plans ───────────────────────────────────────────────────────────────────
125
+
126
+ async function planFolder(opts, io) {
127
+ const cwd = io.cwd || process.cwd();
128
+ const given = path.resolve(cwd, opts.target);
129
+ let input;
130
+ try { input = await realpath(given); } catch { throw usage(`No such folder: ${opts.target}`); }
131
+ if (!statSync(input).isDirectory()) throw usage(`${opts.target} is not a folder.`);
132
+ if (!path.basename(input)) throw usage("Give the output folder with --out.");
133
+ const output = path.resolve(cwd, opts.out || path.join(path.dirname(given), `${path.basename(given)}-is`));
134
+ const outputReal = await realish(output);
135
+ if (samePath(outputReal, input) || isInside(outputReal, input) || isInside(input, outputReal)) {
136
+ throw usage(`--out must be a separate folder, not the input folder, inside it or around it: ${output}`);
137
+ }
138
+ if (existsSync(output) && !statSync(output).isDirectory()) throw usage(`--out ${output} is a file; give a folder.`);
139
+
140
+ const files = [];
141
+ for (const rel of await walk(input)) {
142
+ const source = path.join(input, ...rel.split("/"));
143
+ const file = {
144
+ path: rel, source, target: path.join(output, ...rel.split("/")), format: FORMATS[path.extname(rel).toLowerCase()],
145
+ };
146
+ file.bytes = await readFile(source);
147
+ let text;
148
+ if (file.bytes.length > MAX_FILE_BYTES) file.note = "larger than 5 MB";
149
+ else {
150
+ try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.bytes); } catch { file.note = "not UTF-8 text"; }
151
+ }
152
+ if (text !== undefined) {
153
+ if (file.format === "json" && isConfigJson(path.basename(rel))) file.note = "configuration, not a string catalog";
154
+ else {
155
+ const seg = segment(text, file.format);
156
+ file.text = text;
157
+ file.units = seg.units;
158
+ file.note = seg.note || (seg.units.length ? undefined : "nothing to refine");
159
+ }
160
+ }
161
+ file.status = file.note ? "copy" : "refine";
162
+ files.push(file);
163
+ }
164
+ return { mode: "folder", label: opts.target, input, output, outputIsFolder: true, files };
165
+ }
166
+
167
+ const withSource = (page) => `<!-- Source: ${page.url} -->\n\n${page.markdown}\n`;
168
+
169
+ function pageFile(page, rel, target) {
170
+ const text = withSource(page);
171
+ const { units } = segmentMarkdown(text);
172
+ return {
173
+ path: rel, source: page.url, target, format: "md", text, units,
174
+ status: units.length ? "refine" : "copy", note: units.length ? undefined : "nothing to refine",
175
+ };
176
+ }
177
+
178
+ async function planWeb(opts, io, log) {
179
+ const cwd = io.cwd || process.cwd();
180
+ const web = { fetch: io.fetch, sleep: io.sleep, userAgent: userAgent(io.version || "0") };
181
+ if (!opts.site) {
182
+ const page = await readPage(opts.target, web);
183
+ const last = pagePath(page.url).replace(/\.md$/, "").split("/").pop();
184
+ const name = `${last === "index" ? safeSegment(new URL(page.url).host) : last}-is.md`;
185
+ let target = path.resolve(cwd, opts.out || name);
186
+ if (existsSync(target) && statSync(target).isDirectory()) target = path.join(target, name);
187
+ return { mode: "page", label: opts.target, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
188
+ }
189
+ log(`Reading ${opts.target} (up to ${plural(opts.maxPages, "page", "pages")}) …`);
190
+ const { pages, skipped, source } = await readSite(opts.target, {
191
+ maxPages: opts.maxPages, io: web, onPage: (p) => log(` read ${p.url}`),
192
+ });
193
+ const output = path.resolve(cwd, opts.out || `${safeSegment(new URL(pages[0].url).host)}-is`);
194
+ if (existsSync(output) && !statSync(output).isDirectory()) throw usage(`--out ${output} is a file; --site writes a folder.`);
195
+ const taken = new Set();
196
+ const files = pages.map((page) => {
197
+ let rel = pagePath(page.url);
198
+ for (let k = 2; taken.has(rel.toLowerCase()); k++) rel = pagePath(page.url).replace(/\.md$/, `-${k}.md`);
199
+ taken.add(rel.toLowerCase());
200
+ return pageFile(page, rel, path.join(output, ...rel.split("/")));
201
+ });
202
+ return { mode: "site", label: opts.target, output, outputIsFolder: true, files, skipped, discovery: source };
203
+ }
204
+
205
+ // ── calls ───────────────────────────────────────────────────────────────────
206
+
207
+ /** One call per distinct piece of text: a passage seen twice is paid once. A
208
+ * passage over the API limit becomes several pieces cut at sentences. */
209
+ function buildCalls(files, maxChars) {
210
+ const calls = new Map();
211
+ let budgetUsed = 0;
212
+ let overBudget = false;
213
+ for (const file of files) {
214
+ if (file.status !== "refine") continue;
215
+ const fresh = new Map();
216
+ for (const unit of file.units) {
217
+ const { pieces, joins } = splitForApi(unit.text);
218
+ unit.joins = joins;
219
+ unit.calls = pieces.map((text) => calls.get(text) || fresh.get(text) || fresh.set(text, { text, units: [] }).get(text));
220
+ }
221
+ const added = [...fresh.values()].reduce((n, c) => n + c.text.length, 0);
222
+ if (overBudget || (maxChars && budgetUsed + added > maxChars)) {
223
+ overBudget = true;
224
+ file.status = "over-budget";
225
+ continue;
226
+ }
227
+ budgetUsed += added;
228
+ for (const [text, call] of fresh) calls.set(text, call);
229
+ for (const unit of file.units) {
230
+ unit.file = file;
231
+ for (const call of new Set(unit.calls)) call.units.push(unit);
232
+ }
233
+ }
234
+ return [...calls.values()];
235
+ }
236
+
237
+ /** The requests of a run: short calls together in batches of at most
238
+ * BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone. */
239
+ export function groupCalls(calls) {
240
+ const jobs = [];
241
+ let open = null;
242
+ for (const call of calls) {
243
+ if (call.text.length > BATCH_PASSAGE_MAX_CHARS) { jobs.push({ batch: false, calls: [call] }); continue; }
244
+ if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
245
+ if (!open) { open = { batch: true, calls: [], chars: 0 }; jobs.push(open); }
246
+ open.calls.push(call);
247
+ open.chars += call.text.length;
248
+ }
249
+ return jobs;
250
+ }
251
+
252
+ /** Characters a request is billed: the call minimum for one text; for a batch,
253
+ * each item at least the item minimum, the call minimum once for the request. */
254
+ function jobChars(job, pricing) {
255
+ if (!job.batch) return Math.max(pricing.minChars, billableChars(job.calls[0].text));
256
+ const items = job.calls.reduce((n, c) => n + Math.max(pricing.itemMinChars, billableChars(c.text)), 0);
257
+ return Math.max(pricing.minChars, items);
258
+ }
259
+
260
+ const fin = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
261
+
262
+ async function fetchPricing(baseUrl, fetchImpl) {
263
+ const controller = new AbortController();
264
+ const timer = setTimeout(() => controller.abort(), 5000);
265
+ try {
266
+ const res = await fetchImpl(`${baseUrl}/v1/pricing`, { headers: { accept: "application/json" }, signal: controller.signal });
267
+ if (!res.ok) return null;
268
+ const p = JSON.parse(await res.text());
269
+ const lane = p?.tiers?.standard?.fast;
270
+ if (!lane || typeof lane !== "object") return null;
271
+ // Characters (billingUnit "character"); an engine from before the switch
272
+ // gives word units, converted with charsPerWord.
273
+ const cpw = fin(p.charsPerWord) > 0 ? p.charsPerWord : CHARS_PER_WORD;
274
+ const chars = (c, u) => fin(p[c]) ?? (fin(p[u]) != null ? Math.round(p[u] * cpw) : null);
275
+ // The tier's price already carries its weight, as iskPer1000Units did.
276
+ const per100 = fin(lane.iskPer100Chars) ?? (fin(lane.iskPer1000Units) != null ? lane.iskPer1000Units / 10 / cpw : null);
277
+ return {
278
+ minChars: chars("minimumBillableChars", "minimumBillableUnits") ?? MIN_BILLABLE_CHARS,
279
+ itemMinChars: chars("batchItemMinimumChars", "batchItemMinimumUnits") ?? BATCH_ITEM_MIN_CHARS,
280
+ multiplier: Number(lane.multiplier) || 1,
281
+ iskPerChar: per100 != null ? per100 / 100 : null,
282
+ freeDaily: chars("accountFreeDailyChars", "accountFreeDailyUnits") ?? 0,
283
+ charsPerWord: cpw,
284
+ };
285
+ } catch {
286
+ return null;
287
+ } finally {
288
+ clearTimeout(timer);
289
+ }
290
+ }
291
+
292
+ function estimateLines(plan, calls, jobs, pricing, opts) {
293
+ const refining = plan.files.filter((f) => f.status === "refine");
294
+ const passages = refining.reduce((n, f) => n + f.units.length, 0);
295
+ const chars = calls.reduce((n, c) => n + c.text.length, 0);
296
+ const lines = [
297
+ `${plural(refining.length, "file", "files")}, ${plural(passages, "passage", "passages")}, about ${fmt(chars)} characters`
298
+ + `${calls.length !== passages ? ` (${fmt(calls.length)} to send: repeated passages are sent once)` : ""}.`,
299
+ ];
300
+ const batches = jobs.filter((j) => j.batch);
301
+ if (calls.length) {
302
+ const together = batches.reduce((n, j) => n + j.calls.length, 0);
303
+ const alone = jobs.length - batches.length;
304
+ lines.push(`${plural(jobs.length, "request", "requests")}: `
305
+ + [together && `${plural(together, "short passage", "short passages")} together in ${plural(batches.length, "batch", "batches")}`,
306
+ alone && `${plural(alone, "longer passage", "longer passages")} one by one`].filter(Boolean).join(", ") + ".");
307
+ }
308
+ const copied = plan.files.filter((f) => f.status === "copy").length;
309
+ if (copied) lines.push(`${plural(copied, "more file has", "more files have")} nothing to refine and ${copied === 1 ? "is" : "are"} copied unchanged.`);
310
+ const over = plan.files.filter((f) => f.status === "over-budget");
311
+ if (over.length) {
312
+ lines.push(`--max-chars ${fmt(opts.maxChars)}: ${plural(over.length, "file does", "files do")} not fit and will not be sent `
313
+ + `(from ${over[0].path} on).`);
314
+ }
315
+ if (pricing && calls.length) {
316
+ // In Icelandic, as on the site: characters, one equivalent and krónur;
317
+ // never the rate per 100 characters.
318
+ const raw = jobs.reduce((n, j) => n + jobChars(j, pricing), 0);
319
+ const billed = raw * pricing.multiplier;
320
+ const isk = pricing.iskPerChar != null ? raw * pricing.iskPerChar : null;
321
+ const price = isk != null ? `, um ${formatIsk(isk >= 10 ? Math.round(isk) : isk)} ef greitt er af inneign` : "";
322
+ lines.push(`Kostnaður: um ${formatChars(billed)} (${charsEquivalent(billed)})${price}.`);
323
+ const rule = `Hvert kall telst minnst ${formatChars(pricing.minChars)}`
324
+ + (batches.length ? `; margir stuttir textar í einu kalli minnst ${formatChars(pricing.itemMinChars)} hver.` : ".");
325
+ lines.push(rule + (pricing.freeDaily ? ` ${formatChars(pricing.freeDaily)} á dag eru ókeypis.` : ""));
326
+ }
327
+ return lines;
328
+ }
329
+
330
+ // ── the run ─────────────────────────────────────────────────────────────────
331
+
332
+ const VERDICT = {
333
+ passed: "meaning verified",
334
+ changed: "the meaning may have changed",
335
+ reverted: "rewrite refused; original returned",
336
+ skipped: "meaning not checked",
337
+ unavailable: "the meaning check did not finish",
338
+ };
339
+
340
+ /** Decide a passage once all its calls are back. */
341
+ function settle(unit) {
342
+ const results = unit.calls.map((c) => c.result);
343
+ const failed = results.find((r) => r.error);
344
+ if (failed) return Object.assign(unit, { status: "failed", reason: failed.error });
345
+ const data = results.map((r) => r.data);
346
+ const refined = data.map((d) => String(d.output ?? "").trim())
347
+ .reduce((acc, piece, k) => (k ? acc + (unit.joins[k - 1] ?? " ") + piece : piece), "");
348
+ const verified = data.every((d) => d.meaningCheck === "passed" && d.meaningPreserved === true);
349
+ const notPassed = data.find((d) => d.meaningCheck !== "passed");
350
+ const check = notPassed ? (notPassed.meaningCheck ?? null) : "passed";
351
+ Object.assign(unit, {
352
+ after: refined,
353
+ meaningCheck: check,
354
+ meaningPreserved: data.every((d) => d.meaningPreserved === true) ? true : data.some((d) => d.meaningPreserved === false) ? false : null,
355
+ ids: data.map((d) => d.id).filter(Boolean),
356
+ });
357
+ const unverified = check !== "passed"
358
+ ? VERDICT[check] || `meaning check: ${check ?? "missing"}`
359
+ : `meaning not verified (meaningPreserved: ${unit.meaningPreserved})`;
360
+ if (refined.replace(/\s+/g, " ") === unit.text.replace(/\s+/g, " ")) {
361
+ // The engine kept the text. That is a refusal of the meaning check only
362
+ // when it says so; "already fine" and its own quality veto are not.
363
+ const refused = data.find((d) => !d.candidateVetoFired && (d.meaningCheck === "reverted" || d.rejectionReason));
364
+ return Object.assign(unit, refused
365
+ ? { status: "kept", reason: refused.rejectionReason || VERDICT.reverted }
366
+ : { status: "unchanged" });
367
+ }
368
+ const mojibake = data.find((d) => d.quality?.encodingArtifacts?.length);
369
+ if (mojibake) return Object.assign(unit, { status: "kept", reason: "encoding artifacts in the output" });
370
+ if (!verified) return Object.assign(unit, { status: "kept", reason: unverified });
371
+ const repaired = repairCodeSpans(unit.text, refined);
372
+ const placed = unit.place(repaired);
373
+ if (!placed.ok) return Object.assign(unit, { status: "kept", reason: placed.reason });
374
+ return Object.assign(unit, { status: "changed", value: placed.value, after: repaired });
375
+ }
376
+
377
+ function passageSummary(unit, n) {
378
+ return {
379
+ n,
380
+ status: unit.status || "pending",
381
+ before: unit.text,
382
+ ...(unit.after !== undefined ? { after: unit.after } : {}),
383
+ ...(unit.meaningCheck !== undefined ? { meaningCheck: unit.meaningCheck, meaningPreserved: unit.meaningPreserved } : {}),
384
+ ...(unit.reason ? { reason: unit.reason } : {}),
385
+ ...(unit.ids?.length ? { ids: unit.ids } : {}),
386
+ };
387
+ }
388
+
389
+ /**
390
+ * The whole command. Returns an exit code:
391
+ * 0 done · 2 done, some passages kept their original · 4 out of credit · 1 error
392
+ */
393
+ export async function runBatch(opts, io = {}) {
394
+ const log = io.log || ((m) => process.stderr.write(`${m}\n`));
395
+ const print = io.print || ((m) => process.stdout.write(`${m}\n`));
396
+ const wait = io.sleep || sleep;
397
+ const now = io.now || Date.now;
398
+ const cwd = io.cwd || process.cwd();
399
+ const fetchImpl = io.fetch || globalThis.fetch;
400
+ const isUrl = /^https?:\/\//i.test(opts.target);
401
+
402
+ if (opts.fast) {
403
+ log("--fast skips the meaning check, and a folder or site run only writes verified changes. Leave --fast out.");
404
+ return EXIT_ERROR;
405
+ }
406
+ if (opts.review) {
407
+ log("--review is for one text. A folder or site run lists every change in its report: run it with --dry-run first, then --yes.");
408
+ return EXIT_ERROR;
409
+ }
410
+ const concurrency = opts.concurrency ?? 2;
411
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) { log("--concurrency must be 1, 2, 3 or 4."); return EXIT_ERROR; }
412
+ if (opts.maxChars !== undefined && !(Number.isInteger(opts.maxChars) && opts.maxChars > 0)) { log("--max-chars must be a positive whole number."); return EXIT_ERROR; }
413
+ const maxPages = opts.maxPages ?? 20;
414
+ if (!Number.isInteger(maxPages) || maxPages < 1) { log("--max-pages must be a positive whole number."); return EXIT_ERROR; }
415
+ if (opts.site && !isUrl) { log("--site needs a URL, such as https://example.com"); return EXIT_ERROR; }
416
+ // Checked here, not by the API: a bad value would otherwise fail every call.
417
+ if (!DOMAINS.includes(opts.domain ?? "general")) { log(`--domain must be one of: ${DOMAINS.join(", ")}`); return EXIT_ERROR; }
418
+ if (!REGISTERS.includes(opts.register ?? "neutral")) { log(`--register must be one of: ${REGISTERS.join(", ")}`); return EXIT_ERROR; }
419
+ // Before reading anything, so a site is not crawled for nothing. --dry-run
420
+ // sends nothing and needs no key.
421
+ if (!opts.dryRun && !opts.key) { log("Not logged in. Run once: npx altalandi login (or set ALTALANDI_API_KEY)"); return EXIT_ERROR; }
422
+
423
+ let plan;
424
+ try {
425
+ plan = isUrl ? await planWeb({ ...opts, maxPages }, { ...io, fetch: fetchImpl, cwd }, log) : await planFolder(opts, { cwd });
426
+ } catch (e) {
427
+ log(e.usage ? e.message : `Could not read ${opts.target}: ${e.message}`);
428
+ return EXIT_ERROR;
429
+ }
430
+ const calls = buildCalls(plan.files, opts.maxChars);
431
+ const jobs = groupCalls(calls);
432
+ const baseUrl = (opts.baseUrl || "https://altalandi.is/api").replace(/\/+$/, "");
433
+ const where = `${plan.label} → ${display(plan.output, cwd)}`;
434
+ const refining = () => plan.files.filter((f) => f.status === "refine");
435
+
436
+ if (opts.maxChars && !refining().length && plan.files.some((f) => f.status === "over-budget")) {
437
+ log(`Nothing fits in --max-chars ${fmt(opts.maxChars)}: ${plan.files.find((f) => f.status === "over-budget").path} alone is more.`);
438
+ return EXIT_ERROR;
439
+ }
440
+ // The public tariff: no key, no text sent, nothing charged.
441
+ const pricing = calls.length ? await fetchPricing(baseUrl, fetchImpl) : null;
442
+ const estimate = estimateLines(plan, calls, jobs, pricing, opts);
443
+
444
+ if (opts.dryRun) {
445
+ if (opts.json) {
446
+ print(JSON.stringify({
447
+ dryRun: true, input: plan.label, output: plan.output, estimate,
448
+ files: plan.files.map((f) => ({
449
+ path: f.path, source: f.source, status: f.status, ...(f.note ? { note: f.note } : {}),
450
+ passages: (f.units || []).map((u) => u.text),
451
+ })),
452
+ }, null, 2));
453
+ return EXIT_OK;
454
+ }
455
+ print(`Dry run: nothing is sent and nothing is written.\n${where}\n`);
456
+ for (const f of plan.files) {
457
+ if (f.status === "copy") { print(`${f.path}: copied unchanged (${f.note})`); continue; }
458
+ const chars = f.units.reduce((n, u) => n + u.text.length, 0);
459
+ print(`${f.path}: ${plural(f.units.length, "passage", "passages")}, ${fmt(chars)} characters${f.status === "over-budget" ? " (over --max-chars, not sent)" : ""}`);
460
+ f.units.forEach((u, k) => print(` ${k + 1}. ${preview(u.text)}`));
461
+ }
462
+ print(`\n${estimate.join("\n")}`);
463
+ return EXIT_OK;
464
+ }
465
+
466
+ if (!refining().length) {
467
+ log(`${where}\nNothing to refine: no passages in ${plural(plan.files.length, "file", "files")}.`);
468
+ } else {
469
+ log(`${where}\n${estimate.join("\n")}`);
470
+ if (!opts.yes) {
471
+ const interactive = io.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
472
+ if (!interactive) {
473
+ log("Nothing was sent. Without a terminal to ask in, pass --yes to confirm (see the estimate above, or run with --dry-run first).");
474
+ return EXIT_ERROR;
475
+ }
476
+ const confirm = io.confirm || (async (q) => (await import("./update.mjs")).ask(q));
477
+ const n = refining().reduce((k, f) => k + f.units.length, 0);
478
+ if (!(await confirm(`Send ${plural(n, "passage", "passages")} to Altalandi?`))) {
479
+ log("Nothing was sent.");
480
+ return EXIT_ERROR;
481
+ }
482
+ }
483
+ }
484
+
485
+ // Never write inside the input, whatever the paths say.
486
+ const write = async (target, data) => {
487
+ if (plan.input && (samePath(target, plan.input) || isInside(target, plan.input))) {
488
+ throw new Error(`refusing to write inside the input: ${target}`);
489
+ }
490
+ if (plan.outputIsFolder ? !isInside(target, plan.output) : !samePath(target, plan.output)) {
491
+ throw new Error(`refusing to write outside the output: ${target}`);
492
+ }
493
+ await mkdir(path.dirname(target), { recursive: true });
494
+ await writeFile(target, data);
495
+ };
496
+
497
+ const state = { stop: null, pausedUntil: 0, errorsInARow: 0, noBatch: false, billed: null, billedChars: null, approx: false };
498
+ // What a response says it billed: characters, or the word units of an
499
+ // engine from before the switch (converted, and marked as approximate).
500
+ const bill = (data) => {
501
+ const b = data?.billing || {};
502
+ const units = fin(b.units) ?? fin(data?.units);
503
+ if (units != null) state.billed = (state.billed ?? 0) + units;
504
+ const chars = fin(b.characters) ?? fin(data?.characters);
505
+ if (chars == null && units == null) return;
506
+ if (chars == null) state.approx = true;
507
+ state.billedChars = (state.billedChars ?? 0) + (chars ?? units * (pricing?.charsPerWord ?? CHARS_PER_WORD));
508
+ };
509
+ const stop = (reason, message) => { if (!state.stop) state.stop = { reason, message }; };
510
+
511
+ for (const f of plan.files.filter((x) => x.status === "copy")) {
512
+ try { await write(f.target, f.bytes ?? f.text); } catch (e) { stop("error", `Could not write ${f.target}: ${e.message}`); }
513
+ }
514
+
515
+ const requestOptions = {
516
+ apiKey: opts.key, baseUrl, domain: opts.domain, register: opts.register,
517
+ // Only verified changes are written, so ask for the check explicitly.
518
+ preserveMeaning: true,
519
+ ...(opts.noStore ? { contribute: false } : {}),
520
+ ...(io.fetch ? { fetch: io.fetch } : {}),
521
+ };
522
+
523
+ const pause = async () => {
524
+ for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
525
+ };
526
+
527
+ // One request with the run's retry rules: `send` is one /v1/refine or one
528
+ // /v1/refine/batch call. A batch endpoint the server does not have (404/405,
529
+ // an older self-hosted engine) answers { unsupported }.
530
+ const attempt = async (send, { batch = false } = {}) => {
531
+ let rateLimited = 0;
532
+ let transient = 0;
533
+ for (;;) {
534
+ await pause();
535
+ if (state.stop) return { skipped: true };
536
+ try {
537
+ return { data: await send() };
538
+ } catch (e) {
539
+ if (batch && (e.status === 404 || e.status === 405)) return { unsupported: true };
540
+ if (e.status === 429) {
541
+ rateLimited++;
542
+ const ms = Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : Math.min(60_000, 2000 * 2 ** (rateLimited - 1));
543
+ if (ms > MAX_RETRY_WAIT_MS) {
544
+ stop("credit", `${e.message} Try again in about ${Math.ceil(ms / 60_000)} min.`);
545
+ return { skipped: true };
546
+ }
547
+ if (rateLimited > MAX_RATE_LIMIT_RETRIES) {
548
+ stop("error", `Still rate limited after ${MAX_RATE_LIMIT_RETRIES} retries: ${e.message}`);
549
+ return { skipped: true };
550
+ }
551
+ if (now() + ms > state.pausedUntil) {
552
+ state.pausedUntil = now() + ms;
553
+ log(`Rate limited (429): waiting ${Math.ceil(ms / 1000)} s, as the server asked.`);
554
+ }
555
+ continue;
556
+ }
557
+ if (e.status === 402) { stop("credit", e.message); return { skipped: true }; }
558
+ if (e.status === 401 || e.status === 403) { stop("error", `${e.message} → The key is invalid or revoked. Run: npx altalandi login`); return { skipped: true }; }
559
+ // Network errors, timeouts and server errors get two more tries.
560
+ if ((!e.status || e.status === 408 || e.status >= 500) && transient < MAX_TRANSIENT_RETRIES) {
561
+ transient++;
562
+ await wait(1000 * 2 ** transient);
563
+ continue;
564
+ }
565
+ return { error: e.message };
566
+ }
567
+ }
568
+ };
569
+
570
+ const progress = (file) => {
571
+ const u = file.units;
572
+ const changed = u.filter((x) => x.status === "changed").length;
573
+ const kept = u.filter((x) => x.status === "kept").length;
574
+ const failed = u.filter((x) => x.status === "failed").length;
575
+ const extra = [kept && `${kept} kept as they were (check did not pass)`, failed && `${failed} failed`].filter(Boolean).join(", ");
576
+ log(`${kept || failed ? "!" : "✓"} ${file.path}: ${changed} of ${plural(u.length, "passage", "passages")} changed${extra ? `; ${extra}` : ""}`);
577
+ };
578
+
579
+ const finish = async (file) => {
580
+ file.status = "writing"; // before the first await, so no other worker writes it too
581
+ const values = new Map(file.units.filter((u) => u.status === "changed").map((u) => [u, u.value]));
582
+ try {
583
+ await write(file.target, values.size ? assemble(file.text, file.units, values) : (file.bytes ?? file.text));
584
+ file.status = values.size ? "written" : "unchanged";
585
+ progress(file);
586
+ } catch (e) {
587
+ file.status = "failed";
588
+ file.note = e.message;
589
+ stop("error", `Could not write ${file.target}: ${e.message}`);
590
+ }
591
+ };
592
+
593
+ const countError = (error) => {
594
+ state.errorsInARow++;
595
+ if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${error}`);
596
+ };
597
+
598
+ const done = async (job) => {
599
+ if (job.calls.some((c) => c.result?.skipped)) return;
600
+ if (job.failed) countError(job.failed); // a whole request failed: one error, not one per passage
601
+ else {
602
+ for (const call of job.calls) {
603
+ if (call.result.error) countError(call.result.error);
604
+ else state.errorsInARow = 0;
605
+ }
606
+ }
607
+ for (const call of job.calls) {
608
+ for (const unit of call.units) {
609
+ if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
610
+ settle(unit);
611
+ const file = unit.file;
612
+ if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
613
+ }
614
+ }
615
+ };
616
+
617
+ const single = async (call) => {
618
+ const r = await attempt(() => refine(call.text, requestOptions));
619
+ if (r.data) bill(r.data);
620
+ return r;
621
+ };
622
+
623
+ /** One batch request; every item is mapped back to its call by id. */
624
+ const sendBatch = async (job) => {
625
+ const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
626
+ const r = await attempt(() => refineBatch(items, requestOptions), { batch: true });
627
+ if (!r.data) return r;
628
+ const data = r.data;
629
+ bill(data);
630
+ const byId = new Map(data.items.map((it) => [String(it.id), it]));
631
+ const artifacts = data.quality?.encodingArtifacts || [];
632
+ job.calls.forEach((call, k) => {
633
+ const item = byId.get(String(k));
634
+ if (!item) call.result = { error: "the batch response has no result for this passage" };
635
+ else if (item.error) call.result = { error: String(item.error) };
636
+ else {
637
+ // The batch's quality block covers every item; keep the mojibake guard per item.
638
+ const own = artifacts.filter((a) => a && String(item.output).includes(a));
639
+ call.result = { data: { ...item, id: data.id || undefined, quality: { encodingArtifacts: own } } };
640
+ }
641
+ });
642
+ return { done: true };
643
+ };
644
+
645
+ const alone = (job) => job.calls.map((call) => ({ batch: false, calls: [call] }));
646
+ const hadWork = refining().length > 0;
647
+ const queue = [...jobs];
648
+ const worker = async () => {
649
+ while (queue.length && !state.stop) {
650
+ const job = queue.shift();
651
+ if (job.batch && state.noBatch) { queue.unshift(...alone(job)); continue; }
652
+ if (!job.batch) {
653
+ job.calls[0].result = await single(job.calls[0]);
654
+ } else {
655
+ const r = await sendBatch(job);
656
+ if (r.unsupported) {
657
+ if (!state.noBatch) {
658
+ state.noBatch = true;
659
+ log("Note: this server has no batch endpoint (/v1/refine/batch), so every passage is sent on its own "
660
+ + `(each call is billed at least ${fmt(pricing?.minChars ?? MIN_BILLABLE_CHARS)} characters).`);
661
+ }
662
+ queue.unshift(...alone(job));
663
+ fill(); // one batch became many calls: use every worker for them
664
+ continue;
665
+ }
666
+ if (!r.done) {
667
+ for (const call of job.calls) call.result = r;
668
+ if (r.error) job.failed = r.error;
669
+ }
670
+ }
671
+ await done(job);
672
+ }
673
+ };
674
+ const running = [];
675
+ let active = 0;
676
+ function fill() {
677
+ while (active < concurrency && queue.length && !state.stop) {
678
+ active++;
679
+ running.push(worker().finally(() => { active--; }));
680
+ }
681
+ }
682
+ fill();
683
+ while (running.length) await Promise.all(running.splice(0));
684
+
685
+ for (const f of plan.files) if (f.status === "refine") f.status = "unfinished";
686
+
687
+ // ── results ───────────────────────────────────────────────────────────────
688
+ const files = plan.files.map((f) => ({
689
+ path: f.path,
690
+ ...(f.source && isUrl ? { source: f.source } : {}),
691
+ output: display(f.target, cwd),
692
+ status: f.status,
693
+ ...(f.note ? { note: f.note } : {}),
694
+ passages: (f.units || []).map((u, k) => passageSummary(u, k + 1)),
695
+ }));
696
+ const passages = files.flatMap((f) => f.passages);
697
+ const count = (s) => passages.filter((p) => p.status === s).length;
698
+ const summary = {
699
+ input: plan.label,
700
+ output: display(plan.output, cwd),
701
+ mode: plan.mode,
702
+ domain: opts.domain,
703
+ register: opts.register,
704
+ finishedAt: new Date(now()).toISOString(),
705
+ stopped: state.stop,
706
+ totals: {
707
+ files: files.length,
708
+ written: files.filter((f) => f.status === "written").length,
709
+ unchanged: files.filter((f) => f.status === "unchanged").length,
710
+ copied: files.filter((f) => f.status === "copy").length,
711
+ unfinished: files.filter((f) => f.status === "unfinished").length,
712
+ overBudget: files.filter((f) => f.status === "over-budget").length,
713
+ passages: passages.length,
714
+ changed: count("changed"),
715
+ unchangedPassages: count("unchanged"),
716
+ kept: count("kept"),
717
+ failed: count("failed"),
718
+ characters: calls.filter((c) => c.result && !c.result.skipped).reduce((n, c) => n + c.text.length, 0),
719
+ // What the server says it billed, summed over the responses (null: none
720
+ // said). billedCharacters is what is shown; units is the word equivalent.
721
+ billedCharacters: state.billedChars == null ? null
722
+ : state.approx ? Math.round(state.billedChars / 10) * 10 : Math.round(state.billedChars),
723
+ ...(state.approx ? { billedCharactersApprox: true } : {}),
724
+ units: state.billed,
725
+ },
726
+ ...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
727
+ files,
728
+ };
729
+
730
+ let exit = EXIT_OK;
731
+ if (summary.totals.kept) exit = EXIT_KEPT;
732
+ if (summary.totals.failed || summary.totals.overBudget || state.stop) exit = EXIT_ERROR;
733
+ if (state.stop?.reason === "credit") exit = EXIT_CREDIT;
734
+ summary.exitCode = exit;
735
+
736
+ if (passages.length) {
737
+ // Next to the output by default (./altalandi-reports for `altalandi ./docs`),
738
+ // and never inside the input, where the next run would pick it up.
739
+ const beside = path.join(path.dirname(plan.output), "altalandi-reports");
740
+ let reportDir = path.resolve(cwd, opts.reportDir || process.env.ALTALANDI_REPORT_DIR || beside);
741
+ if (plan.input && (samePath(reportDir, plan.input) || isInside(reportDir, plan.input))) reportDir = beside;
742
+ try {
743
+ summary.report = await writeBatchReport(summary, reportDir);
744
+ } catch (e) {
745
+ log(`Could not write the report: ${e.message}`);
746
+ }
747
+ }
748
+
749
+ const t = summary.totals;
750
+ if (state.stop) {
751
+ log(`\nStopped: ${state.stop.message}`);
752
+ if (state.stop.reason === "credit") log("→ Add credit at https://altalandi.is/stjornbord/inneign, then run the same command for the rest.");
753
+ const finished = files.filter((f) => ["written", "unchanged"].includes(f.status)).map((f) => f.path);
754
+ const unfinished = files.filter((f) => f.status === "unfinished").map((f) => f.path);
755
+ log(`Finished (${finished.length}): ${finished.join(", ") || "none"}`);
756
+ log(`Not finished, not written (${unfinished.length}): ${unfinished.join(", ") || "none"}`);
757
+ }
758
+ log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
759
+ + `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
760
+ + (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
761
+ if (t.billedCharacters != null) {
762
+ log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
763
+ }
764
+ if (t.kept) log(`${plural(t.kept, "passage keeps", "passages keep")} the original text because a check did not pass; the report lists ${t.kept === 1 ? "it" : "them"}.`);
765
+ if (t.overBudget) log(`${plural(t.overBudget, "file was", "files were")} left out for --max-chars.`);
766
+ if (summary.report) log(`Report: ${display(summary.report, cwd)}`);
767
+ if (opts.json) print(JSON.stringify(summary, null, 2));
768
+ return exit;
769
+ }