Print list of runs as CSV
This commit is contained in:
commit
845c53231a
4 changed files with 515 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
114
Cargo.lock
generated
Normal file
114
Cargo.lock
generated
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.79"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qhd"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"csv",
|
||||||
|
"quick-xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.197"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.197"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
10
Cargo.toml
Normal file
10
Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
[package]
|
||||||
|
name = "qhd"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
csv = "1.3.0"
|
||||||
|
quick-xml = "0.31.0"
|
390
src/main.rs
Normal file
390
src/main.rs
Normal file
|
@ -0,0 +1,390 @@
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::reader::Reader;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::io::BufRead;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
use std::{env, io};
|
||||||
|
|
||||||
|
/*
|
||||||
|
<!ATTLIST Workout
|
||||||
|
workoutActivityType CDATA #REQUIRED
|
||||||
|
duration CDATA #IMPLIED
|
||||||
|
durationUnit CDATA #IMPLIED
|
||||||
|
totalDistance CDATA #IMPLIED
|
||||||
|
totalDistanceUnit CDATA #IMPLIED
|
||||||
|
totalEnergyBurned CDATA #IMPLIED
|
||||||
|
totalEnergyBurnedUnit CDATA #IMPLIED
|
||||||
|
sourceName CDATA #REQUIRED
|
||||||
|
sourceVersion CDATA #IMPLIED
|
||||||
|
device CDATA #IMPLIED
|
||||||
|
creationDate CDATA #IMPLIED
|
||||||
|
startDate CDATA #REQUIRED
|
||||||
|
endDate CDATA #REQUIRED
|
||||||
|
>
|
||||||
|
*/
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct RunningWorkout {
|
||||||
|
duration: String,
|
||||||
|
duration_unit: String,
|
||||||
|
creation_date: String,
|
||||||
|
start_date: String,
|
||||||
|
end_date: String,
|
||||||
|
// <WorkoutStatistics type="HKQuantityTypeIdentifierDistanceWalkingRunning" startDate="2024-03-30 17:00:48 +1000" endDate="2024-03-30 17:25:28 +1000" sum="4.10642" unit="km"/>
|
||||||
|
distance: String,
|
||||||
|
distance_unit: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HKWorkoutActivityType
|
||||||
|
///
|
||||||
|
/// <https://developer.apple.com/documentation/healthkit/hkworkoutactivitytype?language=objc>
|
||||||
|
#[allow(unused)]
|
||||||
|
enum Activity {
|
||||||
|
// Individual sports
|
||||||
|
/// Shooting archery.
|
||||||
|
Archery,
|
||||||
|
/// Bowling.
|
||||||
|
Bowling,
|
||||||
|
/// Fencing.
|
||||||
|
Fencing,
|
||||||
|
/// Performing gymnastics.
|
||||||
|
Gymnastics,
|
||||||
|
/// Participating in track and field events, including shot put, javelin, pole vaulting, and related sports.
|
||||||
|
TrackAndField,
|
||||||
|
|
||||||
|
// Team sports
|
||||||
|
/// Playing American football.
|
||||||
|
AmericanFootball,
|
||||||
|
/// Playing Australian football.
|
||||||
|
AustralianFootball,
|
||||||
|
/// Playing baseball.
|
||||||
|
Baseball,
|
||||||
|
/// Playing basketball.
|
||||||
|
Basketball,
|
||||||
|
/// Playing cricket.
|
||||||
|
Cricket,
|
||||||
|
/// Playing disc sports such as Ultimate and Disc Golf.
|
||||||
|
DiscSports,
|
||||||
|
/// Playing handball.
|
||||||
|
Handball,
|
||||||
|
/// Playing hockey, including ice hockey, field hockey, and related sports.
|
||||||
|
Hockey,
|
||||||
|
/// Playing lacrosse.
|
||||||
|
Lacrosse,
|
||||||
|
/// Playing rugby.
|
||||||
|
Rugby,
|
||||||
|
/// Playing soccer.
|
||||||
|
Soccer,
|
||||||
|
/// Playing softball.
|
||||||
|
Softball,
|
||||||
|
/// Playing volleyball.
|
||||||
|
Volleyball,
|
||||||
|
|
||||||
|
// Exercise and fitness
|
||||||
|
/// Warm-up and therapeutic activities like foam rolling and stretching.
|
||||||
|
PreparationAndRecovery,
|
||||||
|
/// A flexibility workout.
|
||||||
|
Flexibility,
|
||||||
|
/// Low intensity stretching and mobility exercises following a more vigorous workout.
|
||||||
|
Cooldown,
|
||||||
|
/// Walking.
|
||||||
|
Walking,
|
||||||
|
/// Running and jogging.
|
||||||
|
Running,
|
||||||
|
/// A wheelchair workout at walking pace.
|
||||||
|
WheelchairWalkPace,
|
||||||
|
/// Wheelchair workout at running pace.
|
||||||
|
WheelchairRunPace,
|
||||||
|
/// Cycling.
|
||||||
|
Cycling,
|
||||||
|
/// Hand cycling.
|
||||||
|
HandCycling,
|
||||||
|
/// Core training.
|
||||||
|
CoreTraining,
|
||||||
|
/// Workouts on an elliptical machine.
|
||||||
|
Elliptical,
|
||||||
|
/// Strength training, primarily with free weights and body weight.
|
||||||
|
FunctionalStrengthTraining,
|
||||||
|
/// Strength training exercises primarily using machines or free weights.
|
||||||
|
TraditionalStrengthTraining,
|
||||||
|
/// Exercise that includes any mixture of cardio, strength, and/or flexibility training.
|
||||||
|
CrossTraining,
|
||||||
|
/// Workouts that mix a variety of cardio exercise machines or modalities.
|
||||||
|
MixedCardio,
|
||||||
|
/// High intensity interval training.
|
||||||
|
HighIntensityIntervalTraining,
|
||||||
|
/// Jumping rope.
|
||||||
|
JumpRope,
|
||||||
|
/// Workouts using a stair climbing machine.
|
||||||
|
StairClimbing,
|
||||||
|
/// Running, walking, or other drills using stairs (for example, in a stadium or inside a multilevel building).
|
||||||
|
Stairs,
|
||||||
|
/// Training using a step bench.
|
||||||
|
StepTraining,
|
||||||
|
/// Playing fitness-based video games.
|
||||||
|
FitnessGaming,
|
||||||
|
|
||||||
|
// Studio activities
|
||||||
|
/// Barre workout.
|
||||||
|
Barre,
|
||||||
|
/// Cardiovascular dance workouts.
|
||||||
|
CardioDance,
|
||||||
|
/// Dancing with a partner or partners, such as swing, salsa, or folk dances.
|
||||||
|
SocialDance,
|
||||||
|
/// Practicing yoga.
|
||||||
|
Yoga,
|
||||||
|
/// Performing activities like walking meditation, Gyrotonic exercise, and Qigong.
|
||||||
|
MindAndBody,
|
||||||
|
/// A pilates workout.
|
||||||
|
Pilates,
|
||||||
|
|
||||||
|
// Racket sports
|
||||||
|
/// Playing badminton.
|
||||||
|
Badminton,
|
||||||
|
/// Playing pickleball.
|
||||||
|
Pickleball,
|
||||||
|
/// Playing racquetball.
|
||||||
|
Racquetball,
|
||||||
|
/// Playing squash.
|
||||||
|
Squash,
|
||||||
|
/// Playing table tennis.
|
||||||
|
TableTennis,
|
||||||
|
/// Playing tennis.
|
||||||
|
Tennis,
|
||||||
|
|
||||||
|
// Outdoor activities
|
||||||
|
/// Climbing.
|
||||||
|
Climbing,
|
||||||
|
/// Activities that involve riding a horse, including polo, horse racing, and horse riding.
|
||||||
|
EquestrianSports,
|
||||||
|
/// Fishing.
|
||||||
|
Fishing,
|
||||||
|
/// Playing golf.
|
||||||
|
Golf,
|
||||||
|
/// Hiking.
|
||||||
|
Hiking,
|
||||||
|
/// Hunting.
|
||||||
|
Hunting,
|
||||||
|
/// Play-based activities like tag, dodgeball, hopscotch, tetherball, and playing on a jungle gym.
|
||||||
|
Play,
|
||||||
|
|
||||||
|
// Snow and ice sports
|
||||||
|
/// Cross country skiing.
|
||||||
|
CrossCountrySkiing,
|
||||||
|
/// Curling.
|
||||||
|
Curling,
|
||||||
|
/// Downhill skiing.
|
||||||
|
DownhillSkiing,
|
||||||
|
/// A variety of snow sports, including sledding, snowmobiling, or building a snowman.
|
||||||
|
SnowSports,
|
||||||
|
/// Snowboarding.
|
||||||
|
Snowboarding,
|
||||||
|
/// Skating activities, including ice skating, speed skating, inline skating, and skateboarding.
|
||||||
|
SkatingSports,
|
||||||
|
|
||||||
|
// Water activities
|
||||||
|
/// Canoeing, kayaking, paddling an outrigger, paddling a stand-up paddle board, and related sports.
|
||||||
|
PaddleSports,
|
||||||
|
/// Rowing.
|
||||||
|
Rowing,
|
||||||
|
/// Sailing.
|
||||||
|
Sailing,
|
||||||
|
/// A variety of surf sports, including surfing, kite surfing, and wind surfing.
|
||||||
|
SurfingSports,
|
||||||
|
/// Swimming.
|
||||||
|
Swimming,
|
||||||
|
/// Aerobic exercise performed in shallow water.
|
||||||
|
WaterFitness,
|
||||||
|
/// Playing water polo.
|
||||||
|
WaterPolo,
|
||||||
|
/// A variety of water sports, including water skiing, wake boarding, and related activities.
|
||||||
|
WaterSports,
|
||||||
|
|
||||||
|
// Martial arts
|
||||||
|
/// Boxing.
|
||||||
|
Boxing,
|
||||||
|
/// Kickboxing.
|
||||||
|
Kickboxing,
|
||||||
|
/// Practicing martial arts.
|
||||||
|
MartialArts,
|
||||||
|
/// Tai chi.
|
||||||
|
TaiChi,
|
||||||
|
/// Wrestling.
|
||||||
|
Wrestling,
|
||||||
|
|
||||||
|
// Other activities
|
||||||
|
/// A workout that does not match any of the other workout activity types.
|
||||||
|
Other,
|
||||||
|
// Deprecated activity types
|
||||||
|
/// Dancing.
|
||||||
|
Dance,
|
||||||
|
/// Workouts inspired by dance, including Pilates, Barre, and Feldenkrais.
|
||||||
|
DanceInspiredTraining,
|
||||||
|
/// Performing any mix of cardio-focused exercises.
|
||||||
|
MixedMetabolicCardioTraining,
|
||||||
|
|
||||||
|
// Multisport activities
|
||||||
|
/// Multisport activities like triathlons.
|
||||||
|
SwimBikeRun,
|
||||||
|
/// A constant for the transition time between activities in a multisport workout.
|
||||||
|
Transition,
|
||||||
|
|
||||||
|
// Enumeration Cases
|
||||||
|
UnderwaterDiving,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let Some(export_path) = env::args_os().skip(1).next().map(PathBuf::from) else {
|
||||||
|
eprintln!("Usage: qhd path/to/export.xml");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reader = Reader::from_file(&export_path).expect("unable to read input path");
|
||||||
|
reader.trim_text(true);
|
||||||
|
|
||||||
|
match try_main(&mut reader) {
|
||||||
|
Ok(workouts) => {
|
||||||
|
// println!("{} workouts", workouts.len());
|
||||||
|
match output_csv(&workouts) {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("CSV Error: {err}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Error: {err}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_main<R>(reader: &mut Reader<R>) -> Result<Vec<RunningWorkout>, quick_xml::Error>
|
||||||
|
where
|
||||||
|
R: BufRead,
|
||||||
|
{
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
// Match <Workout> with workoutActivityType=HKWorkoutActivityTypeRunning
|
||||||
|
// Find <WorkoutStatistics> type=HKQuantityTypeIdentifierDistanceWalkingRunning
|
||||||
|
let mut workouts = Vec::new();
|
||||||
|
let mut workout = None;
|
||||||
|
|
||||||
|
// The `Reader` does not implement `Iterator` because it outputs borrowed data (`Cow`s)
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e),
|
||||||
|
// exits the loop when reaching end of file
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
|
||||||
|
// Handle start element
|
||||||
|
Ok(Event::Start(e)) => match e.name().as_ref() {
|
||||||
|
b"Workout" => {
|
||||||
|
let Ok(Some(workout_activity_type)) =
|
||||||
|
e.try_get_attribute("workoutActivityType")
|
||||||
|
else {
|
||||||
|
buf.clear();
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if workout_activity_type.value.as_ref() != b"HKWorkoutActivityTypeRunning" {
|
||||||
|
buf.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut w = RunningWorkout::default();
|
||||||
|
e.attributes().try_for_each(|a| {
|
||||||
|
let attr = a?;
|
||||||
|
match attr.key.as_ref() {
|
||||||
|
b"duration" => w.duration = string(attr.value),
|
||||||
|
b"durationUnit" => w.duration_unit = string(attr.value),
|
||||||
|
b"creationDate" => w.creation_date = string(attr.value),
|
||||||
|
b"startDate" => w.start_date = string(attr.value),
|
||||||
|
b"endDate" => w.end_date = string(attr.value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok::<(), quick_xml::Error>(())
|
||||||
|
})?;
|
||||||
|
workout = Some(w);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle empty elements
|
||||||
|
Ok(Event::Empty(e)) => match e.name().as_ref() {
|
||||||
|
b"WorkoutStatistics" if workout.is_some() => {
|
||||||
|
let Ok(Some(stat_type)) = e.try_get_attribute("type") else {
|
||||||
|
buf.clear();
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if stat_type.value.as_ref() != b"HKQuantityTypeIdentifierDistanceWalkingRunning"
|
||||||
|
{
|
||||||
|
buf.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (distance, unit) = e
|
||||||
|
.try_get_attribute("sum")?
|
||||||
|
.map(|attr| string(attr.value))
|
||||||
|
.and_then(|sum| {
|
||||||
|
e.try_get_attribute("unit")
|
||||||
|
.ok()?
|
||||||
|
.map(|attr| (sum, string(attr.value)))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let w = workout.as_mut().unwrap();
|
||||||
|
w.distance = distance;
|
||||||
|
w.distance_unit = unit;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle element end
|
||||||
|
Ok(Event::End(e)) if workout.is_some() => match e.name().as_ref() {
|
||||||
|
b"Workout" => workouts.push(workout.take().unwrap()),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ok(Event::Text(e)) => txt.push(e.unescape().unwrap().into_owned()),
|
||||||
|
|
||||||
|
// There are several other `Event`s we do not consider here
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
// if we don't keep a borrow elsewhere, we can clear the buffer to keep memory usage low
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(workouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_csv(workouts: &[RunningWorkout]) -> Result<(), csv::Error> {
|
||||||
|
let stdout = io::stdout().lock();
|
||||||
|
let mut out = csv::Writer::from_writer(stdout);
|
||||||
|
|
||||||
|
out.write_record(&[
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"creation_date",
|
||||||
|
"duration",
|
||||||
|
"duration_unit",
|
||||||
|
"distance",
|
||||||
|
"distance_unit",
|
||||||
|
])?;
|
||||||
|
for w in workouts {
|
||||||
|
out.write_record(&[
|
||||||
|
&w.start_date,
|
||||||
|
&w.end_date,
|
||||||
|
&w.creation_date,
|
||||||
|
&w.duration,
|
||||||
|
&w.duration_unit,
|
||||||
|
&w.distance,
|
||||||
|
&w.distance_unit,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string<'a>(data: Cow<'a, [u8]>) -> String {
|
||||||
|
String::from_utf8(data.into_owned()).unwrap()
|
||||||
|
}
|
Loading…
Reference in a new issue