Initial webiste
This commit is contained in:
commit
df98aaaac9
30 changed files with 3267 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
18
.gitignore
vendored
Normal 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
268
SETUP.md
Normal 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 2–5 Min)
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Logs ansehen
|
||||||
|
docker compose logs -f nextjs
|
||||||
|
docker compose logs -f pocketbase
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Website ist jetzt unter Port 8888 erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Daten importieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python-Abhängigkeiten
|
||||||
|
pip3 install requests
|
||||||
|
|
||||||
|
# plates-Repo klonen (falls noch nicht vorhanden)
|
||||||
|
git clone https://git.denode.eu/denode/plates /opt/plates
|
||||||
|
|
||||||
|
# data.db auf den Server bringen
|
||||||
|
scp data.db user@dein-server:/opt/
|
||||||
|
|
||||||
|
# Import starten
|
||||||
|
cd /opt/kennzeichen-website
|
||||||
|
python3 scripts/import.py \
|
||||||
|
--pb-url http://localhost:8090 \
|
||||||
|
--pb-email deine@email.de \
|
||||||
|
--pb-password deinpasswort \
|
||||||
|
--repo-path /opt/plates \
|
||||||
|
--db-path /opt/data.db
|
||||||
|
```
|
||||||
|
|
||||||
|
PocketBase ist beim Import direkt über localhost:8090 erreichbar
|
||||||
|
(kein Port nach außen nötig, nur von der Servermaschine selbst).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reverse Proxy konfigurieren
|
||||||
|
|
||||||
|
In deinem Hetzner-Reverse-Proxy die Domain auf `<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
20
docker-compose.yml
Normal 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:
|
||||||
15
docker/pocketbase/Dockerfile
Normal file
15
docker/pocketbase/Dockerfile
Normal 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
26
frontend/Dockerfile
Normal 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"]
|
||||||
32
frontend/app/blog/[slug]/page.tsx
Normal file
32
frontend/app/blog/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/app/blog/page.tsx
Normal file
41
frontend/app/blog/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
frontend/app/diplomatenkennzeichen/page.tsx
Normal file
158
frontend/app/diplomatenkennzeichen/page.tsx
Normal 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
82
frontend/app/globals.css
Normal 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); }
|
||||||
99
frontend/app/kennzeichen/KennzeichenFilter.tsx
Normal file
99
frontend/app/kennzeichen/KennzeichenFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/app/kennzeichen/page.tsx
Normal file
121
frontend/app/kennzeichen/page.tsx
Normal 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
27
frontend/app/layout.tsx
Normal 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
132
frontend/app/page.tsx
Normal 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: "Diplomatenkz.", 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/app/sammlung/page.tsx
Normal file
107
frontend/app/sammlung/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/components/ui/Nav.tsx
Normal file
47
frontend/components/ui/Nav.tsx
Normal 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
150
frontend/lib/pb.ts
Normal 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
11
frontend/next.config.js
Normal 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
33
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
32
frontend/tailwind.config.ts
Normal file
32
frontend/tailwind.config.ts
Normal 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
21
frontend/tsconfig.json
Normal 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
27
scripts/backup_pb.sh
Executable 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
246
scripts/cleanup.py
Normal 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
238
scripts/fix_schema.py
Normal 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
292
scripts/import.py
Normal 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
455
scripts/import1.py
Normal 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
455
scripts/import_all.py
Normal 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
43
scripts/import_app_backup.sh
Executable 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
58
scripts/sync.sh
Executable 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 ==="
|
||||||
Loading…
Reference in a new issue