fierj
PublicTiny personal git forge
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)
}