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>
480 lines
11 KiB
Go
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
|
|
}
|