Files
pitodo/internal/todos/store.go
T
2026-11-04 16:57:28 +01:00

284 lines
7.9 KiB
Go

// Package todos reads and writes the Markdown todo files used by Pi's todo extension.
package todos
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var idPattern = regexp.MustCompile(`^[a-f0-9]{8}$`)
// ErrLocked means another session owns the todo's .lock file. We never steal locks.
var ErrLocked = errors.New("todo is locked by another session")
// Todo matches the pi extension's JSON header and optional Markdown body.
type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Tags []string `json:"tags"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
AssignedToSession string `json:"assigned_to_session,omitempty"`
Body string `json:"-"`
}
// Dir returns Pi's default todo directory for a project.
func Dir(project string) string { return filepath.Join(project, ".pi", "todos") }
func validateID(id string) error {
if !idPattern.MatchString(id) {
return fmt.Errorf("invalid todo ID %q: expected 8 lowercase hex digits", id)
}
return nil
}
// Parse reads a JSON object followed by an optional blank line and Markdown body.
// The filename is authoritative for the ID, as in the Pi extension.
func Parse(id string, data []byte) (Todo, error) {
if err := validateID(id); err != nil {
return Todo{}, err
}
if len(data) == 0 || data[0] != '{' {
return Todo{}, fmt.Errorf("todo %s: missing JSON header", id)
}
var header struct {
ID string `json:"id"`
Title string `json:"title"`
Tags []string `json:"tags"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
AssignedToSession string `json:"assigned_to_session"`
}
decoder := json.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&header); err != nil {
return Todo{}, fmt.Errorf("todo %s: invalid JSON header: %w", id, err)
}
if header.ID != "" && header.ID != id {
return Todo{}, fmt.Errorf("todo %s: header ID %q differs from filename", id, header.ID)
}
rest := data[decoder.InputOffset():]
if len(rest) > 0 && rest[0] != '\n' && !bytes.HasPrefix(rest, []byte("\r\n")) {
return Todo{}, fmt.Errorf("todo %s: expected newline after JSON header", id)
}
body := strings.TrimLeft(string(rest), "\r\n")
if header.Status == "" {
header.Status = "open"
}
if header.Tags == nil {
header.Tags = []string{}
}
return Todo{ID: id, Title: header.Title, Tags: header.Tags, Status: header.Status,
CreatedAt: header.CreatedAt, AssignedToSession: header.AssignedToSession, Body: body}, nil
}
// Serialize emits the extension's JSON header and a blank line before any body.
func Serialize(todo Todo) ([]byte, error) {
if err := validateID(todo.ID); err != nil {
return nil, err
}
if todo.Tags == nil {
todo.Tags = []string{}
}
if todo.Status == "" {
todo.Status = "open"
}
header, err := json.MarshalIndent(todo, "", " ")
if err != nil {
return nil, err
}
body := strings.TrimRight(strings.TrimLeft(todo.Body, "\r\n"), " \t\r\n")
if body == "" {
return append(header, '\n'), nil
}
return []byte(string(header) + "\n\n" + body + "\n"), nil
}
// Read loads one todo. It does not modify or acquire a lock on the file.
func Read(dir, id string) (Todo, error) {
if err := validateID(id); err != nil {
return Todo{}, err
}
data, err := os.ReadFile(filepath.Join(dir, id+".md"))
if err != nil {
return Todo{}, fmt.Errorf("read todo %s: %w", id, err)
}
return Parse(id, data)
}
// Scan reads valid todos in filename order and reports malformed files separately.
// A missing directory is empty; other directory errors are fatal.
func Scan(dir string) ([]Todo, []error, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return []Todo{}, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("read todo directory: %w", err)
}
result := make([]Todo, 0)
var warnings []error
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
id := strings.TrimSuffix(entry.Name(), ".md")
if err := validateID(id); err != nil {
continue // unrelated Markdown file
}
todo, err := Read(dir, id)
if err != nil {
warnings = append(warnings, err)
continue
}
result = append(result, todo)
}
return result, warnings, nil
}
// List reports malformed files as errors rather than silently omitting them.
func List(dir string) ([]Todo, error) {
result, warnings, err := Scan(dir)
if err != nil {
return nil, err
}
if len(warnings) > 0 {
return nil, errors.Join(warnings...)
}
return result, nil
}
func withLock(dir, id string, fn func() error) error {
lockPath := filepath.Join(dir, id+".lock")
lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("todo %s: %w", id, ErrLocked)
}
if err != nil {
return fmt.Errorf("lock todo %s: %w", id, err)
}
// Include the fields Pi's extension expects, without claiming a Pi session.
info, _ := json.Marshal(map[string]any{"id": id, "pid": os.Getpid(), "created_at": time.Now().UTC().Format(time.RFC3339Nano)})
if _, err := lock.Write(info); err != nil {
lock.Close()
os.Remove(lockPath)
return fmt.Errorf("write lock for todo %s: %w", id, err)
}
lock.Close()
defer os.Remove(lockPath)
return fn()
}
func writeTemp(dir string, data []byte, perm os.FileMode) (string, error) {
temp, err := os.CreateTemp(dir, ".todoapp-*")
if err != nil {
return "", err
}
name := temp.Name()
defer func() {
temp.Close()
if err != nil {
os.Remove(name)
}
}()
if err = temp.Chmod(perm); err != nil {
return "", err
}
if _, err = temp.Write(data); err != nil {
return "", err
}
if err = temp.Sync(); err != nil {
return "", err
}
if err = temp.Close(); err != nil {
return "", err
}
return name, nil
}
// Create allocates a random Pi-compatible ID and never replaces an existing file.
func Create(dir string, todo Todo) (Todo, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return Todo{}, fmt.Errorf("create todo directory: %w", err)
}
for range 10 {
var random [4]byte
if _, err := rand.Read(random[:]); err != nil {
return Todo{}, err
}
todo.ID = hex.EncodeToString(random[:])
todo.CreatedAt = time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if todo.Status == "" {
todo.Status = "open"
}
data, err := Serialize(todo)
if err != nil {
return Todo{}, err
}
err = withLock(dir, todo.ID, func() error {
name, err := writeTemp(dir, data, 0600)
if err != nil {
return err
}
defer os.Remove(name)
return os.Link(name, filepath.Join(dir, todo.ID+".md")) // atomic, no overwrite
})
if errors.Is(err, os.ErrExist) || errors.Is(err, ErrLocked) {
continue // rare ID collision
}
if err != nil {
return Todo{}, fmt.Errorf("create todo %s: %w", todo.ID, err)
}
return todo, nil
}
return Todo{}, errors.New("failed to allocate an unused todo ID")
}
// Update edits a todo under a Pi-compatible lock and atomically replaces the file.
// ID, creation time, and assignment always come from the existing file.
func Update(dir string, todo Todo) error {
if err := validateID(todo.ID); err != nil {
return err
}
return withLock(dir, todo.ID, func() error {
old, err := Read(dir, todo.ID)
if err != nil {
return err
}
todo.CreatedAt = old.CreatedAt
todo.AssignedToSession = old.AssignedToSession
if todo.Status == "" {
todo.Status = old.Status
}
if todo.Tags == nil {
todo.Tags = old.Tags
}
data, err := Serialize(todo)
if err != nil {
return err
}
path := filepath.Join(dir, todo.ID+".md")
info, err := os.Stat(path)
if err != nil {
return err
}
name, err := writeTemp(dir, data, info.Mode().Perm())
if err != nil {
return err
}
defer os.Remove(name)
if err := os.Rename(name, path); err != nil {
return fmt.Errorf("replace todo %s: %w", todo.ID, err)
}
return nil
})
}