//! 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()
}