Added bento :)
This commit is contained in:
@@ -14,4 +14,6 @@ vendor/
|
|||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
# Executable and runtime files
|
||||||
wrapped
|
wrapped
|
||||||
|
.wrapped/
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
.PHONY: build run clean
|
.PHONY: build run clean
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o wrapped ./cmd/gitea-wrapped/
|
go build -o wrapped ./cmd/wrapped-cli/
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./wrapped
|
./wrapped
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f wrapped
|
rm -rf ./wrapped ./.wrapped
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
# Gitea Wrapped
|
# 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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"github.com/atridad/gitea-wrapped/pkg/ui"
|
"github.com/atridad/wrapped-cli/pkg/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/atridad/gitea-wrapped
|
module github.com/atridad/wrapped-cli
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ require (
|
|||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // 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/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
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=
|
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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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/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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+50
-10
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,18 +18,18 @@ type Client struct {
|
|||||||
|
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
|
||||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||||
baseURL = "https://" + baseURL
|
baseURL = "https://" + baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(baseURL, "/api/v1") {
|
if strings.HasSuffix(baseURL, "/api/v1") {
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/api/v1")
|
baseURL = strings.TrimSuffix(baseURL, "/api/v1")
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(baseURL, "/api") {
|
if strings.HasSuffix(baseURL, "/api") {
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/api")
|
baseURL = strings.TrimSuffix(baseURL, "/api")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
Token: token,
|
Token: token,
|
||||||
@@ -46,8 +47,9 @@ type GiteaRepo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GiteaCommit struct {
|
type GiteaCommit struct {
|
||||||
SHA string `json:"sha"`
|
SHA string `json:"sha"`
|
||||||
Commit struct {
|
RepoName string `json:"-"`
|
||||||
|
Commit struct {
|
||||||
Author struct {
|
Author struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -57,10 +59,14 @@ type GiteaCommit struct {
|
|||||||
} `json:"commit"`
|
} `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) {
|
func (c *Client) do(method, path string) ([]byte, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path)
|
url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path)
|
||||||
|
|
||||||
req, err := http.NewRequest(method, url, nil)
|
req, err := http.NewRequest(method, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request for %s: %w", url, err)
|
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("Authorization", fmt.Sprintf("token %s", c.Token))
|
||||||
req.Header.Set("Accept", "application/json")
|
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)
|
resp, err := c.Client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +102,7 @@ func (c *Client) GetUserRepos() ([]GiteaRepo, error) {
|
|||||||
var repos []GiteaRepo
|
var repos []GiteaRepo
|
||||||
page := 1
|
page := 1
|
||||||
pageSize := 50
|
pageSize := 50
|
||||||
maxPages := 100 // Safety limit to prevent infinite loops
|
maxPages := 100
|
||||||
|
|
||||||
for page <= maxPages {
|
for page <= maxPages {
|
||||||
path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize)
|
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
|
var commits []GiteaCommit
|
||||||
page := 1
|
page := 1
|
||||||
pageSize := 50
|
pageSize := 50
|
||||||
maxPages := 1000 // Safety limit to prevent infinite loops
|
maxPages := 1000
|
||||||
|
|
||||||
for page <= maxPages {
|
for page <= maxPages {
|
||||||
path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize)
|
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
|
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 {
|
func (c *Client) TestConnection() error {
|
||||||
_, err := c.do("GET", "/user")
|
_, err := c.do("GET", "/user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+19
-18
@@ -12,11 +12,12 @@ type Repository struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Commit struct {
|
type Commit struct {
|
||||||
SHA string
|
SHA string
|
||||||
Message string
|
Message string
|
||||||
Author string
|
Author string
|
||||||
Timestamp time.Time
|
AuthorEmail string
|
||||||
RepoName string
|
Timestamp time.Time
|
||||||
|
RepoName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LanguageStats struct {
|
type LanguageStats struct {
|
||||||
@@ -26,9 +27,9 @@ type LanguageStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DateStats struct {
|
type DateStats struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Count int
|
Count int
|
||||||
Day string
|
Day string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagStats struct {
|
type TagStats struct {
|
||||||
@@ -37,15 +38,15 @@ type TagStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserStats struct {
|
type UserStats struct {
|
||||||
Username string
|
Username string
|
||||||
TotalCommits int
|
TotalCommits int
|
||||||
TotalRepositories int
|
TotalRepositories int
|
||||||
Languages []LanguageStats
|
Languages []LanguageStats
|
||||||
Tags []TagStats
|
Tags []TagStats
|
||||||
CommitsByWeekday []DateStats
|
CommitsByWeekday []DateStats
|
||||||
CommitsByMonth []DateStats
|
CommitsByMonth []DateStats
|
||||||
TopRepositories []Repository
|
TopRepositories []Repository
|
||||||
MostActiveMonth string
|
MostActiveMonth string
|
||||||
MostActiveDay string
|
MostActiveDay string
|
||||||
AverageCommitsPerDay float64
|
AverageCommitsPerDay float64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+58
-32
@@ -4,8 +4,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/atridad/gitea-wrapped/pkg/gitea"
|
"github.com/atridad/wrapped-cli/pkg/gitea"
|
||||||
"github.com/atridad/gitea-wrapped/pkg/models"
|
"github.com/atridad/wrapped-cli/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Analyzer struct {
|
type Analyzer struct {
|
||||||
@@ -28,17 +28,48 @@ func (a *Analyzer) AddCommits(commits []models.Commit) {
|
|||||||
a.commits = append(a.commits, commits...)
|
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 {
|
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{
|
stats := &models.UserStats{
|
||||||
Username: username,
|
Username: username,
|
||||||
TotalCommits: len(a.commits),
|
TotalCommits: len(filteredCommits),
|
||||||
TotalRepositories: len(a.repos),
|
TotalRepositories: len(a.repos),
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.Languages = a.generateLanguageStats()
|
stats.Languages = a.generateLanguageStats()
|
||||||
stats.CommitsByWeekday = a.generateWeekdayStats()
|
stats.CommitsByWeekday = a.generateWeekdayStats(filteredCommits)
|
||||||
stats.CommitsByMonth = a.generateMonthStats()
|
stats.CommitsByMonth = a.generateMonthStats(filteredCommits)
|
||||||
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay()
|
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay(filteredCommits)
|
||||||
|
|
||||||
if len(stats.CommitsByMonth) > 0 {
|
if len(stats.CommitsByMonth) > 0 {
|
||||||
stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January")
|
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.MostActiveDay = stats.CommitsByWeekday[0].Day
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.TopRepositories = a.getTopRepositories(5)
|
stats.TopRepositories = a.getTopRepositories(filteredCommits, 5)
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateLanguageStats creates language statistics
|
|
||||||
func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
|
func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
|
||||||
langCount := make(map[string]int)
|
langCount := make(map[string]int)
|
||||||
|
|
||||||
@@ -82,12 +112,11 @@ func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateWeekdayStats creates weekday statistics for commits
|
func (a *Analyzer) generateWeekdayStats(commits []models.Commit) []models.DateStats {
|
||||||
func (a *Analyzer) generateWeekdayStats() []models.DateStats {
|
|
||||||
dayCount := make(map[time.Weekday]int)
|
dayCount := make(map[time.Weekday]int)
|
||||||
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||||
|
|
||||||
for _, commit := range a.commits {
|
for _, commit := range commits {
|
||||||
day := commit.Timestamp.Weekday()
|
day := commit.Timestamp.Weekday()
|
||||||
dayCount[day]++
|
dayCount[day]++
|
||||||
}
|
}
|
||||||
@@ -108,19 +137,17 @@ func (a *Analyzer) generateWeekdayStats() []models.DateStats {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateMonthStats creates monthly statistics for commits
|
func (a *Analyzer) generateMonthStats(commits []models.Commit) []models.DateStats {
|
||||||
func (a *Analyzer) generateMonthStats() []models.DateStats {
|
|
||||||
monthCount := make(map[string]int)
|
monthCount := make(map[string]int)
|
||||||
monthKeys := []string{}
|
monthKeys := []string{}
|
||||||
monthMap := make(map[string]time.Time)
|
|
||||||
|
|
||||||
for _, commit := range a.commits {
|
for _, commit := range commits {
|
||||||
monthStr := commit.Timestamp.Format("2006-01")
|
monthStr := commit.Timestamp.Format("2006-01")
|
||||||
monthCount[monthStr]++
|
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
|
var stats []models.DateStats
|
||||||
@@ -139,37 +166,35 @@ func (a *Analyzer) generateMonthStats() []models.DateStats {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateAverageCommitsPerDay calculates average commits per calendar day
|
func (a *Analyzer) calculateAverageCommitsPerDay(commits []models.Commit) float64 {
|
||||||
func (a *Analyzer) calculateAverageCommitsPerDay() float64 {
|
if len(commits) == 0 {
|
||||||
if len(a.commits) == 0 {
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(a.commits) < 2 {
|
if len(commits) < 2 {
|
||||||
return float64(len(a.commits))
|
return float64(len(commits))
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(a.commits, func(i, j int) bool {
|
sort.Slice(commits, func(i, j int) bool {
|
||||||
return a.commits[i].Timestamp.Before(a.commits[j].Timestamp)
|
return commits[i].Timestamp.Before(commits[j].Timestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
firstDay := a.commits[0].Timestamp
|
firstDay := commits[0].Timestamp
|
||||||
lastDay := a.commits[len(a.commits)-1].Timestamp
|
lastDay := commits[len(commits)-1].Timestamp
|
||||||
|
|
||||||
daysDiff := lastDay.Sub(firstDay).Hours() / 24
|
daysDiff := lastDay.Sub(firstDay).Hours() / 24
|
||||||
if daysDiff == 0 {
|
if daysDiff == 0 {
|
||||||
daysDiff = 1
|
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(commits []models.Commit, n int) []models.Repository {
|
||||||
func (a *Analyzer) getTopRepositories(n int) []models.Repository {
|
|
||||||
repoCommitCount := make(map[string]int)
|
repoCommitCount := make(map[string]int)
|
||||||
repoMap := make(map[string]models.Repository)
|
repoMap := make(map[string]models.Repository)
|
||||||
|
|
||||||
for _, commit := range a.commits {
|
for _, commit := range commits {
|
||||||
repoCommitCount[commit.RepoName]++
|
repoCommitCount[commit.RepoName]++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +213,7 @@ func (a *Analyzer) getTopRepositories(n int) []models.Repository {
|
|||||||
repo models.Repository
|
repo models.Repository
|
||||||
count int
|
count int
|
||||||
}
|
}
|
||||||
|
|
||||||
var repos []repoWithCount
|
var repos []repoWithCount
|
||||||
for name, count := range repoCommitCount {
|
for name, count := range repoCommitCount {
|
||||||
if repo, exists := repoMap[name]; exists {
|
if repo, exists := repoMap[name]; exists {
|
||||||
|
|||||||
+243
-173
@@ -8,17 +8,19 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"github.com/atridad/gitea-wrapped/pkg/gitea"
|
"github.com/atridad/wrapped-cli/pkg/config"
|
||||||
"github.com/atridad/gitea-wrapped/pkg/models"
|
"github.com/atridad/wrapped-cli/pkg/gitea"
|
||||||
"github.com/atridad/gitea-wrapped/pkg/stats"
|
"github.com/atridad/wrapped-cli/pkg/models"
|
||||||
|
"github.com/atridad/wrapped-cli/pkg/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
type screenType int
|
type screenType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
inputScreenType screenType = iota
|
menuScreenType screenType = iota
|
||||||
loadingScreenType
|
inputScreenType
|
||||||
reportScreenType
|
loadingScreenType
|
||||||
|
reportScreenType
|
||||||
)
|
)
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
@@ -26,215 +28,283 @@ type progressMsg string
|
|||||||
type errorMsg string
|
type errorMsg string
|
||||||
|
|
||||||
type doneMsg struct {
|
type doneMsg struct {
|
||||||
repos []gitea.GiteaRepo
|
repos []gitea.GiteaRepo
|
||||||
commits []models.Commit
|
commits []models.Commit
|
||||||
username string
|
username string
|
||||||
}
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
screen screenType
|
screen screenType
|
||||||
inputScreen *InputScreen
|
menuScreen *MenuScreen
|
||||||
reportScreen *ReportScreen
|
inputScreen *InputScreen
|
||||||
loadingMsg string
|
reportScreen *ReportScreen
|
||||||
err error
|
loadingMsg string
|
||||||
giteaClient *gitea.Client
|
err error
|
||||||
analyzer *stats.Analyzer
|
giteaClient *gitea.Client
|
||||||
spinnerFrame int
|
analyzer *stats.Analyzer
|
||||||
repos []gitea.GiteaRepo
|
spinnerFrame int
|
||||||
username string
|
repos []gitea.GiteaRepo
|
||||||
|
username string
|
||||||
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{
|
cfg, err := config.LoadConfig()
|
||||||
screen: inputScreenType,
|
if err != nil || cfg == nil {
|
||||||
inputScreen: NewInputScreen(),
|
cfg = &config.Config{Connections: []config.Connection{}}
|
||||||
analyzer: stats.NewAnalyzer(),
|
}
|
||||||
}
|
|
||||||
|
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 {
|
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) {
|
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if msg.Type == tea.KeyCtrlC {
|
if msg.Type == tea.KeyCtrlC {
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
}
|
}
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
if a.screen == loadingScreenType {
|
if a.screen == loadingScreenType {
|
||||||
a.spinnerFrame = (a.spinnerFrame + 1) % 4
|
a.spinnerFrame = (a.spinnerFrame + 1) % 4
|
||||||
return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch a.screen {
|
switch a.screen {
|
||||||
case inputScreenType:
|
case menuScreenType:
|
||||||
_, cmd := a.inputScreen.Update(msg)
|
_, cmd := a.menuScreen.Update(msg)
|
||||||
|
|
||||||
if a.inputScreen.IsDone() {
|
if a.menuScreen.IsNewConnection() {
|
||||||
url, username, token := a.inputScreen.GetCredentials()
|
a.screen = inputScreenType
|
||||||
|
a.menuScreen.ResetSelection()
|
||||||
|
a.inputScreen = NewInputScreen()
|
||||||
|
return a, a.inputScreen.Init()
|
||||||
|
}
|
||||||
|
|
||||||
if url == "" {
|
if selected := a.menuScreen.GetSelectedName(); selected != "" {
|
||||||
a.inputScreen.err = fmt.Errorf("URL is required")
|
conn := a.config.GetConnection(selected)
|
||||||
a.inputScreen.done = false
|
if conn != nil {
|
||||||
return a, cmd
|
a.menuScreen.ResetSelection()
|
||||||
}
|
a.giteaClient = gitea.NewClient(conn.URL, conn.Token)
|
||||||
if username == "" {
|
a.username = conn.Username
|
||||||
a.inputScreen.err = fmt.Errorf("username is required")
|
a.screen = loadingScreenType
|
||||||
a.inputScreen.done = false
|
a.loadingMsg = "Connecting to Gitea..."
|
||||||
return a, cmd
|
a.spinnerFrame = 0
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
a.inputScreen.err = fmt.Errorf("token is required")
|
|
||||||
a.inputScreen.done = false
|
|
||||||
return a, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
a.giteaClient = gitea.NewClient(url, token)
|
return a, tea.Batch(
|
||||||
a.username = username
|
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
||||||
a.screen = loadingScreenType
|
return tickMsg(t)
|
||||||
a.loadingMsg = "Connecting to Gitea..."
|
}),
|
||||||
a.spinnerFrame = 0
|
a.testConnection())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return a, tea.Batch(cmd,
|
return a, cmd
|
||||||
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
|
||||||
return tickMsg(t)
|
|
||||||
}),
|
|
||||||
a.testConnection())
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, cmd
|
case inputScreenType:
|
||||||
|
_, cmd := a.inputScreen.Update(msg)
|
||||||
|
|
||||||
case loadingScreenType:
|
if a.inputScreen.IsDone() {
|
||||||
switch msg := msg.(type) {
|
url, username, token, connName := a.inputScreen.GetCredentials()
|
||||||
case progressMsg:
|
|
||||||
a.loadingMsg = string(msg)
|
|
||||||
|
|
||||||
switch string(msg) {
|
if url == "" {
|
||||||
case "Fetching repositories...":
|
a.inputScreen.err = fmt.Errorf("URL is required")
|
||||||
return a, a.fetchRepos()
|
a.inputScreen.done = false
|
||||||
case "Fetching commits...":
|
return a, cmd
|
||||||
return a, a.fetchCommits()
|
}
|
||||||
}
|
if username == "" {
|
||||||
return a, nil
|
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:
|
if connName != "" {
|
||||||
a.err = fmt.Errorf("%s", msg)
|
a.config.AddConnection(config.Connection{
|
||||||
a.loadingMsg = "Error"
|
Name: connName,
|
||||||
return a, nil
|
URL: url,
|
||||||
|
Username: username,
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
a.config.Save()
|
||||||
|
}
|
||||||
|
|
||||||
case doneMsg:
|
a.giteaClient = gitea.NewClient(url, token)
|
||||||
a.analyzer.AddRepos(msg.repos)
|
a.username = username
|
||||||
a.analyzer.AddCommits(msg.commits)
|
a.screen = loadingScreenType
|
||||||
userStats := a.analyzer.Generate(msg.username)
|
a.loadingMsg = "Connecting to Gitea..."
|
||||||
a.reportScreen = NewReportScreen(userStats)
|
a.spinnerFrame = 0
|
||||||
a.screen = reportScreenType
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case reportScreenType:
|
return a, tea.Batch(cmd,
|
||||||
if a.reportScreen != nil {
|
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
||||||
_, cmd := a.reportScreen.Update(msg)
|
return tickMsg(t)
|
||||||
return a, cmd
|
}),
|
||||||
}
|
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 {
|
func (a *App) View() string {
|
||||||
switch a.screen {
|
switch a.screen {
|
||||||
case inputScreenType:
|
case menuScreenType:
|
||||||
return a.inputScreen.View()
|
return a.menuScreen.View()
|
||||||
case loadingScreenType:
|
case inputScreenType:
|
||||||
return a.renderLoadingScreen()
|
return a.inputScreen.View()
|
||||||
case reportScreenType:
|
case loadingScreenType:
|
||||||
if a.reportScreen != nil {
|
return a.renderLoadingScreen()
|
||||||
return a.reportScreen.View()
|
case reportScreenType:
|
||||||
}
|
if a.reportScreen != nil {
|
||||||
}
|
return a.reportScreen.View()
|
||||||
return ""
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderLoadingScreen() string {
|
func (a *App) renderLoadingScreen() string {
|
||||||
var s string
|
var s string
|
||||||
s += lipgloss.NewStyle().
|
s += lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("212")).
|
Foreground(lipgloss.Color("212")).
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Render("🎵 Gitea Wrapped") + "\n\n"
|
Render("Gitea Wrapped") + "\n\n"
|
||||||
|
|
||||||
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
|
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
|
||||||
spinner := spinners[a.spinnerFrame%4]
|
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 {
|
if a.err != nil {
|
||||||
s += "\n" + lipgloss.NewStyle().
|
s += "\n" + lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("1")).
|
Foreground(lipgloss.Color("1")).
|
||||||
Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
|
Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// testConnection tests API connection
|
|
||||||
func (a *App) testConnection() tea.Cmd {
|
func (a *App) testConnection() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := a.giteaClient.TestConnection(); err != nil {
|
if err := a.giteaClient.TestConnection(); err != nil {
|
||||||
return errorMsg(fmt.Sprintf("failed to connect: %v", err))
|
return errorMsg(fmt.Sprintf("failed to connect: %v", err))
|
||||||
}
|
}
|
||||||
return progressMsg("Fetching repositories...")
|
return progressMsg("Fetching repositories...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchRepos fetches all repositories
|
|
||||||
func (a *App) fetchRepos() tea.Cmd {
|
func (a *App) fetchRepos() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
repos, err := a.giteaClient.GetUserRepos()
|
repos, err := a.giteaClient.GetUserRepos()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err))
|
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 {
|
func (a *App) fetchCommits() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
var commits []models.Commit
|
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, "/")
|
var commits []models.Commit
|
||||||
if len(parts) < 2 {
|
for _, gc := range giteaCommits {
|
||||||
continue
|
commits = append(commits, models.Commit{
|
||||||
}
|
SHA: gc.SHA,
|
||||||
|
Message: gc.Commit.Message,
|
||||||
owner := parts[0]
|
Author: gc.Commit.Author.Name,
|
||||||
giteaCommits, err := a.giteaClient.GetRepoCommits(owner, repo.Name)
|
AuthorEmail: gc.Commit.Author.Email,
|
||||||
if err != nil {
|
Timestamp: gc.Commit.Author.Date,
|
||||||
continue
|
RepoName: gc.RepoName,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
return doneMsg{a.repos, commits, a.username}
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+28
-6
@@ -14,17 +14,20 @@ const (
|
|||||||
urlInput inputScreenState = iota
|
urlInput inputScreenState = iota
|
||||||
usernameInput
|
usernameInput
|
||||||
tokenInput
|
tokenInput
|
||||||
|
connNameInput
|
||||||
)
|
)
|
||||||
|
|
||||||
type InputScreen struct {
|
type InputScreen struct {
|
||||||
urlField textinput.Model
|
urlField textinput.Model
|
||||||
usernameField textinput.Model
|
usernameField textinput.Model
|
||||||
tokenField textinput.Model
|
tokenField textinput.Model
|
||||||
|
connNameField textinput.Model
|
||||||
focusedField inputScreenState
|
focusedField inputScreenState
|
||||||
err error
|
err error
|
||||||
url string
|
url string
|
||||||
username string
|
username string
|
||||||
token string
|
token string
|
||||||
|
connName string
|
||||||
done bool
|
done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +43,14 @@ func NewInputScreen() *InputScreen {
|
|||||||
tokenField.Placeholder = "your_access_token"
|
tokenField.Placeholder = "your_access_token"
|
||||||
tokenField.EchoMode = textinput.EchoPassword
|
tokenField.EchoMode = textinput.EchoPassword
|
||||||
|
|
||||||
|
connNameField := textinput.New()
|
||||||
|
connNameField.Placeholder = "(optional) Connection name"
|
||||||
|
|
||||||
return &InputScreen{
|
return &InputScreen{
|
||||||
urlField: urlField,
|
urlField: urlField,
|
||||||
usernameField: usernameField,
|
usernameField: usernameField,
|
||||||
tokenField: tokenField,
|
tokenField: tokenField,
|
||||||
|
connNameField: connNameField,
|
||||||
focusedField: urlInput,
|
focusedField: urlInput,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +79,11 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return is, nil
|
return is, nil
|
||||||
case tokenInput:
|
case tokenInput:
|
||||||
is.token = is.tokenField.Value()
|
is.token = is.tokenField.Value()
|
||||||
|
is.focusedField = connNameInput
|
||||||
|
is.connNameField.Focus()
|
||||||
|
return is, nil
|
||||||
|
case connNameInput:
|
||||||
|
is.connName = is.connNameField.Value()
|
||||||
is.done = true
|
is.done = true
|
||||||
return is, nil
|
return is, nil
|
||||||
}
|
}
|
||||||
@@ -83,17 +95,20 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
is.username = is.usernameField.Value()
|
is.username = is.usernameField.Value()
|
||||||
case tokenInput:
|
case tokenInput:
|
||||||
is.token = is.tokenField.Value()
|
is.token = is.tokenField.Value()
|
||||||
|
case connNameInput:
|
||||||
|
is.connName = is.connNameField.Value()
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Type == tea.KeyTab {
|
if msg.Type == tea.KeyTab {
|
||||||
is.focusedField = (is.focusedField + 1) % 3
|
is.focusedField = (is.focusedField + 1) % 4
|
||||||
} else {
|
} else {
|
||||||
is.focusedField = (is.focusedField - 1 + 3) % 3
|
is.focusedField = (is.focusedField - 1 + 4) % 4
|
||||||
}
|
}
|
||||||
|
|
||||||
is.urlField.Blur()
|
is.urlField.Blur()
|
||||||
is.usernameField.Blur()
|
is.usernameField.Blur()
|
||||||
is.tokenField.Blur()
|
is.tokenField.Blur()
|
||||||
|
is.connNameField.Blur()
|
||||||
|
|
||||||
switch is.focusedField {
|
switch is.focusedField {
|
||||||
case urlInput:
|
case urlInput:
|
||||||
@@ -102,6 +117,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
is.usernameField.Focus()
|
is.usernameField.Focus()
|
||||||
case tokenInput:
|
case tokenInput:
|
||||||
is.tokenField.Focus()
|
is.tokenField.Focus()
|
||||||
|
case connNameInput:
|
||||||
|
is.connNameField.Focus()
|
||||||
}
|
}
|
||||||
return is, nil
|
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)
|
is.usernameField, cmd = is.usernameField.Update(msg)
|
||||||
case tokenInput:
|
case tokenInput:
|
||||||
is.tokenField, cmd = is.tokenField.Update(msg)
|
is.tokenField, cmd = is.tokenField.Update(msg)
|
||||||
|
case connNameInput:
|
||||||
|
is.connNameField, cmd = is.connNameField.Update(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return is, cmd
|
return is, cmd
|
||||||
@@ -132,7 +151,7 @@ func (is *InputScreen) View() string {
|
|||||||
Bold(true).
|
Bold(true).
|
||||||
Render("Gitea Wrapped") + "\n\n"
|
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 += "Gitea URL\n"
|
||||||
s += is.urlField.View() + "\n\n"
|
s += is.urlField.View() + "\n\n"
|
||||||
@@ -143,6 +162,9 @@ func (is *InputScreen) View() string {
|
|||||||
s += "Access Token\n"
|
s += "Access Token\n"
|
||||||
s += is.tokenField.View() + "\n\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 {
|
if is.err != nil {
|
||||||
s += lipgloss.NewStyle().
|
s += lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("1")).
|
Foreground(lipgloss.Color("1")).
|
||||||
@@ -151,7 +173,7 @@ func (is *InputScreen) View() string {
|
|||||||
|
|
||||||
s += lipgloss.NewStyle().
|
s += lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("8")).
|
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
|
return s
|
||||||
}
|
}
|
||||||
@@ -160,8 +182,8 @@ func (is *InputScreen) IsDone() bool {
|
|||||||
return is.done
|
return is.done
|
||||||
}
|
}
|
||||||
|
|
||||||
func (is *InputScreen) GetCredentials() (string, string, string) {
|
func (is *InputScreen) GetCredentials() (url, username, token, connName string) {
|
||||||
return is.url, is.username, is.token
|
return is.url, is.username, is.token, is.connName
|
||||||
}
|
}
|
||||||
|
|
||||||
type errMsg error
|
type errMsg error
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+18
-8
@@ -7,20 +7,23 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"github.com/atridad/gitea-wrapped/pkg/models"
|
"github.com/atridad/wrapped-cli/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReportScreen struct {
|
type ReportScreen struct {
|
||||||
stats *models.UserStats
|
stats *models.UserStats
|
||||||
page int
|
page int
|
||||||
maxPages int
|
maxPages int
|
||||||
|
exportFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReportScreen(stats *models.UserStats) *ReportScreen {
|
func NewReportScreen(stats *models.UserStats) *ReportScreen {
|
||||||
|
exportFile, _ := ExportReport(stats)
|
||||||
return &ReportScreen{
|
return &ReportScreen{
|
||||||
stats: stats,
|
stats: stats,
|
||||||
page: 0,
|
page: 0,
|
||||||
maxPages: 3,
|
maxPages: 3,
|
||||||
|
exportFile: exportFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +70,14 @@ func (rs *ReportScreen) View() string {
|
|||||||
Foreground(lipgloss.Color("8")).
|
Foreground(lipgloss.Color("8")).
|
||||||
Render(fmt.Sprintf("Page %d/%d | ← → to navigate | q to quit", rs.page+1, rs.maxPages))
|
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 {
|
func (rs *ReportScreen) renderOverviewPage() string {
|
||||||
|
|||||||
Reference in New Issue
Block a user