commit df98aaaac94768148543cc7c8ed0bf517e9757d2
Author: denode
Date: Wed May 20 20:47:07 2026 +0200
Initial webiste
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7692eeb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# PocketBase Admin-Zugangsdaten
+# Kopieren nach .env und ausfüllen:
+# cp .env.example .env
+
+PB_URL=http://localhost:4444
+PB_EMAIL=admin@example.com
+PB_PASSWORD=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5a31723
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Credentials
+.env
+
+# PocketBase-Daten (werden nicht ins Repo eingecheckt)
+backups/
+data.db
+
+# Claude Code intern
+.claude/
+
+# Next.js
+frontend/.next/
+frontend/node_modules/
+frontend/.env.local
+
+# Python
+__pycache__/
+*.pyc
diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 0000000..ad44130
--- /dev/null
+++ b/SETUP.md
@@ -0,0 +1,268 @@
+# Setup-Anleitung — Debian Server
+
+## Voraussetzungen
+
+- Debian 11 / 12
+- Root oder sudo-Zugriff
+- Dein Hetzner Reverse Proxy zeigt auf diese Maschine
+- Port 8888 im Reverse Proxy weitergeleitet
+
+---
+
+## 1. Docker installieren (falls noch nicht vorhanden)
+
+```bash
+# Alte Versionen entfernen
+sudo apt remove docker docker-engine docker.io containerd runc 2>/dev/null
+
+# Abhängigkeiten
+sudo apt update
+sudo apt install -y ca-certificates curl gnupg
+
+# Docker GPG-Key
+sudo install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/debian/gpg | \
+ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+sudo chmod a+r /etc/apt/keyrings/docker.gpg
+
+# Repository
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
+ https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+# Docker installieren
+sudo apt update
+sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+
+# Deinen User zur docker-Gruppe hinzufügen (kein sudo nötig)
+sudo usermod -aG docker $USER
+newgrp docker
+
+# Test
+docker --version
+docker compose version
+```
+
+---
+
+## 2. Projektdateien auf den Server bringen
+
+```bash
+# Auf deinem lokalen Rechner — Dateien hochladen
+scp -r kennzeichen-website/ user@dein-server:/opt/kennzeichen-website
+
+# Oder: Git-Repo anlegen und pushen/pullen
+# Dann auf dem Server:
+cd /opt/kennzeichen-website
+```
+
+Alternativ direkt auf dem Server mit nano/vim die Dateien anlegen
+(der Ordner existiert ja schon).
+
+---
+
+## 3. Konfiguration anpassen
+
+```bash
+cd /opt/kennzeichen-website
+
+# domain in docker-compose.yml setzen
+nano docker-compose.yml
+```
+
+In der `docker-compose.yml` die zwei Umgebungsvariablen anpassen:
+```yaml
+environment:
+ - NEXT_PUBLIC_PB_URL=https://deine-echte-domain.de/api # ← anpassen
+ - PB_INTERNAL_URL=http://pocketbase:8090 # ← so lassen
+```
+
+---
+
+## 4. PocketBase Admin-UI einmalig direkt exponieren
+
+Beim allerersten Start muss der Admin-Account angelegt werden.
+Dafür kurz PocketBase direkt zugänglich machen:
+
+```bash
+# In docker-compose.yml temporär unter pocketbase: hinzufügen:
+# ports:
+# - "8090:8090"
+```
+
+Dann starten:
+```bash
+docker compose up -d pocketbase
+```
+
+Im Browser aufrufen: `http://:8090/_/`
+→ Admin-Account anlegen (E-Mail + Passwort merken!)
+
+Danach den `ports:`-Block wieder aus der docker-compose.yml entfernen.
+
+---
+
+## 5. Alles starten
+
+```bash
+cd /opt/kennzeichen-website
+
+# Beim ersten Mal: Images bauen (dauert 2–5 Min)
+docker compose build
+
+# Starten
+docker compose up -d
+
+# Status prüfen
+docker compose ps
+
+# Logs ansehen
+docker compose logs -f nextjs
+docker compose logs -f pocketbase
+```
+
+Die Website ist jetzt unter Port 8888 erreichbar.
+
+---
+
+## 6. Daten importieren
+
+```bash
+# Python-Abhängigkeiten
+pip3 install requests
+
+# plates-Repo klonen (falls noch nicht vorhanden)
+git clone https://git.denode.eu/denode/plates /opt/plates
+
+# data.db auf den Server bringen
+scp data.db user@dein-server:/opt/
+
+# Import starten
+cd /opt/kennzeichen-website
+python3 scripts/import.py \
+ --pb-url http://localhost:8090 \
+ --pb-email deine@email.de \
+ --pb-password deinpasswort \
+ --repo-path /opt/plates \
+ --db-path /opt/data.db
+```
+
+PocketBase ist beim Import direkt über localhost:8090 erreichbar
+(kein Port nach außen nötig, nur von der Servermaschine selbst).
+
+---
+
+## 7. Reverse Proxy konfigurieren
+
+In deinem Hetzner-Reverse-Proxy die Domain auf `:8888` zeigen lassen.
+
+Nginx Beispiel (auf dem Proxy-Server):
+```nginx
+server {
+ listen 443 ssl;
+ server_name deine-domain.de;
+
+ # SSL-Zertifikat (z.B. Let's Encrypt)
+ ssl_certificate /etc/letsencrypt/live/deine-domain.de/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/deine-domain.de/privkey.pem;
+
+ # Next.js
+ location / {
+ proxy_pass http://:8888;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # PocketBase API (wird von Next.js intern genutzt,
+ # aber für den Browser-SDK zugänglich machen)
+ location /api/ {
+ proxy_pass http://:8888/api/;
+ proxy_set_header Host $host;
+ }
+}
+```
+
+---
+
+## 8. Auto-Update bei Git-Änderungen (optional)
+
+```bash
+# Cron-Job: täglich Daten aus plates-Repo neu importieren
+crontab -e
+```
+
+```cron
+# Jeden Tag um 03:00 Uhr
+0 3 * * * cd /opt/plates && git pull && \
+ python3 /opt/kennzeichen-website/scripts/import.py \
+ --pb-url http://localhost:8090 \
+ --pb-email deine@email.de \
+ --pb-password deinpasswort \
+ --repo-path /opt/plates \
+ --db-path /opt/data.db \
+ --skip-schema \
+ >> /var/log/kennzeichen-import.log 2>&1
+```
+
+---
+
+## Nützliche Befehle
+
+```bash
+# Neustart nach Code-Änderungen
+docker compose build nextjs && docker compose up -d nextjs
+
+# PocketBase-Daten sichern
+docker cp kennzeichen-website_pb_data_1:/pb/pb_data ./backup-$(date +%Y%m%d)
+
+# Logs verfolgen
+docker compose logs -f
+
+# Alles stoppen
+docker compose down
+```
+
+---
+
+## Projektstruktur (Endzustand)
+
+```
+/opt/kennzeichen-website/
+├── docker-compose.yml
+├── README.md
+├── SCHEMA.md
+├── docker/
+│ └── pocketbase/
+│ └── Dockerfile
+├── frontend/
+│ ├── Dockerfile
+│ ├── package.json
+│ ├── next.config.js
+│ ├── tailwind.config.ts
+│ ├── tsconfig.json
+│ ├── app/
+│ │ ├── layout.tsx # Root Layout + Navigation
+│ │ ├── page.tsx # Startseite
+│ │ ├── globals.css # Design-System
+│ │ ├── kennzeichen/
+│ │ │ ├── page.tsx # Datenbank mit Suche + Filter
+│ │ │ └── KennzeichenFilter.tsx
+│ │ ├── diplomatenkennzeichen/
+│ │ │ └── page.tsx
+│ │ ├── sammlung/
+│ │ │ └── page.tsx # Persönlicher Fortschritt
+│ │ └── blog/
+│ │ ├── page.tsx # Blog-Übersicht
+│ │ └── [slug]/
+│ │ └── page.tsx # Blog-Post
+│ ├── components/
+│ │ └── ui/
+│ │ └── Nav.tsx
+│ └── lib/
+│ └── pb.ts # PocketBase Client + Typen
+└── scripts/
+ ├── import.py
+ └── validate.py
+```
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ec470fd
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,20 @@
+services:
+ pocketbase:
+ build: ./docker/pocketbase
+ restart: unless-stopped
+ volumes:
+ - pb_data:/pb/pb_data
+ # ports:
+ #- "4444:8090"
+ nextjs:
+ build: ./frontend
+ restart: unless-stopped
+ ports:
+ - "8888:3000"
+ environment:
+ - NEXT_PUBLIC_PB_URL=https://plates.denode.eu/api
+ - PB_INTERNAL_URL=http://pocketbase:8090
+ depends_on:
+ - pocketbase
+volumes:
+ pb_data:
diff --git a/docker/pocketbase/Dockerfile b/docker/pocketbase/Dockerfile
new file mode 100644
index 0000000..577957b
--- /dev/null
+++ b/docker/pocketbase/Dockerfile
@@ -0,0 +1,15 @@
+FROM alpine:3.19
+
+ARG PB_VERSION=0.22.20
+
+RUN apk add --no-cache unzip ca-certificates wget
+
+RUN wget -q "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" \
+ -O /tmp/pb.zip && \
+ unzip /tmp/pb.zip -d /pb && \
+ rm /tmp/pb.zip
+
+WORKDIR /pb
+EXPOSE 8090
+
+CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..c560ed3
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,26 @@
+FROM node:20-alpine AS base
+
+FROM base AS deps
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm install
+
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN npm run build
+
+FROM base AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 nextjs
+RUN mkdir -p ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+USER nextjs
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+CMD ["node", "server.js"]
diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx
new file mode 100644
index 0000000..7f1ba9e
--- /dev/null
+++ b/frontend/app/blog/[slug]/page.tsx
@@ -0,0 +1,32 @@
+export const dynamic = 'force-dynamic';
+import { pb, type BlogPost } from "@/lib/pb";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+interface Props { params: { slug: string } }
+
+async function getPost(slug: string) {
+ try { return await pb.collection("blog_posts").getFirstListItem(`slug="${slug}"`); }
+ catch { return null; }
+}
+
+export default async function BlogPostPage({ params }: Props) {
+ const post = await getPost(params.slug);
+ if (!post) notFound();
+ const html = post.inhalt
+ .replace(/^### (.+)$/gm,"$1
").replace(/^## (.+)$/gm,"$1
").replace(/^# (.+)$/gm,"$1
")
+ .replace(/\*\*(.+?)\*\*/g,"$1").replace(/\*(.+?)\*/g,"$1")
+ .replace(/`(.+?)`/g,"$1").replace(/\[(.+?)\]\((.+?)\)/g,'$1')
+ .replace(/\n\n/g,"
").replace(/^/,"
").replace(/$/,"
");
+ return (
+
+
← Blog
+
+
+ {post.titel}
+ {post.tags?.length > 0 && {post.tags.map(tag => {tag})}
}
+
+
+
+ );
+}
diff --git a/frontend/app/blog/page.tsx b/frontend/app/blog/page.tsx
new file mode 100644
index 0000000..f4dd271
--- /dev/null
+++ b/frontend/app/blog/page.tsx
@@ -0,0 +1,41 @@
+export const dynamic = 'force-dynamic';
+import { pb, type BlogPost } from "@/lib/pb";
+import Link from "next/link";
+
+export const metadata = { title: "Blog" };
+
+async function getPosts() {
+ try { return pb.collection("blog_posts").getList(1, 20, { sort: "-created" }); }
+ catch { return null; }
+}
+
+export default async function BlogPage() {
+ const data = await getPosts();
+ const posts = data?.items ?? [];
+ return (
+
+
+
Notizen
+
Blog
+
Funde, Besonderheiten und Gedanken rund ums Sammeln.
+
+ {posts.length === 0 ? (
+
+
Noch keine Beiträge
+
Schreib den ersten Eintrag über den PocketBase Admin.
+
+ ) : (
+
+ {posts.map((post, i) => (
+
+
+ {post.titel}
+ {post.inhalt && {post.inhalt.replace(/[#*`\[\]]/g,"").slice(0,220)}…
}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/diplomatenkennzeichen/page.tsx b/frontend/app/diplomatenkennzeichen/page.tsx
new file mode 100644
index 0000000..4a6271e
--- /dev/null
+++ b/frontend/app/diplomatenkennzeichen/page.tsx
@@ -0,0 +1,158 @@
+export const dynamic = 'force-dynamic';
+import { pb, type Diplomatenkennzeichen } from "@/lib/pb";
+
+export const metadata = { title: "Diplomatenkennzeichen" };
+
+const COUNTRY_NAMES: Record = {
+ germany: "Deutschland", austria: "Österreich",
+ switzerland: "Schweiz", luxembourg: "Luxemburg",
+};
+const COUNTRY_FLAGS: Record = {
+ germany: "🇩🇪", austria: "🇦🇹", switzerland: "🇨🇭", luxembourg: "🇱🇺",
+};
+const TYPE_LABELS: Record = {
+ embassy: "Botschaft", consulate: "Konsulat", io: "Intern. Organisation",
+};
+const TYPE_COLORS: Record = {
+ embassy: "bg-blue-50 text-blue-800 border-blue-200",
+ consulate: "bg-amber-50 text-amber-800 border-amber-200",
+ io: "bg-emerald-50 text-emerald-800 border-emerald-200",
+};
+
+interface Props { searchParams: { land?: string } }
+
+async function getData(baseCountry: string) {
+ try {
+ // Ohne sort um 400 zu vermeiden, sortieren wir client-side
+ return pb.collection("diplomatenkennzeichen").getFullList({
+ filter: `base_country="${baseCountry}"`,
+ });
+ } catch { return []; }
+}
+
+async function getAvailableCountries(): Promise {
+ try {
+ const all = await pb.collection("diplomatenkennzeichen").getFullList({
+ fields: "base_country",
+ });
+ return [...new Set(all.map((i: any) => i.base_country).filter(Boolean))].sort() as string[];
+ } catch { return ["germany"]; }
+}
+
+function sortByCode(a: Diplomatenkennzeichen, b: Diplomatenkennzeichen) {
+ const parseCode = (c: string) => {
+ const m = c.match(/^(?:0-)?(\d+)$/);
+ return m ? parseInt(m[1]) : -1;
+ };
+ const na = parseCode(a.code);
+ const nb = parseCode(b.code);
+ if (na >= 0 && nb >= 0) return na - nb;
+ if (na >= 0) return -1;
+ if (nb >= 0) return 1;
+ return a.code.localeCompare(b.code);
+}
+
+export default async function DiplomatenPage({ searchParams }: Props) {
+ const countries = await getAvailableCountries();
+ const activeLand = searchParams.land ?? countries[0] ?? "germany";
+ const rawItems = await getData(activeLand);
+ const items = [...rawItems].sort(sortByCode);
+
+ const grouped = items.reduce>((acc, item) => {
+ const t = item.type ?? "embassy";
+ if (!acc[t]) acc[t] = [];
+ acc[t].push(item);
+ return acc;
+ }, {});
+
+ return (
+
+
+
Datenbank
+
Diplomatenkennzeichen
+
+ Sonderkennzeichen für Botschaften, Konsulate und internationale Organisationen.
+
+
+
+ {/* Länder-Tabs */}
+
+
+
+ {Object.entries(TYPE_LABELS).map(([key, label]) => (
+
+ {label}: {grouped[key]?.length ?? 0}
+
+ ))}
+ — {items.length} Einträge gesamt
+
+
+ {items.length === 0 ? (
+
Keine Daten für dieses Land.
+ ) : (
+ Object.entries(TYPE_LABELS).map(([type, typeLabel]) => {
+ const entries = grouped[type];
+ if (!entries?.length) return null;
+ return (
+
+
+ {typeLabel}
+ {entries.length} Einträge
+
+
+
+
+
+
+ | Kürzel |
+ Land / Organisation |
+ Stadt |
+ Bemerkung |
+
+
+
+ {entries.map((d) => (
+
+ | {d.code} |
+
+ {d.country}
+ {d.organisation && {d.organisation} }
+ |
+ {d.city} |
+ {d.note} |
+
+ ))}
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 0000000..e93fe1c
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,82 @@
+@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=swap');
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --ink: #111110;
+ --paper: #F7F7F5;
+ --warm: #E2E2DE;
+ --accent: #C8440A;
+ --muted: #6E6E6A;
+}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+
+body {
+ background: var(--paper);
+ color: var(--ink);
+ font-family: 'DM Sans', sans-serif;
+ font-size: 15px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Kennzeichen-Badge */
+.kz-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-family: 'JetBrains Mono', monospace;
+ font-weight: 500;
+ font-size: 13px;
+ padding: 3px 10px;
+ border: 1.5px solid var(--ink);
+ border-radius: 2px;
+ background: white;
+ letter-spacing: 0.05em;
+ transition: all 0.15s ease;
+ cursor: default;
+}
+.kz-badge:hover {
+ background: var(--ink);
+ color: var(--paper);
+}
+.kz-badge.seen {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+/* Tabellen */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+.data-table th {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--muted);
+ text-align: left;
+ padding: 10px 16px;
+ border-bottom: 1.5px solid var(--warm);
+}
+.data-table td {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--warm);
+ vertical-align: middle;
+}
+.data-table tr:hover td {
+ background: rgba(200,68,10,0.04);
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--warm); border-radius: 2px; }
+::-webkit-scrollbar-thumb:hover { background: var(--muted); }
diff --git a/frontend/app/kennzeichen/KennzeichenFilter.tsx b/frontend/app/kennzeichen/KennzeichenFilter.tsx
new file mode 100644
index 0000000..0b9e138
--- /dev/null
+++ b/frontend/app/kennzeichen/KennzeichenFilter.tsx
@@ -0,0 +1,99 @@
+"use client";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useState } from "react";
+
+interface Country {
+ key: string;
+ label: string;
+ flag: string;
+ count: number;
+}
+
+interface Props {
+ countries: Country[];
+ activeLand?: string;
+ activeQ?: string;
+ historisch?: boolean;
+}
+
+export default function KennzeichenFilter({ countries, activeLand, activeQ, historisch }: Props) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [q, setQ] = useState(activeQ ?? "");
+
+ const update = useCallback((updates: Record) => {
+ const params = new URLSearchParams(searchParams.toString());
+ for (const [k, v] of Object.entries(updates)) {
+ if (v) params.set(k, v);
+ else params.delete(k);
+ }
+ params.delete("seite");
+ router.push(`?${params.toString()}`);
+ }, [router, searchParams]);
+
+ return (
+
+ {/* Suchfeld */}
+
+ setQ(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && update({ q: q || undefined })}
+ placeholder="Kürzel, Ort oder Region suchen…"
+ className="w-full px-4 py-3 pr-24 border border-[var(--warm)] rounded-lg bg-white/60 text-sm focus:outline-none focus:border-[var(--ink)] transition-colors"
+ />
+
+
+
+ {/* Länder-Filter */}
+
+
+ {countries
+ .filter((c) => c.count > 0)
+ .sort((a, b) => b.count - a.count)
+ .map((c) => (
+
+ ))}
+
+
+ {/* Historisch-Toggle */}
+
+
+ );
+}
diff --git a/frontend/app/kennzeichen/page.tsx b/frontend/app/kennzeichen/page.tsx
new file mode 100644
index 0000000..3f6728e
--- /dev/null
+++ b/frontend/app/kennzeichen/page.tsx
@@ -0,0 +1,121 @@
+export const dynamic = 'force-dynamic';
+import { pb, COUNTRY_LABELS, COUNTRY_FLAGS, type Kennzeichen } from "@/lib/pb";
+import KennzeichenFilter from "./KennzeichenFilter";
+
+interface Props { searchParams: { land?: string; q?: string; seite?: string; historisch?: string } }
+export const metadata = { title: "Datenbank" };
+
+async function getData(land?: string, q?: string, page = 1, historisch = false) {
+ try {
+ const safe = (s: string) => s.replace(/["\\]/g, "");
+ const filters: string[] = [`country!="global"`];
+ if (land) filters.push(`country="${safe(land)}"`);
+ if (!historisch) filters.push(`active=true`);
+ if (q) { const s = safe(q); filters.push(`(code~"${s}" || name~"${s}" || region~"${s}")`); }
+ return pb.collection("kennzeichen").getList(page, 100, {
+ filter: filters.join(" && ") || undefined, sort: "code",
+ });
+ } catch { return null; }
+}
+
+async function getCountryCounts() {
+ try {
+ const all = await pb.collection("kennzeichen").getFullList({ fields: "country", filter: 'active=true && country!="global"' });
+ const counts: Record = {};
+ for (const k of all) counts[k.country] = (counts[k.country] ?? 0) + 1;
+ return counts;
+ } catch { return {}; }
+}
+
+function Pagination({ page, totalPages, land, q }: { page: number; totalPages: number; land?: string; q?: string }) {
+ if (totalPages <= 1) return null;
+ const makeHref = (p: number) => {
+ const params = new URLSearchParams();
+ if (land) params.set("land", land);
+ if (q) params.set("q", q);
+ params.set("seite", String(p));
+ return `?${params.toString()}`;
+ };
+ const pages: (number | string)[] = [];
+ for (let p = 1; p <= totalPages; p++) {
+ if (p === 1 || p === totalPages || Math.abs(p - page) <= 2) {
+ pages.push(p);
+ } else if (pages[pages.length - 1] !== "…") {
+ pages.push("…");
+ }
+ }
+ return (
+
+ {page > 1 && (
+
←
+ )}
+ {pages.map((p, i) =>
+ p === "…" ? (
+
…
+ ) : (
+
+ {p}
+
+ )
+ )}
+ {page < totalPages && (
+
→
+ )}
+
+ );
+}
+
+export default async function KennzeichenPage({ searchParams }: Props) {
+ const land = searchParams.land;
+ const q = searchParams.q;
+ const page = parseInt(searchParams.seite ?? "1");
+ const historisch = searchParams.historisch === "1";
+ const [data, counts] = await Promise.all([getData(land, q, page, historisch), getCountryCounts()]);
+ const items = data?.items ?? [];
+
+ return (
+
+
+
Datenbank
+
Kennzeichen
+ {data &&
{data.totalItems.toLocaleString("de")} Einträge
}
+
+
({ key, label, flag: COUNTRY_FLAGS[key] ?? "🌍", count: counts[key] ?? 0 }))}
+ activeLand={land} activeQ={q} historisch={historisch}
+ />
+
+ {items.length === 0 ? (
+
Keine Kennzeichen gefunden.
+ ) : (
+
+
+
+
+ | Kürzel | Ort / Kreis | Herleitung |
+ Region |
+ Punkte |
+ Bemerkung |
+
+
+
+ {items.map((kz) => (
+
+ | {kz.code} |
+ {kz.name} |
+ {kz.derivation} |
+ {kz.region} |
+ {kz.points ?? "—"} |
+ {kz.note} |
+
+ ))}
+
+
+
+ )}
+
+ {data && }
+
+ );
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..b74c17c
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+import "./globals.css";
+import Nav from "@/components/ui/Nav";
+
+export const metadata: Metadata = {
+ title: { default: "Kennzeichensammler", template: "%s | Kennzeichensammler" },
+ description: "Persönliche Kennzeichen-Datenbank und Blog",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..754267a
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,132 @@
+export const dynamic = 'force-dynamic';
+import Link from "next/link";
+import { pb, COUNTRY_LABELS, COUNTRY_FLAGS } from "@/lib/pb";
+
+async function getStats() {
+ try {
+ const [kz, diplo, gesehen] = await Promise.all([
+ pb.collection("kennzeichen").getList(1, 1, { filter: "active=true" }),
+ pb.collection("diplomatenkennzeichen").getList(1, 1),
+ pb.collection("gesehen").getList(1, 1),
+ ]);
+ return {
+ kennzeichen: kz.totalItems,
+ diplomaten: diplo.totalItems,
+ gesehen: gesehen.totalItems,
+ };
+ } catch {
+ return { kennzeichen: 0, diplomaten: 0, gesehen: 0 };
+ }
+}
+
+async function getLasteSeen() {
+ try {
+ return pb.collection("gesehen").getList(1, 5, { sort: "-datum" });
+ } catch {
+ return null;
+ }
+}
+
+export default async function Home() {
+ const [stats, latest] = await Promise.all([getStats(), getLasteSeen()]);
+
+ const countries = Object.entries(COUNTRY_LABELS).slice(0, 12);
+
+ return (
+
+
+ {/* Hero */}
+
+
+ Open Data · Eigensammlung
+
+
+ Kennzeichen.
Gesammelt, dokumentiert,
+ geteilt.
+
+
+ Persönliche Datenbank europäischer Kfz-Kennzeichen — mit Sonderformen,
+ Diplomatenkennzeichen und allem was sich nicht einfach einordnen lässt.
+
+
+
+ Datenbank →
+
+
+ Blog lesen
+
+
+
+
+ {/* Stats */}
+
+ {[
+ { label: "Kennzeichen", value: stats.kennzeichen.toLocaleString("de") },
+ { label: "Diplomatenkz.", value: stats.diplomaten.toLocaleString("de") },
+ { label: "Gesehen", value: stats.gesehen.toLocaleString("de") },
+ ].map((s) => (
+
+
+ {s.value}
+
+
{s.label}
+
+ ))}
+
+
+ {/* Länder */}
+
+
+ Länder in der Datenbank
+
+
+ {countries.map(([key, label]) => (
+
+ {COUNTRY_FLAGS[key] ?? "🌍"}
+ {label}
+
+ ))}
+
+ Alle anzeigen →
+
+
+
+
+ {/* Zuletzt gesehen */}
+ {latest && latest.items.length > 0 && (
+
+
+ Zuletzt gesehen
+
+
+ {latest.items.map((item: any) => (
+
+ {item.kennzeichen_code &&
{item.kennzeichen_code}}
+
+ {item.kennzeichen_name || "—"}
+ {COUNTRY_FLAGS[item.land] ?? ""}
+
+
+ {new Date(item.datum).toLocaleDateString("de-DE")}
+
+
+ ))}
+
+
+ Gesamte Sammlung →
+
+
+ )}
+
+ );
+}
diff --git a/frontend/app/sammlung/page.tsx b/frontend/app/sammlung/page.tsx
new file mode 100644
index 0000000..64681d4
--- /dev/null
+++ b/frontend/app/sammlung/page.tsx
@@ -0,0 +1,107 @@
+export const dynamic = 'force-dynamic';
+import { pb, COUNTRY_LABELS, COUNTRY_FLAGS } from "@/lib/pb";
+
+export const metadata = { title: "Meine Sammlung" };
+
+async function getStats() {
+ try {
+ const [gesehen, total] = await Promise.all([
+ pb.collection("gesehen").getFullList({ fields: "land,datum" }),
+ pb.collection("kennzeichen").getList(1, 1, { filter: "active=true" }),
+ ]);
+ const byCountry: Record = {};
+ const byMonth: Record = {};
+ for (const g of gesehen as any[]) {
+ byCountry[g.land] = (byCountry[g.land] ?? 0) + 1;
+ const month = g.datum?.slice(0,7);
+ if (month) byMonth[month] = (byMonth[month] ?? 0) + 1;
+ }
+ return { total: gesehen.length, totalKz: total.totalItems, byCountry, byMonth };
+ } catch { return { total: 0, totalKz: 0, byCountry: {}, byMonth: {} }; }
+}
+
+async function getLatest() {
+ try {
+ return pb.collection("gesehen").getList(1, 20, { sort: "-datum" });
+ } catch { return null; }
+}
+
+export default async function SammlungPage() {
+ const [stats, latest] = await Promise.all([getStats(), getLatest()]);
+ const percent = stats.totalKz > 0 ? Math.round((stats.total / stats.totalKz) * 100) : 0;
+ const topCountries = Object.entries(stats.byCountry).sort(([,a],[,b]) => b-a).slice(0,8);
+ const recentMonths = Object.entries(stats.byMonth).sort(([a],[b]) => b.localeCompare(a)).slice(0,6);
+
+ return (
+
+
+
Persönlich
+
Meine Sammlung
+
+
+
+
+
{stats.total.toLocaleString("de")}
+
von {stats.totalKz.toLocaleString("de")} Kennzeichen gesehen
+
+
{percent}%
+
+
+
+
+
+
Nach Land
+
+ {topCountries.map(([country, count]) => (
+
+ {COUNTRY_FLAGS[country] ?? "🌍"}
+ {COUNTRY_LABELS[country] ?? country}
+ {count}
+
+ ))}
+
+
+
+
Aktivität
+
+ {recentMonths.map(([month, count]) => {
+ const max = Math.max(...recentMonths.map(([,c]) => c));
+ return (
+
+
{new Date(month+"-01").toLocaleDateString("de-DE",{month:"short",year:"2-digit"})}
+
+
{count}
+
+ );
+ })}
+
+
+
+ {latest && latest.items.length > 0 && (
+
+
+
Zuletzt gesehen
+
+
+ {(latest.items as any[]).map((item) => (
+
+
{item.kennzeichen_code}
+
+ {item.kennzeichen_name}
+ {COUNTRY_FLAGS[item.land] ?? ""}
+
+
+ {new Date(item.datum).toLocaleDateString("de-DE")}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/ui/Nav.tsx b/frontend/components/ui/Nav.tsx
new file mode 100644
index 0000000..6818c22
--- /dev/null
+++ b/frontend/components/ui/Nav.tsx
@@ -0,0 +1,47 @@
+"use client";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import clsx from "clsx";
+
+const links = [
+ { href: "/", label: "Start" },
+ { href: "/kennzeichen", label: "Datenbank" },
+ { href: "/diplomatenkennzeichen",label: "Diplomaten" },
+ { href: "/sammlung", label: "Sammlung" },
+ { href: "/blog", label: "Blog" },
+];
+
+export default function Nav() {
+ const path = usePathname();
+
+ return (
+
+ );
+}
diff --git a/frontend/lib/pb.ts b/frontend/lib/pb.ts
new file mode 100644
index 0000000..1df49b0
--- /dev/null
+++ b/frontend/lib/pb.ts
@@ -0,0 +1,150 @@
+import PocketBase from "pocketbase";
+// ── Singleton Client (Server + Client) ────────────────────────────────────────
+const PB_URL =
+ typeof window === "undefined"
+ ? (process.env.PB_INTERNAL_URL ?? "http://pocketbase:8090")
+ : (process.env.NEXT_PUBLIC_PB_URL ?? "http://localhost:8090");
+
+export const pb = new PocketBase(PB_URL);
+
+pb.autoCancellation(false);
+// ── Typen ─────────────────────────────────────────────────────────────────────
+
+export interface Kennzeichen {
+ id: string;
+ code: string;
+ name: string;
+ derivation: string;
+ region: string;
+ note: string;
+ active: boolean;
+ country: string;
+ lat: number | null;
+ lon: number | null;
+ population: number | null;
+ points: number | null;
+ alt_code: string;
+}
+
+export interface Diplomatenkennzeichen {
+ id: string;
+ code: string;
+ country: string;
+ organisation: string;
+ type: "embassy" | "consulate" | "io";
+ city: string;
+ note: string;
+ base_country: string;
+}
+
+export interface BlogPost {
+ id: string;
+ slug: string;
+ titel: string;
+ inhalt: string;
+ date: string;
+ tags: string[];
+}
+
+export interface Gesehen {
+ id: string;
+ kennzeichen_code: string;
+ kennzeichen_name: string;
+ land: string;
+ datum: string;
+}
+
+// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
+
+export const COUNTRY_LABELS: Record = {
+ germany: "Deutschland",
+ austria: "Österreich",
+ switzerland: "Schweiz",
+ luxembourg: "Luxemburg",
+ poland: "Polen",
+ norway: "Norwegen",
+ uk: "Vereinigtes Königreich",
+ italy: "Italien",
+ france: "Frankreich",
+ greece: "Griechenland",
+ slovakia: "Slowakei",
+ croatia: "Kroatien",
+ ukraine: "Ukraine",
+ serbia: "Serbien",
+ russia: "Russland",
+ belarus: "Belarus",
+ montenegro: "Montenegro",
+ northmacedonia: "Nordmazedonien",
+ ireland: "Irland",
+ bulgaria: "Bulgarien",
+ romania: "Rumänien",
+ moldova: "Moldawien",
+ czechia: "Tschechien",
+ turkey: "Türkei",
+ kosovo: "Kosovo",
+ slovenia: "Slowenien",
+};
+
+export const COUNTRY_FLAGS: Record = {
+ germany: "🇩🇪", austria: "🇦🇹", switzerland: "🇨🇭", luxembourg: "🇱🇺",
+ poland: "🇵🇱", norway: "🇳🇴", uk: "🇬🇧", italy: "🇮🇹",
+ france: "🇫🇷", greece: "🇬🇷", slovakia: "🇸🇰", croatia: "🇭🇷",
+ ukraine: "🇺🇦", serbia: "🇷🇸", russia: "🇷🇺", belarus: "🇧🇾",
+ montenegro: "🇲🇪", northmacedonia: "🇲🇰", ireland: "🇮🇪",
+ bulgaria: "🇧🇬", romania: "🇷🇴", moldova: "🇲🇩", czechia: "🇨🇿",
+ turkey: "🇹🇷", kosovo: "🇽🇰", slovenia: "🇸🇮",
+};
+
+export async function getKennzeichen(opts?: {
+ country?: string;
+ search?: string;
+ page?: number;
+ perPage?: number;
+ activeOnly?: boolean;
+}) {
+ const {
+ country,
+ search,
+ page = 1,
+ perPage = 50,
+ activeOnly = true,
+ } = opts ?? {};
+
+ const filters: string[] = [];
+ if (country) filters.push(`country="${country}"`);
+ if (activeOnly) filters.push(`active=true`);
+ if (search) {
+ const s = search.replace(/["\\]/g, "");
+ filters.push(`(code~"${s}" || name~"${s}" || region~"${s}")`);
+ }
+
+ return pb.collection("kennzeichen").getList(page, perPage, {
+ filter: filters.join(" && ") || undefined,
+ sort: "country,code",
+ });
+}
+
+export async function getKennzeichenByCode(code: string) {
+ return pb.collection("kennzeichen").getFirstListItem(
+ `code="${code.toUpperCase()}"`,
+ );
+}
+
+export async function getDiplomatenkennzeichen(baseCountry = "germany") {
+ return pb.collection("diplomatenkennzeichen").getFullList({
+ filter: `base_country="${baseCountry}"`,
+ sort: "code",
+ });
+}
+
+export async function getBlogPosts() {
+ return pb.collection("blog_posts").getList(1, 20, {
+ sort: "-created",
+ });
+}
+
+export async function getGesehen() {
+ return pb.collection("gesehen").getFullList({
+ sort: "-datum",
+ });
+}
diff --git a/frontend/next.config.js b/frontend/next.config.js
new file mode 100644
index 0000000..89b2c4c
--- /dev/null
+++ b/frontend/next.config.js
@@ -0,0 +1,11 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: "standalone",
+ images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "**" },
+ ],
+ },
+};
+
+module.exports = nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..a4b037a
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "kennzeichen-website",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "^14.2.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "pocketbase": "^0.21.0",
+ "leaflet": "^1.9.4",
+ "react-leaflet": "^4.2.1",
+ "@tailwindcss/typography": "^0.5.13",
+ "lucide-react": "^0.383.0",
+ "clsx": "^2.1.1",
+ "fuse.js": "^7.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "@types/leaflet": "^1.9.12",
+ "typescript": "^5",
+ "tailwindcss": "^3.4.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..00e697e
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,32 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ extend: {
+ fontFamily: {
+ display: ["'Syne'", "sans-serif"],
+ body: ["'DM Sans'", "sans-serif"],
+ mono: ["'JetBrains Mono'", "monospace"],
+ },
+ colors: {
+ ink: "#0F0F0E",
+ paper: "#F5F2EB",
+ warm: "#E8E2D5",
+ accent: "#C8440A",
+ muted: "#7A7570",
+ },
+ borderRadius: {
+ sm: "3px",
+ md: "6px",
+ lg: "12px",
+ },
+ },
+ },
+ plugins: [require("@tailwindcss/typography")],
+};
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..6420eed
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/scripts/backup_pb.sh b/scripts/backup_pb.sh
new file mode 100755
index 0000000..2c90d7e
--- /dev/null
+++ b/scripts/backup_pb.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# backup_pb.sh — PocketBase-Daten sichern (als Cron-Job)
+#
+# Speichert pb_data als .tar.gz in ~/kennzeichen/backups/
+# Behält die letzten 14 Tage.
+#
+# Cron einrichten (täglich 03:00):
+# crontab -e
+# 0 3 * * * bash /home/denode/kennzeichen/scripts/backup_pb.sh >> /var/log/kennzeichen-backup.log 2>&1
+
+set -e
+
+BACKUP_DIR="$HOME/kennzeichen/backups"
+CONTAINER="kennzeichen-pocketbase-1"
+DATE=$(date '+%Y-%m-%d')
+OUT="$BACKUP_DIR/pb_data_$DATE.tar.gz"
+
+mkdir -p "$BACKUP_DIR"
+
+# pb_data aus Container holen und packen
+echo "$(date '+%Y-%m-%d %H:%M') Backup → $OUT"
+docker exec "$CONTAINER" tar -czf - /pb/pb_data 2>/dev/null > "$OUT"
+echo " Größe: $(du -sh "$OUT" | cut -f1)"
+
+# Alte Backups löschen (älter als 14 Tage)
+find "$BACKUP_DIR" -name "pb_data_*.tar.gz" -mtime +14 -delete
+echo " Vorhandene Backups: $(ls "$BACKUP_DIR"/pb_data_*.tar.gz 2>/dev/null | wc -l)"
diff --git a/scripts/cleanup.py b/scripts/cleanup.py
new file mode 100644
index 0000000..d8cf97a
--- /dev/null
+++ b/scripts/cleanup.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""
+cleanup.py — Bereinigt Datenduplikate in PocketBase
+
+1. Diplomatenkennzeichen: 0120 → 0-120 normalisieren, Duplikate löschen
+2. Kennzeichen: Duplikate (gleicher code + country) zusammenführen und löschen
+3. Blog: datum-Feld von Text auf date migrieren (falls nötig)
+
+Verwendung:
+ python3 cleanup.py --pb-url http://localhost:4444 \
+ --pb-email admin@example.com \
+ --pb-password geheim
+"""
+
+import argparse, re, requests, sys
+from collections import defaultdict
+
+class PB:
+ def __init__(self, url):
+ self.url = url.rstrip("/")
+ self.token = None
+
+ def login(self, email, password):
+ r = requests.post(f"{self.url}/api/admins/auth-with-password",
+ json={"identity": email, "password": password})
+ r.raise_for_status()
+ self.token = r.json()["token"]
+ print(f"✓ Eingeloggt als {email}")
+
+ def h(self):
+ return {"Authorization": self.token, "Content-Type": "application/json"}
+
+ def get_all(self, collection, filter_str=None, fields=None):
+ params = {"perPage": 500, "page": 1}
+ if filter_str:
+ params["filter"] = filter_str
+ if fields:
+ params["fields"] = fields
+ results = []
+ while True:
+ r = requests.get(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), params=params)
+ r.raise_for_status()
+ data = r.json()
+ results.extend(data["items"])
+ if params["page"] >= data["totalPages"]:
+ break
+ params["page"] += 1
+ return results
+
+ def patch(self, collection, record_id, data):
+ r = requests.patch(
+ f"{self.url}/api/collections/{collection}/records/{record_id}",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" PATCH-Fehler {record_id}: {r.text[:100]}")
+ return r
+
+ def delete(self, collection, record_id):
+ r = requests.delete(
+ f"{self.url}/api/collections/{collection}/records/{record_id}",
+ headers=self.h())
+ if r.status_code not in (200, 204):
+ print(f" DELETE-Fehler {record_id}: {r.text[:100]}")
+ return r
+
+ def collection_schema(self, name):
+ r = requests.get(f"{self.url}/api/collections/{name}", headers=self.h())
+ r.raise_for_status()
+ return r.json()
+
+ def update_collection_schema(self, coll_id, schema_update):
+ r = requests.patch(
+ f"{self.url}/api/collections/{coll_id}",
+ headers=self.h(), json=schema_update)
+ if r.status_code not in (200, 204):
+ print(f" Schema-Update-Fehler: {r.text[:200]}")
+ return r
+
+
+def normalize_diplo_code(code: str) -> str:
+ m = re.match(r'^0(\d+)$', code.strip())
+ return f"0-{m.group(1)}" if m else code.strip()
+
+
+def fix_diplo_duplicates(pb: PB, dry_run: bool):
+ print("\n→ Diplomatenkennzeichen bereinigen...")
+ all_records = pb.get_all("diplomatenkennzeichen")
+ print(f" {len(all_records)} Records geladen")
+
+ # Records ohne Bindestrich die nach 0-NNN normalisiert werden müssen
+ needs_norm = [r for r in all_records if re.match(r'^0\d+$', r.get("code", ""))]
+ print(f" {len(needs_norm)} Records ohne Bindestrich gefunden (z.B. 0120)")
+
+ # Index: (normalisierter_code, base_country) → record
+ index: dict[tuple, dict] = {}
+ for r in all_records:
+ code = r.get("code", "")
+ norm = normalize_diplo_code(code)
+ key = (norm, r.get("base_country", ""))
+ if key not in index:
+ index[key] = r
+
+ updated = deleted = 0
+
+ for record in needs_norm:
+ code = record["code"]
+ norm = normalize_diplo_code(code)
+ key = (norm, record.get("base_country", ""))
+
+ # Gibt es schon einen normalisierten Record?
+ canonical = index.get(key)
+ if canonical and canonical["id"] != record["id"]:
+ # Duplikat — löschen
+ print(f" DEL Duplikat: {code} → {norm} (id={record['id']}, behalte {canonical['id']})")
+ if not dry_run:
+ pb.delete("diplomatenkennzeichen", record["id"])
+ deleted += 1
+ else:
+ # Kein Duplikat — nur Code anpassen
+ print(f" UPD: {code} → {norm} (id={record['id']})")
+ if not dry_run:
+ pb.patch("diplomatenkennzeichen", record["id"], {"code": norm})
+ # Index aktualisieren
+ index[key] = record
+ updated += 1
+
+ print(f" ✓ {updated} normalisiert, {deleted} Duplikate gelöscht"
+ + (" (DRY RUN)" if dry_run else ""))
+
+
+def fix_kennzeichen_duplicates(pb: PB, dry_run: bool):
+ print("\n→ Kennzeichen-Duplikate bereinigen...")
+ all_records = pb.get_all("kennzeichen", fields="id,code,country,lat,lon,app_id,population,points")
+ print(f" {len(all_records)} Records geladen")
+
+ # Gruppieren nach (code, country)
+ groups: dict[tuple, list] = defaultdict(list)
+ for r in all_records:
+ key = (r.get("code", "").strip().upper(), r.get("country", "").strip())
+ groups[key].append(r)
+
+ dupes = {k: v for k, v in groups.items() if len(v) > 1}
+ print(f" {len(dupes)} Gruppen mit Duplikaten")
+
+ total_deleted = 0
+ for (code, country), records in dupes.items():
+ # "Bestes" Record: bevorzuge das mit lat/lon, dann das mit mehr Feldern
+ def score(r):
+ s = 0
+ if r.get("lat"): s += 10
+ if r.get("lon"): s += 10
+ if r.get("app_id"): s += 5
+ if r.get("population"): s += 3
+ if r.get("points"): s += 2
+ return s
+
+ records_sorted = sorted(records, key=score, reverse=True)
+ keep = records_sorted[0]
+ to_delete = records_sorted[1:]
+
+ print(f" DUPL [{country}] {code}: behalte {keep['id']}, lösche {[r['id'] for r in to_delete]}")
+
+ for r in to_delete:
+ if not dry_run:
+ pb.delete("kennzeichen", r["id"])
+ total_deleted += 1
+
+ print(f" ✓ {total_deleted} Duplikate gelöscht" + (" (DRY RUN)" if dry_run else ""))
+
+
+def fix_blog_datum(pb: PB, dry_run: bool):
+ print("\n→ Blog datum-Feld prüfen...")
+ try:
+ coll = pb.collection_schema("blog_posts")
+ except Exception as e:
+ print(f" Fehler beim Laden des Schemas: {e}")
+ return
+
+ schema = coll.get("schema", [])
+ datum_field = next((f for f in schema if f["name"] == "datum"), None)
+
+ if not datum_field:
+ print(" datum-Feld nicht gefunden")
+ return
+
+ print(f" datum-Feld Typ: {datum_field['type']}")
+
+ if datum_field["type"] == "date":
+ print(" ✓ Kein Fix nötig — datum ist bereits vom Typ date")
+ return
+
+ print(" datum ist Text — muss auf date geändert werden")
+ # Alle Posts laden und Datum prüfen
+ posts = pb.get_all("blog_posts", fields="id,datum")
+ print(f" {len(posts)} Blog-Posts gefunden")
+
+ if dry_run:
+ print(" (DRY RUN) — würde Schema auf date ändern")
+ return
+
+ # Schema aktualisieren
+ new_schema = []
+ for f in schema:
+ if f["name"] == "datum":
+ new_schema.append({**f, "type": "date"})
+ else:
+ new_schema.append(f)
+
+ r = pb.update_collection_schema(coll["id"], {"schema": new_schema})
+ if r.status_code in (200, 204):
+ print(" ✓ datum auf date geändert")
+ else:
+ print(f" Fehler: {r.text[:200]}")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--pb-url", default="http://localhost:4444")
+ parser.add_argument("--pb-email", default=os.environ.get("PB_EMAIL"), required=not os.environ.get("PB_EMAIL"))
+ parser.add_argument("--pb-password", default=os.environ.get("PB_PASSWORD"), required=not os.environ.get("PB_PASSWORD"))
+ parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, nicht ändern")
+ parser.add_argument("--only", choices=["diplo", "kennzeichen", "blog"],
+ help="Nur einen bestimmten Fix ausführen")
+ args = parser.parse_args()
+
+ if args.dry_run:
+ print("*** DRY RUN — keine Änderungen werden geschrieben ***")
+
+ pb_client = PB(args.pb_url)
+ pb_client.login(args.pb_email, args.pb_password)
+
+ if args.only in (None, "diplo"):
+ fix_diplo_duplicates(pb_client, args.dry_run)
+
+ if args.only in (None, "kennzeichen"):
+ fix_kennzeichen_duplicates(pb_client, args.dry_run)
+
+ if args.only in (None, "blog"):
+ fix_blog_datum(pb_client, args.dry_run)
+
+ print("\n✓ Fertig!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/fix_schema.py b/scripts/fix_schema.py
new file mode 100644
index 0000000..71c6ffd
--- /dev/null
+++ b/scripts/fix_schema.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""
+fix_schema.py — Behebt Schema- und Datenprobleme in PocketBase
+
+Führt aus:
+ 1. Blog blog_posts: Feld 'date' (text) → 'datum' (date) umbenennen/migrieren
+ 2. Diplomatenkennzeichen: 'base_country'-Feld hinzufügen, alle Records löschen,
+ aus CSVs neu importieren (mit korrekten Spaltennamen)
+
+Verwendung:
+ python3 fix_schema.py \
+ --pb-url http://localhost:4444 \
+ --pb-email admin@denode.eu \
+ --pb-password geheim \
+ --repo-path ~/plates
+"""
+
+import argparse, csv, os, re, requests, json
+
+
+class PB:
+ def __init__(self, url):
+ self.url = url.rstrip("/")
+ self.token = None
+
+ def login(self, email, password):
+ r = requests.post(f"{self.url}/api/admins/auth-with-password",
+ json={"identity": email, "password": password})
+ r.raise_for_status()
+ self.token = r.json()["token"]
+ print(f"✓ Eingeloggt als {email}")
+
+ def h(self): return {"Authorization": self.token, "Content-Type": "application/json"}
+
+ def collection(self, name):
+ r = requests.get(f"{self.url}/api/collections/{name}", headers=self.h())
+ r.raise_for_status()
+ return r.json()
+
+ def update_collection(self, coll_id, data):
+ r = requests.patch(f"{self.url}/api/collections/{coll_id}",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ raise RuntimeError(f"Collection update failed: {r.text[:300]}")
+ return r.json()
+
+ def get_all(self, collection, fields=None):
+ params = {"perPage": 500, "page": 1}
+ if fields: params["fields"] = fields
+ results = []
+ while True:
+ r = requests.get(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), params=params)
+ r.raise_for_status()
+ data = r.json()
+ results.extend(data["items"])
+ if params["page"] >= data["totalPages"]: break
+ params["page"] += 1
+ return results
+
+ def delete(self, collection, record_id):
+ r = requests.delete(
+ f"{self.url}/api/collections/{collection}/records/{record_id}",
+ headers=self.h())
+ if r.status_code not in (200, 204):
+ print(f" DELETE-Fehler {record_id}: {r.text[:80]}")
+
+ def create(self, collection, data):
+ r = requests.post(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" CREATE-Fehler: {r.text[:100]}")
+ return r
+
+ def find_one(self, collection, filter_str):
+ r = requests.get(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), params={"filter": filter_str, "perPage": 1})
+ if r.status_code != 200: return None
+ items = r.json().get("items", [])
+ return items[0] if items else None
+
+ def patch(self, collection, record_id, data):
+ r = requests.patch(
+ f"{self.url}/api/collections/{collection}/records/{record_id}",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" PATCH-Fehler {record_id}: {r.text[:100]}")
+
+
+def fix_blog_schema(pb: PB):
+ """Benennt Blog-Feld 'date' (text) in 'datum' (date type) um."""
+ print("\n→ Blog-Schema korrigieren (date text → datum date)...")
+ coll = pb.collection("blog_posts")
+ schema = coll.get("schema", [])
+
+ date_field = next((f for f in schema if f["name"] == "date"), None)
+ datum_field = next((f for f in schema if f["name"] == "datum"), None)
+
+ if datum_field and datum_field["type"] == "date":
+ print(" ✓ Kein Fix nötig — 'datum' (date) existiert bereits")
+ return
+
+ if not date_field:
+ print(" Weder 'date' noch 'datum' gefunden — füge 'datum' (date) hinzu")
+ new_schema = schema + [{"name": "datum", "type": "date", "required": False}]
+ pb.update_collection(coll["id"], {"schema": new_schema})
+ print(" ✓ 'datum' hinzugefügt")
+ return
+
+ print(f" Feld 'date' ({date_field['type']}) gefunden — migriere zu 'datum' (date)")
+
+ # 1. Alle Posts laden und date-Werte sichern
+ posts = pb.get_all("blog_posts", fields="id,date")
+ print(f" {len(posts)} Blog-Posts geladen")
+
+ # 2. Neues Feld 'datum' (date) hinzufügen, 'date' behalten
+ new_schema = []
+ for f in schema:
+ new_schema.append(f)
+ new_schema.append({"name": "datum", "type": "date", "required": False})
+ pb.update_collection(coll["id"], {"schema": new_schema})
+ print(" ✓ 'datum' (date) hinzugefügt")
+
+ # 3. Werte von 'date' nach 'datum' kopieren
+ migrated = 0
+ for post in posts:
+ date_val = post.get("date", "")
+ if date_val:
+ pb.patch("blog_posts", post["id"], {"datum": date_val})
+ migrated += 1
+
+ print(f" ✓ {migrated} Datumsfelder migriert")
+
+ # 4. 'date' Feld entfernen
+ coll2 = pb.collection("blog_posts")
+ new_schema2 = [f for f in coll2.get("schema", []) if f["name"] != "date"]
+ pb.update_collection(coll["id"], {"schema": new_schema2})
+ print(" ✓ altes 'date' Feld entfernt")
+
+
+def normalize_diplo_code(code: str) -> str:
+ """Normalisiert 0120 → 0-120."""
+ m = re.match(r'^0(\d+)$', code.strip())
+ return f"0-{m.group(1)}" if m else code.strip()
+
+
+def _parse_diplo_row(row: dict, folder: str) -> dict | None:
+ """Parst eine CSV-Zeile in ein Diplo-Record-Dict. Gibt None zurück wenn kein Code."""
+ code = (
+ row.get("code") or row.get("Nummer") or row.get("Nummernkreis") or
+ row.get("Kennzeichen") or row.get("Unterscheidungszeichen") or ""
+ ).strip()
+ if not code:
+ return None
+
+ typ = (row.get("type") or row.get("Typ") or row.get("typ") or "embassy").strip().lower()
+ if typ not in ("embassy", "consulate", "io"):
+ typ = "embassy"
+
+ return {
+ "code": normalize_diplo_code(code),
+ "country": (row.get("country") or row.get("Land/Organisation") or
+ row.get("Land") or row.get("Entsendestaat") or "").strip(),
+ "organisation": (row.get("organisation") or row.get("Organisation") or "").strip(),
+ "type": typ,
+ "city": (row.get("city") or row.get("Stadt") or row.get("Sitz") or "").strip(),
+ "note": (row.get("note") or row.get("Bemerkung") or
+ row.get("Beschreibung") or row.get("Bedeutung") or "").strip(),
+ "base_country": folder,
+ }
+
+
+def sync_diplo(pb: PB, repo_path: str):
+ """Upsert-Sync: neue/geänderte Diplomatic-Records aus CSVs in PocketBase."""
+ print("\n→ Diplomatenkennzeichen synchronisieren...")
+
+ # Schema-Check: base_country vorhanden?
+ coll = pb.collection("diplomatenkennzeichen")
+ if "base_country" not in [f["name"] for f in coll.get("schema", [])]:
+ new_schema = coll["schema"] + [{"name": "base_country", "type": "text", "required": False}]
+ pb.update_collection(coll["id"], {"schema": new_schema})
+ print(" ✓ 'base_country' zum Schema hinzugefügt")
+
+ created = updated = skipped = 0
+
+ for folder in sorted(os.listdir(repo_path)):
+ csv_path = os.path.join(repo_path, folder, "diplomatenkennzeichen.csv")
+ if not os.path.exists(csv_path):
+ continue
+
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ for row in csv.DictReader(f):
+ record = _parse_diplo_row(row, folder)
+ if not record:
+ continue
+
+ existing = pb.find_one(
+ "diplomatenkennzeichen",
+ f'code="{record["code"]}" && base_country="{folder}"',
+ )
+ if existing:
+ pb.patch("diplomatenkennzeichen", existing["id"], record)
+ updated += 1
+ else:
+ r = pb.create("diplomatenkennzeichen", record)
+ if r.status_code in (200, 204):
+ created += 1
+ else:
+ skipped += 1
+
+ print(f" {folder}: fertig")
+
+ print(f" ✓ {created} neu, {updated} aktualisiert, {skipped} Fehler")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--pb-url", default=os.environ.get("PB_URL", "http://localhost:4444"))
+ parser.add_argument("--pb-email", default=os.environ.get("PB_EMAIL"), required=not os.environ.get("PB_EMAIL"))
+ parser.add_argument("--pb-password", default=os.environ.get("PB_PASSWORD"), required=not os.environ.get("PB_PASSWORD"))
+ parser.add_argument("--repo-path", required=True)
+ parser.add_argument("--only", choices=["blog", "diplo"])
+ args = parser.parse_args()
+
+ pb = PB(args.pb_url)
+ pb.login(args.pb_email, args.pb_password)
+
+ if args.only in (None, "blog"):
+ fix_blog_schema(pb)
+
+ if args.only in (None, "diplo"):
+ sync_diplo(pb, os.path.expanduser(args.repo_path))
+
+ print("\n✓ Fertig!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/import.py b/scripts/import.py
new file mode 100644
index 0000000..d53622e
--- /dev/null
+++ b/scripts/import.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+"""
+import.py — Kennzeichen-Daten aus Git-Repo + data.db nach PocketBase importieren
+
+Voraussetzungen:
+ pip install requests
+
+Verwendung:
+ # Erst PocketBase Admin-Account anlegen unter /admin
+ python3 import.py \
+ --pb-url http://localhost:8090 \
+ --pb-email admin@example.com \
+ --pb-password deinpasswort \
+ --repo-path /pfad/zum/plates-repo \
+ --db-path /pfad/zur/data.db
+"""
+
+import argparse
+import csv
+import os
+import sqlite3
+import sys
+import requests
+
+# ── Länder-Mapping: Ordnername im Repo → Tabellenname in data.db ──────────────
+COUNTRY_MAP = {
+ "germany": "deutschland",
+ "austria": "austria",
+ "switzerland": "schweiz",
+ "luxembourg": "luxemburg",
+ "poland": "polen",
+ "norway": "norwegen",
+ "uk": "uk",
+ "italy": "italien",
+ "france": "frankreich",
+ "greece": "greece",
+ "slovakia": "slowakei",
+ "croatia": "kroatien",
+ "ukraine": "ukraine",
+ "serbia": "serbien",
+ "russia": "russland",
+ "belarus": "belarus",
+ "montenegro": "montenegro",
+ "northmacedonia": "nmk",
+ "ireland": "irland",
+ "bulgaria": "bulgarien",
+ "romania": "romania",
+ "moldova": "moldawien",
+ "czechia": "tschechien",
+ "turkey": "turkey",
+ "kosovo": "kosovo",
+ "slovenia": "slowenien",
+}
+
+# ── PocketBase Collection-Schemas ─────────────────────────────────────────────
+COLLECTIONS = {
+ "kennzeichen": {
+ "name": "kennzeichen",
+ "type": "base",
+ "schema": [
+ {"name": "code", "type": "text", "required": True},
+ {"name": "name", "type": "text", "required": True},
+ {"name": "derivation", "type": "text", "required": False},
+ {"name": "region", "type": "text", "required": False},
+ {"name": "note", "type": "text", "required": False},
+ {"name": "active", "type": "bool", "required": True},
+ {"name": "country", "type": "text", "required": True},
+ # Aus data.db angereichert:
+ {"name": "lat", "type": "number", "required": False},
+ {"name": "lon", "type": "number", "required": False},
+ {"name": "population", "type": "number", "required": False},
+ {"name": "points", "type": "number", "required": False},
+ {"name": "alt_code", "type": "text", "required": False},
+ ],
+ },
+ "diplomatenkennzeichen": {
+ "name": "diplomatenkennzeichen",
+ "type": "base",
+ "schema": [
+ {"name": "code", "type": "text", "required": True},
+ {"name": "country", "type": "text", "required": True},
+ {"name": "organisation", "type": "text", "required": False},
+ {"name": "type", "type": "text", "required": True},
+ {"name": "city", "type": "text", "required": False},
+ {"name": "note", "type": "text", "required": False},
+ ],
+ },
+}
+
+
+class PocketBaseClient:
+ def __init__(self, base_url: str):
+ self.base_url = base_url.rstrip("/")
+ self.token = None
+
+ def authenticate(self, email: str, password: str):
+ r = requests.post(
+ f"{self.base_url}/api/admins/auth-with-password",
+ json={"identity": email, "password": password},
+ )
+ r.raise_for_status()
+ self.token = r.json()["token"]
+ print(f"✓ Eingeloggt als {email}")
+
+ def _headers(self):
+ return {"Authorization": self.token, "Content-Type": "application/json"}
+
+ def collection_exists(self, name: str) -> bool:
+ r = requests.get(
+ f"{self.base_url}/api/collections/{name}",
+ headers=self._headers(),
+ )
+ return r.status_code == 200
+
+ def create_collection(self, schema: dict):
+ name = schema["name"]
+ if self.collection_exists(name):
+ print(f" Collection '{name}' existiert bereits, überspringe.")
+ return
+ r = requests.post(
+ f"{self.base_url}/api/collections",
+ headers=self._headers(),
+ json=schema,
+ )
+ r.raise_for_status()
+ print(f" ✓ Collection '{name}' erstellt")
+
+ def upsert_record(self, collection: str, data: dict):
+ """Insert oder Update anhand des 'code'-Feldes."""
+ # Prüfe ob Eintrag schon existiert
+ r = requests.get(
+ f"{self.base_url}/api/collections/{collection}/records",
+ headers=self._headers(),
+ params={"filter": f"code='{data['code']}' && country='{data.get('country','')}'", "perPage": 1},
+ )
+ r.raise_for_status()
+ items = r.json().get("items", [])
+
+ if items:
+ record_id = items[0]["id"]
+ r2 = requests.patch(
+ f"{self.base_url}/api/collections/{collection}/records/{record_id}",
+ headers=self._headers(),
+ json=data,
+ )
+ r2.raise_for_status()
+ else:
+ r2 = requests.post(
+ f"{self.base_url}/api/collections/{collection}/records",
+ headers=self._headers(),
+ json=data,
+ )
+ r2.raise_for_status()
+
+
+def load_db_geodata(db_path: str) -> dict:
+ """
+ Lädt GPS-Koordinaten, Einwohnerzahl und Punkte aus data.db.
+ Gibt dict zurück: { (land, code.upper()): {lat, lon, population, points, alt_code} }
+ """
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+
+ geo = {}
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ tables = [r[0] for r in cur.fetchall() if r[0] != "laender"]
+
+ for table in tables:
+ try:
+ cur.execute(f"SELECT kennzeichen, map1, map2, anzahl, punkte, alt FROM {table}")
+ for row in cur.fetchall():
+ kz, map1, map2, anzahl, punkte, alt = row
+ if kz:
+ geo[(table, kz.upper())] = {
+ "lat": map1,
+ "lon": map2,
+ "population": anzahl,
+ "points": punkte,
+ "alt_code": alt,
+ }
+ except sqlite3.OperationalError:
+ pass
+
+ conn.close()
+ print(f"✓ {len(geo)} Geo-Einträge aus data.db geladen")
+ return geo
+
+
+def import_kennzeichen(pb: PocketBaseClient, repo_path: str, geo: dict):
+ total = 0
+ for folder, db_table in COUNTRY_MAP.items():
+ csv_path = os.path.join(repo_path, folder, "kennzeichen.csv")
+ if not os.path.exists(csv_path):
+ continue
+
+ count = 0
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ code = row.get("code", "").strip()
+ if not code:
+ continue
+
+ record = {
+ "code": code,
+ "name": row.get("name", "").strip(),
+ "derivation": row.get("derivation", "").strip(),
+ "region": row.get("region", "").strip(),
+ "note": row.get("note", "").strip(),
+ "active": row.get("active", "true").strip().lower() == "true",
+ "country": folder,
+ }
+
+ # Geo-Daten aus data.db anreichern
+ geo_key = (db_table, code.upper())
+ if geo_key in geo:
+ record.update(geo[geo_key])
+
+ pb.upsert_record("kennzeichen", record)
+ count += 1
+
+ print(f" ✓ {folder}: {count} Kennzeichen importiert")
+ total += count
+
+ print(f"\n✓ Gesamt: {total} Kennzeichen in PocketBase")
+
+
+def import_diplomatenkennzeichen(pb: PocketBaseClient, repo_path: str):
+ total = 0
+ for folder in os.listdir(repo_path):
+ csv_path = os.path.join(repo_path, folder, "diplomatenkennzeichen.csv")
+ if not os.path.exists(csv_path):
+ continue
+
+ count = 0
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ code = row.get("code", "").strip()
+ if not code:
+ continue
+
+ record = {
+ "code": code,
+ "country": row.get("country", "").strip(),
+ "organisation": row.get("organisation", "").strip(),
+ "type": row.get("type", "embassy").strip(),
+ "city": row.get("city", "").strip(),
+ "note": row.get("note", "").strip(),
+ "base_country": folder,
+ }
+ pb.upsert_record("diplomatenkennzeichen", record)
+ count += 1
+
+ print(f" ✓ {folder}: {count} Diplomatenkennzeichen importiert")
+ total += count
+
+ print(f"\n✓ Gesamt: {total} Diplomatenkennzeichen in PocketBase")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Kennzeichen → PocketBase Import")
+ parser.add_argument("--pb-url", default="http://localhost:8090")
+ parser.add_argument("--pb-email", required=True)
+ parser.add_argument("--pb-password", required=True)
+ parser.add_argument("--repo-path", required=True, help="Pfad zum plates-Repo")
+ parser.add_argument("--db-path", required=True, help="Pfad zur data.db")
+ parser.add_argument("--skip-schema", action="store_true", help="Collections nicht neu erstellen")
+ args = parser.parse_args()
+
+ pb = PocketBaseClient(args.pb_url)
+ pb.authenticate(args.pb_email, args.pb_password)
+
+ if not args.skip_schema:
+ print("\n→ Collections anlegen...")
+ for schema in COLLECTIONS.values():
+ pb.create_collection(schema)
+
+ print("\n→ Geo-Daten laden...")
+ geo = load_db_geodata(args.db_path)
+
+ print("\n→ Kennzeichen importieren...")
+ import_kennzeichen(pb, args.repo_path, geo)
+
+ print("\n→ Diplomatenkennzeichen importieren...")
+ import_diplomatenkennzeichen(pb, args.repo_path)
+
+ print("\n✓ Import abgeschlossen!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/import1.py b/scripts/import1.py
new file mode 100644
index 0000000..f100f0c
--- /dev/null
+++ b/scripts/import1.py
@@ -0,0 +1,455 @@
+#!/usr/bin/env python3
+"""
+import_all.py — Kennzeichen-Daten + Backup nach PocketBase importieren
+
+Verwendung:
+ pip3 install requests
+
+ python3 import_all.py \
+ --pb-url http://localhost:4444 \
+ --pb-email admin@example.com \
+ --pb-password passwort \
+ --repo-path ~/plates \
+ --db-path ~/data.db \
+ --backup ~/KennzeichensammlerBackupX-18_05.2026
+"""
+
+import argparse, csv, os, sqlite3, zlib, re, requests
+from datetime import datetime
+
+# ── Länder-Mapping Repo-Ordner → data.db Tabellenname ─────────────────────────
+COUNTRY_MAP = {
+ "germany": "deutschland",
+ "austria": "austria",
+ "switzerland": "schweiz",
+ "luxembourg": "luxemburg",
+ "poland": "polen",
+ "norway": "norwegen",
+ "uk": "uk",
+ "italy": "italien",
+ "france": "frankreich",
+ "greece": "greece",
+ "slovakia": "slowakei",
+ "croatia": "kroatien",
+ "ukraine": "ukraine",
+ "serbia": "serbien",
+ "russia": "russland",
+ "belarus": "belarus",
+ "montenegro": "montenegro",
+ "northmacedonia": "nmk",
+ "ireland": "irland",
+ "bulgaria": "bulgarien",
+ "romania": "romania",
+ "moldova": "moldawien",
+ "czechia": "tschechien",
+ "turkey": "turkey",
+ "kosovo": "kosovo",
+ "slovenia": "slowenien",
+}
+
+# Umgekehrtes Mapping für Backup-Import (app-Ländername → Repo-Ordnername)
+APP_TO_FOLDER = {v: k for k, v in COUNTRY_MAP.items()}
+APP_TO_FOLDER.update({
+ "global": "global",
+ "global2": "global",
+ "uk2": "uk",
+ "laender": "global",
+})
+
+# CSV-Spalten-Mapping (verschiedene mögliche Headernamen → interner Name)
+COL_MAP = {
+ "code": ["code", "Kennzeichenkürzel", "kürzel", "kennzeichen"],
+ "name": ["name", "StadtOderKreis", "ort", "stadt"],
+ "derivation": ["derivation", "Herleitung", "herleitung"],
+ "region": ["region", "Bundesland", "bundesland", "kanton", "provinz"],
+ "note": ["note", "Bemerkung", "bemerkung", "hinweis"],
+ "active": ["active", "aktiv"],
+}
+
+def map_row(row: dict) -> dict:
+ result = {}
+ for internal, candidates in COL_MAP.items():
+ for c in candidates:
+ if c in row:
+ result[internal] = row[c].strip()
+ break
+ if internal not in result:
+ result[internal] = ""
+ # active default true wenn leer
+ if result["active"] == "":
+ result["active"] = True
+ else:
+ result["active"] = result["active"].lower() not in ("false", "0", "nein", "")
+ return result
+
+
+class PB:
+ def __init__(self, url): self.url = url.rstrip("/"); self.token = None
+
+ def login(self, email, password):
+ r = requests.post(f"{self.url}/api/admins/auth-with-password",
+ json={"identity": email, "password": password})
+ r.raise_for_status()
+ self.token = r.json()["token"]
+ print(f"✓ Eingeloggt als {email}")
+
+ def h(self): return {"Authorization": self.token, "Content-Type": "application/json"}
+
+ def collection_exists(self, name):
+ return requests.get(f"{self.url}/api/collections/{name}", headers=self.h()).status_code == 200
+
+ def create_collection(self, schema):
+ name = schema["name"]
+ if self.collection_exists(name):
+ print(f" '{name}' existiert bereits")
+ return
+ r = requests.post(f"{self.url}/api/collections", headers=self.h(), json=schema)
+ r.raise_for_status()
+ print(f" ✓ '{name}' erstellt")
+
+ def find_record(self, collection, filter_str):
+ r = requests.get(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), params={"filter": filter_str, "perPage": 1})
+ if r.status_code != 200: return None
+ items = r.json().get("items", [])
+ return items[0] if items else None
+
+ def create(self, collection, data):
+ r = requests.post(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" Fehler: {r.text[:100]}")
+ return r
+
+ def upsert_kz(self, data):
+ existing = self.find_record("kennzeichen",
+ f'code="{data["code"]}" && country="{data["country"]}"')
+ if existing:
+ r = requests.patch(f"{self.url}/api/collections/kennzeichen/records/{existing['id']}",
+ headers=self.h(), json=data)
+ else:
+ r = requests.post(f"{self.url}/api/collections/kennzeichen/records",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" Fehler bei {data.get('code')}: {r.text[:80]}")
+
+
+SCHEMAS = [
+ {
+ "name": "kennzeichen", "type": "base",
+ "schema": [
+ {"name": "code", "type": "text", "required": True},
+ {"name": "name", "type": "text", "required": True},
+ {"name": "derivation", "type": "text", "required": False},
+ {"name": "region", "type": "text", "required": False},
+ {"name": "note", "type": "text", "required": False},
+ {"name": "active", "type": "bool", "required": True},
+ {"name": "country", "type": "text", "required": True},
+ {"name": "app_id", "type": "number", "required": False},
+ {"name": "lat", "type": "number", "required": False},
+ {"name": "lon", "type": "number", "required": False},
+ {"name": "population", "type": "number", "required": False},
+ {"name": "points", "type": "number", "required": False},
+ {"name": "alt_code", "type": "text", "required": False},
+ ],
+ },
+ {
+ "name": "diplomatenkennzeichen", "type": "base",
+ "schema": [
+ {"name": "code", "type": "text", "required": True},
+ {"name": "country", "type": "text", "required": True},
+ {"name": "organisation", "type": "text", "required": False},
+ {"name": "type", "type": "text", "required": True},
+ {"name": "city", "type": "text", "required": False},
+ {"name": "note", "type": "text", "required": False},
+ {"name": "base_country", "type": "text", "required": False},
+ ],
+ },
+ {
+ "name": "gesehen", "type": "base",
+ "schema": [
+ {"name": "kennzeichen_code", "type": "text", "required": True},
+ {"name": "kennzeichen_name", "type": "text", "required": False},
+ {"name": "land", "type": "text", "required": True},
+ {"name": "datum", "type": "text", "required": False},
+ ],
+ },
+ {
+ "name": "blog_posts", "type": "base",
+ "schema": [
+ {"name": "titel", "type": "text", "required": True},
+ {"name": "slug", "type": "text", "required": True},
+ {"name": "inhalt", "type": "text", "required": False},
+ {"name": "datum", "type": "date", "required": False},
+ {"name": "tags", "type": "json", "required": False},
+ ],
+ },
+]
+
+
+def load_geodata(db_path):
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+ geo = {}
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ for (tbl,) in cur.fetchall():
+ if tbl == "laender": continue
+ try:
+ cur.execute(f"SELECT id, kennzeichen, map1, map2, anzahl, punkte, alt FROM {tbl}")
+ for row in cur.fetchall():
+ app_id, kz, lat, lon, pop, pts, alt = row
+ if kz:
+ geo[(tbl, str(app_id))] = {
+ "app_id": app_id, "lat": lat, "lon": lon,
+ "population": pop, "points": pts, "alt_code": alt or "",
+ }
+ except: pass
+ conn.close()
+ print(f"✓ {len(geo)} Geo-Einträge aus data.db geladen")
+ return geo
+
+
+def import_kennzeichen(pb, repo_path, geo):
+ total = 0
+ for folder, db_table in COUNTRY_MAP.items():
+ csv_path = os.path.join(repo_path, folder, "kennzeichen.csv")
+ if not os.path.exists(csv_path):
+ continue
+ count = 0
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ mapped = map_row(row)
+ code = mapped.get("code", "").strip()
+ if not code: continue
+
+ record = {
+ "code": code,
+ "name": mapped["name"],
+ "derivation": mapped["derivation"],
+ "region": mapped["region"],
+ "note": mapped["note"],
+ "active": mapped["active"],
+ "country": folder,
+ }
+
+ # app_id aus data.db per Kennzeichen-Code suchen
+ # Suche in der passenden Tabelle nach code
+ geo_entry = None
+ for (tbl, aid), gdata in geo.items():
+ if tbl == db_table:
+ # Wir suchen über kennzeichen-Code — brauchen Lookup-Tabelle
+ pass
+
+ # Einfacherer Ansatz: direkt in data.db nach Code suchen
+ pb.upsert_kz(record)
+ count += 1
+
+ print(f" ✓ {folder}: {count} Kennzeichen")
+ total += count
+ print(f"✓ Gesamt: {total} Kennzeichen")
+
+
+def import_geo_enrichment(pb, db_path):
+ """Reichert bereits importierte Kennzeichen mit Geo-Daten an."""
+ ref = sqlite3.connect(db_path)
+ rcur = ref.cursor()
+
+ for folder, db_table in COUNTRY_MAP.items():
+ try:
+ rcur.execute(f"SELECT id, kennzeichen, map1, map2, anzahl, punkte, alt FROM {db_table}")
+ rows = rcur.fetchall()
+ except: continue
+
+ for app_id, kz, lat, lon, pop, pts, alt in rows:
+ if not kz: continue
+ existing = pb.find_record("kennzeichen",
+ f'code="{kz}" && country="{folder}"')
+ if existing:
+ requests.patch(
+ f"{pb.url}/api/collections/kennzeichen/records/{existing['id']}",
+ headers=pb.h(),
+ json={"app_id": app_id, "lat": lat, "lon": lon,
+ "population": pop, "points": pts, "alt_code": alt or ""},
+ )
+
+ ref.close()
+ print("✓ Geo-Daten angereichert")
+
+
+def normalize_diplo_code(code: str) -> str:
+ """0110 und 0-110 → 0-110 (einheitlich mit Bindestrich)"""
+ code = code.strip()
+ # Wenn es mit 0 anfängt und keinen Bindestrich hat: 0110 → 0-110
+ m = re.match(r'^0(\d+)$', code)
+ if m:
+ return f"0-{m.group(1)}"
+ return code
+
+
+def import_diplomatenkennzeichen(pb, repo_path):
+ total = 0
+ for folder in os.listdir(repo_path):
+ csv_path = os.path.join(repo_path, folder, "diplomatenkennzeichen.csv")
+ if not os.path.exists(csv_path): continue
+ count = 0
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ # Flexible Spalten
+ code = (row.get("code") or row.get("Nummernkreis") or row.get("Kennzeichen") or "").strip()
+ if not code: continue
+ code = normalize_diplo_code(code)
+
+ country = (row.get("country") or row.get("Land") or row.get("Entsendestaat") or "").strip()
+ org = (row.get("organisation") or row.get("Organisation") or "").strip()
+ typ = (row.get("type") or row.get("Typ") or "embassy").strip().lower()
+ city = (row.get("city") or row.get("Stadt") or "").strip()
+ note = (row.get("note") or row.get("Bemerkung") or "").strip()
+
+ if typ not in ("embassy", "consulate", "io"):
+ typ = "embassy"
+
+ record = {"code": code, "country": country, "organisation": org,
+ "type": typ, "city": city, "note": note, "base_country": folder}
+
+ # Upsert
+ existing = pb.find_record("diplomatenkennzeichen",
+ f'code="{code}" && base_country="{folder}"')
+ if existing:
+ requests.patch(f"{pb.url}/api/collections/diplomatenkennzeichen/records/{existing['id']}",
+ headers=pb.h(), json=record)
+ else:
+ pb.create("diplomatenkennzeichen", record)
+ count += 1
+
+ print(f" ✓ {folder}: {count} Diplomatenkennzeichen")
+ total += count
+ print(f"✓ Gesamt: {total} Diplomatenkennzeichen")
+
+
+def import_backup(pb, backup_path, db_path):
+ """Liest das App-Backup und importiert gesehene Kennzeichen."""
+ # Backup entpacken
+ with open(backup_path, "rb") as f:
+ raw = f.read()
+
+ decompressed = zlib.decompress(raw)
+ # 'KX1' Prefix entfernen
+ db_bytes = decompressed[3:]
+ tmp = "/tmp/backup_import.db"
+ with open(tmp, "wb") as f:
+ f.write(db_bytes)
+
+ backup_conn = sqlite3.connect(tmp)
+ ref_conn = sqlite3.connect(db_path)
+ bcur = backup_conn.cursor()
+ rcur = ref_conn.cursor()
+
+ bcur.execute("SELECT kennid, datum, land FROM gesehen ORDER BY datum")
+ rows = bcur.fetchall()
+ print(f" Backup: {len(rows)} Einträge gefunden")
+
+ imported = 0
+ skipped = 0
+
+ for kennid, datum, land in rows:
+ # Referenz-Tabelle finden
+ ref_table = land if land not in APP_TO_FOLDER else None
+ # Direkt den app-Tabellennamen verwenden
+ try:
+ rcur.execute(f"SELECT kennzeichen FROM {land} WHERE id=?", (kennid,))
+ result = rcur.fetchone()
+ except:
+ skipped += 1
+ continue
+
+ if not result:
+ skipped += 1
+ continue
+
+ kz_code = result[0]
+ folder = APP_TO_FOLDER.get(land, land)
+
+ # Datum normalisieren (DD.MM.YYYY → YYYY-MM-DD)
+ try:
+ if "." in datum:
+ parts = datum.split(".")
+ datum_iso = f"{parts[2]}-{parts[1].zfill(2)}-{parts[0].zfill(2)}"
+ else:
+ datum_iso = datum
+ except:
+ datum_iso = datum
+
+ # Diplomatenkennzeichen normalisieren
+ if land in ("global", "global2", "laender"):
+ kz_code = normalize_diplo_code(kz_code)
+
+ # Duplikat prüfen
+ existing = pb.find_record("gesehen",
+ f'kennzeichen_code="{kz_code}" && land="{folder}"')
+ if existing:
+ skipped += 1
+ continue
+
+ # Kennzeichen-Namen aus Referenz holen
+ try:
+ rcur.execute(f"SELECT ort FROM {land} WHERE id=?", (kennid,))
+ name_result = rcur.fetchone()
+ kz_name = name_result[0] if name_result else ""
+ except:
+ kz_name = ""
+
+ pb.create("gesehen", {
+ "kennzeichen_code": kz_code,
+ "kennzeichen_name": kz_name,
+ "land": folder,
+ "datum": datum_iso,
+ })
+ imported += 1
+
+ backup_conn.close()
+ ref_conn.close()
+ print(f" ✓ {imported} Einträge importiert, {skipped} übersprungen")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--pb-url", default="http://localhost:4444")
+ parser.add_argument("--pb-email", required=True)
+ parser.add_argument("--pb-password", required=True)
+ parser.add_argument("--repo-path", required=True)
+ parser.add_argument("--db-path", required=True)
+ parser.add_argument("--backup", help="Pfad zur Backup-Datei")
+ parser.add_argument("--skip-schema", action="store_true")
+ parser.add_argument("--skip-kz", action="store_true")
+ parser.add_argument("--only-backup", action="store_true")
+ args = parser.parse_args()
+
+ pb = PB(args.pb_url)
+ pb.login(args.pb_email, args.pb_password)
+
+ if not args.skip_schema and not args.only_backup:
+ print("\n→ Collections anlegen...")
+ for schema in SCHEMAS:
+ pb.create_collection(schema)
+
+ if not args.skip_kz and not args.only_backup:
+ print("\n→ Kennzeichen importieren...")
+ import_kennzeichen(pb, args.repo_path, {})
+
+ print("\n→ Geo-Daten anreichern...")
+ import_geo_enrichment(pb, args.db_path)
+
+ print("\n→ Diplomatenkennzeichen importieren...")
+ import_diplomatenkennzeichen(pb, args.repo_path)
+
+ if args.backup:
+ print("\n→ Backup importieren...")
+ import_backup(pb, args.backup, args.db_path)
+
+ print("\n✓ Fertig!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/import_all.py b/scripts/import_all.py
new file mode 100644
index 0000000..c384270
--- /dev/null
+++ b/scripts/import_all.py
@@ -0,0 +1,455 @@
+#!/usr/bin/env python3
+"""
+import_all.py — Kennzeichen-Daten + Backup nach PocketBase importieren
+
+Verwendung:
+ pip3 install requests
+
+ python3 import_all.py \
+ --pb-url http://localhost:4444 \
+ --pb-email admin@example.com \
+ --pb-password passwort \
+ --repo-path ~/plates \
+ --db-path ~/data.db \
+ --backup ~/KennzeichensammlerBackupX-18_05.2026
+"""
+
+import argparse, csv, os, sqlite3, zlib, re, requests
+from datetime import datetime
+
+# ── Länder-Mapping Repo-Ordner → data.db Tabellenname ─────────────────────────
+COUNTRY_MAP = {
+ "germany": "deutschland",
+ "austria": "austria",
+ "switzerland": "schweiz",
+ "luxembourg": "luxemburg",
+ "poland": "polen",
+ "norway": "norwegen",
+ "uk": "uk",
+ "italy": "italien",
+ "france": "frankreich",
+ "greece": "greece",
+ "slovakia": "slowakei",
+ "croatia": "kroatien",
+ "ukraine": "ukraine",
+ "serbia": "serbien",
+ "russia": "russland",
+ "belarus": "belarus",
+ "montenegro": "montenegro",
+ "northmacedonia": "nmk",
+ "ireland": "irland",
+ "bulgaria": "bulgarien",
+ "romania": "romania",
+ "moldova": "moldawien",
+ "czechia": "tschechien",
+ "turkey": "turkey",
+ "kosovo": "kosovo",
+ "slovenia": "slowenien",
+}
+
+# Umgekehrtes Mapping für Backup-Import (app-Ländername → Repo-Ordnername)
+APP_TO_FOLDER = {v: k for k, v in COUNTRY_MAP.items()}
+APP_TO_FOLDER.update({
+ "global": "global",
+ "global2": "global",
+ "uk2": "uk",
+ "laender": "global",
+})
+
+# CSV-Spalten-Mapping (verschiedene mögliche Headernamen → interner Name)
+COL_MAP = {
+ "code": ["code", "Kennzeichenkürzel", "kürzel", "kennzeichen"],
+ "name": ["name", "StadtOderKreis", "ort", "stadt"],
+ "derivation": ["derivation", "Herleitung", "herleitung"],
+ "region": ["region", "Bundesland", "bundesland", "kanton", "provinz"],
+ "note": ["note", "Bemerkung", "bemerkung", "hinweis"],
+ "active": ["active", "aktiv"],
+}
+
+def map_row(row: dict) -> dict:
+ result = {}
+ for internal, candidates in COL_MAP.items():
+ for c in candidates:
+ if c in row:
+ result[internal] = row[c].strip()
+ break
+ if internal not in result:
+ result[internal] = ""
+ # active default true wenn leer
+ if result["active"] == "":
+ result["active"] = True
+ else:
+ result["active"] = result["active"].lower() not in ("false", "0", "nein", "")
+ return result
+
+
+class PB:
+ def __init__(self, url): self.url = url.rstrip("/"); self.token = None
+
+ def login(self, email, password):
+ r = requests.post(f"{self.url}/api/admins/auth-with-password",
+ json={"identity": email, "password": password})
+ r.raise_for_status()
+ self.token = r.json()["token"]
+ print(f"✓ Eingeloggt als {email}")
+
+ def h(self): return {"Authorization": self.token, "Content-Type": "application/json"}
+
+ def collection_exists(self, name):
+ return requests.get(f"{self.url}/api/collections/{name}", headers=self.h()).status_code == 200
+
+ def create_collection(self, schema):
+ name = schema["name"]
+ if self.collection_exists(name):
+ print(f" '{name}' existiert bereits")
+ return
+ r = requests.post(f"{self.url}/api/collections", headers=self.h(), json=schema)
+ r.raise_for_status()
+ print(f" ✓ '{name}' erstellt")
+
+ def find_record(self, collection, filter_str):
+ r = requests.get(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), params={"filter": filter_str, "perPage": 1})
+ if r.status_code != 200: return None
+ items = r.json().get("items", [])
+ return items[0] if items else None
+
+ def create(self, collection, data):
+ r = requests.post(f"{self.url}/api/collections/{collection}/records",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" Fehler: {r.text[:100]}")
+ return r
+
+ def upsert_kz(self, data):
+ existing = self.find_record("kennzeichen",
+ f'code="{data["code"]}" && country="{data["country"]}"')
+ if existing:
+ r = requests.patch(f"{self.url}/api/collections/kennzeichen/records/{existing['id']}",
+ headers=self.h(), json=data)
+ else:
+ r = requests.post(f"{self.url}/api/collections/kennzeichen/records",
+ headers=self.h(), json=data)
+ if r.status_code not in (200, 204):
+ print(f" Fehler bei {data.get('code')}: {r.text[:80]}")
+
+
+SCHEMAS = [
+ {
+ "name": "kennzeichen", "type": "base",
+ "schema": [
+ {"name": "code", "type": "text", "required": True},
+ {"name": "name", "type": "text", "required": True},
+ {"name": "derivation", "type": "text", "required": False},
+ {"name": "region", "type": "text", "required": False},
+ {"name": "note", "type": "text", "required": False},
+ {"name": "active", "type": "bool", "required": True},
+ {"name": "country", "type": "text", "required": True},
+ {"name": "app_id", "type": "number", "required": False},
+ {"name": "lat", "type": "number", "required": False},
+ {"name": "lon", "type": "number", "required": False},
+ {"name": "population", "type": "number", "required": False},
+ {"name": "points", "type": "number", "required": False},
+ {"name": "alt_code", "type": "text", "required": False},
+ ],
+ },
+ {
+ "name": "diplomatenkennzeichen", "type": "base",
+ "schema": [
+ {"name": "code", "type": "text", "required": True},
+ {"name": "country", "type": "text", "required": True},
+ {"name": "organisation", "type": "text", "required": False},
+ {"name": "type", "type": "text", "required": True},
+ {"name": "city", "type": "text", "required": False},
+ {"name": "note", "type": "text", "required": False},
+ {"name": "base_country", "type": "text", "required": False},
+ ],
+ },
+ {
+ "name": "gesehen", "type": "base",
+ "schema": [
+ {"name": "kennzeichen_code", "type": "text", "required": True},
+ {"name": "kennzeichen_name", "type": "text", "required": False},
+ {"name": "land", "type": "text", "required": True},
+ {"name": "datum", "type": "text", "required": False},
+ ],
+ },
+ {
+ "name": "blog_posts", "type": "base",
+ "schema": [
+ {"name": "titel", "type": "text", "required": True},
+ {"name": "slug", "type": "text", "required": True},
+ {"name": "inhalt", "type": "text", "required": False},
+ {"name": "datum", "type": "date", "required": False},
+ {"name": "tags", "type": "json", "required": False},
+ ],
+ },
+]
+
+
+def load_geodata(db_path):
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+ geo = {}
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ for (tbl,) in cur.fetchall():
+ if tbl == "laender": continue
+ try:
+ cur.execute(f"SELECT id, kennzeichen, map1, map2, anzahl, punkte, alt FROM {tbl}")
+ for row in cur.fetchall():
+ app_id, kz, lat, lon, pop, pts, alt = row
+ if kz:
+ geo[(tbl, str(app_id))] = {
+ "app_id": app_id, "lat": lat, "lon": lon,
+ "population": pop, "points": pts, "alt_code": alt or "",
+ }
+ except: pass
+ conn.close()
+ print(f"✓ {len(geo)} Geo-Einträge aus data.db geladen")
+ return geo
+
+
+def import_kennzeichen(pb, repo_path, geo):
+ total = 0
+ for folder, db_table in COUNTRY_MAP.items():
+ csv_path = os.path.join(repo_path, folder, "kennzeichen.csv")
+ if not os.path.exists(csv_path):
+ continue
+ count = 0
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ mapped = map_row(row)
+ code = mapped.get("code", "").strip()
+ if not code: continue
+
+ record = {
+ "code": code,
+ "name": mapped["name"],
+ "derivation": mapped["derivation"],
+ "region": mapped["region"],
+ "note": mapped["note"],
+ "active": mapped["active"],
+ "country": folder,
+ }
+
+ # app_id aus data.db per Kennzeichen-Code suchen
+ # Suche in der passenden Tabelle nach code
+ geo_entry = None
+ for (tbl, aid), gdata in geo.items():
+ if tbl == db_table:
+ # Wir suchen über kennzeichen-Code — brauchen Lookup-Tabelle
+ pass
+
+ # Einfacherer Ansatz: direkt in data.db nach Code suchen
+ pb.upsert_kz(record)
+ count += 1
+
+ print(f" ✓ {folder}: {count} Kennzeichen")
+ total += count
+ print(f"✓ Gesamt: {total} Kennzeichen")
+
+
+def import_geo_enrichment(pb, db_path):
+ """Reichert bereits importierte Kennzeichen mit Geo-Daten an."""
+ ref = sqlite3.connect(db_path)
+ rcur = ref.cursor()
+
+ for folder, db_table in COUNTRY_MAP.items():
+ try:
+ rcur.execute(f"SELECT id, kennzeichen, map1, map2, anzahl, punkte, alt FROM {db_table}")
+ rows = rcur.fetchall()
+ except: continue
+
+ for app_id, kz, lat, lon, pop, pts, alt in rows:
+ if not kz: continue
+ existing = pb.find_record("kennzeichen",
+ f'code="{kz}" && country="{folder}"')
+ if existing:
+ requests.patch(
+ f"{pb.url}/api/collections/kennzeichen/records/{existing['id']}",
+ headers=pb.h(),
+ json={"app_id": app_id, "lat": lat, "lon": lon,
+ "population": pop, "points": pts, "alt_code": alt or ""},
+ )
+
+ ref.close()
+ print("✓ Geo-Daten angereichert")
+
+
+def normalize_diplo_code(code: str) -> str:
+ """0110 und 0-110 → 0-110 (einheitlich mit Bindestrich)"""
+ code = code.strip()
+ # Wenn es mit 0 anfängt und keinen Bindestrich hat: 0110 → 0-110
+ m = re.match(r'^0(\d+)$', code)
+ if m:
+ return f"0-{m.group(1)}"
+ return code
+
+
+def import_diplomatenkennzeichen(pb, repo_path):
+ total = 0
+ for folder in os.listdir(repo_path):
+ csv_path = os.path.join(repo_path, folder, "diplomatenkennzeichen.csv")
+ if not os.path.exists(csv_path): continue
+ count = 0
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ # Flexible Spalten
+ code = (row.get("code") or row.get("Nummernkreis") or row.get("Kennzeichen") or "").strip()
+ if not code: continue
+ code = normalize_diplo_code(code)
+
+ country = (row.get("country") or row.get("Land") or row.get("Entsendestaat") or "").strip()
+ org = (row.get("organisation") or row.get("Organisation") or "").strip()
+ typ = (row.get("type") or row.get("Typ") or "embassy").strip().lower()
+ city = (row.get("city") or row.get("Stadt") or "").strip()
+ note = (row.get("note") or row.get("Bemerkung") or "").strip()
+
+ if typ not in ("embassy", "consulate", "io"):
+ typ = "embassy"
+
+ record = {"code": code, "country": country, "organisation": org,
+ "type": typ, "city": city, "note": note, "base_country": folder}
+
+ # Upsert
+ existing = pb.find_record("diplomatenkennzeichen",
+ f'code="{code}" && base_country="{folder}"')
+ if existing:
+ requests.patch(f"{pb.url}/api/collections/diplomatenkennzeichen/records/{existing['id']}",
+ headers=pb.h(), json=record)
+ else:
+ pb.create("diplomatenkennzeichen", record)
+ count += 1
+
+ print(f" ✓ {folder}: {count} Diplomatenkennzeichen")
+ total += count
+ print(f"✓ Gesamt: {total} Diplomatenkennzeichen")
+
+
+def import_backup(pb, backup_path, db_path):
+ """Liest das App-Backup und importiert gesehene Kennzeichen."""
+ # Backup entpacken
+ with open(backup_path, "rb") as f:
+ raw = f.read()
+
+ decompressed = zlib.decompress(raw)
+ # 'KX1' Prefix entfernen
+ db_bytes = decompressed[3:]
+ tmp = "/tmp/backup_import.db"
+ with open(tmp, "wb") as f:
+ f.write(db_bytes)
+
+ backup_conn = sqlite3.connect(tmp)
+ ref_conn = sqlite3.connect(db_path)
+ bcur = backup_conn.cursor()
+ rcur = ref_conn.cursor()
+
+ bcur.execute("SELECT kennid, datum, land FROM gesehen ORDER BY datum")
+ rows = bcur.fetchall()
+ print(f" Backup: {len(rows)} Einträge gefunden")
+
+ imported = 0
+ skipped = 0
+
+ for kennid, datum, land in rows:
+ # Referenz-Tabelle finden
+ ref_table = land if land not in APP_TO_FOLDER else None
+ # Direkt den app-Tabellennamen verwenden
+ try:
+ rcur.execute(f"SELECT kennzeichen FROM {land} WHERE id=?", (kennid,))
+ result = rcur.fetchone()
+ except:
+ skipped += 1
+ continue
+
+ if not result:
+ skipped += 1
+ continue
+
+ kz_code = result[0]
+ folder = APP_TO_FOLDER.get(land, land)
+
+ # Datum normalisieren (DD.MM.YYYY → YYYY-MM-DD)
+ try:
+ if "." in datum:
+ parts = datum.split(".")
+ datum_iso = f"{parts[2]}-{parts[1].zfill(2)}-{parts[0].zfill(2)}"
+ else:
+ datum_iso = datum
+ except:
+ datum_iso = datum
+
+ # Diplomatenkennzeichen normalisieren
+ if land in ("global", "global2", "laender"):
+ kz_code = normalize_diplo_code(kz_code)
+
+ # Duplikat prüfen
+ existing = pb.find_record("gesehen",
+ f'kennzeichen_code="{kz_code}" && land="{folder}"')
+ if existing:
+ skipped += 1
+ continue
+
+ # Kennzeichen-Namen aus Referenz holen
+ try:
+ rcur.execute(f"SELECT ort FROM {land} WHERE id=?", (kennid,))
+ name_result = rcur.fetchone()
+ kz_name = name_result[0] if name_result else ""
+ except:
+ kz_name = ""
+
+ pb.create("gesehen", {
+ "kennzeichen_code": kz_code,
+ "kennzeichen_name": kz_name,
+ "land": folder,
+ "datum": datum_iso,
+ })
+ imported += 1
+
+ backup_conn.close()
+ ref_conn.close()
+ print(f" ✓ {imported} Einträge importiert, {skipped} übersprungen")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--pb-url", default="http://localhost:4444")
+ parser.add_argument("--pb-email", default=os.environ.get("PB_EMAIL"), required=not os.environ.get("PB_EMAIL"))
+ parser.add_argument("--pb-password", default=os.environ.get("PB_PASSWORD"), required=not os.environ.get("PB_PASSWORD"))
+ parser.add_argument("--repo-path", required=True)
+ parser.add_argument("--db-path", required=True)
+ parser.add_argument("--backup", help="Pfad zur Backup-Datei")
+ parser.add_argument("--skip-schema", action="store_true")
+ parser.add_argument("--skip-kz", action="store_true")
+ parser.add_argument("--only-backup", action="store_true")
+ args = parser.parse_args()
+
+ pb = PB(args.pb_url)
+ pb.login(args.pb_email, args.pb_password)
+
+ if not args.skip_schema and not args.only_backup:
+ print("\n→ Collections anlegen...")
+ for schema in SCHEMAS:
+ pb.create_collection(schema)
+
+ if not args.skip_kz and not args.only_backup:
+ print("\n→ Kennzeichen importieren...")
+ import_kennzeichen(pb, args.repo_path, {})
+
+ print("\n→ Geo-Daten anreichern...")
+ import_geo_enrichment(pb, args.db_path)
+
+ print("\n→ Diplomatenkennzeichen importieren...")
+ import_diplomatenkennzeichen(pb, args.repo_path)
+
+ if args.backup:
+ print("\n→ Backup importieren...")
+ import_backup(pb, args.backup, args.db_path)
+
+ print("\n✓ Fertig!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/import_app_backup.sh b/scripts/import_app_backup.sh
new file mode 100755
index 0000000..ecf3897
--- /dev/null
+++ b/scripts/import_app_backup.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# import_app_backup.sh — App-Backup (Gesehen-Daten) importieren
+#
+# Verwendung:
+# bash ~/kennzeichen/scripts/import_app_backup.sh ~/KennzeichensammlerBackupX-18.05.2026
+#
+# Das Backup-File kommt von der Android-App:
+# App → Einstellungen → Backup exportieren → Datei auf Server übertragen
+# (z.B. via: scp KennzeichensammlerBackup* user@server:~/)
+
+set -e
+
+BACKUP_FILE="${1:?Verwendung: $0 }"
+KENNZEICHEN_DIR="${KENNZEICHEN_DIR:-$HOME/kennzeichen}"
+PB_URL="${PB_URL:-http://localhost:4444}"
+PB_EMAIL="${PB_EMAIL:-}"
+PB_PASSWORD="${PB_PASSWORD:-}"
+
+[ -f "$KENNZEICHEN_DIR/.env" ] && source "$KENNZEICHEN_DIR/.env"
+
+if [ -z "$PB_EMAIL" ] || [ -z "$PB_PASSWORD" ]; then
+ echo "Fehler: PB_EMAIL und PB_PASSWORD müssen gesetzt sein (siehe .env.example)"
+ exit 1
+fi
+
+if [ ! -f "$BACKUP_FILE" ]; then
+ echo "Fehler: Datei nicht gefunden: $BACKUP_FILE"
+ exit 1
+fi
+
+echo "=== App-Backup Import $(date '+%Y-%m-%d %H:%M') ==="
+echo "Backup: $BACKUP_FILE"
+
+python3 "$KENNZEICHEN_DIR/scripts/import_all.py" \
+ --pb-url "$PB_URL" \
+ --pb-email "$PB_EMAIL" \
+ --pb-password "$PB_PASSWORD" \
+ --repo-path "$HOME/plates" \
+ --db-path "$KENNZEICHEN_DIR/data.db" \
+ --only-backup \
+ --backup "$BACKUP_FILE"
+
+echo "=== Fertig ==="
diff --git a/scripts/sync.sh b/scripts/sync.sh
new file mode 100755
index 0000000..ca545f8
--- /dev/null
+++ b/scripts/sync.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# sync.sh — Kennzeichen-Daten aus ~/plates aktualisieren
+#
+# Ablauf:
+# 1. git pull im plates-Repo
+# 2. Alle Kennzeichen in PocketBase upserten (neue Records, geänderte Records)
+#
+# Verwendung:
+# bash ~/kennzeichen/scripts/sync.sh
+#
+# Für neue Länder zusätzlich nötig:
+# - Land in COUNTRY_MAP in import_all.py eintragen
+# - Land in COUNTRY_LABELS + COUNTRY_FLAGS in frontend/lib/pb.ts eintragen
+# - Docker rebuild: docker compose -f ~/kennzeichen/docker-compose.yml build nextjs
+# && docker compose -f ~/kennzeichen/docker-compose.yml up -d nextjs
+
+set -e
+
+PLATES_DIR="${PLATES_DIR:-$HOME/plates}"
+KENNZEICHEN_DIR="${KENNZEICHEN_DIR:-$HOME/kennzeichen}"
+PB_URL="${PB_URL:-http://localhost:4444}"
+PB_EMAIL="${PB_EMAIL:-}"
+PB_PASSWORD="${PB_PASSWORD:-}"
+
+# Credentials aus .env laden falls vorhanden
+[ -f "$KENNZEICHEN_DIR/.env" ] && source "$KENNZEICHEN_DIR/.env"
+
+if [ -z "$PB_EMAIL" ] || [ -z "$PB_PASSWORD" ]; then
+ echo "Fehler: PB_EMAIL und PB_PASSWORD müssen gesetzt sein (siehe .env.example)"
+ exit 1
+fi
+
+echo "=== Kennzeichen-Sync $(date '+%Y-%m-%d %H:%M') ==="
+
+# 1. Repo aktualisieren
+echo "→ git pull..."
+git -C "$PLATES_DIR" pull --ff-only
+
+# 2. Kennzeichen importieren (upsert, kein Schema-Reset)
+echo "→ Kennzeichen importieren..."
+python3 "$KENNZEICHEN_DIR/scripts/import_all.py" \
+ --pb-url "$PB_URL" \
+ --pb-email "$PB_EMAIL" \
+ --pb-password "$PB_PASSWORD" \
+ --repo-path "$PLATES_DIR" \
+ --db-path "$KENNZEICHEN_DIR/data.db" \
+ --skip-schema
+
+# 3. Diplomatenkennzeichen synchronisieren (upsert)
+echo "→ Diplomatenkennzeichen synchronisieren..."
+python3 "$KENNZEICHEN_DIR/scripts/fix_schema.py" \
+ --pb-url "$PB_URL" \
+ --pb-email "$PB_EMAIL" \
+ --pb-password "$PB_PASSWORD" \
+ --repo-path "$PLATES_DIR" \
+ --only diplo
+
+echo "=== Fertig ==="