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…';
|
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) {
|
||||||
|
|||||||
+99
-47
@@ -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)
|
||||||
|
|||||||
@@ -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'});
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user