diff --git a/db.go b/db.go index 3d379dd..2067d79 100644 --- a/db.go +++ b/db.go @@ -28,12 +28,15 @@ CREATE TABLE IF NOT EXISTS session ( ); CREATE TABLE IF NOT EXISTS entry ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pid INTEGER UNIQUE, - uid INTEGER, - created_at INTEGER, - content TEXT, - filepath TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + pid INTEGER UNIQUE, + uid INTEGER, + created_at INTEGER, + content TEXT, + filepath TEXT, + reply_to INTEGER NOT NULL DEFAULT 0, + reply_count INTEGER NOT NULL DEFAULT 0, + last_activity INTEGER ); CREATE TABLE IF NOT EXISTS vote ( diff --git a/endpoints_test.go b/endpoints_test.go index 439064b..ffef53e 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -221,6 +221,100 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) { } } +// createReply erstellt eine Antwort auf parentPID und liefert deren pid. +func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 { + t.Helper() + resp := postForm(t, c, srv.URL+"/entry/create", url.Values{ + "content": {content}, "reply_to": {fmt.Sprint(parentPID)}, + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create reply: status %d", resp.StatusCode) + } + var out struct { + PID int64 `json:"pid"` + } + json.NewDecoder(resp.Body).Decode(&out) + return out.PID +} + +type threadEntry struct { + PID int64 `json:"pid"` + ReplyCount int64 `json:"reply_count"` +} + +type threadView struct { + Entry threadEntry `json:"entry"` + Ancestors []threadEntry `json:"ancestors"` + Replies []threadEntry `json:"replies"` +} + +func getThread(t *testing.T, srv *httptest.Server, pid int64) threadView { + t.Helper() + resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("thread %d: status %d", pid, resp.StatusCode) + } + var tv threadView + json.NewDecoder(resp.Body).Decode(&tv) + return tv +} + +func TestThreading(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + + root := createEntry(t, alice, srv, "wurzel") + mid := createReply(t, alice, srv, "antwort 1", root) + createReply(t, alice, srv, "antwort 2", mid) // tiefere Antwort + + // reply_count bubblet bis zum Root: Root = 2 (ganzer Teilbaum), mid = 1. + if tv := getThread(t, srv, root); tv.Entry.ReplyCount != 2 { + t.Fatalf("root reply_count: erwartet 2, bekam %d", tv.Entry.ReplyCount) + } + rootThread := getThread(t, srv, root) + if len(rootThread.Replies) != 1 || rootThread.Replies[0].PID != mid { + t.Fatalf("root replies: erwartet [%d], bekam %+v", mid, rootThread.Replies) + } + if len(rootThread.Ancestors) != 0 { + t.Fatalf("root ancestors: erwartet leer, bekam %+v", rootThread.Ancestors) + } + + midThread := getThread(t, srv, mid) + if midThread.Entry.ReplyCount != 1 { + t.Fatalf("mid reply_count: erwartet 1, bekam %d", midThread.Entry.ReplyCount) + } + if len(midThread.Ancestors) != 1 || midThread.Ancestors[0].PID != root { + t.Fatalf("mid ancestors: erwartet [%d], bekam %+v", root, midThread.Ancestors) + } + + // Hauptfeed: nur der Root, keine Antworten. + resp, _ := http.Get(srv.URL + "/entry/feed/0") + var feed []struct { + PID int64 `json:"pid"` + } + json.NewDecoder(resp.Body).Decode(&feed) + resp.Body.Close() + if len(feed) != 1 || feed[0].PID != root { + t.Fatalf("hauptfeed: erwartet nur Root %d, bekam %+v", root, feed) + } + + // Profil-Feed: alle drei Beiträge (Root + Antworten). + resp, _ = http.Get(srv.URL + "/u/alice/feed/0") + var profile []struct { + PID int64 `json:"pid"` + } + json.NewDecoder(resp.Body).Decode(&profile) + resp.Body.Close() + if len(profile) != 3 { + t.Fatalf("profil-feed: erwartet 3 Beiträge, bekam %d", len(profile)) + } +} + func TestRenameUser(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") diff --git a/entry.go b/entry.go index 7932d68..5063b98 100644 --- a/entry.go +++ b/entry.go @@ -20,17 +20,50 @@ import ( ) type Entry struct { - PID int64 `json:"pid"` - UID int64 `json:"uid"` - CreatedAt int64 `json:"created_at"` - Content string `json:"content"` - Filepath string `json:"filepath"` - Username string `json:"username"` + PID int64 `json:"pid"` + UID int64 `json:"uid"` + CreatedAt int64 `json:"created_at"` + Content string `json:"content"` + Filepath string `json:"filepath"` + ReplyTo int64 `json:"reply_to"` + ReplyCount int64 `json:"reply_count"` + LastActivity int64 `json:"last_activity"` + Username string `json:"username"` } -// feedPage liefert eine Feed-Seite (20 Beiträge, neueste zuerst). Ist uid != 0, -// werden nur die Beiträge dieses Nutzers geliefert. -func feedPage(page int, uid int64) ([]Entry, error) { +const entrySelect = ` + SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, + e.reply_to, e.reply_count, e.last_activity, u.username + FROM entry e JOIN user u ON u.uid = e.uid` + +// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. +func scanEntries(rows *sql.Rows) ([]Entry, error) { + defer rows.Close() + entries := []Entry{} + for rows.Next() { + var e Entry + if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Username); err != nil { + continue + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows. +func entryByPID(pid int64) (Entry, error) { + var e Entry + err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan( + &e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Username) + return e, err +} + +// feedPage liefert eine Feed-Seite (20 Beiträge). Ist uid != 0, nur die Beiträge +// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität; +// sonst werden alle Beiträge chronologisch geliefert. +func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { if page < 0 { page = 0 } @@ -39,37 +72,37 @@ func feedPage(page int, uid int64) ([]Entry, error) { } const count = 20 - query := ` - SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, u.username - FROM entry e JOIN user u ON u.uid = e.uid` + query := entrySelect args := []any{} + where := []string{} if uid != 0 { - query += ` WHERE e.uid = ?` + where = append(where, "e.uid = ?") args = append(args, uid) } - query += ` ORDER BY e.created_at DESC LIMIT ? OFFSET ?` + if rootsOnly { + where = append(where, "e.reply_to = 0") + } + if len(where) > 0 { + query += " WHERE " + strings.Join(where, " AND ") + } + if rootsOnly { + query += ` ORDER BY e.last_activity DESC` + } else { + query += ` ORDER BY e.created_at DESC` + } + query += ` LIMIT ? OFFSET ?` args = append(args, count, page*count) rows, err := db.Query(query, args...) if err != nil { return nil, err } - defer rows.Close() - - entries := []Entry{} - for rows.Next() { - var e Entry - if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, &e.Username); err != nil { - continue - } - entries = append(entries, e) - } - return entries, rows.Err() + return scanEntries(rows) } func handleFeed(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(chi.URLParam(r, "page")) - entries, err := feedPage(page, 0) + entries, err := feedPage(page, 0, true) if err != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return @@ -77,7 +110,7 @@ func handleFeed(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, entries) } -// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich). +// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten). func handleUserFeed(w http.ResponseWriter, r *http.Request) { var uid int64 if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil { @@ -86,7 +119,7 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) { } page, _ := strconv.Atoi(chi.URLParam(r, "page")) - entries, err := feedPage(page, uid) + entries, err := feedPage(page, uid, false) if err != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return @@ -94,6 +127,59 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, entries) } +// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten +// dann über /entry/{pid}/thread. +func serveEntryPage(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "web/entry.html") +} + +// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den +// direkten Antworten (öffentlich). +func handleThread(w http.ResponseWriter, r *http.Request) { + pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige pid") + return + } + + entry, err := entryByPID(pid) + if err != nil { + writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") + return + } + + // Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst). + ancestors := []Entry{} + for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; { + a, err := entryByPID(cur) + if err != nil { + break + } + ancestors = append(ancestors, a) + cur = a.ReplyTo + } + for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 { + ancestors[i], ancestors[j] = ancestors[j], ancestors[i] + } + + rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datenbankfehler") + return + } + replies, err := scanEntries(rows) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datenbankfehler") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "entry": entry, + "ancestors": ancestors, + "replies": replies, + }) +} + func handleCreateEntry(w http.ResponseWriter, r *http.Request) { uid := uidFromContext(r.Context()) @@ -107,6 +193,14 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) { content = content[:1000] } + replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64) + if replyTo != 0 { + if _, err := entryByPID(replyTo); err != nil { + writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden") + return + } + } + filepath := "" if r.MultipartForm != nil { if files := r.MultipartForm.File["file"]; len(files) > 0 { @@ -123,10 +217,37 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) { pid := int64(rand.IntN(999999999999)) now := time.Now().Unix() - if _, err := db.Exec( - `INSERT INTO entry (pid, uid, created_at, content, filepath) VALUES (?, ?, ?, ?, ?)`, - pid, uid, now, content, filepath, + + tx, err := db.Begin() + if err != nil { + writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") + return + } + if _, err := tx.Exec( + `INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity) + VALUES (?, ?, ?, ?, ?, ?, 0, ?)`, + pid, uid, now, content, filepath, replyTo, now, ); err != nil { + tx.Rollback() + writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") + return + } + // Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben. + for cur, i := replyTo, 0; cur != 0 && i < 50; i++ { + if _, err := tx.Exec( + `UPDATE entry SET reply_count = reply_count + 1, + last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`, + now, cur, + ); err != nil { + tx.Rollback() + writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") + return + } + if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil { + break + } + } + if err := tx.Commit(); err != nil { writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") return } @@ -135,6 +256,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) { "pid": pid, "content": content, "filepath": filepath, + "reply_to": replyTo, }) } diff --git a/main.go b/main.go index c60e92c..b4de427 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,8 @@ func routes() http.Handler { // --- Entries --- r.Get("/entry/feed/{page}", handleFeed) r.Get("/entry/{pid}/votes", handleVotes) + r.Get("/entry/{pid}/thread", handleThread) + r.Get("/e/{pid}", serveEntryPage) // --- User (öffentlich) --- r.Get("/u/{username}", serveUserPage) diff --git a/notes/migrations.md b/notes/migrations.md index 53b1e4d..b32d425 100644 --- a/notes/migrations.md +++ b/notes/migrations.md @@ -22,3 +22,27 @@ UPDATE entry SET filepath = '' WHERE filepath = 'none'; ``` Idempotent — kann gefahrlos mehrfach laufen. + +--- + +## Threading-Spalten in `entry` + +**Wann:** beim Deploy der Threading-Version gegen eine bestehende `kver.db`, die +die Tabelle `entry` noch ohne die Threading-Spalten hat. + +**Hintergrund:** Das Threading führt `reply_to` (pid des Eltern-Posts, `0` = Root), +`reply_count` (Anzahl Antworten im gesamten Teilbaum) und `last_activity` +(Sortierschlüssel des Hauptfeeds) ein. `CREATE TABLE IF NOT EXISTS` ergänzt diese +Spalten bei einer vorhandenen Tabelle nicht. + +**Migration:** +```sql +ALTER TABLE entry ADD COLUMN reply_to INTEGER NOT NULL DEFAULT 0; +ALTER TABLE entry ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE entry ADD COLUMN last_activity INTEGER; +UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL; +``` + +Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`) +mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur +einmalig ausführen. diff --git a/web/entry.html b/web/entry.html new file mode 100644 index 0000000..9e0c813 --- /dev/null +++ b/web/entry.html @@ -0,0 +1,36 @@ + + + + + + kver + + + +
+

kver

+
zurück zum Feed
+
+ +
+
+
+ + + + +

Antworten

+
+
+ + + + + diff --git a/web/js/entry.js b/web/js/entry.js new file mode 100644 index 0000000..728d21c --- /dev/null +++ b/web/js/entry.js @@ -0,0 +1,55 @@ +"use strict"; + +// pid aus dem Pfad /e/. +const pid = location.pathname.split("/")[2] || ""; + +document.getElementById("reply-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + fd.append("reply_to", pid); + const res = await api.post("/entry/create", fd); + if (res.ok) { + e.target.reset(); + document.getElementById("reply-msg").textContent = ""; + load(); + } else { + document.getElementById("reply-msg").textContent = + res.data.error || "Antwort fehlgeschlagen."; + } +}); + +async function load() { + const data = await api.get(`/entry/${pid}/thread`); + + const focus = document.getElementById("focus"); + if (data.error) { + focus.textContent = "Beitrag nicht gefunden"; + return; + } + + // Ahnenkette (Root zuerst), dann der fokussierte Beitrag. + const ancestors = document.getElementById("ancestors"); + ancestors.innerHTML = ""; + data.ancestors.forEach((a) => ancestors.appendChild(renderEntry(a))); + + focus.innerHTML = ""; + focus.appendChild(renderEntry(data.entry)); + + // Reply-Box nur eingeloggt zeigen. + const h = await api.get("/auth/headerbar"); + document.getElementById("reply-box").hidden = !h.loggedin; + + // Direkte Antworten. + const replies = document.getElementById("replies"); + replies.innerHTML = ""; + if (!data.replies.length) { + const p = document.createElement("p"); + p.className = "muted"; + p.textContent = "Noch keine Antworten."; + replies.appendChild(p); + } else { + data.replies.forEach((e) => replies.appendChild(renderEntry(e))); + } +} + +load(); diff --git a/web/js/feed.js b/web/js/feed.js index 993a67a..47b68c9 100644 --- a/web/js/feed.js +++ b/web/js/feed.js @@ -40,6 +40,9 @@ function renderEntry(e) { media = ``; } + const replyLabel = + e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`; + el.innerHTML = `
${escapeHtml(e.username)} · ${when}
${linkify(e.content)}
@@ -49,6 +52,7 @@ function renderEntry(e) { – / – +
${replyLabel}
`; setupVoting(el, e.pid); return el;