This commit is contained in:
Tomas Dvorak
2025-10-28 22:38:27 +01:00
parent 3d621e2187
commit 823fabee02
106 changed files with 9011 additions and 3930 deletions
+155
View File
@@ -0,0 +1,155 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"gorm.io/gorm"
)
type logoAPIResponse struct {
LogoURLSVG string `json:"logo_url_svg"`
LogoURLPNG string `json:"logo_url_png"`
LogoURL string `json:"logo_url"`
}
func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
cid := strings.TrimSpace(clubID)
if cid == "" {
return "", fmt.Errorf("empty club id")
}
baseUpload := config.AppConfig.UploadDir
if strings.TrimSpace(baseUpload) == "" {
baseUpload = "./uploads"
}
destDir := filepath.Join(baseUpload, "logos", "club", cid)
_ = os.MkdirAll(destDir, 0o755)
checkExisting := func() (string, bool) {
exts := []string{".svg", ".png", ".jpg", ".jpeg", ".webp"}
for _, ext := range exts {
p := filepath.Join(destDir, "club-logo"+ext)
if fi, err := os.Stat(p); err == nil && fi.Size() > 0 {
pub := "/uploads/" + filepath.ToSlash(filepath.Join("logos", "club", cid, "club-logo"+ext))
return pub, true
}
}
return "", false
}
if url, ok := checkExisting(); ok {
return url, nil
}
client := &http.Client{Timeout: 12 * time.Second}
req, err := http.NewRequest("GET", "https://logoapi.sportcreative.eu/logos/"+cid+"/json", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "fotbal-club/logo-cache")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("logoapi status %d", resp.StatusCode)
}
var api logoAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
return "", err
}
logoURL := strings.TrimSpace(api.LogoURLSVG)
if logoURL == "" {
logoURL = strings.TrimSpace(api.LogoURLPNG)
}
if logoURL == "" {
logoURL = strings.TrimSpace(api.LogoURL)
}
if logoURL == "" {
return "", fmt.Errorf("no logo url in api response")
}
req2, err := http.NewRequest("GET", logoURL, nil)
if err != nil {
return "", err
}
req2.Header.Set("User-Agent", "fotbal-club/logo-cache")
req2.Header.Set("Accept", "*/*")
resp2, err := client.Do(req2)
if err != nil {
return "", err
}
defer resp2.Body.Close()
if resp2.StatusCode < 200 || resp2.StatusCode >= 300 {
return "", fmt.Errorf("logo download status %d", resp2.StatusCode)
}
ct := strings.ToLower(strings.TrimSpace(resp2.Header.Get("Content-Type")))
ext := ".png"
if strings.Contains(ct, "svg") || strings.HasSuffix(strings.ToLower(logoURL), ".svg") {
ext = ".svg"
} else if strings.Contains(ct, "webp") || strings.HasSuffix(strings.ToLower(logoURL), ".webp") {
ext = ".webp"
} else if strings.Contains(ct, "jpeg") || strings.HasSuffix(strings.ToLower(logoURL), ".jpg") || strings.HasSuffix(strings.ToLower(logoURL), ".jpeg") {
ext = ".jpg"
}
destTmp := filepath.Join(destDir, "club-logo"+ext+".tmp")
dest := filepath.Join(destDir, "club-logo"+ext)
f, err := os.Create(destTmp)
if err != nil {
return "", err
}
_, copyErr := io.Copy(f, resp2.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(destTmp)
return "", copyErr
}
if closeErr != nil {
_ = os.Remove(destTmp)
return "", closeErr
}
_ = os.Rename(destTmp, dest)
fi, _ := os.Stat(dest)
mime := "image/png"
if ext == ".svg" {
mime = "image/svg+xml"
} else if ext == ".jpg" || ext == ".jpeg" {
mime = "image/jpeg"
} else if ext == ".webp" {
mime = "image/webp"
}
publicURL := "/uploads/" + filepath.ToSlash(filepath.Join("logos", "club", cid, "club-logo"+ext))
var existing models.UploadedFile
if db != nil {
if err := db.Where("file_path = ?", dest).First(&existing).Error; err != nil {
uf := models.UploadedFile{
Filename: "club-logo" + ext,
FilePath: dest,
FileURL: publicURL,
MimeType: mime,
FileSize: 0,
UploadedByID: nil,
}
if fi != nil {
uf.FileSize = fi.Size()
}
_ = db.Create(&uf).Error
}
}
return publicURL, nil
}
+50 -35
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"log"
"net/url"
"path/filepath"
"strings"
"time"
@@ -475,46 +474,62 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
return nil
}
// HTML builders
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
// Build tracked link
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
url.QueryEscape(articleURL),
url.QueryEscape(token))
html := fmt.Sprintf(`
// Short description: prefer excerpt; otherwise derive from content
desc := strings.TrimSpace(article.Excerpt)
if desc == "" {
plain := utils.SanitizeString(article.Content)
if len(plain) > 260 {
cut := 240
if cut < len(plain) {
for cut < len(plain) && plain[cut] != ' ' {
cut++
}
}
if cut > len(plain) { cut = len(plain) }
plain = strings.TrimSpace(plain[:cut]) + "…"
}
desc = plain
}
// Category badge (if available)
cat := strings.TrimSpace(article.CategoryName)
var catHTML string
if cat != "" {
catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
}
// Cover image (optional)
var imgHTML string
if strings.TrimSpace(article.ImageURL) != "" {
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
}
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
<a href="%s/newsletter/preferences?token=%s" style="color: #2563eb;">Spravovat předvolby</a>
</p>
<h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;">
%s
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size:22px;">%s</h3>
%s
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 12px 0;">%s</p>
<a href="%s" style="display: inline-block; padding: 12px 20px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
</div>
`, htmlEsc(article.Title), htmlEsc(article.Excerpt), trackedURL, baseFE, url.QueryEscape(token))
return html
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL)
return html
}
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
var intro string
if notifType == "reminder_48h" {
intro = "Připomínáme nadcházející zápas:"
} else {
intro = "Zápas je dnes!"
}
html := fmt.Sprintf(`
var intro string
if notifType == "reminder_48h" {
intro = "Připomínáme nadcházející zápas:"
} else {
intro = "Zápas je dnes!"
}
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
+43 -13
View File
@@ -37,6 +37,11 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
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)
@@ -73,10 +78,10 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
}
if len(sections) == 0 {
return "Fotbal Club přehled", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
return fmt.Sprintf("%s přehled", clubName), "<p>Pro vybrané preference nyní nemáme novinky.</p>"
}
subject = "Fotbal Club novinky a zápasy"
subject = fmt.Sprintf("%s novinky a zápasy", clubName)
html = strings.Join(sections, "\n\n")
return subject, html
}
@@ -125,12 +130,22 @@ func pickUpcomingEvents(v any, n int) []Event {
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, Link, Score string }
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)
@@ -142,7 +157,9 @@ func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
ts := parseDateTimeISO(m.Date, m.Time)
if ts.IsZero() || ts.Before(now) { continue }
if len(compSet) > 0 {
if !compSet[strings.ToLower(m.Competition)] { continue }
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 }
@@ -163,7 +180,9 @@ func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.
// 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 }
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)
}
@@ -188,19 +207,20 @@ func facrAllMatches(v any) []Match {
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))
out = append(out, toMatch(asMap(mm), compName, compCode))
}
}
}
// flat matches fallback
for _, mm := range asList(m["matches"]) {
out = append(out, toMatch(asMap(mm), ""))
out = append(out, toMatch(asMap(mm), "", ""))
}
return out
}
func toMatch(m map[string]any, comp string) Match {
func toMatch(m map[string]any, compName, compCode string) Match {
dt := str(m["date_time"], "")
var date, tm string
if dt != "" && strings.Contains(dt, " ") {
@@ -215,7 +235,8 @@ func toMatch(m map[string]any, comp string) Match {
Away: str(m["away"], ""),
Date: date,
Time: tm,
Competition: str(m["competition"], str(m["competition_name"], comp)),
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"], ""),
}
@@ -224,10 +245,19 @@ func toMatch(m map[string]any, comp string) Match {
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)
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 {