Skip to content

Commit d7d48d3

Browse files
committed
Refactor site generator
1 parent 112a0ae commit d7d48d3

File tree

8 files changed

+1164
-1084
lines changed

8 files changed

+1164
-1084
lines changed

src/site_generator.rs

Lines changed: 0 additions & 1084 deletions
This file was deleted.

src/site_generator/assets.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//! Asset management: directory creation, static file copying, and cover generation.
2+
3+
use super::SiteGenerator;
4+
use crate::models::{Book, StatisticsData};
5+
use anyhow::{Context, Result};
6+
use futures::future;
7+
use log::info;
8+
use std::fs;
9+
use std::time::SystemTime;
10+
11+
impl SiteGenerator {
12+
pub(crate) async fn create_directories(&self, books: &[Book], stats_data: &Option<StatisticsData>) -> Result<()> {
13+
fs::create_dir_all(&self.output_dir)?;
14+
15+
// Only create books directory if we have books
16+
if !books.is_empty() {
17+
fs::create_dir_all(self.books_dir())?;
18+
fs::create_dir_all(self.covers_dir())?;
19+
}
20+
21+
// Always create assets directories for CSS/JS as they're always needed
22+
fs::create_dir_all(self.css_dir())?;
23+
fs::create_dir_all(self.js_dir())?;
24+
25+
// Create directories based on what we have
26+
if stats_data.is_some() {
27+
// Create JSON directories for statistics data
28+
fs::create_dir_all(self.json_dir())?;
29+
fs::create_dir_all(self.statistics_json_dir())?;
30+
fs::create_dir_all(self.calendar_json_dir())?;
31+
// Recap pages directory (static HTML)
32+
fs::create_dir_all(self.recap_dir())?;
33+
34+
// Create calendar directory
35+
fs::create_dir_all(self.calendar_dir())?;
36+
37+
// Only create statistics directory if we have books (if no books, stats render to root)
38+
if !books.is_empty() {
39+
fs::create_dir_all(self.statistics_dir())?;
40+
}
41+
}
42+
43+
Ok(())
44+
}
45+
46+
pub(crate) async fn copy_static_assets(&self, books: &[Book], stats_data: &Option<StatisticsData>) -> Result<()> {
47+
// Write the pre-compiled CSS (always needed for basic styling)
48+
let css_content = include_str!(concat!(env!("OUT_DIR"), "/compiled_style.css"));
49+
fs::write(self.css_dir().join("style.css"), css_content)?;
50+
51+
// Copy book-related JavaScript files only if we have books
52+
if !books.is_empty() {
53+
let js_content = include_str!("../../assets/book_list.js");
54+
fs::write(self.js_dir().join("book_list.js"), js_content)?;
55+
56+
let js_content = include_str!("../../assets/book_detail.js");
57+
fs::write(self.js_dir().join("book_detail.js"), js_content)?;
58+
59+
let lazy_loading_content = include_str!("../../assets/lazy-loading.js");
60+
fs::write(self.js_dir().join("lazy-loading.js"), lazy_loading_content)?;
61+
62+
let section_toggle_content = include_str!("../../assets/section-toggle.js");
63+
fs::write(self.js_dir().join("section-toggle.js"), section_toggle_content)?;
64+
}
65+
66+
// Copy statistics-related JavaScript files only if we have stats data
67+
if stats_data.is_some() {
68+
let stats_js_content = include_str!("../../assets/statistics.js");
69+
fs::write(self.js_dir().join("statistics.js"), stats_js_content)?;
70+
71+
let heatmap_js_content = include_str!("../../assets/heatmap.js");
72+
fs::write(self.js_dir().join("heatmap.js"), heatmap_js_content)?;
73+
74+
let calendar_css = include_str!(concat!(env!("OUT_DIR"), "/event-calendar.min.css"));
75+
fs::write(self.css_dir().join("event-calendar.min.css"), calendar_css)?;
76+
77+
let calendar_js = include_str!(concat!(env!("OUT_DIR"), "/event-calendar.min.js"));
78+
fs::write(self.js_dir().join("event-calendar.min.js"), calendar_js)?;
79+
80+
let calendar_map = include_str!(concat!(env!("OUT_DIR"), "/event-calendar.min.js.map"));
81+
fs::write(self.js_dir().join("event-calendar.min.js.map"), calendar_map)?;
82+
83+
let calendar_init_js_content = include_str!("../../assets/calendar.js");
84+
fs::write(self.js_dir().join("calendar.js"), calendar_init_js_content)?;
85+
86+
// Storage utility
87+
let storage_js_content = include_str!("../../assets/storage-manager.js");
88+
fs::write(self.js_dir().join("storage-manager.js"), storage_js_content)?;
89+
90+
// Recap small UI logic
91+
let recap_js_content = include_str!("../../assets/recap.js");
92+
fs::write(self.js_dir().join("recap.js"), recap_js_content)?;
93+
}
94+
95+
Ok(())
96+
}
97+
98+
pub(crate) async fn generate_covers(&self, books: &[Book]) -> Result<()> {
99+
info!("Generating book covers...");
100+
101+
// Collect all cover generation tasks
102+
let mut tasks = Vec::new();
103+
104+
for book in books {
105+
if let Some(ref cover_data) = book.epub_info.cover_data {
106+
let cover_path = self.covers_dir().join(format!("{}.webp", book.id));
107+
let epub_path = book.epub_path.clone();
108+
let cover_data = cover_data.clone();
109+
110+
let should_generate = match (fs::metadata(&epub_path), fs::metadata(&cover_path)) {
111+
(Ok(epub_meta), Ok(cover_meta)) => {
112+
let epub_time = epub_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
113+
let cover_time = cover_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
114+
epub_time > cover_time
115+
}
116+
(Ok(_), Err(_)) => true, // Cover missing
117+
_ => true, // If we can't get metadata, be safe and regenerate
118+
};
119+
120+
if should_generate {
121+
// Spawn a task for each cover generation
122+
let task = tokio::task::spawn_blocking(move || -> Result<()> {
123+
let img = image::load_from_memory(&cover_data)
124+
.context("Failed to load cover image")?;
125+
126+
// Resize to height of 600px while maintaining aspect ratio
127+
let (original_width, original_height) = (img.width(), img.height());
128+
let target_height = 600;
129+
let target_width = (original_width * target_height) / original_height;
130+
131+
let resized = img.resize(target_width, target_height, image::imageops::FilterType::Lanczos3);
132+
133+
// Convert to RGB8 format for WebP encoding
134+
let rgb_img = resized.to_rgb8();
135+
136+
// Use webp crate for better quality control
137+
let encoder = webp::Encoder::from_rgb(&rgb_img, rgb_img.width(), rgb_img.height());
138+
let webp_data = encoder.encode(50.0);
139+
140+
fs::write(&cover_path, &*webp_data)
141+
.with_context(|| format!("Failed to save cover: {:?}", cover_path))?;
142+
143+
Ok(())
144+
});
145+
146+
tasks.push(task);
147+
}
148+
}
149+
}
150+
151+
// Wait for all cover generation tasks to complete
152+
let results = future::join_all(tasks).await;
153+
154+
// Check for any errors
155+
for (i, result) in results.into_iter().enumerate() {
156+
match result {
157+
Ok(Ok(())) => {}, // Success
158+
Ok(Err(e)) => return Err(e.context(format!("Failed to generate cover {}", i))),
159+
Err(e) => return Err(anyhow::Error::new(e).context(format!("Task {} panicked", i))),
160+
}
161+
}
162+
163+
Ok(())
164+
}
165+
}

src/site_generator/books.rs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
//! Book list and detail page generation.
2+
3+
use super::SiteGenerator;
4+
use crate::models::{Book, BookStatus, StatisticsData};
5+
use crate::statistics::BookStatistics;
6+
use crate::templates::{IndexTemplate, BookTemplate, BookMarkdownTemplate};
7+
use anyhow::Result;
8+
use askama::Template;
9+
use log::info;
10+
use std::fs;
11+
12+
impl SiteGenerator {
13+
pub(crate) async fn generate_book_list(&self, books: &[Book], recap_latest_href: Option<String>) -> Result<()> {
14+
info!("Generating book list page...");
15+
16+
let mut reading_books = Vec::new();
17+
let mut completed_books = Vec::new();
18+
let mut abandoned_books = Vec::new();
19+
let mut unread_books = Vec::new();
20+
21+
for book in books {
22+
match book.status() {
23+
BookStatus::Reading => reading_books.push(book.clone()),
24+
BookStatus::Complete => completed_books.push(book.clone()),
25+
BookStatus::Abandoned => abandoned_books.push(book.clone()),
26+
BookStatus::Unknown => {
27+
// If it has KoReader metadata, add to reading; otherwise check if we should include unread
28+
if book.koreader_metadata.is_some() {
29+
reading_books.push(book.clone());
30+
} else if self.include_unread {
31+
unread_books.push(book.clone());
32+
}
33+
// If include_unread is false, we skip books without metadata
34+
}
35+
}
36+
}
37+
38+
// Sort by title
39+
reading_books.sort_by(|a, b| a.epub_info.title.cmp(&b.epub_info.title));
40+
completed_books.sort_by(|a, b| a.epub_info.title.cmp(&b.epub_info.title));
41+
abandoned_books.sort_by(|a, b| a.epub_info.title.cmp(&b.epub_info.title));
42+
unread_books.sort_by(|a, b| a.epub_info.title.cmp(&b.epub_info.title));
43+
44+
// ------------------------------------------------------------------
45+
// Generate books manifest JSON categorized by reading status.
46+
// NOTE: This manifest is not consumed by the frontend code – it is
47+
// generated purely for the convenience of users who may want a
48+
// machine-readable list of all books and their export paths.
49+
// ------------------------------------------------------------------
50+
51+
use serde_json::json;
52+
53+
let to_manifest_entry = |b: &Book| {
54+
json!({
55+
"id": b.id.clone(),
56+
"title": b.epub_info.title.clone(),
57+
"authors": b.epub_info.authors.clone(),
58+
"json_path": format!("/books/{}/details.json", b.id),
59+
"html_path": format!("/books/{}/index.html", b.id),
60+
})
61+
};
62+
63+
let reading_json: Vec<_> = reading_books.iter().map(to_manifest_entry).collect();
64+
let completed_json: Vec<_> = completed_books.iter().map(to_manifest_entry).collect();
65+
let abandoned_json: Vec<_> = abandoned_books.iter().map(to_manifest_entry).collect();
66+
let new_json: Vec<_> = unread_books.iter().map(to_manifest_entry).collect();
67+
68+
let manifest = json!({
69+
"reading": reading_json,
70+
"completed": completed_json,
71+
"abandoned": abandoned_json,
72+
"new": new_json,
73+
"generated_at": self.get_last_updated(),
74+
});
75+
76+
fs::write(
77+
self.books_dir().join("list.json"),
78+
serde_json::to_string_pretty(&manifest)?,
79+
)?;
80+
81+
// ------------------------------------------------------------------
82+
// Render book list HTML
83+
// ------------------------------------------------------------------
84+
85+
let template = IndexTemplate {
86+
site_title: self.site_title.clone(),
87+
reading_books,
88+
completed_books,
89+
abandoned_books,
90+
unread_books,
91+
version: self.get_version(),
92+
last_updated: self.get_last_updated(),
93+
navbar_items: self.create_navbar_items_with_recap("books", recap_latest_href.as_deref()),
94+
};
95+
96+
let html = template.render()?;
97+
self.write_minify_html(self.output_dir.join("index.html"), &html)?;
98+
99+
Ok(())
100+
}
101+
102+
pub(crate) async fn generate_book_pages(&self, books: &[Book], stats_data: &mut Option<StatisticsData>, recap_latest_href: Option<String>) -> Result<()> {
103+
info!("Generating book detail pages...");
104+
105+
for book in books {
106+
// Try to find matching statistics by MD5
107+
let book_stats = stats_data.as_ref().and_then(|stats| {
108+
// Try to match using the partial_md5_checksum from KoReader metadata
109+
book.koreader_metadata
110+
.as_ref()
111+
.and_then(|metadata| metadata.partial_md5_checksum.as_ref())
112+
.and_then(|md5| stats.stats_by_md5.get(md5))
113+
.cloned()
114+
});
115+
116+
// Calculate session statistics if we have book stats
117+
let session_stats = match (stats_data.as_ref(), &book_stats) {
118+
(Some(stats), Some(book_stat)) => Some(book_stat.calculate_session_stats(&stats.page_stats, &self.time_config)),
119+
_ => None,
120+
};
121+
122+
let template = BookTemplate {
123+
site_title: self.site_title.clone(),
124+
book: book.clone(),
125+
book_stats: book_stats.clone(),
126+
session_stats: session_stats.clone(),
127+
version: self.get_version(),
128+
last_updated: self.get_last_updated(),
129+
navbar_items: self.create_navbar_items_with_recap("books", recap_latest_href.as_deref()),
130+
};
131+
132+
let html = template.render()?;
133+
let book_dir = self.books_dir().join(&book.id);
134+
fs::create_dir_all(&book_dir)?;
135+
let book_path = book_dir.join("index.html");
136+
self.write_minify_html(book_path, &html)?;
137+
138+
// Generate Markdown export
139+
let md_template = BookMarkdownTemplate {
140+
book: book.clone(),
141+
book_stats: book_stats.clone(),
142+
session_stats: session_stats.clone(),
143+
version: self.get_version(),
144+
last_updated: self.get_last_updated(),
145+
};
146+
let markdown = md_template.render()?;
147+
fs::write(book_dir.join("details.md"), markdown)?;
148+
149+
// Generate JSON export / not used by the frontend code - only for the user's convenience
150+
let json_data = serde_json::json!({
151+
"book": {
152+
"title": book.epub_info.title,
153+
"authors": book.epub_info.authors,
154+
"series": book.series_display(),
155+
"language": book.language(),
156+
"publisher": book.publisher(),
157+
"description": book.epub_info.sanitized_description(),
158+
"rating": book.rating(),
159+
"review_note": book.review_note(),
160+
"status": book.status().to_string(),
161+
"progress_percentage": book.progress_percentage(),
162+
"subjects": book.subjects(),
163+
"identifiers": book.identifiers().iter().map(|id| {
164+
serde_json::json!({
165+
"scheme": id.scheme,
166+
"value": id.value,
167+
"display_scheme": id.display_scheme(),
168+
"url": id.url()
169+
})
170+
}).collect::<Vec<_>>()
171+
},
172+
"annotations": book.koreader_metadata.as_ref().map(|m| &m.annotations).unwrap_or(&vec![]),
173+
"statistics": {
174+
"book_stats": book_stats,
175+
"session_stats": session_stats,
176+
"completions": book_stats.as_ref().and_then(|stats| stats.completions.as_ref())
177+
},
178+
"export_info": {
179+
"generated_by": "KoShelf",
180+
"version": self.get_version(),
181+
"generated_at": self.get_last_updated()
182+
}
183+
});
184+
let json_str = serde_json::to_string_pretty(&json_data)?;
185+
fs::write(book_dir.join("details.json"), json_str)?;
186+
}
187+
188+
Ok(())
189+
}
190+
}

0 commit comments

Comments
 (0)