This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
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)
}