diff --git a/.eslintrc.js b/.eslintrc.js
index 2925873c0e4..86e239483c2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -8,6 +8,7 @@ module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
+ 'plugin:react/jsx-runtime',
'plugin:prettier/recommended',
],
overrides: [],
diff --git a/.github/workflows/run_data_sync.yml b/.github/workflows/run_data_sync.yml
index a81889d89be..91a7a4e0637 100644
--- a/.github/workflows/run_data_sync.yml
+++ b/.github/workflows/run_data_sync.yml
@@ -21,7 +21,7 @@ on:
env:
# please change to your own config.
- RUN_TYPE: pass # support strava/nike/garmin/garmin_cn/keep/only_gpx/only_fit/nike_to_strava/strava_to_garmin/strava_to_garmin_cn/garmin_to_strava/garmin_to_strava_cn/codoon, Please change the 'pass' it to your own
+ RUN_TYPE: pass # support strava/nike/garmin/garmin_cn/garmin_sync_cn_global/keep/only_gpx/only_fit/nike_to_strava/strava_to_garmin/strava_to_garmin_cn/garmin_to_strava/garmin_to_strava_cn/codoon, Please change the 'pass' it to your own
ATHLETE: yihong0618
TITLE: Yihong0618 Running
MIN_GRID_DISTANCE: 10 # change min distance here
@@ -125,6 +125,15 @@ jobs:
# If you only want to sync `type running` add args --only-run, default script is to sync all data (rides and runs).
# python run_page/garmin_sync.py ${{ secrets.GARMIN_SECRET_STRING_CN }} --only-run --is-cn
+ - name: Run sync Garmin CN to Garmin script
+ if: env.RUN_TYPE == 'garmin_sync_cn_global'
+ run: |
+ # make garimin secret string `python run_page/garmin_sync_cn_global.py ${email} ${password} --is-cn
+ python run_page/garmin_sync_cn_global.py ${{ secrets.GARMIN_SECRET_STRING_CN }} ${{ secrets.GARMIN_SECRET_STRING }}
+ # If you only want to sync `type running` add args --only-run, default script is to sync all data (rides and runs).
+ # python run_page/garmin_sync_cn_global.py ${{ secrets.GARMIN_SECRET_STRING_CN }} ${{ secrets.GARMIN_SECRET_STRING }} --only-run
+
+
- name: Run sync Only GPX script
if: env.RUN_TYPE == 'only_gpx'
run: |
diff --git a/Dockerfile b/Dockerfile
index fc1db81cb7c..684004d4391 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,5 +59,5 @@ COPY --from=data /root/running_page /root/running_page
RUN pnpm run build
FROM nginx:alpine AS web
-COPY --from=frontend-build /root/running_page/public /usr/share/nginx/html/
+COPY --from=frontend-build /root/running_page/dist /usr/share/nginx/html/
COPY --from=frontend-build /root/running_page/assets /usr/share/nginx/html/assets
diff --git a/README-CN.md b/README-CN.md
index cb687deac0e..c789e7d9eaf 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -95,7 +95,8 @@ R.I.P. 希望大家都能健康顺利的跑过终点,逝者安息。
| [deepinwine](https://github.com/deepinwine) | | Garmin-cn |
| [Jeffggmm](https://github.com/Jeffggmm) | | Garmin |
| [s1smart](https://github.com/s1smart) | | Strava |
-
+| [Ryan](https://github.com/85Ryan) | | Strava |
+| [PPZ](https://github.com/8824PPZ) | | Strava |
## 它是怎么工作的
@@ -136,6 +137,7 @@ R.I.P. 希望大家都能健康顺利的跑过终点,逝者安息。
- **[GPX](#gpx)**
- **[TCX](#tcx)**
- **[FIT](#fit)**
+- **[佳明国内同步国际](#Garmin-CN-to-Garmin)**
- **[Tcx+Strava(upload all tcx data to strava)](#tcx_to_strava)**
- **[Gpx+Strava(upload all tcx data to strava)](#gpx_to_strava)**
- **[Nike+Strava(Using NRC Run, Strava backup data)](#nikestrava)**
@@ -569,6 +571,43 @@ python3(python) run_page/garmin_sync.py xxxxxxxxxx --is-cn --only-run
+### Garmin-CN to Garmin
+
+
+ 同步佳明 CN 数据到 佳明国际区
+
+
+
+- 如果你只想同步 `type running` 使用参数 --only-run
+**The Python version must be >=3.10**
+
+#### 获取佳明 CN 的密钥
+
+在终端中输入以下命令
+
+```bash
+python3(python) run_page/get_garmin_secret.py ${your email} ${your password} --is-cn
+```
+
+#### 获取佳明全球的密钥
+
+在终端中输入以下命令
+
+```bash
+python3(python) run_page/get_garmin_secret.py ${your email} ${your password}
+```
+
+#### 同步 佳明 CN 到 佳明全球
+
+在终端中输入以下命令
+
+```bash
+python3(python) run_page/garmin_sync_cn_global.py ${garmin_cn_secret_string} ${garmin_secret_string}
+```
+
+
+
+
### Nike Run Club
diff --git a/README.md b/README.md
index 740a1a1de26..faeeb702ca9 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,9 @@ English | [简体中文](https://github.com/yihong0618/running_page/blob/master/
| [Echo](https://github.com/donghao526) | | JoyRun |
| [Jeffggmm](https://github.com/Jeffggmm) | | Garmin |
| [s1smart](https://github.com/s1smart) | | Strava |
-
+| [XmchxUp](https://github.com/XmchxUp) | | Strava |
+| [Ryan](https://github.com/85Ryan) | | Strava |
+| [PPZ](https://github.com/8824PPZ) | | Strava |
## How it works
@@ -119,6 +121,7 @@ English | [简体中文](https://github.com/yihong0618/running_page/blob/master/
- **[GPX](#gpx)**
- **[TCX](#tcx)**
- **[FIT](#fit)**
+- **[Garmin-CN_to_Garmin(Sync Garmin-CN activities to Garmin Global)](#garmin-cn-to-garmin)**
- **[Nike_to_Strava(Using NRC Run, Strava backup data)](#nike_to_strava)**
- **[Tcx_to_Strava(upload all tcx data to strava)](#tcx_to_strava)**
- **[Gpx_to_Strava(upload all gpx data to strava)](#gpx_to_strava)**
@@ -214,6 +217,13 @@ const USE_DASH_LINE = true;
const LINE_OPACITY = 0.4;
```
+- To use Google Analytics, you need to modify the configuration in the `src/utils/const.ts` file.
+
+```typescript
+const USE_GOOGLE_ANALYTICS = false;
+const GOOGLE_ANALYTICS_TRACKING_ID = '';
+```
+
> privacy protection,setting flowing env:
```bash
@@ -370,6 +380,45 @@ python3(python) run_page/garmin_sync.py xxxxxxxxxxxxxx(secret_string) --is-cn -
+### Garmin-CN to Garmin
+
+
+ Sync your Garmin-CN
data to Garmin
+
+
+
+- If you only want to sync `type running` add args --only-run
+**The Python version must be >=3.10**
+
+#### Get Garmin CN Secret
+
+Enter the following command in the terminal
+
+```bash
+# to get secret_string
+python3(python) run_page/get_garmin_secret.py ${your email} ${your password} --is-cn
+```
+
+#### Get Garmin Secret
+
+Enter the following command in the terminal
+
+```bash
+# to get secret_string
+python3(python) run_page/get_garmin_secret.py ${your email} ${your password}
+```
+
+#### Sync Garmin CN to Garmin
+
+Enter the following command in the terminal
+
+```bash
+# to sync garmin-cn to garmin-global
+python3(python) run_page/garmin_sync_cn_global.py ${garmin_cn_secret_string} ${garmin_secret_string}
+```
+
+
+
### Nike Run Club
@@ -721,8 +770,6 @@ For more display effects, see:
4. make sure you have write permissions in Workflow permissions settings.
-
-
5. If you want to deploy your running_page to xxx.github.io instead of xxx.github.io/running_page, you need to do three things:
- Rename your forked running_page repository to `xxx.github.io`, where xxx is your GitHub username
diff --git a/assets/index.tsx b/assets/index.tsx
index 03ce70dcee2..8efea083b54 100644
--- a/assets/index.tsx
+++ b/assets/index.tsx
@@ -1 +1,2 @@
export const yearStats = import.meta.glob('./year_*.svg', { import: 'ReactComponent' })
+export const totalStat = import.meta.glob(['./github.svg', './grid.svg'], { import: 'ReactComponent' })
diff --git a/package.json b/package.json
index d32ec67a5c9..054204c055a 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"dependencies": {
"@mapbox/mapbox-gl-language": "^1.0.0",
"@mapbox/polyline": "^1.1.1",
+ "@svgr/plugin-svgo": "^8.1.0",
"@vercel/analytics": "^0.1.6",
"@vitejs/plugin-react": "^4.0.0",
"gcoord": "^0.3.2",
@@ -14,6 +15,7 @@
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-ga4": "^2.1.0",
"react-helmet-async": "^1.3.0",
"react-map-gl": "^7.1.6",
"react-router-dom": "^6.15.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f85f54cedd8..6c5483dbf78 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ dependencies:
'@mapbox/polyline':
specifier: ^1.1.1
version: 1.2.0
+ '@svgr/plugin-svgo':
+ specifier: ^8.1.0
+ version: 8.1.0(@svgr/core@7.0.0)
'@vercel/analytics':
specifier: ^0.1.6
version: 0.1.11(react@18.2.0)
@@ -35,6 +38,9 @@ dependencies:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ react-ga4:
+ specifier: ^2.1.0
+ version: 2.1.0
react-helmet-async:
specifier: ^1.3.0
version: 1.3.0(react-dom@18.2.0)(react@18.2.0)
@@ -862,6 +868,23 @@ packages:
- supports-color
dev: false
+ /@svgr/plugin-svgo@8.1.0(@svgr/core@7.0.0):
+ resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ '@svgr/core': '*'
+ dependencies:
+ '@svgr/core': 7.0.0
+ cosmiconfig: 8.2.0
+ deepmerge: 4.3.1
+ svgo: 3.1.0
+ dev: false
+
+ /@trysound/sax@0.2.0:
+ resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
+ engines: {node: '>=10.13.0'}
+ dev: false
+
/@types/estree@1.0.1:
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
dev: false
@@ -1152,6 +1175,10 @@ packages:
engines: {node: '>=8'}
dev: false
+ /boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+ dev: false
+
/brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
@@ -1276,6 +1303,11 @@ packages:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: true
+ /commander@7.2.0:
+ resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+ engines: {node: '>= 10'}
+ dev: false
+
/concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
@@ -1303,10 +1335,48 @@ packages:
which: 2.0.2
dev: true
+ /css-select@5.1.0:
+ resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
+ dependencies:
+ boolbase: 1.0.0
+ css-what: 6.1.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ nth-check: 2.1.1
+ dev: false
+
+ /css-tree@2.2.1:
+ resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+ dependencies:
+ mdn-data: 2.0.28
+ source-map-js: 1.0.2
+ dev: false
+
+ /css-tree@2.3.1:
+ resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+ dependencies:
+ mdn-data: 2.0.30
+ source-map-js: 1.0.2
+ dev: false
+
+ /css-what@6.1.0:
+ resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
+ engines: {node: '>= 6'}
+ dev: false
+
/csscolorparser@1.0.3:
resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==}
dev: false
+ /csso@5.0.5:
+ resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+ dependencies:
+ css-tree: 2.2.1
+ dev: false
+
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
dev: true
@@ -1339,6 +1409,11 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
+ /deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+ dev: false
+
/define-properties@1.2.0:
resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==}
engines: {node: '>= 0.4'}
@@ -1368,6 +1443,33 @@ packages:
esutils: 2.0.3
dev: true
+ /dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+ dev: false
+
+ /domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+ dev: false
+
+ /domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+ dependencies:
+ domelementtype: 2.3.0
+ dev: false
+
+ /domutils@3.1.0:
+ resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ dev: false
+
/earcut@2.2.4:
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
dev: false
@@ -2382,6 +2484,14 @@ packages:
vt-pbf: 3.1.3
dev: false
+ /mdn-data@2.0.28:
+ resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
+ dev: false
+
+ /mdn-data@2.0.30:
+ resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
+ dev: false
+
/meow@6.1.1:
resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}
engines: {node: '>=8'}
@@ -2471,6 +2581,12 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+ dependencies:
+ boolbase: 1.0.0
+ dev: false
+
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -2706,6 +2822,10 @@ packages:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false
+ /react-ga4@2.1.0:
+ resolution: {integrity: sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==}
+ dev: false
+
/react-helmet-async@1.3.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==}
peerDependencies:
@@ -3131,6 +3251,20 @@ packages:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
dev: false
+ /svgo@3.1.0:
+ resolution: {integrity: sha512-R5SnNA89w1dYgNv570591F66v34b3eQShpIBcQtZtM5trJwm1VvxbIoMpRYY3ybTAutcKTLEmTsdnaknOHbiQA==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+ dependencies:
+ '@trysound/sax': 0.2.0
+ commander: 7.2.0
+ css-select: 5.1.0
+ css-tree: 2.3.1
+ css-what: 6.1.0
+ csso: 5.0.5
+ picocolors: 1.0.0
+ dev: false
+
/tachyons-custom@4.9.8:
resolution: {integrity: sha512-MVnslsN5dFswmh2+Sw+OOA4/6pQfnJrA8/wrtaf+Px347wze3MIk3lp3Q3QmbUPqrJda79s0dZtY3dgTwVHdfg==}
dev: false
diff --git a/requirements.txt b/requirements.txt
index da5d2237234..bd368b63a8e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,13 +16,14 @@ aiofiles
cloudscraper==1.2.58
git+https://github.com/alenrajsp/tcxreader.git
rich
-lxml
+lxml==4.9.4
eviltransform
stravaweblib
tenacity
numpy
tzlocal
fit-tool
+garmin-fit-sdk
haversine==2.8.0
garth
pycryptodome
diff --git a/run_page/codoon_sync.py b/run_page/codoon_sync.py
index aa0d198975e..84a19bf4650 100755
--- a/run_page/codoon_sync.py
+++ b/run_page/codoon_sync.py
@@ -129,6 +129,12 @@ def formated_input(
def tcx_output(fit_array, run_data):
# route ID
fit_id = str(run_data["id"])
+ # local time
+ fit_start_time_local = run_data["start_time"]
+ # zulu time
+ utc = adjust_time_to_utc(to_date(fit_start_time_local), str(get_localzone()))
+ fit_start_time = utc.strftime("%Y-%m-%dT%H:%M:%SZ")
+
# Root node
training_center_database = ET.Element(
"TrainingCenterDatabase",
@@ -154,7 +160,7 @@ def tcx_output(fit_array, run_data):
activities.append(activity)
# Id
activity_id = ET.Element("Id")
- activity_id.text = fit_id
+ activity_id.text = fit_start_time # Codoon use start_time as ID
activity.append(activity_id)
# Creator
activity_creator = ET.Element("Creator")
@@ -164,13 +170,6 @@ def tcx_output(fit_array, run_data):
activity_creator_name.text = "咕咚"
activity_creator.append(activity_creator_name)
# Lap
-
- # local time
- fit_start_time_local = run_data["start_time"]
- # zulu time
- utc = adjust_time_to_utc(to_date(fit_start_time_local), str(get_localzone()))
- fit_start_time = utc.strftime("%Y-%m-%dT%H:%M:%SZ")
-
activity_lap = ET.Element("Lap", {"StartTime": fit_start_time})
activity.append(activity_lap)
# TotalTimeSeconds
diff --git a/run_page/config.py b/run_page/config.py
index bae7be41597..cbc2031302d 100644
--- a/run_page/config.py
+++ b/run_page/config.py
@@ -19,6 +19,7 @@
SQL_FILE = os.path.join(parent, "run_page", "data.db")
JSON_FILE = os.path.join(parent, "src", "static", "activities.json")
SYNCED_FILE = os.path.join(parent, "imported.json")
+SYNCED_ACTIVITY_FILE = os.path.join(parent, "synced_activity.json")
# TODO: Move into nike_sync NRC THINGS
diff --git a/run_page/garmin_sync.py b/run_page/garmin_sync.py
index 1c06cd21fe1..1defb34a4fa 100755
--- a/run_page/garmin_sync.py
+++ b/run_page/garmin_sync.py
@@ -117,7 +117,9 @@ async def download_activity(self, activity_id, file_type="gpx"):
response.raise_for_status()
return response.read()
- async def upload_activities_original(self, datas, use_fake_garmin_device=False):
+ async def upload_activities_original_from_strava(
+ self, datas, use_fake_garmin_device=False
+ ):
print(
"start upload activities to garmin!, use_fake_garmin_device:",
use_fake_garmin_device,
@@ -152,6 +154,38 @@ async def upload_activities_original(self, datas, use_fake_garmin_device=False):
print("garmin upload failed: ", e)
await self.req.aclose()
+ async def upload_activity_from_file(self, file):
+ print("Uploading " + str(file))
+ f = open(file, "rb")
+
+ file_body = BytesIO(f.read())
+ files = {"file": (file, file_body)}
+
+ try:
+ res = await self.req.post(
+ self.upload_url, files=files, headers=self.headers
+ )
+ f.close()
+ except Exception as e:
+ print(str(e))
+ # just pass for now
+ return
+ try:
+ resp = res.json()["detailedImportResult"]
+ print("garmin upload success: ", resp)
+ except Exception as e:
+ print("garmin upload failed: ", e)
+
+ async def upload_activities_files(self, files):
+ print("start upload activities to garmin!")
+
+ await gather_with_concurrency(
+ 10,
+ [self.upload_activity_from_file(file=f) for f in files],
+ )
+
+ await self.req.aclose()
+
class GarminConnectHttpError(Exception):
def __init__(self, status):
@@ -201,10 +235,18 @@ async def download_garmin_data(client, activity_id, file_type="gpx"):
zip_file = zipfile.ZipFile(file_path, "r")
for file_info in zip_file.infolist():
zip_file.extract(file_info, folder)
- os.rename(
- os.path.join(folder, f"{activity_id}_ACTIVITY.fit"),
- os.path.join(folder, f"{activity_id}.fit"),
- )
+ if file_info.filename.endswith(".fit"):
+ os.rename(
+ os.path.join(folder, f"{activity_id}_ACTIVITY.fit"),
+ os.path.join(folder, f"{activity_id}.fit"),
+ )
+ elif file_info.filename.endswith(".gpx"):
+ os.rename(
+ os.path.join(folder, f"{activity_id}_ACTIVITY.gpx"),
+ os.path.join(FOLDER_DICT["gpx"], f"{activity_id}.gpx"),
+ )
+ else:
+ os.remove(os.path.join(folder, file_info.filename))
os.remove(file_path)
except Exception as e:
print(f"Failed to download activity {activity_id}: {str(e)}")
@@ -320,4 +362,7 @@ async def download_new_activities(
)
)
loop.run_until_complete(future)
+ # fit may contain gpx(maybe upload by user)
+ if file_type == "fit":
+ make_activities_file(SQL_FILE, FOLDER_DICT["gpx"], JSON_FILE, file_suffix="gpx")
make_activities_file(SQL_FILE, folder, JSON_FILE, file_suffix=file_type)
diff --git a/run_page/garmin_sync_cn_global.py b/run_page/garmin_sync_cn_global.py
new file mode 100644
index 00000000000..8d4ab0cd58b
--- /dev/null
+++ b/run_page/garmin_sync_cn_global.py
@@ -0,0 +1,105 @@
+"""
+Python 3 API wrapper for Garmin Connect to get your statistics.
+Copy most code from https://github.com/cyberjunky/python-garminconnect
+"""
+
+import argparse
+import asyncio
+import logging
+import os
+import sys
+import time
+import traceback
+import zipfile
+from io import BytesIO
+
+import aiofiles
+import cloudscraper
+import garth
+import httpx
+from config import FIT_FOLDER, GPX_FOLDER, JSON_FILE, SQL_FILE, config
+from garmin_device_adaptor import wrap_device_info
+from garmin_sync import Garmin, get_downloaded_ids
+from garmin_sync import download_new_activities, gather_with_concurrency
+from synced_data_file_logger import load_synced_activity_list, save_synced_activity_list
+from utils import make_activities_file
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "cn_secret_string", nargs="?", help="secret_string fro get_garmin_secret.py"
+ )
+ parser.add_argument(
+ "global_secret_string", nargs="?", help="secret_string fro get_garmin_secret.py"
+ )
+ parser.add_argument(
+ "--only-run",
+ dest="only_run",
+ action="store_true",
+ help="if is only for running",
+ )
+
+ options = parser.parse_args()
+ secret_string_cn = options.cn_secret_string
+ secret_string_global = options.global_secret_string
+ auth_domain = "CN"
+ is_only_running = options.only_run
+ if secret_string_cn is None or secret_string_global is None:
+ print("Missing argument nor valid configuration file")
+ sys.exit(1)
+
+ # Step 1:
+ # Sync all activities from Garmin CN to Garmin Global in FIT format
+ # If the activity is manually imported with a GPX, the GPX file will be synced
+
+ # load synced activity list
+ synced_activity = load_synced_activity_list()
+
+ folder = FIT_FOLDER
+ # make gpx or tcx dir
+ if not os.path.exists(folder):
+ os.mkdir(folder)
+
+ loop = asyncio.get_event_loop()
+ future = asyncio.ensure_future(
+ download_new_activities(
+ secret_string_cn,
+ auth_domain,
+ synced_activity,
+ is_only_running,
+ folder,
+ "fit",
+ )
+ )
+ loop.run_until_complete(future)
+ new_ids = future.result()
+
+ to_upload_files = []
+ for i in new_ids:
+ if os.path.exists(os.path.join(FIT_FOLDER, f"{i}.fit")):
+ # upload fit files
+ to_upload_files.append(os.path.join(FIT_FOLDER, f"{i}.fit"))
+ elif os.path.exists(os.path.join(GPX_FOLDER, f"{i}.gpx")):
+ # upload gpx files which are manually uploaded to garmin connect
+ to_upload_files.append(os.path.join(GPX_FOLDER, f"{i}.gpx"))
+
+ print("Files to sync:" + " ".join(to_upload_files))
+ garmin_global_client = Garmin(
+ secret_string_global,
+ config("sync", "garmin", "authentication_domain"),
+ is_only_running,
+ )
+ loop = asyncio.get_event_loop()
+ future = asyncio.ensure_future(
+ garmin_global_client.upload_activities_files(to_upload_files)
+ )
+ loop.run_until_complete(future)
+
+ # Save synced activity list for speeding up
+ synced_activity.extend(new_ids)
+ save_synced_activity_list(synced_activity)
+
+ # Step 2:
+ # Generate track from fit/gpx file
+ make_activities_file(SQL_FILE, GPX_FOLDER, JSON_FILE, file_suffix="gpx")
+ make_activities_file(SQL_FILE, FIT_FOLDER, JSON_FILE, file_suffix="fit")
diff --git a/run_page/gpxtrackposter/track.py b/run_page/gpxtrackposter/track.py
index 3172db26a1c..3efbed2a447 100644
--- a/run_page/gpxtrackposter/track.py
+++ b/run_page/gpxtrackposter/track.py
@@ -12,14 +12,8 @@
import lxml
import polyline
import s2sphere as s2
-from fit_tool.fit_file import FitFile
-from fit_tool.profile.messages.activity_message import ActivityMessage
-from fit_tool.profile.messages.device_info_message import DeviceInfoMessage
-from fit_tool.profile.messages.file_id_message import FileIdMessage
-from fit_tool.profile.messages.record_message import RecordMessage
-from fit_tool.profile.messages.session_message import SessionMessage
-from fit_tool.profile.messages.software_message import SoftwareMessage
-from fit_tool.profile.profile_type import Sport
+from garmin_fit_sdk import Decoder, Stream
+from garmin_fit_sdk.util import FIT_EPOCH_S
from polyline_processor import filter_out
from rich import print
from tcxreader.tcxreader import TCXReader
@@ -32,6 +26,13 @@
IGNORE_BEFORE_SAVING = os.getenv("IGNORE_BEFORE_SAVING", False)
+# Garmin stores all latitude and longitude values as 32-bit integer values.
+# This unit is called semicircle.
+# So that gives 2^32 possible values.
+# And to represent values up to 360° (or -180° to 180°), each 'degree' represents 2^32 / 360 = 11930465.
+# So dividing latitude and longitude (int32) value by 11930465 will give the decimal value.
+SEMICIRCLE = 11930465
+
class Track:
def __init__(self):
@@ -91,9 +92,12 @@ def load_fit(self, file_name):
# (for example, treadmill runs pulled via garmin-connect-export)
if os.path.getsize(file_name) == 0:
raise TrackLoadError("Empty FIT file")
-
- fit = FitFile.from_file(file_name)
- self._load_fit_data(fit)
+ stream = Stream.from_file(file_name)
+ decoder = Decoder(stream)
+ messages, errors = decoder.read(convert_datetimes_to_dates=False)
+ if errors:
+ print(f"FIT file read fail: {errors}")
+ self._load_fit_data(messages)
except Exception as e:
print(
f"Something went wrong when loading FIT. for file {self.file_names[0]}, we just ignore this file and continue"
@@ -223,59 +227,55 @@ def _load_gpx_data(self, gpx):
)
self.moving_dict = self._get_moving_data(gpx)
- def _load_fit_data(self, fit: FitFile):
+ def _load_fit_data(self, fit: dict):
_polylines = []
self.polyline_container = []
+ message = fit["session_mesgs"][0]
+ self.start_time = datetime.datetime.utcfromtimestamp(
+ (message["start_time"] + FIT_EPOCH_S)
+ )
+ self.run_id = self.__make_run_id(self.start_time)
+ self.end_time = datetime.datetime.utcfromtimestamp(
+ (message["start_time"] + FIT_EPOCH_S + message["total_elapsed_time"])
+ )
+ self.length = message["total_distance"]
+ self.average_heartrate = (
+ message["avg_heart_rate"] if "avg_heart_rate" in message else None
+ )
+ self.type = message["sport"].lower()
- for record in fit.records:
- message = record.message
-
- if isinstance(message, RecordMessage):
- if message.position_lat and message.position_long:
- _polylines.append(
- s2.LatLng.from_degrees(
- message.position_lat, message.position_long
- )
- )
- self.polyline_container.append(
- [message.position_lat, message.position_long]
- )
- elif isinstance(message, SessionMessage):
- self.start_time = datetime.datetime.utcfromtimestamp(
- message.start_time / 1000
- )
- self.run_id = message.start_time
- self.end_time = datetime.datetime.utcfromtimestamp(
- (message.start_time + message.total_elapsed_time * 1000) / 1000
- )
- self.length = message.total_distance
- self.average_heartrate = (
- message.avg_heart_rate if message.avg_heart_rate != 0 else None
- )
- self.type = Sport(message.sport).name.lower()
-
- # moving_dict
- self.moving_dict["distance"] = message.total_distance
- self.moving_dict["moving_time"] = datetime.timedelta(
- seconds=message.total_moving_time
- if message.total_moving_time
- else message.total_timer_time
- )
- self.moving_dict["elapsed_time"] = datetime.timedelta(
- seconds=message.total_elapsed_time
- )
- self.moving_dict["average_speed"] = (
- message.enhanced_avg_speed
- if message.enhanced_avg_speed
- else message.avg_speed
- )
-
- self.start_time_local, self.end_time_local = parse_datetime_to_local(
- self.start_time, self.end_time, self.polyline_container[0]
+ # moving_dict
+ self.moving_dict["distance"] = message["total_distance"]
+ self.moving_dict["moving_time"] = datetime.timedelta(
+ seconds=message["total_moving_time"]
+ if "total_moving_time" in message
+ else message["total_timer_time"]
+ )
+ self.moving_dict["elapsed_time"] = datetime.timedelta(
+ seconds=message["total_elapsed_time"]
)
- self.start_latlng = start_point(*self.polyline_container[0])
- self.polylines.append(_polylines)
- self.polyline_str = polyline.encode(self.polyline_container)
+ self.moving_dict["average_speed"] = (
+ message["enhanced_avg_speed"]
+ if message["enhanced_avg_speed"]
+ else message["avg_speed"]
+ )
+ for record in fit["record_mesgs"]:
+ if "position_lat" in record and "position_long" in record:
+ lat = record["position_lat"] / SEMICIRCLE
+ lng = record["position_long"] / SEMICIRCLE
+ _polylines.append(s2.LatLng.from_degrees(lat, lng))
+ self.polyline_container.append([lat, lng])
+ if self.polyline_container:
+ self.start_time_local, self.end_time_local = parse_datetime_to_local(
+ self.start_time, self.end_time, self.polyline_container[0]
+ )
+ self.start_latlng = start_point(*self.polyline_container[0])
+ self.polylines.append(_polylines)
+ self.polyline_str = polyline.encode(self.polyline_container)
+ else:
+ self.start_time_local, self.end_time_local = parse_datetime_to_local(
+ self.start_time, self.end_time, None
+ )
def append(self, other):
"""Append other track to self."""
diff --git a/run_page/gpxtrackposter/utils.py b/run_page/gpxtrackposter/utils.py
index b2234c97484..6b371ec1507 100644
--- a/run_page/gpxtrackposter/utils.py
+++ b/run_page/gpxtrackposter/utils.py
@@ -129,16 +129,19 @@ def format_float(f):
def parse_datetime_to_local(start_time, end_time, point):
- # just parse the start time, because start/end maybe different
- offset = start_time.utcoffset()
- if offset:
- return start_time + offset, end_time + offset
- lat, lng = point
- try:
- timezone = get_tz(lng=lng, lat=lat)
- except:
- # just a little trick when tzfpy support windows will delete this
+ if not point:
+ timezone = "Asia/Shanghai"
+ else:
+ # just parse the start time, because start/end maybe different
+ offset = start_time.utcoffset()
+ if offset:
+ return start_time + offset, end_time + offset
lat, lng = point
- timezone = tf.timezone_at(lng=lng, lat=lat)
+ try:
+ timezone = get_tz(lng=lng, lat=lat)
+ except:
+ # just a little trick when tzfpy support windows will delete this
+ lat, lng = point
+ timezone = tf.timezone_at(lng=lng, lat=lat)
tc_offset = datetime.now(pytz.timezone(timezone)).utcoffset()
return start_time + tc_offset, end_time + tc_offset
diff --git a/run_page/strava_to_garmin_sync.py b/run_page/strava_to_garmin_sync.py
index 34adba7d7a5..ca8890c2f23 100644
--- a/run_page/strava_to_garmin_sync.py
+++ b/run_page/strava_to_garmin_sync.py
@@ -98,7 +98,9 @@ async def upload_to_activities(
files_list.append(data)
except Exception as ex:
print("get strava data error: ", ex)
- await garmin_client.upload_activities_original(files_list, use_fake_garmin_device)
+ await garmin_client.upload_activities_original_from_strava(
+ files_list, use_fake_garmin_device
+ )
return files_list
diff --git a/run_page/synced_data_file_logger.py b/run_page/synced_data_file_logger.py
index de9583b59d5..5a7fb98efce 100644
--- a/run_page/synced_data_file_logger.py
+++ b/run_page/synced_data_file_logger.py
@@ -1,5 +1,5 @@
import os
-from config import SYNCED_FILE
+from config import SYNCED_FILE, SYNCED_ACTIVITY_FILE
import json
@@ -12,6 +12,11 @@ def save_synced_data_file_list(file_list: list):
json.dump(file_list, f)
+def save_synced_activity_list(activity_list: list):
+ with open(SYNCED_ACTIVITY_FILE, "w") as f:
+ json.dump(activity_list, f)
+
+
def load_synced_file_list():
if os.path.exists(SYNCED_FILE):
with open(SYNCED_FILE, "r") as f:
@@ -22,3 +27,15 @@ def load_synced_file_list():
pass
return []
+
+
+def load_synced_activity_list():
+ if os.path.exists(SYNCED_ACTIVITY_FILE):
+ with open(SYNCED_ACTIVITY_FILE, "r") as f:
+ try:
+ return json.load(f)
+ except Exception as e:
+ print(f"json load {SYNCED_ACTIVITY_FILE} \nerror {e}")
+ pass
+
+ return []
diff --git a/run_page/utils.py b/run_page/utils.py
index b003707284a..de6945ee48a 100644
--- a/run_page/utils.py
+++ b/run_page/utils.py
@@ -40,7 +40,9 @@ def to_date(ts):
# shouldn't be an issue since it's an offline cmdline tool
return datetime.strptime(ts, ts_fmt)
except ValueError:
- print("Error: Can not execute strptime")
+ print(
+ f"Warning: Can not execute strptime {ts} with ts_fmt {ts_fmt}, try next one..."
+ )
pass
raise ValueError(f"cannot parse timestamp {ts} into date with fmts: {ts_fmts}")
diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx
index 2d431b69ac3..a98cda8c369 100644
--- a/src/components/Header/index.tsx
+++ b/src/components/Header/index.tsx
@@ -1,5 +1,4 @@
import { Link } from 'react-router-dom';
-import React from 'react';
import useSiteMetadata from '@/hooks/useSiteMetadata';
const Header = () => {
diff --git a/src/components/LocationStat/CitiesStat.tsx b/src/components/LocationStat/CitiesStat.tsx
index 18f0d83a0a5..3dd5fcdae66 100644
--- a/src/components/LocationStat/CitiesStat.tsx
+++ b/src/components/LocationStat/CitiesStat.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import Stat from '@/components/Stat';
import useActivities from '@/hooks/useActivities';
diff --git a/src/components/LocationStat/LocationSummary.tsx b/src/components/LocationStat/LocationSummary.tsx
index 4c5b618ba44..03647609c86 100644
--- a/src/components/LocationStat/LocationSummary.tsx
+++ b/src/components/LocationStat/LocationSummary.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import Stat from '@/components/Stat';
import useActivities from '@/hooks/useActivities';
diff --git a/src/components/LocationStat/PeriodStat.tsx b/src/components/LocationStat/PeriodStat.tsx
index b733e97771b..14c7d7a690f 100644
--- a/src/components/LocationStat/PeriodStat.tsx
+++ b/src/components/LocationStat/PeriodStat.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import Stat from '@/components/Stat';
import useActivities from '@/hooks/useActivities';
diff --git a/src/components/LocationStat/index.tsx b/src/components/LocationStat/index.tsx
index d23548ad167..c0f58e2c6dd 100644
--- a/src/components/LocationStat/index.tsx
+++ b/src/components/LocationStat/index.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import YearStat from '@/components/YearStat';
import {
CHINESE_LOCATION_INFO_MESSAGE_FIRST,
diff --git a/src/components/RunMap/RunMapButtons.tsx b/src/components/RunMap/RunMapButtons.tsx
index 5153a63a288..7eec958a472 100644
--- a/src/components/RunMap/RunMapButtons.tsx
+++ b/src/components/RunMap/RunMapButtons.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import useActivities from '@/hooks/useActivities';
import styles from './style.module.scss';
diff --git a/src/components/RunMap/RunMaker.tsx b/src/components/RunMap/RunMarker.tsx
similarity index 97%
rename from src/components/RunMap/RunMaker.tsx
rename to src/components/RunMap/RunMarker.tsx
index d57bf8f3cd8..c373d8a22d0 100644
--- a/src/components/RunMap/RunMaker.tsx
+++ b/src/components/RunMap/RunMarker.tsx
@@ -1,6 +1,5 @@
import { ReactComponent as EndSvg } from '@assets/end.svg';
import { ReactComponent as StartSvg } from '@assets/start.svg';
-import React from 'react';
import { Marker } from 'react-map-gl';
import styles from './style.module.scss';
diff --git a/src/components/RunMap/index.tsx b/src/components/RunMap/index.tsx
index ebb5280ac6a..47f00bdf5e1 100644
--- a/src/components/RunMap/index.tsx
+++ b/src/components/RunMap/index.tsx
@@ -14,7 +14,7 @@ import {
MAP_HEIGHT,
} from '@/utils/const';
import { Coordinate, IViewState, geoJsonForMap } from '@/utils/utils';
-import RunMarker from './RunMaker';
+import RunMarker from './RunMarker';
import RunMapButtons from './RunMapButtons';
import styles from './style.module.scss';
import { FeatureCollection } from 'geojson';
@@ -46,10 +46,10 @@ const RunMap = ({
(ref: MapRef) => {
if (ref !== null) {
const map = ref.getMap();
- if (map) {
+ if (map && IS_CHINESE) {
map.addControl(new MapboxLanguage({defaultLanguage: 'zh-Hans'}));
- }
- map.on('load', () => {
+ }
+ map.on('load', () => {
if (!ROAD_LABEL_DISPLAY) {
// todo delete layers
MAP_LAYER_LIST.forEach((layerId) => {
@@ -57,8 +57,8 @@ const RunMap = ({
});
}
mapRef.current = ref;
- });
- }
+ });
+ }
if (mapRef.current) {
const map = mapRef.current.getMap();
if (map) {
@@ -107,7 +107,7 @@ const RunMap = ({
[endLon, endLat] = points[points.length - 1];
}
let dash = USE_DASH_LINE && !isSingleRun && !isBigMap ? [2, 2] : [2, 0];
- const onMove = React.useCallback(({ viewState }: {viewState: IViewState}) => {
+ const onMove = React.useCallback(({ viewState }: { viewState: IViewState }) => {
setViewState(viewState);
}, []);
const style: React.CSSProperties = {
@@ -123,7 +123,7 @@ const RunMap = ({
return (