about/internal/plugins/code.go
2025-11-02 22:27:09 +03:00

719 lines
19 KiB
Go

package plugins
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"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
lastUpdate time.Time
}
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"`
}
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) getLanguageColor(languageName string) string {
colors := map[string]string{
"Go": "#00ADD8",
"Python": "#3776ab",
"JavaScript": "#f1e05a",
"TypeScript": "#2b7489",
"Java": "#b07219",
"C++": "#f34b7d",
"C": "#555555",
"C#": "#239120",
"Rust": "#dea584",
"HTML": "#e34c26",
"CSS": "#1572B6",
"Shell": "#89e051",
"Bash": "#89e051",
"PHP": "#4F5D95",
"Ruby": "#701516",
"Swift": "#FA7343",
"Kotlin": "#A97BFF",
"Dart": "#00B4AB",
"Vue": "#4FC08D",
"React": "#61DAFB",
"JSON": "#292929",
"XML": "#0060ac",
"YAML": "#cb171e",
"Markdown": "#083fa1",
"SQL": "#e38c00",
"Dockerfile": "#384d54",
"Vim script": "#199f4b",
"Lua": "#000080",
"PowerShell": "#012456",
"Assembly": "#6E4C13",
"SCSS": "#c6538c",
"Less": "#1d365d",
"Sass": "#a53b70",
"Makefile": "#427819",
"CMake": "#DA3434",
"Perl": "#0298c3",
"R": "#198CE7",
"MATLAB": "#e16737",
"Scala": "#c22d40",
"Clojure": "#db5855",
"Elixir": "#6e4a7e",
"Erlang": "#B83998",
"Haskell": "#5e5086",
"F#": "#b845fc",
"OCaml": "#3be133",
"Reason": "#ff5847",
"Elm": "#60B5CC",
"PureScript": "#1D222D",
"CoffeeScript": "#244776",
"LiveScript": "#499886",
"Nim": "#ffc200",
"Crystal": "#000100",
"D": "#ba595e",
"Zig": "#ec915c",
"V": "#4f87c4",
"Odin": "#60AFFE",
"Text": "#383A42",
"Plain Text": "#383A42",
"Other": "#8b949e",
}
if color, exists := colors[languageName]; exists {
return color
}
lowerName := strings.ToLower(languageName)
for lang, color := range colors {
if strings.ToLower(lang) == lowerName {
return color
}
}
return "#8b949e"
}
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)
showCommitGraph := p.getConfigBool(settings, "ui.showCommitGraph", true)
githubUsername := p.getConfigValue(settings, "github.username", "")
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 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 .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>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 .ShowWakatime .WakatimeData .WakatimeData.Languages}}
<div class="code-subsection">
<button class="section-toggle" data-target="wakatime-langs" type="button" aria-expanded="false">
<span class="toggle-icon">▶</span>
<span>This Week</span>
<span class="section-count">({{len .WakatimeData.Languages}} langs)</span>
</button>
<div class="collapsible-content collapsed" 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 .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')" style="cursor: pointer;">
<span class="repo-name">{{.Name}}</span>
<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>`
maxWeeklyCommits := 1
if p.githubData != nil && len(p.githubData.CommitStats.WeeklyCommits) > 0 {
for _, commits := range p.githubData.CommitStats.WeeklyCommits {
if commits > maxWeeklyCommits {
maxWeeklyCommits = commits
}
}
}
data := struct {
SectionTitle string
ShowGitHub bool
ShowWakatime bool
ShowLanguages bool
ShowCommitGraph bool
GitHubData *GitHubUserData
WakatimeData *WakatimeData
MaxWeeklyCommits int
GitHubUsername string
}{
SectionTitle: sectionTitle,
ShowGitHub: showGitHub,
ShowWakatime: showWakatime,
ShowLanguages: showLanguages,
ShowCommitGraph: showCommitGraph,
GitHubData: p.githubData,
WakatimeData: p.wakatimeData,
MaxWeeklyCommits: maxWeeklyCommits,
GitHubUsername: githubUsername,
}
funcMap := template.FuncMap{
"printf": fmt.Sprintf,
"add": func(a, b int) int { 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 },
}
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) 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) updateGitHubData(username string) error {
client := &http.Client{Timeout: 15 * time.Second}
userURL := fmt.Sprintf("https://api.github.com/users/%s", username)
req, err := http.NewRequest("GET", userURL, nil)
if err != nil {
return err
}
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 err
}
defer resp.Body.Close()
var userData GitHubUserData
if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil {
return 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 err
}
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 err
}
defer resp.Body.Close()
var repos []GitHubRepo
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
return err
}
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 := p.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]
}
recentRepos := repos
if len(recentRepos) > 5 {
recentRepos = recentRepos[:5]
}
weeklyCommits := []int{12, 8, 15, 22, 18, 7, 3}
userData.TotalStars = totalStars
userData.TopLanguages = topLanguages
userData.RecentRepos = recentRepos
userData.TotalCommits = 847
userData.CommitStats = GitHubCommitStats{
TotalCommits: 847,
WeeklyCommits: weeklyCommits,
}
p.githubData = &userData
p.hub.Broadcast("github_update", map[string]interface{}{
"repos": userData.PublicRepos,
"followers": userData.Followers,
"stars": userData.TotalStars,
"languages": len(topLanguages),
})
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
}
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: p.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
}