Skip to content

Commit da226f9

Browse files
committed
feat: add tcx2webvtt cli script
1 parent 309a1fd commit da226f9

File tree

6 files changed

+250
-23
lines changed

6 files changed

+250
-23
lines changed

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ Pro XML export) to omit parts of the output and ensure synchronization with an e
99

1010
- [x] Set up vitest browser testing that will enable testing of WebVTT output
1111
- [x] Class to generate WebVTT file from array of `Sample`s
12-
- [] Create a CLI Node.js executable that takes the XML file as an argument and outputs
13-
WebVTT.
12+
- [x] Create a CLI Node.js executable that takes the XML file as an argument and outputs
13+
WebVTT.
1414
- [] Parse timecode data from a Final Cut Pro XML export
1515
- [] Create a class that uses the timecode info from the FCP XML and creates a list of
1616
datetime intervals that the video project includes

fixtures/invalid.tcx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<?xml version="1.0"?><unclosed

package-lock.json

Lines changed: 24 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"dist",
1717
"src"
1818
],
19+
"bin": {
20+
"tcx2webvtt": "dist/bin/tcx2webvtt.js"
21+
},
1922
"type": "module",
2023
"scripts": {
2124
"prebuild": "npm run clean",

src/bin/tcx2webvtt.spec.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { join } from 'node:path'
2+
import { fileURLToPath } from 'node:url'
3+
import { describe, it, expect, beforeEach, vi } from 'vitest'
4+
5+
import { main, ProcessLike } from './tcx2webvtt.js'
6+
7+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
8+
9+
describe('tcx2webvtt CLI', () => {
10+
let mockProcess: ProcessLike
11+
let stdout: string
12+
let stderr: string
13+
14+
beforeEach(async () => {
15+
// Reset output capture variables
16+
stdout = ''
17+
stderr = ''
18+
19+
// Mock process with string buffers
20+
mockProcess = {
21+
argv: ['node', 'tcx2webvtt.js'],
22+
stdout: {
23+
write(data: string) {
24+
stdout += data
25+
},
26+
},
27+
stderr: {
28+
write(data: string) {
29+
stderr += data
30+
},
31+
},
32+
exit: vi.fn(),
33+
}
34+
})
35+
36+
it('should display help when --help flag is used', async () => {
37+
mockProcess.argv.push('--help')
38+
39+
await main(mockProcess)
40+
41+
expect(stdout).toContain('Usage:')
42+
expect(stdout).toContain('Options:')
43+
expect(stdout).toContain('--help')
44+
expect(stdout).toContain('--version')
45+
expect(mockProcess.exit).toHaveBeenCalledWith(0)
46+
})
47+
48+
it('should display version when --version flag is used', async () => {
49+
mockProcess.argv.push('--version')
50+
51+
await main(mockProcess)
52+
53+
expect(stdout).toMatch(/\d+\.\d+\.\d+/)
54+
expect(mockProcess.exit).toHaveBeenCalledWith(0)
55+
})
56+
57+
it('should require an input file', async () => {
58+
await main(mockProcess)
59+
60+
expect(stderr).toContain('Input file is required')
61+
expect(mockProcess.exit).toHaveBeenCalledWith(1)
62+
})
63+
64+
it('should check if input file exists', async () => {
65+
mockProcess.argv.push('nonexistent.tcx')
66+
67+
await main(mockProcess)
68+
69+
expect(stderr).toContain('Input file does not exist')
70+
expect(mockProcess.exit).toHaveBeenCalledWith(1)
71+
})
72+
73+
it('should convert TCX to WebVTT and output to stdout', async () => {
74+
const fixtureFile = join(__dirname, '../../fixtures/concept2.tcx')
75+
mockProcess.argv.push(fixtureFile)
76+
77+
await main(mockProcess)
78+
79+
// Verify the output is WebVTT format
80+
expect(stdout).toContain('WEBVTT')
81+
expect(stdout).toContain('-->')
82+
expect(stdout).toContain('"metric"')
83+
expect(mockProcess.exit).not.toHaveBeenCalled()
84+
})
85+
86+
it('should handle invalid TCX files', async () => {
87+
const invalidXmlFile = join(__dirname, '../../fixtures/invalid.tcx')
88+
89+
mockProcess.argv.push(invalidXmlFile)
90+
91+
await main(mockProcess)
92+
93+
expect(stderr).not.toBe('')
94+
expect(mockProcess.exit).toHaveBeenCalledWith(1)
95+
})
96+
97+
it('should handle TCX files with no trackpoints', async () => {
98+
// Use the fixture with no activities
99+
const fixtureFile = join(__dirname, '../../fixtures/no-activities.tcx')
100+
101+
mockProcess.argv.push(fixtureFile)
102+
103+
await main(mockProcess)
104+
105+
// Should still output a valid WebVTT header even with no samples
106+
expect(stdout).toBe('WEBVTT')
107+
expect(mockProcess.exit).not.toHaveBeenCalled()
108+
})
109+
})

src/bin/tcx2webvtt.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env node
2+
3+
import { existsSync } from 'node:fs'
4+
import { readFile } from 'node:fs/promises'
5+
import { join, dirname } from 'node:path'
6+
import { fileURLToPath } from 'node:url'
7+
import { parseArgs } from 'node:util'
8+
9+
import { TCXReader } from '../lib/tcx-reader.js'
10+
import { WebVTTGenerator } from '../lib/webvtt-generator.js'
11+
12+
const __filename = fileURLToPath(import.meta.url)
13+
const __dirname = dirname(__filename)
14+
15+
function displayHelp(stdout: { write: (data: string) => void }) {
16+
stdout.write(`
17+
Usage: tcx2webvtt [options] <input-file>
18+
19+
Convert TCX workout files to WebVTT format with embedded JSON metadata.
20+
21+
Options:
22+
-h, --help Display this help message
23+
-v, --version Display version information
24+
25+
Examples:
26+
tcx2webvtt workout.tcx > workout.vtt
27+
`)
28+
}
29+
30+
export interface ProcessLike {
31+
argv: string[]
32+
stdout: {
33+
write: (data: string) => void
34+
}
35+
stderr: {
36+
write: (data: string) => void
37+
}
38+
exit: (code: number) => void
39+
}
40+
41+
export async function main(proc: ProcessLike) {
42+
try {
43+
// Parse command line arguments
44+
const { values, positionals } = parseArgs({
45+
args: proc.argv.slice(2),
46+
options: {
47+
help: { type: 'boolean', short: 'h' },
48+
version: { type: 'boolean', short: 'v' },
49+
},
50+
allowPositionals: true,
51+
})
52+
53+
// Display help
54+
if (values.help) {
55+
displayHelp(proc.stdout)
56+
proc.exit(0)
57+
}
58+
59+
// Display version
60+
if (values.version) {
61+
const packagePath = join(__dirname, '../..', 'package.json')
62+
const packageData = JSON.parse(await readFile(packagePath, 'utf-8'))
63+
proc.stdout.write(packageData.version)
64+
proc.exit(0)
65+
}
66+
67+
// Check if input file is provided
68+
if (positionals.length === 0) {
69+
proc.stderr.write('Error: Input file is required\n')
70+
displayHelp(proc.stdout)
71+
proc.exit(1)
72+
}
73+
74+
const inputFile = positionals[0]
75+
76+
// Check if input file exists
77+
if (!existsSync(inputFile)) {
78+
proc.stderr.write(`Error: Input file does not exist: ${inputFile}\n`)
79+
proc.exit(1)
80+
}
81+
82+
// Read TCX file
83+
const tcxContent = await readFile(inputFile, 'utf-8')
84+
85+
try {
86+
// Parse TCX
87+
const tcxReader = new TCXReader(tcxContent)
88+
const samples = tcxReader.getSamples()
89+
90+
// Generate WebVTT
91+
const webvttGenerator = new WebVTTGenerator()
92+
const webvttOutput = webvttGenerator.generate(samples)
93+
94+
// Output to stdout
95+
proc.stdout.write(webvttOutput)
96+
} catch (error) {
97+
// Handle any processing errors
98+
proc.stderr.write(`Error processing file: ${error}\n`)
99+
proc.exit(1)
100+
}
101+
} catch (error) {
102+
proc.stderr.write(`Error: ${error}\n`)
103+
proc.exit(1)
104+
}
105+
}
106+
107+
/* c8 ignore start */
108+
if (fileURLToPath(import.meta.url) === process.argv[1]) {
109+
await main(process)
110+
}
111+
/* c8 ignore stop */

0 commit comments

Comments
 (0)