package main import ( "flag" "fmt" "log" "sort" "time" "git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) type projectTimestampRow struct { UUID string UpdatedAt time.Time } type updatePlanRow struct { UUID string Code string Variant string LocalUpdatedAt time.Time ServerUpdatedAt time.Time } func main() { defaultLocalDBPath, err := appstate.ResolveDBPath("") if err != nil { log.Fatalf("failed to resolve default local SQLite path: %v", err) } localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)") apply := flag.Bool("apply", false, "apply updates to local SQLite (default is preview only)") flag.Parse() local, err := localdb.New(*localDBPath) if err != nil { log.Fatalf("failed to initialize local database: %v", err) } defer local.Close() if !local.HasSettings() { log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.") } dsn, err := local.GetDSN() if err != nil { log.Fatalf("failed to build DSN from SQLite settings: %v", err) } db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("failed to connect to MariaDB: %v", err) } serverRows, err := loadServerProjects(db) if err != nil { log.Fatalf("failed to load server projects: %v", err) } localProjects, err := local.GetAllProjects(true) if err != nil { log.Fatalf("failed to load local projects: %v", err) } plan := buildUpdatePlan(localProjects, serverRows) printPlan(plan, *apply) if !*apply || len(plan) == 0 { return } updated := 0 for i := range plan { project, err := local.GetProjectByUUID(plan[i].UUID) if err != nil { log.Printf("skip %s: load local project: %v", plan[i].UUID, err) continue } project.UpdatedAt = plan[i].ServerUpdatedAt if err := local.SaveProjectPreservingUpdatedAt(project); err != nil { log.Printf("skip %s: save local project: %v", plan[i].UUID, err) continue } updated++ } log.Printf("updated %d local project timestamps", updated) } func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) { var rows []projectTimestampRow if err := db.Model(&models.Project{}). Select("uuid, updated_at"). Find(&rows).Error; err != nil { return nil, err } out := make(map[string]time.Time, len(rows)) for _, row := range rows { if row.UUID == "" { continue } out[row.UUID] = row.UpdatedAt } return out, nil } func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow { plan := make([]updatePlanRow, 0) for i := range localProjects { project := localProjects[i] serverUpdatedAt, ok := serverRows[project.UUID] if !ok { continue } if project.UpdatedAt.Equal(serverUpdatedAt) { continue } plan = append(plan, updatePlanRow{ UUID: project.UUID, Code: project.Code, Variant: project.Variant, LocalUpdatedAt: project.UpdatedAt, ServerUpdatedAt: serverUpdatedAt, }) } sort.Slice(plan, func(i, j int) bool { if plan[i].Code != plan[j].Code { return plan[i].Code < plan[j].Code } return plan[i].Variant < plan[j].Variant }) return plan } func printPlan(plan []updatePlanRow, apply bool) { mode := "preview" if apply { mode = "apply" } log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan)) if len(plan) == 0 { log.Printf("no local project timestamps need resync") return } for _, row := range plan { variant := row.Variant if variant == "" { variant = "main" } log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt)) } if !apply { fmt.Println("Re-run with -apply to write server updated_at into local SQLite.") } } func formatStamp(value time.Time) string { if value.IsZero() { return "zero" } return value.Format(time.RFC3339) }