package admin import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) type triggerEntry struct { ID string `json:"id"` Kind string `json:"kind"` } func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: entries, err := listTriggerEntries(s.dataDir) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } if entries == nil { entries = []triggerEntry{} } writeJSON(w, entries) case http.MethodPost: var m map[string]any if err := readJSON(r, &m); err != nil { writeJSON(w, map[string]any{"error": "invalid json"}) return } id, ok := m["id"].(string) if !ok || strings.TrimSpace(id) == "" { writeJSON(w, map[string]any{"error": "missing id"}) return } delete(m, "id") kind := triggerKind(m) subdir := kindToSubdir(kind) path := filepath.Join(s.dataDir, "triggers", subdir, id+".yaml") os.MkdirAll(filepath.Dir(path), 0755) if _, err := os.Stat(path); err == nil { writeJSON(w, map[string]any{"error": "trigger already exists"}) return } newContent, err := writeMapAsYAML(path, m) if err != nil { writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) return } s.undoStack.Push(ChangeDesc{ Description: "Created trigger " + id, FilePath: path, NewContent: newContent, IsCreate: true, }) writeJSON(w, map[string]any{"ok": true, "id": id, "kind": kind}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/triggers/") if id == "" { http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) return } switch r.Method { case http.MethodGet: path, _, err := findTriggerFile(s.dataDir, id) if err != nil { writeJSON(w, map[string]any{"error": "not found: " + id}) return } data, err := os.ReadFile(path) if err != nil { writeJSON(w, map[string]any{"error": "not found: " + id}) return } raw, err := yamlToMap(data) if err != nil { writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) return } raw["id"] = id raw["_path"] = path raw["_raw"] = string(data) raw["_kind"] = triggerKind(raw) writeJSON(w, raw) case http.MethodPut: var body map[string]any if err := readJSON(r, &body); err != nil { writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()}) return } delete(body, "id") delete(body, "_path") delete(body, "_raw") oldPath, oldDir, findErr := findTriggerFile(s.dataDir, id) newKind := triggerKind(body) newSubdir := kindToSubdir(newKind) newPath := filepath.Join(s.dataDir, "triggers", newSubdir, id+".yaml") var oldContent []byte if existing, err := snapshotFile(oldPath); err == nil { oldContent = existing } // If the kind changed, ensure the target directory exists and delete // the old path after writing the new one. if findErr == nil && newSubdir != oldDir { os.MkdirAll(filepath.Dir(newPath), 0755) } triggerData, err := triggerMapToYAML(body) if err != nil { writeJSON(w, map[string]any{"error": "serialize error: " + err.Error()}) return } if err := os.WriteFile(newPath, triggerData, 0644); err != nil { writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) return } if findErr == nil && newSubdir != oldDir { os.Remove(oldPath) } s.undoStack.Push(ChangeDesc{ Description: "Update trigger " + id, FilePath: newPath, OldContent: oldContent, NewContent: triggerData, }) s.rebuildTriggerIndex() writeJSON(w, map[string]any{"ok": true, "id": id, "kind": newKind}) case http.MethodDelete: path, _, err := findTriggerFile(s.dataDir, id) if err != nil { writeJSON(w, map[string]any{"error": "not found: " + id}) return } oldContent, err := snapshotFile(path) if err != nil { writeJSON(w, map[string]any{"error": "not found: " + id}) return } if err := os.Remove(path); err != nil { writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) return } s.undoStack.Push(ChangeDesc{ Description: "Delete trigger " + id, FilePath: path, OldContent: oldContent, }) s.rebuildTriggerIndex() writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // triggerKind returns "player", "global", or "" based on the map content. func triggerKind(m map[string]any) string { if _, ok := m["on_player_flag"]; ok { if s, is := m["on_player_flag"].(string); is && s != "" { return "player" } } if _, ok := m["on_global_flag"]; ok { if s, is := m["on_global_flag"].(string); is && s != "" { return "global" } } // Fallback: if neither is present, return empty. return "" } func kindToSubdir(kind string) string { if kind == "global" { return "global_flag" } return "player_flag" } func listTriggerEntries(dataDir string) ([]triggerEntry, error) { root := filepath.Join(dataDir, "triggers") if _, err := os.Stat(root); os.IsNotExist(err) { return []triggerEntry{}, nil } var entries []triggerEntry var walk func(p string) error walk = func(p string) error { dirs, err := os.ReadDir(p) if err != nil { return err } for _, entry := range dirs { full := filepath.Join(p, entry.Name()) if entry.IsDir() { if err := walk(full); err != nil { return err } continue } ext := filepath.Ext(entry.Name()) if ext != ".yaml" && ext != ".yml" { continue } id := strings.TrimSuffix(entry.Name(), ext) // Determine kind from the parent directory name. parent := filepath.Base(filepath.Dir(full)) kind := "" if parent == "global_flag" { kind = "global" } else if parent == "player_flag" { kind = "player" } entries = append(entries, triggerEntry{ID: id, Kind: kind}) } return nil } if err := walk(root); err != nil { return nil, err } return entries, nil } // findTriggerFile searches for a trigger file in the triggers directory tree // and returns its full path and the parent directory name (relative to the // triggers root). Searches player_flag/ then global_flag/ then root. func findTriggerFile(dataDir, id string) (path string, dir string, err error) { root := filepath.Join(dataDir, "triggers") for _, sub := range []string{"player_flag", "global_flag", ""} { var p string if sub == "" { p = filepath.Join(root, id+".yaml") } else { p = filepath.Join(root, sub, id+".yaml") } if _, statErr := os.Stat(p); statErr == nil { return p, sub, nil } p2 := filepath.Join(root, sub, id+".yml") if _, statErr := os.Stat(p2); statErr == nil { return p2, sub, nil } } return "", "", fmt.Errorf("trigger %q not found", id) } // triggerMapToYAML serializes a flat map into a Trigger YAML document. // The map contains keys like on_player_flag, on_global_flag, // lock, condition, steps, etc. func triggerMapToYAML(m map[string]any) ([]byte, error) { var t behavior.Trigger // Handle steps specially — need to convert from []any to []behavior.Step. if rawSteps, ok := m["steps"]; ok { delete(m, "steps") stepsJSON, err := json.Marshal(rawSteps) if err != nil { return nil, fmt.Errorf("marshal steps: %w", err) } var steps []behavior.Step if err := json.Unmarshal(stepsJSON, &steps); err != nil { return nil, fmt.Errorf("unmarshal steps: %w", err) } t.Steps = steps } else { t.Steps = []behavior.Step{} } // Handle condition. if rawCond, ok := m["condition"]; ok { delete(m, "condition") condJSON, err := json.Marshal(rawCond) if err == nil { var cond behavior.Condition if err := json.Unmarshal(condJSON, &cond); err == nil { t.Condition = &cond } } } // Handle simple fields. if v, ok := m["on_player_flag"]; ok { if s, ok := v.(string); ok { t.OnPlayerFlag = s } delete(m, "on_player_flag") } if v, ok := m["on_global_flag"]; ok { if s, ok := v.(string); ok { t.OnGlobalFlag = s } delete(m, "on_global_flag") } if v, ok := m["lock"]; ok { if b, ok := v.(bool); ok { t.Lock = b } delete(m, "lock") } // Serialize to YAML. yamlBytes, err := yaml.Marshal(t) if err != nil { return nil, fmt.Errorf("yaml marshal: %w", err) } return yamlBytes, nil } func (s *AdminServer) rebuildTriggerIndex() {}