package cache import ( "context" "html/template" "time" "gitcitadel-online/internal/generator" "gitcitadel-online/internal/logger" "gitcitadel-online/internal/nostr" ) // Rewarmer handles cache rewarming type Rewarmer struct { cache *Cache feedCache *FeedCache wikiService *nostr.WikiService feedService *nostr.FeedService ebooksService *nostr.EBooksService htmlGenerator *generator.HTMLGenerator wikiIndex string blogIndex string feedRelay string maxFeedEvents int interval time.Duration feedInterval time.Duration } // NewRewarmer creates a new cache rewarming service func NewRewarmer( cache *Cache, feedCache *FeedCache, wikiService *nostr.WikiService, feedService *nostr.FeedService, ebooksService *nostr.EBooksService, htmlGenerator *generator.HTMLGenerator, wikiIndex, blogIndex, feedRelay string, maxFeedEvents int, interval, feedInterval time.Duration, ) *Rewarmer { return &Rewarmer{ cache: cache, feedCache: feedCache, wikiService: wikiService, feedService: feedService, ebooksService: ebooksService, htmlGenerator: htmlGenerator, wikiIndex: wikiIndex, blogIndex: blogIndex, feedRelay: feedRelay, maxFeedEvents: maxFeedEvents, interval: interval, feedInterval: feedInterval, } } // Start starts the rewarming goroutines func (r *Rewarmer) Start(ctx context.Context) { // Initial population go r.rewarmPages(ctx) go r.rewarmFeed(ctx) // Periodic rewarming go r.periodicRewarmPages(ctx) go r.periodicRewarmFeed(ctx) } // rewarmPages rewarms the page cache func (r *Rewarmer) rewarmPages(ctx context.Context) { logger.Info("Starting page cache rewarming...") // Initialize wikiPages as empty - will be populated if wiki fetch succeeds wikiPages := make([]generator.WikiPageInfo, 0) // Fetch wiki index (non-blocking - landing page can still be generated) // If theforest fails, leave pages as-is (don't remove existing events) wikiIndex, err := r.wikiService.FetchWikiIndex(ctx, r.wikiIndex) if err != nil { logger.Warnf("Error fetching wiki index from theforest: %v - keeping existing pages", err) // Don't update cache - leave existing pages as-is // Continue to generate landing page even if wiki fetch fails } else { // Fetch wiki events // If theforest fails, leave pages as-is (don't remove existing events) wikiEvents, err := r.wikiService.FetchWikiEvents(ctx, wikiIndex) if err != nil { logger.Warnf("Error fetching wiki events from theforest: %v - keeping existing pages", err) // Don't update cache - leave existing pages as-is } else { // Build wiki page info for navigation wikiPages = make([]generator.WikiPageInfo, 0, len(wikiEvents)) for _, event := range wikiEvents { wikiPages = append(wikiPages, generator.WikiPageInfo{ DTag: event.DTag, Title: event.Title, }) } // Generate and cache wiki index page wikiIndexHTML, err := r.htmlGenerator.GenerateWikiIndexPage(wikiIndex, wikiPages, []generator.FeedItemInfo{}) if err != nil { logger.Errorf("Error generating wiki index page: %v", err) } else { if err := r.cache.Set("/wiki", wikiIndexHTML); err != nil { logger.Errorf("Error caching wiki index page: %v", err) } else { logger.WithField("pages", len(wikiPages)).Info("Wiki index page cached successfully") } } // Generate and cache wiki pages for _, event := range wikiEvents { html, err := r.htmlGenerator.GenerateWikiPage(event, wikiPages, []generator.FeedItemInfo{}) if err != nil { logger.WithField("dtag", event.DTag).Errorf("Error generating wiki page: %v", err) continue } if err := r.cache.Set("/wiki/"+event.DTag, html); err != nil { logger.WithField("dtag", event.DTag).Errorf("Error caching wiki page: %v", err) } } } } // Fetch blog index if configured (needed for landing page) // If theforest fails, leave pages as-is (don't remove existing events) var newestBlogItem *generator.BlogItemInfo if r.blogIndex != "" { blogIndex, err := r.wikiService.FetchWikiIndex(ctx, r.blogIndex) if err != nil { logger.Warnf("Error fetching blog index from theforest: %v - keeping existing pages", err) // Don't update cache - leave existing pages as-is } else { // Fetch blog events using the generic FetchIndexEvents function // If theforest fails, leave pages as-is (don't remove existing events) blogKind := r.wikiService.GetBlogKind() blogEventList, err := r.wikiService.FetchIndexEvents(ctx, blogIndex, blogKind) if err != nil { logger.Warnf("Error fetching blog events from theforest: %v - keeping existing pages", err) // Don't update cache - leave existing pages as-is } else { logger.WithFields(map[string]interface{}{ "events": len(blogEventList), "kind": blogKind, }).Debug("Fetched blog events") blogItems := make([]generator.BlogItemInfo, 0, len(blogEventList)) for _, event := range blogEventList { // Parse the blog event blog, err := nostr.ParseBlogEvent(event, blogKind) if err != nil { logger.WithField("event_id", event.ID).Warnf("Error parsing blog event: %v", err) continue } html, err := r.htmlGenerator.ProcessAsciiDoc(blog.Content) if err != nil { logger.WithField("dtag", blog.DTag).Warnf("Error processing blog content: %v", err) html = blog.Content // Fallback to raw content } blogItems = append(blogItems, generator.BlogItemInfo{ DTag: blog.DTag, Title: blog.Title, Summary: blog.Summary, Content: template.HTML(html), Author: event.PubKey, Image: blog.Image, CreatedAt: int64(event.CreatedAt), }) } logger.WithField("items", len(blogItems)).Debug("Generated blog items") // Get newest blog item for landing page if len(blogItems) > 0 { newestBlogItem = &blogItems[0] } // Generate blog page without feed items (feed only on landing page) blogHTML, err := r.htmlGenerator.GenerateBlogPage(blogIndex, blogItems, []generator.FeedItemInfo{}) if err != nil { logger.Errorf("Error generating blog page: %v", err) } else { if err := r.cache.Set("/blog", blogHTML); err != nil { logger.Errorf("Error caching blog page: %v", err) } else { logger.WithField("items", len(blogItems)).Info("Blog page cached successfully") } } } } } // Fetch and cache articles page (longform articles) - needed for landing page // If theforest fails, leave pages as-is (don't remove existing events) var allArticleItems []generator.ArticleItemInfo var newestArticleItem *generator.ArticleItemInfo longformKind := r.wikiService.GetLongformKind() if longformKind > 0 { articleEvents, err := r.wikiService.FetchLongformArticles(ctx, "wss://theforest.nostr1.com", longformKind, 50) if err != nil { logger.Warnf("Error fetching longform articles from theforest: %v - keeping existing pages", err) // Don't update cache - leave existing pages as-is } else { articleItems := make([]generator.ArticleItemInfo, 0, len(articleEvents)) for _, event := range articleEvents { // Parse the longform article article, err := nostr.ParseLongformEvent(event, longformKind) if err != nil { logger.WithField("event_id", event.ID).Warnf("Error parsing longform article: %v", err) continue } // Process markdown content html, err := r.htmlGenerator.ProcessMarkdown(article.Content) if err != nil { logger.WithField("dtag", article.DTag).Warnf("Error processing markdown content: %v", err) html = article.Content // Fallback to raw content } articleItems = append(articleItems, generator.ArticleItemInfo{ DTag: article.DTag, Title: article.Title, Summary: article.Summary, Content: template.HTML(html), Author: event.PubKey, Image: article.Image, CreatedAt: int64(event.CreatedAt), }) } logger.WithField("items", len(articleItems)).Debug("Generated article items") // Store all article items for landing page allArticleItems = articleItems // Get newest article item for landing page if len(articleItems) > 0 { newestArticleItem = &articleItems[0] } // Generate articles page articlesHTML, err := r.htmlGenerator.GenerateArticlesPage(articleItems, []generator.FeedItemInfo{}) if err != nil { logger.Errorf("Error generating articles page: %v", err) } else { if err := r.cache.Set("/articles", articlesHTML); err != nil { logger.Errorf("Error caching articles page: %v", err) } else { logger.WithField("items", len(articleItems)).Info("Articles page cached successfully") } } } } // Fetch and cache e-books page (needed for landing page) // If theforest fails, leave pages as-is (don't remove existing events) var allEBooks []generator.EBookInfo if r.ebooksService != nil { ebooks, err := r.ebooksService.FetchTopLevelIndexEvents(ctx) if err != nil { logger.Warnf("Error fetching e-books from theforest: %v - keeping existing pages", err) // Don't update cache - leave existing pages as-is } else { // Convert to generator.EBookInfo generatorEBooks := make([]generator.EBookInfo, 0, len(ebooks)) for _, ebook := range ebooks { generatorEBooks = append(generatorEBooks, generator.EBookInfo{ EventID: ebook.EventID, Title: ebook.Title, DTag: ebook.DTag, Author: ebook.Author, Summary: ebook.Summary, Image: ebook.Image, Type: ebook.Type, CreatedAt: ebook.CreatedAt, Naddr: ebook.Naddr, }) } // Store all e-books for landing page allEBooks = generatorEBooks ebooksHTML, err := r.htmlGenerator.GenerateEBooksPage(generatorEBooks, []generator.FeedItemInfo{}) if err != nil { logger.Errorf("Error generating e-books page: %v", err) } else { if err := r.cache.Set("/ebooks", ebooksHTML); err != nil { logger.Errorf("Error caching e-books page: %v", err) } else { logger.WithField("ebooks", len(generatorEBooks)).Info("E-books page cached successfully") } } } } // Always generate landing page AFTER blog, articles, and e-books are fetched and cached // Now we have all the data needed for the landing page landingHTML, err := r.htmlGenerator.GenerateLandingPage(wikiPages, newestBlogItem, newestArticleItem, allArticleItems, allEBooks) if err != nil { logger.Errorf("Error generating landing page: %v", err) } else { if err := r.cache.Set("/", landingHTML); err != nil { logger.Errorf("Error caching landing page: %v", err) } else { logger.WithField("pages", len(wikiPages)).Info("Landing page cached successfully") } } // Generate and cache Feed page (using feed items from cache) feedItems := r.convertFeedItemsToInfo(r.feedCache.Get()) feedHTML, err := r.htmlGenerator.GenerateFeedPage(feedItems) if err != nil { logger.Errorf("Error generating feed page: %v", err) } else { if err := r.cache.Set("/feed", feedHTML); err != nil { logger.Errorf("Error caching feed page: %v", err) } else { logger.WithField("items", len(feedItems)).Info("Feed page cached successfully") } } logger.Info("Page cache rewarming completed") } // rewarmFeed rewarms the feed cache func (r *Rewarmer) rewarmFeed(ctx context.Context) { logger.WithFields(map[string]interface{}{ "relay": r.feedRelay, "max_events": r.maxFeedEvents, }).Info("Starting feed cache rewarming") nostrItems, err := r.feedService.FetchFeedItems(ctx, r.feedRelay, r.maxFeedEvents) if err != nil { logger.WithField("relay", r.feedRelay).Warnf("Error fetching feed: %v", err) // Don't clear the cache on error - keep old items return } if len(nostrItems) == 0 { logger.WithField("relay", r.feedRelay).Warn("No feed items fetched") // Don't clear the cache - keep old items return } // Convert nostr.FeedItem to cache.FeedItem items := make([]FeedItem, 0, len(nostrItems)) for _, item := range nostrItems { items = append(items, FeedItem{ EventID: item.EventID, Author: item.Author, Content: item.Content, Time: item.Time, Link: item.Link, Title: item.Title, Summary: item.Summary, Image: item.Image, }) } r.feedCache.Set(items) logger.WithFields(map[string]interface{}{ "items": len(items), "relay": r.feedRelay, }).Info("Feed cache rewarmed successfully") } // periodicRewarmPages periodically rewarms pages func (r *Rewarmer) periodicRewarmPages(ctx context.Context) { ticker := time.NewTicker(r.interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: r.rewarmPages(ctx) } } } // convertFeedItemsToInfo converts cache.FeedItem to generator.FeedItemInfo func (r *Rewarmer) convertFeedItemsToInfo(items []FeedItem) []generator.FeedItemInfo { feedItems := make([]generator.FeedItemInfo, 0, len(items)) for _, item := range items { feedItems = append(feedItems, generator.FeedItemInfo{ EventID: item.EventID, Author: item.Author, Content: item.Content, Time: item.Time.Format("2006-01-02 15:04:05"), TimeISO: item.Time.Format(time.RFC3339), Link: item.Link, }) } return feedItems } // periodicRewarmFeed periodically rewarms feed func (r *Rewarmer) periodicRewarmFeed(ctx context.Context) { ticker := time.NewTicker(r.feedInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: r.rewarmFeed(ctx) } } }