use std::path::{Path, PathBuf};
use eframe::egui::{self};
use facett_docview::{DocView, Facet};
pub struct ManualTab {
view: Option<DocView>,
loaded: bool,
error: Option<String>,
dir: Option<PathBuf>,
}
impl Default for ManualTab {
fn default() -> Self {
Self { view: None, loaded: false, error: None, dir: None }
}
}
impl ManualTab {
pub fn new() -> Self {
Self::default()
}
pub fn reload(&mut self) {
self.loaded = false;
self.view = None;
self.error = None;
}
pub fn is_loaded(&self) -> bool {
self.view.as_ref().is_some_and(|v| v.page_count() > 0)
}
fn load(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
#[cfg(feature = "manual-embed")]
pluck_out_embedded();
#[cfg(feature = "manual-znippy")]
if let Some(path) = resolve_book_znippy() {
match load_manual_znippy(&path) {
Ok(view) => {
self.dir = Some(path);
self.view = Some(view);
return;
}
Err(e) => eprintln!("âš manual: book.znippy load failed, trying page dir: {e}"),
}
}
match resolve_book_svg_dir() {
Some(dir) => {
self.dir = Some(dir.clone());
match load_manual(&dir) {
Ok(view) => self.view = Some(view),
Err(e) => self.error = Some(e),
}
}
None => {
self.error = Some(
"no docs/book-svg/ found — run `nornir docs book --format svg` \
(or set NORNIR_MANUAL_DIR) to generate the manual"
.to_string(),
);
}
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.load();
if let Some(e) = &self.error {
ui.add_space(20.0);
ui.label(egui::RichText::new("📖 Manual not available").strong());
ui.label(e);
return;
}
match &mut self.view {
Some(v) => Facet::ui(v, ui),
None => {
ui.label("loading manual…");
}
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"dir": self.dir.as_ref().map(|p| p.display().to_string()),
"loaded": self.is_loaded(),
"error": self.error,
"doc": self.view.as_ref().map(|v| v.state_json()),
})
}
}
fn manual_data_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".nornir/manual"))
}
fn resolve_book_svg_dir() -> Option<PathBuf> {
if let Some(env) = std::env::var_os("NORNIR_MANUAL_DIR") {
let p = PathBuf::from(env);
if p.is_dir() {
return Some(p);
}
}
let mut bases: Vec<PathBuf> = Vec::new();
if let Some(d) = manual_data_dir() {
bases.push(d);
}
if let Ok(cwd) = std::env::current_dir() {
bases.push(cwd.join("docs"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(root) = exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
bases.push(root.join("docs"));
}
}
for b in &bases {
let dir = b.join("book-svg");
if dir.is_dir() {
return Some(dir);
}
}
for b in &bases {
for name in ["book-svg.nbook", "book.nbook"] {
let bundle = b.join(name);
if bundle.is_file() {
if let Some(dir) = extract_bundle(&bundle) {
return Some(dir);
}
}
}
}
None
}
fn extract_bundle(bundle: &Path) -> Option<PathBuf> {
use nornir_airgap::archive::{Archive, TarZstd};
let dest = manual_data_dir()?;
std::fs::create_dir_all(&dest).ok()?;
TarZstd::new().extract_all(bundle, &dest).ok()?;
let dir = dest.join("book-svg");
dir.is_dir().then_some(dir)
}
fn load_manual(dir: &Path) -> Result<DocView, String> {
let manifest_path = dir.join("manifest.json");
let (title, toc) = match std::fs::read_to_string(&manifest_path) {
Ok(s) => parse_manifest(&s),
Err(_) => ("manual".to_string(), Vec::new()),
};
let mut svg_paths: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| format!("read {}: {e}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.extension().and_then(|x| x.to_str()) == Some("svg")
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("page-"))
})
.collect();
svg_paths.sort();
if svg_paths.is_empty() {
return Err(format!("no page-*.svg in {}", dir.display()));
}
let bytes: Vec<Vec<u8>> = svg_paths.iter().filter_map(|p| std::fs::read(p).ok()).collect();
Ok(DocView::from_svgs(title, bytes, toc))
}
fn parse_manifest(s: &str) -> (String, Vec<(String, usize)>) {
let v: serde_json::Value = match serde_json::from_str(s) {
Ok(v) => v,
Err(_) => return ("manual".to_string(), Vec::new()),
};
let title = v
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("manual")
.to_string();
let toc = v
.get("toc")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| {
let title = e.get("title")?.as_str()?.to_string();
let page = e.get("page")?.as_u64()? as usize;
Some((title, page))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
(title, toc)
}
#[cfg(feature = "manual-embed")]
static EMBEDDED_MANUAL: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/book.znippy"));
#[cfg(feature = "manual-embed")]
fn pluck_out_embedded() {
if EMBEDDED_MANUAL.is_empty() {
return; }
let Some(dir) = manual_data_dir() else { return };
let path = dir.join("book.znippy");
if std::fs::metadata(&path).map(|m| m.len() as usize == EMBEDDED_MANUAL.len()).unwrap_or(false) {
return;
}
if std::fs::create_dir_all(&dir).is_ok() {
if let Err(e) = std::fs::write(&path, EMBEDDED_MANUAL) {
eprintln!("âš manual: pluck-out to {} failed (non-fatal): {e}", path.display());
}
}
}
#[cfg(feature = "manual-znippy")]
use znippy_source::{load_manual_znippy, resolve_book_znippy};
#[cfg(feature = "manual-znippy")]
mod znippy_source {
use super::*;
use facett_docview::PageSource;
use znippy_common::{ZnippyArchive, ZnippyReader};
struct ZnippyManual {
archive: ZnippyArchive,
count: usize,
}
impl PageSource for ZnippyManual {
fn page_count(&self) -> usize {
self.count
}
fn page_svg(&self, i: usize) -> Option<Vec<u8>> {
self.archive.extract_file(&format!("page-{:04}.svg", i + 1)).ok()
}
}
fn is_page(name: &str) -> bool {
let f = name.rsplit('/').next().unwrap_or(name);
f.starts_with("page-") && f.ends_with(".svg")
}
pub(super) fn load_manual_znippy(path: &Path) -> Result<DocView, String> {
let archive =
ZnippyArchive::open(path).map_err(|e| format!("open {}: {e:#}", path.display()))?;
let files = archive.list_files().map_err(|e| format!("list {}: {e:#}", path.display()))?;
let count = files.iter().filter(|f| is_page(f)).count();
if count == 0 {
return Err(format!("no page-*.svg in {}", path.display()));
}
let (title, toc) = archive
.extract_file("manifest.json")
.ok()
.map(|b| parse_manifest(&String::from_utf8_lossy(&b)))
.unwrap_or_else(|| ("manual".to_string(), Vec::new()));
Ok(DocView::from_source(title, Box::new(ZnippyManual { archive, count }), toc))
}
pub(super) fn resolve_book_znippy() -> Option<PathBuf> {
let mut cands: Vec<PathBuf> = Vec::new();
if let Some(env) = std::env::var_os("NORNIR_MANUAL_DIR") {
let p = PathBuf::from(env);
if p.is_file() {
return Some(p);
}
cands.push(p.join("book.znippy"));
}
if let Some(d) = manual_data_dir() {
cands.push(d.join("book.znippy"));
}
if let Ok(cwd) = std::env::current_dir() {
cands.push(cwd.join("docs/book.znippy"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(root) = exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
cands.push(root.join("docs/book.znippy"));
}
}
cands.into_iter().find(|p| p.is_file())
}
}