Threading: Antworten (reply_to) + Focus-View
- entry: reply_to/reply_count/last_activity; reply_count bubbelt bis Root
- Hauptfeed nur Roots nach last_activity, Profil-Feed inkl. Antworten
- GET /entry/{pid}/thread (Ahnen+Antworten), GET /e/{pid} Focus-Seite
- Frontend: Antworten-Link im Feed, entry.html/entry.js Focus-Seite
- notes/migrations.md: ALTER TABLE fuer Prod
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user