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