feat: publish unlisted GTIN tool

This commit is contained in:
desfoto automation
2026-09-25 19:01:51 +02:00
parent 68613788b8
commit 160d72f144
35 changed files with 5632 additions and 31 deletions

View File

@@ -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:-<missing>}"
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

View File

@@ -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

View File

@@ -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

View File

@@ -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/; }

View File

@@ -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" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
@@ -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

View File

@@ -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

View File

@@ -101,8 +101,11 @@
<h2>5. Cookies und lokale Speicherung</h2>
<p>Diese Website setzt <strong>keine Cookies</strong> 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.</p>
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.</p>
<h2>6. Schriftarten und Bilder</h2>
<p>Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.

774
site/gtin/css/app.css Normal file
View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 32" role="img" aria-label="GTIN Generator"><rect width="35" height="32" fill="#ffffff"/><g fill="#111111"><rect x="0" y="0" width="1" height="32"/><rect x="2" y="0" width="1" height="32"/><rect x="5" y="0" width="1" height="32"/><rect x="7" y="0" width="2" height="32"/><rect x="11" y="0" width="1" height="32"/><rect x="14" y="0" width="1" height="32"/><rect x="16" y="0" width="1" height="32"/><rect x="19" y="0" width="2" height="32"/><rect x="22" y="0" width="1" height="32"/><rect x="25" y="0" width="1" height="32"/><rect x="27" y="0" width="2" height="32"/><rect x="30" y="0" width="2" height="32"/><rect x="33" y="0" width="1" height="32"/></g></svg>

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

190
site/gtin/index.html Normal file
View File

@@ -0,0 +1,190 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>GTIN Generator · desfoto</title>
<meta
name="description"
content="Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen. Kostenlos, lokal im Browser und ohne Anmeldung."
/>
<link rel="canonical" href="https://desfoto.de/gtin/" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#ffffff" />
<meta name="color-scheme" content="light" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="GTIN" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<link rel="stylesheet" href="./css/app.css" />
</head>
<body>
<main class="app">
<!-- Kein sichtbarer Titel und keine Beschreibung: Auf dem Bildschirm
zaehlt nur die Eingabe mit GTIN und Barcode. Die Ueberschrift bleibt
fuer Hilfstechnik vorhanden. -->
<h1 class="nur-vorlesen">GTIN Generator</h1>
<p class="hinweis-badge" id="offline-hinweis" hidden>Offline – die Berechnung läuft weiter.</p>
<section class="karte eingabe" aria-labelledby="eingabe-titel">
<h2 class="feld-titel" id="eingabe-titel">Artikelnummer</h2>
<input
class="feld"
id="artikelnummer"
name="artikelnummer"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
enterkeyhint="go"
placeholder="z. B. 5122"
aria-labelledby="eingabe-titel"
aria-describedby="format-hinweis"
/>
<p class="feld-hinweis" id="format-hinweis">Erlaubt sind 3, 4 oder 7 Ziffern.</p>
<p class="fehler" id="fehler" role="alert" hidden></p>
</section>
<section class="karte ergebnis" id="ergebnis" aria-live="polite" hidden>
<p class="gtin-typ" id="gtin-typ"></p>
<button class="gtin" id="gtin" type="button" title="GTIN kopieren"></button>
<div class="barcode" id="barcode"></div>
<div class="aktionen">
<button class="knopf knopf-stark" id="gross" type="button">Barcode groß anzeigen</button>
<div class="aktionen-reihe">
<button class="knopf" id="kopieren" type="button">GTIN kopieren</button>
<button class="knopf" id="neu" type="button">Neue Artikelnummer</button>
</div>
</div>
</section>
<section class="karte verlauf" id="verlauf" hidden aria-labelledby="verlauf-titel">
<h2 class="verlauf-titel" id="verlauf-titel">Zuletzt verwendet</h2>
<ul class="verlauf-liste" id="verlauf-liste"></ul>
<button class="knopf knopf-leise" id="verlauf-loeschen" type="button">Verlauf löschen</button>
</section>
<footer class="fuss">
<button class="knopf knopf-stark" id="installieren" type="button" hidden>
App installieren
</button>
<p class="fuss-hinweis" id="ios-hinweis" hidden>
Installieren: Teilen&nbsp;→&nbsp;„Zum Home-Bildschirm“.
</p>
<!-- Kleiner Einstieg in den optionalen Listenmodus: Die Einzelansicht
bleibt unveraendert, die Liste liegt darunter als Link. -->
<p class="fuss-liste">
<button class="link-klein" id="liste-oeffnen" type="button" aria-haspopup="dialog">
Liste
</button>
</p>
<p class="fuss-datenschutz">
Eingaben und Verlauf bleiben in deinem Browser.
<a href="/datenschutz/">Datenschutz</a>
</p>
<nav class="fuss-rechtliches" aria-label="Rechtliche Informationen">
<a href="/impressum/">Impressum</a>
<a href="/datenschutz/">Datenschutz</a>
</nav>
</footer>
</main>
<div class="scan" id="scan" role="dialog" aria-modal="true" aria-label="Barcode im Scanmodus" hidden>
<div class="scan-innen" id="scan-innen" tabindex="-1">
<div class="scan-barcode" id="scan-barcode"></div>
<div class="scan-seite">
<p class="scan-gtin" id="scan-gtin"></p>
<p class="scan-hinweis" id="scan-hinweis" hidden>
Barcode wird nicht erkannt? Displayhelligkeit erhöhen.
</p>
<button class="knopf knopf-stark scan-zurueck" id="scan-zurueck" type="button">Zurück</button>
</div>
</div>
</div>
<p class="toast" id="toast" role="status" aria-live="polite" hidden></p>
<div class="liste" id="liste" role="dialog" aria-modal="true" aria-label="Liste" hidden>
<div class="liste-innen" id="liste-innen">
<!-- Schritt 1: Nummern eingeben, jede ueber die Schaltflaeche oder die
Bestaetigungstaste der Bildschirmtastatur in die Liste uebernehmen. -->
<section class="liste-erfassen" id="liste-erfassen" aria-labelledby="liste-titel">
<h2 class="liste-titel" id="liste-titel">
Liste <span class="liste-anzahl" id="liste-anzahl">0</span>
</h2>
<!-- Ein echtes Formular: Ein Numpad auf dem Telefon hat keine
Eingabetaste, seine Bestaetigungstaste (Haekchen) loest aber das
submit-Ereignis aus. Der sichtbare Knopf deckt jedes Geraet ab. -->
<form class="liste-form" id="liste-form" novalidate>
<label class="feld-titel" for="liste-eingabe">Artikelnummer</label>
<div class="liste-eingabezeile">
<input
class="feld"
id="liste-eingabe"
name="liste-eingabe"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
enterkeyhint="done"
placeholder="z. B. 5122"
aria-describedby="liste-format"
/>
<button class="knopf knopf-stark liste-hinzufuegen" id="liste-hinzufuegen" type="submit">
Hinzufügen
</button>
</div>
<p class="feld-hinweis" id="liste-format">
Hinzufügen übernimmt die Nummer; mehrere Nummern dürfen durch Leerzeichen getrennt sein.
</p>
<p class="fehler" id="liste-fehler" role="alert" hidden></p>
</form>
<ul class="liste-eintraege" id="liste-eintraege"></ul>
<p class="liste-leer" id="liste-leer">Noch keine Nummer in der Liste.</p>
<div class="liste-aktionen">
<button class="knopf" id="liste-weiter" type="button" disabled>
Weiter
</button>
<button class="link-klein" id="liste-schliessen" type="button">
Zurück zur Einzelansicht
</button>
</div>
</section>
<!-- Schritt 2: GTIN und Barcode nacheinander anzeigen. -->
<section class="liste-anzeige" id="liste-anzeige" aria-label="Barcodes der Liste" hidden>
<p class="liste-zaehler" id="liste-zaehler"></p>
<div class="liste-buehne" id="liste-buehne">
<button class="liste-pfeil" id="liste-zurueck" type="button" aria-label="Vorheriger Eintrag">
←
</button>
<div class="liste-mitte" id="liste-mitte" tabindex="-1">
<div class="liste-barcode" id="liste-barcode"></div>
<p class="liste-gtin" id="liste-gtin" aria-live="polite"></p>
</div>
<button class="liste-pfeil" id="liste-vor" type="button" aria-label="Nächster Eintrag">
→
</button>
</div>
<p class="liste-artikel" id="liste-artikel"></p>
<div class="liste-links">
<button class="link-klein" id="liste-aendern" type="button">Liste ändern</button>
<button class="link-klein" id="liste-beenden" type="button">
Zurück zur Einzelansicht
</button>
</div>
</section>
</div>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>

998
site/gtin/js/app.js Normal file
View File

@@ -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<void>, 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<string, unknown>} */ (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<WakeLockLike> } | 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();

343
site/gtin/js/barcode.js Normal file
View File

@@ -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 `<rect x="${links + start}" y="0" width="${breite}" height="${hoehe}" fill="#000000"/>`;
})
.join('');
const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
const text = symbol.textgruppen
.map(
(gruppe) =>
`<text x="${links + gruppe.start + gruppe.breite / 2}" y="${grundlinie}" ` +
`font-size="${TEXT_GROESSE_MODULE}" text-anchor="middle" ` +
`textLength="${gruppe.breite}" lengthAdjust="spacing" fill="#000000">${gruppe.text}</text>`,
)
.join('');
const fuehrend = symbol.fuehrendeZiffer
? `<text x="${links - 4}" y="${grundlinie}" font-size="${TEXT_GROESSE_MODULE}" ` +
`text-anchor="middle" fill="#000000">${symbol.fuehrendeZiffer}</text>`
: '';
const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
const markup =
`<svg xmlns="http://www.w3.org/2000/svg" width="${breitePx}" height="${hoehePx}" ` +
`viewBox="0 0 ${gesamtModule} ${hoeheModule}" preserveAspectRatio="xMidYMid meet" ` +
`role="img" aria-label="${beschriftung}" focusable="false">` +
`<title>${beschriftung}</title>` +
`<rect x="0" y="0" width="${gesamtModule}" height="${hoeheModule}" fill="#ffffff"/>` +
`<g shape-rendering="crispEdges">${rechtecke}</g>` +
`<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">${fuehrend}${text}</g>` +
`</svg>`;
return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
}

162
site/gtin/js/gtin.js Normal file
View File

@@ -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,
};
}

75
site/gtin/js/liste.js Normal file
View File

@@ -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;
}

View File

@@ -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"
}
]
}

173
site/gtin/sw.js Normal file
View File

@@ -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<boolean>}
*/
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<Response>}
*/
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;
});
}),
);
});

View File

@@ -85,7 +85,8 @@
<h2>Marke und Angebot</h2>
<p><strong>desfoto</strong> ist der Markenauftritt von Denny Schulz Fotografie. Alle über diese
Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht.</p>
Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht. Das kostenlose
GTIN-Tool ist ein browserbasiertes Zusatzwerkzeug desselben Anbieters.</p>
<h2>Urheberrecht und Bildrechte</h2>
<p>Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://desfoto.de/</loc><lastmod>2026-09-19</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
<url><loc>https://desfoto.de/fotografie/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/businessfotografie/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/portrait-und-model/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/familie/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/minishootings/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/video/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/musik-und-buehne/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/social-media/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/projekte/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://desfoto.de/ueber/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://desfoto.de/kontakt/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://desfoto.de/impressum/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
<url><loc>https://desfoto.de/datenschutz/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
<url><loc>https://desfoto.de/</loc><lastmod>2026-09-25</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
<url><loc>https://desfoto.de/fotografie/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/businessfotografie/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/portrait-und-model/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/familie/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/minishootings/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/video/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/musik-und-buehne/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/social-media/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
<url><loc>https://desfoto.de/projekte/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://desfoto.de/ueber/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://desfoto.de/kontakt/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://desfoto.de/impressum/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
<url><loc>https://desfoto.de/datenschutz/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
</urlset>

774
src/gtin/css/app.css Normal file
View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 32" role="img" aria-label="GTIN Generator"><rect width="35" height="32" fill="#ffffff"/><g fill="#111111"><rect x="0" y="0" width="1" height="32"/><rect x="2" y="0" width="1" height="32"/><rect x="5" y="0" width="1" height="32"/><rect x="7" y="0" width="2" height="32"/><rect x="11" y="0" width="1" height="32"/><rect x="14" y="0" width="1" height="32"/><rect x="16" y="0" width="1" height="32"/><rect x="19" y="0" width="2" height="32"/><rect x="22" y="0" width="1" height="32"/><rect x="25" y="0" width="1" height="32"/><rect x="27" y="0" width="2" height="32"/><rect x="30" y="0" width="2" height="32"/><rect x="33" y="0" width="1" height="32"/></g></svg>

After

Width:  |  Height:  |  Size: 715 B

BIN
src/gtin/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

BIN
src/gtin/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

190
src/gtin/index.html Normal file
View File

@@ -0,0 +1,190 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>GTIN Generator · desfoto</title>
<meta
name="description"
content="Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen. Kostenlos, lokal im Browser und ohne Anmeldung."
/>
<link rel="canonical" href="https://desfoto.de/gtin/" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#ffffff" />
<meta name="color-scheme" content="light" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="GTIN" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<link rel="stylesheet" href="./css/app.css" />
</head>
<body>
<main class="app">
<!-- Kein sichtbarer Titel und keine Beschreibung: Auf dem Bildschirm
zaehlt nur die Eingabe mit GTIN und Barcode. Die Ueberschrift bleibt
fuer Hilfstechnik vorhanden. -->
<h1 class="nur-vorlesen">GTIN Generator</h1>
<p class="hinweis-badge" id="offline-hinweis" hidden>Offline – die Berechnung läuft weiter.</p>
<section class="karte eingabe" aria-labelledby="eingabe-titel">
<h2 class="feld-titel" id="eingabe-titel">Artikelnummer</h2>
<input
class="feld"
id="artikelnummer"
name="artikelnummer"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
enterkeyhint="go"
placeholder="z. B. 5122"
aria-labelledby="eingabe-titel"
aria-describedby="format-hinweis"
/>
<p class="feld-hinweis" id="format-hinweis">Erlaubt sind 3, 4 oder 7 Ziffern.</p>
<p class="fehler" id="fehler" role="alert" hidden></p>
</section>
<section class="karte ergebnis" id="ergebnis" aria-live="polite" hidden>
<p class="gtin-typ" id="gtin-typ"></p>
<button class="gtin" id="gtin" type="button" title="GTIN kopieren"></button>
<div class="barcode" id="barcode"></div>
<div class="aktionen">
<button class="knopf knopf-stark" id="gross" type="button">Barcode groß anzeigen</button>
<div class="aktionen-reihe">
<button class="knopf" id="kopieren" type="button">GTIN kopieren</button>
<button class="knopf" id="neu" type="button">Neue Artikelnummer</button>
</div>
</div>
</section>
<section class="karte verlauf" id="verlauf" hidden aria-labelledby="verlauf-titel">
<h2 class="verlauf-titel" id="verlauf-titel">Zuletzt verwendet</h2>
<ul class="verlauf-liste" id="verlauf-liste"></ul>
<button class="knopf knopf-leise" id="verlauf-loeschen" type="button">Verlauf löschen</button>
</section>
<footer class="fuss">
<button class="knopf knopf-stark" id="installieren" type="button" hidden>
App installieren
</button>
<p class="fuss-hinweis" id="ios-hinweis" hidden>
Installieren: Teilen&nbsp;→&nbsp;„Zum Home-Bildschirm“.
</p>
<!-- Kleiner Einstieg in den optionalen Listenmodus: Die Einzelansicht
bleibt unveraendert, die Liste liegt darunter als Link. -->
<p class="fuss-liste">
<button class="link-klein" id="liste-oeffnen" type="button" aria-haspopup="dialog">
Liste
</button>
</p>
<p class="fuss-datenschutz">
Eingaben und Verlauf bleiben in deinem Browser.
<a href="/datenschutz/">Datenschutz</a>
</p>
<nav class="fuss-rechtliches" aria-label="Rechtliche Informationen">
<a href="/impressum/">Impressum</a>
<a href="/datenschutz/">Datenschutz</a>
</nav>
</footer>
</main>
<div class="scan" id="scan" role="dialog" aria-modal="true" aria-label="Barcode im Scanmodus" hidden>
<div class="scan-innen" id="scan-innen" tabindex="-1">
<div class="scan-barcode" id="scan-barcode"></div>
<div class="scan-seite">
<p class="scan-gtin" id="scan-gtin"></p>
<p class="scan-hinweis" id="scan-hinweis" hidden>
Barcode wird nicht erkannt? Displayhelligkeit erhöhen.
</p>
<button class="knopf knopf-stark scan-zurueck" id="scan-zurueck" type="button">Zurück</button>
</div>
</div>
</div>
<p class="toast" id="toast" role="status" aria-live="polite" hidden></p>
<div class="liste" id="liste" role="dialog" aria-modal="true" aria-label="Liste" hidden>
<div class="liste-innen" id="liste-innen">
<!-- Schritt 1: Nummern eingeben, jede ueber die Schaltflaeche oder die
Bestaetigungstaste der Bildschirmtastatur in die Liste uebernehmen. -->
<section class="liste-erfassen" id="liste-erfassen" aria-labelledby="liste-titel">
<h2 class="liste-titel" id="liste-titel">
Liste <span class="liste-anzahl" id="liste-anzahl">0</span>
</h2>
<!-- Ein echtes Formular: Ein Numpad auf dem Telefon hat keine
Eingabetaste, seine Bestaetigungstaste (Haekchen) loest aber das
submit-Ereignis aus. Der sichtbare Knopf deckt jedes Geraet ab. -->
<form class="liste-form" id="liste-form" novalidate>
<label class="feld-titel" for="liste-eingabe">Artikelnummer</label>
<div class="liste-eingabezeile">
<input
class="feld"
id="liste-eingabe"
name="liste-eingabe"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
enterkeyhint="done"
placeholder="z. B. 5122"
aria-describedby="liste-format"
/>
<button class="knopf knopf-stark liste-hinzufuegen" id="liste-hinzufuegen" type="submit">
Hinzufügen
</button>
</div>
<p class="feld-hinweis" id="liste-format">
Hinzufügen übernimmt die Nummer; mehrere Nummern dürfen durch Leerzeichen getrennt sein.
</p>
<p class="fehler" id="liste-fehler" role="alert" hidden></p>
</form>
<ul class="liste-eintraege" id="liste-eintraege"></ul>
<p class="liste-leer" id="liste-leer">Noch keine Nummer in der Liste.</p>
<div class="liste-aktionen">
<button class="knopf" id="liste-weiter" type="button" disabled>
Weiter
</button>
<button class="link-klein" id="liste-schliessen" type="button">
Zurück zur Einzelansicht
</button>
</div>
</section>
<!-- Schritt 2: GTIN und Barcode nacheinander anzeigen. -->
<section class="liste-anzeige" id="liste-anzeige" aria-label="Barcodes der Liste" hidden>
<p class="liste-zaehler" id="liste-zaehler"></p>
<div class="liste-buehne" id="liste-buehne">
<button class="liste-pfeil" id="liste-zurueck" type="button" aria-label="Vorheriger Eintrag">
←
</button>
<div class="liste-mitte" id="liste-mitte" tabindex="-1">
<div class="liste-barcode" id="liste-barcode"></div>
<p class="liste-gtin" id="liste-gtin" aria-live="polite"></p>
</div>
<button class="liste-pfeil" id="liste-vor" type="button" aria-label="Nächster Eintrag">
→
</button>
</div>
<p class="liste-artikel" id="liste-artikel"></p>
<div class="liste-links">
<button class="link-klein" id="liste-aendern" type="button">Liste ändern</button>
<button class="link-klein" id="liste-beenden" type="button">
Zurück zur Einzelansicht
</button>
</div>
</section>
</div>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>

998
src/gtin/js/app.js Normal file
View File

@@ -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<void>, 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<string, unknown>} */ (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<WakeLockLike> } | 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();

343
src/gtin/js/barcode.js Normal file
View File

@@ -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 `<rect x="${links + start}" y="0" width="${breite}" height="${hoehe}" fill="#000000"/>`;
})
.join('');
const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
const text = symbol.textgruppen
.map(
(gruppe) =>
`<text x="${links + gruppe.start + gruppe.breite / 2}" y="${grundlinie}" ` +
`font-size="${TEXT_GROESSE_MODULE}" text-anchor="middle" ` +
`textLength="${gruppe.breite}" lengthAdjust="spacing" fill="#000000">${gruppe.text}</text>`,
)
.join('');
const fuehrend = symbol.fuehrendeZiffer
? `<text x="${links - 4}" y="${grundlinie}" font-size="${TEXT_GROESSE_MODULE}" ` +
`text-anchor="middle" fill="#000000">${symbol.fuehrendeZiffer}</text>`
: '';
const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
const markup =
`<svg xmlns="http://www.w3.org/2000/svg" width="${breitePx}" height="${hoehePx}" ` +
`viewBox="0 0 ${gesamtModule} ${hoeheModule}" preserveAspectRatio="xMidYMid meet" ` +
`role="img" aria-label="${beschriftung}" focusable="false">` +
`<title>${beschriftung}</title>` +
`<rect x="0" y="0" width="${gesamtModule}" height="${hoeheModule}" fill="#ffffff"/>` +
`<g shape-rendering="crispEdges">${rechtecke}</g>` +
`<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">${fuehrend}${text}</g>` +
`</svg>`;
return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
}

162
src/gtin/js/gtin.js Normal file
View File

@@ -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,
};
}

75
src/gtin/js/liste.js Normal file
View File

@@ -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;
}

View File

@@ -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"
}
]
}

173
src/gtin/sw.js Normal file
View File

@@ -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<boolean>}
*/
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<Response>}
*/
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;
});
}),
);
});

View File

@@ -1749,7 +1749,8 @@ def impressum() -> str:
<h2>Marke und Angebot</h2>
<p><strong>desfoto</strong> ist der Markenauftritt von {e(LEGAL['business'])}. Alle über diese
Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht.</p>
Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht. Das kostenlose
GTIN-Tool ist ein browserbasiertes Zusatzwerkzeug desselben Anbieters.</p>
<h2>Urheberrecht und Bildrechte</h2>
<p>Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.
@@ -1829,8 +1830,11 @@ def datenschutz() -> str:
<h2>5. Cookies und lokale Speicherung</h2>
<p>Diese Website setzt <strong>keine Cookies</strong> 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.</p>
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.</p>
<h2>6. Schriftarten und Bilder</h2>
<p>Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.

View File

@@ -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('<link rel="canonical" href="https://desfoto.de/gtin/" />', text)
self.assertIn('<meta name="robots" content="noindex, nofollow" />', 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."""