altalandi 0.6.2 → 0.6.3
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 +14 -0
- package/altalandi.mjs +32 -0
- package/batch.mjs +79 -17
- package/client.d.ts +200 -179
- package/client.mjs +260 -258
- package/package.json +1 -1
- package/report.mjs +6 -1
- package/skill/SKILL.md +17 -8
- package/update.mjs +1 -0
package/README.md
CHANGED
|
@@ -99,6 +99,8 @@ altalandi --fast "..." # skip the meaning + nativeness gates
|
|
|
99
99
|
altalandi --diff --file draft.txt # show before, after and every change with its reason
|
|
100
100
|
altalandi --review --file in.txt --out out.txt # for agents: one block to show as it is
|
|
101
101
|
altalandi --report --file draft.txt # also write a Markdown report to ./altalandi-reports/
|
|
102
|
+
altalandi --review --job j_20261013a1b2c3 --job-title "Handbók" --part "Hluti 1 af 3" --file part1.txt
|
|
103
|
+
# one part of a longer text: same --job on every part
|
|
102
104
|
```
|
|
103
105
|
|
|
104
106
|
| flag | default | meaning |
|
|
@@ -112,6 +114,9 @@ altalandi --report --file draft.txt # also write a Markdown report to ./altal
|
|
|
112
114
|
| `--out <file>` | | write the refined text to `<file>` (UTF-8); with `--review` the block names it when the text is shortened |
|
|
113
115
|
| `--report` | off | also write `./altalandi-reports/altalandi-<time>-<id>.md`: input, output, a table of every change (before, after, reason, layer), warnings and the verdict |
|
|
114
116
|
| `--report-dir <dir>` | | write the report to `<dir>` instead (implies `--report`) |
|
|
117
|
+
| `--job <id>` | | make this call a part of a job (verk): every call with the same id is one job on the site, one page for the whole text. The id is yours to choose: `j_` and 8–64 of `A-Z a-z 0-9 _ -`, e.g. `j_` + the date + a few random characters. With `--review`, the block links the job (`Verkið`) and this part (`Hlutinn`) |
|
|
118
|
+
| `--job-title <text>` | | the job's name (up to 120 characters); the first call that sends one names it |
|
|
119
|
+
| `--part <text>` | | this call's part, e.g. `Hluti 2 af 5` (up to 300 characters); needs `--job` |
|
|
115
120
|
| `--fast` | off | `preserveMeaning:false`, `scoreNativeness:false`, `onlyImprove:false`, `polishPass:false`, `scoring:"none"` — normally one model call (structured input can still add a structure-preserving retry) |
|
|
116
121
|
|
|
117
122
|
## Folders, i18n catalogs and websites
|
|
@@ -182,11 +187,20 @@ agents) it sends nothing unless you pass `--yes`.
|
|
|
182
187
|
| `--site` / `--max-pages <n>` | off / `20` | read a site from the URL, up to n pages |
|
|
183
188
|
| `--no-store` | off | keep none of the passages (free use is otherwise kept, scrubbed of personal data) |
|
|
184
189
|
| `--json` | off | a machine-readable summary of every file and passage on stdout |
|
|
190
|
+
| `--job <id>` / `--job-title <text>` | a new job | add the run to an existing job, or name it |
|
|
185
191
|
|
|
186
192
|
A `429` waits as long as `Retry-After` asks (up to 5 minutes) and retries. A
|
|
187
193
|
`402` stops the run cleanly: finished files are written, the rest are listed and
|
|
188
194
|
not written; run the same command again after topping up.
|
|
189
195
|
|
|
196
|
+
**One job.** A run is one job (verk) on your account: every request sends the
|
|
197
|
+
same `job` id (`j_` + the date + 10 random characters, made per run), titled by
|
|
198
|
+
the folder's name or the site's host, and each file (its relative path) or page
|
|
199
|
+
(its URL) is a part. A batch that holds several small files is named by its
|
|
200
|
+
first file; a file that fits in one batch is not split across two. The summary
|
|
201
|
+
and the report show one `Verkið: <link>` to the job's page, not one link per
|
|
202
|
+
call. An engine that does not know jobs simply leaves the line out.
|
|
203
|
+
|
|
190
204
|
**Results.** Only verified changes are written. A passage keeps its original
|
|
191
205
|
text when its meaning check did not pass (`meaningCheck` other than `passed`, or
|
|
192
206
|
`meaningPreserved` not `true`), or when the refined text lost or changed code, a
|
package/altalandi.mjs
CHANGED
|
@@ -108,6 +108,9 @@ for (let i = 0; i < args.length; i++) {
|
|
|
108
108
|
else if (a === "--yes" || a === "-y") opts.yes = true;
|
|
109
109
|
else if (a === "--max-chars") opts.maxChars = Number(args[++i]);
|
|
110
110
|
else if (a === "--concurrency") opts.concurrency = Number(args[++i]);
|
|
111
|
+
else if (a === "--job") opts.job = args[++i] ?? "";
|
|
112
|
+
else if (a === "--job-title") opts.jobTitle = args[++i] ?? "";
|
|
113
|
+
else if (a === "--part") opts.part = args[++i] ?? "";
|
|
111
114
|
else positional.push(a);
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -122,6 +125,9 @@ Usage:
|
|
|
122
125
|
for agents: one block to show as it is
|
|
123
126
|
echo "text" | altalandi
|
|
124
127
|
altalandi --domain marketing --register warm "text"
|
|
128
|
+
altalandi --review --job j_20261013a1b2c3 --job-title "Handbók" --part "Hluti 1 af 3" --file part1.txt
|
|
129
|
+
one part of a longer text: every part with the same
|
|
130
|
+
--job is one verk (job) on the site
|
|
125
131
|
|
|
126
132
|
Folders and websites:
|
|
127
133
|
altalandi ./docs every .md .txt .html .json under ./docs → ./docs-is
|
|
@@ -154,6 +160,10 @@ Options:
|
|
|
154
160
|
--report also write a Markdown report (input, output, changes, reasons, verdict)
|
|
155
161
|
to ./altalandi-reports/ (or --report-dir <dir>; ALTALANDI_REPORT=1 turns it on)
|
|
156
162
|
--file read the text from a UTF-8 file instead of an argument
|
|
163
|
+
--job a job id (j_ and 8-64 of A-Z a-z 0-9 _ -) you choose: calls with the same id
|
|
164
|
+
are the parts of one verk; --review then links the job and this part
|
|
165
|
+
--job-title the job's name, kept from the first call that sends one (up to 120 characters)
|
|
166
|
+
--part this call's part, e.g. "Hluti 2 af 5" (up to 300 characters; needs --job)
|
|
157
167
|
--base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
|
|
158
168
|
|
|
159
169
|
stdout = refined text; stderr = nativeness / meaning / warnings / diff.
|
|
@@ -169,6 +179,9 @@ Folder and website options:
|
|
|
169
179
|
--site with a URL: its pages on the same site, under the URL's folder
|
|
170
180
|
--max-pages pages to read with --site (default 20)
|
|
171
181
|
--json print a machine-readable summary at the end
|
|
182
|
+
--job add the run to this job; --job-title names it (default: a new
|
|
183
|
+
job per run, named by the folder or the site's host; each file
|
|
184
|
+
or page is a part)
|
|
172
185
|
Code, URLs, link targets, placeholders ({name}, %s, ICU), HTML tags and JSON keys
|
|
173
186
|
stay byte for byte. Only changes whose meaning check passed are written; other
|
|
174
187
|
passages keep the original text and are listed in the report
|
|
@@ -186,6 +199,13 @@ function readStdin() {
|
|
|
186
199
|
});
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
// --job / --job-title / --part: checked here so a bad id never reaches the API.
|
|
203
|
+
const JOB_ID = /^j_[A-Za-z0-9_-]{8,64}$/;
|
|
204
|
+
if (opts.job !== undefined && !JOB_ID.test(opts.job)) {
|
|
205
|
+
console.error(`--job must be j_ followed by 8 to 64 letters, digits, _ or - (for example j_20261013a1b2c3); got "${opts.job}".`);
|
|
206
|
+
process.exit(2);
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
const stored = readCredentials();
|
|
190
210
|
const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || stored?.baseUrl || "https://altalandi.is/api").replace(/\/$/, "");
|
|
191
211
|
const { key } = resolveKey({ flag: opts.key });
|
|
@@ -206,6 +226,10 @@ if (opts.site || opts.dryRun || opts.maxChars !== undefined || opts.maxPages !==
|
|
|
206
226
|
console.error("--site, --dry-run, --max-chars, --max-pages and --concurrency need a folder or a URL: altalandi ./docs --dry-run");
|
|
207
227
|
process.exit(2);
|
|
208
228
|
}
|
|
229
|
+
if (opts.job === undefined && (opts.jobTitle !== undefined || opts.part !== undefined)) {
|
|
230
|
+
console.error("--job-title and --part need --job <id>: the same id on every part of the text.");
|
|
231
|
+
process.exit(2);
|
|
232
|
+
}
|
|
209
233
|
|
|
210
234
|
let text = positional.join(" ").trim();
|
|
211
235
|
if (opts.file) {
|
|
@@ -224,6 +248,14 @@ const requestOptions = {
|
|
|
224
248
|
register: opts.register,
|
|
225
249
|
...(opts.noStore ? { contribute: false } : {}),
|
|
226
250
|
};
|
|
251
|
+
if (opts.job) {
|
|
252
|
+
const cap = (s, n) => [...String(s)].slice(0, n).join("");
|
|
253
|
+
requestOptions.job = {
|
|
254
|
+
id: opts.job,
|
|
255
|
+
...(opts.jobTitle ? { title: cap(opts.jobTitle, 120) } : {}),
|
|
256
|
+
...(opts.part ? { part: cap(opts.part, 300) } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
227
259
|
// --fast disables every optional quality/verification pass. The writer can
|
|
228
260
|
// still retry once when it drops document structure, as documented in --help.
|
|
229
261
|
// Name every switch explicitly so a future server-default change cannot make
|
package/batch.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// Every side effect is injectable (io), so the tests need no network, terminal
|
|
20
20
|
// or API key.
|
|
21
21
|
|
|
22
|
+
import { randomInt } from "node:crypto";
|
|
22
23
|
import { existsSync, statSync } from "node:fs";
|
|
23
24
|
import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
24
25
|
import path from "node:path";
|
|
@@ -52,6 +53,11 @@ export const BATCH_MAX_CHARS = 6000;
|
|
|
52
53
|
// nor the word units of an older engine.
|
|
53
54
|
const MIN_BILLABLE_CHARS = 250;
|
|
54
55
|
const BATCH_ITEM_MIN_CHARS = 130;
|
|
56
|
+
// One run is one job ("verk") on the account: every request names it, and each
|
|
57
|
+
// file or page is a part. The engine keeps a title of 120 and a part of 300.
|
|
58
|
+
export const JOB_ID_PATTERN = /^j_[A-Za-z0-9_-]{8,64}$/;
|
|
59
|
+
const JOB_TITLE_MAX = 120;
|
|
60
|
+
const JOB_PART_MAX = 300;
|
|
55
61
|
const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
|
|
56
62
|
const REGISTERS = ["formal", "neutral", "warm"];
|
|
57
63
|
|
|
@@ -73,6 +79,16 @@ const preview = (s, n = 72) => {
|
|
|
73
79
|
return one.length > n ? `${one.slice(0, n - 1)}…` : one;
|
|
74
80
|
};
|
|
75
81
|
|
|
82
|
+
/** A new job id: `j_`, the UTC date (yyyymmdd) and 10 random [a-z0-9]. */
|
|
83
|
+
export function newJobId(now = Date.now()) {
|
|
84
|
+
const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
85
|
+
const date = new Date(now).toISOString().slice(0, 10).replace(/-/g, "");
|
|
86
|
+
return `j_${date}${Array.from({ length: 10 }, () => abc[randomInt(abc.length)]).join("")}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** At most n code points, so a cut never splits a surrogate pair. */
|
|
90
|
+
const cap = (s, n) => [...String(s)].slice(0, n).join("");
|
|
91
|
+
|
|
76
92
|
/** Billable characters: every Unicode code point of the trimmed text, spaces,
|
|
77
93
|
* punctuation and line breaks included (the server's rule). */
|
|
78
94
|
export const billableChars = (text) => [...String(text).trim()].length;
|
|
@@ -159,9 +175,10 @@ async function planFolder(opts, io) {
|
|
|
159
175
|
}
|
|
160
176
|
}
|
|
161
177
|
file.status = file.note ? "copy" : "refine";
|
|
178
|
+
file.part = rel;
|
|
162
179
|
files.push(file);
|
|
163
180
|
}
|
|
164
|
-
return { mode: "folder", label: opts.target, input, output, outputIsFolder: true, files };
|
|
181
|
+
return { mode: "folder", label: opts.target, title: path.basename(given), input, output, outputIsFolder: true, files };
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
const withSource = (page) => `<!-- Source: ${page.url} -->\n\n${page.markdown}\n`;
|
|
@@ -170,7 +187,7 @@ function pageFile(page, rel, target) {
|
|
|
170
187
|
const text = withSource(page);
|
|
171
188
|
const { units } = segmentMarkdown(text);
|
|
172
189
|
return {
|
|
173
|
-
path: rel, source: page.url, target, format: "md", text, units,
|
|
190
|
+
path: rel, source: page.url, part: page.url, target, format: "md", text, units,
|
|
174
191
|
status: units.length ? "refine" : "copy", note: units.length ? undefined : "nothing to refine",
|
|
175
192
|
};
|
|
176
193
|
}
|
|
@@ -184,7 +201,7 @@ async function planWeb(opts, io, log) {
|
|
|
184
201
|
const name = `${last === "index" ? safeSegment(new URL(page.url).host) : last}-is.md`;
|
|
185
202
|
let target = path.resolve(cwd, opts.out || name);
|
|
186
203
|
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)] };
|
|
204
|
+
return { mode: "page", label: opts.target, title: new URL(page.url).host, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
|
|
188
205
|
}
|
|
189
206
|
log(`Reading ${opts.target} (up to ${plural(opts.maxPages, "page", "pages")}) …`);
|
|
190
207
|
const { pages, skipped, source } = await readSite(opts.target, {
|
|
@@ -199,7 +216,7 @@ async function planWeb(opts, io, log) {
|
|
|
199
216
|
taken.add(rel.toLowerCase());
|
|
200
217
|
return pageFile(page, rel, path.join(output, ...rel.split("/")));
|
|
201
218
|
});
|
|
202
|
-
return { mode: "site", label: opts.target, output, outputIsFolder: true, files, skipped, discovery: source };
|
|
219
|
+
return { mode: "site", label: opts.target, title: new URL(pages[0].url).host, output, outputIsFolder: true, files, skipped, discovery: source };
|
|
203
220
|
}
|
|
204
221
|
|
|
205
222
|
// ── calls ───────────────────────────────────────────────────────────────────
|
|
@@ -225,7 +242,8 @@ function buildCalls(files, maxChars) {
|
|
|
225
242
|
continue;
|
|
226
243
|
}
|
|
227
244
|
budgetUsed += added;
|
|
228
|
-
|
|
245
|
+
// A call belongs to the first file that sends it: that file is its part.
|
|
246
|
+
for (const [text, call] of fresh) calls.set(text, Object.assign(call, { part: file.part }));
|
|
229
247
|
for (const unit of file.units) {
|
|
230
248
|
unit.file = file;
|
|
231
249
|
for (const call of new Set(unit.calls)) call.units.push(unit);
|
|
@@ -235,18 +253,36 @@ function buildCalls(files, maxChars) {
|
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
/** 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.
|
|
256
|
+
* BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone.
|
|
257
|
+
* A batch takes whole files: when a file's short calls fit in a batch of
|
|
258
|
+
* their own but not in the room left in the open one, that batch is closed
|
|
259
|
+
* and the file starts the next, so a file is split only when it alone is
|
|
260
|
+
* bigger than a batch. Small files still share a batch (named by the first
|
|
261
|
+
* file's part). The limits are unchanged, and closing a batch early costs
|
|
262
|
+
* nothing: two or more items are already past the call minimum. */
|
|
239
263
|
export function groupCalls(calls) {
|
|
240
|
-
const
|
|
264
|
+
const requests = [];
|
|
241
265
|
let open = null;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
266
|
+
const isShort = (c) => c.text.length <= BATCH_PASSAGE_MAX_CHARS;
|
|
267
|
+
for (let i = 0; i < calls.length;) {
|
|
268
|
+
let end = i;
|
|
269
|
+
while (end < calls.length && calls[end].part === calls[i].part) end++;
|
|
270
|
+
const file = calls.slice(i, end);
|
|
271
|
+
i = end;
|
|
272
|
+
const short = file.filter(isShort);
|
|
273
|
+
const chars = short.reduce((n, c) => n + c.text.length, 0);
|
|
274
|
+
const fitsAlone = short.length <= BATCH_MAX_ITEMS && chars <= BATCH_MAX_CHARS;
|
|
275
|
+
if (open && short.length && fitsAlone
|
|
276
|
+
&& (open.calls.length + short.length > BATCH_MAX_ITEMS || open.chars + chars > BATCH_MAX_CHARS)) open = null;
|
|
277
|
+
for (const call of file) {
|
|
278
|
+
if (!isShort(call)) { requests.push({ batch: false, calls: [call] }); continue; }
|
|
279
|
+
if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
|
|
280
|
+
if (!open) { open = { batch: true, calls: [], chars: 0 }; requests.push(open); }
|
|
281
|
+
open.calls.push(call);
|
|
282
|
+
open.chars += call.text.length;
|
|
283
|
+
}
|
|
248
284
|
}
|
|
249
|
-
return
|
|
285
|
+
return requests;
|
|
250
286
|
}
|
|
251
287
|
|
|
252
288
|
/** Characters a request is billed: the call minimum for one text; for a batch,
|
|
@@ -413,6 +449,11 @@ export async function runBatch(opts, io = {}) {
|
|
|
413
449
|
const maxPages = opts.maxPages ?? 20;
|
|
414
450
|
if (!Number.isInteger(maxPages) || maxPages < 1) { log("--max-pages must be a positive whole number."); return EXIT_ERROR; }
|
|
415
451
|
if (opts.site && !isUrl) { log("--site needs a URL, such as https://example.com"); return EXIT_ERROR; }
|
|
452
|
+
if (opts.part !== undefined) { log("--part is for one text. A folder or site run names each part by its file or page."); return EXIT_ERROR; }
|
|
453
|
+
if (opts.job !== undefined && !JOB_ID_PATTERN.test(String(opts.job))) {
|
|
454
|
+
log("--job must be j_ followed by 8 to 64 letters, digits, _ or - (for example j_20261013a1b2c3).");
|
|
455
|
+
return EXIT_ERROR;
|
|
456
|
+
}
|
|
416
457
|
// Checked here, not by the API: a bad value would otherwise fail every call.
|
|
417
458
|
if (!DOMAINS.includes(opts.domain ?? "general")) { log(`--domain must be one of: ${DOMAINS.join(", ")}`); return EXIT_ERROR; }
|
|
418
459
|
if (!REGISTERS.includes(opts.register ?? "neutral")) { log(`--register must be one of: ${REGISTERS.join(", ")}`); return EXIT_ERROR; }
|
|
@@ -519,6 +560,22 @@ export async function runBatch(opts, io = {}) {
|
|
|
519
560
|
...(opts.noStore ? { contribute: false } : {}),
|
|
520
561
|
...(io.fetch ? { fetch: io.fetch } : {}),
|
|
521
562
|
};
|
|
563
|
+
// One job for the whole run (--job joins an existing one); each request names
|
|
564
|
+
// its part: the file's relative path, or the page URL.
|
|
565
|
+
const runJob = {
|
|
566
|
+
id: opts.job || newJobId(now()),
|
|
567
|
+
title: cap(opts.jobTitle || plan.title || plan.label || "", JOB_TITLE_MAX),
|
|
568
|
+
};
|
|
569
|
+
const withPart = (part) => ({
|
|
570
|
+
...requestOptions,
|
|
571
|
+
job: { id: runJob.id, ...(runJob.title ? { title: runJob.title } : {}), ...(part ? { part: cap(part, JOB_PART_MAX) } : {}) },
|
|
572
|
+
});
|
|
573
|
+
// The job's page, from the first response that names it (an older engine never does).
|
|
574
|
+
const noteJob = (data) => {
|
|
575
|
+
if (state.jobUrl) return;
|
|
576
|
+
const url = [data?.jobUrl, data?.items?.[0]?.jobUrl].find((u) => typeof u === "string" && u);
|
|
577
|
+
if (url) Object.assign(state, { jobUrl: url, jobId: typeof data.jobId === "string" && data.jobId ? data.jobId : runJob.id });
|
|
578
|
+
};
|
|
522
579
|
|
|
523
580
|
const pause = async () => {
|
|
524
581
|
for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
|
|
@@ -615,18 +672,20 @@ export async function runBatch(opts, io = {}) {
|
|
|
615
672
|
};
|
|
616
673
|
|
|
617
674
|
const single = async (call) => {
|
|
618
|
-
const r = await attempt(() => refine(call.text,
|
|
619
|
-
if (r.data) bill(r.data);
|
|
675
|
+
const r = await attempt(() => refine(call.text, withPart(call.part)));
|
|
676
|
+
if (r.data) { bill(r.data); noteJob(r.data); }
|
|
620
677
|
return r;
|
|
621
678
|
};
|
|
622
679
|
|
|
623
680
|
/** One batch request; every item is mapped back to its call by id. */
|
|
624
681
|
const sendBatch = async (job) => {
|
|
625
682
|
const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
|
|
626
|
-
|
|
683
|
+
// A batch that holds several small files is named by its first.
|
|
684
|
+
const r = await attempt(() => refineBatch(items, withPart(job.calls[0].part)), { batch: true });
|
|
627
685
|
if (!r.data) return r;
|
|
628
686
|
const data = r.data;
|
|
629
687
|
bill(data);
|
|
688
|
+
noteJob(data);
|
|
630
689
|
const byId = new Map(data.items.map((it) => [String(it.id), it]));
|
|
631
690
|
const artifacts = data.quality?.encodingArtifacts || [];
|
|
632
691
|
job.calls.forEach((call, k) => {
|
|
@@ -724,6 +783,7 @@ export async function runBatch(opts, io = {}) {
|
|
|
724
783
|
units: state.billed,
|
|
725
784
|
},
|
|
726
785
|
...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
|
|
786
|
+
...(state.jobUrl ? { jobId: state.jobId, jobUrl: state.jobUrl } : {}),
|
|
727
787
|
files,
|
|
728
788
|
};
|
|
729
789
|
|
|
@@ -758,6 +818,8 @@ export async function runBatch(opts, io = {}) {
|
|
|
758
818
|
log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
|
|
759
819
|
+ `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
|
|
760
820
|
+ (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
|
|
821
|
+
// One link for the whole run, never one per call.
|
|
822
|
+
if (summary.jobUrl) log(`Verkið: ${summary.jobUrl}`);
|
|
761
823
|
if (t.billedCharacters != null) {
|
|
762
824
|
log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
|
|
763
825
|
}
|