From 79764732406d6484f67d3dec045483dcb5d08c28 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 20:03:13 -0400 Subject: feat: better card ascii art --- internal/admin/undo.go | 134 +++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 61 deletions(-) (limited to 'internal/admin/undo.go') diff --git a/internal/admin/undo.go b/internal/admin/undo.go index c2ea61d..3001c8b 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -227,6 +227,17 @@ func (us *UndoStack) Redo() *ChangeDesc { return &last } +// requireSnapshot rejects a nil before/after image on a change that must +// restore real content. A failed before-image read leaves nil, and writing +// it would truncate the file to zero bytes (which parses as a zero-value +// def), so a missing image must fail the undo/redo instead. +func requireSnapshot(content []byte, path, action string) error { + if content == nil { + return fmt.Errorf("%s %s: no snapshot recorded", action, path) + } + return nil +} + // applyUndo reverts a change: the primary file, then every extra file. // Extra-file failures propagate: a multi-file undo that restored only part // of a change must be reported as failed, never as success. @@ -270,6 +281,9 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error { return fmt.Errorf("remove created %s: %w", c.FilePath, err) } } else { + if err := requireSnapshot(c.OldContent, c.FilePath, "revert"); err != nil { + return err + } if err := us.writeSnapshot(c.FilePath, restored); err != nil { return fmt.Errorf("revert %s: %w", c.FilePath, err) } @@ -332,6 +346,9 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error { if c.NewFilePath != "" { target = c.NewFilePath } + if err := requireSnapshot(c.NewContent, target, "reapply"); err != nil { + return err + } if err := us.writeSnapshot(target, restored); err != nil { return fmt.Errorf("reapply %s: %w", target, err) } @@ -362,6 +379,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } case ef.NewFilePath != "": + if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo restore"); err != nil { + return err + } if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err) } @@ -369,6 +389,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } default: + if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo revert"); err != nil { + return err + } if err := us.writeRestored(ef.FilePath, ef.OldContent); err != nil { return fmt.Errorf("undo revert %s: %w", ef.FilePath, err) } @@ -387,6 +410,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error { return fmt.Errorf("redo create %s: %w", ef.FilePath, err) } case ef.NewFilePath != "": + if err := requireSnapshot(ef.NewContent, ef.NewFilePath, "redo create"); err != nil { + return err + } if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) } @@ -394,6 +420,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error { return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err) } default: + if err := requireSnapshot(ef.NewContent, ef.FilePath, "redo reapply"); err != nil { + return err + } if err := us.writeRestored(ef.FilePath, ef.NewContent); err != nil { return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err) } @@ -450,7 +479,9 @@ func (us *UndoStack) writeSnapshot(path string, content []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } - if err := os.WriteFile(path, content, us.fileMode(path)); err != nil { + // Atomic temp+fsync+rename: a crash mid-undo must never leave a + // truncated world file (empty YAML parses as a zero-value def). + if err := persistence.WriteFile(path, content, us.fileMode(path)); err != nil { return err } if us.isPlayerDataPath(path) { @@ -513,39 +544,46 @@ func (us *UndoStack) Info() UndoInfo { return info } +// undoExtraEntry and undoEntry are the persisted undo/redo wire types. They +// are declared ONCE and shared by save and load: when each function declared +// its own copy, a field added to one and not the other silently dropped +// persisted history on the next restart. +type undoExtraEntry 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 undoEntry 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 []undoExtraEntry `json:"extra_files,omitempty"` + IDMap map[int]int `json:"id_map,omitempty"` +} + +type undoFile struct { + History []undoEntry `json:"history"` + Redo []undoEntry `json:"redo"` +} + func (us *UndoStack) save() { - 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"` - } - toEntries := func(changes []ChangeDesc) []entry { - var entries []entry + toEntries := func(changes []ChangeDesc) []undoEntry { + var entries []undoEntry for _, c := range changes { - var extra []extraEntry + var extra []undoExtraEntry for _, ef := range c.ExtraFiles { - extra = append(extra, extraEntry{ + extra = append(extra, undoExtraEntry{ FilePath: ef.FilePath, NewFilePath: ef.NewFilePath, OldContent: string(us.redactContent(ef.OldContent, ef.FilePath, ef.NewFilePath)), @@ -555,7 +593,7 @@ func (us *UndoStack) save() { IsDirDelete: ef.IsDirDelete, }) } - entries = append(entries, entry{ + entries = append(entries, undoEntry{ Time: c.Time, Description: c.Description, FilePath: c.FilePath, @@ -571,7 +609,7 @@ func (us *UndoStack) save() { } return entries } - data := saveData{ + data := undoFile{ History: toEntries(us.history), Redo: toEntries(us.redo), } @@ -708,38 +746,12 @@ func (us *UndoStack) load() { 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 + var sd undoFile if err := json.Unmarshal(data, &sd); err != nil { log.Printf("undo: failed to load history: %v", err) return } - toChanges := func(entries []entry) []ChangeDesc { + toChanges := func(entries []undoEntry) []ChangeDesc { out := make([]ChangeDesc, 0, len(entries)) for _, e := range entries { c := ChangeDesc{ -- cgit v1.2.3