Add the Config view: edit and save the settings, applied without a restart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||
)
|
||||
|
||||
// settingsValues are the settings as shown in the Config view.
|
||||
type settingsValues struct {
|
||||
radarrURL, radarrKey, radarrProfile, radarrFolder string
|
||||
sonarrURL, sonarrKey, sonarrProfile, sonarrFolder string
|
||||
sabURL, sabKey string
|
||||
refresh string // seconds, as typed
|
||||
covers bool
|
||||
coverStyle string
|
||||
notify, bell bool
|
||||
}
|
||||
|
||||
var coverStyles = []string{"auto", "kitty", "sextant", "quadrant"}
|
||||
|
||||
// settingsFrom reads the settings in use.
|
||||
func settingsFrom(c *config.Config) settingsValues {
|
||||
style := c.UI.CoverStyle
|
||||
if style == "" {
|
||||
style = "auto"
|
||||
}
|
||||
return settingsValues{
|
||||
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
|
||||
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
|
||||
sonarrURL: c.Sonarr.URL, sonarrKey: c.Sonarr.APIKey,
|
||||
sonarrProfile: c.Sonarr.DefaultQualityProfile, sonarrFolder: c.Sonarr.DefaultRootFolder,
|
||||
sabURL: c.SABnzbd.URL, sabKey: c.SABnzbd.APIKey,
|
||||
refresh: strconv.Itoa(c.UI.RefreshSeconds),
|
||||
covers: c.UI.ShowCovers(), coverStyle: style,
|
||||
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
|
||||
}
|
||||
}
|
||||
|
||||
// applySettings returns raw (the config file's contents) with the edited
|
||||
// values. Settings the environment overrides are left as the file has them, so
|
||||
// a value that only came from the environment is never written to disk. The
|
||||
// result is checked the way the program checks its configuration.
|
||||
func applySettings(raw config.Config, v settingsValues, overridden map[string]string) (config.Config, error) {
|
||||
set := func(key string, dst *string, value string) {
|
||||
if _, env := overridden[key]; !env {
|
||||
*dst = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
set("radarr.url", &raw.Radarr.URL, v.radarrURL)
|
||||
set("radarr.apikey", &raw.Radarr.APIKey, v.radarrKey)
|
||||
set("sonarr.url", &raw.Sonarr.URL, v.sonarrURL)
|
||||
set("sonarr.apikey", &raw.Sonarr.APIKey, v.sonarrKey)
|
||||
set("sabnzbd.url", &raw.SABnzbd.URL, v.sabURL)
|
||||
set("sabnzbd.apikey", &raw.SABnzbd.APIKey, v.sabKey)
|
||||
raw.Radarr.DefaultQualityProfile = strings.TrimSpace(v.radarrProfile)
|
||||
raw.Radarr.DefaultRootFolder = strings.TrimSpace(v.radarrFolder)
|
||||
raw.Sonarr.DefaultQualityProfile = strings.TrimSpace(v.sonarrProfile)
|
||||
raw.Sonarr.DefaultRootFolder = strings.TrimSpace(v.sonarrFolder)
|
||||
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v.refresh))
|
||||
if err != nil || n < 1 || n > 3600 {
|
||||
return raw, fmt.Errorf("refresh interval: %q is not a number of seconds between 1 and 3600", v.refresh)
|
||||
}
|
||||
raw.UI.RefreshSeconds = n
|
||||
// Defaults stay out of the file unless the file already named them.
|
||||
raw.UI.Covers = boolSetting(raw.UI.Covers, v.covers, true)
|
||||
raw.UI.NotifyDownloads = boolSetting(raw.UI.NotifyDownloads, v.notify, true)
|
||||
raw.UI.Bell = v.bell
|
||||
raw.UI.CoverStyle = v.coverStyle
|
||||
if v.coverStyle == "auto" {
|
||||
raw.UI.CoverStyle = ""
|
||||
}
|
||||
|
||||
if _, err := raw.Effective(os.Getenv); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// boolSetting keeps an optional boolean out of the file while it equals its
|
||||
// default and was not in the file before.
|
||||
func boolSetting(old *bool, value, def bool) *bool {
|
||||
if old == nil && value == def {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// settingsView edits the configuration file's settings. Everything except
|
||||
// the cover style of already open panes applies right after saving.
|
||||
type settingsView struct {
|
||||
app *App
|
||||
root *tview.Flex
|
||||
|
||||
radarr, sonarr, sab, ui, actions *tview.Form
|
||||
groups []*tview.Form // keyboard order
|
||||
status *tview.TextView
|
||||
|
||||
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
||||
refresh *tview.InputField
|
||||
covers *tview.Checkbox
|
||||
style *tview.DropDown
|
||||
notify *tview.Checkbox
|
||||
bell *tview.Checkbox
|
||||
showKeys *tview.Checkbox
|
||||
overridden map[string]string
|
||||
dirty bool
|
||||
filling bool // set while the fields are filled, so that does not count as an edit
|
||||
}
|
||||
|
||||
func settingsHints() string {
|
||||
return "↑↓ move · Tab next section · Space toggles · Enter presses a button"
|
||||
}
|
||||
|
||||
func newSettingsView(app *App) *settingsView {
|
||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
||||
|
||||
section := func(title string) *tview.Form {
|
||||
f := tview.NewForm().SetItemPadding(0)
|
||||
f.SetBorder(true).SetTitle(" " + title + " ")
|
||||
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
||||
return f
|
||||
}
|
||||
input := func(f *tview.Form, label, key string, width int) {
|
||||
in := tview.NewInputField().SetLabel(label).SetFieldWidth(width)
|
||||
in.SetLabelWidth(24) // the same column in every section
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
in.SetMaskCharacter('*')
|
||||
}
|
||||
if _, ok := v.overridden[key]; ok {
|
||||
in.SetLabel(label + " (env)")
|
||||
in.SetDisabled(true)
|
||||
}
|
||||
in.SetChangedFunc(func(string) { v.edited() })
|
||||
f.AddFormItem(in)
|
||||
v.inputs[key] = in
|
||||
}
|
||||
service := func(f *tview.Form, name string, withDefaults bool) {
|
||||
input(f, "Address", name+".url", 0)
|
||||
input(f, "API key", name+".apikey", 0)
|
||||
if withDefaults {
|
||||
input(f, "Default quality profile", name+".profile", 0)
|
||||
input(f, "Default root folder", name+".folder", 0)
|
||||
}
|
||||
}
|
||||
v.radarr, v.sonarr, v.sab = section("Radarr"), section("Sonarr"), section("SABnzbd")
|
||||
service(v.radarr, "radarr", true)
|
||||
service(v.sonarr, "sonarr", true)
|
||||
service(v.sab, "sabnzbd", false)
|
||||
|
||||
v.ui = section("Interface")
|
||||
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
||||
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
||||
v.ui.AddFormItem(v.refresh)
|
||||
v.ui.AddCheckbox("Posters", true, func(bool) { v.edited() })
|
||||
v.ui.AddDropDown("Poster style", coverStyles, 0, func(string, int) { v.edited() })
|
||||
v.ui.AddCheckbox("Notify about finished downloads", true, func(bool) { v.edited() })
|
||||
v.ui.AddCheckbox("Ring the terminal bell too", false, func(bool) { v.edited() })
|
||||
v.ui.AddCheckbox("Show API keys", false, func(shown bool) { v.maskKeys(!shown) })
|
||||
v.covers = v.ui.GetFormItem(1).(*tview.Checkbox)
|
||||
v.style = v.ui.GetFormItem(2).(*tview.DropDown)
|
||||
v.notify = v.ui.GetFormItem(3).(*tview.Checkbox)
|
||||
v.bell = v.ui.GetFormItem(4).(*tview.Checkbox)
|
||||
v.showKeys = v.ui.GetFormItem(5).(*tview.Checkbox)
|
||||
|
||||
v.actions = tview.NewForm().SetItemPadding(0)
|
||||
v.actions.SetBorder(true).SetTitle(" Actions ")
|
||||
v.actions.SetBorderPadding(0, 0, 1, 1)
|
||||
v.actions.AddButton("Test connections", v.test)
|
||||
v.actions.AddButton("Save", v.save)
|
||||
v.actions.AddButton("Revert", v.revert)
|
||||
|
||||
v.status = tview.NewTextView().SetDynamicColors(true).SetWordWrap(true)
|
||||
v.status.SetBorder(true).SetTitle(" Result ")
|
||||
|
||||
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.radarr, 6, 0, true).
|
||||
AddItem(v.sonarr, 6, 0, false).
|
||||
AddItem(v.sab, 4, 0, false)
|
||||
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.ui, 8, 0, false).
|
||||
AddItem(v.actions, 4, 0, false).
|
||||
AddItem(v.status, 0, 1, false)
|
||||
v.root = tview.NewFlex().AddItem(left, 0, 1, true).AddItem(right, 0, 1, false)
|
||||
|
||||
v.groups = []*tview.Form{v.radarr, v.sonarr, v.sab, v.ui, v.actions}
|
||||
for i, g := range v.groups {
|
||||
i := i
|
||||
arrowNavigation(g, func() {}, &browseHooks{
|
||||
edge: func(down bool) bool {
|
||||
if down && i+1 < len(v.groups) {
|
||||
v.focusGroup(i+1, false)
|
||||
return true
|
||||
}
|
||||
if !down && i > 0 {
|
||||
v.focusGroup(i-1, true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
tab: func(dir int) bool {
|
||||
v.focusGroup((i+dir+len(v.groups))%len(v.groups), false)
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
v.fill(settingsFrom(app.cfg))
|
||||
v.maskKeys(true)
|
||||
v.describe()
|
||||
return v
|
||||
}
|
||||
|
||||
// focusGroup moves the focus to a section, to its first or last field.
|
||||
func (v *settingsView) focusGroup(i int, last bool) {
|
||||
// tview only blurs the leaf that had the focus, not the other items of a
|
||||
// form or the form itself; a form that keeps thinking it has focus would
|
||||
// take the key presses.
|
||||
for j, other := range v.groups {
|
||||
if j != i {
|
||||
blurForm(other)
|
||||
}
|
||||
}
|
||||
g := v.groups[i]
|
||||
idx := 0
|
||||
if last {
|
||||
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
|
||||
}
|
||||
g.SetFocus(idx)
|
||||
v.app.app.SetFocus(g)
|
||||
}
|
||||
|
||||
// blurForm takes the focus away from a form and everything in it.
|
||||
func blurForm(f *tview.Form) {
|
||||
for i := 0; i < f.GetFormItemCount(); i++ {
|
||||
f.GetFormItem(i).Blur()
|
||||
}
|
||||
for i := 0; i < f.GetButtonCount(); i++ {
|
||||
f.GetButton(i).Blur()
|
||||
}
|
||||
f.Blur()
|
||||
}
|
||||
|
||||
func (v *settingsView) edited() {
|
||||
if !v.filling {
|
||||
v.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// maskKeys hides or shows the API keys.
|
||||
func (v *settingsView) maskKeys(hide bool) {
|
||||
for key, in := range v.inputs {
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
if hide {
|
||||
in.SetMaskCharacter('*')
|
||||
} else {
|
||||
in.SetMaskCharacter(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fill puts the values into the fields.
|
||||
func (v *settingsView) fill(s settingsValues) {
|
||||
v.filling = true
|
||||
defer func() { v.filling = false }()
|
||||
set := map[string]string{
|
||||
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
|
||||
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
|
||||
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
||||
}
|
||||
for key, value := range set {
|
||||
v.inputs[key].SetText(value)
|
||||
}
|
||||
v.refresh.SetText(s.refresh)
|
||||
v.covers.SetChecked(s.covers)
|
||||
for i, name := range coverStyles {
|
||||
if name == s.coverStyle {
|
||||
v.style.SetCurrentOption(i)
|
||||
}
|
||||
}
|
||||
v.notify.SetChecked(s.notify)
|
||||
v.bell.SetChecked(s.bell)
|
||||
v.dirty = false
|
||||
}
|
||||
|
||||
// read collects the values from the fields.
|
||||
func (v *settingsView) read() settingsValues {
|
||||
text := func(key string) string { return v.inputs[key].GetText() }
|
||||
i, _ := v.style.GetCurrentOption()
|
||||
style := "auto"
|
||||
if i >= 0 && i < len(coverStyles) {
|
||||
style = coverStyles[i]
|
||||
}
|
||||
return settingsValues{
|
||||
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
|
||||
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
|
||||
sonarrURL: text("sonarr.url"), sonarrKey: text("sonarr.apikey"),
|
||||
sonarrProfile: text("sonarr.profile"), sonarrFolder: text("sonarr.folder"),
|
||||
sabURL: text("sabnzbd.url"), sabKey: text("sabnzbd.apikey"),
|
||||
refresh: v.refresh.GetText(),
|
||||
covers: v.covers.IsChecked(), coverStyle: style,
|
||||
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
|
||||
}
|
||||
}
|
||||
|
||||
// describe writes the standing information into the result box.
|
||||
func (v *settingsView) describe() {
|
||||
var b strings.Builder
|
||||
if v.app.cfgPath == "" {
|
||||
b.WriteString("[yellow]No config file is known, changes cannot be saved.[-]\n")
|
||||
} else {
|
||||
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
|
||||
}
|
||||
b.WriteString("Changes take effect when you save; nothing needs a restart.\n")
|
||||
if len(v.overridden) > 0 {
|
||||
var names []string
|
||||
for _, env := range v.overridden {
|
||||
names = append(names, env)
|
||||
}
|
||||
b.WriteString("[gray]Greyed-out fields come from the environment (" + strings.Join(names, ", ") + ") and are not written to the file.[-]\n")
|
||||
}
|
||||
v.status.SetText(b.String())
|
||||
}
|
||||
|
||||
// activate refreshes the fields when the view is shown, unless they hold unsaved edits.
|
||||
func (v *settingsView) activate() {
|
||||
if !v.dirty {
|
||||
v.fill(settingsFrom(v.app.cfg))
|
||||
}
|
||||
v.focusGroup(0, false)
|
||||
}
|
||||
|
||||
// report appends a line to the result box.
|
||||
func (v *settingsView) report(line string) {
|
||||
fmt.Fprintln(v.status, line)
|
||||
v.status.ScrollToEnd()
|
||||
}
|
||||
|
||||
func (v *settingsView) revert() {
|
||||
v.fill(settingsFrom(v.app.cfg))
|
||||
v.describe()
|
||||
v.report("[green]Reverted to the saved settings.[-]")
|
||||
}
|
||||
|
||||
// test checks the three services with the values in the fields, without saving.
|
||||
func (v *settingsView) test() {
|
||||
s := v.read()
|
||||
v.describe()
|
||||
v.report("Testing the connections…")
|
||||
type probeFn func(ctx context.Context) (string, error)
|
||||
tests := []struct {
|
||||
name string
|
||||
run probeFn
|
||||
}{
|
||||
{"Radarr", func(ctx context.Context) (string, error) {
|
||||
st, err := arr.NewClient(strings.TrimSpace(s.radarrURL), strings.TrimSpace(s.radarrKey)).SystemStatus(ctx)
|
||||
return st.Version, err
|
||||
}},
|
||||
{"Sonarr", func(ctx context.Context) (string, error) {
|
||||
st, err := arr.NewClient(strings.TrimSpace(s.sonarrURL), strings.TrimSpace(s.sonarrKey)).SystemStatus(ctx)
|
||||
return st.Version, err
|
||||
}},
|
||||
{"SABnzbd", func(ctx context.Context) (string, error) {
|
||||
return sabnzbd.NewClient(strings.TrimSpace(s.sabURL), strings.TrimSpace(s.sabKey)).Version(ctx)
|
||||
}},
|
||||
}
|
||||
for _, t := range tests {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
version, err := t.run(ctx)
|
||||
v.app.post(func() {
|
||||
if err != nil {
|
||||
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// save writes the config file and applies the settings.
|
||||
func (v *settingsView) save() {
|
||||
v.describe()
|
||||
if v.app.cfgPath == "" || v.app.cfgFile == nil {
|
||||
v.report("[red]There is no config file to save to.[-]")
|
||||
return
|
||||
}
|
||||
raw, err := applySettings(*v.app.cfgFile, v.read(), v.overridden)
|
||||
if err != nil {
|
||||
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
if err := config.Save(v.app.cfgPath, &raw); err != nil {
|
||||
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
effective, err := raw.Effective(os.Getenv)
|
||||
if err != nil { // cannot happen: applySettings validated it
|
||||
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
v.app.cfgFile = &raw
|
||||
v.app.applyConfig(effective)
|
||||
v.dirty = false
|
||||
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
|
||||
v.app.Notify(levelSuccess, "Settings saved")
|
||||
}
|
||||
Reference in New Issue
Block a user