Compare commits
22 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
da62a0f5c6 | |
|
|
1a0d26f4ca | |
|
|
c9ab304460 | |
|
|
e32b65926f | |
|
|
e49a1738ec | |
|
|
29cdd41966 | |
|
|
6bbdea1908 | |
|
|
cec3c57477 | |
|
|
198d678ccd | |
|
|
5de1049834 | |
|
|
93684069f0 | |
|
|
574272602f | |
|
|
ba92dd50d1 | |
|
|
99dd16199d | |
|
|
a61f80cde4 | |
|
|
67f09cc0bd | |
|
|
7d934408fd | |
|
|
22f71ba51f | |
|
|
9012a38917 | |
|
|
dd6410d896 | |
|
|
bfae2b12e0 | |
|
|
5aa11ddb3d |
|
|
@ -18,7 +18,7 @@ Editing .zwo files by hand is also inconvenient:
|
|||
|
||||
- you'll have to constantly convert minutes to seconds,
|
||||
- you can easily make errors in XML syntax, rendering the file invalid,
|
||||
- The syntax is quite inconsistent, making it hard to memoize.
|
||||
- [it's really a bad format.][zwo-sucks]
|
||||
|
||||
There are a few alternative editors online:
|
||||
|
||||
|
|
@ -117,10 +117,9 @@ console.log(stats(workout));
|
|||
- More restricted syntax for text (with quotes)
|
||||
- Concatenate similar intervals
|
||||
- Distinguish between terrain-sensitive and insensitive free-ride.
|
||||
- Syntax for comments placed relative to previous ones, e.g. `@ +00:10`
|
||||
- Detect overlaps of comments.
|
||||
|
||||
[zwift]: https://zwift.com/
|
||||
[zwofactory]: https://zwofactory.com/
|
||||
[simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
|
||||
[workout-editor]: https://nene.github.io/workout-editor/
|
||||
[zwo-sucks]: http://nene.github.io/2021/01/14/zwo-sucks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
Name: Ramps
|
||||
Author: R.Saarsoo
|
||||
Description:
|
||||
Various kinds of ramp intervals.
|
||||
|
||||
Ramp: 5:00 40%..75%
|
||||
|
||||
Ramp: 10:00 80%..90%
|
||||
Ramp: 10:00 90%..80%
|
||||
|
||||
Warmup: 10:00 80%..90%
|
||||
Cooldown: 10:00 90%..80%
|
||||
|
||||
Ramp: 5:00 75%..40%
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "zwiftout",
|
||||
"version": "1.0.0",
|
||||
"version": "2.3.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"description": "Zwift workout generator command line tool and library",
|
||||
"author": "Rene Saarsoo <github@triin.net>",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"zwiftout": "./bin/zwiftout.js"
|
||||
"zwiftout": "bin/zwiftout.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,26 @@ export class RangeIntensity implements Intensity {
|
|||
}
|
||||
}
|
||||
|
||||
export class RangeIntensityEnd implements Intensity {
|
||||
constructor(private _end: number) {}
|
||||
|
||||
get value() {
|
||||
return this._end;
|
||||
}
|
||||
|
||||
get start(): number {
|
||||
throw new Error("RangeIntensityEnd has no start");
|
||||
}
|
||||
|
||||
get end() {
|
||||
return this._end;
|
||||
}
|
||||
|
||||
get zone() {
|
||||
return intensityValueToZoneType(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
export class FreeIntensity implements Intensity {
|
||||
get value() {
|
||||
// To match Zwift, which gives 64 TSS for 1h of freeride.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const generateTextEvents = (comments: Comment[]): xml.XmlObject[] => {
|
|||
};
|
||||
|
||||
const generateRangeInterval = (
|
||||
tagName: "Warmup" | "Cooldown",
|
||||
tagName: "Warmup" | "Cooldown" | "Ramp",
|
||||
{ duration, intensity, cadence, comments }: Interval,
|
||||
): xml.XmlObject => {
|
||||
return {
|
||||
|
|
@ -79,16 +79,22 @@ const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject =>
|
|||
};
|
||||
};
|
||||
|
||||
const generateInterval = (interval: Interval | RepeatedInterval): xml.XmlObject => {
|
||||
const generateInterval = (
|
||||
interval: Interval | RepeatedInterval,
|
||||
index: number,
|
||||
allIntervals: (Interval | RepeatedInterval)[],
|
||||
): xml.XmlObject => {
|
||||
if (interval.type === "repeat") {
|
||||
return generateRepeatInterval(interval);
|
||||
}
|
||||
|
||||
const { intensity } = interval;
|
||||
if (intensity.start < intensity.end) {
|
||||
if (index === 0 && intensity.start < intensity.end) {
|
||||
return generateRangeInterval("Warmup", interval);
|
||||
} else if (intensity.start > intensity.end) {
|
||||
} else if (index === allIntervals.length - 1 && intensity.start > intensity.end) {
|
||||
return generateRangeInterval("Cooldown", interval);
|
||||
} else if (intensity.start !== intensity.end) {
|
||||
return generateRangeInterval("Ramp", interval);
|
||||
} else if (intensity.zone === "free") {
|
||||
return generateFreeRideInterval(interval);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,10 @@ export const parseCliOptions = (): CliOptions => {
|
|||
default: false,
|
||||
});
|
||||
|
||||
argParser.add_argument("file", { nargs: 1 });
|
||||
argParser.add_argument("file", {
|
||||
nargs: "?",
|
||||
default: 0, // Default to reading STDIN
|
||||
});
|
||||
|
||||
// As we only allow one file as input,
|
||||
// convert filenames array to just a single string.
|
||||
const { file, ...rest } = argParser.parse_args();
|
||||
return {
|
||||
file: file[0],
|
||||
...rest,
|
||||
};
|
||||
return argParser.parse_args();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { Interval, Workout } from "../ast";
|
||||
import { FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
|
||||
|
||||
const fillIntensities = (prevInterval: Interval, interval: Interval): Interval => {
|
||||
if (!(interval.intensity instanceof RangeIntensityEnd)) {
|
||||
return interval;
|
||||
}
|
||||
|
||||
if (prevInterval.intensity instanceof FreeIntensity) {
|
||||
throw new Error("range-intensity-end interval can't be after free-intensity interval");
|
||||
}
|
||||
|
||||
return {
|
||||
...interval,
|
||||
intensity: new RangeIntensity(prevInterval.intensity.end, interval.intensity.end),
|
||||
};
|
||||
};
|
||||
|
||||
// Given: [1, 2, 3, 4]
|
||||
// Returns: [[1,2], [2,3], [3,4]]
|
||||
const pairs = <T>(arr: T[]): T[][] => {
|
||||
const result: T[][] = [];
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
result.push([arr[i - 1], arr[i]]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const fillIntensitiesInIntervals = (intervals: Interval[]): Interval[] => {
|
||||
if (intervals.length <= 1) {
|
||||
if (intervals.length === 1 && intervals[0].intensity instanceof RangeIntensityEnd) {
|
||||
throw new Error("range-intensity-end interval can't be the first interval");
|
||||
}
|
||||
return intervals;
|
||||
}
|
||||
|
||||
return [intervals[0], ...pairs(intervals).map(([prev, curr]) => fillIntensities(prev, curr))];
|
||||
};
|
||||
|
||||
export const fillRangeIntensities = (workout: Workout): Workout => {
|
||||
return {
|
||||
...workout,
|
||||
intervals: fillIntensitiesInIntervals(workout.intervals),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Workout } from "../ast";
|
||||
import { fillRangeIntensities } from "./fillRangeIntensities";
|
||||
import { parseTokens } from "./parser";
|
||||
import { tokenize } from "./tokenizer";
|
||||
import { validate } from "./validate";
|
||||
|
||||
export const parse = (source: string): Workout => validate(parseTokens(tokenize(source)));
|
||||
export const parse = (source: string): Workout => validate(fillRangeIntensities(parseTokens(tokenize(source))));
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ Name: My Workout
|
|||
|
||||
Warmup: 5:30 50%..80% 100rpm
|
||||
Cooldown: 5:30 70%..45%
|
||||
Ramp: 5:30 90%..100%
|
||||
`).intervals,
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
|
@ -258,10 +259,147 @@ Cooldown: 5:30 70%..45%
|
|||
},
|
||||
"type": "Cooldown",
|
||||
},
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 330,
|
||||
},
|
||||
"intensity": RangeIntensity {
|
||||
"_end": 1,
|
||||
"_start": 0.9,
|
||||
},
|
||||
"type": "Ramp",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("parses end-of-range intervals after power-range interval", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
|
||||
Ramp: 5:00 50%..75%
|
||||
Ramp: 5:00 ..100%
|
||||
Ramp: 5:00 ..50%
|
||||
`).intervals,
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"intensity": RangeIntensity {
|
||||
"_end": 0.75,
|
||||
"_start": 0.5,
|
||||
},
|
||||
"type": "Ramp",
|
||||
},
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"intensity": RangeIntensity {
|
||||
"_end": 1,
|
||||
"_start": 0.75,
|
||||
},
|
||||
"type": "Ramp",
|
||||
},
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"intensity": RangeIntensity {
|
||||
"_end": 0.5,
|
||||
"_start": 1,
|
||||
},
|
||||
"type": "Ramp",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("parses end-of-range intervals after normal interval", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
|
||||
Interval: 5:00 75%
|
||||
Ramp: 5:00 ..100%
|
||||
Ramp: 5:00 ..50%
|
||||
`).intervals,
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"intensity": ConstantIntensity {
|
||||
"_value": 0.75,
|
||||
},
|
||||
"type": "Interval",
|
||||
},
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"intensity": RangeIntensity {
|
||||
"_end": 1,
|
||||
"_start": 0.75,
|
||||
},
|
||||
"type": "Ramp",
|
||||
},
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"intensity": RangeIntensity {
|
||||
"_end": 0.5,
|
||||
"_start": 1,
|
||||
},
|
||||
"type": "Ramp",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws error when end-of-range intervals after free-ride interval", () => {
|
||||
expect(
|
||||
() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
|
||||
FreeRide: 5:00
|
||||
Ramp: 5:00 ..100%
|
||||
Ramp: 5:00 ..50%
|
||||
`).intervals,
|
||||
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be after free-intensity interval"`);
|
||||
});
|
||||
|
||||
it("throws error when end-of-range intervals is the first interval", () => {
|
||||
expect(
|
||||
() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
|
||||
Ramp: 5:00 ..50%
|
||||
`).intervals,
|
||||
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be the first interval"`);
|
||||
});
|
||||
|
||||
it("parses free-ride intervals", () => {
|
||||
expect(
|
||||
parse(`
|
||||
|
|
@ -555,17 +693,13 @@ Rest: 5:00 50%
|
|||
`);
|
||||
});
|
||||
|
||||
it("parses intervals with negative comment offsets", () => {
|
||||
it("parses last comment with negative offset", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 10:00 90%
|
||||
@ 0:10 Find your rythm.
|
||||
@ -0:10 Final push. YOU GOT IT!
|
||||
|
||||
Rest: 5:00 50%
|
||||
@ -4:30 Great effort!
|
||||
@ -2:00 Cool down well after all of this.
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
|
@ -604,18 +738,197 @@ Rest: 5:00 50%
|
|||
},
|
||||
"type": "Interval",
|
||||
},
|
||||
],
|
||||
"name": "My Workout",
|
||||
"tags": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("parses comment with negative offset before absolutely offset comment", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 1:00 90%
|
||||
@ -0:10 Before last
|
||||
@ 0:50 Last!
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"author": "",
|
||||
"description": "",
|
||||
"intervals": Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 7,
|
||||
"row": 3,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 40,
|
||||
},
|
||||
"text": "Before last",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 4,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 50,
|
||||
},
|
||||
"text": "Last!",
|
||||
},
|
||||
],
|
||||
"duration": Duration {
|
||||
"seconds": 60,
|
||||
},
|
||||
"intensity": ConstantIntensity {
|
||||
"_value": 0.9,
|
||||
},
|
||||
"type": "Interval",
|
||||
},
|
||||
],
|
||||
"name": "My Workout",
|
||||
"tags": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("parses multiple comments with negative offsets in row", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 1:00 90%
|
||||
@ -0:10 One more before last
|
||||
@ -0:10 Before last
|
||||
@ -0:10 Last!
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"author": "",
|
||||
"description": "",
|
||||
"intervals": Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 3,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 30,
|
||||
},
|
||||
"text": "Great effort!",
|
||||
"text": "One more before last",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 4,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 40,
|
||||
},
|
||||
"text": "Before last",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 5,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 50,
|
||||
},
|
||||
"text": "Last!",
|
||||
},
|
||||
],
|
||||
"duration": Duration {
|
||||
"seconds": 60,
|
||||
},
|
||||
"intensity": ConstantIntensity {
|
||||
"_value": 0.9,
|
||||
},
|
||||
"type": "Interval",
|
||||
},
|
||||
],
|
||||
"name": "My Workout",
|
||||
"tags": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("parses intervals with positive comment offsets", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 10:00 90%
|
||||
@ 0:50 First comment
|
||||
@ +0:10 Comment #2 10 seconds later
|
||||
@ +0:10 Comment #3 another 10 seconds later
|
||||
@ 5:00 Half way!
|
||||
@ +0:10 Comment #5 10 seconds later
|
||||
@ +0:10 Comment #6 another 10 seconds later
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"author": "",
|
||||
"description": "",
|
||||
"intervals": Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 3,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 50,
|
||||
},
|
||||
"text": "First comment",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 4,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 60,
|
||||
},
|
||||
"text": "Comment #2 10 seconds later",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 5,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 70,
|
||||
},
|
||||
"text": "Comment #3 another 10 seconds later",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 6,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 300,
|
||||
},
|
||||
"text": "Half way!",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 7,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 310,
|
||||
},
|
||||
"text": "Comment #5 10 seconds later",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
|
|
@ -623,18 +936,123 @@ Rest: 5:00 50%
|
|||
"row": 8,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 180,
|
||||
"seconds": 320,
|
||||
},
|
||||
"text": "Cool down well after all of this.",
|
||||
"text": "Comment #6 another 10 seconds later",
|
||||
},
|
||||
],
|
||||
"duration": Duration {
|
||||
"seconds": 300,
|
||||
"seconds": 600,
|
||||
},
|
||||
"intensity": ConstantIntensity {
|
||||
"_value": 0.5,
|
||||
"_value": 0.9,
|
||||
},
|
||||
"type": "Rest",
|
||||
"type": "Interval",
|
||||
},
|
||||
],
|
||||
"name": "My Workout",
|
||||
"tags": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("treats positive comment offset as relative to interval start when there's no previous comment", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 10:00 90%
|
||||
@ +1:00 First comment
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"author": "",
|
||||
"description": "",
|
||||
"intervals": Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 3,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 60,
|
||||
},
|
||||
"text": "First comment",
|
||||
},
|
||||
],
|
||||
"duration": Duration {
|
||||
"seconds": 600,
|
||||
},
|
||||
"intensity": ConstantIntensity {
|
||||
"_value": 0.9,
|
||||
},
|
||||
"type": "Interval",
|
||||
},
|
||||
],
|
||||
"name": "My Workout",
|
||||
"tags": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws error when negative offset is followed by positive offset", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 2:00 90%
|
||||
@ -0:10 Comment 1
|
||||
@ +0:30 Comment 2
|
||||
@ 1:30 Comment 3
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Negative offset followed by positive offset at line 5 char 5"`);
|
||||
});
|
||||
|
||||
it("works fine when positive offset is followed by negative offset", () => {
|
||||
expect(
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 1:00 90%
|
||||
@ +0:10 Comment 1
|
||||
@ -0:10 Comment 2
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"author": "",
|
||||
"description": "",
|
||||
"intervals": Array [
|
||||
Object {
|
||||
"cadence": undefined,
|
||||
"comments": Array [
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 3,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 10,
|
||||
},
|
||||
"text": "Comment 1",
|
||||
},
|
||||
Object {
|
||||
"loc": Object {
|
||||
"col": 4,
|
||||
"row": 4,
|
||||
},
|
||||
"offset": Duration {
|
||||
"seconds": 50,
|
||||
},
|
||||
"text": "Comment 2",
|
||||
},
|
||||
],
|
||||
"duration": Duration {
|
||||
"seconds": 60,
|
||||
},
|
||||
"intensity": ConstantIntensity {
|
||||
"_value": 0.9,
|
||||
},
|
||||
"type": "Interval",
|
||||
},
|
||||
],
|
||||
"name": "My Workout",
|
||||
|
|
@ -664,4 +1082,85 @@ Interval: 2:00 90%
|
|||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Negative comment offset is larger than interval length at line 5 char 5"`);
|
||||
});
|
||||
|
||||
it("throws error when comment offset is the same as another comment offset", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 2:00 90%
|
||||
@ 0:00 First comment
|
||||
@ 1:00 Comment
|
||||
@ 1:00 Overlapping comment
|
||||
@ 1:50 Last comment
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
|
||||
});
|
||||
|
||||
it("throws error when comment offset is greater than next comment offset", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 2:00 90%
|
||||
@ 0:00 First comment
|
||||
@ 1:20 Comment
|
||||
@ 1:00 Misplaced comment
|
||||
@ 1:50 Last comment
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
|
||||
});
|
||||
|
||||
it("throws error when comments too close together", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 2:00 90%
|
||||
@ 0:00 First comment
|
||||
@ 0:01 Second Comment
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
|
||||
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 2:00 90%
|
||||
@ 0:00 First comment
|
||||
@ 0:09 Second Comment
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
|
||||
});
|
||||
|
||||
it("triggers no error when comments at least 10 seconds apart", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 2:00 90%
|
||||
@ 0:00 First comment
|
||||
@ 0:10 Second Comment
|
||||
`),
|
||||
).not.toThrowError();
|
||||
});
|
||||
|
||||
it("throws error when comment does not finish before end of interval", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 1:00 80%
|
||||
@ 0:51 First comment
|
||||
Interval: 1:00 90%
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Less than 10 seconds between comment start and interval end at line 4 char 5"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers no error when comment finishes right at interval end", () => {
|
||||
expect(() =>
|
||||
parse(`
|
||||
Name: My Workout
|
||||
Interval: 1:00 80%
|
||||
@ 0:50 First comment
|
||||
Interval: 1:00 90%
|
||||
`),
|
||||
).not.toThrowError();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { last } from "ramda";
|
||||
import { Interval, Workout, Comment } from "../ast";
|
||||
import { Duration } from "../Duration";
|
||||
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
|
||||
import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
|
||||
import { ParseError } from "./ParseError";
|
||||
import { IntervalType, SourceLocation, Token } from "./tokenizer";
|
||||
import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
|
||||
|
||||
type Header = Partial<Omit<Workout, "intervals">>;
|
||||
|
||||
|
|
@ -57,12 +58,18 @@ const parseHeader = (tokens: Token[]): [Header, Token[]] => {
|
|||
return [header, tokens];
|
||||
};
|
||||
|
||||
type PartialComment = {
|
||||
offsetToken: OffsetToken;
|
||||
text: string;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
|
||||
const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => {
|
||||
const comments: Comment[] = [];
|
||||
const comments: PartialComment[] = [];
|
||||
while (tokens[0]) {
|
||||
const [start, offset, text, ...rest] = tokens;
|
||||
if (start.type === "comment-start") {
|
||||
if (!offset || offset.type !== "duration") {
|
||||
if (!offset || offset.type !== "offset") {
|
||||
throw new ParseError(
|
||||
`Expected [comment offset] instead got ${tokenToString(offset)}`,
|
||||
offset?.loc || start.loc,
|
||||
|
|
@ -72,8 +79,7 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
|
|||
throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
|
||||
}
|
||||
comments.push({
|
||||
// when offset is negative, recalculate it based on interval length
|
||||
offset: new Duration(offset.value >= 0 ? offset.value : intervalDuration.seconds + offset.value),
|
||||
offsetToken: offset,
|
||||
text: text.value,
|
||||
loc: offset.loc,
|
||||
});
|
||||
|
|
@ -82,7 +88,55 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
|
|||
break;
|
||||
}
|
||||
}
|
||||
return [comments, tokens];
|
||||
|
||||
return [computeAbsoluteOffsets(comments, intervalDuration), tokens];
|
||||
};
|
||||
|
||||
const computeAbsoluteOffsets = (partialComments: PartialComment[], intervalDuration: Duration): Comment[] => {
|
||||
const comments: Comment[] = [];
|
||||
for (let i = 0; i < partialComments.length; i++) {
|
||||
const pComment = partialComments[i];
|
||||
const offsetToken = pComment.offsetToken;
|
||||
|
||||
// Assume absolute offset by default
|
||||
let offset: Duration = new Duration(offsetToken.value);
|
||||
|
||||
if (offsetToken.kind === "relative-plus") {
|
||||
// Position relative to previous already-computed comment offset
|
||||
const previousComment = last(comments);
|
||||
if (previousComment) {
|
||||
offset = new Duration(previousComment.offset.seconds + offset.seconds);
|
||||
}
|
||||
} else if (offsetToken.kind === "relative-minus") {
|
||||
// Position relative to next comment or interval end
|
||||
offset = new Duration(nextCommentOffset(partialComments, i, intervalDuration).seconds - offset.seconds);
|
||||
}
|
||||
|
||||
comments.push({
|
||||
offset,
|
||||
loc: pComment.loc,
|
||||
text: pComment.text,
|
||||
});
|
||||
}
|
||||
return comments;
|
||||
};
|
||||
|
||||
const nextCommentOffset = (partialComments: PartialComment[], i: number, intervalDuration: Duration): Duration => {
|
||||
const nextComment = partialComments[i + 1];
|
||||
if (!nextComment) {
|
||||
return intervalDuration;
|
||||
}
|
||||
switch (nextComment.offsetToken.kind) {
|
||||
case "relative-minus":
|
||||
return new Duration(
|
||||
nextCommentOffset(partialComments, i + 1, intervalDuration).seconds - nextComment.offsetToken.value,
|
||||
);
|
||||
case "relative-plus":
|
||||
throw new ParseError("Negative offset followed by positive offset", nextComment.offsetToken.loc);
|
||||
case "absolute":
|
||||
default:
|
||||
return new Duration(nextComment.offsetToken.value);
|
||||
}
|
||||
};
|
||||
|
||||
const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
|
||||
|
|
@ -104,6 +158,9 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
|
|||
} else if (token.type === "intensity-range") {
|
||||
intensity = new RangeIntensity(token.value[0], token.value[1]);
|
||||
tokens.shift();
|
||||
} else if (token.type === "intensity-range-end") {
|
||||
intensity = new RangeIntensityEnd(token.value);
|
||||
tokens.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { ParseError } from "./ParseError";
|
||||
|
||||
export type HeaderType = "Name" | "Author" | "Description" | "Tags";
|
||||
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide";
|
||||
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide" | "Ramp";
|
||||
|
||||
const isHeaderType = (value: string): value is HeaderType => {
|
||||
return ["Name", "Author", "Description", "Tags"].includes(value);
|
||||
};
|
||||
const isIntervalType = (value: string): value is IntervalType => {
|
||||
return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide"].includes(value);
|
||||
return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide", "Ramp"].includes(value);
|
||||
};
|
||||
|
||||
// 0-based row and column indexes. First line is 0th.
|
||||
|
|
@ -36,17 +36,36 @@ export type NumberToken = {
|
|||
value: number;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type OffsetToken = {
|
||||
type: "offset";
|
||||
kind: "absolute" | "relative-plus" | "relative-minus";
|
||||
value: number;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type RangeIntensityToken = {
|
||||
type: "intensity-range";
|
||||
value: [number, number];
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type RangeIntensityEndToken = {
|
||||
type: "intensity-range-end";
|
||||
value: number;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type CommentStartToken = {
|
||||
type: "comment-start";
|
||||
value?: undefined;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type Token = HeaderToken | IntervalToken | TextToken | NumberToken | RangeIntensityToken | CommentStartToken;
|
||||
export type Token =
|
||||
| HeaderToken
|
||||
| IntervalToken
|
||||
| TextToken
|
||||
| NumberToken
|
||||
| OffsetToken
|
||||
| RangeIntensityToken
|
||||
| RangeIntensityEndToken
|
||||
| CommentStartToken;
|
||||
|
||||
const toInteger = (str: string): number => {
|
||||
return parseInt(str.replace(/[^0-9]/, ""), 10);
|
||||
|
|
@ -68,10 +87,14 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
|
|||
if (/^[0-9]+rpm$/.test(text)) {
|
||||
return { type: "cadence", value: toInteger(text), loc };
|
||||
}
|
||||
if (/^[0-9]+%..[0-9]+%$/.test(text)) {
|
||||
if (/^[0-9]+%\.\.[0-9]+%$/.test(text)) {
|
||||
const [from, to] = text.split("..").map(toInteger).map(toFraction);
|
||||
return { type: "intensity-range", value: [from, to], loc };
|
||||
}
|
||||
if (/^\.\.[0-9]+%$/.test(text)) {
|
||||
const [, /* _ */ to] = text.split("..").map(toInteger).map(toFraction);
|
||||
return { type: "intensity-range-end", value: to, loc };
|
||||
}
|
||||
if (/^[0-9]+%$/.test(text)) {
|
||||
return { type: "intensity", value: toFraction(toInteger(text)), loc };
|
||||
}
|
||||
|
|
@ -89,21 +112,36 @@ const tokenizeParams = (text: string, loc: SourceLocation): Token[] => {
|
|||
};
|
||||
|
||||
const tokenizeComment = (line: string, row: number): Token[] | undefined => {
|
||||
const [, commentHead, minus, offset, commentText] = line.match(/^(\s*@\s*)(-?)([0-9:]+)(.*?)$/) || [];
|
||||
const [, commentHead, sign, offset, commentText] = line.match(/^(\s*@\s*)([-+]?)([0-9:]+)(.*?)$/) || [];
|
||||
if (!commentHead) {
|
||||
return undefined;
|
||||
}
|
||||
const sign = minus ? -1 : 1;
|
||||
if (!DURATION_REGEX.test(offset)) {
|
||||
throw new ParseError("Invalid comment offset", { row, col: commentHead.length });
|
||||
}
|
||||
return [
|
||||
{ type: "comment-start", loc: { row, col: line.indexOf("@") } },
|
||||
{ type: "duration", value: sign * toSeconds(offset), loc: { row, col: commentHead.length } },
|
||||
{
|
||||
type: "offset",
|
||||
kind: signToKind(sign),
|
||||
value: toSeconds(offset),
|
||||
loc: { row, col: commentHead.length },
|
||||
},
|
||||
{ type: "text", value: commentText.trim(), loc: { row, col: commentHead.length + offset.length } },
|
||||
];
|
||||
};
|
||||
|
||||
const signToKind = (sign: string) => {
|
||||
switch (sign) {
|
||||
case "-":
|
||||
return "relative-minus";
|
||||
case "+":
|
||||
return "relative-plus";
|
||||
default:
|
||||
return "absolute";
|
||||
}
|
||||
};
|
||||
|
||||
const tokenizeHeader = (label: HeaderType, separator: string, paramString: string, row: number): Token[] => {
|
||||
const token: HeaderToken = {
|
||||
type: "header",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { Workout, Interval } from "../ast";
|
||||
import { ValidationError } from "./ValidationError";
|
||||
|
||||
const validateCommentOffsets = (interval: Interval) => {
|
||||
for (const comment of interval.comments) {
|
||||
if (comment.offset.seconds >= interval.duration.seconds) {
|
||||
const validateCommentOffsets = ({ comments, duration }: Interval) => {
|
||||
for (let i = 0; i < comments.length; i++) {
|
||||
const comment = comments[i];
|
||||
if (comment.offset.seconds >= duration.seconds) {
|
||||
throw new ValidationError(`Comment offset is larger than interval length`, comment.loc);
|
||||
}
|
||||
if (comment.offset.seconds < 0) {
|
||||
throw new ValidationError(`Negative comment offset is larger than interval length`, comment.loc);
|
||||
}
|
||||
if (i > 0 && comment.offset.seconds <= comments[i - 1].offset.seconds) {
|
||||
throw new ValidationError(`Comment overlaps previous comment`, comment.loc);
|
||||
}
|
||||
if (i > 0 && comment.offset.seconds < comments[i - 1].offset.seconds + 10) {
|
||||
throw new ValidationError(`Less than 10 seconds between comments`, comment.loc);
|
||||
}
|
||||
if (comment.offset.seconds + 10 > duration.seconds) {
|
||||
throw new ValidationError(`Less than 10 seconds between comment start and interval end`, comment.loc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { pipe, sum } from "ramda";
|
||||
import { map, pipe, sum } from "ramda";
|
||||
import { Interval } from "../ast";
|
||||
import { ConstantIntensity } from "../Intensity";
|
||||
import { average } from "./average";
|
||||
|
|
@ -32,12 +32,6 @@ const fourthRoot = (x: number) => Math.pow(x, 1 / 4);
|
|||
|
||||
export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
|
||||
return new ConstantIntensity(
|
||||
pipe(
|
||||
intervalsToIntensityNumbers,
|
||||
rollingAverages,
|
||||
(averages) => averages.map(fourthPower),
|
||||
average,
|
||||
fourthRoot,
|
||||
)(intervals),
|
||||
pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -226,6 +226,31 @@ The workouts are alphabetically ordered from easiest to hardest, so enjoy the mi
|
|||
</workout_file>"
|
||||
`;
|
||||
|
||||
exports[`Generate ZWO examples/ramps.txt 1`] = `
|
||||
"<workout_file>
|
||||
<name>Ramps</name>
|
||||
<author>R.Saarsoo</author>
|
||||
<description>Various kinds of ramp intervals.</description>
|
||||
<tags>
|
||||
</tags>
|
||||
<sportType>bike</sportType>
|
||||
<workout>
|
||||
<Warmup Duration=\\"300\\" PowerLow=\\"0.4\\" PowerHigh=\\"0.75\\">
|
||||
</Warmup>
|
||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.8\\" PowerHigh=\\"0.9\\">
|
||||
</Ramp>
|
||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.9\\" PowerHigh=\\"0.8\\">
|
||||
</Ramp>
|
||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.8\\" PowerHigh=\\"0.9\\">
|
||||
</Ramp>
|
||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.9\\" PowerHigh=\\"0.8\\">
|
||||
</Ramp>
|
||||
<Cooldown Duration=\\"300\\" PowerLow=\\"0.75\\" PowerHigh=\\"0.4\\">
|
||||
</Cooldown>
|
||||
</workout>
|
||||
</workout_file>"
|
||||
`;
|
||||
|
||||
exports[`Generate ZWO examples/threshold-pushing.txt 1`] = `
|
||||
"<workout_file>
|
||||
<name>Threshold pushing</name>
|
||||
|
|
@ -338,6 +363,27 @@ Zone Distribution:
|
|||
"
|
||||
`;
|
||||
|
||||
exports[`Generate stats examples/ramps.txt 1`] = `
|
||||
"
|
||||
Total duration: 50 minutes
|
||||
|
||||
Average intensity: 80%
|
||||
Normalized intensity: 82%
|
||||
|
||||
TSS: 56
|
||||
XP: 300
|
||||
|
||||
Zone Distribution:
|
||||
6 min - Z1: Recovery
|
||||
4 min - Z2: Endurance
|
||||
40 min - Z3: Tempo
|
||||
0 min - Z4: Threshold
|
||||
0 min - Z5: VO2 Max
|
||||
0 min - Z6: Anaerobic
|
||||
0 min - Freeride
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Generate stats examples/threshold-pushing.txt 1`] = `
|
||||
"
|
||||
Total duration: 79 minutes
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const filenames = [
|
|||
"examples/ftp-test.txt",
|
||||
"examples/halvfems.txt",
|
||||
"examples/threshold-pushing.txt",
|
||||
"examples/ramps.txt",
|
||||
];
|
||||
|
||||
describe("Generate ZWO", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue