mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-05 11:12:56 +00:00
upload
This commit is contained in:
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
Reference in New Issue
Block a user