From 87d08f0151e643951769e323995f1c5ed89ee200 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 01:50:17 -0400 Subject: fix: harden economy recovery, shutdown concurrency, world validation, and inventory-loss paths --- internal/admin/undo.go | 95 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 2 deletions(-) (limited to 'internal/admin/undo.go') diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 51610f8..1786295 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -17,6 +17,7 @@ type ExtraFile struct { NewFilePath string `json:"new_file_path,omitempty"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` } type ChangeDesc struct { @@ -28,6 +29,7 @@ type ChangeDesc struct { NewContent []byte `json:"new_content"` IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` ExtraFiles []ExtraFile `json:"extra_files,omitempty"` // IDMap records a room-ID change (oldID -> newID) included in the // change. Undo applies its inverse, redo the forward map, through the @@ -78,11 +80,13 @@ type UndoStack struct { } func NewUndoStack(dataDir string) *UndoStack { - return &UndoStack{ + us := &UndoStack{ dataDir: dataDir, maxSize: 100, filePath: filepath.Join(dataDir, ".admin_history.json"), } + us.load() + return us } // SetMigrateRoomIDs wires the runtime room-ID migration used by undo/redo @@ -174,11 +178,14 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error { } } if c.IsDelete { - if c.OldContent == nil { + if c.IsDirDelete { if err := os.MkdirAll(c.FilePath, 0755); err != nil { return fmt.Errorf("recreate dir %s: %w", c.FilePath, err) } } else { + if c.OldContent == nil { + return fmt.Errorf("restore deleted %s: no snapshot recorded", c.FilePath) + } if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil { return fmt.Errorf("mkdir: %w", err) } @@ -266,7 +273,14 @@ func applyUndoExtraFile(ef ExtraFile) error { if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("undo remove %s: %w", ef.FilePath, err) } + case ef.IsDelete && ef.IsDirDelete: + if err := os.MkdirAll(ef.FilePath, 0755); err != nil { + return fmt.Errorf("undo mkdir: %w", err) + } case ef.IsDelete: + if ef.OldContent == nil { + return fmt.Errorf("undo restore %s: no snapshot recorded", ef.FilePath) + } if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { return fmt.Errorf("undo mkdir: %w", err) } @@ -354,6 +368,7 @@ func (us *UndoStack) save() { NewContent string `json:"new_content"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` } type entry struct { Time string `json:"time"` @@ -364,6 +379,7 @@ func (us *UndoStack) save() { NewContent string `json:"new_content"` IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` ExtraFiles []extraEntry `json:"extra_files,omitempty"` IDMap map[int]int `json:"id_map,omitempty"` } @@ -383,6 +399,7 @@ func (us *UndoStack) save() { NewContent: string(ef.NewContent), IsDelete: ef.IsDelete, IsCreate: ef.IsCreate, + IsDirDelete: ef.IsDirDelete, }) } entries = append(entries, entry{ @@ -394,6 +411,7 @@ func (us *UndoStack) save() { NewContent: string(c.NewContent), IsDelete: c.IsDelete, IsCreate: c.IsCreate, + IsDirDelete: c.IsDirDelete, ExtraFiles: extra, IDMap: c.IDMap, }) @@ -414,6 +432,79 @@ func (us *UndoStack) save() { } } +// load restores persisted undo/redo history written by save. A missing or +// unreadable history file is not fatal: the stack simply starts empty. The +// editor documents that history survives restarts, so NewUndoStack calls this. +func (us *UndoStack) load() { + data, err := os.ReadFile(us.filePath) + if err != nil { + return + } + type extraEntry struct { + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete,omitempty"` + IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` + } + type entry struct { + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` + ExtraFiles []extraEntry `json:"extra_files,omitempty"` + IDMap map[int]int `json:"id_map,omitempty"` + } + type saveData struct { + History []entry `json:"history"` + Redo []entry `json:"redo"` + } + var sd saveData + if err := json.Unmarshal(data, &sd); err != nil { + log.Printf("undo: failed to load history: %v", err) + return + } + toChanges := func(entries []entry) []ChangeDesc { + out := make([]ChangeDesc, 0, len(entries)) + for _, e := range entries { + c := ChangeDesc{ + Time: e.Time, + Description: e.Description, + FilePath: e.FilePath, + NewFilePath: e.NewFilePath, + OldContent: []byte(e.OldContent), + NewContent: []byte(e.NewContent), + IsDelete: e.IsDelete, + IsCreate: e.IsCreate, + IsDirDelete: e.IsDirDelete, + IDMap: e.IDMap, + } + for _, ef := range e.ExtraFiles { + c.ExtraFiles = append(c.ExtraFiles, ExtraFile{ + FilePath: ef.FilePath, + NewFilePath: ef.NewFilePath, + OldContent: []byte(ef.OldContent), + NewContent: []byte(ef.NewContent), + IsDelete: ef.IsDelete, + IsCreate: ef.IsCreate, + IsDirDelete: ef.IsDirDelete, + }) + } + out = append(out, c) + } + return out + } + us.history = toChanges(sd.History) + us.redo = toChanges(sd.Redo) +} + func (us *UndoStack) Clear() { us.mu.Lock() defer us.mu.Unlock() -- cgit v1.2.3