package tui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
tea "charm.land/bubbletea/v2"
"intake/internal/sheet"
)
type editorFinishedMsg struct {
err error
before sheet.Sheet
}
func editorCommand() string {
for _, k := range []string{"VISUAL", "EDITOR"} {
if v := os.Getenv(k); v != "" {
return v
}
}
return "vi"
}
// openSheetEditor hands the whole album to $EDITOR.
//
// It is available from every screen that has a plan loaded except the one that
// is filing it: deciding on a cover is exactly when somebody notices that the
// album artist is wrong, and having to back out of the picker first, or quit and
// start over, would make the editor useless at the moment it is most wanted.
func (m Module) openSheetEditor() (tea.Model, tea.Cmd) {
if len(m.pl.Tracks) == 0 {
m.status = "nothing to edit yet"
return m, nil
}
current := m.pl.Sheet()
current.Cover = m.coverDescription()
path := filepath.Join(m.opts.Config.StateDir, "sheet.txt")
if m.opts.Config.StateDir == "" {
dir, err := os.MkdirTemp("", "intake-sheet-")
if err != nil {
m.status = "could not create a temporary file: " + err.Error()
return m, nil
}
path = filepath.Join(dir, "sheet.txt")
} else if err := os.MkdirAll(m.opts.Config.StateDir, 0o755); err != nil {
m.status = "could not create " + m.opts.Config.StateDir + ": " + err.Error()
return m, nil
}
if err := os.WriteFile(path, []byte(sheet.Render(current)), 0o644); err != nil {
m.status = "could not write " + path + ": " + err.Error()
return m, nil
}
m.sheetPath = path
m.sheetBefore = ¤t
cmd := exec.Command("sh", "-c", editorCommand()+" "+shellQuote(path))
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
return editorFinishedMsg{err: err, before: current}
})
}
// sheetStore is where an album's edits are kept between runs.
func (m Module) sheetStore() sheet.Store {
return sheet.Store{Dir: filepath.Join(m.opts.Config.StateDir, "review")}
}
// restoreSavedEdits applies the sheet saved for this album, if there is one, so
// that quitting in the middle of a review does not throw the corrections away.
func (m *Module) restoreSavedEdits() bool {
text, ok, err := m.sheetStore().Load(m.current.Dir)
if err != nil || !ok {
return false
}
before := m.pl.Sheet()
next, err := sheet.Parse(text, before)
if err != nil {
// A saved sheet that no longer fits the album -- the files changed under
// it -- is dropped rather than left to fail on every open.
m.sheetStore().Delete(m.current.Dir)
m.status = "could not restore your saved edits: " + err.Error()
return false
}
if err := m.pl.ApplySheet(next); err != nil {
m.sheetStore().Delete(m.current.Dir)
m.status = "could not restore your saved edits: " + err.Error()
return false
}
m.restored = true
if next.ChangedFrom(before).Cover {
if err := m.applySheetCover(next.Cover); err != nil {
m.status = err.Error()
}
}
return true
}
// coverDescription is what the sheet's cover field shows: the cover already
// chosen, or nothing.
func (m Module) coverDescription() string {
switch {
case m.hasCover && m.cover.Placeholder:
return "placeholder"
case m.hasCover && m.cover.Path != "":
return m.cover.Path
default:
return ""
}
}
func (m *Module) applyEditedSheet(msg editorFinishedMsg) {
path, before := m.sheetPath, msg.before
if path == "" {
return
}
defer func() {
os.Remove(path)
m.sheetPath, m.sheetBefore = "", nil
}()
text, err := os.ReadFile(path)
if err != nil {
m.status = "could not read the edited sheet: " + err.Error()
return
}
next, err := sheet.Parse(string(text), before)
if err != nil {
m.status = err.Error()
return
}
if err := m.pl.ApplySheet(next); err != nil {
m.status = err.Error()
return
}
// Kept so that quitting, or opening another album and coming back, does not
// lose what was just typed.
if _, err := m.sheetStore().Save(m.current.Dir, string(text)); err != nil {
m.status = "the album was updated but your edits could not be saved: " + err.Error()
}
// A cover typed into the sheet replaces whatever was chosen and one set to
// "none" clears it; a cover left alone is left alone.
if next.ChangedFrom(before).Cover {
if err := m.applySheetCover(next.Cover); err != nil {
m.status = err.Error()
return
}
}
m.status = fmt.Sprintf("album updated: %d track%s", len(m.pl.Tracks), plural(len(m.pl.Tracks)))
}
func (m *Module) applySheetCover(value string) error {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return nil
case "none":
m.cover, m.hasCover = CoverOption{}, false
return nil
case "placeholder":
m.cover = CoverOption{Source: "placeholder", Label: "placeholder", Placeholder: true}
m.hasCover = true
return nil
}
path := strings.TrimSpace(value)
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
path = filepath.Join(home, path[2:])
}
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("the cover in the sheet cannot be read: %v", err)
}
m.cover = CoverOption{Path: path, Source: "file", Label: filepath.Base(path)}
m.hasCover = true
return nil
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}