//! The in-memory posts cache: rebuilt from disk at startup and after every //! mutation, plus prev/next neighbour lookup over the visible set. use tokio::fs; use tracing::warn; use crate::models::{PostInfo, PostMeta, PostNeighbor}; use crate::post::images::{cover_from, dim_for_url, extract_images}; use crate::post::parse::{excerpt_from, parse_post, reading_time}; use crate::{AppState, CachedPost}; fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo { let images = extract_images(body); PostInfo { slug: slug.to_string(), date: meta.date, title: meta.title.clone(), summary: meta.summary.clone(), tags: meta.tags.clone(), draft: meta.draft, reading_time: reading_time(body), excerpt: excerpt_from(meta, body), cover_image: cover_from(&images), image_count: images.len() as u32, } } /// Scans the posts directory and replaces the in-memory cache. /// Called at startup and after any mutation (create/rename/delete). pub(crate) async fn rebuild_posts_cache(state: &AppState) { let posts_dir = state.data_dir.join("posts"); let mut posts: Vec = Vec::new(); let mut rd = match fs::read_dir(&posts_dir).await { Ok(rd) => rd, Err(_) => { *state.posts_cache.write().await = posts; return; } }; loop { match rd.next_entry().await { Ok(Some(entry)) => { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else { continue; }; if slug.starts_with('.') { continue; } let Ok(raw) = fs::read_to_string(&path).await else { continue; }; let Ok((meta, body)) = parse_post(&raw) else { warn!("Skipping post with bad frontmatter: {}", slug); continue; }; let mut info = build_post_info(slug, &meta, &body); if let Some(cover) = info.cover_image.as_mut() { if let Some(d) = dim_for_url(state, &cover.url).await { cover.w = Some(d.w); cover.h = Some(d.h); } } posts.push(CachedPost { info, body }); } Ok(None) => break, Err(e) => { warn!("Error iterating posts dir: {}", e); break; } } } posts.sort_by(|a, b| { b.info .date .cmp(&a.info.date) .then_with(|| a.info.slug.cmp(&b.info.slug)) }); *state.posts_cache.write().await = posts; } pub(crate) async fn neighbors_from_cache( state: &AppState, slug: &str, admin: bool, ) -> (Option, Option) { let cache = state.posts_cache.read().await; let visible: Vec<&PostInfo> = cache .iter() .filter(|p| admin || !p.info.draft) .map(|p| &p.info) .collect(); let Some(i) = visible.iter().position(|p| p.slug == slug) else { return (None, None); }; let to_neighbor = |p: &PostInfo| PostNeighbor { slug: p.slug.clone(), title: p.title.clone(), }; let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None }; let next = visible.get(i + 1).map(|p| to_neighbor(p)); (prev, next) }