diff --git a/app/screen_home.go b/app/screen_home.go index a6ccbde..59f5d8e 100644 --- a/app/screen_home.go +++ b/app/screen_home.go @@ -23,8 +23,10 @@ package app import ( "fmt" + "log/slog" "net/url" "strings" + "time" "git.bullercodeworks.com/brian/expds/data" "git.bullercodeworks.com/brian/expds/data/models" @@ -43,8 +45,7 @@ type ScreenHome struct { w, h int style tcell.Style - alert *w.Alert - showAlert bool + alert *w.Alert menuLayout *w.TopMenuLayout @@ -215,9 +216,6 @@ func (s *ScreenHome) Draw() { s.a.DrawWidget(s.loading) } s.a.DrawWidget(s.status) - if s.showAlert { - s.a.DrawWidget(s.alert) - } } func (s *ScreenHome) Exit() error { return nil } func (s *ScreenHome) Log(t string, a ...any) { @@ -318,9 +316,11 @@ func (s *ScreenHome) initCli() { func (s *ScreenHome) toggleCli() { if s.layout.Contains(s.cli) { s.layout.Delete(s.cli) + s.layout.ActivateWidget(s.columns) } else { s.layout.Add(s.cli) s.cli.SetVisible(true) + s.layout.ActivateWidget(s.cli) } } func (s *ScreenHome) hideCli() { @@ -346,7 +346,13 @@ func (s *ScreenHome) cliGetPds(args ...string) bool { } go func() { defer func() { s.isLoading = false }() - pds, err := s.r.GetPDS(args[1]) + var pds *models.Pds + var err error + if s.activePds != nil && string(s.activePds.AtId) == args[1] { + pds, err = s.r.ReloadPds(args[1]) + } else { + pds, err = s.r.GetPDS(args[1]) + } if err != nil { s.Log(err.Error()) return @@ -355,7 +361,7 @@ func (s *ScreenHome) cliGetPds(args ...string) bool { return } s.doOpen = false - s.cli.Log("Retrieved: %s (%s)", pds.AtId, pds.Did) + slog.Default().Debug(fmt.Sprintf("Retrieved: %s (%s) at %s", pds.AtId, pds.Did, time.Now().Format(time.RFC3339))) s.activePds = pds s.updatePdsListing() n, err := s.pdsListing.GetActiveNode() @@ -425,11 +431,14 @@ func (s *ScreenHome) cliSendStatus(args ...string) bool { s.isLoading = true go func() { defer func() { s.isLoading = false }() - if !s.r.Auth.HasAuth() { + if !s.r.Auth.HasAuth(s.activePds.Did) { s.Log("Not authorized. Run `authpds`") return } - s.r.SendToPDS() + err := s.r.SendToPDS(s.activePds.Did) + if err != nil { + s.Log("Error sending status: %w", err) + } }() return true } @@ -438,6 +447,7 @@ func (s *ScreenHome) updatePdsListing() { s.pdsListing.SetTitle(fmt.Sprintf("─ %s (%s)", s.activePds.AtId.String(), s.activePds.Did.String())) s.pdsListing.Clear() nsidList := s.activePds.NSIDStringList() + var tree []*wd.TreeNode for i, v := range nsidList { t := wd.NewTreeNode(v, v) nsid := s.activePds.NSIDs[i] @@ -460,8 +470,9 @@ func (s *ScreenHome) updatePdsListing() { c := wd.NewTreeNode(label, rIds[j]) t.AddChild(c) } - s.pdsListing.Add(t) + tree = append(tree, t) } + s.pdsListing.SetTree(tree) s.layout.ActivateWidget(s.columns) s.columns.ActivateWidget(s.pdsListing) s.pdsListing.SetActive(true) diff --git a/app/screen_home.go.orig b/app/screen_home.go.orig new file mode 100644 index 0000000..2c1a81e --- /dev/null +++ b/app/screen_home.go.orig @@ -0,0 +1,531 @@ +/* +Copyright © Brian Buller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package app + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "git.bullercodeworks.com/brian/expds/data" + "git.bullercodeworks.com/brian/expds/data/models" + wd "git.bullercodeworks.com/brian/expds/widgets" + w "git.bullercodeworks.com/brian/tcell-widgets" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/gdamore/tcell" + "github.com/skratchdot/open-golang/open" + "github.com/spf13/viper" + "golang.design/x/clipboard" +) + +type ScreenHome struct { + a *App + r *data.Repo + w, h int + style tcell.Style + + alert *w.Alert + + menuLayout *w.TopMenuLayout + + openPdsEntry *w.Field + + layout *w.LinearLayout + columns *w.LinearLayout + + activePds *models.Pds + pdsListing *wd.TreeBrowser + jsonContent *wd.JsonContent + status *wd.StatusBar + stPathBlock *wd.StatusBlock + + cli *w.Cli + + doOpen bool + isLoading bool + loading *w.Spinner + + cursor int +} + +func (s *ScreenHome) Init(a *App) { + s.a, s.r = a, a.repo + s.r.SetLogFunc(s.Log) + + s.style = a.style + + s.alert = w.NewAlert("expds.alert", s.style) + + s.openPdsEntry = w.NewField("home.openpds.field", s.style) + s.openPdsEntry.SetLabel("ID") + s.openPdsEntry.SetActive(true) + s.openPdsEntry.SetPos(w.Coord{X: 3, Y: 1}) + s.openPdsEntry.SetSize(w.Coord{X: 20, Y: 1}) + s.doOpen = true + + s.isLoading = false + s.loading = w.NewSpinner("home.loading", s.style) + s.loading.SetVisible(true) + s.loading.SetPos(w.Coord{X: 2, Y: 1}) + + s.menuLayout = w.NewTopMenuLayout("home.toplayout", s.style) + s.initMenu() + + s.cli = w.NewCli("home.cli", s.style) + s.initCli() + s.cli.SetVisible(false) + + s.layout = w.NewLinearLayout("home.layout", s.style) + + s.columns = w.NewLinearLayout("home.layout.columns", s.style) + s.columns.SetOrientation(w.LinLayH) + + //s.pdsListing = w.NewSimpleListWithHelp("pdslisting", s.style) + s.pdsListing = wd.NewTreeBrowser("pdslisting", s.style) + s.pdsListing.SetBorder([]rune{'─', '┬', '│', '┴', '─', '└', '│', '┌', '├', '─', '┤', '┬', '│', '┴', '┼'}) + + s.pdsListing.SetTitle(strings.Repeat(" ", 30)) + s.pdsListing.SetOnSelect(s.selectPdsListingEntry) + s.pdsListing.SetOnChange(s.changePdsList) + s.pdsListing.SetVimMode(viper.GetBool(data.KeyVimMode)) + s.pdsListing.SetLogger(s.Log) + + s.jsonContent = wd.NewJsonContent("jsoncontent", s.style) + km := s.jsonContent.GetKeyMap() + km.Add( + w.NewKey(w.BuildEK(tcell.KeyEnter), func(ev *tcell.EventKey) bool { + // Init returns an error if the package is not ready for use. + err := clipboard.Init() + if err != nil { + s.Log("Error initializing clipboard: %s", err.Error()) + return true + } + clipboard.Write(clipboard.FmtText, []byte(s.jsonContent.GetSelectedValue())) + return true + }), + w.NewKey(w.BuildEKr('O'), func(ev *tcell.EventKey) bool { + url := fmt.Sprintf("https://%s/xrpc/com.atproto.sync.getBlob?did=did:plc:pqwuemo2ic5tqmpwrajb2phi&cid=%s", s.activePds.AtId.String(), s.jsonContent.GetSelectedValue()) + open.Run(url) + return true + }), + ) + s.jsonContent.SetKeyMap(km) + s.jsonContent.SetBorder( + []rune{'─', '┐', '│', '┘', '─', '─', ' ', '─', '├', '─', '┤', '┬', '│', '┴', '┼'}, + ) + + statusStyle := s.style.Background(tcell.ColorDarkSlateGray) + s.status = wd.NewStatusBar("home.statusbar", statusStyle) + s.status.SetPos(w.Coord{X: 0, Y: s.a.GetH() - 1}) + s.status.SetLogger(s.Log) + s.stPathBlock = wd.NewStatusBlock("home.statusbar.block", statusStyle.Foreground(tcell.ColorDarkSlateGray).Background(tcell.ColorOrange)) + s.stPathBlock.SetType(wd.SBTypePath) + s.stPathBlock.SetParts([]string{"No PDS Loaded"}) + s.status.Add(s.stPathBlock) + + s.columns.AddAll(s.pdsListing, s.jsonContent) + + s.layout.AddAll(s.columns) + s.layout.SetLogger(s.Log) + s.layout.SetWeight(s.columns, 4) + s.menuLayout.SetWidget(s.layout) + s.layout.SetLogger(s.Log) + s.columns.SetLogger(s.Log) +} + +func (s *ScreenHome) GetName() string { return "home" } +func (s *ScreenHome) HandleResize(ev *tcell.EventResize) { + s.w, s.h = ev.Size() + s.menuLayout.HandleResize(w.Coord{X: s.w, Y: s.h - 1}.ResizeEvent()) + s.alert.HandleResize(ev) + + s.status.SetPos(w.Coord{X: 0, Y: s.h - 1}) + s.status.HandleResize(w.Coord{X: s.w, Y: 1}.ResizeEvent()) +} + +func (s *ScreenHome) HandleKey(ev *tcell.EventKey) bool { +<<<<<<< HEAD + if s.showAlert { + handled := s.alert.HandleKey(ev) + s.alert.SetMessage(fmt.Sprintf("Alert Handled? %v -> %s", handled, ev.Name())) + return handled + } +======= +>>>>>>> 367d62ff009186e5aa584fd069dd57aaf7d46a8a + if ev.Key() == tcell.KeyF12 { + s.toggleCli() + return true + } + m := s.menuLayout.Menu() + if ev.Key() == tcell.KeyEscape { + return s.menuLayout.HandleKey(ev) + } else if m.Active() { + return s.menuLayout.HandleKey(ev) + } + + if s.doOpen { + if ev.Key() == tcell.KeyCtrlU { + s.openPdsEntry.SetValue("") + return true + } + + if ev.Key() == tcell.KeyEnter { + return s.cliGetPds("getpds", s.openPdsEntry.Value()) + } + return s.openPdsEntry.HandleKey(ev) + } + + return s.menuLayout.HandleKey(ev) +} +func (s *ScreenHome) HandleTime(ev *tcell.EventTime) { + s.menuLayout.HandleTime(ev) + s.loading.HandleTime(ev) + s.status.HandleTime(ev) +} +func (s *ScreenHome) Draw() { + if s.doOpen { + s.menuLayout.SetStyle(s.style.Foreground(tcell.ColorGray)) + } + s.menuLayout.SetStyle(s.style) + m := s.menuLayout.Menu() + s.a.DrawWidget(s.menuLayout) + if s.doOpen && !m.Active() { + s.a.DrawWidget(s.openPdsEntry) + } + + // These are outside of the menuLayout + if s.isLoading { + s.a.DrawWidget(s.loading) + } + s.a.DrawWidget(s.status) +} +func (s *ScreenHome) Exit() error { return nil } +func (s *ScreenHome) Log(t string, a ...any) { + s.cli.Log(t, a...) + s.showCli() +} + +func (s *ScreenHome) initMenu() { + s.menuLayout.SetActive(true) + wrk := "[ ]" + if viper.GetBool(data.KeyVimMode) { + wrk = "[X]" + } + vimText := fmt.Sprintf("%s Vim Mode", wrk) + wrk = "[ ]" + if viper.GetBool(data.KeyRecNmInfer) { + wrk = "[X]" + } + inferRecNm := fmt.Sprintf("%s Infer Record Names", wrk) + s.menuLayout.AddMenuItems( + s.menuLayout.CreateMenuItem("file", "File", nil, 'f', + s.menuLayout.CreateMenuItem("file.openpds", "Open PDS", func() bool { + s.menuLayout.ToggleMenu() + s.doOpen = true + s.pdsListing.SetTitle(strings.Repeat(" ", 30)) + return true + }, 'o'), + s.menuLayout.CreateMenuItem("file.reloadpds", "Reload PDS", func() bool { + s.menuLayout.ToggleMenu() + if s.activePds == nil { + return false + } + return s.cliGetPds("getpds", s.activePds.AtId.String()) + }, 'r'), + s.menuLayout.CreateMenuItem("file.exit", "Exit", func() bool { + s.a.Exit() + return true + }, 'x'), + ), + s.menuLayout.CreateMenuItem("settings", "Settings", nil, 's', + s.menuLayout.CreateMenuItem("settings.vimmode", vimText, func() bool { + s.menuLayout.ToggleMenu() + viper.Set(data.KeyVimMode, !viper.GetBool(data.KeyVimMode)) + viper.WriteConfig() + s.update() + return true + }, 'v'), + s.menuLayout.CreateMenuItem("settings.inferrecnm", inferRecNm, func() bool { + s.menuLayout.ToggleMenu() + viper.Set(data.KeyRecNmInfer, !viper.GetBool(data.KeyRecNmInfer)) + viper.WriteConfig() + s.update() + return true + }, 'r'), + ), + ) +} + +func (s *ScreenHome) update() { + if s.doOpen { + s.menuLayout.SetWidget(s.openPdsEntry) + } else { + s.menuLayout.SetWidget(s.layout) + } + s.pdsListing.SetVimMode(viper.GetBool(data.KeyVimMode)) + s.jsonContent.SetVimMode(viper.GetBool(data.KeyVimMode)) + vimMI := s.menuLayout.FindItem("settings.vimmode") + wrk := "[ ]" + if viper.GetBool(data.KeyVimMode) { + wrk = "[X]" + } + vimMI.SetLabel(fmt.Sprintf("%s Vim Mode", wrk)) + recNmInf := s.menuLayout.FindItem("settings.inferrecnm") + wrk = "[ ]" + if viper.GetBool(data.KeyRecNmInfer) { + wrk = "[X]" + } + recNmInf.SetLabel(fmt.Sprintf("%s Infer Record Names", wrk)) + + miReload := s.menuLayout.FindItem("file.reloadpds") + if s.activePds == nil { + miReload.SetDisabled(true) + miReload.SetStyle(s.style.Foreground(tcell.ColorGray)) + } else { + miReload.SetDisabled(false) + miReload.SetStyle(s.style.Foreground(tcell.ColorLime)) + } +} + +func (s *ScreenHome) initCli() { + s.cli.SetVisible(true) + s.cli.AddCommand(w.NewCliCommand("getpds", s.cliGetPds)) + s.cli.AddCommand(w.NewCliCommand("authpds", s.cliAuthPds)) + s.cli.AddCommand(w.NewCliCommand("backuppds", s.cliBackupPds)) + s.cli.AddCommand(w.NewCliCommand("sendstatus", s.cliSendStatus)) +} + +func (s *ScreenHome) toggleCli() { + if s.layout.Contains(s.cli) { + s.layout.Delete(s.cli) + s.layout.ActivateWidget(s.columns) + } else { + s.layout.Add(s.cli) + s.cli.SetVisible(true) + s.layout.ActivateWidget(s.cli) + } +} +func (s *ScreenHome) hideCli() { + s.cli.SetVisible(false) + if s.layout.Contains(s.cli) { + s.layout.Delete(s.cli) + } +} +func (s *ScreenHome) showCli() { + s.cli.SetVisible(true) + if !s.layout.Contains(s.cli) { + s.layout.Add(s.cli) + } +} + +func (s *ScreenHome) cliGetPds(args ...string) bool { + s.isLoading = true + if len(args) < 1 { + s.Log("No id given.") + s.Log("Usage: 'getpds '") + s.isLoading = false + return true + } + go func() { + defer func() { s.isLoading = false }() + var pds *models.Pds + var err error + if s.activePds != nil && string(s.activePds.AtId) == args[1] { + pds, err = s.r.ReloadPds(args[1]) + } else { + pds, err = s.r.GetPDS(args[1]) + } + if err != nil { + s.Log(err.Error()) + return + } else if pds == nil { + s.Log("PDS (%s) Not Found.", args[1]) + return + } + s.doOpen = false + slog.Default().Debug(fmt.Sprintf("Retrieved: %s (%s) at %s", pds.AtId, pds.Did, time.Now().Format(time.RFC3339))) + s.activePds = pds + s.updatePdsListing() + n, err := s.pdsListing.GetActiveNode() + if err == nil && n != nil { + s.changePdsList(n) + } + + s.layout.ActivateWidget(s.columns) + s.columns.ActivateWidget(s.pdsListing) + }() + return true +} + +func (s *ScreenHome) cliAuthPds(args ...string) bool { + if s.activePds == nil { + s.Log("No active PDS.") + return true + } + s.isLoading = true + go func() { + defer func() { s.isLoading = false }() + atid := s.activePds.AtId.String() + callbackRes := make(chan url.Values, 1) + listenPort, err := s.r.Auth.ListenForCallback(callbackRes) + if err != nil { + s.Log("Error Instantiating HTTP Server for Callback: %w", err) + return + } + s.Log("Listening on %d", listenPort) + var authUrl string + authUrl, err = s.r.Auth.StartAuthFlow(listenPort, atid, callbackRes) + if err != nil { + s.Log("Error starting auth flow: %w", err) + } +<<<<<<< HEAD + s.alert.SetTitle("Authentication Started") + s.alert.SetMessage(fmt.Sprintf("OAuth Process Started.\nIf a browser window didn't open, you can open this URL manually:\n%s", authUrl)) + s.alert.SetVisible(true) + s.alert.SetActive(true) + s.showAlert = true +======= + s.Log("Authentication Started") + s.Log(fmt.Sprintf("OAuth Process Started.\nIf a browser window didn't open, you can open this URL manually:\n%s", strings.ReplaceAll(authUrl, "%", "%%"))) +>>>>>>> 367d62ff009186e5aa584fd069dd57aaf7d46a8a + }() + return true +} + +func (s *ScreenHome) cliBackupPds(args ...string) bool { + if s.activePds == nil { + s.Log("No active PDS.") + return true + } + s.isLoading = true + go func() { + defer func() { s.isLoading = false }() + nm, sz, err := s.activePds.Backup() + if err != nil { + s.Log("Error: %w", err) + return + } + s.Log("Backup Created: %s (%d bytes)", nm, sz) + }() + return true +} + +func (s *ScreenHome) cliSendStatus(args ...string) bool { + if s.activePds == nil { + s.Log("No active PDS.") + return true + } + s.isLoading = true + go func() { + defer func() { s.isLoading = false }() + if !s.r.Auth.HasAuth(s.activePds.Did) { + s.Log("Not authorized. Run `authpds`") + return + } + err := s.r.SendToPDS(s.activePds.Did) + if err != nil { + s.Log("Error sending status: %w", err) + } + }() + return true +} + +func (s *ScreenHome) updatePdsListing() { + s.pdsListing.SetTitle(fmt.Sprintf("─ %s (%s)", s.activePds.AtId.String(), s.activePds.Did.String())) + s.pdsListing.Clear() + nsidList := s.activePds.NSIDStringList() + var tree []*wd.TreeNode + for i, v := range nsidList { + t := wd.NewTreeNode(v, v) + nsid := s.activePds.NSIDs[i] + rIds := s.activePds.GetRecordIdsFor(nsid) + for j := range rIds { + label := rIds[j] + if viper.GetBool(data.KeyRecNmInfer) { + if rec, ok := s.activePds.Records[rIds[j]]; ok { + for k := range rec { + if k == "name" || k == "title" || k == "label" || k == "displayName" { + if f, ok := rec[k].(string); ok { + label = fmt.Sprintf("%s (%s)", f, rIds[j]) + break + } + } + } + } + } + + c := wd.NewTreeNode(label, rIds[j]) + t.AddChild(c) + } + tree = append(tree, t) + } + s.pdsListing.SetTree(tree) + s.layout.ActivateWidget(s.columns) + s.columns.ActivateWidget(s.pdsListing) + s.pdsListing.SetActive(true) + s.pdsListing.SetFocusable(true) + s.hideCli() +} + +func (s *ScreenHome) selectPdsListingEntry(tn *wd.TreeNode) bool { + if !s.updateJsonView(tn) { + return false + } + if tn.HasChildren() { + tn.ToggleExpand() + s.pdsListing.UpdateList() + } else { + s.columns.ActivateWidget(s.jsonContent) + } + + return true +} + +func (s *ScreenHome) changePdsList(tn *wd.TreeNode) bool { + upd := s.updateJsonView(tn) + upd = s.updateStatusPathBlock(tn) + return upd +} +func (s *ScreenHome) updateStatusPathBlock(tn *wd.TreeNode) bool { + s.stPathBlock.SetParts(tn.GetLabelPath()) + return true +} + +func (s *ScreenHome) updateJsonView(tn *wd.TreeNode) bool { + // TODO: Update JSON View + if tn.Depth() == 0 { + nsid, err := syntax.ParseNSID(tn.Value()) + if err != nil { + s.Log("error parsing NSID from %s: %w", tn.Value(), err) + return false + } + recordIds := s.activePds.GetRecordIdsFor(nsid) + s.jsonContent.SetValue(recordIds) + return true + } else { + s.jsonContent.SetValue(s.activePds.Records[tn.Value()]) + } + + return true +} diff --git a/cmd/root.go b/cmd/root.go index 127a8ba..1da2d09 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -41,6 +41,7 @@ var ( Name = "expds" cfgFile string ConfigDir = "" + debug = false rootCmd = &cobra.Command{ Use: "expds", Short: "Utility to edit and view PDS", @@ -60,6 +61,7 @@ func Execute() { func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file") + rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "debug mode") } func initConfig() { diff --git a/data/app_log_handler.go b/data/app_log_handler.go index 618f040..f65a674 100644 --- a/data/app_log_handler.go +++ b/data/app_log_handler.go @@ -27,7 +27,7 @@ func (a *AppLogHandler) addGroups(groups ...string) { a.groups = append(a.group // AppLogHandler can handle all levels func (a *AppLogHandler) Enabled(_ context.Context, lvl slog.Level) bool { return lvl >= a.level } -func (a *AppLogHandler) Handle(ctx context.Context, rcd slog.Record) error { +func (a *AppLogHandler) Handle(_ context.Context, rcd slog.Record) error { if a.logFunc == nil { return errors.New("no log func defined") } diff --git a/data/repo.go b/data/repo.go index d3a2f87..41a5d15 100644 --- a/data/repo.go +++ b/data/repo.go @@ -40,6 +40,7 @@ type Repo struct { handler *AppLogHandler logFunc func(string, ...any) + Logger *slog.Logger context context.Context } @@ -56,6 +57,7 @@ func NewRepo() (*Repo, error) { } else { r.handler.SetLevel(slog.LevelWarn) } + r.Logger = slog.Default() var err error r.Auth, err = NewAuthRepo(r) if err != nil { @@ -64,10 +66,7 @@ func NewRepo() (*Repo, error) { return r, nil } -func (r *Repo) GetPDS(atId string) (*models.Pds, error) { - if p, ok := r.LoadedPDSs[atId]; ok && time.Since(p.RefreshTime) < r.BestBy { - return p, nil - } +func (r *Repo) fetchPds(atId string) (*models.Pds, error) { p, err := models.NewPdsFromDid(atId) if err != nil { return nil, err @@ -76,8 +75,19 @@ func (r *Repo) GetPDS(atId string) (*models.Pds, error) { return p, nil } -func (r *Repo) SendToPDS() error { - session, err := r.Auth.GetSession() +func (r *Repo) ReloadPds(atId string) (*models.Pds, error) { + return r.fetchPds(atId) +} + +func (r *Repo) GetPDS(atId string) (*models.Pds, error) { + if p, ok := r.LoadedPDSs[atId]; ok && time.Since(p.RefreshTime) < r.BestBy { + return p, nil + } + return r.fetchPds(atId) +} + +func (r *Repo) SendToPDS(did syntax.DID) error { + session, err := r.Auth.GetSession(did) if err != nil { return err } @@ -94,11 +104,11 @@ func (r *Repo) SendToPDS() error { var resp struct { Uri syntax.ATURI `json:"uri"` } - slog.Debug("posting expds status...") + r.Logger.Debug("posting expds status...") if err := c.Post(r.context, "com.atproto.repo.CreateRecord", body, &resp); err != nil { return err } - slog.Debug(fmt.Sprintf("posted: %s :: %s", resp.Uri.Authority(), resp.Uri.RecordKey())) + r.Logger.Debug(fmt.Sprintf("posted: %s :: %s", resp.Uri.Authority(), resp.Uri.RecordKey())) return nil } @@ -106,4 +116,6 @@ func (r *Repo) SetLogFunc(l func(string, ...any)) { r.logFunc = l r.handler = NewAppLogHandler(r.logFunc) slog.SetDefault(slog.New(r.handler)) + r.Logger = slog.Default() + r.Logger.Debug("New Log Func Set for slog") } diff --git a/data/repo_auth.go b/data/repo_auth.go index 6d7d5bd..7362f0c 100644 --- a/data/repo_auth.go +++ b/data/repo_auth.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net" "net/http" "net/url" @@ -13,6 +14,7 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/spf13/viper" ) @@ -25,12 +27,15 @@ type AuthRepo struct { session *oauth.ClientSessionData authError error + + Logger *slog.Logger } func NewAuthRepo(r *Repo) (*AuthRepo, error) { a := &AuthRepo{ r: r, context: r.context, + Logger: r.Logger, } var err error a.oauthConfig, a.oauthClient, a.store, err = a.buildOAuthClient() @@ -109,20 +114,22 @@ func (r *AuthRepo) ListenForCallback(res chan url.Values) (int, error) { if !errors.Is(err, http.ErrServerClosed) { panic(err) } + r.Logger.Debug("Server Shut Down") }() return listener.Addr().(*net.TCPAddr).Port, nil } -func (r *AuthRepo) HasAuth() bool { - sess, err := r.store.GetMostRecentSession(r.context) - return err != nil || sess == nil +func (r *AuthRepo) HasAuth(did syntax.DID) bool { + sess, err := r.GetSession(did) + return err == nil && sess != nil } -func (r *AuthRepo) GetSession() (*oauth.ClientSession, error) { - sess, err := r.store.GetMostRecentSession(r.context) +func (r *AuthRepo) GetSession(did syntax.DID) (*oauth.ClientSession, error) { + sess, err := r.store.GetMostRecentSessionFor(r.context, did) if err != nil { return nil, fmt.Errorf("error getting most recent session: %w", err) } + r.Logger.Warn(fmt.Sprintf("GetSession(): Resuming Session: %s (%s)", sess.SessionID, sess.AccountDID)) return r.oauthClient.ResumeSession(r.context, sess.AccountDID, sess.SessionID) } diff --git a/data/repo_auth.go.orig b/data/repo_auth.go.orig new file mode 100644 index 0000000..b029995 --- /dev/null +++ b/data/repo_auth.go.orig @@ -0,0 +1,139 @@ +package data + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/spf13/viper" +) + +type AuthRepo struct { + r *Repo + oauthClient *oauth.ClientApp + oauthConfig *oauth.ClientConfig + store *SqliteStore + context context.Context + +<<<<<<< HEAD + session *oauth.ClientSessionData + authError error +======= + session *oauth.ClientSessionData + + Logger *slog.Logger +>>>>>>> 367d62ff009186e5aa584fd069dd57aaf7d46a8a +} + +func NewAuthRepo(r *Repo) (*AuthRepo, error) { + a := &AuthRepo{ + r: r, + context: r.context, + Logger: r.Logger, + } + var err error + a.oauthConfig, a.oauthClient, a.store, err = a.buildOAuthClient() + if err != nil { + return nil, err + } + return a, nil +} + +// Build the OAuthClient connected to our sqlite db +func (r *AuthRepo) buildOAuthClient() (*oauth.ClientConfig, *oauth.ClientApp, *SqliteStore, error) { + config := oauth.ClientConfig{ + ClientID: "https://expds.bullercodeworks.com/oauth-client-metadata.json", + Scopes: []string{"atproto", "repo:*", "blob:*/*"}, + UserAgent: "expds", + } + + store, err := NewSqliteStore(&SqliteStoreConfig{ + DatabasePath: r.prepareDbPath(), + SessionExpiryDuration: time.Hour * 24 * 90, + SessionInactivityDuration: time.Hour * 24 * 14, + AuthRequestExpiryDuration: time.Minute * 30, + }) + if err != nil { + return nil, nil, nil, err + } + + oauthClient := oauth.NewClientApp(&config, store) + return &config, oauthClient, store, nil +} + +func (r *AuthRepo) StartAuthFlow(port int, identifier string, callbackRes chan url.Values) (string, error) { + r.oauthConfig.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", port) + authUrl, err := r.oauthClient.StartAuthFlow(r.context, identifier) + if err != nil { + return "", fmt.Errorf("error logging in: %w", err) + } + if !strings.HasPrefix(authUrl, "https://") { + return "", fmt.Errorf("non-https authUrl") + } + go func() { + exec.Command("xdg-open", authUrl).Start() + r.session, r.authError = r.oauthClient.ProcessCallback(r.context, <-callbackRes) + }() + return authUrl, nil +} + +// Follows XDG conventions and creates the directories if necessary. +// By default, on linux, this will be "~/.local/share/go-oauth-cli-app/oauth_sessions.sqlite3" +func (r *AuthRepo) prepareDbPath() string { + return filepath.Join(viper.GetString(KeyDataDir), "expds.sqlite3") +} + +// HTTP Server listening for OAuth Response +func (r *AuthRepo) ListenForCallback(res chan url.Values) (int, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + + mux := http.NewServeMux() + server := &http.Server{ + Handler: mux, + } + + mux.HandleFunc("/callback", func(w http.ResponseWriter, req *http.Request) { + res <- req.URL.Query() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(200) + w.Write([]byte("

expds

You can safely close this window and return to your application.

\n")) + go server.Shutdown(r.context) + }) + + go func() { + err := server.Serve(listener) + if !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + r.Logger.Debug("Server Shut Down") + }() + + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func (r *AuthRepo) HasAuth(did syntax.DID) bool { + sess, err := r.GetSession(did) + return err == nil && sess != nil +} + +func (r *AuthRepo) GetSession(did syntax.DID) (*oauth.ClientSession, error) { + sess, err := r.store.GetMostRecentSessionFor(r.context, did) + if err != nil { + return nil, fmt.Errorf("error getting most recent session: %w", err) + } + r.Logger.Warn(fmt.Sprintf("GetSession(): Resuming Session: %s (%s)", sess.SessionID, sess.AccountDID)) + return r.oauthClient.ResumeSession(r.context, sess.AccountDID, sess.SessionID) +} diff --git a/data/repo_auth_store.go b/data/repo_auth_store.go index 03ab6cc..2062325 100644 --- a/data/repo_auth_store.go +++ b/data/repo_auth_store.go @@ -96,6 +96,20 @@ func (m *SqliteStore) GetSession(ctx context.Context, did syntax.DID, sessionID return &row.Data, nil } +func (m *SqliteStore) GetMostRecentSessionFor(ctx context.Context, did syntax.DID) (*oauth.ClientSessionData, error) { + var row storedSessionData + res := m.db.WithContext(ctx).Where(&storedSessionData{ + AccountDid: did, + }).Order(clause.OrderByColumn{ + Column: clause.Column{Name: "updated_at"}, + Desc: true, + }).First(&row) + if res.Error != nil { + return nil, res.Error + } + return &row.Data, nil +} + // not part of the ClientAuthStore interface, just used for the CLI app func (m *SqliteStore) GetMostRecentSession(ctx context.Context) (*oauth.ClientSessionData, error) { var row storedSessionData diff --git a/widgets/status_bar_block.go b/widgets/status_bar_block.go index 78ded75..cfd75f3 100644 --- a/widgets/status_bar_block.go +++ b/widgets/status_bar_block.go @@ -114,7 +114,7 @@ func (b *StatusBlock) DrawPath(screen tcell.Screen) { x := b.x wh.DrawText(x, b.y, b.end, b.style, screen) x++ - pts := fmt.Sprintf(" %s ", strings.Join(b.parts, b.sep)) + pts := fmt.Sprintf(" %s ", strings.Join(b.parts, b.sep)) wh.DrawText(x, b.y, pts, b.style, screen) x += len(pts) - len(b.parts) wh.DrawText(x, b.y, b.end, b.style.Reverse(true), screen) diff --git a/widgets/tree_browser.go b/widgets/tree_browser.go index a6fcdc3..e5ab0d0 100644 --- a/widgets/tree_browser.go +++ b/widgets/tree_browser.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "sync" h "git.bullercodeworks.com/brian/expds/helpers" t "git.bullercodeworks.com/brian/tcell-widgets" @@ -36,7 +37,12 @@ type TreeBrowser struct { keyMap *t.KeyMap vimMode bool + searching bool + searchStr string + logger func(string, ...any) + + m sync.Mutex } var _ t.Widget = (*TreeBrowser)(nil) @@ -55,6 +61,10 @@ func (w *TreeBrowser) Init(id string, style tcell.Style) { t.NewKey(t.BuildEK(tcell.KeyUp), func(_ *tcell.EventKey) bool { return w.MoveUp() }), t.NewKey(t.BuildEK(tcell.KeyDown), func(_ *tcell.EventKey) bool { return w.MoveDown() }), t.NewKey(t.BuildEK(tcell.KeyEnter), func(ev *tcell.EventKey) bool { + if w.searching { + w.searching = !w.searching + return true + } if w.onSelect != nil { n, err := w.GetActiveNode() if err != nil || n == nil { @@ -97,6 +107,14 @@ func (w *TreeBrowser) Init(id string, style tcell.Style) { } return false }), + t.NewKey(t.BuildEKr('/'), func(ev *tcell.EventKey) bool { + if !w.searching { + w.searching = true + w.searchStr = "" + return true + } + return false + }), ) } @@ -111,12 +129,20 @@ func (w *TreeBrowser) HandleKey(ev *tcell.EventKey) bool { if !w.active || !w.focusable { return false } - return w.keyMap.Handle(ev) + if w.keyMap.Handle(ev) { + return true + } else if w.searching { + w.updateSearch(ev) + return true + } + return false } func (w *TreeBrowser) HandleTime(ev *tcell.EventTime) {} func (w *TreeBrowser) Draw(screen tcell.Screen) { + w.m.Lock() + defer w.m.Unlock() if !w.visible { return } @@ -170,9 +196,23 @@ func (w *TreeBrowser) Draw(screen tcell.Screen) { if len(txt) > w.w-brdSz && w.w-brdSz >= 0 { txt = txt[:(w.w - brdSz)] } - wh.DrawText(x, y, txt, dS.Reverse(rev), screen) + if w.searching && strings.Contains(txt, w.searchStr) { + // TODO: Fix multi-byte depth indicator + srchIdx := strings.Index(txt, w.searchStr) + endSrchIdx := srchIdx + len(w.searchStr) + wh.DrawText(x, y, txt[:srchIdx], dS.Reverse(rev), screen) + wh.DrawText(x+srchIdx, y, txt[srchIdx:endSrchIdx], dS.Reverse(!rev), screen) + if len(txt) > endSrchIdx { + wh.DrawText(x+endSrchIdx, y, txt[endSrchIdx:], dS.Reverse(rev), screen) + } + } else { + wh.DrawText(x, y, txt, dS.Reverse(rev), screen) + } y += 1 } + if w.searching { + wh.DrawText(w.x, w.y+w.h, fmt.Sprintf("Searching: %s", w.searchStr), dS, screen) + } } func (w *TreeBrowser) SetStyle(s tcell.Style) { w.style = s } @@ -197,6 +237,8 @@ func (w *TreeBrowser) SetW(wd int) { w.SetSize(t.Coord{X: wd, Y: w.h}) } func (w *TreeBrowser) SetH(h int) { w.SetSize(t.Coord{X: w.w, Y: h}) } func (w *TreeBrowser) SetSize(c t.Coord) { w.w, w.h = c.X, c.Y } func (w *TreeBrowser) WantW() int { + w.m.Lock() + defer w.m.Unlock() var want int for i := range w.list { want = h.MaxI(want, len(w.list[i])) @@ -204,6 +246,8 @@ func (w *TreeBrowser) WantW() int { return w.w } func (w *TreeBrowser) WantH() int { + w.m.Lock() + defer w.m.Unlock() want := len(w.list) if len(w.border) > 0 { return want + 2 @@ -225,8 +269,8 @@ func (w *TreeBrowser) ClearBorder() { w.border = []rune{} func (w *TreeBrowser) SetOnChange(c func(*TreeNode) bool) { w.onChange = c } func (w *TreeBrowser) SetOnSelect(s func(*TreeNode) bool) { w.onSelect = s } func (w *TreeBrowser) SetVimMode(b bool) { w.vimMode = b } -func (w *TreeBrowser) GetActiveNode() (*TreeNode, error) { - if len(w.listNodes) < 0 { +func (w *TreeBrowser) nmGetActiveNode() (*TreeNode, error) { + if len(w.listNodes) <= 0 { return nil, errors.New("no nodes") } if w.cursor < 0 { @@ -237,6 +281,11 @@ func (w *TreeBrowser) GetActiveNode() (*TreeNode, error) { } return w.listNodes[w.cursor], nil } +func (w *TreeBrowser) GetActiveNode() (*TreeNode, error) { + w.m.Lock() + defer w.m.Unlock() + return w.nmGetActiveNode() +} func (w *TreeBrowser) SetCursorWrap(b bool) { w.cursorWrap = b } func (w *TreeBrowser) MoveUp() bool { if w.cursor > 0 { @@ -305,22 +354,30 @@ func (w *TreeBrowser) PageDn() bool { func (w *TreeBrowser) Title() string { return w.title } func (w *TreeBrowser) SetTitle(ttl string) { w.title = ttl } func (w *TreeBrowser) SetTree(l []*TreeNode) { + w.m.Lock() + defer w.m.Unlock() w.nodes = l - w.UpdateList() + w.nmUpdateList() } func (w *TreeBrowser) Clear() { + w.m.Lock() + defer w.m.Unlock() w.nodes = []*TreeNode{} - w.UpdateList() + w.nmUpdateList() } func (w *TreeBrowser) Add(n *TreeNode) { + w.m.Lock() + defer w.m.Lock() if n.depthIndic == "" { n.depthIndic = w.depthIndic } w.nodes = append(w.nodes, n) - w.UpdateList() + w.nmUpdateList() } -func (w *TreeBrowser) UpdateList() { +// Update the list, intended to be called locally within other functions that +// handle the mutex +func (w *TreeBrowser) nmUpdateList() { w.list = []string{} w.listNodes = []*TreeNode{} for i := range w.nodes { @@ -335,6 +392,98 @@ func (w *TreeBrowser) UpdateList() { } } +func (w *TreeBrowser) UpdateList() { + w.m.Lock() + defer w.m.Unlock() + w.nmUpdateList() +} + +func (w *TreeBrowser) nmSetNodeActive(tn *TreeNode) { + // Make sure that the selected node is visible + wrk := tn.parent + for wrk != nil { + wrk.expanded = true + wrk = wrk.parent + } + w.nmUpdateList() + for i := range w.listNodes { + if w.listNodes[i] == tn { + w.cursor = i + return + } + } +} +func (w *TreeBrowser) SetNodeActive(tn *TreeNode) { + w.m.Lock() + defer w.m.Unlock() + w.nmSetNodeActive(tn) +} + +func (w *TreeBrowser) updateSearch(ev *tcell.EventKey) { + w.m.Lock() + defer w.m.Unlock() + + if len(w.nodes) == 0 { + return + } + + if wh.IsBS(*ev) { + if len(w.searchStr) > 0 { + w.searchStr = w.searchStr[:len(w.searchStr)-1] + if len(w.searchStr) == 0 { + w.searching = false + } + } + return + } + w.searchStr = fmt.Sprintf("%s%s", w.searchStr, string(ev.Rune())) + wrk, _ := w.nmGetActiveNode() + if wrk == nil { + wrk = w.nodes[0] + } + // Check the ative node & it's children for the search + if fnd := wrk.SearchLabels(w.searchStr); fnd != nil { + w.nmSetNodeActive(fnd) + return + } + + // Didn't find a child of the active node that matched, look for a sibling + if wrk.parent != nil { + if fnd := wrk.parent.SearchLabels(w.searchStr); fnd != nil { + w.nmSetNodeActive(fnd) + return + } + } + // Check the next browser node + var stIdx int + for i := range w.nodes { + if w.nodes[i] == wrk { + stIdx = i + 1 + break + } + } + for i := 0; i < len(w.nodes); i++ { + idx := (i + stIdx) % len(w.nodes) + if fnd := w.nodes[idx].SearchLabels(w.searchStr); fnd != nil { + w.nmSetNodeActive(fnd) + return + } + } +} + +func (w *TreeBrowser) getCurrentLine() string { + w.m.Lock() + defer w.m.Unlock() + l := len(w.list) + if l == 0 { + return "" + } + if w.cursor < 0 || w.cursor >= l { + return "" + } + return w.list[w.cursor] +} + /* * Tree Node */ @@ -370,6 +519,7 @@ func (tn *TreeNode) GetLabelPath() []string { } return append(path, tn.Label()) } + func (tn *TreeNode) getList() []string { pre := strings.Repeat(tn.depthIndic, tn.Depth()) ret := []string{fmt.Sprintf("%s%s", pre, tn.label)} @@ -380,6 +530,7 @@ func (tn *TreeNode) getList() []string { } return ret } + func (tn *TreeNode) getVisibleNodeList() []*TreeNode { ret := []*TreeNode{tn} if tn.expanded { @@ -390,6 +541,19 @@ func (tn *TreeNode) getVisibleNodeList() []*TreeNode { return ret } +func (tn *TreeNode) SearchLabels(f string) *TreeNode { + if strings.Contains(tn.label, f) { + return tn + } + for i := 0; i < len(tn.children); i++ { + fnd := tn.children[i].SearchLabels(f) + if fnd != nil { + return fnd + } + } + return nil +} + func (tn *TreeNode) ToggleExpand() { tn.expanded = !tn.expanded } func (tn *TreeNode) AddChild(t *TreeNode, rest ...*TreeNode) { @@ -415,3 +579,44 @@ func (tn *TreeNode) GetPath() []string { } return append(path, tn.value) } + +func (tn *TreeNode) GetChildren() []*TreeNode { return tn.children } + +func (tn *TreeNode) GetFirstChild() *TreeNode { + if !tn.HasChildren() { + return nil + } + return tn.children[0] +} + +func (tn *TreeNode) GetPrevChild(before *TreeNode) *TreeNode { + if !tn.HasChildren() { + return nil + } + var found bool + for i := len(tn.children) - 1; i >= 0; i-- { + if found { + return tn.children[i] + } + if tn.children[i] == before { + found = true + } + } + return nil +} + +func (tn *TreeNode) GetNextChild(after *TreeNode) *TreeNode { + if !tn.HasChildren() { + return nil + } + var found bool + for i := range tn.children { + if found { + return tn.children[i] + } + if tn.children[i] == after { + found = true + } + } + return nil +}