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"`
	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 {
	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(),
		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
}

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)
}