Skip to content

Commit 778c269

Browse files
committedMay 11, 2023
io-tsを導入してTCXの型を厳密にする
1 parent 0c66e43 commit 778c269

10 files changed

+299
-159
lines changed
 

‎package-lock.json

+17
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"eslint": "^8.28.0",
2121
"eslint-config-prettier": "^8.5.0",
2222
"eslint-plugin-svelte": "^2.26.0",
23+
"fp-ts": "^2.15.0",
24+
"io-ts": "^2.2.20",
2325
"prettier": "^2.8.0",
2426
"prettier-plugin-svelte": "^2.8.1",
2527
"svelte": "^3.57.0",

‎src/App.svelte

+56-58
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,82 @@
11
<script lang="ts">
2-
import { parser } from './lib/parser'
2+
import { isLeft } from 'fp-ts/Either'
3+
import { PathReporter } from 'io-ts/PathReporter'
4+
import CoursePointRow from './components/CoursePointRow.svelte'
5+
import CoursePointTypeOptions from './components/CoursePointTypeOptions.svelte'
6+
import { findNearestTrackPoint, findTimeEquivalentTrackPoint } from './lib/TrackPoint+find'
37
import { builder } from './lib/builder'
4-
import { findTimeEquivalentTrackPoint, findNearestTrackPoint } from './lib/TrackPoint+find'
5-
import { CoursePointType } from './types/TCX.type'
6-
import type { TCX, Course } from './types/TCX.type'
7-
import CoursePoint from './components/CoursePoint.svelte'
8-
import Emoji from './components/Emoji.svelte'
9-
10-
let tcx: TCX | null = null
11-
let course: Course | null
12-
13-
$: if (!tcx?.TrainingCenterDatabase.Courses || !tcx.TrainingCenterDatabase.Courses.Course[0]) {
14-
course = null
15-
} else {
16-
course = tcx.TrainingCenterDatabase.Courses.Course[0]
17-
}
8+
import { parser } from './lib/parser'
9+
import type { CoursePointT, CoursePointTypeT, TCXT } from './types/TCX.type'
10+
import { TCX } from './types/TCX.type'
11+
12+
let tcx: TCXT | null = null
1813
1914
let files: FileList | null
20-
$: if (files) {
15+
$: if (files != null) {
2116
parse(files[0])
2217
}
2318
function parse(file: File) {
2419
file.text().then((text) => {
25-
tcx = parser.parse(text) as TCX
26-
console.log('parsed')
20+
const parsed = parser.parse(text)
21+
const decoded = TCX.decode(parsed)
22+
23+
if (isLeft(decoded)) {
24+
console.error(`Could not validate data: ${PathReporter.report(decoded).join('\n')}`)
25+
return
26+
}
27+
28+
tcx = decoded.right
2729
})
2830
}
2931
3032
let newCoursePointInput: {
3133
name: string | null
32-
type: CoursePointType | null
34+
type: CoursePointTypeT | null
3335
distanceKiloMeters: number | null
3436
} = { name: null, type: null, distanceKiloMeters: null }
3537
function handleAddingCoursePoint() {
3638
if (
37-
!(
38-
newCoursePointInput.name &&
39-
newCoursePointInput.type &&
40-
newCoursePointInput.distanceKiloMeters !== null
41-
)
39+
newCoursePointInput.name == null ||
40+
newCoursePointInput.type == null ||
41+
newCoursePointInput.distanceKiloMeters == null
4242
) {
4343
return
4444
}
4545
46-
if (!course || !course.Track || !course.Track[0]) {
46+
if (tcx?.TrainingCenterDatabase.Courses.Course[0] == null) {
4747
return
4848
}
4949
50+
const course = tcx.TrainingCenterDatabase.Courses.Course[0]
51+
5052
const trackPoint = findNearestTrackPoint(
5153
course.Track[0].Trackpoint,
5254
newCoursePointInput.distanceKiloMeters * 1000
5355
)
5456
55-
if (!course.CoursePoint) {
57+
if (course.CoursePoint == null) {
5658
return
5759
}
5860
59-
course.CoursePoint = [
60-
...course.CoursePoint,
61-
{
62-
Name: newCoursePointInput.name,
63-
Time: trackPoint.Time,
64-
Position: trackPoint.Position,
65-
PointType: newCoursePointInput.type,
66-
},
67-
].sort((lhs, rhs) => {
61+
const newCoursePoint: CoursePointT = {
62+
Name: newCoursePointInput.name,
63+
Time: trackPoint.Time,
64+
Position: trackPoint.Position,
65+
PointType: newCoursePointInput.type,
66+
}
67+
68+
course.CoursePoint = [...course.CoursePoint, newCoursePoint].sort((lhs, rhs) => {
6869
return lhs.Time.getTime() - rhs.Time.getTime()
6970
})
7071
72+
tcx.TrainingCenterDatabase.Courses.Course[0] = course
73+
7174
newCoursePointInput = { name: null, type: null, distanceKiloMeters: null }
7275
}
7376
7477
let downloadButton: HTMLAnchorElement
7578
function download() {
76-
if (!tcx?.TrainingCenterDatabase.Folders?.Courses?.CourseFolder.CourseNameRef) {
79+
if (tcx?.TrainingCenterDatabase.Folders.Courses.CourseFolder.CourseNameRef == null) {
7780
return
7881
}
7982
@@ -97,21 +100,28 @@
97100
}
98101
99102
function removeCoursePointAt(index: number, name: string): void {
100-
if (!(course?.CoursePoint && tcx?.TrainingCenterDatabase.Courses?.Course)) {
103+
if (tcx?.TrainingCenterDatabase.Courses.Course[0] == null) {
104+
return
105+
}
106+
107+
const course = tcx.TrainingCenterDatabase.Courses.Course[0]
108+
109+
if (course.CoursePoint == null) {
101110
return
102111
}
103112
104113
if (confirm(`"${name}"を本当に削除しますか?`)) {
105-
course?.CoursePoint?.splice(index, 1)
114+
course.CoursePoint.splice(index, 1)
106115
tcx.TrainingCenterDatabase.Courses.Course[0] = course
107116
}
108117
}
109118
</script>
110119

111120
<main>
112121
<div id="file-operations">
113-
{#if tcx}
122+
{#if tcx != null}
114123
<div>
124+
<!-- TODO: 日本語にする -->
115125
<button on:click|preventDefault={download}>Download</button>
116126
<a bind:this={downloadButton} hidden aria-hidden="true" />
117127

@@ -125,17 +135,14 @@
125135
{/if}
126136
</div>
127137

128-
{#if course?.CoursePoint}
138+
{#if tcx?.TrainingCenterDatabase.Courses.Course[0].CoursePoint != null}
129139
<div id="tcx-content">
130140
<form on:submit|preventDefault={handleAddingCoursePoint}>
131141
<div>
132142
<label>
133143
種別
134144
<select bind:value={newCoursePointInput.type} required>
135-
<option value="" hidden disabled selected />
136-
{#each Object.values(CoursePointType) as pointType}
137-
<option value={pointType}><Emoji coursePointType={pointType} /></option>
138-
{/each}
145+
<CoursePointTypeOptions />
139146
</select>
140147
</label>
141148
</div>
@@ -171,15 +178,15 @@
171178

172179
<table id="course-point-table">
173180
<tbody>
174-
{#if course?.Track}
175-
{#each course?.CoursePoint as coursePoint, index}
181+
{#if tcx?.TrainingCenterDatabase.Courses.Course[0].Track != null}
182+
{#each tcx.TrainingCenterDatabase.Courses.Course[0].CoursePoint as coursePoint, index}
176183
{@const trackPoint = findTimeEquivalentTrackPoint(
177-
course?.Track[0].Trackpoint,
184+
tcx.TrainingCenterDatabase.Courses.Course[0].Track[0].Trackpoint,
178185
coursePoint.Time
179186
)}
180187

181-
{#if trackPoint?.DistanceMeters}
182-
<CoursePoint
188+
{#if trackPoint?.DistanceMeters != null}
189+
<CoursePointRow
183190
{coursePoint}
184191
{trackPoint}
185192
on:remove={() => removeCoursePointAt(index, coursePoint.Name)}
@@ -205,13 +212,4 @@
205212
table#course-point-table {
206213
margin-top: 20px;
207214
}
208-
209-
@keyframes course-point-type-animation {
210-
from {
211-
background-color: unset;
212-
}
213-
to {
214-
background-color: #dadadaee;
215-
}
216-
}
217215
</style>

‎src/components/CoursePoint.svelte ‎src/components/CoursePointRow.svelte

+21-22
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,30 @@
11
<script lang="ts">
2-
import type { CoursePoint, TrackPoint } from '../types/TCX.type'
3-
import { CoursePointType } from '../types/TCX.type'
4-
import Emoji from './Emoji.svelte'
52
import { createEventDispatcher } from 'svelte'
3+
import type { CoursePointT, TrackPointT } from '../types/TCX.type'
4+
import CoursePointTypeOptions from './CoursePointTypeOptions.svelte'
65
7-
export let coursePoint: CoursePoint
8-
export let trackPoint: TrackPoint
6+
export let coursePoint: CoursePointT
7+
export let trackPoint: TrackPointT
98
109
const dispatch = createEventDispatcher()
1110
</script>
1211

1312
<tr id="course-point">
1413
<td>
1514
<select id="course-point-type" bind:value={coursePoint.PointType}>
16-
{#each Object.values(CoursePointType) as pointType}
17-
<option value={pointType}><Emoji coursePointType={pointType} /></option>
18-
{/each}
15+
<CoursePointTypeOptions />
1916
</select>
2017
</td>
2118
<td>
2219
<input id="course-point-name" bind:value={coursePoint.Name} type="string" maxlength="10" />
2320
</td>
2421
<td>
25-
{#if trackPoint.DistanceMeters}
26-
{Intl.NumberFormat(undefined, {
27-
style: 'unit',
28-
unit: 'kilometer',
29-
maximumFractionDigits: 1,
30-
minimumFractionDigits: 1,
31-
}).format(trackPoint.DistanceMeters / 1000)}
32-
{/if}
22+
{Intl.NumberFormat(undefined, {
23+
style: 'unit',
24+
unit: 'kilometer',
25+
maximumFractionDigits: 1,
26+
minimumFractionDigits: 1,
27+
}).format(trackPoint.DistanceMeters / 1000)}
3328
</td>
3429
<td>
3530
<a
@@ -54,6 +49,15 @@
5449
animation: course-point-type-animation 0.5s 1 normal forwards;
5550
}
5651
52+
@keyframes course-point-type-animation {
53+
from {
54+
background-color: unset;
55+
}
56+
to {
57+
background-color: #dadadaee;
58+
}
59+
}
60+
5761
tr#course-point select#course-point-type:focus-visible {
5862
background-color: #dadadaee;
5963
outline: unset;
@@ -73,11 +77,6 @@
7377
}
7478
7579
tr#course-point button {
76-
background: unset;
77-
border: unset;
78-
}
79-
80-
tr#course-point button:hover {
81-
cursor: pointer;
80+
margin-left: 12px;
8281
}
8382
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<script lang="ts">
2+
import { isLeft } from 'fp-ts/Either'
3+
import type { CoursePointTypeT } from '../types/TCX.type'
4+
import { CoursePointType, coursePointTypes } from '../types/TCX.type'
5+
6+
function decode(u: string): CoursePointTypeT | null {
7+
const decoded = CoursePointType.decode(u)
8+
9+
if (isLeft(decoded)) {
10+
return null
11+
}
12+
13+
return decoded.right
14+
}
15+
16+
function emoji(
17+
type: CoursePointTypeT | null
18+
):
19+
| '📍'
20+
| '🏔'
21+
| '⾕'
22+
| '🧃'
23+
| '🍱'
24+
| '⚠️'
25+
| '⬅'
26+
| '➡'
27+
| '⬆'
28+
| '🍙'
29+
| '4️⃣'
30+
| '🥉'
31+
| '🥈'
32+
| '🥇'
33+
| '🏆'
34+
| '🚴'
35+
| null {
36+
if (type === null) {
37+
return null
38+
}
39+
switch (type) {
40+
case 'Generic':
41+
return '📍'
42+
case 'Summit':
43+
return '🏔'
44+
case 'Valley':
45+
return ''
46+
case 'Water':
47+
return '🧃'
48+
case 'Food':
49+
return '🍱'
50+
case 'Danger':
51+
return '⚠️'
52+
case 'Left':
53+
return ''
54+
case 'Right':
55+
return ''
56+
case 'Straight':
57+
return ''
58+
case 'First Aid':
59+
return '🍙'
60+
case '4th Category':
61+
return '4️⃣'
62+
case '3rd Category':
63+
return '🥉'
64+
case '2nd Category':
65+
return '🥈'
66+
case '1st Category':
67+
return '🥇'
68+
case 'Hors Category':
69+
return '🏆'
70+
case 'Sprint':
71+
return '🚴'
72+
}
73+
}
74+
</script>
75+
76+
{#each coursePointTypes as pointType}
77+
{@const type = decode(pointType)}
78+
{#if type}
79+
<option value={type}>{emoji(type)}</option>
80+
{/if}
81+
{/each}

‎src/components/Emoji.svelte

-22
This file was deleted.

‎src/lib/TrackPoint+find.ts

+6-10
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import type { TrackPoint } from '../types/TCX.type'
1+
import type { TrackPointT } from '../types/TCX.type'
22

33
export function findNearestTrackPoint(
4-
trackPoints: TrackPoint[],
4+
trackPoints: TrackPointT[],
55
distanceMeters: number
6-
): TrackPoint {
6+
): TrackPointT {
77
const minIndexOverDistanceMeters = trackPoints.findIndex((trackPoint) => {
8-
if (trackPoint.DistanceMeters) {
9-
return trackPoint.DistanceMeters > distanceMeters
10-
} else {
11-
return false
12-
}
8+
return trackPoint.DistanceMeters > distanceMeters
139
})
1410

1511
if (minIndexOverDistanceMeters == -1) {
@@ -20,9 +16,9 @@ export function findNearestTrackPoint(
2016
}
2117

2218
export function findTimeEquivalentTrackPoint(
23-
trackPoints: TrackPoint[],
19+
trackPoints: TrackPointT[],
2420
time: Date
25-
): TrackPoint | null {
21+
): TrackPointT | null {
2622
let minIndexEquivalentTime = trackPoints.findIndex((trackPoint) => {
2723
return trackPoint.Time.getTime() === time.getTime()
2824
})

‎src/lib/parser.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@ export const parser = new XMLParser({
44
ignoreAttributes: false,
55
isArray: (name, jpath) => {
66
return (
7-
['CourseNameRef', 'Course', 'Track', 'CoursePoint'].includes(name) ||
7+
['CourseNameRef', 'Course', 'Track', 'Trackpoint', 'CoursePoint'].includes(name) ||
88
['Course.Lap'].some((candidate: string) => jpath.endsWith(candidate))
99
)
1010
},
11+
numberParseOptions: {
12+
leadingZeros: true,
13+
hex: false,
14+
eNotation: true,
15+
},
1116
tagValueProcessor: (tagName, tagValue) => {
12-
if (tagName == 'Time') {
13-
return new Date(tagValue)
14-
} else {
17+
if (tagName === 'Name') {
1518
return null
19+
} else {
20+
return tagValue
1621
}
1722
},
23+
parseTagValue: true,
24+
parseAttributeValue: true,
1825
})

‎src/types/TCX.type.ts

+104-42
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,117 @@
1-
export type TCX = {
2-
'?xml': string
3-
TrainingCenterDatabase: TrainingCenterDatabase
4-
}
1+
import { Chain } from 'fp-ts/Either'
2+
import * as t from 'io-ts'
53

6-
export type TrainingCenterDatabase = { Folders?: Folders; Courses?: CourseList }
4+
const DateFromString = new t.Type<Date, string, unknown>(
5+
'DateFromString',
6+
(u): u is Date => u instanceof Date,
7+
(u, c) =>
8+
Chain.chain(t.string.validate(u, c), (s) => {
9+
const d = new Date(s)
10+
return isNaN(d.getTime()) ? t.failure(u, c) : t.success(d)
11+
}),
12+
(a) => a.toISOString()
13+
)
714

8-
type Folders = { Courses?: Courses }
15+
export const coursePointTypes = [
16+
'Generic',
17+
'Summit',
18+
'Valley',
19+
'Water',
20+
'Food',
21+
'Danger',
22+
'Left',
23+
'Right',
24+
'Straight',
25+
'First Aid',
26+
'4th Category',
27+
'3rd Category',
28+
'2nd Category',
29+
'1st Category',
30+
'Hors Category',
31+
'Sprint',
32+
]
933

10-
type Courses = { CourseFolder: CourseFolder }
34+
export const CoursePointType = t.union([
35+
t.literal('Generic'),
36+
t.literal('Summit'),
37+
t.literal('Valley'),
38+
t.literal('Water'),
39+
t.literal('Food'),
40+
t.literal('Danger'),
41+
t.literal('Left'),
42+
t.literal('Right'),
43+
t.literal('Straight'),
44+
t.literal('First Aid'),
45+
t.literal('4th Category'),
46+
t.literal('3rd Category'),
47+
t.literal('2nd Category'),
48+
t.literal('1st Category'),
49+
t.literal('Hors Category'),
50+
t.literal('Sprint'),
51+
])
1152

12-
type CourseFolder = { CourseNameRef?: [NameKeyReference] }
53+
export type CoursePointTypeT = t.TypeOf<typeof CoursePointType>
1354

14-
type NameKeyReference = { Id: string }
55+
const Position = t.type({
56+
LatitudeDegrees: t.number,
57+
LongitudeDegrees: t.number,
58+
})
1559

16-
type CourseList = { Course: Course[] }
60+
const TrackPoint = t.type({
61+
Time: DateFromString,
62+
Position: Position,
63+
AltitudeMeters: t.number,
64+
DistanceMeters: t.number,
65+
})
1766

18-
export type Course = { Name: string; Lap?: Lap[]; Track?: Track[]; CoursePoint?: CoursePoint[] }
67+
export type TrackPointT = t.TypeOf<typeof TrackPoint>
1968

20-
type Lap = {
21-
TotalTimeSeconds: number
22-
DistanceMeters: number
23-
BeginPosition?: Position
24-
EndPosition?: Position
25-
}
69+
const Track = t.type({ Trackpoint: t.array(TrackPoint) })
2670

27-
type Position = {
28-
LatitudeDegrees: number
29-
LongitudeDegrees: number
30-
}
71+
const Lap = t.type({
72+
TotalTimeSeconds: t.number,
73+
DistanceMeters: t.number,
74+
BeginPosition: Position,
75+
EndPosition: Position,
76+
})
3177

32-
type Track = { Trackpoint: TrackPoint[] }
78+
const CoursePoint = t.type({
79+
Name: t.string,
80+
Time: DateFromString,
81+
Position: Position,
82+
PointType: CoursePointType,
83+
})
3384

34-
export type TrackPoint = {
35-
Time: Date
36-
Position: Position
37-
AltitudeMeters?: number
38-
DistanceMeters?: number
39-
}
85+
export type CoursePointT = t.TypeOf<typeof CoursePoint>
4086

41-
export type CoursePoint = {
42-
Name: string
43-
Time: Date
44-
Position: Position
45-
PointType: CoursePointType
46-
}
87+
const Course = t.type({
88+
Name: t.string,
89+
Lap: t.array(Lap),
90+
Track: t.array(Track),
91+
CoursePoint: t.union([t.array(CoursePoint), t.undefined]),
92+
})
4793

48-
export enum CoursePointType {
49-
Generic = 'Generic',
50-
FirstAid = 'First Aid',
51-
Left = 'Left',
52-
Right = 'Right',
53-
Straight = 'Straight',
54-
// TODO: 他のも追加する
55-
}
94+
const NameKeyReference = t.type({ Id: t.string })
95+
96+
const CourseFolder = t.type({ CourseNameRef: t.array(NameKeyReference) })
97+
98+
const Courses = t.type({ CourseFolder: CourseFolder })
99+
100+
const Folders = t.type({ Courses: Courses })
101+
102+
const CourseList = t.type({ Course: t.array(Course) })
103+
104+
const TrainingCenterDatabase = t.type({
105+
Folders: Folders,
106+
Courses: CourseList,
107+
})
108+
109+
export const TCX = t.type({
110+
'?xml': t.type({
111+
'@_version': t.number,
112+
'@_encoding': t.string,
113+
}),
114+
TrainingCenterDatabase: TrainingCenterDatabase,
115+
})
116+
117+
export type TCXT = t.TypeOf<typeof TCX>

‎vite.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { defineConfig } from 'vite'
21
import { svelte } from '@sveltejs/vite-plugin-svelte'
2+
import { defineConfig } from 'vite'
33

44
// https://vitejs.dev/config/
55
export default defineConfig({

0 commit comments

Comments
 (0)
Please sign in to comment.