mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
de day #74
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user