Skip to content

Commit 71bb603

Browse files
committed
refactor: handle pagination + add from/to parameters
1 parent d4e8f9e commit 71bb603

File tree

11 files changed

+202
-137
lines changed

11 files changed

+202
-137
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"build": "nest build",
77
"format:check": "prettier --check src",
88
"format:fix": "prettier --write src",
9-
"start": "nest start",
9+
"start": "nest start --",
1010
"start:dev": "nest start --watch",
1111
"start:debug": "nest start --debug --watch",
1212
"start:prod": "node dist/main",

src/app.module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Module } from '@nestjs/common';
22
import { ExportActivitiesCommandRunner } from './command-runner/export-activities.command-runner';
3-
import { DownloadFileCommand } from './service/download-file.command';
3+
import { DownloadFile } from './core/download-file.service';
44
import { CorosModule } from './coros/coros.module';
55
import { HttpModule } from '@nestjs/axios';
66

77
@Module({
88
imports: [CorosModule, HttpModule],
9-
providers: [ExportActivitiesCommandRunner, DownloadFileCommand],
9+
providers: [ExportActivitiesCommandRunner, DownloadFile],
1010
})
1111
export class AppModule {}
Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,46 @@
11
import dayjs from 'dayjs';
22
import { Command, CommandRunner, Option } from 'nest-commander';
3-
import { DownloadFileCommand } from '../service/download-file.command';
3+
import { DownloadFile } from '../core/download-file.service';
44
import { CorosAPI } from '../coros/coros-api';
5-
import { DEFAULT_FILE_TYPE, parseReadableFileType, READABLE_FILE_TYPE, ReadableFileType } from '../coros/file-type';
5+
import { FileTypes } from '../coros/file-type';
6+
import { existsSync } from 'node:fs';
7+
import { InvalidParameterError } from './invalid-parameter-error';
68

79
type Flags = {
810
outDir: string;
9-
fileType: ReadableFileType;
11+
fileType: { key: string; value: string };
12+
from: Date;
13+
to: Date;
1014
};
1115

1216
@Command({ name: 'export-activities', description: 'Bulk export your Coros activities' })
1317
export class ExportActivitiesCommandRunner extends CommandRunner {
1418
constructor(
15-
private readonly downloadFileCommand: DownloadFileCommand,
19+
private readonly downloadFileCommand: DownloadFile,
1620
private readonly corosService: CorosAPI,
1721
) {
1822
super();
1923
}
2024

21-
async run(_passedParams: string[], { outDir, fileType }: Flags): Promise<void> {
25+
async run(_passedParams: string[], { outDir, fileType, from, to }: Flags): Promise<void> {
2226
await this.corosService.login();
2327

24-
const activities = await this.corosService.queryActivities({ size: 100, page: 1 });
25-
const activitiesToDownload = activities.dataList.map((it) => {
28+
const { activities } = await this.corosService.queryActivities({ from, to });
29+
const activitiesToDownload = activities.map((it) => {
2630
const activityDate = dayjs(String(it.date), 'YYYYMMDD');
2731

2832
return {
2933
labelId: it.labelId,
3034
sportType: it.sportType,
31-
fileName: `${activityDate.format('YYYY-MM-DD')} ${it.name.trim()} ${it.labelId}.${fileType}`,
35+
fileName: `${activityDate.format('YYYY-MM-DD')} ${it.name.trim()} ${it.labelId}.${fileType.key}`,
3236
};
3337
});
3438

3539
for (const { labelId, sportType, fileName } of activitiesToDownload) {
3640
const { fileUrl } = await this.corosService.downloadActivityDetail({
3741
labelId,
3842
sportType,
39-
fileType: parseReadableFileType(fileType),
43+
fileType: fileType.value,
4044
});
4145
await this.downloadFileCommand.handle(fileUrl, outDir, fileName);
4246
}
@@ -49,18 +53,56 @@ export class ExportActivitiesCommandRunner extends CommandRunner {
4953
required: true,
5054
})
5155
parseOutDir(out: string) {
56+
if (!existsSync(out)) {
57+
throw new InvalidParameterError('out', `${out} directory does not exists`);
58+
}
59+
5260
return out;
5361
}
5462

5563
@Option({
5664
name: 'fileType',
57-
flags: '-t, --type <fileType>',
58-
choices: READABLE_FILE_TYPE,
65+
flags: '--exportType <fileType>',
66+
choices: FileTypes.keys,
5967
description: 'Export data type',
60-
defaultValue: DEFAULT_FILE_TYPE,
68+
defaultValue: FileTypes.default.key,
69+
required: false,
70+
})
71+
parseFileType(fileType: string): { key: string; value: string } {
72+
if (!FileTypes.isValid(fileType)) {
73+
throw new InvalidParameterError('exportType', `Must be one of: ${FileTypes.keys.join(', ')}.`);
74+
}
75+
76+
return FileTypes.parse(fileType);
77+
}
78+
79+
@Option({
80+
name: 'from',
81+
flags: '--fromDate <from>',
82+
description: 'Export activities created after this date (inclusive). Format must be YYYY-MM-DD',
6183
required: false,
6284
})
63-
parseFileType(fileType: ReadableFileType): ReadableFileType {
64-
return fileType;
85+
parseFrom(from: string): Date {
86+
const maybeDate = dayjs(from, 'YYYY-MM-DD', true);
87+
if (!maybeDate.isValid()) {
88+
throw new InvalidParameterError('fromDate', 'Format must be YYYY-MM-DD');
89+
}
90+
91+
return maybeDate.toDate();
92+
}
93+
94+
@Option({
95+
name: 'to',
96+
flags: '--toDate <to>',
97+
description: 'Export activities created before this date (inclusive). Format must be YYYY-MM-DD',
98+
required: false,
99+
})
100+
parseTo(to: string): Date {
101+
const maybeDate = dayjs(to, 'YYYY-MM-DD', true);
102+
if (!maybeDate.isValid()) {
103+
throw new InvalidParameterError('toDate', 'Format must be YYYY-MM-DD');
104+
}
105+
106+
return maybeDate.toDate();
65107
}
66108
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class InvalidParameterError extends Error {
2+
constructor(parameterName: string, reason?: string) {
3+
super(`Invalid parameter ${parameterName}${reason ? `: ${reason}` : ''}`);
4+
this.name = 'InvalidParameterError';
5+
}
6+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { HttpService } from '@nestjs/axios';
55
import { Injectable } from '@nestjs/common';
66

77
@Injectable()
8-
export class DownloadFileCommand {
8+
export class DownloadFile {
99
constructor(private readonly httpService: HttpService) {}
1010

1111
async handle(url: string, directory: string, fileName: string): Promise<void> {

src/coros/activity/query-activities.request.ts

Lines changed: 100 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -30,68 +30,67 @@ export const QueryActivitiesInput = object({
3030
});
3131
export type QueryActivitiesInput = Input<typeof QueryActivitiesInput>;
3232

33+
export const Activity = object({
34+
// adjustedPace: optional(number()),
35+
// ascent: number(),
36+
// avg5x10s: number(),
37+
// avgCadence: number(),
38+
// avgHr: number(),
39+
// avgPower: optional(number()),
40+
// avgSpeed: number(),
41+
// avgStrkRate: number(),
42+
// best: number(),
43+
// best500m: optional(number()),
44+
// bestKm: optional(number()),
45+
// bestLen: optional(number()),
46+
// bodyTemperature: optional(number()),
47+
// cadence: number(),
48+
// calorie: number(),
49+
date: number(),
50+
// descent: number(),
51+
// device: string(),
52+
// deviceSportMode: optional(number()),
53+
// distance: number(),
54+
// downhillDesc: number(),
55+
// downhillDist: number(),
56+
// downhillTime: number(),
57+
// endTime: number(),
58+
// endTimezone: number(),
59+
// hasMessage: number(),
60+
// imageUrl: string(),
61+
// imageUrlType: number(),
62+
// isRunTest: number(),
63+
// isShowMs: number(),
64+
labelId: string(),
65+
// lengths: number(),
66+
// max2s: number(),
67+
// maxGrade: number(),
68+
// maxSlope: number(),
69+
// maxSpeed: number(),
70+
// mode: number(),
71+
name: string(),
72+
// np: number(),
73+
// pitch: number(),
74+
// sets: number(),
75+
// speedType: number(),
76+
sportType: number(),
77+
// startTime: number(),
78+
// startTimezone: number(),
79+
// step: number(),
80+
// subMode: number(),
81+
// swolf: optional(number()),
82+
// total: number(),
83+
// totalDescent: number(),
84+
// totalReps: number(),
85+
// totalTime: number(),
86+
// trainingLoad: number(),
87+
// unitType: number(),
88+
// waterTemperature: optional(number()),
89+
// workoutTime: number(),
90+
});
3391
export const QueryActivitiesData = object({
3492
count: number(),
35-
dataList: array(
36-
object({
37-
adjustedPace: number(),
38-
ascent: number(),
39-
avg5x10s: number(),
40-
avgCadence: number(),
41-
avgHr: number(),
42-
avgPower: number(),
43-
avgSpeed: number(),
44-
avgStrkRate: number(),
45-
best: number(),
46-
best500m: number(),
47-
bestKm: number(),
48-
bestLen: number(),
49-
bodyTemperature: number(),
50-
cadence: number(),
51-
calorie: number(),
52-
date: number(),
53-
descent: number(),
54-
device: string(),
55-
deviceSportMode: number(),
56-
distance: number(),
57-
downhillDesc: number(),
58-
downhillDist: number(),
59-
downhillTime: number(),
60-
endTime: number(),
61-
endTimezone: number(),
62-
hasMessage: number(),
63-
imageUrl: string(),
64-
imageUrlType: number(),
65-
isRunTest: number(),
66-
isShowMs: number(),
67-
labelId: string(),
68-
lengths: number(),
69-
max2s: number(),
70-
maxGrade: number(),
71-
maxSlope: number(),
72-
maxSpeed: number(),
73-
mode: number(),
74-
name: string(),
75-
np: number(),
76-
pitch: number(),
77-
sets: number(),
78-
speedType: number(),
79-
sportType: number(),
80-
startTime: number(),
81-
startTimezone: number(),
82-
step: number(),
83-
subMode: number(),
84-
swolf: number(),
85-
total: number(),
86-
totalDescent: number(),
87-
totalReps: number(),
88-
totalTime: number(),
89-
trainingLoad: number(),
90-
unitType: number(),
91-
waterTemperature: number(),
92-
workoutTime: number(),
93-
}),
94-
),
93+
dataList: array(Activity),
9594
pageNumber: number(),
9695
totalPage: number(),
9796
});
@@ -100,8 +99,18 @@ export type QueryActivitiesData = Input<typeof QueryActivitiesData>;
10099
export const QueryActivitiesResponse = CorosResponse(QueryActivitiesData);
101100
export type QueryActivitiesResponse = Input<typeof QueryActivitiesResponse>;
102101

102+
export const QueryActivitiesOutput = object({
103+
count: number(),
104+
activities: array(Activity),
105+
});
106+
export type QueryActivitiesOutput = Input<typeof QueryActivitiesOutput>;
107+
103108
@Injectable()
104-
export class QueryActivitiesRequest extends BaseRequest<QueryActivitiesInput, QueryActivitiesResponse> {
109+
export class QueryActivitiesRequest extends BaseRequest<
110+
QueryActivitiesInput,
111+
QueryActivitiesResponse,
112+
QueryActivitiesOutput
113+
> {
105114
constructor(
106115
private readonly httpService: HttpService,
107116
private readonly corosConfig: CorosConfigService,
@@ -118,7 +127,26 @@ export class QueryActivitiesRequest extends BaseRequest<QueryActivitiesInput, Qu
118127
return QueryActivitiesResponse;
119128
}
120129

121-
async handle({ pageSize = 20, pageNumber = 1, from, to }: QueryActivitiesInput): Promise<QueryActivitiesData> {
130+
async handle({ pageSize = 20, pageNumber = 1, from, to }: QueryActivitiesInput): Promise<QueryActivitiesOutput> {
131+
const activities = await this.getActivities({ pageSize, pageNumber, from, to });
132+
133+
return {
134+
count: activities.length,
135+
activities,
136+
};
137+
}
138+
139+
private async getActivities({
140+
pageSize,
141+
pageNumber,
142+
from,
143+
to,
144+
}: {
145+
pageSize: number;
146+
pageNumber: number;
147+
from?: Date;
148+
to?: Date;
149+
}): Promise<QueryActivitiesData['dataList']> {
122150
const url = new URL('/activity/query', this.corosConfig.apiUrl);
123151
url.searchParams.append('size', String(pageSize));
124152
url.searchParams.append('pageNumber', String(pageNumber));
@@ -140,6 +168,15 @@ export class QueryActivitiesRequest extends BaseRequest<QueryActivitiesInput, Qu
140168
this.assertCorosResponseBase(data);
141169
this.assertCorosResponse(data);
142170

143-
return data.data;
171+
const {
172+
data: { dataList, totalPage },
173+
} = data;
174+
175+
const activities = [...dataList];
176+
if (pageNumber < totalPage) {
177+
const next = await this.getActivities({ pageSize, pageNumber: pageNumber + 1, from, to });
178+
activities.push(...next);
179+
}
180+
return activities;
144181
}
145182
}

src/coros/file-type.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
1-
export enum FileType {
2-
fit = '4',
3-
tcx = '3',
4-
gpx = '1',
5-
kml = '2',
6-
csv = '0',
1+
enum FileType {
2+
fit,
3+
tcx,
4+
gpx,
5+
kml,
6+
csv,
7+
}
8+
export type FileTypeKey = keyof typeof FileType;
9+
10+
export class FileTypes {
11+
private static readonly All: Record<FileType, { key: string; value: string }> = {
12+
[FileType.fit]: { key: 'fit', value: '4' },
13+
[FileType.tcx]: { key: 'tcx', value: '3' },
14+
[FileType.gpx]: { key: 'gpx', value: '1' },
15+
[FileType.kml]: { key: 'kml', value: '2' },
16+
[FileType.csv]: { key: 'csv', value: '0' },
17+
};
18+
19+
static get keys() {
20+
return Object.values(FileTypes.All).map(({ key }) => key);
21+
}
22+
23+
static get default() {
24+
return FileTypes.All[FileType.fit];
25+
}
26+
27+
static parse(value: FileTypeKey) {
28+
return FileTypes.All[FileType[value]];
29+
}
30+
31+
static isValid(value: string): value is FileTypeKey {
32+
return Object.keys(FileType).some((it) => it === value);
33+
}
734
}
8-
export type ReadableFileType = keyof typeof FileType;
9-
export const READABLE_FILE_TYPE = Object.keys(FileType);
10-
export const DEFAULT_FILE_TYPE = 'fit';
11-
export const parseReadableFileType = (value: ReadableFileType): FileType => FileType[value];

0 commit comments

Comments
 (0)