bundle.rs
1 //! Bundle save/load helpers for offline use. 2 //! 3 //! Bundle layout: 4 //! - bundle.json (manifest) 5 //! - page.html 6 //! - images/... 7 8 use crate::util::sanitize_filename; 9 use anyhow::Context; 10 use serde::{Deserialize, Serialize}; 11 use std::fs; 12 use std::path::{Path, PathBuf}; 13 use std::time::{SystemTime, UNIX_EPOCH}; 14 use url::Url; 15 16 /// An input image for bundle creation. 17 pub struct BundleImageInput<'a> { 18 pub url: &'a Url, 19 pub alt: &'a str, 20 pub path: &'a Path, 21 } 22 23 /// A loaded bundle. 24 #[derive(Debug, Clone)] 25 pub struct BundleContents { 26 pub url: Url, 27 pub html: String, 28 pub images: Vec<BundleImage>, 29 } 30 31 /// A bundled image entry. 32 #[derive(Debug, Clone)] 33 pub struct BundleImage { 34 pub url: Url, 35 pub alt: String, 36 pub path: PathBuf, 37 } 38 39 #[derive(Debug, Serialize, Deserialize)] 40 struct BundleManifest { 41 version: u32, 42 url: String, 43 saved_at: u64, 44 html_file: String, 45 images: Vec<BundleImageEntry>, 46 } 47 48 #[derive(Debug, Serialize, Deserialize)] 49 struct BundleImageEntry { 50 index: usize, 51 url: String, 52 alt: String, 53 file: String, 54 } 55 56 /// Save the current page as an offline bundle. 57 pub fn save_bundle( 58 base_dir: &Path, 59 url: &Url, 60 html: &str, 61 images: &[BundleImageInput<'_>], 62 ) -> anyhow::Result<PathBuf> { 63 let bundle_dir = suggest_bundle_dir(base_dir, url); 64 fs::create_dir_all(bundle_dir.join("images")) 65 .with_context(|| format!("Failed creating {}", bundle_dir.display()))?; 66 67 let html_file = "page.html"; 68 fs::write(bundle_dir.join(html_file), html) 69 .with_context(|| format!("Failed writing {}", html_file))?; 70 71 let mut entries = Vec::new(); 72 for (idx, img) in images.iter().enumerate() { 73 let file_name = bundle_image_filename(idx + 1, img.path); 74 let rel_path = PathBuf::from("images").join(&file_name); 75 fs::copy(img.path, bundle_dir.join(&rel_path)) 76 .with_context(|| format!("Failed copying image {} to bundle", img.path.display()))?; 77 entries.push(BundleImageEntry { 78 index: idx + 1, 79 url: img.url.to_string(), 80 alt: img.alt.to_string(), 81 file: rel_path.to_string_lossy().to_string(), 82 }); 83 } 84 85 let manifest = BundleManifest { 86 version: 1, 87 url: url.to_string(), 88 saved_at: now_epoch_secs(), 89 html_file: html_file.to_string(), 90 images: entries, 91 }; 92 let manifest_path = bundle_dir.join("bundle.json"); 93 let data = serde_json::to_string_pretty(&manifest)?; 94 fs::write(&manifest_path, data) 95 .with_context(|| format!("Failed writing {}", manifest_path.display()))?; 96 97 Ok(bundle_dir) 98 } 99 100 /// Load an offline bundle from a directory or manifest file. 101 pub fn load_bundle(path: &Path) -> anyhow::Result<BundleContents> { 102 let (bundle_dir, manifest_path) = resolve_bundle_paths(path); 103 let data = fs::read_to_string(&manifest_path) 104 .with_context(|| format!("Failed reading {}", manifest_path.display()))?; 105 let manifest: BundleManifest = serde_json::from_str(&data)?; 106 let url = Url::parse(&manifest.url)?; 107 108 let html_path = bundle_dir.join(&manifest.html_file); 109 let html = fs::read_to_string(&html_path) 110 .with_context(|| format!("Failed reading {}", html_path.display()))?; 111 112 let mut images = Vec::new(); 113 for entry in manifest.images { 114 let file_path = bundle_dir.join(&entry.file); 115 if !file_path.exists() { 116 continue; 117 } 118 if let Ok(url) = Url::parse(&entry.url) { 119 images.push(BundleImage { 120 url, 121 alt: entry.alt, 122 path: file_path, 123 }); 124 } 125 } 126 127 Ok(BundleContents { url, html, images }) 128 } 129 130 /// Export HTML to Markdown (best-effort). 131 pub fn export_markdown(path: &Path, html: &str) -> anyhow::Result<()> { 132 let md = html2text::from_read(html.as_bytes(), 100); 133 fs::write(path, md).with_context(|| format!("Failed writing {}", path.display()))?; 134 Ok(()) 135 } 136 137 fn resolve_bundle_paths(path: &Path) -> (PathBuf, PathBuf) { 138 if path.is_dir() { 139 let manifest = path.join("bundle.json"); 140 return (path.to_path_buf(), manifest); 141 } 142 let dir = path 143 .parent() 144 .unwrap_or_else(|| Path::new(".")) 145 .to_path_buf(); 146 (dir, path.to_path_buf()) 147 } 148 149 fn suggest_bundle_dir(base_dir: &Path, url: &Url) -> PathBuf { 150 let host = url.host_str().unwrap_or("page"); 151 let slug = sanitize_filename(host); 152 let ts = now_epoch_secs(); 153 let mut candidate = base_dir.join(format!("{}_{}", slug, ts)); 154 if !candidate.exists() { 155 return candidate; 156 } 157 for i in 1..=9999 { 158 let alt = base_dir.join(format!("{}_{}_{}", slug, ts, i)); 159 if !alt.exists() { 160 candidate = alt; 161 break; 162 } 163 } 164 candidate 165 } 166 167 fn bundle_image_filename(index: usize, source: &Path) -> String { 168 let name = source 169 .file_name() 170 .and_then(|s| s.to_str()) 171 .unwrap_or("image"); 172 format!("{}_{}", index, sanitize_filename(name)) 173 } 174 175 fn now_epoch_secs() -> u64 { 176 SystemTime::now() 177 .duration_since(UNIX_EPOCH) 178 .unwrap_or_default() 179 .as_secs() 180 } 181 182 #[cfg(test)] 183 mod tests { 184 use super::*; 185 use pretty_assertions::assert_eq; 186 187 fn temp_dir(name: &str) -> PathBuf { 188 let mut dir = std::env::temp_dir(); 189 dir.push(format!("forbyte_bundle_test_{}_{}", name, now_epoch_secs())); 190 fs::create_dir_all(&dir).unwrap(); 191 dir 192 } 193 194 #[test] 195 fn save_and_load_bundle_roundtrip() { 196 let base = temp_dir("save_load"); 197 let url = Url::parse("https://example.com/page").unwrap(); 198 let html = "<h1>Title</h1>"; 199 let img_path = base.join("img.png"); 200 fs::write(&img_path, b"data").unwrap(); 201 let img_url = Url::parse("https://example.com/img.png").unwrap(); 202 let images = vec![BundleImageInput { 203 url: &img_url, 204 alt: "alt text", 205 path: &img_path, 206 }]; 207 208 let bundle_dir = save_bundle(&base, &url, html, &images).unwrap(); 209 let loaded = load_bundle(&bundle_dir).unwrap(); 210 211 assert_eq!(loaded.url.as_str(), "https://example.com/page"); 212 assert!(loaded.html.contains("Title")); 213 assert_eq!(loaded.images.len(), 1); 214 assert!(loaded.images[0].path.exists()); 215 } 216 217 #[test] 218 fn export_markdown_writes_file() { 219 let dir = temp_dir("export_md"); 220 let path = dir.join("out.md"); 221 export_markdown(&path, "<h1>Hello</h1><p>World</p>").unwrap(); 222 let md = fs::read_to_string(&path).unwrap(); 223 assert!(md.contains("Hello")); 224 assert!(md.contains("World")); 225 } 226 }