The code in //users/wpcarro/tools/monzo_ynab/ynab/client.go was not valid Go and has been commented out. Change-Id: Icb4003607f30294dcbf60132eb7722702c7f0d84 Reviewed-on: https://cl.tvl.fyi/c/depot/+/4400 Tested-by: BuildkiteCI Reviewed-by: wpcarro <wpcarro@gmail.com> Reviewed-by: Profpatsch <mail@profpatsch.de>
		
			
				
	
	
		
			431 lines
		
	
	
	
		
			8.7 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			431 lines
		
	
	
	
		
			8.7 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package main
 | 
						||
 | 
						||
import (
 | 
						||
	json "encoding/json"
 | 
						||
	"fmt"
 | 
						||
	"log"
 | 
						||
	"os"
 | 
						||
	"sort"
 | 
						||
	"strings"
 | 
						||
 | 
						||
	tea "github.com/charmbracelet/bubbletea"
 | 
						||
	lipgloss "github.com/charmbracelet/lipgloss"
 | 
						||
	// termenv "github.com/muesli/termenv"
 | 
						||
	// isatty "github.com/mattn/go-isatty"
 | 
						||
)
 | 
						||
 | 
						||
// Keeps the full data structure and a path that indexes our current position into it.
 | 
						||
type model struct {
 | 
						||
	path []index
 | 
						||
	data val
 | 
						||
}
 | 
						||
 | 
						||
// an index into a value, uint for lists and string for maps.
 | 
						||
// nil for any scalar value.
 | 
						||
// TODO: use an actual interface for these
 | 
						||
type index interface{}
 | 
						||
 | 
						||
/// recursive value that we can represent.
 | 
						||
type val struct {
 | 
						||
	// the “type” of value; see tag const belove
 | 
						||
	tag tag
 | 
						||
	// last known position of our cursor
 | 
						||
	last_index index
 | 
						||
	// documentation (TODO)
 | 
						||
	doc string
 | 
						||
	// the actual value;
 | 
						||
	// the actual structure is behind a pointer so we can replace the struct.
 | 
						||
	// determined by the tag
 | 
						||
	// tagString -> *string
 | 
						||
	// tagFloat -> *float64
 | 
						||
	// tagList -> *[]val
 | 
						||
	// tagMap -> *map[string]val
 | 
						||
	val interface{}
 | 
						||
}
 | 
						||
 | 
						||
type tag string
 | 
						||
 | 
						||
const (
 | 
						||
	tagString tag = "string"
 | 
						||
	tagFloat  tag = "float"
 | 
						||
	tagList   tag = "list"
 | 
						||
	tagMap    tag = "map"
 | 
						||
)
 | 
						||
 | 
						||
// print a value, flat
 | 
						||
func (v val) Render() string {
 | 
						||
	s := ""
 | 
						||
	switch v.tag {
 | 
						||
	case tagString:
 | 
						||
		s += *v.val.(*string)
 | 
						||
	case tagFloat:
 | 
						||
		s += fmt.Sprint(*v.val.(*float64))
 | 
						||
	case tagList:
 | 
						||
		s += "[ "
 | 
						||
		vs := []string{}
 | 
						||
		for _, enum := range v.enumerate() {
 | 
						||
			vs = append(vs, enum.v.Render())
 | 
						||
		}
 | 
						||
		s += strings.Join(vs, ", ")
 | 
						||
		s += " ]"
 | 
						||
	case tagMap:
 | 
						||
		s += "{ "
 | 
						||
		vs := []string{}
 | 
						||
		for _, enum := range v.enumerate() {
 | 
						||
			vs = append(vs, fmt.Sprintf("%s: %s", enum.i.(string), enum.v.Render()))
 | 
						||
		}
 | 
						||
		s += strings.Join(vs, ", ")
 | 
						||
		s += " }"
 | 
						||
	default:
 | 
						||
		s += fmt.Sprintf("<unknown: %v>", v)
 | 
						||
	}
 | 
						||
	return s
 | 
						||
}
 | 
						||
 | 
						||
// render an index, depending on the type
 | 
						||
func renderIndex(i index) (s string) {
 | 
						||
	switch i := i.(type) {
 | 
						||
	case nil:
 | 
						||
		s = ""
 | 
						||
	// list index
 | 
						||
	case uint:
 | 
						||
		s = "*"
 | 
						||
	// map index
 | 
						||
	case string:
 | 
						||
		s = i + ":"
 | 
						||
	}
 | 
						||
	return
 | 
						||
}
 | 
						||
 | 
						||
// take an arbitrary (within restrictions) go value and construct a val from it
 | 
						||
func makeVal(i interface{}) val {
 | 
						||
	var v val
 | 
						||
	switch i := i.(type) {
 | 
						||
	case string:
 | 
						||
		v = val{
 | 
						||
			tag:        tagString,
 | 
						||
			last_index: index(nil),
 | 
						||
			doc:        "",
 | 
						||
			val:        &i,
 | 
						||
		}
 | 
						||
	case float64:
 | 
						||
		v = val{
 | 
						||
			tag:        tagFloat,
 | 
						||
			last_index: index(nil),
 | 
						||
			doc:        "",
 | 
						||
			val:        &i,
 | 
						||
		}
 | 
						||
	case []interface{}:
 | 
						||
		ls := []val{}
 | 
						||
		for _, i := range i {
 | 
						||
			ls = append(ls, makeVal(i))
 | 
						||
		}
 | 
						||
		v = val{
 | 
						||
			tag:        tagList,
 | 
						||
			last_index: pos1Inner(tagList, &ls),
 | 
						||
			doc:        "",
 | 
						||
			val:        &ls,
 | 
						||
		}
 | 
						||
	case map[string]interface{}:
 | 
						||
		ls := map[string]val{}
 | 
						||
		for k, i := range i {
 | 
						||
			ls[k] = makeVal(i)
 | 
						||
		}
 | 
						||
		v = val{
 | 
						||
			tag:        tagMap,
 | 
						||
			last_index: pos1Inner(tagMap, &ls),
 | 
						||
			doc:        "",
 | 
						||
			val:        &ls,
 | 
						||
		}
 | 
						||
	default:
 | 
						||
		log.Fatalf("makeVal: cannot read json of type %T", i)
 | 
						||
	}
 | 
						||
	return v
 | 
						||
}
 | 
						||
 | 
						||
// return an index that points at the first entry in val
 | 
						||
func (v val) pos1() index {
 | 
						||
	return v.enumerate()[0].i
 | 
						||
}
 | 
						||
 | 
						||
func pos1Inner(tag tag, v interface{}) index {
 | 
						||
	return enumerateInner(tag, v)[0].i
 | 
						||
}
 | 
						||
 | 
						||
type enumerate struct {
 | 
						||
	i index
 | 
						||
	v val
 | 
						||
}
 | 
						||
 | 
						||
// enumerate gives us a stable ordering of elements in this val.
 | 
						||
// for scalars it’s just a nil index & the val itself.
 | 
						||
// Guaranteed to always return at least one element.
 | 
						||
func (v val) enumerate() (e []enumerate) {
 | 
						||
	e = enumerateInner(v.tag, v.val)
 | 
						||
	if e == nil {
 | 
						||
		e = append(e, enumerate{
 | 
						||
			i: nil,
 | 
						||
			v: v,
 | 
						||
		})
 | 
						||
	}
 | 
						||
	return
 | 
						||
}
 | 
						||
 | 
						||
// like enumerate, but returns an empty slice for scalars without inner vals.
 | 
						||
func enumerateInner(tag tag, v interface{}) (e []enumerate) {
 | 
						||
	switch tag {
 | 
						||
	case tagString:
 | 
						||
		fallthrough
 | 
						||
	case tagFloat:
 | 
						||
		e = nil
 | 
						||
	case tagList:
 | 
						||
		for i, v := range *v.(*[]val) {
 | 
						||
			e = append(e, enumerate{i: index(uint(i)), v: v})
 | 
						||
		}
 | 
						||
	case tagMap:
 | 
						||
		// map sorting order is not stable (actually randomized thank jabber)
 | 
						||
		// so let’s sort them
 | 
						||
		keys := []string{}
 | 
						||
		m := *v.(*map[string]val)
 | 
						||
		for k, _ := range m {
 | 
						||
			keys = append(keys, k)
 | 
						||
		}
 | 
						||
		sort.Strings(keys)
 | 
						||
		for _, k := range keys {
 | 
						||
			e = append(e, enumerate{i: index(k), v: m[k]})
 | 
						||
		}
 | 
						||
	default:
 | 
						||
		log.Fatalf("unknown val tag %s, %v", tag, v)
 | 
						||
	}
 | 
						||
	return
 | 
						||
}
 | 
						||
 | 
						||
func (m model) PathString() string {
 | 
						||
	s := "/ "
 | 
						||
	var is []string
 | 
						||
	for _, v := range m.path {
 | 
						||
		is = append(is, fmt.Sprintf("%v", v))
 | 
						||
	}
 | 
						||
	s += strings.Join(is, " / ")
 | 
						||
	return s
 | 
						||
}
 | 
						||
 | 
						||
// walk the given path down in data, to get the value at that point.
 | 
						||
// Assumes that all path indexes are valid indexes into data.
 | 
						||
// Returns a pointer to the value at point, in order to be able to change it.
 | 
						||
func walk(data *val, path []index) (*val, bool, error) {
 | 
						||
	res := data
 | 
						||
	atPath := func(index int) string {
 | 
						||
		return fmt.Sprintf("at path %v", path[:index+1])
 | 
						||
	}
 | 
						||
	errf := func(ty string, val interface{}, index int) error {
 | 
						||
		return fmt.Errorf("walk: can’t walk into %s %v %s", ty, val, atPath(index))
 | 
						||
	}
 | 
						||
	for i, p := range path {
 | 
						||
		switch res.tag {
 | 
						||
		case tagString:
 | 
						||
			return nil, true, nil
 | 
						||
		case tagFloat:
 | 
						||
			return nil, true, nil
 | 
						||
		case tagList:
 | 
						||
			switch p := p.(type) {
 | 
						||
			case uint:
 | 
						||
				list := *res.val.(*[]val)
 | 
						||
				if int(p) >= len(list) || p < 0 {
 | 
						||
					return nil, false, fmt.Errorf("index out of bounds %s", atPath(i))
 | 
						||
				}
 | 
						||
				res = &list[p]
 | 
						||
			default:
 | 
						||
				return nil, false, fmt.Errorf("not a list index %s", atPath(i))
 | 
						||
			}
 | 
						||
		case tagMap:
 | 
						||
			switch p := p.(type) {
 | 
						||
			case string:
 | 
						||
				m := *res.val.(*map[string]val)
 | 
						||
				if a, ok := m[p]; ok {
 | 
						||
					res = &a
 | 
						||
				} else {
 | 
						||
					return nil, false, fmt.Errorf("index %s not in map %s", p, atPath(i))
 | 
						||
				}
 | 
						||
			default:
 | 
						||
				return nil, false, fmt.Errorf("not a map index %v %s", p, atPath(i))
 | 
						||
			}
 | 
						||
 | 
						||
		default:
 | 
						||
			return nil, false, errf(string(res.tag), res.val, i)
 | 
						||
		}
 | 
						||
	}
 | 
						||
	return res, false, nil
 | 
						||
}
 | 
						||
 | 
						||
// descend into the selected index. Assumes that the index is valid.
 | 
						||
// Will not descend into scalars.
 | 
						||
func (m model) descend() (model, error) {
 | 
						||
	// TODO: two walks?!
 | 
						||
	this, _, err := walk(&m.data, m.path)
 | 
						||
	if err != nil {
 | 
						||
		return m, err
 | 
						||
	}
 | 
						||
	newPath := append(m.path, this.last_index)
 | 
						||
	_, bounce, err := walk(&m.data, newPath)
 | 
						||
	if err != nil {
 | 
						||
		return m, err
 | 
						||
	}
 | 
						||
	// only descend if we *can*
 | 
						||
	if !bounce {
 | 
						||
		m.path = newPath
 | 
						||
	}
 | 
						||
	return m, nil
 | 
						||
}
 | 
						||
 | 
						||
// ascend to one level up. stops at the root.
 | 
						||
func (m model) ascend() (model, error) {
 | 
						||
	if len(m.path) > 0 {
 | 
						||
		m.path = m.path[:len(m.path)-1]
 | 
						||
		_, _, err := walk(&m.data, m.path)
 | 
						||
		return m, err
 | 
						||
	}
 | 
						||
	return m, nil
 | 
						||
}
 | 
						||
 | 
						||
/// go to the next item, or wraparound
 | 
						||
func (min model) next() (m model, err error) {
 | 
						||
	m = min
 | 
						||
	this, _, err := walk(&m.data, m.path)
 | 
						||
	if err != nil {
 | 
						||
		return
 | 
						||
	}
 | 
						||
	enumL := this.enumerate()
 | 
						||
	setNext := false
 | 
						||
	for _, enum := range enumL {
 | 
						||
		if setNext {
 | 
						||
			this.last_index = enum.i
 | 
						||
			setNext = false
 | 
						||
			break
 | 
						||
		}
 | 
						||
		if enum.i == this.last_index {
 | 
						||
			setNext = true
 | 
						||
		}
 | 
						||
	}
 | 
						||
	// wraparound
 | 
						||
	if setNext {
 | 
						||
		this.last_index = enumL[0].i
 | 
						||
	}
 | 
						||
	return
 | 
						||
}
 | 
						||
 | 
						||
/// go to the previous item, or wraparound
 | 
						||
func (min model) prev() (m model, err error) {
 | 
						||
	m = min
 | 
						||
	this, _, err := walk(&m.data, m.path)
 | 
						||
	if err != nil {
 | 
						||
		return
 | 
						||
	}
 | 
						||
	enumL := this.enumerate()
 | 
						||
	// last element, wraparound
 | 
						||
	prevIndex := enumL[len(enumL)-1].i
 | 
						||
	for _, enum := range enumL {
 | 
						||
		if enum.i == this.last_index {
 | 
						||
			this.last_index = prevIndex
 | 
						||
			break
 | 
						||
		}
 | 
						||
		prevIndex = enum.i
 | 
						||
	}
 | 
						||
	return
 | 
						||
}
 | 
						||
 | 
						||
/// bubbletea implementations
 | 
						||
 | 
						||
func (m model) Init() tea.Cmd {
 | 
						||
	return nil
 | 
						||
}
 | 
						||
 | 
						||
func initialModel(v interface{}) model {
 | 
						||
	val := makeVal(v)
 | 
						||
	return model{
 | 
						||
		path: []index{},
 | 
						||
		data: val,
 | 
						||
	}
 | 
						||
}
 | 
						||
 | 
						||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 | 
						||
	var err error
 | 
						||
	switch msg := msg.(type) {
 | 
						||
	case tea.KeyMsg:
 | 
						||
		switch msg.String() {
 | 
						||
		case "ctrl+c", "q":
 | 
						||
			return m, tea.Quit
 | 
						||
 | 
						||
		case "up":
 | 
						||
			m, err = m.prev()
 | 
						||
 | 
						||
		case "down":
 | 
						||
			m, err = m.next()
 | 
						||
 | 
						||
		case "right":
 | 
						||
			m, err = m.descend()
 | 
						||
 | 
						||
		case "left":
 | 
						||
			m, err = m.ascend()
 | 
						||
 | 
						||
			// 	case "enter":
 | 
						||
			// 		_, ok := m.selected[m.cursor]
 | 
						||
			// 		if ok {
 | 
						||
			// 			delete(m.selected, m.cursor)
 | 
						||
			// 		} else {
 | 
						||
			// 			m.selected[m.cursor] = struct{}{}
 | 
						||
			// 		}
 | 
						||
		}
 | 
						||
 | 
						||
	}
 | 
						||
	if err != nil {
 | 
						||
		log.Fatal(err)
 | 
						||
	}
 | 
						||
	return m, nil
 | 
						||
}
 | 
						||
 | 
						||
var pathColor = lipgloss.NewStyle().
 | 
						||
	// light blue
 | 
						||
	Foreground(lipgloss.Color("12"))
 | 
						||
 | 
						||
var selectedColor = lipgloss.NewStyle().
 | 
						||
	Bold(true)
 | 
						||
 | 
						||
func (m model) View() string {
 | 
						||
	s := pathColor.Render(m.PathString())
 | 
						||
	cur, _, err := walk(&m.data, m.path)
 | 
						||
	if err != nil {
 | 
						||
		log.Fatal(err)
 | 
						||
	}
 | 
						||
	s += cur.doc + "\n"
 | 
						||
	s += "\n"
 | 
						||
	for _, enum := range cur.enumerate() {
 | 
						||
		is := renderIndex(enum.i)
 | 
						||
		if is != "" {
 | 
						||
			s += is + " "
 | 
						||
		}
 | 
						||
		if enum.i == cur.last_index {
 | 
						||
			s += selectedColor.Render(enum.v.Render())
 | 
						||
		} else {
 | 
						||
			s += enum.v.Render()
 | 
						||
		}
 | 
						||
		s += "\n"
 | 
						||
	}
 | 
						||
 | 
						||
	// s += fmt.Sprintf("%v\n", m)
 | 
						||
	// s += fmt.Sprintf("%v\n", cur)
 | 
						||
 | 
						||
	return s
 | 
						||
}
 | 
						||
 | 
						||
func main() {
 | 
						||
	var input interface{}
 | 
						||
	err := json.NewDecoder(os.Stdin).Decode(&input)
 | 
						||
	if err != nil {
 | 
						||
		log.Fatal("json from stdin: ", err)
 | 
						||
	}
 | 
						||
	p := tea.NewProgram(initialModel(input))
 | 
						||
	if err := p.Start(); err != nil {
 | 
						||
		log.Fatal("bubbletea TUI error: ", err)
 | 
						||
	}
 | 
						||
}
 |