#!/usr/bin/env python3 """build_sizing_web.py — website/assets/sizing-data.json for the machine estimator page. python3 -I scripts/build_sizing_web.py write the file python3 -I scripts/build_sizing_web.py --check exit 1 if the committed file differs from a fresh build Inputs (read only): documentation/audits/sizing-2026-10-13/sizing.json the 2026-10-13 N100 measurement website/alkalmazasok.html, website/en/apps.html the apps the site lists, their names and categories The page offers EXACTLY the apps the two apps pages list. Refusals (exit 1, nothing written): - an app listed on a page but absent from sizing.json (FileBrowser excepted, BY NAME: it is built into every box and its memory is inside the platform figure — SIZING.md §1) - an app whose peak is below its typical figure - the two pages listing different apps The rule's numbers live HERE and in the data file only; website/assets/sizing.js reads them from the file and has none of its own (scripts/test_build_sizing_web.py checks that). """ import hashlib, io, json, os, re, sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SIZING = os.path.join(ROOT, "documentation", "audits", "sizing-2026-10-13", "sizing.json") SOURCE_COMMIT = "c885b5ac" # the commit that added sizing.json; the sha256 below shows when it moved on HU_PAGE = os.path.join(ROOT, "website", "alkalmazasok.html") EN_PAGE = os.path.join(ROOT, "website", "en", "apps.html") OUT = os.path.join(ROOT, "website", "assets", "sizing-data.json") # SIZING.md §1/§5: platform inside the guest = its containers (255 MB) + the Docker daemons (≈ 220 MB RSS) PLATFORM_MB = 470 HEADROOM = 1.5 # scripts/felhom-host-install.sh autosize_guest_caps(): guest = clamp(host-4096, min 4096, max host-2048), # then the ceiling host-1024. `host` is MemTotal, which is below the label: the N100 sold as 16 GB reports # 15,772 MiB (measured 2026-10-13), so a box of N GB is taken as N*1024 - 612 MiB. GUEST_RULE = {"reserve_mib": 4096, "max_gap_mib": 2048, "min_mib": 4096, "ceiling_gap_mib": 1024, "memtotal_shortfall_mib": 612} BOXES_GB = [8, 16, 32] BUILTIN = {"filebrowser"} # One short line per medium / heavy-in-bursts app (the 9 of 2026-10-13). sizing.json's English notes, tidied. CPU_NOTES = { "immich": ("csak amíg új fotókat dolgoz fel (bélyegképek, arcok, keresés): 200 fotó kb. 6 perc", "only while it processes new photos (thumbnails, faces, search): 200 photos took about 6 minutes"), "jellyfin": ("csak amíg egy filmet átalakít egy olyan eszköznek, amely azt nem tudja lejátszani", "only while it converts a film for a device that cannot play it"), "paperless-ngx": ("csak amíg új beszkennelt dokumentumokat olvas be (szövegfelismerés, kb. 4 oldal percenként)", "only while it reads new scanned documents (text recognition, about 4 pages a minute)"), "calibre-web": ("csak amíg új könyveket vesz fel", "only while it imports new books"), "romm": ("az első indításkor; a játékkönyvtár átnézését nem mértük", "at first start; a library scan was not measured"), "onlyoffice": ("csak az első indításkor", "only at first start"), "grimmory": ("csak az első indításkor", "only at first start"), "tandoor": ("csak az első indításkor", "only at first start"), "adventurelog": ("csak az első indításkor", "only at first start"), } def fail(msg): print("build_sizing_web: REFUSED —", msg) sys.exit(1) def text(s): return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", s)).replace("&", "&").strip() def parse_apps_page(path): t = io.open(path, encoding="utf-8-sig").read() cats, apps = [], [] for sec in re.finditer(r'
(.*?)(?=
(.*?)", sec.group(2), re.S).group(1)))) for c in re.finditer(r'
.*?

(.*?)

(?:(.*?))?', sec.group(2), re.S): apps.append(dict(slug=c.group(1), category=len(cats) - 1, name=text(c.group(2)), tagline=text(c.group(3) or ""))) return cats, apps def build(): raw = open(SIZING, "rb").read() sz = json.loads(raw) S = {a["slug"]: a for a in sz["apps"]} hu_cats, hu = parse_apps_page(HU_PAGE) en_cats, en = parse_apps_page(EN_PAGE) if [a["slug"] for a in hu] != [a["slug"] for a in en] or len(hu_cats) != len(en_cats): fail("the Hungarian and English apps pages do not list the same apps in the same order") E = {a["slug"]: a for a in en} apps = [] for a in hu: slug = a["slug"] rec = dict(slug=slug, name_hu=a["name"], name_en=E[slug]["name"], tagline_hu=a["tagline"], tagline_en=E[slug]["tagline"], category=hu_cats[a["category"]][0]) if slug in BUILTIN: rec.update(builtin=True, typical_mb=0, peak_mb=0, cpu_class="light", cpu_note_hu="", cpu_note_en="", measured=True) else: if slug not in S: fail("%s is listed on the apps pages but missing from sizing.json" % slug) s = S[slug] if s["mem_peak_mb"] is None or s["mem_typical_mb"] is None: fail("%s has no memory figure in sizing.json" % slug) if s["mem_peak_mb"] < s["mem_typical_mb"]: fail("%s: peak %s < typical %s in sizing.json" % (slug, s["mem_peak_mb"], s["mem_typical_mb"])) cls = s["cpu_class"] note = CPU_NOTES.get(slug, ("", "")) if cls in ("medium", "heavy-in-bursts") and not note[0]: fail("%s is %s but has no CPU note here" % (slug, cls)) rec.update(typical_mb=s["mem_typical_mb"], peak_mb=s["mem_peak_mb"], cpu_class=cls, cpu_note_hu=note[0] if cls != "light" else "", cpu_note_en=note[1] if cls != "light" else "", measured=s["source"] != "unmeasured") apps.append(rec) return dict( schema=1, measured="2026-10-13", measured_on="Intel N100 mini PC, 16 GB (demo-felhom)", source=dict(path="documentation/audits/sizing-2026-10-13/sizing.json", commit=SOURCE_COMMIT, sha256=hashlib.sha256(raw).hexdigest()), platform_mb=PLATFORM_MB, headroom=HEADROOM, guest_rule=GUEST_RULE, boxes_gb=BOXES_GB, categories=[dict(id=h[0], id_en=e[0], name_hu=h[1], name_en=e[1]) for h, e in zip(hu_cats, en_cats)], apps=apps, ) def render(d): return json.dumps(d, ensure_ascii=False, indent=1) + "\n" if __name__ == "__main__": out = render(build()) if "--check" in sys.argv: cur = io.open(OUT, encoding="utf-8").read() if os.path.exists(OUT) else "" if cur != out: print("build_sizing_web: website/assets/sizing-data.json is NOT what the builder makes — rebuild it") sys.exit(1) print("build_sizing_web: sizing-data.json matches a fresh build") else: io.open(OUT, "w", encoding="utf-8", newline="\n").write(out) print("wrote", os.path.relpath(OUT, ROOT), "-", len(json.loads(out)["apps"]), "apps")