Compare commits

..

3 Commits

Author SHA1 Message Date
Tomas Dvorak 4c904a1546 fix(backend): improve video resilience and add blog ordering validation
Implement fallback mechanisms for YouTube video data and add startup
validation for blog post ordering.

- Add `loadVideosJSON` to persist and reload video data from disk.
- Update `refreshVideos` to retain existing local videos if the YouTube
  API returns an empty response.
- Add `validateBlogOrdering` to perform a health check on blog post
  sequence during startup.
- Call video loading and blog validation in `main`.
2026-05-11 15:30:24 +02:00
Tomas Dvorak 76c447a395 fix(ui): add defensive sorting for blog posts
Implement client-side sorting for blog post lists to ensure they are
displayed in descending order by numeric ID. This prevents issues
where API response ordering or file modification timestamps might
cause older posts to appear newer than recent ones.

Also add backend tests to verify blog ordering logic.
2026-05-11 13:03:04 +02:00
Tomas Dvorak a6b47de1a4 refactor(backend): improve blog canonical ID resolution
Refactor the blog listing logic to use a canonical numeric ID when available. This ensures consistent identity and deduplication between numeric and slug-based filenames, and ensures image path resolution uses the correct identifier.
2026-05-11 12:33:26 +02:00
6 changed files with 299 additions and 53 deletions
+7 -1
View File
@@ -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
View File
@@ -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) {
+99 -47
View File
@@ -330,6 +330,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 +405,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 +618,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 +627,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 +666,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 +692,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 +719,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 +874,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)
+170
View File
@@ -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)
}
}
}
+6
View File
@@ -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
View File
@@ -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');