doxa-maps/library/scripts/posters/packet.mjs
doxa-maps-88655c70 1c24759e21
Some checks are pending
build-gate / build (push) Waiting to run
feat(posters): UPG-100 prayer packet generator, letter portrait, i18n
The packet as specified: page 1 the map one-pager, pages 2..n the people
groups 20-per-page with photos, then the 12 prayer points, then the prayer
wheel. Every string is externalised so a new language is a new block in
packet-strings.json, not a code change; fallback is PER KEY, so a partial
translation renders what it has and leaves the rest in English rather than
blanking. Verified with a partial es block before removing the stub.

Three bugs worth recording, each of which rendered silently wrong:

1. zoom, NOT transform:scale(), for print. transform is paint-time, so the
   sheet still RESERVES its 1941px layout box and the paginator breaks it
   across two pages, inking only the top of the first: 8 sheets came out as
   16 PDF pages. Negative margins do not help — they collapse the outer
   space, not the fragmentation box. zoom is layout-time, so pagination sees
   the scaled box. Measured: transform 2 pages/sheet, transform+margins 2,
   zoom 1.

2. The map SVG carries almost no presentation attributes — generate.mjs
   styles it from the poster document's stylesheet. Lifting the <svg> alone
   left those rules behind, and SVG fill falls back to its initial value,
   which is BLACK: 670 elements painted solid black with no error, no broken
   image, nothing any existing assertion could see. Fixed by carrying the
   poster's stylesheet minus its document-level rules, so a palette change
   in generate.mjs still follows the packet automatically.

3. LANG is the shell's POSIX locale ("en_US.UTF-8"), not a BCP-47 tag, and
   throws in localeCompare. PACKET_LANG is the only source.

The verification lesson is the bigger one. The probe PRINTED "PDF pages: 16"
for several runs while reporting the packet verified, because the bad counter
never looked at that value — a number that is displayed but not asserted on
is decoration. Then the first fix for the black map asserted on the first
<rect>, the one element just repaired, and went green while 647 others were
still black. Both assertions now test the property rather than a proxy: one
sheet must be exactly one page, and the map as a whole must not be
predominantly black-filled. Both were confirmed to FAIL on the known-broken
input before being accepted.
2026-09-24 01:39:26 +00:00

411 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// packet.mjs — the UPG-100 PRAYER PACKET generator.
//
// One printable packet per zone. Structure is the Driver's, from the ideation
// (_qa-cycle/01-ideation-research/prayer-points.md):
//
// page 1 the MAP — a lightweight vertical one-pager, NOT the poster format
// pages 2..n the PEOPLE GROUPS — 20 per page, photo + name + brief detail
// page n+1 the 12 PRAYER POINTS
// page n+2 the PRAYER WHEEL — the rhythm these twelve points follow
//
// Printed front-and-back: Laos (95 groups) is 8 pages = 4 sheets.
//
// ── WHY A SEPARATE SCRIPT, not a FORMAT inside generate.mjs ──────────────────
// generate.mjs is a 2,600-line POSTER renderer. The packet is a different artefact
// with different pages; forking it would duplicate the design. Instead this reads
// the SAME zone resolution (clusters.json + the selector evaluator) so a packet can
// never disagree with its poster about which groups belong to a zone.
//
// ── EVERY STRING IS IN packet-strings.json ──────────────────────────────────
// Driver: "it all needs to be generative so that I can generate it in multiple
// languages." So no human-readable text is written in this file. LANG=xx picks a
// block; missing keys fall back to English individually, so a partial translation
// renders what it has rather than blanking the page.
//
// ── PHOTOS ──────────────────────────────────────────────────────────────────
// MEASURED: 1,072 of 2,107 groups have one (51%); they average ~244KB on Joshua
// Project's CDN, ~0.8s each. Fetched in parallel at packet time and inlined as
// data URIs so the finished HTML is ONE self-contained file that prints anywhere.
// Groups without a photo get a typographic card, never a broken image box.
//
// Usage:
// node packet.mjs # every zone, English
// ONLY=laos node packet.mjs # one zone
// LANG=es ONLY=laos node packet.mjs # one zone, Spanish
// NOPHOTOS=1 ONLY=laos node packet.mjs # skip the fetch (fast layout iteration)
//
// Output: out/packets/<zoneKey>[.<lang>].html
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA = join(__dirname, '../../../plugin-designs/my-upg-100-list/data');
const OUT = join(__dirname, 'out', 'packets');
mkdirSync(OUT, { recursive: true });
// ⚠ NOT process.env.LANG — that is the SHELL's POSIX locale ("en_US.UTF-8"), which
// is not a valid BCP-47 tag and throws in localeCompare/toLocaleString. Measured:
// it crashed the first run. PACKET_LANG is the only source.
const LANG = process.env.PACKET_LANG || 'en';
const ONLY = process.env.ONLY || '';
const NOPHOTOS = process.env.NOPHOTOS === '1';
const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
if (!API) {
console.error('[packet] No API base — set VITE_API_BASE_URL. Failing closed.');
process.exit(1);
}
// ── STRINGS: pick the language, fall back per-key to English ────────────────
const STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.json'), 'utf8'));
const EN = STRINGS.en;
const L = STRINGS[LANG] || {};
// Per-KEY fallback (not whole-block): a half-finished translation should render
// its finished keys, not fall back wholesale to English.
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythms[k]);
const POINTS = L.points || EN.points;
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
const PAGE_W_IN = 8.5;
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
const M = 60, HEAD = 90; // margins + running header
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
const GUT = 14;
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
const PHOTO = Math.min(CH - 8, CW * 0.34);
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// The API returns some fields as { value, label } dicts; the card wants the label.
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(LANG) : '—');
// ── DATA ────────────────────────────────────────────────────────────────────
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
async function loadRecords() {
const cache = join(__dirname, 'out', '_records.json');
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
{ headers: { accept: 'application/json' } });
if (!r.ok) throw new Error(`PG API ${r.status}`);
const recs = normalizeRecords(extractRecords(await r.json()));
try { writeFileSync(cache, JSON.stringify(recs)); } catch { /* best effort */ }
return recs;
}
function allZones() {
const out = [];
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id });
}
return out;
}
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────
// Bounded concurrency: 16 was measured at 20 photos in 5.9s. Unbounded would open
// ~118 sockets against someone else's CDN, which is rude and slower in practice.
async function fetchPhotos(groups, limit = 16) {
if (NOPHOTOS) return new Map();
const out = new Map();
let i = 0;
const worker = async () => {
while (i < groups.length) {
const g = groups[i++];
if (!g.image_url) continue;
try {
const r = await fetch(g.image_url, { signal: AbortSignal.timeout(20000) });
if (!r.ok) continue;
const buf = Buffer.from(await r.arrayBuffer());
const mime = r.headers.get('content-type') || 'image/jpeg';
out.set(g.slug, `data:${mime};base64,${buf.toString('base64')}`);
} catch { /* a missing photo degrades to the typographic card */ }
}
};
await Promise.all(Array.from({ length: Math.min(limit, groups.length) }, worker));
return out;
}
// ── PAGE SHELL ──────────────────────────────────────────────────────────────
const sheet = (inner, cls = '') =>
`<div class="sheet ${cls}">${inner}</div>`;
function css() {
return `
@page{size:letter portrait;margin:0}
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
.card{position:absolute;display:flex;gap:12px;align-items:center;
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
.card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px}
.card img{object-fit:cover}
.card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center;
justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px}
.nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1}
.mt{font:400 17px Georgia,serif;color:#6d766e}
/* FINE PRINT — Driver: "attribution minimal, fine print small". */
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
font:400 13px Georgia,serif;color:#a89f8c}
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
completely differently under pagination:
transform is paint-time — the element still RESERVES its full ${H}px layout
box, so the paginator walks ${H}px of content and breaks it across
two sheets, inking only the top of the first. Measured: 8 sheets
came out as 16 PDF pages. Negative margins do NOT fix this — they
collapse the outer space, not the fragmentation box.
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
@media print{
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
}`;
}
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
// dropping only the rules that would fight the packet's own page shell.
//
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
// prose that contains braces and selector-like text, so any brace-counting or
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
function mapCss(posterHtml) {
const block = posterHtml.match(/<style>([\s\S]*?)<\/style>/);
if (!block) return '';
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
// Drop @media (the poster's own print-scaling rules, which do not apply here).
// Depth-aware because @media bodies nest a further level of braces.
let out = '', depth = 0, i = 0;
while (i < css.length) {
if (css.startsWith('@media', i)) {
let d = 0, started = false;
while (i < css.length) {
if (css[i] === '{') { d++; started = true; }
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
i++;
}
continue;
}
out += css[i++];
}
// Drop document-level rules: these style the POSTER page, not the map, and would
// override the packet's background, box-sizing and stage geometry.
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
return out.split('}')
.map((r) => {
const at = r.indexOf('{');
if (at < 0) return '';
const sel = r.slice(0, at).trim();
if (!sel) return '';
// :root must be KEPT — it carries the palette the map's var()s resolve against.
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
return `${sel}{${r.slice(at + 1).trim()}}`;
})
.filter(Boolean).join('\n');
}
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
// the geography with all the pins", explicitly NOT the poster. The poster remains
// its own artefact (generate.mjs); this is the packet's cover.
function mapPage(zone, groups, mapSvg) {
const count = groups.length;
return sheet(`
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
</div>
${mapSvg}
<div class="fine">${esc(t('privacy_note'))}</div>`);
}
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
function listPages(zone, groups, photos) {
const pages = [];
for (let start = 0; start < groups.length; start += PER_PAGE) {
const slice = groups.slice(start, start + PER_PAGE);
const cards = slice.map((g, k) => {
const i = start + k;
const c = k % COLS, r = Math.floor(k / COLS);
const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT);
const src = photos.get(g.slug);
const img = src
? `<img src="${src}" alt="">`
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
<div style="min-width:0">
<div class="nm">${i + 1}. ${esc(g.name)}</div>
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
</div></div>`;
}).join('');
pages.push(sheet(`
<div class="hd">${esc(zone.label || zone.id)}</div>
<div class="hd-r">${esc(fill(t('list_header'),
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
${cards}`));
}
return pages;
}
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
function pointsPage() {
const rows = POINTS.map((p) => {
const refs = (p.refs || []).map((r) => esc(r)).join(' · ');
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
const passage = p.print_passage
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
// scripture, because prayer point number four is actually read the word."
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
${esc(t('point4_passage_credit'))}</div></div>`
: '';
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
<div style="font:700 19px Georgia,serif;color:#1b1a17">
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
${passage}
</div>`;
}).join('');
return sheet(`
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
</div>
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
<div class="fine">${esc(t('points_attrib'))}</div>`);
}
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
// be translated, and this packet must generate in any language. Twelve equal
// segments, the rhythm name set along each, 5 minutes each = one hour.
function wheelPage() {
const cx = W / 2, cy = 1090, R = 470;
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
const seg = order.map((key, i) => {
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
const hue = Math.round(i * 30);
const mid = (a0 + a1) / 2;
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
let deg = mid * 180 / Math.PI;
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
text-anchor="middle" dominant-baseline="middle"
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
}).join('');
const pips = order.map((_, i) => {
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
}).join('');
return sheet(`
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
</div>
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
${seg}${pips}
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
</svg>
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
}
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
async function buildPacket(zone, groups, mapSvg) {
const photos = await fetchPhotos(groups);
const pages = [
mapPage(zone, groups, mapSvg),
...listPages(zone, groups, photos),
pointsPage(),
wheelPage(),
];
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
const html = `<!DOCTYPE html><html lang="${esc(LANG)}"><head><meta charset="utf-8">
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
<body>${pages.join('\n')}</body></html>`;
return { html, pageCount: pages.length, withPhoto };
}
// ── MAIN ────────────────────────────────────────────────────────────────────
const records = await loadRecords();
const bySlug = new Map(records.map((r) => [r.slug, r]));
const zones = allZones().filter((z) => !ONLY || z.id === ONLY);
if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); }
let made = 0;
for (const z of zones) {
let slugs = [];
try {
// evaluate() returns a SET, not an array — spread it rather than assuming.
slugs = z.selector?.type === 'pg_slugs'
? (z.selector.values || [])
: (z.selector ? [...evaluate(z.selector, records)] : []);
} catch (e) { console.warn(`[packet] selector failed for ${z.id}: ${e.message}`); continue; }
const groups = slugs.map((s) => bySlug.get(s)).filter(Boolean)
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG));
if (!groups.length) { console.warn(`[packet] ${z.id}: no groups resolved — skipped`); continue; }
// The map one-pager reuses the poster's SVG when it has been generated; without
// it the packet still builds (cover page without the map) rather than failing.
const posterPath = join(__dirname, 'out', 'posters', `${z.id}.html`);
let mapSvg = '';
if (existsSync(posterPath)) {
const poster = readFileSync(posterPath, 'utf8');
const m = poster.match(/<svg[\s\S]*?<\/svg>/);
// ⚠ The map SVG carries almost NO presentation attributes — generate.mjs styles
// it entirely from the poster document's <style> block (.ctry, .lbl, .lead, the
// :root palette...). Lifting the <svg> alone leaves those rules behind, and SVG
// `fill` then falls back to its initial value, which is BLACK. Measured: 349
// <text>, 203 <path class="ctry"> and 95 <circle> painted solid black, with no
// error and no broken image — the map was a black rectangle.
// So carry the poster's stylesheet across too, MINUS its document-level rules
// (html/body/.stage/@media print) which would fight the packet's own shell.
// Taking the stylesheet rather than duplicating hex codes means a palette or
// map-style change in generate.mjs follows the packet automatically.
if (m) mapSvg = `<style>${mapCss(poster)}</style>
<div style="position:absolute;left:0;top:250px;width:${W}px;
transform:scale(0.62);transform-origin:top center">${m[0]}</div>`;
}
const { html, pageCount, withPhoto } = await buildPacket(z, groups, mapSvg);
const name = LANG === 'en' ? `${z.id}.html` : `${z.id}.${LANG}.html`;
writeFileSync(join(OUT, name), html);
made++;
console.log(` ✓ ${z.id.padEnd(34)} ${String(groups.length).padStart(4)} groups `
+ `${pageCount} pages ${withPhoto} photos ${(html.length / 1024 / 1024).toFixed(1)} MB`);
}
console.log(`\n${made} packet(s) → ${OUT}${ONLY ? ` (ONLY=${ONLY})` : ''}${LANG !== 'en' ? ` [${LANG}]` : ''}`);