xsanda / Strava route planner: GPX import

// ==UserScript==
// @name     Strava route planner: GPX import
// @version  0.1.1
// @grant    none
// @include  https://www.strava.com/routes/new*
// @require https://openuserjs.org/src/libs/xsanda/Run_code_as_client.js
// @updateURL https://openuserjs.org/meta/xsanda/Strava_route_planner_GPX_import.meta.js
// @downloadURL https://openuserjs.org/install/xsanda/Strava_route_planner_GPX_import.user.js
// @license MIT
// ==/UserScript==

/* jshint esversion: 8 */
/* globals runAsClient, polyline, Distance, gpxParse */

runAsClient(async () => {
  const loadLibrary = async function (url) {
    await new Promise((res) => {
      var script = document.createElement('script');
      script.setAttribute('src', url);
      script.onload = () => res();
      document.body.appendChild(script);
    });
  };

  // Add some libraries. The polyline library is already bundled, but React makes it awkward to access.
  await Promise.all([
    loadLibrary('https://unpkg.com/@mapbox/polyline@^1.1.1'),
    loadLibrary('https://unpkg.com/gpx-parse@^0.10/dist/gpx-parse-browser.js'),
    loadLibrary('https://unpkg.com/geo-distance@0.2.0'),
  ]);

  // For reference only, based on deminifying Strava’s implementation.
  const decodeElevation = function* (elevationPolyline) {
    const rawDecoded = polyline.decode(elevationPolyline);
    let p = 0;
    for (let i = 0; i < rawDecoded.length; ++i) {
      const s = rawDecoded[i];
      const distance = s[0] / 0.0001;
      const spotHeight = s[1] / 0.001;
      // if distance has increased by more than a kilometre, add a gap of a very large number
      // idk why
      if (i > 0 && rawDecoded[i - 1][0] > rawDecoded[i][0] + 0.1) ++p;
      yield [distance + (90 * p) / 0.0001, spotHeight];
    }
    console.log(p);
  };

  // Inverse of decodeElevation, except for the weird jump of 90.
  const encodeElevation = (elevation) => {
    const raw = [];

    for (const [d, h] of elevation) {
      raw.push([d * 0.0001, h * 0.001]);
    }
    return polyline.encode(raw);
  };

  // Get the file from the transfer. At the moment nothing happens if multiple files are dropped.
  async function getFile(e) {
    if (e.dataTransfer.items && e.dataTransfer.items.length == 1) {
      if (e.dataTransfer.items[0].kind === 'file') {
        return await e.dataTransfer.items[0].getAsFile().text();
      }
    } else if (e.dataTransfer.files && e.dataTransfer.files.length == 1) {
      return await e.dataTransfer.files[0].text();
    }
    return undefined;
  }

  const convertRoute = (track) => {
    const points = [];
    const elePoints = [];
    let distance = 0;
    for (const segment of track.segments) {
      for (const { lat, lon, elevation } of segment) {
        if (points.length) {
          distance +=
            Distance.between(points[points.length - 1], { lat, lon }) *
            Distance.unit_conversion['m'];
        }
        points.push([lat, lon]);
        elePoints.push([distance, elevation]);
      }
    }
    return {
      points: polyline.encode(points),
      elevation: encodeElevation(elePoints),
    };
  };

  const elevationGain = (track) => {
    let total = 0;
    for (const segment of track.segments) {
      let last;
      for (const point of segment) {
        if (last !== undefined && point.elevation > last) {
          total += point.elevation - last;
        }
        last = point.elevation;
      }
    }
    return total;
  };

  const last = (arr) => arr[arr.length - 1];

  const startLocation = (track) => {
    const { lat, lon: lng } = track.segments[0][0];
    return { lat, lng };
  };

  const endLocation = (track) => {
    const { lat, lon: lng } = last(last(track.segments));
    return { lat, lng };
  };

  // This is based on React’s internal structure, so very fragile.
  // Defaults to a run if it can’t access the chosen option.
  const routeType = () => {
    try {
      return [
        ...Object.entries(
          document.querySelector('[class^="RouteBuilder--sidebar"]')
        ),
      ].find((a) => /^__reactInternalInstance/.test(a[0]))[1].memoizedProps
        .children.props.routingPreferences.routeType;
    } catch (e) {
      return 2;
    }
  };

  // Based on the data sent to the server normally.
  const prepareRequest = (gpx) => {
    const data = JSON.parse(document.getElementById('__NEXT_DATA__').text);
    const athlete = data.props.pageProps.currentAthlete;
    const track = gpx.tracks[0];
    const { points, elevation } = convertRoute(track);
    return {
      metadata: {
        overview: { encoding: 1, data: '' },
        elevation_profile: { encoding: 2, data: '' },
        popularity: 0.5,
        elevation: 0,
        manual: 0,
        route_type: routeType(),
        length: track.length() * 1000,
        elevation_gain: elevationGain(track),
        name: track.name,
        description: '',
        sub_type: 1, // TODO: get this to be more advanced, e.g. set to trail if importing a hike.
        is_private: false,
        created_at: Date.now(),
        athlete_id: athlete.id,
      },
      starred: false,
      route: {
        preferences: { popularity: 0.5, elevation: 0, route_type: 1 },
        elements: [
          { element_type: 1, waypoint: { point: startLocation(track) } },
          {
            element_type: 1,
            waypoint: { point: endLocation(track) },
          },
        ],
        legs: [
          {
            leg_type: 2,
            start_element: 0,
            paths: [
              {
                path_type: 1,
                origin: startLocation(track),
                target: endLocation(track),
                length: track.length() * 1000,
                elevation_gain: elevationGain(track),
                polyline: {
                  encoding: 2,
                  data: points,
                },
                elevation: { encoding: 1, data: elevation },
                directions: [],
                surface_type_offsets: [{ distance_offset: 0, surface_type: 3 }],
              },
            ],
          },
        ],
      },
    };
  };
  
  // Take a GPX file (as a string) and parse and convert it, then send it to Strava.
  const handleFile = async (rawGPX) => {
    try { document.querySelector('.mapboxgl-ctrl-geocoder--input').placeholder = "Importing GPX file…"; } catch (e) {};
    try { document.querySelector("[class^='PaywallModal--body'] .text-caption1").innerText = "Importing GPX file…"; } catch (e) {};
    
    const gpx = await new Promise((resolve, reject) =>
      gpxParse.parseGpx(rawGPX, (err, val) =>
        err ? reject(err) : resolve(val)
      )
    );
    const req = prepareRequest(gpx);
    const raw = await fetch('https://www.strava.com/frontend/routes', {
      method: 'POST',
      body: JSON.stringify(req),
      headers: {
        'x-csrf-token': document.querySelector("meta[name='csrf']").content,
        Accept: 'application/json',
        'Content-Type': 'application/json;charset=utf-8',
      },
    });
    const res = await raw.json();
    
    // Go to the new route
    window.location = `https://www.strava.com/routes/${res.route_id}`;
  
  };
  
  // Try every second until something is non-undefined.
  const waitFor = (f, freq = 1000) =>
    new Promise((resolve) => {
      const interval = setInterval(() => {
        const result = f();
        if (result !== undefined) {
          clearInterval(interval);
          resolve(result);
        }
      }, freq);
    });

  // When the header is loaded, add a button to it to import the GPX file.
  waitFor(() => document.querySelector("header [class^='Header--actions']")).then(header => {
    const label = document.createElement("label");
    label.classList.add('text-footnote', 'mr-sm');
    label.href = '#';
    label.innerText = 'Import GPX route';
    label.style.cursor = 'pointer';
    const filePicker = document.createElement("input");
    filePicker.type = 'file';
    filePicker.style.display = 'none';
    filePicker.addEventListener('change', async e => {
      const file = e.target.files[0];
      if (file) handleFile(await file.text());
    });
    label.append(filePicker);
    header.prepend(label);
  });
    

  // preventDefault on both is needed to avoid opening the file in the browser as soon as it is dropped.
  document.body.addEventListener('dragover', (e) => e.preventDefault());
  document.body.addEventListener('drop', async (e) => {
    e.preventDefault();
    const rawGPX = await getFile(e);
    if (rawGPX) handleFile(rawGPX);
  });
});