Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

+454
View File
@@ -0,0 +1,454 @@
//! The files of the engine and the file system: picking files and folders, saving, closing, and
//! what the frontend shows of it (see `workspace`).
//!
//! The commands that can lose the edits of a file (closing, opening another folder) take what to do
//! with the files that have some: the frontend asks the user, and the files that have edits stay
//! open if it does not say.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use gpx_engine::{ExportOptions, FileId, api};
use serde::Deserialize;
use tauri::{AppHandle, Runtime, State};
use tauri_plugin_dialog::{DialogExt, FilePath};
use uuid::Uuid;
use crate::state::AppState;
use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
picked
.into_iter()
.filter_map(|path| path.into_path().ok())
.collect()
}
/// Asks where to write a GPX file, proposing `file_name` in `folder`. `None` if the user cancelled.
fn ask_save_path<R: Runtime>(
app: &AppHandle<R>,
file_name: &str,
folder: Option<&Path>,
) -> Option<PathBuf> {
let mut dialog = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(file_name);
if let Some(folder) = folder {
dialog = dialog.set_directory(folder);
}
dialog.blocking_save_file()?.into_path().ok()
}
/// Asks for a folder, starting in `folder`. `None` if the user cancelled.
fn ask_folder<R: Runtime>(app: &AppHandle<R>, folder: Option<&Path>) -> Option<PathBuf> {
let mut dialog = app.dialog().file();
if let Some(folder) = folder {
dialog = dialog.set_directory(folder);
}
dialog.blocking_pick_folder()?.into_path().ok()
}
#[tauri::command(async)]
pub fn workspace_state(state: State<AppState>) -> WorkspaceState {
state.core(|core| core.workspace_state())
}
/// Opens the files that the user picks. Nothing happens if they pick none.
#[tauri::command(async)]
pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api::Outcome {
let picked = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.blocking_pick_files();
let Some(picked) = picked else {
return api::Outcome::failed_none();
};
open_paths(state, paths(picked), None)
}
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
/// With `preview`, a single file is open as a preview (a single click in the tree, see
/// `Workspace::open`); otherwise they are open for good, even the preview.
#[tauri::command(async)]
pub fn open_paths(
state: State<AppState>,
paths: Vec<PathBuf>,
preview: Option<bool>,
) -> api::Outcome {
state.core(move |core| {
let outcome = core
.workspace
.open(&mut core.engine, &paths, preview.unwrap_or_default());
core.commit(None);
outcome
})
}
/// The files are open for good: the preview among them is not a preview anymore (the user did
/// something to it that the engine does not see, like hiding it).
#[tauri::command(async)]
pub fn keep_files(state: State<AppState>, file_ids: Vec<Uuid>) {
state.core(move |core| {
core.workspace.keep(&file_ids);
core.commit(None);
})
}
/// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
/// that have no place: the file dialog for a single one, a folder for several of them. Says whether
/// they were written, which is not the case if the user cancelled.
///
/// The files whose file on the disk was replaced are overwritten: the frontend asks before.
fn write_unsaved<R: Runtime>(
app: &AppHandle<R>,
state: &AppState,
ids: Vec<Uuid>,
) -> Result<bool, String> {
let (unlinked, start) = {
let ids = ids.clone();
state.core(move |core| {
(
core.workspace.unlinked(&core.engine, &ids),
core.workspace.folder.clone(),
)
})
};
let mut chosen = BTreeMap::new();
let mut folder = None;
match unlinked.as_slice() {
[] => {}
[(id, name)] => match ask_save_path(app, &format!("{name}.gpx"), start.as_deref()) {
Some(path) => {
chosen.insert(*id, path);
}
None => return Ok(false),
},
_ => match ask_folder(app, start.as_deref()) {
Some(path) => folder = Some(path),
None => return Ok(false),
},
}
state.core(move |core| {
let result = core
.workspace
.save(&mut core.engine, &ids, &chosen, folder.as_deref());
core.wrote(result)
})
}
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
/// their folder, or where the user says. Says whether they were written (`false` if the user
/// cancelled).
#[tauri::command(async)]
pub fn save_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
) -> Result<bool, String> {
write_unsaved(&app, &state, file_ids)
}
/// Writes a file where the user says, which is where the file is from then on. Says whether it was
/// written (`false` if the user cancelled).
#[tauri::command(async)]
pub fn save_file_as<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_id: Uuid,
) -> Result<bool, String> {
let (name, current, folder) = state.core(move |core| {
let name = core
.engine
.file_structure(&FileId(file_id))
.map(|structure| structure.name.clone());
let status = core.workspace_state();
let file = status.files.get(&file_id.to_string());
let current = file.and_then(|file| file.path.clone().map(PathBuf::from));
let folder = file
.and_then(|file| file.folder.clone().map(PathBuf::from))
.or(status.folder.map(PathBuf::from));
(name, current, folder)
});
let Some(name) = name else {
return Ok(false);
};
// where the file is, or else where it would be written
let folder = current
.as_ref()
.and_then(|path| path.parent().map(Path::to_path_buf))
.or(folder);
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), folder.as_deref()) else {
return Ok(false);
};
state.core(move |core| {
let result = core.workspace.save_as(&mut core.engine, file_id, &path);
core.wrote(result)
})
}
/// Reads files again from the disk: the edits they have are lost (they are one undo step).
#[tauri::command(async)]
pub fn reload_files(state: State<AppState>, file_ids: Vec<Uuid>) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core.workspace.reload(&mut core.engine, &file_ids);
core.commit(None);
outcome
})
}
/// The files among `ids` that are not the ones on the disk, saved first if `unsaved` says so.
/// Says whether to go on, which is not the case if the user cancelled saving them.
fn settle<R: Runtime>(
app: &AppHandle<R>,
state: &AppState,
ids: Vec<Uuid>,
unsaved: Option<Unsaved>,
) -> Result<bool, String> {
if unsaved != Some(Unsaved::Save) {
return Ok(true);
}
let unsaved_ids = state.core(move |core| core.workspace.unsaved(&core.engine, &ids));
if unsaved_ids.is_empty() {
return Ok(true);
}
write_unsaved(app, state, unsaved_ids)
}
/// Opens the folder that the user picks, which is watched. Its files are not opened. The folder
/// that was open is closed with its files: `unsaved` says what to do with the ones that have
/// unsaved edits.
#[tauri::command(async)]
pub fn open_folder<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
let Some(folder) = app
.dialog()
.file()
.blocking_pick_folder()
.and_then(|folder| folder.into_path().ok())
else {
return Ok(api::Outcome::failed_none());
};
let all = state.core(|core| core.workspace.all(&core.engine));
if !settle(&app, &state, all, unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| {
let discard = unsaved.is_some();
let outcome = core
.workspace
.open_folder(&mut core.engine, &folder, discard);
core.commit(None);
Ok(outcome)
})
}
/// Closes the folder: it is not watched anymore, and the files are closed with it. `unsaved` says
/// what to do with the ones that have unsaved edits (they stay open without it).
#[tauri::command(async)]
pub fn close_folder<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
let all = state.core(|core| core.workspace.all(&core.engine));
if !settle(&app, &state, all, unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| {
let outcome = core
.workspace
.close_folder(&mut core.engine, unsaved.is_some());
core.commit(None);
Ok(outcome)
})
}
/// Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved
/// first, or the edits are lost (they stay open without it).
#[tauri::command(async)]
pub fn close_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
if !settle(&app, &state, file_ids.clone(), unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| {
let outcome = core
.workspace
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
core.commit(None);
Ok(outcome)
})
}
/// The folders and GPX files that are in a folder of the open folder, or in it (the root of the
/// tree when no path is given).
#[tauri::command(async)]
pub fn list_folder(state: State<AppState>, path: Option<PathBuf>) -> Result<Vec<Entry>, String> {
state.core(move |core| {
let Some(path) = path.or_else(|| core.workspace.folder.clone()) else {
return Ok(vec![]);
};
core.workspace.list_folder(&path)
})
}
/// What the files are exported with, see `ExportOptions`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportArguments {
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
}
impl From<ExportArguments> for ExportOptions {
fn from(arguments: ExportArguments) -> Self {
Self {
time: arguments.time,
hr: arguments.hr,
cad: arguments.cad,
atemp: arguments.atemp,
power: arguments.power,
osm: arguments.osm,
}
}
}
/// Writes copies of files where the user says: a file dialog for a single one, a folder for
/// several. The files stay as they are. Says whether they were written (`false` if the user
/// cancelled).
#[tauri::command(async)]
pub fn export_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
options: ExportArguments,
) -> Result<bool, String> {
let options = ExportOptions::from(options);
let files: Vec<(String, Vec<u8>)> = state.core(move |core| {
file_ids
.iter()
.filter_map(|id| {
let name = core.engine.file_structure(&FileId(*id))?.name.clone();
Some((name, api::export_file(&core.engine, *id, options)?))
})
.collect()
});
let write = |path: PathBuf, bytes: &[u8]| {
crate::store::write_atomically(&path, bytes)
.map_err(|error| format!("{}: {error}", path.display()))
};
match files.as_slice() {
[] => Ok(false),
[(name, bytes)] => {
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), None) else {
return Ok(false);
};
write(path, bytes).map(|()| true)
}
_ => {
let Some(folder) = ask_folder(&app, None) else {
return Ok(false);
};
let mut written: Vec<PathBuf> = vec![];
for (name, bytes) in &files {
let path = free_path(&folder, name, |path| written.iter().any(|w| w == path));
write(path.clone(), bytes)?;
written.push(path);
}
Ok(true)
}
}
}
/// Makes a folder in a folder of the open folder, called `name` (numbered if that is taken). Gives
/// its path.
#[tauri::command(async)]
pub fn create_folder(
state: State<AppState>,
parent: PathBuf,
name: String,
) -> Result<PathBuf, String> {
state.core(move |core| core.workspace.create_folder(&parent, &name))
}
/// Renames a file or a folder of the open folder, on the disk only: an open file keeps its name,
/// and follows. Not undoable.
#[tauri::command(async)]
pub fn rename_path(state: State<AppState>, path: PathBuf, name: String) -> Result<(), String> {
state.core(move |core| {
core.workspace.rename_path(&path, &name)?;
core.commit(None);
Ok(())
})
}
/// Renames the file on the disk of an open file, next to where it is (see `Workspace::rename_file`).
/// Not undoable, the name of the file in the engine is not changed. Says whether it was renamed:
/// not for a file that is not on the disk.
#[tauri::command(async)]
pub fn rename_file(state: State<AppState>, file_id: Uuid, name: String) -> Result<bool, String> {
state.core(move |core| {
let renamed = core.workspace.rename_file(file_id, &name)?;
if renamed {
core.commit(None);
}
Ok(renamed)
})
}
/// Moves files and folders of the open folder into a folder of it. The open files follow.
#[tauri::command(async)]
pub fn move_paths(
state: State<AppState>,
paths: Vec<PathBuf>,
folder: PathBuf,
) -> Result<(), String> {
state.core(move |core| {
let result = core.workspace.move_paths(&paths, &folder);
core.commit(None);
result
})
}
/// Moves files and folders of the open folder to the trash. The open files that were among them
/// are closed, unless they have edits: they stay open, as gone from the disk.
#[tauri::command(async)]
pub fn delete_paths(state: State<AppState>, paths: Vec<PathBuf>) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core
.workspace
.delete_paths(&mut core.engine, &paths, to_trash)?;
core.commit(None);
Ok(outcome)
})
}
/// Moves files and folders to the trash of the system.
fn to_trash(paths: &[PathBuf]) -> Result<(), String> {
#[allow(unused_mut)]
let mut trash = trash::TrashContext::default();
// without asking the Finder, which needs a permission
#[cfg(target_os = "macos")]
{
use trash::macos::{DeleteMethod, TrashContextExtMacos};
trash.set_delete_method(DeleteMethod::NsFileManager);
}
trash.delete_all(paths).map_err(|error| {
eprintln!("the files cannot be deleted: {error}");
crate::workspace::DELETE_FAILED.to_owned()
})
}
+230
View File
@@ -0,0 +1,230 @@
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
//! reversing, cropping, reducing, timestamps, merging, cleaning...
use gpx_engine::{CleanType, MergeType, SplitType, api};
use tauri::State;
use uuid::Uuid;
use crate::state::AppState;
/// Names and describes the selected files or tracks. The files on the disk keep their name: see
/// `disk::rename_file`.
#[tauri::command(async)]
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome {
state.run(move |e| api::metadata(e, &name, &desc))
}
#[tauri::command(async)]
pub fn style(
state: State<AppState>,
color: Option<String>,
opacity: Option<f64>,
width: Option<f64>,
) -> api::Outcome {
state.run(move |e| api::style(e, color.as_deref(), opacity, width))
}
#[tauri::command(async)]
pub fn convert(state: State<AppState>) -> api::Outcome {
state.run(api::convert)
}
#[tauri::command(async)]
pub fn new_route(state: State<AppState>) -> api::Outcome {
state.run(api::new_route)
}
#[tauri::command(async)]
pub fn new_track(state: State<AppState>) -> api::Outcome {
state.run(api::new_track)
}
#[tauri::command(async)]
pub fn new_track_segment(state: State<AppState>) -> api::Outcome {
state.run(api::new_track_segment)
}
// Waypoints
#[tauri::command(async)]
#[allow(clippy::too_many_arguments)]
pub fn new_waypoint(
state: State<AppState>,
lng: f64,
lat: f64,
ele: f64,
name: String,
desc: String,
icon: String,
link: String,
) -> api::Outcome {
state.run(move |e| api::new_waypoint(e, lng, lat, ele, &name, &desc, &icon, &link))
}
#[tauri::command(async)]
pub fn move_waypoint(
state: State<AppState>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
) -> api::Outcome {
state.run(move |e| api::move_waypoint(e, file_id, waypoint_id, lng, lat, ele))
}
#[tauri::command(async)]
pub fn delete_waypoint(state: State<AppState>, file_id: Uuid, waypoint_id: Uuid) -> api::Outcome {
state.run(move |e| api::delete_waypoint(e, file_id, waypoint_id))
}
#[tauri::command(async)]
#[allow(clippy::too_many_arguments)]
pub fn update_waypoint(
state: State<AppState>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
name: String,
desc: String,
icon: String,
link: String,
) -> api::Outcome {
state.run(move |e| {
api::update_waypoint(
e,
file_id,
waypoint_id,
lng,
lat,
ele,
&name,
&desc,
&icon,
&link,
)
})
}
// Tools
#[tauri::command(async)]
pub fn reverse(state: State<AppState>) -> api::Outcome {
state.run(api::reverse)
}
#[tauri::command(async)]
pub fn round_trip(state: State<AppState>) -> api::Outcome {
state.run(api::round_trip)
}
#[tauri::command(async)]
pub fn crop(state: State<AppState>, start: u32, end: u32) -> api::Outcome {
state.run(move |e| api::crop(e, start, end))
}
#[tauri::command(async)]
pub fn reduce(state: State<AppState>, tolerance: f64) -> api::Outcome {
state.run(move |e| api::reduce(e, tolerance))
}
#[tauri::command(async)]
pub fn change_timestamps(
state: State<AppState>,
start_time: f64,
speed: f64,
ratio: f64,
) -> api::Outcome {
state.run(move |e| api::change_timestamps(e, start_time, speed, ratio))
}
#[tauri::command(async)]
pub fn create_artificial_timestamps(
state: State<AppState>,
start_time: f64,
total_time: f64,
) -> api::Outcome {
state.run(move |e| api::create_artificial_timestamps(e, start_time, total_time))
}
#[tauri::command(async)]
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome {
state.core(move |core| core.creating(None, false, |e| api::merge(e, merge_type, remove_gaps)))
}
#[tauri::command(async)]
pub fn extract(state: State<AppState>) -> api::Outcome {
state.core(|core| core.creating(None, false, api::extract))
}
#[tauri::command(async)]
pub fn elevation(state: State<AppState>, ele: Vec<f64>) -> api::Outcome {
state.run(move |e| api::elevation(e, &ele))
}
#[tauri::command(async)]
#[allow(clippy::too_many_arguments)]
pub fn clean(
state: State<AppState>,
west: f64,
south: f64,
east: f64,
north: f64,
clean_type: CleanType,
trkpt: bool,
wpt: bool,
) -> api::Outcome {
state.run(move |e| api::clean(e, west, south, east, north, clean_type, trkpt, wpt))
}
// Routing
#[tauri::command(async)]
#[allow(clippy::too_many_arguments)]
pub fn route(
state: State<AppState>,
revision: u32,
start: u32,
end: u32,
lng: Vec<f64>,
lat: Vec<f64>,
ele: Vec<f64>,
attributes: api::RouteAttributes,
anchors: Vec<u32>,
) -> api::Outcome {
state.run(move |e| {
api::route(
e,
revision,
start,
end,
&lng,
&lat,
&ele,
&attributes,
&anchors,
)
})
}
#[tauri::command(async)]
pub fn insert_anchor(state: State<AppState>, revision: u32, lng: f64, lat: f64) -> api::Outcome {
state.run(move |e| api::insert_anchor(e, revision, lng, lat))
}
#[tauri::command(async)]
pub fn change_loop_start(state: State<AppState>, revision: u32, index: u32) -> api::Outcome {
state.run(move |e| api::change_loop_start(e, revision, index))
}
#[tauri::command(async)]
pub fn split(
state: State<AppState>,
revision: u32,
at: u32,
split_type: SplitType,
) -> api::Outcome {
state.core(move |core| core.creating(None, false, |e| api::split(e, revision, at, split_type)))
}
+124
View File
@@ -0,0 +1,124 @@
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use std::path::PathBuf;
use gpx_engine::api;
use tauri::State;
use tauri::ipc::{InvokeBody, Request};
use uuid::Uuid;
use crate::state::AppState;
#[tauri::command(async)]
pub fn new_file(
state: State<AppState>,
name: String,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: Option<bool>,
folder: Option<PathBuf>,
) -> api::Outcome {
let route = route.unwrap_or_default();
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
state.core(move |core| {
core.creating(folder, true, |e| {
api::new_file(e, &name, lng, lat, ele, route)
})
})
}
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
/// percent-encoded) telling the number of bytes and the name of each one.
#[tauri::command(async)]
pub fn load_files(state: State<AppState>, request: Request<'_>) -> api::Outcome {
let InvokeBody::Raw(data) = request.body() else {
return api::Outcome::failed("the files are missing");
};
let header = |key: &str| {
request
.headers()
.get(key)
.and_then(|value| value.to_str().ok())
.and_then(percent_decode)
};
let (Some(lengths), Some(names)) = (
header("lengths").and_then(|json| serde_json::from_str::<Vec<usize>>(&json).ok()),
header("names").and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok()),
) else {
return api::Outcome::failed("the lengths or the names of the files are not valid");
};
let data = data.clone();
state.run(move |e| api::load_files(e, &data, &lengths, &names))
}
fn percent_decode(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = value.get(i + 1..i + 3)?;
decoded.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
decoded.push(bytes[i]);
i += 1;
}
}
String::from_utf8(decoded).ok()
}
#[tauri::command(async)]
pub fn duplicate(state: State<AppState>) -> api::Outcome {
state.core(|core| core.creating(None, false, api::duplicate))
}
#[tauri::command(async)]
pub fn delete(state: State<AppState>, whole_files: bool) -> api::Outcome {
state.run(move |e| api::delete(e, whole_files))
}
#[tauri::command(async)]
pub fn delete_all(state: State<AppState>) -> api::Outcome {
state.run(api::delete_all)
}
#[tauri::command(async)]
pub fn reorder(state: State<AppState>, file_ids: Vec<Uuid>, index: usize) -> api::Outcome {
state.run(move |e| api::reorder(e, &file_ids, index))
}
#[tauri::command(async)]
pub fn copy(state: State<AppState>) -> api::Outcome {
state.run(api::copy)
}
#[tauri::command(async)]
pub fn cut(state: State<AppState>) -> api::Outcome {
state.run(api::cut)
}
#[tauri::command(async)]
pub fn paste(state: State<AppState>) -> api::Outcome {
state.run(api::paste)
}
#[tauri::command(async)]
pub fn move_elements(
state: State<AppState>,
what: api::Selection,
to: api::MoveTarget,
) -> api::Outcome {
state.run(move |e| api::move_elements(e, what, to))
}
#[tauri::command(async)]
pub fn undo(state: State<AppState>) -> api::Outcome {
state.run(api::undo)
}
#[tauri::command(async)]
pub fn redo(state: State<AppState>) -> api::Outcome {
state.run(api::redo)
}
+15
View File
@@ -0,0 +1,15 @@
//! The functions of the frontend's interface to the engine, as Tauri commands. They are the
//! counterparts of the functions that `gpx-rs/wasm` exports, and cross the same data: see
//! `website/src/lib/engine/backend`.
//!
//! The commands that edit the files or change the selection return an `Outcome`.
//! They are `async` (so they run off the main thread), the frontend sends them one after the other.
pub mod disk;
pub mod edit;
pub mod files;
pub mod queries;
pub mod selection;
pub mod statistics;
pub mod storage;
pub mod system;
+112
View File
@@ -0,0 +1,112 @@
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of
//! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays).
use gpx_engine::{ExportOptions, api};
use tauri::State;
use uuid::Uuid;
use crate::bytes::*;
use crate::state::AppState;
#[tauri::command(async)]
pub fn file_order(state: State<AppState>) -> Vec<String> {
state.read(api::file_order)
}
#[tauri::command(async)]
pub fn file_structure(state: State<AppState>, file_id: Uuid) -> Option<api::FileStructure> {
state.read(move |e| api::file_structure(e, file_id))
}
#[tauri::command(async)]
pub fn file_statistics(state: State<AppState>, file_id: Uuid) -> Option<api::GlobalStatistics> {
state.read(move |e| api::file_statistics(e, file_id))
}
#[tauri::command(async)]
pub fn selection(state: State<AppState>) -> api::Selection {
state.read(api::selection)
}
#[tauri::command(async)]
pub fn clipboard(state: State<AppState>) -> Option<api::Clipboard> {
state.read(api::clipboard)
}
#[tauri::command(async)]
pub fn can_undo(state: State<AppState>) -> bool {
state.read(api::can_undo)
}
#[tauri::command(async)]
pub fn can_redo(state: State<AppState>) -> bool {
state.read(api::can_redo)
}
#[tauri::command(async)]
pub fn can_paste(state: State<AppState>) -> bool {
state.read(api::can_paste)
}
#[tauri::command(async)]
pub fn waypoint(
state: State<AppState>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Option<api::WaypointDetails> {
state.read(move |e| api::waypoint(e, file_id, waypoint_id))
}
#[tauri::command(async)]
pub fn trackpoint(
state: State<AppState>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<api::TrackpointDetails> {
state.read(move |e| api::trackpoint(e, file_id, segment_id, index))
}
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
#[tauri::command(async)]
#[allow(clippy::too_many_arguments)]
pub fn export_file(
state: State<AppState>,
file_id: Uuid,
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
) -> Bytes {
let options = ExportOptions {
time,
hr,
cad,
atemp,
power,
osm,
};
optional(state.read(move |e| api::export_file(e, file_id, options)))
}
#[tauri::command(async)]
pub fn exportable_data(state: State<AppState>, file_ids: Vec<Uuid>) -> u8 {
state.read(move |e| api::exportable_data(e, &file_ids))
}
#[tauri::command(async)]
pub fn reduction_distances(state: State<AppState>) -> Bytes {
f64s(&state.read(api::reduction_distances))
}
#[tauri::command(async)]
pub fn segment_coordinates(state: State<AppState>, segment_id: Uuid) -> Bytes {
f64s(&state.read(move |e| api::segment_coordinates(e, segment_id).to_vec()))
}
#[tauri::command(async)]
pub fn waypoint_coordinates(state: State<AppState>, file_id: Uuid) -> Bytes {
f64s(&state.read(move |e| api::waypoint_coordinates(e, file_id).to_vec()))
}
+72
View File
@@ -0,0 +1,72 @@
//! Changing what is selected.
use gpx_engine::{SelectMode, api};
use tauri::State;
use uuid::Uuid;
use crate::state::AppState;
#[tauri::command(async)]
pub fn select(state: State<AppState>, file_ids: Vec<Uuid>, mode: SelectMode) -> api::Outcome {
state.run(move |e| api::select(e, &file_ids, mode))
}
#[tauri::command(async)]
pub fn select_all(state: State<AppState>) -> api::Outcome {
state.run(api::select_all)
}
#[tauri::command(async)]
pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> api::Outcome {
state.run(move |e| api::arrow_select(e, down, add))
}
#[tauri::command(async)]
pub fn select_routes(
state: State<AppState>,
file_id: Uuid,
route_ids: Vec<Uuid>,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_routes(e, file_id, &route_ids, mode))
}
#[tauri::command(async)]
pub fn select_tracks(
state: State<AppState>,
file_id: Uuid,
track_ids: Vec<Uuid>,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_tracks(e, file_id, &track_ids, mode))
}
#[tauri::command(async)]
pub fn select_segments(
state: State<AppState>,
file_id: Uuid,
track_id: Uuid,
segment_ids: Vec<Uuid>,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_segments(e, file_id, track_id, &segment_ids, mode))
}
#[tauri::command(async)]
pub fn select_waypoint_group(
state: State<AppState>,
file_id: Uuid,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_waypoint_group(e, file_id, mode))
}
#[tauri::command(async)]
pub fn select_waypoints(
state: State<AppState>,
file_id: Uuid,
waypoint_ids: Vec<Uuid>,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_waypoints(e, file_id, &waypoint_ids, mode))
}
+106
View File
@@ -0,0 +1,106 @@
//! The statistics of the selection and the anchors of the routing tool, as little-endian arrays
//! (one per call, copies of the buffers of the engine). Optional ones start with a byte telling
//! whether they are there.
use gpx_engine::api;
use tauri::State;
use crate::bytes::*;
use crate::state::AppState;
macro_rules! f64_getters {
($($name:ident),*) => {$(
#[tauri::command(async)]
pub fn $name(state: State<AppState>) -> Bytes {
f64s(&state.read(|e| api::$name(e).to_vec()))
}
)*};
}
f64_getters!(total_distance, slope_segment_slopes, lng, lat, ele);
macro_rules! optional_f64_getters {
($($name:ident),*) => {$(
#[tauri::command(async)]
pub fn $name(state: State<AppState>) -> Bytes {
optional(state.read(|e| api::$name(e).map(f64_bytes)))
}
)*};
}
optional_f64_getters!(speed, hr, cad, atemp, power);
/// The index of the first trackpoint of each smoothed elevation segment of the selection.
#[tauri::command(async)]
pub fn slope_segment_starts(state: State<AppState>) -> Bytes {
u32s(&state.read(|e| api::slope_segment_starts(e).to_vec()))
}
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`.
#[tauri::command(async)]
pub fn selection_point(state: State<AppState>, index: usize) -> Option<api::SelectionPoint> {
state.read(move |e| api::selection_point(e, index))
}
#[tauri::command(async)]
pub fn selection_statistics(state: State<AppState>) -> api::GlobalStatistics {
state.read(api::selection_statistics)
}
#[tauri::command(async)]
pub fn slice_statistics(
state: State<AppState>,
start: usize,
end: usize,
) -> Option<api::GlobalStatistics> {
state.read(move |e| api::slice_statistics(e, start, end))
}
macro_rules! category_getters {
($starts:ident, $values:ident, $names:ident) => {
#[tauri::command(async)]
pub fn $starts(state: State<AppState>) -> Bytes {
u32s(&state.read(|e| api::$starts(e).to_vec()))
}
#[tauri::command(async)]
pub fn $values(state: State<AppState>) -> Bytes {
u8s(&state.read(|e| api::$values(e).to_vec()))
}
#[tauri::command(async)]
pub fn $names(state: State<AppState>) -> Vec<String> {
state.read(|e| api::$names(e).to_vec())
}
};
}
category_getters!(surface_starts, surface_values, surfaces);
category_getters!(highway_starts, highway_values, highways);
category_getters!(sac_scale_starts, sac_scale_values, sac_scales);
category_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales);
#[tauri::command(async)]
pub fn anchor_indices(state: State<AppState>) -> Bytes {
u32s(&state.read(|e| api::anchor_indices(e).to_vec()))
}
#[tauri::command(async)]
pub fn anchor_zooms(state: State<AppState>) -> Bytes {
u8s(&state.read(|e| api::anchor_zooms(e).to_vec()))
}
#[tauri::command(async)]
pub fn segment_starts(state: State<AppState>) -> Bytes {
u32s(&state.read(|e| api::segment_starts(e).to_vec()))
}
#[tauri::command(async)]
pub fn segment_ids(state: State<AppState>) -> Vec<String> {
state.read(api::segment_ids)
}
#[tauri::command(async)]
pub fn routing_revision(state: State<AppState>) -> u32 {
state.read(api::routing_revision)
}
+48
View File
@@ -0,0 +1,48 @@
//! What the frontend knows of the storage, which is kept by the engine thread (see `state`).
use std::collections::BTreeMap;
use gpx_engine::api;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
/// What opening the storage found.
#[derive(Serialize)]
pub struct StorageOpened {
settings: BTreeMap<String, String>,
/// All the files of the engine as added: the frontend starts with none.
outcome: api::Outcome,
}
/// The files were restored when the app started: the frontend is told about all of them.
#[tauri::command(async)]
pub fn open_storage(state: State<AppState>) -> StorageOpened {
StorageOpened {
settings: state.storage().settings(),
outcome: state.read(api::Outcome::everything),
}
}
/// Resolves when what was changed has been written to the storage, which is the case when a command
/// returns.
#[tauri::command(async)]
pub fn flush_storage() {}
/// There is no other writer.
#[tauri::command(async)]
pub fn sync_storage() -> api::Outcome {
api::Outcome::failed_none()
}
/// Saves a setting, `value` being its JSON.
#[tauri::command(async)]
pub fn set_setting(state: State<AppState>, key: String, value: String) {
state.put_setting(&key, &value);
}
#[tauri::command(async)]
pub fn delete_setting(state: State<AppState>, key: String) {
state.delete_setting(&key);
}
+28
View File
@@ -0,0 +1,28 @@
//! What the app asks of the system, outside of the files.
use std::path::PathBuf;
use tauri::{AppHandle, Runtime};
use tauri_plugin_opener::OpenerExt;
/// Opens a web page (or an email to write) with the app of the system for it: the links of the
/// page are not followed in the window of the app. Only `http`, `https` and `mailto` addresses are
/// opened.
#[tauri::command(async)]
pub fn open_url<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
let allowed = ["http://", "https://", "mailto:"];
if !allowed.iter().any(|scheme| url.starts_with(scheme)) {
return Err(format!("{url} is not a web address"));
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|error| error.to_string())
}
/// Shows a file or a folder in the file manager of the system (the Finder...).
#[tauri::command(async)]
pub fn reveal_path<R: Runtime>(app: AppHandle<R>, path: PathBuf) -> Result<(), String> {
app.opener()
.reveal_item_in_dir(path)
.map_err(|error| error.to_string())
}