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.

6. Schriftarten und Bilder

Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden. diff --git a/site/gtin/css/app.css b/site/gtin/css/app.css new file mode 100644 index 0000000..bf5b842 --- /dev/null +++ b/site/gtin/css/app.css @@ -0,0 +1,774 @@ +/* GTIN Generator – bewusst klein, hell und scanfreundlich. */ + +:root { + --schwarz: #111111; + --text: #111111; + --text-leise: #4b5563; + --linie: #d1d5db; + --flaeche: #ffffff; + --hintergrund: #f4f5f7; + --fokus: #1d4ed8; + --radius: 12px; + --abstand: 16px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--hintergrund); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 1rem; + line-height: 1.5; +} + +body.scan-offen { + overflow: hidden; +} + +body.liste-offen { + overflow: hidden; +} + +[hidden] { + display: none !important; +} + +:focus-visible { + outline: 3px solid var(--fokus); + outline-offset: 2px; +} + +.app { + width: 100%; + max-width: 44rem; + margin: 0 auto; + padding: calc(12px + env(safe-area-inset-top)) var(--abstand) + calc(var(--abstand) + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Fuer Hilfstechnik vorhanden, ohne Platz auf dem Bildschirm zu kosten. */ +.nur-vorlesen { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.hinweis-badge { + display: block; + margin: 0; + padding: 6px 10px; + border-radius: var(--radius); + background: #fef3c7; + color: #92400e; + font-size: 0.85rem; + text-align: center; +} + +.karte { + background: var(--flaeche); + border: 1px solid var(--linie); + border-radius: var(--radius); + padding: var(--abstand); +} + +.feld-titel { + margin: 0 0 8px; + font-size: 1rem; + font-weight: 600; +} + +.feld { + width: 100%; + min-height: 3.5rem; + padding: 12px 14px; + border: 2px solid var(--linie); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + font-size: 1.5rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.08em; + text-align: center; +} + +.feld:focus { + border-color: var(--schwarz); +} + +.feld-hinweis { + margin: 8px 0 0; + color: var(--text-leise); + font-size: 0.9rem; + text-align: center; +} + +.fehler { + margin: 10px 0 0; + padding: 10px 12px; + border-radius: var(--radius); + background: #fee2e2; + color: #991b1b; + font-weight: 600; + text-align: center; +} + +.ergebnis { + text-align: center; +} + +.gtin-typ { + margin: 0; + color: var(--text-leise); + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.gtin { + margin: 6px 0 14px; + padding: 6px 10px; + border: 0; + border-radius: var(--radius); + background: none; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: clamp(1.9rem, 9vw, 2.75rem); + font-weight: 700; + letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; + cursor: pointer; +} + +.barcode { + display: flex; + justify-content: center; + padding: 10px 4px; + background: var(--flaeche); + border-radius: var(--radius); +} + +.barcode svg { + display: block; + max-width: 100%; + height: auto; +} + +.aktionen { + margin-top: var(--abstand); + display: flex; + flex-direction: column; + gap: 10px; +} + +.aktionen-reihe { + display: flex; + gap: 10px; +} + +.aktionen-reihe .knopf { + flex: 1 1 0; + min-width: 0; +} + +.knopf { + min-height: 3rem; + padding: 12px 16px; + border: 2px solid var(--schwarz); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + font-size: 1rem; + font-weight: 600; + cursor: pointer; +} + +.knopf-stark { + background: var(--schwarz); + color: #ffffff; +} + +.knopf-leise { + border-color: transparent; + background: none; + color: var(--text-leise); + text-decoration: underline; + font-weight: 500; +} + +.knopf:hover { + opacity: 0.9; +} + +.verlauf-titel { + margin: 0 0 10px; + font-size: 1rem; + font-weight: 600; +} + +.verlauf-liste { + margin: 0 0 10px; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; +} + +.verlauf-liste li + li { + border-top: 1px solid var(--linie); +} + +.verlauf-eintrag { + width: 100%; + min-height: 3rem; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 8px; + border: 0; + border-radius: 8px; + background: none; + color: var(--text); + font-size: 1rem; + font-variant-numeric: tabular-nums; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.verlauf-eintrag:hover, +.verlauf-eintrag:focus-visible { + background: #eef2ff; +} + +.verlauf-artikel { + min-width: 5.5rem; + font-weight: 600; +} + +.verlauf-pfeil { + color: var(--text-leise); +} + +.verlauf-gtin { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; +} + +.fuss { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-bottom: 8px; +} + +.fuss-hinweis { + margin: 0; + color: var(--text-leise); + font-size: 0.9rem; + text-align: center; +} + +.toast { + position: fixed; + left: 50%; + bottom: calc(20px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + margin: 0; + padding: 10px 18px; + border-radius: 999px; + background: var(--schwarz); + color: #ffffff; + font-weight: 600; + z-index: 60; +} + +.kopier-hilfe { + position: fixed; + top: -1000px; + left: -1000px; + opacity: 0; +} + +/* ------------------------------------------------------------ Scanmodus -- */ + +.scan { + position: fixed; + inset: 0; + z-index: 50; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + padding: calc(12px + env(safe-area-inset-top)) 12px + calc(12px + env(safe-area-inset-bottom)); +} + +.scan-innen { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + outline: none; +} + +.scan-seite { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.scan-barcode { + width: 100%; + /* Nimmt im Hochformat die verbleibende Hoehe ein, damit der Barcode so + gross wie moeglich wird. Die Groesse wird in app.js gemessen. */ + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.scan-barcode svg { + display: block; + max-width: 100%; + max-height: 100%; + height: auto; +} + +.scan-gtin { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: clamp(1.5rem, 7vw, 2.5rem); + font-weight: 700; + letter-spacing: 0.08em; + font-variant-numeric: tabular-nums; +} + +.scan-hinweis { + margin: 0; + color: var(--text-leise); + font-size: 0.9rem; + text-align: center; +} + +.scan-zurueck { + min-width: 12rem; +} + +/* + * Querformat: Der Barcode bekommt die volle Breite des Bildschirms, GTIN und + * Zurueck-Schaltflaeche stehen daneben. Dadurch wird der grosse Code deutlich + * groesser als im Hochformat, wo die Beschriftung darunter liegt. + */ +@media (orientation: landscape) { + .scan-innen { + flex-direction: row; + gap: 18px; + } + + .scan-barcode { + width: auto; + align-self: stretch; + } + + .scan-seite { + flex: 0 1 auto; + min-width: 0; + max-width: 34%; + align-self: center; + gap: 10px; + } + + .scan-gtin { + font-size: clamp(1rem, 4.4vh, 1.9rem); + } + + .scan-hinweis { + font-size: 0.8rem; + } + + .scan-zurueck { + width: 100%; + min-width: 0; + } +} + +/* ---------------------------------------------------------- Listenmodus -- */ + +/* Der Einstieg ist bewusst ein kleiner Link unter dem Werkzeug: Die + Einzelansicht bleibt die Hauptansicht. */ +.fuss-liste { + margin: 0; +} + +.fuss-datenschutz { + margin: 0; + color: var(--text-leise); + font-size: 0.78rem; + text-align: center; +} + +.fuss-datenschutz a, +.fuss-rechtliches a { + color: inherit; + text-underline-offset: 0.18em; +} + +.fuss-rechtliches { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.35rem 0.9rem; + font-size: 0.78rem; +} + +.link-klein { + min-height: 2.75rem; + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: none; + color: var(--text-leise); + font-size: 0.9rem; + font-weight: 500; + text-decoration: underline; + cursor: pointer; +} + +.link-klein:hover { + color: var(--text); +} + +.liste { + position: fixed; + inset: 0; + z-index: 50; + background: var(--flaeche); + padding: calc(12px + env(safe-area-inset-top)) var(--abstand) + calc(12px + env(safe-area-inset-bottom)); +} + +.liste-innen { + width: 100%; + max-width: 44rem; + height: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 10px; + outline: none; +} + +.liste-erfassen, +.liste-anzeige { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Schritt 1: Nummern erfassen. */ + +.liste-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Die Abstaende liegen am Formular, nicht an den einzelnen Elementen. */ +.liste-form .feld-titel, +.liste-form .feld-hinweis, +.liste-form .fehler { + margin: 0; +} + +/* + * Feld und Bestaetigung bilden eine Zeile: Der Knopf sitzt unmittelbar neben + * der Eingabe, damit das Bestaetigen auf dem Telefon nicht von der + * Bildschirmtastatur abhaengt. Auf sehr schmalen Anzeigen bricht er unter das + * Feld und bleibt dort ein volles Touchziel. + */ +.liste-eingabezeile { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.liste-eingabezeile .feld { + flex: 1 1 9rem; + width: auto; + min-width: 0; +} + +.liste-hinzufuegen { + flex: 1 1 auto; +} + +.liste-titel { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.liste-anzahl { + display: inline-block; + min-width: 1.6rem; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + background: var(--schwarz); + color: #ffffff; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.liste-eintraege { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; +} + +.liste-eintrag { + display: flex; + align-items: center; + gap: 10px; + border-top: 1px solid var(--linie); +} + +.liste-eintrag:first-child { + border-top: 0; +} + +.liste-nr { + min-width: 5.5rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.liste-eintrag .liste-gtin { + font-size: 1rem; + font-weight: 400; + letter-spacing: 0.04em; +} + +.liste-weg { + margin-left: auto; + min-width: 2.75rem; + min-height: 2.75rem; + border: 0; + border-radius: 8px; + background: none; + color: var(--text-leise); + font-size: 1.1rem; + cursor: pointer; +} + +.liste-weg:hover, +.liste-weg:focus-visible { + background: var(--hintergrund); + color: var(--text); +} + +.liste-leer { + margin: 0; + padding: 14px 0; + color: var(--text-leise); + text-align: center; +} + +.liste-aktionen, +.liste-links { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +/* Schritt 2: GTIN und Barcode nacheinander anzeigen. */ + +.liste-zaehler { + margin: 0; + color: var(--text-leise); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.liste-buehne { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: minmax(0, 1fr) auto; + grid-template-areas: + "mitte mitte" + "zurueck vor"; + gap: 10px; + touch-action: pan-y; +} + +.liste-mitte { + grid-area: mitte; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + outline: none; +} + +.liste-barcode { + flex: 1 1 auto; + min-height: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.liste-barcode svg { + display: block; + max-width: 100%; + max-height: 100%; + height: auto; +} + +.liste-gtin { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: clamp(1.4rem, 6.5vw, 2.2rem); + font-weight: 700; + letter-spacing: 0.07em; + font-variant-numeric: tabular-nums; +} + +.liste-pfeil { + min-height: 3rem; + border: 2px solid var(--schwarz); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + font-size: 1.25rem; + cursor: pointer; +} + +#liste-zurueck { + grid-area: zurueck; +} + +#liste-vor { + grid-area: vor; +} + +.liste-artikel { + margin: 0; + color: var(--text-leise); + font-variant-numeric: tabular-nums; + text-align: center; +} + +/* + * Der Schritt zur naechsten Nummer zeigt die Richtung: Der neue Barcode kommt + * von der Seite, in die der Nutzer geblättert hat. 160 ms, einmalig, und unter + * "prefers-reduced-motion: reduce" abgeschaltet. + */ +@keyframes liste-schritt-vor { + from { + transform: translateX(14px); + opacity: 0.35; + } + + to { + transform: none; + opacity: 1; + } +} + +@keyframes liste-schritt-zurueck { + from { + transform: translateX(-14px); + opacity: 0.35; + } + + to { + transform: none; + opacity: 1; + } +} + +.liste-schritt-vor { + animation: liste-schritt-vor 160ms ease-out; +} + +.liste-schritt-zurueck { + animation: liste-schritt-zurueck 160ms ease-out; +} + +/* + * Querformat: Die Pfeile stehen als Leisten neben dem Barcode, der dadurch die + * volle Hoehe bekommt. + */ +@media (orientation: landscape) { + .liste-buehne { + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr); + grid-template-areas: "zurueck mitte vor"; + } + + .liste-pfeil { + width: 4rem; + height: 100%; + } +} + +@media (min-width: 40rem) { + .aktionen { + flex-direction: row-reverse; + } + + .aktionen .knopf-stark { + flex: 1 1 0; + } + + .aktionen-reihe { + flex: 1 1 0; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} diff --git a/site/gtin/icons/favicon.svg b/site/gtin/icons/favicon.svg new file mode 100644 index 0000000..834ecd9 --- /dev/null +++ b/site/gtin/icons/favicon.svg @@ -0,0 +1 @@ + diff --git a/site/gtin/icons/icon-192.png b/site/gtin/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9474075aa1cac5940da64e84ccf7876e8dc3e08e GIT binary patch literal 251 zcmeAS@N?(olHy`uVBq!ia0vp^2SAtuNHCOdH@*#|4tcsbhE&{od)|?YL4kwCVgG~w z^3m5jy7E-d#I?A#c81w7f>a}c57G+8#dC_czw$5N{=aNDk_^q6r3|J_XqJ3Xliiq{ U5tgB{wE!gJ>FVdQ&MBb@00~Vc^#A|> literal 0 HcmV?d00001 diff --git a/site/gtin/icons/icon-512.png b/site/gtin/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d8cb2c297f4382b4aaea6de78aaf6f00db8e5d39 GIT binary patch literal 1275 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H3kNjL!K^z Jmvv4FO#r90MEd{$ literal 0 HcmV?d00001 diff --git a/site/gtin/index.html b/site/gtin/index.html new file mode 100644 index 0000000..cc384ce --- /dev/null +++ b/site/gtin/index.html @@ -0,0 +1,190 @@ + + + + + + GTIN Generator · desfoto + + + + + + + + + + + + + + +

+ +

GTIN Generator

+ + +
+

Artikelnummer

+ +

Erlaubt sind 3, 4 oder 7 Ziffern.

+ +
+ + + + + +
+ + + +

+ +

+

+ Eingaben und Verlauf bleiben in deinem Browser. + Datenschutz +

+ +
+
+ + + + + + + + + + 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 = + `` + + `${beschriftung}` + + `` + + `${rechtecke}` + + `${fuehrend}${text}` + + ``; + + 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.

Urheberrecht und Bildrechte

Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt. diff --git a/site/sitemap.xml b/site/sitemap.xml index 5055f49..b7fb502 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -1,17 +1,17 @@ - https://desfoto.de/2026-09-19weekly1.0 - https://desfoto.de/fotografie/2026-09-19monthly0.9 - https://desfoto.de/businessfotografie/2026-09-19monthly0.9 - https://desfoto.de/portrait-und-model/2026-09-19monthly0.9 - https://desfoto.de/familie/2026-09-19monthly0.9 - https://desfoto.de/minishootings/2026-09-19monthly0.9 - https://desfoto.de/video/2026-09-19monthly0.9 - https://desfoto.de/musik-und-buehne/2026-09-19monthly0.9 - https://desfoto.de/social-media/2026-09-19monthly0.9 - https://desfoto.de/projekte/2026-09-19monthly0.8 - https://desfoto.de/ueber/2026-09-19monthly0.8 - https://desfoto.de/kontakt/2026-09-19monthly0.8 - https://desfoto.de/impressum/2026-09-19monthly0.4 - https://desfoto.de/datenschutz/2026-09-19monthly0.4 + https://desfoto.de/2026-09-25weekly1.0 + https://desfoto.de/fotografie/2026-09-25monthly0.9 + https://desfoto.de/businessfotografie/2026-09-25monthly0.9 + https://desfoto.de/portrait-und-model/2026-09-25monthly0.9 + https://desfoto.de/familie/2026-09-25monthly0.9 + https://desfoto.de/minishootings/2026-09-25monthly0.9 + https://desfoto.de/video/2026-09-25monthly0.9 + https://desfoto.de/musik-und-buehne/2026-09-25monthly0.9 + https://desfoto.de/social-media/2026-09-25monthly0.9 + https://desfoto.de/projekte/2026-09-25monthly0.8 + https://desfoto.de/ueber/2026-09-25monthly0.8 + https://desfoto.de/kontakt/2026-09-25monthly0.8 + https://desfoto.de/impressum/2026-09-25monthly0.4 + https://desfoto.de/datenschutz/2026-09-25monthly0.4 diff --git a/src/gtin/css/app.css b/src/gtin/css/app.css new file mode 100644 index 0000000..bf5b842 --- /dev/null +++ b/src/gtin/css/app.css @@ -0,0 +1,774 @@ +/* GTIN Generator – bewusst klein, hell und scanfreundlich. */ + +:root { + --schwarz: #111111; + --text: #111111; + --text-leise: #4b5563; + --linie: #d1d5db; + --flaeche: #ffffff; + --hintergrund: #f4f5f7; + --fokus: #1d4ed8; + --radius: 12px; + --abstand: 16px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--hintergrund); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 1rem; + line-height: 1.5; +} + +body.scan-offen { + overflow: hidden; +} + +body.liste-offen { + overflow: hidden; +} + +[hidden] { + display: none !important; +} + +:focus-visible { + outline: 3px solid var(--fokus); + outline-offset: 2px; +} + +.app { + width: 100%; + max-width: 44rem; + margin: 0 auto; + padding: calc(12px + env(safe-area-inset-top)) var(--abstand) + calc(var(--abstand) + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Fuer Hilfstechnik vorhanden, ohne Platz auf dem Bildschirm zu kosten. */ +.nur-vorlesen { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.hinweis-badge { + display: block; + margin: 0; + padding: 6px 10px; + border-radius: var(--radius); + background: #fef3c7; + color: #92400e; + font-size: 0.85rem; + text-align: center; +} + +.karte { + background: var(--flaeche); + border: 1px solid var(--linie); + border-radius: var(--radius); + padding: var(--abstand); +} + +.feld-titel { + margin: 0 0 8px; + font-size: 1rem; + font-weight: 600; +} + +.feld { + width: 100%; + min-height: 3.5rem; + padding: 12px 14px; + border: 2px solid var(--linie); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + font-size: 1.5rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.08em; + text-align: center; +} + +.feld:focus { + border-color: var(--schwarz); +} + +.feld-hinweis { + margin: 8px 0 0; + color: var(--text-leise); + font-size: 0.9rem; + text-align: center; +} + +.fehler { + margin: 10px 0 0; + padding: 10px 12px; + border-radius: var(--radius); + background: #fee2e2; + color: #991b1b; + font-weight: 600; + text-align: center; +} + +.ergebnis { + text-align: center; +} + +.gtin-typ { + margin: 0; + color: var(--text-leise); + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.gtin { + margin: 6px 0 14px; + padding: 6px 10px; + border: 0; + border-radius: var(--radius); + background: none; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: clamp(1.9rem, 9vw, 2.75rem); + font-weight: 700; + letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; + cursor: pointer; +} + +.barcode { + display: flex; + justify-content: center; + padding: 10px 4px; + background: var(--flaeche); + border-radius: var(--radius); +} + +.barcode svg { + display: block; + max-width: 100%; + height: auto; +} + +.aktionen { + margin-top: var(--abstand); + display: flex; + flex-direction: column; + gap: 10px; +} + +.aktionen-reihe { + display: flex; + gap: 10px; +} + +.aktionen-reihe .knopf { + flex: 1 1 0; + min-width: 0; +} + +.knopf { + min-height: 3rem; + padding: 12px 16px; + border: 2px solid var(--schwarz); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + font-size: 1rem; + font-weight: 600; + cursor: pointer; +} + +.knopf-stark { + background: var(--schwarz); + color: #ffffff; +} + +.knopf-leise { + border-color: transparent; + background: none; + color: var(--text-leise); + text-decoration: underline; + font-weight: 500; +} + +.knopf:hover { + opacity: 0.9; +} + +.verlauf-titel { + margin: 0 0 10px; + font-size: 1rem; + font-weight: 600; +} + +.verlauf-liste { + margin: 0 0 10px; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; +} + +.verlauf-liste li + li { + border-top: 1px solid var(--linie); +} + +.verlauf-eintrag { + width: 100%; + min-height: 3rem; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 8px; + border: 0; + border-radius: 8px; + background: none; + color: var(--text); + font-size: 1rem; + font-variant-numeric: tabular-nums; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.verlauf-eintrag:hover, +.verlauf-eintrag:focus-visible { + background: #eef2ff; +} + +.verlauf-artikel { + min-width: 5.5rem; + font-weight: 600; +} + +.verlauf-pfeil { + color: var(--text-leise); +} + +.verlauf-gtin { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; +} + +.fuss { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-bottom: 8px; +} + +.fuss-hinweis { + margin: 0; + color: var(--text-leise); + font-size: 0.9rem; + text-align: center; +} + +.toast { + position: fixed; + left: 50%; + bottom: calc(20px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + margin: 0; + padding: 10px 18px; + border-radius: 999px; + background: var(--schwarz); + color: #ffffff; + font-weight: 600; + z-index: 60; +} + +.kopier-hilfe { + position: fixed; + top: -1000px; + left: -1000px; + opacity: 0; +} + +/* ------------------------------------------------------------ Scanmodus -- */ + +.scan { + position: fixed; + inset: 0; + z-index: 50; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + padding: calc(12px + env(safe-area-inset-top)) 12px + calc(12px + env(safe-area-inset-bottom)); +} + +.scan-innen { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + outline: none; +} + +.scan-seite { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.scan-barcode { + width: 100%; + /* Nimmt im Hochformat die verbleibende Hoehe ein, damit der Barcode so + gross wie moeglich wird. Die Groesse wird in app.js gemessen. */ + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.scan-barcode svg { + display: block; + max-width: 100%; + max-height: 100%; + height: auto; +} + +.scan-gtin { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: clamp(1.5rem, 7vw, 2.5rem); + font-weight: 700; + letter-spacing: 0.08em; + font-variant-numeric: tabular-nums; +} + +.scan-hinweis { + margin: 0; + color: var(--text-leise); + font-size: 0.9rem; + text-align: center; +} + +.scan-zurueck { + min-width: 12rem; +} + +/* + * Querformat: Der Barcode bekommt die volle Breite des Bildschirms, GTIN und + * Zurueck-Schaltflaeche stehen daneben. Dadurch wird der grosse Code deutlich + * groesser als im Hochformat, wo die Beschriftung darunter liegt. + */ +@media (orientation: landscape) { + .scan-innen { + flex-direction: row; + gap: 18px; + } + + .scan-barcode { + width: auto; + align-self: stretch; + } + + .scan-seite { + flex: 0 1 auto; + min-width: 0; + max-width: 34%; + align-self: center; + gap: 10px; + } + + .scan-gtin { + font-size: clamp(1rem, 4.4vh, 1.9rem); + } + + .scan-hinweis { + font-size: 0.8rem; + } + + .scan-zurueck { + width: 100%; + min-width: 0; + } +} + +/* ---------------------------------------------------------- Listenmodus -- */ + +/* Der Einstieg ist bewusst ein kleiner Link unter dem Werkzeug: Die + Einzelansicht bleibt die Hauptansicht. */ +.fuss-liste { + margin: 0; +} + +.fuss-datenschutz { + margin: 0; + color: var(--text-leise); + font-size: 0.78rem; + text-align: center; +} + +.fuss-datenschutz a, +.fuss-rechtliches a { + color: inherit; + text-underline-offset: 0.18em; +} + +.fuss-rechtliches { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.35rem 0.9rem; + font-size: 0.78rem; +} + +.link-klein { + min-height: 2.75rem; + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: none; + color: var(--text-leise); + font-size: 0.9rem; + font-weight: 500; + text-decoration: underline; + cursor: pointer; +} + +.link-klein:hover { + color: var(--text); +} + +.liste { + position: fixed; + inset: 0; + z-index: 50; + background: var(--flaeche); + padding: calc(12px + env(safe-area-inset-top)) var(--abstand) + calc(12px + env(safe-area-inset-bottom)); +} + +.liste-innen { + width: 100%; + max-width: 44rem; + height: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 10px; + outline: none; +} + +.liste-erfassen, +.liste-anzeige { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Schritt 1: Nummern erfassen. */ + +.liste-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Die Abstaende liegen am Formular, nicht an den einzelnen Elementen. */ +.liste-form .feld-titel, +.liste-form .feld-hinweis, +.liste-form .fehler { + margin: 0; +} + +/* + * Feld und Bestaetigung bilden eine Zeile: Der Knopf sitzt unmittelbar neben + * der Eingabe, damit das Bestaetigen auf dem Telefon nicht von der + * Bildschirmtastatur abhaengt. Auf sehr schmalen Anzeigen bricht er unter das + * Feld und bleibt dort ein volles Touchziel. + */ +.liste-eingabezeile { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.liste-eingabezeile .feld { + flex: 1 1 9rem; + width: auto; + min-width: 0; +} + +.liste-hinzufuegen { + flex: 1 1 auto; +} + +.liste-titel { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.liste-anzahl { + display: inline-block; + min-width: 1.6rem; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + background: var(--schwarz); + color: #ffffff; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.liste-eintraege { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; +} + +.liste-eintrag { + display: flex; + align-items: center; + gap: 10px; + border-top: 1px solid var(--linie); +} + +.liste-eintrag:first-child { + border-top: 0; +} + +.liste-nr { + min-width: 5.5rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.liste-eintrag .liste-gtin { + font-size: 1rem; + font-weight: 400; + letter-spacing: 0.04em; +} + +.liste-weg { + margin-left: auto; + min-width: 2.75rem; + min-height: 2.75rem; + border: 0; + border-radius: 8px; + background: none; + color: var(--text-leise); + font-size: 1.1rem; + cursor: pointer; +} + +.liste-weg:hover, +.liste-weg:focus-visible { + background: var(--hintergrund); + color: var(--text); +} + +.liste-leer { + margin: 0; + padding: 14px 0; + color: var(--text-leise); + text-align: center; +} + +.liste-aktionen, +.liste-links { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +/* Schritt 2: GTIN und Barcode nacheinander anzeigen. */ + +.liste-zaehler { + margin: 0; + color: var(--text-leise); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.liste-buehne { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: minmax(0, 1fr) auto; + grid-template-areas: + "mitte mitte" + "zurueck vor"; + gap: 10px; + touch-action: pan-y; +} + +.liste-mitte { + grid-area: mitte; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + outline: none; +} + +.liste-barcode { + flex: 1 1 auto; + min-height: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.liste-barcode svg { + display: block; + max-width: 100%; + max-height: 100%; + height: auto; +} + +.liste-gtin { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: clamp(1.4rem, 6.5vw, 2.2rem); + font-weight: 700; + letter-spacing: 0.07em; + font-variant-numeric: tabular-nums; +} + +.liste-pfeil { + min-height: 3rem; + border: 2px solid var(--schwarz); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + font-size: 1.25rem; + cursor: pointer; +} + +#liste-zurueck { + grid-area: zurueck; +} + +#liste-vor { + grid-area: vor; +} + +.liste-artikel { + margin: 0; + color: var(--text-leise); + font-variant-numeric: tabular-nums; + text-align: center; +} + +/* + * Der Schritt zur naechsten Nummer zeigt die Richtung: Der neue Barcode kommt + * von der Seite, in die der Nutzer geblättert hat. 160 ms, einmalig, und unter + * "prefers-reduced-motion: reduce" abgeschaltet. + */ +@keyframes liste-schritt-vor { + from { + transform: translateX(14px); + opacity: 0.35; + } + + to { + transform: none; + opacity: 1; + } +} + +@keyframes liste-schritt-zurueck { + from { + transform: translateX(-14px); + opacity: 0.35; + } + + to { + transform: none; + opacity: 1; + } +} + +.liste-schritt-vor { + animation: liste-schritt-vor 160ms ease-out; +} + +.liste-schritt-zurueck { + animation: liste-schritt-zurueck 160ms ease-out; +} + +/* + * Querformat: Die Pfeile stehen als Leisten neben dem Barcode, der dadurch die + * volle Hoehe bekommt. + */ +@media (orientation: landscape) { + .liste-buehne { + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr); + grid-template-areas: "zurueck mitte vor"; + } + + .liste-pfeil { + width: 4rem; + height: 100%; + } +} + +@media (min-width: 40rem) { + .aktionen { + flex-direction: row-reverse; + } + + .aktionen .knopf-stark { + flex: 1 1 0; + } + + .aktionen-reihe { + flex: 1 1 0; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} diff --git a/src/gtin/icons/favicon.svg b/src/gtin/icons/favicon.svg new file mode 100644 index 0000000..834ecd9 --- /dev/null +++ b/src/gtin/icons/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/gtin/icons/icon-192.png b/src/gtin/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9474075aa1cac5940da64e84ccf7876e8dc3e08e GIT binary patch literal 251 zcmeAS@N?(olHy`uVBq!ia0vp^2SAtuNHCOdH@*#|4tcsbhE&{od)|?YL4kwCVgG~w z^3m5jy7E-d#I?A#c81w7f>a}c57G+8#dC_czw$5N{=aNDk_^q6r3|J_XqJ3Xliiq{ U5tgB{wE!gJ>FVdQ&MBb@00~Vc^#A|> literal 0 HcmV?d00001 diff --git a/src/gtin/icons/icon-512.png b/src/gtin/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d8cb2c297f4382b4aaea6de78aaf6f00db8e5d39 GIT binary patch literal 1275 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H3kNjL!K^z Jmvv4FO#r90MEd{$ literal 0 HcmV?d00001 diff --git a/src/gtin/index.html b/src/gtin/index.html new file mode 100644 index 0000000..cc384ce --- /dev/null +++ b/src/gtin/index.html @@ -0,0 +1,190 @@ + + + + + + GTIN Generator · desfoto + + + + + + + + + + + + + + +

+ +

GTIN Generator

+ + +
+

Artikelnummer

+ +

Erlaubt sind 3, 4 oder 7 Ziffern.

+ +
+ + + + + +
+ + + +

+ +

+

+ Eingaben und Verlauf bleiben in deinem Browser. + Datenschutz +

+ +
+
+ + + + + + + + + + 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 = + `` + + `${beschriftung}` + + `` + + `${rechtecke}` + + `${fuehrend}${text}` + + ``; + + 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."""