mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
366 lines
13 KiB
Go
366 lines
13 KiB
Go
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(
|
||
"&", "&",
|
||
"<", "<",
|
||
">", ">",
|
||
"\"", """,
|
||
"'", "'",
|
||
)
|
||
return r.Replace(s)
|
||
}
|