/ src / bundle.rs
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  }