Skip to content

Commit

Permalink
Merge branch 'pe-st:master' into cristian5th/customization
Browse files Browse the repository at this point in the history
  • Loading branch information
cristian5th authored Mar 12, 2024
2 parents f961d03 + 54b52af commit 2d727ae
Show file tree
Hide file tree
Showing 2 changed files with 85 additions and 19 deletions.
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,11 @@ If you have many activities, you may find that this script crashes with an "Oper
You will need a little experience running things from the command line to use this script. That said, here are the usage details from the `--help` flag:

```
usage: gcexport.py [-h] [--version] [-v] [--username USERNAME]
[--password PASSWORD] [-c COUNT]
[-sd START_DATE] [-ed END_DATE]
[-e EXTERNAL] [-a ARGS]
[-f {gpx,tcx,original,json}] [-d DIRECTORY] [-s SUBDIR]
[-lp LOGPATH] [-u] [-ot] [--desc [DESC]] [-t TEMPLATE]
[-fp] [-sa START_ACTIVITY_NO] [-ex FILE]
usage: gcexport.py [-h] [--version] [-v] [--username USERNAME] [--password PASSWORD]
[-c COUNT] [-sd START_DATE] [-ed END_DATE] [-e EXTERNAL] [-a ARGS]
[-f {gpx,tcx,original,json}] [-d DIRECTORY] [-s SUBDIR] [-lp LOGPATH]
[-u] [-ot] [--desc [DESC]] [-t TEMPLATE] [-fp] [-sa START_ACTIVITY_NO]
[-ex FILE] [-tf TYPE_FILTER] [-ss DIRECTORY]
Garmin Connect Exporter
Expand Down Expand Up @@ -94,10 +92,26 @@ optional arguments:
-sa START_ACTIVITY_NO, --start_activity_no START_ACTIVITY_NO
give index for first activity to import, i.e. skipping the newest activities
-ex FILE, --exclude FILE
JSON file with Array of activity IDs to exclude from download.
JSON file with array of activity IDs to exclude from download.
Format example: {"ids": ["6176888711"]}
-tf TYPE_FILTER, --type_filter TYPE_FILTER
comma-separated list of activity type IDs to allow. Format example: 3,9
-ss DIRECTORY, --session DIRECTORY
enable loading and storing SSO information from/to given directory
```

### Authentication

You have to authenticate with username and password, and possibly an MFA code, at least for an initial login.

The script is then using OAuth tokens (thanks to the [garth](https://github.com/matin/garth) library).
You can persist the OAuth token by giving a session directory, removing the need to provide username/password/MFA
for every script run.

But keep the persistent tokens safe; if somebody gets hold of your tokens, they might be able to
read all your data in Garmin Connect (e.g. your health data), maybe even change or delete it.


### Examples

- `python gcexport.py --count all`
Expand Down Expand Up @@ -181,7 +195,7 @@ For the history of this fork see the [CHANGELOG](CHANGELOG.md)

Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md)

Contributors as of 2023-10 (Hope I didn't forget anyone,
Contributors as of 2024-03 (Hope I didn't forget anyone,
see also [Contributors](https://github.com/pe-st/garmin-connect-export/graphs/contributors)):

- Kyle Krafka @kjkjava
Expand Down Expand Up @@ -210,6 +224,8 @@ see also [Contributors](https://github.com/pe-st/garmin-connect-export/graphs/co
- @geraudloup
- @app4g
- Simon Ågren @agrensimon
- @embear
- Joe Timmerman @joetimmerman

## License

Expand Down
70 changes: 60 additions & 10 deletions gcexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@

# PyPI imports
import garth
from garth.exc import GarthException

# Local application/library specific imports
from filtering import read_exclude, update_download_stats

COOKIE_JAR = http.cookiejar.CookieJar()
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(COOKIE_JAR), urllib.request.HTTPSHandler(debuglevel=0))

SCRIPT_VERSION = '4.2.0'
SCRIPT_VERSION = '4.3.0'

# This version here should correspond to what is written in CONTRIBUTING.md#python-3x-versions
MINIMUM_PYTHON_VERSION = (3, 8)
Expand Down Expand Up @@ -476,7 +477,11 @@ def parse_arguments(argv):
parser.add_argument('-sa', '--start_activity_no', type=int, default=1,
help='give index for first activity to import, i.e. skipping the newest activities')
parser.add_argument('-ex', '--exclude', metavar='FILE',
help='JSON file with Array of activity IDs to exclude from download. Format example: {"ids": ["6176888711"]}')
help='JSON file with array of activity IDs to exclude from download. Format example: {"ids": ["6176888711"]}')
parser.add_argument('-tf', '--type_filter',
help='comma-separated list of activity type IDs to allow. Format example: 3,9')
parser.add_argument('-ss', '--session', metavar='DIRECTORY',
help='enable loading and storing SSO information from/to given directory')
# fmt: on

return parser.parse_args(argv[1:])
Expand All @@ -486,12 +491,40 @@ def login_to_garmin_connect(args):
"""
Perform all HTTP requests to login to Garmin Connect.
"""
username = args.username if args.username else input('Username: ')
password = args.password if args.password else getpass()
garth_session_directory = args.session if args.session else None

print('Authenticating using OAuth...', end=' ')
print('Authenticating...', end='')
try:
garth.login(username, password)
login_required = False

# try to load data if a session directory is given
if garth_session_directory:
try:
garth.resume(garth_session_directory)
except GarthException as ex:
logging.debug("Could not resume session, error: %s", ex)
login_required = True
try:
garth.client.username
except GarthException as ex:
logging.debug("Session expired, error: %s", ex)
login_required = True
logging.info("Authenticating using OAuth token from %s", garth_session_directory)
else:
login_required = True

if login_required:
username = args.username if args.username else input('Username: ')
password = args.password if args.password else getpass()
garth.login(username, password)

# try to store data if a session directory is given
if garth_session_directory:
try:
garth.save(garth_session_directory)
except GarthException as ex:
logging.warning("Unable to store session data to %s, error: %s", garth_session_directory, ex)

except Exception as ex:
raise GarminException(f'Authentication failure ({ex}). Did you enter correct credentials?') from ex
print(' Done.')
Expand Down Expand Up @@ -930,7 +963,7 @@ def fetch_activity_list(args, total_to_download):
return activities


def annotate_activity_list(activities, start, exclude_list):
def annotate_activity_list(activities, start, exclude_list, type_filter):
"""
Creates an action list with a tuple per activity summary
Expand All @@ -944,6 +977,7 @@ def annotate_activity_list(activities, start, exclude_list):
:param start: One-based index of the first non-skipped activity
(i.e. with 1 no activity gets skipped, with 2 the first activity gets skipped etc)
:param exclude_list: List of activity ids that have to be skipped explicitly
:param type_filter: list of activity types to include in the output
:return: List of action tuples
"""

Expand All @@ -953,6 +987,8 @@ def annotate_activity_list(activities, start, exclude_list):
action = 's'
elif str(activity['activityId']) in exclude_list:
action = 'e'
elif type_filter is not None and activity['activityType']['typeId'] not in type_filter:
action = 'f'
else:
action = 'd'

Expand Down Expand Up @@ -1079,13 +1115,14 @@ def copy_details_to_summary(summary, details):
# fmt: on


def process_activity_item(item, number_of_items, device_dict, activity_type_name, event_type_name, csv_filter, args):
def process_activity_item(item, number_of_items, device_dict, type_filter, activity_type_name, event_type_name, csv_filter, args):
"""
Process one activity item: download the data, parse it and write a line to the CSV file
:param item: activity item tuple, see `annotate_activity_list()`
:param number_of_items: total number of items (for progress output)
:param device_dict: cache (dict) of already known devices
:param type_filter: list of activity types to include in the output
:param activity_type_name: lookup table for activity type descriptions
:param event_type_name: lookup table for event type descriptions
:param csv_filter: object encapsulating CSV file access
Expand All @@ -1109,6 +1146,14 @@ def process_activity_item(item, number_of_items, device_dict, activity_type_name
print(f"({current_index}/{number_of_items}) [{actvty['activityId']}]")
return

# Action: Filtered out by typeId
if action == 'f':
# Display which entry we're skipping.
type_id = actvty['activityType']['typeId']
print(f"Filtering out due to type ID {type_id} not in {type_filter}: Garmin Connect activity ", end='')
print(f"({current_index}/{number_of_items}) [{actvty['activityId']}]")
return

# Action: download
# Display which entry we're working on.
print('Downloading: Garmin Connect activity ', end='')
Expand Down Expand Up @@ -1241,7 +1286,10 @@ def main(argv):
event_type_name = load_properties(event_type_props)

activities = fetch_activity_list(args, total_to_download)
action_list = annotate_activity_list(activities, args.start_activity_no, exclude_list)

type_filter = list(map(int, args.type_filter.split(','))) if args.type_filter is not None else None

action_list = annotate_activity_list(activities, args.start_activity_no, exclude_list, type_filter)

csv_filename = os.path.join(args.directory, 'activities.csv')
csv_existed = os.path.isfile(csv_filename)
Expand All @@ -1256,7 +1304,9 @@ def main(argv):

# Process each activity.
for item in action_list:
process_activity_item(item, len(action_list), device_dict, activity_type_name, event_type_name, csv_filter, args)
process_activity_item(
item, len(action_list), device_dict, type_filter, activity_type_name, event_type_name, csv_filter, args
)

logging.info('CSV file written.')

Expand Down

0 comments on commit 2d727ae

Please sign in to comment.