New Sign in

fierj

Public

Tiny personal git forge

← fierj / thread.go
package main

import (
	"crypto/rand"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
)

var threadLocks sync.Map

func lockThread(threadPath string) func() {
	mu := &sync.Mutex{}
	actual, _ := threadLocks.LoadOrStore(threadPath, mu)
	mu = actual.(*sync.Mutex)
	mu.Lock()
	return func() { mu.Unlock() }
}

type Thread struct {
	ID         string  `json:"id"`
	State      string  `json:"state"` // "open" or "closed"
	Title      string  `json:"title"`
	Body       string  `json:"body"`
	Author     string  `json:"author"`      // AP actor URI or "admin"
	AuthorName string  `json:"author_name"` // display name
	Created    string  `json:"created"`
	Updated    string  `json:"updated"`
	Replies    []Reply `json:"replies"`
}

type Reply struct {
	ID         string `json:"id"`
	ParentID   string `json:"parent_id,omitempty"` // ID of parent reply (empty = reply to thread root)
	Body       string `json:"body"`
	Author     string `json:"author"`
	AuthorName string `json:"author_name"`
	Created    string `json:"created"`
}

func threadsDir(repoPath string) string {
	return filepath.Join(repoPath, ".forge", "threads")
}

func newULID() string {
	t := time.Now().UnixMilli()
	b := make([]byte, 5)
	rand.Read(b)
	return fmt.Sprintf("%012x%x", t, b)
}

func createThread(repoPath, title, body, author, authorName string) (*Thread, error) {
	dir := threadsDir(repoPath)
	if err := os.MkdirAll(dir, 0755); err != nil {
		return nil, err
	}

	now := time.Now().UTC().Format(time.RFC3339)
	t := &Thread{
		ID:         newULID(),
		State:      "open",
		Title:      title,
		Body:       body,
		Author:     author,
		AuthorName: authorName,
		Created:    now,
		Updated:    now,
		Replies:    []Reply{},
	}

	return t, writeThread(repoPath, t)
}

func addReply(repoPath, threadID, body, author, authorName string) error {
	return addNestedReply(repoPath, threadID, "", body, author, authorName)
}

// addNestedReply adds a reply, optionally nesting it under a parent reply.
func addNestedReply(repoPath, threadID, parentID, body, author, authorName string) error {
	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
	unlock := lockThread(threadPath)
	defer unlock()

	t, err := loadThread(repoPath, threadID)
	if err != nil {
		return err
	}

	now := time.Now().UTC().Format(time.RFC3339)
	t.Replies = append(t.Replies, Reply{
		ID:         newULID(),
		ParentID:   parentID,
		Body:       body,
		Author:     author,
		AuthorName: authorName,
		Created:    now,
	})
	t.Updated = now

	return writeThread(repoPath, t)
}

func closeThread(repoPath, threadID string) error {
	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
	unlock := lockThread(threadPath)
	defer unlock()

	t, err := loadThread(repoPath, threadID)
	if err != nil {
		return err
	}
	t.State = "closed"
	t.Updated = time.Now().UTC().Format(time.RFC3339)
	return writeThread(repoPath, t)
}

func reopenThread(repoPath, threadID string) error {
	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
	unlock := lockThread(threadPath)
	defer unlock()

	t, err := loadThread(repoPath, threadID)
	if err != nil {
		return err
	}
	t.State = "open"
	t.Updated = time.Now().UTC().Format(time.RFC3339)
	return writeThread(repoPath, t)
}

func loadThread(repoPath, threadID string) (*Thread, error) {
	p := filepath.Join(threadsDir(repoPath), threadID+".json")
	data, err := os.ReadFile(p)
	if err != nil {
		return nil, err
	}
	var t Thread
	if err := json.Unmarshal(data, &t); err != nil {
		return nil, err
	}
	return &t, nil
}

// listThreadsFiltered returns threads matching the given state, plus total open/closed counts.
// A single pass over the files avoids the double-scan of separate list+count calls.
func listThreadsFiltered(repoPath, state string) ([]Thread, int, int) {
	dir := threadsDir(repoPath)
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, 0, 0
	}

	var threads []Thread
	openCount, closedCount := 0, 0
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".json") {
			continue
		}
		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
		if err != nil {
			continue
		}
		var t Thread
		if err := json.Unmarshal(data, &t); err != nil {
			continue
		}
		if t.State == "open" {
			openCount++
		} else {
			closedCount++
		}
		if state == "" || t.State == state {
			threads = append(threads, t)
		}
	}

	sort.Slice(threads, func(i, j int) bool {
		return threads[i].Updated > threads[j].Updated
	})
	return threads, openCount, closedCount
}

// ReplyView augments a Reply with a rendering hint: whether its reply
// target needs to be called out to the reader.
type ReplyView struct {
	Reply
	ShowParent   bool
	ParentAuthor string
}

// buildReplyViews lays replies out in their natural chronological (storage)
// order — a thread reads as one linear conversation, not a tree — and marks
// which ones need a "replying to X" pointer. A reply to the thread root, or
// to whichever reply immediately precedes it, is the assumed default (that
// covers a plain web comment and the common "reply to the latest message"
// case from Mastodon) and stays unannotated. Anything else is a genuine
// callback into earlier history and gets pointed out.
func buildReplyViews(replies []Reply) []ReplyView {
	byID := make(map[string]Reply, len(replies))
	for _, r := range replies {
		byID[r.ID] = r
	}
	views := make([]ReplyView, len(replies))
	prevID := ""
	for i, r := range replies {
		v := ReplyView{Reply: r}
		if r.ParentID != "" && r.ParentID != prevID {
			v.ShowParent = true
			if p, ok := byID[r.ParentID]; ok {
				v.ParentAuthor = p.AuthorName
			} else {
				v.ParentAuthor = "an earlier message"
			}
		}
		views[i] = v
		prevID = r.ID
	}
	return views
}

func writeThread(repoPath string, t *Thread) error {
	dir := threadsDir(repoPath)
	if err := os.MkdirAll(dir, 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(t, "", "  ")
	if err != nil {
		return err
	}
	target := filepath.Join(dir, t.ID+".json")
	tmp := target + ".tmp"
	if err := os.WriteFile(tmp, data, 0644); err != nil {
		return err
	}
	return os.Rename(tmp, target)
}