Files
cc-connect/core/cron.go
T
chenhg5andClaude e7d67fc87d feat(i18n): add Traditional Chinese, Japanese and Spanish language support
Added support for three new languages (Traditional Chinese, Japanese and Spanish) throughout the application including:
- Added new language constants and detection logic
- Updated weekday/month names and cron expression formatting
- Added localized strings for all UI messages
- Implemented language-specific formatting for durations
- Extended language switching command
- Added CJK and Spanish character detection
- Consolidated hardcoded strings into i18n system

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-03 19:09:45 +08:00

480 lines
11 KiB
Go

package core
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
// CronJob represents a persisted scheduled task.
type CronJob struct {
ID string `json:"id"`
Project string `json:"project"`
SessionKey string `json:"session_key"`
CronExpr string `json:"cron_expr"`
Prompt string `json:"prompt"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
LastRun time.Time `json:"last_run,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// CronStore persists cron jobs to a JSON file.
type CronStore struct {
path string
mu sync.Mutex
jobs []*CronJob
}
func NewCronStore(dataDir string) (*CronStore, error) {
dir := filepath.Join(dataDir, "crons")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dir, "jobs.json")
s := &CronStore{path: path}
s.load()
return s, nil
}
func (s *CronStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
json.Unmarshal(data, &s.jobs)
}
func (s *CronStore) save() error {
data, err := json.MarshalIndent(s.jobs, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o644)
}
func (s *CronStore) Add(job *CronJob) error {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = append(s.jobs, job)
return s.save()
}
func (s *CronStore) Remove(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, j := range s.jobs {
if j.ID == id {
s.jobs = append(s.jobs[:i], s.jobs[i+1:]...)
s.save()
return true
}
}
return false
}
func (s *CronStore) SetEnabled(id string, enabled bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, j := range s.jobs {
if j.ID == id {
j.Enabled = enabled
s.save()
return true
}
}
return false
}
func (s *CronStore) MarkRun(id string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, j := range s.jobs {
if j.ID == id {
j.LastRun = time.Now()
if err != nil {
j.LastError = err.Error()
} else {
j.LastError = ""
}
s.save()
return
}
}
}
func (s *CronStore) List() []*CronJob {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*CronJob, len(s.jobs))
copy(out, s.jobs)
return out
}
func (s *CronStore) ListByProject(project string) []*CronJob {
s.mu.Lock()
defer s.mu.Unlock()
var out []*CronJob
for _, j := range s.jobs {
if j.Project == project {
out = append(out, j)
}
}
return out
}
func (s *CronStore) ListBySessionKey(sessionKey string) []*CronJob {
s.mu.Lock()
defer s.mu.Unlock()
var out []*CronJob
for _, j := range s.jobs {
if j.SessionKey == sessionKey {
out = append(out, j)
}
}
return out
}
func (s *CronStore) Get(id string) *CronJob {
s.mu.Lock()
defer s.mu.Unlock()
for _, j := range s.jobs {
if j.ID == id {
return j
}
}
return nil
}
// CronScheduler runs cron jobs by injecting synthetic messages into engines.
type CronScheduler struct {
store *CronStore
cron *cron.Cron
engines map[string]*Engine // project name → engine
mu sync.RWMutex
entries map[string]cron.EntryID // job ID → cron entry
}
func NewCronScheduler(store *CronStore) *CronScheduler {
return &CronScheduler{
store: store,
cron: cron.New(),
engines: make(map[string]*Engine),
entries: make(map[string]cron.EntryID),
}
}
func (cs *CronScheduler) RegisterEngine(name string, e *Engine) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.engines[name] = e
}
func (cs *CronScheduler) Start() error {
jobs := cs.store.List()
for _, job := range jobs {
if job.Enabled {
if err := cs.scheduleJob(job); err != nil {
slog.Warn("cron: failed to schedule job", "id", job.ID, "error", err)
}
}
}
cs.cron.Start()
slog.Info("cron: scheduler started", "jobs", len(jobs))
return nil
}
func (cs *CronScheduler) Stop() {
cs.cron.Stop()
}
func (cs *CronScheduler) AddJob(job *CronJob) error {
if _, err := cron.ParseStandard(job.CronExpr); err != nil {
return fmt.Errorf("invalid cron expression %q: %w", job.CronExpr, err)
}
if err := cs.store.Add(job); err != nil {
return err
}
if job.Enabled {
return cs.scheduleJob(job)
}
return nil
}
func (cs *CronScheduler) RemoveJob(id string) bool {
cs.mu.Lock()
if entryID, ok := cs.entries[id]; ok {
cs.cron.Remove(entryID)
delete(cs.entries, id)
}
cs.mu.Unlock()
return cs.store.Remove(id)
}
func (cs *CronScheduler) EnableJob(id string) error {
if !cs.store.SetEnabled(id, true) {
return fmt.Errorf("job %q not found", id)
}
job := cs.store.Get(id)
if job != nil {
return cs.scheduleJob(job)
}
return nil
}
func (cs *CronScheduler) DisableJob(id string) error {
if !cs.store.SetEnabled(id, false) {
return fmt.Errorf("job %q not found", id)
}
cs.mu.Lock()
if entryID, ok := cs.entries[id]; ok {
cs.cron.Remove(entryID)
delete(cs.entries, id)
}
cs.mu.Unlock()
return nil
}
func (cs *CronScheduler) Store() *CronStore {
return cs.store
}
// NextRun returns the next scheduled run time for a job, or zero if not scheduled.
func (cs *CronScheduler) NextRun(jobID string) time.Time {
cs.mu.RLock()
entryID, ok := cs.entries[jobID]
cs.mu.RUnlock()
if !ok {
return time.Time{}
}
for _, e := range cs.cron.Entries() {
if e.ID == entryID {
return e.Next
}
}
return time.Time{}
}
func (cs *CronScheduler) scheduleJob(job *CronJob) error {
cs.mu.Lock()
defer cs.mu.Unlock()
// Remove existing schedule if any
if old, ok := cs.entries[job.ID]; ok {
cs.cron.Remove(old)
}
jobID := job.ID
entryID, err := cs.cron.AddFunc(job.CronExpr, func() {
cs.executeJob(jobID)
})
if err != nil {
return err
}
cs.entries[jobID] = entryID
return nil
}
func (cs *CronScheduler) executeJob(jobID string) {
job := cs.store.Get(jobID)
if job == nil || !job.Enabled {
return
}
cs.mu.RLock()
engine, ok := cs.engines[job.Project]
cs.mu.RUnlock()
if !ok {
slog.Error("cron: project not found", "job", jobID, "project", job.Project)
cs.store.MarkRun(jobID, fmt.Errorf("project %q not found", job.Project))
return
}
slog.Info("cron: executing job", "id", jobID, "project", job.Project, "prompt", truncateStr(job.Prompt, 60))
err := engine.ExecuteCronJob(job)
cs.store.MarkRun(jobID, err)
if err != nil {
slog.Error("cron: job failed", "id", jobID, "error", err)
} else {
slog.Info("cron: job completed", "id", jobID)
}
}
func GenerateCronID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
var cronWeekdays = map[Language][7]string{
LangEnglish: {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
LangChinese: {"周日", "周一", "周二", "周三", "周四", "周五", "周六"},
LangTraditionalChinese: {"週日", "週一", "週二", "週三", "週四", "週五", "週六"},
LangJapanese: {"日曜", "月曜", "火曜", "水曜", "木曜", "金曜", "土曜"},
LangSpanish: {"domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"},
}
var cronMonths = map[Language][13]string{
LangEnglish: {"", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},
LangChinese: {"", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},
LangTraditionalChinese: {"", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},
LangJapanese: {"", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},
LangSpanish: {"", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"},
}
func cronLangNames(lang Language) (weekdays [7]string, months [13]string) {
if w, ok := cronWeekdays[lang]; ok {
weekdays = w
} else {
weekdays = cronWeekdays[LangEnglish]
}
if m, ok := cronMonths[lang]; ok {
months = m
} else {
months = cronMonths[LangEnglish]
}
return
}
func isZhLikeLang(lang Language) bool {
return lang == LangChinese || lang == LangTraditionalChinese || lang == LangJapanese
}
// CronExprToHuman converts a standard 5-field cron expression to a human-readable string.
func CronExprToHuman(expr string, lang Language) string {
fields := strings.Fields(expr)
if len(fields) != 5 {
return expr
}
minute, hour, dom, month, dow := fields[0], fields[1], fields[2], fields[3], fields[4]
weekdays, months := cronLangNames(lang)
cjk := isZhLikeLang(lang)
var parts []string
// Weekday
if dow != "*" {
if d, err := fmt.Sscanf(dow, "%d", new(int)); err == nil && d == 1 {
var n int
fmt.Sscanf(dow, "%d", &n)
if n >= 0 && n <= 6 {
if cjk {
parts = append(parts, weekdays[n])
} else {
parts = append(parts, "Every "+weekdays[n])
}
}
} else {
parts = append(parts, "weekday("+dow+")")
}
}
// Month
if month != "*" {
if m, err := fmt.Sscanf(month, "%d", new(int)); err == nil && m == 1 {
var n int
fmt.Sscanf(month, "%d", &n)
if n >= 1 && n <= 12 {
parts = append(parts, months[n])
}
}
}
// Day of month
if dom != "*" {
if cjk {
parts = append(parts, dom+"日")
} else {
parts = append(parts, "day "+dom)
}
}
// Time
if hour != "*" && minute != "*" {
parts = append(parts, fmt.Sprintf("%s:%s", padZero(hour), padZero(minute)))
} else if hour != "*" {
if cjk {
parts = append(parts, hour+"時")
} else {
parts = append(parts, "hour "+hour)
}
} else if minute != "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
parts = append(parts, "每小时第"+minute+"分")
case LangJapanese:
parts = append(parts, "毎時"+minute+"分")
default:
parts = append(parts, "minute "+minute+" of every hour")
}
}
// Frequency hint
if dow == "*" && month == "*" && dom == "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
return "每天 " + strings.Join(parts, " ")
case LangJapanese:
return "毎日 " + strings.Join(parts, " ")
case LangSpanish:
return "Diario a las " + strings.Join(parts, " ")
default:
return "Daily at " + strings.Join(parts, " ")
}
}
if dow != "*" && month == "*" && dom == "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
return "每" + strings.Join(parts, " ")
case LangJapanese:
return "毎" + strings.Join(parts, " ")
default:
return strings.Join(parts, " at ")
}
}
if dom != "*" && month == "*" && dow == "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
return "每月" + strings.Join(parts, " ")
case LangJapanese:
return "毎月" + strings.Join(parts, " ")
case LangSpanish:
return "Mensual, " + strings.Join(parts, ", ")
default:
return "Monthly, " + strings.Join(parts, ", ")
}
}
if cjk {
return strings.Join(parts, " ")
}
return strings.Join(parts, ", ")
}
func padZero(s string) string {
if len(s) == 1 {
return "0" + s
}
return s
}