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