package main import ( "bytes" "context" "database/sql" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "view/platform" "view/service" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/services/notifications" ) type App struct { ctx context.Context store *Store bootstrapFile string bootstrap BootstrapStatus mu sync.Mutex tasks map[string]context.CancelFunc taskResults map[string]string aiStreams map[int64]context.CancelFunc shell *shellState notifier *notifications.NotificationService syncing atomic.Bool syncOnline atomic.Bool syncMu sync.Mutex syncLastErr string // 全局文件存储配置的实时读取缓存(见 filestorage.go currentFileStorage)。 fsMu sync.Mutex fsCfg FileStorageConfig fsCfgAt time.Time // 管理员敏感操作二次验证(内存,进程内有效) stepupMu sync.Mutex stepupToken string stepupExp time.Time } func NewApp() *App { return &App{tasks: map[string]context.CancelFunc{}, taskResults: map[string]string{}, aiStreams: map[int64]context.CancelFunc{}} } // emit 向前端广播事件;纯测试环境(无 Wails 应用实例)下静默忽略。 func (a *App) emit(name string, data any) { if app := application.Get(); app != nil { app.Event.Emit(name, data) } } // ServiceStartup 由 Wails v3 在应用启动时调用,执行数据库引导流程。 func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { a.startup(ctx) a.RefreshShell() go a.runAutoUpdateLoop() go a.runAppUpdateLoop() go a.runReminderLoop() go a.runSyncLoop() go a.runTeamDigestLoop() go a.runActivityPingLoop() return nil } // ServiceShutdown 由 Wails v3 在应用退出时调用。 func (a *App) ServiceShutdown() error { a.shutdown(context.Background()) return nil } func (a *App) startup(ctx context.Context) { a.ctx = ctx defaultPath, e := defaultDBPath() if e != nil { a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()} return } a.bootstrapFile, e = bootstrapPath() if e != nil { a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, ErrorCode: "BOOTSTRAP_PATH_FAILED", ErrorDetail: e.Error()} return } c, e := readBootstrap(a.bootstrapFile) if e != nil { if os.IsNotExist(e) { if _, se := os.Stat(defaultPath); se == nil { c = BootstrapConfig{DatabasePath: defaultPath, Initialized: true} _ = writeBootstrap(a.bootstrapFile, c) } else { a.bootstrap = BootstrapStatus{State: BootstrapSetup, DefaultPath: defaultPath, DatabasePath: defaultPath} return } } else { a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: defaultPath, ErrorCode: "BOOTSTRAP_INVALID", ErrorDetail: e.Error()} return } } if _, e = os.Stat(c.DatabasePath); e != nil { code := "DB_FILE_UNREADABLE" if os.IsNotExist(e) { code = "DB_FILE_MISSING" } a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: c.DatabasePath, ErrorCode: code, ErrorDetail: e.Error()} return } s, e := OpenStore(c.DatabasePath) if e != nil { a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e) return } a.store = s a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: defaultPath, DatabasePath: c.DatabasePath} a.applyPackagedSyncConfig() a.store.Log("info", "系统", "应用程序启动", "") a.store.Log("info", "数据库", "桌面运行时已连接", s.path) } func (a *App) GetBootstrapStatus() BootstrapStatus { return a.bootstrap } func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) { if strings.TrimSpace(defaultPath) == "" { defaultPath = a.bootstrap.DefaultPath } return application.Get().Dialog.SaveFile(). SetMessage("Initialize 年糕崽崽 PMS database"). SetDirectory(filepath.Dir(defaultPath)). SetFilename(filepath.Base(defaultPath)). AddFilter("SQLite Database", "*.db"). PromptForSingleSelection() } func (a *App) InitializeDatabase(path string) (BootstrapStatus, error) { a.mu.Lock() defer a.mu.Unlock() s, e := createValidatedStore(path) if e != nil { a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, e) return a.bootstrap, e } if a.store != nil { _ = a.store.db.Close() } a.store = s if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: s.path, Initialized: true}); e != nil { _ = s.db.Close() a.store = nil a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, coded("BOOTSTRAP_WRITE_FAILED", e)) return a.bootstrap, e } a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: s.path} a.applyPackagedSyncConfig() a.store.Log("info", "数据库", "数据库初始化成功", s.path) return a.bootstrap, nil } func (a *App) RetryDatabase() (BootstrapStatus, error) { return a.InitializeDatabase(a.bootstrap.DatabasePath) } func (a *App) shutdown(context.Context) { if a.store != nil && a.store.db != nil { _ = a.store.db.Close() } } func (a *App) ready() error { if a.store == nil { return errors.New("DATABASE_NOT_READY") } return nil } func (a *App) SelectDirectory() (string, error) { if e := a.ready(); e != nil { return "", e } return application.Get().Dialog.OpenFile(). SetTitle(a.localized("选择代码目录", "Select code directory")). CanChooseDirectories(true). CanChooseFiles(false). CanCreateDirectories(true). PromptForSingleSelection() } // ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。 func (a *App) ListWSLDistros() ([]string, error) { return platform.ListWSLDistros(a.ctx) } // SelectWSLDirectory 从指定发行版根目录打开原生目录选择器。 func (a *App) SelectWSLDirectory(distro string) (string, error) { if strings.TrimSpace(distro) == "" { return "", errors.New("WSL_DISTRO_REQUIRED") } root := "\\\\wsl.localhost\\" + distro + "\\" return application.Get().Dialog.OpenFile(). SetTitle(a.localized("选择 WSL 代码目录", "Select WSL code directory")). SetDirectory(root). CanChooseDirectories(true). CanChooseFiles(false). PromptForSingleSelection() } func (a *App) localized(zh, en string) string { if a.store != nil { if s, e := a.store.Settings(); e == nil && s.Locale == "en" { return en } } return zh } func (a *App) SelectDatabaseFile() (string, error) { return application.Get().Dialog.SaveFile(). SetMessage("Select database location"). SetFilename("code-count.db"). AddFilter("SQLite Database", "*.db"). PromptForSingleSelection() } func (a *App) ListProjects() ([]Project, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListProjects(0) } func (a *App) ListProjectsByGroup(groupID int64) ([]Project, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListProjects(groupID) } func (a *App) ListProjectGroups() ([]ProjectGroup, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListProjectGroups() } func (a *App) SaveProjectGroup(id int64, name string) (ProjectGroup, error) { if e := a.ready(); e != nil { return ProjectGroup{}, e } g, e := a.store.SaveProjectGroup(id, name) if e == nil { a.store.Log("info", "project", "Project group saved", g.Name) } return g, e } func (a *App) DeleteProjectGroup(id int64) error { if e := a.ready(); e != nil { return e } e := a.store.DeleteProjectGroup(id) if e == nil { a.store.Log("info", "project", "Project group deleted", fmt.Sprint(id)) } return e } func (a *App) GetProject(id int64) (Project, error) { if e := a.ready(); e != nil { return Project{}, e } return a.store.GetProject(id) } func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) { if e := a.ready(); e != nil { return Project{}, e } p, e := a.store.SaveProject(id, in) if e == nil { a.store.Log("info", "项目", "项目保存成功", p.Path) a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表 } else { a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error()) } return p, e } func (a *App) ReportClientError(category, message, detail string) { if a.store != nil { a.store.Log("error", category, message, detail) } } func (a *App) DeleteProject(id int64) error { if e := a.ready(); e != nil { return e } e := a.store.DeleteProject(id) if e == nil { a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id)) a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表 } return e } func (a *App) GetDashboard() (Dashboard, error) { if e := a.ready(); e != nil { return Dashboard{}, e } return a.store.Dashboard(0) } func (a *App) GetDashboardByGroup(groupID int64) (Dashboard, error) { if e := a.ready(); e != nil { return Dashboard{}, e } return a.store.Dashboard(groupID) } func (a *App) GetStructure(id int64) (StructureStats, error) { if e := a.ready(); e != nil { return StructureStats{}, e } return a.store.Structure(id) } func (a *App) GetProjectInsights(id int64) (ProjectInsights, error) { if e := a.ready(); e != nil { return ProjectInsights{}, e } x, e := a.store.Insights(id) if e == nil { return x, nil } if errors.Is(e, sql.ErrNoRows) { return a.RefreshProjectInsights(id) } return ProjectInsights{}, e } func (a *App) RefreshProjectInsights(id int64) (ProjectInsights, error) { if e := a.ready(); e != nil { return ProjectInsights{}, e } p, e := a.store.GetProject(id) if e != nil { return ProjectInsights{}, e } structure, e := a.store.Structure(id) if e != nil { return ProjectInsights{}, e } git, _ := a.store.GitStats(id) x, e := (service.InsightService{}).Analyze(a.ctx, p, structure, git) if e != nil { a.store.Log("error", "项目检查", "深度检查失败", p.Name+" | "+e.Error()) return x, e } if e = a.store.ReplaceInsights(id, x); e != nil { a.store.Log("error", "项目检查", "保存检查结果失败", p.Name+" | "+e.Error()) return x, e } a.store.Log("info", "项目检查", "深度检查完成", fmt.Sprintf("%s | %d 分 | %d 个问题", p.Name, x.HealthScore, len(x.Issues))) return x, nil } func (a *App) GetGitStats(id int64) (GitStats, error) { if e := a.ready(); e != nil { return GitStats{}, e } return a.store.GitStats(id) } // GetGitDiagnostics 分步检查 Git/WSL 状态,用于解释 Git 统计为空或失败的原因。 func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) { if e := a.ready(); e != nil { return GitDiagnostics{}, e } p, e := a.store.GetProject(id) if e != nil { return GitDiagnostics{}, e } d := (GitAnalyzer{}).Diagnostics(a.ctx, p.Path, "") if d.Available { a.store.Log("info", "Git分析", "Git 诊断完成", p.Name+" | "+d.WorkspaceBranch) } else { a.store.Log("warning", "Git分析", "Git 诊断失败", p.Name+" | "+d.ErrorCode+" | "+d.Detail) } return d, nil } // GetGitStatsForRef 只改变统计视图,不执行 checkout。 func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) { if e := a.ready(); e != nil { return GitStats{}, e } p, e := a.store.GetProject(id) if e != nil { return GitStats{}, e } a.store.Log("info", "Git分析", "查询分支统计", p.Name+" | "+ref) g, e := (GitAnalyzer{}).AnalyzeRef(a.ctx, p.Path, ref) if e != nil { a.store.Log("error", "Git分析", "查询分支统计失败", e.Error()) } return g, e } // GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。 func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) { if e := a.ready(); e != nil { return GitCommitDetail{}, e } p, e := a.store.GetProject(id) if e != nil { return GitCommitDetail{}, e } return (GitAnalyzer{}).CommitDetail(a.ctx, p.Path, hash) } // CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。 func (a *App) CheckoutBranch(id int64, ref string) (CheckoutResult, error) { if e := a.ready(); e != nil { return CheckoutResult{}, e } p, e := a.store.GetProject(id) if e != nil { return CheckoutResult{}, e } result, e := (GitAnalyzer{}).CheckoutBranch(a.ctx, p.Path, ref) if e != nil { a.store.Log("error", "Git分析", "切换分支失败", p.Name+" | "+ref+" | "+e.Error()) return result, e } a.store.Log("info", "Git分析", "切换分支成功", p.Name+" | "+result.Branch) return result, nil } func (a *App) GetRules() ([]ExclusionRule, error) { if e := a.ready(); e != nil { return nil, e } return a.store.Rules() } func (a *App) AddRule(pattern, category string) (ExclusionRule, error) { if e := a.ready(); e != nil { return ExclusionRule{}, e } return a.store.AddRule(pattern, category) } func (a *App) DeleteRule(id int64) error { if e := a.ready(); e != nil { return e } return a.store.DeleteRule(id) } func (a *App) GetLogs(level string) ([]LogEntry, error) { if e := a.ready(); e != nil { return nil, e } return a.store.Logs(level) } func (a *App) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) { if e := a.ready(); e != nil { return LogPage{}, e } return a.store.SearchLogs(query, level, category, offset, limit) } func (a *App) GetLogCategories() ([]string, error) { if e := a.ready(); e != nil { return nil, e } return a.store.LogCategories() } // previewMaxBytes 是文件预览读取上限(1MB),超出部分截断。 const previewMaxBytes = 1 << 20 // ReadProjectFile 读取项目内的相对路径文件用于预览:限制大小、检测二进制、阻止路径穿越。 func (a *App) ReadProjectFile(projectID int64, relPath string) (FilePreview, error) { if e := a.ready(); e != nil { return FilePreview{}, e } p, e := a.store.GetProject(projectID) if e != nil { return FilePreview{}, e } rel := strings.TrimSpace(relPath) if rel == "" { return FilePreview{}, errors.New("FILE_PATH_REQUIRED") } desc, e := platform.ResolvePath(p.Path) if e != nil { return FilePreview{}, e } root := desc.WindowsPath full := filepath.Join(root, filepath.FromSlash(rel)) if back, e := filepath.Rel(root, full); e != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) { return FilePreview{}, errors.New("FILE_PATH_INVALID") } st, e := os.Stat(full) if e != nil { if os.IsNotExist(e) { return FilePreview{}, coded("FILE_NOT_FOUND", e) } return FilePreview{}, coded("FILE_READ_FAILED", e) } if st.IsDir() { return FilePreview{}, errors.New("FILE_IS_DIRECTORY") } out := FilePreview{Path: filepath.ToSlash(rel), Name: st.Name(), Extension: strings.ToLower(filepath.Ext(st.Name())), Size: st.Size()} f, e := os.Open(full) if e != nil { return out, coded("FILE_READ_FAILED", e) } defer f.Close() buf := make([]byte, min(st.Size(), previewMaxBytes)) n, e := io.ReadFull(f, buf) if e != nil && !errors.Is(e, io.ErrUnexpectedEOF) && !errors.Is(e, io.EOF) { return out, coded("FILE_READ_FAILED", e) } buf = buf[:n] out.Truncated = st.Size() > previewMaxBytes probe := buf if len(probe) > 8192 { probe = probe[:8192] } if bytes.IndexByte(probe, 0) >= 0 { out.Binary = true return out, nil } out.Content = string(buf) if out.Content != "" { out.Lines = strings.Count(out.Content, "\n") + 1 } return out, nil } func (a *App) ClearLogs() error { if e := a.ready(); e != nil { return e } return a.store.ClearLogs() } func (a *App) GetSettings() (AppSettings, error) { if e := a.ready(); e != nil { return AppSettings{}, e } return a.store.Settings() } func (a *App) SaveSettings(x AppSettings) error { if e := a.ready(); e != nil { return e } if e := a.store.SaveSettings(x); e != nil { return e } a.RefreshShell() return nil } func (a *App) ClearData(mode string, projectID int64) error { if e := a.ready(); e != nil { return e } e := a.store.ClearData(mode, projectID) if e == nil { a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表 } return e } // MigrateDatabase 仅把本机 SQLite 文件搬到新路径并切换连接。 // 不做任何 schema DDL;云端 MySQL 建表/升级只允许通过仓库根目录 init.sql(或 tools/applyinit)执行。 func (a *App) MigrateDatabase(target string) error { if e := a.ready(); e != nil { return e } if target == "" { return errors.New("PATH_REQUIRED") } target = filepath.Clean(target) if target == a.store.path { return nil } if e := os.MkdirAll(filepath.Dir(target), 0755); e != nil { return e } tmp := target + ".tmp" _ = os.Remove(tmp) _, _ = a.store.db.Exec(`PRAGMA wal_checkpoint(FULL)`) src, e := os.Open(a.store.path) if e != nil { return e } defer src.Close() dst, e := os.Create(tmp) if e != nil { return e } if _, e = dst.ReadFrom(src); e != nil { dst.Close() os.Remove(tmp) return e } _ = dst.Sync() dst.Close() check, e := OpenStore(tmp) if e != nil { os.Remove(tmp) return e } var result string e = check.db.QueryRow(`PRAGMA integrity_check`).Scan(&result) check.db.Close() if e != nil || result != "ok" { os.Remove(tmp) return errors.New("DATABASE_INTEGRITY_FAILED") } if e = os.Rename(tmp, target); e != nil { return e } next, e := OpenStore(target) if e != nil { return e } old := a.store a.store = next _ = old.db.Close() if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: target, Initialized: true}); e != nil { return coded("BOOTSTRAP_WRITE_FAILED", e) } a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: target} a.applyPackagedSyncConfig() a.store.Log("info", "数据库", "数据库迁移成功", target) return nil } func (a *App) StartAnalysis(projectID int64, kind string) (string, error) { if e := a.ready(); e != nil { return "", e } p, e := a.store.GetProject(projectID) if e != nil { return "", e } taskID := fmt.Sprintf("%d-%s", projectID, kind) a.mu.Lock() if _, ok := a.tasks[taskID]; ok { a.mu.Unlock() return "", errors.New("TASK_ALREADY_RUNNING") } ctx, cancel := context.WithCancel(a.ctx) a.tasks[taskID] = cancel a.mu.Unlock() go func() { finalStage := "completed" defer func() { if recovered := recover(); recovered != nil { detail := fmt.Sprintf("%v", recovered) finalStage = "error" a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail) a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}}) } a.mu.Lock() if a.taskResults != nil { a.taskResults[taskID] = finalStage } delete(a.tasks, taskID) a.mu.Unlock() }() emit := func(stage string, progress int, messageKey string) { a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}}) } emit("start", 2, "task.start") a.store.Log("info", "代码分析", "开始分析项目", p.Name) var err error if kind == "all" || kind == "code" { rules, _ := a.store.Rules() var ls []LanguageStat var fs []FileEntry ls, fs, err = (Scanner{}).Analyze(ctx, p.Path, rules, func(n int, s string) { emit(s, n, s) }) if err == nil { err = a.store.ReplaceScan(projectID, ls, fs) } } if err == nil && (kind == "all" || kind == "git") { emit("git", 80, "task.git") scopeAll := false if st, se := a.store.Settings(); se == nil && st.GitScope == "all" { scopeAll = true } var g GitStats var incremental bool g, incremental, err = (GitAnalyzer{}).AnalyzeWithOptions(ctx, p.Path, service.GitAnalyzeOptions{ AllBranches: scopeAll, SinceHash: a.store.LatestCommitHash(projectID), }) if err == nil { if incremental { err = a.store.MergeGit(projectID, g) a.store.Log("info", "Git分析", "增量分析完成", fmt.Sprintf("%s | 新增 %d 个提交", p.Name, len(g.Commits))) } else { err = a.store.ReplaceGit(projectID, g) } } else if kind == "all" && (err.Error() == "NOT_GIT_REPOSITORY" || err.Error() == "GIT_NOT_INSTALLED") { a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error()) err = nil } } if err != nil { level, stage := "error", "error" if errors.Is(err, context.Canceled) { level, stage = "warning", "cancelled" } finalStage = stage a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error()) emit(stage, 100, "task.failed") return } a.store.Log("info", "代码分析", "项目分析完成", p.Name) emit("completed", 100, "task.completed") // 数据就绪后异步生成各模块的 AI 分析(未配置 Key 时静默跳过)。 // 代码数据变化会使洞察过期,先重算再交给 AI 解读。 switch kind { case "code": go func() { a.RefreshProjectInsights(projectID) a.generateAISummaries(projectID, "project", "structure", "insights") }() case "git": go a.generateAISummaries(projectID, "git") default: go func() { a.RefreshProjectInsights(projectID) a.generateAISummaries(projectID, "project", "git", "structure", "insights") }() } }() return taskID, nil } func (a *App) CancelAnalysis(taskID string) error { a.mu.Lock() defer a.mu.Unlock() if c, ok := a.tasks[taskID]; ok { c() return nil } return errors.New("TASK_NOT_FOUND") } func (a *App) StartBatchAnalysis() ([]string, error) { return a.StartBatchAnalysisByGroup(0) } func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) { if e := a.ready(); e != nil { return nil, e } ps, e := a.store.ListProjects(groupID) if e != nil { return nil, e } ids := make([]string, 0, len(ps)) for _, p := range ps { ids = append(ids, fmt.Sprintf("%d-all", p.ID)) } go func() { summary := BatchSummary{Total: len(ps), Failures: []string{}} for _, p := range ps { tid, e := a.StartAnalysis(p.ID, "all") if e != nil { summary.Failed++ summary.Failures = append(summary.Failures, p.Name+": "+e.Error()) continue } for { a.mu.Lock() _, running := a.tasks[tid] a.mu.Unlock() if !running { break } select { case <-a.ctx.Done(): return case <-time.After(100 * time.Millisecond): } } a.mu.Lock() result := a.taskResults[tid] delete(a.taskResults, tid) a.mu.Unlock() switch result { case "cancelled": summary.Cancelled++ case "error": summary.Failed++ summary.Failures = append(summary.Failures, p.Name) default: summary.Completed++ } } a.store.Log("info", "代码分析", "批量统计完成", fmt.Sprintf("共 %d 个 | 成功 %d | 失败 %d | 取消 %d | %s", summary.Total, summary.Completed, summary.Failed, summary.Cancelled, strings.Join(summary.Failures, ";"))) a.emit("batch:done", summary) a.notifyBatchDone(summary) }() return ids, nil } // notifyBatchDone 将批量统计结果写入消息中心,失败时附系统通知。 func (a *App) notifyBatchDone(s BatchSummary) { locale := "zh-CN" if st, e := a.store.Settings(); e == nil { locale = st.Locale } title := "批量统计完成" body := fmt.Sprintf("共 %d 个项目:成功 %d,失败 %d,取消 %d", s.Total, s.Completed, s.Failed, s.Cancelled) if locale == "en" { title = "Batch analysis finished" body = fmt.Sprintf("%d projects: %d succeeded, %d failed, %d cancelled", s.Total, s.Completed, s.Failed, s.Cancelled) } if len(s.Failures) > 0 { sep := ";失败:" if locale == "en" { sep = "; failed: " } body += sep + strings.Join(s.Failures, ", ") } a.pushMessage("analysis", title, body, "", 0, s.Failed > 0) }