new: yew-based front-end

This commit is contained in:
Matthew Ryan Dillon 2021-12-09 12:08:28 -07:00
parent abadb2ec7d
commit d64938e8e5
20 changed files with 680 additions and 6263 deletions

81
src/app.rs Normal file
View file

@ -0,0 +1,81 @@
use super::loader::Loader;
use web_sys::Node;
use yew::virtual_dom::VNode;
use yew::{function_component, html, Html};
#[function_component(App)]
pub fn app() -> Html {
html! {
<>
<h1>
<a href="/">
{ "gpx.thermokar.st" }
</a>
</h1>
<p>
{ "a client-side tool for merging " }
<a href="https://www.topografix.com/gpx.asp">
{ "gpx files" }
</a>
</p>
<Loader />
<hr/>
<Footer />
</>
}
}
#[function_component(Footer)]
fn footer() -> Html {
let notes = Vec::from([
// note 1
"this has only been tested on GPX files produced by \
<a href=\"https://strava.com\" target=\"_blank\">strava</a> and \
<a href=\"https://garmin.com\" target=\"_blank\">garmin</a>",
// note 2
"all third-party extension info \
<a href=\"https://github.com/georust/gpx/issues/8\" target=\"_blank\">\
is stripped</a>",
// note 3
"if the app breaks, try refreshing the page",
"source (public access): git://pingo.thermokar.st/gpx-web-utils",
// note 4
"source (mirror): \
<a href=\"https://github.com/thermokarst/gpx-web-utils\" target=\"_blank\">\
https://github.com/thermokarst/gpx-web-utils</a>",
]);
html! {
<div>
<ul>
{ for notes.iter().map(|n| inner_html_enabled_li(n)) }
</ul>
<span>
<small>
{ "\u{000A9} matthew ryan dillon, 2021" }
</small>
</span>
</div>
}
}
fn inner_html_enabled_li(data: &str) -> Html {
let li = web_sys::window()
.unwrap()
.document()
.unwrap()
.create_element("li")
.unwrap();
li.set_inner_html(data);
let node = Node::from(li);
VNode::VRef(node)
}

88
src/gpx.rs Normal file
View file

@ -0,0 +1,88 @@
use std::error::Error;
use wasm_bindgen::prelude::*;
use web_sys::{Blob, MouseEvent, Url};
fn join_gpx_files(files: &[String]) -> Result<gpx::Gpx, Box<dyn Error>> {
let mut merged_gpx: gpx::Gpx = Default::default();
let mut merged_track: gpx::Track = gpx::Track::new();
for file in files.iter() {
let buffer = std::io::BufReader::new(file.as_bytes());
let mut parsed_gpx: gpx::Gpx = gpx::read(buffer)?;
// consolidate all track segements into one single track.
for track in parsed_gpx.tracks {
for segment in track.segments {
merged_track.segments.push(segment);
}
}
merged_gpx.waypoints.append(&mut parsed_gpx.waypoints);
}
merged_gpx.tracks.push(merged_track);
let link = gpx::Link {
href: String::from("https://gpx.thermokar.st"),
..Default::default()
};
let author = gpx::Person {
link: Some(link),
..Default::default()
};
let metadata = gpx::Metadata {
name: Some(String::from("merged")),
author: Some(author),
..Default::default()
};
merged_gpx.metadata = Some(metadata);
merged_gpx.version = gpx::GpxVersion::Gpx11;
Ok(merged_gpx)
}
fn write_gpx_to_buffer(gpx: gpx::Gpx) -> Result<js_sys::Array, Box<dyn Error>> {
let mut buffer = Vec::new();
gpx::write(&gpx, &mut buffer)?;
let uint8arr = js_sys::Uint8Array::new(&unsafe { js_sys::Uint8Array::view(&buffer) }.into());
let array = js_sys::Array::new();
array.push(&uint8arr.buffer());
Ok(array)
}
pub fn merge(files: &[String]) -> Result<Blob, Box<dyn Error>> {
let merged: gpx::Gpx = join_gpx_files(files)?;
let out_vec = write_gpx_to_buffer(merged)?;
let result = Blob::new_with_u8_array_sequence(&out_vec).map_err(|e| e.as_string().unwrap())?;
Ok(result)
}
pub fn download(merged: Blob) -> Result<(), Box<dyn Error>> {
let window = web_sys::window().ok_or("no global `window` exists")?;
let document = window
.document()
.ok_or("should have a document on window")?;
let err_handler = |e: JsValue| e.as_string().unwrap();
let anchor_element = document.create_element("a").map_err(err_handler)?;
let url = Url::create_object_url_with_blob(&merged).map_err(err_handler)?;
anchor_element
.set_attribute("href", &url)
.map_err(err_handler)?;
anchor_element
.set_attribute("download", "merged.gpx")
.map_err(err_handler)?;
let event = MouseEvent::new("click").map_err(err_handler)?;
anchor_element.dispatch_event(&event).map_err(err_handler)?;
Ok(())
}
#[wasm_bindgen]
extern "C" {
pub fn alert(s: &str);
}

View file

@ -1,17 +0,0 @@
mod utils;
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub fn merge(files: js_sys::Array) -> wasm_bindgen::JsValue {
utils::set_panic_hook();
let files: Vec<String> = utils::translate_js_to_rust(files);
let merged: gpx::Gpx = utils::join_gpx_files(files);
let out_vec: Vec<u8> = utils::write_gpx_to_buffer(merged);
JsValue::from_str(&String::from_utf8(out_vec).unwrap())
}

155
src/loader.rs Normal file
View file

@ -0,0 +1,155 @@
use std::collections::HashMap;
use gloo_file::callbacks::FileReader;
use gloo_file::File;
use web_sys::{Blob, Event, HtmlInputElement};
use yew::{html, html::TargetCast, Component, Context, Html};
use super::gpx;
pub enum Msg {
FileLoaded(String, String),
StartLoad(Vec<File>),
FilesLoaded,
Download(Blob),
Reset,
}
pub struct Loader {
readers: HashMap<String, FileReader>,
files: Vec<String>,
count: usize,
is_loading: bool,
// This field is to handle resetting the native HTML widget's state on error
field_value: &'static str,
}
impl Component for Loader {
type Message = Msg;
type Properties = ();
fn create(_ctx: &Context<Self>) -> Self {
Self {
readers: HashMap::default(),
files: vec![],
count: 0,
is_loading: false,
field_value: "",
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::FileLoaded(filename, data) => {
self.files.push(data);
self.readers.remove(&filename);
if self.files.len() == self.count {
ctx.link().send_message(Msg::FilesLoaded);
}
true
}
Msg::StartLoad(files) => {
self.count = files.len();
if self.count < 2 {
gpx::alert("must load two or more files");
ctx.link().send_message(Msg::Reset);
return true;
}
self.is_loading = true;
for file in files.into_iter() {
let file_name = file.name();
let task = {
let file_name = file_name.clone();
let link = ctx.link().clone();
gloo_file::callbacks::read_as_text(&file, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.unwrap_or_else(|e| e.to_string()),
))
})
};
self.readers.insert(file_name, task);
}
true
}
Msg::FilesLoaded => {
let link = ctx.link();
let merged = match gpx::merge(&self.files) {
Ok(result) => result,
Err(err) => {
gpx::alert(&err.to_string());
link.send_message(Msg::Reset);
return true;
}
};
link.send_message(Msg::Download(merged));
true
}
Msg::Download(merged) => {
let link = ctx.link();
match gpx::download(merged) {
Ok(_) => (),
Err(err) => {
gpx::alert(&err.to_string());
link.send_message(Msg::Reset);
return true;
}
}
link.send_message(Msg::Reset);
true
}
Msg::Reset => {
self.readers = HashMap::default();
self.files = vec![];
self.count = 0;
self.is_loading = false;
self.field_value = "";
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let cb = move |e: Event| {
let mut result = Vec::new();
let input: HtmlInputElement = e.target_unchecked_into();
if let Some(files) = input.files() {
let files = js_sys::try_iter(&files)
.unwrap()
.unwrap()
.map(|v| web_sys::File::from(v.unwrap()))
.map(File::from);
result.extend(files);
}
Msg::StartLoad(result)
};
html! {
if self.is_loading {
<span><strong>{"processing..."}</strong></span>
} else {
<input
type="file"
value={self.field_value}
multiple=true
onchange={ctx.link().callback(cb)}
/>
}
}
}
}

13
src/main.rs Normal file
View file

@ -0,0 +1,13 @@
extern crate wee_alloc;
mod app;
mod gpx;
mod loader;
// Use `wee_alloc` as the global allocator.
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
fn main() {
yew::start_app::<app::App>();
}

View file

@ -1,55 +0,0 @@
pub fn set_panic_hook() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
pub fn translate_js_to_rust(files: js_sys::Array) -> Vec<String> {
// https://github.com/rustwasm/wasm-bindgen/issues/111
files.iter().map(|f| f.as_string().unwrap()).collect()
}
pub fn join_gpx_files(files: Vec<String>) -> gpx::Gpx {
let mut merged_gpx: gpx::Gpx = Default::default();
let mut merged_track: gpx::Track = gpx::Track::new();
for file in files.iter() {
let buffer = std::io::BufReader::new(file.as_bytes());
let mut parsed_gpx: gpx::Gpx = gpx::read(buffer).expect("invalid gpx");
// consolidate all track segements into one single track.
for track in parsed_gpx.tracks {
for segment in track.segments {
merged_track.segments.push(segment);
}
}
merged_gpx.waypoints.append(&mut parsed_gpx.waypoints);
}
merged_gpx.tracks.push(merged_track);
let link = gpx::Link {
href: String::from("https://gpx.thermokar.st"),
..Default::default()
};
let author = gpx::Person {
link: Some(link),
..Default::default()
};
let metadata = gpx::Metadata {
name: Some(String::from("merged")),
author: Some(author),
..Default::default()
};
merged_gpx.metadata = Some(metadata);
merged_gpx.version = gpx::GpxVersion::Gpx11;
merged_gpx
}
pub fn write_gpx_to_buffer(gpx: gpx::Gpx) -> Vec<u8> {
let mut buffer = Vec::new();
gpx::write(&gpx, &mut buffer).unwrap();
buffer
}