mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #99
This commit is contained in:
+26
-20
@@ -14,10 +14,10 @@ import (
|
||||
// Config holds all configuration for the application
|
||||
type Config struct {
|
||||
// App settings
|
||||
AppEnv string
|
||||
Port string
|
||||
Debug bool
|
||||
Premium bool
|
||||
AppEnv string
|
||||
Port string
|
||||
Debug bool
|
||||
Premium bool
|
||||
|
||||
// Database settings
|
||||
DatabaseURL string
|
||||
@@ -68,15 +68,15 @@ type Config struct {
|
||||
AllowedOrigins []string
|
||||
|
||||
// External services
|
||||
ScraperBaseURL string
|
||||
FrontendBaseURL string
|
||||
ScraperBaseURL string
|
||||
FrontendBaseURL string
|
||||
PublicAPIBaseURL string
|
||||
|
||||
|
||||
// Umami Analytics
|
||||
UmamiURL string
|
||||
UmamiUsername string
|
||||
UmamiPassword string
|
||||
UmamiWebsiteID string
|
||||
UmamiURL string
|
||||
UmamiUsername string
|
||||
UmamiPassword string
|
||||
UmamiWebsiteID string
|
||||
|
||||
ErrorIngestURL string
|
||||
ErrorIngestToken string
|
||||
@@ -85,6 +85,9 @@ type Config struct {
|
||||
ClamAVEnabled bool
|
||||
ClamAVHost string
|
||||
ClamAVPort int
|
||||
|
||||
// Feature flags
|
||||
RembgEnabled bool
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
@@ -96,10 +99,10 @@ func LoadConfig() {
|
||||
|
||||
AppConfig = &Config{
|
||||
// App settings
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Debug: getEnvAsBool("DEBUG", true),
|
||||
Premium: getEnvAsBool("PREMIUM", false),
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Debug: getEnvAsBool("DEBUG", true),
|
||||
Premium: getEnvAsBool("PREMIUM", false),
|
||||
|
||||
// Database settings
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/fotbal_club?sslmode=disable"),
|
||||
@@ -131,11 +134,11 @@ func LoadConfig() {
|
||||
"image/svg+xml",
|
||||
// Documents
|
||||
"application/pdf",
|
||||
"application/msword", // .doc
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||
"application/vnd.ms-excel", // .xls
|
||||
"application/msword", // .doc
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||
"application/vnd.ms-excel", // .xls
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
||||
"application/vnd.ms-powerpoint", // .ppt
|
||||
"application/vnd.ms-powerpoint", // .ppt
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
||||
// Text
|
||||
"text/plain",
|
||||
@@ -178,7 +181,7 @@ func LoadConfig() {
|
||||
ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"),
|
||||
FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"),
|
||||
PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"),
|
||||
|
||||
|
||||
// Umami Analytics
|
||||
UmamiURL: getEnv("UMAMI_URL", ""),
|
||||
UmamiUsername: getEnv("UMAMI_USERNAME", ""),
|
||||
@@ -192,6 +195,9 @@ func LoadConfig() {
|
||||
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
|
||||
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
|
||||
ClamAVPort: getEnvAsInt("CLAMAV_PORT", 3310),
|
||||
|
||||
// Feature flags
|
||||
RembgEnabled: getEnvAsBool("REMBG_ENABLED", true),
|
||||
}
|
||||
|
||||
// Override allowed origins if specified in environment (comma-separated)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -225,6 +226,10 @@ func getLogoBySearch(name string) string {
|
||||
best = payload.Results[0].LogoURL
|
||||
}
|
||||
if best != "" {
|
||||
// Attempt to process FACR logos to transparent PNG via rembg (best-effort)
|
||||
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
|
||||
best = p
|
||||
}
|
||||
logoCache[key] = best
|
||||
return best
|
||||
}
|
||||
@@ -280,6 +285,9 @@ func getLogoBySearch(name string) string {
|
||||
best = partial
|
||||
}
|
||||
if best != "" {
|
||||
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
|
||||
best = p
|
||||
}
|
||||
logoCache[key] = best
|
||||
}
|
||||
return best
|
||||
@@ -292,11 +300,18 @@ func getLogo(teamName, teamID string) string {
|
||||
return placeholder
|
||||
}
|
||||
if logo := getLogoBySearch(teamName); logo != "" {
|
||||
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
|
||||
return p
|
||||
}
|
||||
return logo
|
||||
}
|
||||
tid := strings.TrimSpace(teamID)
|
||||
if tid != "" {
|
||||
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
|
||||
u := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
|
||||
if p, err := services.ProcessFACRLogo(u); err == nil && strings.TrimSpace(p) != "" {
|
||||
return p
|
||||
}
|
||||
return u
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
@@ -410,6 +425,10 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
|
||||
}
|
||||
img := a.Find("img").First()
|
||||
logoURL, _ := img.Attr("src")
|
||||
// Best-effort: Process FACR logos to transparent PNG. Non-facr URLs are returned unchanged.
|
||||
if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" {
|
||||
logoURL = p
|
||||
}
|
||||
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
|
||||
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
|
||||
clubType := "football"
|
||||
@@ -535,6 +554,63 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
|
||||
return
|
||||
}
|
||||
// Best-effort: rewrite FACR logos in matches to processed local PNGs via rembg
|
||||
// so that all consumers receive transparent logos consistently.
|
||||
{
|
||||
var orig map[string]any
|
||||
if json.Unmarshal(b, &orig) == nil {
|
||||
if comps, ok := orig["competitions"].([]any); ok {
|
||||
seen := map[string]string{}
|
||||
for i := range comps {
|
||||
comp, _ := comps[i].(map[string]any)
|
||||
if comp == nil {
|
||||
continue
|
||||
}
|
||||
if matches, ok2 := comp["matches"].([]any); ok2 {
|
||||
for j := range matches {
|
||||
m, _ := matches[j].(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// home_logo_url
|
||||
if s, ok3 := m["home_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
|
||||
if rep, ok := seen[s]; ok {
|
||||
if rep != "" && rep != s {
|
||||
m["home_logo_url"] = rep
|
||||
}
|
||||
} else {
|
||||
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
|
||||
seen[s] = p
|
||||
m["home_logo_url"] = p
|
||||
} else {
|
||||
seen[s] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
// away_logo_url
|
||||
if s, ok3 := m["away_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
|
||||
if rep, ok := seen[s]; ok {
|
||||
if rep != "" && rep != s {
|
||||
m["away_logo_url"] = rep
|
||||
}
|
||||
} else {
|
||||
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
|
||||
seen[s] = p
|
||||
m["away_logo_url"] = p
|
||||
} else {
|
||||
seen[s] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if nb, err := json.Marshal(orig); err == nil {
|
||||
b = nb
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setCachedJSON(cacheKey, b)
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
@@ -573,6 +649,49 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
|
||||
return
|
||||
}
|
||||
// Best-effort: rewrite team_logo_url in tables to processed local PNGs via rembg
|
||||
{
|
||||
var orig map[string]any
|
||||
if json.Unmarshal(b, &orig) == nil {
|
||||
if comps, ok := orig["competitions"].([]any); ok {
|
||||
seen := map[string]string{}
|
||||
for i := range comps {
|
||||
comp, _ := comps[i].(map[string]any)
|
||||
if comp == nil {
|
||||
continue
|
||||
}
|
||||
tbl, _ := comp["table"].(map[string]any)
|
||||
if tbl == nil {
|
||||
continue
|
||||
}
|
||||
overall, _ := tbl["overall"].([]any)
|
||||
for j := range overall {
|
||||
row, _ := overall[j].(map[string]any)
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok3 := row["team_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
|
||||
if rep, ok := seen[s]; ok {
|
||||
if rep != "" && rep != s {
|
||||
row["team_logo_url"] = rep
|
||||
}
|
||||
} else {
|
||||
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
|
||||
seen[s] = p
|
||||
row["team_logo_url"] = p
|
||||
} else {
|
||||
seen[s] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if nb, err := json.Marshal(orig); err == nil {
|
||||
b = nb
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setCachedJSON(cacheKey, b)
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewNavigationController(db *gorm.DB) *NavigationController {
|
||||
// @Router /api/v1/navigation [get]
|
||||
func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
|
||||
var items []models.NavigationItem
|
||||
|
||||
|
||||
// Get only top-level items (no parent) that are visible and NOT admin-only
|
||||
if err := nc.DB.Where("parent_id IS NULL AND visible = ? AND requires_admin = ?", true, false).
|
||||
Order("display_order ASC").
|
||||
@@ -37,7 +37,7 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Compute URLs for items
|
||||
for i := range items {
|
||||
if items[i].URL == "" {
|
||||
@@ -49,7 +49,7 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
|
||||
// @Router /api/v1/admin/navigation [get]
|
||||
func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
|
||||
var items []models.NavigationItem
|
||||
|
||||
|
||||
if err := nc.DB.Where("parent_id IS NULL").
|
||||
Order("requires_admin ASC, display_order ASC").
|
||||
Preload("Children", func(db *gorm.DB) *gorm.DB {
|
||||
@@ -73,7 +73,7 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Compute URLs for items
|
||||
for i := range items {
|
||||
if items[i].URL == "" {
|
||||
@@ -85,7 +85,7 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
@@ -101,40 +101,40 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
|
||||
// @Router /api/v1/admin/navigation [post]
|
||||
func (nc *NavigationController) CreateNavigationItem(c *gin.Context) {
|
||||
var item models.NavigationItem
|
||||
|
||||
|
||||
if err := c.ShouldBindJSON(&item); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// If no display order is set, put it at the end
|
||||
if item.DisplayOrder == 0 {
|
||||
var maxOrder int
|
||||
query := nc.DB.Model(&models.NavigationItem{})
|
||||
|
||||
|
||||
// Calculate max order for items at the same level (same parent) and same admin status
|
||||
if item.ParentID == nil {
|
||||
query = query.Where("parent_id IS NULL")
|
||||
} else {
|
||||
query = query.Where("parent_id = ?", *item.ParentID)
|
||||
}
|
||||
|
||||
|
||||
// Also consider requires_admin to keep frontend and admin items separate
|
||||
query = query.Where("requires_admin = ?", item.RequiresAdmin)
|
||||
|
||||
|
||||
query.Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxOrder)
|
||||
item.DisplayOrder = maxOrder
|
||||
}
|
||||
|
||||
|
||||
if err := nc.DB.Create(&item).Error; err != nil {
|
||||
// Log the actual error for debugging
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to create navigation item",
|
||||
"error": "Failed to create navigation item",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
@@ -155,13 +155,13 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var item models.NavigationItem
|
||||
if err := nc.DB.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Navigation item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Bind into a generic map to know which fields are present (partial update)
|
||||
var raw map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
@@ -173,19 +173,29 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if v, ok := raw["label"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["label"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["label"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["url"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["url"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["url"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["icon"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["icon"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["type"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["type"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["type"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["page_type"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["page_type"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["page_type"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["page_id"]; ok {
|
||||
switch t := v.(type) {
|
||||
@@ -202,7 +212,9 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if v, ok := raw["visible"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
|
||||
if b, ok2 := v.(bool); ok2 {
|
||||
updates["visible"] = b
|
||||
}
|
||||
}
|
||||
if v, ok := raw["display_order"]; ok {
|
||||
switch t := v.(type) {
|
||||
@@ -231,16 +243,24 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if v, ok := raw["target"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["target"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["target"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["css_class"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["css_class"] = s }
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
updates["css_class"] = s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["requires_auth"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 { updates["requires_auth"] = b }
|
||||
if b, ok2 := v.(bool); ok2 {
|
||||
updates["requires_auth"] = b
|
||||
}
|
||||
}
|
||||
if v, ok := raw["requires_admin"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 { updates["requires_admin"] = b }
|
||||
if b, ok2 := v.(bool); ok2 {
|
||||
updates["requires_admin"] = b
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
@@ -251,7 +271,7 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
|
||||
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to update navigation item",
|
||||
"error": "Failed to update navigation item",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
@@ -280,12 +300,12 @@ func (nc *NavigationController) DeleteNavigationItem(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if err := nc.DB.Delete(&models.NavigationItem{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete navigation item"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Navigation item deleted successfully"})
|
||||
}
|
||||
|
||||
@@ -305,7 +325,7 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Update each item's order in a transaction
|
||||
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, order := range orders {
|
||||
@@ -314,7 +334,7 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
|
||||
if !ok1 || !ok2 {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if err := tx.Model(&models.NavigationItem{}).
|
||||
Where("id = ?", id).
|
||||
Update("display_order", displayOrder).Error; err != nil {
|
||||
@@ -323,12 +343,12 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder navigation items"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Navigation items reordered successfully"})
|
||||
}
|
||||
|
||||
@@ -341,14 +361,14 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
|
||||
// @Router /api/v1/social-links [get]
|
||||
func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
|
||||
var links []models.SocialLink
|
||||
|
||||
|
||||
if err := nc.DB.Where("visible = ?", true).
|
||||
Order("display_order ASC").
|
||||
Find(&links).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch social links"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
|
||||
@@ -362,12 +382,12 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
|
||||
// @Router /api/v1/admin/social-links [get]
|
||||
func (nc *NavigationController) GetAllSocialLinks(c *gin.Context) {
|
||||
var links []models.SocialLink
|
||||
|
||||
|
||||
if err := nc.DB.Order("display_order ASC").Find(&links).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch social links"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
|
||||
@@ -383,12 +403,12 @@ func (nc *NavigationController) GetAllSocialLinks(c *gin.Context) {
|
||||
// @Router /api/v1/admin/social-links [post]
|
||||
func (nc *NavigationController) CreateSocialLink(c *gin.Context) {
|
||||
var link models.SocialLink
|
||||
|
||||
|
||||
if err := c.ShouldBindJSON(&link); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// If no display order is set, put it at the end
|
||||
if link.DisplayOrder == 0 {
|
||||
var maxOrder int
|
||||
@@ -397,12 +417,12 @@ func (nc *NavigationController) CreateSocialLink(c *gin.Context) {
|
||||
Scan(&maxOrder)
|
||||
link.DisplayOrder = maxOrder
|
||||
}
|
||||
|
||||
|
||||
if err := nc.DB.Create(&link).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create social link"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusCreated, link)
|
||||
}
|
||||
|
||||
@@ -423,30 +443,30 @@ func (nc *NavigationController) UpdateSocialLink(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var link models.SocialLink
|
||||
if err := nc.DB.First(&link, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Social link not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var updates models.SocialLink
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
link.Platform = updates.Platform
|
||||
link.URL = updates.URL
|
||||
link.DisplayOrder = updates.DisplayOrder
|
||||
link.Visible = updates.Visible
|
||||
link.Icon = updates.Icon
|
||||
|
||||
|
||||
if err := nc.DB.Save(&link).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update social link"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, link)
|
||||
}
|
||||
|
||||
@@ -465,12 +485,12 @@ func (nc *NavigationController) DeleteSocialLink(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if err := nc.DB.Delete(&models.SocialLink{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete social link"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Social link deleted successfully"})
|
||||
}
|
||||
|
||||
@@ -490,7 +510,7 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, order := range orders {
|
||||
id, ok1 := order["id"]
|
||||
@@ -498,7 +518,7 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
|
||||
if !ok1 || !ok2 {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if err := tx.Model(&models.SocialLink{}).
|
||||
Where("id = ?", id).
|
||||
Update("display_order", displayOrder).Error; err != nil {
|
||||
@@ -507,12 +527,12 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder social links"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Social links reordered successfully"})
|
||||
}
|
||||
|
||||
@@ -524,17 +544,17 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/admin/navigation/seed [post]
|
||||
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
// Check existing counts for frontend and admin separately
|
||||
var frontendCount int64
|
||||
var adminCount int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||
|
||||
// Default frontend navigation items
|
||||
frontendItems := []models.NavigationItem{
|
||||
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
|
||||
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
|
||||
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
|
||||
// Check existing counts for frontend and admin separately
|
||||
var frontendCount int64
|
||||
var adminCount int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||
|
||||
// Default frontend navigation items
|
||||
frontendItems := []models.NavigationItem{
|
||||
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
|
||||
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
|
||||
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
|
||||
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
|
||||
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
|
||||
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
|
||||
@@ -545,128 +565,257 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
{Label: "Sponzoři", Type: models.NavTypePage, PageType: "sponsors", DisplayOrder: 10, Visible: true, RequiresAdmin: false},
|
||||
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
|
||||
}
|
||||
|
||||
// Create items in a transaction with admin categories and children (seed missing parts only)
|
||||
seededFrontend := false
|
||||
seededAdmin := false
|
||||
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if frontendCount == 0 {
|
||||
for _, item := range frontendItems {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seededFrontend = true
|
||||
}
|
||||
|
||||
if adminCount == 0 {
|
||||
catOrder := 0
|
||||
createCategory := func(label string) (*models.NavigationItem, error) {
|
||||
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
|
||||
catOrder++
|
||||
if err := tx.Create(cat).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
// Create items in a transaction with admin categories and children (seed missing parts only)
|
||||
seededFrontend := false
|
||||
seededAdmin := false
|
||||
addedMissing := false
|
||||
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if frontendCount == 0 {
|
||||
for _, item := range frontendItems {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seededFrontend = true
|
||||
}
|
||||
|
||||
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
|
||||
pid := parent.ID
|
||||
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
|
||||
child.ParentID = &pid
|
||||
return tx.Create(child).Error
|
||||
}
|
||||
if adminCount == 0 {
|
||||
catOrder := 0
|
||||
createCategory := func(label string) (*models.NavigationItem, error) {
|
||||
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
|
||||
catOrder++
|
||||
if err := tx.Create(cat).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
|
||||
zakladni, err := createCategory("Základní")
|
||||
if err != nil { return err }
|
||||
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
|
||||
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
|
||||
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
|
||||
pid := parent.ID
|
||||
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
|
||||
child.ParentID = &pid
|
||||
return tx.Create(child).Error
|
||||
}
|
||||
|
||||
sport, err := createCategory("Sport")
|
||||
if err != nil { return err }
|
||||
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
|
||||
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
|
||||
if err := createChild(sport, "Hráči", "players", 2); err != nil { return err }
|
||||
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
|
||||
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil { return err }
|
||||
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil { return err }
|
||||
zakladni, err := createCategory("Základní")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obsah, err := createCategory("Obsah")
|
||||
if err != nil { return err }
|
||||
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
|
||||
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
|
||||
// Kategorie admin page removed (categories derived from competition aliases)
|
||||
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
|
||||
sport, err := createCategory("Sport")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(sport, "Týmy", "teams", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(sport, "Zápasy", "matches", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(sport, "Hráči", "players", 2); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
media, err := createCategory("Média")
|
||||
if err != nil { return err }
|
||||
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
|
||||
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
|
||||
if err := createChild(media, "Soubory", "files", 2); err != nil { return err }
|
||||
obsah, err := createCategory("Obsah")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(obsah, "Články", "articles", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
// "O klubu" admin page
|
||||
if err := createChild(obsah, "O klubu", "about", 2); err != nil {
|
||||
return err
|
||||
}
|
||||
// Kategorie admin page removed (categories derived from competition aliases)
|
||||
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kom, err := createCategory("Komunikace")
|
||||
if err != nil { return err }
|
||||
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
|
||||
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil { return err }
|
||||
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil { return err }
|
||||
media, err := createCategory("Média")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(media, "Videa", "videos", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(media, "Soubory", "files", 2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
marketing, err := createCategory("Marketing")
|
||||
if err != nil { return err }
|
||||
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
|
||||
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
|
||||
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
|
||||
if err := createChild(marketing, "Ankety", "polls", 3); err != nil { return err }
|
||||
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil { return err }
|
||||
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil { return err }
|
||||
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil { return err }
|
||||
kom, err := createCategory("Komunikace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(kom, "Zprávy", "messages", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nastroje, err := createCategory("Nástroje")
|
||||
if err != nil { return err }
|
||||
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil { return err }
|
||||
marketing, err := createCategory("Marketing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Bannery", "banners", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Ankety", "polls", 3); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nastaveni, err := createCategory("Nastavení")
|
||||
if err != nil { return err }
|
||||
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil { return err }
|
||||
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
|
||||
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil { return err }
|
||||
nastroje, err := createCategory("Nástroje")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
napoveda, err := createCategory("Nápověda")
|
||||
if err != nil { return err }
|
||||
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
|
||||
nastaveni, err := createCategory("Nastavení")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seededAdmin = true
|
||||
}
|
||||
napoveda, err := createCategory("Nápověda")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seededAdmin = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
|
||||
return
|
||||
}
|
||||
|
||||
// Since creation is split, compute counts again
|
||||
var total int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Count(&total)
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||
|
||||
message := "Navigation items already exist"
|
||||
if seededFrontend && seededAdmin {
|
||||
message = "Default frontend and admin navigation created successfully"
|
||||
} else if seededFrontend {
|
||||
message = "Default frontend navigation created successfully"
|
||||
} else if seededAdmin {
|
||||
message = "Default admin navigation created successfully"
|
||||
}
|
||||
// Also add missing admin "O klubu" item under "Obsah" when admin navigation exists but the item is missing
|
||||
if adminCount > 0 {
|
||||
var aboutCount int64
|
||||
// Check if an admin nav item with page_type 'about' exists
|
||||
if err := nc.DB.Model(&models.NavigationItem{}).
|
||||
Where("requires_admin = ? AND page_type = ?", true, "about").
|
||||
Count(&aboutCount).Error; err == nil {
|
||||
if aboutCount == 0 {
|
||||
// Ensure the 'Obsah' category exists (admin dropdown)
|
||||
var obsah models.NavigationItem
|
||||
findCatErr := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND type = ? AND label = ?", true, models.NavTypeDropdown, "Obsah").First(&obsah).Error
|
||||
if findCatErr != nil {
|
||||
if findCatErr == gorm.ErrRecordNotFound {
|
||||
// Create category at the end of admin categories
|
||||
var maxCat int
|
||||
nc.DB.Model(&models.NavigationItem{}).
|
||||
Where("parent_id IS NULL AND requires_admin = ?", true).
|
||||
Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxCat)
|
||||
obsah = models.NavigationItem{Label: "Obsah", Type: models.NavTypeDropdown, DisplayOrder: maxCat, Visible: true, RequiresAdmin: true}
|
||||
if err := nc.DB.Create(&obsah).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin category"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Create the missing child under 'Obsah'
|
||||
var maxChild int
|
||||
nc.DB.Model(&models.NavigationItem{}).
|
||||
Where("parent_id = ?", obsah.ID).
|
||||
Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxChild)
|
||||
pid := obsah.ID
|
||||
aboutNav := models.NavigationItem{Label: "O klubu", Type: models.NavTypeInternal, PageType: "about", DisplayOrder: maxChild, Visible: true, RequiresAdmin: true}
|
||||
aboutNav.ParentID = &pid
|
||||
if err := nc.DB.Create(&aboutNav).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create about nav item"})
|
||||
return
|
||||
}
|
||||
addedMissing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": message,
|
||||
"count": total,
|
||||
"frontend_count": frontendCount,
|
||||
"admin_count": adminCount,
|
||||
"seeded": seededFrontend || seededAdmin,
|
||||
"seeded_frontend": seededFrontend,
|
||||
"seeded_admin": seededAdmin,
|
||||
})
|
||||
// Since creation is split, compute counts again
|
||||
var total int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Count(&total)
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||
|
||||
message := "Navigation items already exist"
|
||||
if seededFrontend && seededAdmin {
|
||||
message = "Default frontend and admin navigation created successfully"
|
||||
} else if seededFrontend {
|
||||
message = "Default frontend navigation created successfully"
|
||||
} else if seededAdmin {
|
||||
message = "Default admin navigation created successfully"
|
||||
}
|
||||
if addedMissing && !(seededFrontend || seededAdmin) {
|
||||
message = "Added missing navigation items"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": message,
|
||||
"count": total,
|
||||
"frontend_count": frontendCount,
|
||||
"admin_count": adminCount,
|
||||
"seeded": (seededFrontend || seededAdmin || addedMissing),
|
||||
"seeded_frontend": seededFrontend,
|
||||
"seeded_admin": seededAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RembgController struct{}
|
||||
|
||||
func NewRembgController() *RembgController { return &RembgController{} }
|
||||
|
||||
func (rc *RembgController) Status(c *gin.Context) {
|
||||
s := services.GetRembgStatus()
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (rc *RembgController) Start(c *gin.Context) {
|
||||
cacheDir := strings.TrimSpace(c.Query("cache_dir"))
|
||||
if cacheDir == "" {
|
||||
cacheDir = filepath.Join("cache", "prefetch")
|
||||
}
|
||||
started := services.StartFACRLogosBatch(cacheDir)
|
||||
s := services.GetRembgStatus()
|
||||
c.JSON(http.StatusOK, gin.H{"started": started, "status": s})
|
||||
}
|
||||
@@ -1,296 +1,354 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"mime/multipart"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func uploadsBaseDir() string {
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
}
|
||||
return dir
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
|
||||
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b := img.Bounds()
|
||||
minX, minY := b.Max.X, b.Max.Y
|
||||
maxX, maxY := b.Min.X, b.Min.Y
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl, a := img.At(x, y).RGBA()
|
||||
if a <= 0x10 { // near transparent
|
||||
continue
|
||||
}
|
||||
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
|
||||
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
|
||||
continue
|
||||
}
|
||||
if x < minX { minX = x }
|
||||
if y < minY { minY = y }
|
||||
if x > maxX { maxX = x }
|
||||
if y > maxY { maxY = y }
|
||||
}
|
||||
}
|
||||
if minX >= maxX || minY >= maxY {
|
||||
// fallback to full image
|
||||
minX, minY = b.Min.X, b.Min.Y
|
||||
maxX, maxY = b.Max.X-1, b.Max.Y-1
|
||||
}
|
||||
cw, ch := maxX-minX+1, maxY-minY+1
|
||||
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
|
||||
for y := 0; y < ch; y++ {
|
||||
for x := 0; x < cw; x++ {
|
||||
nrgba.Set(x, y, img.At(minX+x, minY+y))
|
||||
}
|
||||
}
|
||||
// resize to 64px height using nearest-neighbor
|
||||
targetH := 64
|
||||
if ch != targetH {
|
||||
targetW := int(float64(cw) * float64(targetH) / float64(ch))
|
||||
if targetW < 1 { targetW = 1 }
|
||||
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
|
||||
for y2 := 0; y2 < targetH; y2++ {
|
||||
srcY := y2 * ch / targetH
|
||||
for x2 := 0; x2 < targetW; x2++ {
|
||||
srcX := x2 * cw / targetW
|
||||
c := nrgba.NRGBAAt(srcX, srcY)
|
||||
resized.SetNRGBA(x2, y2, c)
|
||||
}
|
||||
}
|
||||
nrgba = resized
|
||||
}
|
||||
// write PNG
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err }
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return png.Encode(f, nrgba)
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b := img.Bounds()
|
||||
minX, minY := b.Max.X, b.Max.Y
|
||||
maxX, maxY := b.Min.X, b.Min.Y
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl, a := img.At(x, y).RGBA()
|
||||
if a <= 0x10 { // near transparent
|
||||
continue
|
||||
}
|
||||
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
|
||||
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
|
||||
continue
|
||||
}
|
||||
if x < minX {
|
||||
minX = x
|
||||
}
|
||||
if y < minY {
|
||||
minY = y
|
||||
}
|
||||
if x > maxX {
|
||||
maxX = x
|
||||
}
|
||||
if y > maxY {
|
||||
maxY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
if minX >= maxX || minY >= maxY {
|
||||
// fallback to full image
|
||||
minX, minY = b.Min.X, b.Min.Y
|
||||
maxX, maxY = b.Max.X-1, b.Max.Y-1
|
||||
}
|
||||
cw, ch := maxX-minX+1, maxY-minY+1
|
||||
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
|
||||
for y := 0; y < ch; y++ {
|
||||
for x := 0; x < cw; x++ {
|
||||
nrgba.Set(x, y, img.At(minX+x, minY+y))
|
||||
}
|
||||
}
|
||||
// resize to 64px height using nearest-neighbor
|
||||
targetH := 64
|
||||
if ch != targetH {
|
||||
targetW := int(float64(cw) * float64(targetH) / float64(ch))
|
||||
if targetW < 1 {
|
||||
targetW = 1
|
||||
}
|
||||
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
|
||||
for y2 := 0; y2 < targetH; y2++ {
|
||||
srcY := y2 * ch / targetH
|
||||
for x2 := 0; x2 < targetW; x2++ {
|
||||
srcX := x2 * cw / targetW
|
||||
c := nrgba.NRGBAAt(srcX, srcY)
|
||||
resized.SetNRGBA(x2, y2, c)
|
||||
}
|
||||
}
|
||||
nrgba = resized
|
||||
}
|
||||
// write PNG
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return png.Encode(f, nrgba)
|
||||
}
|
||||
|
||||
// ensureUniqueFilename ensures name does not collide within dir, adding -1, -2 etc.
|
||||
func ensureUniqueFilename(dir, name string) string {
|
||||
base := name
|
||||
ext := ""
|
||||
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||
base = name[:i]
|
||||
ext = name[i:]
|
||||
}
|
||||
try := name
|
||||
idx := 1
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
|
||||
return try
|
||||
}
|
||||
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
|
||||
idx++
|
||||
}
|
||||
base := name
|
||||
ext := ""
|
||||
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||
base = name[:i]
|
||||
ext = name[i:]
|
||||
}
|
||||
try := name
|
||||
idx := 1
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
|
||||
return try
|
||||
}
|
||||
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
|
||||
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
entries, err := os.ReadDir(sponsorDir)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, []string{})
|
||||
return
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() { continue }
|
||||
name := e.Name()
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") {
|
||||
out = append(out, "/uploads/sponsors/"+name)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, out)
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
entries, err := os.ReadDir(sponsorDir)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, []string{})
|
||||
return
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") {
|
||||
out = append(out, "/uploads/sponsors/"+name)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// UploadSponsors accepts multipart form files under field name "files" (or single "file")
|
||||
func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
||||
return
|
||||
}
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
||||
return
|
||||
}
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||
|
||||
saved := 0
|
||||
created := make([]string, 0, 8)
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
files := ctx.Request.MultipartForm.File["files"]
|
||||
if len(files) == 0 {
|
||||
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
|
||||
_ = f.Close()
|
||||
files = []*multipart.FileHeader{hdr}
|
||||
}
|
||||
}
|
||||
for _, hdr := range files {
|
||||
if hdr == nil { continue }
|
||||
src, err := hdr.Open()
|
||||
if err != nil { continue }
|
||||
// do not defer: loop
|
||||
name := sanitizeFilename(hdr.Filename)
|
||||
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
||||
base := name
|
||||
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
|
||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
outPath := filepath.Join(sponsorDir, outName)
|
||||
saved := 0
|
||||
created := make([]string, 0, 8)
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
files := ctx.Request.MultipartForm.File["files"]
|
||||
if len(files) == 0 {
|
||||
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
|
||||
_ = f.Close()
|
||||
files = []*multipart.FileHeader{hdr}
|
||||
}
|
||||
}
|
||||
for _, hdr := range files {
|
||||
if hdr == nil {
|
||||
continue
|
||||
}
|
||||
src, err := hdr.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// do not defer: loop
|
||||
name := sanitizeFilename(hdr.Filename)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
|
||||
}
|
||||
base := name
|
||||
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||
base = name[:i]
|
||||
}
|
||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
outPath := filepath.Join(sponsorDir, outName)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, src); err == nil {
|
||||
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
|
||||
saved++
|
||||
created = append(created, "/uploads/sponsors/"+outName)
|
||||
} else {
|
||||
// Fallback: write original bytes with original extension
|
||||
rawName := ensureUniqueFilename(sponsorDir, name)
|
||||
rawPath := filepath.Join(sponsorDir, rawName)
|
||||
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
||||
saved++
|
||||
created = append(created, "/uploads/sponsors/"+rawName)
|
||||
}
|
||||
}
|
||||
_ = src.Close()
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, src); err == nil {
|
||||
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
|
||||
saved++
|
||||
created = append(created, "/uploads/sponsors/"+outName)
|
||||
} else {
|
||||
// Fallback: write original bytes with original extension
|
||||
rawName := ensureUniqueFilename(sponsorDir, name)
|
||||
rawPath := filepath.Join(sponsorDir, rawName)
|
||||
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
||||
saved++
|
||||
created = append(created, "/uploads/sponsors/"+rawName)
|
||||
}
|
||||
}
|
||||
_ = src.Close()
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
|
||||
}
|
||||
|
||||
// DeleteSponsor deletes a sponsor logo by filename (?name=)
|
||||
func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
||||
name := sanitizeFilename(ctx.Query("name"))
|
||||
if name == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
||||
return
|
||||
}
|
||||
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
name := sanitizeFilename(ctx.Query("name"))
|
||||
if name == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
||||
return
|
||||
}
|
||||
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DeleteQR deletes the QR image (uploads/qr.png) if present
|
||||
func (c *ScoreboardController) DeleteQR(ctx *gin.Context) {
|
||||
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GetQR returns the current QR image URL if present
|
||||
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
|
||||
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
|
||||
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
|
||||
}
|
||||
|
||||
// UploadQR accepts a single file and stores/overwrites uploads/qr.png
|
||||
func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
|
||||
file, _, err := ctx.Request.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
dir := uploadsBaseDir()
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
file, _, err := ctx.Request.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
dir := uploadsBaseDir()
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use.
|
||||
// Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors.
|
||||
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
|
||||
var body struct{ IDs []uint `json:"ids"` }
|
||||
_ = ctx.ShouldBindJSON(&body)
|
||||
var list []models.Sponsor
|
||||
q := c.DB.Model(&models.Sponsor{})
|
||||
if len(body.IDs) > 0 {
|
||||
q = q.Where("id IN ?", body.IDs)
|
||||
} else {
|
||||
q = q.Where("is_active = ?", true)
|
||||
}
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
|
||||
return
|
||||
}
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||
created := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
logo := strings.TrimSpace(s.LogoURL)
|
||||
if logo == "" { continue }
|
||||
var data []byte
|
||||
if strings.HasPrefix(logo, "/uploads/") {
|
||||
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
|
||||
if b, err := os.ReadFile(p); err == nil { data = b } else { continue }
|
||||
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
|
||||
resp, err := http.Get(logo)
|
||||
if err != nil { continue }
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return }
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if len(b) > 0 { data = b }
|
||||
}()
|
||||
if len(data) == 0 { continue }
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
base := sanitizeFilename(s.Name)
|
||||
if base == "" {
|
||||
seg := logo
|
||||
if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] }
|
||||
if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] }
|
||||
base = sanitizeFilename(seg)
|
||||
if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
||||
}
|
||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
outPath := filepath.Join(sponsorDir, outName)
|
||||
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
|
||||
// fallback to raw write
|
||||
rawName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
|
||||
created = append(created, "/uploads/sponsors/"+rawName)
|
||||
} else {
|
||||
created = append(created, "/uploads/sponsors/"+outName)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
|
||||
var body struct {
|
||||
IDs []uint `json:"ids"`
|
||||
}
|
||||
_ = ctx.ShouldBindJSON(&body)
|
||||
var list []models.Sponsor
|
||||
q := c.DB.Model(&models.Sponsor{})
|
||||
if len(body.IDs) > 0 {
|
||||
q = q.Where("id IN ?", body.IDs)
|
||||
} else {
|
||||
q = q.Where("is_active = ?", true)
|
||||
}
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
|
||||
return
|
||||
}
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||
created := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
logo := strings.TrimSpace(s.LogoURL)
|
||||
if logo == "" {
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
if strings.HasPrefix(logo, "/uploads/") {
|
||||
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
data = b
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
|
||||
resp, err := http.Get(logo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if len(b) > 0 {
|
||||
data = b
|
||||
}
|
||||
}()
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
base := sanitizeFilename(s.Name)
|
||||
if base == "" {
|
||||
seg := logo
|
||||
if i := strings.LastIndex(seg, "/"); i >= 0 {
|
||||
seg = seg[i+1:]
|
||||
}
|
||||
if j := strings.LastIndex(seg, "."); j >= 0 {
|
||||
seg = seg[:j]
|
||||
}
|
||||
base = sanitizeFilename(seg)
|
||||
if base == "" {
|
||||
base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
|
||||
}
|
||||
}
|
||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
outPath := filepath.Join(sponsorDir, outName)
|
||||
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
|
||||
// fallback to raw write
|
||||
rawName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
|
||||
created = append(created, "/uploads/sponsors/"+rawName)
|
||||
} else {
|
||||
created = append(created, "/uploads/sponsors/"+outName)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,23 @@ func codeFromHash(s string, n int) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func sanitizeCode(in string) string {
|
||||
s := strings.TrimSpace(in)
|
||||
if s == "" { return "" }
|
||||
// filter allowed runes
|
||||
rb := make([]rune, 0, len(s))
|
||||
for _, ch := range s {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
|
||||
rb = append(rb, ch)
|
||||
}
|
||||
}
|
||||
if len(rb) == 0 { return "" }
|
||||
if len(rb) > 16 {
|
||||
rb = rb[:16]
|
||||
}
|
||||
return string(rb)
|
||||
}
|
||||
|
||||
func getScheme(c *gin.Context) string {
|
||||
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
|
||||
return p
|
||||
@@ -256,18 +273,18 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(body.Code)
|
||||
if code == "" {
|
||||
for i := 0; i < 5; i++ {
|
||||
cnd, _ := randCode(7)
|
||||
var cnt int64
|
||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
code = cnd
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
code := sanitizeCode(strings.TrimSpace(body.Code))
|
||||
if code == "" {
|
||||
for i := 0; i < 5; i++ {
|
||||
cnd, _ := randCode(7)
|
||||
var cnt int64
|
||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
code = cnd
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
|
||||
return
|
||||
|
||||
@@ -57,6 +57,11 @@ func ValidateContentType() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(path, "/rembg/start") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Require JSON for other API endpoints
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{
|
||||
@@ -127,6 +132,6 @@ func LogSecurityEvent(c *gin.Context, eventType string, details map[string]inter
|
||||
}
|
||||
|
||||
// Log to your logger
|
||||
// logger.Warn("SECURITY_EVENT: type=%s user_id=%d ip=%s path=%s",
|
||||
// logger.Warn("SECURITY_EVENT: type=%s user_id=%d ip=%s path=%s",
|
||||
// event.Type, event.UserID, event.IP, event.Path)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ type ScoreboardState struct {
|
||||
AwayShort string `json:"away_short"`
|
||||
PrimaryColor string `json:"primary_color"`
|
||||
SecondaryColor string `json:"secondary_color"`
|
||||
HomeTextColor string `json:"home_text_color"`
|
||||
AwayTextColor string `json:"away_text_color"`
|
||||
HomeScore int `json:"home_score"`
|
||||
AwayScore int `json:"away_score"`
|
||||
HalfLength int `json:"half_length"`
|
||||
|
||||
@@ -42,6 +42,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
notificationsController := controllers.NewNotificationsController(db, emailService)
|
||||
emailController := controllers.NewEmailController(db)
|
||||
prefetchController := controllers.NewPrefetchController()
|
||||
rembgController := controllers.NewRembgController()
|
||||
seoController := controllers.NewSEOController(db)
|
||||
navigationController := controllers.NewNavigationController(db)
|
||||
pollController := controllers.NewPollController(db)
|
||||
@@ -96,6 +97,10 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// SMTP validation (public during setup; does not send email, only connects)
|
||||
api.POST("/setup/validate-smtp", baseController.ValidateSMTP)
|
||||
|
||||
// Rembg batch (public status + start)
|
||||
api.GET("/rembg/status", rembgController.Status)
|
||||
api.POST("/rembg/start", rembgController.Start)
|
||||
|
||||
api.POST("/errors", middleware.RateLimit(120, time.Minute), errorController.Ingest)
|
||||
|
||||
// Auth routes
|
||||
@@ -333,6 +338,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
admin.DELETE("/scoreboard/sponsors", scoreboardController.DeleteSponsor)
|
||||
admin.GET("/scoreboard/qr", scoreboardController.GetQR)
|
||||
admin.POST("/scoreboard/qr", scoreboardController.UploadQR)
|
||||
admin.DELETE("/scoreboard/qr", scoreboardController.DeleteQR)
|
||||
|
||||
// Users (admin)
|
||||
admin.GET("/users", authController.ListUsers)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
)
|
||||
|
||||
// saveAsPNG decodes an image from srcPath and writes it as PNG to dstPath.
|
||||
func saveAsPNG(srcPath, dstPath string) error {
|
||||
f, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dstPath + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Use png encoder (imported via blank import)
|
||||
if err := pngEncode(out, img); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dstPath)
|
||||
}
|
||||
|
||||
// pngEncode wraps png.Encode without importing directly to top to keep imports tidy
|
||||
func pngEncode(w io.Writer, img image.Image) error { return png.Encode(w, img) }
|
||||
|
||||
// ProcessFACRLogo downloads a logo from fotbal.cz (FACR) and removes background via rembg.
|
||||
// Returns a public URL (under /uploads) to a transparent PNG. If processing fails or the
|
||||
// URL is not a FACR source, returns the original URL.
|
||||
func ProcessFACRLogo(src string) (string, error) {
|
||||
u := strings.TrimSpace(src)
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("empty url")
|
||||
}
|
||||
// Feature flag: allow disabling background removal entirely via .env
|
||||
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
|
||||
// Simply return the original URL (no processing)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Unwrap proxied URLs like /api/v1/proxy/image?url=...
|
||||
if strings.Contains(u, "/proxy/image") {
|
||||
if parsed, err := neturl.Parse(u); err == nil {
|
||||
raw := parsed.Query().Get("url")
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
u = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isFACRURL(u) {
|
||||
// Only process fotbal.cz sources; leave others (logoapi etc.) unchanged
|
||||
return u, nil
|
||||
}
|
||||
|
||||
baseUpload := strings.TrimSpace(config.AppConfig.UploadDir)
|
||||
if baseUpload == "" {
|
||||
baseUpload = "./uploads"
|
||||
}
|
||||
|
||||
key := facrKeyFromURL(u)
|
||||
outPath := filepath.Join(baseUpload, "logos", "facr", key+".png")
|
||||
if info, err := os.Stat(outPath); err == nil && info.Size() > 0 {
|
||||
return toPublicURL(outPath), nil
|
||||
}
|
||||
|
||||
// Ensure directory
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Download source to temp file
|
||||
inTmp := outPath + ".in"
|
||||
if err := downloadWithUA(u, inTmp); err != nil {
|
||||
// On download failure, fallback to original URL
|
||||
return u, err
|
||||
}
|
||||
defer os.Remove(inTmp)
|
||||
|
||||
// Run Python rembg script with timeout
|
||||
script := filepath.Join("scripts", "rembg_remove_bg.py")
|
||||
outTmp := outPath + ".tmp"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "python3", script, inTmp, outTmp)
|
||||
// Inherit minimal env; ensure PATH is present. If python3 not installed, this will fail.
|
||||
if err := cmd.Run(); err != nil {
|
||||
// If script missing or python not installed, simply return original FACR URL (fallback)
|
||||
_ = os.Remove(outTmp)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Move tmp to destination if non-empty
|
||||
if fi, err := os.Stat(outTmp); err == nil && fi.Size() > 0 {
|
||||
if err := os.Rename(outTmp, outPath); err == nil {
|
||||
return toPublicURL(outPath), nil
|
||||
}
|
||||
}
|
||||
_ = os.Remove(outTmp)
|
||||
return u, errors.New("rembg produced no output")
|
||||
}
|
||||
|
||||
func isFACRURL(s string) bool {
|
||||
pu, err := neturl.Parse(s)
|
||||
if err != nil {
|
||||
return strings.Contains(strings.ToLower(s), "fotbal.cz")
|
||||
}
|
||||
h := strings.ToLower(pu.Host)
|
||||
return strings.Contains(h, "fotbal.cz")
|
||||
}
|
||||
|
||||
// facrKeyFromURL tries to extract a stable key (team UUID) from known FACR paths; falls back to sha1(url).
|
||||
func facrKeyFromURL(s string) string {
|
||||
// Try to parse URL path and find /media/kluby/<id>/
|
||||
if u, err := neturl.Parse(s); err == nil {
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
if parts[i] == "kluby" && i+1 < len(parts) {
|
||||
cand := strings.TrimSpace(parts[i+1])
|
||||
if cand != "" {
|
||||
return sanitizeFileKey(cand)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some search images may embed id in filename like <id>_crop.jpg
|
||||
base := filepath.Base(u.Path)
|
||||
if idx := strings.Index(strings.ToLower(base), "_crop"); idx > 0 {
|
||||
return sanitizeFileKey(base[:idx])
|
||||
}
|
||||
}
|
||||
// Fallback: sha1 of URL
|
||||
h := sha1.Sum([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func sanitizeFileKey(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
// Keep alphanum and dashes only
|
||||
cleaned := make([]rune, 0, len(s))
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
cleaned = append(cleaned, r)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
return string(cleaned)
|
||||
}
|
||||
|
||||
func toPublicURL(path string) string {
|
||||
// Assumes files live under ./uploads
|
||||
// Convert absolute/relative FS path to /uploads/...
|
||||
i := strings.Index(path, string(filepath.Separator)+"uploads"+string(filepath.Separator))
|
||||
if i >= 0 {
|
||||
rel := path[i+1:]
|
||||
return "/" + filepath.ToSlash(rel)
|
||||
}
|
||||
// Try to find trailing uploads/... even if base dir differs
|
||||
idx := strings.Index(strings.ReplaceAll(path, "\\", "/"), "/uploads/")
|
||||
if idx >= 0 {
|
||||
return path[idx:]
|
||||
}
|
||||
// Fallback: cannot compute public URL
|
||||
return path
|
||||
}
|
||||
|
||||
func downloadWithUA(src, outPath string) error {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, err := http.NewRequest("GET", src, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "fotbal-club/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(outPath)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
)
|
||||
|
||||
// EnsureFACRLogosProcessed triggers post-processing of FACR logos referenced
|
||||
// in cached prefetch JSON files (facr_club_info.json, facr_tables.json).
|
||||
// It is idempotent and safe to call multiple times.
|
||||
func EnsureFACRLogosProcessed(cacheDir string) error {
|
||||
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
clubInfo := filepath.Join(cacheDir, "facr_club_info.json")
|
||||
if _, err := os.Stat(clubInfo); err == nil {
|
||||
if err := postProcessFACRClubInfoLogos(cacheDir); err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("club_info: %w", err)
|
||||
}
|
||||
}
|
||||
tables := filepath.Join(cacheDir, "facr_tables.json")
|
||||
if _, err := os.Stat(tables); err == nil {
|
||||
if err := postProcessFACRTablesLogos(cacheDir); err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("tables: %w", err)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// progress state for rembg batch
|
||||
var rembgMu sync.RWMutex
|
||||
var rembgRunning bool
|
||||
var rembgTotal int
|
||||
var rembgDone int
|
||||
var rembgStarted time.Time
|
||||
var rembgFinished *time.Time
|
||||
|
||||
// RembgStatus represents current background removal progress
|
||||
type RembgStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetRembgStatus returns a snapshot of current rembg batch progress
|
||||
func GetRembgStatus() RembgStatus {
|
||||
rembgMu.RLock()
|
||||
defer rembgMu.RUnlock()
|
||||
return RembgStatus{
|
||||
Running: rembgRunning,
|
||||
Total: rembgTotal,
|
||||
Done: rembgDone,
|
||||
StartedAt: rembgStarted,
|
||||
FinishedAt: rembgFinished,
|
||||
}
|
||||
}
|
||||
|
||||
// StartFACRLogosBatch scans cached FACR JSONs and processes all referenced FACR logos
|
||||
// using ProcessFACRLogo. Progress is tracked via GetRembgStatus. If a batch is already
|
||||
// running, this call is a no-op and returns false. Otherwise returns true and starts
|
||||
// a background goroutine.
|
||||
func StartFACRLogosBatch(cacheDir string) bool {
|
||||
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
|
||||
return false
|
||||
}
|
||||
rembgMu.Lock()
|
||||
if rembgRunning {
|
||||
rembgMu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Collect unique FACR logo URLs to determine total
|
||||
urls := collectFACRLogos(cacheDir)
|
||||
rembgRunning = true
|
||||
rembgTotal = len(urls)
|
||||
rembgDone = 0
|
||||
rembgStarted = time.Now()
|
||||
rembgFinished = nil
|
||||
rembgMu.Unlock()
|
||||
|
||||
if len(urls) == 0 {
|
||||
// nothing to do; mark finished quickly
|
||||
rembgMu.Lock()
|
||||
rembgRunning = false
|
||||
now := time.Now()
|
||||
rembgFinished = &now
|
||||
rembgMu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
go func(urlList []string) {
|
||||
defer func() {
|
||||
// Best-effort: after processing, ensure JSONs are rewritten to point to processed URLs
|
||||
_ = EnsureFACRLogosProcessed(cacheDir)
|
||||
rembgMu.Lock()
|
||||
rembgRunning = false
|
||||
now := time.Now()
|
||||
rembgFinished = &now
|
||||
rembgMu.Unlock()
|
||||
}()
|
||||
for _, u := range urlList {
|
||||
_, _ = ProcessFACRLogo(u) // best effort; skip errors
|
||||
rembgMu.Lock()
|
||||
rembgDone++
|
||||
rembgMu.Unlock()
|
||||
}
|
||||
}(urls)
|
||||
return true
|
||||
}
|
||||
|
||||
// collectFACRLogos returns unique FACR-hosted logo URLs from cached prefetch JSONs.
|
||||
func collectFACRLogos(cacheDir string) []string {
|
||||
uniq := map[string]struct{}{}
|
||||
add := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
// Only FACR sources; skip already-processed local /uploads and other hosts
|
||||
ls := strings.ToLower(s)
|
||||
if strings.HasPrefix(ls, "/uploads/") || strings.HasPrefix(ls, "/dist/") {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(ls, "fotbal.cz") {
|
||||
return
|
||||
}
|
||||
uniq[s] = struct{}{}
|
||||
}
|
||||
// facr_club_info.json
|
||||
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_club_info.json")); err == nil {
|
||||
var payload struct {
|
||||
Competitions []struct {
|
||||
Matches []struct {
|
||||
HomeLogoURL string `json:"home_logo_url"`
|
||||
AwayLogoURL string `json:"away_logo_url"`
|
||||
} `json:"matches"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if json.Unmarshal(b, &payload) == nil {
|
||||
for _, c := range payload.Competitions {
|
||||
for _, m := range c.Matches {
|
||||
add(m.HomeLogoURL)
|
||||
add(m.AwayLogoURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// facr_tables.json
|
||||
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_tables.json")); err == nil {
|
||||
var payload struct {
|
||||
Competitions []struct {
|
||||
Table struct {
|
||||
Overall []struct {
|
||||
TeamLogoURL string `json:"team_logo_url"`
|
||||
} `json:"overall"`
|
||||
} `json:"table"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if json.Unmarshal(b, &payload) == nil {
|
||||
for _, c := range payload.Competitions {
|
||||
for _, r := range c.Table.Overall {
|
||||
add(r.TeamLogoURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(uniq))
|
||||
for k := range uniq {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
)
|
||||
|
||||
// StartPrefetcher starts a background job that periodically fetches
|
||||
@@ -81,7 +83,7 @@ func fetchZonerama(link string) error {
|
||||
|
||||
// Increase timeout to 60s since the API can take longer to fetch
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
|
||||
// Retry logic with exponential backoff (3 attempts)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
@@ -114,7 +116,7 @@ func fetchZonerama(link string) error {
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,7 +167,7 @@ func fetchZonerama(link string) error {
|
||||
}
|
||||
|
||||
log.Printf("[prefetch] Zonerama: Fetching %d albums with photos...", len(profile.Albums))
|
||||
|
||||
|
||||
// Fetch individual albums with photos
|
||||
photoLimit := envInt("ZONERAMA_PHOTO_LIMIT", 50) // Default 50 photos per album
|
||||
fetchedAlbums, err := fetchZoneramaAlbums(profile.Albums, photoLimit, client)
|
||||
@@ -223,7 +225,7 @@ func fetchZoneramaAlbums(albums []struct {
|
||||
// Fetch album with photos
|
||||
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
|
||||
url.QueryEscape(album.URL), photoLimit)
|
||||
|
||||
|
||||
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
@@ -238,7 +240,7 @@ func fetchZoneramaAlbums(albums []struct {
|
||||
log.Printf("[prefetch] Zonerama: Failed to fetch album %s: %v", album.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("[prefetch] Zonerama: Album %s returned status %d", album.ID, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
@@ -255,9 +257,9 @@ func fetchZoneramaAlbums(albums []struct {
|
||||
|
||||
albumData.FetchedAt = fetchedAt
|
||||
result = append(result, albumData)
|
||||
|
||||
|
||||
log.Printf("[prefetch] Zonerama: Album %s fetched with %d photos", albumData.ID, len(albumData.Photos))
|
||||
|
||||
|
||||
// Small delay between requests to avoid overwhelming the API
|
||||
if i < len(albums)-1 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
@@ -506,28 +508,28 @@ func PrefetchOnce(baseURL string) {
|
||||
|
||||
// doPrefetchCycle performs one full prefetch cycle: static public endpoints + dynamic FACR endpoints.
|
||||
func doPrefetchCycle(client *http.Client, baseURL string) {
|
||||
cacheDir := filepath.Join("cache", "prefetch")
|
||||
_ = os.MkdirAll(cacheDir, 0o755)
|
||||
start := time.Now()
|
||||
// Track endpoint fetch statuses for admin/debugging payload
|
||||
type epStatus struct {
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
var statuses []epStatus
|
||||
cacheDir := filepath.Join("cache", "prefetch")
|
||||
_ = os.MkdirAll(cacheDir, 0o755)
|
||||
start := time.Now()
|
||||
// Track endpoint fetch statuses for admin/debugging payload
|
||||
type epStatus struct {
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
var statuses []epStatus
|
||||
|
||||
// 1) Static public endpoints
|
||||
endpoints := map[string]string{
|
||||
"/settings": "settings.json",
|
||||
"/seo": "seo.json",
|
||||
"/articles?page=1&page_size=10&published=true": "articles.json",
|
||||
"/sponsors": "sponsors.json",
|
||||
"/events/upcoming": "events_upcoming.json",
|
||||
"/public/team-logo-overrides": "team_logo_overrides.json",
|
||||
"/competition-aliases": "competition_aliases.json",
|
||||
}
|
||||
// 1) Static public endpoints
|
||||
endpoints := map[string]string{
|
||||
"/settings": "settings.json",
|
||||
"/seo": "seo.json",
|
||||
"/articles?page=1&page_size=10&published=true": "articles.json",
|
||||
"/sponsors": "sponsors.json",
|
||||
"/events/upcoming": "events_upcoming.json",
|
||||
"/public/team-logo-overrides": "team_logo_overrides.json",
|
||||
"/competition-aliases": "competition_aliases.json",
|
||||
}
|
||||
statuses = make([]epStatus, 0, len(endpoints))
|
||||
for path, file := range endpoints {
|
||||
url := baseURL + path
|
||||
@@ -542,82 +544,96 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare matches.json from FACR data if available; fallback to events_upcoming.json
|
||||
createdMatches := false
|
||||
buildMatchesFromFACR := func(data []byte) bool {
|
||||
type facrMatch struct {
|
||||
DateTime string `json:"date_time"`
|
||||
Home string `json:"home"`
|
||||
Away string `json:"away"`
|
||||
Venue string `json:"venue"`
|
||||
HomeLogoURL string `json:"home_logo_url"`
|
||||
AwayLogoURL string `json:"away_logo_url"`
|
||||
MatchID string `json:"match_id"`
|
||||
}
|
||||
var facr struct {
|
||||
Competitions []struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Matches []facrMatch `json:"matches"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &facr); err != nil {
|
||||
return false
|
||||
}
|
||||
// Collect upcoming matches (next 30 days) in simplified format
|
||||
now := time.Now()
|
||||
max := now.Add(30 * 24 * time.Hour)
|
||||
var out []map[string]any
|
||||
for _, c := range facr.Competitions {
|
||||
compName := strings.TrimSpace(c.Name)
|
||||
if compName == "" {
|
||||
compName = strings.TrimSpace(c.Code)
|
||||
}
|
||||
for _, m := range c.Matches {
|
||||
dt := strings.TrimSpace(m.DateTime)
|
||||
if dt == "" {
|
||||
continue
|
||||
}
|
||||
// dt like "12.08.2023 18:00"
|
||||
parts := strings.SplitN(dt, " ", 2)
|
||||
d := parts[0]
|
||||
t := ""
|
||||
if len(parts) > 1 { t = parts[1] }
|
||||
// parse date dd.mm.yyyy
|
||||
dd := strings.Split(d, ".")
|
||||
if len(dd) < 3 { continue }
|
||||
day := dd[0]
|
||||
month := dd[1]
|
||||
year := dd[2]
|
||||
if len(month) == 1 { month = "0" + month }
|
||||
if len(day) == 1 { day = "0" + day }
|
||||
isoDate := year + "-" + month + "-" + day
|
||||
if len(t) >= 5 {
|
||||
t = t[:5]
|
||||
} else {
|
||||
t = "18:00"
|
||||
}
|
||||
// Build time.Time for filtering
|
||||
ts, err := time.ParseInLocation("2006-01-02 15:04", isoDate+" "+t, time.Local)
|
||||
if err != nil { continue }
|
||||
if ts.Before(now) || ts.After(max) { continue }
|
||||
out = append(out, map[string]any{
|
||||
"id": m.MatchID,
|
||||
"home": m.Home,
|
||||
"away": m.Away,
|
||||
"competition": compName,
|
||||
"date": isoDate,
|
||||
"time": t,
|
||||
"venue": m.Venue,
|
||||
"home_logo_url": m.HomeLogoURL,
|
||||
"away_logo_url": m.AwayLogoURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 { return false }
|
||||
_ = writeJSONAtomic(filepath.Join(cacheDir, "matches.json"), out)
|
||||
return true
|
||||
}
|
||||
// Prepare matches.json from FACR data if available; fallback to events_upcoming.json
|
||||
createdMatches := false
|
||||
buildMatchesFromFACR := func(data []byte) bool {
|
||||
type facrMatch struct {
|
||||
DateTime string `json:"date_time"`
|
||||
Home string `json:"home"`
|
||||
Away string `json:"away"`
|
||||
Venue string `json:"venue"`
|
||||
HomeLogoURL string `json:"home_logo_url"`
|
||||
AwayLogoURL string `json:"away_logo_url"`
|
||||
MatchID string `json:"match_id"`
|
||||
}
|
||||
var facr struct {
|
||||
Competitions []struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Matches []facrMatch `json:"matches"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &facr); err != nil {
|
||||
return false
|
||||
}
|
||||
// Collect upcoming matches (next 30 days) in simplified format
|
||||
now := time.Now()
|
||||
max := now.Add(30 * 24 * time.Hour)
|
||||
var out []map[string]any
|
||||
for _, c := range facr.Competitions {
|
||||
compName := strings.TrimSpace(c.Name)
|
||||
if compName == "" {
|
||||
compName = strings.TrimSpace(c.Code)
|
||||
}
|
||||
for _, m := range c.Matches {
|
||||
dt := strings.TrimSpace(m.DateTime)
|
||||
if dt == "" {
|
||||
continue
|
||||
}
|
||||
// dt like "12.08.2023 18:00"
|
||||
parts := strings.SplitN(dt, " ", 2)
|
||||
d := parts[0]
|
||||
t := ""
|
||||
if len(parts) > 1 {
|
||||
t = parts[1]
|
||||
}
|
||||
// parse date dd.mm.yyyy
|
||||
dd := strings.Split(d, ".")
|
||||
if len(dd) < 3 {
|
||||
continue
|
||||
}
|
||||
day := dd[0]
|
||||
month := dd[1]
|
||||
year := dd[2]
|
||||
if len(month) == 1 {
|
||||
month = "0" + month
|
||||
}
|
||||
if len(day) == 1 {
|
||||
day = "0" + day
|
||||
}
|
||||
isoDate := year + "-" + month + "-" + day
|
||||
if len(t) >= 5 {
|
||||
t = t[:5]
|
||||
} else {
|
||||
t = "18:00"
|
||||
}
|
||||
// Build time.Time for filtering
|
||||
ts, err := time.ParseInLocation("2006-01-02 15:04", isoDate+" "+t, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ts.Before(now) || ts.After(max) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": m.MatchID,
|
||||
"home": m.Home,
|
||||
"away": m.Away,
|
||||
"competition": compName,
|
||||
"date": isoDate,
|
||||
"time": t,
|
||||
"venue": m.Venue,
|
||||
"home_logo_url": m.HomeLogoURL,
|
||||
"away_logo_url": m.AwayLogoURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return false
|
||||
}
|
||||
_ = writeJSONAtomic(filepath.Join(cacheDir, "matches.json"), out)
|
||||
return true
|
||||
}
|
||||
|
||||
// 2) Dynamic FACR endpoints based on saved settings
|
||||
settingsPath := filepath.Join(cacheDir, "settings.json")
|
||||
@@ -651,12 +667,31 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
|
||||
statuses = append(statuses, epStatus{Path: path, File: file, Ok: false, Error: err.Error()})
|
||||
} else {
|
||||
log.Printf("[prefetch] FACR SUCCESS: updated %s", file)
|
||||
// Post-process logo URLs only when REMBG is enabled
|
||||
if config.AppConfig != nil && config.AppConfig.RembgEnabled {
|
||||
// Post-process club info logos to ensure transparent backgrounds on FACR logos
|
||||
if file == "facr_club_info.json" {
|
||||
if err := postProcessFACRClubInfoLogos(cacheDir); err != nil {
|
||||
log.Printf("[prefetch] FACR post-process WARN: %v", err)
|
||||
}
|
||||
}
|
||||
// Post-process tables logos similarly (team_logo_url)
|
||||
if file == "facr_tables.json" {
|
||||
if err := postProcessFACRTablesLogos(cacheDir); err != nil {
|
||||
log.Printf("[prefetch] FACR tables post-process WARN: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[prefetch] REMBG disabled, skipping FACR logo post-processing for %s", file)
|
||||
}
|
||||
statuses = append(statuses, epStatus{Path: path, File: file, Ok: true})
|
||||
}
|
||||
}
|
||||
// Try to build matches.json from freshly fetched FACR prefetch file
|
||||
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_club_info.json")); err == nil {
|
||||
if buildMatchesFromFACR(b) { createdMatches = true }
|
||||
if buildMatchesFromFACR(b) {
|
||||
createdMatches = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[prefetch] WARNING: FACR skipped: missing club_id=%q or club_type=%q in settings", clubID, clubType)
|
||||
@@ -666,9 +701,13 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
|
||||
if !createdMatches && clubID != "" && clubType != "" {
|
||||
facrCachePath := filepath.Join("cache", "facr", fmt.Sprintf("%s_%s_info.json", clubType, clubID))
|
||||
if b, err := os.ReadFile(facrCachePath); err == nil {
|
||||
var cached struct{ Data []byte `json:"data"` }
|
||||
var cached struct {
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(b, &cached); jsonErr == nil && len(cached.Data) > 0 {
|
||||
if buildMatchesFromFACR(cached.Data) { createdMatches = true }
|
||||
if buildMatchesFromFACR(cached.Data) {
|
||||
createdMatches = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -700,22 +739,22 @@ var prefetchInFlight int32
|
||||
var prefetchPending int32
|
||||
|
||||
func doPrefetchCycleGuarded(client *http.Client, baseURL string) {
|
||||
if !atomic.CompareAndSwapInt32(&prefetchInFlight, 0, 1) {
|
||||
// Mark a rerun so we don't lose triggers (e.g., from setup) while a cycle is running
|
||||
atomic.StoreInt32(&prefetchPending, 1)
|
||||
log.Printf("[prefetch] in-flight: marked pending rerun")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
atomic.StoreInt32(&prefetchInFlight, 0)
|
||||
// If a trigger arrived during this run, execute one more cycle immediately
|
||||
if atomic.SwapInt32(&prefetchPending, 0) == 1 {
|
||||
// Small delay to allow DB/cache writes to settle
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
doPrefetchCycleGuarded(client, baseURL)
|
||||
}
|
||||
}()
|
||||
doPrefetchCycle(client, baseURL)
|
||||
if !atomic.CompareAndSwapInt32(&prefetchInFlight, 0, 1) {
|
||||
// Mark a rerun so we don't lose triggers (e.g., from setup) while a cycle is running
|
||||
atomic.StoreInt32(&prefetchPending, 1)
|
||||
log.Printf("[prefetch] in-flight: marked pending rerun")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
atomic.StoreInt32(&prefetchInFlight, 0)
|
||||
// If a trigger arrived during this run, execute one more cycle immediately
|
||||
if atomic.SwapInt32(&prefetchPending, 0) == 1 {
|
||||
// Small delay to allow DB/cache writes to settle
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
doPrefetchCycleGuarded(client, baseURL)
|
||||
}
|
||||
}()
|
||||
doPrefetchCycle(client, baseURL)
|
||||
}
|
||||
|
||||
func isDuringMatch(cacheDir string) bool {
|
||||
@@ -935,7 +974,7 @@ func fetchYouTubeChannel(channel string) error {
|
||||
func RegenerateFlatGalleryFiles() error {
|
||||
cacheDir := filepath.Join("cache", "prefetch")
|
||||
albumsFile := filepath.Join(cacheDir, "zonerama_albums.json")
|
||||
|
||||
|
||||
// Read albums
|
||||
data, err := os.ReadFile(albumsFile)
|
||||
if err != nil {
|
||||
@@ -945,7 +984,7 @@ func RegenerateFlatGalleryFiles() error {
|
||||
emptyJSON, _ := json.MarshalIndent(emptyList, "", " ")
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, "gallery.json"), emptyJSON, 0644)
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json"), emptyJSON, 0644)
|
||||
|
||||
|
||||
hdr := map[string]string{
|
||||
"fetched_at": time.Now().Format(time.RFC3339),
|
||||
"link": "",
|
||||
@@ -956,7 +995,7 @@ func RegenerateFlatGalleryFiles() error {
|
||||
}
|
||||
return fmt.Errorf("failed to read albums: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Define album and photo structures
|
||||
type ZoneramaPhoto struct {
|
||||
ID string `json:"id"`
|
||||
@@ -970,26 +1009,26 @@ func RegenerateFlatGalleryFiles() error {
|
||||
Photos []ZoneramaPhoto `json:"photos"`
|
||||
FetchedAt string `json:"fetched_at,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
var albums []ZoneramaAlbum
|
||||
if err := json.Unmarshal(data, &albums); err != nil {
|
||||
return fmt.Errorf("failed to parse albums: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Flatten all photos from all albums
|
||||
type FlatPhoto struct {
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
PageURL string `json:"page_url"`
|
||||
Local string `json:"local"`
|
||||
Src string `json:"src"`
|
||||
Title string `json:"title"`
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
PageURL string `json:"page_url"`
|
||||
Local string `json:"local"`
|
||||
Src string `json:"src"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
|
||||
var flatPhotos []FlatPhoto
|
||||
var latestFetchTime time.Time
|
||||
var sourceLink string
|
||||
|
||||
|
||||
for _, album := range albums {
|
||||
// Track the most recent fetch time
|
||||
if album.FetchedAt != "" {
|
||||
@@ -1000,7 +1039,7 @@ func RegenerateFlatGalleryFiles() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for _, photo := range album.Photos {
|
||||
flatPhotos = append(flatPhotos, FlatPhoto{
|
||||
ID: photo.ID,
|
||||
@@ -1012,7 +1051,7 @@ func RegenerateFlatGalleryFiles() error {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Write gallery.json
|
||||
galleryJSON, err := json.MarshalIndent(flatPhotos, "", " ")
|
||||
if err != nil {
|
||||
@@ -1021,18 +1060,18 @@ func RegenerateFlatGalleryFiles() error {
|
||||
if err := os.WriteFile(filepath.Join(cacheDir, "gallery.json"), galleryJSON, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write gallery.json: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Write zonerama_flat.json (same content for compatibility)
|
||||
if err := os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json"), galleryJSON, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write zonerama_flat.json: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Write header file with metadata
|
||||
fetchedAt := latestFetchTime.Format(time.RFC3339)
|
||||
if fetchedAt == "0001-01-01T00:00:00Z" {
|
||||
fetchedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
|
||||
hdr := map[string]string{
|
||||
"fetched_at": fetchedAt,
|
||||
"link": sourceLink,
|
||||
@@ -1044,7 +1083,7 @@ func RegenerateFlatGalleryFiles() error {
|
||||
if err := os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json.hdr"), hdrJSON, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
|
||||
log.Printf("[gallery] Regenerated flat gallery files: %d photos from %d albums", len(flatPhotos), len(albums))
|
||||
return nil
|
||||
}
|
||||
@@ -1120,3 +1159,207 @@ func fetchToFile(client *http.Client, url string, outPath string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// postProcessFACRClubInfoLogos rewrites home_logo_url/away_logo_url in facr_club_info.json
|
||||
// to point to locally cached transparent PNGs produced by rembg.
|
||||
// Best-effort: logs warning on failure and preserves original file.
|
||||
func postProcessFACRClubInfoLogos(cacheDir string) error {
|
||||
p := filepath.Join(cacheDir, "facr_club_info.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Fast check: if file is tiny or empty, skip
|
||||
if len(strings.TrimSpace(string(b))) == 0 {
|
||||
return fmt.Errorf("facr_club_info.json empty")
|
||||
}
|
||||
// Parse into a minimal shape we care about
|
||||
var payload struct {
|
||||
Competitions []struct {
|
||||
Matches []struct {
|
||||
HomeLogoURL string `json:"home_logo_url"`
|
||||
AwayLogoURL string `json:"away_logo_url"`
|
||||
} `json:"matches"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &payload); err != nil {
|
||||
return fmt.Errorf("parse facr_club_info: %w", err)
|
||||
}
|
||||
// Build a map of original -> processed URL to avoid duplicate processing
|
||||
seen := make(map[string]string, 64)
|
||||
changed := false
|
||||
// Walk and rewrite
|
||||
for ci := range payload.Competitions {
|
||||
for mi := range payload.Competitions[ci].Matches {
|
||||
h := strings.TrimSpace(payload.Competitions[ci].Matches[mi].HomeLogoURL)
|
||||
if h != "" {
|
||||
if rep, ok := seen[h]; ok {
|
||||
if rep != h && rep != "" {
|
||||
payload.Competitions[ci].Matches[mi].HomeLogoURL = rep
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
if newURL, err := ProcessFACRLogo(h); err == nil && newURL != "" && newURL != h {
|
||||
seen[h] = newURL
|
||||
payload.Competitions[ci].Matches[mi].HomeLogoURL = newURL
|
||||
changed = true
|
||||
} else {
|
||||
seen[h] = h
|
||||
}
|
||||
}
|
||||
}
|
||||
a := strings.TrimSpace(payload.Competitions[ci].Matches[mi].AwayLogoURL)
|
||||
if a != "" {
|
||||
if rep, ok := seen[a]; ok {
|
||||
if rep != a && rep != "" {
|
||||
payload.Competitions[ci].Matches[mi].AwayLogoURL = rep
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
if newURL, err := ProcessFACRLogo(a); err == nil && newURL != "" && newURL != a {
|
||||
seen[a] = newURL
|
||||
payload.Competitions[ci].Matches[mi].AwayLogoURL = newURL
|
||||
changed = true
|
||||
} else {
|
||||
seen[a] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
// Merge back into original JSON to preserve other fields
|
||||
// Unmarshal original into generic map, then overlay changes
|
||||
var orig map[string]any
|
||||
if err := json.Unmarshal(b, &orig); err != nil {
|
||||
return fmt.Errorf("parse orig for merge: %w", err)
|
||||
}
|
||||
// Rebuild competitions with rewritten logos
|
||||
if comps, ok := orig["competitions"].([]any); ok {
|
||||
for i := range comps {
|
||||
comp, _ := comps[i].(map[string]any)
|
||||
if comp == nil {
|
||||
continue
|
||||
}
|
||||
if matches, ok2 := comp["matches"].([]any); ok2 {
|
||||
for j := range matches {
|
||||
m, _ := matches[j].(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// Safely get strings
|
||||
hv := ""
|
||||
av := ""
|
||||
if s, ok3 := m["home_logo_url"].(string); ok3 {
|
||||
hv = s
|
||||
}
|
||||
if s, ok3 := m["away_logo_url"].(string); ok3 {
|
||||
av = s
|
||||
}
|
||||
if rep, ok3 := seen[hv]; ok3 && rep != "" && rep != hv {
|
||||
m["home_logo_url"] = rep
|
||||
}
|
||||
if rep, ok3 := seen[av]; ok3 && rep != "" && rep != av {
|
||||
m["away_logo_url"] = rep
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Write back atomically
|
||||
if err := writeJSONAtomic(p, orig); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// postProcessFACRTablesLogos rewrites team_logo_url entries in facr_tables.json
|
||||
// to point to locally cached transparent PNGs produced by rembg.
|
||||
// Best-effort: logs warning on failure and preserves original file.
|
||||
func postProcessFACRTablesLogos(cacheDir string) error {
|
||||
p := filepath.Join(cacheDir, "facr_tables.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Fast check: if file is tiny or empty, skip
|
||||
if len(strings.TrimSpace(string(b))) == 0 {
|
||||
return fmt.Errorf("facr_tables.json empty")
|
||||
}
|
||||
// Parse minimal shape
|
||||
var payload struct {
|
||||
Competitions []struct {
|
||||
Table struct {
|
||||
Overall []struct {
|
||||
TeamLogoURL string `json:"team_logo_url"`
|
||||
} `json:"overall"`
|
||||
} `json:"table"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &payload); err != nil {
|
||||
return fmt.Errorf("parse facr_tables: %w", err)
|
||||
}
|
||||
seen := make(map[string]string, 64)
|
||||
changed := false
|
||||
for ci := range payload.Competitions {
|
||||
rows := payload.Competitions[ci].Table.Overall
|
||||
for ri := range rows {
|
||||
s := strings.TrimSpace(rows[ri].TeamLogoURL)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if rep, ok := seen[s]; ok {
|
||||
if rep != "" && rep != s {
|
||||
payload.Competitions[ci].Table.Overall[ri].TeamLogoURL = rep
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
if purl, err := ProcessFACRLogo(s); err == nil && strings.TrimSpace(purl) != "" && purl != s {
|
||||
seen[s] = purl
|
||||
payload.Competitions[ci].Table.Overall[ri].TeamLogoURL = purl
|
||||
changed = true
|
||||
} else {
|
||||
seen[s] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
// Merge back into original JSON preserving other fields
|
||||
var orig map[string]any
|
||||
if err := json.Unmarshal(b, &orig); err != nil {
|
||||
return fmt.Errorf("parse orig for merge: %w", err)
|
||||
}
|
||||
if comps, ok := orig["competitions"].([]any); ok {
|
||||
for i := range comps {
|
||||
comp, _ := comps[i].(map[string]any)
|
||||
if comp == nil {
|
||||
continue
|
||||
}
|
||||
tbl, _ := comp["table"].(map[string]any)
|
||||
if tbl == nil {
|
||||
continue
|
||||
}
|
||||
overall, _ := tbl["overall"].([]any)
|
||||
for j := range overall {
|
||||
row, _ := overall[j].(map[string]any)
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok3 := row["team_logo_url"].(string); ok3 {
|
||||
if rep, ok := seen[s]; ok && rep != "" && rep != s {
|
||||
row["team_logo_url"] = rep
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := writeJSONAtomic(p, orig); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user