mirror of
https://github.com/Dvorinka/bizoni.git
synced 2026-06-03 18:22:57 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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) {
|
||||
|
||||
+103
-51
@@ -330,6 +330,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 +405,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 +618,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 +627,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 +666,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 +692,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 +719,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 +874,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user