about/internal/plugins/code.go

1180 lines
31 KiB
Go

package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"time"
"github.com/Alexander-D-Karpov/about/internal/storage"
"github.com/Alexander-D-Karpov/about/internal/stream"
)
type CodePlugin struct {
storage *storage.Storage
hub *stream.Hub
githubData *GitHubUserData
wakatimeData *WakatimeData
allRepoLanguages []LanguageStat
lastUpdate time.Time
mutex sync.RWMutex
}
type RepoLanguageBreakdown struct {
Name string
Percentage float64
Color string
}
type GitHubUserData struct {
Login string `json:"login"`
Name string `json:"name"`
PublicRepos int `json:"public_repos"`
Followers int `json:"followers"`
Following int `json:"following"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Bio string `json:"bio"`
Location string `json:"location"`
TotalCommits int `json:"-"`
TotalStars int `json:"-"`
TopLanguages []LanguageStat `json:"-"`
RecentRepos []GitHubRepo `json:"-"`
CommitStats GitHubCommitStats `json:"-"`
}
type GitHubRepo struct {
Name string `json:"name"`
Stars int `json:"stargazers_count"`
Language string `json:"language"`
UpdatedAt time.Time `json:"updated_at"`
Description string `json:"description"`
LanguagesURL string `json:"languages_url"`
Languages []RepoLanguageBreakdown `json:"-"`
}
type GitHubCommitStats struct {
TotalCommits int `json:"total_commits"`
WeeklyCommits []int `json:"weekly_commits"`
LanguageStats map[string]int `json:"language_stats"`
ContributionMap map[string]int `json:"contribution_map"`
}
type LanguageStat struct {
Name string `json:"name"`
Percentage float64 `json:"percentage"`
Color string `json:"color"`
Bytes int `json:"bytes"`
}
type WakatimeData struct {
TotalTime struct {
Seconds float64 `json:"seconds"`
Text string `json:"text"`
} `json:"total_time"`
LastWeek struct {
Seconds float64 `json:"seconds"`
Text string `json:"text"`
} `json:"last_week"`
Languages []WakatimeLanguage `json:"languages"`
Editors []struct {
Name string `json:"name"`
TotalSeconds float64 `json:"total_seconds"`
Percent float64 `json:"percent"`
Text string `json:"text"`
} `json:"editors"`
OperatingSystems []struct {
Name string `json:"name"`
TotalSeconds float64 `json:"total_seconds"`
Percent float64 `json:"percent"`
Text string `json:"text"`
} `json:"operating_systems"`
}
type WakatimeLanguage struct {
Name string `json:"name"`
TotalSeconds float64 `json:"total_seconds"`
Percent float64 `json:"percent"`
Text string `json:"text"`
Color string `json:"color"`
}
func NewCodePlugin(storage *storage.Storage, hub *stream.Hub) *CodePlugin {
return &CodePlugin{
storage: storage,
hub: hub,
}
}
func (p *CodePlugin) Name() string {
return "code"
}
func (p *CodePlugin) Render(ctx context.Context) (string, error) {
config := p.storage.GetPluginConfig(p.Name())
settings := config.Settings
sectionTitle := p.getConfigValue(settings, "ui.sectionTitle", "Coding Stats")
showGitHub := p.getConfigBool(settings, "ui.showGitHub", true)
showWakatime := p.getConfigBool(settings, "ui.showWakatime", true)
showLanguages := p.getConfigBool(settings, "ui.showLanguages", true)
githubUsername := p.getConfigValue(settings, "github.username", "")
p.mutex.RLock()
allRepoLanguages := p.allRepoLanguages
p.mutex.RUnlock()
tmpl := `
<div class="code-section section" data-w="2">
<div class="plugin-header">
<h3 class="plugin-title">{{.SectionTitle}}</h3>
</div>
<div class="plugin__inner">
{{if .AllRepoLanguages}}
<div class="code-lang-summary">
<div class="lang-summary-label">Languages across all repositories</div>
<div class="lang-summary-bar">
{{range .AllRepoLanguages}}
<div class="lang-segment" style="flex: {{printf "%.2f" .Percentage}}; background: {{.Color}};" title="{{.Name}} {{printf "%.1f" .Percentage}}%"></div>
{{end}}
</div>
<div class="lang-summary-legend">
{{range .AllRepoLanguages}}
<span class="lang-legend-item"><span class="lang-dot" style="background: {{.Color}};"></span>{{.Name}} <span class="lang-pct">{{printf "%.1f" .Percentage}}%</span></span>
{{end}}
</div>
</div>
{{end}}
{{if and .ShowGitHub .GitHubData}}
<div class="stats-overview">
<div class="stat-card">
<div class="stat-number">{{.GitHubData.PublicRepos}}</div>
<div class="stat-label">Repos</div>
</div>
<div class="stat-card">
<div class="stat-number">{{.GitHubData.TotalStars}}</div>
<div class="stat-label">Stars</div>
</div>
<div class="stat-card">
<div class="stat-number">{{.GitHubData.Followers}}</div>
<div class="stat-label">Followers</div>
</div>
<div class="stat-card">
<div class="stat-number">{{.GitHubData.TotalCommits}}</div>
<div class="stat-label">Commits</div>
</div>
</div>
{{end}}
{{if and .ShowWakatime .WakatimeData}}
<div class="time-summary">
<div class="time-card">
<span class="time-value">{{.WakatimeData.LastWeek.Text}}</span>
<span class="time-label">this week</span>
</div>
<div class="time-card">
<span class="time-value">{{.WakatimeData.TotalTime.Text}}</span>
<span class="time-label">all time</span>
</div>
</div>
{{end}}
{{if and .ShowWakatime .WakatimeData .WakatimeData.Languages}}
<div class="code-subsection">
<button class="section-toggle" data-target="wakatime-langs" type="button" aria-expanded="true">
<span class="toggle-icon">▼</span>
<span>This Week</span>
<span class="section-count">({{len .WakatimeData.Languages}} langs)</span>
</button>
<div class="collapsible-content" id="wakatime-langs">
<div class="wakatime-list">
{{range .WakatimeData.Languages}}
{{if gt .Percent 1.0}}
<div class="waka-item">
<span class="waka-lang">{{.Name}}</span>
<div class="waka-bar">
<div class="waka-fill" style="width: {{.Percent}}%; background-color: {{.Color}};"></div>
</div>
<span class="waka-time">{{.Text}}</span>
</div>
{{end}}
{{end}}
</div>
{{if .WakatimeData.Editors}}
<div class="editor-list">
{{range .WakatimeData.Editors}}
{{if gt .Percent 5.0}}
<span class="editor-chip">{{.Name}} {{printf "%.0f" .Percent}}%</span>
{{end}}
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{if and .ShowLanguages .GitHubData .GitHubData.TopLanguages}}
<div class="code-subsection">
<button class="section-toggle" data-target="languages" type="button" aria-expanded="false">
<span class="toggle-icon">▶</span>
<span>Top Languages</span>
<span class="section-count">({{len .GitHubData.TopLanguages}})</span>
</button>
<div class="collapsible-content collapsed" id="languages">
<div class="language-chart">
{{range .GitHubData.TopLanguages}}
<div class="language-item">
<div class="lang-info">
<span class="lang-name">{{.Name}}</span>
<span class="lang-percent">{{printf "%.1f" .Percentage}}%</span>
</div>
<div class="lang-bar">
<div class="lang-fill" style="width: {{.Percentage}}%; background-color: {{.Color}};"></div>
</div>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{if and .ShowGitHub .GitHubData .GitHubData.RecentRepos}}
<div class="code-subsection">
<button class="section-toggle" data-target="repos" type="button" aria-expanded="false">
<span class="toggle-icon">▶</span>
<span>Recent Repos</span>
<span class="section-count">({{len .GitHubData.RecentRepos}})</span>
</button>
<div class="collapsible-content collapsed" id="repos">
<div class="repo-list">
{{range .GitHubData.RecentRepos}}
<div class="repo-item" onclick="window.open('https://github.com/{{$.GitHubUsername}}/{{.Name}}', '_blank')">
<div class="repo-content">
<div class="repo-name">{{.Name}}</div>
{{if .Languages}}
{{$total := 0.0}}
{{range .Languages}}{{$total = add $total .Percentage}}{{end}}
<div class="repo-lang-bar">
{{range .Languages}}
<div style="flex: {{.Percentage}}; background-color: {{.Color}};" title="{{.Name}} {{printf "%.1f" .Percentage}}%"></div>
{{end}}
{{if lt $total 100.0}}
<div style="flex: {{printf "%.6f" (sub 100.0 $total)}}; background-color: #8b949e;" title="Other {{printf "%.1f" (sub 100.0 $total)}}%"></div>
{{end}}
</div>
{{else if .Language}}
<div class="repo-lang-bar">
<div style="flex: 100; background-color: {{call $.GetLanguageColor .Language}};" title="{{.Language}} 100%"></div>
</div>
{{end}}
</div>
<div class="repo-tags">
{{if .Language}}<span class="repo-lang">{{.Language}}</span>{{end}}
{{if gt .Stars 0}}<span class="repo-stars">★{{.Stars}}</span>{{end}}
</div>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{if not (or .GitHubData .WakatimeData)}}
<div class="no-data">
<p class="text-muted">Configure GitHub username or WakaTime API key to see coding statistics.</p>
</div>
{{end}}
</div>
</div>`
data := struct {
SectionTitle string
ShowGitHub bool
ShowWakatime bool
ShowLanguages bool
GitHubData *GitHubUserData
WakatimeData *WakatimeData
AllRepoLanguages []LanguageStat
GitHubUsername string
GetLanguageColor func(string) string
}{
SectionTitle: sectionTitle,
ShowGitHub: showGitHub,
ShowWakatime: showWakatime,
ShowLanguages: showLanguages,
GitHubData: p.githubData,
WakatimeData: p.wakatimeData,
AllRepoLanguages: allRepoLanguages,
GitHubUsername: githubUsername,
GetLanguageColor: GetLanguageColor,
}
funcMap := template.FuncMap{
"printf": fmt.Sprintf,
"add": func(a, b float64) float64 { return a + b },
"sub": func(a, b float64) float64 { return a - b },
"div": func(a, b int) int {
if b == 0 {
return 0
}
return a / b
},
"mul": func(a, b int) int { return a * b },
"lt": func(a, b float64) bool { return a < b },
}
t, err := template.New("code").Funcs(funcMap).Parse(tmpl)
if err != nil {
return "", err
}
var buf strings.Builder
err = t.Execute(&buf, data)
if err != nil {
return "", err
}
return buf.String(), nil
}
func (p *CodePlugin) checkRecentCommits(client *http.Client, username, repoName string, since time.Time) bool {
commitsURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits?since=%s&author=%s&per_page=1",
username, repoName, since.Format(time.RFC3339), username)
req, err := http.NewRequest("GET", commitsURL, nil)
if err != nil {
return false
}
config := p.storage.GetPluginConfig(p.Name())
if token := p.getConfigValue(config.Settings, "github.token", ""); token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var commits []interface{}
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
return false
}
return len(commits) > 0
}
func (p *CodePlugin) UpdateData(ctx context.Context) error {
if time.Since(p.lastUpdate) < 6*time.Hour {
return nil
}
config := p.storage.GetPluginConfig(p.Name())
settings := config.Settings
if githubUsername := p.getConfigValue(settings, "github.username", ""); githubUsername != "" {
if err := p.updateGitHubData(githubUsername); err != nil {
fmt.Printf("Warning: Failed to update GitHub data: %v\n", err)
}
}
if wakatimeKey := p.getConfigValue(settings, "wakatime.api_key", ""); wakatimeKey != "" {
if err := p.updateWakatimeData(wakatimeKey); err != nil {
fmt.Printf("Warning: Failed to update WakaTime data: %v\n", err)
}
}
p.lastUpdate = time.Now()
return nil
}
func (p *CodePlugin) fetchAllRepoLanguages(client *http.Client, username string, repos []GitHubRepo) []LanguageStat {
config := p.storage.GetPluginConfig(p.Name())
token := p.getConfigValue(config.Settings, "github.token", "")
allLanguageBytes := make(map[string]int)
for _, repo := range repos {
if repo.LanguagesURL == "" {
continue
}
req, err := http.NewRequest("GET", repo.LanguagesURL, nil)
if err != nil {
continue
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
continue
}
var languages map[string]int
if err := json.NewDecoder(resp.Body).Decode(&languages); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
for lang, bytes := range languages {
allLanguageBytes[lang] += bytes
}
time.Sleep(50 * time.Millisecond)
}
totalBytes := 0
for _, bytes := range allLanguageBytes {
totalBytes += bytes
}
var avgLanguages []LanguageStat
if totalBytes > 0 {
for lang, bytes := range allLanguageBytes {
percentage := float64(bytes) / float64(totalBytes) * 100
if percentage >= 0.5 {
avgLanguages = append(avgLanguages, LanguageStat{
Name: lang,
Percentage: percentage,
Color: GetLanguageColor(lang),
Bytes: bytes,
})
}
}
for i := 0; i < len(avgLanguages); i++ {
for j := i + 1; j < len(avgLanguages); j++ {
if avgLanguages[i].Percentage < avgLanguages[j].Percentage {
avgLanguages[i], avgLanguages[j] = avgLanguages[j], avgLanguages[i]
}
}
}
if len(avgLanguages) > 12 {
avgLanguages = avgLanguages[:12]
}
}
return avgLanguages
}
func (p *CodePlugin) updateGitHubData(username string) error {
client := &http.Client{Timeout: 15 * time.Second}
fmt.Println("Updating github info...")
userURL := fmt.Sprintf("https://api.github.com/users/%s", username)
req, err := http.NewRequest("GET", userURL, nil)
if err != nil {
return err
}
config := p.storage.GetPluginConfig(p.Name())
if token := p.getConfigValue(config.Settings, "github.token", ""); token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch user data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("GitHub API returned status %d for user %s", resp.StatusCode, username)
}
var userData GitHubUserData
if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil {
return fmt.Errorf("failed to decode user data: %w", err)
}
reposURL := fmt.Sprintf("https://api.github.com/users/%s/repos?sort=updated&per_page=100", username)
req, err = http.NewRequest("GET", reposURL, nil)
if err != nil {
return fmt.Errorf("failed to create repos request: %w", err)
}
if token := p.getConfigValue(config.Settings, "github.token", ""); token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err = client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch repos: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
var errorResp struct {
Message string `json:"message"`
DocumentationURL string `json:"documentation_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err == nil {
return fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, errorResp.Message)
}
return fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var repos []GitHubRepo
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
return fmt.Errorf("failed to decode repos (status %d): %w", resp.StatusCode, err)
}
allRepoLanguages := p.fetchAllRepoLanguages(client, username, repos)
totalStars := 0
languageBytes := make(map[string]int)
for _, repo := range repos {
totalStars += repo.Stars
if repo.Language != "" {
languageBytes[repo.Language] += 1000
}
}
totalBytes := 0
for _, bytes := range languageBytes {
totalBytes += bytes
}
var topLanguages []LanguageStat
for lang, bytes := range languageBytes {
if totalBytes > 0 {
percentage := float64(bytes) / float64(totalBytes) * 100
if percentage >= 1.0 {
color := GetLanguageColor(lang)
topLanguages = append(topLanguages, LanguageStat{
Name: lang,
Percentage: percentage,
Color: color,
Bytes: bytes,
})
}
}
}
for i := 0; i < len(topLanguages); i++ {
for j := i + 1; j < len(topLanguages); j++ {
if topLanguages[i].Percentage < topLanguages[j].Percentage {
topLanguages[i], topLanguages[j] = topLanguages[j], topLanguages[i]
}
}
}
if len(topLanguages) > 8 {
topLanguages = topLanguages[:8]
}
timeSince := time.Now().AddDate(0, -3, 0)
var recentActiveRepos []GitHubRepo
for _, repo := range repos {
if repo.UpdatedAt.After(timeSince) {
hasRecentCommits := p.checkRecentCommits(client, username, repo.Name, timeSince)
if hasRecentCommits {
recentActiveRepos = append(recentActiveRepos, repo)
}
}
}
for i := 0; i < len(recentActiveRepos); i++ {
for j := i + 1; j < len(recentActiveRepos); j++ {
if recentActiveRepos[i].UpdatedAt.Before(recentActiveRepos[j].UpdatedAt) {
recentActiveRepos[i], recentActiveRepos[j] = recentActiveRepos[j], recentActiveRepos[i]
}
}
}
for i := range recentActiveRepos {
if recentActiveRepos[i].LanguagesURL != "" {
recentActiveRepos[i].Languages = p.fetchRepoLanguages(client, recentActiveRepos[i].LanguagesURL)
}
}
commitStats := p.fetchCommitStats(client, username, repos)
totalCommitsLastYear := p.fetchTotalCommits(client, username)
if totalCommitsLastYear > 0 {
commitStats.TotalCommits = totalCommitsLastYear
}
userData.TotalStars = totalStars
userData.TopLanguages = topLanguages
userData.RecentRepos = recentActiveRepos
userData.TotalCommits = commitStats.TotalCommits
userData.CommitStats = commitStats
p.githubData = &userData
p.mutex.Lock()
p.allRepoLanguages = allRepoLanguages
p.mutex.Unlock()
p.hub.Broadcast("github_update", map[string]interface{}{
"repos": userData.PublicRepos,
"followers": userData.Followers,
"stars": userData.TotalStars,
"languages": len(topLanguages),
})
fmt.Println("Updating github info... done")
return nil
}
func (p *CodePlugin) updateWakatimeData(apiKey string) error {
client := &http.Client{Timeout: 30 * time.Second}
weekURL := "https://wakatime.com/api/v1/users/current/stats/last_7_days"
req, err := http.NewRequest("GET", weekURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Basic "+apiKey)
req.Header.Set("User-Agent", "AboutPage/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var weekData struct {
Data struct {
TotalSeconds float64 `json:"total_seconds"`
Languages []struct {
Name string `json:"name"`
TotalSeconds float64 `json:"total_seconds"`
Percent float64 `json:"percent"`
Text string `json:"text"`
} `json:"languages"`
Editors []struct {
Name string `json:"name"`
TotalSeconds float64 `json:"total_seconds"`
Percent float64 `json:"percent"`
Text string `json:"text"`
} `json:"editors"`
OperatingSystems []struct {
Name string `json:"name"`
TotalSeconds float64 `json:"total_seconds"`
Percent float64 `json:"percent"`
Text string `json:"text"`
} `json:"operating_systems"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&weekData); err != nil {
return err
}
allTimeURL := "https://wakatime.com/api/v1/users/current/all_time_since_today"
req, err = http.NewRequest("GET", allTimeURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Basic "+apiKey)
req.Header.Set("User-Agent", "AboutPage/1.0")
resp, err = client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var allTimeData struct {
Data struct {
TotalSeconds float64 `json:"total_seconds"`
Text string `json:"text"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&allTimeData); err != nil {
return err
}
if len(weekData.Data.Languages) == 0 && weekData.Data.TotalSeconds == 0 && p.wakatimeData != nil {
p.hub.Broadcast("wakatime_update", map[string]interface{}{
"week_hours": p.wakatimeData.LastWeek.Seconds / 3600,
"total_text": p.wakatimeData.TotalTime.Text,
"languages": len(p.wakatimeData.Languages),
})
return nil
}
weekHours := weekData.Data.TotalSeconds / 3600
weekTimeText := fmt.Sprintf("%.1f hrs", weekHours)
var languages []WakatimeLanguage
for _, lang := range weekData.Data.Languages {
languages = append(languages, WakatimeLanguage{
Name: lang.Name,
TotalSeconds: lang.TotalSeconds,
Percent: lang.Percent,
Text: lang.Text,
Color: GetLanguageColor(lang.Name),
})
}
wakatimeData := &WakatimeData{
TotalTime: struct {
Seconds float64 `json:"seconds"`
Text string `json:"text"`
}{
Seconds: allTimeData.Data.TotalSeconds,
Text: allTimeData.Data.Text,
},
LastWeek: struct {
Seconds float64 `json:"seconds"`
Text string `json:"text"`
}{
Seconds: weekData.Data.TotalSeconds,
Text: weekTimeText,
},
Languages: languages,
Editors: weekData.Data.Editors,
OperatingSystems: weekData.Data.OperatingSystems,
}
p.wakatimeData = wakatimeData
p.hub.Broadcast("wakatime_update", map[string]interface{}{
"week_hours": weekHours,
"total_text": allTimeData.Data.Text,
"languages": len(weekData.Data.Languages),
})
return nil
}
func (p *CodePlugin) GetSettings() map[string]interface{} {
config := p.storage.GetPluginConfig(p.Name())
return config.Settings
}
func (p *CodePlugin) SetSettings(settings map[string]interface{}) error {
config := p.storage.GetPluginConfig(p.Name())
config.Settings = settings
err := p.storage.SetPluginConfig(p.Name(), config)
if err != nil {
return err
}
p.hub.Broadcast("plugin_update", map[string]interface{}{
"plugin": p.Name(),
"action": "settings_changed",
})
return nil
}
func (p *CodePlugin) getConfigValue(settings map[string]interface{}, key string, defaultValue string) string {
keys := strings.Split(key, ".")
current := settings
for i, k := range keys {
if i == len(keys)-1 {
if value, ok := current[k].(string); ok {
return value
}
return defaultValue
} else {
if next, ok := current[k].(map[string]interface{}); ok {
current = next
} else {
return defaultValue
}
}
}
return defaultValue
}
func (p *CodePlugin) getConfigBool(settings map[string]interface{}, key string, defaultValue bool) bool {
keys := strings.Split(key, ".")
current := settings
for i, k := range keys {
if i == len(keys)-1 {
if value, ok := current[k].(bool); ok {
return value
}
return defaultValue
} else {
if next, ok := current[k].(map[string]interface{}); ok {
current = next
} else {
return defaultValue
}
}
}
return defaultValue
}
func (p *CodePlugin) RenderText(ctx context.Context) (string, error) {
if p.githubData == nil && p.wakatimeData == nil {
return "Code: No data available", nil
}
var parts []string
if p.githubData != nil {
parts = append(parts, fmt.Sprintf("%d repos, %d stars", p.githubData.PublicRepos, p.githubData.TotalStars))
}
if p.wakatimeData != nil {
parts = append(parts, fmt.Sprintf("%s this week", p.wakatimeData.LastWeek.Text))
}
if len(parts) == 0 {
return "Code: No stats available", nil
}
return fmt.Sprintf("Code: %s", strings.Join(parts, ", ")), nil
}
func (p *CodePlugin) fetchRepoLanguages(client *http.Client, languagesURL string) []RepoLanguageBreakdown {
req, err := http.NewRequest("GET", languagesURL, nil)
if err != nil {
return nil
}
config := p.storage.GetPluginConfig(p.Name())
if token := p.getConfigValue(config.Settings, "github.token", ""); token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
var languages map[string]int
if err := json.NewDecoder(resp.Body).Decode(&languages); err != nil {
return nil
}
total := 0
for _, bytes := range languages {
total += bytes
}
if total == 0 {
return nil
}
var breakdown []RepoLanguageBreakdown
for lang, bytes := range languages {
percentage := float64(bytes) / float64(total) * 100
if percentage >= 0.5 {
breakdown = append(breakdown, RepoLanguageBreakdown{
Name: lang,
Percentage: percentage,
Color: GetLanguageColor(lang),
})
}
}
for i := 0; i < len(breakdown); i++ {
for j := i + 1; j < len(breakdown); j++ {
if breakdown[i].Percentage < breakdown[j].Percentage {
breakdown[i], breakdown[j] = breakdown[j], breakdown[i]
}
}
}
return breakdown
}
func (p *CodePlugin) fetchCommitStats(client *http.Client, username string, repos []GitHubRepo) GitHubCommitStats {
stats := GitHubCommitStats{
WeeklyCommits: make([]int, 7),
}
oneWeekAgo := time.Now().AddDate(0, 0, -7)
maxRepos := len(repos)
if maxRepos > 20 {
maxRepos = 20
}
config := p.storage.GetPluginConfig(p.Name())
token := p.getConfigValue(config.Settings, "github.token", "")
for _, repo := range repos[:maxRepos] {
commitsURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits?since=%s&author=%s",
username, repo.Name, oneWeekAgo.Format(time.RFC3339), username)
req, err := http.NewRequest("GET", commitsURL, nil)
if err != nil {
continue
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
continue
}
var commits []struct {
Commit struct {
Author struct {
Date time.Time `json:"date"`
} `json:"author"`
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
for _, commit := range commits {
daysSince := int(time.Since(commit.Commit.Author.Date).Hours() / 24)
if daysSince >= 0 && daysSince < 7 {
stats.WeeklyCommits[6-daysSince]++
}
}
time.Sleep(100 * time.Millisecond)
}
for _, count := range stats.WeeklyCommits {
stats.TotalCommits += count
}
if stats.TotalCommits == 0 {
stats.TotalCommits = p.estimateTotalCommits(client, username, repos[:maxRepos])
}
return stats
}
func (p *CodePlugin) estimateTotalCommits(client *http.Client, username string, repos []GitHubRepo) int {
totalCommits := 0
for _, repo := range repos {
statsURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/stats/contributors", username, repo.Name)
req, err := http.NewRequest("GET", statsURL, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "AboutPage/1.0")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
continue
}
if resp.StatusCode == 202 {
resp.Body.Close()
continue
}
var contributors []struct {
Author struct {
Login string `json:"login"`
} `json:"author"`
Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&contributors); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
for _, contrib := range contributors {
if contrib.Author.Login == username {
totalCommits += contrib.Total
break
}
}
time.Sleep(100 * time.Millisecond)
}
return totalCommits
}
func (p *CodePlugin) fetchTotalCommits(client *http.Client, username string) int {
config := p.storage.GetPluginConfig(p.Name())
token := p.getConfigValue(config.Settings, "github.token", "")
if token == "" {
return 0
}
totalCommits := 0
now := time.Now()
for year := 0; year < 10; year++ {
yearStart := now.AddDate(-year-1, 0, 0)
yearEnd := now.AddDate(-year, 0, 0)
query := fmt.Sprintf(`{
user(login: "%s") {
contributionsCollection(from: "%s", to: "%s") {
contributionCalendar {
totalContributions
}
}
}
}`, username, yearStart.Format(time.RFC3339), yearEnd.Format(time.RFC3339))
requestBody := map[string]string{
"query": query,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
break
}
req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer(jsonData))
if err != nil {
break
}
req.Header.Set("Authorization", "bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "AboutPage/1.0")
resp, err := client.Do(req)
if err != nil {
break
}
if resp.StatusCode != 200 {
resp.Body.Close()
break
}
var result struct {
Data struct {
User struct {
ContributionsCollection struct {
ContributionCalendar struct {
TotalContributions int `json:"totalContributions"`
} `json:"contributionCalendar"`
} `json:"contributionsCollection"`
} `json:"user"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
break
}
resp.Body.Close()
yearContributions := result.Data.User.ContributionsCollection.ContributionCalendar.TotalContributions
totalCommits += yearContributions
if yearContributions == 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
return totalCommits
}
func (p *CodePlugin) GetMetrics() map[string]interface{} {
metrics := map[string]interface{}{
"github_repos": 0,
"github_stars": 0,
"github_commits": 0,
"github_followers": 0,
"github_following": 0,
"github_languages": 0,
"wakatime_hours_7d": 0.0,
"wakatime_hours_total": 0.0,
"wakatime_languages": 0,
"wakatime_editors": 0,
"recent_repos_count": 0,
"top_languages_count": 0,
}
if p.githubData != nil {
metrics["github_repos"] = p.githubData.PublicRepos
metrics["github_stars"] = p.githubData.TotalStars
metrics["github_commits"] = p.githubData.TotalCommits
metrics["github_followers"] = p.githubData.Followers
metrics["github_following"] = p.githubData.Following
metrics["github_languages"] = len(p.githubData.TopLanguages)
metrics["recent_repos_count"] = len(p.githubData.RecentRepos)
metrics["top_languages_count"] = len(p.githubData.TopLanguages)
}
if p.wakatimeData != nil {
metrics["wakatime_hours_7d"] = p.wakatimeData.LastWeek.Seconds / 3600.0
metrics["wakatime_hours_total"] = p.wakatimeData.TotalTime.Seconds / 3600.0
metrics["wakatime_languages"] = len(p.wakatimeData.Languages)
metrics["wakatime_editors"] = len(p.wakatimeData.Editors)
}
p.mutex.RLock()
if len(p.allRepoLanguages) > 0 {
metrics["github_languages"] = len(p.allRepoLanguages)
}
p.mutex.RUnlock()
return metrics
}