ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/mockup_store.go

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"os"
	"path/filepath"
	"regexp"
	"sync"
	"time"
)

var mockID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$`)
var mockKey = regexp.MustCompile(`^(sitemap|W-[0-9]{2,3})$`)
var errMockConflict = errors.New("mockup graph revision conflict")

type MockNode struct {
	ID          string  `json:"id"`
	Type        string  `json:"type"`
	Title       string  `json:"title"`
	Description string  `json:"description"`
	ScreenID    string  `json:"screen_id,omitempty"`
	Variant     string  `json:"variant,omitempty"`
	StepID      string  `json:"step_id,omitempty"`
	Mapping     string  `json:"mapping,omitempty"`
	X           float64 `json:"x"`
	Y           float64 `json:"y"`
}
type MockGraph struct {
	Revision   int           `json:"revision"`
	SourceHash string        `json:"source_hash"`
	Direction  string        `json:"direction"`
	Nodes      []MockNode    `json:"nodes"`
	Edges      []DiagramEdge `json:"edges"`
}
type MockRevision struct {
	MockGraph
	SavedAt      string `json:"saved_at"`
	Action       string `json:"action"`
	RestoredFrom int    `json:"restored_from,omitempty"`
}
type mockDocument struct {
	MockGraph
	History []MockRevision `json:"history"`
}
type mockStore struct {
	dir string
	mu  sync.Mutex
}

func validateMockGraph(g MockGraph, c MockCatalog) error {
	bad := func() error {
		return errors.New("ตรวจ shape หน้าจอ สถานะ หรือเส้นเชื่อมไม่ถูกต้อง")
	}
	if g.Revision < 0 || g.Revision > 1000000000 || len(g.SourceHash) != 64 || (g.Direction != "TB" && g.Direction != "LR") || g.Nodes == nil || g.Edges == nil || len(g.Nodes) > 1000 || len(g.Edges) > 4000 {
		return bad()
	}
	ids, edges := map[string]bool{}, map[string]bool{}
	for _, n := range g.Nodes {
		if !mockID.MatchString(n.ID) || ids[n.ID] || len(n.Title) > 800 || len(n.Description) > 20000 || len(n.StepID) > 100 || len(n.Mapping) > 100 {
			return bad()
		}
		ids[n.ID] = true
		if n.Type != "screen" && n.Type != "process" && n.Type != "decision" && n.Type != "start" && n.Type != "end" {
			return bad()
		}
		for _, p := range []float64{n.X, n.Y} {
			if math.IsNaN(p) || math.IsInf(p, 0) || p < 0 || p > 100000 {
				return bad()
			}
		}
		if n.Type == "screen" {
			screen, ok := c.ByID[n.ScreenID]
			if !ok {
				return bad()
			}
			found := false
			for _, v := range screen.Variants {
				found = found || v.ID == n.Variant
			}
			if !found {
				return bad()
			}
		}
	}
	arrow := func(s string) bool { return s == "none" || s == "arrow" || s == "open" || s == "diamond" }
	for _, e := range g.Edges {
		if !mockID.MatchString(e.ID) || edges[e.ID] || !ids[e.Source] || !ids[e.Target] || len(e.Label) > 2000 || !arrow(e.Head) || !arrow(e.Tail) {
			return bad()
		}
		edges[e.ID] = true
	}
	return nil
}
func (s *mockStore) read(key string) (mockDocument, error) {
	if !mockKey.MatchString(key) {
		return mockDocument{}, errors.New("invalid graph key")
	}
	var d mockDocument
	b, err := os.ReadFile(filepath.Join(s.dir, "mockups", "graphs", key+".json"))
	if err != nil {
		return d, err
	}
	err = json.Unmarshal(b, &d)
	return d, err
}
func (s *mockStore) load(key string, draft MockGraph) (MockGraph, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	d, err := s.read(key)
	if errors.Is(err, os.ErrNotExist) {
		return draft, nil
	}
	return d.MockGraph, err
}
func (s *mockStore) write(key string, d mockDocument) error {
	if !mockKey.MatchString(key) {
		return errors.New("invalid graph key")
	}
	dir := filepath.Join(s.dir, "mockups", "graphs")
	if err := os.MkdirAll(dir, 0755); err != nil {
		return err
	}
	b, err := json.MarshalIndent(d, "", "  ")
	if err != nil {
		return err
	}
	f, err := os.CreateTemp(dir, ".graph-*")
	if err != nil {
		return err
	}
	defer os.Remove(f.Name())
	if _, err = f.Write(append(b, '\n')); err != nil {
		f.Close()
		return err
	}
	if err = f.Sync(); err != nil {
		f.Close()
		return err
	}
	if err = f.Close(); err != nil {
		return err
	}
	return os.Rename(f.Name(), filepath.Join(dir, key+".json"))
}
func (s *mockStore) save(key string, g MockGraph, c MockCatalog) (MockGraph, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := validateMockGraph(g, c); err != nil {
		return g, err
	}
	d, err := s.read(key)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return g, err
	}
	if g.Revision != d.Revision {
		return g, errMockConflict
	}
	g.Revision++
	d.MockGraph = g
	d.History = append(d.History, MockRevision{MockGraph: g, SavedAt: time.Now().In(ict).Format(time.RFC3339), Action: "save"})
	return g, s.write(key, d)
}
func (s *mockStore) history(key string) ([]MockRevision, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	d, err := s.read(key)
	if errors.Is(err, os.ErrNotExist) {
		return []MockRevision{}, nil
	}
	return d.History, err
}
func (s *mockStore) restore(key string, expected, target int, c MockCatalog) (MockGraph, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	d, err := s.read(key)
	if err != nil {
		return MockGraph{}, err
	}
	if d.Revision != expected {
		return d.MockGraph, errMockConflict
	}
	for _, r := range d.History {
		if r.Revision == target {
			g := r.MockGraph
			g.Revision = d.Revision + 1
			if err = validateMockGraph(g, c); err != nil {
				return g, err
			}
			d.MockGraph = g
			d.History = append(d.History, MockRevision{MockGraph: g, SavedAt: time.Now().In(ict).Format(time.RFC3339), Action: "restore", RestoredFrom: target})
			return g, s.write(key, d)
		}
	}
	return d.MockGraph, fmt.Errorf("ไม่พบ revision %d", target)
}