From cd3443be5107de8e61b8e2065bb39603f0d31614 Mon Sep 17 00:00:00 2001 From: jifshan Date: Wed, 3 Jun 2026 20:56:38 +0800 Subject: [PATCH] support sync / migrate wellness data --- .env | 33 +- .../migrate_garmin_cn_to_garmin_global.yml | 21 +- .../migrate_garmin_global_to_garmin_cn.yml | 21 +- .../sync_garmin_cn_to_garmin_global.yml | 21 +- .../sync_garmin_global_to_garmin_cn.yml | 21 +- .gitignore | 3 + Dockerfile | 2 +- README.md | 148 +++++++-- package.json | 8 + src/constant.ts | 67 +++- src/migrate_all_garmin_cn_to_global.ts | 19 ++ src/migrate_all_garmin_global_to_cn.ts | 19 ++ src/migrate_wellness_garmin_cn_to_global.ts | 23 ++ src/migrate_wellness_garmin_global_to_cn.ts | 23 ++ src/sync_all_garmin_cn_to_global.ts | 19 ++ src/sync_all_garmin_global_to_cn.ts | 19 ++ src/sync_wellness_garmin_cn_to_global.ts | 23 ++ src/sync_wellness_garmin_global_to_cn.ts | 23 ++ src/utils/garmin_cn.ts | 42 ++- src/utils/garmin_global.ts | 44 ++- src/utils/garmin_wellness.ts | 290 ++++++++++++++++++ 21 files changed, 836 insertions(+), 53 deletions(-) create mode 100644 src/migrate_all_garmin_cn_to_global.ts create mode 100644 src/migrate_all_garmin_global_to_cn.ts create mode 100644 src/migrate_wellness_garmin_cn_to_global.ts create mode 100644 src/migrate_wellness_garmin_global_to_cn.ts create mode 100644 src/sync_all_garmin_cn_to_global.ts create mode 100644 src/sync_all_garmin_global_to_cn.ts create mode 100644 src/sync_wellness_garmin_cn_to_global.ts create mode 100644 src/sync_wellness_garmin_global_to_cn.ts create mode 100644 src/utils/garmin_wellness.ts diff --git a/.env b/.env index 0a7acdbe..0362720f 100644 --- a/.env +++ b/.env @@ -1,9 +1,24 @@ -# 佳明国区账号密码 -GARMIN_USERNAME_DEFAULT=; -GARMIN_PASSWORD_DEFAULT=; -# 佳明国际区账号密码 -GARMIN_GLOBAL_USERNAME_DEFAULT=; -GARMIN_GLOBAL_PASSWORD_DEFAULT=; -# 佳明迁移数量配置 -GARMIN_MIGRATE_NUM_DEFAULT=100; -GARMIN_MIGRATE_START_DEFAULT=1; +# 佳明中国区账号密码,对应 https://connect.garmin.cn/ +GARMIN_USERNAME= +GARMIN_PASSWORD= + +# 佳明国际区账号密码,对应 https://connect.garmin.com/ +GARMIN_GLOBAL_USERNAME= +GARMIN_GLOBAL_PASSWORD= + +# 活动历史迁移配置:每次迁移多少条活动,以及从第几条活动开始 +GARMIN_MIGRATE_NUM=100 +GARMIN_MIGRATE_START=1 + +# Wellness 健康数据同步配置,默认关闭;需要同步步数、睡眠、HRV、压力等健康数据时改为 true +# 注意:目标账号需要先人工绑定/连接一台支持健康数据的 Garmin 设备,否则上传会报 419 +GARMIN_SYNC_WELLNESS=false + +# 日常同步时检查最近几天的健康数据 +GARMIN_WELLNESS_SYNC_DAYS=1 + +# 历史迁移健康数据天数;0 表示不迁移历史健康数据 +GARMIN_WELLNESS_MIGRATE_DAYS=30 + +# 历史迁移从今天往前跳过多少天开始;0 表示从今天开始 +GARMIN_WELLNESS_MIGRATE_START_DAYS=0 diff --git a/.github/workflows/migrate_garmin_cn_to_garmin_global.yml b/.github/workflows/migrate_garmin_cn_to_garmin_global.yml index c8e66e43..36e2ecb5 100644 --- a/.github/workflows/migrate_garmin_cn_to_garmin_global.yml +++ b/.github/workflows/migrate_garmin_cn_to_garmin_global.yml @@ -2,6 +2,19 @@ name: Migrate Garmin CN to Garmin Global on: workflow_dispatch: + inputs: + migrate_command: + description: "迁移命令:支持 CN -> Global 或 Global -> CN" + required: true + default: "migrate_garmin_cn_to_global" + type: choice + options: + - migrate_garmin_cn_to_global + - migrate_all_cn_to_global + - migrate_wellness_cn_to_global + - migrate_garmin_global_to_cn + - migrate_all_global_to_cn + - migrate_wellness_global_to_cn env: BARK_KEY: ${{ secrets.BARK_KEY }} @@ -11,6 +24,12 @@ env: GARMIN_GLOBAL_PASSWORD: ${{ secrets.GARMIN_GLOBAL_PASSWORD }} GARMIN_MIGRATE_NUM: ${{ secrets.GARMIN_MIGRATE_NUM }} GARMIN_MIGRATE_START: ${{ secrets.GARMIN_MIGRATE_START }} + # Wellness 健康数据默认关闭;需要迁移步数、睡眠、HRV、压力等数据时,在 Secrets 中设置 GARMIN_SYNC_WELLNESS=true + GARMIN_SYNC_WELLNESS: ${{ secrets.GARMIN_SYNC_WELLNESS }} + # 历史迁移健康数据天数;未配置或 0 表示不迁移历史健康数据 + GARMIN_WELLNESS_MIGRATE_DAYS: ${{ secrets.GARMIN_WELLNESS_MIGRATE_DAYS }} + # 历史迁移从今天往前跳过多少天开始;0 表示从今天开始 + GARMIN_WELLNESS_MIGRATE_START_DAYS: ${{ secrets.GARMIN_WELLNESS_MIGRATE_START_DAYS }} jobs: build: @@ -25,7 +44,7 @@ jobs: node-version: '18' cache: 'yarn' - run: yarn - - run: yarn migrate_garmin_cn_to_global + - run: yarn ${{ github.event.inputs.migrate_command || 'migrate_garmin_cn_to_global' }} - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Save Garmin Session diff --git a/.github/workflows/migrate_garmin_global_to_garmin_cn.yml b/.github/workflows/migrate_garmin_global_to_garmin_cn.yml index 2e54d293..7d81adca 100644 --- a/.github/workflows/migrate_garmin_global_to_garmin_cn.yml +++ b/.github/workflows/migrate_garmin_global_to_garmin_cn.yml @@ -2,6 +2,19 @@ name: Migrate Garmin Global to Garmin CN on: workflow_dispatch: + inputs: + migrate_command: + description: "迁移命令:支持 Global -> CN 或 CN -> Global" + required: true + default: "migrate_garmin_global_to_cn" + type: choice + options: + - migrate_garmin_global_to_cn + - migrate_all_global_to_cn + - migrate_wellness_global_to_cn + - migrate_garmin_cn_to_global + - migrate_all_cn_to_global + - migrate_wellness_cn_to_global env: BARK_KEY: ${{ secrets.BARK_KEY }} @@ -11,6 +24,12 @@ env: GARMIN_GLOBAL_PASSWORD: ${{ secrets.GARMIN_GLOBAL_PASSWORD }} GARMIN_MIGRATE_NUM: ${{ secrets.GARMIN_MIGRATE_NUM }} GARMIN_MIGRATE_START: ${{ secrets.GARMIN_MIGRATE_START }} + # Wellness 健康数据默认关闭;需要迁移步数、睡眠、HRV、压力等数据时,在 Secrets 中设置 GARMIN_SYNC_WELLNESS=true + GARMIN_SYNC_WELLNESS: ${{ secrets.GARMIN_SYNC_WELLNESS }} + # 历史迁移健康数据天数;未配置或 0 表示不迁移历史健康数据 + GARMIN_WELLNESS_MIGRATE_DAYS: ${{ secrets.GARMIN_WELLNESS_MIGRATE_DAYS }} + # 历史迁移从今天往前跳过多少天开始;0 表示从今天开始 + GARMIN_WELLNESS_MIGRATE_START_DAYS: ${{ secrets.GARMIN_WELLNESS_MIGRATE_START_DAYS }} jobs: build: @@ -25,7 +44,7 @@ jobs: node-version: '18' cache: 'yarn' - run: yarn - - run: yarn migrate_garmin_global_to_cn + - run: yarn ${{ github.event.inputs.migrate_command || 'migrate_garmin_global_to_cn' }} - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Save Garmin Session diff --git a/.github/workflows/sync_garmin_cn_to_garmin_global.yml b/.github/workflows/sync_garmin_cn_to_garmin_global.yml index 5fad31b5..c89980e6 100644 --- a/.github/workflows/sync_garmin_cn_to_garmin_global.yml +++ b/.github/workflows/sync_garmin_cn_to_garmin_global.yml @@ -2,6 +2,19 @@ name: Sync Garmin CN to Garmin Global on: workflow_dispatch: + inputs: + sync_command: + description: "同步命令:普通 sync 只同步活动;sync_all 同步活动+Wellness;sync_wellness 只同步 Wellness" + required: true + default: "sync_cn" + type: choice + options: + - sync_cn + - sync_all_cn_to_global + - sync_wellness_cn_to_global + - sync_global + - sync_all_global_to_cn + - sync_wellness_global_to_cn schedule: # 每6小时检查一次 - cron: "0 */6 * * *" @@ -22,6 +35,10 @@ env: GARMIN_PASSWORD: ${{ secrets.GARMIN_PASSWORD }} GARMIN_GLOBAL_USERNAME: ${{ secrets.GARMIN_GLOBAL_USERNAME }} GARMIN_GLOBAL_PASSWORD: ${{ secrets.GARMIN_GLOBAL_PASSWORD }} + # Wellness 健康数据默认关闭;需要同步步数、睡眠、HRV、压力等数据时,在 Secrets 中设置 GARMIN_SYNC_WELLNESS=true + GARMIN_SYNC_WELLNESS: ${{ secrets.GARMIN_SYNC_WELLNESS }} + # 日常同步时检查最近几天的健康数据;未配置时默认为 1 + GARMIN_WELLNESS_SYNC_DAYS: ${{ secrets.GARMIN_WELLNESS_SYNC_DAYS }} jobs: build: @@ -36,8 +53,8 @@ jobs: node-version: '18' cache: 'yarn' - run: yarn - - run: yarn sync_cn - timeout-minutes: 5 + - run: yarn ${{ github.event.inputs.sync_command || 'sync_cn' }} + timeout-minutes: 15 - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Save Garmin Session diff --git a/.github/workflows/sync_garmin_global_to_garmin_cn.yml b/.github/workflows/sync_garmin_global_to_garmin_cn.yml index 0927e840..0215de66 100644 --- a/.github/workflows/sync_garmin_global_to_garmin_cn.yml +++ b/.github/workflows/sync_garmin_global_to_garmin_cn.yml @@ -2,6 +2,19 @@ name: Sync Garmin Global to Garmin CN on: workflow_dispatch: + inputs: + sync_command: + description: "同步命令:普通 sync 只同步活动;sync_all 同步活动+Wellness;sync_wellness 只同步 Wellness" + required: true + default: "sync_global" + type: choice + options: + - sync_global + - sync_all_global_to_cn + - sync_wellness_global_to_cn + - sync_cn + - sync_all_cn_to_global + - sync_wellness_cn_to_global schedule: # 每6小时检查一次 - cron: "0 */6 * * *" @@ -19,6 +32,10 @@ env: GARMIN_PASSWORD: ${{ secrets.GARMIN_PASSWORD }} GARMIN_GLOBAL_USERNAME: ${{ secrets.GARMIN_GLOBAL_USERNAME }} GARMIN_GLOBAL_PASSWORD: ${{ secrets.GARMIN_GLOBAL_PASSWORD }} + # Wellness 健康数据默认关闭;需要同步步数、睡眠、HRV、压力等数据时,在 Secrets 中设置 GARMIN_SYNC_WELLNESS=true + GARMIN_SYNC_WELLNESS: ${{ secrets.GARMIN_SYNC_WELLNESS }} + # 日常同步时检查最近几天的健康数据;未配置时默认为 1 + GARMIN_WELLNESS_SYNC_DAYS: ${{ secrets.GARMIN_WELLNESS_SYNC_DAYS }} jobs: build: @@ -33,8 +50,8 @@ jobs: node-version: "18" cache: "yarn" - run: yarn - - run: yarn sync_global - timeout-minutes: 5 + - run: yarn ${{ github.event.inputs.sync_command || 'sync_global' }} + timeout-minutes: 15 - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Save Garmin Session diff --git a/.gitignore b/.gitignore index 93b30ca1..81982ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ dist node_modules *.log garmin_fit_files +garmin_wellness_files +.env +db/*.db ./src/constant.my.ts # Virtual environment diff --git a/Dockerfile b/Dockerfile index 45eb88bf..38727625 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,4 +5,4 @@ COPY package.json yarn.lock* tsconfig.json ./ RUN yarn --frozen-lockfile COPY src ./src VOLUME /app/db -CMD ["yarn", "sync_cn"] +CMD ["yarn", "sync_global"] diff --git a/README.md b/README.md index cabdf4d3..dafa4518 100644 --- a/README.md +++ b/README.md @@ -36,19 +36,70 @@ github:(actions方式正常可用) git clone https://github.com/gooin/dailysync-rev.git ``` ### 修改配置文件 -打开`.env`文件,按注释填入信息 +打开`.env`文件,按注释填入信息。Docker 运行会通过 `docker-compose.yml` 的 `env_file` 读取 `.env`;本地 `yarn` 运行也会自动读取项目根目录的 `.env`。 + +```dotenv +# 佳明中国区账号密码,对应 https://connect.garmin.cn/ +GARMIN_USERNAME=example@example.com +GARMIN_PASSWORD=password + +# 佳明国际区账号密码,对应 https://connect.garmin.com/ +GARMIN_GLOBAL_USERNAME=example@example.com +GARMIN_GLOBAL_PASSWORD=password + +# 活动历史迁移配置:每次迁移多少条活动,以及从第几条活动开始 +GARMIN_MIGRATE_NUM=100 +GARMIN_MIGRATE_START=0 + +# Wellness 健康数据同步配置,默认关闭;需要同步步数、睡眠、HRV、压力等健康数据时改为 true +# 注意:目标账号需要先人工绑定/连接一台支持健康数据的 Garmin 设备,否则上传会报 419 +GARMIN_SYNC_WELLNESS=false + +# 日常同步时检查最近几天的健康数据 +GARMIN_WELLNESS_SYNC_DAYS=1 + +# 历史迁移健康数据天数;0 表示不迁移历史健康数据 +GARMIN_WELLNESS_MIGRATE_DAYS=0 + +# 历史迁移从今天往前跳过多少天开始;0 表示从今天开始 +GARMIN_WELLNESS_MIGRATE_START_DAYS=0 +``` ### 修改docker-compsoe.yml 文件 -可以通过修改文件中的`command`参数决定每次执行的功能,默认是国区同步到国际区 +可以通过修改文件中的`command`参数决定每次执行的功能。普通 `sync_*` 只同步活动数据;`sync_all_*` 是复合同步入口,会先同步活动数据,再在 `GARMIN_SYNC_WELLNESS=true` 时同步 Wellness 健康数据。 +历史迁移同理:`migrate_garmin_*` 只迁移活动数据;`migrate_wellness_*` 只迁移 Wellness 健康数据;`migrate_all_*` 会迁移活动数据和 Wellness 健康数据。 + +同步中国区到国际区(仅活动数据) ```shell yarn sync_cn ``` -同步国际区到中国区 +同步国际区到中国区(仅活动数据) ```shell yarn sync_global ``` + +同步中国区到国际区(活动数据 + Wellness 健康数据) +```shell +yarn sync_all_cn_to_global +``` + +同步国际区到中国区(活动数据 + Wellness 健康数据) +```shell +yarn sync_all_global_to_cn +``` + +同步中国区到国际区(仅 Wellness 健康数据,按 `GARMIN_WELLNESS_SYNC_DAYS` 检查最近几天) +```shell +yarn sync_wellness_cn_to_global +``` + +同步国际区到中国区(仅 Wellness 健康数据,按 `GARMIN_WELLNESS_SYNC_DAYS` 检查最近几天) +```shell +yarn sync_wellness_global_to_cn +``` + 迁移历史数据:中国区到国际区 ```shell yarn migrate_garmin_cn_to_global @@ -58,6 +109,26 @@ yarn migrate_garmin_cn_to_global yarn migrate_garmin_global_to_cn ``` +迁移历史全量数据:中国区到国际区(活动数据 + Wellness 健康数据) +```shell +yarn migrate_all_cn_to_global +``` + +迁移历史全量数据:国际区到中国区(活动数据 + Wellness 健康数据) +```shell +yarn migrate_all_global_to_cn +``` + +迁移历史 Wellness 健康数据:中国区到国际区(按 `GARMIN_WELLNESS_MIGRATE_DAYS` 和 `GARMIN_WELLNESS_MIGRATE_START_DAYS`) +```shell +yarn migrate_wellness_cn_to_global +``` + +迁移历史 Wellness 健康数据:国际区到中国区(按 `GARMIN_WELLNESS_MIGRATE_DAYS` 和 `GARMIN_WELLNESS_MIGRATE_START_DAYS`) +```shell +yarn migrate_wellness_global_to_cn +``` + ### 打包运行一次项目 ```shell docker-compose up @@ -72,6 +143,29 @@ docker start daily-sync 因为项目之前在Github上占用过多资源被封禁,现在已经调整了执行的频率,熟悉代码的话,将代码下载下来,上传到github,通过 github Actions执行 具体参考下方文档或参考视频教程: https://www.bilibili.com/video/BV1v94y1Q7oR/?spm_id_from=333.999.0.0 +Github Actions 需要在仓库 `Settings -> Secrets and variables -> Actions` 中配置同名 Secrets。手动运行 Sync workflow 时,可以选择 `sync_*`、`sync_all_*` 或 `sync_wellness_*`;定时运行默认仍执行普通 `sync_*`,只同步活动数据。 + +```text +GARMIN_USERNAME # 佳明中国区账号 +GARMIN_PASSWORD # 佳明中国区密码 +GARMIN_GLOBAL_USERNAME # 佳明国际区账号 +GARMIN_GLOBAL_PASSWORD # 佳明国际区密码 +GARMIN_MIGRATE_NUM # 历史活动迁移数量,迁移 workflow 使用 +GARMIN_MIGRATE_START # 历史活动迁移起始位置,迁移 workflow 使用 +BARK_KEY # 可选,Bark 失败通知 +``` + +Wellness 健康数据默认关闭。需要同步步数、睡眠、HRV、压力等健康数据时,再额外配置,并运行 `sync_all_*` 或 `sync_wellness_*` 命令: + +```text +GARMIN_SYNC_WELLNESS=true # 开启健康数据同步 +GARMIN_WELLNESS_SYNC_DAYS=1 # 日常同步最近几天 +GARMIN_WELLNESS_MIGRATE_DAYS=30 # 历史迁移健康数据天数;0 表示不迁移 +GARMIN_WELLNESS_MIGRATE_START_DAYS=0 # 从今天往前跳过多少天开始 +``` + +注意:目标账号需要先人工绑定或连接一台支持健康数据的 Garmin 设备。若日志出现 `419 Wellness device is not active for this user`,请先在目标账号中连接设备并打开 Garmin Connect,让健康数据功能完成初始化。 + ## 使用前账号准备与配置 【重要重要重要!!!】请先参照 [账号准备](https://dailysync.vyzt.dev/docs/%E8%B4%A6%E5%8F%B7%E5%87%86%E5%A4%87) 进行账号配置,再来使用此工具 @@ -166,21 +260,7 @@ Windows在文件管理器中打开脚本所在的目录,在地址栏输入 `cm yarn ``` ### 填入账号密码 -打开 `src/constant.ts`, -填入您的佳明账号及密码 -```js -//中国区 -export const GARMIN_USERNAME_DEFAULT = 'example@example.com'; -export const GARMIN_PASSWORD_DEFAULT = 'password'; -//国际区 -export const GARMIN_GLOBAL_USERNAME_DEFAULT = 'example@example.com'; -export const GARMIN_GLOBAL_PASSWORD_DEFAULT = 'password'; - -// 佳明迁移数量配置(批量同步历史数据使用) -export const GARMIN_MIGRATE_NUM_DEFAULT = 100; //每次要迁移的数量,不要填太大 -export const GARMIN_MIGRATE_START_DEFAULT = 0; // 从第几条活动开始 - -``` +打开 `.env`,按上方“修改配置文件”中的注释填入佳明账号、迁移配置以及可选的 Wellness 健康数据配置。不建议把账号密码写入 `src/constant.ts`。 ### 运行脚本 注意: 如果执行不能成功,请尝试将梯子更换为美国IP,多更换几个ip试试 @@ -193,6 +273,22 @@ yarn sync_cn ```shell yarn sync_global ``` +同步中国区到国际区(活动数据 + Wellness 健康数据) +```shell +yarn sync_all_cn_to_global +``` +同步国际区到中国区(活动数据 + Wellness 健康数据) +```shell +yarn sync_all_global_to_cn +``` +同步中国区到国际区(仅 Wellness 健康数据) +```shell +yarn sync_wellness_cn_to_global +``` +同步国际区到中国区(仅 Wellness 健康数据) +```shell +yarn sync_wellness_global_to_cn +``` 迁移历史数据:中国区到国际区 ```shell yarn migrate_garmin_cn_to_global @@ -201,6 +297,22 @@ yarn migrate_garmin_cn_to_global ```shell yarn migrate_garmin_global_to_cn ``` +迁移历史全量数据:中国区到国际区(活动数据 + Wellness 健康数据) +```shell +yarn migrate_all_cn_to_global +``` +迁移历史全量数据:国际区到中国区(活动数据 + Wellness 健康数据) +```shell +yarn migrate_all_global_to_cn +``` +迁移历史 Wellness 健康数据:中国区到国际区 +```shell +yarn migrate_wellness_cn_to_global +``` +迁移历史 Wellness 健康数据:国际区到中国区 +```shell +yarn migrate_wellness_global_to_cn +``` #### 常见问题 diff --git a/package.json b/package.json index 6206e647..8d3955ac 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,16 @@ "rq": "ts-node src/rq.ts", "sync_cn": "ts-node src/sync_garmin_cn_to_global.ts", "sync_global": "ts-node src/sync_garmin_global_to_cn.ts", + "sync_all_cn_to_global": "ts-node src/sync_all_garmin_cn_to_global.ts", + "sync_all_global_to_cn": "ts-node src/sync_all_garmin_global_to_cn.ts", + "sync_wellness_cn_to_global": "ts-node src/sync_wellness_garmin_cn_to_global.ts", + "sync_wellness_global_to_cn": "ts-node src/sync_wellness_garmin_global_to_cn.ts", "migrate_garmin_global_to_cn": "ts-node src/migrate_garmin_global_to_cn.ts", "migrate_garmin_cn_to_global": "ts-node src/migrate_garmin_cn_to_global.ts", + "migrate_all_global_to_cn": "ts-node src/migrate_all_garmin_global_to_cn.ts", + "migrate_all_cn_to_global": "ts-node src/migrate_all_garmin_cn_to_global.ts", + "migrate_wellness_global_to_cn": "ts-node src/migrate_wellness_garmin_global_to_cn.ts", + "migrate_wellness_cn_to_global": "ts-node src/migrate_wellness_garmin_cn_to_global.ts", "export_garmin_sessions": "ts-node src/export_garmin_sessions.ts", "start2": "node dist/index.js", "dev": "nodemon dist/index.js", diff --git a/src/constant.ts b/src/constant.ts index 9a99ad37..e60811c8 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,25 +1,78 @@ +import fs from 'fs'; +import path from 'path'; + +const loadDotEnv = (): void => { + const envPath = path.resolve(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) { + return; + } + + const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/); + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue; + } + + const separatorIndex = trimmedLine.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + const name = trimmedLine.slice(0, separatorIndex).trim(); + let value = trimmedLine.slice(separatorIndex + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (process.env[name] === undefined) { + process.env[name] = value; + } + } +}; + +loadDotEnv(); + +const envOrDefault = (name: string, defaultValue: string): string => { + const value = process.env[name]; + if (value === undefined || value.trim() === '') { + return defaultValue; + } + + return value; +}; + export const FILE_SUFFIX = { FIT: 'fit', GPX: 'gpx', TCX: 'tcx', }; export const DOWNLOAD_DIR = './garmin_fit_files'; +export const WELLNESS_DOWNLOAD_DIR = './garmin_wellness_files'; export const DB_FILE_PATH = './db/garmin.db'; export const AESKEY_DEFAULT = 'LSKDAJALSD'; /** * GARMIN ACCOUNT */ // 佳明中国区账号及密码 -export const GARMIN_USERNAME_DEFAULT = process.env.GARMIN_USERNAME_DEFAULT || ''; -export const GARMIN_PASSWORD_DEFAULT = process.env.GARMIN_PASSWORD_DEFAULT || ''; +export const GARMIN_USERNAME_DEFAULT = envOrDefault('GARMIN_USERNAME_DEFAULT', ''); +export const GARMIN_PASSWORD_DEFAULT = envOrDefault('GARMIN_PASSWORD_DEFAULT', ''); // 佳明国际区区账号及密码 -export const GARMIN_GLOBAL_USERNAME_DEFAULT = process.env.GARMIN_GLOBAL_USERNAME_DEFAULT || ''; -export const GARMIN_GLOBAL_PASSWORD_DEFAULT = process.env.GARMIN_GLOBAL_PASSWORD_DEFAULT || ''; +export const GARMIN_GLOBAL_USERNAME_DEFAULT = envOrDefault('GARMIN_GLOBAL_USERNAME_DEFAULT', ''); +export const GARMIN_GLOBAL_PASSWORD_DEFAULT = envOrDefault('GARMIN_GLOBAL_PASSWORD_DEFAULT', ''); // 佳明迁移数量配置 -export const GARMIN_MIGRATE_NUM_DEFAULT = process.env.GARMIN_MIGRATE_NUM_DEFAULT || ''; -export const GARMIN_MIGRATE_START_DEFAULT = process.env.GARMIN_MIGRATE_START_DEFAULT || ''; +export const GARMIN_MIGRATE_NUM_DEFAULT = envOrDefault('GARMIN_MIGRATE_NUM_DEFAULT', ''); +export const GARMIN_MIGRATE_START_DEFAULT = envOrDefault('GARMIN_MIGRATE_START_DEFAULT', ''); // 佳明每次同步时检查的最多的数量 export const GARMIN_SYNC_NUM_DEFAULT = 10; +export const GARMIN_SYNC_WELLNESS_DEFAULT = envOrDefault('GARMIN_SYNC_WELLNESS_DEFAULT', 'false'); +export const GARMIN_WELLNESS_SYNC_DAYS_DEFAULT = envOrDefault('GARMIN_WELLNESS_SYNC_DAYS_DEFAULT', ''); +// 佳明迁移时检查的天数范围 +export const GARMIN_WELLNESS_MIGRATE_DAYS_DEFAULT = envOrDefault('GARMIN_WELLNESS_MIGRATE_DAYS_DEFAULT', ''); +export const GARMIN_WELLNESS_MIGRATE_START_DAYS_DEFAULT = envOrDefault('GARMIN_WELLNESS_MIGRATE_START_DAYS_DEFAULT', ''); export const GARMIN_URL_DEFAULT = { 'BASE_URL': 'https://connect.garmin.cn', @@ -63,5 +116,3 @@ export const STRAVA_ACCESS_TOKEN_DEFAULT = ''; export const STRAVA_CLIENT_ID_DEFAULT = ''; export const STRAVA_CLIENT_SECRET_DEFAULT = ''; export const STRAVA_REDIRECT_URI_DEFAULT = ''; - - diff --git a/src/migrate_all_garmin_cn_to_global.ts b/src/migrate_all_garmin_cn_to_global.ts new file mode 100644 index 00000000..bd630770 --- /dev/null +++ b/src/migrate_all_garmin_cn_to_global.ts @@ -0,0 +1,19 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { migrateAllGarminCN2GarminGlobal } from './utils/garmin_cn'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + await migrateAllGarminCN2GarminGlobal(); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin CN -> Garmin Global 全量迁移数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/migrate_all_garmin_global_to_cn.ts b/src/migrate_all_garmin_global_to_cn.ts new file mode 100644 index 00000000..da892770 --- /dev/null +++ b/src/migrate_all_garmin_global_to_cn.ts @@ -0,0 +1,19 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { migrateAllGarminGlobal2GarminCN } from './utils/garmin_global'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + await migrateAllGarminGlobal2GarminCN(); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin Global -> Garmin CN 全量迁移数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/migrate_wellness_garmin_cn_to_global.ts b/src/migrate_wellness_garmin_cn_to_global.ts new file mode 100644 index 00000000..5e7af129 --- /dev/null +++ b/src/migrate_wellness_garmin_cn_to_global.ts @@ -0,0 +1,23 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { getGaminCNClient } from './utils/garmin_cn'; +import { getGaminGlobalClient } from './utils/garmin_global'; +import { migrateGarminWellnessByDateRange } from './utils/garmin_wellness'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + await migrateGarminWellnessByDateRange(clientCN, clientGlobal); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin CN -> Garmin Global Wellness 迁移数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/migrate_wellness_garmin_global_to_cn.ts b/src/migrate_wellness_garmin_global_to_cn.ts new file mode 100644 index 00000000..15eb740c --- /dev/null +++ b/src/migrate_wellness_garmin_global_to_cn.ts @@ -0,0 +1,23 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { getGaminCNClient } from './utils/garmin_cn'; +import { getGaminGlobalClient } from './utils/garmin_global'; +import { migrateGarminWellnessByDateRange } from './utils/garmin_wellness'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + await migrateGarminWellnessByDateRange(clientGlobal, clientCN); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin Global -> Garmin CN Wellness 迁移数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/sync_all_garmin_cn_to_global.ts b/src/sync_all_garmin_cn_to_global.ts new file mode 100644 index 00000000..6933c6da --- /dev/null +++ b/src/sync_all_garmin_cn_to_global.ts @@ -0,0 +1,19 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { syncAllGarminCN2GarminGlobal } from './utils/garmin_cn'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + await syncAllGarminCN2GarminGlobal(); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin CN -> Garmin Global 全量同步数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/sync_all_garmin_global_to_cn.ts b/src/sync_all_garmin_global_to_cn.ts new file mode 100644 index 00000000..3081b54f --- /dev/null +++ b/src/sync_all_garmin_global_to_cn.ts @@ -0,0 +1,19 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { syncAllGarminGlobal2GarminCN } from './utils/garmin_global'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + await syncAllGarminGlobal2GarminCN(); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin Global -> Garmin CN 全量同步数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/sync_wellness_garmin_cn_to_global.ts b/src/sync_wellness_garmin_cn_to_global.ts new file mode 100644 index 00000000..cc1be55d --- /dev/null +++ b/src/sync_wellness_garmin_cn_to_global.ts @@ -0,0 +1,23 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { getGaminCNClient } from './utils/garmin_cn'; +import { getGaminGlobalClient } from './utils/garmin_global'; +import { syncGarminWellnessRecentDays } from './utils/garmin_wellness'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + await syncGarminWellnessRecentDays(clientCN, clientGlobal, undefined, true); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin CN -> Garmin Global Wellness 同步数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/sync_wellness_garmin_global_to_cn.ts b/src/sync_wellness_garmin_global_to_cn.ts new file mode 100644 index 00000000..ad8ff655 --- /dev/null +++ b/src/sync_wellness_garmin_global_to_cn.ts @@ -0,0 +1,23 @@ +import { BARK_KEY_DEFAULT } from './constant'; +import { getGaminCNClient } from './utils/garmin_cn'; +import { getGaminGlobalClient } from './utils/garmin_global'; +import { syncGarminWellnessRecentDays } from './utils/garmin_wellness'; + +const axios = require('axios'); +const core = require('@actions/core'); +const BARK_KEY = process.env.BARK_KEY ?? BARK_KEY_DEFAULT; + +const main = async () => { + try { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + await syncGarminWellnessRecentDays(clientGlobal, clientCN, undefined, true); + } catch (e) { + axios.get( + `https://api.day.app/${BARK_KEY}/Garmin Global -> Garmin CN Wellness 同步数据运行失败了,快去检查!/${e.message}`); + core.setFailed(e.message); + throw new Error(e); + } +}; + +main(); diff --git a/src/utils/garmin_cn.ts b/src/utils/garmin_cn.ts index 2c1e1bbb..0ddedc19 100644 --- a/src/utils/garmin_cn.ts +++ b/src/utils/garmin_cn.ts @@ -14,6 +14,7 @@ const core = require('@actions/core'); import _ from 'lodash'; import { getSessionFromDB, initDB, saveSessionToDB, updateSessionToDB } from './sqlite'; import { getSessionFromEnv } from './garmin_session_env'; +import { migrateGarminWellnessByDateRange, syncGarminWellnessRecentDays } from './garmin_wellness'; const CryptoJS = require('crypto-js'); const fs = require('fs'); @@ -81,14 +82,15 @@ export const getGaminCNClient = async (): Promise => { } }; -export const migrateGarminCN2GarminGlobal = async (count = 200) => { +export const migrateGarminCNActivities2GarminGlobal = async ( + clientCN: GarminClientType, + clientGlobal: GarminClientType, + count = 200, +) => { const actIndex = Number(GARMIN_MIGRATE_START) ?? 0; // const actPerGroup = 10; const totalAct = Number(GARMIN_MIGRATE_NUM) ?? count; - const clientCN = await getGaminCNClient(); - const clientGlobal = await getGaminGlobalClient(); - const actSlices = await clientCN.getActivities(actIndex, totalAct); // only running // const runningActs = _.filter(actSlices, { activityType: { typeKey: 'running' } }); @@ -106,10 +108,25 @@ export const migrateGarminCN2GarminGlobal = async (count = 200) => { } }; -export const syncGarminCN2GarminGlobal = async () => { +export const migrateGarminCN2GarminGlobal = async (count = 200) => { const clientCN = await getGaminCNClient(); const clientGlobal = await getGaminGlobalClient(); + await migrateGarminCNActivities2GarminGlobal(clientCN, clientGlobal, count); +}; + +export const migrateAllGarminCN2GarminGlobal = async (count = 200) => { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + + await migrateGarminCNActivities2GarminGlobal(clientCN, clientGlobal, count); + await migrateGarminWellnessByDateRange(clientCN, clientGlobal); +}; + +export const syncGarminCNActivities2GarminGlobal = async ( + clientCN: GarminClientType, + clientGlobal: GarminClientType, +) => { let cnActs = await clientCN.getActivities(0, Number(GARMIN_SYNC_NUM)); const globalActs = await clientGlobal.getActivities(0, 1); @@ -135,3 +152,18 @@ export const syncGarminCN2GarminGlobal = async () => { } } }; + +export const syncGarminCN2GarminGlobal = async () => { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + + await syncGarminCNActivities2GarminGlobal(clientCN, clientGlobal); +}; + +export const syncAllGarminCN2GarminGlobal = async () => { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + + await syncGarminCNActivities2GarminGlobal(clientCN, clientGlobal); + await syncGarminWellnessRecentDays(clientCN, clientGlobal); +}; diff --git a/src/utils/garmin_global.ts b/src/utils/garmin_global.ts index 4e61806c..ef835626 100644 --- a/src/utils/garmin_global.ts +++ b/src/utils/garmin_global.ts @@ -12,6 +12,7 @@ const core = require('@actions/core'); import _ from 'lodash'; import { getSessionFromDB, initDB, saveSessionToDB, updateSessionToDB } from './sqlite'; import { getSessionFromEnv } from './garmin_session_env'; +import { migrateGarminWellnessByDateRange, syncGarminWellnessRecentDays } from './garmin_wellness'; const { GarminConnect } = require('@gooin/garmin-connect'); @@ -76,14 +77,15 @@ export const getGaminGlobalClient = async (): Promise => { } }; -export const migrateGarminGlobal2GarminCN = async (count = 200) => { +export const migrateGarminGlobalActivities2GarminCN = async ( + clientGlobal: GarminClientType, + clientCn: GarminClientType, + count = 200, +) => { const actIndex = Number(GARMIN_MIGRATE_START) ?? 0; // const actPerGroup = 10; const totalAct = Number(GARMIN_MIGRATE_NUM) ?? count; - const clientGlobal = await getGaminGlobalClient(); - const clientCn = await getGaminCNClient(); - // 从佳明国际区读取活动数据 const actSlices = await clientGlobal.getActivities(actIndex, totalAct); // only running @@ -102,10 +104,25 @@ export const migrateGarminGlobal2GarminCN = async (count = 200) => { } }; -export const syncGarminGlobal2GarminCN = async () => { - const clientCN = await getGaminCNClient(); +export const migrateGarminGlobal2GarminCN = async (count = 200) => { const clientGlobal = await getGaminGlobalClient(); + const clientCn = await getGaminCNClient(); + + await migrateGarminGlobalActivities2GarminCN(clientGlobal, clientCn, count); +}; + +export const migrateAllGarminGlobal2GarminCN = async (count = 200) => { + const clientGlobal = await getGaminGlobalClient(); + const clientCn = await getGaminCNClient(); + await migrateGarminGlobalActivities2GarminCN(clientGlobal, clientCn, count); + await migrateGarminWellnessByDateRange(clientGlobal, clientCn); +}; + +export const syncGarminGlobalActivities2GarminCN = async ( + clientGlobal: GarminClientType, + clientCN: GarminClientType, +) => { const cnActs = await clientCN.getActivities(0, 1); let globalActs = await clientGlobal.getActivities(0, Number(GARMIN_SYNC_NUM)); @@ -132,3 +149,18 @@ export const syncGarminGlobal2GarminCN = async () => { } } }; + +export const syncGarminGlobal2GarminCN = async () => { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + + await syncGarminGlobalActivities2GarminCN(clientGlobal, clientCN); +}; + +export const syncAllGarminGlobal2GarminCN = async () => { + const clientCN = await getGaminCNClient(); + const clientGlobal = await getGaminGlobalClient(); + + await syncGarminGlobalActivities2GarminCN(clientGlobal, clientCN); + await syncGarminWellnessRecentDays(clientGlobal, clientCN); +}; diff --git a/src/utils/garmin_wellness.ts b/src/utils/garmin_wellness.ts new file mode 100644 index 00000000..a77948c8 --- /dev/null +++ b/src/utils/garmin_wellness.ts @@ -0,0 +1,290 @@ +import fs from 'fs'; +import path from 'path'; + +import { + GARMIN_SYNC_WELLNESS_DEFAULT, + GARMIN_WELLNESS_MIGRATE_DAYS_DEFAULT, + GARMIN_WELLNESS_MIGRATE_START_DAYS_DEFAULT, + GARMIN_WELLNESS_SYNC_DAYS_DEFAULT, + WELLNESS_DOWNLOAD_DIR, +} from '../constant'; +import { GarminClientType } from './type'; + +const decompress = require('decompress'); +const FormData = require('form-data'); + +const envOrDefault = (name: string, defaultValue: string): string => { + const value = process.env[name]; + if (value === undefined || value.trim() === '') { + return defaultValue; + } + + return value; +}; + +export type GarminWellnessUploadStatus = 'uploaded' | 'duplicate'; + +export type GarminWellnessUploadResult = { + filePath: string; + status: GarminWellnessUploadStatus; + response?: any; +}; + +export type GarminWellnessSyncResult = { + date: string; + total: number; + uploaded: number; + duplicate: number; + failed: number; +}; + +const GARMIN_SYNC_WELLNESS = envOrDefault('GARMIN_SYNC_WELLNESS', GARMIN_SYNC_WELLNESS_DEFAULT); +const GARMIN_WELLNESS_SYNC_DAYS = envOrDefault('GARMIN_WELLNESS_SYNC_DAYS', GARMIN_WELLNESS_SYNC_DAYS_DEFAULT); +const GARMIN_WELLNESS_MIGRATE_DAYS = envOrDefault('GARMIN_WELLNESS_MIGRATE_DAYS', GARMIN_WELLNESS_MIGRATE_DAYS_DEFAULT); +const GARMIN_WELLNESS_MIGRATE_START_DAYS = envOrDefault( + 'GARMIN_WELLNESS_MIGRATE_START_DAYS', + GARMIN_WELLNESS_MIGRATE_START_DAYS_DEFAULT, +); + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); + +const ensureDirectory = (dirPath: string): void => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +const pad2 = (value: number): string => String(value).padStart(2, '0'); + +export const formatGarminDate = (date: Date): string => { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +}; + +export const getGarminDatesByOffset = (startDaysAgo: number, days: number): string[] => { + const normalizedDays = Math.max(1, Number(days) || 1); + const normalizedStartDaysAgo = Math.max(0, Number(startDaysAgo) || 0); + const dates: string[] = []; + + for (let dayOffset = 0; dayOffset < normalizedDays; dayOffset++) { + const date = new Date(); + date.setDate(date.getDate() - normalizedStartDaysAgo - dayOffset); + dates.push(formatGarminDate(date)); + } + + return dates; +}; + +export const getRecentGarminDates = (days: number): string[] => getGarminDatesByOffset(0, days); + +export const isGarminWellnessSyncEnabled = (): boolean => { + const normalizedValue = GARMIN_SYNC_WELLNESS.trim().toLowerCase(); + return normalizedValue === 'true' || normalizedValue === '1'; +}; + +export const getGarminWellnessSyncDays = (): number => { + return Math.max(1, Number(GARMIN_WELLNESS_SYNC_DAYS) || 1); +}; + +export const getGarminWellnessMigrateDays = (): number => { + return Math.max(0, Number(GARMIN_WELLNESS_MIGRATE_DAYS) || 0); +}; + +export const getGarminWellnessMigrateStartDays = (): number => { + return Math.max(0, Number(GARMIN_WELLNESS_MIGRATE_START_DAYS) || 0); +}; + +export const downloadGarminWellnessZip = async (client: GarminClientType, date: string): Promise => { + ensureDirectory(WELLNESS_DOWNLOAD_DIR); + + const zipFilePath = path.join(WELLNESS_DOWNLOAD_DIR, `${date}.zip`); + const url = `${client.url.GC_API}/download-service/files/wellness/${date}`; + const data = await client.client.get(url, { responseType: 'arraybuffer' }); + const buffer = Buffer.from(data); + + fs.writeFileSync(zipFilePath, buffer); + console.log(`Garmin wellness: downloaded ${date} zip, bytes=${buffer.length}`); + + return zipFilePath; +}; + +export const extractGarminWellnessFitFiles = async (zipFilePath: string, date: string): Promise => { + const outputDir = path.join(WELLNESS_DOWNLOAD_DIR, date); + ensureDirectory(outputDir); + + const entries = await decompress(zipFilePath, outputDir); + return entries + .map(entry => path.join(outputDir, entry.path)) + .filter(filePath => filePath.toLowerCase().endsWith('.fit')); +}; + +const isDuplicateWellnessFileError = (error: any): boolean => { + const message = error?.message ?? String(error); + return message.includes('Duplicate Wellness File') || message.includes('ERROR: (409)'); +}; + +const isInactiveWellnessDeviceError = (error: any): boolean => { + const message = error?.message ?? String(error); + return message.includes('Wellness device is not active for this user') || message.includes('ERROR: (419)'); +}; + +const logInactiveWellnessDeviceHint = (): void => { + console.error( + [ + 'Garmin wellness: target account is not ready for wellness imports.', + 'Please manually pair or connect a Garmin device that supports health data on the target account,', + 'open Garmin Connect once, and make sure wellness data such as steps, sleep, HRV, or stress is enabled.', + ].join(' '), + ); +}; + +export const uploadGarminWellnessFile = async ( + client: GarminClientType, + filePath: string, +): Promise => { + const form = new FormData(); + form.append('userfile', fs.createReadStream(filePath)); + + try { + const response = await client.client.post(`${client.url.UPLOAD}.fit`, form, { + headers: { + 'Content-Type': form.getHeaders()['content-type'], + }, + }); + + return { + filePath, + status: 'uploaded', + response, + }; + } catch (error) { + if (isDuplicateWellnessFileError(error)) { + return { + filePath, + status: 'duplicate', + }; + } + + if (isInactiveWellnessDeviceError(error)) { + logInactiveWellnessDeviceHint(); + } + + throw error; + } +}; + +export const syncGarminWellnessByDate = async ( + date: string, + sourceClient: GarminClientType, + targetClient: GarminClientType, +): Promise => { + console.log(`Garmin wellness: sync ${date} start`); + + const zipFilePath = await downloadGarminWellnessZip(sourceClient, date); + const fitFilePaths = await extractGarminWellnessFitFiles(zipFilePath, date); + + let uploaded = 0; + let duplicate = 0; + let failed = 0; + + for (const fitFilePath of fitFilePaths) { + const fileName = path.basename(fitFilePath); + + try { + const result = await uploadGarminWellnessFile(targetClient, fitFilePath); + if (result.status === 'duplicate') { + duplicate++; + console.log(`Garmin wellness: duplicate ${fileName}`); + } else { + uploaded++; + console.log(`Garmin wellness: uploaded ${fileName}`); + } + } catch (error) { + failed++; + console.error(`Garmin wellness: upload failed ${fileName}`, error); + } + + await sleep(500); + } + + console.log( + `Garmin wellness: sync ${date} done, total=${fitFilePaths.length}, uploaded=${uploaded}, duplicate=${duplicate}, failed=${failed}`, + ); + + return { + date, + total: fitFilePaths.length, + uploaded, + duplicate, + failed, + }; +}; + +export const syncGarminWellnessRecentDays = async ( + sourceClient: GarminClientType, + targetClient: GarminClientType, + days = getGarminWellnessSyncDays(), + force = false, +): Promise => { + if (!force && !isGarminWellnessSyncEnabled()) { + console.log('Garmin wellness: disabled by GARMIN_SYNC_WELLNESS'); + return []; + } + + const dates = getRecentGarminDates(days); + const results: GarminWellnessSyncResult[] = []; + + for (const date of dates) { + try { + const result = await syncGarminWellnessByDate(date, sourceClient, targetClient); + results.push(result); + } catch (error) { + console.error(`Garmin wellness: sync ${date} failed`, error); + results.push({ + date, + total: 0, + uploaded: 0, + duplicate: 0, + failed: 1, + }); + } + } + + return results; +}; + +export const migrateGarminWellnessByDateRange = async ( + sourceClient: GarminClientType, + targetClient: GarminClientType, + days = getGarminWellnessMigrateDays(), + startDaysAgo = getGarminWellnessMigrateStartDays(), +): Promise => { + if (days <= 0) { + console.log('Garmin wellness migrate: skipped, set GARMIN_WELLNESS_MIGRATE_DAYS to enable'); + return []; + } + + const dates = getGarminDatesByOffset(startDaysAgo, days); + const results: GarminWellnessSyncResult[] = []; + + console.log( + `Garmin wellness migrate: start, days=${days}, startDaysAgo=${startDaysAgo}, first=${dates[0]}, last=${dates[dates.length - 1]}`, + ); + + for (const date of dates) { + try { + const result = await syncGarminWellnessByDate(date, sourceClient, targetClient); + results.push(result); + } catch (error) { + console.error(`Garmin wellness migrate: sync ${date} failed`, error); + results.push({ + date, + total: 0, + uploaded: 0, + duplicate: 0, + failed: 1, + }); + } + } + + return results; +};