From 9ea2cf8c34fdaac105f0914ee8250f568b5b7113 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 1 May 2026 14:10:22 -0600 Subject: [PATCH] Added bento :) --- .gitignore | 2 + Makefile | 4 +- README.md | 4 +- cmd/{gitea-wrapped => wrapped-cli}/main.go | 2 +- go.mod | 8 +- go.sum | 11 + pkg/config/config.go | 94 +++++ pkg/gitea/client.go | 60 ++- pkg/models/types.go | 37 +- pkg/reports/image_export.go | 458 +++++++++++++++++++++ pkg/reports/reports.go | 179 ++++++++ pkg/stats/analyzer.go | 90 ++-- pkg/ui/app.go | 416 +++++++++++-------- pkg/ui/export.go | 10 + pkg/ui/input_screen.go | 34 +- pkg/ui/menu_screen.go | 122 ++++++ pkg/ui/report_screen.go | 26 +- 17 files changed, 1304 insertions(+), 253 deletions(-) rename cmd/{gitea-wrapped => wrapped-cli}/main.go (84%) create mode 100644 pkg/config/config.go create mode 100644 pkg/reports/image_export.go create mode 100644 pkg/reports/reports.go create mode 100644 pkg/ui/export.go create mode 100644 pkg/ui/menu_screen.go diff --git a/.gitignore b/.gitignore index 44c0656..9ff974e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ vendor/ *.test *.out +# Executable and runtime files wrapped +.wrapped/ diff --git a/Makefile b/Makefile index 3c06456..b9d11fd 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ .PHONY: build run clean build: - go build -o wrapped ./cmd/gitea-wrapped/ + go build -o wrapped ./cmd/wrapped-cli/ run: build ./wrapped clean: - rm -f wrapped + rm -rf ./wrapped ./.wrapped diff --git a/README.md b/README.md index 55664de..d70052d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # Gitea Wrapped -This was me testing what all this vibe coding nonsense was like. IDK man... +**NOTE: This was initially vibe-coded to see why people do this. My stance is still strictly anti-AI, and do not intend to continue to use it. Results were disappointing enough that I had to make significant changes before pushing anything. This is just a disclaimer for those who care about transparency.** + +This is a Go based TUI that builds a "Spotify Wrapped" style recap of your gitea/forgejo activity. All login info is stored locally so I see nothing. Literally just sits in a JSON file on your machine. I hope you like this dumb project. diff --git a/cmd/gitea-wrapped/main.go b/cmd/wrapped-cli/main.go similarity index 84% rename from cmd/gitea-wrapped/main.go rename to cmd/wrapped-cli/main.go index 6cc0f26..0a7d7d4 100644 --- a/cmd/gitea-wrapped/main.go +++ b/cmd/wrapped-cli/main.go @@ -6,7 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/atridad/gitea-wrapped/pkg/ui" + "github.com/atridad/wrapped-cli/pkg/ui" ) func main() { diff --git a/go.mod b/go.mod index 9cac35e..9ba00d1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/atridad/gitea-wrapped +module github.com/atridad/wrapped-cli go 1.26.2 @@ -19,6 +19,8 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -28,6 +30,8 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/image v0.39.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e92ed90..9a1f8e0 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -44,9 +48,16 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..2eddb3e --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,94 @@ +package config + +import ( +"os" +"path/filepath" + +"gopkg.in/yaml.v3" +) + +type Connection struct { +Name string `yaml:"name"` +URL string `yaml:"url"` +Username string `yaml:"username"` +Token string `yaml:"token"` +} + +type Config struct { +Connections []Connection `yaml:"connections"` +} + +func GetConfigPath() (string, error) { +exe, err := os.Executable() +if err != nil { +return "", err +} +dir := filepath.Dir(exe) +wrappedDir := filepath.Join(dir, ".wrapped") +if err := os.MkdirAll(wrappedDir, 0o755); err != nil { +return "", err +} +return filepath.Join(wrappedDir, "config.yaml"), nil +} + +func GetResultsDir() (string, error) { +exe, err := os.Executable() +if err != nil { +return "", err +} +dir := filepath.Dir(exe) +resultsDir := filepath.Join(dir, ".wrapped", "results") +if err := os.MkdirAll(resultsDir, 0o755); err != nil { +return "", err +} +return resultsDir, nil +} + +func LoadConfig() (*Config, error) { +configPath, err := GetConfigPath() +if err != nil { +return nil, err +} + +data, err := os.ReadFile(configPath) +if err != nil { +if os.IsNotExist(err) { +return &Config{Connections: []Connection{}}, nil +} +return nil, err +} + +var cfg Config +if err := yaml.Unmarshal(data, &cfg); err != nil { +return nil, err +} + +return &cfg, nil +} + +func (c *Config) Save() error { +configPath, err := GetConfigPath() +if err != nil { +return err +} + +data, err := yaml.Marshal(c) +if err != nil { +return err +} + +return os.WriteFile(configPath, data, 0o600) +} + +func (c *Config) GetConnection(name string) *Connection { +for i := range c.Connections { +if c.Connections[i].Name == name { +return &c.Connections[i] +} +} +return nil +} + +func (c *Config) AddConnection(conn Connection) { +c.Connections = append(c.Connections, conn) +} diff --git a/pkg/gitea/client.go b/pkg/gitea/client.go index 3d1b260..0378b7a 100644 --- a/pkg/gitea/client.go +++ b/pkg/gitea/client.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strings" + "sync" "time" ) @@ -17,18 +18,18 @@ type Client struct { func NewClient(baseURL, token string) *Client { baseURL = strings.TrimRight(baseURL, "/") - + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { baseURL = "https://" + baseURL } - + if strings.HasSuffix(baseURL, "/api/v1") { baseURL = strings.TrimSuffix(baseURL, "/api/v1") } if strings.HasSuffix(baseURL, "/api") { baseURL = strings.TrimSuffix(baseURL, "/api") } - + return &Client{ BaseURL: baseURL, Token: token, @@ -46,8 +47,9 @@ type GiteaRepo struct { } type GiteaCommit struct { - SHA string `json:"sha"` - Commit struct { + SHA string `json:"sha"` + RepoName string `json:"-"` + Commit struct { Author struct { Name string `json:"name"` Email string `json:"email"` @@ -57,10 +59,14 @@ type GiteaCommit struct { } `json:"commit"` } -// do makes an authenticated HTTP request to Gitea API +type RepoRef struct { + Owner string + Name string +} + func (c *Client) do(method, path string) ([]byte, error) { url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path) - + req, err := http.NewRequest(method, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request for %s: %w", url, err) @@ -68,7 +74,7 @@ func (c *Client) do(method, path string) ([]byte, error) { req.Header.Set("Authorization", fmt.Sprintf("token %s", c.Token)) req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "gitea-wrapped/1.0") + req.Header.Set("User-Agent", "wrapped-cli/1.0") resp, err := c.Client.Do(req) if err != nil { @@ -96,7 +102,7 @@ func (c *Client) GetUserRepos() ([]GiteaRepo, error) { var repos []GiteaRepo page := 1 pageSize := 50 - maxPages := 100 // Safety limit to prevent infinite loops + maxPages := 100 for page <= maxPages { path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize) @@ -133,7 +139,7 @@ func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) { var commits []GiteaCommit page := 1 pageSize := 50 - maxPages := 1000 // Safety limit to prevent infinite loops + maxPages := 1000 for page <= maxPages { path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize) @@ -166,6 +172,40 @@ func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) { return commits, nil } +func (c *Client) GetReposCommitsParallel(repos []RepoRef) ([]GiteaCommit, error) { + var allCommits []GiteaCommit + var mu sync.Mutex + var wg sync.WaitGroup + + semaphore := make(chan struct{}, 5) + + for _, repo := range repos { + wg.Add(1) + go func(owner, repoName string) { + defer wg.Done() + + semaphore <- struct{}{} + defer func() { <-semaphore }() + + commits, err := c.GetRepoCommits(owner, repoName) + if err != nil { + return + } + + for i := range commits { + commits[i].RepoName = repoName + } + + mu.Lock() + allCommits = append(allCommits, commits...) + mu.Unlock() + }(repo.Owner, repo.Name) + } + + wg.Wait() + return allCommits, nil +} + func (c *Client) TestConnection() error { _, err := c.do("GET", "/user") if err != nil { diff --git a/pkg/models/types.go b/pkg/models/types.go index 055e90c..2468c0c 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -12,11 +12,12 @@ type Repository struct { } type Commit struct { - SHA string - Message string - Author string - Timestamp time.Time - RepoName string + SHA string + Message string + Author string + AuthorEmail string + Timestamp time.Time + RepoName string } type LanguageStats struct { @@ -26,9 +27,9 @@ type LanguageStats struct { } type DateStats struct { - Date time.Time - Count int - Day string + Date time.Time + Count int + Day string } type TagStats struct { @@ -37,15 +38,15 @@ type TagStats struct { } type UserStats struct { - Username string - TotalCommits int - TotalRepositories int - Languages []LanguageStats - Tags []TagStats - CommitsByWeekday []DateStats - CommitsByMonth []DateStats - TopRepositories []Repository - MostActiveMonth string - MostActiveDay string + Username string + TotalCommits int + TotalRepositories int + Languages []LanguageStats + Tags []TagStats + CommitsByWeekday []DateStats + CommitsByMonth []DateStats + TopRepositories []Repository + MostActiveMonth string + MostActiveDay string AverageCommitsPerDay float64 } diff --git a/pkg/reports/image_export.go b/pkg/reports/image_export.go new file mode 100644 index 0000000..7dba7cc --- /dev/null +++ b/pkg/reports/image_export.go @@ -0,0 +1,458 @@ +package reports + +import ( + "fmt" + "image/color" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" + "time" + + "github.com/atridad/wrapped-cli/pkg/config" + "github.com/atridad/wrapped-cli/pkg/models" + "github.com/fogleman/gg" +) + +const ( + canvasW = 1400 + canvasH = 1160 + gridGap = 20 + radius = 24 + unitCol = 325 + twoCol = 670 + fourCol = 1360 +) + +var ( + bgColor = color.RGBA{9, 9, 11, 255} + cardBg = color.RGBA{24, 24, 27, 255} + cardBgPurple = color.RGBA{30, 20, 36, 255} + cardBgBlue = color.RGBA{15, 23, 42, 255} + accentMagenta = color.RGBA{192, 132, 252, 255} + accentCyan = color.RGBA{45, 212, 191, 255} + accentOrange = color.RGBA{251, 146, 60, 255} + accentGreen = color.RGBA{74, 222, 128, 255} + accentBlue = color.RGBA{96, 165, 250, 255} + textPrimary = color.RGBA{244, 244, 245, 255} + textSecondary = color.RGBA{161, 161, 170, 255} + textMuted = color.RGBA{113, 113, 122, 255} + borderColor = color.RGBA{39, 39, 42, 255} + barTrack = color.RGBA{39, 39, 42, 255} +) + +type bentoCard struct { + x, y, w, h float64 + bg color.RGBA + accent color.RGBA +} + +func col(n int) float64 { + return float64(gridGap + n*(unitCol+gridGap)) +} + +func cardLayout() []bentoCard { + r1y := float64(gridGap) + r1h := float64(240) + r2y := r1y + r1h + gridGap + r2h := float64(240) + r3y := r2y + r2h + gridGap + r3h := float64(300) + r4y := r3y + r3h + gridGap + r4h := float64(280) + + return []bentoCard{ + {col(0), r1y, twoCol, r1h, cardBgPurple, accentMagenta}, + {col(2), r1y, unitCol, r1h, cardBg, accentOrange}, + {col(3), r1y, unitCol, r1h, cardBg, accentCyan}, + {col(0), r2y, unitCol, r2h, cardBg, accentGreen}, + {col(1), r2y, unitCol, r2h, cardBg, accentOrange}, + {col(2), r2y, twoCol, r2h, cardBgBlue, accentBlue}, + {col(0), r3y, fourCol, r3h, cardBg, accentMagenta}, + {col(0), r4y, fourCol, r4h, cardBg, accentCyan}, + } +} + +func loadFont(dc *gg.Context, size float64) { + paths := []string{ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/SFNS.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + } + for _, p := range paths { + if err := dc.LoadFontFace(p, size); err == nil { + return + } + } +} + +func drawCard(dc *gg.Context, c bentoCard) { + dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius) + dc.SetColor(c.bg) + dc.Fill() + dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius) + dc.SetColor(borderColor) + dc.SetLineWidth(1) + dc.Stroke() +} + +func label(dc *gg.Context, c bentoCard, text string) { + loadFont(dc, 12) + dc.SetColor(textSecondary) + dc.DrawString(text, c.x+24, c.y+32) +} + +func bigNum(dc *gg.Context, c bentoCard, value, sub string) { + loadFont(dc, 64) + dc.SetColor(c.accent) + dc.DrawString(value, c.x+24, c.y+110) + + if sub != "" { + loadFont(dc, 14) + dc.SetColor(textSecondary) + dc.DrawString(sub, c.x+24, c.y+136) + } +} + +func bigText(dc *gg.Context, c bentoCard, value string, size float64) { + loadFont(dc, size) + dc.SetColor(c.accent) + dc.DrawString(value, c.x+24, c.y+110) +} + +func filledBar(dc *gg.Context, x, y, totalW, h, pct float64, fill color.RGBA) { + dc.DrawRoundedRectangle(x, y, totalW, h, 6) + dc.SetColor(barTrack) + dc.Fill() + + fillW := totalW * pct + if fillW < 8 { + fillW = 8 + } + dc.DrawRoundedRectangle(x, y, fillW, h, 6) + dc.SetColor(fill) + dc.Fill() +} + +func renderCommitsCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "TOTAL COMMITS") + bigNum(dc, c, formatNumber(stats.TotalCommits), + fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay)) + + dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, c.w-48, 10, 5) + dc.SetColor(color.RGBA{50, 40, 60, 255}) + dc.Fill() + dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, (c.w-48)*0.72, 10, 5) + dc.SetColor(c.accent) + dc.Fill() +} + +func renderReposCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "REPOSITORIES") + bigNum(dc, c, formatNumber(stats.TotalRepositories), "worked on this year") + + y := c.y + 160 + maxW := c.w - 48 + for i, repo := range stats.TopRepositories { + if i >= 3 { + break + } + loadFont(dc, 12) + dc.SetColor(textMuted) + dc.DrawString(truncate(repo.Name, maxW, dc), c.x+24, y) + y += 24 + } +} + +func renderActiveDayCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "MOST ACTIVE DAY") + bigText(dc, c, stats.MostActiveDay, 48) + + if len(stats.CommitsByWeekday) > 0 { + loadFont(dc, 14) + dc.SetColor(textSecondary) + dc.DrawString( + fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count), + c.x+24, c.y+136, + ) + } +} + +func renderAvgCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "AVG COMMITS / DAY") + bigNum(dc, c, fmt.Sprintf("%.1f", stats.AverageCommitsPerDay), "daily average") +} + +func renderActiveMonthCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "MOST ACTIVE MONTH") + bigText(dc, c, stats.MostActiveMonth, 44) +} + +func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "TOP REPOSITORY") + + if len(stats.TopRepositories) == 0 { + return + } + + top := stats.TopRepositories[0] + maxW := c.w - 48 + + loadFont(dc, 32) + dc.SetColor(c.accent) + dc.DrawString(truncate(top.Name, maxW, dc), c.x+24, c.y+100) + + loadFont(dc, 13) + dc.SetColor(textSecondary) + dc.DrawString("your #1 repository this year", c.x+24, c.y+126) + + y := c.y + 164 + for i := 1; i < len(stats.TopRepositories) && i < 4; i++ { + loadFont(dc, 12) + dc.SetColor(textMuted) + dc.DrawString( + fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-30, dc)), + c.x+24, y, + ) + y += 24 + } +} + +func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "LANGUAGES") + + if len(stats.Languages) == 0 { + loadFont(dc, 15) + dc.SetColor(textSecondary) + dc.DrawString("No language data available.", c.x+24, c.y+100) + return + } + + const ( + numCols = 2 + rowsPerCol = 5 + rowH = 48.0 + startY = 60.0 + labelW = 110.0 + countW = 90.0 + barH = 14.0 + ) + + maxCount := stats.Languages[0].Count + halfW := (c.w - 48 - gridGap) / 2 + + for i, lang := range stats.Languages { + if i >= numCols*rowsPerCol { + break + } + + colIdx := i / rowsPerCol + rowIdx := i % rowsPerCol + + baseX := c.x + 24 + float64(colIdx)*(halfW+float64(gridGap)) + baseY := c.y + startY + float64(rowIdx)*rowH + + pct := float64(lang.Count) / float64(maxCount) + barW := halfW - labelW - countW - 10 + + loadFont(dc, 13) + dc.SetColor(textPrimary) + dc.DrawString(truncatePx(lang.Language, labelW-8, dc), baseX, baseY+12) + + filledBar(dc, baseX+labelW, baseY+2, barW, barH, pct, c.accent) + + loadFont(dc, 12) + dc.SetColor(textSecondary) + dc.DrawString( + fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent), + baseX+labelW+barW+10, baseY+13, + ) + } +} + +func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { + label(dc, c, "ACTIVITY PATTERNS") + + const ( + startY = 54.0 + rowH = 28.0 + labelW = 44.0 + countW = 50.0 + barH = 14.0 + ) + + halfW := (c.w - 48 - float64(gridGap)) / 2 + lx := c.x + 24 + + loadFont(dc, 12) + dc.SetColor(textSecondary) + dc.DrawString("By Weekday", lx, c.y+startY) + + if len(stats.CommitsByWeekday) > 0 { + maxC := stats.CommitsByWeekday[0].Count + barW := halfW - labelW - countW - 10 + + for i, day := range stats.CommitsByWeekday { + if i >= 7 { + break + } + by := c.y + startY + 24 + float64(i)*rowH + + loadFont(dc, 12) + dc.SetColor(textPrimary) + dc.DrawString(day.Day[:3], lx, by+12) + + pct := float64(day.Count) / float64(maxC) + filledBar(dc, lx+labelW, by+2, barW, barH, pct, accentCyan) + + loadFont(dc, 11) + dc.SetColor(textMuted) + dc.DrawString(formatNumber(day.Count), lx+labelW+barW+8, by+12) + } + } + + rx := c.x + 24 + halfW + float64(gridGap) + + loadFont(dc, 12) + dc.SetColor(textSecondary) + dc.DrawString("By Month", rx, c.y+startY) + + if len(stats.CommitsByMonth) > 0 { + maxC := stats.CommitsByMonth[0].Count + barW := halfW - labelW - countW - 10 + + for i, month := range stats.CommitsByMonth { + if i >= 7 { + break + } + by := c.y + startY + 24 + float64(i)*rowH + + loadFont(dc, 12) + dc.SetColor(textPrimary) + dc.DrawString(month.Date.Format("Jan"), rx, by+12) + + pct := float64(month.Count) / float64(maxC) + filledBar(dc, rx+labelW, by+2, barW, barH, pct, accentOrange) + + loadFont(dc, 11) + dc.SetColor(textMuted) + dc.DrawString(formatNumber(month.Count), rx+labelW+barW+8, by+12) + } + } +} + +func formatNumber(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fK", float64(n)/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +func truncate(s string, maxPx float64, dc *gg.Context) string { + return truncatePx(s, maxPx, dc) +} + +func truncatePx(s string, maxPx float64, dc *gg.Context) string { + w, _ := dc.MeasureString(s) + if w <= maxPx { + return s + } + for len(s) > 1 { + s = s[:len(s)-1] + w, _ = dc.MeasureString(s + "…") + if w <= maxPx { + return s + "…" + } + } + return s +} + +func ExportReportAsImage(stats *models.UserStats, format string) (string, error) { + resultsDir, err := config.GetResultsDir() + if err != nil { + return "", err + } + + dc := gg.NewContext(canvasW, canvasH) + dc.SetColor(bgColor) + dc.Clear() + + cards := cardLayout() + for _, c := range cards { + drawCard(dc, c) + } + + renderCommitsCard(dc, cards[0], stats) + renderReposCard(dc, cards[1], stats) + renderActiveDayCard(dc, cards[2], stats) + renderAvgCard(dc, cards[3], stats) + renderActiveMonthCard(dc, cards[4], stats) + renderTopRepoCard(dc, cards[5], stats) + renderLanguagesCard(dc, cards[6], stats) + renderActivityCard(dc, cards[7], stats) + + now := time.Now() + timestamp := now.Format("2006-01-02 15-04-05") + filenameWithExt := fmt.Sprintf("%s - Gitea Wrapped.%s", timestamp, format) + filename := filepath.Join(resultsDir, filenameWithExt) + + switch format { + case "png": + f, err := os.Create(filename) + if err != nil { + return "", err + } + defer f.Close() + if err := png.Encode(f, dc.Image()); err != nil { + return "", err + } + case "jpg", "jpeg": + f, err := os.Create(filename) + if err != nil { + return "", err + } + defer f.Close() + if err := jpeg.Encode(f, dc.Image(), &jpeg.Options{Quality: 92}); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported format: %s", format) + } + + return filenameWithExt, nil +} + +func GenerateReportImage(stats *models.UserStats, format string) (string, error) { + return ExportReportAsImage(stats, format) +} + +func stripANSI(s string) string { + b := make([]byte, 0, len(s)) + inEscape := false + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + inEscape = true + } else if inEscape && s[i] == 'm' { + inEscape = false + } else if !inEscape { + b = append(b, s[i]) + } + } + return string(b) +} + +func getTextColor(line string) color.Color { + if strings.Contains(line, "Overview") || + strings.Contains(line, "Languages") || + strings.Contains(line, "Activity") { + return color.RGBA{0, 255, 255, 255} + } + return color.RGBA{200, 200, 200, 255} +} diff --git a/pkg/reports/reports.go b/pkg/reports/reports.go new file mode 100644 index 0000000..38b151c --- /dev/null +++ b/pkg/reports/reports.go @@ -0,0 +1,179 @@ +package reports + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "gopkg.in/yaml.v3" + + "github.com/atridad/wrapped-cli/pkg/config" + "github.com/atridad/wrapped-cli/pkg/models" +) + +type ReportMetadata struct { + Timestamp time.Time `yaml:"timestamp"` + Username string `yaml:"username"` + TotalCommits int `yaml:"total_commits"` + Repositories int `yaml:"repositories"` + Stats *models.UserStats `yaml:"stats"` +} + +type Report struct { + Metadata *ReportMetadata + MarkdownID string + MetadataID string + Timestamp time.Time +} + +func SaveReport(stats *models.UserStats) (string, error) { + resultsDir, err := config.GetResultsDir() + if err != nil { + return "", err + } + + now := time.Now() + timestamp := now.Format("2006-01-02 15-04-05") + metadataID := fmt.Sprintf("%s.yaml", now.Format("2006-01-02-15-04-05")) + imageID := fmt.Sprintf("%s - Gitea Wrapped.png", timestamp) + + metadata := &ReportMetadata{ + Timestamp: now, + Username: stats.Username, + TotalCommits: stats.TotalCommits, + Repositories: stats.TotalRepositories, + Stats: stats, + } + + metadataPath := filepath.Join(resultsDir, metadataID) + data, err := yaml.Marshal(metadata) + if err != nil { + return "", err + } + + if err := os.WriteFile(metadataPath, data, 0o644); err != nil { + return "", err + } + + if _, err := ExportReportAsImage(stats, "png"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to generate image: %v\n", err) + } + + return imageID, nil +} + +func ListReports() ([]Report, error) { + resultsDir, err := config.GetResultsDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(resultsDir) + if err != nil { + if os.IsNotExist(err) { + return []Report{}, nil + } + return nil, err + } + + var reports []Report + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if filepath.Ext(entry.Name()) == ".yaml" { + metadataPath := filepath.Join(resultsDir, entry.Name()) + data, err := os.ReadFile(metadataPath) + if err != nil { + continue + } + + var metadata ReportMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + continue + } + + reports = append(reports, Report{ + Metadata: &metadata, + MetadataID: entry.Name(), + Timestamp: metadata.Timestamp, + }) + } + } + + sort.Slice(reports, func(i, j int) bool { + return reports[i].Timestamp.After(reports[j].Timestamp) + }) + + return reports, nil +} + +func GetReport(metadataID string) (*ReportMetadata, error) { + resultsDir, err := config.GetResultsDir() + if err != nil { + return nil, err + } + + metadataPath := filepath.Join(resultsDir, metadataID) + data, err := os.ReadFile(metadataPath) + if err != nil { + return nil, err + } + + var metadata ReportMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + return nil, err + } + + return &metadata, nil +} + +func generateMarkdown(stats *models.UserStats) string { + md := fmt.Sprintf("# Gitea Wrapped — %s\n\n", time.Now().Format("January 2, 2006")) + + md += fmt.Sprintf("**User**: %s\n\n", stats.Username) + + md += "## Overview\n\n" + md += fmt.Sprintf("- **Total Commits**: %d\n", stats.TotalCommits) + md += fmt.Sprintf("- **Total Repositories**: %d\n", stats.TotalRepositories) + md += fmt.Sprintf("- **Average Commits/Day**: %.2f\n", stats.AverageCommitsPerDay) + md += fmt.Sprintf("- **Most Active Day**: %s\n", stats.MostActiveDay) + md += fmt.Sprintf("- **Most Active Month**: %s\n\n", stats.MostActiveMonth) + + md += "## Languages\n\n" + for i, lang := range stats.Languages { + if i >= 10 { + break + } + md += fmt.Sprintf("- %s: %d repos (%.1f%%)\n", lang.Language, lang.Count, lang.Percent) + } + md += "\n" + + md += "## Top Repositories\n\n" + for i, repo := range stats.TopRepositories { + md += fmt.Sprintf("%d. [%s](%s)\n", i+1, repo.Name, repo.HTMLURL) + } + md += "\n" + + md += "## Most Active Days\n\n" + for i, day := range stats.CommitsByWeekday { + if i >= 7 { + break + } + md += fmt.Sprintf("- %s: %d commits\n", day.Day, day.Count) + } + md += "\n" + + md += "## Most Active Months\n\n" + for i, month := range stats.CommitsByMonth { + if i >= 12 { + break + } + md += fmt.Sprintf("- %s: %d commits\n", month.Date.Format("January 2006"), month.Count) + } + + return md +} diff --git a/pkg/stats/analyzer.go b/pkg/stats/analyzer.go index 8fbc08d..0c9a7f6 100644 --- a/pkg/stats/analyzer.go +++ b/pkg/stats/analyzer.go @@ -4,8 +4,8 @@ import ( "sort" "time" - "github.com/atridad/gitea-wrapped/pkg/gitea" - "github.com/atridad/gitea-wrapped/pkg/models" + "github.com/atridad/wrapped-cli/pkg/gitea" + "github.com/atridad/wrapped-cli/pkg/models" ) type Analyzer struct { @@ -28,17 +28,48 @@ func (a *Analyzer) AddCommits(commits []models.Commit) { a.commits = append(a.commits, commits...) } +func (a *Analyzer) inferPrimaryEmail(username string) string { + emailCount := make(map[string]int) + + for _, c := range a.commits { + if c.AuthorEmail != "" { + emailCount[c.AuthorEmail]++ + } + } + + var bestEmail string + max := 0 + + for email, count := range emailCount { + if count > max { + max = count + bestEmail = email + } + } + + return bestEmail +} + func (a *Analyzer) Generate(username string) *models.UserStats { + primaryEmail := a.inferPrimaryEmail(username) + + var filteredCommits []models.Commit + for _, commit := range a.commits { + if primaryEmail != "" && commit.AuthorEmail == primaryEmail { + filteredCommits = append(filteredCommits, commit) + } + } + stats := &models.UserStats{ Username: username, - TotalCommits: len(a.commits), + TotalCommits: len(filteredCommits), TotalRepositories: len(a.repos), } stats.Languages = a.generateLanguageStats() - stats.CommitsByWeekday = a.generateWeekdayStats() - stats.CommitsByMonth = a.generateMonthStats() - stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay() + stats.CommitsByWeekday = a.generateWeekdayStats(filteredCommits) + stats.CommitsByMonth = a.generateMonthStats(filteredCommits) + stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay(filteredCommits) if len(stats.CommitsByMonth) > 0 { stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January") @@ -47,12 +78,11 @@ func (a *Analyzer) Generate(username string) *models.UserStats { stats.MostActiveDay = stats.CommitsByWeekday[0].Day } - stats.TopRepositories = a.getTopRepositories(5) + stats.TopRepositories = a.getTopRepositories(filteredCommits, 5) return stats } -// generateLanguageStats creates language statistics func (a *Analyzer) generateLanguageStats() []models.LanguageStats { langCount := make(map[string]int) @@ -82,12 +112,11 @@ func (a *Analyzer) generateLanguageStats() []models.LanguageStats { return stats } -// generateWeekdayStats creates weekday statistics for commits -func (a *Analyzer) generateWeekdayStats() []models.DateStats { +func (a *Analyzer) generateWeekdayStats(commits []models.Commit) []models.DateStats { dayCount := make(map[time.Weekday]int) dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} - for _, commit := range a.commits { + for _, commit := range commits { day := commit.Timestamp.Weekday() dayCount[day]++ } @@ -108,19 +137,17 @@ func (a *Analyzer) generateWeekdayStats() []models.DateStats { return stats } -// generateMonthStats creates monthly statistics for commits -func (a *Analyzer) generateMonthStats() []models.DateStats { +func (a *Analyzer) generateMonthStats(commits []models.Commit) []models.DateStats { monthCount := make(map[string]int) monthKeys := []string{} - monthMap := make(map[string]time.Time) - for _, commit := range a.commits { + for _, commit := range commits { monthStr := commit.Timestamp.Format("2006-01") monthCount[monthStr]++ - if _, exists := monthMap[monthStr]; !exists { - monthKeys = append(monthKeys, monthStr) - monthMap[monthStr] = commit.Timestamp - } + } + + for k := range monthCount { + monthKeys = append(monthKeys, k) } var stats []models.DateStats @@ -139,37 +166,35 @@ func (a *Analyzer) generateMonthStats() []models.DateStats { return stats } -// calculateAverageCommitsPerDay calculates average commits per calendar day -func (a *Analyzer) calculateAverageCommitsPerDay() float64 { - if len(a.commits) == 0 { +func (a *Analyzer) calculateAverageCommitsPerDay(commits []models.Commit) float64 { + if len(commits) == 0 { return 0 } - if len(a.commits) < 2 { - return float64(len(a.commits)) + if len(commits) < 2 { + return float64(len(commits)) } - sort.Slice(a.commits, func(i, j int) bool { - return a.commits[i].Timestamp.Before(a.commits[j].Timestamp) + sort.Slice(commits, func(i, j int) bool { + return commits[i].Timestamp.Before(commits[j].Timestamp) }) - firstDay := a.commits[0].Timestamp - lastDay := a.commits[len(a.commits)-1].Timestamp + firstDay := commits[0].Timestamp + lastDay := commits[len(commits)-1].Timestamp daysDiff := lastDay.Sub(firstDay).Hours() / 24 if daysDiff == 0 { daysDiff = 1 } - return float64(len(a.commits)) / daysDiff + return float64(len(commits)) / daysDiff } -// getTopRepositories returns the top N repositories by commit count -func (a *Analyzer) getTopRepositories(n int) []models.Repository { +func (a *Analyzer) getTopRepositories(commits []models.Commit, n int) []models.Repository { repoCommitCount := make(map[string]int) repoMap := make(map[string]models.Repository) - for _, commit := range a.commits { + for _, commit := range commits { repoCommitCount[commit.RepoName]++ } @@ -188,6 +213,7 @@ func (a *Analyzer) getTopRepositories(n int) []models.Repository { repo models.Repository count int } + var repos []repoWithCount for name, count := range repoCommitCount { if repo, exists := repoMap[name]; exists { diff --git a/pkg/ui/app.go b/pkg/ui/app.go index 8a0ea04..7c50078 100644 --- a/pkg/ui/app.go +++ b/pkg/ui/app.go @@ -8,17 +8,19 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/atridad/gitea-wrapped/pkg/gitea" - "github.com/atridad/gitea-wrapped/pkg/models" - "github.com/atridad/gitea-wrapped/pkg/stats" + "github.com/atridad/wrapped-cli/pkg/config" + "github.com/atridad/wrapped-cli/pkg/gitea" + "github.com/atridad/wrapped-cli/pkg/models" + "github.com/atridad/wrapped-cli/pkg/stats" ) type screenType int const ( -inputScreenType screenType = iota -loadingScreenType -reportScreenType + menuScreenType screenType = iota + inputScreenType + loadingScreenType + reportScreenType ) type tickMsg time.Time @@ -26,215 +28,283 @@ type progressMsg string type errorMsg string type doneMsg struct { -repos []gitea.GiteaRepo -commits []models.Commit -username string + repos []gitea.GiteaRepo + commits []models.Commit + username string } type App struct { -screen screenType -inputScreen *InputScreen -reportScreen *ReportScreen -loadingMsg string -err error -giteaClient *gitea.Client -analyzer *stats.Analyzer -spinnerFrame int -repos []gitea.GiteaRepo -username string + screen screenType + menuScreen *MenuScreen + inputScreen *InputScreen + reportScreen *ReportScreen + loadingMsg string + err error + giteaClient *gitea.Client + analyzer *stats.Analyzer + spinnerFrame int + repos []gitea.GiteaRepo + username string + config *config.Config } func NewApp() *App { -return &App{ -screen: inputScreenType, -inputScreen: NewInputScreen(), -analyzer: stats.NewAnalyzer(), -} + cfg, err := config.LoadConfig() + if err != nil || cfg == nil { + cfg = &config.Config{Connections: []config.Connection{}} + } + + screen := menuScreenType + var inputScreen *InputScreen + if len(cfg.Connections) == 0 { + screen = inputScreenType + inputScreen = NewInputScreen() + } + + return &App{ + screen: screen, + menuScreen: NewMenuScreen(cfg), + inputScreen: inputScreen, + config: cfg, + analyzer: stats.NewAnalyzer(), + } } func (a *App) Init() tea.Cmd { -return a.inputScreen.Init() + if a.screen == inputScreenType && a.inputScreen != nil { + return a.inputScreen.Init() + } + return nil } func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { -switch msg := msg.(type) { -case tea.KeyMsg: -if msg.Type == tea.KeyCtrlC { -return a, tea.Quit -} -case tickMsg: -if a.screen == loadingScreenType { -a.spinnerFrame = (a.spinnerFrame + 1) % 4 -return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { -return tickMsg(t) -}) -} -} + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return a, tea.Quit + } + case tickMsg: + if a.screen == loadingScreenType { + a.spinnerFrame = (a.spinnerFrame + 1) % 4 + return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { + return tickMsg(t) + }) + } + } -switch a.screen { -case inputScreenType: -_, cmd := a.inputScreen.Update(msg) + switch a.screen { + case menuScreenType: + _, cmd := a.menuScreen.Update(msg) -if a.inputScreen.IsDone() { -url, username, token := a.inputScreen.GetCredentials() + if a.menuScreen.IsNewConnection() { + a.screen = inputScreenType + a.menuScreen.ResetSelection() + a.inputScreen = NewInputScreen() + return a, a.inputScreen.Init() + } -if url == "" { -a.inputScreen.err = fmt.Errorf("URL is required") -a.inputScreen.done = false -return a, cmd -} -if username == "" { -a.inputScreen.err = fmt.Errorf("username is required") -a.inputScreen.done = false -return a, cmd -} -if token == "" { -a.inputScreen.err = fmt.Errorf("token is required") -a.inputScreen.done = false -return a, cmd -} + if selected := a.menuScreen.GetSelectedName(); selected != "" { + conn := a.config.GetConnection(selected) + if conn != nil { + a.menuScreen.ResetSelection() + a.giteaClient = gitea.NewClient(conn.URL, conn.Token) + a.username = conn.Username + a.screen = loadingScreenType + a.loadingMsg = "Connecting to Gitea..." + a.spinnerFrame = 0 -a.giteaClient = gitea.NewClient(url, token) -a.username = username -a.screen = loadingScreenType -a.loadingMsg = "Connecting to Gitea..." -a.spinnerFrame = 0 + return a, tea.Batch( + tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { + return tickMsg(t) + }), + a.testConnection()) + } + } -return a, tea.Batch(cmd, -tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { -return tickMsg(t) -}), -a.testConnection()) -} + return a, cmd -return a, cmd + case inputScreenType: + _, cmd := a.inputScreen.Update(msg) -case loadingScreenType: -switch msg := msg.(type) { -case progressMsg: -a.loadingMsg = string(msg) + if a.inputScreen.IsDone() { + url, username, token, connName := a.inputScreen.GetCredentials() -switch string(msg) { -case "Fetching repositories...": -return a, a.fetchRepos() -case "Fetching commits...": -return a, a.fetchCommits() -} -return a, nil + if url == "" { + a.inputScreen.err = fmt.Errorf("URL is required") + a.inputScreen.done = false + return a, cmd + } + if username == "" { + a.inputScreen.err = fmt.Errorf("username is required") + a.inputScreen.done = false + return a, cmd + } + if token == "" { + a.inputScreen.err = fmt.Errorf("token is required") + a.inputScreen.done = false + return a, cmd + } -case errorMsg: -a.err = fmt.Errorf("%s", msg) -a.loadingMsg = "Error" -return a, nil + if connName != "" { + a.config.AddConnection(config.Connection{ + Name: connName, + URL: url, + Username: username, + Token: token, + }) + a.config.Save() + } -case doneMsg: -a.analyzer.AddRepos(msg.repos) -a.analyzer.AddCommits(msg.commits) -userStats := a.analyzer.Generate(msg.username) -a.reportScreen = NewReportScreen(userStats) -a.screen = reportScreenType -return a, nil -} + a.giteaClient = gitea.NewClient(url, token) + a.username = username + a.screen = loadingScreenType + a.loadingMsg = "Connecting to Gitea..." + a.spinnerFrame = 0 -case reportScreenType: -if a.reportScreen != nil { -_, cmd := a.reportScreen.Update(msg) -return a, cmd -} -} + return a, tea.Batch(cmd, + tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { + return tickMsg(t) + }), + a.testConnection()) + } -return a, nil + return a, cmd + + case loadingScreenType: + switch msg := msg.(type) { + case progressMsg: + a.loadingMsg = string(msg) + + switch string(msg) { + case "Fetching repositories...": + return a, a.fetchRepos() + case "Fetching commits...": + return a, a.fetchCommits() + } + return a, nil + + case errorMsg: + a.err = fmt.Errorf("%s", msg) + a.loadingMsg = "Error" + return a, nil + + case doneMsg: + a.analyzer.AddRepos(msg.repos) + a.analyzer.AddCommits(msg.commits) + userStats := a.analyzer.Generate(msg.username) + a.reportScreen = NewReportScreen(userStats) + a.screen = reportScreenType + return a, nil + } + + case reportScreenType: + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + a.screen = menuScreenType + a.menuScreen = NewMenuScreen(a.config) + a.reportScreen = nil + a.analyzer = stats.NewAnalyzer() + return a, nil + } + } + if a.reportScreen != nil { + _, cmd := a.reportScreen.Update(msg) + return a, cmd + } + } + + return a, nil } func (a *App) View() string { -switch a.screen { -case inputScreenType: -return a.inputScreen.View() -case loadingScreenType: -return a.renderLoadingScreen() -case reportScreenType: -if a.reportScreen != nil { -return a.reportScreen.View() -} -} -return "" + switch a.screen { + case menuScreenType: + return a.menuScreen.View() + case inputScreenType: + return a.inputScreen.View() + case loadingScreenType: + return a.renderLoadingScreen() + case reportScreenType: + if a.reportScreen != nil { + return a.reportScreen.View() + } + } + return "" } func (a *App) renderLoadingScreen() string { -var s string -s += lipgloss.NewStyle(). -Foreground(lipgloss.Color("212")). -Bold(true). -Render("🎵 Gitea Wrapped") + "\n\n" + var s string + s += lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true). + Render("Gitea Wrapped") + "\n\n" -spinners := []string{"⠋", "⠙", "⠹", "⠸"} -spinner := spinners[a.spinnerFrame%4] + spinners := []string{"⠋", "⠙", "⠹", "⠸"} + spinner := spinners[a.spinnerFrame%4] -s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg) + s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg) -if a.err != nil { -s += "\n" + lipgloss.NewStyle(). -Foreground(lipgloss.Color("1")). -Render(fmt.Sprintf("Error: %v", a.err)) + "\n" + if a.err != nil { + s += "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("1")). + Render(fmt.Sprintf("Error: %v", a.err)) + "\n" + } + + return s } -return s -} - -// testConnection tests API connection func (a *App) testConnection() tea.Cmd { -return func() tea.Msg { -if err := a.giteaClient.TestConnection(); err != nil { -return errorMsg(fmt.Sprintf("failed to connect: %v", err)) -} -return progressMsg("Fetching repositories...") -} + return func() tea.Msg { + if err := a.giteaClient.TestConnection(); err != nil { + return errorMsg(fmt.Sprintf("failed to connect: %v", err)) + } + return progressMsg("Fetching repositories...") + } } -// fetchRepos fetches all repositories func (a *App) fetchRepos() tea.Cmd { -return func() tea.Msg { -repos, err := a.giteaClient.GetUserRepos() -if err != nil { -return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err)) + return func() tea.Msg { + repos, err := a.giteaClient.GetUserRepos() + if err != nil { + return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err)) + } + + a.repos = repos + return progressMsg("Fetching commits...") + } } -a.repos = repos -return progressMsg("Fetching commits...") -} -} - -// fetchCommits fetches commits from all repos func (a *App) fetchCommits() tea.Cmd { -return func() tea.Msg { -var commits []models.Commit + return func() tea.Msg { + var repoRefs []gitea.RepoRef + for _, repo := range a.repos { + parts := strings.Split(repo.FullName, "/") + if len(parts) < 2 { + continue + } + repoRefs = append(repoRefs, gitea.RepoRef{ + Owner: parts[0], + Name: repo.Name, + }) + } -for i, repo := range a.repos { + giteaCommits, err := a.giteaClient.GetReposCommitsParallel(repoRefs) + if err != nil { + return errorMsg(fmt.Sprintf("failed to fetch commits: %v", err)) + } -parts := strings.Split(repo.FullName, "/") -if len(parts) < 2 { -continue -} - -owner := parts[0] -giteaCommits, err := a.giteaClient.GetRepoCommits(owner, repo.Name) -if err != nil { -continue -} - - -for _, gc := range giteaCommits { -commits = append(commits, models.Commit{ -SHA: gc.SHA, -Message: gc.Commit.Message, -Author: gc.Commit.Author.Name, -Timestamp: gc.Commit.Author.Date, -RepoName: repo.Name, -}) -} -} - - -return doneMsg{a.repos, commits, a.username} -} + var commits []models.Commit + for _, gc := range giteaCommits { + commits = append(commits, models.Commit{ + SHA: gc.SHA, + Message: gc.Commit.Message, + Author: gc.Commit.Author.Name, + AuthorEmail: gc.Commit.Author.Email, + Timestamp: gc.Commit.Author.Date, + RepoName: gc.RepoName, + }) + } + return doneMsg{a.repos, commits, a.username} + } } diff --git a/pkg/ui/export.go b/pkg/ui/export.go new file mode 100644 index 0000000..f6afe3b --- /dev/null +++ b/pkg/ui/export.go @@ -0,0 +1,10 @@ +package ui + +import ( +"github.com/atridad/wrapped-cli/pkg/models" +"github.com/atridad/wrapped-cli/pkg/reports" +) + +func ExportReport(stats *models.UserStats) (string, error) { +return reports.SaveReport(stats) +} diff --git a/pkg/ui/input_screen.go b/pkg/ui/input_screen.go index 63bd229..a2d0006 100644 --- a/pkg/ui/input_screen.go +++ b/pkg/ui/input_screen.go @@ -14,17 +14,20 @@ const ( urlInput inputScreenState = iota usernameInput tokenInput + connNameInput ) type InputScreen struct { urlField textinput.Model usernameField textinput.Model tokenField textinput.Model + connNameField textinput.Model focusedField inputScreenState err error url string username string token string + connName string done bool } @@ -40,10 +43,14 @@ func NewInputScreen() *InputScreen { tokenField.Placeholder = "your_access_token" tokenField.EchoMode = textinput.EchoPassword + connNameField := textinput.New() + connNameField.Placeholder = "(optional) Connection name" + return &InputScreen{ urlField: urlField, usernameField: usernameField, tokenField: tokenField, + connNameField: connNameField, focusedField: urlInput, } } @@ -72,6 +79,11 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return is, nil case tokenInput: is.token = is.tokenField.Value() + is.focusedField = connNameInput + is.connNameField.Focus() + return is, nil + case connNameInput: + is.connName = is.connNameField.Value() is.done = true return is, nil } @@ -83,17 +95,20 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { is.username = is.usernameField.Value() case tokenInput: is.token = is.tokenField.Value() + case connNameInput: + is.connName = is.connNameField.Value() } if msg.Type == tea.KeyTab { - is.focusedField = (is.focusedField + 1) % 3 + is.focusedField = (is.focusedField + 1) % 4 } else { - is.focusedField = (is.focusedField - 1 + 3) % 3 + is.focusedField = (is.focusedField - 1 + 4) % 4 } is.urlField.Blur() is.usernameField.Blur() is.tokenField.Blur() + is.connNameField.Blur() switch is.focusedField { case urlInput: @@ -102,6 +117,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { is.usernameField.Focus() case tokenInput: is.tokenField.Focus() + case connNameInput: + is.connNameField.Focus() } return is, nil } @@ -119,6 +136,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { is.usernameField, cmd = is.usernameField.Update(msg) case tokenInput: is.tokenField, cmd = is.tokenField.Update(msg) + case connNameInput: + is.connNameField, cmd = is.connNameField.Update(msg) } return is, cmd @@ -132,7 +151,7 @@ func (is *InputScreen) View() string { Bold(true). Render("Gitea Wrapped") + "\n\n" - s += "Enter your Gitea credentials to get started:\n\n" + s += "Enter your Gitea credentials:\n\n" s += "Gitea URL\n" s += is.urlField.View() + "\n\n" @@ -143,6 +162,9 @@ func (is *InputScreen) View() string { s += "Access Token\n" s += is.tokenField.View() + "\n\n" + s += "Connection Name (optional - to save for later)\n" + s += is.connNameField.View() + "\n\n" + if is.err != nil { s += lipgloss.NewStyle(). Foreground(lipgloss.Color("1")). @@ -151,7 +173,7 @@ func (is *InputScreen) View() string { s += lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). - Render("(press Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n" + Render("(Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n" return s } @@ -160,8 +182,8 @@ func (is *InputScreen) IsDone() bool { return is.done } -func (is *InputScreen) GetCredentials() (string, string, string) { - return is.url, is.username, is.token +func (is *InputScreen) GetCredentials() (url, username, token, connName string) { + return is.url, is.username, is.token, is.connName } type errMsg error diff --git a/pkg/ui/menu_screen.go b/pkg/ui/menu_screen.go new file mode 100644 index 0000000..b7a2308 --- /dev/null +++ b/pkg/ui/menu_screen.go @@ -0,0 +1,122 @@ +package ui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/atridad/wrapped-cli/pkg/config" +) + +type MenuScreen struct { + connections []config.Connection + selected int + newSelected bool + confirmed bool +} + +func NewMenuScreen(cfg *config.Config) *MenuScreen { + return &MenuScreen{ + connections: cfg.Connections, + selected: 0, + confirmed: false, + } +} + +func (m *MenuScreen) Init() tea.Cmd { + return nil +} + +func (m *MenuScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.selected > 0 { + m.selected-- + } + case "down", "j": + menuItems := len(m.connections) + 1 + if m.selected < menuItems-1 { + m.selected++ + } + case "enter": + if m.selected == len(m.connections) { + m.newSelected = true + } else if m.selected < len(m.connections) { + m.confirmed = true + } + } + } + return m, nil +} + +func (m *MenuScreen) View() string { + var s string + s += lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true). + Render("Gitea Wrapped") + "\n\n" + + if len(m.connections) == 0 { + s += "No saved connections yet.\n\n" + + newPrefix := " " + if m.selected == 0 { + newPrefix = "→ " + } + if m.selected == 1 { + } + + s += fmt.Sprintf("%s+ New Connection\n", newPrefix) + + s += "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render("↑/↓ navigate | Enter to select | Ctrl+C to quit") + } else { + s += "Select a connection:\n\n" + + for i, conn := range m.connections { + prefix := " " + if i == m.selected { + prefix = "→ " + } + s += fmt.Sprintf("%s%s (%s)\n", prefix, conn.Name, conn.URL) + } + + newPrefix := " " + if m.selected == len(m.connections) { + newPrefix = "→ " + } + if m.selected == len(m.connections)+1 { + } + + s += fmt.Sprintf("%s+ New Connection\n", newPrefix) + + s += "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render("↑/↓ navigate | Enter to select | Ctrl+C to quit") + } + + return s +} + +func (m *MenuScreen) IsNewConnection() bool { + return m.newSelected +} + +func (m *MenuScreen) GetSelectedName() string { + if m.confirmed && m.selected < len(m.connections) { + return m.connections[m.selected].Name + } + return "" +} + +func (m *MenuScreen) ResetSelection() { + m.newSelected = false + m.confirmed = false + m.selected = 0 +} diff --git a/pkg/ui/report_screen.go b/pkg/ui/report_screen.go index 74915e1..16fbe87 100644 --- a/pkg/ui/report_screen.go +++ b/pkg/ui/report_screen.go @@ -7,20 +7,23 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/atridad/gitea-wrapped/pkg/models" + "github.com/atridad/wrapped-cli/pkg/models" ) type ReportScreen struct { - stats *models.UserStats - page int - maxPages int + stats *models.UserStats + page int + maxPages int + exportFile string } func NewReportScreen(stats *models.UserStats) *ReportScreen { + exportFile, _ := ExportReport(stats) return &ReportScreen{ - stats: stats, - page: 0, - maxPages: 3, + stats: stats, + page: 0, + maxPages: 3, + exportFile: exportFile, } } @@ -67,7 +70,14 @@ func (rs *ReportScreen) View() string { Foreground(lipgloss.Color("8")). Render(fmt.Sprintf("Page %d/%d | ← → to navigate | q to quit", rs.page+1, rs.maxPages)) - return header + "\n" + content + "\n\n" + footer + report := header + "\n" + content + "\n\n" + footer + if rs.exportFile != "" { + report += "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("10")). + Render(fmt.Sprintf("✓ Report saved: %s", rs.exportFile)) + } + + return report } func (rs *ReportScreen) renderOverviewPage() string {