altalandi 0.4.0 → 0.6.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 +114 -0
- package/altalandi.mjs +325 -223
- package/auth.mjs +223 -215
- package/batch.mjs +620 -0
- package/client.d.ts +9 -0
- package/client.mjs +13 -2
- package/package.json +5 -2
- package/report.mjs +288 -1
- package/segment.mjs +1059 -0
- package/skill/SKILL.md +112 -117
- package/update.mjs +3 -1
- package/web.mjs +558 -0
package/client.mjs
CHANGED
|
@@ -12,17 +12,28 @@ const REQUEST_FIELDS = [
|
|
|
12
12
|
"scoring",
|
|
13
13
|
"glossary",
|
|
14
14
|
"returnDiffs",
|
|
15
|
+
"contribute",
|
|
15
16
|
];
|
|
16
17
|
|
|
17
18
|
export class AltalandiError extends Error {
|
|
18
|
-
constructor(message, { code = "ALTALANDI_ERROR", status } = {}) {
|
|
19
|
+
constructor(message, { code = "ALTALANDI_ERROR", status, retryAfterMs } = {}) {
|
|
19
20
|
super(message);
|
|
20
21
|
this.name = "AltalandiError";
|
|
21
22
|
this.code = code;
|
|
22
23
|
if (Number.isInteger(status)) this.status = status;
|
|
24
|
+
if (Number.isFinite(retryAfterMs)) this.retryAfterMs = retryAfterMs;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/** Retry-After as milliseconds: delay-seconds or an HTTP date. */
|
|
29
|
+
function retryAfterMs(response) {
|
|
30
|
+
const value = response?.headers?.get?.("retry-after");
|
|
31
|
+
if (!value) return undefined;
|
|
32
|
+
if (/^\s*\d+\s*$/.test(value)) return Number(value) * 1000;
|
|
33
|
+
const at = Date.parse(value);
|
|
34
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
export class MeaningVerificationError extends AltalandiError {
|
|
27
38
|
constructor(verdict) {
|
|
28
39
|
const changed = verdict === false;
|
|
@@ -175,7 +186,7 @@ export async function refine(text, options = {}) {
|
|
|
175
186
|
const suffix = detail || statusText;
|
|
176
187
|
throw new AltalandiError(
|
|
177
188
|
`Altalandi API request failed (${status || "unknown status"})${suffix ? `: ${suffix}` : "."}`,
|
|
178
|
-
{ code: "API_ERROR", status },
|
|
189
|
+
{ code: "API_ERROR", status, retryAfterMs: retryAfterMs(response) },
|
|
179
190
|
);
|
|
180
191
|
}
|
|
181
192
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "altalandi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Native-sounding Icelandic from your terminal or AI agent: one-time login, a Claude Code skill, and meaning verification.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./client.mjs",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"auth.mjs",
|
|
21
21
|
"update.mjs",
|
|
22
22
|
"report.mjs",
|
|
23
|
+
"batch.mjs",
|
|
24
|
+
"segment.mjs",
|
|
25
|
+
"web.mjs",
|
|
23
26
|
"skill/SKILL.md",
|
|
24
27
|
"client.mjs",
|
|
25
28
|
"client.d.ts",
|
|
@@ -27,7 +30,7 @@
|
|
|
27
30
|
"LICENSE"
|
|
28
31
|
],
|
|
29
32
|
"scripts": {
|
|
30
|
-
"test": "node --test test/cli.test.mjs test/client.test.mjs test/update.test.mjs test/auth.test.mjs"
|
|
33
|
+
"test": "node --test test/cli.test.mjs test/client.test.mjs test/update.test.mjs test/auth.test.mjs test/report.test.mjs test/segment.test.mjs test/web.test.mjs test/batch.test.mjs"
|
|
31
34
|
},
|
|
32
35
|
"keywords": [
|
|
33
36
|
"icelandic",
|
package/report.mjs
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
// reportMarkdown() the report file: input, output, every change with its
|
|
7
7
|
// reason, and the verdict
|
|
8
8
|
// writeReport() writes it to ./altalandi-reports/ (or ALTALANDI_REPORT_DIR)
|
|
9
|
+
// batchReportMarkdown() / writeBatchReport()
|
|
10
|
+
// the one summary of a folder or site run (CLI only)
|
|
9
11
|
//
|
|
10
|
-
//
|
|
12
|
+
// Single-text reports are opt-in (--report, or ALTALANDI_REPORT=1): they
|
|
13
|
+
// contain the text. A folder or site run always writes its summary, since its
|
|
14
|
+
// output files hold the same text anyway.
|
|
11
15
|
|
|
12
16
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
17
|
import { join, resolve } from "node:path";
|
|
@@ -112,3 +116,286 @@ export async function writeReport(input, data, options = {}, dir) {
|
|
|
112
116
|
await writeFile(path, reportMarkdown(input, data, options), "utf8");
|
|
113
117
|
return path;
|
|
114
118
|
}
|
|
119
|
+
|
|
120
|
+
// ── The review block ─────────────────────────────────────────────────────────
|
|
121
|
+
// One Markdown block an agent pastes into the chat as it is: verdict, the text
|
|
122
|
+
// before and after, every change with its reason, what the call cost and what
|
|
123
|
+
// is left. `--review` prints it; the MCP tool returns it. The agent does not
|
|
124
|
+
// rebuild it from JSON, so every run looks the same.
|
|
125
|
+
|
|
126
|
+
const REJECTION = [
|
|
127
|
+
["MEANING DRIFT", "merkingin breyttist í endurskriftinni"],
|
|
128
|
+
["HARD INVARIANT LOST", "tala, dagsetning, nafn eða neitun hefði glatast"],
|
|
129
|
+
["UNVERIFIED", "ekki tókst að staðfesta að merkingin héldist"],
|
|
130
|
+
["CANDIDATE VETO", "endurskriftin reyndist ekki betri en frumtextinn"],
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export function rejectionText(reason) {
|
|
134
|
+
const raw = String(reason ?? "").trim();
|
|
135
|
+
if (!raw) return "";
|
|
136
|
+
const hit = REJECTION.find(([code]) => raw.toUpperCase().startsWith(code));
|
|
137
|
+
return hit ? `${hit[1][0].toUpperCase()}${hit[1].slice(1)} (${raw})` : raw;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 1688 → "1.688" (Icelandic thousands separator). */
|
|
141
|
+
export function formatInt(n) {
|
|
142
|
+
return String(Math.round(Number(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 487.5 → "487,50 kr.", 500 → "500 kr." */
|
|
146
|
+
export function formatIsk(n) {
|
|
147
|
+
const v = Math.round(Number(n) * 100) / 100;
|
|
148
|
+
const sign = v < 0 ? "−" : "";
|
|
149
|
+
const abs = Math.abs(v);
|
|
150
|
+
const whole = formatInt(Math.trunc(abs));
|
|
151
|
+
const cents = Math.round((abs - Math.trunc(abs)) * 100);
|
|
152
|
+
return `${sign}${whole}${cents ? "," + String(cents).padStart(2, "0") : ""} kr.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What the call cost and what is left. `data.billing` (sent by newer engines)
|
|
159
|
+
* is exact; otherwise the cost is the balance difference between /v1/me
|
|
160
|
+
* before and after the call, which parallel calls on the same account blur.
|
|
161
|
+
*/
|
|
162
|
+
export function costLines(data, before, after) {
|
|
163
|
+
if (data?.test) return ["- Prófunarlykill: ekkert var rukkað og ekkert líkan keyrði."];
|
|
164
|
+
const b = data?.billing || {};
|
|
165
|
+
let charged = num(b.chargedIsk);
|
|
166
|
+
const exact = charged != null;
|
|
167
|
+
if (charged == null && num(before?.balanceIsk) != null && num(after?.balanceIsk) != null) {
|
|
168
|
+
charged = Math.round((before.balanceIsk - after.balanceIsk) * 100) / 100;
|
|
169
|
+
}
|
|
170
|
+
const units = num(b.units);
|
|
171
|
+
const unitsText = units != null ? ` · ${formatInt(units)} einingar` : "";
|
|
172
|
+
const lines = [];
|
|
173
|
+
if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${unitsText}`);
|
|
174
|
+
else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${unitsText}`);
|
|
175
|
+
else if (charged === 0) lines.push(b.paidFrom === "credit"
|
|
176
|
+
? `- Þetta kall: 0 kr. af inneign${unitsText}`
|
|
177
|
+
: `- Þetta kall: 0 kr., ókeypis dagskammtur${unitsText}`);
|
|
178
|
+
else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${unitsText}`);
|
|
179
|
+
|
|
180
|
+
const balance = num(b.balanceIsk) ?? num(after?.balanceIsk);
|
|
181
|
+
const left = num(b.freeUnitsLeftToday) ?? num(after?.freeUnitsLeftToday);
|
|
182
|
+
const daily = num(after?.freeDailyUnits) ?? num(before?.freeDailyUnits);
|
|
183
|
+
const parts = [];
|
|
184
|
+
if (balance != null) parts.push(`inneign ${formatIsk(balance)}`);
|
|
185
|
+
if (left != null && daily != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} af ${formatInt(daily)} einingum`);
|
|
186
|
+
else if (left != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} einingar`);
|
|
187
|
+
else if (daily != null && daily > 0) parts.push(`ókeypis ${formatInt(daily)} einingar á dag`);
|
|
188
|
+
if (parts.length) lines.push(`- Staða: ${parts.join(" · ")}`);
|
|
189
|
+
else lines.push("- Staða: náðist ekki að sækja (`npx altalandi whoami`)");
|
|
190
|
+
if (balance != null && balance < 50) lines.push("- Bæta við inneign: https://altalandi.is/portal");
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function clip(text, max) {
|
|
195
|
+
const s = String(text ?? "");
|
|
196
|
+
if (!max || s.length <= max) return { text: s, clipped: false };
|
|
197
|
+
const cut = s.lastIndexOf(" ", max);
|
|
198
|
+
return { text: s.slice(0, cut > max * 0.6 ? cut : max).trimEnd() + " …", clipped: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A quoted line that starts with "#" or ">" would render as a heading or a
|
|
202
|
+
// nested quote; escape that first character.
|
|
203
|
+
const quote = (s) => String(s).split(/\r?\n/)
|
|
204
|
+
.map((l) => (l.trim() ? `> ${l.replace(/^(\s*)([#>])/, "$1\\$2")}` : ">")).join("\n");
|
|
205
|
+
const inline = (s) => String(s ?? "").replace(/\s+/g, " ").trim().replace(/([\\`*_~[\]<>|#])/g, "\\$1");
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The block to show the user. `before`/`after` are /v1/me responses (either
|
|
209
|
+
* may be null). Long texts are shortened for the chat; `fullTextAt` names the
|
|
210
|
+
* file that holds the whole output.
|
|
211
|
+
*/
|
|
212
|
+
export function reviewMarkdown(input, data, { before = null, after = null, maxChars = 1500, maxChanges = 30, fullTextAt = "" } = {}) {
|
|
213
|
+
const q = data?.quality || {};
|
|
214
|
+
const outcome = outcomeOf(data);
|
|
215
|
+
const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
|
|
216
|
+
const score = q.nativenessScore != null ? ` · innfæddni ${q.nativenessScore}/100` : "";
|
|
217
|
+
const out = [`**Altalandi:** ${VERDICT[outcome]}${score}`, ""];
|
|
218
|
+
|
|
219
|
+
const inText = clip(input, maxChars);
|
|
220
|
+
out.push("**Fyrir**", quote(inText.text), "");
|
|
221
|
+
const same = String(data?.output ?? "").trim() === String(input ?? "").trim();
|
|
222
|
+
if (same) {
|
|
223
|
+
out.push("**Eftir**", outcome === "refused" ? "> Óbreytt: upprunalega textanum var skilað." : "> Óbreytt.", "");
|
|
224
|
+
} else {
|
|
225
|
+
const outText = clip(data?.output, maxChars);
|
|
226
|
+
out.push("**Eftir**", quote(outText.text), "");
|
|
227
|
+
if (outText.clipped || inText.clipped) {
|
|
228
|
+
out.push(`_Textinn er styttur hér${fullTextAt ? `; allur textinn er í ${fullTextAt}` : ""}._`, "");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (outcome === "refused" && data?.rejectionReason) {
|
|
233
|
+
out.push(`**Af hverju óbreytt:** ${inline(rejectionText(data.rejectionReason))}`, "");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (diffs.length) {
|
|
237
|
+
out.push(`**Breytingar (${diffs.length})**`);
|
|
238
|
+
diffs.slice(0, maxChanges).forEach((d, i) => {
|
|
239
|
+
out.push(`${i + 1}. „${inline(d.before)}“ → „${inline(d.after)}“`);
|
|
240
|
+
if (d.reason) out.push(` Ástæða: ${inline(d.reason)}`);
|
|
241
|
+
});
|
|
242
|
+
if (diffs.length > maxChanges) out.push(` … og ${diffs.length - maxChanges} í viðbót (sjá \`--report\`).`);
|
|
243
|
+
out.push("");
|
|
244
|
+
} else if (data?.diffStatus === "translation" || (data?.sourceLang === "en" && !same)) {
|
|
245
|
+
out.push("**Breytingar:** þýðing úr ensku, svo allur textinn er nýr.", "");
|
|
246
|
+
} else if (!same && data?.diffStatus === "unavailable") {
|
|
247
|
+
out.push("**Breytingar:** textinn breyttist en listi yfir breytingar fékkst ekki. Berðu saman fyrir og eftir.", "");
|
|
248
|
+
} else if (outcome !== "refused") {
|
|
249
|
+
out.push("**Breytingar:** engar.", "");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The refusal reason is already shown above; its warning repeats it.
|
|
253
|
+
const code = REJECTION.find(([c]) => String(data?.rejectionReason ?? "").toUpperCase().startsWith(c))?.[0];
|
|
254
|
+
const warnings = (Array.isArray(data?.warnings) ? data.warnings : [])
|
|
255
|
+
.filter((w) => !String(w).startsWith("TEST KEY") && !(code && String(w).toUpperCase().startsWith(code)));
|
|
256
|
+
if (warnings.length && outcome !== "rewritten") {
|
|
257
|
+
out.push("**Athugasemdir**", ...warnings.slice(0, 3).map((w) => `- ${inline(w)}`), "");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const term = data?.terminology;
|
|
261
|
+
if (term?.dictionaries?.length) {
|
|
262
|
+
// Offered is not used: only a term verified in the output (by form or BÍN
|
|
263
|
+
// lemma) is "í textanum"; a stem-only match is "líklega"; an older engine
|
|
264
|
+
// without `usage` is listed as offered. The caller's glossary wins.
|
|
265
|
+
const usage = (x) => x.status === "overridden" ? "overridden"
|
|
266
|
+
: x.usage ?? (x.inOutput === true ? "verified" : x.inOutput === false ? "absent" : "unchecked");
|
|
267
|
+
const tag = { verified: "í textanum", probable: "líklega í textanum", absent: "ekki notað",
|
|
268
|
+
unchecked: "boðið", overridden: "þitt orðasafn gekk framar" };
|
|
269
|
+
const shown = (term.terms || []).filter((x) => !x.inText);
|
|
270
|
+
out.push(`**Fagmál:** ${inline(term.dictionaries.map((d) => d.name).join(", "))}`);
|
|
271
|
+
if (!shown.some((x) => x.status !== "overridden")) out.push("- Engin fagorð áttu við þennan texta.");
|
|
272
|
+
shown.slice(0, 12).forEach((x) => {
|
|
273
|
+
const u = usage(x);
|
|
274
|
+
const is = u === "verified" || u === "probable" ? (x.used ?? x.is) : x.is;
|
|
275
|
+
out.push(`- ${inline(x.source)} → ${inline(is)} (${tag[u]})`);
|
|
276
|
+
});
|
|
277
|
+
if (shown.length > 12) out.push(`- … og ${shown.length - 12} í viðbót`);
|
|
278
|
+
out.push("");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
out.push("**Kostnaður**", ...costLines(data, before, after));
|
|
282
|
+
if (data?.id) out.push(`- Auðkenni: \`${data.id}\``);
|
|
283
|
+
return out.join("\n") + "\n";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── folder and site runs ────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
const MEANING = {
|
|
289
|
+
passed: "staðfest",
|
|
290
|
+
changed: "gæti hafa breyst",
|
|
291
|
+
reverted: "endurskrift hafnað",
|
|
292
|
+
skipped: "ekki athuguð",
|
|
293
|
+
unavailable: "athugun lauk ekki",
|
|
294
|
+
};
|
|
295
|
+
const FILE_STATE = {
|
|
296
|
+
written: "breytt",
|
|
297
|
+
unchanged: "óbreytt eftir yfirferð",
|
|
298
|
+
copy: "afrituð án yfirferðar",
|
|
299
|
+
unfinished: "ekki kláruð, ekki skrifuð",
|
|
300
|
+
"over-budget": "utan --max-chars, ekki skrifuð",
|
|
301
|
+
failed: "villa",
|
|
302
|
+
};
|
|
303
|
+
const count = (n) => formatInt(n || 0);
|
|
304
|
+
|
|
305
|
+
/** The one summary of a folder or site run: every file, every changed passage
|
|
306
|
+
* (before and after, with the meaning verdict) and, under their own heading,
|
|
307
|
+
* the passages that keep the original text because a check did not pass. */
|
|
308
|
+
export function batchReportMarkdown(summary) {
|
|
309
|
+
const t = summary?.totals || {};
|
|
310
|
+
const files = Array.isArray(summary?.files) ? summary.files : [];
|
|
311
|
+
const when = String(summary?.finishedAt || new Date().toISOString()).replace("T", " ").slice(0, 19);
|
|
312
|
+
const fileStates = [
|
|
313
|
+
`${count(t.written)} breyttar`,
|
|
314
|
+
`${count(t.unchanged)} óbreyttar eftir yfirferð`,
|
|
315
|
+
`${count(t.copied)} afritaðar án yfirferðar`,
|
|
316
|
+
t.unfinished ? `${count(t.unfinished)} ekki kláraðar` : "",
|
|
317
|
+
t.overBudget ? `${count(t.overBudget)} utan --max-chars` : "",
|
|
318
|
+
].filter(Boolean).join(", ");
|
|
319
|
+
const out = [
|
|
320
|
+
`# Altalandi: ${cell(summary?.input)} → ${cell(summary?.output)}`,
|
|
321
|
+
"",
|
|
322
|
+
"| | |", "|---|---|",
|
|
323
|
+
`| Tími | ${when} UTC |`,
|
|
324
|
+
`| Inntak | ${cell(summary?.input)} |`,
|
|
325
|
+
`| Úttak | ${cell(summary?.output)} |`,
|
|
326
|
+
`| Skrár | ${count(t.files)}: ${fileStates} |`,
|
|
327
|
+
`| Kaflar | ${count(t.passages)}: ${count(t.changed)} breyttir, ${count(t.unchangedPassages)} þurftu ekki breytingu, `
|
|
328
|
+
+ `${count(t.kept)} héldu frumtextanum, ${count(t.failed)} mistókust |`,
|
|
329
|
+
`| Stafir sendir | ${count(t.characters)} |`,
|
|
330
|
+
`| Tegund texta | ${cell(summary?.domain || "general")} |`,
|
|
331
|
+
`| Málsnið | ${cell(summary?.register || "neutral")} |`,
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
if (summary?.stopped) {
|
|
335
|
+
const done = files.filter((f) => f.status === "written" || f.status === "unchanged").map((f) => f.path);
|
|
336
|
+
const open = files.filter((f) => f.status === "unfinished").map((f) => f.path);
|
|
337
|
+
out.push("", "## Keyrslan stöðvaðist", "", cell(summary.stopped.message), "",
|
|
338
|
+
`- Kláraðar skrár (${done.length}): ${done.map(cell).join(", ") || "engar"}`,
|
|
339
|
+
`- Ekki kláraðar, ekki skrifaðar (${open.length}): ${open.map(cell).join(", ") || "engar"}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const rows = (status) => files.flatMap((f) => (f.passages || []).filter((p) => p.status === status).map((p) => ({ f, p })));
|
|
343
|
+
const kept = rows("kept");
|
|
344
|
+
if (kept.length) {
|
|
345
|
+
out.push("", `## Frumtextinn látinn standa (${kept.length})`, "",
|
|
346
|
+
"Athugunin stóðst ekki í þessum köflum, svo frumtextinn er óbreyttur í úttakinu. "
|
|
347
|
+
+ "Tillagan fylgir svo þú getir metið hana.",
|
|
348
|
+
"", "| Skrá | # | Frumtexti | Tillaga (ekki notuð) | Ástæða |", "|---|---|---|---|---|");
|
|
349
|
+
for (const { f, p } of kept) {
|
|
350
|
+
out.push(`| ${cell(f.path)} | ${p.n} | ${cell(p.before)} | ${cell(p.after ?? "")} | ${cell(p.reason || MEANING[p.meaningCheck] || "")} |`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const failed = rows("failed");
|
|
354
|
+
if (failed.length) {
|
|
355
|
+
out.push("", `## Villur (${failed.length})`, "", "Þessi köll mistókust, svo frumtextinn er óbreyttur.", "",
|
|
356
|
+
"| Skrá | # | Frumtexti | Villa |", "|---|---|---|---|");
|
|
357
|
+
for (const { f, p } of failed) out.push(`| ${cell(f.path)} | ${p.n} | ${cell(p.before)} | ${cell(p.reason)} |`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const changedFiles = files.filter((f) => (f.passages || []).some((p) => p.status === "changed"));
|
|
361
|
+
out.push("", "## Breytingar eftir skrám");
|
|
362
|
+
if (!changedFiles.length) out.push("", "Engar breytingar.");
|
|
363
|
+
for (const f of changedFiles) {
|
|
364
|
+
const changed = f.passages.filter((p) => p.status === "changed");
|
|
365
|
+
out.push("", `### ${cell(f.path)}`, "");
|
|
366
|
+
if (f.source) out.push(`Heimild: ${f.source}`, "");
|
|
367
|
+
out.push(`Breyttir kaflar: ${count(changed.length)} af ${count(f.passages.length)}.`, "",
|
|
368
|
+
"| # | Fyrir | Eftir | Merking |", "|---|---|---|---|");
|
|
369
|
+
for (const p of changed) out.push(`| ${p.n} | ${cell(p.before)} | ${cell(p.after)} | ${MEANING[p.meaningCheck] || "?"} |`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const quiet = files.filter((f) => !changedFiles.includes(f));
|
|
373
|
+
if (quiet.length) {
|
|
374
|
+
out.push("", "## Skrár án breytinga", "");
|
|
375
|
+
for (const f of quiet) out.push(`- ${cell(f.path)}: ${FILE_STATE[f.status] || f.status}${f.note ? ` (${cell(f.note)})` : ""}`);
|
|
376
|
+
}
|
|
377
|
+
if (Array.isArray(summary?.skippedPages) && summary.skippedPages.length) {
|
|
378
|
+
out.push("", "## Síður sem voru ekki lesnar", "");
|
|
379
|
+
for (const s of summary.skippedPages) out.push(`- ${s.url}: ${cell(s.reason)}`);
|
|
380
|
+
}
|
|
381
|
+
return out.join("\n") + "\n";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Write the summary of a folder or site run; returns its path. */
|
|
385
|
+
export async function writeBatchReport(summary, dir) {
|
|
386
|
+
const base = resolve(dir || process.env.ALTALANDI_REPORT_DIR || join(process.cwd(), "altalandi-reports"));
|
|
387
|
+
await mkdir(base, { recursive: true });
|
|
388
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
389
|
+
// A folder is named by its own name, a URL by its host and path.
|
|
390
|
+
const input = String(summary?.input || "");
|
|
391
|
+
const name = /^https?:\/\//i.test(input)
|
|
392
|
+
? input.replace(/^https?:\/\//i, "")
|
|
393
|
+
: input.replace(/[\\/]+$/, "").split(/[\\/]/).pop();
|
|
394
|
+
const slug = name
|
|
395
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
|
396
|
+
.replace(/^-+|-+$/g, "")
|
|
397
|
+
.slice(0, 40) || "safn";
|
|
398
|
+
const path = join(base, `altalandi-${stamp}-${slug}.md`);
|
|
399
|
+
await writeFile(path, batchReportMarkdown(summary), "utf8");
|
|
400
|
+
return path;
|
|
401
|
+
}
|