Files
MyClub/internal/services/newsletter_content.go
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

336 lines
11 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"))
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", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
}
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, "<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)
}