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, "'", `'\''`) + "'" }