From 71bb6039f45ddd13bfabe0c0a09f1ae970c58e8a Mon Sep 17 00:00:00 2001 From: Xavier Balloy <686305+xballoy@users.noreply.github.com> Date: Fri, 19 Apr 2024 16:05:53 -0400 Subject: [PATCH] refactor: handle pagination + add from/to parameters --- package.json | 2 +- src/app.module.ts | 4 +- .../export-activities.command-runner.ts | 70 ++++++-- src/command-runner/invalid-parameter-error.ts | 6 + .../download-file.service.ts} | 2 +- .../activity/query-activities.request.ts | 163 +++++++++++------- src/coros/file-type.ts | 43 +++-- src/main.ts | 3 + src/service/common.ts | 17 -- src/service/index.ts | 1 - src/service/training-type.ts | 28 --- 11 files changed, 202 insertions(+), 137 deletions(-) create mode 100644 src/command-runner/invalid-parameter-error.ts rename src/{service/download-file.command.ts => core/download-file.service.ts} (94%) delete mode 100644 src/service/common.ts delete mode 100644 src/service/index.ts delete mode 100644 src/service/training-type.ts diff --git a/package.json b/package.json index cfc5ab7..13797c9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build": "nest build", "format:check": "prettier --check src", "format:fix": "prettier --write src", - "start": "nest start", + "start": "nest start --", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", diff --git a/src/app.module.ts b/src/app.module.ts index 560d103..1086313 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { ExportActivitiesCommandRunner } from './command-runner/export-activities.command-runner'; -import { DownloadFileCommand } from './service/download-file.command'; +import { DownloadFile } from './core/download-file.service'; import { CorosModule } from './coros/coros.module'; import { HttpModule } from '@nestjs/axios'; @Module({ imports: [CorosModule, HttpModule], - providers: [ExportActivitiesCommandRunner, DownloadFileCommand], + providers: [ExportActivitiesCommandRunner, DownloadFile], }) export class AppModule {} diff --git a/src/command-runner/export-activities.command-runner.ts b/src/command-runner/export-activities.command-runner.ts index fc08add..d33aac1 100644 --- a/src/command-runner/export-activities.command-runner.ts +++ b/src/command-runner/export-activities.command-runner.ts @@ -1,34 +1,38 @@ import dayjs from 'dayjs'; import { Command, CommandRunner, Option } from 'nest-commander'; -import { DownloadFileCommand } from '../service/download-file.command'; +import { DownloadFile } from '../core/download-file.service'; import { CorosAPI } from '../coros/coros-api'; -import { DEFAULT_FILE_TYPE, parseReadableFileType, READABLE_FILE_TYPE, ReadableFileType } from '../coros/file-type'; +import { FileTypes } from '../coros/file-type'; +import { existsSync } from 'node:fs'; +import { InvalidParameterError } from './invalid-parameter-error'; type Flags = { outDir: string; - fileType: ReadableFileType; + fileType: { key: string; value: string }; + from: Date; + to: Date; }; @Command({ name: 'export-activities', description: 'Bulk export your Coros activities' }) export class ExportActivitiesCommandRunner extends CommandRunner { constructor( - private readonly downloadFileCommand: DownloadFileCommand, + private readonly downloadFileCommand: DownloadFile, private readonly corosService: CorosAPI, ) { super(); } - async run(_passedParams: string[], { outDir, fileType }: Flags): Promise { + async run(_passedParams: string[], { outDir, fileType, from, to }: Flags): Promise { await this.corosService.login(); - const activities = await this.corosService.queryActivities({ size: 100, page: 1 }); - const activitiesToDownload = activities.dataList.map((it) => { + const { activities } = await this.corosService.queryActivities({ from, to }); + const activitiesToDownload = activities.map((it) => { const activityDate = dayjs(String(it.date), 'YYYYMMDD'); return { labelId: it.labelId, sportType: it.sportType, - fileName: `${activityDate.format('YYYY-MM-DD')} ${it.name.trim()} ${it.labelId}.${fileType}`, + fileName: `${activityDate.format('YYYY-MM-DD')} ${it.name.trim()} ${it.labelId}.${fileType.key}`, }; }); @@ -36,7 +40,7 @@ export class ExportActivitiesCommandRunner extends CommandRunner { const { fileUrl } = await this.corosService.downloadActivityDetail({ labelId, sportType, - fileType: parseReadableFileType(fileType), + fileType: fileType.value, }); await this.downloadFileCommand.handle(fileUrl, outDir, fileName); } @@ -49,18 +53,56 @@ export class ExportActivitiesCommandRunner extends CommandRunner { required: true, }) parseOutDir(out: string) { + if (!existsSync(out)) { + throw new InvalidParameterError('out', `${out} directory does not exists`); + } + return out; } @Option({ name: 'fileType', - flags: '-t, --type ', - choices: READABLE_FILE_TYPE, + flags: '--exportType ', + choices: FileTypes.keys, description: 'Export data type', - defaultValue: DEFAULT_FILE_TYPE, + defaultValue: FileTypes.default.key, + required: false, + }) + parseFileType(fileType: string): { key: string; value: string } { + if (!FileTypes.isValid(fileType)) { + throw new InvalidParameterError('exportType', `Must be one of: ${FileTypes.keys.join(', ')}.`); + } + + return FileTypes.parse(fileType); + } + + @Option({ + name: 'from', + flags: '--fromDate ', + description: 'Export activities created after this date (inclusive). Format must be YYYY-MM-DD', required: false, }) - parseFileType(fileType: ReadableFileType): ReadableFileType { - return fileType; + parseFrom(from: string): Date { + const maybeDate = dayjs(from, 'YYYY-MM-DD', true); + if (!maybeDate.isValid()) { + throw new InvalidParameterError('fromDate', 'Format must be YYYY-MM-DD'); + } + + return maybeDate.toDate(); + } + + @Option({ + name: 'to', + flags: '--toDate ', + description: 'Export activities created before this date (inclusive). Format must be YYYY-MM-DD', + required: false, + }) + parseTo(to: string): Date { + const maybeDate = dayjs(to, 'YYYY-MM-DD', true); + if (!maybeDate.isValid()) { + throw new InvalidParameterError('toDate', 'Format must be YYYY-MM-DD'); + } + + return maybeDate.toDate(); } } diff --git a/src/command-runner/invalid-parameter-error.ts b/src/command-runner/invalid-parameter-error.ts new file mode 100644 index 0000000..abdac22 --- /dev/null +++ b/src/command-runner/invalid-parameter-error.ts @@ -0,0 +1,6 @@ +export class InvalidParameterError extends Error { + constructor(parameterName: string, reason?: string) { + super(`Invalid parameter ${parameterName}${reason ? `: ${reason}` : ''}`); + this.name = 'InvalidParameterError'; + } +} diff --git a/src/service/download-file.command.ts b/src/core/download-file.service.ts similarity index 94% rename from src/service/download-file.command.ts rename to src/core/download-file.service.ts index eda4bab..aad3e13 100644 --- a/src/service/download-file.command.ts +++ b/src/core/download-file.service.ts @@ -5,7 +5,7 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; @Injectable() -export class DownloadFileCommand { +export class DownloadFile { constructor(private readonly httpService: HttpService) {} async handle(url: string, directory: string, fileName: string): Promise { diff --git a/src/coros/activity/query-activities.request.ts b/src/coros/activity/query-activities.request.ts index 70af264..a1dc33e 100644 --- a/src/coros/activity/query-activities.request.ts +++ b/src/coros/activity/query-activities.request.ts @@ -30,68 +30,67 @@ export const QueryActivitiesInput = object({ }); export type QueryActivitiesInput = Input; +export const Activity = object({ + // adjustedPace: optional(number()), + // ascent: number(), + // avg5x10s: number(), + // avgCadence: number(), + // avgHr: number(), + // avgPower: optional(number()), + // avgSpeed: number(), + // avgStrkRate: number(), + // best: number(), + // best500m: optional(number()), + // bestKm: optional(number()), + // bestLen: optional(number()), + // bodyTemperature: optional(number()), + // cadence: number(), + // calorie: number(), + date: number(), + // descent: number(), + // device: string(), + // deviceSportMode: optional(number()), + // distance: number(), + // downhillDesc: number(), + // downhillDist: number(), + // downhillTime: number(), + // endTime: number(), + // endTimezone: number(), + // hasMessage: number(), + // imageUrl: string(), + // imageUrlType: number(), + // isRunTest: number(), + // isShowMs: number(), + labelId: string(), + // lengths: number(), + // max2s: number(), + // maxGrade: number(), + // maxSlope: number(), + // maxSpeed: number(), + // mode: number(), + name: string(), + // np: number(), + // pitch: number(), + // sets: number(), + // speedType: number(), + sportType: number(), + // startTime: number(), + // startTimezone: number(), + // step: number(), + // subMode: number(), + // swolf: optional(number()), + // total: number(), + // totalDescent: number(), + // totalReps: number(), + // totalTime: number(), + // trainingLoad: number(), + // unitType: number(), + // waterTemperature: optional(number()), + // workoutTime: number(), +}); export const QueryActivitiesData = object({ count: number(), - dataList: array( - object({ - adjustedPace: number(), - ascent: number(), - avg5x10s: number(), - avgCadence: number(), - avgHr: number(), - avgPower: number(), - avgSpeed: number(), - avgStrkRate: number(), - best: number(), - best500m: number(), - bestKm: number(), - bestLen: number(), - bodyTemperature: number(), - cadence: number(), - calorie: number(), - date: number(), - descent: number(), - device: string(), - deviceSportMode: number(), - distance: number(), - downhillDesc: number(), - downhillDist: number(), - downhillTime: number(), - endTime: number(), - endTimezone: number(), - hasMessage: number(), - imageUrl: string(), - imageUrlType: number(), - isRunTest: number(), - isShowMs: number(), - labelId: string(), - lengths: number(), - max2s: number(), - maxGrade: number(), - maxSlope: number(), - maxSpeed: number(), - mode: number(), - name: string(), - np: number(), - pitch: number(), - sets: number(), - speedType: number(), - sportType: number(), - startTime: number(), - startTimezone: number(), - step: number(), - subMode: number(), - swolf: number(), - total: number(), - totalDescent: number(), - totalReps: number(), - totalTime: number(), - trainingLoad: number(), - unitType: number(), - waterTemperature: number(), - workoutTime: number(), - }), - ), + dataList: array(Activity), pageNumber: number(), totalPage: number(), }); @@ -100,8 +99,18 @@ export type QueryActivitiesData = Input; export const QueryActivitiesResponse = CorosResponse(QueryActivitiesData); export type QueryActivitiesResponse = Input; +export const QueryActivitiesOutput = object({ + count: number(), + activities: array(Activity), +}); +export type QueryActivitiesOutput = Input; + @Injectable() -export class QueryActivitiesRequest extends BaseRequest { +export class QueryActivitiesRequest extends BaseRequest< + QueryActivitiesInput, + QueryActivitiesResponse, + QueryActivitiesOutput +> { constructor( private readonly httpService: HttpService, private readonly corosConfig: CorosConfigService, @@ -118,7 +127,26 @@ export class QueryActivitiesRequest extends BaseRequest { + async handle({ pageSize = 20, pageNumber = 1, from, to }: QueryActivitiesInput): Promise { + const activities = await this.getActivities({ pageSize, pageNumber, from, to }); + + return { + count: activities.length, + activities, + }; + } + + private async getActivities({ + pageSize, + pageNumber, + from, + to, + }: { + pageSize: number; + pageNumber: number; + from?: Date; + to?: Date; + }): Promise { const url = new URL('/activity/query', this.corosConfig.apiUrl); url.searchParams.append('size', String(pageSize)); url.searchParams.append('pageNumber', String(pageNumber)); @@ -140,6 +168,15 @@ export class QueryActivitiesRequest extends BaseRequest = { + [FileType.fit]: { key: 'fit', value: '4' }, + [FileType.tcx]: { key: 'tcx', value: '3' }, + [FileType.gpx]: { key: 'gpx', value: '1' }, + [FileType.kml]: { key: 'kml', value: '2' }, + [FileType.csv]: { key: 'csv', value: '0' }, + }; + + static get keys() { + return Object.values(FileTypes.All).map(({ key }) => key); + } + + static get default() { + return FileTypes.All[FileType.fit]; + } + + static parse(value: FileTypeKey) { + return FileTypes.All[FileType[value]]; + } + + static isValid(value: string): value is FileTypeKey { + return Object.keys(FileType).some((it) => it === value); + } } -export type ReadableFileType = keyof typeof FileType; -export const READABLE_FILE_TYPE = Object.keys(FileType); -export const DEFAULT_FILE_TYPE = 'fit'; -export const parseReadableFileType = (value: ReadableFileType): FileType => FileType[value]; diff --git a/src/main.ts b/src/main.ts index 5838e54..58fdbe7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,8 @@ +import dayjs from 'dayjs'; import { AppModule } from './app.module'; import { CommandFactory } from 'nest-commander'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +dayjs.extend(customParseFormat); async function bootstrap() { await CommandFactory.run(AppModule, ['warn', 'error']); diff --git a/src/service/common.ts b/src/service/common.ts deleted file mode 100644 index 45b0b70..0000000 --- a/src/service/common.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { object, ObjectEntries, ObjectSchema, string, merge } from 'valibot'; - -export const BASE_URL = 'https://teamapi.coros.com'; - -export const CorosResponseBase = object({ - apiCode: string(), - message: string(), - result: string(), -}); - -export const CorosResponse = (dataSchema: ObjectSchema) => - merge([ - CorosResponseBase, - object({ - data: dataSchema, - }), - ]); diff --git a/src/service/index.ts b/src/service/index.ts deleted file mode 100644 index 487acb3..0000000 --- a/src/service/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TrainingType } from './training-type.js'; diff --git a/src/service/training-type.ts b/src/service/training-type.ts deleted file mode 100644 index 278ad11..0000000 --- a/src/service/training-type.ts +++ /dev/null @@ -1,28 +0,0 @@ -export enum TrainingType { - Run = '100', - IndoorRun = '101', - TrackRun = '103', - Hike = '104', - MtnClimb = '105', - MultiPitch = '10003', - Bike = '299,200', - IndoorBike = '201', - PoolSwim = '300', - OpenWater = '301', - Strength = '402', - Walk = '900', - GymCardio = '400', - GpsCardio = '401', - Ski = '500', - Snowboard = '501', - XcSki = '502', - SkiTouring = '503,10002', - MultiSport = '10001', - Triathlon = '10000', - Speedsurfing = '706', - Windsurfing = '705', - Rowing = '700', - IndoorRower = '701', - Whitewater = '702', - Flatwater = '704', -}