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…';
|
||||
const res = await fetch('/api/blog/latest?limit=12');
|
||||
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='';
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
grid.innerHTML = '<div class="muted">Žádné příspěvky.</div>';
|
||||
|
||||
+7
-1
@@ -53,7 +53,13 @@
|
||||
try {
|
||||
const res = await fetch('/api/blog/latest?limit=12');
|
||||
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}`;
|
||||
mount.innerHTML = '';
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
|
||||
+132
-63
@@ -27,9 +27,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
clubID = "441d3783-06aa-436a-b438-359300ee0371"
|
||||
clubType = "futsal"
|
||||
baseURL = "https://facr.tdvorak.dev"
|
||||
clubID = "441d3783-06aa-436a-b438-359300ee0371"
|
||||
clubType = "futsal"
|
||||
baseURL = "https://facr.tdvorak.dev"
|
||||
fallbackBaseURL = "https://flashscore.tdvorak.dev"
|
||||
fallbackClubID = "xzS3gX3T"
|
||||
fallbackSlug = "uherske-hradiste"
|
||||
)
|
||||
|
||||
// Paths
|
||||
@@ -330,6 +333,14 @@ func refreshVideos(ctx context.Context) error {
|
||||
return fmt.Errorf("yt get: %w", err)
|
||||
}
|
||||
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 {
|
||||
items = items[:5]
|
||||
}
|
||||
@@ -397,6 +408,28 @@ func writeVideosJSON() error {
|
||||
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 {
|
||||
if p := os.Getenv("VIDEOS_PATH"); p != "" {
|
||||
return p
|
||||
@@ -588,7 +621,6 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
||||
}
|
||||
// Match both numeric (0001.html) and slug-based filenames
|
||||
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
|
||||
numericRe := regexp.MustCompile(`^\d{4}$`)
|
||||
var items []BlogItem
|
||||
seenIDs := make(map[string]bool) // Track seen IDs to avoid duplicates
|
||||
for _, e := range entries {
|
||||
@@ -598,24 +630,37 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
||||
}
|
||||
id := strings.TrimSuffix(name, ".html")
|
||||
|
||||
// Skip if this ID was already processed (deduplication)
|
||||
if seenIDs[id] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Title and categories extraction from blog HTML
|
||||
blogPath := filepath.Join(blogDir, name)
|
||||
title := extractTitle(blogPath)
|
||||
slug := extractSlug(blogPath, name)
|
||||
cats := extractCategories(blogPath)
|
||||
|
||||
// Mark this ID as seen
|
||||
seenIDs[id] = true
|
||||
// Also mark slug/numeric counterpart to prevent duplicates
|
||||
if slug != "" && slug != id {
|
||||
seenIDs[slug] = true
|
||||
// Determine canonical ID: prefer numeric when available.
|
||||
// This ensures consistent item identity regardless of whether the
|
||||
// numeric or slug filename is encountered first in the directory.
|
||||
canonicalID := id
|
||||
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
|
||||
}
|
||||
// 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 {
|
||||
mtime = htmlInfo.ModTime()
|
||||
}
|
||||
// For image path, try to find corresponding numeric ID
|
||||
imageID := id
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// For image path, canonicalID is already numeric when a numeric file exists
|
||||
imageID := canonicalID
|
||||
if imgInfo, err2 := os.Stat(filepath.Join(imgDir, imageID+".png")); err2 == nil {
|
||||
// If image is newer, use that as a proxy for recency
|
||||
if imgInfo.ModTime().After(mtime) {
|
||||
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
|
||||
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
|
||||
link = "/blog/" + slug
|
||||
} else {
|
||||
link = "/blog/" + id + ".html"
|
||||
link = "/blog/" + canonicalID + ".html"
|
||||
}
|
||||
items = append(items, BlogItem{
|
||||
ID: id,
|
||||
ID: canonicalID,
|
||||
Title: title,
|
||||
Slug: slug,
|
||||
Link: link,
|
||||
@@ -663,32 +695,25 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
// Check if files were recently processed (all have same timestamp from setup script)
|
||||
recentThreshold := time.Now().Add(-24 * time.Hour)
|
||||
allRecent := items[i].MTime.After(recentThreshold) && items[j].MTime.After(recentThreshold)
|
||||
|
||||
if allRecent {
|
||||
// If both files are recent (from setup script), sort by numeric ID (higher = newer)
|
||||
ii, err1 := strconv.Atoi(items[i].ID)
|
||||
jj, err2 := strconv.Atoi(items[j].ID)
|
||||
if err1 == nil && err2 == nil {
|
||||
return ii > jj
|
||||
}
|
||||
// If not numeric, fall back to string comparison
|
||||
return items[i].ID > items[j].ID
|
||||
}
|
||||
|
||||
// Otherwise, use modification time (newest first)
|
||||
if !items[i].MTime.Equal(items[j].MTime) {
|
||||
return items[i].MTime.After(items[j].MTime)
|
||||
}
|
||||
|
||||
// If times are equal and not recent, fallback to numeric ID
|
||||
// Always prefer numeric ID descending (higher ID = newer post).
|
||||
// Numeric IDs monotonically increase via nextBlogID, so they are
|
||||
// the authoritative ordering regardless of file MTime.
|
||||
ii, err1 := strconv.Atoi(items[i].ID)
|
||||
jj, err2 := strconv.Atoi(items[j].ID)
|
||||
if err1 == nil && err2 == nil {
|
||||
return ii > jj
|
||||
}
|
||||
// If only one item has a numeric ID, it is newer
|
||||
if err1 == nil {
|
||||
return true
|
||||
}
|
||||
if err2 == nil {
|
||||
return false
|
||||
}
|
||||
// Both non-numeric: fall back to MTime, then string comparison
|
||||
if !items[i].MTime.Equal(items[j].MTime) {
|
||||
return items[i].MTime.After(items[j].MTime)
|
||||
}
|
||||
return items[i].ID > items[j].ID
|
||||
})
|
||||
if limit > 0 && len(items) > limit {
|
||||
@@ -697,6 +722,26 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
||||
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)
|
||||
func extractTitle(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
@@ -832,11 +877,21 @@ func main() {
|
||||
go scheduler(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
|
||||
if err := refreshVideos(ctx); err != nil {
|
||||
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.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
okCORS(w)
|
||||
@@ -1600,12 +1655,26 @@ func refresh(ctx context.Context) error {
|
||||
urlTable := fmt.Sprintf("%s/club/%s/%s/table", baseURL, clubType, clubID)
|
||||
|
||||
var detail ClubDetail
|
||||
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
||||
return fmt.Errorf("detail: %w", err)
|
||||
}
|
||||
var table ClubTable
|
||||
if err := getJSON(ctx, client, urlTable, &table); err != nil {
|
||||
return fmt.Errorf("table: %w", err)
|
||||
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 {
|
||||
return fmt.Errorf("table: %w", err)
|
||||
}
|
||||
activeClubID = clubID
|
||||
}
|
||||
|
||||
// Override or inject facr_link based on match_id
|
||||
@@ -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)
|
||||
}
|
||||
// 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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1628,7 +1697,7 @@ func refresh(ctx context.Context) error {
|
||||
// Override logo URLs for our club in the table standings
|
||||
for i := range table.Competitions {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1646,7 +1715,7 @@ func refresh(ctx context.Context) error {
|
||||
if err := writeDiskJSON(c.data); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</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="partners-item item center-flex">
|
||||
<a href="https://vechra.cz/" target="_blank">
|
||||
@@ -1865,6 +1858,34 @@
|
||||
</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://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>
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
const res = await fetch('/api/blog/latest?limit=12', {credentials: 'omit'});
|
||||
if (!res.ok) throw new Error('HTTP '+res.status);
|
||||
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 (!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>';
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
if (!res.ok) throw new Error('HTTP '+res.status);
|
||||
let items = await res.json();
|
||||
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
|
||||
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-content">
|
||||
<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>
|
||||
@@ -464,13 +464,6 @@
|
||||
</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://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="partners-item item center-flex">
|
||||
<a href="https://vechra.cz/" target="_blank">
|
||||
@@ -555,6 +548,34 @@
|
||||
</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://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>
|
||||
|
||||
Reference in New Issue
Block a user