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

tracker/mockups.go

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"html/template"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

func ucNumber(id string) int { n, _ := strconv.Atoi(strings.TrimPrefix(id, "W-")); return n }

type MockupView struct {
	ModuleNames                    map[string]string
	FlowGroups                     []UCModuleGroup
	Tab                            string
	Screens                        []MockScreen
	Selected                       *MockScreen
	Catalog                        MockCatalog
	UseCases                       []UseCase
	UseCase                        *UseCase
	Query, Module, Channel, Status string
	Modules, Channels              []string
	Total                          int
	Key                            string
	Components                     []struct {
		ID          string `json:"id"`
		Name        string `json:"name"`
		Description string `json:"description"`
	}
}
type mockGraphResponse struct {
	Graph             MockGraph `json:"graph"`
	CurrentSourceHash string    `json:"current_source_hash"`
	SourceChanged     bool      `json:"source_changed"`
}

func mockDecode(w http.ResponseWriter, r *http.Request, v any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
	d := json.NewDecoder(r.Body)
	d.DisallowUnknownFields()
	if err := d.Decode(v); err != nil {
		return err
	}
	var extra any
	if d.Decode(&extra) != io.EOF {
		return errors.New("one JSON value required")
	}
	return nil
}
func mockJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	json.NewEncoder(w).Encode(v)
}
func registerMockups(mux *http.ServeMux, store *Store) {
	tpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS, "templates/layout.html", "templates/partials.html", "templates/mockups.html"))
	ms := &mockStore{dir: store.dir}
	page := func(w http.ResponseWriter, r *http.Request) {
		c, err := loadMockCatalog(repoRoot)
		if err != nil {
			http.Error(w, "อ่าน artifacts ไม่สำเร็จ", 500)
			return
		}
		uc, err := loadUseCases(repoRoot)
		if err != nil {
			http.Error(w, "อ่าน Use cases ไม่สำเร็จ", 500)
			return
		}
		v := MockupView{Tab: "sitemap", Catalog: c, Total: len(c.Screens), Query: strings.TrimSpace(r.URL.Query().Get("q")), Module: r.URL.Query().Get("module"), Channel: r.URL.Query().Get("channel"), Status: r.URL.Query().Get("status"), Key: "sitemap"}
		v.ModuleNames = c.ModuleNames
		if strings.HasPrefix(r.URL.Path, "/mockup/screens") {
			v.Tab = "screens"
		}
		if strings.HasPrefix(r.URL.Path, "/mockup/page-flow") {
			v.Tab = "flow"
			v.Key = ""
		}
		modules, channels := map[string]bool{}, map[string]bool{}
		for _, s := range c.Screens {
			for _, m := range s.Modules {
				modules[m] = true
			}
			channels[s.Channel] = true
			search := strings.ToLower(s.ID + " " + s.Title + " " + strings.Join(s.UseCases, " "))
			if v.Query != "" && !strings.Contains(search, strings.ToLower(v.Query)) {
				continue
			}
			if v.Module != "" && !containsUC(s.Modules, v.Module) {
				continue
			}
			if v.Channel != "" && s.Channel != v.Channel {
				continue
			}
			if v.Status != "" && s.Status != v.Status {
				continue
			}
			v.Screens = append(v.Screens, s)
		}
		for m := range modules {
			v.Modules = append(v.Modules, m)
		}
		for ch := range channels {
			v.Channels = append(v.Channels, ch)
		}
		sort.Strings(v.Modules)
		sort.Strings(v.Channels)
		for _, u := range uc.UC {
			v.UseCases = append(v.UseCases, u)
		}
		sort.Slice(v.UseCases, func(i, j int) bool { return ucNumber(v.UseCases[i].ID) < ucNumber(v.UseCases[j].ID) })
		codes := []string{}
		for code := range uc.Modules {
			codes = append(codes, code)
		}
		sort.Strings(codes)
		for _, code := range codes {
			group := UCModuleGroup{Code: code, Name: moduleLabel(v.ModuleNames, code), Group: uc.Modules[code].Group}
			for _, u := range v.UseCases {
				if len(u.Modules) > 0 && u.Modules[0] == code {
					group.Items = append(group.Items, u)
				}
			}
			if len(group.Items) > 0 {
				v.FlowGroups = append(v.FlowGroups, group)
			}
		}
		if sid := r.PathValue("sid"); sid != "" {
			s, ok := c.ByID[sid]
			if !ok {
				http.NotFound(w, r)
				return
			}
			v.Selected = &s
		}
		if id := r.PathValue("uid"); id != "" {
			u, ok := uc.UC[id]
			if !ok {
				http.NotFound(w, r)
				return
			}
			v.UseCase = &u
			v.Key = id
		}
		if b, err := mockFile(repoRoot, "mockups/design-system/components.json"); err == nil {
			var defs struct {
				Components []struct {
					ID          string `json:"id"`
					Name        string `json:"name"`
					Description string `json:"description"`
				} `json:"components"`
			}
			if json.Unmarshal(b, &defs) == nil {
				v.Components = defs.Components
			}
		}
		p := buildPage(store, "mockup", "Mockup")
		p.Mockups = &v
		var b bytes.Buffer
		if err = tpl.ExecuteTemplate(&b, "layout", p); err != nil {
			http.Error(w, "render mockup error: "+err.Error(), 500)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Header().Set("Cache-Control", "no-store")
		b.WriteTo(w)
	}
	for _, path := range []string{"/mockup", "/mockup/sitemap", "/mockup/page-flow", "/mockup/page-flow/{uid}", "/mockup/screens", "/mockup/screens/{sid}"} {
		mux.HandleFunc("GET "+path, page)
	}
	mux.HandleFunc("GET /api/mockups/catalog", func(w http.ResponseWriter, r *http.Request) {
		c, err := loadMockCatalog(repoRoot)
		if err != nil {
			http.Error(w, "อ่าน catalog ไม่สำเร็จ", 500)
			return
		}
		mockJSON(w, c)
	})
	mux.HandleFunc("GET /mockup-assets/{path...}", func(w http.ResponseWriter, r *http.Request) {
		c, err := loadMockCatalog(repoRoot)
		path := r.PathValue("path")
		if err != nil || !c.Assets[path] {
			http.NotFound(w, r)
			return
		}
		b, err := mockFile(repoRoot, path)
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		if filepath.Ext(path) == ".svg" {
			w.Header().Set("Content-Type", "image/svg+xml")
		} else {
			w.Header().Set("Content-Type", http.DetectContentType(b))
		}
		w.Write(b)
	})
	source := func(w http.ResponseWriter, r *http.Request) (string, MockCatalog, MockGraph, bool) {
		key := r.PathValue("key")
		c, err := loadMockCatalog(repoRoot)
		if err != nil {
			http.Error(w, "อ่าน catalog ไม่สำเร็จ", 500)
			return key, c, MockGraph{}, false
		}
		var draft MockGraph
		if key == "sitemap" {
			draft, err = draftMockSitemap(repoRoot, c)
		} else {
			uc, e := loadUseCases(repoRoot)
			u, ok := uc.UC[key]
			if e != nil || !ok {
				http.NotFound(w, r)
				return key, c, draft, false
			}
			draft, err = draftMockFlow(repoRoot, u, c)
		}
		if err != nil {
			http.Error(w, "อ่านต้นฉบับ graph ไม่สำเร็จ", 500)
			return key, c, draft, false
		}
		return key, c, draft, true
	}
	mux.HandleFunc("GET /api/mockups/graphs/{key}", func(w http.ResponseWriter, r *http.Request) {
		key, c, draft, ok := source(w, r)
		if !ok {
			return
		}
		g, err := ms.load(key, draft)
		if err != nil {
			http.Error(w, "อ่าน diagram ที่บันทึกไม่สำเร็จ ไม่มีการรีเซ็ตข้อมูล", 500)
			return
		}
		if r.URL.Query().Get("draft") == "1" {
			rev := g.Revision
			g = draft
			g.Revision = rev
		}
		if err = validateMockGraph(g, c); err != nil {
			http.Error(w, "diagram มี screen/variant ที่ไม่อยู่ใน catalog กรุณาตรวจ artifacts ก่อน", 422)
			return
		}
		mockJSON(w, mockGraphResponse{Graph: g, CurrentSourceHash: draft.SourceHash, SourceChanged: g.SourceHash != draft.SourceHash})
	})
	mux.HandleFunc("PUT /api/mockups/graphs/{key}", func(w http.ResponseWriter, r *http.Request) {
		if !sameOrigin(r) {
			http.Error(w, "ต้องบันทึกจาก tracker นี้", 403)
			return
		}
		key, c, draft, ok := source(w, r)
		if !ok {
			return
		}
		var g MockGraph
		if mockDecode(w, r, &g) != nil || g.Direction != draft.Direction {
			http.Error(w, "ข้อมูล graph ไม่ถูกต้อง", 400)
			return
		}
		saved, err := ms.save(key, g, c)
		if err != nil {
			code := 400
			if errors.Is(err, errMockConflict) {
				code = 409
			}
			http.Error(w, err.Error(), code)
			return
		}
		mockJSON(w, mockGraphResponse{Graph: saved, CurrentSourceHash: draft.SourceHash, SourceChanged: saved.SourceHash != draft.SourceHash})
	})
	mux.HandleFunc("GET /api/mockups/graphs/{key}/revisions", func(w http.ResponseWriter, r *http.Request) {
		key, _, _, ok := source(w, r)
		if !ok {
			return
		}
		history, err := ms.history(key)
		if err != nil {
			http.Error(w, "อ่านประวัติไม่สำเร็จ", 500)
			return
		}
		mockJSON(w, history)
	})
	mux.HandleFunc("POST /api/mockups/graphs/{key}/restore", func(w http.ResponseWriter, r *http.Request) {
		if !sameOrigin(r) {
			http.Error(w, "ต้องคืน revision จาก tracker นี้", 403)
			return
		}
		key, c, draft, ok := source(w, r)
		if !ok {
			return
		}
		var sub struct {
			Revision int `json:"revision"`
			Target   int `json:"target_revision"`
		}
		if mockDecode(w, r, &sub) != nil {
			http.Error(w, "ข้อมูล restore ไม่ถูกต้อง", 400)
			return
		}
		g, err := ms.restore(key, sub.Revision, sub.Target, c)
		if err != nil {
			code := 400
			if errors.Is(err, errMockConflict) {
				code = 409
			}
			if errors.Is(err, os.ErrNotExist) {
				code = 404
			}
			http.Error(w, err.Error(), code)
			return
		}
		mockJSON(w, mockGraphResponse{Graph: g, CurrentSourceHash: draft.SourceHash, SourceChanged: g.SourceHash != draft.SourceHash})
	})
}