cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / recovery.go
recovery.go 10.3K
1package ui23import (4 "bytes"5 "crypto/sha256"6 "encoding/hex"7 "errors"8 "fmt"9 "io/fs"10 "net/url"11 "os"12 "path/filepath"13 "regexp"14 "slices"15 "strconv"16 "strings"17 "time"1819 tea "charm.land/bubbletea/v2"2021 "src.rest/cat/012/internal/fileio"22 "src.rest/cat/012/internal/sheet"23 "src.rest/cat/012/internal/ui/transfer"24)2526// Recovery files: when 012 serve stops, or a session idles out, with27// unsaved changes, the workbook is kept in a hidden directory of the28// served directory, and the next session opening that file offers it29// back. The directory is hidden, so names typed in a session never reach30// it (internal/confine); only this file reads and writes it. See31// docs/terminal/ssh.md.32//33// A local session keeps them too when it stops on an internal error34// (crash.go), in Settings.RecoveryDir, keyed by the file's absolute path,35// and offers them back when that file is opened again. See36// docs/files/saving.md.3738// RecoveryDir is the directory, inside the served one, recovery files39// are kept in.40const RecoveryDir = ".012-recovery"4142const (43 recoveryKeep = 3 // recovery files kept per file name; older ones are removed44 recoveryUntitled = "(untitled)" // the key for a book never saved; "(" is escaped in file names45 recoveryStamp = "20060102-150405"46 // recoveryKeyMax keeps a recovery file's name (the key, the time and47 // .012) under the 255 bytes file systems allow.48 recoveryKeyMax = 20049)5051// recoveryName matches a recovery file's name after its key and "-":52// the time it was written, a count when two share a second, and .012.53var recoveryName = regexp.MustCompile(`^(\d{8}-\d{6})(-\d+)?\.012$`)5455// OpenOnStart makes a served session start on name, as `ssh -t host56// name` asks: a sheet to open (or a new one to save as name, when there's57// no such file) or a file to import. Either way, or with name empty, the58// session then offers unsaved changes kept for that name.59func (m *Model) OpenOnStart(name string) {60 m.start = &name61}6263// OfferKept makes a local session offer, once it starts, the changes64// kept for its file (or for an untitled book) when 012 last stopped on65// an internal error. cmd/012 asks for it when started on a file or on66// nothing, not on an import or standard input.67func (m *Model) OfferKept() { m.offerKept = true }6869// startOpenCmd opens what OpenOnStart set, once, when the program starts.70func (m *Model) startOpenCmd() tea.Cmd {71 if m.start == nil {72 if m.offerKept {73 m.offerKept = false74 m.offerRecovery(m.filename)75 }76 return nil77 }78 name := *m.start79 m.start = nil80 if name == "" {81 m.offerRecovery("")82 return nil83 }84 if m.share.reg != nil && strings.HasPrefix(name, "@") {85 m.joinNamed(name)86 return nil87 }88 if _, ok := fileio.KindOf(name); ok {89 return m.startImport(name, fileio.Options{}, transfer.Book)90 }91 name = withExt(name)92 p, ok := m.path("open", name)93 if !ok {94 return nil95 }96 if m.share.reg != nil {97 return m.openShared(name, p)98 }99 if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) {100 // A new sheet, saved under this name.101 m.filename, m.disk = name, stamp{}102 m.offerRecovery(name)103 return nil104 }105 return loadCmd(name, p, m.spans.Parent())106}107108// Unsaved reports whether the workbook has changes a recovery file109// would keep: it was changed since it was last saved or opened, and110// isn't empty.111func (m *Model) Unsaved() bool {112 if !m.changed {113 return false114 }115 wb := m.sheet.Book()116 if len(wb.Macros()) > 0 {117 return true118 }119 for _, s := range slices.Concat(wb.Visible(), wb.HiddenSheets()) {120 if _, ok := s.UsedRange(); ok {121 return true122 }123 }124 return false125}126127// Filename is the open file's name as typed, "" for a book never saved.128func (m *Model) Filename() string { return m.filename }129130// Recover writes the workbook to a new recovery file for its name,131// removing the oldest beyond recoveryKeep, and returns the file's name:132// relative to the served directory in 012 serve, its full path in a133// local session. It's for when the program has stopped; a local model134// without Settings.RecoveryDir has nowhere to put one.135func (m *Model) Recover(now time.Time) (string, error) {136 dir, key, err := m.recoveryPlace(m.filename, true)137 if err != nil {138 return "", err139 }140 var buf bytes.Buffer141 if err := m.sheet.Write(&buf); err != nil {142 return "", err143 }144 base := key + "-" + now.Format(recoveryStamp)145 for i := 1; i < 100; i++ {146 name := base + sheet.FileExt147 if i > 1 {148 name = fmt.Sprintf("%s-%d%s", base, i, sheet.FileExt)149 }150 f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)151 if errors.Is(err, fs.ErrExist) {152 continue153 }154 if err != nil {155 return "", err156 }157 _, err = f.Write(buf.Bytes())158 if cerr := f.Close(); err == nil {159 err = cerr160 }161 if err != nil {162 os.Remove(f.Name())163 return "", err164 }165 prune(dir, key)166 if !m.root.Confined() {167 return filepath.Join(dir, name), nil168 }169 return filepath.Join(RecoveryDir, name), nil170 }171 return "", errors.New("too many recovery files this second")172}173174// recoveryPlace is the directory recovery files for name are kept in,175// made when create is set, and the start of their names there.176func (m *Model) recoveryPlace(name string, create bool) (dir, key string, err error) {177 if m.root.Confined() {178 dir, err = recoveryDir(m.root.Dir(), create)179 return dir, recoveryKey(name), err180 }181 dir = m.prefs.RecoveryDir182 if dir == "" {183 return "", "", errors.New("this session keeps no recovery files")184 }185 if create {186 if err := os.MkdirAll(dir, 0o700); err != nil {187 return "", "", err188 }189 }190 if st, err := os.Stat(dir); err != nil || !st.IsDir() {191 return "", "", fmt.Errorf("no recovery directory at %s", dir)192 }193 if name != "" {194 if abs, err := filepath.Abs(name); err == nil {195 name = abs196 }197 }198 return dir, recoveryKey(name), nil199}200201// recoveryDir is the recovery directory in root, made 0700 when create202// is set. It must be a directory of its own, not a link elsewhere.203func recoveryDir(root string, create bool) (string, error) {204 dir := filepath.Join(root, RecoveryDir)205 if create {206 if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {207 return "", err208 }209 }210 st, err := os.Lstat(dir)211 if err != nil {212 return "", err213 }214 if !st.IsDir() {215 return "", fmt.Errorf("%s isn't a directory", RecoveryDir)216 }217 return dir, nil218}219220// recoveryKey is the start of the recovery files' names for a file name221// as typed: the name without its extension, escaped so any path is one222// file name, or recoveryUntitled for a book never saved. A key too long223// for a file name keeps its end after a hash of the whole.224func recoveryKey(name string) string {225 if name == "" {226 return recoveryUntitled227 }228 name = filepath.ToSlash(filepath.Clean(name))229 key := url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))230 if len(key) > recoveryKeyMax {231 sum := sha256.Sum256([]byte(key))232 key = hex.EncodeToString(sum[:8]) + "-" + key[len(key)-(recoveryKeyMax-17):]233 }234 return key235}236237// recoveryFile is a recovery file found for a name.238type recoveryFile struct {239 path string240 when time.Time241 n int // which of the files written in that second242}243244// recoveries lists the recovery files kept for key in dir, oldest first.245func recoveries(dir, key string) []recoveryFile {246 entries, _ := os.ReadDir(dir)247 var out []recoveryFile248 for _, e := range entries {249 rest, ok := strings.CutPrefix(e.Name(), key+"-")250 if !ok || !e.Type().IsRegular() {251 continue252 }253 mm := recoveryName.FindStringSubmatch(rest)254 if mm == nil {255 continue256 }257 when, err := time.ParseInLocation(recoveryStamp, mm[1], time.Local)258 if err != nil {259 continue260 }261 n, _ := strconv.Atoi(strings.TrimPrefix(mm[2], "-")) // 0 for the first in its second262 out = append(out, recoveryFile{path: filepath.Join(dir, e.Name()), when: when, n: n})263 }264 slices.SortFunc(out, func(a, b recoveryFile) int {265 if c := a.when.Compare(b.when); c != 0 {266 return c267 }268 return a.n - b.n269 })270 return out271}272273// prune removes the oldest recovery files for key beyond recoveryKeep.274func prune(dir, key string) {275 files := recoveries(dir, key)276 for _, f := range files[:max(0, len(files)-recoveryKeep)] {277 os.Remove(f.path)278 }279}280281// offerRecovery asks, on the context line, whether to restore the282// newest recovery file kept for name, when a session opens it.283func (m *Model) offerRecovery(name string) {284 dir, key, err := m.recoveryPlace(name, false)285 if err != nil {286 return287 }288 files := recoveries(dir, key)289 if len(files) == 0 {290 return291 }292 f := files[len(files)-1]293 what := "an untitled sheet"294 if name != "" {295 what = filepath.Base(name)296 }297 m.ask(question{298 msg: "Unsaved changes to " + what + " were kept.",299 warn: true,300 desc: "Kept " + f.when.Format("Jan 2 15:04") + ", when a session ended unsaved. Later asks again next time",301 choices: []choice{302 {key: "enter", label: "Restore", run: func(m *Model) tea.Cmd { return restoreCmd(name, f.path) }},303 {key: "d", label: "Discard", run: func(m *Model) tea.Cmd {304 if err := os.Remove(f.path); err != nil {305 m.fail("Couldn't delete the kept changes: " + err.Error())306 return nil307 }308 m.note = "Deleted the kept changes to " + what309 return nil310 }},311 {key: "esc", label: "Later", run: func(*Model) tea.Cmd { return nil }},312 },313 })314}315316// restoredMsg carries a recovery file read back for name.317type restoredMsg struct {318 name, file string319 sheet *sheet.Sheet320 err error321}322323func restoreCmd(name, file string) tea.Cmd {324 return func() tea.Msg {325 f, err := os.Open(file)326 if err != nil {327 return restoredMsg{name: name, file: file, err: err}328 }329 defer f.Close()330 s, err := sheet.Read(f)331 return restoredMsg{name: name, file: file, sheet: s, err: err}332 }333}334335// restored puts the kept changes in place of the opened file, as unsaved336// changes to it: saving still checks the file on disk hasn't changed337// since it was opened. The recovery file goes once they're saved.338func (m *Model) restored(msg restoredMsg) {339 if msg.err != nil {340 m.fail("Couldn't restore the kept changes: " + msg.err.Error())341 return342 }343 if seat := m.share.seat; seat != nil {344 m.restoreShared(seat, msg)345 return346 }347 disk := m.disk348 m.reset(msg.sheet, msg.name)349 m.disk, m.saved, m.changed = disk, -1, true350 m.recovered = msg.file351 m.note = "Restored the kept changes; save to keep them"352}353354// savedRecovered removes the recovery file restored into this book, once355// the book is saved.356func (m *Model) savedRecovered() {357 if m.recovered != "" {358 os.Remove(m.recovered)359 m.recovered = ""360 }361}