Files
MyClub/internal/services/newsletter_content.go
T
Tomas Dvorak 823fabee02 de day #74
2025-10-28 22:38:27 +01:00

366 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), "<p>Pro vybrané preference nyní nemáme novinky.</p>"
}
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, "<h2 style='margin:0 0 12px 0;'>Články</h2>")
for _, a := range items {
fmt.Fprintf(b, "<div style='margin:10px 0;padding:10px;border-left:4px solid #2b6cb0;background:#f8fafc;'>")
if a.Title != "" { fmt.Fprintf(b, "<div style='font-weight:600;'>%s</div>", htmlEsc(a.Title)) }
if a.Excerpt != "" { fmt.Fprintf(b, "<div style='color:#4a5568;font-size:14px;'>%s</div>", htmlEsc(a.Excerpt)) }
if a.Url != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Číst více</a></div>", a.Url) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
func renderEventsSection(items []Event) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Události</h2>")
for _, e := range items {
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #3182ce;background:#ebf8ff;'>")
fmt.Fprintf(b, "<div style='font-weight:600;'>%s</div>", htmlEsc(e.Title))
if e.Date != "" || e.Time != "" {
fmt.Fprintf(b, "<div style='color:#2c5282;font-size:14px;'>%s %s</div>", htmlEsc(e.Date), htmlEsc(e.Time))
}
if e.Url != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Zobrazit</a></div>", e.Url) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
func renderMatchesSection(items []Match) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Nejbližší zápasy</h2>")
for _, m := range items {
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #38a169;background:#f0fff4;'>")
fmt.Fprintf(b, "<div style='font-weight:600;'>%s vs %s</div>", htmlEsc(m.Home), htmlEsc(m.Away))
meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition))
fmt.Fprintf(b, "<div style='color:#276749;font-size:14px;'>%s</div>", htmlEsc(meta))
if m.Link != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Detail</a></div>", m.Link) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
func renderResultsSection(items []Match) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Výsledky týdne</h2>")
for _, m := range items {
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #d69e2e;background:#fffbeb;'>")
fmt.Fprintf(b, "<div style='font-weight:600;'>%s %s %s</div>", 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, "<div style='color:#975a16;font-size:14px;'>%s</div>", htmlEsc(meta))
if m.Link != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Zápis</a></div>", m.Link) }
fmt.Fprintf(b, "</div>")
}
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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}