This commit is contained in:
vcoppe committed 2026-10-14 08:51:38 +02:00
1 parent ce8398f749
commit 41b9116c6a
60 files changed
+7525 -340

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
//! 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::{self as engine, Command, LngLat, LngLatBounds};
use tauri::State;
use uuid::Uuid;
use crate::dto::*;
use crate::state::{act, edit, AppState};
#[tauri::command(async)]
pub fn metadata(state: State<AppState>, name: String, desc: String) -> Outcome {
edit!(
state,
Command::Metadata(engine::Metadata {
name: &name,
desc: &desc,
})
)
}
#[tauri::command(async)]
pub fn style(
state: State<AppState>,
color: Option<String>,
opacity: Option<f64>,
width: Option<f64>,
) -> Outcome {
edit!(
state,
Command::Style(engine::Style {
color: color.as_deref(),
opacity,
width,
})
)
}
#[tauri::command(async)]
pub fn new_track(state: State<AppState>) -> Outcome {
edit!(state, Command::NewTrack(engine::NewTrack))
}
#[tauri::command(async)]
pub fn new_track_segment(state: State<AppState>) -> Outcome {
edit!(state, Command::NewTrackSegment(engine::NewTrackSegment))
}
// 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,
) -> Outcome {
edit!(
state,
Command::NewWaypoint(engine::NewWaypoint {
lng,
lat,
ele,
name: &name,
desc: &desc,
icon: &icon,
link: &link,
})
)
}
#[tauri::command(async)]
pub fn move_waypoint(
state: State<AppState>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
) -> Outcome {
edit!(
state,
Command::MoveWaypoint(engine::MoveWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
})
)
}
#[tauri::command(async)]
pub fn delete_waypoint(state: State<AppState>, file_id: Uuid, waypoint_id: Uuid) -> Outcome {
edit!(
state,
Command::DeleteWaypoint(engine::DeleteWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(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,
) -> Outcome {
edit!(
state,
Command::EditWaypoint(engine::EditWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
name: &name,
desc: &desc,
icon: &icon,
link: &link,
})
)
}
// Tools
#[tauri::command(async)]
pub fn reverse(state: State<AppState>) -> Outcome {
edit!(state, Command::Reverse(engine::Reverse))
}
#[tauri::command(async)]
pub fn round_trip(state: State<AppState>) -> Outcome {
edit!(state, Command::RoundTrip(engine::RoundTrip))
}
#[tauri::command(async)]
pub fn crop(state: State<AppState>, start: u32, end: u32) -> Outcome {
if start > end {
return Outcome::failed(format!("the range {start}..{end} is reversed"));
}
edit!(state, Command::Crop(engine::Crop { start, end }))
}
#[tauri::command(async)]
pub fn reduce(state: State<AppState>, tolerance: f64) -> Outcome {
edit!(state, Command::Reduce(engine::Reduce { tolerance }))
}
#[tauri::command(async)]
pub fn change_timestamps(
state: State<AppState>,
start_time: f64,
speed: f64,
ratio: f64,
) -> Outcome {
edit!(
state,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Change { speed, ratio },
})
)
}
#[tauri::command(async)]
pub fn create_artificial_timestamps(
state: State<AppState>,
start_time: f64,
total_time: f64,
) -> Outcome {
edit!(
state,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Artificial { total_time },
})
)
}
#[tauri::command(async)]
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> Outcome {
edit!(
state,
Command::Merge(engine::Merge {
type_: merge_type.into(),
remove_gaps,
})
)
}
#[tauri::command(async)]
pub fn extract(state: State<AppState>) -> Outcome {
edit!(state, Command::Extract(engine::Extract))
}
#[tauri::command(async)]
pub fn elevation(state: State<AppState>, ele: Vec<f64>) -> Outcome {
edit!(state, Command::Elevation(engine::Elevation { ele: &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,
) -> Outcome {
edit!(
state,
Command::Clean(engine::Clean {
bounds: LngLatBounds {
sw: LngLat {
lng: west,
lat: south,
},
ne: LngLat {
lng: east,
lat: north,
},
},
type_: clean_type.into(),
trkpt,
wpt,
})
)
}
+161
View File
@@ -0,0 +1,161 @@
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use gpx_engine::{self as engine, Action, Command};
use tauri::ipc::{InvokeBody, Request};
use tauri::State;
use uuid::Uuid;
use crate::dto::*;
use crate::state::{act, edit, AppState};
#[tauri::command(async)]
pub fn new_file(
state: State<AppState>,
name: String,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
) -> Outcome {
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
lng,
lat,
ele: ele.unwrap_or_default(),
});
edit!(
state,
Command::New(engine::New {
name: &name,
trackpoint,
})
)
}
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON) telling
/// the number of bytes and the name of each one.
#[tauri::command(async)]
pub fn load_files(state: State<AppState>, request: Request<'_>) -> Outcome {
let InvokeBody::Raw(data) = request.body() else {
return Outcome::failed("the files are missing");
};
let header = |key: &str| {
request
.headers()
.get(key)
.and_then(|value| value.to_str().ok())
.and_then(urlencoding_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 Outcome::failed("the lengths or the names of the files are not valid");
};
if lengths.len() != names.len() {
return Outcome::failed(format!(
"{} lengths for {} names",
lengths.len(),
names.len()
));
}
let (data, mut offset) = (data.clone(), 0);
let mut ranges = Vec::with_capacity(names.len());
for (length, name) in lengths.iter().zip(&names) {
let end = offset + length;
if end > data.len() {
return Outcome::failed(format!("the data of {name} is shorter than its length"));
}
ranges.push(offset..end);
offset = end;
}
edit!(state, {
let files = ranges
.into_iter()
.zip(&names)
.map(|(range, name)| engine::Load {
data: &data[range],
name,
})
.collect();
Command::LoadFiles(engine::LoadFiles { files })
})
}
/// Headers are ASCII: the frontend percent-encodes the JSON.
fn urlencoding_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>) -> Outcome {
edit!(state, Command::Duplicate(engine::Duplicate))
}
#[tauri::command(async)]
pub fn delete(state: State<AppState>, whole_files: bool) -> Outcome {
edit!(state, Command::Delete(engine::Delete { whole_files }))
}
#[tauri::command(async)]
pub fn delete_all(state: State<AppState>) -> Outcome {
edit!(state, Command::DeleteAll(engine::DeleteAll))
}
#[tauri::command(async)]
pub fn reorder(state: State<AppState>, file_ids: Vec<Uuid>, index: usize) -> Outcome {
act!(
state,
Action::Reorder {
file_ids: file_ids.into_iter().map(engine::FileId).collect(),
index,
}
)
}
#[tauri::command(async)]
pub fn copy(state: State<AppState>) -> Outcome {
act!(state, Action::Copy)
}
#[tauri::command(async)]
pub fn cut(state: State<AppState>) -> Outcome {
act!(state, Action::Cut)
}
#[tauri::command(async)]
pub fn paste(state: State<AppState>) -> Outcome {
edit!(state, Command::Paste(engine::Paste))
}
#[tauri::command(async)]
pub fn move_elements(state: State<AppState>, what: Selection, to: MoveTarget) -> Outcome {
edit!(
state,
Command::Move(engine::Move {
what: what.into(),
to: to.into(),
})
)
}
#[tauri::command(async)]
pub fn undo(state: State<AppState>) -> Outcome {
act!(state, Action::Undo)
}
#[tauri::command(async)]
pub fn redo(state: State<AppState>) -> Outcome {
act!(state, Action::Redo)
}
+14
View File
@@ -0,0 +1,14 @@
//! 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 edit;
pub mod files;
pub mod queries;
pub mod routing;
pub mod selection;
pub mod statistics;
pub mod storage;
+136
View File
@@ -0,0 +1,136 @@
//! 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::{self as engine, FileId};
use tauri::State;
use uuid::Uuid;
use crate::dto::*;
use crate::state::AppState;
#[tauri::command(async)]
pub fn file_order(state: State<AppState>) -> Vec<String> {
state.read(|e| ids(e.order()))
}
#[tauri::command(async)]
pub fn file_structure(state: State<AppState>, file_id: Uuid) -> Option<FileStructure> {
state.read(move |e| e.file_structure(&FileId(file_id)).map(FileStructure::new))
}
#[tauri::command(async)]
pub fn file_statistics(state: State<AppState>, file_id: Uuid) -> Option<GlobalStatistics> {
state.read(move |e| {
e.file_statistics(&FileId(file_id))
.map(|s| GlobalStatistics::new(&s))
})
}
#[tauri::command(async)]
pub fn selection(state: State<AppState>) -> Selection {
state.read(|e| e.selection().into())
}
#[tauri::command(async)]
pub fn clipboard(state: State<AppState>) -> Option<Clipboard> {
state.read(|e| e.clipboard().map(Clipboard::new))
}
#[tauri::command(async)]
pub fn can_undo(state: State<AppState>) -> bool {
state.read(|e| e.can_undo())
}
#[tauri::command(async)]
pub fn can_redo(state: State<AppState>) -> bool {
state.read(|e| e.can_redo())
}
#[tauri::command(async)]
pub fn can_paste(state: State<AppState>) -> bool {
state.read(|e| e.can_paste())
}
#[tauri::command(async)]
pub fn waypoint(
state: State<AppState>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Option<WaypointDetails> {
state.read(move |e| {
e.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
.map(WaypointDetails::new)
})
}
#[tauri::command(async)]
pub fn trackpoint(
state: State<AppState>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<TrackpointDetails> {
state.read(move |e| {
e.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
.map(TrackpointDetails::new)
})
}
/// 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,
as_route: bool,
) -> Bytes {
let options = engine::ExportOptions {
time,
hr,
cad,
atemp,
power,
osm,
as_route,
};
optional(state.read(move |e| e.export(&FileId(file_id), options)))
}
/// The data that some files have: a bit for the time (1), heart rate (2), cadence (4), temperature
/// (8), power (16) and the OpenStreetMap data (32).
#[tauri::command(async)]
pub fn exportable_data(state: State<AppState>, file_ids: Vec<Uuid>) -> u8 {
state.read(move |e| {
let ids: Vec<FileId> = file_ids.into_iter().map(FileId).collect();
let data = e.exportable_data(&ids);
[
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
})
}
#[tauri::command(async)]
pub fn reduction_distances(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| f64s(&e.reduction_distances())))
}
#[tauri::command(async)]
pub fn segment_coordinates(state: State<AppState>, segment_id: Uuid) -> Bytes {
Bytes::new(
state.read(move |e| f64s(e.segment_coordinates(&engine::TrackSegmentId(segment_id)))),
)
}
#[tauri::command(async)]
pub fn waypoint_coordinates(state: State<AppState>, file_id: Uuid) -> Bytes {
Bytes::new(state.read(move |e| f64s(e.waypoint_coordinates(&FileId(file_id)))))
}
+73
View File
@@ -0,0 +1,73 @@
//! The commands that refer to trackpoints by their index in the routing buffers, which only mean
//! something for the `revision` they were read with: the commands do nothing if the selection is
//! not the one of that revision anymore.
use gpx_engine::{self as engine, Command};
use tauri::State;
use crate::dto::*;
use crate::state::{edit_at, AppState};
#[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: RouteAttributes,
anchors: Vec<u32>,
) -> Outcome {
if lng.len() != lat.len() || lat.len() != ele.len() {
return Outcome::failed("lng, lat and ele differ in length");
}
edit_at!(
state,
revision,
Command::Route(engine::Route {
start,
end,
lng: &lng,
lat: &lat,
ele: &ele,
surface: attributes.surface.view(),
highway: attributes.highway.view(),
sac_scale: attributes.sac_scale.view(),
mtb_scale: attributes.mtb_scale.view(),
anchors: &anchors,
})
)
}
#[tauri::command(async)]
pub fn insert_anchor(state: State<AppState>, revision: u32, lng: f64, lat: f64) -> Outcome {
edit_at!(
state,
revision,
Command::InsertAnchor(engine::InsertAnchor { lng, lat })
)
}
#[tauri::command(async)]
pub fn change_loop_start(state: State<AppState>, revision: u32, index: u32) -> Outcome {
edit_at!(
state,
revision,
Command::ChangeLoopStart(engine::ChangeLoopStart { index })
)
}
#[tauri::command(async)]
pub fn split(state: State<AppState>, revision: u32, at: u32, split_type: SplitType) -> Outcome {
edit_at!(
state,
revision,
Command::Split(engine::Split {
at,
split_type: split_type.into(),
})
)
}
+102
View File
@@ -0,0 +1,102 @@
//! Changing what is selected.
use gpx_engine::{self as engine, Action};
use tauri::State;
use uuid::Uuid;
use crate::dto::*;
use crate::state::{act, AppState};
fn select_elements(
state: &AppState,
selection: Option<engine::Selection>,
mode: SelectMode,
) -> Outcome {
match selection {
Some(selection) => act!(
state,
Action::Select {
selection,
mode: mode.into(),
}
),
None => Outcome::failed("the ids are not UUIDs"),
}
}
fn to_file_ids(ids: Vec<Uuid>) -> Vec<engine::FileId> {
ids.into_iter().map(engine::FileId).collect()
}
#[tauri::command(async)]
pub fn select(state: State<AppState>, file_ids: Vec<Uuid>, mode: SelectMode) -> Outcome {
let selection = engine::Selection::File {
file_ids: to_file_ids(file_ids).into_iter().collect(),
};
select_elements(&state, Some(selection), mode)
}
#[tauri::command(async)]
pub fn select_all(state: State<AppState>) -> Outcome {
act!(state, Action::SelectAll)
}
#[tauri::command(async)]
pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> Outcome {
act!(state, Action::ArrowSelect { down, add })
}
#[tauri::command(async)]
pub fn select_tracks(
state: State<AppState>,
file_id: Uuid,
track_ids: Vec<Uuid>,
mode: SelectMode,
) -> Outcome {
let selection = engine::Selection::Track {
file_id: engine::FileId(file_id),
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
};
select_elements(&state, Some(selection), mode)
}
#[tauri::command(async)]
pub fn select_segments(
state: State<AppState>,
file_id: Uuid,
track_id: Uuid,
segment_ids: Vec<Uuid>,
mode: SelectMode,
) -> Outcome {
let selection = engine::Selection::TrackSegment {
file_id: engine::FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.into_iter()
.map(engine::TrackSegmentId)
.collect(),
};
select_elements(&state, Some(selection), mode)
}
#[tauri::command(async)]
pub fn select_waypoint_group(state: State<AppState>, file_id: Uuid, mode: SelectMode) -> Outcome {
let selection = engine::Selection::Waypoints {
file_id: engine::FileId(file_id),
};
select_elements(&state, Some(selection), mode)
}
#[tauri::command(async)]
pub fn select_waypoints(
state: State<AppState>,
file_id: Uuid,
waypoint_ids: Vec<Uuid>,
mode: SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoint {
file_id: engine::FileId(file_id),
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
};
select_elements(&state, Some(selection), mode)
}
+135
View File
@@ -0,0 +1,135 @@
//! 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 tauri::State;
use crate::dto::*;
use crate::state::AppState;
macro_rules! f64_getters {
($($name:ident),*) => {$(
#[tauri::command(async)]
pub fn $name(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| f64s(&e.statistics().$name)))
}
)*};
}
f64_getters!(
total_distance,
slope,
slope_segment_slope,
slope_segment_distance,
lng,
lat,
ele
);
macro_rules! optional_f64_getters {
($($name:ident),*) => {$(
#[tauri::command(async)]
pub fn $name(state: State<AppState>) -> Bytes {
optional(state.read(|e| e.statistics().$name.as_deref().map(f64s)))
}
)*};
}
optional_f64_getters!(speed, hr, cad, atemp, power);
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), as `i64`.
#[tauri::command(async)]
pub fn timestamps(state: State<AppState>) -> Bytes {
optional(state.read(|e| e.statistics().time.as_deref().map(i64s)))
}
#[tauri::command(async)]
pub fn selection_statistics(state: State<AppState>) -> GlobalStatistics {
state.read(|e| GlobalStatistics::new(&e.statistics().global))
}
#[tauri::command(async)]
pub fn slice_statistics(
state: State<AppState>,
start: usize,
end: usize,
) -> Option<GlobalStatistics> {
state.read(move |e| {
e.statistics()
.slice(start, end)
.map(|s| GlobalStatistics::new(&s))
})
}
macro_rules! intervals_getters {
($starts:ident, $values:ident, $field:ident) => {
#[tauri::command(async)]
pub fn $starts(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| u32s(&e.statistics().$field.starts)))
}
#[tauri::command(async)]
pub fn $values(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| e.statistics().$field.values.clone()))
}
};
}
intervals_getters!(surface_starts, surface_values, surface);
intervals_getters!(highway_starts, highway_values, highway);
intervals_getters!(sac_scale_starts, sac_scale_values, sac_scale);
intervals_getters!(mtb_scale_starts, mtb_scale_values, mtb_scale);
/// The names of the categories, in the order of their codes. The lists only grow.
#[tauri::command(async)]
pub fn surfaces(state: State<AppState>) -> Vec<String> {
state.read(|e| e.categories().surface.names().to_vec())
}
#[tauri::command(async)]
pub fn highways(state: State<AppState>) -> Vec<String> {
state.read(|e| e.categories().highway.names().to_vec())
}
#[tauri::command(async)]
pub fn sac_scales(state: State<AppState>) -> Vec<String> {
state.read(|e| e.categories().sac_scale.names().to_vec())
}
#[tauri::command(async)]
pub fn mtb_scales(state: State<AppState>) -> Vec<String> {
state.read(|e| e.categories().mtb_scale.names().to_vec())
}
// Routing buffers
#[tauri::command(async)]
pub fn anchor_indices(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| u32s(&e.routing().anchor_indices)))
}
#[tauri::command(async)]
pub fn anchor_zooms(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| e.routing().anchor_zooms.clone()))
}
#[tauri::command(async)]
pub fn segment_starts(state: State<AppState>) -> Bytes {
Bytes::new(state.read(|e| u32s(&e.routing().segment_starts)))
}
#[tauri::command(async)]
pub fn segment_ids(state: State<AppState>) -> Vec<String> {
state.read(|e| {
e.routing()
.segment_ids
.iter()
.map(|id| id.0.to_string())
.collect()
})
}
#[tauri::command(async)]
pub fn routing_revision(state: State<AppState>) -> u32 {
state.read(|e| e.routing().revision)
}
+46
View File
@@ -0,0 +1,46 @@
//! Keeping the files and the settings.
//!
//! For now nothing is written: the files only live as long as the app, and so do the settings.
//! The file system storage will go here.
use serde::Serialize;
use tauri::State;
use crate::dto::Outcome;
use crate::state::AppState;
/// What opening the storage found.
#[derive(Serialize)]
pub struct StorageOpened {
settings: std::collections::BTreeMap<String, String>,
outcome: Outcome,
}
#[tauri::command(async)]
pub fn open_storage(state: State<AppState>) -> StorageOpened {
StorageOpened {
settings: state.settings().clone(),
outcome: Outcome::failed_none(),
}
}
/// Resolves when what was changed has been written to the storage.
#[tauri::command(async)]
pub fn flush_storage() {}
/// Reads the files again if they were written by someone else.
#[tauri::command(async)]
pub fn sync_storage() -> Outcome {
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.settings().insert(key, value);
}
#[tauri::command(async)]
pub fn delete_setting(state: State<AppState>, key: String) {
state.settings().remove(&key);
}