mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
Compare commits
11
Commits
3bb553d6b5
..
wasm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8398f749 | ||
|
|
3c2055b50e | ||
|
|
1d13972014 | ||
|
|
890bcd765a | ||
|
|
4795513344 | ||
|
|
d84ca47745 | ||
|
|
9adb8e7221 | ||
|
|
a2b4d89150 | ||
|
|
77929cfa04 | ||
|
|
dc36de2a48 | ||
|
|
da9abbae5e |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||
<rte>
|
||||
<name>Lausanne</name>
|
||||
<cmt>comment</cmt>
|
||||
<desc>description</desc>
|
||||
<type>Cycling</type>
|
||||
<rtept lat="46.544628096744418" lon="6.658079791814089">
|
||||
<ele>500</ele>
|
||||
<extensions>
|
||||
<gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
|
||||
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||
</gpxx:RoutePointExtension>
|
||||
</extensions>
|
||||
</rtept>
|
||||
<rtept lat="46.544100000000000" lon="6.659000000000000">
|
||||
<ele>510</ele>
|
||||
<extensions>
|
||||
<gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
|
||||
</gpxx:RoutePointExtension>
|
||||
</extensions>
|
||||
</rtept>
|
||||
<rtept lat="46.543000000000000" lon="6.660000000000000">
|
||||
<ele>505</ele>
|
||||
</rtept>
|
||||
</rte>
|
||||
</gpx>
|
||||
@@ -7,13 +7,6 @@
|
||||
pub struct Categories(Vec<String>);
|
||||
|
||||
impl Categories {
|
||||
/// A table with these names, in the order of their codes (at most [`Self::CAPACITY`] of them
|
||||
/// are kept).
|
||||
pub fn from_names(mut names: Vec<String>) -> Self {
|
||||
names.truncate(Self::CAPACITY);
|
||||
Self(names)
|
||||
}
|
||||
|
||||
/// Maximum number of distinct values.
|
||||
pub const CAPACITY: usize = u8::MAX as usize;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
||||
TrackpointChunk, Waypoint, WaypointChunk,
|
||||
TrackpointChunk, Waypoint, WaypointChunk, distance,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Error;
|
||||
@@ -300,6 +300,93 @@ fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the segment of a route.
|
||||
///
|
||||
/// The route points are the anchors of the segment, and are the only ones that the file gives
|
||||
/// (no anchor is computed). The points of the detailed path between a route point and the next one
|
||||
/// (`gpxx:rpt`, which only have a position) are regular trackpoints, with the elevation worked out
|
||||
/// from the one of the route points around them.
|
||||
#[derive(Default)]
|
||||
struct RouteReader {
|
||||
segment: TrackSegment,
|
||||
chunk: TrackpointChunk,
|
||||
/// The intermediate points that follow the last route point, up to the next one.
|
||||
pending: Vec<Trackpoint>,
|
||||
/// Position and elevation of the last route point.
|
||||
previous: Option<(LngLat, f64)>,
|
||||
}
|
||||
|
||||
impl RouteReader {
|
||||
fn push(&mut self, point: Trackpoint) {
|
||||
self.chunk.trkpt.push(point);
|
||||
if self.chunk.is_full() {
|
||||
self.segment.push(std::mem::take(&mut self.chunk));
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a route point, after the intermediate points of the previous one. `following` are the
|
||||
/// intermediate points that lead from it to the next one.
|
||||
fn push_route_point(&mut self, mut rtept: Trackpoint, following: Vec<Trackpoint>) {
|
||||
rtept.anchor = Some(0);
|
||||
let here = (rtept.coordinates, rtept.ele);
|
||||
self.flush(Some(here));
|
||||
self.previous = Some(here);
|
||||
self.push(rtept);
|
||||
self.pending = following;
|
||||
}
|
||||
|
||||
/// Adds the pending intermediate points, which lead to the route point `next` if there is one.
|
||||
fn flush(&mut self, next: Option<(LngLat, f64)>) {
|
||||
let mut pending = std::mem::take(&mut self.pending);
|
||||
if let Some(previous) = self.previous {
|
||||
interpolate_elevations(&mut pending, previous, next);
|
||||
}
|
||||
for point in pending {
|
||||
self.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> TrackSegment {
|
||||
self.flush(None);
|
||||
self.segment.push(std::mem::take(&mut self.chunk));
|
||||
self.segment.ensure_end_anchors();
|
||||
self.segment
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the points of a path from `from` to `to` an elevation that goes from the one of the first
|
||||
/// to the one of the second, in proportion to the distance along the path. Without `to`, they have
|
||||
/// the elevation of `from`.
|
||||
fn interpolate_elevations(
|
||||
points: &mut [Trackpoint],
|
||||
from: (LngLat, f64),
|
||||
to: Option<(LngLat, f64)>,
|
||||
) {
|
||||
let Some((to_position, to_ele)) = to else {
|
||||
points.iter_mut().for_each(|point| point.ele = from.1);
|
||||
return;
|
||||
};
|
||||
let mut distances = Vec::with_capacity(points.len() + 1);
|
||||
let mut total = 0.0;
|
||||
let mut last = from.0;
|
||||
for position in points
|
||||
.iter()
|
||||
.map(|point| point.coordinates)
|
||||
.chain([to_position])
|
||||
{
|
||||
total += distance(last, position);
|
||||
distances.push(total);
|
||||
last = position;
|
||||
}
|
||||
for (point, along) in points.iter_mut().zip(distances) {
|
||||
point.ele = if total > 0.0 {
|
||||
from.1 + (to_ele - from.1) * along / total
|
||||
} else {
|
||||
from.1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
|
||||
/// `categories`, which learns the values it does not know yet.
|
||||
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
|
||||
@@ -309,9 +396,9 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
let mut stack: Vec<GPXElement> = vec![];
|
||||
let mut trkpt_chunk = TrackpointChunk::default();
|
||||
let mut wpt_chunk = WaypointChunk::default();
|
||||
// Routes are read as tracks of a single segment, which is what `rte_segment` collects the points of. A route
|
||||
// point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it.
|
||||
let mut rte_segment = TrackSegment::default();
|
||||
// Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed
|
||||
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
|
||||
let mut route = RouteReader::default();
|
||||
let mut rpt_points: Vec<Trackpoint> = vec![];
|
||||
// the text of the element at the top of the stack, up to now
|
||||
let mut text = String::new();
|
||||
@@ -381,13 +468,13 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
}
|
||||
"rtept" => {
|
||||
if let Some(GPXElement::Track(_)) = stack.last() {
|
||||
trkpt_chunk.trkpt.push(Trackpoint {
|
||||
route.push_route_point(
|
||||
Trackpoint {
|
||||
coordinates: parse_coordinates(e.attributes()),
|
||||
..Default::default()
|
||||
});
|
||||
if trkpt_chunk.is_full() {
|
||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
||||
}
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
}
|
||||
name if is_route_point_extension(name) => {
|
||||
@@ -444,10 +531,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
}
|
||||
"rte" => {
|
||||
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
|
||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
||||
if !rte_segment.is_empty() {
|
||||
let mut trkseg = std::mem::take(&mut rte_segment);
|
||||
trkseg.compute_anchors();
|
||||
let trkseg = std::mem::take(&mut route).finish();
|
||||
if !trkseg.is_empty() {
|
||||
trk.trkseg.push(trkseg);
|
||||
}
|
||||
gpx.trk.push(trk);
|
||||
@@ -457,17 +542,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
if let Some(GPXElement::Trackpoint(rtept)) = stack.pop()
|
||||
&& let Some(GPXElement::Track(_)) = stack.last()
|
||||
{
|
||||
let points = if rpt_points.is_empty() {
|
||||
vec![rtept]
|
||||
} else {
|
||||
std::mem::take(&mut rpt_points)
|
||||
};
|
||||
for point in points {
|
||||
trkpt_chunk.trkpt.push(point);
|
||||
if trkpt_chunk.is_full() {
|
||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
||||
}
|
||||
}
|
||||
route.push_route_point(rtept, std::mem::take(&mut rpt_points));
|
||||
}
|
||||
}
|
||||
name if is_route_point_extension(name) => {
|
||||
@@ -697,17 +772,128 @@ mod tests {
|
||||
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
|
||||
assert_eq!(trk.trkseg.len(), 1);
|
||||
let points: Vec<_> = trk.trkseg[0].iter().collect();
|
||||
// the detailed path replaces the route point it comes with
|
||||
// the route points are kept, the points of the detailed path follow the one they come with
|
||||
let coordinates: Vec<_> = points
|
||||
.iter()
|
||||
.map(|p| (p.coordinates.lat, p.coordinates.lng))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
coordinates,
|
||||
[(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
|
||||
[(1.0, 1.0), (1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
|
||||
);
|
||||
assert_eq!(points[3].ele, 20.0);
|
||||
assert!(points[3].time.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_points_are_the_anchors_and_nothing_else_is() {
|
||||
let gpx = parse_data("with_route_extensions");
|
||||
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect();
|
||||
assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]);
|
||||
|
||||
// no anchor is computed for the points of a route that has none in between
|
||||
let gpx = parse_data("with_routes");
|
||||
for trk in &gpx.trk {
|
||||
let segment = &trk.trkseg[0];
|
||||
assert!(segment.iter().all(|p| p.anchor == Some(0)));
|
||||
}
|
||||
// while a track gets the anchors that its shape needs
|
||||
let track = parse_data("with_tracks");
|
||||
let segment = &track.trk[0].trkseg[0];
|
||||
let anchors = segment.iter().filter(|p| p.anchor.is_some()).count();
|
||||
assert!(anchors >= 2 && anchors < segment.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_the_points_of_the_detailed_path_of_a_route() {
|
||||
let gpx = parse_data("with_route_points");
|
||||
assert_eq!(gpx.trk.len(), 1);
|
||||
let trk = &gpx.trk[0];
|
||||
assert_eq!(trk.info.name.as_deref(), Some("Lausanne"));
|
||||
assert_eq!(trk.info.cmt.as_deref(), Some("comment"));
|
||||
assert_eq!(trk.info.desc.as_deref(), Some("description"));
|
||||
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
|
||||
|
||||
let points: Vec<_> = trk.trkseg[0].iter().cloned().collect();
|
||||
// 3 route points and 4 points between them, the repeated one being kept
|
||||
assert_eq!(points.len(), 7);
|
||||
let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect();
|
||||
assert_eq!(kinds, [true, false, false, false, true, false, true]);
|
||||
assert_eq!(points[0].coordinates.lat, 46.54462809674442);
|
||||
assert_eq!(points[0].coordinates.lng, 6.658079791814089);
|
||||
assert_eq!(points[1].coordinates.lat, 46.54448392800987);
|
||||
assert_eq!(points[3].coordinates.lng, points[2].coordinates.lng);
|
||||
assert_eq!(points[4].coordinates.lat, 46.5441);
|
||||
assert_eq!(points[5].coordinates.lat, 46.5439);
|
||||
|
||||
// the points in between have no elevation of their own: it goes from one route point to
|
||||
// the next
|
||||
let ele: Vec<_> = points.iter().map(|p| p.ele).collect();
|
||||
assert_eq!((ele[0], ele[4], ele[6]), (500.0, 510.0, 505.0));
|
||||
assert!(ele[0] < ele[1] && ele[1] < ele[2] && ele[2] <= ele[3] && ele[3] < ele[4]);
|
||||
assert!(ele[5] < 510.0 && ele[5] > 505.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_last_point_of_a_route_is_an_anchor_even_after_a_path() {
|
||||
// the last route point comes with a path that leads nowhere
|
||||
let data = r#"<gpx xmlns:gpxx="x"><rte>
|
||||
<rtept lat="1" lon="1"><ele>10</ele></rtept>
|
||||
<rtept lat="2" lon="2"><ele>20</ele>
|
||||
<extensions><gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="2.1" lon="2.1"/><gpxx:rpt lat="2.2" lon="2.2"/>
|
||||
</gpxx:RoutePointExtension></extensions>
|
||||
</rtept>
|
||||
</rte></gpx>"#;
|
||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||
let points: Vec<_> = gpx.trk[0].trkseg[0].iter().cloned().collect();
|
||||
let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect();
|
||||
assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]);
|
||||
// the route point is an anchor, and so is the end of the path
|
||||
let anchors: Vec<_> = points.iter().map(|p| p.anchor).collect();
|
||||
assert_eq!(anchors, [Some(0), Some(0), None, Some(0)]);
|
||||
// with the elevation of the last route point, as there is no next one
|
||||
assert_eq!(points[2].ele, 20.0);
|
||||
assert!(points[2].time.is_some());
|
||||
assert_eq!(points[3].ele, 20.0);
|
||||
|
||||
// a path after a single route point
|
||||
let data = r#"<gpx xmlns:gpxx="x"><rte><rtept lat="1" lon="1"><extensions>
|
||||
<gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension>
|
||||
</extensions></rtept></rte></gpx>"#;
|
||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect();
|
||||
assert_eq!(anchors, [Some(0), Some(0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevations_between_route_points() {
|
||||
let here = |lng: f64| LngLat { lng, lat: 0.0 };
|
||||
let point = |lng: f64| Trackpoint {
|
||||
coordinates: here(lng),
|
||||
..Default::default()
|
||||
};
|
||||
// evenly spaced points go up evenly
|
||||
let mut points = vec![point(1.0), point(2.0), point(3.0)];
|
||||
interpolate_elevations(&mut points, (here(0.0), 100.0), Some((here(4.0), 200.0)));
|
||||
let ele: Vec<_> = points.iter().map(|p| p.ele.round()).collect();
|
||||
assert_eq!(ele, [125.0, 150.0, 175.0]);
|
||||
|
||||
// the distance counts, not the number of points
|
||||
let mut points = vec![point(1.0)];
|
||||
interpolate_elevations(&mut points, (here(0.0), 0.0), Some((here(4.0), 400.0)));
|
||||
assert!((points[0].ele - 100.0).abs() < 1e-6);
|
||||
|
||||
// after the last route point, the elevation does not change
|
||||
let mut points = vec![point(1.0), point(2.0)];
|
||||
interpolate_elevations(&mut points, (here(0.0), 321.0), None);
|
||||
assert!(points.iter().all(|p| p.ele == 321.0));
|
||||
|
||||
// route points at the same place
|
||||
let mut points = vec![point(0.0)];
|
||||
interpolate_elevations(&mut points, (here(0.0), 10.0), Some((here(0.0), 20.0)));
|
||||
assert_eq!(points[0].ele, 10.0);
|
||||
// nothing between them is fine
|
||||
interpolate_elevations(&mut [], (here(0.0), 1.0), Some((here(1.0), 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,9 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
||||
|
||||
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||
///
|
||||
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
|
||||
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExportOptions {
|
||||
pub time: bool,
|
||||
@@ -17,6 +20,8 @@ pub struct ExportOptions {
|
||||
pub power: bool,
|
||||
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||
pub osm: bool,
|
||||
/// Writes every segment as a route (`rte`) instead of a track.
|
||||
pub as_route: bool,
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
@@ -31,6 +36,7 @@ impl ExportOptions {
|
||||
atemp: value,
|
||||
power: value,
|
||||
osm: value,
|
||||
as_route: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +49,7 @@ impl ExportOptions {
|
||||
atemp: self.atemp || other.atemp,
|
||||
power: self.power || other.power,
|
||||
osm: self.osm || other.osm,
|
||||
as_route: self.as_route || other.as_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +88,11 @@ type XmlWriter = Writer<Vec<u8>>;
|
||||
|
||||
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
||||
/// the names of their codes in `categories`.
|
||||
///
|
||||
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
|
||||
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
|
||||
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
|
||||
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
|
||||
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||
// writing to memory does not fail
|
||||
@@ -131,8 +143,14 @@ fn write_file(
|
||||
(_, 1) => Some(file.info.name.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
if options.as_route {
|
||||
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
|
||||
write_route(w, trk, segment, name, categories, options)?;
|
||||
}
|
||||
} else {
|
||||
write_track(w, trk, name, categories, options)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
@@ -237,15 +255,13 @@ fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_track(
|
||||
/// The information of a track that its routes have too: name, comment, description, source,
|
||||
/// links, type and style.
|
||||
fn write_track_info(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
info: &crate::TrackInfo,
|
||||
) -> io::Result<()> {
|
||||
let info = &trk.info;
|
||||
w.create_element("trk").write_inner_content(|w| {
|
||||
text(w, "name", name.unwrap_or_default())?;
|
||||
optional_text(w, "cmt", &info.cmt)?;
|
||||
optional_text(w, "desc", &info.desc)?;
|
||||
@@ -268,10 +284,22 @@ fn write_track(
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_track(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.create_element("trk").write_inner_content(|w| {
|
||||
write_track_info(w, name, &trk.info)?;
|
||||
for segment in &trk.trkseg {
|
||||
w.create_element("trkseg").write_inner_content(|w| {
|
||||
for point in segment.iter() {
|
||||
write_trackpoint(w, point, categories, options)?;
|
||||
write_point(w, "trkpt", point, &[], categories, options)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -281,9 +309,48 @@ fn write_track(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_trackpoint(
|
||||
fn write_route(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
segment: &crate::TrackSegment,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.create_element("rte").write_inner_content(|w| {
|
||||
write_track_info(w, name, &trk.info)?;
|
||||
let points: Vec<&Trackpoint> = segment.iter().collect();
|
||||
let last = points.len() - 1;
|
||||
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
|
||||
let mut i = 0;
|
||||
while i < points.len() {
|
||||
// the trackpoints up to the next anchor
|
||||
let mut next = i + 1;
|
||||
while next < points.len() && !is_anchor(next) {
|
||||
next += 1;
|
||||
}
|
||||
write_point(
|
||||
w,
|
||||
"rtept",
|
||||
points[i],
|
||||
&points[i + 1..next],
|
||||
categories,
|
||||
options,
|
||||
)?;
|
||||
i = next;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
|
||||
/// of which only the positions are written.
|
||||
fn write_point(
|
||||
w: &mut XmlWriter,
|
||||
tag: &str,
|
||||
point: &Trackpoint,
|
||||
path: &[&Trackpoint],
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
@@ -306,12 +373,12 @@ fn write_trackpoint(
|
||||
let has_osm = osm.iter().any(|(_, value)| value.is_some());
|
||||
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
|
||||
|
||||
write_coordinates(w, "trkpt", point.coordinates).write_inner_content(|w| {
|
||||
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
|
||||
number(w, "ele", point.ele)?;
|
||||
if options.time {
|
||||
time(w, point.time)?;
|
||||
}
|
||||
if !has_track_point_extension && power.is_none() {
|
||||
if !has_track_point_extension && power.is_none() && path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
w.create_element("extensions").write_inner_content(|w| {
|
||||
@@ -343,6 +410,15 @@ fn write_trackpoint(
|
||||
w.create_element("gpxpx:PowerExtension")
|
||||
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
|
||||
}
|
||||
if !path.is_empty() {
|
||||
w.create_element("gpxx:RoutePointExtension")
|
||||
.write_inner_content(|w| {
|
||||
for point in path {
|
||||
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
@@ -518,4 +594,173 @@ mod tests {
|
||||
let (file, _) = read("with_power_2");
|
||||
assert!(file.exportable_data().power);
|
||||
}
|
||||
|
||||
fn as_route() -> ExportOptions {
|
||||
ExportOptions {
|
||||
as_route: true,
|
||||
..ExportOptions::ALL
|
||||
}
|
||||
}
|
||||
|
||||
fn count(text: &str, pattern: &str) -> usize {
|
||||
text.matches(pattern).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_as_route_writes_routes_instead_of_tracks() {
|
||||
let (file, categories) = read("with_route_points");
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
|
||||
assert_eq!(count(&text, "<rte>"), 1);
|
||||
assert_eq!(count(&text, "<trk>"), 0);
|
||||
assert_eq!(count(&text, "<trkseg>"), 0);
|
||||
assert_eq!(count(&text, "<trkpt"), 0);
|
||||
// the anchors are the route points, the trackpoints between them the points of their path
|
||||
assert_eq!(count(&text, "<rtept"), 3);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
||||
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
||||
// a route has the information of its track
|
||||
assert!(text.contains("<name>Lausanne</name>"));
|
||||
assert!(text.contains("<cmt>comment</cmt>"));
|
||||
assert!(text.contains("<desc>description</desc>"));
|
||||
assert!(text.contains("<type>Cycling</type>"));
|
||||
// and its points only have a position in the path, and an elevation as route points
|
||||
let first_rtept = &text[text.find("<rtept").unwrap()..];
|
||||
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
|
||||
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
|
||||
assert!(first_rtept.contains("<ele>500</ele>"));
|
||||
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
|
||||
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_route_survives_being_exported_as_a_route() {
|
||||
let (file, mut categories) = read("with_route_points");
|
||||
let written = write(&file, &categories, as_route());
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
|
||||
assert_eq!(again.trk.len(), 1);
|
||||
assert_eq!(again.trk[0].info, file.trk[0].info);
|
||||
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||
assert_eq!(a.anchor, b.anchor);
|
||||
}
|
||||
// the elevation of the route points is kept, the other ones are worked out again
|
||||
let anchors = |s: &crate::TrackSegment| {
|
||||
s.iter()
|
||||
.filter(|p| p.anchor.is_some())
|
||||
.map(|p| p.ele)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(anchors(a), anchors(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
|
||||
let (file, mut categories) = read("with_tracks_and_segments");
|
||||
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||
assert!(segments > 2);
|
||||
let written = write(&file, &categories, as_route());
|
||||
let text = String::from_utf8(written.clone()).unwrap();
|
||||
assert_eq!(count(&text, "<rte>"), segments);
|
||||
assert_eq!(count(&text, "<trk>"), 0);
|
||||
// every route starts and ends with a route point
|
||||
assert_eq!(count(&text, "</rte>"), segments);
|
||||
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
assert_eq!(again.trk.len(), segments);
|
||||
let mut routes = again.trk.iter();
|
||||
for track in &file.trk {
|
||||
for segment in &track.trkseg {
|
||||
let route = routes.next().unwrap();
|
||||
// the information of the track, repeated for each of its segments
|
||||
assert_eq!(route.info.name, track.info.name);
|
||||
assert_eq!(route.info.type_, track.info.type_);
|
||||
let (a, b) = (segment, &route.trkseg[0]);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(
|
||||
(a.coordinates.lat, a.coordinates.lng),
|
||||
(b.coordinates.lat, b.coordinates.lng)
|
||||
);
|
||||
}
|
||||
// the anchors of the segment are the ones of its route
|
||||
let anchors = |s: &crate::TrackSegment| {
|
||||
s.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.anchor.is_some())
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(anchors(a), anchors(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
||||
let (mut file, categories) = read("simple");
|
||||
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
|
||||
let len = file.trk[0].trkseg[0].len();
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert_eq!(count(&text, "<rtept"), 2);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
||||
// and a single trackpoint is a route of one point
|
||||
file.trk[0].trkseg[0].splice(1, len, vec![]);
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert_eq!(count(&text, "<rtept"), 1);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_segments_are_not_routes() {
|
||||
let (mut file, categories) = read("simple");
|
||||
file.trk[0].trkseg.push(crate::TrackSegment::default());
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert_eq!(count(&text, "<rte>"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_have_the_style_of_their_track() {
|
||||
let (file, categories) = read("with_style");
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert!(count(&text, "<gpx_style:line>") >= 1);
|
||||
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
||||
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
|
||||
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
||||
let (file, categories) = read("with_hr");
|
||||
let with_all = written_text(&file, &categories, as_route());
|
||||
assert!(with_all.contains("<gpxtpx:hr>"));
|
||||
let without = written_text(
|
||||
&file,
|
||||
&categories,
|
||||
ExportOptions {
|
||||
hr: false,
|
||||
time: false,
|
||||
as_route: true,
|
||||
..ExportOptions::ALL
|
||||
},
|
||||
);
|
||||
assert!(!without.contains("<gpxtpx:hr>"));
|
||||
assert!(!without.contains("<time>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_route_option_is_not_data() {
|
||||
let (file, _) = read("with_route_points");
|
||||
assert!(!file.exportable_data().as_route);
|
||||
// it is a way to write the data, never part of them
|
||||
let all = std::hint::black_box(ExportOptions::ALL);
|
||||
assert!(!all.as_route && !ExportOptions::NONE.as_route);
|
||||
assert!(as_route().union(ExportOptions::NONE).as_route);
|
||||
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint};
|
||||
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
|
||||
|
||||
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
|
||||
/// are deleted instead, even if only a track or a waypoint is selected.
|
||||
@@ -75,7 +75,7 @@ impl Apply for Delete {
|
||||
}
|
||||
|
||||
pub(crate) fn delete_waypoints(
|
||||
files: &mut StackEntry,
|
||||
files: &mut Files,
|
||||
file_id: FileId,
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
) -> Result<Selection, CommandError> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
|
||||
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment,
|
||||
copy_track, copy_waypoint,
|
||||
};
|
||||
|
||||
@@ -107,7 +107,7 @@ impl Apply for Duplicate {
|
||||
}
|
||||
|
||||
fn duplicate_waypoints(
|
||||
files: &mut StackEntry,
|
||||
files: &mut Files,
|
||||
file_id: FileId,
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
) -> Result<Selection, CommandError> {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
|
||||
use crate::{Clipboard, FileId, FileOrder, Files, Selection, State, TrackpointCategories};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Fixture {
|
||||
pub files: StackEntry,
|
||||
pub files: Files,
|
||||
pub selection: Selection,
|
||||
pub order: FileOrder,
|
||||
pub clipboard: Option<Clipboard>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint};
|
||||
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint};
|
||||
|
||||
/// What an [`Editor`] hook did to the element it was given.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -70,7 +70,7 @@ fn edit_where<T>(
|
||||
|
||||
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
|
||||
/// new snapshot only if something changed.
|
||||
fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
|
||||
fn update_file(files: &mut Files, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
|
||||
let Some(current) = files.get(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
|
||||
};
|
||||
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
|
||||
|
||||
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
|
||||
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
|
||||
use crate::{Diff, FileId, FileStructure, Files};
|
||||
|
||||
/// The structure of each file, only recomputed for the files that changed according to the
|
||||
/// [`Diff`] of the stack.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{FileId, Selection, StackEntry, TrackSegmentId, TrackSegmentRevisionId};
|
||||
use crate::{FileId, Files, Selection, TrackSegmentId, TrackSegmentRevisionId};
|
||||
|
||||
/// What the routing tool needs about the selected segments (see
|
||||
/// [`Selection::segment_locations`]): where their anchors are among their trackpoints.
|
||||
@@ -27,7 +27,7 @@ pub struct RoutingBuffer {
|
||||
}
|
||||
|
||||
impl RoutingBuffer {
|
||||
pub fn update(&mut self, files: Option<&StackEntry>, selection: &Selection, order: &[FileId]) {
|
||||
pub fn update(&mut self, files: Option<&Files>, selection: &Selection, order: &[FileId]) {
|
||||
self.anchor_indices.clear();
|
||||
self.anchor_zooms.clear();
|
||||
self.segment_starts.clear();
|
||||
@@ -70,11 +70,11 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded() -> (StackEntry, FileId) {
|
||||
fn loaded() -> (Files, FileId) {
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let id = file.id;
|
||||
let mut files = StackEntry::default();
|
||||
let mut files = Files::default();
|
||||
files.insert(id, Rc::new(file));
|
||||
(files, id)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet, hash_map::Entry};
|
||||
|
||||
use crate::{
|
||||
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
|
||||
File, FileId, Files, GlobalStatistics, Selection, Statistics, Track, TrackSegment,
|
||||
TrackSegmentRevisionId,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct StatisticsCache {
|
||||
}
|
||||
|
||||
impl StatisticsCache {
|
||||
pub fn update(&mut self, state: Option<&StackEntry>) {
|
||||
pub fn update(&mut self, state: Option<&Files>) {
|
||||
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
|
||||
if let Some(state) = state {
|
||||
for file in state.values() {
|
||||
@@ -40,7 +40,7 @@ impl StatisticsCache {
|
||||
/// Statistics of the segments of the selection.
|
||||
pub fn get<'a>(
|
||||
&'a self,
|
||||
state: Option<&'a StackEntry>,
|
||||
state: Option<&'a Files>,
|
||||
selection: &Selection,
|
||||
) -> Vec<&'a Statistics> {
|
||||
self.selected(state, selection, &[])
|
||||
@@ -53,7 +53,7 @@ impl StatisticsCache {
|
||||
/// files follow `order` (the files missing from it come last).
|
||||
pub fn selected<'a>(
|
||||
&'a self,
|
||||
state: Option<&'a StackEntry>,
|
||||
state: Option<&'a Files>,
|
||||
selection: &Selection,
|
||||
order: &[FileId],
|
||||
) -> Vec<SelectedSegment<'a>> {
|
||||
@@ -159,10 +159,10 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn state(path: &str) -> (StackEntry, Rc<File>) {
|
||||
fn state(path: &str) -> (Files, Rc<File>) {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
|
||||
let mut state = StackEntry::default();
|
||||
let mut state = Files::default();
|
||||
state.insert(file.id, file.clone());
|
||||
(state, file)
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mod tests {
|
||||
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
|
||||
|
||||
// a new snapshot of the file that shares its segments (same revision ids)
|
||||
let mut next = StackEntry::default();
|
||||
let mut next = Files::default();
|
||||
let renamed = {
|
||||
let mut f = (*file).clone();
|
||||
f.info.name = "other".to_string();
|
||||
@@ -258,7 +258,7 @@ mod tests {
|
||||
let mut f = (*file).clone();
|
||||
f.trk[0].trkseg[0].rev_id = Default::default();
|
||||
let new_rev = f.trk[0].trkseg[0].rev_id;
|
||||
let mut next = StackEntry::default();
|
||||
let mut next = Files::default();
|
||||
next.insert(f.id, Rc::new(f));
|
||||
cache.update(Some(&next));
|
||||
assert!(cache.map.contains_key(&new_rev));
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::{
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Engine {
|
||||
stack: Stack,
|
||||
history: History,
|
||||
selection: Selection,
|
||||
order: FileOrder,
|
||||
statistics_cache: StatisticsCache,
|
||||
@@ -27,9 +27,6 @@ pub struct Engine {
|
||||
routing_buffer: RoutingBuffer,
|
||||
categories: TrackpointCategories,
|
||||
clipboard: Option<Clipboard>,
|
||||
/// What the tests of the last action read, see `Engine::run`.
|
||||
#[cfg(test)]
|
||||
last: Outcome,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
@@ -59,7 +56,7 @@ impl Engine {
|
||||
|
||||
/// Global statistics (distance, time, elevation, bounds...) of a file.
|
||||
pub fn file_statistics(&self, id: &FileId) -> Option<GlobalStatistics> {
|
||||
let file = self.stack.current()?.get(id)?;
|
||||
let file = self.history.current()?.get(id)?;
|
||||
Some(self.statistics_cache.file_global(file))
|
||||
}
|
||||
|
||||
@@ -77,7 +74,7 @@ impl Engine {
|
||||
|
||||
/// A waypoint of a file, with all its data. `None` if it does not exist.
|
||||
pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> {
|
||||
self.stack
|
||||
self.history
|
||||
.current()?
|
||||
.get(file_id)?
|
||||
.wpt
|
||||
@@ -93,7 +90,7 @@ impl Engine {
|
||||
index: usize,
|
||||
) -> Option<&Trackpoint> {
|
||||
let segment = self
|
||||
.stack
|
||||
.history
|
||||
.current()?
|
||||
.get(file_id)?
|
||||
.trk
|
||||
@@ -106,7 +103,7 @@ impl Engine {
|
||||
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
||||
/// which `Reduce` keeps it, see [`reduction_distances`].
|
||||
pub fn reduction_distances(&self) -> Vec<f64> {
|
||||
let Some(files) = self.stack.current() else {
|
||||
let Some(files) = self.history.current() else {
|
||||
return vec![];
|
||||
};
|
||||
self.selection
|
||||
@@ -123,56 +120,58 @@ impl Engine {
|
||||
/// What there is to keep of the engine, see [`crate::Persister`].
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
files: self.stack.current().cloned().unwrap_or_default(),
|
||||
files: self.history.current().cloned().unwrap_or_default(),
|
||||
order: self.order.0.clone(),
|
||||
categories: self.categories.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts files in an engine that has none, typically the ones read from the storage. They are
|
||||
/// the state to start from: they cannot be undone. The files are reported as added, and the
|
||||
/// order, the selection and the clipboard as changed.
|
||||
/// Replaces what the engine holds by the files read from the storage: at the start, or when
|
||||
/// another writer (a tab) committed since, which makes what was held here out of date. What
|
||||
/// was here is gone, edits that were not saved included, and the files are the state to start
|
||||
/// from: they cannot be undone, and nothing from before can be.
|
||||
///
|
||||
/// The selection keeps what still exists, and so does the clipboard. The files that differ
|
||||
/// from the ones that were here are reported as added, removed or modified (all the files
|
||||
/// that were here, as they are not the same objects), and the order as changed if it is.
|
||||
pub fn restore(&mut self, restored: Restored) -> Outcome {
|
||||
let files: StackEntry = restored
|
||||
let selection_before = self.selection.clone();
|
||||
let order_before = self.order.0.clone();
|
||||
let before = self.history.current().cloned();
|
||||
let files: Files = restored
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|file| (file.id, file))
|
||||
.collect();
|
||||
let diff = History::diff_between(before.as_ref(), Some(&files));
|
||||
let mut diff =
|
||||
(!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty()))
|
||||
.then_some(diff);
|
||||
self.categories = restored.categories;
|
||||
self.order = FileOrder(restored.order);
|
||||
self.selection = Selection::Empty;
|
||||
self.clipboard = None;
|
||||
let mut diff = Some(Diff {
|
||||
added: files.keys().copied().collect(),
|
||||
..Default::default()
|
||||
});
|
||||
self.stack = Stack::restored(files);
|
||||
self.history = History::restored(files);
|
||||
self.refresh(diff.as_ref());
|
||||
sort_diff(&mut diff, &self.order.0);
|
||||
let outcome = Outcome {
|
||||
changed: true,
|
||||
Outcome {
|
||||
changed: diff.is_some()
|
||||
|| self.order.0 != order_before
|
||||
|| self.selection != selection_before,
|
||||
diff,
|
||||
order_changed: true,
|
||||
selection_changed: true,
|
||||
clipboard_changed: true,
|
||||
error: None,
|
||||
};
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.last = outcome.clone();
|
||||
order_changed: self.order.0 != order_before,
|
||||
selection_changed: self.selection != selection_before,
|
||||
clipboard_changed: false,
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8), `None` if it does not exist.
|
||||
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
|
||||
let file = self.stack.current()?.get(id)?;
|
||||
let file = self.history.current()?.get(id)?;
|
||||
Some(write(file, &self.categories, options))
|
||||
}
|
||||
|
||||
/// The data that the given files have, which the options of `export` can leave out.
|
||||
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
|
||||
let Some(files) = self.stack.current() else {
|
||||
let Some(files) = self.history.current() else {
|
||||
return ExportOptions::NONE;
|
||||
};
|
||||
ids.iter()
|
||||
@@ -184,12 +183,12 @@ impl Engine {
|
||||
|
||||
/// Whether there is something to undo.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.stack.can_undo()
|
||||
self.history.can_undo()
|
||||
}
|
||||
|
||||
/// Whether there is something to redo.
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.stack.can_redo()
|
||||
self.history.can_redo()
|
||||
}
|
||||
|
||||
/// Currently selected elements.
|
||||
@@ -207,33 +206,31 @@ impl Engine {
|
||||
self.coordinates_cache.waypoints(id)
|
||||
}
|
||||
|
||||
/// Single entry point for every action coming from the frontend. Says what it changed.
|
||||
pub fn execute(&mut self, action: Action) -> Outcome {
|
||||
/// Single entry point for every action coming from the frontend. Says what it changed, or why
|
||||
/// it did nothing: an edit that fails or has nothing to do.
|
||||
pub fn execute(&mut self, action: Action) -> Result<Outcome, CommandError> {
|
||||
let selection_before = self.selection.clone();
|
||||
let order_before = self.order.0.clone();
|
||||
let clipboard_before = self.clipboard.clone();
|
||||
let is_edit = matches!(action, Action::Edit(_));
|
||||
let mut error = None;
|
||||
// the files change iff there is a diff, but selection and order actions have none
|
||||
let mut diff = match action {
|
||||
Action::Edit(command) => self.edit(command).unwrap_or_else(|err| {
|
||||
error = Some(err);
|
||||
None
|
||||
}),
|
||||
Action::Undo => self.stack.undo(),
|
||||
Action::Redo => self.stack.redo(),
|
||||
Action::Edit(command) => self.edit(command)?,
|
||||
Action::Undo => self.history.undo(),
|
||||
Action::Redo => self.history.redo(),
|
||||
Action::Select { selection, mode } => {
|
||||
self.selection.select(self.stack.current(), selection, mode);
|
||||
self.selection
|
||||
.select(self.history.current(), selection, mode);
|
||||
None
|
||||
}
|
||||
Action::SelectAll => {
|
||||
if let Some(files) = self.stack.current() {
|
||||
if let Some(files) = self.history.current() {
|
||||
self.selection.select_all(files, &self.order.0);
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::ArrowSelect { down, add } => {
|
||||
if let Some(files) = self.stack.current() {
|
||||
if let Some(files) = self.history.current() {
|
||||
self.selection
|
||||
.select_neighbour(files, &self.order.0, down, add);
|
||||
}
|
||||
@@ -256,32 +253,26 @@ impl Engine {
|
||||
|| self.selection != selection_before
|
||||
|| self.order.0 != order_before
|
||||
|| self.clipboard != clipboard_before;
|
||||
if is_edit && !changed && error.is_none() {
|
||||
error = Some(CommandError::NothingToDo);
|
||||
if is_edit && !changed {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
if changed {
|
||||
self.refresh(diff.as_ref());
|
||||
sort_diff(&mut diff, &self.order.0);
|
||||
}
|
||||
// after the refresh, which also syncs the order and selection with the files (undo, redo)
|
||||
let outcome = Outcome {
|
||||
Ok(Outcome {
|
||||
changed,
|
||||
diff,
|
||||
order_changed: self.order.0 != order_before,
|
||||
selection_changed: self.selection != selection_before,
|
||||
clipboard_changed: self.clipboard != clipboard_before,
|
||||
error,
|
||||
};
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.last = outcome.clone();
|
||||
}
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts the selection in the clipboard, if there is one.
|
||||
fn copy(&mut self, cut: bool) {
|
||||
if let Some(files) = self.stack.current()
|
||||
if let Some(files) = self.history.current()
|
||||
&& let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut)
|
||||
{
|
||||
self.clipboard = Some(clipboard);
|
||||
@@ -289,7 +280,7 @@ impl Engine {
|
||||
}
|
||||
|
||||
fn edit(&mut self, command: Command) -> Result<Option<Diff>, CommandError> {
|
||||
self.stack.create_and_push_next(|files| {
|
||||
self.history.create_and_push_next(|files| {
|
||||
let mut state = State {
|
||||
categories: &mut self.categories,
|
||||
files,
|
||||
@@ -301,9 +292,9 @@ impl Engine {
|
||||
})
|
||||
}
|
||||
|
||||
/// Brings everything derived from the files back in line with the current stack entry.
|
||||
/// Brings everything derived from the files back in line with the current history entry.
|
||||
fn refresh(&mut self, diff: Option<&Diff>) {
|
||||
let current = self.stack.current();
|
||||
let current = self.history.current();
|
||||
match current {
|
||||
Some(files) => {
|
||||
// files coming back after an undo/redo are added at the end of the order
|
||||
@@ -329,7 +320,7 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the files of a diff in the order of the files (the stack does not know it), so that
|
||||
/// Puts the files of a diff in the order of the files (the history does not know it), so that
|
||||
/// whatever is done with them one after the other, like giving them colors, follows it.
|
||||
fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
|
||||
let position = |id: &FileId| order.iter().position(|other| other == id);
|
||||
@@ -339,34 +330,5 @@ fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Engine {
|
||||
/// Executes an action and tells whether it changed anything. What it did is kept for the
|
||||
/// accessors below, which the tests read right after.
|
||||
pub(crate) fn run(&mut self, action: Action) -> bool {
|
||||
self.execute(action).changed
|
||||
}
|
||||
|
||||
pub(crate) fn last_diff(&self) -> Option<&Diff> {
|
||||
self.last.diff.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn last_error(&self) -> Option<&CommandError> {
|
||||
self.last.error.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn order_changed(&self) -> bool {
|
||||
self.last.order_changed
|
||||
}
|
||||
|
||||
pub(crate) fn selection_changed(&self) -> bool {
|
||||
self.last.selection_changed
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_changed(&self) -> bool {
|
||||
self.last.clipboard_changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{CommandError, Diff};
|
||||
use crate::Diff;
|
||||
|
||||
/// What an action did, returned by [`crate::Engine::execute`] and [`crate::Engine::restore`].
|
||||
/// What an action did, returned by [`crate::Engine::execute`] (unless it was refused) and
|
||||
/// [`crate::Engine::restore`].
|
||||
///
|
||||
/// It describes that action only: it is not kept by the engine, so reading it never depends on
|
||||
/// what was done since.
|
||||
@@ -15,7 +16,4 @@ pub struct Outcome {
|
||||
pub order_changed: bool,
|
||||
pub selection_changed: bool,
|
||||
pub clipboard_changed: bool,
|
||||
/// Why an edit did nothing: the command failed, or it had nothing to change. `None` if it
|
||||
/// succeeded, and for the actions that do not edit the files.
|
||||
pub error: Option<CommandError>,
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
|
||||
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
|
||||
WaypointId,
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ impl Clipboard {
|
||||
/// selected does not exist).
|
||||
pub fn new(
|
||||
selection: &Selection,
|
||||
files: &StackEntry,
|
||||
files: &Files,
|
||||
order: &[FileId],
|
||||
cut: bool,
|
||||
) -> Option<Clipboard> {
|
||||
@@ -238,14 +238,14 @@ mod tests {
|
||||
file
|
||||
}
|
||||
|
||||
fn files_of(files: Vec<File>) -> StackEntry {
|
||||
fn files_of(files: Vec<File>) -> Files {
|
||||
files
|
||||
.into_iter()
|
||||
.map(|file| (file.id, Rc::new(file)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn order_of(files: &StackEntry) -> Vec<FileId> {
|
||||
fn order_of(files: &Files) -> Vec<FileId> {
|
||||
files.keys().copied().collect()
|
||||
}
|
||||
|
||||
|
||||
+143
-137
@@ -6,17 +6,17 @@ use std::{
|
||||
use crate::{CommandError, File, FileId};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Stack {
|
||||
entries: Vec<StackEntry>,
|
||||
pub struct History {
|
||||
entries: Vec<Files>,
|
||||
index: Option<usize>,
|
||||
/// Whether the first entry is the state the stack starts from, which is not undone: files
|
||||
/// Whether the first entry is the state the history starts from, which is not undone: files
|
||||
/// restored from the storage.
|
||||
floor: bool,
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
/// A stack whose first entry is `entry`, from which nothing can be undone.
|
||||
pub fn restored(entry: StackEntry) -> Self {
|
||||
impl History {
|
||||
/// A history whose first entry is `entry`, from which nothing can be undone.
|
||||
pub fn restored(entry: Files) -> Self {
|
||||
Self {
|
||||
entries: vec![entry],
|
||||
index: Some(0),
|
||||
@@ -24,7 +24,7 @@ impl Stack {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Option<&StackEntry> {
|
||||
pub fn current(&self) -> Option<&Files> {
|
||||
self.index.map_or_default(|i| self.get(i))
|
||||
}
|
||||
|
||||
@@ -35,18 +35,18 @@ impl Stack {
|
||||
/// history. Files are told apart by their identity, not their content.
|
||||
pub fn create_and_push_next<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
|
||||
where
|
||||
F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||
F: FnOnce(&mut Files) -> Result<(), CommandError>,
|
||||
{
|
||||
let mut next = self.current().map_or_default(|c| c.clone());
|
||||
f(&mut next)?;
|
||||
if self.is_current(&next) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.record_diff(|stack| stack.push(next)))
|
||||
Ok(self.record_diff(|history| history.push(next)))
|
||||
}
|
||||
|
||||
/// Whether `entry` holds exactly the files of the current entry.
|
||||
fn is_current(&self, entry: &StackEntry) -> bool {
|
||||
fn is_current(&self, entry: &Files) -> bool {
|
||||
let current = self.current();
|
||||
let len = current.map_or(0, |current| current.len());
|
||||
entry.len() == len
|
||||
@@ -58,29 +58,29 @@ impl Stack {
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Option<Diff> {
|
||||
self.record_diff(|stack| {
|
||||
if let Some(i) = stack.index {
|
||||
self.record_diff(|history| {
|
||||
if let Some(i) = history.index {
|
||||
if i == 0 {
|
||||
if !stack.floor {
|
||||
stack.index = None;
|
||||
if !history.floor {
|
||||
history.index = None;
|
||||
}
|
||||
} else {
|
||||
stack.index = Some(i - 1);
|
||||
history.index = Some(i - 1);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Option<Diff> {
|
||||
self.record_diff(|stack| match stack.index {
|
||||
self.record_diff(|history| match history.index {
|
||||
Some(i) => {
|
||||
if i + 1 < stack.entries.len() {
|
||||
stack.index = Some(i + 1);
|
||||
if i + 1 < history.entries.len() {
|
||||
history.index = Some(i + 1);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if !stack.entries.is_empty() {
|
||||
stack.index = Some(0);
|
||||
if !history.entries.is_empty() {
|
||||
history.index = Some(0);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -97,11 +97,11 @@ impl Stack {
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, index: usize) -> Option<&StackEntry> {
|
||||
fn get(&self, index: usize) -> Option<&Files> {
|
||||
self.entries.get(index)
|
||||
}
|
||||
|
||||
fn push(&mut self, entry: StackEntry) {
|
||||
fn push(&mut self, entry: Files) {
|
||||
if let Some(i) = self.index {
|
||||
self.entries.truncate(i + 1);
|
||||
}
|
||||
@@ -120,8 +120,14 @@ impl Stack {
|
||||
if prev == cur {
|
||||
return None;
|
||||
}
|
||||
let prev = prev.map_or_default(|i| self.get(i));
|
||||
let cur = cur.map_or_default(|i| self.get(i));
|
||||
Some(Self::diff_between(
|
||||
prev.map_or_default(|i| self.get(i)),
|
||||
cur.map_or_default(|i| self.get(i)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Which files differ between two states.
|
||||
pub fn diff_between(prev: Option<&Files>, cur: Option<&Files>) -> Diff {
|
||||
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
|
||||
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
|
||||
let mut modified = vec![];
|
||||
@@ -132,17 +138,17 @@ impl Stack {
|
||||
modified.push(*id);
|
||||
}
|
||||
}
|
||||
Some(Diff {
|
||||
Diff {
|
||||
added: cur_ids.difference(&prev_ids).copied().collect(),
|
||||
removed: prev_ids.difference(&cur_ids).copied().collect(),
|
||||
modified,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type StackEntry = HashMap<FileId, Rc<File>>;
|
||||
pub type Files = HashMap<FileId, Rc<File>>;
|
||||
|
||||
/// Which files were added, removed or modified by a change of the current stack entry.
|
||||
/// Which files were added, removed or modified by a change of the current history entry.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Diff {
|
||||
pub added: Vec<FileId>,
|
||||
@@ -154,11 +160,11 @@ pub struct Diff {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
impl Stack {
|
||||
impl History {
|
||||
/// A step that is expected not to fail.
|
||||
fn push_ok(
|
||||
&mut self,
|
||||
f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||
f: impl FnOnce(&mut Files) -> Result<(), CommandError>,
|
||||
) -> Option<Diff> {
|
||||
self.create_and_push_next(f).unwrap()
|
||||
}
|
||||
@@ -166,21 +172,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_new_file() {
|
||||
let mut stack = Stack::default();
|
||||
let mut history = History::default();
|
||||
|
||||
assert!(!stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.current().is_none());
|
||||
assert!(!history.can_undo());
|
||||
assert!(!history.can_redo());
|
||||
assert!(history.current().is_none());
|
||||
|
||||
let diff = stack.push_ok(|e| {
|
||||
let diff = history.push_ok(|e| {
|
||||
let file = Rc::new(File::default());
|
||||
e.insert(file.id, file);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.current().is_some());
|
||||
assert!(history.can_undo());
|
||||
assert!(!history.can_redo());
|
||||
assert!(history.current().is_some());
|
||||
|
||||
assert!(diff.is_some());
|
||||
|
||||
@@ -190,10 +196,10 @@ mod tests {
|
||||
assert!(diff.modified.is_empty());
|
||||
}
|
||||
|
||||
fn add_file(stack: &mut Stack) -> FileId {
|
||||
fn add_file(history: &mut History) -> FileId {
|
||||
let file = Rc::new(File::default());
|
||||
let id = file.id;
|
||||
stack
|
||||
history
|
||||
.push_ok(|e| {
|
||||
e.insert(id, file);
|
||||
Ok(())
|
||||
@@ -204,46 +210,46 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_failed_command_pushes_nothing() {
|
||||
let mut stack = Stack::default();
|
||||
let diff = stack.create_and_push_next(|e| {
|
||||
let mut history = History::default();
|
||||
let diff = history.create_and_push_next(|e| {
|
||||
e.insert(FileId::default(), Rc::new(File::default()));
|
||||
Err(CommandError::InvalidData("nope".to_string()))
|
||||
});
|
||||
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.current().is_none());
|
||||
assert!(!history.can_undo());
|
||||
assert!(history.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_redo() {
|
||||
let mut stack = Stack::default();
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.redo().is_none());
|
||||
let mut history = History::default();
|
||||
assert!(history.undo().is_none());
|
||||
assert!(history.redo().is_none());
|
||||
|
||||
let id = add_file(&mut stack);
|
||||
let id = add_file(&mut history);
|
||||
|
||||
let diff = stack.undo().unwrap();
|
||||
let diff = history.undo().unwrap();
|
||||
assert_eq!(diff.removed, vec![id]);
|
||||
assert!(diff.added.is_empty());
|
||||
assert!(stack.current().is_none());
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.can_redo());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(history.current().is_none());
|
||||
assert!(!history.can_undo());
|
||||
assert!(history.can_redo());
|
||||
assert!(history.undo().is_none());
|
||||
|
||||
let diff = stack.redo().unwrap();
|
||||
let diff = history.redo().unwrap();
|
||||
assert_eq!(diff.added, vec![id]);
|
||||
assert!(stack.current().unwrap().contains_key(&id));
|
||||
assert!(stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.redo().is_none());
|
||||
assert!(history.current().unwrap().contains_key(&id));
|
||||
assert!(history.can_undo());
|
||||
assert!(!history.can_redo());
|
||||
assert!(history.redo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modified_and_removed() {
|
||||
let mut stack = Stack::default();
|
||||
let id = add_file(&mut stack);
|
||||
let mut history = History::default();
|
||||
let id = add_file(&mut history);
|
||||
|
||||
let diff = stack
|
||||
let diff = history
|
||||
.push_ok(|e| {
|
||||
let mut file = (**e.get(&id).unwrap()).clone();
|
||||
file.info.name = "renamed".to_string();
|
||||
@@ -255,9 +261,9 @@ mod tests {
|
||||
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
||||
|
||||
// a step that changes nothing is not a step
|
||||
assert!(stack.push_ok(|_| Ok(())).is_none());
|
||||
assert!(history.push_ok(|_| Ok(())).is_none());
|
||||
assert!(
|
||||
stack
|
||||
history
|
||||
.push_ok(|e| {
|
||||
let file = e[&id].clone();
|
||||
e.insert(id, file);
|
||||
@@ -266,28 +272,28 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let diff = stack
|
||||
let diff = history
|
||||
.push_ok(|e| {
|
||||
e.remove(&id);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diff.removed, vec![id]);
|
||||
assert!(stack.current().unwrap().is_empty());
|
||||
assert!(history.current().unwrap().is_empty());
|
||||
|
||||
// going back restores the renamed file
|
||||
stack.undo();
|
||||
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
|
||||
history.undo();
|
||||
assert_eq!(history.current().unwrap()[&id].info.name, "renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
|
||||
let mut stack = Stack::default();
|
||||
let id = add_file(&mut stack);
|
||||
let mut history = History::default();
|
||||
let id = add_file(&mut history);
|
||||
|
||||
// a copy, even if it has the same content, is another file for the stack: files are
|
||||
// a copy, even if it has the same content, is another file for the history: files are
|
||||
// told apart by their identity, comparing them would read all of them
|
||||
let diff = stack
|
||||
let diff = history
|
||||
.push_ok(|e| {
|
||||
let copy = (**e.get(&id).unwrap()).clone();
|
||||
e.insert(id, Rc::new(copy));
|
||||
@@ -297,8 +303,8 @@ mod tests {
|
||||
assert_eq!(diff.modified, vec![id]);
|
||||
|
||||
// and the files that were not replaced are not
|
||||
let other = add_file(&mut stack);
|
||||
let diff = stack
|
||||
let other = add_file(&mut history);
|
||||
let diff = history
|
||||
.push_ok(|e| {
|
||||
let copy = (**e.get(&other).unwrap()).clone();
|
||||
e.insert(other, Rc::new(copy));
|
||||
@@ -310,120 +316,120 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_push_after_undo_drops_redo_branch() {
|
||||
let mut stack = Stack::default();
|
||||
let first = add_file(&mut stack);
|
||||
let second = add_file(&mut stack);
|
||||
let mut history = History::default();
|
||||
let first = add_file(&mut history);
|
||||
let second = add_file(&mut history);
|
||||
|
||||
stack.undo();
|
||||
assert!(stack.can_redo());
|
||||
assert!(!stack.current().unwrap().contains_key(&second));
|
||||
history.undo();
|
||||
assert!(history.can_redo());
|
||||
assert!(!history.current().unwrap().contains_key(&second));
|
||||
|
||||
let third = add_file(&mut stack);
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.redo().is_none());
|
||||
let current = stack.current().unwrap();
|
||||
let third = add_file(&mut history);
|
||||
assert!(!history.can_redo());
|
||||
assert!(history.redo().is_none());
|
||||
let current = history.current().unwrap();
|
||||
assert!(current.contains_key(&first));
|
||||
assert!(current.contains_key(&third));
|
||||
assert!(!current.contains_key(&second));
|
||||
}
|
||||
|
||||
fn restored_entry() -> (StackEntry, FileId) {
|
||||
fn restored_entry() -> (Files, FileId) {
|
||||
let file = Rc::new(File::default());
|
||||
let id = file.id;
|
||||
(StackEntry::from([(id, file)]), id)
|
||||
(Files::from([(id, file)]), id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_starts_from_its_entry() {
|
||||
fn test_restored_history_starts_from_its_entry() {
|
||||
let (entry, id) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
let mut history = History::restored(entry);
|
||||
|
||||
assert!(stack.current().unwrap().contains_key(&id));
|
||||
assert!(history.current().unwrap().contains_key(&id));
|
||||
// there is nothing to go back to, nor forward to
|
||||
assert!(!stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.redo().is_none());
|
||||
assert!(stack.current().unwrap().contains_key(&id));
|
||||
assert!(!history.can_undo());
|
||||
assert!(!history.can_redo());
|
||||
assert!(history.undo().is_none());
|
||||
assert!(history.redo().is_none());
|
||||
assert!(history.current().unwrap().contains_key(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_cannot_undo_an_empty_entry_either() {
|
||||
let mut stack = Stack::restored(StackEntry::new());
|
||||
fn test_restored_history_cannot_undo_an_empty_entry_either() {
|
||||
let mut history = History::restored(Files::new());
|
||||
|
||||
assert!(stack.current().is_some_and(|files| files.is_empty()));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.undo().is_none());
|
||||
// the stack is not the one of an engine that has done nothing
|
||||
assert!(stack.current().is_some());
|
||||
assert!(history.current().is_some_and(|files| files.is_empty()));
|
||||
assert!(!history.can_undo());
|
||||
assert!(history.undo().is_none());
|
||||
// the history is not the one of an engine that has done nothing
|
||||
assert!(history.current().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_undoes_what_comes_after_it_and_no_further() {
|
||||
fn test_restored_history_undoes_what_comes_after_it_and_no_further() {
|
||||
let (entry, restored) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
let mut history = History::restored(entry);
|
||||
|
||||
let added = add_file(&mut stack);
|
||||
assert!(stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert_eq!(stack.current().unwrap().len(), 2);
|
||||
let added = add_file(&mut history);
|
||||
assert!(history.can_undo());
|
||||
assert!(!history.can_redo());
|
||||
assert_eq!(history.current().unwrap().len(), 2);
|
||||
|
||||
// back to the restored files
|
||||
let diff = stack.undo().unwrap();
|
||||
let diff = history.undo().unwrap();
|
||||
assert_eq!(diff.removed, vec![added]);
|
||||
assert!(diff.added.is_empty() && diff.modified.is_empty());
|
||||
assert_eq!(stack.current().unwrap().len(), 1);
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.can_redo());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert_eq!(history.current().unwrap().len(), 1);
|
||||
assert!(history.current().unwrap().contains_key(&restored));
|
||||
assert!(!history.can_undo());
|
||||
assert!(history.can_redo());
|
||||
assert!(history.undo().is_none());
|
||||
assert!(history.current().unwrap().contains_key(&restored));
|
||||
|
||||
let diff = stack.redo().unwrap();
|
||||
let diff = history.redo().unwrap();
|
||||
assert_eq!(diff.added, vec![added]);
|
||||
assert!(stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert!(history.can_undo());
|
||||
assert!(!history.can_redo());
|
||||
|
||||
// and again
|
||||
assert!(stack.undo().is_some());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(history.undo().is_some());
|
||||
assert!(history.undo().is_none());
|
||||
assert!(history.current().unwrap().contains_key(&restored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_removing_its_files_can_be_undone() {
|
||||
fn test_restored_history_removing_its_files_can_be_undone() {
|
||||
let (entry, restored) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
let mut history = History::restored(entry);
|
||||
|
||||
let diff = stack
|
||||
let diff = history
|
||||
.push_ok(|e| {
|
||||
e.remove(&restored);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diff.removed, vec![restored]);
|
||||
assert!(stack.current().unwrap().is_empty());
|
||||
assert!(history.current().unwrap().is_empty());
|
||||
|
||||
let diff = stack.undo().unwrap();
|
||||
let diff = history.undo().unwrap();
|
||||
assert_eq!(diff.added, vec![restored]);
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(history.current().unwrap().contains_key(&restored));
|
||||
assert!(!history.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_push_after_undo_drops_redo_branch() {
|
||||
fn test_restored_history_push_after_undo_drops_redo_branch() {
|
||||
let (entry, restored) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
add_file(&mut stack);
|
||||
stack.undo().unwrap();
|
||||
assert!(stack.can_redo());
|
||||
let mut history = History::restored(entry);
|
||||
add_file(&mut history);
|
||||
history.undo().unwrap();
|
||||
assert!(history.can_redo());
|
||||
|
||||
let other = add_file(&mut stack);
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.can_undo());
|
||||
let diff = stack.undo().unwrap();
|
||||
let other = add_file(&mut history);
|
||||
assert!(!history.can_redo());
|
||||
assert!(history.can_undo());
|
||||
let diff = history.undo().unwrap();
|
||||
assert_eq!(diff.removed, vec![other]);
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(history.current().unwrap().contains_key(&restored));
|
||||
assert!(!history.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
mod clipboard;
|
||||
mod history;
|
||||
mod order;
|
||||
mod selection;
|
||||
mod stack;
|
||||
|
||||
pub use clipboard::*;
|
||||
pub use history::*;
|
||||
pub use order::*;
|
||||
pub use selection::*;
|
||||
pub use stack::*;
|
||||
|
||||
pub struct State<'a> {
|
||||
pub categories: &'a mut crate::TrackpointCategories,
|
||||
pub files: &'a mut StackEntry,
|
||||
pub files: &'a mut Files,
|
||||
pub selection: &'a mut Selection,
|
||||
pub order: &'a mut FileOrder,
|
||||
pub clipboard: &'a mut Option<Clipboard>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, StackEntry as Files};
|
||||
use crate::{FileId, Files};
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct FileOrder(pub Vec<FileId>);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
|
||||
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId};
|
||||
|
||||
/// How a new selection combines with the current one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -52,12 +52,7 @@ impl Selection {
|
||||
/// Combines `selection` with this one according to `mode`, ignoring the elements of
|
||||
/// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection
|
||||
/// as it is, while replacing it with nothing deselects everything.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
files: Option<&StackEntry>,
|
||||
mut selection: Selection,
|
||||
mode: SelectMode,
|
||||
) {
|
||||
pub fn select(&mut self, files: Option<&Files>, mut selection: Selection, mode: SelectMode) {
|
||||
match files {
|
||||
Some(files) => selection.retain_existing(files),
|
||||
None => selection = Selection::Empty,
|
||||
@@ -72,7 +67,7 @@ impl Selection {
|
||||
|
||||
/// Selects all the elements of the same kind as the selected ones, see
|
||||
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
|
||||
pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) {
|
||||
pub fn select_all(&mut self, files: &Files, order: &[FileId]) {
|
||||
if let Some(all) = self.all_at_level(files, order) {
|
||||
*self = all;
|
||||
}
|
||||
@@ -81,13 +76,7 @@ impl Selection {
|
||||
/// Moves the selection to the next (`down`) or previous element, see
|
||||
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
|
||||
/// replacing it.
|
||||
pub fn select_neighbour(
|
||||
&mut self,
|
||||
files: &StackEntry,
|
||||
order: &[FileId],
|
||||
down: bool,
|
||||
add: bool,
|
||||
) {
|
||||
pub fn select_neighbour(&mut self, files: &Files, order: &[FileId], down: bool, add: bool) {
|
||||
if let Some(next) = self.neighbour(files, order, down) {
|
||||
if add {
|
||||
self.extend(next);
|
||||
@@ -102,7 +91,7 @@ impl Selection {
|
||||
/// The track segments covered by the selection, in order: the segments of several selected
|
||||
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
||||
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
|
||||
pub fn segment_locations(&self, files: &StackEntry, order: &[FileId]) -> Vec<SegmentLocation> {
|
||||
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
|
||||
let mut locations = vec![];
|
||||
let mut add = |file_id: FileId,
|
||||
trk_filter: &dyn Fn(&crate::Track) -> bool,
|
||||
@@ -145,7 +134,7 @@ impl Selection {
|
||||
|
||||
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
|
||||
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
|
||||
pub fn retain_existing(&mut self, files: &StackEntry) {
|
||||
pub fn retain_existing(&mut self, files: &Files) {
|
||||
let empty = match self {
|
||||
Selection::Empty => false,
|
||||
Selection::File { file_ids } => {
|
||||
@@ -340,7 +329,7 @@ impl Selection {
|
||||
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
|
||||
/// of the file. All the files when nothing is selected. `None` when there is nothing to
|
||||
/// select (the waypoints node, or a file that does not exist).
|
||||
pub fn all_at_level(&self, files: &StackEntry, order: &[FileId]) -> Option<Selection> {
|
||||
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
|
||||
match self {
|
||||
Selection::Empty | Selection::File { .. } => Some(Selection::File {
|
||||
file_ids: order.iter().copied().collect(),
|
||||
@@ -375,7 +364,7 @@ impl Selection {
|
||||
/// (`down`) or the previous one among the elements of the same kind, in the same place.
|
||||
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
|
||||
/// the ones that are already selected. `None` when there is no such element.
|
||||
pub fn neighbour(&self, files: &StackEntry, order: &[FileId], down: bool) -> Option<Selection> {
|
||||
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
|
||||
match self {
|
||||
Selection::Empty | Selection::Waypoints { .. } => None,
|
||||
Selection::File { file_ids } => {
|
||||
@@ -443,7 +432,7 @@ mod tests {
|
||||
fn test_retain_existing() {
|
||||
let file = File::default();
|
||||
let (kept, gone) = (file.id, FileId::default());
|
||||
let mut files = StackEntry::default();
|
||||
let mut files = Files::default();
|
||||
files.insert(kept, Rc::new(file));
|
||||
|
||||
let mut selection = Selection::File {
|
||||
@@ -481,7 +470,7 @@ mod tests {
|
||||
});
|
||||
let (file_id, trk_id) = (file.id, file.trk[0].id);
|
||||
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
|
||||
let mut files = StackEntry::default();
|
||||
let mut files = Files::default();
|
||||
files.insert(file_id, Rc::new(file));
|
||||
|
||||
let mut selection = Selection::Track {
|
||||
@@ -610,7 +599,7 @@ mod tests {
|
||||
|
||||
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
|
||||
struct Tree {
|
||||
files: StackEntry,
|
||||
files: Files,
|
||||
order: Vec<FileId>,
|
||||
file: FileId,
|
||||
tracks: Vec<TrackId>,
|
||||
@@ -644,7 +633,7 @@ mod tests {
|
||||
.collect(),
|
||||
waypoints: file.wpt.iter().map(|w| w.id).collect(),
|
||||
order: vec![file.id],
|
||||
files: StackEntry::from([(file.id, Rc::new(file))]),
|
||||
files: Files::from([(file.id, Rc::new(file))]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,7 +761,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
|
||||
let files = StackEntry::default();
|
||||
let files = Files::default();
|
||||
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
|
||||
let order = [a, b, c];
|
||||
let select = |ids: &[FileId]| Selection::File {
|
||||
|
||||
@@ -1,12 +1,73 @@
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId,
|
||||
ClipboardIds, Delete, DeleteAll, File, Load, Metadata, New, NewTrack, Paste, Style, TrackId,
|
||||
TrackSegmentId,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// An engine that remembers what the last action it was given did, for the tests to read right
|
||||
/// after (see `run`).
|
||||
#[derive(Default)]
|
||||
struct TestEngine {
|
||||
engine: Engine,
|
||||
last: Outcome,
|
||||
last_error: Option<CommandError>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TestEngine {
|
||||
type Target = Engine;
|
||||
|
||||
fn deref(&self) -> &Engine {
|
||||
&self.engine
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for TestEngine {
|
||||
fn deref_mut(&mut self) -> &mut Engine {
|
||||
&mut self.engine
|
||||
}
|
||||
}
|
||||
|
||||
impl TestEngine {
|
||||
/// Executes an action and tells whether it changed anything.
|
||||
fn run(&mut self, action: Action) -> bool {
|
||||
let result = self.engine.execute(action);
|
||||
self.last = result.clone().unwrap_or_default();
|
||||
self.last_error = result.as_ref().err().cloned();
|
||||
result.is_ok_and(|outcome| outcome.changed)
|
||||
}
|
||||
|
||||
fn restore(&mut self, restored: Restored) -> Outcome {
|
||||
let outcome = self.engine.restore(restored);
|
||||
self.last = outcome.clone();
|
||||
self.last_error = None;
|
||||
outcome
|
||||
}
|
||||
|
||||
fn last_diff(&self) -> Option<&Diff> {
|
||||
self.last.diff.as_ref()
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&CommandError> {
|
||||
self.last_error.as_ref()
|
||||
}
|
||||
|
||||
fn order_changed(&self) -> bool {
|
||||
self.last.order_changed
|
||||
}
|
||||
|
||||
fn selection_changed(&self) -> bool {
|
||||
self.last.selection_changed
|
||||
}
|
||||
|
||||
fn clipboard_changed(&self) -> bool {
|
||||
self.last.clipboard_changed
|
||||
}
|
||||
}
|
||||
|
||||
fn expand(intervals: &crate::Intervals, len: usize) -> Vec<u8> {
|
||||
let mut values = Vec::new();
|
||||
for (i, value) in intervals.values.iter().enumerate() {
|
||||
@@ -16,11 +77,11 @@ fn expand(intervals: &crate::Intervals, len: usize) -> Vec<u8> {
|
||||
values
|
||||
}
|
||||
|
||||
fn edit(engine: &mut Engine, command: Command) -> bool {
|
||||
fn edit(engine: &mut TestEngine, command: Command) -> bool {
|
||||
engine.run(Action::Edit(command))
|
||||
}
|
||||
|
||||
fn new(engine: &mut Engine, name: &str) -> bool {
|
||||
fn new(engine: &mut TestEngine, name: &str) -> bool {
|
||||
edit(
|
||||
engine,
|
||||
Command::New(New {
|
||||
@@ -30,7 +91,7 @@ fn new(engine: &mut Engine, name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn load(engine: &mut Engine, path: &str) -> bool {
|
||||
fn load(engine: &mut TestEngine, path: &str) -> bool {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
edit(
|
||||
engine,
|
||||
@@ -50,23 +111,23 @@ fn selected(engine: &Engine) -> Vec<FileId> {
|
||||
|
||||
#[test]
|
||||
fn test_load_file() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(load(&mut engine, "data/simple.gpx"));
|
||||
assert_eq!(engine.statistics_buffer.total_distance.len(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_file_has_no_statistics() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(new(&mut engine, "empty"));
|
||||
assert_eq!(engine.stack.current().unwrap().len(), 1);
|
||||
assert_eq!(engine.history.current().unwrap().len(), 1);
|
||||
assert_eq!(engine.order.0.len(), 1);
|
||||
assert!(engine.statistics().total_distance.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_statistics_follow_selection() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(load(&mut engine, "data/simple.gpx"));
|
||||
let n = engine.statistics().total_distance.len();
|
||||
assert!(n > 0);
|
||||
@@ -74,7 +135,7 @@ fn test_statistics_follow_selection() {
|
||||
// a newly created (empty) file becomes the selection
|
||||
assert!(new(&mut engine, "empty"));
|
||||
assert!(engine.statistics().total_distance.is_empty());
|
||||
assert_eq!(engine.stack.current().unwrap().len(), 2);
|
||||
assert_eq!(engine.history.current().unwrap().len(), 2);
|
||||
|
||||
// loading a file selects it again
|
||||
assert!(load(&mut engine, "data/simple.gpx"));
|
||||
@@ -83,7 +144,7 @@ fn test_statistics_follow_selection() {
|
||||
|
||||
#[test]
|
||||
fn test_invalid_load_is_rejected() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(!edit(
|
||||
&mut engine,
|
||||
Command::Load(Load {
|
||||
@@ -91,23 +152,23 @@ fn test_invalid_load_is_rejected() {
|
||||
name: "file",
|
||||
})
|
||||
));
|
||||
assert!(engine.stack.current().is_none());
|
||||
assert!(!engine.stack.can_undo());
|
||||
assert!(engine.history.current().is_none());
|
||||
assert!(!engine.history.can_undo());
|
||||
assert!(engine.order.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_with_nothing_to_do_is_rejected() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "file");
|
||||
assert!(!edit(&mut engine, Command::Reverse(crate::Reverse)));
|
||||
assert!(!engine.stack.can_redo());
|
||||
assert_eq!(engine.stack.current().unwrap().len(), 1);
|
||||
assert!(!engine.history.can_redo());
|
||||
assert_eq!(engine.history.current().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_renames_selected_file() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "before");
|
||||
assert!(edit(
|
||||
&mut engine,
|
||||
@@ -116,14 +177,14 @@ fn test_metadata_renames_selected_file() {
|
||||
desc: "about",
|
||||
})
|
||||
));
|
||||
let file = engine.stack.current().unwrap().values().next().unwrap();
|
||||
let file = engine.history.current().unwrap().values().next().unwrap();
|
||||
assert_eq!(file.info.name, "after");
|
||||
assert_eq!(file.info.desc.as_deref(), Some("about"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_without_selection_does_not_modify_files() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "file");
|
||||
engine.selection = Selection::Empty;
|
||||
edit(
|
||||
@@ -133,17 +194,17 @@ fn test_metadata_without_selection_does_not_modify_files() {
|
||||
desc: "",
|
||||
}),
|
||||
);
|
||||
let file = engine.stack.current().unwrap().values().next().unwrap();
|
||||
let file = engine.history.current().unwrap().values().next().unwrap();
|
||||
assert_eq!(file.info.name, "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_changes_statistics_when_segments_change() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
let before = engine
|
||||
.statistics_cache
|
||||
.get(engine.stack.current(), &engine.selection)[0] as *const _;
|
||||
.get(engine.history.current(), &engine.selection)[0] as *const _;
|
||||
edit(&mut engine, Command::NewTrack(NewTrack));
|
||||
edit(
|
||||
&mut engine,
|
||||
@@ -156,13 +217,13 @@ fn test_edit_changes_statistics_when_segments_change() {
|
||||
// styling does not touch the segments: their statistics are reused
|
||||
let after = engine
|
||||
.statistics_cache
|
||||
.get(engine.stack.current(), &engine.selection)[0] as *const _;
|
||||
.get(engine.history.current(), &engine.selection)[0] as *const _;
|
||||
assert!(std::ptr::eq(before, after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_redo_keep_selection_and_order_consistent() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
let a = selected(&engine)[0];
|
||||
new(&mut engine, "b");
|
||||
@@ -180,12 +241,12 @@ fn test_undo_redo_keep_selection_and_order_consistent() {
|
||||
assert!(engine.run(Action::Undo));
|
||||
assert!(engine.order.0.is_empty());
|
||||
assert!(!engine.run(Action::Undo));
|
||||
assert!(engine.stack.current().is_none());
|
||||
assert!(engine.history.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reorder_is_not_undoable_and_returning_files_go_last() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
let a = engine.order.0[0];
|
||||
new(&mut engine, "b");
|
||||
@@ -213,7 +274,7 @@ fn test_reorder_is_not_undoable_and_returning_files_go_last() {
|
||||
|
||||
#[test]
|
||||
fn test_structures_and_coordinates_follow_actions() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(engine.order().is_empty());
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
let loaded_id = engine.order()[0];
|
||||
@@ -266,7 +327,7 @@ fn test_structures_and_coordinates_follow_actions() {
|
||||
|
||||
#[test]
|
||||
fn test_selection_changed_flag() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
let a = engine.order()[0];
|
||||
// a new file is selected by the edit
|
||||
@@ -284,7 +345,7 @@ fn test_selection_changed_flag() {
|
||||
|
||||
#[test]
|
||||
fn test_undo_updates_statistics() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
assert!(!engine.statistics().total_distance.is_empty());
|
||||
engine.run(Action::Undo);
|
||||
@@ -296,7 +357,7 @@ fn test_undo_updates_statistics() {
|
||||
|
||||
#[test]
|
||||
fn test_selection_actions() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
let a = selected(&engine)[0];
|
||||
new(&mut engine, "b");
|
||||
@@ -316,14 +377,14 @@ fn test_selection_actions() {
|
||||
|
||||
#[test]
|
||||
fn test_selection_does_not_create_undo_steps() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
select_all(&mut engine);
|
||||
engine.run(Action::Undo);
|
||||
assert!(engine.stack.current().is_none());
|
||||
assert!(engine.history.current().is_none());
|
||||
}
|
||||
|
||||
fn select_elements(engine: &mut Engine, selection: Selection, mode: SelectMode) -> bool {
|
||||
fn select_elements(engine: &mut TestEngine, selection: Selection, mode: SelectMode) -> bool {
|
||||
engine.run(Action::Select { selection, mode })
|
||||
}
|
||||
|
||||
@@ -333,21 +394,21 @@ fn files(ids: &[FileId]) -> Selection {
|
||||
}
|
||||
}
|
||||
|
||||
fn select_files(engine: &mut Engine, ids: &[FileId]) -> bool {
|
||||
fn select_files(engine: &mut TestEngine, ids: &[FileId]) -> bool {
|
||||
select_elements(engine, files(ids), SelectMode::Replace)
|
||||
}
|
||||
|
||||
fn select_all(engine: &mut Engine) -> bool {
|
||||
fn select_all(engine: &mut TestEngine) -> bool {
|
||||
let ids = engine.order().to_vec();
|
||||
select_files(engine, &ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_elements() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
||||
let file = engine
|
||||
.stack
|
||||
.history
|
||||
.current()
|
||||
.unwrap()
|
||||
.values()
|
||||
@@ -443,10 +504,10 @@ fn test_select_elements() {
|
||||
|
||||
#[test]
|
||||
fn test_toggle_elements() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
||||
let file = engine
|
||||
.stack
|
||||
.history
|
||||
.current()
|
||||
.unwrap()
|
||||
.values()
|
||||
@@ -458,7 +519,7 @@ fn test_toggle_elements() {
|
||||
file_id: file.id,
|
||||
trk_ids: ids.iter().copied().collect(),
|
||||
};
|
||||
let toggle = |engine: &mut Engine, ids: &[TrackId]| {
|
||||
let toggle = |engine: &mut TestEngine, ids: &[TrackId]| {
|
||||
select_elements(engine, tracks(ids), SelectMode::Toggle)
|
||||
};
|
||||
|
||||
@@ -477,10 +538,10 @@ fn test_toggle_elements() {
|
||||
|
||||
#[test]
|
||||
fn test_selected_elements_are_dropped_when_they_disappear() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
||||
let file = engine
|
||||
.stack
|
||||
.history
|
||||
.current()
|
||||
.unwrap()
|
||||
.values()
|
||||
@@ -502,7 +563,7 @@ fn test_selected_elements_are_dropped_when_they_disappear() {
|
||||
|
||||
#[test]
|
||||
fn test_can_undo_and_redo() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(!engine.can_undo() && !engine.can_redo());
|
||||
new(&mut engine, "a");
|
||||
assert!(engine.can_undo() && !engine.can_redo());
|
||||
@@ -520,7 +581,7 @@ fn test_can_undo_and_redo() {
|
||||
|
||||
#[test]
|
||||
fn test_select_all_follows_the_selected_level() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
new(&mut engine, "b");
|
||||
let order = engine.order().to_vec();
|
||||
@@ -533,10 +594,10 @@ fn test_select_all_follows_the_selected_level() {
|
||||
assert!(!engine.run(Action::SelectAll));
|
||||
|
||||
// the tracks of a file
|
||||
let load = |engine: &mut Engine| load(engine, "data/with_tracks_and_segments.gpx");
|
||||
let load = |engine: &mut TestEngine| load(engine, "data/with_tracks_and_segments.gpx");
|
||||
load(&mut engine);
|
||||
let file = engine
|
||||
.stack
|
||||
.history
|
||||
.current()
|
||||
.unwrap()
|
||||
.values()
|
||||
@@ -581,13 +642,13 @@ fn test_select_all_follows_the_selected_level() {
|
||||
|
||||
#[test]
|
||||
fn test_arrow_select() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "file");
|
||||
for _ in 0..3 {
|
||||
edit(&mut engine, Command::NewTrack(NewTrack));
|
||||
}
|
||||
let file = engine
|
||||
.stack
|
||||
.history
|
||||
.current()
|
||||
.unwrap()
|
||||
.values()
|
||||
@@ -600,7 +661,7 @@ fn test_arrow_select() {
|
||||
file_id: file.id,
|
||||
trk_ids: ids.iter().copied().collect(),
|
||||
};
|
||||
let arrow = |engine: &mut Engine, down, add| engine.run(Action::ArrowSelect { down, add });
|
||||
let arrow = |engine: &mut TestEngine, down, add| engine.run(Action::ArrowSelect { down, add });
|
||||
|
||||
select_elements(&mut engine, tracks(&[ids[0]]), SelectMode::Replace);
|
||||
// without shift the next one replaces the selection
|
||||
@@ -625,7 +686,7 @@ fn test_arrow_select() {
|
||||
|
||||
#[test]
|
||||
fn test_arrow_select_does_not_create_undo_steps() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
new(&mut engine, "b");
|
||||
let a = engine.order()[0];
|
||||
@@ -642,7 +703,7 @@ fn test_arrow_select_does_not_create_undo_steps() {
|
||||
}
|
||||
|
||||
/// A file named `name` with `n` tracks, selected.
|
||||
fn file_with_tracks(engine: &mut Engine, name: &str, n: usize) -> FileId {
|
||||
fn file_with_tracks(engine: &mut TestEngine, name: &str, n: usize) -> FileId {
|
||||
new(engine, name);
|
||||
for _ in 0..n {
|
||||
edit(engine, Command::NewTrack(NewTrack));
|
||||
@@ -660,13 +721,13 @@ fn track_ids(engine: &Engine, id: FileId) -> Vec<TrackId> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn paste(engine: &mut Engine) -> bool {
|
||||
fn paste(engine: &mut TestEngine) -> bool {
|
||||
edit(engine, Command::Paste(Paste))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_and_paste() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let a = file_with_tracks(&mut engine, "a", 2);
|
||||
let b = file_with_tracks(&mut engine, "b", 0);
|
||||
assert!(engine.clipboard().is_none() && !engine.can_paste());
|
||||
@@ -723,7 +784,7 @@ fn test_copy_and_paste() {
|
||||
|
||||
#[test]
|
||||
fn test_cut_and_paste_moves_after_the_paste() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let a = file_with_tracks(&mut engine, "a", 2);
|
||||
let b = file_with_tracks(&mut engine, "b", 0);
|
||||
let tracks = track_ids(&engine, a);
|
||||
@@ -751,7 +812,7 @@ fn test_cut_and_paste_moves_after_the_paste() {
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_survives_what_happens_to_the_elements() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let a = file_with_tracks(&mut engine, "a", 2);
|
||||
let tracks = track_ids(&engine, a);
|
||||
select_elements(
|
||||
@@ -800,7 +861,7 @@ fn test_clipboard_survives_what_happens_to_the_elements() {
|
||||
|
||||
#[test]
|
||||
fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let a = file_with_tracks(&mut engine, "a", 0);
|
||||
let b = file_with_tracks(&mut engine, "b", 0);
|
||||
select_files(&mut engine, &[a]);
|
||||
@@ -824,7 +885,7 @@ fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() {
|
||||
fn test_move_elements_is_an_edit_and_selects_what_moved() {
|
||||
use crate::{Move, MoveTarget};
|
||||
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let a = file_with_tracks(&mut engine, "a", 3);
|
||||
let tracks = track_ids(&engine, a);
|
||||
let moved = Selection::Track {
|
||||
@@ -864,7 +925,7 @@ fn test_move_elements_is_an_edit_and_selects_what_moved() {
|
||||
fn test_added_files_are_reported_in_the_order_of_the_files() {
|
||||
use crate::LoadFiles;
|
||||
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let datas: Vec<Vec<u8>> = (0..12)
|
||||
.map(|i| {
|
||||
format!(r#"<gpx version="1.1"><metadata><name>{i}</name></metadata></gpx>"#)
|
||||
@@ -891,7 +952,7 @@ fn test_added_files_are_reported_in_the_order_of_the_files() {
|
||||
fn test_load_several_files_is_one_edit() {
|
||||
use crate::LoadFiles;
|
||||
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "before");
|
||||
let gpx = |name: &str| {
|
||||
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
|
||||
@@ -929,7 +990,7 @@ fn test_load_several_files_is_one_edit() {
|
||||
fn test_create_edit_and_move_a_waypoint() {
|
||||
use crate::{EditWaypoint, MoveWaypoint, NewWaypoint};
|
||||
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "file");
|
||||
let file = engine.order()[0];
|
||||
assert!(edit(
|
||||
@@ -1018,7 +1079,7 @@ fn test_create_edit_and_move_a_waypoint() {
|
||||
|
||||
#[test]
|
||||
fn test_categories_are_shared_and_kept_by_the_history() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(engine.categories().surface.names().is_empty());
|
||||
|
||||
load(&mut engine, "data/with_highway.gpx");
|
||||
@@ -1063,7 +1124,7 @@ fn test_categories_are_shared_and_kept_by_the_history() {
|
||||
|
||||
#[test]
|
||||
fn test_trackpoint() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
let file = engine.order()[0];
|
||||
let structure = engine.file_structure(&file).unwrap();
|
||||
@@ -1081,7 +1142,7 @@ fn test_trackpoint() {
|
||||
|
||||
#[test]
|
||||
fn test_export_writes_the_file_as_it_is() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(
|
||||
engine
|
||||
.export(&FileId::default(), ExportOptions::ALL)
|
||||
@@ -1092,7 +1153,7 @@ fn test_export_writes_the_file_as_it_is() {
|
||||
let id = engine.order()[0];
|
||||
let bytes = engine.export(&id, ExportOptions::ALL).unwrap();
|
||||
let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap();
|
||||
let file = engine.stack.current().unwrap()[&id].clone();
|
||||
let file = engine.history.current().unwrap()[&id].clone();
|
||||
assert_eq!(again.trk.len(), file.trk.len());
|
||||
assert_eq!(again.trk[0].trkseg[0].len(), file.trk[0].trkseg[0].len());
|
||||
assert!(again.trk[0].trkseg[0][0].time.is_some());
|
||||
@@ -1109,7 +1170,7 @@ fn test_export_writes_the_file_as_it_is() {
|
||||
|
||||
#[test]
|
||||
fn test_exportable_data_is_the_union_over_the_files() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert_eq!(engine.exportable_data(&[]), ExportOptions::NONE);
|
||||
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
@@ -1128,7 +1189,7 @@ fn test_exportable_data_is_the_union_over_the_files() {
|
||||
|
||||
#[test]
|
||||
fn test_file_statistics_are_the_ones_of_the_whole_file() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(engine.file_statistics(&FileId::default()).is_none());
|
||||
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
@@ -1155,7 +1216,7 @@ fn test_file_statistics_are_the_ones_of_the_whole_file() {
|
||||
|
||||
#[test]
|
||||
fn test_reduction_distances_follow_the_selection() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(engine.reduction_distances().is_empty());
|
||||
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
@@ -1173,7 +1234,7 @@ fn test_reduction_distances_follow_the_selection() {
|
||||
|
||||
#[test]
|
||||
fn test_routing_describes_the_anchors_of_the_selection() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(engine.routing().anchor_indices.is_empty());
|
||||
|
||||
load(&mut engine, "data/simple.gpx");
|
||||
@@ -1206,7 +1267,7 @@ fn test_routing_describes_the_anchors_of_the_selection() {
|
||||
|
||||
#[test]
|
||||
fn test_last_error_tells_why_an_edit_did_nothing() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
assert!(engine.last_error().is_none());
|
||||
|
||||
// invalid data
|
||||
@@ -1239,7 +1300,7 @@ fn test_last_error_tells_why_an_edit_did_nothing() {
|
||||
|
||||
#[test]
|
||||
fn test_an_edit_that_changes_nothing_is_not_an_undo_step() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "file");
|
||||
// "file" is selected: naming it the same still builds a new file, but selecting nothing
|
||||
// leaves nothing to rename
|
||||
@@ -1265,46 +1326,111 @@ fn test_an_edit_that_changes_nothing_is_not_an_undo_step() {
|
||||
|
||||
#[test]
|
||||
fn test_execute_returns_what_the_action_did() {
|
||||
let mut engine = Engine::default();
|
||||
let mut engine = TestEngine::default();
|
||||
let data = std::fs::read("data/simple.gpx").unwrap();
|
||||
let outcome = engine.execute(Action::Edit(Command::Load(Load {
|
||||
let outcome = engine
|
||||
.execute(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
})));
|
||||
assert!(outcome.changed && outcome.error.is_none());
|
||||
})))
|
||||
.unwrap();
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(outcome.diff.as_ref().unwrap().added, engine.order());
|
||||
assert!(outcome.order_changed && outcome.selection_changed);
|
||||
assert!(!outcome.clipboard_changed);
|
||||
|
||||
// the outcome of an action does not depend on what is done after it
|
||||
let kept = outcome.clone();
|
||||
engine.execute(Action::Undo);
|
||||
engine.execute(Action::Undo).unwrap();
|
||||
assert_eq!(outcome, kept);
|
||||
|
||||
// a selection only changes the selection
|
||||
engine.execute(Action::Redo);
|
||||
let outcome = engine.execute(Action::Select {
|
||||
selection: files(engine.order()),
|
||||
engine.execute(Action::Redo).unwrap();
|
||||
let selection = files(engine.order());
|
||||
let outcome = engine
|
||||
.execute(Action::Select {
|
||||
selection,
|
||||
mode: SelectMode::Replace,
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
assert!(outcome.changed && outcome.selection_changed);
|
||||
assert!(outcome.diff.is_none() && !outcome.order_changed);
|
||||
|
||||
// an edit with nothing to do says why
|
||||
engine.execute(Action::Select {
|
||||
engine
|
||||
.execute(Action::Select {
|
||||
selection: Selection::Empty,
|
||||
mode: SelectMode::Replace,
|
||||
});
|
||||
let outcome = engine.execute(Action::Edit(Command::Reverse(crate::Reverse)));
|
||||
assert!(!outcome.changed);
|
||||
assert_eq!(outcome.error, Some(CommandError::NothingToDo));
|
||||
assert_eq!(outcome.diff, None);
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.execute(Action::Edit(Command::Reverse(crate::Reverse)));
|
||||
assert_eq!(result, Err(CommandError::NothingToDo));
|
||||
|
||||
// a failing one too
|
||||
let outcome = engine.execute(Action::Edit(Command::Load(Load {
|
||||
let result = engine.execute(Action::Edit(Command::Load(Load {
|
||||
data: b"<gpx><trk></gpx>",
|
||||
name: "file",
|
||||
})));
|
||||
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||
}
|
||||
|
||||
// What the engine does when it is overwritten with what is stored
|
||||
|
||||
fn restored_from(engine: &Engine, keep: &[FileId], extra: Vec<Rc<File>>) -> crate::Restored {
|
||||
let mut files: Vec<Rc<File>> = keep
|
||||
.iter()
|
||||
.map(|id| engine.snapshot().files[id].clone())
|
||||
.collect();
|
||||
files.extend(extra);
|
||||
crate::Restored {
|
||||
order: files.iter().map(|file| file.id).collect(),
|
||||
files,
|
||||
categories: engine.categories().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwriting_reports_what_was_added_removed_and_modified() {
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "kept");
|
||||
new(&mut engine, "modified");
|
||||
new(&mut engine, "removed");
|
||||
let [kept, modified, removed] = engine.order()[..] else {
|
||||
panic!()
|
||||
};
|
||||
let mut changed = (*engine.snapshot().files[&modified]).clone();
|
||||
changed.info.name = "other name".into();
|
||||
let added = File::default();
|
||||
let added_id = added.id;
|
||||
|
||||
let restored = restored_from(&engine, &[kept], vec![Rc::new(changed), Rc::new(added)]);
|
||||
let outcome = engine.restore(restored);
|
||||
assert!(outcome.changed);
|
||||
let diff = outcome.diff.unwrap();
|
||||
assert_eq!(diff.added, vec![added_id]);
|
||||
assert_eq!(diff.removed, vec![removed]);
|
||||
assert_eq!(diff.modified, vec![modified]);
|
||||
assert!(outcome.order_changed);
|
||||
assert_eq!(engine.order(), &[kept, modified, added_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwriting_with_the_same_files_changes_nothing() {
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
let ids = engine.order().to_vec();
|
||||
let outcome = engine.restore(restored_from(&engine, &ids, vec![]));
|
||||
assert!(!outcome.changed);
|
||||
assert!(matches!(outcome.error, Some(CommandError::InvalidData(_))));
|
||||
assert!(outcome.diff.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwriting_forgets_the_history() {
|
||||
let mut engine = TestEngine::default();
|
||||
new(&mut engine, "a");
|
||||
new(&mut engine, "b");
|
||||
assert!(engine.can_undo());
|
||||
let ids = engine.order().to_vec();
|
||||
engine.restore(restored_from(&engine, &ids, vec![]));
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint,
|
||||
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId,
|
||||
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
|
||||
WaypointChunkId, Waypoints,
|
||||
};
|
||||
@@ -144,22 +144,21 @@ pub fn decode_file(bytes: &[u8]) -> Result<FileRecord, DecodeError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec<u8> {
|
||||
encode(
|
||||
&chunk
|
||||
.trkpt
|
||||
.iter()
|
||||
.map(v1::Trackpoint1::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
/// The bytes of a chunk of trackpoints. Its surfaces, highways and scales are written as names,
|
||||
/// the codes of `categories` being the ones of the engine that has the chunk.
|
||||
pub fn encode_trackpoints(chunk: &TrackpointChunk, categories: &TrackpointCategories) -> Vec<u8> {
|
||||
encode(&v1::TrackpointChunk1::new(&chunk.trkpt, categories))
|
||||
}
|
||||
|
||||
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Result<TrackpointChunk, DecodeError> {
|
||||
/// A chunk of trackpoints from its bytes. The names it holds are added to `categories` if it does
|
||||
/// not know them yet, and the trackpoints refer to them by the codes of `categories`.
|
||||
pub fn decode_trackpoints(
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
categories: &mut TrackpointCategories,
|
||||
) -> Result<TrackpointChunk, DecodeError> {
|
||||
let trkpt = match split(bytes)? {
|
||||
(1, data) => parse::<Vec<v1::Trackpoint1>>(data)?
|
||||
.into_iter()
|
||||
.map(Trackpoint::from)
|
||||
.collect(),
|
||||
(1, data) => parse::<v1::TrackpointChunk1>(data)?.into_points(categories),
|
||||
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
||||
};
|
||||
Ok(TrackpointChunk {
|
||||
@@ -203,22 +202,11 @@ pub fn decode_order(bytes: &[u8]) -> Result<Vec<FileId>, DecodeError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_categories(categories: &TrackpointCategories) -> Vec<u8> {
|
||||
encode(&v1::Categories1::from(categories))
|
||||
}
|
||||
|
||||
pub fn decode_categories(bytes: &[u8]) -> Result<TrackpointCategories, DecodeError> {
|
||||
match split(bytes)? {
|
||||
(1, data) => Ok(parse::<v1::Categories1>(data)?.into()),
|
||||
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Author, Link, LngLat};
|
||||
use crate::{Author, Chunk, Link, LngLat, Trackpoint};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -246,10 +234,11 @@ mod tests {
|
||||
hr: Some(140 + u16::from(n)),
|
||||
cad: Some(80),
|
||||
power: Some(250),
|
||||
surface: Some(1),
|
||||
highway: Some(2),
|
||||
sac_scale: Some(3),
|
||||
mtb_scale: Some(4),
|
||||
// the first has the second name of the table, which comes first in the chunk
|
||||
surface: Some(if n == 0 { 1 } else { 0 }),
|
||||
highway: Some(0),
|
||||
sac_scale: Some(0),
|
||||
mtb_scale: Some(0),
|
||||
anchor: Some(n),
|
||||
}
|
||||
}
|
||||
@@ -439,12 +428,34 @@ mod tests {
|
||||
assert!(built.trk.is_empty() && built.wpt.is_empty());
|
||||
}
|
||||
|
||||
/// The names of the categories of a trackpoint.
|
||||
fn names(point: &Trackpoint, categories: &TrackpointCategories) -> [Option<String>; 4] {
|
||||
let name = |table: &crate::Categories, code: Option<u8>| {
|
||||
code.and_then(|code| table.name(code)).map(str::to_owned)
|
||||
};
|
||||
[
|
||||
name(&categories.surface, point.surface),
|
||||
name(&categories.highway, point.highway),
|
||||
name(&categories.sac_scale, point.sac_scale),
|
||||
name(&categories.mtb_scale, point.mtb_scale),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_keep_their_identity_and_items() {
|
||||
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(chunk)).unwrap();
|
||||
let categories = categories();
|
||||
let mut reader = categories.clone();
|
||||
let decoded = decode_trackpoints(
|
||||
chunk.id.0,
|
||||
&encode_trackpoints(chunk, &categories),
|
||||
&mut reader,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.id, chunk.id);
|
||||
assert_eq!(decoded.trkpt.len(), 3);
|
||||
// read by an engine that has the same table, the trackpoints are the same
|
||||
assert_eq!(reader, categories);
|
||||
for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||
assert_eq!(format!("{a:?}"), format!("{b:?}"));
|
||||
}
|
||||
@@ -459,14 +470,99 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_order_and_categories_round_trip() {
|
||||
fn test_a_chunk_means_the_same_to_an_engine_with_another_table() {
|
||||
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let writer = categories();
|
||||
let bytes = encode_trackpoints(chunk, &writer);
|
||||
|
||||
// an engine that learned other names first, and the same ones in another order
|
||||
let mut reader = TrackpointCategories::default();
|
||||
reader.surface.code("sand");
|
||||
reader.surface.code("gravel");
|
||||
reader.surface.code("asphalt");
|
||||
reader.highway.code("track");
|
||||
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
|
||||
|
||||
for (read, written) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||
assert_eq!(names(read, &reader), names(written, &writer));
|
||||
}
|
||||
// the codes are the ones of the reader, which learned what it did not know
|
||||
assert_eq!(decoded.trkpt[0].surface, Some(1));
|
||||
assert_eq!(decoded.trkpt[1].surface, Some(2));
|
||||
assert_eq!(reader.highway.names(), ["track", "path"]);
|
||||
assert_eq!(decoded.trkpt[0].highway, Some(1));
|
||||
assert_eq!(reader.sac_scale.names(), ["hiking"]);
|
||||
// and an empty engine learns them in the order they come
|
||||
let mut empty = TrackpointCategories::default();
|
||||
decode_trackpoints(chunk.id.0, &bytes, &mut empty).unwrap();
|
||||
assert_eq!(empty.surface.names(), ["gravel", "asphalt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_chunk_only_carries_the_names_it_uses() {
|
||||
let mut writer = TrackpointCategories::default();
|
||||
for i in 0..10 {
|
||||
writer.surface.code(&format!("surface-{i}"));
|
||||
}
|
||||
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
|
||||
chunk.iter_mut().for_each(|p| p.surface = Some(7));
|
||||
let chunk = TrackpointChunk::new(chunk);
|
||||
let bytes = encode_trackpoints(&chunk, &writer);
|
||||
|
||||
let mut reader = TrackpointCategories::default();
|
||||
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
|
||||
assert_eq!(reader.surface.names(), ["surface-7"]);
|
||||
assert!(decoded.trkpt.iter().all(|p| p.surface == Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codes_without_a_name_are_not_stored_and_names_without_room_are_lost() {
|
||||
let mut writer = categories();
|
||||
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
|
||||
// a code that the table does not know
|
||||
chunk[1].surface = Some(200);
|
||||
let chunk = TrackpointChunk::new(chunk);
|
||||
let bytes = encode_trackpoints(&chunk, &writer);
|
||||
let decoded =
|
||||
decode_trackpoints(chunk.id.0, &bytes, &mut TrackpointCategories::default()).unwrap();
|
||||
assert_eq!(decoded.trkpt[1].surface, None);
|
||||
assert_eq!(decoded.trkpt[0].surface, Some(0));
|
||||
|
||||
// an engine whose table is full keeps the names it has and loses the other ones
|
||||
let mut full = TrackpointCategories::default();
|
||||
for i in 0..crate::Categories::CAPACITY {
|
||||
full.surface.code(&format!("other-{i}"));
|
||||
}
|
||||
writer.surface.code("other-3");
|
||||
let mut chunk = chunk.trkpt.clone();
|
||||
chunk[0].surface = writer.surface.code("other-3");
|
||||
chunk[1].surface = writer.surface.code("gravel");
|
||||
let chunk = TrackpointChunk::new(chunk);
|
||||
let decoded =
|
||||
decode_trackpoints(chunk.id.0, &encode_trackpoints(&chunk, &writer), &mut full)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.trkpt[0].surface, Some(3));
|
||||
assert_eq!(decoded.trkpt[1].surface, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_read_one_after_the_other_share_the_table() {
|
||||
let writer = categories();
|
||||
let chunk = sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let bytes = encode_trackpoints(&chunk, &writer);
|
||||
let mut reader = TrackpointCategories::default();
|
||||
let a = decode_trackpoints(id(1), &bytes, &mut reader).unwrap();
|
||||
let b = decode_trackpoints(id(2), &bytes, &mut reader).unwrap();
|
||||
for (a, b) in a.trkpt.iter().zip(&b.trkpt) {
|
||||
assert_eq!((a.surface, a.highway), (b.surface, b.highway));
|
||||
}
|
||||
assert_eq!(reader.surface.names().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_order_round_trips() {
|
||||
let order = vec![FileId(id(9)), FileId(id(8))];
|
||||
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
|
||||
let categories = categories();
|
||||
assert_eq!(
|
||||
decode_categories(&encode_categories(&categories)).unwrap(),
|
||||
categories
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -486,17 +582,13 @@ mod tests {
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_trackpoints(id(1), &bytes),
|
||||
decode_trackpoints(id(1), &bytes, &mut Default::default()),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_waypoints(id(1), &bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_categories(&bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
|
||||
// nothing at all, or a known version with something else
|
||||
assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt));
|
||||
@@ -505,10 +597,9 @@ mod tests {
|
||||
Some(DecodeError::Corrupt)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_trackpoints(id(1), &[LATEST, 9, 9, 9]).err(),
|
||||
decode_trackpoints(id(1), &[LATEST, 9, 9, 9], &mut Default::default()).err(),
|
||||
Some(DecodeError::Corrupt)
|
||||
);
|
||||
assert_eq!(decode_categories(&[LATEST]), Err(DecodeError::Corrupt));
|
||||
}
|
||||
|
||||
// The stored forms of version 1, as they were written when the version was made. They must
|
||||
@@ -529,10 +620,10 @@ mod tests {
|
||||
"000000000000000000000000000600011000000000000000000000000000000005",
|
||||
);
|
||||
const TRACKPOINTS_V1: &str = concat!(
|
||||
"01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101",
|
||||
"0101020103010401000000000000001440000000000080494000000000006059400182a0abfef9620105018d",
|
||||
"01015001fa010101010201030104010100000000000000000000000000000000000000000000000000000000",
|
||||
"000000000000",
|
||||
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
|
||||
"1040000000000000494000000000002059400180a0abfef9620105018c01015001fa01010001000100010001",
|
||||
"000000000000001440000000000080494000000000006059400182a0abfef9620105018d01015001fa010101",
|
||||
"010001000100010100000000000000000000000000000000000000000000000000000000000000000000",
|
||||
);
|
||||
const WAYPOINTS_V1: &str = concat!(
|
||||
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
|
||||
@@ -545,15 +636,16 @@ mod tests {
|
||||
);
|
||||
const ORDER_V1: &str =
|
||||
"010210000000000000000000000000000000091000000000000000000000000000000008";
|
||||
const CATEGORIES_V1: &str =
|
||||
"01020761737068616c740667726176656c010470617468010668696b696e6701046d746230";
|
||||
|
||||
#[test]
|
||||
fn test_version_1_is_still_written_as_it_was() {
|
||||
let file = sample();
|
||||
assert_eq!(hex(&encode_file(&file)), FILE_V1);
|
||||
assert_eq!(
|
||||
hex(&encode_trackpoints(&file.trk[0].trkseg[0].chunks()[0])),
|
||||
hex(&encode_trackpoints(
|
||||
&file.trk[0].trkseg[0].chunks()[0],
|
||||
&categories()
|
||||
)),
|
||||
TRACKPOINTS_V1
|
||||
);
|
||||
assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1);
|
||||
@@ -561,7 +653,6 @@ mod tests {
|
||||
hex(&encode_order(&[FileId(id(9)), FileId(id(8))])),
|
||||
ORDER_V1
|
||||
);
|
||||
assert_eq!(hex(&encode_categories(&categories())), CATEGORIES_V1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -571,12 +662,15 @@ mod tests {
|
||||
&rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(),
|
||||
&file,
|
||||
);
|
||||
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1)).unwrap();
|
||||
let mut reader = TrackpointCategories::default();
|
||||
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1), &mut reader).unwrap();
|
||||
let expected = &file.trk[0].trkseg[0].chunks()[0];
|
||||
assert_eq!(
|
||||
format!("{:?}", chunk.trkpt),
|
||||
format!("{:?}", expected.trkpt)
|
||||
);
|
||||
assert_eq!(chunk.trkpt.len(), expected.trkpt.len());
|
||||
for (read, written) in chunk.trkpt.iter().zip(&expected.trkpt) {
|
||||
assert_eq!(names(read, &reader), names(written, &categories()));
|
||||
assert_eq!(read.hr, written.hr);
|
||||
assert_eq!(read.anchor, written.anchor);
|
||||
}
|
||||
let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap();
|
||||
assert_eq!(chunk.wpt.len(), 2);
|
||||
assert!(chunk.wpt[0].same_content(&waypoint()));
|
||||
@@ -585,9 +679,5 @@ mod tests {
|
||||
decode_order(&unhex(ORDER_V1)).unwrap(),
|
||||
vec![FileId(id(9)), FileId(id(8))]
|
||||
);
|
||||
assert_eq!(
|
||||
decode_categories(&unhex(CATEGORIES_V1)).unwrap(),
|
||||
categories()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
//! compares the bytes they give with the ones of a fixture). A change of the form is a new
|
||||
//! version, next to this one, that the decoders convert from.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -183,6 +185,7 @@ pub struct Trackpoint1 {
|
||||
hr: Option<u16>,
|
||||
cad: Option<u16>,
|
||||
power: Option<u16>,
|
||||
/// The position of the name in the list of the chunk (see [`TrackpointChunk1`]).
|
||||
surface: Option<u8>,
|
||||
highway: Option<u8>,
|
||||
sac_scale: Option<u8>,
|
||||
@@ -285,34 +288,107 @@ impl From<Waypoint1> for Waypoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of each table of categories, in the order of their codes.
|
||||
/// A chunk of trackpoints. It carries the names its categories refer to: the surface, highway,
|
||||
/// SAC scale and MTB scale of its trackpoints are the positions of the names in its own lists,
|
||||
/// which only hold the names it uses. So it means the same whatever the table of categories of
|
||||
/// the engine that reads it is, and it is not changed by what other chunks learn.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Categories1 {
|
||||
pub struct TrackpointChunk1 {
|
||||
surface: Vec<String>,
|
||||
highway: Vec<String>,
|
||||
sac_scale: Vec<String>,
|
||||
mtb_scale: Vec<String>,
|
||||
points: Vec<Trackpoint1>,
|
||||
}
|
||||
|
||||
impl From<&TrackpointCategories> for Categories1 {
|
||||
fn from(categories: &TrackpointCategories) -> Self {
|
||||
let names = |table: &Categories| table.names().to_vec();
|
||||
Self {
|
||||
surface: names(&categories.surface),
|
||||
highway: names(&categories.highway),
|
||||
sac_scale: names(&categories.sac_scale),
|
||||
mtb_scale: names(&categories.mtb_scale),
|
||||
/// The names of a category used by a chunk, with their position in the list of the chunk.
|
||||
struct LocalNames<'a> {
|
||||
table: &'a Categories,
|
||||
names: Vec<String>,
|
||||
local: HashMap<u8, u8>,
|
||||
}
|
||||
|
||||
impl<'a> LocalNames<'a> {
|
||||
fn new(table: &'a Categories) -> Self {
|
||||
Self {
|
||||
table,
|
||||
names: vec![],
|
||||
local: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Categories1> for TrackpointCategories {
|
||||
fn from(categories: Categories1) -> Self {
|
||||
/// The position in the list of the chunk of the name of a code of the table. A code that
|
||||
/// has no name has no position.
|
||||
fn position(&mut self, code: Option<u8>) -> Option<u8> {
|
||||
let code = code?;
|
||||
if let Some(position) = self.local.get(&code) {
|
||||
return Some(*position);
|
||||
}
|
||||
let name = self.table.name(code)?;
|
||||
let position = u8::try_from(self.names.len()).ok()?;
|
||||
self.names.push(name.to_owned());
|
||||
self.local.insert(code, position);
|
||||
Some(position)
|
||||
}
|
||||
}
|
||||
|
||||
/// The codes of the table for the names of a chunk, which are added to it if it does not know
|
||||
/// them. A name that does not fit in a full table has no code.
|
||||
fn codes_of(names: &[String], table: &mut Categories) -> Vec<Option<u8>> {
|
||||
names.iter().map(|name| table.code(name)).collect()
|
||||
}
|
||||
|
||||
impl TrackpointChunk1 {
|
||||
pub fn new(points: &[Trackpoint], categories: &TrackpointCategories) -> Self {
|
||||
let mut surface = LocalNames::new(&categories.surface);
|
||||
let mut highway = LocalNames::new(&categories.highway);
|
||||
let mut sac_scale = LocalNames::new(&categories.sac_scale);
|
||||
let mut mtb_scale = LocalNames::new(&categories.mtb_scale);
|
||||
let points = points
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let mut stored = Trackpoint1::from(point);
|
||||
stored.surface = surface.position(point.surface);
|
||||
stored.highway = highway.position(point.highway);
|
||||
stored.sac_scale = sac_scale.position(point.sac_scale);
|
||||
stored.mtb_scale = mtb_scale.position(point.mtb_scale);
|
||||
stored
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
surface: Categories::from_names(categories.surface),
|
||||
highway: Categories::from_names(categories.highway),
|
||||
sac_scale: Categories::from_names(categories.sac_scale),
|
||||
mtb_scale: Categories::from_names(categories.mtb_scale),
|
||||
surface: surface.names,
|
||||
highway: highway.names,
|
||||
sac_scale: sac_scale.names,
|
||||
mtb_scale: mtb_scale.names,
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
/// The trackpoints, with the codes of `categories` for the names of the chunk.
|
||||
pub fn into_points(self, categories: &mut TrackpointCategories) -> Vec<Trackpoint> {
|
||||
let surface = codes_of(&self.surface, &mut categories.surface);
|
||||
let highway = codes_of(&self.highway, &mut categories.highway);
|
||||
let sac_scale = codes_of(&self.sac_scale, &mut categories.sac_scale);
|
||||
let mtb_scale = codes_of(&self.mtb_scale, &mut categories.mtb_scale);
|
||||
let global = |codes: &[Option<u8>], local: Option<u8>| {
|
||||
local.and_then(|local| codes.get(usize::from(local)).copied().flatten())
|
||||
};
|
||||
self.points
|
||||
.into_iter()
|
||||
.map(|stored| {
|
||||
let (s, h, sac, mtb) = (
|
||||
global(&surface, stored.surface),
|
||||
global(&highway, stored.highway),
|
||||
global(&sac_scale, stored.sac_scale),
|
||||
global(&mtb_scale, stored.mtb_scale),
|
||||
);
|
||||
let mut point = Trackpoint::from(stored);
|
||||
point.surface = s;
|
||||
point.highway = h;
|
||||
point.sac_scale = sac;
|
||||
point.mtb_scale = mtb;
|
||||
point
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
||||
use crate::{
|
||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||
};
|
||||
|
||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||
/// and what the tests use.
|
||||
@@ -12,10 +14,10 @@ pub struct MemoryStorage {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
head: Revision,
|
||||
files: HashMap<FileId, Vec<u8>>,
|
||||
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||
order: Option<Vec<u8>>,
|
||||
categories: Option<Vec<u8>>,
|
||||
settings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -47,12 +49,19 @@ impl Storage for MemoryStorage {
|
||||
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
order: state.order.clone(),
|
||||
categories: state.categories.clone(),
|
||||
head: state.head,
|
||||
})
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
Ok(self.state.borrow().head)
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.head != batch.expect_head {
|
||||
return Err(CommitError::Stale { head: state.head });
|
||||
}
|
||||
state.files.extend(batch.put_files.iter().cloned());
|
||||
for id in &batch.delete_files {
|
||||
state.files.remove(id);
|
||||
@@ -64,10 +73,8 @@ impl Storage for MemoryStorage {
|
||||
if let Some(order) = &batch.order {
|
||||
state.order = Some(order.clone());
|
||||
}
|
||||
if let Some(categories) = &batch.categories {
|
||||
state.categories = Some(categories.clone());
|
||||
}
|
||||
Ok(())
|
||||
state.head = state.head.next();
|
||||
Ok(state.head)
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
@@ -140,7 +147,7 @@ mod tests {
|
||||
(ChunkKey::waypoints(w), vec![4]),
|
||||
],
|
||||
order: Some(vec![5]),
|
||||
categories: Some(vec![6]),
|
||||
expect_head: Revision(0),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
@@ -157,30 +164,26 @@ mod tests {
|
||||
(ChunkKey::waypoints(w), vec![4])
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
(data.order, data.categories),
|
||||
(Some(vec![5]), Some(vec![6]))
|
||||
);
|
||||
assert_eq!(data.order, Some(vec![5]));
|
||||
|
||||
// a put replaces, a delete removes, what is not mentioned stays
|
||||
block_on(storage.commit(&Batch {
|
||||
put_files: vec![(a, vec![10])],
|
||||
delete_files: vec![b],
|
||||
delete_chunks: vec![ChunkKey::trackpoints(t)],
|
||||
expect_head: Revision(1),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), counts(1, 0, 1));
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data.files, vec![(a, vec![10])]);
|
||||
assert_eq!(
|
||||
(data.order, data.categories),
|
||||
(Some(vec![5]), Some(vec![6]))
|
||||
);
|
||||
assert_eq!(data.order, Some(vec![5]));
|
||||
|
||||
block_on(storage.commit(&Batch {
|
||||
delete_chunks: vec![ChunkKey::waypoints(w)],
|
||||
order: Some(vec![]),
|
||||
expect_head: Revision(2),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
@@ -216,4 +219,50 @@ mod tests {
|
||||
block_on(storage.put_setting("a", "1")).unwrap();
|
||||
assert_eq!(block_on(storage.load()).unwrap(), StoredData::default());
|
||||
}
|
||||
|
||||
fn batch(expect_head: u64, entry: u8) -> Batch {
|
||||
Batch {
|
||||
put_files: vec![(FileId::default(), vec![entry])],
|
||||
expect_head: Revision(expect_head),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_commit_advances_the_head() {
|
||||
let storage = MemoryStorage::default();
|
||||
assert_eq!(block_on(storage.head()).unwrap(), Revision(0));
|
||||
assert_eq!(block_on(storage.load()).unwrap().head, Revision(0));
|
||||
assert_eq!(block_on(storage.commit(&batch(0, 1))), Ok(Revision(1)));
|
||||
assert_eq!(block_on(storage.commit(&batch(1, 2))), Ok(Revision(2)));
|
||||
assert_eq!(block_on(storage.head()).unwrap(), Revision(2));
|
||||
assert_eq!(block_on(storage.load()).unwrap().head, Revision(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_commit_based_on_another_head_changes_nothing() {
|
||||
let storage = MemoryStorage::default();
|
||||
block_on(storage.commit(&batch(0, 1))).unwrap();
|
||||
block_on(storage.commit(&batch(1, 2))).unwrap();
|
||||
let before = block_on(storage.load()).unwrap();
|
||||
|
||||
// behind, or ahead: the head is not the one it is based on
|
||||
for expect in [0, 1, 3] {
|
||||
let stale = Batch {
|
||||
delete_files: vec![before.files[0].0],
|
||||
order: Some(vec![9]),
|
||||
..batch(expect, 3)
|
||||
};
|
||||
assert_eq!(
|
||||
block_on(storage.commit(&stale)),
|
||||
Err(CommitError::Stale { head: Revision(2) })
|
||||
);
|
||||
}
|
||||
let after = block_on(storage.load()).unwrap();
|
||||
assert_eq!(after.head, Revision(2));
|
||||
assert_eq!(after.files.len(), before.files.len());
|
||||
assert_eq!(after.order, before.order);
|
||||
// and it can be done again from the right head
|
||||
assert_eq!(block_on(storage.commit(&batch(2, 3))), Ok(Revision(3)));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,17 @@
|
||||
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
|
||||
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
|
||||
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
|
||||
//!
|
||||
//! Several engines can write to a storage (tabs, a server). It has a head, a revision that each
|
||||
//! commit advances, and a commit only applies if it is based on the current head: an engine that
|
||||
//! is behind is refused, and has to read the storage again, which replaces what it holds, before
|
||||
//! it writes. So what an engine knows of the storage is exact whenever it writes, which is what
|
||||
//! lets it write only what changed and delete what it does not use anymore.
|
||||
//!
|
||||
//! The surface, highway and scales of the trackpoints are stored as names, in the chunk that has
|
||||
//! them: the codes that the engine gives them (see [`crate::TrackpointCategories`]) depend on the
|
||||
//! order in which it learned the names, so they mean nothing outside of it. A storage that two
|
||||
//! engines (tabs, a server) write to therefore has nothing to agree on but the chunks.
|
||||
|
||||
mod codec;
|
||||
mod memory;
|
||||
@@ -20,16 +31,63 @@ use uuid::Uuid;
|
||||
use crate::FileId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StorageError(pub String);
|
||||
pub enum StorageError {
|
||||
/// The storage could not be read or written.
|
||||
Failed(String),
|
||||
/// Some of what is stored was written by a newer version of the app, which this one cannot
|
||||
/// read. Nothing is written then, so that the newer version finds its data again.
|
||||
Newer,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "storage error: {}", self.0)
|
||||
match self {
|
||||
Self::Failed(message) => write!(f, "storage error: {message}"),
|
||||
Self::Newer => write!(f, "stored by a newer version"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
/// A state of the storage: the head is the revision of the last commit, 0 for a storage that was
|
||||
/// never written to.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Revision(pub u64);
|
||||
|
||||
impl Revision {
|
||||
pub fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a commit did not happen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CommitError {
|
||||
/// The head is not the one the commit is based on: another engine wrote since.
|
||||
Stale {
|
||||
head: Revision,
|
||||
},
|
||||
Failed(StorageError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommitError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Stale { head } => write!(f, "the storage changed (now at revision {})", head.0),
|
||||
Self::Failed(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommitError {}
|
||||
|
||||
impl From<StorageError> for CommitError {
|
||||
fn from(error: StorageError) -> Self {
|
||||
Self::Failed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chunk holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChunkKind {
|
||||
@@ -68,8 +126,8 @@ pub struct StoredData {
|
||||
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
/// The order of the files.
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// The categories of the trackpoints, which their chunks refer to.
|
||||
pub categories: Option<Vec<u8>>,
|
||||
/// The revision of the storage that this is the state of.
|
||||
pub head: Revision,
|
||||
}
|
||||
|
||||
/// A change of the stored data, to be applied all at once: a stored state is always one that the
|
||||
@@ -81,7 +139,8 @@ pub struct Batch {
|
||||
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
pub delete_chunks: Vec<ChunkKey>,
|
||||
pub order: Option<Vec<u8>>,
|
||||
pub categories: Option<Vec<u8>>,
|
||||
/// The head that the batch is based on: it is only applied if the head still is that one.
|
||||
pub expect_head: Revision,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
@@ -116,8 +175,12 @@ pub trait Storage {
|
||||
/// Reads the files.
|
||||
async fn load(&self) -> Result<StoredData, StorageError>;
|
||||
|
||||
/// Applies a batch: either all of it is stored, or none.
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
|
||||
/// The head of the storage.
|
||||
async fn head(&self) -> Result<Revision, StorageError>;
|
||||
|
||||
/// Applies a batch if the head is the one it is based on (`Stale` otherwise): either all of it
|
||||
/// is stored, or none. The head advances. Gives the new head.
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError>;
|
||||
|
||||
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
||||
@@ -155,17 +218,13 @@ mod tests {
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
categories: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
assert!(holds.iter().all(|batch| !batch.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_error_is_readable() {
|
||||
let error = StorageError("disk full".into());
|
||||
let error = StorageError::Failed("disk full".into());
|
||||
assert_eq!(error.to_string(), "storage error: disk full");
|
||||
let _: &dyn std::error::Error = &error;
|
||||
}
|
||||
|
||||
@@ -5,20 +5,19 @@ use std::rc::Rc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::codec::{
|
||||
DecodeError, decode_categories, decode_file, decode_order, decode_trackpoints,
|
||||
decode_waypoints, encode_categories, encode_file, encode_order, encode_trackpoints,
|
||||
encode_waypoints,
|
||||
DecodeError, decode_file, decode_order, decode_trackpoints, decode_waypoints, encode_file,
|
||||
encode_order, encode_trackpoints, encode_waypoints,
|
||||
};
|
||||
use crate::{
|
||||
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
|
||||
TrackpointChunk, WaypointChunk,
|
||||
Batch, ChunkKey, CommitError, File, FileId, Files, Revision, Storage, StorageError,
|
||||
TrackpointCategories, TrackpointChunk, WaypointChunk,
|
||||
};
|
||||
|
||||
/// What there is to keep of the engine: the files of the current state, their order, and the
|
||||
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub files: StackEntry,
|
||||
pub files: Files,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
@@ -29,12 +28,6 @@ pub struct Restored {
|
||||
pub files: Vec<Rc<File>>,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
/// The files that could not be read: they are dropped at the next save.
|
||||
pub unreadable: usize,
|
||||
/// Whether some of what is stored was written by a newer version, which this one cannot read.
|
||||
/// Nothing is written then, so that the newer version finds its data again: the files that
|
||||
/// could be read are only kept in memory, and the persister does not save.
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
|
||||
@@ -51,7 +44,8 @@ pub struct Persister {
|
||||
stored_files: HashSet<FileId>,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
order: Option<Vec<FileId>>,
|
||||
categories: Option<TrackpointCategories>,
|
||||
/// The head of the storage that all of this is the state of.
|
||||
head: Revision,
|
||||
/// Set by `restore`, see [`Restored::read_only`].
|
||||
read_only: bool,
|
||||
}
|
||||
@@ -82,16 +76,18 @@ struct Plan {
|
||||
|
||||
impl Persister {
|
||||
/// Reads the files of the storage. The persister then knows that they are saved.
|
||||
///
|
||||
/// If some of what is stored was written by a newer version, nothing is read (`Newer`), and the
|
||||
/// persister does not save until it reads a storage that is not newer: the newer version
|
||||
/// needs its data.
|
||||
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
|
||||
let data = storage.load().await?;
|
||||
*self = Self::default();
|
||||
|
||||
// what comes from a newer version is not corrupt: it must not be overwritten
|
||||
let newer = Newer::default();
|
||||
let categories: Option<TrackpointCategories> = data
|
||||
.categories
|
||||
.as_deref()
|
||||
.and_then(|bytes| newer.read(decode_categories(bytes)));
|
||||
// the names of the chunks are added to the categories of the engine as they are read
|
||||
let categories: RefCell<TrackpointCategories> = Default::default();
|
||||
let stored_order: Option<Vec<FileId>> = data
|
||||
.order
|
||||
.as_deref()
|
||||
@@ -108,7 +104,13 @@ impl Persister {
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
||||
newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new)
|
||||
newer
|
||||
.read(decode_trackpoints(
|
||||
id.0,
|
||||
bytes,
|
||||
&mut categories.borrow_mut(),
|
||||
))
|
||||
.map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
@@ -123,69 +125,88 @@ impl Persister {
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut restored = Restored {
|
||||
categories: categories.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
||||
for (id, bytes) in &data.files {
|
||||
self.stored_files.insert(*id);
|
||||
let file = newer
|
||||
.read(decode_file(bytes))
|
||||
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
||||
match file {
|
||||
Some(file) => {
|
||||
// a file that cannot be read is left out, and dropped at the next save
|
||||
if let Some(file) = file {
|
||||
files.insert(*id, Rc::new(file));
|
||||
}
|
||||
None => restored.unreadable += 1,
|
||||
}
|
||||
if newer.0.get() {
|
||||
// `*self` was reset: only what is needed to not write, and to know the storage
|
||||
self.read_only = true;
|
||||
self.head = data.head;
|
||||
return Err(StorageError::Newer);
|
||||
}
|
||||
|
||||
restored.order = stored_order
|
||||
let mut order: Vec<FileId> = stored_order
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|id| files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let ordered: HashSet<FileId> = restored.order.iter().copied().collect();
|
||||
restored
|
||||
.order
|
||||
.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
||||
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
||||
let ordered: HashSet<FileId> = order.iter().copied().collect();
|
||||
order.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
||||
let restored = Restored {
|
||||
files: order.iter().map(|id| files[id].clone()).collect(),
|
||||
order,
|
||||
categories: categories.into_inner(),
|
||||
};
|
||||
|
||||
self.files = files;
|
||||
self.chunks = chunk_bytes.into_keys().collect();
|
||||
self.order = stored_order;
|
||||
self.categories = categories;
|
||||
self.read_only = newer.0.get();
|
||||
restored.read_only = self.read_only;
|
||||
self.head = data.head;
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// The revision of the storage that the persister knows.
|
||||
pub fn head(&self) -> Revision {
|
||||
self.head
|
||||
}
|
||||
|
||||
/// Writes what changed since the last save. Nothing is written if nothing changed.
|
||||
///
|
||||
/// Nothing is written while what is stored comes from a newer version (`Failed(Newer)`, see
|
||||
/// [`Persister::restore`]).
|
||||
///
|
||||
/// The write only happens if nothing else was committed since the persister read or wrote
|
||||
/// last: otherwise it is refused (`Stale`), nothing is written, and the persister has to read
|
||||
/// the storage again ([`Persister::restore`]) before it can write.
|
||||
pub async fn save<S: Storage>(
|
||||
&mut self,
|
||||
storage: &S,
|
||||
snapshot: Snapshot,
|
||||
) -> Result<(), StorageError> {
|
||||
) -> Result<(), CommitError> {
|
||||
if self.read_only {
|
||||
return Ok(());
|
||||
return Err(StorageError::Newer.into());
|
||||
}
|
||||
let plan = self.plan(snapshot);
|
||||
let mut plan = self.plan(snapshot);
|
||||
if plan.batch.is_empty() {
|
||||
self.files = plan.snapshot.files;
|
||||
return Ok(());
|
||||
}
|
||||
storage.commit(&plan.batch).await?;
|
||||
plan.batch.expect_head = self.head;
|
||||
self.head = storage.commit(&plan.batch).await?;
|
||||
|
||||
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
||||
self.files = plan.snapshot.files;
|
||||
self.chunks = plan.chunks;
|
||||
self.order = Some(plan.snapshot.order);
|
||||
self.categories = Some(plan.snapshot.categories);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether something else was committed to the storage since the persister read or wrote it
|
||||
/// last: what it knows of the storage is out of date, and it cannot write until it reads
|
||||
/// again ([`Persister::restore`]).
|
||||
pub async fn is_behind<S: Storage>(&self, storage: &S) -> Result<bool, StorageError> {
|
||||
Ok(storage.head().await? != self.head)
|
||||
}
|
||||
|
||||
fn plan(&self, snapshot: Snapshot) -> Plan {
|
||||
let mut batch = Batch::default();
|
||||
// the chunks that the files use, which the storage has afterwards
|
||||
@@ -200,7 +221,9 @@ impl Persister {
|
||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
||||
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch.put_chunks.push((key, encode_trackpoints(chunk)));
|
||||
batch
|
||||
.put_chunks
|
||||
.push((key, encode_trackpoints(chunk, &snapshot.categories)));
|
||||
}
|
||||
}
|
||||
for chunk in file.wpt.chunks() {
|
||||
@@ -224,9 +247,6 @@ impl Persister {
|
||||
if self.order.as_ref() != Some(&snapshot.order) {
|
||||
batch.order = Some(encode_order(&snapshot.order));
|
||||
}
|
||||
if self.categories.as_ref() != Some(&snapshot.categories) {
|
||||
batch.categories = Some(encode_categories(&snapshot.categories));
|
||||
}
|
||||
|
||||
Plan {
|
||||
batch,
|
||||
@@ -271,9 +291,14 @@ mod tests {
|
||||
self.inner.load().await
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
self.inner.head().await
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let revision = self.inner.commit(batch).await?;
|
||||
self.batches.borrow_mut().push(batch.clone());
|
||||
self.inner.commit(batch).await
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
@@ -318,7 +343,6 @@ mod tests {
|
||||
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.unreadable, 0);
|
||||
assert_eq!(restored.order, snapshot.order);
|
||||
assert_eq!(restored.categories, snapshot.categories);
|
||||
for file in &restored.files {
|
||||
@@ -375,7 +399,7 @@ mod tests {
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert!(batch.put_chunks.is_empty());
|
||||
assert!(batch.delete_chunks.is_empty());
|
||||
assert!(batch.order.is_none() && batch.categories.is_none());
|
||||
assert!(batch.order.is_none());
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
|
||||
@@ -444,13 +468,12 @@ mod tests {
|
||||
let broken = snapshot.order[0];
|
||||
block_on(storage.inner.commit(&Batch {
|
||||
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
|
||||
expect_head: persister.head(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.unreadable, 1);
|
||||
assert!(!restored.read_only);
|
||||
assert_eq!(restored.files.len(), FILES.len() - 1);
|
||||
assert!(restored.files.iter().all(|file| file.id != broken));
|
||||
|
||||
@@ -470,6 +493,7 @@ mod tests {
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
let mut batch = Batch::default();
|
||||
damage(&snapshot, &mut batch);
|
||||
batch.expect_head = block_on(storage.inner.head()).unwrap();
|
||||
block_on(storage.inner.commit(&batch)).unwrap();
|
||||
storage.batches.borrow_mut().clear();
|
||||
(storage, snapshot)
|
||||
@@ -482,7 +506,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_what_a_newer_version_wrote_is_left_alone() {
|
||||
type Damage = (&'static str, fn(&Snapshot, &mut Batch));
|
||||
let damages: [Damage; 4] = [
|
||||
let damages: [Damage; 3] = [
|
||||
("a file", |snapshot, batch| {
|
||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||
}),
|
||||
@@ -496,22 +520,24 @@ mod tests {
|
||||
("the order", |_, batch| {
|
||||
batch.order = Some(from_the_future())
|
||||
}),
|
||||
("the categories", |_, batch| {
|
||||
batch.categories = Some(from_the_future())
|
||||
}),
|
||||
];
|
||||
for (what, damage) in damages {
|
||||
let (storage, snapshot) = stored_with(damage);
|
||||
let before = storage.inner.counts();
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert!(restored.read_only, "{what}");
|
||||
let result = block_on(persister.restore(&storage));
|
||||
assert_eq!(result.unwrap_err(), StorageError::Newer, "{what}");
|
||||
|
||||
// whatever is saved afterwards, nothing is written: the newer version needs its data
|
||||
// whatever is saved afterwards is refused: the newer version needs its data
|
||||
let mut changed = snapshot.clone();
|
||||
changed.order.clear();
|
||||
changed.files.clear();
|
||||
block_on(persister.save(&storage, changed)).unwrap();
|
||||
let result = block_on(persister.save(&storage, changed));
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(CommitError::Failed(StorageError::Newer)),
|
||||
"{what}"
|
||||
);
|
||||
assert!(storage.batches.borrow().is_empty(), "{what}");
|
||||
assert_eq!(storage.inner.counts(), before, "{what}");
|
||||
}
|
||||
@@ -523,26 +549,187 @@ mod tests {
|
||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||
});
|
||||
let mut persister = Persister::default();
|
||||
assert!(block_on(persister.restore(&storage)).unwrap().read_only);
|
||||
assert!(block_on(persister.restore(&storage)).is_err());
|
||||
|
||||
// the same persister, on a storage that is not newer
|
||||
let clean = Recording::default();
|
||||
let restored = block_on(persister.restore(&clean)).unwrap();
|
||||
assert!(!restored.read_only);
|
||||
block_on(persister.restore(&clean)).unwrap();
|
||||
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
|
||||
assert_eq!(clean.inner.counts().files, FILES.len());
|
||||
}
|
||||
|
||||
/// Opens a storage as a tab does: the persister, and the state of the engine it gives.
|
||||
fn open(storage: &Recording) -> (Persister, Snapshot) {
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(storage)).unwrap();
|
||||
let snapshot = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
(persister, snapshot)
|
||||
}
|
||||
|
||||
/// Adds a file that has just been read to the state of an engine.
|
||||
fn add_file(snapshot: &mut Snapshot, path: &str) -> FileId {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data, &mut snapshot.categories).unwrap();
|
||||
let id = file.id;
|
||||
snapshot.order.push(id);
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_saved_by_engines_with_other_categories_read_the_same() {
|
||||
// a second engine opens the storage, so its categories come from what it read, in the
|
||||
// order it read them; it adds a file that has names of its own
|
||||
let storage = Recording::default();
|
||||
let first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]);
|
||||
block_on(Persister::default().save(&storage, first.clone())).unwrap();
|
||||
|
||||
let (mut persister, mut second) = open(&storage);
|
||||
let added = add_file(&mut second, "data/with_surface.gpx");
|
||||
block_on(persister.save(&storage, second.clone())).unwrap();
|
||||
|
||||
// the first engine reads it all again, and every file means what it did for its engine
|
||||
let (_, reader) = open(&storage);
|
||||
assert_eq!(reader.files.len(), 3);
|
||||
for (id, file) in &reader.files {
|
||||
let (original, categories) = match first.files.get(id) {
|
||||
Some(original) => (original, &first.categories),
|
||||
None => (&second.files[id], &second.categories),
|
||||
};
|
||||
assert_eq!(gpx(file, &reader.categories), gpx(original, categories));
|
||||
}
|
||||
assert!(reader.files.contains_key(&added));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_head_follows_the_commits_of_the_persister() {
|
||||
let storage = Recording::default();
|
||||
let mut persister = Persister::default();
|
||||
assert_eq!(persister.head(), Revision(0));
|
||||
|
||||
let mut snapshot = snapshot_of(&FILES);
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
assert_eq!(persister.head(), Revision(1));
|
||||
assert_eq!(block_on(storage.head()).unwrap(), Revision(1));
|
||||
|
||||
// nothing to write is no commit
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
assert_eq!(persister.head(), Revision(1));
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
|
||||
let id = snapshot.order[0];
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
file.info.name = "renamed".into();
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
assert_eq!(persister.head(), Revision(2));
|
||||
// a persister that opens the storage starts from its head
|
||||
assert_eq!(open(&storage).0.head(), Revision(2));
|
||||
assert_eq!(storage.batches.borrow()[1].expect_head, Revision(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_tab_that_is_behind_cannot_write() {
|
||||
// two tabs open the same file; one edits a point, the other renames the file
|
||||
let storage = Recording::default();
|
||||
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
|
||||
let ((mut a, mut snap_a), (mut b, mut snap_b)) = (open(&storage), open(&storage));
|
||||
let id = snap_a.order[0];
|
||||
|
||||
let mut file = (*snap_a.files[&id]).clone();
|
||||
file.trk[0].trkseg[0].update(0, |p| p.ele += 100.0);
|
||||
snap_a.files.insert(id, Rc::new(file));
|
||||
block_on(a.save(&storage, snap_a.clone())).unwrap();
|
||||
let stored = block_on(storage.load()).unwrap();
|
||||
|
||||
let mut file = (*snap_b.files[&id]).clone();
|
||||
file.info.name = "renamed by B".into();
|
||||
snap_b.files.insert(id, Rc::new(file));
|
||||
assert_eq!(
|
||||
block_on(b.save(&storage, snap_b)),
|
||||
Err(CommitError::Stale { head: Revision(2) })
|
||||
);
|
||||
// nothing was written, the file is still readable, and the persister did not move
|
||||
assert_eq!(block_on(storage.load()).unwrap(), stored);
|
||||
assert_eq!(b.head(), Revision(1));
|
||||
let (_, reader) = open(&storage);
|
||||
assert_eq!(reader.files.len(), 1);
|
||||
assert_eq!(reader.files[&id].info.name, snap_a.files[&id].info.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_tab_that_is_behind_reads_the_storage_again_and_writes_again() {
|
||||
let storage = Recording::default();
|
||||
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
|
||||
let ((mut a, mut snap_a), (mut b, snap_b)) = (open(&storage), open(&storage));
|
||||
let id = snap_a.order[0];
|
||||
assert!(!block_on(b.is_behind(&storage)).unwrap());
|
||||
|
||||
let mut file = (*snap_a.files[&id]).clone();
|
||||
file.info.name = "renamed by A".into();
|
||||
snap_a.files.insert(id, Rc::new(file));
|
||||
block_on(a.save(&storage, snap_a)).unwrap();
|
||||
assert!(block_on(b.is_behind(&storage)).unwrap());
|
||||
assert!(!block_on(a.is_behind(&storage)).unwrap());
|
||||
|
||||
let restored = block_on(b.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files[0].info.name, "renamed by A");
|
||||
assert!(!block_on(b.is_behind(&storage)).unwrap());
|
||||
let mut snapshot = snap_b;
|
||||
snapshot.files.insert(id, restored.files[0].clone());
|
||||
assert!(block_on(b.save(&storage, snapshot)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_shared_by_files_still_are_after_the_names_are_read() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&["data/with_surface.gpx"]);
|
||||
let original = snapshot.files[&snapshot.order[0]].clone();
|
||||
let mut copy = (*original).clone();
|
||||
copy.id = FileId::default();
|
||||
snapshot.order.push(copy.id);
|
||||
snapshot.files.insert(copy.id, Rc::new(copy));
|
||||
block_on(Persister::default().save(&storage, snapshot)).unwrap();
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files.len(), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
&restored.files[0].trk[0].trkseg[0].chunks()[0],
|
||||
&restored.files[1].trk[0].trkseg[0].chunks()[0]
|
||||
));
|
||||
assert!(!restored.categories.surface.names().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_but_the_files_and_the_order_is_stored() {
|
||||
let storage = Recording::default();
|
||||
block_on(Persister::default().save(&storage, snapshot_of(&FILES))).unwrap();
|
||||
// the categories are in the chunks: a storage with no more than these is complete
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data.files.len(), FILES.len());
|
||||
assert!(data.order.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_engine_starts_from_what_is_restored() {
|
||||
let storage = Recording::default();
|
||||
let mut engine = Engine::default();
|
||||
for path in FILES {
|
||||
let load = |engine: &mut Engine, path: &str| {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
||||
engine
|
||||
.execute(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}))));
|
||||
})))
|
||||
.unwrap()
|
||||
.changed
|
||||
};
|
||||
for path in FILES {
|
||||
assert!(load(&mut engine, path));
|
||||
}
|
||||
let snapshot = engine.snapshot();
|
||||
assert_eq!(snapshot.files.len(), FILES.len());
|
||||
@@ -550,25 +737,20 @@ mod tests {
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine.restore(restored);
|
||||
let outcome = engine.restore(restored);
|
||||
assert_eq!(engine.snapshot().order, snapshot.order);
|
||||
let diff = engine.last_diff().unwrap();
|
||||
assert_eq!(diff.added.len(), FILES.len());
|
||||
assert!(engine.order_changed());
|
||||
assert_eq!(outcome.diff.unwrap().added.len(), FILES.len());
|
||||
assert!(outcome.order_changed);
|
||||
for id in &snapshot.order {
|
||||
assert!(engine.file_structure(id).is_some());
|
||||
}
|
||||
|
||||
// the files that were restored are not undone
|
||||
assert!(!engine.can_undo());
|
||||
assert!(!engine.run(Action::Undo));
|
||||
let data = std::fs::read(FILES[0]).unwrap();
|
||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}))));
|
||||
assert!(!engine.execute(Action::Undo).unwrap().changed);
|
||||
assert!(load(&mut engine, FILES[0]));
|
||||
assert!(engine.can_undo());
|
||||
assert!(engine.run(Action::Undo));
|
||||
assert!(engine.execute(Action::Undo).unwrap().changed);
|
||||
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
|
||||
+57
-26
@@ -2,9 +2,13 @@
|
||||
//!
|
||||
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
|
||||
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
||||
//! ones that the engine had.
|
||||
//! ones that the engine had. The head is a number in the meta store, which the transaction of a
|
||||
//! batch checks before it writes anything (so that a tab that is behind cannot write over what
|
||||
//! another tab saved).
|
||||
|
||||
use gpx_engine::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
||||
use gpx_engine::{
|
||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||
};
|
||||
use idb::TransactionMode;
|
||||
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
||||
use js_sys::Uint8Array;
|
||||
@@ -14,20 +18,24 @@ use wasm_bindgen::JsValue;
|
||||
const FILES: &str = "files";
|
||||
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
|
||||
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
|
||||
/// The order of the files and the categories of the trackpoints, under their own names.
|
||||
/// The order of the files and the head, under their own names.
|
||||
const META: &str = "meta";
|
||||
const SETTINGS: &str = "settings";
|
||||
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
|
||||
/// The stores of the files, which a commit writes to together with the head.
|
||||
const COMMIT_STORES: [&str; 4] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META];
|
||||
|
||||
const ORDER_KEY: &str = "order";
|
||||
const CATEGORIES_KEY: &str = "categories";
|
||||
const HEAD_KEY: &str = "head";
|
||||
/// Version of the database.
|
||||
const VERSION: u32 = 1;
|
||||
|
||||
pub struct IdbStorage {
|
||||
database: Database,
|
||||
}
|
||||
|
||||
fn error(error: impl std::fmt::Display) -> StorageError {
|
||||
StorageError(error.to_string())
|
||||
StorageError::Failed(error.to_string())
|
||||
}
|
||||
|
||||
/// The object store of a kind of chunks.
|
||||
@@ -60,7 +68,7 @@ impl IdbStorage {
|
||||
/// Opens the database, which is created if it does not exist.
|
||||
pub async fn open(name: &str) -> Result<Self, StorageError> {
|
||||
let factory = Factory::new().map_err(error)?;
|
||||
let mut request = factory.open(name, Some(1)).map_err(error)?;
|
||||
let mut request = factory.open(name, Some(VERSION)).map_err(error)?;
|
||||
request.on_upgrade_needed(|event| {
|
||||
if let Ok(database) = event.database() {
|
||||
for store in STORES {
|
||||
@@ -72,6 +80,18 @@ impl IdbStorage {
|
||||
Ok(Self { database })
|
||||
}
|
||||
|
||||
/// The head, 0 for a database that was never written to.
|
||||
async fn read_head(meta: &ObjectStore) -> Result<Revision, StorageError> {
|
||||
let head = meta
|
||||
.get(JsValue::from_str(HEAD_KEY))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
Ok(Revision(
|
||||
head.and_then(|head| head.as_f64()).unwrap_or_default() as u64,
|
||||
))
|
||||
}
|
||||
|
||||
fn transaction(
|
||||
&self,
|
||||
stores: &[&str],
|
||||
@@ -88,9 +108,19 @@ impl IdbStorage {
|
||||
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
|
||||
}
|
||||
|
||||
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
|
||||
let files = transaction.object_store(FILES).map_err(error)?;
|
||||
/// Writes a batch in a transaction, if the head is the one it is based on. Gives the new head.
|
||||
async fn write(
|
||||
&self,
|
||||
transaction: &Transaction,
|
||||
batch: &Batch,
|
||||
) -> Result<Revision, CommitError> {
|
||||
let meta = transaction.object_store(META).map_err(error)?;
|
||||
let head = Self::read_head(&meta).await?;
|
||||
if head != batch.expect_head {
|
||||
return Err(CommitError::Stale { head });
|
||||
}
|
||||
let new_head = head.next();
|
||||
let files = transaction.object_store(FILES).map_err(error)?;
|
||||
|
||||
// the requests are all made before waiting for them
|
||||
let mut puts = vec![];
|
||||
@@ -111,13 +141,13 @@ impl IdbStorage {
|
||||
.map_err(error)?,
|
||||
);
|
||||
}
|
||||
if let Some(categories) = &batch.categories {
|
||||
let value = bytes(categories);
|
||||
puts.push(
|
||||
meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY)))
|
||||
meta.put(
|
||||
&JsValue::from_f64(new_head.0 as f64),
|
||||
Some(&JsValue::from_str(HEAD_KEY)),
|
||||
)
|
||||
.map_err(error)?,
|
||||
);
|
||||
}
|
||||
for FileId(id) in &batch.delete_files {
|
||||
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
||||
}
|
||||
@@ -133,7 +163,7 @@ impl IdbStorage {
|
||||
for delete in deletes {
|
||||
delete.await.map_err(error)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(new_head)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,11 +183,7 @@ impl Storage for IdbStorage {
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
let categories = meta
|
||||
.get(JsValue::from_str(CATEGORIES_KEY))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
let head = Self::read_head(&meta).await?;
|
||||
transaction.await.map_err(error)?;
|
||||
|
||||
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
|
||||
@@ -181,19 +207,24 @@ impl Storage for IdbStorage {
|
||||
)
|
||||
.collect(),
|
||||
order: order.as_ref().map(to_vec),
|
||||
categories: categories.as_ref().map(to_vec),
|
||||
head,
|
||||
})
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
let transaction = self.transaction(
|
||||
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
|
||||
TransactionMode::ReadWrite,
|
||||
)?;
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
let transaction = self.transaction(&[META], TransactionMode::ReadOnly)?;
|
||||
let meta = transaction.object_store(META).map_err(error)?;
|
||||
let head = Self::read_head(&meta).await?;
|
||||
transaction.await.map_err(error)?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let transaction = self.transaction(&COMMIT_STORES, TransactionMode::ReadWrite)?;
|
||||
match self.write(&transaction, batch).await {
|
||||
Ok(()) => {
|
||||
Ok(head) => {
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(())
|
||||
Ok(head)
|
||||
}
|
||||
Err(err) => {
|
||||
// nothing of the batch is kept
|
||||
|
||||
+132
-13
@@ -10,13 +10,16 @@ use wasm_bindgen::prelude::*;
|
||||
use crate::convert::{outcome_object, set};
|
||||
use crate::idb::IdbStorage;
|
||||
use crate::session::*;
|
||||
use crate::ts::StorageOpened;
|
||||
use crate::ts::{Outcome, StorageOpened};
|
||||
|
||||
// Storage
|
||||
//
|
||||
// The files are saved in IndexedDB after each change that touches them, without making the change
|
||||
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
|
||||
// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them.
|
||||
// nothing has. When another tab wrote in between, the write is refused (the storage has a newer
|
||||
// head than this tab last saw): the tab reads the storage again and overwrites what it holds, see
|
||||
// `sync_storage`. When what is stored was written by a newer version of the app, the write is
|
||||
// refused too, and the frontend is told, so that it makes the user reload the page. The settings are opaque to the engine, the frontend keeps its own copy of them.
|
||||
|
||||
thread_local! {
|
||||
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||
@@ -24,9 +27,20 @@ thread_local! {
|
||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||
/// Resolve functions of the promises of `flush_storage`.
|
||||
/// Called after the files were written, and when a write was refused because they were
|
||||
/// written by someone else since: see `open_storage`.
|
||||
static LISTENERS: RefCell<Option<Listeners>> = const { RefCell::new(None) };
|
||||
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// What the frontend is told: the files were written, a write was refused because another tab
|
||||
/// wrote since, a write was refused because what is stored comes from a newer version.
|
||||
struct Listeners {
|
||||
saved: js_sys::Function,
|
||||
behind: js_sys::Function,
|
||||
newer: js_sys::Function,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
||||
@@ -47,6 +61,7 @@ pub(crate) fn schedule_save() {
|
||||
return;
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let (mut saved, mut behind, mut newer) = (false, false, false);
|
||||
while DIRTY.replace(false) {
|
||||
let Some(storage) = storage() else { break };
|
||||
let Some(snapshot) = with_engine(Engine::snapshot) else {
|
||||
@@ -57,38 +72,86 @@ pub(crate) fn schedule_save() {
|
||||
.unwrap_or_default();
|
||||
let result = persister.save(&*storage, snapshot).await;
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
if let Err(error) = result {
|
||||
match result {
|
||||
Ok(()) => saved = true,
|
||||
// nothing was written, so that the files of the other tab are not damaged: this
|
||||
// tab has to read them, which replaces what it did
|
||||
Err(engine::CommitError::Stale { .. }) => {
|
||||
DIRTY.set(false);
|
||||
behind = true;
|
||||
}
|
||||
// nothing is written, so that the newer version finds its data again
|
||||
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
|
||||
DIRTY.set(false);
|
||||
newer = true;
|
||||
}
|
||||
// what failed is written with the next change
|
||||
console_error(&error.to_string());
|
||||
Err(error) => console_error(&error.to_string()),
|
||||
}
|
||||
}
|
||||
SAVING.set(false);
|
||||
if saved {
|
||||
notify(|listeners| &listeners.saved);
|
||||
}
|
||||
if behind {
|
||||
notify(|listeners| &listeners.behind);
|
||||
}
|
||||
if newer {
|
||||
notify(|listeners| &listeners.newer);
|
||||
}
|
||||
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn notify(pick: impl FnOnce(&Listeners) -> &js_sys::Function) {
|
||||
let callback = LISTENERS.with(|l| l.borrow().as_ref().map(|l| pick(l).clone()));
|
||||
if let Some(callback) = callback {
|
||||
let _ = callback.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
|
||||
/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the
|
||||
/// outcome of putting the files in. The changes of the files that follow are saved, unless some of
|
||||
/// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that
|
||||
/// the newer version finds its data again.
|
||||
/// what is stored was written by a newer version of the app: then nothing is read and nothing is
|
||||
/// ever written, so that the newer version finds its data again.
|
||||
///
|
||||
/// `on_saved` is called after the files were written (to tell the other tabs), `on_behind` when
|
||||
/// a write was refused because another tab wrote since (call `sync_storage` then), and `on_newer`
|
||||
/// when what is stored comes from a newer version (now, or when it is read again): the page has to
|
||||
/// be reloaded, the changes are not kept.
|
||||
#[wasm_bindgen]
|
||||
pub async fn open_storage(name: &str) -> Result<StorageOpened, JsValue> {
|
||||
pub async fn open_storage(
|
||||
name: &str,
|
||||
on_saved: js_sys::Function,
|
||||
on_behind: js_sys::Function,
|
||||
on_newer: js_sys::Function,
|
||||
) -> Result<StorageOpened, JsValue> {
|
||||
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
|
||||
if !empty {
|
||||
return Err(JsValue::from_str("the engine already has files"));
|
||||
}
|
||||
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
|
||||
let mut persister = Persister::default();
|
||||
let restored = persister.restore(&storage).await.map_err(to_js_error)?;
|
||||
let (read_only, unreadable) = (restored.read_only, restored.unreadable);
|
||||
let outcome = restore_engine(persister.restore(&storage).await).map_err(to_js_error)?;
|
||||
let newer = outcome.is_none();
|
||||
let outcome = outcome.unwrap_or_else(|| outcome_object(&engine::Outcome::default(), None));
|
||||
let settings = storage.load_settings().await.map_err(to_js_error)?;
|
||||
|
||||
let outcome = with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default();
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
|
||||
LISTENERS.with(|slot| {
|
||||
*slot.borrow_mut() = Some(Listeners {
|
||||
saved: on_saved,
|
||||
behind: on_behind,
|
||||
newer: on_newer,
|
||||
})
|
||||
});
|
||||
if newer {
|
||||
notify(|listeners| &listeners.newer);
|
||||
}
|
||||
|
||||
let settings_object = Object::new();
|
||||
for (key, value) in settings {
|
||||
@@ -96,12 +159,28 @@ pub async fn open_storage(name: &str) -> Result<StorageOpened, JsValue> {
|
||||
}
|
||||
let opened = Object::new();
|
||||
set(&opened, "settings", settings_object);
|
||||
set(&opened, "outcome", outcome_object(&outcome, None));
|
||||
set(&opened, "readOnly", read_only);
|
||||
set(&opened, "unreadable", unreadable as f64);
|
||||
set(&opened, "outcome", outcome);
|
||||
Ok(opened.unchecked_into())
|
||||
}
|
||||
|
||||
/// Overwrites the files of the engine with what was read, and tells what that did. If what is
|
||||
/// stored comes from a newer version of the app, the files are left as they are and `None` is
|
||||
/// returned: the persister does not write anymore (see `Persister::save`).
|
||||
fn restore_engine(
|
||||
read: Result<engine::Restored, engine::StorageError>,
|
||||
) -> Result<Option<Outcome>, engine::StorageError> {
|
||||
match read {
|
||||
Ok(restored) => Ok(Some(
|
||||
match with_engine_mut(|engine| engine.restore(restored)) {
|
||||
Some(outcome) => outcome_object(&outcome, None),
|
||||
None => outcome_object(&engine::Outcome::default(), None),
|
||||
},
|
||||
)),
|
||||
Err(engine::StorageError::Newer) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
||||
JsValue::from_str(&error.to_string())
|
||||
}
|
||||
@@ -119,6 +198,46 @@ pub async fn flush_storage() -> Result<(), JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If another tab wrote the files since this one last did, reads them again and overwrites the
|
||||
/// files of the engine with them (what was changed here and not saved is lost, and the history
|
||||
/// starts over). Returns what that changed, to apply like the outcome of any other call.
|
||||
#[wasm_bindgen]
|
||||
pub async fn sync_storage() -> Result<Outcome, JsValue> {
|
||||
// the writes are done first, and none starts before this is done
|
||||
while SAVING.get() {
|
||||
flush_storage().await?;
|
||||
}
|
||||
let (Some(storage), Some(mut persister)) = (
|
||||
storage(),
|
||||
PERSISTER.with(|persister| persister.borrow_mut().take()),
|
||||
) else {
|
||||
return Ok(outcome_object(&engine::Outcome::default(), None));
|
||||
};
|
||||
SAVING.set(true);
|
||||
let result = async {
|
||||
if !persister.is_behind(&*storage).await? {
|
||||
return Ok(outcome_object(&engine::Outcome::default(), None));
|
||||
}
|
||||
let outcome = restore_engine(persister.restore(&*storage).await)?;
|
||||
DIRTY.set(false);
|
||||
if outcome.is_none() {
|
||||
notify(|listeners| &listeners.newer);
|
||||
}
|
||||
Ok(outcome.unwrap_or_else(|| outcome_object(&engine::Outcome::default(), None)))
|
||||
}
|
||||
.await;
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
SAVING.set(false);
|
||||
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
if DIRTY.get() {
|
||||
// edited while reading, and nothing was overwritten
|
||||
schedule_save();
|
||||
}
|
||||
result.map_err(to_js_error)
|
||||
}
|
||||
|
||||
/// Saves a setting, `value` being its JSON.
|
||||
#[wasm_bindgen]
|
||||
pub fn set_setting(key: String, value: String) {
|
||||
|
||||
@@ -56,8 +56,10 @@ pub fn selection_statistics() -> GlobalStatistics {
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
||||
/// to keep.
|
||||
/// to keep, and with `as_route` that the segments are written as routes (`rte`) instead of tracks:
|
||||
/// the anchors are the route points, and the trackpoints between them the points of their path.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_file(
|
||||
file_id: &str,
|
||||
time: bool,
|
||||
@@ -66,6 +68,7 @@ pub fn export_file(
|
||||
atemp: bool,
|
||||
power: bool,
|
||||
osm: bool,
|
||||
as_route: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let id = parse_file_id(file_id)?;
|
||||
let options = engine::ExportOptions {
|
||||
@@ -75,6 +78,7 @@ pub fn export_file(
|
||||
atemp,
|
||||
power,
|
||||
osm,
|
||||
as_route,
|
||||
};
|
||||
with_engine(|e| e.export(&id, options)).flatten()
|
||||
}
|
||||
|
||||
@@ -21,14 +21,15 @@ pub(crate) fn fail(message: impl AsRef<str>) -> Outcome {
|
||||
|
||||
/// Runs an action and tells what it did, see `Outcome` in `ts`.
|
||||
pub(crate) fn execute(action: Action) -> Outcome {
|
||||
let Some(outcome) = with_engine_mut(|engine| engine.execute(action)) else {
|
||||
return fail("the engine is not started");
|
||||
let outcome = match with_engine_mut(|engine| engine.execute(action)) {
|
||||
Some(Ok(outcome)) => outcome,
|
||||
Some(Err(error)) => return fail(error.to_string()),
|
||||
None => return fail("the engine is not started"),
|
||||
};
|
||||
if outcome.changed && (outcome.diff.is_some() || outcome.order_changed) {
|
||||
schedule_save();
|
||||
}
|
||||
let error = outcome.error.as_ref().map(ToString::to_string);
|
||||
outcome_object(&outcome, error.as_deref())
|
||||
outcome_object(&outcome, None)
|
||||
}
|
||||
|
||||
pub(crate) fn edit(command: Command) -> Outcome {
|
||||
|
||||
@@ -112,7 +112,10 @@ fn test_unknown_file_has_no_structure() {
|
||||
assert!(crate::queries::files::file_structure(&id).is_undefined());
|
||||
assert!(crate::queries::files::file_structure("not an id").is_undefined());
|
||||
assert!(crate::queries::files::file_statistics(&id).is_undefined());
|
||||
assert!(crate::queries::files::export_file(&id, true, true, true, true, true, true).is_none());
|
||||
assert!(
|
||||
crate::queries::files::export_file(&id, true, true, true, true, true, true, false)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::queries::coordinates::segment_coordinates(&id).length(),
|
||||
0
|
||||
@@ -265,7 +268,8 @@ fn test_export_and_exportable_data() {
|
||||
let ids = order();
|
||||
|
||||
let bytes =
|
||||
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true).unwrap();
|
||||
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true, false)
|
||||
.unwrap();
|
||||
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
|
||||
|
||||
// the time is the first bit
|
||||
@@ -284,6 +288,23 @@ fn test_export_and_exportable_data() {
|
||||
assert_eq!(crate::queries::files::exportable_data(&[1, 2, 3]), 0);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_export_as_route() {
|
||||
started_with(&[(SIMPLE, "plain")]);
|
||||
let id = order().remove(0);
|
||||
let text = |as_route| {
|
||||
let bytes =
|
||||
crate::queries::files::export_file(&id, true, true, true, true, true, true, as_route)
|
||||
.unwrap();
|
||||
String::from_utf8(bytes).unwrap()
|
||||
};
|
||||
let tracks = text(false);
|
||||
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
|
||||
let routes = text(true);
|
||||
assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
|
||||
assert!(routes.contains("<rtept"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_select_and_reorder_files() {
|
||||
started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]);
|
||||
|
||||
@@ -97,10 +97,6 @@ export interface StorageOpened {
|
||||
settings: Record<string, string>;
|
||||
/** The files that were restored are the added ones. */
|
||||
outcome: Outcome;
|
||||
/** Some of what is stored was written by a newer version: nothing is saved. */
|
||||
readOnly: boolean;
|
||||
/** How many stored files could not be read, and are dropped at the next save. */
|
||||
unreadable: number;
|
||||
}
|
||||
export interface FileStructure {
|
||||
id: string;
|
||||
|
||||
Generated
+48
-519
@@ -29,7 +29,7 @@
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.6.0",
|
||||
"@sveltejs/kit": "^2.21.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/events": "^3.0.3",
|
||||
@@ -68,7 +68,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^7.3.7",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
}
|
||||
},
|
||||
@@ -2313,43 +2313,42 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
|
||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
|
||||
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||
"debug": "^4.4.1",
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.17",
|
||||
"vitefu": "^1.0.6"
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.0",
|
||||
"vitefu": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||
"node": "^20.19 || ^22.12 || >=24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.3.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
|
||||
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
|
||||
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.7"
|
||||
"obug": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||
"node": "^20.19 || ^22.12 || >=24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.3.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
@@ -4821,6 +4820,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"dev": true,
|
||||
@@ -6068,24 +6081,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "7.3.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
|
||||
"integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
@@ -6094,14 +6107,14 @@
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "*",
|
||||
"less": "^4.0.0",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
@@ -6167,490 +6180,6 @@
|
||||
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.3",
|
||||
"dev": true,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node --import tsx --test \"src/**/*.test.ts\"",
|
||||
"test": "node --import tsx --import ./src/test/register.mjs --experimental-wasm-modules --disable-warning=ExperimentalWarning --test \"src/**/*.test.ts\"",
|
||||
"generate:example": "node --import tsx src/lib/scripts/example-statistics.ts",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
|
||||
@@ -22,7 +22,7 @@
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.6.0",
|
||||
"@sveltejs/kit": "^2.21.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/events": "^3.0.3",
|
||||
@@ -61,7 +61,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^7.3.7",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base } from '$app/paths';
|
||||
import { languages } from '$lib/languages';
|
||||
import { languages } from '$lib/i18n/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultFileName } from './default-file-name';
|
||||
import { defaultFileName } from '$lib/actions/default-file-name';
|
||||
|
||||
describe('default file name', () => {
|
||||
it('is the default name when there is no other file with it', () => {
|
||||
File renamed without changes.
@@ -1,9 +1,9 @@
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { boundsManager } from './bounds';
|
||||
import { boundsManager } from '$lib/state/bounds';
|
||||
import { engine } from '$lib/engine';
|
||||
import { defaultFileName } from '$lib/default-file-name';
|
||||
import { defaultFileName } from '$lib/actions/default-file-name';
|
||||
|
||||
/** The name of a new file: the translated default name, numbered if other files have it. */
|
||||
export function newFileName() {
|
||||
@@ -2,7 +2,7 @@
|
||||
import docsearch from '@docsearch/js';
|
||||
import '@docsearch/css';
|
||||
import { onMount } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import type { SelectionStatistics } from '$lib/engine';
|
||||
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { settings } from '$lib/state/settings';
|
||||
|
||||
const { velocityUnits } = settings;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { CircleQuestionMark } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { languages } from '$lib/languages';
|
||||
import { languages } from '$lib/i18n/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Languages } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" value={i18n.lang}>
|
||||
|
||||
@@ -51,19 +51,19 @@
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
|
||||
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
||||
import { defaultOverlays } from '$lib/assets/layers';
|
||||
import { defaultOverlays } from '$lib/data/layers';
|
||||
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
||||
import Export from '$lib/components/export/Export.svelte';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { languages } from '$lib/languages';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { languages } from '$lib/i18n/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { createFile, loadFiles, triggerFileInput } from '$lib/logic/file-actions';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { createFile, loadFiles, triggerFileInput } from '$lib/actions/file-actions';
|
||||
import { engine } from '$lib/engine';
|
||||
import { allHidden } from '$lib/all-hidden';
|
||||
import { selectionSize } from '$lib/selection-helpers';
|
||||
import { boundsManager } from '$lib/logic/bounds';
|
||||
import { allHidden } from '$lib/engine/all-hidden';
|
||||
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||
import { boundsManager } from '$lib/state/bounds';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Moon, Sun } from '@lucide/svelte';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { isMac, isSafari } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||
|
||||
let {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS,
|
||||
} from '$lib/units';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
} from '$lib/units/units';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
|
||||
let {
|
||||
value,
|
||||
|
||||
@@ -16,27 +16,6 @@ import {
|
||||
} from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export const guides: Record<string, string[]> = {
|
||||
'getting-started': [],
|
||||
menu: ['file', 'edit', 'view', 'settings'],
|
||||
'files-and-stats': [],
|
||||
toolbar: [
|
||||
'routing',
|
||||
'poi',
|
||||
'scissors',
|
||||
'time',
|
||||
'merge',
|
||||
'extract',
|
||||
'elevation',
|
||||
'minify',
|
||||
'clean',
|
||||
],
|
||||
'map-controls': [],
|
||||
gpx: [],
|
||||
integration: [],
|
||||
faq: [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | Component<IconProps>> = {
|
||||
'getting-started': '🚀',
|
||||
menu: '📂 ⚙️',
|
||||
@@ -61,64 +40,4 @@ export const guideIcons: Record<string, string | Component<IconProps>> = {
|
||||
faq: '🔮',
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
let subguides = currentGuide.split('/');
|
||||
|
||||
if (subguides.length === 1) {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(currentGuide);
|
||||
if (index === 0) {
|
||||
return undefined;
|
||||
}
|
||||
let previousGuide = keys[index - 1];
|
||||
if (previousGuide === undefined) {
|
||||
return undefined;
|
||||
} else if (guides[previousGuide].length === 0) {
|
||||
return previousGuide;
|
||||
} else {
|
||||
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
|
||||
}
|
||||
} else {
|
||||
if (guides.hasOwnProperty(subguides[0])) {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex > 0) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
|
||||
} else {
|
||||
return subguides[0];
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextGuide(currentGuide: string): string | undefined {
|
||||
let subguides = currentGuide.split('/');
|
||||
|
||||
if (subguides.length === 1) {
|
||||
if (guides.hasOwnProperty(currentGuide)) {
|
||||
if (guides[currentGuide].length === 0) {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(currentGuide);
|
||||
return keys[index + 1];
|
||||
} else {
|
||||
return `${currentGuide}/${guides[currentGuide][0]}`;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (guides.hasOwnProperty(subguides[0])) {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex < guides[subguides[0]].length - 1) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
|
||||
} else {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(subguides[0]);
|
||||
return keys[index + 1];
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
export { guides, getNextGuide, getPreviousGuide } from './guides';
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { describe, it } from 'node:test';
|
||||
import { languages } from '$lib/i18n/languages';
|
||||
import { getNextGuide, getPreviousGuide, guides } from './guides';
|
||||
|
||||
/** Every page of the help in reading order, as the links at the bottom of the pages follow them. */
|
||||
const pages = Object.entries(guides).flatMap(([guide, subguides]) => [
|
||||
guide,
|
||||
...subguides.map((subguide) => `${guide}/${subguide}`),
|
||||
]);
|
||||
|
||||
describe('guides', () => {
|
||||
it('go from one page to the next, subguides included', () => {
|
||||
assert.equal(getNextGuide('getting-started'), 'menu');
|
||||
assert.equal(getNextGuide('menu'), 'menu/file');
|
||||
assert.equal(getNextGuide('menu/file'), 'menu/edit');
|
||||
assert.equal(getNextGuide('menu/settings'), 'files-and-stats');
|
||||
assert.equal(getNextGuide('toolbar/clean'), 'map-controls');
|
||||
assert.equal(getNextGuide('integration'), 'faq');
|
||||
});
|
||||
|
||||
it('go from one page to the previous one, subguides included', () => {
|
||||
assert.equal(getPreviousGuide('menu'), 'getting-started');
|
||||
assert.equal(getPreviousGuide('menu/file'), 'menu');
|
||||
assert.equal(getPreviousGuide('menu/edit'), 'menu/file');
|
||||
assert.equal(getPreviousGuide('files-and-stats'), 'menu/settings');
|
||||
assert.equal(getPreviousGuide('map-controls'), 'toolbar/clean');
|
||||
assert.equal(getPreviousGuide('faq'), 'integration');
|
||||
});
|
||||
|
||||
it('stop at the ends', () => {
|
||||
assert.equal(getPreviousGuide('getting-started'), undefined);
|
||||
assert.equal(getNextGuide('faq'), undefined);
|
||||
});
|
||||
|
||||
it('know nothing of pages that do not exist', () => {
|
||||
assert.equal(getNextGuide('nope'), undefined);
|
||||
assert.equal(getNextGuide('nope/sub'), undefined);
|
||||
assert.equal(getPreviousGuide('nope'), undefined);
|
||||
assert.equal(getPreviousGuide('nope/sub'), undefined);
|
||||
});
|
||||
|
||||
it('walk through every page in both directions', () => {
|
||||
const forward = [pages[0]];
|
||||
for (let next = getNextGuide(pages[0]); next; next = getNextGuide(next)) {
|
||||
forward.push(next);
|
||||
}
|
||||
assert.deepEqual(forward, pages);
|
||||
|
||||
const backward = [pages.at(-1)!];
|
||||
for (
|
||||
let previous = getPreviousGuide(pages.at(-1)!);
|
||||
previous;
|
||||
previous = getPreviousGuide(previous)
|
||||
) {
|
||||
backward.push(previous);
|
||||
}
|
||||
assert.deepEqual(backward.reverse(), pages);
|
||||
});
|
||||
|
||||
it('are written in every language of the site', () => {
|
||||
const root = new URL('../../docs/', import.meta.url);
|
||||
const missing: string[] = [];
|
||||
for (const language of Object.keys(languages)) {
|
||||
for (const page of pages) {
|
||||
if (!existsSync(new URL(`${language}/${page}.mdx`, root))) {
|
||||
missing.push(`${language}/${page}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
it('have a translation of the interface for every language of the site', () => {
|
||||
const root = new URL('../../../locales/', import.meta.url);
|
||||
for (const language of Object.keys(languages)) {
|
||||
assert.ok(existsSync(new URL(`${language}.json`, root)), language);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// The guides of the help section, and how they follow one another. The icons are in `docs.ts`.
|
||||
|
||||
export const guides: Record<string, string[]> = {
|
||||
'getting-started': [],
|
||||
menu: ['file', 'edit', 'view', 'settings'],
|
||||
'files-and-stats': [],
|
||||
toolbar: [
|
||||
'routing',
|
||||
'poi',
|
||||
'scissors',
|
||||
'time',
|
||||
'merge',
|
||||
'extract',
|
||||
'elevation',
|
||||
'minify',
|
||||
'clean',
|
||||
],
|
||||
'map-controls': [],
|
||||
gpx: [],
|
||||
integration: [],
|
||||
faq: [],
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
const subguides = currentGuide.split('/');
|
||||
|
||||
if (subguides.length === 1) {
|
||||
const keys = Object.keys(guides);
|
||||
const index = keys.indexOf(currentGuide);
|
||||
if (index === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const previousGuide = keys[index - 1];
|
||||
if (previousGuide === undefined) {
|
||||
return undefined;
|
||||
} else if (guides[previousGuide].length === 0) {
|
||||
return previousGuide;
|
||||
} else {
|
||||
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
|
||||
}
|
||||
} else {
|
||||
if (guides.hasOwnProperty(subguides[0])) {
|
||||
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex > 0) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
|
||||
} else {
|
||||
return subguides[0];
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextGuide(currentGuide: string): string | undefined {
|
||||
const subguides = currentGuide.split('/');
|
||||
|
||||
if (subguides.length === 1) {
|
||||
if (guides.hasOwnProperty(currentGuide)) {
|
||||
if (guides[currentGuide].length === 0) {
|
||||
const keys = Object.keys(guides);
|
||||
const index = keys.indexOf(currentGuide);
|
||||
return keys[index + 1];
|
||||
} else {
|
||||
return `${currentGuide}/${guides[currentGuide][0]}`;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (guides.hasOwnProperty(subguides[0])) {
|
||||
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex < guides[subguides[0]].length - 1) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
|
||||
} else {
|
||||
const keys = Object.keys(guides);
|
||||
const index = keys.indexOf(subguides[0]);
|
||||
return keys[index + 1];
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,11 @@
|
||||
Construction,
|
||||
} from '@lucide/svelte';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import type { Coordinates } from '$lib/geo';
|
||||
import type { Coordinates } from '$lib/geo/coordinates';
|
||||
import type { SelectionStatistics } from '$lib/engine';
|
||||
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||
|
||||
const { velocityUnits } = settings;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import {
|
||||
getCadenceWithUnits,
|
||||
getConvertedDistance,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getPowerWithUnits,
|
||||
getTemperatureWithUnits,
|
||||
getVelocityWithUnits,
|
||||
} from '$lib/units';
|
||||
} from '$lib/units/units';
|
||||
import Chart, {
|
||||
type ChartEvent,
|
||||
type ChartOptions,
|
||||
@@ -21,7 +21,7 @@ import Chart, {
|
||||
type TooltipItem,
|
||||
} from 'chart.js/auto';
|
||||
import { get, type Readable, type Writable } from 'svelte/store';
|
||||
import type { Coordinates } from '$lib/geo';
|
||||
import type { Coordinates } from '$lib/geo/coordinates';
|
||||
import {
|
||||
engine,
|
||||
NO_TIME,
|
||||
@@ -29,10 +29,10 @@ import {
|
||||
type StatisticsMetric,
|
||||
type StatisticsRequest,
|
||||
} from '$lib/engine';
|
||||
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { categoryValues } from '$lib/trackpoint-categories';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||
import { categoryValues } from '$lib/engine/trackpoint-categories';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
|
||||
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
type EmbeddingOptions,
|
||||
} from './embedding';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { loadFiles } from '$lib/logic/file-actions';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { loadFiles } from '$lib/actions/file-actions';
|
||||
import { untrack } from 'svelte';
|
||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
import { engine } from '$lib/engine';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||
|
||||
let {
|
||||
useHash = true,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
Milestone,
|
||||
Video,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
defaultEmbeddingOptions,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
files,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
convertOldEmbeddingOptions,
|
||||
defaultEmbeddingOptions,
|
||||
getCleanedEmbeddingOptions,
|
||||
getFilesFromEmbeddingOptions,
|
||||
getMergedEmbeddingOptions,
|
||||
getURLForGoogleDriveFile,
|
||||
} from './embedding';
|
||||
|
||||
describe('embedding options', () => {
|
||||
it('are the defaults when nothing is given', () => {
|
||||
assert.deepEqual(getMergedEmbeddingOptions({}), defaultEmbeddingOptions);
|
||||
// and a copy of them
|
||||
const merged = getMergedEmbeddingOptions({});
|
||||
merged.elevation.show = false;
|
||||
assert.equal(defaultEmbeddingOptions.elevation.show, true);
|
||||
});
|
||||
|
||||
it('override the defaults, also inside the groups of options', () => {
|
||||
const merged = getMergedEmbeddingOptions({
|
||||
basemap: 'osm',
|
||||
distanceUnits: 'imperial',
|
||||
elevation: { fill: 'slope', hr: true },
|
||||
files: ['https://example.com/a.gpx'],
|
||||
});
|
||||
assert.equal(merged.basemap, 'osm');
|
||||
assert.equal(merged.distanceUnits, 'imperial');
|
||||
assert.equal(merged.elevation.fill, 'slope');
|
||||
assert.equal(merged.elevation.hr, true);
|
||||
// the options of a group that are not given stay as they are
|
||||
assert.equal(merged.elevation.height, 170);
|
||||
assert.equal(merged.elevation.show, true);
|
||||
assert.deepEqual(merged.files, ['https://example.com/a.gpx']);
|
||||
assert.equal(merged.velocityUnits, 'speed');
|
||||
});
|
||||
|
||||
it('take a list or a null as a value, not as a group', () => {
|
||||
const merged = getMergedEmbeddingOptions({ ids: ['a', 'b'], key: null });
|
||||
assert.deepEqual(merged.ids, ['a', 'b']);
|
||||
assert.equal(merged.key, null);
|
||||
});
|
||||
|
||||
it('are cleaned of what is a default', () => {
|
||||
const options = getMergedEmbeddingOptions({ basemap: 'osm', elevation: { hr: true } });
|
||||
assert.deepEqual(getCleanedEmbeddingOptions(options), {
|
||||
basemap: 'osm',
|
||||
elevation: { hr: true },
|
||||
});
|
||||
assert.deepEqual(getCleanedEmbeddingOptions(defaultEmbeddingOptions), {});
|
||||
// merging what was cleaned gives the options back
|
||||
assert.deepEqual(getMergedEmbeddingOptions(getCleanedEmbeddingOptions(options)), options);
|
||||
});
|
||||
|
||||
it('are cleaned of the key of the site, and keep the key of the user', () => {
|
||||
assert.deepEqual(
|
||||
getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'test-key' }),
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'mine' }), {
|
||||
key: 'mine',
|
||||
});
|
||||
});
|
||||
|
||||
it('are not changed by cleaning them', () => {
|
||||
const options = getMergedEmbeddingOptions({ elevation: { hr: true } });
|
||||
const before = JSON.stringify(options);
|
||||
getCleanedEmbeddingOptions(options);
|
||||
assert.equal(JSON.stringify(options), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedded files', () => {
|
||||
it('are the urls, then the files of Google Drive', () => {
|
||||
const options = getMergedEmbeddingOptions({
|
||||
files: ['https://example.com/a.gpx'],
|
||||
ids: ['abc', 'def'],
|
||||
});
|
||||
assert.deepEqual(getFilesFromEmbeddingOptions(options), [
|
||||
'https://example.com/a.gpx',
|
||||
getURLForGoogleDriveFile('abc'),
|
||||
getURLForGoogleDriveFile('def'),
|
||||
]);
|
||||
assert.ok(getURLForGoogleDriveFile('abc').includes('/files/abc?alt=media'));
|
||||
assert.deepEqual(getFilesFromEmbeddingOptions(getMergedEmbeddingOptions({})), []);
|
||||
});
|
||||
|
||||
it('can use the basemaps of the site except a few', () => {
|
||||
assert.ok(allowedEmbeddingBasemaps.includes('libertyTopo'));
|
||||
assert.ok(allowedEmbeddingBasemaps.includes('osm'));
|
||||
assert.ok(!allowedEmbeddingBasemaps.includes('ordnanceSurvey'));
|
||||
assert.ok(allowedEmbeddingBasemaps.includes(defaultEmbeddingOptions.basemap));
|
||||
});
|
||||
});
|
||||
|
||||
describe('options of the old embedding', () => {
|
||||
const convert = (query: string) => convertOldEmbeddingOptions(new URLSearchParams(query));
|
||||
|
||||
it('are converted to the new ones', () => {
|
||||
assert.deepEqual(convert(''), { key: 'test-key', files: [], ids: [] });
|
||||
assert.deepEqual(
|
||||
convert(
|
||||
'source=otm&imperial&running&distance&direction&slope&state=' +
|
||||
encodeURIComponent(JSON.stringify({ ids: ['a'], urls: ['https://x/y.gpx'] }))
|
||||
),
|
||||
{
|
||||
key: 'test-key',
|
||||
files: ['https://x/y.gpx'],
|
||||
ids: ['a'],
|
||||
basemap: 'openTopoMap',
|
||||
distanceUnits: 'imperial',
|
||||
velocityUnits: 'pace',
|
||||
distanceMarkers: true,
|
||||
directionMarkers: true,
|
||||
elevation: { fill: 'slope' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('convert the names of the sources', () => {
|
||||
assert.equal(convert('source=satellite').basemap, 'libertySatellite');
|
||||
assert.equal(convert('source=otm').basemap, 'openTopoMap');
|
||||
assert.equal(convert('source=ohm').basemap, 'openHikingMap');
|
||||
assert.equal(convert('source=unknown').basemap, undefined);
|
||||
});
|
||||
|
||||
it('ignore a state that is not valid', () => {
|
||||
const empty = { key: 'test-key', files: [], ids: [] };
|
||||
assert.deepEqual(convert('state=%7Bbroken'), empty);
|
||||
assert.deepEqual(convert('state=null'), empty);
|
||||
assert.deepEqual(convert('state=42'), empty);
|
||||
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":"abc","urls":3}')}`), empty);
|
||||
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":["a"]}')}&imperial`), {
|
||||
...empty,
|
||||
ids: ['a'],
|
||||
distanceUnits: 'imperial',
|
||||
});
|
||||
});
|
||||
|
||||
it('can be merged with the defaults', () => {
|
||||
const merged = getMergedEmbeddingOptions(convert('slope&source=satellite'));
|
||||
assert.equal(merged.elevation.fill, 'slope');
|
||||
assert.equal(merged.elevation.height, 170);
|
||||
assert.equal(merged.basemap, 'libertySatellite');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import { basemaps } from '$lib/assets/layers';
|
||||
import { basemaps } from '$lib/data/layers';
|
||||
|
||||
export type EmbeddingOptions = {
|
||||
key: string;
|
||||
@@ -115,11 +115,15 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
ids: [],
|
||||
};
|
||||
if (options.has('state')) {
|
||||
let state = JSON.parse(options.get('state')!);
|
||||
if (state.ids) {
|
||||
// the state comes from the url: it may be anything
|
||||
let state: any = {};
|
||||
try {
|
||||
state = JSON.parse(options.get('state')!) ?? {};
|
||||
} catch {}
|
||||
if (Array.isArray(state.ids)) {
|
||||
newOptions.ids.push(...state.ids);
|
||||
}
|
||||
if (state.urls) {
|
||||
if (Array.isArray(state.urls)) {
|
||||
newOptions.files.push(...state.urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
Route,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine, type ExportOptions } from '$lib/engine';
|
||||
|
||||
const selection = engine.selection;
|
||||
@@ -37,6 +38,7 @@
|
||||
atemp: true,
|
||||
power: true,
|
||||
osm: false,
|
||||
asRoute: false,
|
||||
});
|
||||
// the files that are exported
|
||||
let fileIds: string[] = $derived.by(() => {
|
||||
@@ -61,6 +63,8 @@
|
||||
atemp: !available.atemp,
|
||||
power: !available.power,
|
||||
osm: !available.osm,
|
||||
// always possible
|
||||
asRoute: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -113,6 +117,7 @@
|
||||
atemp: exportOptions.atemp && !hide.atemp,
|
||||
power: exportOptions.power && !hide.power,
|
||||
osm: exportOptions.osm && !hide.osm,
|
||||
asRoute: exportOptions.asRoute,
|
||||
};
|
||||
if (exportState.current === ExportState.SELECTION) {
|
||||
exportSelectedFiles(options);
|
||||
@@ -185,15 +190,20 @@
|
||||
{i18n._('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}">
|
||||
<Checkbox id="export-osm" bind:checked={exportOptions.osm} />
|
||||
<Label for="export-osm" class="flex flex-row items-center gap-1">
|
||||
<Earth size="16" />
|
||||
{i18n._('quantities.osm_extensions')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="export-as-route" bind:checked={exportOptions.asRoute} />
|
||||
<Label for="export-as-route" class="flex flex-row items-center gap-1">
|
||||
<Route size="16" />
|
||||
{i18n._('menu.export_as_route', 'Export as route')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { sanitizeFileName, uniqueFileNames } from './file-names';
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('keeps the names that are fine', () => {
|
||||
for (const name of [
|
||||
'Morning ride',
|
||||
'route-2024_05',
|
||||
'Café du coin',
|
||||
'日本の道',
|
||||
'a.b.c',
|
||||
'🚴 tour',
|
||||
]) {
|
||||
assert.equal(sanitizeFileName(name), name);
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces what is not allowed in a name', () => {
|
||||
assert.equal(sanitizeFileName('a/b'), 'a_b');
|
||||
assert.equal(sanitizeFileName('a\\b'), 'a_b');
|
||||
assert.equal(sanitizeFileName('../../etc/passwd'), '_.._etc_passwd');
|
||||
assert.equal(
|
||||
sanitizeFileName('what? <this> *is* "it": a|b'),
|
||||
'what_ _this_ _is_ _it__ a_b'
|
||||
);
|
||||
assert.equal(sanitizeFileName('tab\there\nnew\u0000line\u007f'), 'tab_here_new_line_');
|
||||
});
|
||||
|
||||
it('has no dots or spaces around, which systems drop or hide', () => {
|
||||
assert.equal(sanitizeFileName(' name '), 'name');
|
||||
assert.equal(sanitizeFileName('.hidden'), 'hidden');
|
||||
assert.equal(sanitizeFileName('name...'), 'name');
|
||||
assert.equal(sanitizeFileName('. . name . .'), 'name');
|
||||
});
|
||||
|
||||
it('is never empty', () => {
|
||||
assert.equal(sanitizeFileName(''), 'file');
|
||||
assert.equal(sanitizeFileName(' '), 'file');
|
||||
assert.equal(sanitizeFileName('...'), 'file');
|
||||
});
|
||||
|
||||
it('does not use the names Windows keeps for devices', () => {
|
||||
assert.equal(sanitizeFileName('con'), '_con');
|
||||
assert.equal(sanitizeFileName('NUL'), '_NUL');
|
||||
assert.equal(sanitizeFileName('com1'), '_com1');
|
||||
assert.equal(sanitizeFileName('LPT9'), '_LPT9');
|
||||
assert.equal(sanitizeFileName('aux.backup'), '_aux.backup');
|
||||
assert.equal(sanitizeFileName('console'), 'console');
|
||||
assert.equal(sanitizeFileName('com10'), 'com10');
|
||||
});
|
||||
|
||||
it('is not too long, whatever the characters', () => {
|
||||
assert.equal(sanitizeFileName('a'.repeat(500)).length, 100);
|
||||
const emoji = sanitizeFileName('🚴'.repeat(500));
|
||||
assert.equal([...emoji].length, 100);
|
||||
// and does not end with what was left of a cut
|
||||
assert.equal(sanitizeFileName('a'.repeat(99) + '. b'), 'a'.repeat(99));
|
||||
});
|
||||
|
||||
it('is the same when sanitized again', () => {
|
||||
for (const name of ['a/b', ' .x ', 'con', '', '🚴'.repeat(200), 'what?']) {
|
||||
const once = sanitizeFileName(name);
|
||||
assert.equal(sanitizeFileName(once), once);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueFileNames', () => {
|
||||
it('keeps different names as they are', () => {
|
||||
assert.deepEqual(uniqueFileNames(['a', 'b', 'c']), ['a', 'b', 'c']);
|
||||
assert.deepEqual(uniqueFileNames([]), []);
|
||||
});
|
||||
|
||||
it('numbers the names that are taken', () => {
|
||||
assert.deepEqual(uniqueFileNames(['ride', 'ride', 'ride']), ['ride', 'ride-1', 'ride-2']);
|
||||
// a name that looks like a number is taken too
|
||||
assert.deepEqual(uniqueFileNames(['ride-1', 'ride', 'ride']), ['ride-1', 'ride', 'ride-2']);
|
||||
});
|
||||
|
||||
it('does not tell upper and lower case apart', () => {
|
||||
assert.deepEqual(uniqueFileNames(['Ride', 'ride', 'RIDE']), ['Ride', 'ride-1', 'RIDE-2']);
|
||||
});
|
||||
|
||||
it('compares the names once they are clean', () => {
|
||||
assert.deepEqual(uniqueFileNames(['a/b', 'a_b', 'a?b']), ['a_b', 'a_b-1', 'a_b-2']);
|
||||
assert.deepEqual(uniqueFileNames(['', '', '...']), ['file', 'file-1', 'file-2']);
|
||||
});
|
||||
|
||||
it('keeps the numbered names short enough', () => {
|
||||
const long = 'x'.repeat(300);
|
||||
const names = uniqueFileNames([long, long, long]);
|
||||
assert.equal(new Set(names).size, 3);
|
||||
assert.ok(names.every((name) => name.length <= 100));
|
||||
assert.ok(names[1].endsWith('-1') && names[2].endsWith('-2'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// The names of the files that are exported come from the files of the user, which can have any
|
||||
// text as a name: they have to be made names that every system accepts.
|
||||
|
||||
/** Characters that no system accepts in a file name, or that mean something in a path. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
|
||||
|
||||
/** Names that Windows keeps for devices, with or without an extension. */
|
||||
const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
|
||||
|
||||
/** Longest name, without the extension, in characters. */
|
||||
const MAX_LENGTH = 100;
|
||||
|
||||
/** A name for a file without its extension, that is allowed on every system. */
|
||||
export function sanitizeFileName(name: string): string {
|
||||
let clean = name
|
||||
.replace(FORBIDDEN, '_')
|
||||
.normalize('NFC')
|
||||
// no dots or spaces around the name: Windows drops them, a leading dot hides the file
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '');
|
||||
clean = [...clean]
|
||||
.slice(0, MAX_LENGTH)
|
||||
.join('')
|
||||
.replace(/[\s.]+$/, '');
|
||||
if (clean === '') {
|
||||
return 'file';
|
||||
}
|
||||
return RESERVED.test(clean.split('.')[0]) ? `_${clean}` : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of files that go in the same folder (the same zip): clean, and different, even where
|
||||
* the file system does not tell upper and lower case apart. A name that is taken gets `-1`, `-2`...
|
||||
*/
|
||||
export function uniqueFileNames(names: string[]): string[] {
|
||||
const taken = new Set<string>();
|
||||
return names.map((name) => {
|
||||
const clean = sanitizeFileName(name);
|
||||
let unique = clean;
|
||||
for (let i = 1; taken.has(unique.toLowerCase()); i++) {
|
||||
// the suffix is part of the length
|
||||
unique = `${[...clean].slice(0, MAX_LENGTH - `-${i}`.length).join('')}-${i}`;
|
||||
}
|
||||
taken.add(unique.toLowerCase());
|
||||
return unique;
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { engine, type ExportOptions } from '$lib/engine';
|
||||
import FileSaver from 'file-saver';
|
||||
import JSZip from 'jszip';
|
||||
import { get } from 'svelte/store';
|
||||
import { sanitizeFileName, uniqueFileNames } from './file-names';
|
||||
|
||||
export enum ExportState {
|
||||
NONE,
|
||||
@@ -38,16 +39,11 @@ async function exportFiles(fileIds: string[], options: ExportOptions) {
|
||||
|
||||
if (files.length === 1) {
|
||||
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
|
||||
FileSaver.saveAs(blob, `${files[0].name}.gpx`);
|
||||
FileSaver.saveAs(blob, `${sanitizeFileName(files[0].name)}.gpx`);
|
||||
} else if (files.length > 1) {
|
||||
const zip = new JSZip();
|
||||
for (const { name, gpx } of files) {
|
||||
let filename = name;
|
||||
for (let i = 1; zip.files[filename + '.gpx']; i++) {
|
||||
filename = name + `-${i}`;
|
||||
}
|
||||
zip.file(filename + '.gpx', gpx);
|
||||
}
|
||||
const names = uniqueFileNames(files.map((file) => file.name));
|
||||
files.forEach(({ gpx }, i) => zip.file(`${names[i]}.gpx`, gpx));
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
FileSaver.saveAs(blob, 'gpx-files.zip');
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectedFileIds } from '$lib/selection-helpers';
|
||||
import { createFile } from '$lib/logic/file-actions';
|
||||
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||
import { createFile } from '$lib/actions/file-actions';
|
||||
|
||||
let {
|
||||
orientation,
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import {
|
||||
elementId,
|
||||
hasSelectionWithin,
|
||||
isSelected,
|
||||
type FileTreeNode,
|
||||
} from '$lib/selection-helpers';
|
||||
} from '$lib/engine/selection-helpers';
|
||||
|
||||
let {
|
||||
fileState,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import type { FileTreeNode } from '$lib/selection-helpers';
|
||||
import type { FileTreeNode } from '$lib/engine/selection-helpers';
|
||||
|
||||
let {
|
||||
fileState,
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
FileStack,
|
||||
} from '@lucide/svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import { allHidden } from '$lib/all-hidden';
|
||||
import { normalizeColor } from '$lib/file-colors';
|
||||
import { isHidden } from '$lib/file-visibility';
|
||||
import { allHidden } from '$lib/engine/all-hidden';
|
||||
import { normalizeColor } from '$lib/engine/file-colors';
|
||||
import { isHidden } from '$lib/engine/file-visibility';
|
||||
import {
|
||||
elementId,
|
||||
isInClipboard,
|
||||
@@ -36,8 +36,8 @@
|
||||
selectedElementIds,
|
||||
selectionSize,
|
||||
type FileTreeNode,
|
||||
} from '$lib/selection-helpers';
|
||||
import { boundsManager } from '$lib/logic/bounds';
|
||||
} from '$lib/engine/selection-helpers';
|
||||
import { boundsManager } from '$lib/state/bounds';
|
||||
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||
|
||||
let {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Save } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import type { FileTreeNode } from '$lib/selection-helpers';
|
||||
import type { FileTreeNode } from '$lib/engine/selection-helpers';
|
||||
|
||||
let {
|
||||
fileState,
|
||||
|
||||
@@ -3,7 +3,7 @@ import Sortable, { type Direction } from 'sortablejs/Sortable';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { tick } from 'svelte';
|
||||
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
||||
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
|
||||
import { isSelected, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||
|
||||
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
|
||||
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Save } from '@lucide/svelte';
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import { normalizeColor } from '$lib/file-colors';
|
||||
import { selectedFileIds, type FileTreeNode } from '$lib/selection-helpers';
|
||||
import { normalizeColor } from '$lib/engine/file-colors';
|
||||
import { selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ClipboardCopy } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
coordinates,
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import type { TrackpointDetails } from '$lib/engine';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
let { trackpoint }: { trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>> } = $props();
|
||||
let {
|
||||
trackpoint,
|
||||
}: {
|
||||
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2">
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import { engine, type WaypointDetails } from '$lib/engine';
|
||||
import { hasSelectionWithin } from '$lib/selection-helpers';
|
||||
import { hasSelectionWithin } from '$lib/engine/selection-helpers';
|
||||
|
||||
let {
|
||||
waypoint,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { engine } from '$lib/engine';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units/units';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/all-hidden';
|
||||
import { allHidden } from '$lib/engine/all-hidden';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
|
||||
@@ -7,18 +7,18 @@ import maplibregl, {
|
||||
} from 'maplibre-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import { closestPointIndex } from '$lib/closest-point';
|
||||
import { closestPointIndex } from '$lib/geo/closest-point';
|
||||
import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||
import { engine, type FileState, type Selection } from '$lib/engine';
|
||||
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection-helpers';
|
||||
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||
import { isSegmentHidden, isWaypointHidden } from '$lib/engine/file-visibility';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||
|
||||
@@ -2,10 +2,10 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/all-hidden';
|
||||
import { allHidden } from '$lib/engine/all-hidden';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
Map,
|
||||
Layers2,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/data/layers';
|
||||
import { onMount } from 'svelte';
|
||||
import { remove } from './utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
|
||||
const {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Layers } from '@lucide/svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
overlayTree,
|
||||
overpassTree,
|
||||
terrainSources,
|
||||
} from '$lib/assets/layers';
|
||||
} from '$lib/data/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { untrack } from 'svelte';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import { type LayerTreeType } from '$lib/data/layers';
|
||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||
|
||||
let {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import { type LayerTreeType } from '$lib/data/layers';
|
||||
import { anySelectedLayer } from './utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
let {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { PencilLine, MapPin } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectionSize } from '$lib/selection-helpers';
|
||||
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||
import { getElevation } from '$lib/utils';
|
||||
|
||||
let {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// The API that browser extensions use to add overlays to the map (`window.gpxstudio`).
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||
import { engine } from '$lib/engine';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
||||
import { isSelected } from './utils';
|
||||
|
||||
const original = { set: engine.setSetting, delete: engine.deleteSetting };
|
||||
const overlayTreeBefore = JSON.stringify(overlayTree);
|
||||
const overlayIdsBefore = Object.keys(overlays).sort();
|
||||
|
||||
const overlay = (id: string, extensionName = 'My extension'): CustomOverlay => ({
|
||||
extensionName,
|
||||
id,
|
||||
name: `Overlay ${id}`,
|
||||
tileUrls: [`https://tiles.example.com/${id}/{z}/{x}/{y}.png`],
|
||||
});
|
||||
|
||||
let api: ExtensionAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
engine.setSetting = () => {};
|
||||
engine.deleteSetting = () => {};
|
||||
settings.connect({});
|
||||
settings.initialize();
|
||||
api = new ExtensionAPI();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// the layers are shared by the whole app: leave them as they were
|
||||
api.filterOverlays([]);
|
||||
for (const extension of ['My extension', 'Other extension']) {
|
||||
delete (overlayTree.overlays as LayerTreeType)[extension];
|
||||
}
|
||||
engine.setSetting = original.set;
|
||||
engine.deleteSetting = original.delete;
|
||||
assert.equal(JSON.stringify(overlayTree), overlayTreeBefore);
|
||||
assert.deepEqual(Object.keys(overlays).sort(), overlayIdsBefore);
|
||||
});
|
||||
|
||||
describe('ExtensionAPI', () => {
|
||||
it('adds an overlay as a layer, in the tree of its extension', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
|
||||
const layer = overlays['extension-one'];
|
||||
assert.ok(layer && typeof layer === 'object');
|
||||
const source = (layer as any).sources['extension-one'];
|
||||
assert.equal(source.type, 'raster');
|
||||
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
|
||||
assert.equal(source.tileSize, 256);
|
||||
assert.equal(source.maxzoom, 22);
|
||||
|
||||
assert.ok((overlayTree.overlays as LayerTreeType)['My extension']);
|
||||
assert.equal(isSelected(overlayTree, 'extension-one'), true);
|
||||
assert.equal(isSelected(get(settings.selectedOverlayTree), 'extension-one'), true);
|
||||
// not shown until the user chooses to
|
||||
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), false);
|
||||
assert.ok(get(api.isLayerFromExtension)('extension-one'));
|
||||
assert.equal(get(api.getLayerName)('extension-one'), 'Overlay one');
|
||||
assert.equal(get(api.getLayerName)('other'), '');
|
||||
});
|
||||
|
||||
it('uses big tiles when the urls say so, and the max zoom it is given', () => {
|
||||
api.addOrUpdateOverlay({
|
||||
...overlay('big'),
|
||||
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
|
||||
maxZoom: 15,
|
||||
});
|
||||
const source = (overlays['extension-big'] as any).sources['extension-big'];
|
||||
assert.equal(source.tileSize, 512);
|
||||
assert.equal(source.maxzoom, 15);
|
||||
});
|
||||
|
||||
it('refuses an overlay that is not complete', () => {
|
||||
for (const bad of [
|
||||
{ ...overlay('a'), extensionName: '' },
|
||||
{ ...overlay('a'), id: '' },
|
||||
{ ...overlay('a'), name: '' },
|
||||
{ ...overlay('a'), tileUrls: [] },
|
||||
{ ...overlay('a'), tileUrls: undefined as unknown as string[] },
|
||||
]) {
|
||||
assert.throws(() => api.addOrUpdateOverlay(bad), /must have/);
|
||||
}
|
||||
assert.ok(!('extension-a' in overlays));
|
||||
});
|
||||
|
||||
it('updates an overlay that is added again, keeping it shown', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
settings.currentOverlays.update((current) => {
|
||||
(current.overlays as LayerTreeType)['My extension'] = { 'extension-one': true };
|
||||
return current;
|
||||
});
|
||||
|
||||
api.addOrUpdateOverlay({
|
||||
...overlay('one'),
|
||||
name: 'Renamed',
|
||||
tileUrls: ['https://other/{z}'],
|
||||
});
|
||||
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
|
||||
assert.deepEqual((overlays['extension-one'] as any).sources['extension-one'].tiles, [
|
||||
'https://other/{z}',
|
||||
]);
|
||||
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
|
||||
});
|
||||
|
||||
it('removes the overlays that are not in the list anymore, everywhere', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
api.addOrUpdateOverlay(overlay('two', 'Other extension'));
|
||||
|
||||
api.filterOverlays(['two']);
|
||||
assert.ok(!('extension-one' in overlays));
|
||||
assert.ok('extension-two' in overlays);
|
||||
assert.equal(get(api.isLayerFromExtension)('extension-one'), false);
|
||||
assert.equal(get(api.isLayerFromExtension)('extension-two'), true);
|
||||
for (const tree of [
|
||||
overlayTree,
|
||||
get(settings.selectedOverlayTree),
|
||||
get(settings.currentOverlays)!,
|
||||
]) {
|
||||
assert.equal(isSelected(tree, 'extension-one'), false);
|
||||
}
|
||||
assert.equal(isSelected(overlayTree, 'extension-two'), true);
|
||||
|
||||
api.filterOverlays([]);
|
||||
assert.ok(!('extension-two' in overlays));
|
||||
});
|
||||
|
||||
it('puts the overlays in the order it is given', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
api.addOrUpdateOverlay(overlay('two'));
|
||||
api.addOrUpdateOverlay(overlay('three'));
|
||||
|
||||
api.updateOverlaysOrder(['three', 'one', 'two']);
|
||||
const extension = (get(settings.selectedOverlayTree).overlays as LayerTreeType)[
|
||||
'My extension'
|
||||
];
|
||||
assert.deepEqual(Object.keys(extension as LayerTreeType), [
|
||||
'extension-three',
|
||||
'extension-one',
|
||||
'extension-two',
|
||||
]);
|
||||
// ids it does not know are ignored
|
||||
api.updateOverlaysOrder(['unknown', 'two']);
|
||||
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { derived, get, writable, type Writable } from 'svelte/store';
|
||||
import { isSelected, remove, removeAll } from './utils';
|
||||
import { overlays, overlayTree } from '$lib/assets/layers';
|
||||
import { overlays, overlayTree } from '$lib/data/layers';
|
||||
import { browser } from '$app/environment';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { overpassQueryData } from '$lib/data/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { cachedFetch } from '$lib/cached-fetch';
|
||||
import { cachedFetch } from '$lib/storage/cached-fetch';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { LayerTreeType } from '$lib/data/layers';
|
||||
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
|
||||
|
||||
// roads: bike (selected), foot
|
||||
// satellite: esri, ign: fr (selected)
|
||||
// osm
|
||||
const tree = (): LayerTreeType => ({
|
||||
roads: { bike: true, foot: false },
|
||||
satellite: { esri: false, ign: { fr: true } },
|
||||
osm: false,
|
||||
});
|
||||
|
||||
describe('layer trees', () => {
|
||||
it('tells if a layer is selected', () => {
|
||||
assert.equal(isSelected(tree(), 'bike'), true);
|
||||
assert.equal(isSelected(tree(), 'foot'), false);
|
||||
assert.equal(isSelected(tree(), 'fr'), true);
|
||||
assert.equal(isSelected(tree(), 'osm'), false);
|
||||
assert.equal(isSelected(tree(), 'unknown'), false);
|
||||
});
|
||||
|
||||
it('tells if anything is selected in a branch', () => {
|
||||
const t = tree();
|
||||
assert.equal(anySelectedLayer(t), true);
|
||||
assert.equal(anySelectedLayer(t.roads as LayerTreeType), true);
|
||||
assert.equal(anySelectedLayer(t.satellite as LayerTreeType), true);
|
||||
assert.equal(anySelectedLayer({ a: false, b: { c: false } }), false);
|
||||
assert.equal(anySelectedLayer({}), false);
|
||||
});
|
||||
|
||||
it('flattens the layers of the leaves', () => {
|
||||
assert.deepEqual(getLayers(tree()), {
|
||||
bike: true,
|
||||
foot: false,
|
||||
esri: false,
|
||||
fr: true,
|
||||
osm: false,
|
||||
});
|
||||
assert.deepEqual(getLayers({}), {});
|
||||
});
|
||||
|
||||
it('toggles a layer wherever it is', () => {
|
||||
const t = toggle(tree(), 'fr');
|
||||
assert.equal(isSelected(t, 'fr'), false);
|
||||
assert.equal(isSelected(toggle(t, 'osm'), 'osm'), true);
|
||||
assert.equal(isSelected(toggle(t, 'osm'), 'bike'), true);
|
||||
// the tree is edited, and returned
|
||||
const own = tree();
|
||||
assert.equal(toggle(own, 'foot'), own);
|
||||
assert.equal((own.roads as LayerTreeType).foot, true);
|
||||
// an id that is not there changes nothing
|
||||
assert.deepEqual(toggle(tree(), 'unknown'), tree());
|
||||
});
|
||||
|
||||
it('removes layers and branches', () => {
|
||||
assert.deepEqual(remove(tree(), 'bike'), {
|
||||
roads: { foot: false },
|
||||
satellite: { esri: false, ign: { fr: true } },
|
||||
osm: false,
|
||||
});
|
||||
assert.deepEqual(remove(tree(), 'ign'), {
|
||||
roads: { bike: true, foot: false },
|
||||
satellite: { esri: false },
|
||||
osm: false,
|
||||
});
|
||||
assert.deepEqual(removeAll(tree(), ['bike', 'fr', 'osm']), {
|
||||
roads: { foot: false },
|
||||
satellite: { esri: false, ign: {} },
|
||||
});
|
||||
assert.deepEqual(removeAll(tree(), []), tree());
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import type { LayerTreeType } from '$lib/data/layers';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,7 @@ import MaplibreGeocoder, {
|
||||
} from '@maplibre/maplibre-gl-geocoder';
|
||||
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { tick } from 'svelte';
|
||||
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
|
||||
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { PersonStanding, X } from '@lucide/svelte';
|
||||
import { MapillaryLayer } from './mapillary';
|
||||
import { GoogleRedirect } from './google';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: maplibregl.Map;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
import {
|
||||
basemaps,
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
maptilerKeyPlaceHolder,
|
||||
overlays,
|
||||
terrainSources,
|
||||
} from '$lib/assets/layers';
|
||||
} from '$lib/data/layers';
|
||||
import { getLayers } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
||||
settings;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Scissors,
|
||||
MountainSnow,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { engine } from '$lib/engine';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
let props: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { getElevation, getURLForLanguage } from '$lib/utils';
|
||||
import { engine } from '$lib/engine';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Ungroup } from '@lucide/svelte';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { get } from 'svelte/store';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { Group } from '@lucide/svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers,
|
||||
} from '$lib/units';
|
||||
} from '$lib/units/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
let props: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Funnel } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
@@ -22,16 +22,16 @@
|
||||
SquareArrowOutDownRight,
|
||||
} from '@lucide/svelte';
|
||||
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { get } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { engine } from '$lib/engine';
|
||||
import { newFileName } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { newFileName } from '$lib/actions/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { RoutingControls } from './routing-controls';
|
||||
|
||||
let {
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user