Initial webiste

This commit is contained in:
denode 2026-05-20 20:47:07 +02:00
commit df98aaaac9
30 changed files with 3267 additions and 0 deletions

7
.env.example Normal file
View file

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

18
.gitignore vendored Normal file
View file

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

268
SETUP.md Normal file
View file

@ -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://<server-ip>: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 25 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 `<wireguard-ip>: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://<wireguard-ip>: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://<wireguard-ip>: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
```

20
docker-compose.yml Normal file
View file

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

View file

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

26
frontend/Dockerfile Normal file
View file

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

View file

@ -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<BlogPost>(`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,"<h3>$1</h3>").replace(/^## (.+)$/gm,"<h2>$1</h2>").replace(/^# (.+)$/gm,"<h1>$1</h1>")
.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>").replace(/\*(.+?)\*/g,"<em>$1</em>")
.replace(/`(.+?)`/g,"<code>$1</code>").replace(/\[(.+?)\]\((.+?)\)/g,'<a href="$2">$1</a>')
.replace(/\n\n/g,"</p><p>").replace(/^/,"<p>").replace(/$/,"</p>");
return (
<div className="max-w-2xl mx-auto px-6 py-12">
<Link href="/blog" className="text-sm text-[var(--muted)] hover:text-[var(--ink)] transition-colors mb-8 inline-block"> Blog</Link>
<article>
<time className="text-xs font-mono text-[var(--muted)]">{post.date ? new Date(post.date).toLocaleDateString("de-DE",{day:"numeric",month:"long",year:"numeric"}) : ""}</time>
<h1 style={{ fontFamily:"'Syne',sans-serif", fontWeight:800, fontSize:"2rem", letterSpacing:"-0.03em", lineHeight:1.1 }} className="mt-3 mb-6 text-[var(--ink)]">{post.titel}</h1>
{post.tags?.length > 0 && <div className="flex flex-wrap gap-1.5 mb-8">{post.tags.map(tag => <span key={tag} className="text-xs px-2 py-0.5 bg-[var(--warm)] rounded-sm text-[var(--muted)]">{tag}</span>)}</div>}
<div className="prose prose-sm max-w-none prose-headings:font-['Syne'] prose-a:text-[var(--accent)] prose-code:bg-[var(--warm)] prose-code:px-1 prose-code:rounded" dangerouslySetInnerHTML={{ __html: html }} />
</article>
</div>
);
}

View file

@ -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<BlogPost>(1, 20, { sort: "-created" }); }
catch { return null; }
}
export default async function BlogPage() {
const data = await getPosts();
const posts = data?.items ?? [];
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<div className="mb-12">
<div className="text-xs font-mono text-[var(--muted)] tracking-widest uppercase mb-2">Notizen</div>
<h1 style={{ fontFamily:"'Syne',sans-serif", fontWeight:800, fontSize:"2.25rem", letterSpacing:"-0.03em" }} className="text-[var(--ink)]">Blog</h1>
<p className="text-[var(--muted)] mt-2">Funde, Besonderheiten und Gedanken rund ums Sammeln.</p>
</div>
{posts.length === 0 ? (
<div className="text-center py-24 text-[var(--muted)]">
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:"1.5rem" }} className="mb-2">Noch keine Beiträge</div>
<p className="text-sm">Schreib den ersten Eintrag über den PocketBase Admin.</p>
</div>
) : (
<div className="space-y-8">
{posts.map((post, i) => (
<article key={post.id} className={i < posts.length-1 ? "pb-8 border-b border-[var(--warm)]" : ""}>
<Link href={`/blog/${post.slug}`} className="block group">
<h2 style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:"1.35rem" }} className="mt-2 text-[var(--ink)] group-hover:text-[var(--accent)] transition-colors">{post.titel}</h2>
{post.inhalt && <p className="mt-2 text-[var(--muted)] text-sm leading-relaxed line-clamp-3">{post.inhalt.replace(/[#*`\[\]]/g,"").slice(0,220)}</p>}
</Link>
</article>
))}
</div>
)}
</div>
);
}

View file

@ -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<string, string> = {
germany: "Deutschland", austria: "Österreich",
switzerland: "Schweiz", luxembourg: "Luxemburg",
};
const COUNTRY_FLAGS: Record<string, string> = {
germany: "🇩🇪", austria: "🇦🇹", switzerland: "🇨🇭", luxembourg: "🇱🇺",
};
const TYPE_LABELS: Record<string, string> = {
embassy: "Botschaft", consulate: "Konsulat", io: "Intern. Organisation",
};
const TYPE_COLORS: Record<string, string> = {
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<Diplomatenkennzeichen>({
filter: `base_country="${baseCountry}"`,
});
} catch { return []; }
}
async function getAvailableCountries(): Promise<string[]> {
try {
const all = await pb.collection("diplomatenkennzeichen").getFullList<Diplomatenkennzeichen>({
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<Record<string, Diplomatenkennzeichen[]>>((acc, item) => {
const t = item.type ?? "embassy";
if (!acc[t]) acc[t] = [];
acc[t].push(item);
return acc;
}, {});
return (
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="mb-8">
<div className="text-xs font-mono text-[var(--muted)] tracking-widest uppercase mb-2">Datenbank</div>
<h1 style={{ fontFamily: "'Syne',sans-serif", fontWeight: 800, fontSize: "2.25rem", letterSpacing: "-0.03em" }}
className="text-[var(--ink)]">Diplomatenkennzeichen</h1>
<p className="text-[var(--muted)] mt-2 max-w-2xl">
Sonderkennzeichen für Botschaften, Konsulate und internationale Organisationen.
</p>
</div>
{/* Länder-Tabs */}
<div className="flex gap-2 flex-wrap mb-8">
{countries.map(c => (
<a key={c} href={`?land=${c}`}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border text-sm transition-colors ${
c === activeLand
? "bg-[var(--ink)] text-[var(--paper)] border-[var(--ink)]"
: "border-[var(--warm)] text-[var(--muted)] hover:border-[var(--ink)] hover:text-[var(--ink)]"
}`}
style={{ fontFamily: "'Syne',sans-serif", fontWeight: 500 }}>
<span>{COUNTRY_FLAGS[c] ?? "🌍"}</span>
<span>{COUNTRY_NAMES[c] ?? c}</span>
</a>
))}
</div>
<div className="mb-6 flex gap-3 flex-wrap">
{Object.entries(TYPE_LABELS).map(([key, label]) => (
<span key={key} className={`px-2.5 py-1 rounded border text-xs font-medium ${TYPE_COLORS[key]}`}>
{label}: {grouped[key]?.length ?? 0}
</span>
))}
<span className="text-sm text-[var(--muted)] self-center"> {items.length} Einträge gesamt</span>
</div>
{items.length === 0 ? (
<div className="text-center py-20 text-[var(--muted)]">Keine Daten für dieses Land.</div>
) : (
Object.entries(TYPE_LABELS).map(([type, typeLabel]) => {
const entries = grouped[type];
if (!entries?.length) return null;
return (
<div key={type} className="mb-10">
<h2 className="text-base mb-3 flex items-center gap-2"
style={{ fontFamily: "'Syne',sans-serif", fontWeight: 700 }}>
<span className={`px-2.5 py-0.5 rounded border text-xs ${TYPE_COLORS[type]}`}>{typeLabel}</span>
<span className="text-[var(--muted)] text-sm font-normal">{entries.length} Einträge</span>
</h2>
<div className="border border-[var(--warm)] rounded-lg overflow-hidden bg-white/40">
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Kürzel</th>
<th>Land / Organisation</th>
<th className="hidden md:table-cell">Stadt</th>
<th className="hidden lg:table-cell">Bemerkung</th>
</tr>
</thead>
<tbody>
{entries.map((d) => (
<tr key={d.id}>
<td><span className="kz-badge font-mono text-xs">{d.code}</span></td>
<td>
<div className="font-medium text-sm">{d.country}</div>
{d.organisation && <div className="text-xs text-[var(--muted)]">{d.organisation}</div>}
</td>
<td className="text-sm text-[var(--muted)] hidden md:table-cell">{d.city}</td>
<td className="text-sm text-[var(--muted)] hidden lg:table-cell">{d.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
})
)}
<div className="mt-8 p-5 border border-dashed border-[var(--warm)] rounded-lg text-center">
<p className="text-sm text-[var(--muted)] mb-2">Daten unvollständig oder fehlerhaft?</p>
<a href="https://git.denode.eu/denode/plates" target="_blank" rel="noopener"
className="text-sm text-[var(--accent)] hover:underline font-medium">
git.denode.eu/denode/plates
</a>
</div>
</div>
);
}

82
frontend/app/globals.css Normal file
View file

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

View file

@ -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<string, string | undefined>) => {
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 (
<div className="space-y-4">
{/* Suchfeld */}
<div className="relative">
<input
type="text"
value={q}
onChange={(e) => 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"
/>
<button
onClick={() => update({ q: q || undefined })}
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-[var(--ink)] text-[var(--paper)] rounded-md text-xs"
style={{ fontFamily: "'Syne', sans-serif", fontWeight: 600 }}
>
Suchen
</button>
</div>
{/* Länder-Filter */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => update({ land: undefined })}
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
!activeLand
? "bg-[var(--ink)] text-[var(--paper)] border-[var(--ink)]"
: "border-[var(--warm)] text-[var(--muted)] hover:border-[var(--ink)] hover:text-[var(--ink)]"
}`}
>
Alle Länder
</button>
{countries
.filter((c) => c.count > 0)
.sort((a, b) => b.count - a.count)
.map((c) => (
<button
key={c.key}
onClick={() => update({ land: c.key === activeLand ? undefined : c.key })}
className={`px-3 py-1.5 rounded-md text-sm border transition-colors flex items-center gap-1.5 ${
c.key === activeLand
? "bg-[var(--ink)] text-[var(--paper)] border-[var(--ink)]"
: "border-[var(--warm)] text-[var(--muted)] hover:border-[var(--ink)] hover:text-[var(--ink)]"
}`}
>
<span>{c.flag}</span>
<span>{c.label}</span>
<span className="opacity-50 text-xs">({c.count})</span>
</button>
))}
</div>
{/* Historisch-Toggle */}
<label className="flex items-center gap-2 text-sm text-[var(--muted)] cursor-pointer w-fit">
<input
type="checkbox"
checked={historisch}
onChange={(e) => update({ historisch: e.target.checked ? "1" : undefined })}
className="rounded"
/>
Historische Kennzeichen einblenden
</label>
</div>
);
}

View file

@ -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<Kennzeichen>(page, 100, {
filter: filters.join(" && ") || undefined, sort: "code",
});
} catch { return null; }
}
async function getCountryCounts() {
try {
const all = await pb.collection("kennzeichen").getFullList<Kennzeichen>({ fields: "country", filter: 'active=true && country!="global"' });
const counts: Record<string, number> = {};
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 (
<div className="flex gap-2 mt-6 justify-center flex-wrap">
{page > 1 && (
<a href={makeHref(page - 1)} className="w-9 h-9 flex items-center justify-center rounded-md text-sm border border-[var(--warm)] hover:border-[var(--ink)]"></a>
)}
{pages.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="w-9 h-9 flex items-center justify-center text-[var(--muted)]"></span>
) : (
<a key={p} href={makeHref(p as number)}
className={`w-9 h-9 flex items-center justify-center rounded-md text-sm border transition-colors ${p === page ? "bg-[var(--ink)] text-[var(--paper)] border-[var(--ink)]" : "border-[var(--warm)] hover:border-[var(--ink)]"}`}>
{p}
</a>
)
)}
{page < totalPages && (
<a href={makeHref(page + 1)} className="w-9 h-9 flex items-center justify-center rounded-md text-sm border border-[var(--warm)] hover:border-[var(--ink)]"></a>
)}
</div>
);
}
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 (
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="mb-10">
<div className="text-xs font-mono text-[var(--muted)] tracking-widest uppercase mb-2">Datenbank</div>
<h1 style={{ fontFamily: "'Syne',sans-serif", fontWeight: 800, fontSize: "2.25rem", letterSpacing: "-0.03em" }} className="text-[var(--ink)]">Kennzeichen</h1>
{data && <p className="text-[var(--muted)] mt-1">{data.totalItems.toLocaleString("de")} Einträge</p>}
</div>
<KennzeichenFilter
countries={Object.entries(COUNTRY_LABELS).map(([key, label]) => ({ key, label, flag: COUNTRY_FLAGS[key] ?? "🌍", count: counts[key] ?? 0 }))}
activeLand={land} activeQ={q} historisch={historisch}
/>
<div className="border border-[var(--warm)] rounded-lg overflow-hidden bg-white/40 mt-6">
{items.length === 0 ? (
<div className="text-center py-20 text-[var(--muted)]">Keine Kennzeichen gefunden.</div>
) : (
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Kürzel</th><th>Ort / Kreis</th><th>Herleitung</th>
<th className="hidden md:table-cell">Region</th>
<th className="hidden lg:table-cell">Punkte</th>
<th className="hidden lg:table-cell">Bemerkung</th>
</tr>
</thead>
<tbody>
{items.map((kz) => (
<tr key={kz.id}>
<td><span className="kz-badge">{kz.code}</span></td>
<td className="font-medium">{kz.name}</td>
<td className="text-[var(--muted)] text-sm">{kz.derivation}</td>
<td className="text-[var(--muted)] text-sm hidden md:table-cell">{kz.region}</td>
<td className="text-[var(--muted)] text-sm hidden lg:table-cell font-mono">{kz.points ?? "—"}</td>
<td className="text-[var(--muted)] text-sm hidden lg:table-cell max-w-xs truncate">{kz.note}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{data && <Pagination page={page} totalPages={data.totalPages} land={land} q={q} />}
</div>
);
}

27
frontend/app/layout.tsx Normal file
View file

@ -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 (
<html lang="de">
<body>
<Nav />
<main className="min-h-screen">{children}</main>
<footer className="border-t border-[var(--warm)] mt-24 py-10 px-6">
<div className="max-w-6xl mx-auto flex items-center justify-between text-sm text-[var(--muted)]">
<span style={{ fontFamily: "'Syne', sans-serif", fontWeight: 600 }}>
Kennzeichensammler
</span>
<span>Daten: <a href="https://git.denode.eu/denode/plates" className="hover:text-[var(--accent)] transition-colors" target="_blank" rel="noopener">git.denode.eu/denode/plates</a></span>
</div>
</footer>
</body>
</html>
);
}

132
frontend/app/page.tsx Normal file
View file

@ -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 (
<div className="max-w-6xl mx-auto px-6 py-16">
{/* Hero */}
<div className="mb-20">
<div className="inline-block mb-6 px-3 py-1 border border-[var(--accent)] rounded-sm text-[var(--accent)] text-xs font-mono tracking-widest uppercase">
Open Data · Eigensammlung
</div>
<h1 style={{ fontFamily: "'Syne', sans-serif", fontWeight: 800, fontSize: "clamp(2.5rem, 6vw, 4.5rem)", lineHeight: 1.05, letterSpacing: "-0.03em" }}
className="text-[var(--ink)] mb-6 max-w-3xl">
Kennzeichen.<br />Gesammelt, dokumentiert,<br />
<span className="text-[var(--accent)]">geteilt.</span>
</h1>
<p className="text-[var(--muted)] text-lg max-w-xl leading-relaxed">
Persönliche Datenbank europäischer Kfz-Kennzeichen mit Sonderformen,
Diplomatenkennzeichen und allem was sich nicht einfach einordnen lässt.
</p>
<div className="flex gap-3 mt-8">
<Link href="/kennzeichen"
className="px-5 py-2.5 bg-[var(--ink)] text-[var(--paper)] rounded-md text-sm font-medium hover:bg-[var(--accent)] transition-colors"
style={{ fontFamily: "'Syne', sans-serif" }}>
Datenbank
</Link>
<Link href="/blog"
className="px-5 py-2.5 border border-[var(--warm)] text-[var(--ink)] rounded-md text-sm font-medium hover:border-[var(--ink)] transition-colors"
style={{ fontFamily: "'Syne', sans-serif" }}>
Blog lesen
</Link>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-20">
{[
{ label: "Kennzeichen", value: stats.kennzeichen.toLocaleString("de") },
{ label: "Diplomaten­kz.", value: stats.diplomaten.toLocaleString("de") },
{ label: "Gesehen", value: stats.gesehen.toLocaleString("de") },
].map((s) => (
<div key={s.label} className="border border-[var(--warm)] rounded-lg p-6 bg-white/40">
<div style={{ fontFamily: "'Syne', sans-serif", fontWeight: 700, fontSize: "2.25rem", lineHeight: 1 }}
className="text-[var(--ink)] mb-1">
{s.value}
</div>
<div className="text-sm text-[var(--muted)]">{s.label}</div>
</div>
))}
</div>
{/* Länder */}
<div className="mb-20">
<h2 style={{ fontFamily: "'Syne', sans-serif", fontWeight: 700, fontSize: "1.25rem" }}
className="mb-6 text-[var(--ink)]">
Länder in der Datenbank
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{countries.map(([key, label]) => (
<Link key={key} href={`/kennzeichen?land=${key}`}
className="flex items-center gap-2.5 px-4 py-3 border border-[var(--warm)] rounded-lg bg-white/40 hover:border-[var(--ink)] hover:bg-white/80 transition-all text-sm">
<span className="text-xl">{COUNTRY_FLAGS[key] ?? "🌍"}</span>
<span>{label}</span>
</Link>
))}
<Link href="/kennzeichen"
className="flex items-center justify-center gap-2 px-4 py-3 border border-dashed border-[var(--warm)] rounded-lg text-sm text-[var(--muted)] hover:text-[var(--ink)] hover:border-[var(--ink)] transition-all">
Alle anzeigen
</Link>
</div>
</div>
{/* Zuletzt gesehen */}
{latest && latest.items.length > 0 && (
<div>
<h2 style={{ fontFamily: "'Syne', sans-serif", fontWeight: 700, fontSize: "1.25rem" }}
className="mb-6 text-[var(--ink)]">
Zuletzt gesehen
</h2>
<div className="space-y-2">
{latest.items.map((item: any) => (
<div key={item.id} className="flex items-center gap-4 px-4 py-3 border border-[var(--warm)] rounded-lg bg-white/40">
{item.kennzeichen_code && <span className="kz-badge seen">{item.kennzeichen_code}</span>}
<div className="flex-1">
<span className="text-sm font-medium">{item.kennzeichen_name || "—"}</span>
<span className="text-xs text-[var(--muted)] ml-2">{COUNTRY_FLAGS[item.land] ?? ""}</span>
</div>
<span className="text-xs text-[var(--muted)] font-mono">
{new Date(item.datum).toLocaleDateString("de-DE")}
</span>
</div>
))}
</div>
<Link href="/sammlung" className="inline-block mt-4 text-sm text-[var(--accent)] hover:underline">
Gesamte Sammlung
</Link>
</div>
)}
</div>
);
}

View file

@ -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<string,number> = {};
const byMonth: Record<string,number> = {};
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 (
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="mb-10">
<div className="text-xs font-mono text-[var(--muted)] tracking-widest uppercase mb-2">Persönlich</div>
<h1 style={{ fontFamily:"'Syne',sans-serif", fontWeight:800, fontSize:"2.25rem", letterSpacing:"-0.03em" }} className="text-[var(--ink)]">Meine Sammlung</h1>
</div>
<div className="border border-[var(--warm)] rounded-lg p-8 bg-white/40 mb-8">
<div className="flex items-end justify-between mb-4">
<div>
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:800, fontSize:"3rem", lineHeight:1 }} className="text-[var(--ink)]">{stats.total.toLocaleString("de")}</div>
<div className="text-[var(--muted)] text-sm mt-1">von {stats.totalKz.toLocaleString("de")} Kennzeichen gesehen</div>
</div>
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:"2rem" }} className="text-[var(--accent)]">{percent}%</div>
</div>
<div className="h-3 bg-[var(--warm)] rounded-full overflow-hidden">
<div className="h-full bg-[var(--accent)] rounded-full" style={{ width:`${percent}%` }} />
</div>
</div>
<div className="grid md:grid-cols-2 gap-6 mb-8">
<div className="border border-[var(--warm)] rounded-lg p-6 bg-white/40">
<h2 style={{ fontFamily:"'Syne',sans-serif", fontWeight:700 }} className="mb-4">Nach Land</h2>
<div className="space-y-2">
{topCountries.map(([country, count]) => (
<div key={country} className="flex items-center gap-3 text-sm">
<span className="text-lg w-7">{COUNTRY_FLAGS[country] ?? "🌍"}</span>
<span className="flex-1 text-[var(--muted)]">{COUNTRY_LABELS[country] ?? country}</span>
<span className="font-mono font-medium">{count}</span>
</div>
))}
</div>
</div>
<div className="border border-[var(--warm)] rounded-lg p-6 bg-white/40">
<h2 style={{ fontFamily:"'Syne',sans-serif", fontWeight:700 }} className="mb-4">Aktivität</h2>
<div className="space-y-3">
{recentMonths.map(([month, count]) => {
const max = Math.max(...recentMonths.map(([,c]) => c));
return (
<div key={month} className="flex items-center gap-3 text-sm">
<span className="font-mono text-[var(--muted)] w-16">{new Date(month+"-01").toLocaleDateString("de-DE",{month:"short",year:"2-digit"})}</span>
<div className="flex-1 h-2 bg-[var(--warm)] rounded-full overflow-hidden">
<div className="h-full bg-[var(--ink)] rounded-full" style={{ width:`${Math.round((count/max)*100)}%` }} />
</div>
<span className="font-mono font-medium w-6 text-right">{count}</span>
</div>
);
})}
</div>
</div>
</div>
{latest && latest.items.length > 0 && (
<div className="border border-[var(--warm)] rounded-lg overflow-hidden bg-white/40">
<div className="px-6 py-4 border-b border-[var(--warm)]">
<h2 style={{ fontFamily:"'Syne',sans-serif", fontWeight:700 }}>Zuletzt gesehen</h2>
</div>
<div className="divide-y divide-[var(--warm)]">
{(latest.items as any[]).map((item) => (
<div key={item.id} className="px-6 py-3 flex items-center gap-4">
<span className="kz-badge seen">{item.kennzeichen_code}</span>
<div className="flex-1">
<span className="text-sm font-medium">{item.kennzeichen_name}</span>
<span className="text-xs text-[var(--muted)] ml-2">{COUNTRY_FLAGS[item.land] ?? ""}</span>
</div>
<span className="text-xs font-mono text-[var(--muted)]">
{new Date(item.datum).toLocaleDateString("de-DE")}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -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 (
<header className="sticky top-0 z-50 border-b border-[var(--warm)] bg-[var(--paper)]/90 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<Link
href="/"
style={{ fontFamily: "'Syne', sans-serif", fontWeight: 700, fontSize: 15, letterSpacing: "0em" }}
className="text-[var(--ink)] hover:text-[var(--accent)] transition-colors"
>
Kennzeichensammler
</Link>
<nav className="flex items-center gap-1">
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className={clsx(
"px-3 py-1.5 rounded-md text-sm transition-colors",
path === l.href
? "bg-[var(--ink)] text-[var(--paper)]"
: "text-[var(--muted)] hover:text-[var(--ink)] hover:bg-[var(--warm)]"
)}
style={{ fontFamily: "'Syne', sans-serif", fontWeight: 500 }}
>
{l.label}
</Link>
))}
</nav>
</div>
</header>
);
}

150
frontend/lib/pb.ts Normal file
View file

@ -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<string, string> = {
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<string, string> = {
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<Kennzeichen>(page, perPage, {
filter: filters.join(" && ") || undefined,
sort: "country,code",
});
}
export async function getKennzeichenByCode(code: string) {
return pb.collection("kennzeichen").getFirstListItem<Kennzeichen>(
`code="${code.toUpperCase()}"`,
);
}
export async function getDiplomatenkennzeichen(baseCountry = "germany") {
return pb.collection("diplomatenkennzeichen").getFullList<Diplomatenkennzeichen>({
filter: `base_country="${baseCountry}"`,
sort: "code",
});
}
export async function getBlogPosts() {
return pb.collection("blog_posts").getList<BlogPost>(1, 20, {
sort: "-created",
});
}
export async function getGesehen() {
return pb.collection("gesehen").getFullList<Gesehen>({
sort: "-datum",
});
}

11
frontend/next.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "**" },
],
},
};
module.exports = nextConfig;

33
frontend/package.json Normal file
View file

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

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

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

21
frontend/tsconfig.json Normal file
View file

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

27
scripts/backup_pb.sh Executable file
View file

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

246
scripts/cleanup.py Normal file
View file

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

238
scripts/fix_schema.py Normal file
View file

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

292
scripts/import.py Normal file
View file

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

455
scripts/import1.py Normal file
View file

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

455
scripts/import_all.py Normal file
View file

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

43
scripts/import_app_backup.sh Executable file
View file

@ -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 <pfad-zum-backup-file>}"
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 ==="

58
scripts/sync.sh Executable file
View file

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