English copy — the PILOT: privatebin, paperless-ngx, romm (R-560 slice 5)
gates / gates (push) Failing after 2s
gates / gates (push) Failing after 2s
89 of 1 032 strings. No Hungarian byte moved; no image, pin, catalog_since or compose line changed. EN_MISSING_CEILING 1032 -> 943 in this commit. Chosen for SHAPE: privatebin exercises the plain case (description, tagline, lists); paperless-ngx adds select options, a placeholder and a customer-facing folder label; romm carries the catalog's only optional_config block, whose group has no id of its own and is matched by `match_group` — the Hungarian group name it translates. All three run on the demo box, so the English pages can be fetched rather than reasoned about. Two gate defects fixed while translating, each found by its own decoy rather than by reading: coverage was counted only for the apps NAMED on the command line, so `check-copy-i18n.py privatebin` reported 47 more missing strings than the same tree unscoped and either number could have been made to "pass"; and the ASCII-Hungarian stems matched as bare substrings, so „ird be" convicted "the third best" and „angol" convicted "Angola". Both now have their own case in the decoy suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1,3 +1,26 @@
|
||||
## English copy — the PILOT: privatebin, paperless-ngx, romm (2026-09-20, R-560 slice 5)
|
||||
|
||||
No image, no pin, no `catalog_since`, no compose line changed. Three apps gained an `i18n: en:`
|
||||
block at the end of their `.felhom.yml`; **not one Hungarian byte moved**, and the freeze gate
|
||||
proves it on all 53 apps on every push from now on.
|
||||
|
||||
89 of the catalog's 1 032 customer-facing strings are now English — privatebin 14, paperless-ngx 33,
|
||||
romm 42. `EN_MISSING_CEILING` 1032 → 943 in the same commit.
|
||||
|
||||
The three were chosen for SHAPE, not for size: between them they exercise every part of the overlay
|
||||
the controller learned to read in v0.257.0 — a plain app with only a description, tagline and lists
|
||||
(privatebin); select options, a placeholder and a customer-facing folder label (paperless-ngx); and
|
||||
the catalog's only `optional_config` block, whose group has no id of its own and is therefore matched
|
||||
by `match_group`, the Hungarian group name it translates (romm). All three run on the demo box, so
|
||||
the English pages could be fetched rather than reasoned about.
|
||||
|
||||
A box on a controller older than 0.257.0 ignores the block entirely and keeps rendering Hungarian —
|
||||
which is every box in the fleet until the floor is raised, and is why this push is safe ahead of it.
|
||||
|
||||
What a gate CANNOT judge, and is listed per app in `REPORT.md`: whether an app's „first steps" name
|
||||
the buttons that app's ENGLISH interface actually shows. Those lines are marked unverified and belong
|
||||
to the slice-6 walk.
|
||||
|
||||
## vaultwarden: registration closed by default; paperless-ngx: one worker and room for a batch (2026-09-15, R-512 / R-514 / R-515)
|
||||
|
||||
No image or version change — same `vaultwarden/server:1.36.0-alpine` and paperless-ngx 2.20.15; the
|
||||
|
||||
@@ -67,8 +67,8 @@ SUPPORTED_LANGS = ("en",)
|
||||
# and HU_FORMAL_CEILING.
|
||||
#
|
||||
# Measured on 94bc5febaca2, before any translation: 1 032 copy strings, none with English.
|
||||
# 1032 → (pilot) → (batch 1) → (batch 2) → 0
|
||||
EN_MISSING_CEILING = 1032
|
||||
# 1032 → 943 (pilot: privatebin, paperless-ngx, romm) → (batch 1) → (batch 2) → 0
|
||||
EN_MISSING_CEILING = 943
|
||||
|
||||
# ── What counts as COPY ──────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
@@ -389,7 +389,10 @@ def check_language(app, path, val, hu_val, display_name, fails):
|
||||
fails.append("%s: an accented Hungarian letter in the English text: %r" % (where, val))
|
||||
folded = fold(val)
|
||||
for stem in HU_ASCII_STEMS:
|
||||
if stem in folded:
|
||||
# WORD BOUNDARIES. A bare substring test convicts honest English: „ird be" is inside
|
||||
# "the third best", „angol" is inside "Angola". The stems are words, so they are matched
|
||||
# as words — the negative control below is the case that found it.
|
||||
if re.search(r"(?<![a-z0-9])%s(?![a-z0-9])" % re.escape(stem), folded):
|
||||
fails.append("%s: ASCII-only Hungarian %r in the English text: %r" % (where, stem, val))
|
||||
break
|
||||
if EN_FORBIDDEN.search(val):
|
||||
@@ -461,8 +464,13 @@ def main(argv):
|
||||
if "jelentkezz be" not in fold("Jelentkezz be: admin"):
|
||||
print("copy-i18n INCONCLUSIVE: positive control failed — folding does not strip accents")
|
||||
return 2
|
||||
if any(s in fold("Encrypted notes and text sharing") for s in HU_ASCII_STEMS):
|
||||
print("copy-i18n INCONCLUSIVE: negative control failed — a clean English sentence convicted")
|
||||
for control in ("Encrypted notes and text sharing",
|
||||
"The third best option is in Angola", # „ird be" / „angol" as substrings
|
||||
"These documents are kept on the server"): # „mentes" / „szerver" as substrings
|
||||
if any(re.search(r"(?<![a-z0-9])%s(?![a-z0-9])" % re.escape(st), fold(control))
|
||||
for st in HU_ASCII_STEMS):
|
||||
print("copy-i18n INCONCLUSIVE: negative control failed on %r — a clean English "
|
||||
"sentence convicted" % control)
|
||||
return 2
|
||||
print("copy-i18n: matcher controls OK (positive „Jelentkezz be…\" convicts, "
|
||||
"negative „Encrypted notes…\" does not)")
|
||||
@@ -531,10 +539,15 @@ def main(argv):
|
||||
vals, errs = overlay_strings(i18n[lang], meta)
|
||||
for e in errs:
|
||||
fails.append("%s: %s" % (app, e))
|
||||
if scope and app not in scope:
|
||||
continue
|
||||
# COVERAGE IS COUNTED FOR EVERY APP, scope or not: the ratchet is a fact
|
||||
# about the whole catalog, and a scoped run that counted only the named apps
|
||||
# would report a number that depends on how the gate was invoked — a ratchet
|
||||
# anybody could loosen by naming one app. Only the LANGUAGE checks are scoped,
|
||||
# because they are slow-ish and read only what the push touched.
|
||||
for path, val in sorted(vals.items()):
|
||||
en_paths.add(path)
|
||||
if scope and app not in scope:
|
||||
continue
|
||||
check_language(app, path, val, hu.get(path, ""),
|
||||
meta.get("display_name") or "", fails)
|
||||
|
||||
|
||||
+50
-16
@@ -126,8 +126,22 @@ def case_copy(name, clone, edits, expect_rc, must_contain=(), extra_args=()):
|
||||
try:
|
||||
for relpath, fn in edits:
|
||||
edit(clone, relpath, fn)
|
||||
args = list(extra_args)
|
||||
if "--expect-missing" not in args:
|
||||
# MEASURE the clone's current coverage and hand it back as the ceiling. The ratchet is
|
||||
# not what these cases test — they test the freeze, the structure and the language —
|
||||
# and hard-coding a number here would make every case fail the day a batch lands.
|
||||
# The ratchet has its own two cases below, in both directions.
|
||||
probe = sh([sys.executable, os.path.join(ROOT, "scripts", "check-copy-i18n.py"),
|
||||
"--root", clone, "--expect-missing", "-1"], cwd=clone)
|
||||
m = re.search(r"(\d+) strings have no English", probe.stdout + probe.stderr)
|
||||
if not m:
|
||||
fails.append("%s: could not measure the clone's coverage — the case is broken, "
|
||||
"not the gate" % name)
|
||||
return ""
|
||||
args += ["--expect-missing", m.group(1)]
|
||||
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-copy-i18n.py"),
|
||||
"--root", clone] + list(extra_args), cwd=clone)
|
||||
"--root", clone] + args, cwd=clone)
|
||||
out = r.stdout + r.stderr
|
||||
if r.returncode == expect_rc and all(m in out for m in must_contain):
|
||||
print(" ok %-52s rc=%d (expected %d)" % (name, r.returncode, expect_rc))
|
||||
@@ -267,8 +281,14 @@ i18n:
|
||||
description: "The address this app answers on"
|
||||
"""
|
||||
|
||||
def strip_en(t):
|
||||
"""Remove an existing English block (and its comment header) — a case must behave the
|
||||
same before and after that app's batch lands, or the suite rots on a future push."""
|
||||
t = re.sub(r"\n# --- English copy.*\Z", "\n", t, flags=re.S)
|
||||
return re.sub(r"\n^i18n:\n.*\Z", "\n", t, flags=re.S | re.M)
|
||||
|
||||
def add_en(block=GENUINE_EN):
|
||||
return lambda t: t.rstrip("\n") + "\n" + block
|
||||
return lambda t: strip_en(t).rstrip("\n") + "\n" + block
|
||||
|
||||
def en_with(old_, new_):
|
||||
return add_en(GENUINE_EN.replace(old_, new_))
|
||||
@@ -294,22 +314,18 @@ i18n:
|
||||
expect_rc=1, must_contain=("no Hungarian twin", "NOSUCHFIELD"))
|
||||
case_copy("FACT: an accented Hungarian letter left in the English", clone,
|
||||
[(PB, en_with("Share sensitive text safely", "Érzékeny text sharing"))],
|
||||
expect_rc=1, must_contain=("accented Hungarian letter",),
|
||||
extra_args=("--expect-missing", str(TOTAL - PB_EN)))
|
||||
expect_rc=1, must_contain=("accented Hungarian letter",))
|
||||
case_copy("FACT: ASCII-only Hungarian left in the English (no accent to find)", clone,
|
||||
[(PB, en_with(' description: "The address this app answers on"',
|
||||
' description: "Aldomain for the app"'))],
|
||||
expect_rc=1, must_contain=("ASCII-only Hungarian", "aldomain"),
|
||||
extra_args=("--expect-missing", str(TOTAL - PB_EN)))
|
||||
expect_rc=1, must_contain=("ASCII-only Hungarian", "aldomain"))
|
||||
case_copy("FACT: the product begs (\"please\")", clone,
|
||||
[(PB, en_with("Type your text and select Send", "Please type your text and select Send"))],
|
||||
expect_rc=1, must_contain=("does not beg",),
|
||||
extra_args=("--expect-missing", str(TOTAL - PB_EN)))
|
||||
expect_rc=1, must_contain=("does not beg",))
|
||||
case_copy("FACT: an English retrieval promise the Hungarian never made", clone,
|
||||
[(PB, en_with("Password protection for extra safety",
|
||||
"Deleted notes can still be restored later"))],
|
||||
expect_rc=1, must_contain=("retrieval promise",),
|
||||
extra_args=("--expect-missing", str(TOTAL - PB_EN)))
|
||||
expect_rc=1, must_contain=("retrieval promise",))
|
||||
# A credential is a LOGIN, not prose: gokapi's default_creds carries admin / adminadmin.
|
||||
GK = "templates/gokapi/.felhom.yml"
|
||||
case_copy("FACT: a credential token rewritten in translation", clone,
|
||||
@@ -319,21 +335,39 @@ i18n:
|
||||
app_info:
|
||||
default_creds: "Sign in: administrator / hunter2"
|
||||
""")],
|
||||
expect_rc=1, must_contain=("credential token",),
|
||||
extra_args=("--expect-missing", str(TOTAL - 1)))
|
||||
expect_rc=1, must_contain=("credential token",))
|
||||
case_copy("FACT: an i18n block for a language the controller does not render", clone,
|
||||
[(PB, lambda t: t.rstrip("\n") + "\ni18n:\n de:\n description: \"Verschluesselte Notizen\"\n")],
|
||||
expect_rc=1, must_contain=("renders en only",))
|
||||
case_copy("FACT: an English list with a different number of steps", clone,
|
||||
[(PB, en_with(" - 'Share the link you get - the encryption key is inside the URL'\n", ""))],
|
||||
expect_rc=1, must_contain=("a list is replaced",),
|
||||
extra_args=("--expect-missing", str(TOTAL - PB_EN + 1)))
|
||||
expect_rc=1, must_contain=("a list is replaced",))
|
||||
|
||||
# THE RATCHET — the one thing --expect-missing does not test for the cases above, so it is
|
||||
# tested here explicitly, in BOTH directions. A ceiling that only convicts upwards can be
|
||||
# left behind by a push that translated more than it recorded.
|
||||
case_copy("FACT: ratchet — fewer strings translated than the ceiling records", clone,
|
||||
[(PB, add_en())], expect_rc=1,
|
||||
must_contain=("English coverage", "ABOVE"),
|
||||
extra_args=("--expect-missing", "0"))
|
||||
case_copy("FACT: ratchet — more translated than the ceiling records", clone,
|
||||
[(PB, add_en())], expect_rc=1,
|
||||
must_contain=("English coverage", "BELOW"),
|
||||
extra_args=("--expect-missing", "999999"))
|
||||
|
||||
# THE GENUINE ARTICLE — must pass.
|
||||
case_copy("GENUINE: a correct English block on privatebin", clone,
|
||||
[(PB, add_en())], expect_rc=0,
|
||||
must_contain=("copy-i18n: OK", "privatebin 14/14"),
|
||||
extra_args=("--expect-missing", str(TOTAL - PB_EN)))
|
||||
must_contain=("copy-i18n: OK", "privatebin 14/14"))
|
||||
|
||||
# The ratchet is a fact about the CATALOG, not about how the gate was invoked. Naming one
|
||||
# app must not change the count — the first version of this gate counted coverage only for
|
||||
# the apps in scope, so `check-copy-i18n.py privatebin` reported 47 more missing strings
|
||||
# than the same tree unscoped, and either number could have been made to "pass".
|
||||
case_copy("GENUINE: naming an app does not change the coverage count", clone,
|
||||
[(PB, add_en())], expect_rc=0,
|
||||
must_contain=("copy-i18n: OK",),
|
||||
extra_args=("privatebin", "--expect-missing", str(TOTAL - PB_EN)))
|
||||
|
||||
# THE DECOYS — the LABEL moves, the FACT does not. Each must pass.
|
||||
case_copy("DECOY: Hungarian rewritten inside a YAML COMMENT", clone,
|
||||
|
||||
@@ -165,3 +165,75 @@ healthcheck:
|
||||
checks:
|
||||
- type: http
|
||||
port: 8000
|
||||
|
||||
# --- English copy (localisation slice 5, R-560) --------------------------------------------
|
||||
# The Hungarian above is UNCHANGED. A box on English reads this block field by field; a missing
|
||||
# field shows the Hungarian one; a controller older than 0.257.0 ignores the block entirely.
|
||||
i18n:
|
||||
en:
|
||||
description: "Digitise your documents and keep them in order"
|
||||
|
||||
data_paths:
|
||||
- path: paperless
|
||||
label: "Documents to read in"
|
||||
|
||||
deploy_fields:
|
||||
- env_var: DOMAIN
|
||||
label: "Domain"
|
||||
description: "The server domain name"
|
||||
|
||||
- env_var: SUBDOMAIN
|
||||
label: "Subdomain"
|
||||
description: "The subdomain this app answers on"
|
||||
|
||||
- env_var: DB_PASSWORD
|
||||
label: "Database password"
|
||||
|
||||
- env_var: PAPERLESS_SECRET_KEY
|
||||
label: "Encryption key"
|
||||
|
||||
- env_var: PAPERLESS_ADMIN_USER
|
||||
label: "Admin user name"
|
||||
|
||||
- env_var: PAPERLESS_ADMIN_PASSWORD
|
||||
label: "Admin password"
|
||||
description: "For the first sign-in. You can change it in the app afterwards."
|
||||
|
||||
- env_var: HDD_PATH
|
||||
label: "Data storage path"
|
||||
placeholder: "/mnt/felhom-drives/hdd_1"
|
||||
description: "The path to the external hard drive where the documents are kept"
|
||||
|
||||
- env_var: PAPERLESS_OCR_LANGUAGE
|
||||
label: "OCR language"
|
||||
description: "The language the text recognition reads"
|
||||
options:
|
||||
- value: "hun"
|
||||
label: "Hungarian"
|
||||
- value: "eng"
|
||||
label: "English"
|
||||
- value: "hun+eng"
|
||||
label: "Hungarian + English"
|
||||
- value: "deu+eng"
|
||||
label: "German + English"
|
||||
|
||||
app_info:
|
||||
tagline: 'A digital filing cabinet - scanning, OCR and automatic sorting'
|
||||
|
||||
use_cases:
|
||||
- 'Turn paper documents into files and keep them in order'
|
||||
- 'Automatic OCR text recognition on scanned documents'
|
||||
- 'Smart automatic sorting and tagging'
|
||||
- 'Full text search across every document'
|
||||
- 'Documents that arrive by e-mail are processed automatically'
|
||||
|
||||
first_steps:
|
||||
- 'Open paperless.DOMAIN in your browser'
|
||||
- 'Sign in: user "admin". The password is on the Settings page, under Automatically generated values'
|
||||
- 'Upload a document in the app, OR drop it into the import/paperless folder in the File manager (FileBrowser) - it is read in and processed with OCR from there. The File manager sign-in is on the File manager app page'
|
||||
- 'You can upload many documents at once: they are processed one by one, and a larger batch takes a few minutes'
|
||||
- 'Create tags and correspondents so new documents sort themselves'
|
||||
|
||||
prerequisites:
|
||||
- 'An external hard drive is recommended to keep the documents on'
|
||||
- 'At least 1 GB of free RAM (for the OCR work)'
|
||||
|
||||
@@ -58,3 +58,35 @@ healthcheck:
|
||||
checks:
|
||||
- type: http
|
||||
port: 8080
|
||||
|
||||
# --- English copy (localisation slice 5, R-560) --------------------------------------------
|
||||
# The Hungarian above is UNCHANGED and is what a Hungarian household reads. A box on English
|
||||
# reads this block field by field; a field missing here shows the Hungarian one, and a
|
||||
# controller older than 0.257.0 ignores the whole block.
|
||||
i18n:
|
||||
en:
|
||||
description: "Encrypted note and text sharing"
|
||||
|
||||
app_info:
|
||||
tagline: "Encrypted text sharing - the server never sees the content"
|
||||
|
||||
use_cases:
|
||||
- 'Share sensitive text safely'
|
||||
- 'End-to-end encryption - the server cannot reach the content'
|
||||
- 'You choose how long it lasts (5 minutes to 1 year, or never)'
|
||||
- 'Optional: delete it automatically after it is read'
|
||||
- 'Password protection for extra safety'
|
||||
|
||||
first_steps:
|
||||
- 'Open paste.DOMAIN in your browser'
|
||||
- 'Type your text and select Send'
|
||||
- 'Share the link you get - the encryption key is part of the URL'
|
||||
|
||||
deploy_fields:
|
||||
- env_var: DOMAIN
|
||||
label: "Domain"
|
||||
description: "The server domain name"
|
||||
|
||||
- env_var: SUBDOMAIN
|
||||
label: "Subdomain"
|
||||
description: "The subdomain this app answers on"
|
||||
|
||||
@@ -147,3 +147,90 @@ healthcheck:
|
||||
checks:
|
||||
- type: http
|
||||
port: 8080
|
||||
|
||||
# --- English copy (localisation slice 5, R-560) --------------------------------------------
|
||||
# The Hungarian above is UNCHANGED. A box on English reads this block field by field; a missing
|
||||
# field shows the Hungarian one; a controller older than 0.257.0 ignores the block entirely.
|
||||
i18n:
|
||||
en:
|
||||
description: "Retro game collection manager"
|
||||
|
||||
data_paths:
|
||||
- path: roms
|
||||
label: "ROM collection"
|
||||
|
||||
deploy_fields:
|
||||
- env_var: DOMAIN
|
||||
label: "Domain"
|
||||
description: "The server domain name"
|
||||
|
||||
- env_var: SUBDOMAIN
|
||||
label: "Subdomain"
|
||||
description: "The subdomain this app answers on"
|
||||
|
||||
- env_var: DB_PASSWORD
|
||||
label: "Database password"
|
||||
|
||||
- env_var: MYSQL_ROOT_PASSWORD
|
||||
label: "MariaDB root password"
|
||||
|
||||
- env_var: ROMM_AUTH_SECRET_KEY
|
||||
label: "Sign-in encryption key"
|
||||
|
||||
- env_var: HDD_PATH
|
||||
label: "Data storage path"
|
||||
placeholder: "/mnt/felhom-drives/hdd_1"
|
||||
description: "The path to the external hard drive where the ROMs and cover art are kept"
|
||||
|
||||
app_info:
|
||||
tagline: "Retro game collection manager, browser and player"
|
||||
default_creds: "admin / admin"
|
||||
|
||||
use_cases:
|
||||
- "Sort and browse a retro game collection in your browser"
|
||||
- "Game details download themselves - cover art, descriptions, ratings"
|
||||
- "Filter and search the whole collection by platform"
|
||||
- "Upload and download ROM files in your browser"
|
||||
- "Several people in the household can have their own account"
|
||||
|
||||
first_steps:
|
||||
- "Open arcade.DOMAIN in your browser"
|
||||
- "Sign in with the default admin / admin account"
|
||||
- "Change the password straight away, in the Settings menu"
|
||||
- "Upload the ROM files into the roms/ folder in the File manager (FileBrowser), sorted into platform folders (for example roms/gba/, roms/snes/)"
|
||||
- "Start a Scan from the menu on the left to read the ROMs in"
|
||||
- "Optional: set up metadata providers so cover art and descriptions download themselves (see below)"
|
||||
|
||||
prerequisites:
|
||||
- "An external hard drive is needed to keep the ROM files and cover art on"
|
||||
- "At least 1 GB of free RAM is recommended (MariaDB + Redis + RomM)"
|
||||
- "ROM files sorted into platform folders (for example roms/gba/, roms/snes/)"
|
||||
|
||||
optional_config:
|
||||
- match_group: "Metaadat-szolgáltatók"
|
||||
group: "Metadata providers"
|
||||
description: "So that cover art, descriptions and ratings download themselves. IGDB is the one worth setting up first. All of them are free after you sign up."
|
||||
fields:
|
||||
- env_var: IGDB_CLIENT_ID
|
||||
label: "IGDB Client ID"
|
||||
help_text: '1) Sign up or sign in on the Twitch developer portal (dev.twitch.tv). 2) Create a new application (any name will do). 3) Copy the Client ID across.'
|
||||
|
||||
- env_var: IGDB_CLIENT_SECRET
|
||||
label: "IGDB Client Secret"
|
||||
help_text: 'The Client Secret of your Twitch application (the "New Secret" button makes one).'
|
||||
|
||||
- env_var: STEAMGRIDDB_API_KEY
|
||||
label: "SteamGridDB API Key"
|
||||
help_text: 'Sign up on SteamGridDB, then select the "Generate API key" button under Preferences -> API.'
|
||||
|
||||
- env_var: SCREENSCRAPER_USER
|
||||
label: "ScreenScraper user name"
|
||||
help_text: 'Sign up on screenscraper.fr. Your account name is the API user name.'
|
||||
|
||||
- env_var: SCREENSCRAPER_PASSWORD
|
||||
label: "ScreenScraper password"
|
||||
help_text: 'The password of your screenscraper.fr account.'
|
||||
|
||||
- env_var: MOBYGAMES_API_KEY
|
||||
label: "MobyGames API Key"
|
||||
help_text: 'Sign up on MobyGames, then ask for a key on the API page. It gives detailed game information and credits.'
|
||||
|
||||
Reference in New Issue
Block a user