Files
task-master/internal/command/persist.go
T
2026-10-19 14:19:10 +02:00

234 lines
6.5 KiB
Go

package command
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"task/internal/ticket"
)
const (
historyFileName = "history.json"
// MaxHistory is the maximum number of undo (and redo) entries kept.
MaxHistory = 20
)
type persistedFile struct {
Undo []persistedCmd `json:"undo"`
Redo []persistedCmd `json:"redo"`
}
type persistedCmd struct {
Type string `json:"type"`
Ticket *ticket.Ticket `json:"ticket,omitempty"`
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
Before *ticket.Ticket `json:"before,omitempty"`
After *ticket.Ticket `json:"after,omitempty"`
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
}
const (
typeCreate = "create"
typeDelete = "delete"
typeUpdate = "update"
typeAddTime = "add_time"
typeRemoveTime = "remove_time"
typeUpdateTime = "update_time"
typeMove = "move"
)
// HistoryPath returns DataDir/history.json.
func HistoryPath(dataDir string) string {
return filepath.Join(dataDir, historyFileName)
}
func encodeCmd(cmd Command) (persistedCmd, error) {
switch c := cmd.(type) {
case *CreateTicket:
t := c.Ticket
if c.created.ID != "" {
t = c.created
}
cp := t
return persistedCmd{Type: typeCreate, Ticket: &cp}, nil
case *DeleteTicket:
cp := c.Snapshot
return persistedCmd{Type: typeDelete, Snapshot: &cp}, nil
case *UpdateTicket:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
case *AddTimeEntry:
e := c.Entry
if c.created.ID != "" {
e = c.created
}
cp := e
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
case *RemoveTimeEntry:
cp := c.Snapshot
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
case *UpdateTimeEntry:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
case *MoveTicket:
b, a := c.Before, c.After
return persistedCmd{Type: typeMove, Before: &b, After: &a}, nil
default:
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
}
}
func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
switch rec.Type {
case typeCreate:
if rec.Ticket == nil {
return nil, fmt.Errorf("create record missing ticket")
}
return &CreateTicket{Store: store, Ticket: *rec.Ticket, created: *rec.Ticket}, nil
case typeDelete:
if rec.Snapshot == nil {
return nil, fmt.Errorf("delete record missing snapshot")
}
return &DeleteTicket{Store: store, Snapshot: *rec.Snapshot}, nil
case typeUpdate:
if rec.Before == nil || rec.After == nil {
return nil, fmt.Errorf("update record missing before/after")
}
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
case typeAddTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("add_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("add_time: time store not configured")
}
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
case typeRemoveTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("remove_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("remove_time: time store not configured")
}
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
case typeUpdateTime:
if rec.TimeBefore == nil || rec.TimeAfter == nil {
return nil, fmt.Errorf("update_time record missing before/after")
}
if timeStore == nil {
return nil, fmt.Errorf("update_time: time store not configured")
}
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
case typeMove:
if rec.Before == nil || rec.After == nil {
return nil, fmt.Errorf("move record missing before/after")
}
return &MoveTicket{Store: store, TimeStore: timeStore, Before: *rec.Before, After: *rec.After}, nil
default:
return nil, fmt.Errorf("unknown command type %q", rec.Type)
}
}
func encodeStack(cmds []Command) ([]persistedCmd, error) {
out := make([]persistedCmd, 0, len(cmds))
for _, cmd := range cmds {
rec, err := encodeCmd(cmd)
if err != nil {
return nil, err
}
out = append(out, rec)
}
return out, nil
}
func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
out := make([]Command, 0, len(recs))
for _, rec := range recs {
cmd, err := decodeCmd(rec, store, timeStore)
if err != nil {
return nil, err
}
out = append(out, cmd)
}
return out, nil
}
func trimStack(cmds []Command, max int) []Command {
if max <= 0 || len(cmds) <= max {
return cmds
}
return cmds[len(cmds)-max:]
}
func (h *History) persistLocked() error {
if h.path == "" || h.store == nil {
return nil
}
undo, err := encodeStack(h.undoS)
if err != nil {
return err
}
redo, err := encodeStack(h.redoS)
if err != nil {
return err
}
data, err := json.MarshalIndent(persistedFile{Undo: undo, Redo: redo}, "", " ")
if err != nil {
return fmt.Errorf("marshal history: %w", err)
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil {
return fmt.Errorf("create history dir: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(h.path), "history-*.tmp")
if err != nil {
return fmt.Errorf("create temp history: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp history: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp history: %w", err)
}
if err := os.Rename(tmpName, h.path); err != nil {
return fmt.Errorf("rename history: %w", err)
}
return nil
}
func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("read history: %w", err)
}
if len(data) == 0 {
return nil, nil, nil
}
var file persistedFile
if err := json.Unmarshal(data, &file); err != nil {
return nil, nil, fmt.Errorf("parse history: %w", err)
}
undo, err = decodeStack(file.Undo, store, timeStore)
if err != nil {
return nil, nil, err
}
redo, err = decodeStack(file.Redo, store, timeStore)
if err != nil {
return nil, nil, err
}
return trimStack(undo, MaxHistory), trimStack(redo, MaxHistory), nil
}