mirror of
https://github.com/Dvorinka/bizoni.git
synced 2026-06-03 18:22:57 +00:00
Compare commits
7 Commits
2f65bc03e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a89d6e1a63 | |||
| 60a4b82931 | |||
| efa35518ab | |||
| cc6841e723 | |||
| 4c904a1546 | |||
| 76c447a395 | |||
| a6b47de1a4 |
@@ -234,7 +234,13 @@
|
|||||||
s.textContent = 'Načítám…';
|
s.textContent = 'Načítám…';
|
||||||
const res = await fetch('/api/blog/latest?limit=12');
|
const res = await fetch('/api/blog/latest?limit=12');
|
||||||
if (!res.ok) throw new Error('HTTP '+res.status);
|
if (!res.ok) throw new Error('HTTP '+res.status);
|
||||||
const items = await res.json();
|
let items = await res.json();
|
||||||
|
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
|
||||||
|
items.sort((a,b)=>{
|
||||||
|
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
|
||||||
|
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
|
||||||
|
return (b.id||'').localeCompare(a.id||'');
|
||||||
|
});
|
||||||
grid.innerHTML='';
|
grid.innerHTML='';
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
grid.innerHTML = '<div class="muted">Žádné příspěvky.</div>';
|
grid.innerHTML = '<div class="muted">Žádné příspěvky.</div>';
|
||||||
|
|||||||
+7
-1
@@ -53,7 +53,13 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/blog/latest?limit=12');
|
const res = await fetch('/api/blog/latest?limit=12');
|
||||||
if (!res.ok) throw new Error('HTTP '+res.status);
|
if (!res.ok) throw new Error('HTTP '+res.status);
|
||||||
const items = await res.json();
|
let items = await res.json();
|
||||||
|
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
|
||||||
|
items.sort((a,b)=>{
|
||||||
|
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
|
||||||
|
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
|
||||||
|
return (b.id||'').localeCompare(a.id||'');
|
||||||
|
});
|
||||||
status.textContent = `Nalezeno: ${items.length}`;
|
status.textContent = `Nalezeno: ${items.length}`;
|
||||||
mount.innerHTML = '';
|
mount.innerHTML = '';
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
|||||||
+123
-54
@@ -30,6 +30,9 @@ const (
|
|||||||
clubID = "441d3783-06aa-436a-b438-359300ee0371"
|
clubID = "441d3783-06aa-436a-b438-359300ee0371"
|
||||||
clubType = "futsal"
|
clubType = "futsal"
|
||||||
baseURL = "https://facr.tdvorak.dev"
|
baseURL = "https://facr.tdvorak.dev"
|
||||||
|
fallbackBaseURL = "https://flashscore.tdvorak.dev"
|
||||||
|
fallbackClubID = "xzS3gX3T"
|
||||||
|
fallbackSlug = "uherske-hradiste"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
@@ -330,6 +333,14 @@ func refreshVideos(ctx context.Context) error {
|
|||||||
return fmt.Errorf("yt get: %w", err)
|
return fmt.Errorf("yt get: %w", err)
|
||||||
}
|
}
|
||||||
items := resp.Videos
|
items := resp.Videos
|
||||||
|
if len(items) == 0 {
|
||||||
|
// API returned empty data; keep existing local videos as fallback
|
||||||
|
vc.mu.RLock()
|
||||||
|
existingCount := len(vc.data.Items)
|
||||||
|
vc.mu.RUnlock()
|
||||||
|
log.Printf("warn: yt api returned 0 videos; keeping %d existing videos", existingCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if len(items) > 5 {
|
if len(items) > 5 {
|
||||||
items = items[:5]
|
items = items[:5]
|
||||||
}
|
}
|
||||||
@@ -397,6 +408,28 @@ func writeVideosJSON() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadVideosJSON() error {
|
||||||
|
path := videosPath()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read videos json: %w", err)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Items []YTVideo `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &payload); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal videos json: %w", err)
|
||||||
|
}
|
||||||
|
vc.mu.Lock()
|
||||||
|
vc.data.FetchedAt = payload.FetchedAt
|
||||||
|
vc.data.Channel = payload.Channel
|
||||||
|
vc.data.Items = payload.Items
|
||||||
|
vc.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func videosPath() string {
|
func videosPath() string {
|
||||||
if p := os.Getenv("VIDEOS_PATH"); p != "" {
|
if p := os.Getenv("VIDEOS_PATH"); p != "" {
|
||||||
return p
|
return p
|
||||||
@@ -588,7 +621,6 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
|||||||
}
|
}
|
||||||
// Match both numeric (0001.html) and slug-based filenames
|
// Match both numeric (0001.html) and slug-based filenames
|
||||||
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
|
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
|
||||||
numericRe := regexp.MustCompile(`^\d{4}$`)
|
|
||||||
var items []BlogItem
|
var items []BlogItem
|
||||||
seenIDs := make(map[string]bool) // Track seen IDs to avoid duplicates
|
seenIDs := make(map[string]bool) // Track seen IDs to avoid duplicates
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@@ -598,24 +630,37 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
|||||||
}
|
}
|
||||||
id := strings.TrimSuffix(name, ".html")
|
id := strings.TrimSuffix(name, ".html")
|
||||||
|
|
||||||
// Skip if this ID was already processed (deduplication)
|
|
||||||
if seenIDs[id] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title and categories extraction from blog HTML
|
// Title and categories extraction from blog HTML
|
||||||
blogPath := filepath.Join(blogDir, name)
|
blogPath := filepath.Join(blogDir, name)
|
||||||
title := extractTitle(blogPath)
|
title := extractTitle(blogPath)
|
||||||
slug := extractSlug(blogPath, name)
|
slug := extractSlug(blogPath, name)
|
||||||
cats := extractCategories(blogPath)
|
cats := extractCategories(blogPath)
|
||||||
|
|
||||||
// Mark this ID as seen
|
// Determine canonical ID: prefer numeric when available.
|
||||||
seenIDs[id] = true
|
// This ensures consistent item identity regardless of whether the
|
||||||
// Also mark slug/numeric counterpart to prevent duplicates
|
// numeric or slug filename is encountered first in the directory.
|
||||||
if slug != "" && slug != id {
|
canonicalID := id
|
||||||
seenIDs[slug] = true
|
if regexp.MustCompile(`^[a-z]`).MatchString(id) {
|
||||||
|
numericFiles, _ := filepath.Glob(filepath.Join(blogDir, "????.html"))
|
||||||
|
for _, numericFile := range numericFiles {
|
||||||
|
numericID := strings.TrimSuffix(filepath.Base(numericFile), ".html")
|
||||||
|
numericPath := filepath.Join(blogDir, numericFile)
|
||||||
|
numericSlug := extractSlug(numericPath, numericFile)
|
||||||
|
if numericSlug == id {
|
||||||
|
canonicalID = numericID
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if numericRe.MatchString(id) && slug != "" {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if this canonical ID or its slug was already processed (deduplication)
|
||||||
|
if seenIDs[canonicalID] || (slug != "" && seenIDs[slug]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark both canonical ID and slug as seen
|
||||||
|
seenIDs[canonicalID] = true
|
||||||
|
if slug != "" {
|
||||||
seenIDs[slug] = true
|
seenIDs[slug] = true
|
||||||
}
|
}
|
||||||
// Determine mod time - prefer image modtime if exists, else html
|
// Determine mod time - prefer image modtime if exists, else html
|
||||||
@@ -624,36 +669,23 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
|||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
mtime = htmlInfo.ModTime()
|
mtime = htmlInfo.ModTime()
|
||||||
}
|
}
|
||||||
// For image path, try to find corresponding numeric ID
|
// For image path, canonicalID is already numeric when a numeric file exists
|
||||||
imageID := id
|
imageID := canonicalID
|
||||||
if regexp.MustCompile(`^[a-z]`).MatchString(id) {
|
|
||||||
// This is a slug, try to find corresponding numeric file
|
|
||||||
numericFiles, _ := filepath.Glob(filepath.Join(blogDir, "????.html"))
|
|
||||||
for _, numericFile := range numericFiles {
|
|
||||||
numericID := strings.TrimSuffix(filepath.Base(numericFile), ".html")
|
|
||||||
numericPath := filepath.Join(blogDir, numericFile)
|
|
||||||
numericSlug := extractSlug(numericPath, numericFile)
|
|
||||||
if numericSlug == id {
|
|
||||||
imageID = numericID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imgInfo, err2 := os.Stat(filepath.Join(imgDir, imageID+".png")); err2 == nil {
|
if imgInfo, err2 := os.Stat(filepath.Join(imgDir, imageID+".png")); err2 == nil {
|
||||||
// If image is newer, use that as a proxy for recency
|
// If image is newer, use that as a proxy for recency
|
||||||
if imgInfo.ModTime().After(mtime) {
|
if imgInfo.ModTime().After(mtime) {
|
||||||
mtime = imgInfo.ModTime()
|
mtime = imgInfo.ModTime()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Use slug-based link if slug exists and is not just numeric, otherwise use numeric
|
// Use slug-based link if slug exists and is not just numeric, otherwise use canonical numeric ID
|
||||||
var link string
|
var link string
|
||||||
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
|
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
|
||||||
link = "/blog/" + slug
|
link = "/blog/" + slug
|
||||||
} else {
|
} else {
|
||||||
link = "/blog/" + id + ".html"
|
link = "/blog/" + canonicalID + ".html"
|
||||||
}
|
}
|
||||||
items = append(items, BlogItem{
|
items = append(items, BlogItem{
|
||||||
ID: id,
|
ID: canonicalID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Link: link,
|
Link: link,
|
||||||
@@ -663,32 +695,25 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(items, func(i, j int) bool {
|
sort.Slice(items, func(i, j int) bool {
|
||||||
// Check if files were recently processed (all have same timestamp from setup script)
|
// Always prefer numeric ID descending (higher ID = newer post).
|
||||||
recentThreshold := time.Now().Add(-24 * time.Hour)
|
// Numeric IDs monotonically increase via nextBlogID, so they are
|
||||||
allRecent := items[i].MTime.After(recentThreshold) && items[j].MTime.After(recentThreshold)
|
// the authoritative ordering regardless of file MTime.
|
||||||
|
|
||||||
if allRecent {
|
|
||||||
// If both files are recent (from setup script), sort by numeric ID (higher = newer)
|
|
||||||
ii, err1 := strconv.Atoi(items[i].ID)
|
ii, err1 := strconv.Atoi(items[i].ID)
|
||||||
jj, err2 := strconv.Atoi(items[j].ID)
|
jj, err2 := strconv.Atoi(items[j].ID)
|
||||||
if err1 == nil && err2 == nil {
|
if err1 == nil && err2 == nil {
|
||||||
return ii > jj
|
return ii > jj
|
||||||
}
|
}
|
||||||
// If not numeric, fall back to string comparison
|
// If only one item has a numeric ID, it is newer
|
||||||
return items[i].ID > items[j].ID
|
if err1 == nil {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
if err2 == nil {
|
||||||
// Otherwise, use modification time (newest first)
|
return false
|
||||||
|
}
|
||||||
|
// Both non-numeric: fall back to MTime, then string comparison
|
||||||
if !items[i].MTime.Equal(items[j].MTime) {
|
if !items[i].MTime.Equal(items[j].MTime) {
|
||||||
return items[i].MTime.After(items[j].MTime)
|
return items[i].MTime.After(items[j].MTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If times are equal and not recent, fallback to numeric ID
|
|
||||||
ii, err1 := strconv.Atoi(items[i].ID)
|
|
||||||
jj, err2 := strconv.Atoi(items[j].ID)
|
|
||||||
if err1 == nil && err2 == nil {
|
|
||||||
return ii > jj
|
|
||||||
}
|
|
||||||
return items[i].ID > items[j].ID
|
return items[i].ID > items[j].ID
|
||||||
})
|
})
|
||||||
if limit > 0 && len(items) > limit {
|
if limit > 0 && len(items) > limit {
|
||||||
@@ -697,6 +722,26 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateBlogOrdering performs a startup health check to catch ordering regressions early.
|
||||||
|
func validateBlogOrdering(siteRoot string) error {
|
||||||
|
items, err := listLatestBlogs(siteRoot, 12)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := 0; i < len(items)-1; i++ {
|
||||||
|
ii, e1 := strconv.Atoi(items[i].ID)
|
||||||
|
jj, e2 := strconv.Atoi(items[i+1].ID)
|
||||||
|
if e1 == nil && e2 == nil && ii <= jj {
|
||||||
|
return fmt.Errorf("blog ordering suspect: %s (%d) should be newer than %s (%d)", items[i].ID, ii, items[i+1].ID, jj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("blog ordering validated: newest=%s total=%d", items[0].ID, len(items))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractTitle finds the first <h1>...</h1> and returns its inner text (very simple, best-effort)
|
// extractTitle finds the first <h1>...</h1> and returns its inner text (very simple, best-effort)
|
||||||
func extractTitle(path string) string {
|
func extractTitle(path string) string {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
@@ -832,11 +877,21 @@ func main() {
|
|||||||
go scheduler(ctx)
|
go scheduler(ctx)
|
||||||
go videosScheduler(ctx)
|
go videosScheduler(ctx)
|
||||||
|
|
||||||
|
// Load previously persisted videos so we have a fallback if yt api fails
|
||||||
|
if err := loadVideosJSON(); err != nil {
|
||||||
|
log.Printf("no persisted videos to load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initial videos fetch on startup to warm cache
|
// Initial videos fetch on startup to warm cache
|
||||||
if err := refreshVideos(ctx); err != nil {
|
if err := refreshVideos(ctx); err != nil {
|
||||||
log.Printf("initial videos refresh error: %v", err)
|
log.Printf("initial videos refresh error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Startup validation: warn if blog ordering looks wrong
|
||||||
|
if err := validateBlogOrdering(staticPath()); err != nil {
|
||||||
|
log.Printf("blog ordering validation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
okCORS(w)
|
okCORS(w)
|
||||||
@@ -1600,13 +1655,27 @@ func refresh(ctx context.Context) error {
|
|||||||
urlTable := fmt.Sprintf("%s/club/%s/%s/table", baseURL, clubType, clubID)
|
urlTable := fmt.Sprintf("%s/club/%s/%s/table", baseURL, clubType, clubID)
|
||||||
|
|
||||||
var detail ClubDetail
|
var detail ClubDetail
|
||||||
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
|
||||||
return fmt.Errorf("detail: %w", err)
|
|
||||||
}
|
|
||||||
var table ClubTable
|
var table ClubTable
|
||||||
|
var activeClubID string
|
||||||
|
|
||||||
|
// Try primary API first
|
||||||
|
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
||||||
|
log.Printf("primary api detail failed (%v), trying fallback", err)
|
||||||
|
urlDetail = fmt.Sprintf("%s/club/%s/%s?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
|
||||||
|
urlTable = fmt.Sprintf("%s/club/%s/%s/table?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
|
||||||
|
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
||||||
|
return fmt.Errorf("fallback detail: %w", err)
|
||||||
|
}
|
||||||
|
if err := getJSON(ctx, client, urlTable, &table); err != nil {
|
||||||
|
return fmt.Errorf("fallback table: %w", err)
|
||||||
|
}
|
||||||
|
activeClubID = fallbackClubID
|
||||||
|
} else {
|
||||||
if err := getJSON(ctx, client, urlTable, &table); err != nil {
|
if err := getJSON(ctx, client, urlTable, &table); err != nil {
|
||||||
return fmt.Errorf("table: %w", err)
|
return fmt.Errorf("table: %w", err)
|
||||||
}
|
}
|
||||||
|
activeClubID = clubID
|
||||||
|
}
|
||||||
|
|
||||||
// Override or inject facr_link based on match_id
|
// Override or inject facr_link based on match_id
|
||||||
for i := range detail.Competitions {
|
for i := range detail.Competitions {
|
||||||
@@ -1616,10 +1685,10 @@ func refresh(ctx context.Context) error {
|
|||||||
detail.Competitions[i].Matches[j].FacrLink = fmt.Sprintf("https://www.fotbal.cz/futsal/zapasy/futsal/%s", mid)
|
detail.Competitions[i].Matches[j].FacrLink = fmt.Sprintf("https://www.fotbal.cz/futsal/zapasy/futsal/%s", mid)
|
||||||
}
|
}
|
||||||
// Override logo URLs for our club in match details
|
// Override logo URLs for our club in match details
|
||||||
if detail.Competitions[i].Matches[j].HomeID == clubID {
|
if detail.Competitions[i].Matches[j].HomeID == activeClubID {
|
||||||
detail.Competitions[i].Matches[j].HomeLogoURL = "/img/logo.png"
|
detail.Competitions[i].Matches[j].HomeLogoURL = "/img/logo.png"
|
||||||
}
|
}
|
||||||
if detail.Competitions[i].Matches[j].AwayID == clubID {
|
if detail.Competitions[i].Matches[j].AwayID == activeClubID {
|
||||||
detail.Competitions[i].Matches[j].AwayLogoURL = "/img/logo.png"
|
detail.Competitions[i].Matches[j].AwayLogoURL = "/img/logo.png"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1628,7 +1697,7 @@ func refresh(ctx context.Context) error {
|
|||||||
// Override logo URLs for our club in the table standings
|
// Override logo URLs for our club in the table standings
|
||||||
for i := range table.Competitions {
|
for i := range table.Competitions {
|
||||||
for j := range table.Competitions[i].Table.Overall {
|
for j := range table.Competitions[i].Table.Overall {
|
||||||
if table.Competitions[i].Table.Overall[j].TeamID == clubID {
|
if table.Competitions[i].Table.Overall[j].TeamID == activeClubID {
|
||||||
table.Competitions[i].Table.Overall[j].TeamLogo = "/img/logo.png"
|
table.Competitions[i].Table.Overall[j].TeamLogo = "/img/logo.png"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1646,7 +1715,7 @@ func refresh(ctx context.Context) error {
|
|||||||
if err := writeDiskJSON(c.data); err != nil {
|
if err := writeDiskJSON(c.data); err != nil {
|
||||||
log.Printf("warn: write disk json: %v", err)
|
log.Printf("warn: write disk json: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("refreshed data: comps=%d", len(detail.Competitions))
|
log.Printf("refreshed data: comps=%d source=%s", len(detail.Competitions), map[bool]string{true: "fallback", false: "primary"}[activeClubID == fallbackClubID])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListLatestBlogsOrdering verifies that listLatestBlogs returns items
|
||||||
|
// sorted by numeric ID descending, regardless of file timestamps or order.
|
||||||
|
func TestListLatestBlogsOrdering(t *testing.T) {
|
||||||
|
// Create a temp directory structure mimicking the remote server
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
blogDir := filepath.Join(tmpDir, "blog")
|
||||||
|
imgDir := filepath.Join(tmpDir, "img", "blog")
|
||||||
|
if err := os.MkdirAll(blogDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create numeric blog files with IDs spanning a wide range.
|
||||||
|
// We intentionally create them in non-numeric order and touch
|
||||||
|
// old IDs with newer timestamps to simulate a migration.
|
||||||
|
files := []struct {
|
||||||
|
id string
|
||||||
|
slug string
|
||||||
|
title string
|
||||||
|
}{
|
||||||
|
{"0031", "vstupujeme-spolecne-do-druhe-ligy", "VSTUPUJEME SPOLEČNĚ DO DRUHÉ LIGY!"},
|
||||||
|
{"0032", "nova-mise-pred-nami", "NOVÁ MISE PŘED NÁMI!"},
|
||||||
|
{"0033", "superpohar-divizi-je-zde", "SUPERPOHÁR DIVIZÍ JE ZDE!"},
|
||||||
|
{"0034", "superpohar-je-nas", "SUPERPOHÁR JE NÁŠ!"},
|
||||||
|
{"0035", "fotoreport-1", "FOTOREPORT"},
|
||||||
|
{"0036", "regionalni-finale-je-tady", "REGIONÁLNÍ FINÁLE JE TADY!"},
|
||||||
|
{"0037", "bizoni-slavi-postup", "BIZONI SLAVÍ POSTUP!"},
|
||||||
|
{"0038", "fotoreport-2", "FOTOREPORT"},
|
||||||
|
{"0039", "2-liga-je-tu", "2. LIGA JE TU!"},
|
||||||
|
{"0040", "pred-startem-sezony-1", "PŘED STARTEM SEZONY"},
|
||||||
|
{"0041", "pred-startem-sezony-2", "PŘED STARTEM SEZÓNY"},
|
||||||
|
{"0042", "podpora-futsalu", "Podpora Futsalu"},
|
||||||
|
{"0169", "stepan-stodulka-fanouskum-3", "Štěpán Stodůlka fanouškům: Budujeme klub, který bude dlouhodobě silný"},
|
||||||
|
{"0170", "martin-prokes-fanouskum", "Martin Prokeš fanouškům: První futsalová sezóna přinesla cenné zkušenosti"},
|
||||||
|
{"0171", "andrea-adamikova-fanouskum", "Andrea Adamíková fanouškům: Druhé místo je motivací do další práce"},
|
||||||
|
{"0172", "stepan-stodulka-fanouskum-2", "Štěpán Stodůlka fanouškům: Bizonky jsou hrdou součástí našeho klubu"},
|
||||||
|
{"0173", "martin-lapcik-fanouskum", "Martin Lapčík fanouškům: První rok bizoní mládeže nás všechny nadchl"},
|
||||||
|
{"0174", "marek-stojaspal-fanouskum", "Marek Stojaspal fanouškům: Mládež položila pevné základy budoucnosti"},
|
||||||
|
{"0175", "stepan-stodulka-fanouskum-1", "Štěpán Stodůlka fanouškům: Mládež ukázala velký potenciál"},
|
||||||
|
{"0176", "dekujeme-my-jsme-tu-diky-vam", "DĚKUJEME, MY JSME TU DÍKY VÁM!"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
// Create numeric HTML file with slug meta tag
|
||||||
|
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="slug" content="%s">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="lte-header">%s</h1>
|
||||||
|
<div class="text lte-text-page clearfix">content</div>
|
||||||
|
</body>
|
||||||
|
</html>`, f.slug, f.title)
|
||||||
|
numericPath := filepath.Join(blogDir, f.id+".html")
|
||||||
|
if err := os.WriteFile(numericPath, []byte(htmlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create corresponding slug file (duplicate content)
|
||||||
|
slugPath := filepath.Join(blogDir, f.slug+".html")
|
||||||
|
if err := os.WriteFile(slugPath, []byte(htmlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create a dummy image
|
||||||
|
imgPath := filepath.Join(imgDir, f.id+".png")
|
||||||
|
if err := os.WriteFile(imgPath, []byte("fake png"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch some old IDs with a newer timestamp to simulate post-migration
|
||||||
|
// (this is the exact condition that broke the old sorting).
|
||||||
|
// We sleep briefly between creation and touch so the timestamp is definitely newer.
|
||||||
|
importTime := time.Now()
|
||||||
|
for _, oldID := range []string{"0031", "0032", "0042"} {
|
||||||
|
fpath := filepath.Join(blogDir, oldID+".html")
|
||||||
|
if err := os.Chtimes(fpath, importTime, importTime); err != nil {
|
||||||
|
// ignore errors on Chtimes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call listLatestBlogs
|
||||||
|
items, err := listLatestBlogs(tmpDir, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listLatestBlogs error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 12 {
|
||||||
|
t.Fatalf("expected 12 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify IDs are sorted descending (newest first)
|
||||||
|
for i := 0; i < len(items)-1; i++ {
|
||||||
|
curr, _ := strconv.Atoi(items[i].ID)
|
||||||
|
next, _ := strconv.Atoi(items[i+1].ID)
|
||||||
|
if curr <= next {
|
||||||
|
t.Errorf("IDs not sorted descending at index %d: %s (%d) <= %s (%d)",
|
||||||
|
i, items[i].ID, curr, items[i+1].ID, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the first item is the newest (0176)
|
||||||
|
if items[0].ID != "0176" {
|
||||||
|
t.Errorf("expected first item ID to be 0176, got %s (title: %s)", items[0].ID, items[0].Title)
|
||||||
|
}
|
||||||
|
if items[1].ID != "0175" {
|
||||||
|
t.Errorf("expected second item ID to be 0175, got %s", items[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deduplication: total unique posts should be 20 (not 40)
|
||||||
|
allItems, err := listLatestBlogs(tmpDir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listLatestBlogs unlimited error: %v", err)
|
||||||
|
}
|
||||||
|
if len(allItems) != 20 {
|
||||||
|
t.Errorf("expected 20 unique items after dedup, got %d", len(allItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all items are sorted descending
|
||||||
|
sorted := sort.SliceIsSorted(allItems, func(i, j int) bool {
|
||||||
|
ii, _ := strconv.Atoi(allItems[i].ID)
|
||||||
|
jj, _ := strconv.Atoi(allItems[j].ID)
|
||||||
|
return ii > jj
|
||||||
|
})
|
||||||
|
if !sorted {
|
||||||
|
t.Error("allItems are not sorted by numeric ID descending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListLatestBlogsNoSlug verifies numeric-only blogs still sort correctly.
|
||||||
|
func TestListLatestBlogsNoSlug(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
blogDir := filepath.Join(tmpDir, "blog")
|
||||||
|
imgDir := filepath.Join(tmpDir, "img", "blog")
|
||||||
|
os.MkdirAll(blogDir, 0755)
|
||||||
|
os.MkdirAll(imgDir, 0755)
|
||||||
|
|
||||||
|
for _, id := range []string{"0001", "0005", "0010", "0002"} {
|
||||||
|
content := fmt.Sprintf(`<html><head></head><body><h1 class="lte-header">Title %s</h1></body></html>`, id)
|
||||||
|
os.WriteFile(filepath.Join(blogDir, id+".html"), []byte(content), 0644)
|
||||||
|
os.WriteFile(filepath.Join(imgDir, id+".png"), []byte("png"), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := listLatestBlogs(tmpDir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := []string{"0010", "0005", "0002", "0001"}
|
||||||
|
if len(items) != len(expected) {
|
||||||
|
t.Fatalf("expected %d items, got %d", len(expected), len(items))
|
||||||
|
}
|
||||||
|
for i, exp := range expected {
|
||||||
|
if items[i].ID != exp {
|
||||||
|
t.Errorf("index %d: expected %s, got %s", i, exp, items[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
+28
-7
@@ -1774,13 +1774,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
|
||||||
<div class="partners-item item center-flex">
|
|
||||||
<a href="https://alpacar.pl/" target="_blank">
|
|
||||||
<img decoding="async" src="img/sponzor17.png" class="image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
<div class="partners-item item center-flex">
|
<div class="partners-item item center-flex">
|
||||||
<a href="https://vechra.cz/" target="_blank">
|
<a href="https://vechra.cz/" target="_blank">
|
||||||
@@ -1865,6 +1858,34 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="https://www.kovosteel.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor30.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="http://gracla.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor31.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="https://nsa.gov.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor32.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="https://zlinskykraj.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor33.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,12 @@
|
|||||||
const res = await fetch('/api/blog/latest?limit=12', {credentials: 'omit'});
|
const res = await fetch('/api/blog/latest?limit=12', {credentials: 'omit'});
|
||||||
if (!res.ok) throw new Error('HTTP '+res.status);
|
if (!res.ok) throw new Error('HTTP '+res.status);
|
||||||
let items = await res.json();
|
let items = await res.json();
|
||||||
|
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
|
||||||
|
items.sort((a,b)=>{
|
||||||
|
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
|
||||||
|
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
|
||||||
|
return (b.id||'').localeCompare(a.id||'');
|
||||||
|
});
|
||||||
if (primary) primary.innerHTML = '';
|
if (primary) primary.innerHTML = '';
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
if (primary) primary.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#888;">Žádné příspěvky zatím nejsou.</div>';
|
if (primary) primary.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#888;">Žádné příspěvky zatím nejsou.</div>';
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
if (!res.ok) throw new Error('HTTP '+res.status);
|
if (!res.ok) throw new Error('HTTP '+res.status);
|
||||||
let items = await res.json();
|
let items = await res.json();
|
||||||
if (!Array.isArray(items) || items.length === 0) items = [];
|
if (!Array.isArray(items) || items.length === 0) items = [];
|
||||||
|
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
|
||||||
|
items.sort((a,b)=>{
|
||||||
|
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
|
||||||
|
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
|
||||||
|
return (b.id||'').localeCompare(a.id||'');
|
||||||
|
});
|
||||||
|
|
||||||
// Update background images of zoom slider slides if present
|
// Update background images of zoom slider slides if present
|
||||||
const slides = document.querySelectorAll('.lte-slider-zoom .zs-slides .zs-slide');
|
const slides = document.querySelectorAll('.lte-slider-zoom .zs-slides .zs-slide');
|
||||||
|
|||||||
+29
-8
@@ -191,7 +191,7 @@
|
|||||||
<div class="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader heading-tag-h6 heading-subtag-h6">
|
<div class="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader heading-tag-h6 heading-subtag-h6">
|
||||||
<div class="lte-heading-content">
|
<div class="lte-heading-content">
|
||||||
<h6 class="lte-subheader">2025 / 2026</h6>
|
<h6 class="lte-subheader">2025 / 2026</h6>
|
||||||
<h6 class="lte-header">Účastník 2. FUTSAL LIGY</h6>
|
<h6 class="lte-header">Výherce 2. FUTSAL LIGY</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,13 +464,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
|
||||||
<div class="partners-item item center-flex">
|
|
||||||
<a href="https://alpacar.pl/" target="_blank">
|
|
||||||
<img decoding="async" src="img/sponzor17.png" class="image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
<div class="partners-item item center-flex">
|
<div class="partners-item item center-flex">
|
||||||
<a href="https://vechra.cz/" target="_blank">
|
<a href="https://vechra.cz/" target="_blank">
|
||||||
@@ -555,6 +548,34 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="https://www.kovosteel.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor30.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="http://gracla.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor31.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="https://nsa.gov.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor32.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
|
||||||
|
<div class="partners-item item center-flex">
|
||||||
|
<a href="https://zlinskykraj.cz/" target="_blank">
|
||||||
|
<img decoding="async" src="img/sponzor33.png" class="image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user