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")) 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 "Fotbal Club – přehled", "

Pro vybrané preference nyní nemáme novinky.

" } subject = "Fotbal Club – novinky a zápasy" 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"], ""), } out = append(out, e) } return out } type Match struct { Home, Away, Date, Time, Competition, 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 { if !compSet[strings.ToLower(m.Competition)] { 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 { if !compSet[strings.ToLower(m.Competition)] { 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"], "")) for _, mm := range asList(cm["matches"]) { out = append(out, toMatch(asMap(mm), compName)) } } } // flat matches fallback for _, mm := range asList(m["matches"]) { out = append(out, toMatch(asMap(mm), "")) } return out } func toMatch(m map[string]any, comp 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"], comp)), 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" } layout := "2006-01-02T15:04:05" // try shorter HH:MM format if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) } return parseTime(layout, d+"T"+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, "

Články

") for _, a := range items { fmt.Fprintf(b, "
") if a.Title != "" { fmt.Fprintf(b, "
%s
", htmlEsc(a.Title)) } if a.Excerpt != "" { fmt.Fprintf(b, "
%s
", htmlEsc(a.Excerpt)) } if a.Url != "" { fmt.Fprintf(b, "
Číst více
", a.Url) } fmt.Fprintf(b, "
") } return b.String() } func renderEventsSection(items []Event) string { b := &strings.Builder{} fmt.Fprintf(b, "

Události

") for _, e := range items { fmt.Fprintf(b, "
") fmt.Fprintf(b, "
%s
", htmlEsc(e.Title)) if e.Date != "" || e.Time != "" { fmt.Fprintf(b, "
%s %s
", htmlEsc(e.Date), htmlEsc(e.Time)) } if e.Url != "" { fmt.Fprintf(b, "
Zobrazit
", e.Url) } fmt.Fprintf(b, "
") } return b.String() } func renderMatchesSection(items []Match) string { b := &strings.Builder{} fmt.Fprintf(b, "

Nejbližší zápasy

") for _, m := range items { fmt.Fprintf(b, "
") fmt.Fprintf(b, "
%s vs %s
", htmlEsc(m.Home), htmlEsc(m.Away)) meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition)) fmt.Fprintf(b, "
%s
", htmlEsc(meta)) if m.Link != "" { fmt.Fprintf(b, "
Detail
", m.Link) } fmt.Fprintf(b, "
") } return b.String() } func renderResultsSection(items []Match) string { b := &strings.Builder{} fmt.Fprintf(b, "

Výsledky týdne

") for _, m := range items { fmt.Fprintf(b, "
") fmt.Fprintf(b, "
%s %s %s
", htmlEsc(m.Home), htmlEsc(m.Score), htmlEsc(m.Away)) meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition)) fmt.Fprintf(b, "
%s
", htmlEsc(meta)) if m.Link != "" { fmt.Fprintf(b, "
Zápis
", m.Link) } fmt.Fprintf(b, "
") } return b.String() } // small utils func asList(v any) []any { if v == nil { return nil } if arr, ok := v.([]any); ok { return arr } if m, ok := v.(map[string]any); ok { if a, ok := m["items"]; ok { return asList(a) } if a, ok := m["data"]; ok { return asList(a) } } return nil } func asMap(v any) map[string]any { if v == nil { return map[string]any{} } if m, ok := v.(map[string]any); ok { return m } return map[string]any{} } func str(v any, def string) string { if s, ok := v.(string); ok && s != "" { return s } return def } func urlFromSlug(m map[string]any) string { if s, ok := m["slug"].(string); ok && s != "" { return "/news/" + s } return "" } func htmlEsc(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", "\"", """, "'", "'", ) return r.Replace(s) }