package admin import ( "encoding/json" "fmt" "log" "os" "path/filepath" "sync" "time" ) type ExtraFile struct { FilePath string `json:"file_path"` OldContent []byte `json:"-"` NewContent []byte `json:"-"` } type ChangeDesc struct { Time string `json:"time"` Description string `json:"description"` FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent []byte `json:"old_content"` NewContent []byte `json:"new_content"` IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` ExtraFiles []ExtraFile `json:"extra_files,omitempty"` } func (c ChangeDesc) ShortDesc() string { switch { case c.IsCreate: return fmt.Sprintf("Created %s", filepath.Base(c.FilePath)) case c.IsDelete: return fmt.Sprintf("Deleted %s", filepath.Base(c.FilePath)) case c.NewFilePath != "": return fmt.Sprintf("Renamed %s", filepath.Base(c.NewFilePath)) default: return fmt.Sprintf("Edited %s", filepath.Base(c.FilePath)) } } type UndoInfo struct { CanUndo bool `json:"can_undo"` CanRedo bool `json:"can_redo"` UndoDesc string `json:"undo_desc"` RedoDesc string `json:"redo_desc"` StackSize int `json:"stack_size"` RedoSize int `json:"redo_size"` } type UndoStack struct { mu sync.Mutex dataDir string history []ChangeDesc redo []ChangeDesc maxSize int filePath string } func NewUndoStack(dataDir string) *UndoStack { us := &UndoStack{ dataDir: dataDir, maxSize: 100, filePath: filepath.Join(dataDir, ".admin_history.json"), } us.load() us.redo = nil return us } func (us *UndoStack) Push(desc ChangeDesc) { us.mu.Lock() defer us.mu.Unlock() desc.Time = time.Now().Format(time.RFC3339) us.history = append(us.history, desc) us.redo = nil if len(us.history) > us.maxSize { us.history = us.history[len(us.history)-us.maxSize:] } us.save() log.Printf("undo: push %s", desc.ShortDesc()) } func (us *UndoStack) Undo() *ChangeDesc { us.mu.Lock() defer us.mu.Unlock() if len(us.history) == 0 { return nil } last := us.history[len(us.history)-1] if err := us.applyUndo(last); err != nil { log.Printf("undo: failed %s: %v", last.ShortDesc(), err) return nil } us.history = us.history[:len(us.history)-1] us.redo = append(us.redo, last) us.save() log.Printf("undo: undid %s", last.ShortDesc()) return &last } func (us *UndoStack) Redo() *ChangeDesc { us.mu.Lock() defer us.mu.Unlock() if len(us.redo) == 0 { return nil } last := us.redo[len(us.redo)-1] if err := us.applyRedo(last); err != nil { log.Printf("redo: failed %s: %v", last.ShortDesc(), err) return nil } us.redo = us.redo[:len(us.redo)-1] us.history = append(us.history, last) us.save() log.Printf("redo: redid %s", last.ShortDesc()) return &last } func (us *UndoStack) applyUndo(c ChangeDesc) error { if c.NewFilePath != "" { os.Remove(c.NewFilePath) } if c.IsDelete { if c.OldContent == nil { return fmt.Errorf("no backup content to restore for %s", c.FilePath) } if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil { return fmt.Errorf("restore deleted %s: %w", c.FilePath, err) } for _, ef := range c.ExtraFiles { os.WriteFile(ef.FilePath, ef.OldContent, 0644) } } else if c.IsCreate { os.Remove(c.FilePath) for _, ef := range c.ExtraFiles { os.Remove(ef.FilePath) } } else { target := c.FilePath content := c.OldContent if c.NewFilePath != "" { target = c.FilePath } if err := os.WriteFile(target, content, 0644); err != nil { return fmt.Errorf("revert %s: %w", target, err) } for _, ef := range c.ExtraFiles { os.WriteFile(ef.FilePath, ef.OldContent, 0644) } } return nil } func (us *UndoStack) applyRedo(c ChangeDesc) error { if c.NewFilePath != "" { os.Remove(c.FilePath) } if c.IsCreate { target := c.FilePath if c.NewFilePath != "" { target = c.NewFilePath } if err := os.WriteFile(target, c.NewContent, 0644); err != nil { return fmt.Errorf("recreate %s: %w", target, err) } for _, ef := range c.ExtraFiles { os.WriteFile(ef.FilePath, ef.NewContent, 0644) } } else if c.IsDelete { os.Remove(c.FilePath) for _, ef := range c.ExtraFiles { os.WriteFile(ef.FilePath, ef.NewContent, 0644) } } else { target := c.FilePath content := c.NewContent if c.NewFilePath != "" { target = c.NewFilePath } if err := os.WriteFile(target, content, 0644); err != nil { return fmt.Errorf("reapply %s: %w", target, err) } for _, ef := range c.ExtraFiles { os.WriteFile(ef.FilePath, ef.NewContent, 0644) } } return nil } func (us *UndoStack) Info() UndoInfo { us.mu.Lock() defer us.mu.Unlock() info := UndoInfo{ StackSize: len(us.history), RedoSize: len(us.redo), } if len(us.history) > 0 { info.CanUndo = true info.UndoDesc = us.history[len(us.history)-1].ShortDesc() } if len(us.redo) > 0 { info.CanRedo = true info.RedoDesc = us.redo[len(us.redo)-1].ShortDesc() } return info } func (us *UndoStack) save() { type extraEntry struct { FilePath string `json:"file_path"` OldContent string `json:"old_content"` NewContent string `json:"new_content"` } 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"` ExtraFiles []extraEntry `json:"extra_files,omitempty"` } type saveData struct { History []entry `json:"history"` Redo []entry `json:"redo"` } toEntries := func(changes []ChangeDesc) []entry { var entries []entry for _, c := range changes { var extra []extraEntry for _, ef := range c.ExtraFiles { extra = append(extra, extraEntry{ FilePath: ef.FilePath, OldContent: string(ef.OldContent), NewContent: string(ef.NewContent), }) } entries = append(entries, entry{ Time: c.Time, Description: c.Description, FilePath: c.FilePath, NewFilePath: c.NewFilePath, OldContent: string(c.OldContent), NewContent: string(c.NewContent), IsDelete: c.IsDelete, IsCreate: c.IsCreate, ExtraFiles: extra, }) } return entries } data := saveData{ History: toEntries(us.history), Redo: toEntries(us.redo), } b, err := json.Marshal(data) if err != nil { log.Printf("undo: failed to marshal history: %v", err) return } if err := os.WriteFile(us.filePath, b, 0644); err != nil { log.Printf("undo: failed to write history file: %v", err) } } func (us *UndoStack) load() { data, err := os.ReadFile(us.filePath) if err != nil { return } log.Printf("undo: loaded history from %s", us.filePath) type extraEntry struct { FilePath string `json:"file_path"` OldContent string `json:"old_content"` NewContent string `json:"new_content"` } 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"` ExtraFiles []extraEntry `json:"extra_files,omitempty"` } var saveData struct { History []entry `json:"history"` Redo []entry `json:"redo"` } if err := json.Unmarshal(data, &saveData); err != nil { log.Printf("undo: failed to unmarshal history: %v", err) return } fromEntries := func(entries []entry) []ChangeDesc { var changes []ChangeDesc for _, e := range entries { var extra []ExtraFile for _, ef := range e.ExtraFiles { extra = append(extra, ExtraFile{ FilePath: ef.FilePath, OldContent: []byte(ef.OldContent), NewContent: []byte(ef.NewContent), }) } changes = append(changes, 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, ExtraFiles: extra, }) } return changes } us.history = fromEntries(saveData.History) log.Printf("undo: loaded %d history entries", len(us.history)) }