filter.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. package filter
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "strings"
  8. "git.andreafazzi.eu/andrea/probo/pkg/store/file"
  9. "github.com/alecthomas/chroma/quick"
  10. "github.com/charmbracelet/bubbles/help"
  11. "github.com/charmbracelet/bubbles/key"
  12. "github.com/charmbracelet/bubbles/spinner"
  13. tea "github.com/charmbracelet/bubbletea"
  14. "github.com/charmbracelet/lipgloss"
  15. "github.com/itchyny/gojq"
  16. "github.com/remogatto/sugarfoam/components/group"
  17. "github.com/remogatto/sugarfoam/components/header"
  18. "github.com/remogatto/sugarfoam/components/statusbar"
  19. "github.com/remogatto/sugarfoam/components/textinput"
  20. "github.com/remogatto/sugarfoam/components/viewport"
  21. "github.com/remogatto/sugarfoam/layout"
  22. )
  23. type storeLoadedMsg struct {
  24. store []any
  25. }
  26. type resultMsg struct {
  27. result []any
  28. }
  29. type errorMsg struct {
  30. error error
  31. }
  32. type FilterModel struct {
  33. // UI
  34. textInput *textinput.Model
  35. viewport *viewport.Model
  36. group *group.Model
  37. help help.Model
  38. statusBar *statusbar.Model
  39. spinner spinner.Model
  40. // Layout
  41. document *layout.Layout
  42. // Key bindings
  43. bindings *keyBindings
  44. // file store
  45. store []any
  46. result []any
  47. // json
  48. lastQuery string
  49. FilteredJson string
  50. InputJson string
  51. Result string
  52. // filter file
  53. filterFilePath string
  54. state int
  55. filterType string
  56. }
  57. type keyBindings struct {
  58. group *group.Model
  59. quit, enter key.Binding
  60. }
  61. func (k *keyBindings) ShortHelp() []key.Binding {
  62. keys := make([]key.Binding, 0)
  63. current := k.group.Current()
  64. switch item := current.(type) {
  65. case *viewport.Model:
  66. keys = append(
  67. keys,
  68. item.KeyMap.Up,
  69. item.KeyMap.Down,
  70. )
  71. }
  72. keys = append(
  73. keys,
  74. k.quit,
  75. )
  76. return keys
  77. }
  78. func (k keyBindings) FullHelp() [][]key.Binding {
  79. return [][]key.Binding{
  80. {
  81. k.quit,
  82. },
  83. }
  84. }
  85. func newBindings(g *group.Model) *keyBindings {
  86. return &keyBindings{
  87. group: g,
  88. quit: key.NewBinding(
  89. key.WithKeys("esc"), key.WithHelp("esc", "Quit app"),
  90. ),
  91. enter: key.NewBinding(
  92. key.WithKeys("enter"), key.WithHelp("enter", "Quit app and return the results"),
  93. ),
  94. }
  95. }
  96. func New(path string, filterType string, stdin string) *FilterModel {
  97. textInput := textinput.New(
  98. textinput.WithPlaceholder("Write your jq filter here..."),
  99. )
  100. viewport := viewport.New()
  101. help := help.New()
  102. group := group.New(
  103. group.WithItems(textInput, viewport),
  104. group.WithLayout(
  105. layout.New(
  106. layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 0, 1, 0)}),
  107. layout.WithItem(textInput),
  108. layout.WithItem(viewport),
  109. ),
  110. ),
  111. )
  112. bindings := newBindings(group)
  113. statusBar := statusbar.New(bindings)
  114. s := spinner.New(
  115. spinner.WithStyle(
  116. lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
  117. )
  118. s.Spinner = spinner.Dot
  119. header := header.New(
  120. header.WithContent(
  121. lipgloss.NewStyle().
  122. Bold(true).
  123. Border(lipgloss.NormalBorder(), false, false, true, false).
  124. Render(filterTypeFormats[filterType]),
  125. ),
  126. )
  127. document := layout.New(
  128. layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
  129. layout.WithItem(header),
  130. layout.WithItem(group),
  131. layout.WithItem(statusBar),
  132. )
  133. return &FilterModel{
  134. textInput: textInput,
  135. viewport: viewport,
  136. group: group,
  137. statusBar: statusBar,
  138. spinner: s,
  139. document: document,
  140. bindings: bindings,
  141. help: help,
  142. filterType: filterType,
  143. filterFilePath: path,
  144. InputJson: stdin,
  145. }
  146. }
  147. func (m *FilterModel) Init() tea.Cmd {
  148. var cmds []tea.Cmd
  149. cmds = append(cmds, m.group.Init(), m.loadStore(), m.spinner.Tick)
  150. m.group.Focus()
  151. return tea.Batch(cmds...)
  152. }
  153. func (m *FilterModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  154. var cmds []tea.Cmd
  155. switch msg := msg.(type) {
  156. case tea.WindowSizeMsg:
  157. m.document.SetSize(msg.Width, msg.Height)
  158. case tea.KeyMsg:
  159. switch {
  160. case key.Matches(msg, m.bindings.quit):
  161. m.FilteredJson = ""
  162. return m, tea.Quit
  163. case key.Matches(msg, m.bindings.enter):
  164. m.marshalJSON()
  165. return m, tea.Quit
  166. }
  167. case storeLoadedMsg:
  168. cmds = append(cmds, m.handleStoreLoaded(msg))
  169. case resultMsg:
  170. cmds = append(cmds, m.handleFiltered(msg))
  171. m.state = FilterState
  172. case errorMsg:
  173. m.handleError(msg)
  174. m.state = ErrorState
  175. }
  176. cmds = m.handleState(msg, cmds)
  177. return m, tea.Batch(cmds...)
  178. }
  179. func (m *FilterModel) View() string {
  180. return m.document.View()
  181. }
  182. func (m *FilterModel) marshalJSON() {
  183. if m.FilteredJson == "" {
  184. return
  185. }
  186. if m.InputJson != "" {
  187. // result := make([]interface{}, 2)
  188. // err := json.Unmarshal([]byte(m.InputJson), &result[0])
  189. // if err != nil {
  190. // panic(err)
  191. // }
  192. // filtered := fmt.Sprintf("{\"%s\": %s}", m.filterType, m.FilteredJson)
  193. // err = json.Unmarshal([]byte(filtered), &result[1])
  194. // if err != nil {
  195. // panic(err)
  196. // }
  197. // resultJson, err := json.Marshal(result)
  198. // if err != nil {
  199. // panic(err)
  200. // }
  201. // m.Result = string(resultJson)
  202. m.Result = fmt.Sprintf("{%s, \"%s\": %s}", strings.Trim(m.InputJson, "{}"), m.filterType, m.FilteredJson)
  203. } else {
  204. var result interface{}
  205. filtered := fmt.Sprintf("{\"%s\": %s}", m.filterType, m.FilteredJson)
  206. err := json.Unmarshal([]byte(filtered), &result)
  207. if err != nil {
  208. panic(err)
  209. }
  210. resultJson, err := json.Marshal(result)
  211. if err != nil {
  212. panic(err)
  213. }
  214. m.Result = string(resultJson)
  215. }
  216. }
  217. func (m *FilterModel) showErrorOnStatusBar(err error) {
  218. m.statusBar.SetContent(
  219. stateFormats[ErrorState][0],
  220. fmt.Sprintf(stateFormats[ErrorState][1], err),
  221. stateFormats[ErrorState][2],
  222. )
  223. }
  224. func (m *FilterModel) handleError(msg tea.Msg) {
  225. err := msg.(errorMsg)
  226. m.statusBar.SetContent(
  227. stateFormats[ErrorState][0],
  228. fmt.Sprintf(stateFormats[ErrorState][1], err.error),
  229. stateFormats[ErrorState][2],
  230. )
  231. }
  232. func (m *FilterModel) handleStoreLoaded(msg tea.Msg) tea.Cmd {
  233. return func() tea.Msg {
  234. storeMsg := msg.(storeLoadedMsg)
  235. m.store = storeMsg.store
  236. m.state = FilterState
  237. if m.filterFilePath != "" {
  238. jq, err := os.ReadFile(m.filterFilePath)
  239. if err != nil {
  240. panic(err)
  241. }
  242. m.textInput.SetValue(strings.TrimSpace(string(jq)))
  243. return m.query(strings.TrimSpace(string(jq)))
  244. }
  245. coloredJson, err := toColoredJson(m.store)
  246. if err != nil {
  247. return errorMsg{err}
  248. }
  249. m.viewport.SetContent(coloredJson)
  250. return m.query(".")
  251. }
  252. }
  253. func (m *FilterModel) handleFiltered(msg tea.Msg) tea.Cmd {
  254. return func() tea.Msg {
  255. m.result = msg.(resultMsg).result
  256. coloredJson, err := toColoredJson(m.result)
  257. if err != nil {
  258. return errorMsg{err}
  259. }
  260. json, err := toJson(m.result)
  261. if err != nil {
  262. return errorMsg{err}
  263. }
  264. m.FilteredJson = json
  265. m.viewport.SetContent(coloredJson)
  266. return nil
  267. }
  268. }
  269. func (m *FilterModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
  270. _, cmd := m.group.Update(msg)
  271. if m.state == LoadingStoreState {
  272. return m.updateSpinner(msg, cmd, cmds)
  273. }
  274. cmds = append(cmds, cmd, m.query(m.textInput.Value()))
  275. if m.state != ErrorState {
  276. m.statusBar.SetContent(stateFormats[FilterState]...)
  277. }
  278. return cmds
  279. }
  280. func (m *FilterModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
  281. m.spinner, cmd = m.spinner.Update(msg)
  282. m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
  283. cmds = append(cmds, cmd)
  284. return cmds
  285. }
  286. func (m *FilterModel) loadStore() tea.Cmd {
  287. return func() tea.Msg {
  288. var jsonStore []byte
  289. if m.filterType == "participants" {
  290. pStore, err := file.NewDefaultParticipantFileStore()
  291. if err != nil {
  292. return errorMsg{err}
  293. }
  294. jsonStore, err = pStore.Storer.Json()
  295. if err != nil {
  296. return errorMsg{err}
  297. }
  298. } else if m.filterType == "quizzes" {
  299. qStore, err := file.NewDefaultQuizFileStore()
  300. if err != nil {
  301. return errorMsg{err}
  302. }
  303. jsonStore, err = qStore.Storer.Json()
  304. if err != nil {
  305. return errorMsg{err}
  306. }
  307. } else {
  308. panic("Unknown filter type!")
  309. }
  310. v := make([]any, 0)
  311. err := json.Unmarshal(jsonStore, &v)
  312. if err != nil {
  313. return errorMsg{err}
  314. }
  315. return storeLoadedMsg{v}
  316. }
  317. }
  318. func (m *FilterModel) query(input string) tea.Cmd {
  319. return func() tea.Msg {
  320. if input == m.lastQuery {
  321. return nil
  322. }
  323. if m.state == LoadingStoreState {
  324. return nil
  325. }
  326. m.lastQuery = input
  327. query, err := gojq.Parse(input)
  328. if err != nil {
  329. return errorMsg{fmt.Errorf("jq query parse error: %v", err)}
  330. }
  331. var result []string
  332. iter := query.Run(m.store)
  333. for {
  334. v, ok := iter.Next()
  335. if !ok {
  336. break
  337. }
  338. if err, ok := v.(error); ok {
  339. return errorMsg{fmt.Errorf("jq query run error: %v", err)}
  340. }
  341. b, err := json.MarshalIndent(v, "", " ")
  342. if err != nil {
  343. return errorMsg{err}
  344. }
  345. result = append(result, string(b))
  346. }
  347. v := make([]any, 0)
  348. err = json.Unmarshal([]byte(strings.Join(result, "\n")), &v)
  349. if err != nil {
  350. return errorMsg{err}
  351. }
  352. return resultMsg{v}
  353. }
  354. }
  355. func toColoredJson(data []any) (string, error) {
  356. result, err := json.MarshalIndent(data, "", " ")
  357. if err != nil {
  358. return "", err
  359. }
  360. coloredBytes := make([]byte, 0)
  361. buffer := bytes.NewBuffer(coloredBytes)
  362. err = quick.Highlight(buffer, string(result), "json", "terminal16m", "dracula")
  363. if err != nil {
  364. panic(err)
  365. }
  366. return sanitize(buffer.String()), nil
  367. }
  368. func toJson(data []any) (string, error) {
  369. result, err := json.Marshal(data)
  370. if err != nil {
  371. return "", err
  372. }
  373. return string(result), nil
  374. }
  375. func sanitize(text string) string {
  376. // FIXME: The use of a standard '-' character causes rendering
  377. // issues within the viewport. Further investigation is
  378. // required to resolve this problem.
  379. return strings.Replace(text, "-", "–", -1)
  380. }