初始化

This commit is contained in:
李琦
2026-08-11 19:07:05 +08:00
commit d2aeb13a09
94 changed files with 8704 additions and 0 deletions

739
database.go Normal file
View File

@@ -0,0 +1,739 @@
package main
import (
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type Store struct {
mu sync.RWMutex
db *sql.DB
path string
}
func defaultDBPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
dir = filepath.Join(dir, "CodeCount")
if err = os.MkdirAll(dir, 0755); err != nil {
return "", err
}
return filepath.Join(dir, "code-count.db"), nil
}
func OpenStore(path string) (*Store, error) {
if path == "" {
var err error
path, err = defaultDBPath()
if err != nil {
return nil, err
}
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
s := &Store{db: db, path: path}
if err = s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) migrate() error {
stmts := []string{
`PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`,
`CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS project_groups(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS projects(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', group_id INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS analysis_runs(id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, error TEXT, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS language_stats(project_id INTEGER NOT NULL, name TEXT NOT NULL, files INTEGER NOT NULL, code INTEGER NOT NULL, comments INTEGER NOT NULL, blanks INTEGER NOT NULL, PRIMARY KEY(project_id,name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS file_entries(project_id INTEGER NOT NULL, path TEXT NOT NULL, name TEXT NOT NULL, extension TEXT NOT NULL, size INTEGER NOT NULL, is_dir INTEGER NOT NULL, parent TEXT NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS git_commits(project_id INTEGER NOT NULL, hash TEXT NOT NULL, author TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, committed_at TEXT NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,hash), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS git_refs(project_id INTEGER NOT NULL, name TEXT NOT NULL, hash TEXT NOT NULL, kind TEXT NOT NULL, is_current INTEGER NOT NULL, PRIMARY KEY(project_id,name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS commit_refs(project_id INTEGER NOT NULL, commit_hash TEXT NOT NULL, ref_name TEXT NOT NULL, PRIMARY KEY(project_id,commit_hash,ref_name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS contributors(project_id INTEGER NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, commits INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,email), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS project_insights(project_id INTEGER PRIMARY KEY, health_score INTEGER NOT NULL, generated_at TEXT NOT NULL, high INTEGER NOT NULL, medium INTEGER NOT NULL, low INTEGER NOT NULL, todo_count INTEGER NOT NULL, long_files INTEGER NOT NULL, large_files INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS insight_issues(project_id INTEGER NOT NULL, idx INTEGER NOT NULL, severity TEXT NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, path TEXT NOT NULL DEFAULT '', line INTEGER NOT NULL DEFAULT 0, suggestion TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', PRIMARY KEY(project_id,idx), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS exclusion_rules(id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL UNIQUE, category TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS app_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, category TEXT NOT NULL, message TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
if ok, err := s.columnExists("projects", "group_id"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE projects ADD COLUMN group_id INTEGER NOT NULL DEFAULT 1`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
_, _ = s.db.Exec(`INSERT OR IGNORE INTO project_groups(id,name,created_at,updated_at) VALUES(1,'My Projects',?,?)`, time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339))
_, _ = s.db.Exec(`UPDATE projects SET group_id=1 WHERE group_id IS NULL OR group_id=0`)
defaults := map[string][]string{"general": {".git", ".idea", ".vscode", "node_modules", "dist", "coverage", "*.min.js", "*.min.css", ".DS_Store"}, "php": {"vendor", "storage/framework", "bootstrap/cache", ".phpunit.cache"}, "go": {"go.sum"}, "vue": {".nuxt", ".next", ".output", "unpackage"}}
for category, patterns := range defaults {
for _, p := range patterns {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,1)`, p, category)
}
}
_, _ = s.db.Exec(`INSERT OR IGNORE INTO settings(key,value) VALUES('theme','dark'),('locale','zh-CN'),('gitScope','current'),('autoRefresh','true'),('glassOpacity','55'),('loadingStyle','fullscreen-orbit')`)
_, _ = s.db.Exec(`UPDATE settings SET value='fullscreen-orbit' WHERE key='loadingStyle' AND value IN ('bar','fullscreen') AND NOT EXISTS(SELECT 1 FROM settings WHERE key='loadingStyleSet' AND value='true')`)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)`, time.Now().Format(time.RFC3339))
// 旧版本曾写入英文日志迁移时转换已知固定文案Git 提交消息等用户内容不做修改。
translations := map[string]string{"Application started": "应用程序启动", "Desktop runtime connected": "桌面运行时已连接", "Project saved": "项目保存成功", "Project save failed": "项目保存失败", "Project deleted": "项目删除成功", "Analysis completed": "项目分析完成", "Analysis failed": "项目分析失败", "Database initialized": "数据库初始化成功", "Database migrated": "数据库迁移成功"}
for en, zh := range translations {
_, _ = s.db.Exec(`UPDATE app_logs SET message=? WHERE message=?`, zh, en)
}
_, _ = s.db.Exec(`UPDATE app_logs SET category='系统' WHERE category='system'`)
_, _ = s.db.Exec(`UPDATE app_logs SET category='数据库' WHERE category='database'`)
_, _ = s.db.Exec(`UPDATE app_logs SET category='项目' WHERE category='project'`)
_, _ = s.db.Exec(`UPDATE app_logs SET category='代码分析' WHERE category='analysis'`)
_, _ = s.db.Exec(`UPDATE app_logs SET category='Git分析' WHERE category='git'`)
return nil
}
func (s *Store) columnExists(table, column string) (bool, error) {
rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull, pk int
var dflt sql.NullString
if err = rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func normalizePath(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", errors.New("PATH_REQUIRED")
}
a, e := filepath.Abs(p)
if e != nil {
return "", e
}
a = filepath.Clean(a)
if st, e := os.Stat(a); e != nil {
if os.IsNotExist(e) {
return "", coded("PROJECT_PATH_NOT_FOUND", e)
}
return "", coded("PROJECT_PATH_UNREADABLE", e)
} else if !st.IsDir() {
return "", coded("PROJECT_PATH_NOT_DIRECTORY", nil)
}
d, e := os.Open(a)
if e != nil {
return "", coded("PROJECT_PATH_UNREADABLE", e)
}
_, e = d.Readdirnames(1)
_ = d.Close()
if e != nil && !errors.Is(e, io.EOF) {
return "", coded("PROJECT_PATH_UNREADABLE", e)
}
return a, nil
}
func now() string { return time.Now().Format(time.RFC3339) }
func (s *Store) ListProjectGroups() ([]ProjectGroup, error) {
rows, e := s.db.Query(`SELECT id,name,created_at,updated_at FROM project_groups ORDER BY id=1 DESC, name COLLATE NOCASE`)
if e != nil {
return nil, e
}
defer rows.Close()
out := []ProjectGroup{}
for rows.Next() {
var g ProjectGroup
if e = rows.Scan(&g.ID, &g.Name, &g.CreatedAt, &g.UpdatedAt); e != nil {
return nil, e
}
out = append(out, g)
}
return out, rows.Err()
}
func (s *Store) SaveProjectGroup(id int64, name string) (ProjectGroup, error) {
name = strings.TrimSpace(name)
if name == "" {
return ProjectGroup{}, errors.New("PROJECT_GROUP_NAME_REQUIRED")
}
t := now()
if id == 0 {
r, e := s.db.Exec(`INSERT INTO project_groups(name,created_at,updated_at) VALUES(?,?,?)`, name, t, t)
if e != nil {
if strings.Contains(strings.ToLower(e.Error()), "unique") {
return ProjectGroup{}, errors.New("PROJECT_GROUP_DUPLICATE")
}
return ProjectGroup{}, e
}
id, _ = r.LastInsertId()
} else {
if id == 1 {
return ProjectGroup{}, errors.New("PROJECT_GROUP_DEFAULT_READONLY")
}
_, e := s.db.Exec(`UPDATE project_groups SET name=?,updated_at=? WHERE id=?`, name, t, id)
if e != nil {
if strings.Contains(strings.ToLower(e.Error()), "unique") {
return ProjectGroup{}, errors.New("PROJECT_GROUP_DUPLICATE")
}
return ProjectGroup{}, e
}
}
var g ProjectGroup
e := s.db.QueryRow(`SELECT id,name,created_at,updated_at FROM project_groups WHERE id=?`, id).Scan(&g.ID, &g.Name, &g.CreatedAt, &g.UpdatedAt)
return g, e
}
func (s *Store) DeleteProjectGroup(id int64) error {
if id == 1 {
return errors.New("PROJECT_GROUP_DEFAULT_READONLY")
}
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e = tx.Exec(`UPDATE projects SET group_id=1,updated_at=? WHERE group_id=?`, now(), id); e != nil {
return e
}
if _, e = tx.Exec(`DELETE FROM project_groups WHERE id=?`, id); e != nil {
return e
}
return tx.Commit()
}
func (s *Store) ListProjects(groupID int64) ([]Project, error) {
q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id`
args := []any{}
if groupID > 0 {
q += ` WHERE p.group_id=?`
args = append(args, groupID)
}
q += ` ORDER BY p.updated_at DESC`
rows, e := s.db.Query(q, args...)
if e != nil {
return nil, e
}
out := []Project{}
for rows.Next() {
var p Project
if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt); e != nil {
rows.Close()
return nil, e
}
out = append(out, p)
}
if e = rows.Err(); e != nil {
rows.Close()
return nil, e
}
if e = rows.Close(); e != nil {
return nil, e
}
for i := range out {
out[i].Stats, _ = s.projectStats(out[i].ID)
out[i].Languages, _ = s.languages(out[i].ID)
}
return out, nil
}
func (s *Store) GetProject(id int64) (Project, error) {
var p Project
e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id WHERE p.id=?`, id).Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt)
if e != nil {
return p, e
}
p.Stats, _ = s.projectStats(id)
p.Languages, _ = s.languages(id)
return p, nil
}
func (s *Store) SaveProject(id int64, in ProjectInput) (Project, error) {
p, e := normalizePath(in.Path)
if e != nil {
return Project{}, e
}
name := strings.TrimSpace(in.Name)
if name == "" {
name = filepath.Base(p)
}
groupID := in.GroupID
if groupID <= 0 {
groupID = 1
}
var exists int
if e = s.db.QueryRow(`SELECT COUNT(*) FROM project_groups WHERE id=?`, groupID).Scan(&exists); e != nil {
return Project{}, e
}
if exists == 0 {
return Project{}, errors.New("PROJECT_GROUP_NOT_FOUND")
}
t := now()
if id == 0 {
r, e := s.db.Exec(`INSERT INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`, name, p, in.Description, groupID, t, t)
if e != nil {
if strings.Contains(strings.ToLower(e.Error()), "unique") {
return Project{}, coded("PROJECT_PATH_DUPLICATE", e)
}
return Project{}, coded("PROJECT_SAVE_FAILED", e)
}
id, _ = r.LastInsertId()
} else {
_, e = s.db.Exec(`UPDATE projects SET name=?,path=?,description=?,group_id=?,updated_at=? WHERE id=?`, name, p, in.Description, groupID, t, id)
if e != nil {
return Project{}, e
}
}
return s.GetProject(id)
}
func (s *Store) DeleteProject(id int64) error {
_, e := s.db.Exec(`DELETE FROM projects WHERE id=?`, id)
return e
}
func (s *Store) projectStats(id int64) (ProjectStats, error) {
var x ProjectStats
e := s.db.QueryRow(`SELECT COALESCE(SUM(code+comments+blanks),0),COALESCE(SUM(code),0),COALESCE(SUM(comments),0),COALESCE(SUM(blanks),0),COALESCE(SUM(files),0) FROM language_stats WHERE project_id=?`, id).Scan(&x.TotalLines, &x.CodeLines, &x.CommentLines, &x.BlankLines, &x.FileCount)
if e != nil {
return x, e
}
_ = s.db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(added),0),COALESCE(SUM(deleted),0),COUNT(DISTINCT email) FROM git_commits WHERE project_id=?`, id).Scan(&x.CommitCount, &x.AddedLines, &x.DeletedLines, &x.ContributorCount)
_ = s.db.QueryRow(`SELECT COALESCE(MAX(completed_at),'') FROM analysis_runs WHERE project_id=? AND status='completed'`, id).Scan(&x.LastAnalyzed)
return x, nil
}
func (s *Store) languages(id int64) ([]LanguageStat, error) {
r, e := s.db.Query(`SELECT name,files,code,comments,blanks FROM language_stats WHERE project_id=? ORDER BY code DESC`, id)
if e != nil {
return nil, e
}
defer r.Close()
o := []LanguageStat{}
for r.Next() {
var x LanguageStat
if e = r.Scan(&x.Name, &x.Files, &x.Code, &x.Comments, &x.Blanks); e != nil {
return nil, e
}
o = append(o, x)
}
return o, r.Err()
}
func (s *Store) Dashboard(groupID int64) (Dashboard, error) {
var d Dashboard
if groupID > 0 {
e := s.db.QueryRow(`SELECT COUNT(*),COALESCE((SELECT SUM(ls.code+ls.comments+ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),COALESCE((SELECT COUNT(*) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0) FROM projects WHERE group_id=?`, groupID, groupID, groupID).Scan(&d.Projects, &d.TotalLines, &d.Commits)
return d, e
}
e := s.db.QueryRow(`SELECT COUNT(*),COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats),0),COALESCE((SELECT COUNT(*) FROM git_commits),0) FROM projects`).Scan(&d.Projects, &d.TotalLines, &d.Commits)
return d, e
}
func (s *Store) ReplaceScan(id int64, langs []LanguageStat, files []FileEntry) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e = tx.Exec(`DELETE FROM language_stats WHERE project_id=?`, id); e != nil {
return e
}
if _, e = tx.Exec(`DELETE FROM file_entries WHERE project_id=?`, id); e != nil {
return e
}
for _, x := range langs {
if _, e = tx.Exec(`INSERT INTO language_stats VALUES(?,?,?,?,?,?)`, id, x.Name, x.Files, x.Code, x.Comments, x.Blanks); e != nil {
return e
}
}
for _, x := range files {
if _, e = tx.Exec(`INSERT INTO file_entries VALUES(?,?,?,?,?,?,?)`, id, x.Path, x.Name, x.Extension, x.Size, boolInt(x.IsDir), x.Parent); e != nil {
return e
}
}
_, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'scan','completed',?,?)`, id, now(), now())
if e != nil {
return e
}
return tx.Commit()
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func (s *Store) Structure(id int64) (StructureStats, error) {
r, e := s.db.Query(`SELECT path,name,extension,size,is_dir,parent FROM file_entries WHERE project_id=? ORDER BY path`, id)
if e != nil {
return StructureStats{}, e
}
defer r.Close()
x := StructureStats{Files: []FileEntry{}, LargeFiles: []FileEntry{}}
fm := map[string]*FolderStat{}
em := map[string]*ExtensionStat{}
for r.Next() {
var f FileEntry
var d int
if e = r.Scan(&f.Path, &f.Name, &f.Extension, &f.Size, &d, &f.Parent); e != nil {
return x, e
}
f.IsDir = d == 1
x.Files = append(x.Files, f)
if f.IsDir {
x.TotalDirs++
continue
}
x.TotalFiles++
x.TotalSize += f.Size
if f.Size >= 1024*1024 {
x.LargeFiles = append(x.LargeFiles, f)
}
top := strings.Split(filepath.ToSlash(f.Path), "/")[0]
if top == f.Name {
top = "/"
}
if fm[top] == nil {
fm[top] = &FolderStat{Name: top}
}
fm[top].Files++
fm[top].Size += f.Size
ext := f.Extension
if ext == "" {
ext = "(no extension)"
}
if em[ext] == nil {
em[ext] = &ExtensionStat{Extension: ext}
}
em[ext].Files++
em[ext].Size += f.Size
}
for _, v := range fm {
x.Folders = append(x.Folders, *v)
}
for _, v := range em {
x.Extensions = append(x.Extensions, *v)
}
sort.Slice(x.Folders, func(i, j int) bool { return x.Folders[i].Size > x.Folders[j].Size })
sort.Slice(x.Extensions, func(i, j int) bool { return x.Extensions[i].Size > x.Extensions[j].Size })
sort.Slice(x.LargeFiles, func(i, j int) bool { return x.LargeFiles[i].Size > x.LargeFiles[j].Size })
return x, r.Err()
}
func (s *Store) Rules() ([]ExclusionRule, error) {
r, e := s.db.Query(`SELECT id,pattern,category,builtin FROM exclusion_rules ORDER BY category,builtin DESC,pattern`)
if e != nil {
return nil, e
}
defer r.Close()
o := []ExclusionRule{}
for r.Next() {
var x ExclusionRule
var b int
if e = r.Scan(&x.ID, &x.Pattern, &x.Category, &b); e != nil {
return nil, e
}
x.Builtin = b == 1
o = append(o, x)
}
return o, r.Err()
}
func (s *Store) AddRule(pattern, category string) (ExclusionRule, error) {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return ExclusionRule{}, errors.New("PATTERN_REQUIRED")
}
if category == "" {
category = "custom"
}
r, e := s.db.Exec(`INSERT INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,0)`, pattern, category)
if e != nil {
return ExclusionRule{}, e
}
id, _ := r.LastInsertId()
return ExclusionRule{ID: id, Pattern: pattern, Category: category}, nil
}
func (s *Store) DeleteRule(id int64) error {
r, e := s.db.Exec(`DELETE FROM exclusion_rules WHERE id=? AND builtin=0`, id)
if e != nil {
return e
}
n, _ := r.RowsAffected()
if n == 0 {
return errors.New("BUILTIN_RULE")
}
return nil
}
func (s *Store) Log(level, category, message, detail string) {
_, _ = s.db.Exec(`INSERT INTO app_logs(level,category,message,detail,created_at) VALUES(?,?,?,?,?)`, level, category, message, detail, now())
}
func (s *Store) Logs(level string) ([]LogEntry, error) {
q := `SELECT id,level,category,message,detail,created_at FROM app_logs`
args := []any{}
if level != "" && level != "all" {
q += ` WHERE level=?`
args = append(args, level)
}
q += ` ORDER BY id DESC LIMIT 500`
r, e := s.db.Query(q, args...)
if e != nil {
return nil, e
}
defer r.Close()
o := []LogEntry{}
for r.Next() {
var x LogEntry
if e = r.Scan(&x.ID, &x.Level, &x.Category, &x.Message, &x.Detail, &x.CreatedAt); e != nil {
return nil, e
}
o = append(o, x)
}
return o, r.Err()
}
func (s *Store) ClearLogs() error { _, e := s.db.Exec(`DELETE FROM app_logs`); return e }
func (s *Store) Settings() (AppSettings, error) {
x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit"}
r, e := s.db.Query(`SELECT key,value FROM settings`)
if e != nil {
return x, e
}
defer r.Close()
for r.Next() {
var k, v string
_ = r.Scan(&k, &v)
switch k {
case "theme":
x.Theme = v
case "locale":
x.Locale = v
case "gitScope":
x.GitScope = v
case "autoRefresh":
x.AutoRefresh = v == "true"
case "glassOpacity":
if n, err := strconv.Atoi(v); err == nil && n >= 30 && n <= 75 {
x.GlassOpacity = n
}
case "loadingStyle":
if validLoadingStyle(v) {
x.LoadingStyle = v
}
}
}
return x, nil
}
func (s *Store) SaveSettings(x AppSettings) error {
if x.GlassOpacity < 30 || x.GlassOpacity > 75 {
x.GlassOpacity = 55
}
if !validLoadingStyle(x.LoadingStyle) {
x.LoadingStyle = "fullscreen-orbit"
}
vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true"}
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
for k, v := range vals {
if _, e = tx.Exec(`INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, k, v); e != nil {
return e
}
}
return tx.Commit()
}
func validLoadingStyle(v string) bool {
switch v {
case "bar", "fullscreen", "fullscreen-orbit", "fullscreen-grid", "fullscreen-warp":
return true
default:
return false
}
}
func (s *Store) ClearData(mode string, projectID int64) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "commit_refs", "contributors", "project_insights", "insight_issues", "analysis_runs"}
if mode == "all" {
tables = append(tables, "projects")
}
for _, t := range tables {
q := "DELETE FROM " + t
args := []any{}
if mode == "project" && t != "projects" {
q += " WHERE project_id=?"
args = append(args, projectID)
}
if _, e = tx.Exec(q, args...); e != nil {
return e
}
}
if mode == "all" {
if _, e = tx.Exec(`DELETE FROM project_groups WHERE id<>1`); e != nil {
return e
}
}
return tx.Commit()
}
func (s *Store) ReplaceGit(id int64, g GitStats) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
for _, t := range []string{"git_commits", "git_refs", "commit_refs", "contributors"} {
if _, e = tx.Exec("DELETE FROM "+t+" WHERE project_id=?", id); e != nil {
return e
}
}
for _, c := range g.Commits {
if _, e = tx.Exec(`INSERT INTO git_commits VALUES(?,?,?,?,?,?,?,?)`, id, c.Hash, c.Author, c.Email, c.Message, c.Date, c.Added, c.Deleted); e != nil {
return e
}
}
for _, r := range g.Refs {
if _, e = tx.Exec(`INSERT INTO git_refs VALUES(?,?,?,?,?)`, id, r.Name, r.Hash, r.Kind, boolInt(r.Current)); e != nil {
return e
}
}
for _, c := range g.Contributors {
if _, e = tx.Exec(`INSERT INTO contributors VALUES(?,?,?,?,?,?)`, id, c.Email, c.Name, c.Commits, c.Added, c.Deleted); e != nil {
return e
}
}
_, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now())
if e != nil {
return e
}
return tx.Commit()
}
func (s *Store) GitStats(id int64) (GitStats, error) {
g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}}
r, e := s.db.Query(`SELECT hash,author,email,message,committed_at,added,deleted FROM git_commits WHERE project_id=? ORDER BY committed_at DESC`, id)
if e != nil {
return g, e
}
for r.Next() {
var c GitCommit
if e = r.Scan(&c.Hash, &c.Author, &c.Email, &c.Message, &c.Date, &c.Added, &c.Deleted); e != nil {
r.Close()
return g, e
}
g.Commits = append(g.Commits, c)
g.Added += c.Added
g.Deleted += c.Deleted
}
r.Close()
rr, e := s.db.Query(`SELECT name,hash,kind,is_current FROM git_refs WHERE project_id=? ORDER BY is_current DESC,kind,name`, id)
if e != nil {
return g, e
}
for rr.Next() {
var x GitRef
var c int
_ = rr.Scan(&x.Name, &x.Hash, &x.Kind, &c)
x.Current = c == 1
if x.Current {
g.CurrentBranch = x.Name
}
g.Refs = append(g.Refs, x)
}
rr.Close()
g.WorkspaceBranch = g.CurrentBranch
g.ViewRef = g.CurrentBranch
cr, e := s.db.Query(`SELECT name,email,commits,added,deleted FROM contributors WHERE project_id=? ORDER BY commits DESC`, id)
if e != nil {
return g, e
}
for cr.Next() {
var x Contributor
_ = cr.Scan(&x.Name, &x.Email, &x.Commits, &x.Added, &x.Deleted)
g.Contributors = append(g.Contributors, x)
}
cr.Close()
g.CommitCount = int64(len(g.Commits))
g.ContributorCount = int64(len(g.Contributors))
cut := time.Now().AddDate(-1, 0, 0)
hm := map[string]int64{}
for _, c := range g.Commits {
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) {
hm[t.Local().Format("2006-01-02")]++
}
}
for d, c := range hm {
g.Heatmap = append(g.Heatmap, HeatDay{Date: d, Count: c})
}
sort.Slice(g.Heatmap, func(i, j int) bool { return g.Heatmap[i].Date < g.Heatmap[j].Date })
return g, nil
}
func (s *Store) Insights(id int64) (ProjectInsights, error) {
x := ProjectInsights{ProjectID: id, Issues: []InsightIssue{}}
e := s.db.QueryRow(`SELECT health_score,generated_at,high,medium,low,todo_count,long_files,large_files FROM project_insights WHERE project_id=?`, id).Scan(&x.HealthScore, &x.GeneratedAt, &x.Summary.High, &x.Summary.Medium, &x.Summary.Low, &x.Summary.TodoCount, &x.Summary.LongFiles, &x.Summary.LargeFiles)
if e != nil {
return x, e
}
r, e := s.db.Query(`SELECT severity,type,title,detail,path,line,suggestion,evidence FROM insight_issues WHERE project_id=? ORDER BY idx`, id)
if e != nil {
return x, e
}
defer r.Close()
for r.Next() {
var issue InsightIssue
if e = r.Scan(&issue.Severity, &issue.Type, &issue.Title, &issue.Detail, &issue.Path, &issue.Line, &issue.Suggestion, &issue.Evidence); e != nil {
return x, e
}
x.Issues = append(x.Issues, issue)
}
return x, nil
}
func (s *Store) ReplaceInsights(id int64, x ProjectInsights) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e = tx.Exec(`DELETE FROM insight_issues WHERE project_id=?`, id); e != nil {
return e
}
if _, e = tx.Exec(`DELETE FROM project_insights WHERE project_id=?`, id); e != nil {
return e
}
if _, e = tx.Exec(`INSERT INTO project_insights VALUES(?,?,?,?,?,?,?,?,?)`, id, x.HealthScore, x.GeneratedAt, x.Summary.High, x.Summary.Medium, x.Summary.Low, x.Summary.TodoCount, x.Summary.LongFiles, x.Summary.LargeFiles); e != nil {
return e
}
for i, issue := range x.Issues {
if _, e = tx.Exec(`INSERT INTO insight_issues VALUES(?,?,?,?,?,?,?,?,?,?)`, id, i, issue.Severity, issue.Type, issue.Title, issue.Detail, issue.Path, issue.Line, issue.Suggestion, issue.Evidence); e != nil {
return e
}
}
return tx.Commit()
}