Repository: LiveSplit/livesplit-core
Pull request #137 (2018-09-04 18:49:23)

Implement the Generic Splits I/O Format
Based on the format specified here: https://github.com/glacials/splits-io/tree/master/public/schema


Patch:
diff --git a/capi/src/fuzzy_list.rs b/capi/src/fuzzy_list.rs
index 6343a76..eb321a0 100644
--- a/capi/src/fuzzy_list.rs
+++ b/capi/src/fuzzy_list.rs
@@ -1,6 +1,6 @@
 //! With a Fuzzy List, you can implement a fuzzy searching algorithm. The list
 //! stores all the items that can be searched for. With the `search` method you
-//! can then execute the actual fuzzy search with returns a list of all the
+//! can then execute the actual fuzzy search which returns a list of all the
 //! elements found. This can be used to implement searching in a list of games.
 
 use super::{output_vec, str, Json};
diff --git a/capi/src/parse_run_result.rs b/capi/src/parse_run_result.rs
index 8a21fec..515196b 100644
--- a/capi/src/parse_run_result.rs
+++ b/capi/src/parse_run_result.rs
@@ -2,7 +2,10 @@
 //! information about which parser parsed it.
 
 use super::output_vec;
-use livesplit_core::run::parser::composite::{ParsedRun, Result};
+use livesplit_core::run::parser::{
+    composite::{ParsedRun, Result},
+    TimerKind,
+};
 use run::OwnedRun;
 use std::io::Write;
 use std::os::raw::c_char;
@@ -31,8 +34,24 @@ pub extern "C" fn ParseRunResult_unwrap(this: OwnedParseRunResult) -> OwnedRun {
     Box::new((*this).unwrap().run)
 }
 
-/// Accesses the name of the Parser that parsed the Run.
+/// Accesses the name of the Parser that parsed the Run. You may not call this
+/// if the Run wasn't parsed successfully.
 #[no_mangle]
 pub extern "C" fn ParseRunResult_timer_kind(this: &ParseRunResult) -> *const c_char {
     output_vec(|f| write!(f, "{}", this.as_ref().unwrap().kind).unwrap())
 }
+
+/// Checks whether the Parser parsed a generic timer. Since a generic timer can
+/// have any name, it may clash with the specific timer formats that
+/// livesplit-core supports. With this function you can determine if a generic
+/// timer format was parsed, instead of one of the more specific timer formats.
+#[no_mangle]
+pub extern "C" fn ParseRunResult_is_generic_timer(this: &ParseRunResult) -> bool {
+    match this {
+        Ok(ParsedRun {
+            kind: TimerKind::Generic(_),
+            ..
+        }) => true,
+        _ => false,
+    }
+}
diff --git a/src/run/editor/fuzzy_list.rs b/src/run/editor/fuzzy_list.rs
index 90d66f6..20c1032 100644
--- a/src/run/editor/fuzzy_list.rs
+++ b/src/run/editor/fuzzy_list.rs
@@ -3,7 +3,7 @@ use std::usize;
 
 /// With a Fuzzy List, you can implement a fuzzy searching algorithm. The list
 /// stores all the items that can be searched for. With the `search` method you
-/// can then execute the actual fuzzy search with returns a list of all the
+/// can then execute the actual fuzzy search which returns a list of all the
 /// elements found. This can be used to implement searching in a list of games.
 ///
 /// # Examples
diff --git a/src/run/parser/composite.rs b/src/run/parser/composite.rs
index 9b8299d..3f50403 100644
--- a/src/run/parser/composite.rs
+++ b/src/run/parser/composite.rs
@@ -32,7 +32,8 @@
 
 use super::{
     face_split, livesplit, llanfair, llanfair2, llanfair_gered, portal2_live_timer, shit_split,
-    source_live_timer, splitterz, splitty, time_split_tracker, urn, worstrun, wsplit, TimerKind,
+    source_live_timer, splits_io, splitterz, splitty, time_split_tracker, urn, worstrun, wsplit,
+    TimerKind,
 };
 use std::io::{self, BufRead, Seek, SeekFrom};
 use std::path::PathBuf;
@@ -141,6 +142,11 @@ where
         return Ok(parsed(run, TimerKind::Llanfair2));
     }
 
+    source.seek(SeekFrom::Start(0))?;
+    if let Ok((run, timer)) = splits_io::parse(&mut source) {
+        return Ok(parsed(run, TimerKind::Generic(timer)));
+    }
+
     // SourceLiveTimer needs to be before Urn because of a false positive
     // due to the nature of parsing json files.
     source.seek(SeekFrom::Start(0))?;
diff --git a/src/run/parser/mod.rs b/src/run/parser/mod.rs
index fda15ba..63027df 100644
--- a/src/run/parser/mod.rs
+++ b/src/run/parser/mod.rs
@@ -40,6 +40,7 @@ pub mod llanfair_gered;
 pub mod portal2_live_timer;
 pub mod shit_split;
 pub mod source_live_timer;
+pub mod splits_io;
 pub mod splitterz;
 pub mod splitty;
 pub mod time_split_tracker;
diff --git a/src/run/parser/splits_io.rs b/src/run/parser/splits_io.rs
new file mode 100644
index 0000000..20b2550
--- /dev/null
+++ b/src/run/parser/splits_io.rs
@@ -0,0 +1,330 @@
+//! Provides the parser for generic Splits I/O splits files.
+
+use serde_json::de::from_reader;
+use serde_json::Error as JsonError;
+use std::io::Read;
+use std::result::Result as StdResult;
+use {Run, Segment as LiveSplitSegment, Time, TimeSpan};
+
+quick_error! {
+    /// The Error type for splits files that couldn't be parsed by the generic
+    /// Splits I/O Parser.
+    #[derive(Debug)]
+    pub enum Error {
+        /// Failed to parse JSON.
+        Json(err: JsonError) {
+            from()
+        }
+    }
+}
+
+/// The Result type for the generic Splits I/O Parser.
+pub type Result<T> = StdResult<T, Error>;
+
+/// Duration holds a realtime duration and a gametime duration.
+#[serde(rename = "duration")]
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct Duration {
+    /// Gametime (Milliseconds) is a duration of milliseconds in game-world time.
+    #[serde(rename = "gametimeMS")]
+    gametime_ms: Option<f64>,
+    /// Realtime (Milliseconds) is a duration of milliseconds in real-world time.
+    #[serde(rename = "realtimeMS")]
+    realtime_ms: Option<f64>,
+}
+/// Run Time represents a moment inside a run, and indicates the duration of the run so far at that
+/// moment. It holds a realtime run duration so far and a gametime run duration so far.
+#[serde(rename = "runTime")]
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct RunTime {
+    /// Gametime (Milliseconds) is a duration a run so far in milliseconds.
+    #[serde(rename = "gametimeMS")]
+    gametime_ms: Option<f64>,
+    /// Realtime (Milliseconds) is a duration of a run so far in milliseconds.
+    #[serde(rename = "realtimeMS")]
+    realtime_ms: Option<f64>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Attempt {
+    /// Attempt Number is the number of lifetime attempts the runner will have made after this one.
+    /// The Attempt Number for an attempt is a label, not an index; the first attempt for a
+    /// category has an Attempt Number of 1 (not 0).
+    #[serde(rename = "attemptNumber")]
+    attempt_number: i64,
+    duration: Option<Duration>,
+}
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct Attempts {
+    /// Histories is an array of previous attempts by this runner of this category.
+    histories: Option<Vec<Attempt>>,
+    /// Total holds the total number of attempts for this category.
+    total: Option<u32>,
+}
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct CategoryLinks {
+    /// Speedrun.com ID specifies the category's Speedrun.com ID.
+    #[serde(rename = "speedruncomID")]
+    speedruncom_id: Option<String>,
+    /// Splits I/O ID specifies the category's Splits I/O ID.
+    #[serde(rename = "splitsioID")]
+    splitsio_id: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Category {
+    /// Links specifies the category's identity in other services.
+    links: Option<CategoryLinks>,
+    /// Longname is a human-readable category name, intended for display to users.
+    longname: String,
+    /// Shortname is a machine-readable category name, intended for use in APIs, databases, URLs,
+    /// and filenames.
+    shortname: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct GameLinks {
+    /// Speedrun.com ID specifies the game's Speedrun.com ID.
+    #[serde(rename = "speedruncomID")]
+    speedruncom_id: Option<String>,
+    /// Splits I/O ID specifies the game's Splits I/O ID.
+    #[serde(rename = "splitsioID")]
+    splitsio_id: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Game {
+    /// Links specifies the game's identity in other services.
+    links: Option<GameLinks>,
+    /// Longname is a human-readable game name, intended for display to users.
+    longname: String,
+    /// Shortname is a machine-readable game name, intended for use in APIs, databases, URLs, and
+    /// filenames.
+    shortname: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct RunLinks {
+    /// Speedrun.com ID is the run's ID on Speedrun.com. This can be used to communicate with the
+    /// Speedrun.com API.
+    #[serde(rename = "speedruncomID")]
+    speedruncom_id: Option<String>,
+    /// Splits I/O ID is the run's ID on Splits I/O. This can be used to communicate with the
+    /// Splits I/O API.
+    #[serde(rename = "splitsioID")]
+    splitsio_id: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Pause {
+    /// Ended At is the date and time at which the pause was ended, specified in RFC 3339 format.
+    #[serde(rename = "endedAt")]
+    ended_at: Option<String>,
+    /// Started At is the date and time at which the pause was started, specified in RFC 3339
+    /// format.
+    #[serde(rename = "startedAt")]
+    started_at: String,
+}
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct RunnerLinks {
+    /// Speedrun.com ID specifies the runner's Speedrun.com ID.
+    #[serde(rename = "speedruncomID")]
+    speedruncom_id: Option<String>,
+    /// Splits I/O ID specifies the runner's Splits I/O ID.
+    #[serde(rename = "splitsioID")]
+    splitsio_id: Option<String>,
+    /// Twitch ID specifies the runner's Twitch ID.
+    #[serde(rename = "twitchID")]
+    twitch_id: Option<String>,
+    /// Twitter ID specifies the runner's Twitter ID.
+    #[serde(rename = "twitterID")]
+    twitter_id: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Runner {
+    /// Links specifies the runner's identity in other services.
+    links: Option<RunnerLinks>,
+    /// Longname is a human-readable runner name, intended for display to users.
+    longname: Option<String>,
+    /// Shortname is a machine-readable runner name, intended for use in APIs, databases, URLs, and
+    /// filenames.
+    shortname: String,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct SegmentHistoryElement {
+    /// Attempt Number is the number of lifetime attempts the runner will have made on this
+    /// category after this one. Generally these attempt numbers should correspond to those in
+    /// Attempts -> History, although a number given here may not be present there if the run was
+    /// reset before completion.
+    #[serde(rename = "attemptNumber")]
+    attempt_number: i64,
+    #[serde(rename = "endedAt")]
+    ended_at: Option<RunTime>,
+    /// Is Reset should be true if the runner reset the run during this segment. If so, this and
+    /// all future segments' Ended Ats for this run are ignored.
+    #[serde(rename = "isReset")]
+    is_reset: Option<bool>,
+    /// Is Skipped should be true if the runner skipped over the split that ends this segment,
+    /// rather than splitting. If so, this segment's Ended At is ignored.
+    #[serde(rename = "isSkipped")]
+    is_skipped: Option<bool>,
+}
+#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
+struct Segment {
+    #[serde(rename = "bestDuration")]
+    best_duration: Option<Duration>,
+    #[serde(rename = "endedAt")]
+    ended_at: Option<RunTime>,
+    /// Histories is an array of previous completions of this segment by this runner.
+    histories: Option<Vec<SegmentHistoryElement>>,
+    /// Is Reset should be true if the runner reset the run during this segment. If so, this and
+    /// all future segments' Ended Ats for this run are ignored.
+    #[serde(rename = "isReset")]
+    is_reset: Option<bool>,
+    /// Is Skipped should be true if the runner skipped over the split that ends this segment,
+    /// rather than splitting. If so, this segment's Ended At is ignored.
+    #[serde(rename = "isSkipped")]
+    is_skipped: Option<bool>,
+    /// Name is the runner-provided name of this segment
+    name: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Timer {
+    /// Longname is a human-readable timer name, intended for display to users.
+    longname: String,
+    /// Shortname is a machine-readable timer name, intended for use in APIs, databases, URLs, and
+    /// filenames.
+    shortname: String,
+    /// Version is the version of the timer used to record this run. Semantic Versioning is
+    /// strongly recommended but not enforced.
+    version: String,
+    /// Website is the URL for the timer's website.
+    website: Option<String>,
+}
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+struct Splits {
+    /// Schema Version specifies which version of the Splits I/O JSON Schema is being used. This
+    /// schema specifies only v1.0.0.
+    #[serde(rename = "_schemaVersion")]
+    _schemaversion: String,
+    /// Attempts contains historical information about previous runs by this runner in this
+    /// category.
+    attempts: Option<Attempts>,
+    /// Category specifies information about the category being run.
+    category: Option<Category>,
+    /// Ended At is the date and time at which the run was ended, specified in RFC 3339 format.
+    #[serde(rename = "endedAt")]
+    ended_at: Option<String>,
+    /// Game specifies information about the game being run.
+    game: Option<Game>,
+    /// Image URL is the location of an image associated with this run. Often this is a screenshot
+    /// of the timer at run completion, but can be anything the runner wants displayed alongside
+    /// the run.
+    #[serde(rename = "imageURL")]
+    image_url: Option<String>,
+    /// Links specifies the run's identity in other services.
+    links: Option<RunLinks>,
+    /// Pauses holds runner-caused pauses that took place during the run.
+    pauses: Option<Vec<Pause>>,
+    /// Runners is an array of people who participated in this run. Some games and categories call
+    /// for cooperative play, but otherwise this will usually be just one person.
+    runners: Option<Vec<Runner>>,
+    /// Segments is an array of all segments for this run.
+    segments: Option<Vec<Segment>>,
+    /// Started At is the date and time at which the run was started, specified in RFC 3339 format.
+    #[serde(rename = "startedAt")]
+    started_at: Option<String>,
+    /// Timer holds information about the timer used to record the run.
+    timer: Timer,
+    /// Video URL is the location of a VOD of the run.
+    #[serde(rename = "videoURL")]
+    video_url: Option<String>,
+}
+
+impl From<Option<Duration>> for Time {
+    fn from(duration: Option<Duration>) -> Time {
+        duration.map(Into::into).unwrap_or_default()
+    }
+}
+
+impl From<Duration> for Time {
+    fn from(duration: Duration) -> Time {
+        let mut time = Time::new();
+        if let Some(ms) = duration.realtime_ms {
+            time.real_time = Some(TimeSpan::from_milliseconds(ms));
+        }
+        if let Some(ms) = duration.gametime_ms {
+            time.game_time = Some(TimeSpan::from_milliseconds(ms));
+        }
+        time
+    }
+}
+
+impl From<Option<RunTime>> for Time {
+    fn from(time: Option<RunTime>) -> Time {
+        time.map(Into::into).unwrap_or_default()
+    }
+}
+
+impl From<RunTime> for Time {
+    fn from(run_time: RunTime) -> Time {
+        let mut time = Time::new();
+        if let Some(ms) = run_time.realtime_ms {
+            time.real_time = Some(TimeSpan::from_milliseconds(ms));
+        }
+        if let Some(ms) = run_time.gametime_ms {
+            time.game_time = Some(TimeSpan::from_milliseconds(ms));
+        }
+        time
+    }
+}
+
+/// Attempts to parse a generic Splits I/O splits file.
+pub fn parse<R: Read>(source: R) -> Result<(Run, String)> {
+    let splits: Splits = from_reader(source)?;
+
+    let mut run = Run::new();
+
+    if let Some(game) = splits.game {
+        run.set_game_name(game.longname);
+    }
+    if let Some(category) = splits.category {
+        run.set_category_name(category.longname);
+    }
+    if let Some(attempts) = splits.attempts {
+        if let Some(total) = attempts.total {
+            run.set_attempt_count(total);
+        }
+        for attempt in attempts.histories.into_iter().flat_map(|x| x) {
+            run.add_attempt_with_index(
+                attempt.duration.into(),
+                attempt.attempt_number as i32,
+                None,
+                None,
+                None,
+            );
+        }
+    }
+
+    for split in splits.segments.into_iter().flat_map(|x| x) {
+        let mut segment = LiveSplitSegment::new(split.name.unwrap_or_default());
+        segment.set_personal_best_split_time(split.ended_at.into());
+        segment.set_best_segment_time(split.best_duration.into());
+        if let Some(mut history) = split.histories {
+            let segment_history = segment.segment_history_mut();
+            history.sort_unstable_by_key(|x| x.attempt_number);
+            for element in history {
+                segment_history.insert(element.attempt_number as i32, element.ended_at.into());
+            }
+        }
+        run.push_segment(segment);
+    }
+
+    if let Some(links) = splits.links {
+        if let Some(link) = links.speedruncom_id {
+            run.metadata_mut().run_id = link;
+        }
+    }
+
+    let timer = if splits.timer.longname.is_empty() {
+        String::from("Generic Timer")
+    } else {
+        splits.timer.longname
+    };
+
+    Ok((run, timer))
+}
diff --git a/src/run/parser/splitty.rs b/src/run/parser/splitty.rs
index c38464f..a95f02a 100644
--- a/src/run/parser/splitty.rs
+++ b/src/run/parser/splitty.rs
@@ -49,10 +49,10 @@ fn parse_time(milliseconds: Option<f64>, method: TimingMethod) -> Time {
 
 /// Attempts to parse a Splitty splits file.
 pub fn parse<R: Read>(source: R) -> Result<Run> {
-    let mut run = Run::new();
-
     let splits: Splits = from_reader(source)?;
 
+    let mut run = Run::new();
+
     run.set_game_name(splits.run_name);
     run.set_attempt_count(splits.run_count);
     run.set_offset(TimeSpan::from_milliseconds(-splits.start_delay));
diff --git a/src/run/parser/timer_kind.rs b/src/run/parser/timer_kind.rs
index 2f60c2b..4fbda70 100644
--- a/src/run/parser/timer_kind.rs
+++ b/src/run/parser/timer_kind.rs
@@ -1,7 +1,7 @@
 use std::fmt;
 
 /// Describes the different Timers available that store splits files.
-#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
+#[derive(Clone, Eq, PartialEq, Hash, Debug)]
 pub enum TimerKind {
     /// LiveSplit
     LiveSplit,
@@ -31,11 +31,14 @@ pub enum TimerKind {
     SourceLiveTimer,
     /// Worstrun
     Worstrun,
+    /// A Generic Timer. The name of the timer is associated with the variant.
+    /// "Generic Timer" is used if there is no known name.
+    Generic(String),
 }
 
 impl fmt::Display for TimerKind {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        match *self {
+        match self {
             TimerKind::LiveSplit => write!(f, "LiveSplit"),
             TimerKind::WSplit => write!(f, "WSplit"),
             TimerKind::SplitterZ => write!(f, "SplitterZ"),
@@ -50,6 +53,7 @@ impl fmt::Display for TimerKind {
             TimerKind::Urn => write!(f, "Urn"),
             TimerKind::SourceLiveTimer => write!(f, "SourceLiveTimer"),
             TimerKind::Worstrun => write!(f, "worstrun"),
+            TimerKind::Generic(name) => write!(f, "{}", name),
         }
     }
 }


Test patch:
diff --git a/tests/parsing.rs b/tests/parsing.rs
index f94077e..22bb68c 100644
--- a/tests/parsing.rs
+++ b/tests/parsing.rs
@@ -3,7 +3,8 @@ extern crate livesplit_core;
 mod parse {
     use livesplit_core::run::parser::{
         composite, livesplit, llanfair, llanfair2, llanfair_gered, portal2_live_timer,
-        source_live_timer, splitterz, time_split_tracker, urn, worstrun, wsplit, TimerKind,
+        source_live_timer, splits_io, splitterz, time_split_tracker, urn, worstrun, wsplit,
+        TimerKind,
     };
     use livesplit_core::{analysis::total_playtime, Run, TimeSpan};
     use std::fs::File;
@@ -165,6 +166,22 @@ mod parse {
         worstrun::parse(file("tests/run_files/worstrun.json")).unwrap();
     }
 
+    #[test]
+    fn splits_io() {
+        splits_io::parse(file("tests/run_files/generic_splits_io.json")).unwrap();
+    }
+
+    #[test]
+    fn splits_io_prefers_parsing_as_itself() {
+        let run =
+            composite::parse(file("tests/run_files/generic_splits_io.json"), None, false).unwrap();
+        assert!(if let TimerKind::Generic(_) = run.kind {
+            true
+        } else {
+            false
+        });
+    }
+
     #[test]
     fn portal2_live_timer_prefers_parsing_as_itself() {
         let run =
diff --git a/tests/run_files/generic_splits_io.json b/tests/run_files/generic_splits_io.json
new file mode 100644
index 0000000..badd30b
--- /dev/null
+++ b/tests/run_files/generic_splits_io.json
@@ -0,0 +1,25 @@
+{
+    "_schemaVersion": "v1.0.0",
+    "timer": {
+        "shortname": "livesplit",
+        "longname": "LiveSplit",
+        "version": "v1.6.0"
+    },
+    "game": {
+        "longname": "Super Mario Odyssey"
+    },
+    "category": {
+        "longname": "Any%"
+    },
+    "segments": [
+        {
+            "name": "Cap",
+            "endedAt": {
+                "realtimeMS": 143123
+            },
+            "bestDuration": {
+                "realtimeMS": 141167
+            }
+        }
+    ]
+}