1// Package headless works on workbook files without the screen, for2// 012 get, set, recalc and export (see docs/files/scripts.md): it opens3// a .012 file, finds the cells a reference names, reads and writes4// them, lists the errors formulas show and saves atomically. Nothing5// here runs a program or reaches the network unless its caller asks:6// notebook cells run through RunNotebooks and JEV functions through7// AnswerJEV, which the command line calls only behind flags.8package headless910import (11 "context"12 "errors"13 "fmt"14 "io"15 "io/fs"16 "os"17 "path/filepath"1819 "src.rest/cat/012/internal/sheet"20)2122// File is a workbook opened from a .012 file.23type File struct {24 Path string25 Book *sheet.Workbook26 // New is set when there was no file at Path and Book is empty.27 New bool28 mode fs.FileMode29}3031// Open reads the workbook at path, with the notebook outputs it kept32// sent on to their sheets, as the screen opens it. With create, a path33// with no file opens as an empty workbook that Save writes there, as34// opening a new name in 012 does.35func Open(path string, create bool) (*File, error) {36 f := &File{Path: path, mode: 0o644}37 file, err := os.Open(path)38 switch {39 case errors.Is(err, fs.ErrNotExist) && create:40 f.Book, f.New = sheet.NewBook(), true41 return f, nil42 case err != nil:43 return nil, err44 }45 defer file.Close()46 if st, err := file.Stat(); err == nil {47 if st.IsDir() {48 return nil, fmt.Errorf("%s is a folder, not a %s workbook", path, sheet.FileExt)49 }50 f.mode = st.Mode().Perm()51 }52 if f.Book, err = sheet.ReadBook(file); err != nil {53 return nil, fmt.Errorf("%s: %w", path, err)54 }55 SyncOutputs(f.Book)56 f.ReadSources(context.Background())57 return f, nil58}5960// Save writes the workbook back to its file atomically: into a61// temporary file beside it, renamed over it once complete, so a failed62// write never leaves half a workbook. A file that would come out the63// same isn't touched, so its modification time says when it last64// changed.65func (f *File) Save() error {66 if !f.New {67 if old, err := os.ReadFile(f.Path); err == nil && sameAs(f.Book, old) {68 return nil69 }70 }71 return WriteAtomic(f.Path, f.mode, f.Book.Write)72}7374// sameAs reports whether w writes exactly old.75func sameAs(w *sheet.Workbook, old []byte) bool {76 c := &compareWriter{want: old}77 return w.Write(c) == nil && c.same && c.n == len(old)78}7980// compareWriter checks what's written against want without keeping it.81type compareWriter struct {82 want []byte83 n int84 same bool85}8687func (c *compareWriter) Write(p []byte) (int, error) {88 if c.n == 0 {89 c.same = true90 }91 if c.same && (c.n+len(p) > len(c.want) || string(c.want[c.n:c.n+len(p)]) != string(p)) {92 c.same = false93 }94 c.n += len(p)95 return len(p), nil96}9798// WriteAtomic writes a file with write into a temporary file of its own99// beside path, then renames it over path with mode.100func WriteAtomic(path string, mode fs.FileMode, write func(io.Writer) error) error {101 tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")102 if err != nil {103 return err104 }105 err = write(tmp)106 if err == nil {107 err = tmp.Chmod(mode)108 }109 if cerr := tmp.Close(); err == nil {110 err = cerr111 }112 if err == nil {113 err = os.Rename(tmp.Name(), path)114 }115 if err != nil {116 os.Remove(tmp.Name())117 }118 return err119}120121// Trusted reports whether the workbook's commands may run here without122// asking, by the rule macros follow: they were made or trusted on this123// computer, whose id is machine.124func Trusted(w *sheet.Workbook, machine string) bool {125 o := w.MacroOrigin()126 return o != "" && o == machine127}