Skip to content

Commit

Permalink
refactor: handle pagination + add from/to parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
xballoy committed Apr 19, 2024
1 parent d4e8f9e commit 71bb603
Show file tree
Hide file tree
Showing 11 changed files with 202 additions and 137 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
70 changes: 56 additions & 14 deletions src/command-runner/export-activities.command-runner.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,46 @@
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<void> {
async run(_passedParams: string[], { outDir, fileType, from, to }: Flags): Promise<void> {
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}`,
};
});

for (const { labelId, sportType, fileName } of activitiesToDownload) {
const { fileUrl } = await this.corosService.downloadActivityDetail({
labelId,
sportType,
fileType: parseReadableFileType(fileType),
fileType: fileType.value,
});
await this.downloadFileCommand.handle(fileUrl, outDir, fileName);
}
Expand All @@ -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 <fileType>',
choices: READABLE_FILE_TYPE,
flags: '--exportType <fileType>',
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 <from>',
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 <to>',
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();
}
}
6 changes: 6 additions & 0 deletions src/command-runner/invalid-parameter-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class InvalidParameterError extends Error {
constructor(parameterName: string, reason?: string) {
super(`Invalid parameter ${parameterName}${reason ? `: ${reason}` : ''}`);
this.name = 'InvalidParameterError';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
163 changes: 100 additions & 63 deletions src/coros/activity/query-activities.request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,68 +30,67 @@ export const QueryActivitiesInput = object({
});
export type QueryActivitiesInput = Input<typeof QueryActivitiesInput>;

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(),
});
Expand All @@ -100,8 +99,18 @@ export type QueryActivitiesData = Input<typeof QueryActivitiesData>;
export const QueryActivitiesResponse = CorosResponse(QueryActivitiesData);
export type QueryActivitiesResponse = Input<typeof QueryActivitiesResponse>;

export const QueryActivitiesOutput = object({
count: number(),
activities: array(Activity),
});
export type QueryActivitiesOutput = Input<typeof QueryActivitiesOutput>;

@Injectable()
export class QueryActivitiesRequest extends BaseRequest<QueryActivitiesInput, QueryActivitiesResponse> {
export class QueryActivitiesRequest extends BaseRequest<
QueryActivitiesInput,
QueryActivitiesResponse,
QueryActivitiesOutput
> {
constructor(
private readonly httpService: HttpService,
private readonly corosConfig: CorosConfigService,
Expand All @@ -118,7 +127,26 @@ export class QueryActivitiesRequest extends BaseRequest<QueryActivitiesInput, Qu
return QueryActivitiesResponse;
}

async handle({ pageSize = 20, pageNumber = 1, from, to }: QueryActivitiesInput): Promise<QueryActivitiesData> {
async handle({ pageSize = 20, pageNumber = 1, from, to }: QueryActivitiesInput): Promise<QueryActivitiesOutput> {
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<QueryActivitiesData['dataList']> {
const url = new URL('/activity/query', this.corosConfig.apiUrl);
url.searchParams.append('size', String(pageSize));
url.searchParams.append('pageNumber', String(pageNumber));
Expand All @@ -140,6 +168,15 @@ export class QueryActivitiesRequest extends BaseRequest<QueryActivitiesInput, Qu
this.assertCorosResponseBase(data);
this.assertCorosResponse(data);

return data.data;
const {
data: { dataList, totalPage },
} = data;

const activities = [...dataList];
if (pageNumber < totalPage) {
const next = await this.getActivities({ pageSize, pageNumber: pageNumber + 1, from, to });
activities.push(...next);
}
return activities;
}
}
43 changes: 33 additions & 10 deletions src/coros/file-type.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
export enum FileType {
fit = '4',
tcx = '3',
gpx = '1',
kml = '2',
csv = '0',
enum FileType {
fit,
tcx,
gpx,
kml,
csv,
}
export type FileTypeKey = keyof typeof FileType;

export class FileTypes {
private static readonly All: Record<FileType, { key: string; value: string }> = {
[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];
Loading

0 comments on commit 71bb603

Please sign in to comment.