From b8c90886ef1f3afb8d908aac89028bd177836cae Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 9 Jul 2026 16:09:35 -0400 Subject: feat(admin): multi-select with shift+drag for common bulk operations --- internal/admin/api_rooms_bulk.go | 214 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 internal/admin/api_rooms_bulk.go (limited to 'internal/admin/api_rooms_bulk.go') diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go new file mode 100644 index 0000000..34ca209 --- /dev/null +++ b/internal/admin/api_rooms_bulk.go @@ -0,0 +1,214 @@ +package admin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" +) + +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"` +} + +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 { + writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", readErr)}) + return + } + + var req bulkEditRequest + if err := json.Unmarshal(body, &req); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)}) + return + } + + if len(req.IDs) == 0 { + writeJSON(w, map[string]any{"error": "no room ids"}) + return + } + + if isEmptyPatch(req.Patch) { + writeJSON(w, map[string]any{"error": "no patch fields"}) + return + } + + patch := req.Patch + + roomPaths := make([]string, 0, len(req.IDs)) + for _, id := range req.IDs { + path, ok := s.world.GetRoomPath(id) + if !ok { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) + return + } + roomPaths = append(roomPaths, path) + } + + type roomWrite struct { + oldContent []byte + newContent []byte + oldPath string + newPath string + wasMoved bool + } + + writes := make([]roomWrite, 0, len(req.IDs)) + dirMovePaths := make(map[string]struct{}) + + for i, id := range req.IDs { + path := roomPaths[i] + + oldContent, snapErr := snapshotFile(path) + if snapErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room %d: %v", id, snapErr)}) + return + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, readErr)}) + return + } + + m, yamlErr := yamlToMap(data) + if yamlErr != nil { + writeJSON(w, map[string]any{"error": 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 + } + } + + if patch.Dir != nil && *patch.Dir != "" { + destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + writeJSON(w, map[string]any{"error": fmt.Sprintf("directory does not exist: %s", *patch.Dir)}) + return + } + + fileName := strconv.Itoa(id) + ".yaml" + newPath := filepath.Join(destDir, fileName) + if newPath != path { + if _, err := os.Stat(newPath); err == nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists in target directory", id)}) + return + } + } + + newContent, writeErr := writeMapAsYAML(newPath, m) + if writeErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)}) + return + } + + writes = append(writes, roomWrite{ + oldContent: oldContent, + newContent: newContent, + oldPath: path, + newPath: newPath, + wasMoved: true, + }) + dirMovePaths[path] = struct{}{} + } else { + newContent, writeErr := writeMapAsYAML(path, m) + if writeErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)}) + return + } + + writes = append(writes, roomWrite{ + oldContent: oldContent, + newContent: newContent, + oldPath: path, + newPath: path, + wasMoved: false, + }) + } + } + + for _, rw := range writes { + if rw.wasMoved { + if _, alreadyRemoved := dirMovePaths[rw.oldPath]; alreadyRemoved { + delete(dirMovePaths, rw.oldPath) + if err := os.Remove(rw.oldPath); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("remove old room: %v", err)}) + return + } + } + } + } + + s.world.RebuildRoomIndex(s.dataDir) + + 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, rw := range writes { + ef := ExtraFile{ + FilePath: rw.oldPath, + OldContent: rw.oldContent, + NewContent: rw.newContent, + } + if rw.wasMoved { + ef.NewFilePath = rw.newPath + } + if i == 0 { + firstDesc = ChangeDesc{ + Description: desc, + FilePath: rw.oldPath, + OldContent: rw.oldContent, + NewContent: rw.newContent, + } + if rw.wasMoved { + firstDesc.NewFilePath = rw.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 +} -- cgit v1.2.3