package admin import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "thehouseoficarus/internal/persistence" ) type bulkEditPatch struct { Color *string `json:"color"` BlockTransport *bool `json:"block_transport"` Dir *string `json:"dir"` Hazard *string `json:"hazard"` } type bulkEditRequest struct { Patch bulkEditPatch `json:"patch"` IDs []int `json:"ids"` } // handleBulkEdit applies one patch to many rooms as a single mutation // plan: // // 1. Preflight reads, parses, patches, and marshals every selected room in // memory — including destination-directory and conflict checks for // moves. Any failure aborts BEFORE any file is touched: a failed bulk // edit never leaves partial changes behind. // 2. The writes apply as one journal transaction (in-place replaces, and // move+replace for real directory moves) that rolls back on failure. // 3. One undo record captures every before/after image, so undo restores // the whole plan. func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, readErr := io.ReadAll(r.Body) r.Body.Close() if readErr != nil { failJSON(w, fmt.Sprintf("read body: %v", readErr)) return } var req bulkEditRequest if err := json.Unmarshal(body, &req); err != nil { failJSON(w, fmt.Sprintf("invalid json: %v", err)) return } if len(req.IDs) == 0 { failJSON(w, "no room ids") return } if isEmptyPatch(req.Patch) { failJSON(w, "no patch fields") return } patch := req.Patch type roomWrite struct { id int oldPath, newPath string oldContent, newContent []byte } // ---- Preflight: everything that can fail happens here, before any // file is modified. ---- writes := make([]roomWrite, 0, len(req.IDs)) for _, id := range req.IDs { path, ok := s.world.GetRoomPath(id) if !ok { failJSON(w, fmt.Sprintf("room %d not found", id)) return } oldContent, err := os.ReadFile(path) if err != nil { failJSON(w, fmt.Sprintf("read room %d: %v", id, err)) return } m, yamlErr := yamlToMap(oldContent) if yamlErr != nil { failJSON(w, fmt.Sprintf("parse room %d: %v", id, yamlErr)) return } if patch.Color != nil { m["color"] = *patch.Color } if patch.BlockTransport != nil { m["block_transport"] = *patch.BlockTransport } if patch.Hazard != nil { if *patch.Hazard == "" { delete(m, "hazard") } else { m["hazard"] = *patch.Hazard } } newPath := path if patch.Dir != nil && *patch.Dir != "" { dir, dirErr := s.resolveRoomSubdir(*patch.Dir) if dirErr != nil { failJSON(w, dirErr.Error()) return } destDir := filepath.Join(s.dataDir, "rooms", dir) if _, statErr := os.Stat(destDir); os.IsNotExist(statErr) { failJSON(w, fmt.Sprintf("directory does not exist: %s", *patch.Dir)) return } newPath = filepath.Join(destDir, strconv.Itoa(id)+".yaml") if newPath != path { if _, statErr := os.Stat(newPath); statErr == nil { failJSON(w, fmt.Sprintf("room %d already exists in target directory", id)) return } } // newPath == path is an in-place edit: the room already lives // in the target directory and must NOT be removed after its // write (that deleted existing rooms). } newContent, marshalErr := marshalMapYAML(m) if marshalErr != nil { failJSON(w, fmt.Sprintf("marshal room %d: %v", id, marshalErr)) return } if err := validateRoomSchema(newContent); err != nil { failJSON(w, fmt.Sprintf("room %d failed schema validation: %v", id, err)) return } writes = append(writes, roomWrite{id: id, oldPath: path, newPath: newPath, oldContent: oldContent, newContent: newContent}) } // ---- Apply as one journaled transaction. ---- txn := persistence.NewTransaction(persistence.JournalDir(s.dataDir)) for _, wr := range writes { if wr.newPath != wr.oldPath { if err := txn.Move(wr.oldPath, wr.newPath); err != nil { failJSON(w, fmt.Sprintf("move room %d: %v", wr.id, err)) return } } if err := txn.Replace(wr.newPath, wr.newContent, 0644); err != nil { failJSON(w, fmt.Sprintf("write room %d: %v", wr.id, err)) return } } if err := txn.Commit(); err != nil { failJSON(w, fmt.Sprintf("commit bulk edit: %v", err)) return } s.world.RebuildRoomIndex(s.dataDir) if s.reconcileRoom != nil { for _, wr := range writes { s.reconcileRoom(wr.id) } } var desc string if patch.Dir != nil && *patch.Dir != "" { desc = fmt.Sprintf("bulk edit %d rooms (move to %s)", len(req.IDs), *patch.Dir) } else { desc = fmt.Sprintf("bulk edit %d rooms", len(req.IDs)) } var extraFiles []ExtraFile var firstDesc ChangeDesc for i, wr := range writes { ef := ExtraFile{ FilePath: wr.oldPath, OldContent: wr.oldContent, NewContent: wr.newContent, } if wr.newPath != wr.oldPath { ef.NewFilePath = wr.newPath } if i == 0 { firstDesc = ChangeDesc{ Description: desc, FilePath: wr.oldPath, OldContent: wr.oldContent, NewContent: wr.newContent, } if wr.newPath != wr.oldPath { firstDesc.NewFilePath = wr.newPath } } else { extraFiles = append(extraFiles, ef) } } firstDesc.ExtraFiles = extraFiles s.undoStack.Push(firstDesc) writeJSON(w, map[string]any{"ok": true, "count": len(req.IDs)}) } func isEmptyPatch(p bulkEditPatch) bool { return p.Color == nil && p.BlockTransport == nil && p.Dir == nil && p.Hazard == nil }