Migration, Schema-NOT-NULL & klickbare Karten
migrate.sh erzeugt nicht-destruktiv eine frische kver.db aus original-kver.db mit dem aktuellen Go-Schema (NULL/'none'-filepath normalisiert, Soft-Deletes und Waisen verworfen). Das entry-Schema erzwingt jetzt NOT NULL DEFAULT '' auf content/filepath – NULL-filepath ließ scanEntries Zeilen still überspringen. Feed-Karten sind per Klick navigierbar (mit Hover-Highlight); DB-Artefakte landen im .gitignore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
venv/
|
venv/
|
||||||
kver.db
|
kver.db
|
||||||
|
kver.db.bak
|
||||||
|
original-kver.db
|
||||||
/kver
|
/kver
|
||||||
*.env
|
*.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ CREATE TABLE IF NOT EXISTS session (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entry (
|
CREATE TABLE IF NOT EXISTS entry (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
pid INTEGER UNIQUE,
|
pid INTEGER UNIQUE NOT NULL,
|
||||||
uid INTEGER,
|
uid INTEGER NOT NULL,
|
||||||
created_at INTEGER,
|
created_at INTEGER NOT NULL,
|
||||||
content TEXT,
|
content TEXT NOT NULL DEFAULT '',
|
||||||
filepath TEXT,
|
filepath TEXT NOT NULL DEFAULT '',
|
||||||
reply_to INTEGER NOT NULL DEFAULT 0,
|
reply_to INTEGER NOT NULL DEFAULT 0,
|
||||||
reply_count INTEGER NOT NULL DEFAULT 0,
|
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_activity INTEGER,
|
last_activity INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted INTEGER NOT NULL DEFAULT 0
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# migrate.sh — liest kver-original.db und erzeugt daraus eine frische kver.db
|
||||||
|
# mit dem aktuellen Go-Schema. Die Quelldatenbank wird nie verändert.
|
||||||
|
# Idempotent: kver.db wird bei jedem Lauf neu erstellt.
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# ./migrate.sh [quelldatei [zieldatei]] # Standard: kver-original.db -> kver.db
|
||||||
|
#
|
||||||
|
# Schalter (als Umgebungsvariablen):
|
||||||
|
# REPLIES=keep|drop Antworten (reply_to != 0) übernehmen oder verwerfen (Default: keep)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="${1:-original-kver.db}"
|
||||||
|
DST="${2:-kver.db}"
|
||||||
|
REPLIES="${REPLIES:-keep}"
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC" ]]; then
|
||||||
|
echo "FEHLER: Quelldatenbank '$SRC' nicht gefunden." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$REPLIES" != "keep" && "$REPLIES" != "drop" ]]; then
|
||||||
|
echo "FEHLER: REPLIES muss 'keep' oder 'drop' sein (war: '$REPLIES')." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$SRC" -ef "$DST" ]]; then
|
||||||
|
echo "FEHLER: Quelle und Ziel sind dieselbe Datei." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
qs() { sqlite3 "$SRC" "$1"; }
|
||||||
|
qd() { sqlite3 "$DST" "$1"; }
|
||||||
|
|
||||||
|
echo "== kver-Migration =="
|
||||||
|
echo "Quelle: $SRC"
|
||||||
|
echo "Ziel: $DST"
|
||||||
|
echo "REPLIES: $REPLIES"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "Quelle:"
|
||||||
|
echo " Einträge gesamt: $(qs 'SELECT COUNT(*) FROM entry;')"
|
||||||
|
echo " davon deleted=1: $(qs 'SELECT COUNT(*) FROM entry WHERE deleted=1;')"
|
||||||
|
echo " davon Antworten: $(qs 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
|
||||||
|
echo " User: $(qs 'SELECT COUNT(*) FROM user;')"
|
||||||
|
|
||||||
|
# Zieldatenbank immer frisch anlegen
|
||||||
|
rm -f "$DST"
|
||||||
|
echo
|
||||||
|
echo "Erstelle $DST mit aktuellem Schema..."
|
||||||
|
|
||||||
|
sqlite3 "$DST" <<'SCHEMA'
|
||||||
|
.bail on
|
||||||
|
CREATE TABLE user (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid INTEGER UNIQUE,
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
password BLOB,
|
||||||
|
created_at INTEGER,
|
||||||
|
last_login INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE session (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid INTEGER,
|
||||||
|
value TEXT UNIQUE,
|
||||||
|
created_at INTEGER,
|
||||||
|
expires INTEGER,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE entry (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pid INTEGER UNIQUE NOT NULL,
|
||||||
|
uid INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
filepath TEXT NOT NULL DEFAULT '',
|
||||||
|
reply_to INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_activity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE vote (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid INTEGER,
|
||||||
|
pid INTEGER,
|
||||||
|
mode TEXT,
|
||||||
|
UNIQUE(uid, pid)
|
||||||
|
);
|
||||||
|
SCHEMA
|
||||||
|
|
||||||
|
# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind.
|
||||||
|
# Ältere Python-DBs haben reply_count und last_activity noch nicht.
|
||||||
|
has_col() {
|
||||||
|
sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name='$1';"
|
||||||
|
}
|
||||||
|
|
||||||
|
reply_count_expr="0"
|
||||||
|
[[ "$(has_col reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)"
|
||||||
|
|
||||||
|
last_activity_expr="e.created_at"
|
||||||
|
[[ "$(has_col last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)"
|
||||||
|
|
||||||
|
replies_filter=""
|
||||||
|
[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0"
|
||||||
|
|
||||||
|
has_vote_table=$(qs "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vote';")
|
||||||
|
|
||||||
|
# Quell-Pfad für SQL-String-Literal escapen (einfache Anführungszeichen verdoppeln)
|
||||||
|
src_sql="${SRC//\'/\'\'}"
|
||||||
|
|
||||||
|
sqlite3 "$DST" <<SQL
|
||||||
|
.bail on
|
||||||
|
ATTACH '$src_sql' AS src;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
|
||||||
|
INSERT INTO user (id, uid, username, password, created_at, last_login)
|
||||||
|
SELECT id, uid, username, password, created_at, last_login FROM src.user;
|
||||||
|
|
||||||
|
-- Einträge transformieren:
|
||||||
|
-- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string)
|
||||||
|
-- content NULL -> ''
|
||||||
|
-- deleted: nur nicht-gelöschte Einträge werden übernommen
|
||||||
|
-- orphans: Einträge ohne bekannten User werden verworfen
|
||||||
|
INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, deleted)
|
||||||
|
SELECT
|
||||||
|
e.pid,
|
||||||
|
e.uid,
|
||||||
|
e.created_at,
|
||||||
|
COALESCE(e.content, ''),
|
||||||
|
COALESCE(NULLIF(e.filepath, 'none'), ''),
|
||||||
|
COALESCE(e.reply_to, 0),
|
||||||
|
$reply_count_expr,
|
||||||
|
$last_activity_expr,
|
||||||
|
0
|
||||||
|
FROM src.entry e
|
||||||
|
WHERE e.deleted = 0
|
||||||
|
AND e.uid IN (SELECT uid FROM src.user)
|
||||||
|
$replies_filter;
|
||||||
|
|
||||||
|
-- Sessions leer lassen: Alt-Tokens sind Integer (Python-Format),
|
||||||
|
-- Go erzeugt crypto/rand-Hex-Tokens -> alle Alt-Sessions sind wertlos.
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
DETACH src;
|
||||||
|
VACUUM;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Votes separat, da die Tabelle in alten DBs fehlen kann
|
||||||
|
if [[ "$has_vote_table" == "1" ]]; then
|
||||||
|
sqlite3 "$DST" <<SQL2
|
||||||
|
.bail on
|
||||||
|
ATTACH '$src_sql' AS src;
|
||||||
|
INSERT OR IGNORE INTO vote (uid, pid, mode)
|
||||||
|
SELECT uid, pid, mode FROM src.vote
|
||||||
|
WHERE uid IN (SELECT uid FROM entry)
|
||||||
|
AND pid IN (SELECT pid FROM entry);
|
||||||
|
DETACH src;
|
||||||
|
SQL2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Nachher:"
|
||||||
|
echo " Einträge gesamt: $(qd 'SELECT COUNT(*) FROM entry;')"
|
||||||
|
echo " davon Antworten: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
|
||||||
|
echo " sichtbar im Feed: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to=0;')"
|
||||||
|
echo " User: $(qd 'SELECT COUNT(*) FROM user;')"
|
||||||
|
echo " Votes: $(qd 'SELECT COUNT(*) FROM vote;')"
|
||||||
|
|
||||||
|
# Medien-Check: referenzierte Bilder, die im Dateisystem fehlen
|
||||||
|
missing=0
|
||||||
|
total=0
|
||||||
|
while IFS= read -r fp; do
|
||||||
|
[[ -z "$fp" ]] && continue
|
||||||
|
total=$((total+1))
|
||||||
|
[[ -f "$fp" ]] || missing=$((missing+1))
|
||||||
|
done < <(qd "SELECT filepath FROM entry WHERE filepath != '';")
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Medien:"
|
||||||
|
echo " referenziert: $total"
|
||||||
|
echo " fehlend im Dateisystem: $missing"
|
||||||
|
if [[ "$missing" -gt 0 ]]; then
|
||||||
|
echo " -> static/media/ aus der Produktion kopieren, sonst liefern diese Bilder 404."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Fertig."
|
||||||
@@ -5,6 +5,22 @@ Eine frische DB (Schema aus `db.go`) braucht keine davon.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `filepath` NULL → leerer String
|
||||||
|
|
||||||
|
**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält.
|
||||||
|
|
||||||
|
**Hintergrund:** `scanEntries` scannt `filepath` in ein Go-`string`-Feld (kein Pointer).
|
||||||
|
SQLite NULL in ein `string` scannen schlägt fehl; `scanEntries` überspringt diese Zeilen
|
||||||
|
still mit `continue`. Ergebnis: Einträge ohne Bild fehlen im Feed komplett.
|
||||||
|
Das aktuelle Go-Schema definiert `filepath TEXT NOT NULL DEFAULT ''`. `migrate.sh`
|
||||||
|
normalisiert NULL → `''` beim Kopieren aus der Quelldatenbank.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE entry SET filepath = '' WHERE filepath IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `filepath`-Sentinel `"none"` → leerer String
|
## `filepath`-Sentinel `"none"` → leerer String
|
||||||
|
|
||||||
**Wann:** beim Umstieg auf die Go-Version, falls eine alte `kver.db` aus der
|
**Wann:** beim Umstieg auf die Go-Version, falls eine alte `kver.db` aus der
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ a.button:hover, button:hover {
|
|||||||
/* --- Einträge --- */
|
/* --- Einträge --- */
|
||||||
.card {
|
.card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -141,6 +142,10 @@ a.button:hover, button:hover {
|
|||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background-color: #f7f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
.card .content {
|
.card .content {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function renderEntry(e) {
|
|||||||
<div class="content">[deleted]</div>
|
<div class="content">[deleted]</div>
|
||||||
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
||||||
`;
|
`;
|
||||||
|
el.addEventListener("click", (ev) => {
|
||||||
|
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
|
||||||
|
});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +68,9 @@ function renderEntry(e) {
|
|||||||
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
||||||
`;
|
`;
|
||||||
setupVoting(el, e.pid);
|
setupVoting(el, e.pid);
|
||||||
|
el.addEventListener("click", (ev) => {
|
||||||
|
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
|
||||||
|
});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user