//! one2html and onenote.rs for the browser. The page hands over the files //! (a .one section, a .onepkg package, or a whole notebook folder), they are //! kept in an in-memory file system, one2html converts them exactly as its //! command line does, and the page reads the HTML back out. //! //! Inputs live under /in, output under /out. Everything runs in a worker, //! one conversion at a time, so a global store is enough. use onenote_parser::FileSystem; use std::collections::{BTreeMap, BTreeSet}; use std::io::{Error, ErrorKind, Read}; use std::sync::Mutex; use typed_path::{TypedPath, TypedPathBuf}; use wasm_bindgen::prelude::*; #[derive(Default)] struct Store { files: BTreeMap>, dirs: BTreeSet, } static STORE: Mutex> = Mutex::new(None); static PANIC: Mutex = Mutex::new(String::new()); fn with_store(f: impl FnOnce(&mut Store) -> T) -> T { let mut guard = STORE.lock().unwrap_or_else(|e| e.into_inner()); f(guard.get_or_insert_with(Store::default)) } /// A path as a normalised absolute Unix string: "/in/Notebook/Section.one". fn key(path: TypedPath) -> String { let s = path.to_string_lossy().replace('\\', "/"); let mut parts: Vec<&str> = Vec::new(); for part in s.split('/') { match part { "" | "." => {} ".." => { parts.pop(); } p => parts.push(p), } } format!("/{}", parts.join("/")) } fn parent_dirs(path: &str, dirs: &mut BTreeSet) { let mut p = path; while let Some(i) = p.rfind('/') { p = &p[..i]; dirs.insert(if p.is_empty() { "/".to_string() } else { p.to_string() }); if p.is_empty() { break; } } } fn not_found(path: &str) -> Error { Error::new(ErrorKind::NotFound, format!("{path} not found")) } #[derive(Clone, Copy)] struct MemFs; impl FileSystem for MemFs { fn is_directory(&self, path: TypedPath) -> Result { let k = key(path); Ok(with_store(|s| s.dirs.contains(&k))) } fn read_dir(&self, path: TypedPath) -> Result, Error> { let k = key(path); let prefix = if k == "/" { "/".to_string() } else { format!("{k}/") }; with_store(|s| { if !s.dirs.contains(&k) { return Err(not_found(&k)); } let mut out: BTreeSet = BTreeSet::new(); for name in s.files.keys().chain(s.dirs.iter()) { if let Some(rest) = name.strip_prefix(&prefix) { if !rest.is_empty() { let first = rest.split('/').next().unwrap_or(rest); out.insert(format!("{prefix}{first}")); } } } Ok(out.into_iter().map(|p| TypedPath::derive(&p).to_path_buf()).collect()) }) } fn read_file(&self, path: TypedPath) -> Result, Error> { let k = key(path); with_store(|s| s.files.get(&k).cloned().ok_or_else(|| not_found(&k))) } fn write_file(&self, path: TypedPath, data: &[u8]) -> Result<(), Error> { let k = key(path); with_store(|s| { parent_dirs(&k, &mut s.dirs); s.files.insert(k, data.to_vec()); }); Ok(()) } fn stream_to_file(&self, path: TypedPath, reader: &mut dyn Read) -> Result<(), Error> { let mut data = Vec::new(); reader.read_to_end(&mut data)?; self.write_file(path, &data) } fn make_dir(&self, path: TypedPath) -> Result<(), Error> { let k = key(path); with_store(|s| { parent_dirs(&k, &mut s.dirs); s.dirs.insert(k); }); Ok(()) } fn canonicalize(&self, path: TypedPath) -> Result { Ok(TypedPath::derive(&key(path)).to_path_buf()) } fn exists(&self, path: TypedPath) -> Result { let k = key(path); Ok(with_store(|s| s.files.contains_key(&k) || s.dirs.contains(&k))) } fn is_windows(&self) -> bool { false } } /// Empties the store, ready for another notebook. #[wasm_bindgen] pub fn reset() { with_store(|s| *s = Store::default()); PANIC.lock().unwrap_or_else(|e| e.into_inner()).clear(); std::panic::set_hook(Box::new(|info| { *PANIC.lock().unwrap_or_else(|e| e.into_inner()) = info.to_string(); })); } /// Adds an input file; `path` is relative ("Notebook/Section.one"). #[wasm_bindgen(js_name = addFile)] pub fn add_file(path: &str, data: Vec) { let k = key(TypedPath::derive(&format!("/in/{path}"))); with_store(|s| { parent_dirs(&k, &mut s.dirs); s.files.insert(k, data); }); } /// Converts the input at `entry` (relative, like add_file) into HTML under /out. /// `emoji` renders note-tag icons as emoji (for the Markdown export). #[wasm_bindgen] pub fn convert(entry: &str, emoji: bool) -> Result<(), JsError> { with_store(|s| { s.files.retain(|k, _| k.starts_with("/in/")); s.dirs.retain(|k| k == "/" || k == "/in" || k.starts_with("/in/")); }); let input = format!("/in/{entry}"); let options = one2html::Options { warnings: false, math_target: one2html::MathTarget::MathML, note_tag_icons: if emoji { one2html::NoteTagIcons::Emoji } else { one2html::NoteTagIcons::Svg }, }; one2html::convert(TypedPath::derive(&input), TypedPath::derive("/out"), options, MemFs) .map_err(|e| JsError::new(&format!("{e:#}"))) } /// Every file written under /out, as paths relative to it. #[wasm_bindgen(js_name = outputPaths)] pub fn output_paths() -> Vec { with_store(|s| s.files.keys().filter_map(|k| k.strip_prefix("/out/").map(String::from)).collect()) } /// One output file's bytes, removed from the store as it is read. #[wasm_bindgen(js_name = takeOutput)] pub fn take_output(path: &str) -> Option> { let k = format!("/out/{path}"); with_store(|s| s.files.remove(&k)) } /// The message of the last panic, if a conversion aborted. #[wasm_bindgen(js_name = lastPanic)] pub fn last_panic() -> String { PANIC.lock().unwrap_or_else(|e| e.into_inner()).clone() }