From 009d5701d2fb9eed2d45e2b3ced0f024ca21aeb9 Mon Sep 17 00:00:00 2001 From: Brian Buller Date: Thu, 12 Feb 2026 11:43:52 -0600 Subject: [PATCH] Delete records and stuff --- app/screen_home.go | 269 +++++++++++++++++++++++++++------------- data/repo.go | 48 ------- data/repo_auth.go | 19 ++- data/repo_auth_store.go | 14 ++- data/repo_pds.go | 77 ++++++++++++ widgets/tree_browser.go | 16 ++- 6 files changed, 303 insertions(+), 140 deletions(-) create mode 100644 data/repo_pds.go diff --git a/app/screen_home.go b/app/screen_home.go index 59f5d8e..4e22041 100644 --- a/app/screen_home.go +++ b/app/screen_home.go @@ -25,6 +25,7 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strings" "time" @@ -76,6 +77,11 @@ func (s *ScreenHome) Init(a *App) { s.style = a.style s.alert = w.NewAlert("expds.alert", s.style) + s.alert.SetOkPressed(func() bool { + s.alert.SetVisible(false) + return true + }) + s.alert.SetCancelPressed(nil) s.openPdsEntry = w.NewField("home.openpds.field", s.style) s.openPdsEntry.SetLabel("ID") @@ -131,6 +137,7 @@ func (s *ScreenHome) Init(a *App) { }), ) s.jsonContent.SetKeyMap(km) + s.jsonContent.SetVimMode(viper.GetBool(data.KeyVimMode)) s.jsonContent.SetBorder( []rune{'─', '┐', '│', '┘', '─', '─', ' ', '─', '├', '─', '┤', '┬', '│', '┴', '┼'}, ) @@ -158,17 +165,16 @@ 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.alert.HandleResize(w.Coord{X: s.w / 2, Y: s.h / 2}.ResizeEvent()) + s.alert.SetPos(w.Coord{X: s.w / 4, Y: s.h / 4}) 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 { - if s.showAlert { - handled := s.alert.HandleKey(ev) - s.alert.SetMessage(fmt.Sprintf("Alert Handled? %v -> %s", handled, ev.Name())) - return handled + if s.alert.Visible() { + return s.alert.HandleKey(ev) } if ev.Key() == tcell.KeyF12 { s.toggleCli() @@ -192,7 +198,14 @@ func (s *ScreenHome) HandleKey(ev *tcell.EventKey) bool { } return s.openPdsEntry.HandleKey(ev) } - + switch ev.Key() { + case tcell.KeyCtrlO: + return s.menuOpenPds() + case tcell.KeyCtrlD: + return s.deleteCurrentRecord() + case tcell.KeyCtrlR: + return s.menuRefreshPds() + } return s.menuLayout.HandleKey(ev) } func (s *ScreenHome) HandleTime(ev *tcell.EventTime) { @@ -201,6 +214,10 @@ func (s *ScreenHome) HandleTime(ev *tcell.EventTime) { s.status.HandleTime(ev) } func (s *ScreenHome) Draw() { + if s.alert.Visible() { + s.a.DrawWidget(s.alert) + return + } if s.doOpen { s.menuLayout.SetStyle(s.style.Foreground(tcell.ColorGray)) } @@ -208,6 +225,8 @@ func (s *ScreenHome) Draw() { m := s.menuLayout.Menu() s.a.DrawWidget(s.menuLayout) if s.doOpen && !m.Active() { + x, y := s.pdsListing.GetX(), s.pdsListing.GetY() + s.a.DrawText(x+2, y, strings.Repeat(" ", len(s.pdsListing.Title())), s.style) s.a.DrawWidget(s.openPdsEntry) } @@ -219,6 +238,10 @@ func (s *ScreenHome) Draw() { } func (s *ScreenHome) Exit() error { return nil } func (s *ScreenHome) Log(t string, a ...any) { + if strings.Contains(fmt.Sprintf(t, a...), "auth server request failed") { + // This error happens even on successfully creating an oAuth request? Maybe it's just me + return + } s.cli.Log(t, a...) s.showCli() } @@ -237,39 +260,13 @@ func (s *ScreenHome) initMenu() { 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("file.openpds", "Open PDS", s.menuOpenPds, 'o'), + s.menuLayout.CreateMenuItem("file.reloadpds", "Reload PDS", s.menuRefreshPds, 'r'), + s.menuLayout.CreateMenuItem("file.exit", "Exit", s.menuExit, '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'), + s.menuLayout.CreateMenuItem("settings.vimmode", vimText, s.menuVimMode, 'v'), + s.menuLayout.CreateMenuItem("settings.inferrecnm", inferRecNm, s.menuInferRecNm, 'r'), ), ) } @@ -337,41 +334,13 @@ func (s *ScreenHome) showCli() { } 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) - }() + s.getPds(args[1]) return true } @@ -383,24 +352,32 @@ func (s *ScreenHome) cliAuthPds(args ...string) bool { 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 + // Check if we already have authentication. + if !s.r.Auth.HasAuth(s.activePds.Did) { + 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) + _, err = s.r.Auth.StartAuthFlow(listenPort, atid, callbackRes) + if err != nil { + s.Log("Error starting auth flow: %w", err) + } else { + 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:\nhttp://localhost:%d/auth\nThis url will redirect to the PDS OAuth Page.", listenPort)) + s.alert.SetVisible(true) + s.alert.SetActive(true) + go func() { + for s.isLoading { + time.Sleep(100) + } + s.alert.SetVisible(false) + }() + } } - 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) - } - 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 }() return true } @@ -518,6 +495,130 @@ func (s *ScreenHome) updateJsonView(tn *wd.TreeNode) bool { } else { s.jsonContent.SetValue(s.activePds.Records[tn.Value()]) } - + return true +} + +func (s *ScreenHome) menuOpenPds() bool { + s.menuLayout.HideMenu() + s.doOpen = true + s.pdsListing.SetTitle(strings.Repeat(" ", 30)) + return true +} + +func (s *ScreenHome) menuRefreshPds() bool { + s.menuLayout.HideMenu() + if s.activePds == nil { + return false + } + + // We want to persist 'opened' nodes + var opened []string + for _, n := range s.pdsListing.GetNodeList() { + if n.IsExpanded() { + opened = append(opened, n.Value()) + s.Log("Opened: %s", n.Value()) + } + } + s.getPds(s.activePds.AtId.String()) + for _, n := range s.pdsListing.GetNodeList() { + s.Log("Testing: %s", n.Value()) + if slices.Contains(opened, n.Value()) { + s.Log("-> Is Opened") + n.Expand() + } + } + return true +} + +func (s *ScreenHome) menuExit() bool { + s.a.Exit() + return true +} + +func (s *ScreenHome) menuVimMode() bool { + s.menuLayout.HideMenu() + viper.Set(data.KeyVimMode, !viper.GetBool(data.KeyVimMode)) + viper.WriteConfig() + s.update() + return true +} + +func (s *ScreenHome) menuInferRecNm() bool { + s.menuLayout.HideMenu() + viper.Set(data.KeyRecNmInfer, !viper.GetBool(data.KeyRecNmInfer)) + viper.WriteConfig() + s.update() + return true +} + +func (s *ScreenHome) getPds(atId string) { + s.isLoading = true + go func() { + defer func() { s.isLoading = false }() + var pds *models.Pds + var err error + if s.activePds != nil && string(s.activePds.AtId) == atId { + pds, err = s.r.ReloadPds(atId) + } else { + pds, err = s.r.GetPDS(atId) + } + if err != nil { + s.Log(err.Error()) + return + } else if pds == nil { + s.Log("PDS (%s) Not Found.", atId) + 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) + }() +} + +func (s *ScreenHome) deleteCurrentRecord() bool { + tn, err := s.pdsListing.GetActiveNode() + if err != nil { + s.Log("error getting active node") + return false + } + tnPath := tn.GetValuePath() + if len(tnPath) != 2 { + return false + } + //tnLabelPath := tn.GetLabelPath() + nsid, err := syntax.ParseNSID(tnPath[0]) + if err != nil { + s.Log("error parsing NSID from %s: %w", tnPath[0], err) + return false + } + rkey := tnPath[1] + if err := s.r.DeleteRecord(s.activePds.Did, nsid, rkey); err != nil { + s.Log("error deleting record: %w", err) + } + /* + s.alert.SetTitle("Confirm Deletion") + s.alert.SetMessage(fmt.Sprintf("Are you sure you want to delete this record?\n%s %s\nThis cannot be undone.", tnLabelPath[0], tnLabelPath[1])) + s.alert.SetVisible(true) + s.alert.SetActive(true) + s.alert.SetOkPressed(func() bool { + s.r.DeleteRecord(s.activePds.Did, nsid, rkey) + s.alert.SetVisible(false) + s.alert.SetActive(false) + return true + }) + s.alert.SetCancelPressed(func() bool { + s.alert.SetVisible(false) + s.alert.SetActive(false) + return true + }) + */ return true } diff --git a/data/repo.go b/data/repo.go index 41a5d15..91557f3 100644 --- a/data/repo.go +++ b/data/repo.go @@ -23,12 +23,10 @@ package data import ( "context" - "fmt" "log/slog" "time" "git.bullercodeworks.com/brian/expds/data/models" - "github.com/bluesky-social/indigo/atproto/syntax" "github.com/spf13/viper" ) @@ -66,52 +64,6 @@ func NewRepo() (*Repo, error) { return r, nil } -func (r *Repo) fetchPds(atId string) (*models.Pds, error) { - p, err := models.NewPdsFromDid(atId) - if err != nil { - return nil, err - } - r.LoadedPDSs[atId] = p - return p, nil -} - -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 - } - c := session.APIClient() - body := map[string]any{ - "repo": c.AccountDID.String(), - "collection": "com.bullercodeworks.expds.status", - "record": map[string]any{ - "$type": "com.bullercodeworks.expds.status", - "text": "writeable", - "createdAt": syntax.DatetimeNow(), - }, - } - var resp struct { - Uri syntax.ATURI `json:"uri"` - } - r.Logger.Debug("posting expds status...") - if err := c.Post(r.context, "com.atproto.repo.CreateRecord", body, &resp); err != nil { - return err - } - r.Logger.Debug(fmt.Sprintf("posted: %s :: %s", resp.Uri.Authority(), resp.Uri.RecordKey())) - return nil -} - func (r *Repo) SetLogFunc(l func(string, ...any)) { r.logFunc = l r.handler = NewAppLogHandler(r.logFunc) diff --git a/data/repo_auth.go b/data/repo_auth.go index 7362f0c..ad53912 100644 --- a/data/repo_auth.go +++ b/data/repo_auth.go @@ -26,6 +26,7 @@ type AuthRepo struct { context context.Context session *oauth.ClientSessionData + authUrl string authError error Logger *slog.Logger @@ -58,6 +59,7 @@ func (r *AuthRepo) buildOAuthClient() (*oauth.ClientConfig, *oauth.ClientApp, *S SessionExpiryDuration: time.Hour * 24 * 90, SessionInactivityDuration: time.Hour * 24 * 14, AuthRequestExpiryDuration: time.Minute * 30, + Logger: r.Logger, }) if err != nil { return nil, nil, nil, err @@ -68,19 +70,20 @@ func (r *AuthRepo) buildOAuthClient() (*oauth.ClientConfig, *oauth.ClientApp, *S } func (r *AuthRepo) StartAuthFlow(port int, identifier string, callbackRes chan url.Values) (string, error) { + var err error r.oauthConfig.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", port) - authUrl, err := r.oauthClient.StartAuthFlow(r.context, identifier) + r.authUrl, err = r.oauthClient.StartAuthFlow(r.context, identifier) if err != nil { return "", fmt.Errorf("error logging in: %w", err) } - if !strings.HasPrefix(authUrl, "https://") { + if !strings.HasPrefix(r.authUrl, "https://") { return "", fmt.Errorf("non-https authUrl") } go func() { - exec.Command("xdg-open", authUrl).Start() + exec.Command("xdg-open", r.authUrl).Start() r.session, r.authError = r.oauthClient.ProcessCallback(r.context, <-callbackRes) }() - return authUrl, nil + return r.authUrl, nil } // Follows XDG conventions and creates the directories if necessary. @@ -101,6 +104,12 @@ func (r *AuthRepo) ListenForCallback(res chan url.Values) (int, error) { Handler: mux, } + mux.HandleFunc("/auth", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Location", r.authUrl) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(303) + }) + mux.HandleFunc("/callback", func(w http.ResponseWriter, req *http.Request) { res <- req.URL.Query() w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -130,6 +139,6 @@ func (r *AuthRepo) GetSession(did syntax.DID) (*oauth.ClientSession, error) { 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)) + r.Logger.Debug(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 2062325..c19a55b 100644 --- a/data/repo_auth_store.go +++ b/data/repo_auth_store.go @@ -3,6 +3,9 @@ package data import ( "context" "fmt" + "log" + "log/slog" + "os" "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" @@ -10,6 +13,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/clause" + "gorm.io/gorm/logger" ) // Taken from https://github.com/bluesky-social/cookbook/blob/main/go-oauth-cli-app/sqlitestore.go @@ -21,6 +25,7 @@ type SqliteStoreConfig struct { SessionExpiryDuration time.Duration // duration since session creation SessionInactivityDuration time.Duration // duration since last session update AuthRequestExpiryDuration time.Duration // duration since auth request creation + Logger *slog.Logger } // Implements the [oauth.ClientAuthStore] interface, backed by sqlite via gorm @@ -65,7 +70,14 @@ func NewSqliteStore(cfg *SqliteStoreConfig) (*SqliteStore, error) { return nil, fmt.Errorf("missing AuthRequestExpiryDuration") } - db, err := gorm.Open(sqlite.Open(cfg.DatabasePath), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(cfg.DatabasePath), &gorm.Config{ + Logger: logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + IgnoreRecordNotFoundError: true, + }, + ), + }) if err != nil { return nil, fmt.Errorf("failed opening db: %w", err) } diff --git a/data/repo_pds.go b/data/repo_pds.go new file mode 100644 index 0000000..d6ee2d4 --- /dev/null +++ b/data/repo_pds.go @@ -0,0 +1,77 @@ +package data + +import ( + "time" + + "git.bullercodeworks.com/brian/expds/data/models" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func (r *Repo) fetchPds(atId string) (*models.Pds, error) { + p, err := models.NewPdsFromDid(atId) + if err != nil { + return nil, err + } + r.LoadedPDSs[atId] = p + return p, nil +} + +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 + } + c := session.APIClient() + body := map[string]any{ + "repo": c.AccountDID.String(), + "collection": "com.bullercodeworks.expds.status", + "record": map[string]any{ + "$type": "com.bullercodeworks.expds.status", + "text": "writeable", + "createdAt": syntax.DatetimeNow(), + }, + } + var resp struct { + Uri syntax.ATURI `json:"uri"` + } + r.Logger.Debug("posting expds status...") + if err := c.Post(r.context, "com.atproto.repo.createRecord", body, &resp); err != nil { + return err + } + r.Logger.Debug("posted: %s :: %s", resp.Uri.Authority(), resp.Uri.RecordKey()) + return nil +} + +func (r *Repo) DeleteRecord(did syntax.DID, collection syntax.NSID, rkey string) error { + session, err := r.Auth.GetSession(did) + if err != nil { + return err + } + c := session.APIClient() + body := map[string]any{ + "repo": c.AccountDID, + "collection": collection, + "rkey": rkey, + } + var resp struct { + Cid syntax.CID `json:"cid"` + Rev syntax.TID `json:"rev"` + } + r.Logger.Debug("deleting record (%s)", rkey) + if err := c.Post(r.context, "com.atproto.repo.deleteRecord", body, &resp); err != nil { + return err + } + r.Logger.Debug("posted: %s :: %s", resp.Cid.String(), resp.Rev.String()) + return nil +} diff --git a/widgets/tree_browser.go b/widgets/tree_browser.go index e5ab0d0..0dba240 100644 --- a/widgets/tree_browser.go +++ b/widgets/tree_browser.go @@ -269,6 +269,9 @@ 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) GetNodeList() []*TreeNode { return w.nodes } + func (w *TreeBrowser) nmGetActiveNode() (*TreeNode, error) { if len(w.listNodes) <= 0 { return nil, errors.New("no nodes") @@ -515,10 +518,16 @@ func (tn *TreeNode) GetLabelPath() []string { var path []string if tn.parent != nil { path = tn.parent.GetLabelPath() - } return append(path, tn.Label()) } +func (tn *TreeNode) GetValuePath() []string { + var path []string + if tn.parent != nil { + path = tn.parent.GetValuePath() + } + return append(path, tn.Value()) +} func (tn *TreeNode) getList() []string { pre := strings.Repeat(tn.depthIndic, tn.Depth()) @@ -554,7 +563,10 @@ func (tn *TreeNode) SearchLabels(f string) *TreeNode { return nil } -func (tn *TreeNode) ToggleExpand() { tn.expanded = !tn.expanded } +func (tn *TreeNode) IsExpanded() bool { return tn.expanded } +func (tn *TreeNode) Expand() { tn.expanded = true } +func (tn *TreeNode) Collapse() { tn.expanded = false } +func (tn *TreeNode) ToggleExpand() { tn.expanded = !tn.expanded } func (tn *TreeNode) AddChild(t *TreeNode, rest ...*TreeNode) { if t.depthIndic == "" {