Merge branch 'pr/285' into 0.15.0

This commit is contained in:
João Vitória Silva
2025-10-02 09:27:51 +01:00
17 changed files with 390 additions and 75 deletions

6
.gitignore vendored
View File

@@ -41,12 +41,18 @@ backend/app/data/activity_files/*.tcx
backend/app/data/activity_files/bulk_import/*.gpx
backend/app/data/activity_files/bulk_import/*.fit
backend/app/data/activity_files/bulk_import/*.tcx
backend/app/data/activity_files/bulk_import/*.gz
backend/app/data/activity_files/bulk_import/*.csv
backend/app/data/activity_files/bulk_import/import_errors/*.gpx
backend/app/data/activity_files/bulk_import/import_errors/*.fit
backend/app/data/activity_files/bulk_import/import_errors/*.tcx
backend/app/data/activity_files/bulk_import/import_errors/*.gz
backend/app/data/activity_files/bulk_import/import_errors/*.csv
backend/app/data/activity_files/processed/*.gpx
backend/app/data/activity_files/processed/*.fit
backend/app/data/activity_files/processed/*.tcx
backend/app/data/activity_files/processed/*.gz
backend/app/data/activity_files/processed/*.csv
backend/data/activity_files/
# Frontend

View File

@@ -22,6 +22,7 @@ ACTIVITY_MEDIA_DIR = os.getenv("ACTIVITY_MEDIA_DIR", f"{DATA_DIR}/activity_media
FILES_PROCESSED_DIR = f"{FILES_DIR}/processed"
FILES_BULK_IMPORT_DIR = f"{FILES_DIR}/bulk_import"
FILES_BULK_IMPORT_IMPORT_ERRORS_DIR = f"{FILES_BULK_IMPORT_DIR}/import_errors"
STRAVA_BULK_IMPORT_BIKES_FILE = "bikes.csv"
ENVIRONMENT = os.getenv("ENVIRONMENT", "production").lower()
TZ = os.getenv("TZ", "UTC")
REVERSE_GEO_PROVIDER = os.getenv("REVERSE_GEO_PROVIDER", "nominatim").lower()

View File

@@ -296,19 +296,54 @@ def get_gear_by_garminconnect_id_from_user_id(
def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Session):
try:
# Filter out None values from the gears list
valid_gears = [gear for gear in gears if gear is not None]
# Create a list of gear objects
new_gears = [
gears_utils.transform_schema_gear_to_model_gear(gear, user_id)
for gear in valid_gears
# 1) Filter out None and gears without a usable nickname
valid_gears = [
gear
for gear in (gears or [])
if gear is not None
and getattr(gear, "nickname", None)
and str(gear.nickname).strip()
]
# Add the gears to the database
db.add_all(new_gears)
db.commit()
# 2) De-dupe within the valid_gears payload (case-insensitive, trimmed)
seen = set()
deduped: list[gears_schema.Gear] = []
for gear in valid_gears:
nickname_normalized = str(gear.nickname).replace("+", " ").strip()
if nickname_normalized not in seen:
seen.add(nickname_normalized)
deduped.append(gear)
else:
core_logger.print_to_log_and_console(
f"Duplicate nickname '{gear.nickname}' in request for user {user_id}, skipping",
"warning",
)
# 3) Skip any that already exist for this user
gears_to_create: list[gears_schema.Gear] = []
for gear in deduped:
gear_check = get_gear_user_by_nickname(user_id, gear.nickname, db)
if gear_check is not None:
core_logger.print_to_log_and_console(
f"Gear with nickname '{gear.nickname}' already exists for user {user_id}, skipping",
"warning",
)
else:
gears_to_create.append(gear)
# 4) Persist any remaining
if gears_to_create:
new_gears = [
gears_utils.transform_schema_gear_to_model_gear(gear, user_id)
for gear in gears_to_create
]
db.add_all(new_gears)
db.commit()
except HTTPException as http_err:
# If an HTTPException is raised, re-raise it
raise http_err
except IntegrityError as integrity_error:
# Rollback the transaction
db.rollback()
@@ -316,9 +351,8 @@ def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Sess
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Duplicate entry error. Check if nickname, strava_gear_id or garminconnect_gear_id are unique",
detail="Duplicate entry error. Check if strava_gear_id or garminconnect_gear_id are unique",
) from integrity_error
except Exception as err:
# Rollback the transaction
db.rollback()
@@ -337,9 +371,7 @@ def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Sess
def create_gear(gear: gears_schema.Gear, user_id: int, db: Session):
try:
gear_check = get_gear_user_by_nickname(
user_id, gear.nickname, db
)
gear_check = get_gear_user_by_nickname(user_id, gear.nickname, db)
if gear_check is not None:
# If the gear already exists, raise an HTTPException with a 409 Conflict status code

View File

@@ -1,7 +1,13 @@
from sqlalchemy import func
from urllib.parse import unquote
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
import session.security as session_security
import core.database as core_database
import gears.gear.models as gears_models
import gears.gear.crud as gears_crud
import gears.gear.schema as gears_schema
# Global gear type integer to gear name mapping (ID to name)
@@ -37,6 +43,7 @@ GEAR_NAME_TO_ID.update(
}
)
def transform_schema_gear_to_model_gear(
gear: gears_schema.Gear, user_id: int
) -> gears_models.Gear:
@@ -50,12 +57,16 @@ def transform_schema_gear_to_model_gear(
# Create a new gear object
new_gear = gears_models.Gear(
brand=(
unquote(gear.brand).replace("+", " ") if gear.brand is not None else None
unquote(gear.brand).replace("+", " ").strip()
if gear.brand is not None
else None
),
model=(
unquote(gear.model).replace("+", " ") if gear.model is not None else None
unquote(gear.model).replace("+", " ").strip()
if gear.model is not None
else None
),
nickname=unquote(gear.nickname).replace("+", " "),
nickname=unquote(gear.nickname).replace("+", " ").strip(),
gear_type=gear.gear_type,
user_id=user_id,
created_at=created_date,

View File

@@ -1,7 +1,10 @@
import os
import csv
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from stravalib.client import Client
import core.config as core_config
import core.logger as core_logger
import strava.utils as strava_utils
@@ -9,6 +12,7 @@ import strava.athlete_utils as strava_athlete_utils
import gears.gear.schema as gears_schema
import gears.gear.crud as gears_crud
import gears.gear.utils as gears_utils
import activities.activity.schema as activities_schema
import activities.activity.crud as activities_crud
@@ -32,7 +36,7 @@ def get_strava_gear(gear_id: str, strava_client: Client):
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Not able to fetch Strava gear",
)
) from err
if strava_gear is None:
raise HTTPException(
@@ -80,7 +84,7 @@ def fetch_and_process_gear(strava_client: Client, user_id: int, db: Session) ->
def process_gear(
gear, type: str, user_id: int, strava_client: Client, db: Session
gear, gear_type: str, user_id: int, strava_client: Client, db: Session
) -> gears_schema.Gear | None:
# Get the gear by strava id from user id
gear_db = gears_crud.get_gear_by_strava_id_from_user_id(gear.id, user_id, db)
@@ -99,7 +103,7 @@ def process_gear(
brand=strava_gear.brand_name,
model=strava_gear.model_name,
nickname=strava_gear.name,
gear_type=1 if type == "bike" else 2,
gear_type=1 if gear_type == "bike" else 2,
user_id=user_id,
active=True,
strava_gear_id=gear.id,
@@ -109,11 +113,7 @@ def process_gear(
def iterate_over_activities_and_set_gear(
activity: activities_schema.Activity,
gears: list[gears_schema.Gear],
counter: int,
user_id: int,
db: Session,
activity: activities_schema.Activity, gears: list[gears_schema.Gear], counter: int
) -> dict:
# Iterate over gears and set gear if applicable
@@ -151,9 +151,7 @@ def set_activities_gear(user_id: int, db: Session) -> int:
# iterate over activities and set gear if applicable
for activity in activities:
parsed_activity = iterate_over_activities_and_set_gear(
activity, gears, counter, user_id, db
)
parsed_activity = iterate_over_activities_and_set_gear(activity, gears, counter)
counter = parsed_activity["counter"]
activities_parsed.append(parsed_activity["activity"])
@@ -202,3 +200,95 @@ def get_user_gear(user_id: int):
)
finally:
db.close()
def iterate_over_bikes_csv() -> dict:
"""
Parses a Strava bikes CSV file and returns its contents as a dictionary.
The function looks for a CSV file specified by configuration settings, reads it, and constructs a dictionary where each key is the "Bike Name" from the CSV, and the value is a dictionary of the bike's attributes as provided in the CSV row.
Returns:
dict: A dictionary mapping bike names to their corresponding data from the CSV file.
Raises:
HTTPException: If the CSV file is missing, has invalid headers, or cannot be parsed.
"""
# CSV file location
bulk_import_dir = core_config.FILES_BULK_IMPORT_DIR
bikes_file_name = core_config.STRAVA_BULK_IMPORT_BIKES_FILE
bikes_file_path = os.path.join(bulk_import_dir, bikes_file_name)
# Get file and parse it
bikes_dict = {}
try:
if os.path.isfile(bikes_file_path):
core_logger.print_to_log_and_console(
f"{bikes_file_name} exists in the {bulk_import_dir} directory. Starting to process file."
)
with open(bikes_file_path, "r", encoding="utf-8") as bike_file:
bikes_csv = csv.DictReader(bike_file)
for row in bikes_csv:
if (
("Bike Name" not in row)
or ("Bike Brand" not in row)
or ("Bike Model" not in row)
):
core_logger.print_to_log_and_console(
f"Aborting bikes import: Proper headers not found in {bikes_file_name}. File should have 'Bike Name', 'Bike Brand', and 'Bike Model'."
)
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Invalid file. Proper headers not found in Strava bikes CSV file.",
)
bikes_dict[row["Bike Name"]] = row
core_logger.print_to_log_and_console(
f"Strava bike gear csv file parsed and gear dictionary created. File was {len(bikes_dict)} rows long, ignoring header row."
)
return bikes_dict
core_logger.print_to_log_and_console(
f"No {bikes_file_name} file located in the {bulk_import_dir} directory."
)
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="No Strava bikes CSV file found for import.",
)
except HTTPException as http_err:
raise http_err
except Exception as err:
core_logger.print_to_log_and_console(
f"Error attempting to open {bikes_file_path} file: {err}", "error"
)
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Error parsing Strava bikes CSV file.",
) from err
def transform_csv_bike_gear_to_schema_gear(
bikes_dict: dict, token_user_id: int
) -> list[gears_schema.Gear]:
"""
Transforms a dictionary of bike data (parsed from CSV) into a list of Gear schema objects.
Args:
bikes_dict (dict): A dictionary where each key is a bike nickname and each value is a dictionary
containing bike attributes such as "Bike Brand" and "Bike Model".
token_user_id (int): The user ID to associate with each Gear object.
Returns:
list[gears_schema.Gear]: A list of Gear schema objects created from the input bike data.
"""
gears = []
for bike in bikes_dict:
new_gear = gears_schema.Gear(
user_id=token_user_id,
brand=bikes_dict[bike]["Bike Brand"],
model=bikes_dict[bike]["Bike Model"],
nickname=bike,
gear_type=gears_utils.GEAR_NAME_TO_ID["bike"],
is_active=True,
strava_gear_id=None,
)
gears.append(new_gear)
return gears

View File

@@ -1,3 +1,4 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated, Callable
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Security
@@ -10,14 +11,17 @@ import session.security as session_security
import users.user_integrations.crud as user_integrations_crud
import gears.gear.crud as gears_crud
import gears.gear.utils as gears_utils
import activities.activity.crud as activities_crud
import activities.activity.utils as activities_utils
import strava.gear_utils as strava_gear_utils
import strava.activity_utils as strava_activity_utils
import strava.utils as strava_utils
import strava.schema as strava_schema
import core.config as core_config
import core.cryptography as core_cryptography
import core.logger as core_logger
import core.database as core_database
@@ -67,8 +71,12 @@ async def strava_link(
# Exchange code for token
tokens = strava_client.exchange_code_for_token(
client_id=core_cryptography.decrypt_token_fernet(user_integrations.strava_client_id),
client_secret=core_cryptography.decrypt_token_fernet(user_integrations.strava_client_secret),
client_id=core_cryptography.decrypt_token_fernet(
user_integrations.strava_client_id
),
client_secret=core_cryptography.decrypt_token_fernet(
user_integrations.strava_client_secret
),
code=code,
)
@@ -85,9 +93,7 @@ async def strava_link(
)
# Clean up by setting Strava
user_integrations_crud.unlink_strava_account(
user_integrations.user_id, db
)
user_integrations_crud.unlink_strava_account(user_integrations.user_id, db)
# Raise an HTTPException with appropriate status code
raise HTTPException(
@@ -140,7 +146,7 @@ async def strava_retrieve_activities_days(
}
@router.get("/gear", status_code=202)
@router.get("/gear", status_code=201)
async def strava_retrieve_gear(
validate_access_token: Annotated[
Callable,
@@ -171,6 +177,59 @@ async def strava_retrieve_gear(
}
@router.post("/import/bikes", status_code=201)
async def import_bikes_from_strava_export(
token_user_id: Annotated[
int,
Depends(session_security.get_user_id_from_access_token),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
try:
# Log beginning of bike import
core_logger.print_to_log("Entering bike importing function")
# Get bikes from Strava export CSV file
bikes_dict = strava_gear_utils.iterate_over_bikes_csv()
# Transform bikes dict to list of Gear schema objects
if bikes_dict:
bikes = strava_gear_utils.transform_csv_bike_gear_to_schema_gear(
bikes_dict, token_user_id
)
# Add bikes to the database
if bikes:
gears_crud.create_multiple_gears(bikes, token_user_id, db)
# Define variables for moving the bikes file
processed_dir = core_config.FILES_PROCESSED_DIR
bulk_import_dir = core_config.FILES_BULK_IMPORT_DIR
bikes_file_name = core_config.STRAVA_BULK_IMPORT_BIKES_FILE
bikes_file_path = os.path.join(bulk_import_dir, bikes_file_name)
# Move the bikes file to the processed directory
activities_utils.move_file(processed_dir, bikes_file_name, bikes_file_path)
# Log completion of bike import
core_logger.print_to_log_and_console("Bike import complete.")
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
core_logger.print_to_log_and_console(
f"Error in import_bikes_from_strava_export: {err}", "error"
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
@router.put("/client")
async def strava_set_user_client(
client: strava_schema.StravaClient,

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Callable
from typing import Annotated
from fastapi import APIRouter, Depends, Security
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
import users.user_default_gear.schema as user_default_gear_schema
@@ -40,4 +40,6 @@ async def edit_user_default_gear(
],
):
# Update the user default gear in the database
return user_default_gear_crud.edit_user_default_gear(user_default_gear, token_user_id, db)
return user_default_gear_crud.edit_user_default_gear(
user_default_gear, token_user_id, db
)

View File

@@ -88,8 +88,8 @@ Docker image uses a non-root user, so ensure target folders are not owned by roo
## Bulk import and file upload
To perform a bulk import:
- Place .fit, .tcx, .gz and/or .gpx files into the activity_files/bulk_import folder. Create the folder if needed.
- In the "Settings" menu select "Integrations".
- Place .fit, .tcx, .gz and/or .gpx files into the data/activity_files/bulk_import folder. Create the folder if needed.
- In the "Settings" menu select "Import".
- Click "Import" next to "Bulk Import".
.fit files are preferred. I noticed that Strava/Garmin Connect process of converting .fit to .gpx introduces additional data to the activity file leading to minor variances in the data, like for example additional
@@ -99,8 +99,30 @@ meters in distance and elevation gain. Some notes:
- GEOCODES API has a limit of 1 Request/Second on the free plan, so if you have a large number of files, it might not be possible to import all in the same action
- The bulk import currently only imports data present in the .fit, .tcx or .gpx files - no metadata or other media are imported.
## Importing information from a Strava bulk export (BETA)
Strava allows users to create a bulk export of their historical activity on the site. This information is stored in a zip file, primarily as .csv files, GPS recording files (e.g., .gpx, .fit), and media files (e.g., .jpg, .png).
### Importing gear from a Strava bulk import
At the present time, importing bikes from a Strava bulk export is implemented as a beta feature - use with caution. Components of bikes are not imported - just the bikes themselves.
To perform an import of bikes:
- Place the bikes.csv file from a Strava bulk export into the data/activity_files/bulk_import folder. Create the folder if needed.
- In the "Settings" menu select "Import".
- Click "Bikes Import" next to "Strava import".
- Status messages about the import, including why any gear was not imported, can be found in the logs.
Ensure the file is named "bikes.csv" and has a header row with at least the fields 'Bike Name', 'Bike Brand', and 'Bike Model'.
### Importing other items from a Strava bulk import
Importing of shoes is under development in September 2025.
Importing activity metadata and media is under development in September 2025.
## Image personalization
It is possible (v0.10.0 or higher) to personalize the login image in the login page. To do that, map the data/server_images directory for image persistence on container updates and:
- Set the image in the server settings zone of the settings page
- A square image is expected. Default one uses 1000px vs 1000px
- A square image is expected. Default one uses 1000px vs 1000px

View File

@@ -0,0 +1,90 @@
<template>
<div class="col">
<div class="bg-body-tertiary rounded p-3 shadow-sm">
<!-- list zone -->
<ul class="list-group list-group-flush">
<!-- bulk import zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0">
<div class="d-flex align-items-center">
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t("settingsImportZone.bulkImportIntegrationTitle") }}
</div>
{{ $t("settingsImportZone.bulkImportIntegrationBody") }}
</div>
</div>
<div class="d-flex align-items-center">
<!-- import button -->
<a
href="#"
class="btn btn-primary"
role="button"
@click="submitBulkImport">{{ $t("settingsImportZone.buttonBulkImport") }}
</a>
</div>
</li>
<!-- Strava bulk-export import zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0">
<div class="d-flex align-items-center">
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t("settingsImportZone.stravaGearImportTitle") }}
{{ $t('generalItems.betaTag') }}
</div>
{{ $t("settingsImportZone.stravaGearImportBody") }}
</div>
</div>
<div class="d-flex align-items-center">
<!-- import button -->
<a
href="#"
class="btn btn-primary"
role="button"
@click="submitStravaBikesImport">{{ $t("settingsImportZone.stravaGearImportbuttonBikes") }}
</a>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { useI18n } from "vue-i18n";
// Import Notivue push
import { push } from "notivue";
// Importing the services
import { activities } from "@/services/activitiesService";
import { strava as stravaService } from "@/services/stravaService";
const { t } = useI18n();
async function submitBulkImport() {
try {
await activities.bulkImportActivities();
// Show the loading alert.
push.info(t("settingsImportZone.loadingMessageBulkImport"));
} catch (error) {
// If there is an error, show the error alert.
push.error(
`${t("settingsImportZone.errorMessageUnableToImportActivities")} - ${error}`,
);
}
}
async function submitStravaBikesImport() {
// Set the loading message
const notification = push.promise(t('settingsImportZone.loadingMessageStravaBikesImport'))
try {
await stravaService.importBikes();
// Resolve the loading message with a success message
notification.resolve(t("settingsImportZone.successMessageStravaBikesImport"));
} catch (error) {
// Reject the loading message with an error message
notification.reject(
`${t("settingsImportZone.errorMessageUnableToImportBikes")} - ${error}`,
);
}
}
</script>

View File

@@ -86,24 +86,6 @@
</div>
</div>
</li>
<!-- bulk import zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0">
<div class="d-flex align-items-center">
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t('settingsIntegrationsZone.bulkImportIntegrationTitle') }}
</div>
{{ $t('settingsIntegrationsZone.bulkImportIntegrationBody') }}
</div>
</div>
<div class="d-flex align-items-center">
<!-- import button -->
<a href="#" class="btn btn-primary" role="button" @click="submitBulkImport">{{
$t('settingsIntegrationsZone.buttonBulkImport')
}}</a>
</div>
</li>
<!-- Garmin Connect zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0 pb-0">
<div class="d-flex align-items-center">
@@ -378,15 +360,6 @@ async function buttonStravaUnlink() {
}
}
async function submitBulkImport() {
try {
await activities.bulkImportActivities()
push.info(t('settingsIntegrationsZone.loadingMessageBulkImport'))
} catch (error) {
push.error(`${t('settingsIntegrationsZone.errorMessageUnableToImportActivities')} - ${error}`)
}
}
async function submitRetrieveGarminConnectActivitiesDays(days) {
try {
const endDate = new Date()

View File

@@ -84,6 +84,17 @@
<span class="ms-1">{{ $t('settingsSideBar.integrationsSection') }}</span>
</a>
</li>
<li class="nav-item">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'import' }"
@click.prevent="changeActive('import')"
>
<font-awesome-icon :icon="['fas', 'fa-upload']" />
<span class="ms-1">{{ $t("settingsSideBar.importSection") }}</span>
</a>
</li>
</ul>
</div>
</template>

View File

@@ -60,6 +60,7 @@ const componentPaths = {
settingsUserProfileZone: 'components/settings/settingsUserProfileZoneComponent.json',
settingsSecurityZone: 'components/settings/settingsSecurityZoneComponent.json',
settingsIntegrationsZone: 'components/settings/settingsIntegrationsZoneComponent.json',
settingsImportZone: 'components/settings/settingsImportZoneComponent.json',
garminConnectLoginModalComponent:
'components/settings/settingsIntegrationsZone/garminConnectLoginModalComponent.json',
usersAddEditUserModalComponent:

View File

@@ -0,0 +1,13 @@
{
"bulkImportIntegrationTitle": "Bulk import",
"bulkImportIntegrationBody": "Bulk import activities from files (in the data/activity_files/bulk_import folder)",
"buttonBulkImport": "Import activities",
"loadingMessageBulkImport": "Importing activities from files...",
"errorMessageUnableToImportActivities": "An error occurred while importing activities",
"stravaGearImportTitle": "Strava gear import",
"stravaGearImportBody": "Import gear from a Strava bulk export (in the data/activity_files/bulk_import folder)",
"stravaGearImportbuttonBikes": "Import Strava bikes",
"loadingMessageStravaBikesImport": "Importing Strava bikes from file...",
"successMessageStravaBikesImport": "Strava bikes imported successfully",
"errorMessageUnableToImportBikes": "An error occurred while importing Strava bikes"
}

View File

@@ -27,11 +27,6 @@
"errorMessageUnableToUnlinkStrava": "Unable to unlink Strava account",
"modalUnlinkStravaTitle": "Unlink Strava",
"modalUnlinkStravaBody": "Are you sure you want to unlink your Strava account? Unlinking your Strava account will remove all your Strava activities and gear from Endurain.",
"bulkImportIntegrationTitle": "Bulk import",
"bulkImportIntegrationBody": "Bulk import activities from files stored in the activity_files/bulk_import folder",
"buttonBulkImport": "Import",
"errorMessageUnableToImportActivities": "An error occurred while importing activities",
"loadingMessageBulkImport": "Importing activities",
"garminConnectIntegrationTitle": "Garmin Connect",
"garminConnectIntegrationBody": "Garmin Connect is a health and fitness activity platform for users of Garmin devices",
"loadingMessageRetrievingGarminConnectActivities": "Retrieving Garmin Connect activities",

View File

@@ -5,5 +5,6 @@
"myProfileSection": "My Profile",
"myGoals": "My Goals",
"securitySection": "Security",
"integrationsSection": "Integrations"
"integrationsSection": "Integrations",
"importSection": "Import"
}

View File

@@ -1,4 +1,4 @@
import { fetchGetRequest, fetchPutRequest, fetchDeleteRequest } from '@/utils/serviceUtils'
import { fetchGetRequest, fetchPostRequest, fetchPutRequest, fetchDeleteRequest } from '@/utils/serviceUtils'
export const strava = {
setUniqueUserStateStravaLink(state) {
@@ -21,6 +21,9 @@ export const strava = {
// Redirect to the Strava authorization URL
window.location.href = stravaAuthUrl
},
importBikes() {
return fetchPostRequest('strava/import/bikes');
},
linkStravaCallback(state, code, scope) {
return fetchPutRequest(`strava/link?state=${state}&code=${code}&scope=${scope}`)
},

View File

@@ -30,6 +30,10 @@
<!-- Include the SettingsUserGoals -->
<SettingsUserGoals v-if="activeSection === 'myGoals'" />
<!-- Include the SettingsImportZone -->
<SettingsImportZone v-if="activeSection === 'import'" />
</div>
<!-- back button -->
<BackButtonComponent />
@@ -54,6 +58,7 @@ import SettingsUserProfileZone from '../components/Settings/SettingsUserProfileZ
import SettingsSecurityZone from '../components/Settings/SettingsSecurityZone.vue'
import SettingsIntegrationsZone from '../components/Settings/SettingsIntegrationsZone.vue'
import BackButtonComponent from '@/components/GeneralComponents/BackButtonComponent.vue'
import SettingsImportZone from '../components/Settings/SettingsImportZone.vue'
import SettingsUserGoals from '../components/Settings/SettingsUserGoals.vue'
const authStore = useAuthStore()