package services import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // NewsletterPrefs represents the normalized subset of subscriber preferences // we care about when generating automated content. type NewsletterPrefs struct { Email string `json:"email"` ContentTypes []string `json:"content_types"` // blogs, matches, scores, events Competitions []string `json:"competitions"` // FACR codes Frequency string `json:"frequency"` // daily, weekly, matchday } // BuildNewsletterDigest builds an HTML digest string and subject based on cached JSON // and subscriber preferences. It is robust to cache shape differences. func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject string, html string) { // Normalize content types want := make(map[string]bool) for _, c := range prefs.ContentTypes { want[strings.ToLower(strings.TrimSpace(c))] = true } if len(want) == 0 { // default to blogs + matches want["blogs"] = true want["matches"] = true } // Load caches (best-effort) art := readJSON(filepath.Join(cacheDir, "articles.json")) ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json")) facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.json")) // Club name for subject personalization (fallback to default) set := readJSON(filepath.Join(cacheDir, "settings.json")) sm := asMap(set) clubName := strings.TrimSpace(str(sm["club_name"], "")) if clubName == "" { clubName = "Fotbal Club" } sections := make([]string, 0, 4) // Blogs/articles if want["blogs"] { items := pickArticles(art, 6) if len(items) > 0 { sections = append(sections, renderArticlesSection(items)) } } // Upcoming events if want["events"] || want["matches"] { items := pickUpcomingEvents(ev, 6) if len(items) > 0 { sections = append(sections, renderEventsSection(items)) } } // Matches (from FACR club info) if want["matches"] { items := pickUpcomingMatchesFromFACR(facr, prefs.Competitions, 6) if len(items) > 0 { sections = append(sections, renderMatchesSection(items)) } } // Scores/results digest (past week) from FACR club info if want["scores"] { items := pickRecentResultsFromFACR(facr, prefs.Competitions, 8, 7*24*time.Hour) if len(items) > 0 { sections = append(sections, renderResultsSection(items)) } } if len(sections) == 0 { return fmt.Sprintf("%s – přehled", clubName), "
Pro vybrané preference nyní nemáme novinky.
" } subject = fmt.Sprintf("%s – novinky a zápasy", clubName) html = strings.Join(sections, "\n\n") return subject, html } // Helpers func readJSON(path string) any { b, err := os.ReadFile(path) if err != nil || len(b) == 0 { return nil } var v any _ = json.Unmarshal(b, &v) return v } type Article struct { Title, Url, Image, Excerpt string; Date time.Time } func pickArticles(v any, n int) []Article { // Accept shapes: {items:[]}, {data:[]}, [] list := asList(v) out := make([]Article, 0, n) for i, it := range list { if i >= n { break } m := asMap(it) a := Article{ Title: str(m["title"], str(m["name"], "Článek")), Url: str(m["url"], urlFromSlug(m)), Image: str(m["imageUrl"], str(m["image_url"], str(m["image"], ""))), Excerpt: str(m["excerpt"], str(m["summary"], "")), } out = append(out, a) } return out } type Event struct { Title, Date, Time, Url string } func pickUpcomingEvents(v any, n int) []Event { list := asList(v) out := make([]Event, 0, n) for i, it := range list { if i >= n { break } m := asMap(it) e := Event{ Title: str(m["title"], str(m["name"], "Událost")), Date: str(m["date"], ""), Time: str(m["time"], ""), Url: str(m["url"], ""), } if e.Date == "" { st := str(m["start_time"], "") if st != "" { if tm, err := time.Parse(time.RFC3339, st); err == nil { lt := tm.In(time.Local) e.Date = lt.Format("2006-01-02") e.Time = lt.Format("15:04") } } } out = append(out, e) } return out } type Match struct { Home, Away, Date, Time, Competition, CompCode, Link, Score string } func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match { compSet := make(map[string]bool) for _, c := range competitions { compSet[strings.TrimSpace(strings.ToLower(c))] = true } now := time.Now() list := facrAllMatches(v) out := make([]Match, 0, n) for _, m := range list { ts := parseDateTimeISO(m.Date, m.Time) if ts.IsZero() || ts.Before(now) { continue } if len(compSet) > 0 { nameKey := strings.ToLower(strings.TrimSpace(m.Competition)) codeKey := strings.ToLower(strings.TrimSpace(m.CompCode)) if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue } } out = append(out, m) if len(out) >= n { break } } return out } func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.Duration) []Match { compSet := make(map[string]bool) for _, c := range competitions { compSet[strings.TrimSpace(strings.ToLower(c))] = true } now := time.Now() from := now.Add(-window) list := facrAllMatches(v) out := make([]Match, 0, n) for _, m := range list { ts := parseDateTimeISO(m.Date, m.Time) if ts.IsZero() || ts.After(now) || ts.Before(from) { continue } // treat as result if score like "2:1" exists if m.Score == "" || !strings.Contains(m.Score, ":") { continue } if len(compSet) > 0 { nameKey := strings.ToLower(strings.TrimSpace(m.Competition)) codeKey := strings.ToLower(strings.TrimSpace(m.CompCode)) if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue } } out = append(out, m) } // Show latest first sort.Slice(out, func(i, j int) bool { ti := parseDateTimeISO(out[i].Date, out[i].Time) tj := parseDateTimeISO(out[j].Date, out[j].Time) return ti.After(tj) }) if len(out) > n { out = out[:n] } return out } // FACR helpers (robust to various shapes) func facrAllMatches(v any) []Match { out := []Match{} if v == nil { return out } m := asMap(v) // competitions array if comps, ok := m["competitions"]; ok { for _, c := range asList(comps) { cm := asMap(c) compName := str(cm["name"], str(cm["code"], "")) compCode := str(cm["code"], "") for _, mm := range asList(cm["matches"]) { out = append(out, toMatch(asMap(mm), compName, compCode)) } } } // flat matches fallback for _, mm := range asList(m["matches"]) { out = append(out, toMatch(asMap(mm), "", "")) } return out } func toMatch(m map[string]any, compName, compCode string) Match { dt := str(m["date_time"], "") var date, tm string if dt != "" && strings.Contains(dt, " ") { parts := strings.SplitN(dt, " ", 2) date, tm = parts[0], parts[1] } else { date = str(m["date"], "") tm = str(m["time"], "") } return Match{ Home: str(m["home"], ""), Away: str(m["away"], ""), Date: date, Time: tm, Competition: str(m["competition"], str(m["competition_name"], compName)), CompCode: str(m["competition_code"], compCode), Link: str(m["facr_link"], str(m["report_url"], "#")), Score: str(m["score"], ""), } } func parseDateTimeISO(d, t string) time.Time { if d == "" { return time.Time{} } if t == "" { t = "00:00" } if strings.Contains(d, ".") { if len(t) == 5 { if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm } } if tm := parseTime("02.01.2006 15:04:05", d+" "+t); !tm.IsZero() { return tm } if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm } } if len(t) == 5 { if tm := parseTime("2006-01-02T15:04", d+"T"+t); !tm.IsZero() { return tm } return parseTime("2006-01-02 15:04", d+" "+t) } if tm := parseTime("2006-01-02T15:04:05", d+"T"+t); !tm.IsZero() { return tm } return parseTime("2006-01-02 15:04:05", d+" "+t) } func parseTime(layout, s string) time.Time { if tm, err := time.Parse(layout, s); err == nil { return tm } // Try local if tm, err := time.ParseInLocation(layout, s, time.Local); err == nil { return tm } return time.Time{} } // Render helpers (inline styles for email) func renderArticlesSection(items []Article) string { b := &strings.Builder{} fmt.Fprintf(b, "