From 160d72f1441adc159fd3bd29c5e36d95656fd102 Mon Sep 17 00:00:00 2001
From: desfoto automation
Date: Fri, 25 Sep 2026 19:01:51 +0200
Subject: [PATCH] feat: publish unlisted GTIN tool
---
.ocauto/verify | 26 +-
AGENTS.md | 9 +-
README.md | 14 +-
nginx.conf | 5 +
scripts/build-site.py | 12 +-
site/.well-known/security.txt | 2 +-
site/datenschutz/index.html | 7 +-
site/gtin/css/app.css | 774 ++++++++++++++++++++
site/gtin/icons/favicon.svg | 1 +
site/gtin/icons/icon-192.png | Bin 0 -> 251 bytes
site/gtin/icons/icon-512.png | Bin 0 -> 1275 bytes
site/gtin/icons/icon-maskable-512.png | Bin 0 -> 1187 bytes
site/gtin/index.html | 190 +++++
site/gtin/js/app.js | 998 ++++++++++++++++++++++++++
site/gtin/js/barcode.js | 343 +++++++++
site/gtin/js/gtin.js | 162 +++++
site/gtin/js/liste.js | 75 ++
site/gtin/manifest.webmanifest | 34 +
site/gtin/sw.js | 173 +++++
site/impressum/index.html | 3 +-
site/sitemap.xml | 28 +-
src/gtin/css/app.css | 774 ++++++++++++++++++++
src/gtin/icons/favicon.svg | 1 +
src/gtin/icons/icon-192.png | Bin 0 -> 251 bytes
src/gtin/icons/icon-512.png | Bin 0 -> 1275 bytes
src/gtin/icons/icon-maskable-512.png | Bin 0 -> 1187 bytes
src/gtin/index.html | 190 +++++
src/gtin/js/app.js | 998 ++++++++++++++++++++++++++
src/gtin/js/barcode.js | 343 +++++++++
src/gtin/js/gtin.js | 162 +++++
src/gtin/js/liste.js | 75 ++
src/gtin/manifest.webmanifest | 34 +
src/gtin/sw.js | 173 +++++
src/pages.py | 10 +-
tests/test_site.py | 47 +-
35 files changed, 5632 insertions(+), 31 deletions(-)
create mode 100644 site/gtin/css/app.css
create mode 100644 site/gtin/icons/favicon.svg
create mode 100644 site/gtin/icons/icon-192.png
create mode 100644 site/gtin/icons/icon-512.png
create mode 100644 site/gtin/icons/icon-maskable-512.png
create mode 100644 site/gtin/index.html
create mode 100644 site/gtin/js/app.js
create mode 100644 site/gtin/js/barcode.js
create mode 100644 site/gtin/js/gtin.js
create mode 100644 site/gtin/js/liste.js
create mode 100644 site/gtin/manifest.webmanifest
create mode 100644 site/gtin/sw.js
create mode 100644 src/gtin/css/app.css
create mode 100644 src/gtin/icons/favicon.svg
create mode 100644 src/gtin/icons/icon-192.png
create mode 100644 src/gtin/icons/icon-512.png
create mode 100644 src/gtin/icons/icon-maskable-512.png
create mode 100644 src/gtin/index.html
create mode 100644 src/gtin/js/app.js
create mode 100644 src/gtin/js/barcode.js
create mode 100644 src/gtin/js/gtin.js
create mode 100644 src/gtin/js/liste.js
create mode 100644 src/gtin/manifest.webmanifest
create mode 100644 src/gtin/sw.js
diff --git a/.ocauto/verify b/.ocauto/verify
index f422792..0cbad7b 100755
--- a/.ocauto/verify
+++ b/.ocauto/verify
@@ -91,9 +91,10 @@ fi
printf '\n== routes\n'
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
- /minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/; do
+ /minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/ /gtin/; do
check_status "$base$route" 200
done
+check_redirect "$base/gtin" "$base/gtin/"
# The retired photo URL must keep working: it is the umbrella page now.
check_redirect "$base/shootings" "$base/fotografie/"
@@ -139,6 +140,18 @@ check_contains "$base/musik-und-buehne/" "Sagenbringer"
check_contains "$base/impressum/" "§ 5 DDG"
check_contains "$base/impressum/" "DE462149560"
check_contains "$base/datenschutz/" "keine Zugriffsprotokolle"
+check_contains "$base/datenschutz/" "bis zu 20"
+check_contains "$base/impressum/" "GTIN-Tool"
+check_contains "$base/gtin/" 'name="robots" content="noindex, nofollow"'
+check_not_contains "$base/" 'href="/gtin'
+check_not_contains "$base/sitemap.xml" "/gtin/"
+check_contains "$base/gtin/" 'href="/impressum/"'
+check_contains "$base/gtin/" 'href="/datenschutz/"'
+for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
+ /minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ \
+ /kontakt/ /impressum/ /datenschutz/; do
+ check_not_contains "$base$route" 'href="/gtin'
+done
check_contains "$base/datenschutz/" "youtube-nocookie.com"
check_contains "$base/video/" "NWWFTf7l8g0"
check_contains "$base/" "https://www.dennyschulz.de/"
@@ -157,6 +170,17 @@ check_status "$base/assets/img/logo-160.png" 200
check_status "$base/assets/img/favicon-32.png" 200
check_status "$base/assets/img/og-fotografie.jpg" 200
check_status "$base/assets/site.css" 200
+check_status "$base/gtin/css/app.css" 200
+check_status "$base/gtin/js/app.js" 200
+check_status "$base/gtin/sw.js" 200
+check_status "$base/gtin/manifest.webmanifest" 200
+check_status "$base/gtin/health" 404
+manifest_type="$(curl -sSI --max-time 30 "$base/gtin/manifest.webmanifest" | tr -d '\r' | grep -i '^content-type:' || true)"
+if printf '%s' "$manifest_type" | grep -qi 'application/manifest+json'; then
+ pass "$base/gtin/manifest.webmanifest uses the Web App Manifest MIME type"
+else
+ fail "$base/gtin/manifest.webmanifest has wrong Content-Type: ${manifest_type:-}"
+fi
check_status "$base/assets/fonts/fraunces-latin.woff2" 200
# The portfolio expansion ships the operator's own public work...
check_status "$base/assets/img/music-dreadlocks-1200.webp" 200
diff --git a/AGENTS.md b/AGENTS.md
index abe5726..ee33f33 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,10 +89,17 @@ request and is the only permitted reference (see "Editing rules").
## Privacy contract
The `/datenschutz/` page makes concrete promises. Any change that would break one of
them is a defect, not a style question:
-- no cookies, no `localStorage`, no analytics, no tracking;
+- no cookies, analytics or tracking. The standalone `/gtin/` tool is the only
+ `localStorage`/Cache API exception: it keeps its last 20 generated entries in
+ the browser and caches only its own offline app shell; it sends no tool data
+ to a server. The tool stays unlisted: no site navigation or page links, no
+ sitemap entry, and `noindex,nofollow`; only the tool itself links out to the
+ required legal pages;
- `access_log off` in `nginx.conf` — never enable request logging;
- `error_log /dev/null crit;` in `nginx.conf` — never write an error-log file either;
- fonts, scripts, styles and images are served from this origin only;
+- the GTIN service worker is scoped to `/gtin/` and may not intercept other site
+ routes or delete caches owned by another application;
- YouTube is embedded exclusively via the click-to-load facade
(`youtube-nocookie.com`) — no iframe before the click, no preconnect;
- the contact form never posts anywhere; it composes a local `mailto:` draft
diff --git a/README.md b/README.md
index 9523955..358ca54 100644
--- a/README.md
+++ b/README.md
@@ -41,9 +41,13 @@ open http://127.0.0.1:18430/
## Seiten
-Startseite · Fotografie · Businessfotografie · Portrait & Model · Familien & Paare ·
-Minishootings · Video · Social Media · Projekte · Über · Kontakt · Impressum ·
-Datenschutz — plus eine eigene 404-Seite.
+Startseite · Fotografie · Businessfotografie · Portrait & Model · Familie · Minishootings ·
+Video · Social Media · Projekte · Über · Kontakt · Impressum · Datenschutz — plus eine eigene
+404-Seite.
+
+Der GTIN-Generator unter `/gtin/` ist ein separates, öffentlich direkt aufrufbares Werkzeug.
+Er bleibt absichtlich unverlinkt, fehlt in der Sitemap und trägt `noindex,nofollow`; nur das
+Werkzeug selbst verlinkt auf Impressum und Datenschutz.
## Technische Entscheidungen
@@ -59,6 +63,10 @@ Datenschutz — plus eine eigene 404-Seite.
Es gibt keinen API-Endpunkt und keine Datenübertragung an den Server.
- **Keine Zugriffsprotokolle.** `nginx.conf` schaltet das Access-Log ab; es gibt
keine Cookies, keine Analyse und keine externen Ressourcen.
+- **GTIN-Generator.** Der Generator unter `/gtin/` rechnet vollständig im Browser,
+ ohne Konto, externe API oder Serverübertragung. Er speichert bis zu 20 zuletzt
+ erzeugte Einträge lokal im Browser; der Offline-Appcache enthält nur die
+ Anwendungsdateien. Beide Funktionen sind in der Datenschutzerklärung beschrieben.
## Herkunft der Inhalte
diff --git a/nginx.conf b/nginx.conf
index 0e5d471..c7dc657 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -64,6 +64,11 @@ server {
location = /impressum.html { return 301 /impressum/; }
location = /datenschutz.html { return 301 /datenschutz/; }
location = /kontakt.html { return 301 /kontakt/; }
+ location = /gtin { return 301 /gtin/; }
+ location = /gtin/manifest.webmanifest {
+ default_type application/manifest+json;
+ try_files $uri =404;
+ }
# /fotografie.html used to redirect to /shootings/; it resolves to its own
# canonical page now instead of turning into a 404.
location = /fotografie.html { return 301 /fotografie/; }
diff --git a/scripts/build-site.py b/scripts/build-site.py
index ad79c5d..1308809 100755
--- a/scripts/build-site.py
+++ b/scripts/build-site.py
@@ -40,6 +40,7 @@ from theme import CSS, TOKENS # noqa: E402
WIDTHS = (480, 768, 1200, 1600)
WEBP_QUALITY = 84
+UNLISTED_HTML = ["gtin/index.html"]
INK = (23, 20, 15)
PAPER = (246, 242, 234)
@@ -278,6 +279,9 @@ def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
target.write_text(html, encoding="utf-8")
written.append((path, rel))
print(f" [html] {rel}")
+ shutil.rmtree(SITE / "gtin", ignore_errors=True)
+ shutil.copytree(SRC / "gtin", SITE / "gtin")
+ print(" [html] gtin/index.html (unlisted browser tool)")
not_found = pages.not_found().replace("og-desfoto.jpg", "og-desfoto.jpg")
(SITE / "404.html").write_text(not_found, encoding="utf-8")
print(" [html] 404.html")
@@ -287,7 +291,7 @@ def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
def build_meta_files(routes: list[tuple[str, str]]) -> None:
today = dt.date.today().isoformat()
urls = []
- for path, _rel, _f, _x, priority in pages.ROUTES:
+ for path, _rel, _factory, _extra, priority in pages.ROUTES:
urls.append(
f" {SITE_META['url']}{path}{today}"
f"{'weekly' if priority == '1.0' else 'monthly'}"
@@ -571,7 +575,11 @@ def prune_stale_pages(routes: list[tuple]) -> None:
leave its old HTML (and its retired image srcsets) behind and the link check
would keep failing on files nobody serves any more.
"""
- expected = {SITE / route[1] for route in routes} | {SITE / "404.html"}
+ expected = (
+ {SITE / route[1] for route in routes}
+ | {SITE / rel for rel in UNLISTED_HTML}
+ | {SITE / "404.html"}
+ )
for html in sorted(SITE.rglob("*.html")):
if html in expected:
continue
diff --git a/site/.well-known/security.txt b/site/.well-known/security.txt
index 380ff64..40c7b28 100644
--- a/site/.well-known/security.txt
+++ b/site/.well-known/security.txt
@@ -1,4 +1,4 @@
Contact: mailto:info@dennyschulz.de
-Expires: 2027-09-19T00:00:00.000Z
+Expires: 2027-09-25T00:00:00.000Z
Preferred-Languages: de, en
Canonical: https://desfoto.de/.well-known/security.txt
diff --git a/site/datenschutz/index.html b/site/datenschutz/index.html
index 11fd353..e4f813e 100644
--- a/site/datenschutz/index.html
+++ b/site/datenschutz/index.html
@@ -101,8 +101,11 @@
5. Cookies und lokale Speicherung
Diese Website setzt keine Cookies und verwendet keine Analyse-, Marketing- oder
- Tracking-Technologien. Es werden auch keine Inhalte im lokalen Speicher deines Browsers abgelegt.
- Ein Cookie-Banner ist deshalb nicht erforderlich.
+ Tracking-Technologien. Das browserbasierte GTIN-Tool speichert die letzten bis zu 20
+ Artikelnummern und erzeugten GTINs im lokalen Speicher deines Browsers, damit du sie dort erneut
+ aufrufen oder den Verlauf löschen kannst. Die Einträge werden nicht an einen Server übertragen.
+ Für den Offlinebetrieb speichert dein Browser außerdem die Anwendungsdateien im Cache. Beides
+ kannst du über das Tool beziehungsweise die Browser-Einstellungen löschen.
+ Barcode wird nicht erkannt? Displayhelligkeit erhöhen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Liste 0
+
+
+
+
+
Noch keine Nummer in der Liste.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/gtin/js/app.js b/site/gtin/js/app.js
new file mode 100644
index 0000000..440a7c4
--- /dev/null
+++ b/site/gtin/js/app.js
@@ -0,0 +1,998 @@
+/**
+ * Oberflaeche, Scanmodus, Zwischenablage, Verlauf, Wake Lock und PWA.
+ *
+ * Die fachliche Logik liegt in gtin.js, die Codierung in barcode.js. Diese
+ * Datei verdrahtet beides mit dem DOM.
+ */
+
+import { eingabeStatus, erzeugeGtin, FEHLERTEXT, nurZiffern } from './gtin.js';
+import { barcodeGroesse, erzeugeSvgMarkup, kodiereEan } from './barcode.js';
+import { schritt, istBestaetigungstaste, zaehlerText, zerlegeEingabe } from './liste.js';
+
+/** @typedef {import('./gtin.js').GtinErgebnis} GtinErgebnis */
+
+/** @typedef {{ artikel: string, gtin: string, typ: string }} VerlaufEintrag */
+/** @typedef {{ released: boolean, release: () => Promise, addEventListener: (typ: 'release', hoerer: () => void) => void }} WakeLockLike */
+
+const VERLAUF_SCHLUESSEL = 'desfoto.gtin.verlauf.v1';
+const VERLAUF_MAX = 20;
+const EINGABE_VERZOEGERUNG_MS = 450;
+const VERLAUF_VERZOEGERUNG_MS = 2500;
+const SCAN_HINWEIS_MS = 7000;
+const TOAST_MS = 1800;
+/** Anteil der Bildschirmhoehe, den der Barcode in der Normalansicht nutzen darf. */
+const BALKENHOEHE_ANTEIL = 0.45;
+/** Untergrenze, damit der Barcode auch auf kurzen Anzeigen scanbar bleibt. */
+const BALKENHOEHE_MIN_PX = 180;
+
+/**
+ * @param {string} auswahl
+ * @returns {HTMLElement}
+ */
+function element(auswahl) {
+ const gefunden = document.querySelector(auswahl);
+ if (!gefunden) throw new Error(`Pflichtelement fehlt: ${auswahl}`);
+ return /** @type {HTMLElement} */ (gefunden);
+}
+
+const input = /** @type {HTMLInputElement} */ (element('#artikelnummer'));
+const fehlerText = element('#fehler');
+const ergebnisBereich = element('#ergebnis');
+const gtinTyp = element('#gtin-typ');
+const gtinKnopf = element('#gtin');
+const barcodeBehaelter = element('#barcode');
+const grossKnopf = element('#gross');
+const kopierenKnopf = element('#kopieren');
+const neuKnopf = element('#neu');
+const verlaufBereich = element('#verlauf');
+const verlaufListe = element('#verlauf-liste');
+const verlaufLoeschenKnopf = element('#verlauf-loeschen');
+const installierenKnopf = element('#installieren');
+const iosHinweis = element('#ios-hinweis');
+const offlineHinweis = element('#offline-hinweis');
+const scanBereich = element('#scan');
+const scanInnen = element('#scan-innen');
+const scanBarcodeBehaelter = element('#scan-barcode');
+const scanGtin = element('#scan-gtin');
+const scanHinweis = element('#scan-hinweis');
+const scanZurueckKnopf = element('#scan-zurueck');
+const toast = element('#toast');
+const appBereich = element('.app');
+const listeOeffnenKnopf = element('#liste-oeffnen');
+const listeBereich = element('#liste');
+const listeForm = /** @type {HTMLFormElement} */ (element('#liste-form'));
+const listeErfassen = element('#liste-erfassen');
+const listeAnzeige = element('#liste-anzeige');
+const listeEingabe = /** @type {HTMLInputElement} */ (element('#liste-eingabe'));
+const listeHinzufuegenKnopf = /** @type {HTMLButtonElement} */ (element('#liste-hinzufuegen'));
+const listeFehler = element('#liste-fehler');
+const listeEintraege = element('#liste-eintraege');
+const listeLeer = element('#liste-leer');
+const listeAnzahl = element('#liste-anzahl');
+const listeWeiterKnopf = /** @type {HTMLButtonElement} */ (element('#liste-weiter'));
+const listeSchliessenKnopf = element('#liste-schliessen');
+const listeZaehler = element('#liste-zaehler');
+const listeBuehne = element('#liste-buehne');
+const listeMitte = element('#liste-mitte');
+const listeBarcodeBehaelter = element('#liste-barcode');
+const listeGtin = element('#liste-gtin');
+const listeArtikel = element('#liste-artikel');
+const listeZurueckKnopf = element('#liste-zurueck');
+const listeVorKnopf = element('#liste-vor');
+const listeAendernKnopf = element('#liste-aendern');
+const listeBeendenKnopf = element('#liste-beenden');
+
+/** @type {GtinErgebnis | null} */
+let aktuell = null;
+/** @type {GtinErgebnis[]} */
+const liste = [];
+let listeAktiv = false;
+let listeAnzeigeAktiv = false;
+let listeIndex = 0;
+/** @type {number | undefined} */
+let eingabeTimer;
+/** @type {number | undefined} */
+let verlaufTimer;
+/** @type {number | undefined} */
+let toastTimer;
+/** @type {number | undefined} */
+let scanHinweisTimer;
+/** @type {number | undefined} */
+let groessenTimer;
+/** @type {WakeLockLike | null} */
+let wakeLock = null;
+let scanAktiv = false;
+/** @type {any} */
+let installationsAnfrage = null;
+
+/* ---------------------------------------------------------------- Verlauf -- */
+
+/**
+ * @param {unknown} eintrag
+ * @returns {eintrag is VerlaufEintrag}
+ */
+function istVerlaufEintrag(eintrag) {
+ if (typeof eintrag !== 'object' || eintrag === null) return false;
+ const kandidat = /** @type {Record} */ (eintrag);
+ return (
+ typeof kandidat.artikel === 'string' &&
+ typeof kandidat.gtin === 'string' &&
+ typeof kandidat.typ === 'string'
+ );
+}
+
+/** @returns {VerlaufEintrag[]} */
+function ladeVerlauf() {
+ try {
+ const roh = window.localStorage.getItem(VERLAUF_SCHLUESSEL);
+ if (!roh) return [];
+ const daten = /** @type {unknown} */ (JSON.parse(roh));
+ if (!Array.isArray(daten)) return [];
+ return daten.filter(istVerlaufEintrag).slice(0, VERLAUF_MAX);
+ } catch {
+ return [];
+ }
+}
+
+/** @param {VerlaufEintrag[]} eintraege */
+function speichereVerlaufListe(eintraege) {
+ try {
+ window.localStorage.setItem(VERLAUF_SCHLUESSEL, JSON.stringify(eintraege));
+ } catch {
+ /* Speicher gesperrt oder voll: der Verlauf ist optional. */
+ }
+}
+
+/** @param {GtinErgebnis} ergebnis */
+function merkeImVerlauf(ergebnis) {
+ const eintrag = { artikel: ergebnis.eingabe, gtin: ergebnis.gtin, typ: ergebnis.typ };
+ const bisher = ladeVerlauf().filter((vorhanden) => vorhanden.gtin !== eintrag.gtin);
+ speichereVerlaufListe([eintrag, ...bisher].slice(0, VERLAUF_MAX));
+ rendereVerlauf();
+}
+
+function rendereVerlauf() {
+ const eintraege = ladeVerlauf();
+ verlaufListe.replaceChildren();
+ for (const eintrag of eintraege) {
+ const punkt = document.createElement('li');
+ const knopf = document.createElement('button');
+ knopf.type = 'button';
+ knopf.className = 'verlauf-eintrag';
+ knopf.dataset.artikel = eintrag.artikel;
+ knopf.dataset.gtin = eintrag.gtin;
+
+ const artikel = document.createElement('span');
+ artikel.className = 'verlauf-artikel';
+ artikel.textContent = eintrag.artikel;
+
+ const pfeil = document.createElement('span');
+ pfeil.className = 'verlauf-pfeil';
+ pfeil.setAttribute('aria-hidden', 'true');
+ pfeil.textContent = '→';
+
+ const nummer = document.createElement('span');
+ nummer.className = 'verlauf-gtin';
+ nummer.textContent = eintrag.gtin;
+
+ knopf.append(artikel, pfeil, nummer);
+ knopf.setAttribute(
+ 'aria-label',
+ `Artikelnummer ${eintrag.artikel}, GTIN ${eintrag.gtin} erneut anzeigen`,
+ );
+ punkt.append(knopf);
+ verlaufListe.append(punkt);
+ }
+ verlaufBereich.hidden = eintraege.length === 0;
+}
+
+function leereVerlauf() {
+ speichereVerlaufListe([]);
+ rendereVerlauf();
+ zeigeToast('Verlauf gelöscht');
+}
+
+/* --------------------------------------------------------------- Anzeige -- */
+
+function versteckeFehler() {
+ fehlerText.hidden = true;
+ fehlerText.textContent = '';
+}
+
+function zeigeFehler() {
+ fehlerText.textContent = FEHLERTEXT;
+ fehlerText.hidden = false;
+}
+
+function versteckeErgebnis() {
+ aktuell = null;
+ ergebnisBereich.hidden = true;
+ barcodeBehaelter.replaceChildren();
+ stoppeVerlaufTimer();
+}
+
+/** Verfuegbare Hoehe fuer den Barcode in der Normalansicht. */
+function normalHoehe() {
+ return Math.max(
+ BALKENHOEHE_MIN_PX,
+ Math.round(window.innerHeight * BALKENHOEHE_ANTEIL),
+ );
+}
+
+/**
+ * @param {HTMLElement} behaelter
+ * @param {GtinErgebnis} ergebnis
+ * @param {'normal' | 'scan'} modus
+ */
+function rendereBarcode(behaelter, ergebnis, modus) {
+ const symbol = kodiereEan(ergebnis.gtin);
+ const gesamtModule =
+ symbol.quietzone.links + symbol.module + symbol.quietzone.rechts;
+ const gemessen = behaelter.getBoundingClientRect().width || behaelter.clientWidth;
+ const verfuegbareBreite = Math.max(1, Math.round(gemessen || 320));
+ // Im Scanmodus bestimmt die gemessene Flaeche die Groesse (Querformat: volle
+ // Breite und Hoehe). In der Normalansicht deckelt ein Anteil der
+ // Bildschirmhoehe den Barcode, damit die wichtigsten Bedienelemente ohne
+ // Scrollen sichtbar bleiben.
+ const verfuegbareHoehe =
+ modus === 'scan'
+ ? Math.max(1, Math.round(behaelter.clientHeight || normalHoehe()))
+ : normalHoehe();
+ const { modulbreite: modulbreitePx, balkenhoeheModule } = barcodeGroesse(
+ verfuegbareBreite,
+ verfuegbareHoehe,
+ gesamtModule,
+ );
+ const svg = erzeugeSvgMarkup(ergebnis.gtin, {
+ modulbreite: modulbreitePx,
+ balkenhoeheModule,
+ });
+ behaelter.innerHTML = svg.markup;
+}
+
+function stoppeVerlaufTimer() {
+ if (verlaufTimer !== undefined) {
+ window.clearTimeout(verlaufTimer);
+ verlaufTimer = undefined;
+ }
+}
+
+/** Uebernimmt das aktuelle Ergebnis in den Verlauf, sobald es bestaetigt wirkt. */
+function merkeAktuellesNachKurzerPause() {
+ stoppeVerlaufTimer();
+ verlaufTimer = window.setTimeout(() => {
+ verlaufTimer = undefined;
+ if (aktuell) merkeImVerlauf(aktuell);
+ }, VERLAUF_VERZOEGERUNG_MS);
+}
+
+/** @param {GtinErgebnis} ergebnis */
+function zeigeErgebnis(ergebnis) {
+ aktuell = ergebnis;
+ versteckeFehler();
+ gtinTyp.textContent = ergebnis.typ === 'EAN-8' ? 'GTIN-8' : 'GTIN-13';
+ gtinKnopf.textContent = ergebnis.gtin;
+ ergebnisBereich.hidden = false;
+ rendereBarcode(barcodeBehaelter, ergebnis, 'normal');
+ merkeAktuellesNachKurzerPause();
+}
+
+function pruefeUndZeige() {
+ const status = eingabeStatus(input.value);
+ if (status === 'leer') {
+ versteckeFehler();
+ versteckeErgebnis();
+ return;
+ }
+ // Auch eine zu kurze Nummer ist am Ende ungueltig. Der Fehler erscheint erst
+ // nach der Entprellung, waehrend des Tippens also nicht.
+ if (status === 'unvollstaendig' || status === 'ungueltig') {
+ versteckeErgebnis();
+ zeigeFehler();
+ return;
+ }
+ const ergebnis = erzeugeGtin(input.value);
+ if (!ergebnis) {
+ versteckeErgebnis();
+ zeigeFehler();
+ return;
+ }
+ zeigeErgebnis(ergebnis);
+}
+
+/* ---------------------------------------------------------------- Eingabe -- */
+
+function stoppeEingabeTimer() {
+ if (eingabeTimer !== undefined) {
+ window.clearTimeout(eingabeTimer);
+ eingabeTimer = undefined;
+ }
+}
+
+function beiEingabe() {
+ const sauber = nurZiffern(input.value);
+ if (sauber !== input.value) {
+ input.value = sauber;
+ input.setSelectionRange(sauber.length, sauber.length);
+ }
+ stoppeEingabeTimer();
+ if (sauber.length === 0) {
+ versteckeFehler();
+ versteckeErgebnis();
+ return;
+ }
+ if (sauber.length === 7) {
+ pruefeUndZeige();
+ return;
+ }
+ eingabeTimer = window.setTimeout(() => {
+ eingabeTimer = undefined;
+ pruefeUndZeige();
+ }, EINGABE_VERZOEGERUNG_MS);
+}
+
+/**
+ * Uebernimmt eine Artikelnummer in das Eingabefeld.
+ *
+ * @param {string} artikel
+ * @param {{ fokussieren?: boolean }} [optionen] `fokussieren: false` laesst den
+ * Fokus, wo er ist: Ein Tipp auf den Verlauf soll den Barcode zeigen und auf
+ * Mobilgeraeten nicht die Tastatur ueber das Ergebnis legen.
+ */
+function uebernimmArtikel(artikel, optionen = {}) {
+ input.value = artikel;
+ stoppeEingabeTimer();
+ pruefeUndZeige();
+ if (optionen.fokussieren ?? true) input.focus();
+ // Der Verlauf steht unter dem Ergebnis: Nach dem Uebernehmen dorthin
+ // scrollen, damit der erzeugte Barcode sichtbar ist.
+ ergebnisBereich.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+}
+
+/* ------------------------------------------------------------------- Feld -- */
+
+/**
+ * Markiert die komplette Artikelnummer.
+ *
+ * Ein Tipp auf das Feld waehlt damit die alte Nummer aus: Die naechste Ziffer
+ * ueberschreibt sie direkt, ohne dass vorher geloescht werden muss.
+ */
+function markiereEingabe() {
+ if (input.value === '') {
+ versteckeFehler();
+ return;
+ }
+ input.setSelectionRange(0, input.value.length);
+}
+
+/** true, wenn das Feld beim letzten Zeigerdruck bereits den Fokus hatte. */
+let feldWarFokussiert = false;
+
+function neueNummer() {
+ stoppeEingabeTimer();
+ input.value = '';
+ versteckeFehler();
+ versteckeErgebnis();
+ input.focus();
+}
+
+/* ------------------------------------------------------------ Scanmodus -- */
+
+/** @returns {{ request: (typ: 'screen') => Promise } | null} */
+function wakeLockApi() {
+ const api = /** @type {any} */ (navigator).wakeLock;
+ return api && typeof api.request === 'function' ? api : null;
+}
+
+function starteWakeLock() {
+ const api = wakeLockApi();
+ if (!api || wakeLock) return;
+ api
+ .request('screen')
+ .then((sentinel) => {
+ wakeLock = sentinel;
+ sentinel.addEventListener('release', () => {
+ if (wakeLock === sentinel) wakeLock = null;
+ });
+ })
+ .catch(() => {
+ wakeLock = null;
+ });
+}
+
+function stoppeWakeLock() {
+ const sentinel = wakeLock;
+ wakeLock = null;
+ if (sentinel) sentinel.release().catch(() => undefined);
+}
+
+function oeffneScan() {
+ if (!aktuell || scanAktiv) return;
+ scanAktiv = true;
+ scanGtin.textContent = aktuell.gtin;
+ scanBereich.hidden = false;
+ document.body.classList.add('scan-offen');
+ // Der Rest der Oberflaeche wird unerreichbar: Tastatur und Hilfstechnik
+ // bleiben im Scanmodus, weil nur noch der Dialog bedienbar ist.
+ appBereich.toggleAttribute('inert', true);
+ rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
+ scanInnen.focus();
+ starteWakeLock();
+ scanHinweisTimer = window.setTimeout(() => {
+ scanHinweisTimer = undefined;
+ if (scanAktiv) scanHinweis.hidden = false;
+ }, SCAN_HINWEIS_MS);
+ merkeImVerlauf(aktuell);
+ stoppeVerlaufTimer();
+}
+
+function schliesseScan() {
+ if (!scanAktiv) return;
+ scanAktiv = false;
+ scanBereich.hidden = true;
+ scanHinweis.hidden = true;
+ appBereich.toggleAttribute('inert', false);
+ document.body.classList.remove('scan-offen');
+ if (scanHinweisTimer !== undefined) {
+ window.clearTimeout(scanHinweisTimer);
+ scanHinweisTimer = undefined;
+ }
+ stoppeWakeLock();
+ grossKnopf.focus();
+}
+
+/* ------------------------------------------------------------ Listenmodus -- */
+
+/*
+ * Optionaler Modus: Die Liste liegt als kleiner Link unter dem Werkzeug. Sie
+ * wird in zwei Schritten bedient - Nummern bestaetigen, dann mit Pfeilen,
+ * Pfeiltasten oder Wischen GTIN und Barcode nacheinander anzeigen. Beide
+ * Schritte enden ueber die kleinen Links zurueck in die Einzelansicht.
+ *
+ * Bestaetigt wird ueber das Formular: Auf einem Telefon hat das Numpad keine
+ * Eingabetaste, seine Bestaetigungstaste loest aber das submit-Ereignis aus.
+ * Zusaetzlich liegt unmittelbar neben dem Feld eine sichtbare Schaltflaeche,
+ * damit die Bestaetigung auf keinem Geraet von der Tastatur abhaengt.
+ */
+
+function versteckeListenFehler() {
+ listeFehler.hidden = true;
+ listeFehler.textContent = '';
+}
+
+function zeigeListenFehler() {
+ listeFehler.textContent = FEHLERTEXT;
+ listeFehler.hidden = false;
+}
+
+function rendereListe() {
+ listeEintraege.replaceChildren();
+ liste.forEach((eintrag, index) => {
+ const punkt = document.createElement('li');
+ punkt.className = 'liste-eintrag';
+
+ const nummer = document.createElement('span');
+ nummer.className = 'liste-nr';
+ nummer.textContent = eintrag.eingabe;
+
+ const gtin = document.createElement('span');
+ gtin.className = 'liste-gtin';
+ gtin.textContent = eintrag.gtin;
+
+ const weg = document.createElement('button');
+ weg.type = 'button';
+ weg.className = 'liste-weg';
+ weg.dataset.index = String(index);
+ weg.setAttribute('aria-label', `Artikelnummer ${eintrag.eingabe} aus der Liste entfernen`);
+ weg.textContent = '×';
+
+ punkt.append(nummer, gtin, weg);
+ listeEintraege.append(punkt);
+ });
+
+ listeAnzahl.textContent = String(liste.length);
+ listeLeer.hidden = liste.length > 0;
+ listeWeiterKnopf.disabled = liste.length === 0;
+}
+
+/**
+ * Uebernimmt die Eingabe in die Liste.
+ *
+ * Alles, was keine Ziffer ist, trennt Nummern: So laesst sich eine ganze Spalte
+ * in einem Zug einfuegen. Die Bestaetigung im wirklich leeren Feld startet
+ * direkt die Anzeige, damit die Erfassung ohne Klick auf "Weiter" endet. Steht
+ * dagegen Text ohne eine einzige Ziffer im Feld, ist das ein Fehler und kein
+ * Start.
+ */
+function uebernimmListeneingabe() {
+ const roh = listeEingabe.value;
+ const gruppen = zerlegeEingabe(roh);
+ if (gruppen.length === 0) {
+ if (roh.trim() !== '') {
+ zeigeListenFehler();
+ return;
+ }
+ versteckeListenFehler();
+ if (liste.length > 0) starteListenAnzeige(0);
+ return;
+ }
+ let uebernommen = 0;
+ let verworfen = 0;
+ for (const gruppe of gruppen) {
+ const ergebnis = erzeugeGtin(gruppe);
+ if (ergebnis) {
+ liste.push(ergebnis);
+ uebernommen += 1;
+ } else {
+ verworfen += 1;
+ }
+ }
+ if (uebernommen > 0) {
+ listeEingabe.value = '';
+ rendereListe();
+ listeEintraege.scrollTop = listeEintraege.scrollHeight;
+ // Der Fokus bleibt im Feld: Die Bildschirmtastatur bleibt offen, und die
+ // naechste Nummer laesst sich ohne einen weiteren Tipp eingeben.
+ listeEingabe.focus();
+ }
+ if (verworfen > 0) zeigeListenFehler();
+ else versteckeListenFehler();
+}
+
+/**
+ * Bestaetigt die Erfassung ueber das Formular.
+ *
+ * Ein Numpad auf dem Telefon bestaetigt mit seinem eigenen Schluessel
+ * (Haekchen, "Fertig", "Los"); er loest das submit-Ereignis aus. Hier laufen
+ * deshalb Bestaetigungstaste, eingefuegte Spalte und Klick auf "Hinzufuegen"
+ * zusammen.
+ *
+ * @param {SubmitEvent} ereignis
+ */
+function beiListenAbsenden(ereignis) {
+ ereignis.preventDefault();
+ // Ein leerer Aufruf ueber die Schaltflaeche springt nicht in die Anzeige:
+ // Wer "Hinzufuegen" tippt, will etwas hinzufuegen. Die Bestaetigungstaste im
+ // leeren Feld bleibt dagegen der schnelle Weg nach vorn.
+ if (ereignis.submitter === listeHinzufuegenKnopf && listeEingabe.value.trim() === '') {
+ listeEingabe.focus();
+ return;
+ }
+ uebernimmListeneingabe();
+}
+
+/** Entfernt einen Eintrag; mit der Tastatur bleibt der Fokus in der Liste.
+ *
+ * @param {number} index
+ * @param {boolean} ueberTastatur
+ */
+function entferneListenEintrag(index, ueberTastatur) {
+ if (!Number.isInteger(index) || index < 0 || index >= liste.length) return;
+ const [entfernt] = liste.splice(index, 1);
+ rendereListe();
+ zeigeToast(`Artikelnummer ${entfernt.eingabe} entfernt`);
+ if (ueberTastatur) {
+ const naechster = listeEintraege.querySelector(
+ `.liste-weg[data-index="${String(Math.min(index, liste.length - 1))}"]`,
+ );
+ if (naechster instanceof HTMLElement) {
+ naechster.focus();
+ return;
+ }
+ }
+ listeEingabe.focus();
+}
+
+/** Zeigt die Richtung des Schritts: der neue Barcode kommt von der Seite.
+ *
+ * @param {number} richtung
+ */
+function animiereSchritt(richtung) {
+ listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
+ // Reflow erzwingt den Neustart der Animation beim schnellen Blaettern.
+ void listeMitte.offsetWidth;
+ listeMitte.classList.add(richtung < 0 ? 'liste-schritt-zurueck' : 'liste-schritt-vor');
+}
+
+/**
+ * @param {boolean} animiert
+ * @param {number} [richtung]
+ */
+function zeigeListenEintrag(animiert, richtung = 0) {
+ const eintrag = liste[listeIndex];
+ if (!eintrag) return;
+ listeZaehler.textContent = zaehlerText(listeIndex, liste.length);
+ listeGtin.textContent = eintrag.gtin;
+ listeArtikel.textContent = `Artikelnummer ${eintrag.eingabe}`;
+ rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
+ if (animiert) animiereSchritt(richtung);
+}
+
+/** @param {number} index */
+function starteListenAnzeige(index) {
+ if (listeAnzeigeAktiv || liste.length === 0) return;
+ listeAnzeigeAktiv = true;
+ listeIndex = schritt(index, 0, liste.length);
+ listeErfassen.hidden = true;
+ listeAnzeige.hidden = false;
+ zeigeListenEintrag(false);
+ listeMitte.focus();
+ starteWakeLock();
+}
+
+/** @param {number} richtung */
+function blaettere(richtung) {
+ if (!listeAnzeigeAktiv || liste.length === 0) return;
+ listeIndex = schritt(listeIndex, richtung, liste.length);
+ zeigeListenEintrag(true, richtung);
+}
+
+/** @param {number} index */
+function springeZu(index) {
+ if (!listeAnzeigeAktiv || liste.length === 0) return;
+ const neu = schritt(index, 0, liste.length);
+ if (neu === listeIndex) return;
+ const richtung = neu > listeIndex ? 1 : -1;
+ listeIndex = neu;
+ zeigeListenEintrag(true, richtung);
+}
+
+/** Zurueck zum Erfassen: Die Liste bleibt erhalten und wird nur bearbeitet. */
+function zeigeListeErfassen() {
+ listeAnzeigeAktiv = false;
+ listeAnzeige.hidden = true;
+ listeErfassen.hidden = false;
+ stoppeWakeLock();
+ versteckeListenFehler();
+ listeEingabe.value = '';
+ rendereListe();
+ listeEingabe.focus();
+}
+
+function oeffneListe() {
+ if (listeAktiv) return;
+ listeAktiv = true;
+ listeBereich.hidden = false;
+ document.body.classList.add('liste-offen');
+ // Der Rest der Oberflaeche wird unerreichbar, solange die Liste offen ist.
+ appBereich.toggleAttribute('inert', true);
+ zeigeListeErfassen();
+}
+
+function schliesseListe() {
+ if (!listeAktiv) return;
+ listeAktiv = false;
+ listeAnzeigeAktiv = false;
+ listeBereich.hidden = true;
+ listeErfassen.hidden = false;
+ listeAnzeige.hidden = true;
+ document.body.classList.remove('liste-offen');
+ appBereich.toggleAttribute('inert', false);
+ stoppeWakeLock();
+ listeOeffnenKnopf.focus();
+}
+
+/** @type {{ id: number, x: number, y: number } | null} */
+let geste = null;
+
+/** @param {PointerEvent} ereignis */
+function beendeGeste(ereignis) {
+ window.removeEventListener('pointerup', beendeGeste);
+ window.removeEventListener('pointercancel', beendeGeste);
+ const start = geste;
+ geste = null;
+ if (!start || start.id !== ereignis.pointerId || ereignis.type !== 'pointerup') return;
+ const dx = ereignis.clientX - start.x;
+ const dy = ereignis.clientY - start.y;
+ // Erst ab 40 px und nur bei klarer Richtung: sonst waere jeder Tipp ein Blatt.
+ if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return;
+ blaettere(dx < 0 ? 1 : -1);
+}
+
+/** @param {PointerEvent} ereignis */
+function starteGeste(ereignis) {
+ if (!listeAnzeigeAktiv || !ereignis.isPrimary) return;
+ // Ein Zug, der auf einem Pfeil beginnt, ist ein Klick auf den Pfeil.
+ if (ereignis.target instanceof Element && ereignis.target.closest('button')) return;
+ geste = { id: ereignis.pointerId, x: ereignis.clientX, y: ereignis.clientY };
+ window.addEventListener('pointerup', beendeGeste);
+ window.addEventListener('pointercancel', beendeGeste);
+}
+
+/* ------------------------------------------------------------ Zwischenablage -- */
+/** @param {string} text @returns {boolean} */
+function kopiereFallback(text) {
+ const hilfe = document.createElement('textarea');
+ hilfe.value = text;
+ hilfe.setAttribute('readonly', '');
+ hilfe.className = 'kopier-hilfe';
+ document.body.append(hilfe);
+ hilfe.select();
+ let erfolgreich = false;
+ try {
+ erfolgreich = document.execCommand('copy');
+ } catch {
+ erfolgreich = false;
+ }
+ hilfe.remove();
+ return erfolgreich;
+}
+
+async function kopiereGtin() {
+ if (!aktuell) return;
+ const text = aktuell.gtin;
+ let erfolgreich = false;
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ erfolgreich = true;
+ }
+ } catch {
+ erfolgreich = false;
+ }
+ if (!erfolgreich) erfolgreich = kopiereFallback(text);
+ zeigeToast(erfolgreich ? 'GTIN kopiert' : 'Kopieren nicht möglich');
+ merkeImVerlauf(aktuell);
+ stoppeVerlaufTimer();
+}
+
+/** @param {string} text */
+function zeigeToast(text) {
+ toast.textContent = text;
+ toast.hidden = false;
+ if (toastTimer !== undefined) window.clearTimeout(toastTimer);
+ toastTimer = window.setTimeout(() => {
+ toastTimer = undefined;
+ toast.hidden = true;
+ }, TOAST_MS);
+}
+
+/* ------------------------------------------------------------------ PWA -- */
+
+function registriereServiceWorker() {
+ if (!('serviceWorker' in navigator)) return;
+ window.addEventListener('load', () => {
+ navigator.serviceWorker
+ .register('./sw.js', { scope: './' })
+ .catch((fehler) => window.console.warn('Service Worker nicht registriert.', fehler));
+ });
+}
+
+function aktualisiereOfflineHinweis() {
+ offlineHinweis.hidden = navigator.onLine !== false;
+}
+
+function zeigeInstallationsmoeglichkeit() {
+ const standalone =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ /** @type {any} */ (navigator).standalone === true;
+ const istIos =
+ /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ iosHinweis.hidden = !istIos || standalone;
+}
+
+/* ------------------------------------------------------------- Ereignisse -- */
+
+function verbindeEreignisse() {
+ input.addEventListener('input', beiEingabe);
+ // Fokussieren markiert den gesamten Inhalt (direktes Ueberschreiben), auch
+ // wenn der Browser das Caret nach dem Tippen selbst setzt.
+ input.addEventListener('pointerdown', () => {
+ feldWarFokussiert = document.activeElement === input;
+ });
+ input.addEventListener('focus', markiereEingabe);
+ input.addEventListener('click', () => {
+ if (!feldWarFokussiert) markiereEingabe();
+ });
+ input.addEventListener('blur', () => {
+ feldWarFokussiert = false;
+ });
+
+ gtinKnopf.addEventListener('click', () => void kopiereGtin());
+ kopierenKnopf.addEventListener('click', () => void kopiereGtin());
+ grossKnopf.addEventListener('click', oeffneScan);
+ neuKnopf.addEventListener('click', neueNummer);
+ scanZurueckKnopf.addEventListener('click', schliesseScan);
+ verlaufLoeschenKnopf.addEventListener('click', leereVerlauf);
+
+ listeOeffnenKnopf.addEventListener('click', oeffneListe);
+ listeSchliessenKnopf.addEventListener('click', schliesseListe);
+ listeBeendenKnopf.addEventListener('click', schliesseListe);
+ listeAendernKnopf.addEventListener('click', zeigeListeErfassen);
+ listeWeiterKnopf.addEventListener('click', () => {
+ // Eine noch nicht bestaetigte Nummer wird beim Wechsel uebernommen.
+ if (listeEingabe.value.trim() !== '') {
+ uebernimmListeneingabe();
+ // Eine abgelehnte Nummer haelt die Erfassung offen: Der Fehler muss
+ // gelesen werden koennen, bevor es weitergeht.
+ if (!listeFehler.hidden) return;
+ }
+ // Ein leerer Aufruf mit alter Meldung im Feld raeumt sie weg und startet.
+ versteckeListenFehler();
+ starteListenAnzeige(0);
+ });
+ listeZurueckKnopf.addEventListener('click', () => blaettere(-1));
+ listeVorKnopf.addEventListener('click', () => blaettere(1));
+ listeBuehne.addEventListener('pointerdown', starteGeste);
+ listeMitte.addEventListener('animationend', () => {
+ listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
+ });
+ listeEingabe.addEventListener('keydown', (ereignis) => {
+ if (!istBestaetigungstaste(ereignis)) return;
+ // Selbst bestaetigen und die Standardaktion unterdruecken: Sonst kaeme
+ // dieselbe Nummer ueber Tastendruck und submit zweimal an.
+ ereignis.preventDefault();
+ uebernimmListeneingabe();
+ });
+ listeForm.addEventListener('submit', beiListenAbsenden);
+ // Die Schaltflaeche darf den Fokus nicht aus dem Feld ziehen: So bleibt die
+ // Bildschirmtastatur zwischen zwei Nummern offen.
+ listeHinzufuegenKnopf.addEventListener('pointerdown', (ereignis) => {
+ ereignis.preventDefault();
+ });
+ listeEingabe.addEventListener('input', () => {
+ if (!listeFehler.hidden) versteckeListenFehler();
+ });
+ listeEintraege.addEventListener('click', (ereignis) => {
+ const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.liste-weg') : null;
+ if (!(ziel instanceof HTMLElement)) return;
+ entferneListenEintrag(Number(ziel.dataset.index), ereignis.detail === 0);
+ });
+
+ verlaufListe.addEventListener('click', (ereignis) => {
+ const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.verlauf-eintrag') : null;
+ if (!(ziel instanceof HTMLElement)) return;
+ const artikel = ziel.dataset.artikel ?? '';
+ if (artikel) {
+ // Mit der Tastatur aktiviert (Enter/Leertaste): Fokus ins Feld, damit
+ // direkt weitergetippt werden kann. Mit dem Finger bleibt der Fokus
+ // ausserhalb, sonst verdeckt die Bildschirmtastatur den Barcode.
+ uebernimmArtikel(artikel, { fokussieren: ereignis.detail === 0 });
+ }
+ });
+
+ installierenKnopf.addEventListener('click', () => {
+ if (!installationsAnfrage) return;
+ installationsAnfrage.prompt();
+ Promise.resolve(installationsAnfrage.userChoice).catch(() => undefined);
+ installationsAnfrage = null;
+ installierenKnopf.hidden = true;
+ });
+
+ window.addEventListener('beforeinstallprompt', (ereignis) => {
+ ereignis.preventDefault();
+ installationsAnfrage = ereignis;
+ installierenKnopf.hidden = false;
+ });
+ window.addEventListener('appinstalled', () => {
+ installationsAnfrage = null;
+ installierenKnopf.hidden = true;
+ iosHinweis.hidden = true;
+ zeigeToast('App installiert');
+ });
+
+ window.addEventListener('online', aktualisiereOfflineHinweis);
+ window.addEventListener('offline', aktualisiereOfflineHinweis);
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && (scanAktiv || listeAnzeigeAktiv)) {
+ starteWakeLock();
+ }
+ });
+
+ window.addEventListener('resize', () => {
+ if (groessenTimer !== undefined) window.clearTimeout(groessenTimer);
+ groessenTimer = window.setTimeout(() => {
+ groessenTimer = undefined;
+ if (aktuell) {
+ rendereBarcode(barcodeBehaelter, aktuell, 'normal');
+ if (scanAktiv) rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
+ }
+ if (listeAnzeigeAktiv) {
+ const eintrag = liste[listeIndex];
+ if (eintrag) rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
+ }
+ }, 150);
+ });
+
+ document.addEventListener('keydown', (ereignis) => {
+ if (listeAktiv) {
+ if (ereignis.key === 'Escape') {
+ ereignis.preventDefault();
+ schliesseListe();
+ return;
+ }
+ if (!listeAnzeigeAktiv) return;
+ switch (ereignis.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'PageDown':
+ ereignis.preventDefault();
+ blaettere(1);
+ return;
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'PageUp':
+ ereignis.preventDefault();
+ blaettere(-1);
+ return;
+ case 'Home':
+ ereignis.preventDefault();
+ springeZu(0);
+ return;
+ case 'End':
+ ereignis.preventDefault();
+ springeZu(liste.length - 1);
+ return;
+ default:
+ return;
+ }
+ }
+
+ if (scanAktiv) {
+ if (ereignis.key === 'Escape') {
+ ereignis.preventDefault();
+ schliesseScan();
+ }
+ return;
+ }
+
+ const ziel = ereignis.target;
+ const imEingabefeld = ziel === input;
+ // Auf Schaltflaechen und Links gilt die native Tastenaktivierung.
+ const aufBedienelement =
+ ziel instanceof HTMLElement &&
+ !imEingabefeld &&
+ (ziel.tagName === 'BUTTON' || ziel.tagName === 'A' || ziel.isContentEditable);
+
+ if (ereignis.key === 'Enter') {
+ if (imEingabefeld) {
+ ereignis.preventDefault();
+ stoppeEingabeTimer();
+ if (!aktuell || aktuell.eingabe !== nurZiffern(input.value)) pruefeUndZeige();
+ if (aktuell) oeffneScan();
+ } else if (!aufBedienelement && aktuell) {
+ ereignis.preventDefault();
+ oeffneScan();
+ }
+ return;
+ }
+
+ if (ereignis.key === 'Escape') {
+ if (!aufBedienelement && aktuell) {
+ ereignis.preventDefault();
+ neueNummer();
+ }
+ return;
+ }
+
+ if (ereignis.key === 'Backspace' && !aufBedienelement && !imEingabefeld && aktuell) {
+ ereignis.preventDefault();
+ neueNummer();
+ return;
+ }
+
+ const kopiertastenkombination =
+ (ereignis.ctrlKey || ereignis.metaKey) && ereignis.key.toLowerCase() === 'c';
+ if (kopiertastenkombination && aktuell) {
+ const auswahl = window.getSelection()?.toString() ?? '';
+ const eingabeAuswahl =
+ input.selectionStart !== null && input.selectionStart !== input.selectionEnd;
+ if (auswahl === '' && !eingabeAuswahl) {
+ ereignis.preventDefault();
+ void kopiereGtin();
+ }
+ }
+ });
+}
+
+function starte() {
+ verbindeEreignisse();
+ rendereVerlauf();
+ aktualisiereOfflineHinweis();
+ zeigeInstallationsmoeglichkeit();
+ registriereServiceWorker();
+}
+
+starte();
diff --git a/site/gtin/js/barcode.js b/site/gtin/js/barcode.js
new file mode 100644
index 0000000..f6bc57c
--- /dev/null
+++ b/site/gtin/js/barcode.js
@@ -0,0 +1,343 @@
+/**
+ * EAN-Codierung und SVG-Erzeugung.
+ *
+ * Die Datei ist bewusst frei von DOM-Zugriffen: sie liefert eine reine
+ * Bitfolge (1 = Balken, 0 = Luecke) und daraus ein SVG-Markup. Die
+ * Pruefzifferpruefung aus gtin.js wird vor jeder Codierung wiederholt, damit
+ * kein Barcode fuer eine inkonsistente Nummer entstehen kann.
+ */
+
+import { gtinGueltig } from './gtin.js';
+
+/** L-Code (ungerade Paritaet), linke Haelfte. */
+const L_CODE = [
+ '0001101',
+ '0011001',
+ '0010011',
+ '0111101',
+ '0100011',
+ '0110001',
+ '0101111',
+ '0111011',
+ '0110111',
+ '0001011',
+];
+
+/** G-Code (gerade Paritaet), linke Haelfte bei EAN-13. */
+const G_CODE = [
+ '0100111',
+ '0110011',
+ '0011011',
+ '0100001',
+ '0011101',
+ '0111001',
+ '0000101',
+ '0010001',
+ '0001001',
+ '0010111',
+];
+
+/** R-Code, rechte Haelfte (Komplement des L-Codes). */
+const R_CODE = [
+ '1110010',
+ '1100110',
+ '1101100',
+ '1000010',
+ '1011100',
+ '1001110',
+ '1010000',
+ '1000100',
+ '1001000',
+ '1110100',
+];
+
+/**
+ * Paritaetsmuster der linken Gruppe in Abhaengigkeit der ersten Ziffer
+ * (EAN-13). Die erste Ziffer wird selbst nicht als Balken codiert.
+ */
+const EAN13_PARITAET = [
+ 'LLLLLL',
+ 'LLGLGG',
+ 'LLGGLG',
+ 'LLGGGL',
+ 'LGLLGG',
+ 'LGGLLG',
+ 'LGGGLL',
+ 'LGLGLG',
+ 'LGLGGL',
+ 'LGGLGL',
+];
+
+const START_UND_ENDE = '101';
+const MITTE = '01010';
+
+/** Ruhezonen in Modulen (links/rechts). */
+export const QUIETZONE = {
+ 'EAN-8': { links: 7, rechts: 7 },
+ 'EAN-13': { links: 11, rechts: 7 },
+};
+
+/** Standardhoehe der Balken in Modulen. */
+export const BALKENHOEHE_MODULE = 62;
+
+/** Verlaengerung der Rand- und Mittelbalken nach unten, in Modulen. */
+export const GUARD_ZUSATZ_MODULE = 6;
+
+/** Reservierte Hoehe fuer die Klartextzeile, in Modulen. */
+export const TEXT_HOEHE_MODULE = 13;
+
+/** Schriftgroesse des Klartexts, in Modulen. */
+export const TEXT_GROESSE_MODULE = 9;
+
+/**
+ * @typedef {object} Textgruppe
+ * @property {string} text anzuzeigende Ziffern
+ * @property {number} start Startposition im Symbol (Modulindex)
+ * @property {number} breite Breite der Gruppe in Modulen
+ */
+
+/**
+ * @typedef {object} EanSymbol
+ * @property {string} gtin validierte GTIN
+ * @property {'EAN-8' | 'EAN-13'} typ Barcode-Typ
+ * @property {string} bits Bitfolge (1 = Balken)
+ * @property {number} module Anzahl der Symbolmodule ohne Ruhezonen
+ * @property {{ links: number, rechts: number }} quietzone Ruhezonen in Modulen
+ * @property {Textgruppe[]} textgruppen Klartext unter dem Symbol
+ * @property {string | null} fuehrendeZiffer erste Ziffer (nur EAN-13)
+ */
+
+/**
+ * Codiert eine vollstaendige GTIN als EAN-8 oder EAN-13.
+ *
+ * @param {unknown} gtin vollstaendige GTIN (8 oder 13 Ziffern)
+ * @returns {EanSymbol}
+ * @throws {Error} wenn die GTIN ungueltig ist oder die Codierung nicht aufgeht
+ */
+export function kodiereEan(gtin) {
+ const wert = String(gtin ?? '');
+ if (!gtinGueltig(wert)) {
+ throw new Error(`Ungültige GTIN: ${wert}`);
+ }
+ const typ = wert.length === 8 ? 'EAN-8' : 'EAN-13';
+
+ /** @type {string} */
+ let links;
+ /** @type {string} */
+ let rechts;
+ /** @type {Textgruppe[]} */
+ let textgruppen;
+ let fuehrendeZiffer = null;
+
+ if (typ === 'EAN-8') {
+ links = '';
+ for (let i = 0; i < 4; i += 1) links += L_CODE[Number(wert[i])];
+ rechts = '';
+ for (let i = 4; i < 8; i += 1) rechts += R_CODE[Number(wert[i])];
+ textgruppen = [
+ { text: wert.slice(0, 4), start: 3, breite: 28 },
+ { text: wert.slice(4, 8), start: 36, breite: 28 },
+ ];
+ } else {
+ const paritaet = EAN13_PARITAET[Number(wert[0])];
+ links = '';
+ for (let i = 1; i <= 6; i += 1) {
+ const ziffer = Number(wert[i]);
+ links += paritaet[i - 1] === 'L' ? L_CODE[ziffer] : G_CODE[ziffer];
+ }
+ rechts = '';
+ for (let i = 7; i < 13; i += 1) rechts += R_CODE[Number(wert[i])];
+ textgruppen = [
+ { text: wert.slice(1, 7), start: 3, breite: 42 },
+ { text: wert.slice(7, 13), start: 50, breite: 42 },
+ ];
+ fuehrendeZiffer = wert[0];
+ }
+
+ const bits = START_UND_ENDE + links + MITTE + rechts + START_UND_ENDE;
+ const erwartet = typ === 'EAN-8' ? 67 : 95;
+ if (bits.length !== erwartet || !/^[01]+$/.test(bits)) {
+ throw new Error(`EAN-Codierung fehlerhaft: ${bits.length} statt ${erwartet} Module.`);
+ }
+
+ return {
+ gtin: wert,
+ typ,
+ bits,
+ module: bits.length,
+ quietzone: QUIETZONE[typ],
+ textgruppen,
+ fuehrendeZiffer,
+ };
+}
+
+/**
+ * Groesste ganzzahlige Modulbreite, die in die verfuegbare Breite passt.
+ *
+ * @param {number} verfuegbareBreitePx
+ * @param {number} moduleGesamt Anzahl Module inklusive Ruhezonen
+ * @returns {number} ganzzahlige Modulbreite in Pixeln, mindestens 1
+ */
+export function modulbreite(verfuegbareBreitePx, moduleGesamt) {
+ if (!Number.isFinite(verfuegbareBreitePx) || moduleGesamt <= 0) return 1;
+ return Math.max(1, Math.floor(verfuegbareBreitePx / moduleGesamt));
+}
+
+/**
+ * Maximale Balkenhoehe (in Modulen) fuer eine verfuegbare Anzeigehoehe.
+ *
+ * @param {number} verfuegbareHoehePx
+ * @param {number} modulbreitePx
+ * @param {number} [minimum] kleinste sinnvolle Balkenhoehe in Modulen
+ * @returns {number}
+ */
+export function balkenhoeheFuerHoehe(verfuegbareHoehePx, modulbreitePx, minimum = 30) {
+ if (!Number.isFinite(verfuegbareHoehePx) || verfuegbareHoehePx <= 0 || modulbreitePx <= 0) {
+ return BALKENHOEHE_MODULE;
+ }
+ const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
+ const moeglich = Math.floor(verfuegbareHoehePx / modulbreitePx) - reserviert;
+ return Math.max(minimum, moeglich);
+}
+
+/** Kleinste sinnvolle Balkenhoehe in Modulen. */
+export const MIN_BALKENHOEHE_MODULE = 30;
+
+/**
+ * Waehlt Modulbreite und Balkenhoehe fuer eine verfuegbare Flaeche.
+ *
+ * Gesucht ist die groesste ganzzahlige Modulbreite, mit der der komplette
+ * Barcode (Balken, Guard-Zusatz und Klartextzeile) sowohl in die Breite als
+ * auch in die Hoehe passt. So fuellt der Barcode die Anzeige bestmoeglich aus,
+ * ohne abgeschnitten zu werden; die Modulbreite bleibt ganzzahlig und damit
+ * gestochen scharf.
+ *
+ * @param {number} verfuegbareBreitePx
+ * @param {number} verfuegbareHoehePx
+ * @param {number} moduleGesamt Anzahl der Module inklusive Ruhezonen
+ * @param {{ minimumBalkenhoehe?: number, maximumBalkenhoehe?: number }} [optionen]
+ * @returns {{ modulbreite: number, balkenhoeheModule: number }}
+ */
+export function barcodeGroesse(verfuegbareBreitePx, verfuegbareHoehePx, moduleGesamt, optionen = {}) {
+ const minimum = Math.max(
+ 1,
+ Math.round(optionen.minimumBalkenhoehe ?? MIN_BALKENHOEHE_MODULE),
+ );
+ const maximum = Math.max(
+ minimum,
+ Math.round(optionen.maximumBalkenhoehe ?? BALKENHOEHE_MODULE),
+ );
+ const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
+ const breite =
+ Number.isFinite(verfuegbareBreitePx) && verfuegbareBreitePx > 0
+ ? Math.floor(verfuegbareBreitePx)
+ : 320;
+ const hoehe =
+ Number.isFinite(verfuegbareHoehePx) && verfuegbareHoehePx > 0
+ ? Math.floor(verfuegbareHoehePx)
+ : 320;
+ const module = Number.isFinite(moduleGesamt) && moduleGesamt > 0 ? Math.floor(moduleGesamt) : 1;
+
+ const groessteBreite = Math.max(1, Math.floor(breite / module));
+ for (let kandidat = groessteBreite; kandidat >= 1; kandidat -= 1) {
+ const platz = Math.floor(hoehe / kandidat) - reserviert;
+ if (kandidat === 1 || platz >= minimum) {
+ return {
+ modulbreite: kandidat,
+ balkenhoeheModule: Math.min(maximum, Math.max(minimum, platz)),
+ };
+ }
+ }
+ return { modulbreite: 1, balkenhoeheModule: minimum };
+}
+
+/**
+ * Zerlegt die Bitfolge in zusammenhaengende Balken.
+ *
+ * @param {string} bits
+ * @returns {{ start: number, breite: number }[]}
+ */
+function balkenLaeufe(bits) {
+ /** @type {{ start: number, breite: number }[]} */
+ const laeufe = [];
+ let start = -1;
+ for (let i = 0; i < bits.length; i += 1) {
+ if (bits[i] === '1') {
+ if (start === -1) start = i;
+ } else if (start !== -1) {
+ laeufe.push({ start, breite: i - start });
+ start = -1;
+ }
+ }
+ if (start !== -1) laeufe.push({ start, breite: bits.length - start });
+ return laeufe;
+}
+
+/**
+ * Erzeugt das SVG-Markup fuer einen EAN-Barcode.
+ *
+ * Alle Laengen werden in ganzen Modulen gerechnet; die Pixelbreite ergibt sich
+ * aus einem ganzzahligen Vielfachen der Modulbreite. Dadurch entstehen harte
+ * Kanten ohne Teilpixel.
+ *
+ * @param {unknown} gtin vollstaendige GTIN
+ * @param {{ modulbreite?: number, balkenhoeheModule?: number }} [optionen]
+ * @returns {{ markup: string, breite: number, hoehe: number, modulbreite: number, symbol: EanSymbol }}
+ */
+export function erzeugeSvgMarkup(gtin, optionen = {}) {
+ const symbol = kodiereEan(gtin);
+ const modulbreitePx = Math.max(1, Math.round(optionen.modulbreite ?? 3));
+ const balkenhoehe = Math.max(
+ 20,
+ Math.round(optionen.balkenhoeheModule ?? BALKENHOEHE_MODULE),
+ );
+
+ const { links, rechts } = symbol.quietzone;
+ const gesamtModule = links + symbol.module + rechts;
+ const hoeheModule = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
+ const breitePx = gesamtModule * modulbreitePx;
+ const hoehePx = hoeheModule * modulbreitePx;
+
+ const linkeGruppeEnde = 3 + (symbol.typ === 'EAN-8' ? 28 : 42);
+ const guardBereiche = [
+ { start: 0, ende: 3 },
+ { start: linkeGruppeEnde, ende: linkeGruppeEnde + 5 },
+ { start: symbol.module - 3, ende: symbol.module },
+ ];
+ const istGuard = (/** @type {number} */ start, /** @type {number} */ breite) =>
+ guardBereiche.some((bereich) => start >= bereich.start && start + breite <= bereich.ende);
+
+ const rechtecke = balkenLaeufe(symbol.bits)
+ .map(({ start, breite }) => {
+ const hoehe = balkenhoehe + (istGuard(start, breite) ? GUARD_ZUSATZ_MODULE : 0);
+ return ``;
+ })
+ .join('');
+
+ const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
+ const text = symbol.textgruppen
+ .map(
+ (gruppe) =>
+ `${gruppe.text}`,
+ )
+ .join('');
+ const fuehrend = symbol.fuehrendeZiffer
+ ? `${symbol.fuehrendeZiffer}`
+ : '';
+
+ const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
+ const markup =
+ ``;
+
+ return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
+}
diff --git a/site/gtin/js/gtin.js b/site/gtin/js/gtin.js
new file mode 100644
index 0000000..826c1f2
--- /dev/null
+++ b/site/gtin/js/gtin.js
@@ -0,0 +1,162 @@
+/**
+ * Fachliche Logik des GTIN-Generators.
+ *
+ * Diese Datei enthaelt ausschliesslich die Nummernkreis-Regeln, die
+ * Pruefziffernberechnung und die Validierung. Sie kennt weder DOM noch
+ * Oberflaeche und ist deshalb direkt testbar.
+ *
+ * Regeln (belegte Formate):
+ * 3 Ziffern -> links mit 0 auf 4 Stellen auffuellen -> Praefix 290 -> EAN-8
+ * 4 Ziffern -> Praefix 290 -> EAN-8
+ * 7 Ziffern -> Praefix 20090 -> EAN-13
+ * alles andere ist ungueltig.
+ */
+
+/** @typedef {'EAN-8' | 'EAN-13'} GtinTyp */
+
+/**
+ * @typedef {object} GtinErgebnis
+ * @property {string} eingabe normalisierte Artikelnummer (3, 4 oder 7 Stellen)
+ * @property {string} artikelblock Artikelblock mit fuehrender Null (4 Stellen)
+ * @property {string} praefix Nummernkreis-Praefix
+ * @property {string} basis GTIN ohne Pruefziffer
+ * @property {string} pruefziffer berechnete Pruefziffer
+ * @property {string} gtin vollstaendige GTIN inklusive Pruefziffer
+ * @property {GtinTyp} typ Barcode-Typ
+ * @property {number} laenge GTIN-Laenge (8 oder 13)
+ */
+
+/** Fehlertext fuer die Oberflaeche (bewusst genau ein Text). */
+export const FEHLERTEXT =
+ 'Keine gültige Artikelnummer. Erwartet werden 3, 4 oder 7 Stellen.';
+
+/** Praefix des kurzen Nummernkreises (EAN-8). */
+export const PRAEFIX_KURZ = '290';
+
+/** Praefix des langen Nummernkreises (EAN-13). */
+export const PRAEFIX_LANG = '20090';
+
+/**
+ * Entfernt alles, was keine Ziffer ist. Leere Eingaben werden zu "".
+ *
+ * @param {unknown} eingabe
+ * @returns {string}
+ */
+export function nurZiffern(eingabe) {
+ return String(eingabe ?? '').replace(/[^0-9]/g, '');
+}
+
+/**
+ * Berechnet die GTIN-Pruefziffer fuer eine Ziffernfolge ohne Pruefziffer.
+ *
+ * Die Ziffern werden von rechts nach links abwechselnd mit 3 und 1 gewichtet
+ * (rechteste Ziffer mal 3). Danach gilt (10 - (Summe mod 10)) mod 10.
+ *
+ * @param {string} ziffern Ziffernfolge ohne Pruefziffer, mindestens eine Ziffer
+ * @returns {number} Pruefziffer 0-9
+ */
+export function berechnePruefziffer(ziffern) {
+ if (typeof ziffern !== 'string' || !/^[0-9]+$/.test(ziffern)) {
+ throw new Error('Prüfziffer kann nur für eine Ziffernfolge berechnet werden.');
+ }
+ let summe = 0;
+ for (let i = 0; i < ziffern.length; i += 1) {
+ const ziffer = Number(ziffern[ziffern.length - 1 - i]);
+ summe += ziffer * (i % 2 === 0 ? 3 : 1);
+ }
+ return (10 - (summe % 10)) % 10;
+}
+
+/**
+ * Prueft eine vollstaendige GTIN als komplette EAN-Nummer.
+ *
+ * @param {unknown} gtin
+ * @returns {boolean} true, wenn Laenge, Ziffern und Pruefziffer stimmen
+ */
+export function gtinGueltig(gtin) {
+ const wert = String(gtin ?? '');
+ if (!/^(?:[0-9]{8}|[0-9]{13})$/.test(wert)) return false;
+ const basis = wert.slice(0, -1);
+ return berechnePruefziffer(basis) === Number(wert.slice(-1));
+}
+
+/**
+ * Bestimmt den Nummernkreis einer normalisierten Artikelnummer.
+ *
+ * @param {string} ziffern ausschliesslich Ziffern
+ * @returns {{ praefix: string, artikelblock: string, typ: GtinTyp, laenge: number } | null}
+ */
+export function bestimmeNummernkreis(ziffern) {
+ if (!/^[0-9]+$/.test(ziffern)) return null;
+ if (ziffern.length === 3) {
+ return {
+ praefix: PRAEFIX_KURZ,
+ artikelblock: ziffern.padStart(4, '0'),
+ typ: 'EAN-8',
+ laenge: 8,
+ };
+ }
+ if (ziffern.length === 4) {
+ return {
+ praefix: PRAEFIX_KURZ,
+ artikelblock: ziffern,
+ typ: 'EAN-8',
+ laenge: 8,
+ };
+ }
+ if (ziffern.length === 7) {
+ return {
+ praefix: PRAEFIX_LANG,
+ artikelblock: ziffern,
+ typ: 'EAN-13',
+ laenge: 13,
+ };
+ }
+ return null;
+}
+
+/**
+ * Zustand einer Eingabe fuer die Oberflaeche.
+ *
+ * @param {unknown} eingabe
+ * @returns {'leer' | 'unvollstaendig' | 'ungueltig' | 'ok'}
+ */
+export function eingabeStatus(eingabe) {
+ const ziffern = nurZiffern(eingabe);
+ if (ziffern.length === 0) return 'leer';
+ if (ziffern.length < 3) return 'unvollstaendig';
+ return bestimmeNummernkreis(ziffern) ? 'ok' : 'ungueltig';
+}
+
+/**
+ * Erzeugt aus einer Artikelnummer die vollstaendige GTIN.
+ *
+ * Die erzeugte GTIN wird anschliessend erneut als komplette EAN validiert
+ * (Sicherheitspruefung). Stimmt etwas nicht, wird null zurueckgegeben und es
+ * darf kein Barcode gerendert werden.
+ *
+ * @param {unknown} eingabe Artikelnummer (3, 4 oder 7 Ziffern, Leerzeichen erlaubt)
+ * @returns {GtinErgebnis | null}
+ */
+export function erzeugeGtin(eingabe) {
+ const ziffern = nurZiffern(eingabe);
+ const kreis = bestimmeNummernkreis(ziffern);
+ if (!kreis) return null;
+
+ const basis = kreis.praefix + kreis.artikelblock;
+ const pruefziffer = berechnePruefziffer(basis);
+ const gtin = `${basis}${pruefziffer}`;
+
+ if (gtin.length !== kreis.laenge || !gtinGueltig(gtin)) return null;
+
+ return {
+ eingabe: ziffern,
+ artikelblock: kreis.artikelblock,
+ praefix: kreis.praefix,
+ basis,
+ pruefziffer: String(pruefziffer),
+ gtin,
+ typ: kreis.typ,
+ laenge: kreis.laenge,
+ };
+}
diff --git a/site/gtin/js/liste.js b/site/gtin/js/liste.js
new file mode 100644
index 0000000..4db622d
--- /dev/null
+++ b/site/gtin/js/liste.js
@@ -0,0 +1,75 @@
+/**
+ * Reine Listenlogik des optionalen Listenmodus.
+ *
+ * Die Datei kennt weder DOM noch Oberflaeche: Sie zerlegt eine Eingabe in
+ * Artikelnummern und rechnet die Schritte der Anzeige aus. Damit ist die Logik
+ * unabhaengig von der Oberflaeche testbar, genau wie gtin.js und barcode.js.
+ */
+
+/**
+ * Zerlegt eine Eingabe in einzelne Zifferngruppen.
+ *
+ * Alles, was keine Ziffer ist, trennt die Gruppen: Leerzeichen, Komma,
+ * Semikolon, Zeilenumbruch und auch Buchstaben. So laesst sich eine ganze
+ * Spalte aus einem Lieferschein in einem Zug einfuegen, ohne dass daraus eine
+ * einzige, viel zu lange Nummer wird.
+ *
+ * @param {unknown} eingabe
+ * @returns {string[]} Zifferngruppen in Eingabereihenfolge, ohne leere Gruppen
+ */
+export function zerlegeEingabe(eingabe) {
+ return String(eingabe ?? '')
+ .split(/[^0-9]+/)
+ .filter((gruppe) => gruppe.length > 0);
+}
+
+/**
+ * Naechster Index in der Liste.
+ *
+ * Der Schritt laeuft um: Nach dem letzten Eintrag folgt wieder der erste und
+ * umgekehrt. Damit laesst sich eine Serie ohne Unterbrechung nacheinander
+ * abscannen.
+ *
+ * @param {number} index aktueller Index
+ * @param {number} richtung Schrittweite, z. B. 1 oder -1
+ * @param {number} anzahl Anzahl der Eintraege
+ * @returns {number} Index innerhalb von 0 bis anzahl-1
+ */
+export function schritt(index, richtung, anzahl) {
+ if (!Number.isInteger(anzahl) || anzahl <= 0) return 0;
+ const start = Number.isInteger(index) ? index : 0;
+ const schrittweite = Number.isInteger(richtung) ? richtung : 0;
+ return (((start + schrittweite) % anzahl) + anzahl) % anzahl;
+}
+
+/**
+ * Beschriftung des Zaehlers, z. B. "3 / 7".
+ *
+ * @param {number} index aktueller Index
+ * @param {number} anzahl Anzahl der Eintraege
+ * @returns {string} leer, wenn die Liste leer ist
+ */
+export function zaehlerText(index, anzahl) {
+ if (!Number.isInteger(anzahl) || anzahl <= 0) return '';
+ const position = (Number.isInteger(index) ? index : 0) + 1;
+ return `${Math.min(Math.max(position, 1), anzahl)} / ${anzahl}`;
+}
+
+/**
+ * Erkennt die Bestaetigungstaste einer Tastatur.
+ *
+ * Ein Numpad auf dem Telefon hat keine Eingabetaste: Es bestaetigt mit einem
+ * eigenen Schluessel (Haekchen, "Fertig", "Los"). Je nach Tastatur meldet der
+ * Browser ihn als `key: "Enter"` oder nur ueber den alten `keyCode` 13, in
+ * manchen Faellen mit `key: "Unidentified"`. Beide Kennungen muessen gelten,
+ * sonst bleibt die eingegebene Nummer auf dem Telefon liegen.
+ *
+ * @param {{ key?: unknown, keyCode?: unknown } | null | undefined} ereignis Tastenereignis
+ * @returns {boolean} true, wenn die Taste die Eingabe bestaetigt
+ */
+export function istBestaetigungstaste(ereignis) {
+ if (!ereignis) return false;
+ const taste = typeof ereignis.key === 'string' ? ereignis.key : '';
+ const kennung = typeof ereignis.keyCode === 'number' ? ereignis.keyCode : 0;
+ return taste === 'Enter' || kennung === 13;
+}
diff --git a/site/gtin/manifest.webmanifest b/site/gtin/manifest.webmanifest
new file mode 100644
index 0000000..b875e8e
--- /dev/null
+++ b/site/gtin/manifest.webmanifest
@@ -0,0 +1,34 @@
+{
+ "name": "GTIN Generator",
+ "short_name": "GTIN",
+ "description": "Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen – offline, ohne Anmeldung.",
+ "lang": "de",
+ "dir": "ltr",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff",
+ "categories": ["business", "productivity", "utilities"],
+ "icons": [
+ {
+ "src": "./icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/site/gtin/sw.js b/site/gtin/sw.js
new file mode 100644
index 0000000..d732e3b
--- /dev/null
+++ b/site/gtin/sw.js
@@ -0,0 +1,173 @@
+/* Service Worker des GTIN-Generators.
+ *
+ * Nach dem ersten Laden liegen HTML, CSS, JavaScript, Manifest und Icons im
+ * Cache. Danach funktioniert die Anwendung ohne WLAN, ohne Mobilfunk und ohne
+ * erreichbaren Server.
+ *
+ * CACHE_VERSION wird beim Build aus dem Inhalt der Anwendungsdateien ersetzt.
+ * Dadurch erkennt der Browser ein neues Release und raeumt den alten Cache auf.
+ */
+
+const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (self));
+
+const CACHE_VERSION = '15d23ab0263e';
+const CACHE_PREFIX = 'desfoto-gtin-generator-';
+const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
+
+// PRECACHE
+const PRECACHE = [
+ './',
+ './index.html',
+ './css/app.css',
+ './js/app.js',
+ './js/gtin.js',
+ './js/barcode.js',
+ './js/liste.js',
+ './manifest.webmanifest',
+ './icons/favicon.svg',
+ './icons/icon-192.png',
+ './icons/icon-512.png',
+ './icons/icon-maskable-512.png',
+];
+// END PRECACHE
+
+/** @param {string} pfad */
+function scopeUrl(pfad) {
+ return new URL(pfad, sw.registration.scope).toString();
+}
+
+const INDEX_URL = scopeUrl('./index.html');
+
+/** Pfad des Geltungsbereichs, z. B. "/gtin/". */
+const SCOPE_PFAD = new URL(sw.registration.scope).pathname;
+
+/**
+ * Kennung der ausgelieferten Anwendungsquelle. Nur eine Antwort, die diese
+ * Kennung enthaelt, ist die echte index.html dieses Projekts.
+ */
+const SHELL_KENNUNG = 'id="artikelnummer"';
+
+/**
+ * Entscheidet, ob eine Antwort die vorgehaltene Anwendungshuelle ersetzen darf.
+ *
+ * Nur eine erfolgreiche HTML-Antwort der eigenen Anwendung ist zulaessig. Eine
+ * Anmeldeseite der Steuerungsebene, eine Magic-Link-Seite oder eine Fehlerseite
+ * (z. B. "App deployment in progress") wuerde die Anwendung sonst offline
+ * unbrauchbar machen, weil sie die echte Seite aus dem Cache verdraengt.
+ *
+ * @param {Response} antwort
+ * @returns {Promise}
+ */
+async function istAnwendungshuelle(antwort) {
+ if (!antwort.ok || antwort.type !== 'basic') return false;
+ const inhaltstyp = antwort.headers.get('content-type') ?? '';
+ if (!inhaltstyp.includes('text/html')) return false;
+ const endgueltig = new URL(antwort.url);
+ if (endgueltig.origin !== sw.location.origin) return false;
+ if (!endgueltig.pathname.startsWith(SCOPE_PFAD)) return false;
+ try {
+ return (await antwort.text()).includes(SHELL_KENNUNG);
+ } catch {
+ return false;
+ }
+}
+
+sw.addEventListener('install', (ereignis) => {
+ ereignis.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then((cache) =>
+ cache.addAll(
+ PRECACHE.map((pfad) => new Request(scopeUrl(pfad), { cache: 'reload' })),
+ ),
+ )
+ .then(() => sw.skipWaiting()),
+ );
+});
+
+sw.addEventListener('activate', (ereignis) => {
+ ereignis.waitUntil(
+ caches
+ .keys()
+ .then((namen) =>
+ Promise.all(
+ namen
+ .filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
+ .map((name) => caches.delete(name)),
+ ),
+ )
+ .then(() => sw.clients.claim()),
+ );
+});
+
+/**
+ * Bedient einen Navigationsaufruf.
+ *
+ * Bewusst aus dem Cache: Nur so stammen HTML und Assets immer aus derselben
+ * Cache-Generation. Der neue Service Worker uebernimmt dank
+ * skipWaiting()/clients.claim() sofort; ein neues Release ist damit beim
+ * naechsten Laden konsistent aktiv (kein "neues HTML, altes JavaScript").
+ *
+ * @param {FetchEvent} ereignis
+ * @param {Request} anfrage
+ * @returns {Promise}
+ */
+function bedieneNavigation(ereignis, anfrage) {
+ return caches.match(INDEX_URL).then((treffer) => {
+ if (treffer) return treffer;
+ return fetch(anfrage)
+ .then((antwort) => {
+ const pruefung = antwort.clone();
+ const kopie = antwort.clone();
+ // waitUntil haelt die Cache-Schreiboperation am Leben. Ein blosses
+ // "fire and forget" innerhalb von respondWith kann abgebrochen werden.
+ ereignis.waitUntil(
+ istAnwendungshuelle(pruefung)
+ .then((istHuelle) =>
+ istHuelle
+ ? caches.open(CACHE_NAME).then((cache) => cache.put(INDEX_URL, kopie))
+ : undefined,
+ )
+ .catch(() => undefined),
+ );
+ return antwort;
+ })
+ .catch(() => caches.match(INDEX_URL).then((treffer) => treffer ?? Response.error()));
+ });
+}
+
+sw.addEventListener('fetch', (ereignis) => {
+ const anfrage = ereignis.request;
+ if (anfrage.method !== 'GET') return;
+
+ const ziel = new URL(anfrage.url);
+ if (ziel.origin !== sw.location.origin) return;
+ if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;
+ if (ziel.pathname.endsWith('/health')) return;
+
+ if (anfrage.mode === 'navigate') {
+ const appStart = new URL('./', sw.registration.scope).pathname;
+ const appIndex = new URL('./index.html', sw.registration.scope).pathname;
+ if (ziel.pathname !== appStart && ziel.pathname !== appIndex) return;
+ ereignis.respondWith(bedieneNavigation(ereignis, anfrage));
+ return;
+ }
+
+ ereignis.respondWith(
+ caches.match(anfrage).then((treffer) => {
+ if (treffer) return treffer;
+ return fetch(anfrage).then((antwort) => {
+ if (antwort.ok && antwort.type === 'basic') {
+ const kopie = antwort.clone();
+ ereignis.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then((cache) => cache.put(anfrage, kopie))
+ .catch(() => undefined),
+ );
+ }
+ return antwort;
+ });
+ }),
+ );
+});
diff --git a/site/impressum/index.html b/site/impressum/index.html
index c46ce35..47887b2 100644
--- a/site/impressum/index.html
+++ b/site/impressum/index.html
@@ -85,7 +85,8 @@
Marke und Angebot
desfoto ist der Markenauftritt von Denny Schulz Fotografie. Alle über diese
- Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht.
+ Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht. Das kostenlose
+ GTIN-Tool ist ein browserbasiertes Zusatzwerkzeug desselben Anbieters.
+ Barcode wird nicht erkannt? Displayhelligkeit erhöhen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Liste 0
+
+
+
+
+
Noch keine Nummer in der Liste.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gtin/js/app.js b/src/gtin/js/app.js
new file mode 100644
index 0000000..440a7c4
--- /dev/null
+++ b/src/gtin/js/app.js
@@ -0,0 +1,998 @@
+/**
+ * Oberflaeche, Scanmodus, Zwischenablage, Verlauf, Wake Lock und PWA.
+ *
+ * Die fachliche Logik liegt in gtin.js, die Codierung in barcode.js. Diese
+ * Datei verdrahtet beides mit dem DOM.
+ */
+
+import { eingabeStatus, erzeugeGtin, FEHLERTEXT, nurZiffern } from './gtin.js';
+import { barcodeGroesse, erzeugeSvgMarkup, kodiereEan } from './barcode.js';
+import { schritt, istBestaetigungstaste, zaehlerText, zerlegeEingabe } from './liste.js';
+
+/** @typedef {import('./gtin.js').GtinErgebnis} GtinErgebnis */
+
+/** @typedef {{ artikel: string, gtin: string, typ: string }} VerlaufEintrag */
+/** @typedef {{ released: boolean, release: () => Promise, addEventListener: (typ: 'release', hoerer: () => void) => void }} WakeLockLike */
+
+const VERLAUF_SCHLUESSEL = 'desfoto.gtin.verlauf.v1';
+const VERLAUF_MAX = 20;
+const EINGABE_VERZOEGERUNG_MS = 450;
+const VERLAUF_VERZOEGERUNG_MS = 2500;
+const SCAN_HINWEIS_MS = 7000;
+const TOAST_MS = 1800;
+/** Anteil der Bildschirmhoehe, den der Barcode in der Normalansicht nutzen darf. */
+const BALKENHOEHE_ANTEIL = 0.45;
+/** Untergrenze, damit der Barcode auch auf kurzen Anzeigen scanbar bleibt. */
+const BALKENHOEHE_MIN_PX = 180;
+
+/**
+ * @param {string} auswahl
+ * @returns {HTMLElement}
+ */
+function element(auswahl) {
+ const gefunden = document.querySelector(auswahl);
+ if (!gefunden) throw new Error(`Pflichtelement fehlt: ${auswahl}`);
+ return /** @type {HTMLElement} */ (gefunden);
+}
+
+const input = /** @type {HTMLInputElement} */ (element('#artikelnummer'));
+const fehlerText = element('#fehler');
+const ergebnisBereich = element('#ergebnis');
+const gtinTyp = element('#gtin-typ');
+const gtinKnopf = element('#gtin');
+const barcodeBehaelter = element('#barcode');
+const grossKnopf = element('#gross');
+const kopierenKnopf = element('#kopieren');
+const neuKnopf = element('#neu');
+const verlaufBereich = element('#verlauf');
+const verlaufListe = element('#verlauf-liste');
+const verlaufLoeschenKnopf = element('#verlauf-loeschen');
+const installierenKnopf = element('#installieren');
+const iosHinweis = element('#ios-hinweis');
+const offlineHinweis = element('#offline-hinweis');
+const scanBereich = element('#scan');
+const scanInnen = element('#scan-innen');
+const scanBarcodeBehaelter = element('#scan-barcode');
+const scanGtin = element('#scan-gtin');
+const scanHinweis = element('#scan-hinweis');
+const scanZurueckKnopf = element('#scan-zurueck');
+const toast = element('#toast');
+const appBereich = element('.app');
+const listeOeffnenKnopf = element('#liste-oeffnen');
+const listeBereich = element('#liste');
+const listeForm = /** @type {HTMLFormElement} */ (element('#liste-form'));
+const listeErfassen = element('#liste-erfassen');
+const listeAnzeige = element('#liste-anzeige');
+const listeEingabe = /** @type {HTMLInputElement} */ (element('#liste-eingabe'));
+const listeHinzufuegenKnopf = /** @type {HTMLButtonElement} */ (element('#liste-hinzufuegen'));
+const listeFehler = element('#liste-fehler');
+const listeEintraege = element('#liste-eintraege');
+const listeLeer = element('#liste-leer');
+const listeAnzahl = element('#liste-anzahl');
+const listeWeiterKnopf = /** @type {HTMLButtonElement} */ (element('#liste-weiter'));
+const listeSchliessenKnopf = element('#liste-schliessen');
+const listeZaehler = element('#liste-zaehler');
+const listeBuehne = element('#liste-buehne');
+const listeMitte = element('#liste-mitte');
+const listeBarcodeBehaelter = element('#liste-barcode');
+const listeGtin = element('#liste-gtin');
+const listeArtikel = element('#liste-artikel');
+const listeZurueckKnopf = element('#liste-zurueck');
+const listeVorKnopf = element('#liste-vor');
+const listeAendernKnopf = element('#liste-aendern');
+const listeBeendenKnopf = element('#liste-beenden');
+
+/** @type {GtinErgebnis | null} */
+let aktuell = null;
+/** @type {GtinErgebnis[]} */
+const liste = [];
+let listeAktiv = false;
+let listeAnzeigeAktiv = false;
+let listeIndex = 0;
+/** @type {number | undefined} */
+let eingabeTimer;
+/** @type {number | undefined} */
+let verlaufTimer;
+/** @type {number | undefined} */
+let toastTimer;
+/** @type {number | undefined} */
+let scanHinweisTimer;
+/** @type {number | undefined} */
+let groessenTimer;
+/** @type {WakeLockLike | null} */
+let wakeLock = null;
+let scanAktiv = false;
+/** @type {any} */
+let installationsAnfrage = null;
+
+/* ---------------------------------------------------------------- Verlauf -- */
+
+/**
+ * @param {unknown} eintrag
+ * @returns {eintrag is VerlaufEintrag}
+ */
+function istVerlaufEintrag(eintrag) {
+ if (typeof eintrag !== 'object' || eintrag === null) return false;
+ const kandidat = /** @type {Record} */ (eintrag);
+ return (
+ typeof kandidat.artikel === 'string' &&
+ typeof kandidat.gtin === 'string' &&
+ typeof kandidat.typ === 'string'
+ );
+}
+
+/** @returns {VerlaufEintrag[]} */
+function ladeVerlauf() {
+ try {
+ const roh = window.localStorage.getItem(VERLAUF_SCHLUESSEL);
+ if (!roh) return [];
+ const daten = /** @type {unknown} */ (JSON.parse(roh));
+ if (!Array.isArray(daten)) return [];
+ return daten.filter(istVerlaufEintrag).slice(0, VERLAUF_MAX);
+ } catch {
+ return [];
+ }
+}
+
+/** @param {VerlaufEintrag[]} eintraege */
+function speichereVerlaufListe(eintraege) {
+ try {
+ window.localStorage.setItem(VERLAUF_SCHLUESSEL, JSON.stringify(eintraege));
+ } catch {
+ /* Speicher gesperrt oder voll: der Verlauf ist optional. */
+ }
+}
+
+/** @param {GtinErgebnis} ergebnis */
+function merkeImVerlauf(ergebnis) {
+ const eintrag = { artikel: ergebnis.eingabe, gtin: ergebnis.gtin, typ: ergebnis.typ };
+ const bisher = ladeVerlauf().filter((vorhanden) => vorhanden.gtin !== eintrag.gtin);
+ speichereVerlaufListe([eintrag, ...bisher].slice(0, VERLAUF_MAX));
+ rendereVerlauf();
+}
+
+function rendereVerlauf() {
+ const eintraege = ladeVerlauf();
+ verlaufListe.replaceChildren();
+ for (const eintrag of eintraege) {
+ const punkt = document.createElement('li');
+ const knopf = document.createElement('button');
+ knopf.type = 'button';
+ knopf.className = 'verlauf-eintrag';
+ knopf.dataset.artikel = eintrag.artikel;
+ knopf.dataset.gtin = eintrag.gtin;
+
+ const artikel = document.createElement('span');
+ artikel.className = 'verlauf-artikel';
+ artikel.textContent = eintrag.artikel;
+
+ const pfeil = document.createElement('span');
+ pfeil.className = 'verlauf-pfeil';
+ pfeil.setAttribute('aria-hidden', 'true');
+ pfeil.textContent = '→';
+
+ const nummer = document.createElement('span');
+ nummer.className = 'verlauf-gtin';
+ nummer.textContent = eintrag.gtin;
+
+ knopf.append(artikel, pfeil, nummer);
+ knopf.setAttribute(
+ 'aria-label',
+ `Artikelnummer ${eintrag.artikel}, GTIN ${eintrag.gtin} erneut anzeigen`,
+ );
+ punkt.append(knopf);
+ verlaufListe.append(punkt);
+ }
+ verlaufBereich.hidden = eintraege.length === 0;
+}
+
+function leereVerlauf() {
+ speichereVerlaufListe([]);
+ rendereVerlauf();
+ zeigeToast('Verlauf gelöscht');
+}
+
+/* --------------------------------------------------------------- Anzeige -- */
+
+function versteckeFehler() {
+ fehlerText.hidden = true;
+ fehlerText.textContent = '';
+}
+
+function zeigeFehler() {
+ fehlerText.textContent = FEHLERTEXT;
+ fehlerText.hidden = false;
+}
+
+function versteckeErgebnis() {
+ aktuell = null;
+ ergebnisBereich.hidden = true;
+ barcodeBehaelter.replaceChildren();
+ stoppeVerlaufTimer();
+}
+
+/** Verfuegbare Hoehe fuer den Barcode in der Normalansicht. */
+function normalHoehe() {
+ return Math.max(
+ BALKENHOEHE_MIN_PX,
+ Math.round(window.innerHeight * BALKENHOEHE_ANTEIL),
+ );
+}
+
+/**
+ * @param {HTMLElement} behaelter
+ * @param {GtinErgebnis} ergebnis
+ * @param {'normal' | 'scan'} modus
+ */
+function rendereBarcode(behaelter, ergebnis, modus) {
+ const symbol = kodiereEan(ergebnis.gtin);
+ const gesamtModule =
+ symbol.quietzone.links + symbol.module + symbol.quietzone.rechts;
+ const gemessen = behaelter.getBoundingClientRect().width || behaelter.clientWidth;
+ const verfuegbareBreite = Math.max(1, Math.round(gemessen || 320));
+ // Im Scanmodus bestimmt die gemessene Flaeche die Groesse (Querformat: volle
+ // Breite und Hoehe). In der Normalansicht deckelt ein Anteil der
+ // Bildschirmhoehe den Barcode, damit die wichtigsten Bedienelemente ohne
+ // Scrollen sichtbar bleiben.
+ const verfuegbareHoehe =
+ modus === 'scan'
+ ? Math.max(1, Math.round(behaelter.clientHeight || normalHoehe()))
+ : normalHoehe();
+ const { modulbreite: modulbreitePx, balkenhoeheModule } = barcodeGroesse(
+ verfuegbareBreite,
+ verfuegbareHoehe,
+ gesamtModule,
+ );
+ const svg = erzeugeSvgMarkup(ergebnis.gtin, {
+ modulbreite: modulbreitePx,
+ balkenhoeheModule,
+ });
+ behaelter.innerHTML = svg.markup;
+}
+
+function stoppeVerlaufTimer() {
+ if (verlaufTimer !== undefined) {
+ window.clearTimeout(verlaufTimer);
+ verlaufTimer = undefined;
+ }
+}
+
+/** Uebernimmt das aktuelle Ergebnis in den Verlauf, sobald es bestaetigt wirkt. */
+function merkeAktuellesNachKurzerPause() {
+ stoppeVerlaufTimer();
+ verlaufTimer = window.setTimeout(() => {
+ verlaufTimer = undefined;
+ if (aktuell) merkeImVerlauf(aktuell);
+ }, VERLAUF_VERZOEGERUNG_MS);
+}
+
+/** @param {GtinErgebnis} ergebnis */
+function zeigeErgebnis(ergebnis) {
+ aktuell = ergebnis;
+ versteckeFehler();
+ gtinTyp.textContent = ergebnis.typ === 'EAN-8' ? 'GTIN-8' : 'GTIN-13';
+ gtinKnopf.textContent = ergebnis.gtin;
+ ergebnisBereich.hidden = false;
+ rendereBarcode(barcodeBehaelter, ergebnis, 'normal');
+ merkeAktuellesNachKurzerPause();
+}
+
+function pruefeUndZeige() {
+ const status = eingabeStatus(input.value);
+ if (status === 'leer') {
+ versteckeFehler();
+ versteckeErgebnis();
+ return;
+ }
+ // Auch eine zu kurze Nummer ist am Ende ungueltig. Der Fehler erscheint erst
+ // nach der Entprellung, waehrend des Tippens also nicht.
+ if (status === 'unvollstaendig' || status === 'ungueltig') {
+ versteckeErgebnis();
+ zeigeFehler();
+ return;
+ }
+ const ergebnis = erzeugeGtin(input.value);
+ if (!ergebnis) {
+ versteckeErgebnis();
+ zeigeFehler();
+ return;
+ }
+ zeigeErgebnis(ergebnis);
+}
+
+/* ---------------------------------------------------------------- Eingabe -- */
+
+function stoppeEingabeTimer() {
+ if (eingabeTimer !== undefined) {
+ window.clearTimeout(eingabeTimer);
+ eingabeTimer = undefined;
+ }
+}
+
+function beiEingabe() {
+ const sauber = nurZiffern(input.value);
+ if (sauber !== input.value) {
+ input.value = sauber;
+ input.setSelectionRange(sauber.length, sauber.length);
+ }
+ stoppeEingabeTimer();
+ if (sauber.length === 0) {
+ versteckeFehler();
+ versteckeErgebnis();
+ return;
+ }
+ if (sauber.length === 7) {
+ pruefeUndZeige();
+ return;
+ }
+ eingabeTimer = window.setTimeout(() => {
+ eingabeTimer = undefined;
+ pruefeUndZeige();
+ }, EINGABE_VERZOEGERUNG_MS);
+}
+
+/**
+ * Uebernimmt eine Artikelnummer in das Eingabefeld.
+ *
+ * @param {string} artikel
+ * @param {{ fokussieren?: boolean }} [optionen] `fokussieren: false` laesst den
+ * Fokus, wo er ist: Ein Tipp auf den Verlauf soll den Barcode zeigen und auf
+ * Mobilgeraeten nicht die Tastatur ueber das Ergebnis legen.
+ */
+function uebernimmArtikel(artikel, optionen = {}) {
+ input.value = artikel;
+ stoppeEingabeTimer();
+ pruefeUndZeige();
+ if (optionen.fokussieren ?? true) input.focus();
+ // Der Verlauf steht unter dem Ergebnis: Nach dem Uebernehmen dorthin
+ // scrollen, damit der erzeugte Barcode sichtbar ist.
+ ergebnisBereich.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+}
+
+/* ------------------------------------------------------------------- Feld -- */
+
+/**
+ * Markiert die komplette Artikelnummer.
+ *
+ * Ein Tipp auf das Feld waehlt damit die alte Nummer aus: Die naechste Ziffer
+ * ueberschreibt sie direkt, ohne dass vorher geloescht werden muss.
+ */
+function markiereEingabe() {
+ if (input.value === '') {
+ versteckeFehler();
+ return;
+ }
+ input.setSelectionRange(0, input.value.length);
+}
+
+/** true, wenn das Feld beim letzten Zeigerdruck bereits den Fokus hatte. */
+let feldWarFokussiert = false;
+
+function neueNummer() {
+ stoppeEingabeTimer();
+ input.value = '';
+ versteckeFehler();
+ versteckeErgebnis();
+ input.focus();
+}
+
+/* ------------------------------------------------------------ Scanmodus -- */
+
+/** @returns {{ request: (typ: 'screen') => Promise } | null} */
+function wakeLockApi() {
+ const api = /** @type {any} */ (navigator).wakeLock;
+ return api && typeof api.request === 'function' ? api : null;
+}
+
+function starteWakeLock() {
+ const api = wakeLockApi();
+ if (!api || wakeLock) return;
+ api
+ .request('screen')
+ .then((sentinel) => {
+ wakeLock = sentinel;
+ sentinel.addEventListener('release', () => {
+ if (wakeLock === sentinel) wakeLock = null;
+ });
+ })
+ .catch(() => {
+ wakeLock = null;
+ });
+}
+
+function stoppeWakeLock() {
+ const sentinel = wakeLock;
+ wakeLock = null;
+ if (sentinel) sentinel.release().catch(() => undefined);
+}
+
+function oeffneScan() {
+ if (!aktuell || scanAktiv) return;
+ scanAktiv = true;
+ scanGtin.textContent = aktuell.gtin;
+ scanBereich.hidden = false;
+ document.body.classList.add('scan-offen');
+ // Der Rest der Oberflaeche wird unerreichbar: Tastatur und Hilfstechnik
+ // bleiben im Scanmodus, weil nur noch der Dialog bedienbar ist.
+ appBereich.toggleAttribute('inert', true);
+ rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
+ scanInnen.focus();
+ starteWakeLock();
+ scanHinweisTimer = window.setTimeout(() => {
+ scanHinweisTimer = undefined;
+ if (scanAktiv) scanHinweis.hidden = false;
+ }, SCAN_HINWEIS_MS);
+ merkeImVerlauf(aktuell);
+ stoppeVerlaufTimer();
+}
+
+function schliesseScan() {
+ if (!scanAktiv) return;
+ scanAktiv = false;
+ scanBereich.hidden = true;
+ scanHinweis.hidden = true;
+ appBereich.toggleAttribute('inert', false);
+ document.body.classList.remove('scan-offen');
+ if (scanHinweisTimer !== undefined) {
+ window.clearTimeout(scanHinweisTimer);
+ scanHinweisTimer = undefined;
+ }
+ stoppeWakeLock();
+ grossKnopf.focus();
+}
+
+/* ------------------------------------------------------------ Listenmodus -- */
+
+/*
+ * Optionaler Modus: Die Liste liegt als kleiner Link unter dem Werkzeug. Sie
+ * wird in zwei Schritten bedient - Nummern bestaetigen, dann mit Pfeilen,
+ * Pfeiltasten oder Wischen GTIN und Barcode nacheinander anzeigen. Beide
+ * Schritte enden ueber die kleinen Links zurueck in die Einzelansicht.
+ *
+ * Bestaetigt wird ueber das Formular: Auf einem Telefon hat das Numpad keine
+ * Eingabetaste, seine Bestaetigungstaste loest aber das submit-Ereignis aus.
+ * Zusaetzlich liegt unmittelbar neben dem Feld eine sichtbare Schaltflaeche,
+ * damit die Bestaetigung auf keinem Geraet von der Tastatur abhaengt.
+ */
+
+function versteckeListenFehler() {
+ listeFehler.hidden = true;
+ listeFehler.textContent = '';
+}
+
+function zeigeListenFehler() {
+ listeFehler.textContent = FEHLERTEXT;
+ listeFehler.hidden = false;
+}
+
+function rendereListe() {
+ listeEintraege.replaceChildren();
+ liste.forEach((eintrag, index) => {
+ const punkt = document.createElement('li');
+ punkt.className = 'liste-eintrag';
+
+ const nummer = document.createElement('span');
+ nummer.className = 'liste-nr';
+ nummer.textContent = eintrag.eingabe;
+
+ const gtin = document.createElement('span');
+ gtin.className = 'liste-gtin';
+ gtin.textContent = eintrag.gtin;
+
+ const weg = document.createElement('button');
+ weg.type = 'button';
+ weg.className = 'liste-weg';
+ weg.dataset.index = String(index);
+ weg.setAttribute('aria-label', `Artikelnummer ${eintrag.eingabe} aus der Liste entfernen`);
+ weg.textContent = '×';
+
+ punkt.append(nummer, gtin, weg);
+ listeEintraege.append(punkt);
+ });
+
+ listeAnzahl.textContent = String(liste.length);
+ listeLeer.hidden = liste.length > 0;
+ listeWeiterKnopf.disabled = liste.length === 0;
+}
+
+/**
+ * Uebernimmt die Eingabe in die Liste.
+ *
+ * Alles, was keine Ziffer ist, trennt Nummern: So laesst sich eine ganze Spalte
+ * in einem Zug einfuegen. Die Bestaetigung im wirklich leeren Feld startet
+ * direkt die Anzeige, damit die Erfassung ohne Klick auf "Weiter" endet. Steht
+ * dagegen Text ohne eine einzige Ziffer im Feld, ist das ein Fehler und kein
+ * Start.
+ */
+function uebernimmListeneingabe() {
+ const roh = listeEingabe.value;
+ const gruppen = zerlegeEingabe(roh);
+ if (gruppen.length === 0) {
+ if (roh.trim() !== '') {
+ zeigeListenFehler();
+ return;
+ }
+ versteckeListenFehler();
+ if (liste.length > 0) starteListenAnzeige(0);
+ return;
+ }
+ let uebernommen = 0;
+ let verworfen = 0;
+ for (const gruppe of gruppen) {
+ const ergebnis = erzeugeGtin(gruppe);
+ if (ergebnis) {
+ liste.push(ergebnis);
+ uebernommen += 1;
+ } else {
+ verworfen += 1;
+ }
+ }
+ if (uebernommen > 0) {
+ listeEingabe.value = '';
+ rendereListe();
+ listeEintraege.scrollTop = listeEintraege.scrollHeight;
+ // Der Fokus bleibt im Feld: Die Bildschirmtastatur bleibt offen, und die
+ // naechste Nummer laesst sich ohne einen weiteren Tipp eingeben.
+ listeEingabe.focus();
+ }
+ if (verworfen > 0) zeigeListenFehler();
+ else versteckeListenFehler();
+}
+
+/**
+ * Bestaetigt die Erfassung ueber das Formular.
+ *
+ * Ein Numpad auf dem Telefon bestaetigt mit seinem eigenen Schluessel
+ * (Haekchen, "Fertig", "Los"); er loest das submit-Ereignis aus. Hier laufen
+ * deshalb Bestaetigungstaste, eingefuegte Spalte und Klick auf "Hinzufuegen"
+ * zusammen.
+ *
+ * @param {SubmitEvent} ereignis
+ */
+function beiListenAbsenden(ereignis) {
+ ereignis.preventDefault();
+ // Ein leerer Aufruf ueber die Schaltflaeche springt nicht in die Anzeige:
+ // Wer "Hinzufuegen" tippt, will etwas hinzufuegen. Die Bestaetigungstaste im
+ // leeren Feld bleibt dagegen der schnelle Weg nach vorn.
+ if (ereignis.submitter === listeHinzufuegenKnopf && listeEingabe.value.trim() === '') {
+ listeEingabe.focus();
+ return;
+ }
+ uebernimmListeneingabe();
+}
+
+/** Entfernt einen Eintrag; mit der Tastatur bleibt der Fokus in der Liste.
+ *
+ * @param {number} index
+ * @param {boolean} ueberTastatur
+ */
+function entferneListenEintrag(index, ueberTastatur) {
+ if (!Number.isInteger(index) || index < 0 || index >= liste.length) return;
+ const [entfernt] = liste.splice(index, 1);
+ rendereListe();
+ zeigeToast(`Artikelnummer ${entfernt.eingabe} entfernt`);
+ if (ueberTastatur) {
+ const naechster = listeEintraege.querySelector(
+ `.liste-weg[data-index="${String(Math.min(index, liste.length - 1))}"]`,
+ );
+ if (naechster instanceof HTMLElement) {
+ naechster.focus();
+ return;
+ }
+ }
+ listeEingabe.focus();
+}
+
+/** Zeigt die Richtung des Schritts: der neue Barcode kommt von der Seite.
+ *
+ * @param {number} richtung
+ */
+function animiereSchritt(richtung) {
+ listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
+ // Reflow erzwingt den Neustart der Animation beim schnellen Blaettern.
+ void listeMitte.offsetWidth;
+ listeMitte.classList.add(richtung < 0 ? 'liste-schritt-zurueck' : 'liste-schritt-vor');
+}
+
+/**
+ * @param {boolean} animiert
+ * @param {number} [richtung]
+ */
+function zeigeListenEintrag(animiert, richtung = 0) {
+ const eintrag = liste[listeIndex];
+ if (!eintrag) return;
+ listeZaehler.textContent = zaehlerText(listeIndex, liste.length);
+ listeGtin.textContent = eintrag.gtin;
+ listeArtikel.textContent = `Artikelnummer ${eintrag.eingabe}`;
+ rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
+ if (animiert) animiereSchritt(richtung);
+}
+
+/** @param {number} index */
+function starteListenAnzeige(index) {
+ if (listeAnzeigeAktiv || liste.length === 0) return;
+ listeAnzeigeAktiv = true;
+ listeIndex = schritt(index, 0, liste.length);
+ listeErfassen.hidden = true;
+ listeAnzeige.hidden = false;
+ zeigeListenEintrag(false);
+ listeMitte.focus();
+ starteWakeLock();
+}
+
+/** @param {number} richtung */
+function blaettere(richtung) {
+ if (!listeAnzeigeAktiv || liste.length === 0) return;
+ listeIndex = schritt(listeIndex, richtung, liste.length);
+ zeigeListenEintrag(true, richtung);
+}
+
+/** @param {number} index */
+function springeZu(index) {
+ if (!listeAnzeigeAktiv || liste.length === 0) return;
+ const neu = schritt(index, 0, liste.length);
+ if (neu === listeIndex) return;
+ const richtung = neu > listeIndex ? 1 : -1;
+ listeIndex = neu;
+ zeigeListenEintrag(true, richtung);
+}
+
+/** Zurueck zum Erfassen: Die Liste bleibt erhalten und wird nur bearbeitet. */
+function zeigeListeErfassen() {
+ listeAnzeigeAktiv = false;
+ listeAnzeige.hidden = true;
+ listeErfassen.hidden = false;
+ stoppeWakeLock();
+ versteckeListenFehler();
+ listeEingabe.value = '';
+ rendereListe();
+ listeEingabe.focus();
+}
+
+function oeffneListe() {
+ if (listeAktiv) return;
+ listeAktiv = true;
+ listeBereich.hidden = false;
+ document.body.classList.add('liste-offen');
+ // Der Rest der Oberflaeche wird unerreichbar, solange die Liste offen ist.
+ appBereich.toggleAttribute('inert', true);
+ zeigeListeErfassen();
+}
+
+function schliesseListe() {
+ if (!listeAktiv) return;
+ listeAktiv = false;
+ listeAnzeigeAktiv = false;
+ listeBereich.hidden = true;
+ listeErfassen.hidden = false;
+ listeAnzeige.hidden = true;
+ document.body.classList.remove('liste-offen');
+ appBereich.toggleAttribute('inert', false);
+ stoppeWakeLock();
+ listeOeffnenKnopf.focus();
+}
+
+/** @type {{ id: number, x: number, y: number } | null} */
+let geste = null;
+
+/** @param {PointerEvent} ereignis */
+function beendeGeste(ereignis) {
+ window.removeEventListener('pointerup', beendeGeste);
+ window.removeEventListener('pointercancel', beendeGeste);
+ const start = geste;
+ geste = null;
+ if (!start || start.id !== ereignis.pointerId || ereignis.type !== 'pointerup') return;
+ const dx = ereignis.clientX - start.x;
+ const dy = ereignis.clientY - start.y;
+ // Erst ab 40 px und nur bei klarer Richtung: sonst waere jeder Tipp ein Blatt.
+ if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return;
+ blaettere(dx < 0 ? 1 : -1);
+}
+
+/** @param {PointerEvent} ereignis */
+function starteGeste(ereignis) {
+ if (!listeAnzeigeAktiv || !ereignis.isPrimary) return;
+ // Ein Zug, der auf einem Pfeil beginnt, ist ein Klick auf den Pfeil.
+ if (ereignis.target instanceof Element && ereignis.target.closest('button')) return;
+ geste = { id: ereignis.pointerId, x: ereignis.clientX, y: ereignis.clientY };
+ window.addEventListener('pointerup', beendeGeste);
+ window.addEventListener('pointercancel', beendeGeste);
+}
+
+/* ------------------------------------------------------------ Zwischenablage -- */
+/** @param {string} text @returns {boolean} */
+function kopiereFallback(text) {
+ const hilfe = document.createElement('textarea');
+ hilfe.value = text;
+ hilfe.setAttribute('readonly', '');
+ hilfe.className = 'kopier-hilfe';
+ document.body.append(hilfe);
+ hilfe.select();
+ let erfolgreich = false;
+ try {
+ erfolgreich = document.execCommand('copy');
+ } catch {
+ erfolgreich = false;
+ }
+ hilfe.remove();
+ return erfolgreich;
+}
+
+async function kopiereGtin() {
+ if (!aktuell) return;
+ const text = aktuell.gtin;
+ let erfolgreich = false;
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ erfolgreich = true;
+ }
+ } catch {
+ erfolgreich = false;
+ }
+ if (!erfolgreich) erfolgreich = kopiereFallback(text);
+ zeigeToast(erfolgreich ? 'GTIN kopiert' : 'Kopieren nicht möglich');
+ merkeImVerlauf(aktuell);
+ stoppeVerlaufTimer();
+}
+
+/** @param {string} text */
+function zeigeToast(text) {
+ toast.textContent = text;
+ toast.hidden = false;
+ if (toastTimer !== undefined) window.clearTimeout(toastTimer);
+ toastTimer = window.setTimeout(() => {
+ toastTimer = undefined;
+ toast.hidden = true;
+ }, TOAST_MS);
+}
+
+/* ------------------------------------------------------------------ PWA -- */
+
+function registriereServiceWorker() {
+ if (!('serviceWorker' in navigator)) return;
+ window.addEventListener('load', () => {
+ navigator.serviceWorker
+ .register('./sw.js', { scope: './' })
+ .catch((fehler) => window.console.warn('Service Worker nicht registriert.', fehler));
+ });
+}
+
+function aktualisiereOfflineHinweis() {
+ offlineHinweis.hidden = navigator.onLine !== false;
+}
+
+function zeigeInstallationsmoeglichkeit() {
+ const standalone =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ /** @type {any} */ (navigator).standalone === true;
+ const istIos =
+ /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ iosHinweis.hidden = !istIos || standalone;
+}
+
+/* ------------------------------------------------------------- Ereignisse -- */
+
+function verbindeEreignisse() {
+ input.addEventListener('input', beiEingabe);
+ // Fokussieren markiert den gesamten Inhalt (direktes Ueberschreiben), auch
+ // wenn der Browser das Caret nach dem Tippen selbst setzt.
+ input.addEventListener('pointerdown', () => {
+ feldWarFokussiert = document.activeElement === input;
+ });
+ input.addEventListener('focus', markiereEingabe);
+ input.addEventListener('click', () => {
+ if (!feldWarFokussiert) markiereEingabe();
+ });
+ input.addEventListener('blur', () => {
+ feldWarFokussiert = false;
+ });
+
+ gtinKnopf.addEventListener('click', () => void kopiereGtin());
+ kopierenKnopf.addEventListener('click', () => void kopiereGtin());
+ grossKnopf.addEventListener('click', oeffneScan);
+ neuKnopf.addEventListener('click', neueNummer);
+ scanZurueckKnopf.addEventListener('click', schliesseScan);
+ verlaufLoeschenKnopf.addEventListener('click', leereVerlauf);
+
+ listeOeffnenKnopf.addEventListener('click', oeffneListe);
+ listeSchliessenKnopf.addEventListener('click', schliesseListe);
+ listeBeendenKnopf.addEventListener('click', schliesseListe);
+ listeAendernKnopf.addEventListener('click', zeigeListeErfassen);
+ listeWeiterKnopf.addEventListener('click', () => {
+ // Eine noch nicht bestaetigte Nummer wird beim Wechsel uebernommen.
+ if (listeEingabe.value.trim() !== '') {
+ uebernimmListeneingabe();
+ // Eine abgelehnte Nummer haelt die Erfassung offen: Der Fehler muss
+ // gelesen werden koennen, bevor es weitergeht.
+ if (!listeFehler.hidden) return;
+ }
+ // Ein leerer Aufruf mit alter Meldung im Feld raeumt sie weg und startet.
+ versteckeListenFehler();
+ starteListenAnzeige(0);
+ });
+ listeZurueckKnopf.addEventListener('click', () => blaettere(-1));
+ listeVorKnopf.addEventListener('click', () => blaettere(1));
+ listeBuehne.addEventListener('pointerdown', starteGeste);
+ listeMitte.addEventListener('animationend', () => {
+ listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
+ });
+ listeEingabe.addEventListener('keydown', (ereignis) => {
+ if (!istBestaetigungstaste(ereignis)) return;
+ // Selbst bestaetigen und die Standardaktion unterdruecken: Sonst kaeme
+ // dieselbe Nummer ueber Tastendruck und submit zweimal an.
+ ereignis.preventDefault();
+ uebernimmListeneingabe();
+ });
+ listeForm.addEventListener('submit', beiListenAbsenden);
+ // Die Schaltflaeche darf den Fokus nicht aus dem Feld ziehen: So bleibt die
+ // Bildschirmtastatur zwischen zwei Nummern offen.
+ listeHinzufuegenKnopf.addEventListener('pointerdown', (ereignis) => {
+ ereignis.preventDefault();
+ });
+ listeEingabe.addEventListener('input', () => {
+ if (!listeFehler.hidden) versteckeListenFehler();
+ });
+ listeEintraege.addEventListener('click', (ereignis) => {
+ const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.liste-weg') : null;
+ if (!(ziel instanceof HTMLElement)) return;
+ entferneListenEintrag(Number(ziel.dataset.index), ereignis.detail === 0);
+ });
+
+ verlaufListe.addEventListener('click', (ereignis) => {
+ const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.verlauf-eintrag') : null;
+ if (!(ziel instanceof HTMLElement)) return;
+ const artikel = ziel.dataset.artikel ?? '';
+ if (artikel) {
+ // Mit der Tastatur aktiviert (Enter/Leertaste): Fokus ins Feld, damit
+ // direkt weitergetippt werden kann. Mit dem Finger bleibt der Fokus
+ // ausserhalb, sonst verdeckt die Bildschirmtastatur den Barcode.
+ uebernimmArtikel(artikel, { fokussieren: ereignis.detail === 0 });
+ }
+ });
+
+ installierenKnopf.addEventListener('click', () => {
+ if (!installationsAnfrage) return;
+ installationsAnfrage.prompt();
+ Promise.resolve(installationsAnfrage.userChoice).catch(() => undefined);
+ installationsAnfrage = null;
+ installierenKnopf.hidden = true;
+ });
+
+ window.addEventListener('beforeinstallprompt', (ereignis) => {
+ ereignis.preventDefault();
+ installationsAnfrage = ereignis;
+ installierenKnopf.hidden = false;
+ });
+ window.addEventListener('appinstalled', () => {
+ installationsAnfrage = null;
+ installierenKnopf.hidden = true;
+ iosHinweis.hidden = true;
+ zeigeToast('App installiert');
+ });
+
+ window.addEventListener('online', aktualisiereOfflineHinweis);
+ window.addEventListener('offline', aktualisiereOfflineHinweis);
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && (scanAktiv || listeAnzeigeAktiv)) {
+ starteWakeLock();
+ }
+ });
+
+ window.addEventListener('resize', () => {
+ if (groessenTimer !== undefined) window.clearTimeout(groessenTimer);
+ groessenTimer = window.setTimeout(() => {
+ groessenTimer = undefined;
+ if (aktuell) {
+ rendereBarcode(barcodeBehaelter, aktuell, 'normal');
+ if (scanAktiv) rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
+ }
+ if (listeAnzeigeAktiv) {
+ const eintrag = liste[listeIndex];
+ if (eintrag) rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
+ }
+ }, 150);
+ });
+
+ document.addEventListener('keydown', (ereignis) => {
+ if (listeAktiv) {
+ if (ereignis.key === 'Escape') {
+ ereignis.preventDefault();
+ schliesseListe();
+ return;
+ }
+ if (!listeAnzeigeAktiv) return;
+ switch (ereignis.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'PageDown':
+ ereignis.preventDefault();
+ blaettere(1);
+ return;
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'PageUp':
+ ereignis.preventDefault();
+ blaettere(-1);
+ return;
+ case 'Home':
+ ereignis.preventDefault();
+ springeZu(0);
+ return;
+ case 'End':
+ ereignis.preventDefault();
+ springeZu(liste.length - 1);
+ return;
+ default:
+ return;
+ }
+ }
+
+ if (scanAktiv) {
+ if (ereignis.key === 'Escape') {
+ ereignis.preventDefault();
+ schliesseScan();
+ }
+ return;
+ }
+
+ const ziel = ereignis.target;
+ const imEingabefeld = ziel === input;
+ // Auf Schaltflaechen und Links gilt die native Tastenaktivierung.
+ const aufBedienelement =
+ ziel instanceof HTMLElement &&
+ !imEingabefeld &&
+ (ziel.tagName === 'BUTTON' || ziel.tagName === 'A' || ziel.isContentEditable);
+
+ if (ereignis.key === 'Enter') {
+ if (imEingabefeld) {
+ ereignis.preventDefault();
+ stoppeEingabeTimer();
+ if (!aktuell || aktuell.eingabe !== nurZiffern(input.value)) pruefeUndZeige();
+ if (aktuell) oeffneScan();
+ } else if (!aufBedienelement && aktuell) {
+ ereignis.preventDefault();
+ oeffneScan();
+ }
+ return;
+ }
+
+ if (ereignis.key === 'Escape') {
+ if (!aufBedienelement && aktuell) {
+ ereignis.preventDefault();
+ neueNummer();
+ }
+ return;
+ }
+
+ if (ereignis.key === 'Backspace' && !aufBedienelement && !imEingabefeld && aktuell) {
+ ereignis.preventDefault();
+ neueNummer();
+ return;
+ }
+
+ const kopiertastenkombination =
+ (ereignis.ctrlKey || ereignis.metaKey) && ereignis.key.toLowerCase() === 'c';
+ if (kopiertastenkombination && aktuell) {
+ const auswahl = window.getSelection()?.toString() ?? '';
+ const eingabeAuswahl =
+ input.selectionStart !== null && input.selectionStart !== input.selectionEnd;
+ if (auswahl === '' && !eingabeAuswahl) {
+ ereignis.preventDefault();
+ void kopiereGtin();
+ }
+ }
+ });
+}
+
+function starte() {
+ verbindeEreignisse();
+ rendereVerlauf();
+ aktualisiereOfflineHinweis();
+ zeigeInstallationsmoeglichkeit();
+ registriereServiceWorker();
+}
+
+starte();
diff --git a/src/gtin/js/barcode.js b/src/gtin/js/barcode.js
new file mode 100644
index 0000000..f6bc57c
--- /dev/null
+++ b/src/gtin/js/barcode.js
@@ -0,0 +1,343 @@
+/**
+ * EAN-Codierung und SVG-Erzeugung.
+ *
+ * Die Datei ist bewusst frei von DOM-Zugriffen: sie liefert eine reine
+ * Bitfolge (1 = Balken, 0 = Luecke) und daraus ein SVG-Markup. Die
+ * Pruefzifferpruefung aus gtin.js wird vor jeder Codierung wiederholt, damit
+ * kein Barcode fuer eine inkonsistente Nummer entstehen kann.
+ */
+
+import { gtinGueltig } from './gtin.js';
+
+/** L-Code (ungerade Paritaet), linke Haelfte. */
+const L_CODE = [
+ '0001101',
+ '0011001',
+ '0010011',
+ '0111101',
+ '0100011',
+ '0110001',
+ '0101111',
+ '0111011',
+ '0110111',
+ '0001011',
+];
+
+/** G-Code (gerade Paritaet), linke Haelfte bei EAN-13. */
+const G_CODE = [
+ '0100111',
+ '0110011',
+ '0011011',
+ '0100001',
+ '0011101',
+ '0111001',
+ '0000101',
+ '0010001',
+ '0001001',
+ '0010111',
+];
+
+/** R-Code, rechte Haelfte (Komplement des L-Codes). */
+const R_CODE = [
+ '1110010',
+ '1100110',
+ '1101100',
+ '1000010',
+ '1011100',
+ '1001110',
+ '1010000',
+ '1000100',
+ '1001000',
+ '1110100',
+];
+
+/**
+ * Paritaetsmuster der linken Gruppe in Abhaengigkeit der ersten Ziffer
+ * (EAN-13). Die erste Ziffer wird selbst nicht als Balken codiert.
+ */
+const EAN13_PARITAET = [
+ 'LLLLLL',
+ 'LLGLGG',
+ 'LLGGLG',
+ 'LLGGGL',
+ 'LGLLGG',
+ 'LGGLLG',
+ 'LGGGLL',
+ 'LGLGLG',
+ 'LGLGGL',
+ 'LGGLGL',
+];
+
+const START_UND_ENDE = '101';
+const MITTE = '01010';
+
+/** Ruhezonen in Modulen (links/rechts). */
+export const QUIETZONE = {
+ 'EAN-8': { links: 7, rechts: 7 },
+ 'EAN-13': { links: 11, rechts: 7 },
+};
+
+/** Standardhoehe der Balken in Modulen. */
+export const BALKENHOEHE_MODULE = 62;
+
+/** Verlaengerung der Rand- und Mittelbalken nach unten, in Modulen. */
+export const GUARD_ZUSATZ_MODULE = 6;
+
+/** Reservierte Hoehe fuer die Klartextzeile, in Modulen. */
+export const TEXT_HOEHE_MODULE = 13;
+
+/** Schriftgroesse des Klartexts, in Modulen. */
+export const TEXT_GROESSE_MODULE = 9;
+
+/**
+ * @typedef {object} Textgruppe
+ * @property {string} text anzuzeigende Ziffern
+ * @property {number} start Startposition im Symbol (Modulindex)
+ * @property {number} breite Breite der Gruppe in Modulen
+ */
+
+/**
+ * @typedef {object} EanSymbol
+ * @property {string} gtin validierte GTIN
+ * @property {'EAN-8' | 'EAN-13'} typ Barcode-Typ
+ * @property {string} bits Bitfolge (1 = Balken)
+ * @property {number} module Anzahl der Symbolmodule ohne Ruhezonen
+ * @property {{ links: number, rechts: number }} quietzone Ruhezonen in Modulen
+ * @property {Textgruppe[]} textgruppen Klartext unter dem Symbol
+ * @property {string | null} fuehrendeZiffer erste Ziffer (nur EAN-13)
+ */
+
+/**
+ * Codiert eine vollstaendige GTIN als EAN-8 oder EAN-13.
+ *
+ * @param {unknown} gtin vollstaendige GTIN (8 oder 13 Ziffern)
+ * @returns {EanSymbol}
+ * @throws {Error} wenn die GTIN ungueltig ist oder die Codierung nicht aufgeht
+ */
+export function kodiereEan(gtin) {
+ const wert = String(gtin ?? '');
+ if (!gtinGueltig(wert)) {
+ throw new Error(`Ungültige GTIN: ${wert}`);
+ }
+ const typ = wert.length === 8 ? 'EAN-8' : 'EAN-13';
+
+ /** @type {string} */
+ let links;
+ /** @type {string} */
+ let rechts;
+ /** @type {Textgruppe[]} */
+ let textgruppen;
+ let fuehrendeZiffer = null;
+
+ if (typ === 'EAN-8') {
+ links = '';
+ for (let i = 0; i < 4; i += 1) links += L_CODE[Number(wert[i])];
+ rechts = '';
+ for (let i = 4; i < 8; i += 1) rechts += R_CODE[Number(wert[i])];
+ textgruppen = [
+ { text: wert.slice(0, 4), start: 3, breite: 28 },
+ { text: wert.slice(4, 8), start: 36, breite: 28 },
+ ];
+ } else {
+ const paritaet = EAN13_PARITAET[Number(wert[0])];
+ links = '';
+ for (let i = 1; i <= 6; i += 1) {
+ const ziffer = Number(wert[i]);
+ links += paritaet[i - 1] === 'L' ? L_CODE[ziffer] : G_CODE[ziffer];
+ }
+ rechts = '';
+ for (let i = 7; i < 13; i += 1) rechts += R_CODE[Number(wert[i])];
+ textgruppen = [
+ { text: wert.slice(1, 7), start: 3, breite: 42 },
+ { text: wert.slice(7, 13), start: 50, breite: 42 },
+ ];
+ fuehrendeZiffer = wert[0];
+ }
+
+ const bits = START_UND_ENDE + links + MITTE + rechts + START_UND_ENDE;
+ const erwartet = typ === 'EAN-8' ? 67 : 95;
+ if (bits.length !== erwartet || !/^[01]+$/.test(bits)) {
+ throw new Error(`EAN-Codierung fehlerhaft: ${bits.length} statt ${erwartet} Module.`);
+ }
+
+ return {
+ gtin: wert,
+ typ,
+ bits,
+ module: bits.length,
+ quietzone: QUIETZONE[typ],
+ textgruppen,
+ fuehrendeZiffer,
+ };
+}
+
+/**
+ * Groesste ganzzahlige Modulbreite, die in die verfuegbare Breite passt.
+ *
+ * @param {number} verfuegbareBreitePx
+ * @param {number} moduleGesamt Anzahl Module inklusive Ruhezonen
+ * @returns {number} ganzzahlige Modulbreite in Pixeln, mindestens 1
+ */
+export function modulbreite(verfuegbareBreitePx, moduleGesamt) {
+ if (!Number.isFinite(verfuegbareBreitePx) || moduleGesamt <= 0) return 1;
+ return Math.max(1, Math.floor(verfuegbareBreitePx / moduleGesamt));
+}
+
+/**
+ * Maximale Balkenhoehe (in Modulen) fuer eine verfuegbare Anzeigehoehe.
+ *
+ * @param {number} verfuegbareHoehePx
+ * @param {number} modulbreitePx
+ * @param {number} [minimum] kleinste sinnvolle Balkenhoehe in Modulen
+ * @returns {number}
+ */
+export function balkenhoeheFuerHoehe(verfuegbareHoehePx, modulbreitePx, minimum = 30) {
+ if (!Number.isFinite(verfuegbareHoehePx) || verfuegbareHoehePx <= 0 || modulbreitePx <= 0) {
+ return BALKENHOEHE_MODULE;
+ }
+ const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
+ const moeglich = Math.floor(verfuegbareHoehePx / modulbreitePx) - reserviert;
+ return Math.max(minimum, moeglich);
+}
+
+/** Kleinste sinnvolle Balkenhoehe in Modulen. */
+export const MIN_BALKENHOEHE_MODULE = 30;
+
+/**
+ * Waehlt Modulbreite und Balkenhoehe fuer eine verfuegbare Flaeche.
+ *
+ * Gesucht ist die groesste ganzzahlige Modulbreite, mit der der komplette
+ * Barcode (Balken, Guard-Zusatz und Klartextzeile) sowohl in die Breite als
+ * auch in die Hoehe passt. So fuellt der Barcode die Anzeige bestmoeglich aus,
+ * ohne abgeschnitten zu werden; die Modulbreite bleibt ganzzahlig und damit
+ * gestochen scharf.
+ *
+ * @param {number} verfuegbareBreitePx
+ * @param {number} verfuegbareHoehePx
+ * @param {number} moduleGesamt Anzahl der Module inklusive Ruhezonen
+ * @param {{ minimumBalkenhoehe?: number, maximumBalkenhoehe?: number }} [optionen]
+ * @returns {{ modulbreite: number, balkenhoeheModule: number }}
+ */
+export function barcodeGroesse(verfuegbareBreitePx, verfuegbareHoehePx, moduleGesamt, optionen = {}) {
+ const minimum = Math.max(
+ 1,
+ Math.round(optionen.minimumBalkenhoehe ?? MIN_BALKENHOEHE_MODULE),
+ );
+ const maximum = Math.max(
+ minimum,
+ Math.round(optionen.maximumBalkenhoehe ?? BALKENHOEHE_MODULE),
+ );
+ const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
+ const breite =
+ Number.isFinite(verfuegbareBreitePx) && verfuegbareBreitePx > 0
+ ? Math.floor(verfuegbareBreitePx)
+ : 320;
+ const hoehe =
+ Number.isFinite(verfuegbareHoehePx) && verfuegbareHoehePx > 0
+ ? Math.floor(verfuegbareHoehePx)
+ : 320;
+ const module = Number.isFinite(moduleGesamt) && moduleGesamt > 0 ? Math.floor(moduleGesamt) : 1;
+
+ const groessteBreite = Math.max(1, Math.floor(breite / module));
+ for (let kandidat = groessteBreite; kandidat >= 1; kandidat -= 1) {
+ const platz = Math.floor(hoehe / kandidat) - reserviert;
+ if (kandidat === 1 || platz >= minimum) {
+ return {
+ modulbreite: kandidat,
+ balkenhoeheModule: Math.min(maximum, Math.max(minimum, platz)),
+ };
+ }
+ }
+ return { modulbreite: 1, balkenhoeheModule: minimum };
+}
+
+/**
+ * Zerlegt die Bitfolge in zusammenhaengende Balken.
+ *
+ * @param {string} bits
+ * @returns {{ start: number, breite: number }[]}
+ */
+function balkenLaeufe(bits) {
+ /** @type {{ start: number, breite: number }[]} */
+ const laeufe = [];
+ let start = -1;
+ for (let i = 0; i < bits.length; i += 1) {
+ if (bits[i] === '1') {
+ if (start === -1) start = i;
+ } else if (start !== -1) {
+ laeufe.push({ start, breite: i - start });
+ start = -1;
+ }
+ }
+ if (start !== -1) laeufe.push({ start, breite: bits.length - start });
+ return laeufe;
+}
+
+/**
+ * Erzeugt das SVG-Markup fuer einen EAN-Barcode.
+ *
+ * Alle Laengen werden in ganzen Modulen gerechnet; die Pixelbreite ergibt sich
+ * aus einem ganzzahligen Vielfachen der Modulbreite. Dadurch entstehen harte
+ * Kanten ohne Teilpixel.
+ *
+ * @param {unknown} gtin vollstaendige GTIN
+ * @param {{ modulbreite?: number, balkenhoeheModule?: number }} [optionen]
+ * @returns {{ markup: string, breite: number, hoehe: number, modulbreite: number, symbol: EanSymbol }}
+ */
+export function erzeugeSvgMarkup(gtin, optionen = {}) {
+ const symbol = kodiereEan(gtin);
+ const modulbreitePx = Math.max(1, Math.round(optionen.modulbreite ?? 3));
+ const balkenhoehe = Math.max(
+ 20,
+ Math.round(optionen.balkenhoeheModule ?? BALKENHOEHE_MODULE),
+ );
+
+ const { links, rechts } = symbol.quietzone;
+ const gesamtModule = links + symbol.module + rechts;
+ const hoeheModule = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
+ const breitePx = gesamtModule * modulbreitePx;
+ const hoehePx = hoeheModule * modulbreitePx;
+
+ const linkeGruppeEnde = 3 + (symbol.typ === 'EAN-8' ? 28 : 42);
+ const guardBereiche = [
+ { start: 0, ende: 3 },
+ { start: linkeGruppeEnde, ende: linkeGruppeEnde + 5 },
+ { start: symbol.module - 3, ende: symbol.module },
+ ];
+ const istGuard = (/** @type {number} */ start, /** @type {number} */ breite) =>
+ guardBereiche.some((bereich) => start >= bereich.start && start + breite <= bereich.ende);
+
+ const rechtecke = balkenLaeufe(symbol.bits)
+ .map(({ start, breite }) => {
+ const hoehe = balkenhoehe + (istGuard(start, breite) ? GUARD_ZUSATZ_MODULE : 0);
+ return ``;
+ })
+ .join('');
+
+ const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
+ const text = symbol.textgruppen
+ .map(
+ (gruppe) =>
+ `${gruppe.text}`,
+ )
+ .join('');
+ const fuehrend = symbol.fuehrendeZiffer
+ ? `${symbol.fuehrendeZiffer}`
+ : '';
+
+ const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
+ const markup =
+ ``;
+
+ return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
+}
diff --git a/src/gtin/js/gtin.js b/src/gtin/js/gtin.js
new file mode 100644
index 0000000..826c1f2
--- /dev/null
+++ b/src/gtin/js/gtin.js
@@ -0,0 +1,162 @@
+/**
+ * Fachliche Logik des GTIN-Generators.
+ *
+ * Diese Datei enthaelt ausschliesslich die Nummernkreis-Regeln, die
+ * Pruefziffernberechnung und die Validierung. Sie kennt weder DOM noch
+ * Oberflaeche und ist deshalb direkt testbar.
+ *
+ * Regeln (belegte Formate):
+ * 3 Ziffern -> links mit 0 auf 4 Stellen auffuellen -> Praefix 290 -> EAN-8
+ * 4 Ziffern -> Praefix 290 -> EAN-8
+ * 7 Ziffern -> Praefix 20090 -> EAN-13
+ * alles andere ist ungueltig.
+ */
+
+/** @typedef {'EAN-8' | 'EAN-13'} GtinTyp */
+
+/**
+ * @typedef {object} GtinErgebnis
+ * @property {string} eingabe normalisierte Artikelnummer (3, 4 oder 7 Stellen)
+ * @property {string} artikelblock Artikelblock mit fuehrender Null (4 Stellen)
+ * @property {string} praefix Nummernkreis-Praefix
+ * @property {string} basis GTIN ohne Pruefziffer
+ * @property {string} pruefziffer berechnete Pruefziffer
+ * @property {string} gtin vollstaendige GTIN inklusive Pruefziffer
+ * @property {GtinTyp} typ Barcode-Typ
+ * @property {number} laenge GTIN-Laenge (8 oder 13)
+ */
+
+/** Fehlertext fuer die Oberflaeche (bewusst genau ein Text). */
+export const FEHLERTEXT =
+ 'Keine gültige Artikelnummer. Erwartet werden 3, 4 oder 7 Stellen.';
+
+/** Praefix des kurzen Nummernkreises (EAN-8). */
+export const PRAEFIX_KURZ = '290';
+
+/** Praefix des langen Nummernkreises (EAN-13). */
+export const PRAEFIX_LANG = '20090';
+
+/**
+ * Entfernt alles, was keine Ziffer ist. Leere Eingaben werden zu "".
+ *
+ * @param {unknown} eingabe
+ * @returns {string}
+ */
+export function nurZiffern(eingabe) {
+ return String(eingabe ?? '').replace(/[^0-9]/g, '');
+}
+
+/**
+ * Berechnet die GTIN-Pruefziffer fuer eine Ziffernfolge ohne Pruefziffer.
+ *
+ * Die Ziffern werden von rechts nach links abwechselnd mit 3 und 1 gewichtet
+ * (rechteste Ziffer mal 3). Danach gilt (10 - (Summe mod 10)) mod 10.
+ *
+ * @param {string} ziffern Ziffernfolge ohne Pruefziffer, mindestens eine Ziffer
+ * @returns {number} Pruefziffer 0-9
+ */
+export function berechnePruefziffer(ziffern) {
+ if (typeof ziffern !== 'string' || !/^[0-9]+$/.test(ziffern)) {
+ throw new Error('Prüfziffer kann nur für eine Ziffernfolge berechnet werden.');
+ }
+ let summe = 0;
+ for (let i = 0; i < ziffern.length; i += 1) {
+ const ziffer = Number(ziffern[ziffern.length - 1 - i]);
+ summe += ziffer * (i % 2 === 0 ? 3 : 1);
+ }
+ return (10 - (summe % 10)) % 10;
+}
+
+/**
+ * Prueft eine vollstaendige GTIN als komplette EAN-Nummer.
+ *
+ * @param {unknown} gtin
+ * @returns {boolean} true, wenn Laenge, Ziffern und Pruefziffer stimmen
+ */
+export function gtinGueltig(gtin) {
+ const wert = String(gtin ?? '');
+ if (!/^(?:[0-9]{8}|[0-9]{13})$/.test(wert)) return false;
+ const basis = wert.slice(0, -1);
+ return berechnePruefziffer(basis) === Number(wert.slice(-1));
+}
+
+/**
+ * Bestimmt den Nummernkreis einer normalisierten Artikelnummer.
+ *
+ * @param {string} ziffern ausschliesslich Ziffern
+ * @returns {{ praefix: string, artikelblock: string, typ: GtinTyp, laenge: number } | null}
+ */
+export function bestimmeNummernkreis(ziffern) {
+ if (!/^[0-9]+$/.test(ziffern)) return null;
+ if (ziffern.length === 3) {
+ return {
+ praefix: PRAEFIX_KURZ,
+ artikelblock: ziffern.padStart(4, '0'),
+ typ: 'EAN-8',
+ laenge: 8,
+ };
+ }
+ if (ziffern.length === 4) {
+ return {
+ praefix: PRAEFIX_KURZ,
+ artikelblock: ziffern,
+ typ: 'EAN-8',
+ laenge: 8,
+ };
+ }
+ if (ziffern.length === 7) {
+ return {
+ praefix: PRAEFIX_LANG,
+ artikelblock: ziffern,
+ typ: 'EAN-13',
+ laenge: 13,
+ };
+ }
+ return null;
+}
+
+/**
+ * Zustand einer Eingabe fuer die Oberflaeche.
+ *
+ * @param {unknown} eingabe
+ * @returns {'leer' | 'unvollstaendig' | 'ungueltig' | 'ok'}
+ */
+export function eingabeStatus(eingabe) {
+ const ziffern = nurZiffern(eingabe);
+ if (ziffern.length === 0) return 'leer';
+ if (ziffern.length < 3) return 'unvollstaendig';
+ return bestimmeNummernkreis(ziffern) ? 'ok' : 'ungueltig';
+}
+
+/**
+ * Erzeugt aus einer Artikelnummer die vollstaendige GTIN.
+ *
+ * Die erzeugte GTIN wird anschliessend erneut als komplette EAN validiert
+ * (Sicherheitspruefung). Stimmt etwas nicht, wird null zurueckgegeben und es
+ * darf kein Barcode gerendert werden.
+ *
+ * @param {unknown} eingabe Artikelnummer (3, 4 oder 7 Ziffern, Leerzeichen erlaubt)
+ * @returns {GtinErgebnis | null}
+ */
+export function erzeugeGtin(eingabe) {
+ const ziffern = nurZiffern(eingabe);
+ const kreis = bestimmeNummernkreis(ziffern);
+ if (!kreis) return null;
+
+ const basis = kreis.praefix + kreis.artikelblock;
+ const pruefziffer = berechnePruefziffer(basis);
+ const gtin = `${basis}${pruefziffer}`;
+
+ if (gtin.length !== kreis.laenge || !gtinGueltig(gtin)) return null;
+
+ return {
+ eingabe: ziffern,
+ artikelblock: kreis.artikelblock,
+ praefix: kreis.praefix,
+ basis,
+ pruefziffer: String(pruefziffer),
+ gtin,
+ typ: kreis.typ,
+ laenge: kreis.laenge,
+ };
+}
diff --git a/src/gtin/js/liste.js b/src/gtin/js/liste.js
new file mode 100644
index 0000000..4db622d
--- /dev/null
+++ b/src/gtin/js/liste.js
@@ -0,0 +1,75 @@
+/**
+ * Reine Listenlogik des optionalen Listenmodus.
+ *
+ * Die Datei kennt weder DOM noch Oberflaeche: Sie zerlegt eine Eingabe in
+ * Artikelnummern und rechnet die Schritte der Anzeige aus. Damit ist die Logik
+ * unabhaengig von der Oberflaeche testbar, genau wie gtin.js und barcode.js.
+ */
+
+/**
+ * Zerlegt eine Eingabe in einzelne Zifferngruppen.
+ *
+ * Alles, was keine Ziffer ist, trennt die Gruppen: Leerzeichen, Komma,
+ * Semikolon, Zeilenumbruch und auch Buchstaben. So laesst sich eine ganze
+ * Spalte aus einem Lieferschein in einem Zug einfuegen, ohne dass daraus eine
+ * einzige, viel zu lange Nummer wird.
+ *
+ * @param {unknown} eingabe
+ * @returns {string[]} Zifferngruppen in Eingabereihenfolge, ohne leere Gruppen
+ */
+export function zerlegeEingabe(eingabe) {
+ return String(eingabe ?? '')
+ .split(/[^0-9]+/)
+ .filter((gruppe) => gruppe.length > 0);
+}
+
+/**
+ * Naechster Index in der Liste.
+ *
+ * Der Schritt laeuft um: Nach dem letzten Eintrag folgt wieder der erste und
+ * umgekehrt. Damit laesst sich eine Serie ohne Unterbrechung nacheinander
+ * abscannen.
+ *
+ * @param {number} index aktueller Index
+ * @param {number} richtung Schrittweite, z. B. 1 oder -1
+ * @param {number} anzahl Anzahl der Eintraege
+ * @returns {number} Index innerhalb von 0 bis anzahl-1
+ */
+export function schritt(index, richtung, anzahl) {
+ if (!Number.isInteger(anzahl) || anzahl <= 0) return 0;
+ const start = Number.isInteger(index) ? index : 0;
+ const schrittweite = Number.isInteger(richtung) ? richtung : 0;
+ return (((start + schrittweite) % anzahl) + anzahl) % anzahl;
+}
+
+/**
+ * Beschriftung des Zaehlers, z. B. "3 / 7".
+ *
+ * @param {number} index aktueller Index
+ * @param {number} anzahl Anzahl der Eintraege
+ * @returns {string} leer, wenn die Liste leer ist
+ */
+export function zaehlerText(index, anzahl) {
+ if (!Number.isInteger(anzahl) || anzahl <= 0) return '';
+ const position = (Number.isInteger(index) ? index : 0) + 1;
+ return `${Math.min(Math.max(position, 1), anzahl)} / ${anzahl}`;
+}
+
+/**
+ * Erkennt die Bestaetigungstaste einer Tastatur.
+ *
+ * Ein Numpad auf dem Telefon hat keine Eingabetaste: Es bestaetigt mit einem
+ * eigenen Schluessel (Haekchen, "Fertig", "Los"). Je nach Tastatur meldet der
+ * Browser ihn als `key: "Enter"` oder nur ueber den alten `keyCode` 13, in
+ * manchen Faellen mit `key: "Unidentified"`. Beide Kennungen muessen gelten,
+ * sonst bleibt die eingegebene Nummer auf dem Telefon liegen.
+ *
+ * @param {{ key?: unknown, keyCode?: unknown } | null | undefined} ereignis Tastenereignis
+ * @returns {boolean} true, wenn die Taste die Eingabe bestaetigt
+ */
+export function istBestaetigungstaste(ereignis) {
+ if (!ereignis) return false;
+ const taste = typeof ereignis.key === 'string' ? ereignis.key : '';
+ const kennung = typeof ereignis.keyCode === 'number' ? ereignis.keyCode : 0;
+ return taste === 'Enter' || kennung === 13;
+}
diff --git a/src/gtin/manifest.webmanifest b/src/gtin/manifest.webmanifest
new file mode 100644
index 0000000..b875e8e
--- /dev/null
+++ b/src/gtin/manifest.webmanifest
@@ -0,0 +1,34 @@
+{
+ "name": "GTIN Generator",
+ "short_name": "GTIN",
+ "description": "Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen – offline, ohne Anmeldung.",
+ "lang": "de",
+ "dir": "ltr",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff",
+ "categories": ["business", "productivity", "utilities"],
+ "icons": [
+ {
+ "src": "./icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/src/gtin/sw.js b/src/gtin/sw.js
new file mode 100644
index 0000000..d732e3b
--- /dev/null
+++ b/src/gtin/sw.js
@@ -0,0 +1,173 @@
+/* Service Worker des GTIN-Generators.
+ *
+ * Nach dem ersten Laden liegen HTML, CSS, JavaScript, Manifest und Icons im
+ * Cache. Danach funktioniert die Anwendung ohne WLAN, ohne Mobilfunk und ohne
+ * erreichbaren Server.
+ *
+ * CACHE_VERSION wird beim Build aus dem Inhalt der Anwendungsdateien ersetzt.
+ * Dadurch erkennt der Browser ein neues Release und raeumt den alten Cache auf.
+ */
+
+const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (self));
+
+const CACHE_VERSION = '15d23ab0263e';
+const CACHE_PREFIX = 'desfoto-gtin-generator-';
+const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
+
+// PRECACHE
+const PRECACHE = [
+ './',
+ './index.html',
+ './css/app.css',
+ './js/app.js',
+ './js/gtin.js',
+ './js/barcode.js',
+ './js/liste.js',
+ './manifest.webmanifest',
+ './icons/favicon.svg',
+ './icons/icon-192.png',
+ './icons/icon-512.png',
+ './icons/icon-maskable-512.png',
+];
+// END PRECACHE
+
+/** @param {string} pfad */
+function scopeUrl(pfad) {
+ return new URL(pfad, sw.registration.scope).toString();
+}
+
+const INDEX_URL = scopeUrl('./index.html');
+
+/** Pfad des Geltungsbereichs, z. B. "/gtin/". */
+const SCOPE_PFAD = new URL(sw.registration.scope).pathname;
+
+/**
+ * Kennung der ausgelieferten Anwendungsquelle. Nur eine Antwort, die diese
+ * Kennung enthaelt, ist die echte index.html dieses Projekts.
+ */
+const SHELL_KENNUNG = 'id="artikelnummer"';
+
+/**
+ * Entscheidet, ob eine Antwort die vorgehaltene Anwendungshuelle ersetzen darf.
+ *
+ * Nur eine erfolgreiche HTML-Antwort der eigenen Anwendung ist zulaessig. Eine
+ * Anmeldeseite der Steuerungsebene, eine Magic-Link-Seite oder eine Fehlerseite
+ * (z. B. "App deployment in progress") wuerde die Anwendung sonst offline
+ * unbrauchbar machen, weil sie die echte Seite aus dem Cache verdraengt.
+ *
+ * @param {Response} antwort
+ * @returns {Promise}
+ */
+async function istAnwendungshuelle(antwort) {
+ if (!antwort.ok || antwort.type !== 'basic') return false;
+ const inhaltstyp = antwort.headers.get('content-type') ?? '';
+ if (!inhaltstyp.includes('text/html')) return false;
+ const endgueltig = new URL(antwort.url);
+ if (endgueltig.origin !== sw.location.origin) return false;
+ if (!endgueltig.pathname.startsWith(SCOPE_PFAD)) return false;
+ try {
+ return (await antwort.text()).includes(SHELL_KENNUNG);
+ } catch {
+ return false;
+ }
+}
+
+sw.addEventListener('install', (ereignis) => {
+ ereignis.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then((cache) =>
+ cache.addAll(
+ PRECACHE.map((pfad) => new Request(scopeUrl(pfad), { cache: 'reload' })),
+ ),
+ )
+ .then(() => sw.skipWaiting()),
+ );
+});
+
+sw.addEventListener('activate', (ereignis) => {
+ ereignis.waitUntil(
+ caches
+ .keys()
+ .then((namen) =>
+ Promise.all(
+ namen
+ .filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
+ .map((name) => caches.delete(name)),
+ ),
+ )
+ .then(() => sw.clients.claim()),
+ );
+});
+
+/**
+ * Bedient einen Navigationsaufruf.
+ *
+ * Bewusst aus dem Cache: Nur so stammen HTML und Assets immer aus derselben
+ * Cache-Generation. Der neue Service Worker uebernimmt dank
+ * skipWaiting()/clients.claim() sofort; ein neues Release ist damit beim
+ * naechsten Laden konsistent aktiv (kein "neues HTML, altes JavaScript").
+ *
+ * @param {FetchEvent} ereignis
+ * @param {Request} anfrage
+ * @returns {Promise}
+ */
+function bedieneNavigation(ereignis, anfrage) {
+ return caches.match(INDEX_URL).then((treffer) => {
+ if (treffer) return treffer;
+ return fetch(anfrage)
+ .then((antwort) => {
+ const pruefung = antwort.clone();
+ const kopie = antwort.clone();
+ // waitUntil haelt die Cache-Schreiboperation am Leben. Ein blosses
+ // "fire and forget" innerhalb von respondWith kann abgebrochen werden.
+ ereignis.waitUntil(
+ istAnwendungshuelle(pruefung)
+ .then((istHuelle) =>
+ istHuelle
+ ? caches.open(CACHE_NAME).then((cache) => cache.put(INDEX_URL, kopie))
+ : undefined,
+ )
+ .catch(() => undefined),
+ );
+ return antwort;
+ })
+ .catch(() => caches.match(INDEX_URL).then((treffer) => treffer ?? Response.error()));
+ });
+}
+
+sw.addEventListener('fetch', (ereignis) => {
+ const anfrage = ereignis.request;
+ if (anfrage.method !== 'GET') return;
+
+ const ziel = new URL(anfrage.url);
+ if (ziel.origin !== sw.location.origin) return;
+ if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;
+ if (ziel.pathname.endsWith('/health')) return;
+
+ if (anfrage.mode === 'navigate') {
+ const appStart = new URL('./', sw.registration.scope).pathname;
+ const appIndex = new URL('./index.html', sw.registration.scope).pathname;
+ if (ziel.pathname !== appStart && ziel.pathname !== appIndex) return;
+ ereignis.respondWith(bedieneNavigation(ereignis, anfrage));
+ return;
+ }
+
+ ereignis.respondWith(
+ caches.match(anfrage).then((treffer) => {
+ if (treffer) return treffer;
+ return fetch(anfrage).then((antwort) => {
+ if (antwort.ok && antwort.type === 'basic') {
+ const kopie = antwort.clone();
+ ereignis.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then((cache) => cache.put(anfrage, kopie))
+ .catch(() => undefined),
+ );
+ }
+ return antwort;
+ });
+ }),
+ );
+});
diff --git a/src/pages.py b/src/pages.py
index 26e16d5..a3f1c66 100644
--- a/src/pages.py
+++ b/src/pages.py
@@ -1749,7 +1749,8 @@ def impressum() -> str:
Marke und Angebot
desfoto ist der Markenauftritt von {e(LEGAL['business'])}. Alle über diese
- Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht.
+ Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht. Das kostenlose
+ GTIN-Tool ist ein browserbasiertes Zusatzwerkzeug desselben Anbieters.
Urheberrecht und Bildrechte
Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.
@@ -1829,8 +1830,11 @@ def datenschutz() -> str:
5. Cookies und lokale Speicherung
Diese Website setzt keine Cookies und verwendet keine Analyse-, Marketing- oder
- Tracking-Technologien. Es werden auch keine Inhalte im lokalen Speicher deines Browsers abgelegt.
- Ein Cookie-Banner ist deshalb nicht erforderlich.
+ Tracking-Technologien. Das browserbasierte GTIN-Tool speichert die letzten bis zu 20
+ Artikelnummern und erzeugten GTINs im lokalen Speicher deines Browsers, damit du sie dort erneut
+ aufrufen oder den Verlauf löschen kannst. Die Einträge werden nicht an einen Server übertragen.
+ Für den Offlinebetrieb speichert dein Browser außerdem die Anwendungsdateien im Cache. Beides
+ kannst du über das Tool beziehungsweise die Browser-Einstellungen löschen.
6. Schriftarten und Bilder
Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.
diff --git a/tests/test_site.py b/tests/test_site.py
index 71d1baf..d31d70c 100644
--- a/tests/test_site.py
+++ b/tests/test_site.py
@@ -26,7 +26,8 @@ import pages # noqa: E402
from content import CONTACT, LEGAL, SITE as META # noqa: E402
ROUTE_PATHS = [route[0] for route in pages.ROUTES]
-HTML_FILES = sorted(SITE.rglob("*.html"))
+HTML_FILES = [SITE / route[1] for route in pages.ROUTES] + [SITE / "404.html"]
+GTIN_HTML = SITE / "gtin" / "index.html"
def read(path: Path) -> str:
@@ -48,8 +49,8 @@ class BuildLayout(unittest.TestCase):
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
def test_no_stray_html_files(self):
- expected = {route[1] for route in pages.ROUTES} | {"404.html"}
- actual = {str(f.relative_to(SITE)) for f in HTML_FILES}
+ expected = {route[1] for route in pages.ROUTES} | {"404.html", "gtin/index.html"}
+ actual = {str(f.relative_to(SITE)) for f in SITE.rglob("*.html")}
self.assertEqual(expected, actual)
def test_site_files_are_world_readable(self):
@@ -214,6 +215,9 @@ class PrivacyAndLegal(unittest.TestCase):
for needle in (
"keine Zugriffsprotokolle",
"keine Cookies",
+ "GTIN-Tool",
+ "bis zu 20",
+ "nicht an einen Server übertragen",
"youtube-nocookie.com",
"Art. 6 Abs. 1 lit. f DSGVO",
LEGAL["authority"],
@@ -314,6 +318,43 @@ class Content(unittest.TestCase):
self.assertIn("User-agent: *", read(SITE / "robots.txt"))
self.assertIn(f"Sitemap: {META['url']}/sitemap.xml", read(SITE / "robots.txt"))
+ def test_gtin_tool_is_public_but_unlisted(self):
+ text = read(GTIN_HTML)
+ self.assertIn("GTIN Generator", text)
+ self.assertIn('', text)
+ self.assertIn('', text)
+ self.assertIn('href="/impressum/"', text)
+ self.assertIn('href="/datenschutz/"', text)
+ self.assertNotIn('href="/"', text)
+ self.assertNotIn("/gtin/", read(SITE / "sitemap.xml"))
+ for file in HTML_FILES:
+ with self.subTest(file=str(file.relative_to(SITE))):
+ self.assertNotIn("/gtin", read(file))
+
+ def test_gtin_assets_and_local_data_storage_are_scoped(self):
+ html_text = read(GTIN_HTML)
+ for asset in ("./css/app.css", "./js/app.js", "./manifest.webmanifest"):
+ with self.subTest(asset=asset):
+ self.assertTrue((SITE / "gtin" / asset.removeprefix("./")).is_file())
+ self.assertIn(asset, html_text)
+ self.assertTrue((SITE / "gtin" / "sw.js").is_file())
+ self.assertFalse((SITE / "gtin" / "health").exists())
+ app_js = read(SITE / "gtin" / "js" / "app.js")
+ self.assertIn("desfoto.gtin.verlauf.v1", app_js)
+ self.assertIn("localStorage", app_js)
+ self.assertIn(".register('./sw.js', { scope: './' })", app_js)
+ self.assertNotIn("fetch(", app_js)
+ self.assertNotIn("XMLHttpRequest", app_js)
+ service_worker = read(SITE / "gtin" / "sw.js")
+ self.assertIn("name.startsWith(CACHE_PREFIX)", service_worker)
+ self.assertIn("if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;", service_worker)
+ self.assertNotIn("/impressum/", service_worker)
+
+ def test_gtin_manifest_uses_the_web_app_manifest_mime_type(self):
+ conf = read(ROOT / "nginx.conf")
+ self.assertIn("location = /gtin/manifest.webmanifest", conf)
+ self.assertIn("default_type application/manifest+json;", conf)
+
class Redesign(unittest.TestCase):
"""The 2026 redesign: Arbeitsbereiche, real portfolio, logo, motion layer."""