Compare commits
23 commits
main
...
McPringle/
| Author | SHA1 | Date | |
|---|---|---|---|
| 98be2cfada | |||
| 0663ca407f | |||
| 33c32c5311 | |||
| 0a98aa523d | |||
| 9a9785973d | |||
| 8a900ccd30 | |||
| 70e7632699 | |||
| 67999e8a4a | |||
| 3135a36679 | |||
| ea47cccdb1 | |||
| 84735594f2 | |||
| 6bd7ab8748 | |||
| 7a0315d855 | |||
| df3cd0616d | |||
| b5e88a317f | |||
| 5945a2b139 | |||
| f7f919f0b1 | |||
| 0387ca01e3 | |||
| 6d89426584 | |||
| aef23720d6 | |||
| 803caf06b1 | |||
| 0cea88d033 | |||
| 7ca09f0f27 |
52 changed files with 2101 additions and 1565 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -5,7 +5,10 @@ target/
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
### IntelliJ IDEA ###
|
||||||
.idea/
|
.idea/modules.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/libraries/
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|
@ -46,10 +49,3 @@ logs/
|
||||||
/gadm_410.gpkg
|
/gadm_410.gpkg
|
||||||
/.postgresdata/
|
/.postgresdata/
|
||||||
/peaks_worldwide.geojson
|
/peaks_worldwide.geojson
|
||||||
|
|
||||||
### Coding Assistants ###
|
|
||||||
.codex/
|
|
||||||
.aider*
|
|
||||||
.cursor/
|
|
||||||
.roo/
|
|
||||||
.windsurf/
|
|
||||||
|
|
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="testdb@localhost" uuid="2564811a-81f9-4d83-b1b1-04cb2763e3fa">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:51826/testdb</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/misc.xml
generated
Normal file
17
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ClojureProjectResolveSettings">
|
||||||
|
<currentScheme>IDE</currentScheme>
|
||||||
|
</component>
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/sqldialects.xml
generated
Normal file
8
.idea/sqldialects.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V26__add_published_to_activities.sql" dialect="H2" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
distributionType=only-script
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Enable auto-env through the sdkman_auto_env config
|
|
||||||
# Add key=value pairs of SDKs to use below
|
|
||||||
java=17.0.9-tem
|
|
||||||
295
mvnw
vendored
295
mvnw
vendored
|
|
@ -1,295 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
# or more contributor license agreements. See the NOTICE file
|
|
||||||
# distributed with this work for additional information
|
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing,
|
|
||||||
# software distributed under the License is distributed on an
|
|
||||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
# KIND, either express or implied. See the License for the
|
|
||||||
# specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
|
||||||
#
|
|
||||||
# Optional ENV vars
|
|
||||||
# -----------------
|
|
||||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
|
||||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
|
||||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
|
||||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
set -euf
|
|
||||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
|
||||||
|
|
||||||
# OS specific support.
|
|
||||||
native_path() { printf %s\\n "$1"; }
|
|
||||||
case "$(uname)" in
|
|
||||||
CYGWIN* | MINGW*)
|
|
||||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
|
||||||
native_path() { cygpath --path --windows "$1"; }
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# set JAVACMD and JAVACCMD
|
|
||||||
set_java_home() {
|
|
||||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
|
||||||
if [ -n "${JAVA_HOME-}" ]; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
|
||||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
|
||||||
else
|
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
|
||||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
|
||||||
|
|
||||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
|
||||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
|
||||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD="$(
|
|
||||||
'set' +e
|
|
||||||
'unset' -f command 2>/dev/null
|
|
||||||
'command' -v java
|
|
||||||
)" || :
|
|
||||||
JAVACCMD="$(
|
|
||||||
'set' +e
|
|
||||||
'unset' -f command 2>/dev/null
|
|
||||||
'command' -v javac
|
|
||||||
)" || :
|
|
||||||
|
|
||||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
|
||||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# hash string like Java String::hashCode
|
|
||||||
hash_string() {
|
|
||||||
str="${1:-}" h=0
|
|
||||||
while [ -n "$str" ]; do
|
|
||||||
char="${str%"${str#?}"}"
|
|
||||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
|
||||||
str="${str#?}"
|
|
||||||
done
|
|
||||||
printf %x\\n $h
|
|
||||||
}
|
|
||||||
|
|
||||||
verbose() { :; }
|
|
||||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
|
||||||
|
|
||||||
die() {
|
|
||||||
printf %s\\n "$1" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
trim() {
|
|
||||||
# MWRAPPER-139:
|
|
||||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
|
||||||
# Needed for removing poorly interpreted newline sequences when running in more
|
|
||||||
# exotic environments such as mingw bash on Windows.
|
|
||||||
printf "%s" "${1}" | tr -d '[:space:]'
|
|
||||||
}
|
|
||||||
|
|
||||||
scriptDir="$(dirname "$0")"
|
|
||||||
scriptName="$(basename "$0")"
|
|
||||||
|
|
||||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
|
||||||
while IFS="=" read -r key value; do
|
|
||||||
case "${key-}" in
|
|
||||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
|
||||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
|
||||||
esac
|
|
||||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
|
||||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
|
||||||
|
|
||||||
case "${distributionUrl##*/}" in
|
|
||||||
maven-mvnd-*bin.*)
|
|
||||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
|
||||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
|
||||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
|
||||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
|
||||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
|
||||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
|
||||||
*)
|
|
||||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
|
||||||
distributionPlatform=linux-amd64
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
|
||||||
;;
|
|
||||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
|
||||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
|
||||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
|
||||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
|
||||||
distributionUrlName="${distributionUrl##*/}"
|
|
||||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
|
||||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
|
||||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
|
||||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
|
||||||
|
|
||||||
exec_maven() {
|
|
||||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
|
||||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -d "$MAVEN_HOME" ]; then
|
|
||||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
|
||||||
exec_maven "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${distributionUrl-}" in
|
|
||||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
|
||||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# prepare tmp dir
|
|
||||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
|
||||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
|
||||||
trap clean HUP INT TERM EXIT
|
|
||||||
else
|
|
||||||
die "cannot create temp dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
|
||||||
|
|
||||||
# Download and Install Apache Maven
|
|
||||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
|
||||||
verbose "Downloading from: $distributionUrl"
|
|
||||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
|
||||||
|
|
||||||
# select .zip or .tar.gz
|
|
||||||
if ! command -v unzip >/dev/null; then
|
|
||||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
|
||||||
distributionUrlName="${distributionUrl##*/}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# verbose opt
|
|
||||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
|
||||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
|
||||||
|
|
||||||
# normalize http auth
|
|
||||||
case "${MVNW_PASSWORD:+has-password}" in
|
|
||||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
|
||||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
|
||||||
verbose "Found wget ... using wget"
|
|
||||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
|
||||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
|
||||||
verbose "Found curl ... using curl"
|
|
||||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
|
||||||
elif set_java_home; then
|
|
||||||
verbose "Falling back to use Java to download"
|
|
||||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
|
||||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
|
||||||
cat >"$javaSource" <<-END
|
|
||||||
public class Downloader extends java.net.Authenticator
|
|
||||||
{
|
|
||||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
|
||||||
{
|
|
||||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
|
||||||
}
|
|
||||||
public static void main( String[] args ) throws Exception
|
|
||||||
{
|
|
||||||
setDefault( new Downloader() );
|
|
||||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
END
|
|
||||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
|
||||||
verbose " - Compiling Downloader.java ..."
|
|
||||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
|
||||||
verbose " - Running Downloader.java ..."
|
|
||||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
|
||||||
if [ -n "${distributionSha256Sum-}" ]; then
|
|
||||||
distributionSha256Result=false
|
|
||||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
|
||||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
|
||||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
|
||||||
exit 1
|
|
||||||
elif command -v sha256sum >/dev/null; then
|
|
||||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
|
||||||
distributionSha256Result=true
|
|
||||||
fi
|
|
||||||
elif command -v shasum >/dev/null; then
|
|
||||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
|
||||||
distributionSha256Result=true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
|
||||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ $distributionSha256Result = false ]; then
|
|
||||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
|
||||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# unzip and move
|
|
||||||
if command -v unzip >/dev/null; then
|
|
||||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
|
||||||
else
|
|
||||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
|
||||||
actualDistributionDir=""
|
|
||||||
|
|
||||||
# First try the expected directory name (for regular distributions)
|
|
||||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
|
||||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
|
||||||
actualDistributionDir="$distributionUrlNameMain"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
|
||||||
if [ -z "$actualDistributionDir" ]; then
|
|
||||||
# enable globbing to iterate over items
|
|
||||||
set +f
|
|
||||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
|
||||||
actualDistributionDir="$(basename "$dir")"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
set -f
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$actualDistributionDir" ]; then
|
|
||||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
|
||||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
|
||||||
die "Could not find Maven distribution directory in extracted archive"
|
|
||||||
fi
|
|
||||||
|
|
||||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
|
||||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
|
||||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
|
||||||
|
|
||||||
clean || :
|
|
||||||
exec_maven "$@"
|
|
||||||
189
mvnw.cmd
vendored
189
mvnw.cmd
vendored
|
|
@ -1,189 +0,0 @@
|
||||||
<# : batch portion
|
|
||||||
@REM ----------------------------------------------------------------------------
|
|
||||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
@REM or more contributor license agreements. See the NOTICE file
|
|
||||||
@REM distributed with this work for additional information
|
|
||||||
@REM regarding copyright ownership. The ASF licenses this file
|
|
||||||
@REM to you under the Apache License, Version 2.0 (the
|
|
||||||
@REM "License"); you may not use this file except in compliance
|
|
||||||
@REM with the License. You may obtain a copy of the License at
|
|
||||||
@REM
|
|
||||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@REM
|
|
||||||
@REM Unless required by applicable law or agreed to in writing,
|
|
||||||
@REM software distributed under the License is distributed on an
|
|
||||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
@REM KIND, either express or implied. See the License for the
|
|
||||||
@REM specific language governing permissions and limitations
|
|
||||||
@REM under the License.
|
|
||||||
@REM ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@REM ----------------------------------------------------------------------------
|
|
||||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
|
||||||
@REM
|
|
||||||
@REM Optional ENV vars
|
|
||||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
|
||||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
|
||||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
|
||||||
@REM ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
|
||||||
@SET __MVNW_CMD__=
|
|
||||||
@SET __MVNW_ERROR__=
|
|
||||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
|
||||||
@SET PSModulePath=
|
|
||||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
|
||||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
|
||||||
)
|
|
||||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
|
||||||
@SET __MVNW_PSMODULEP_SAVE=
|
|
||||||
@SET __MVNW_ARG0_NAME__=
|
|
||||||
@SET MVNW_USERNAME=
|
|
||||||
@SET MVNW_PASSWORD=
|
|
||||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
|
||||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
|
||||||
@GOTO :EOF
|
|
||||||
: end batch / begin powershell #>
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
if ($env:MVNW_VERBOSE -eq "true") {
|
|
||||||
$VerbosePreference = "Continue"
|
|
||||||
}
|
|
||||||
|
|
||||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
|
||||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
|
||||||
if (!$distributionUrl) {
|
|
||||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
|
||||||
}
|
|
||||||
|
|
||||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
|
||||||
"maven-mvnd-*" {
|
|
||||||
$USE_MVND = $true
|
|
||||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
|
||||||
$MVN_CMD = "mvnd.cmd"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default {
|
|
||||||
$USE_MVND = $false
|
|
||||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
|
||||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
|
||||||
if ($env:MVNW_REPOURL) {
|
|
||||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
|
||||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
|
||||||
}
|
|
||||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
|
||||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
|
||||||
|
|
||||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
|
||||||
if ($env:MAVEN_USER_HOME) {
|
|
||||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
|
||||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$MAVEN_WRAPPER_DISTS = $null
|
|
||||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
|
||||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
|
||||||
} else {
|
|
||||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
|
||||||
}
|
|
||||||
|
|
||||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
|
||||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
|
||||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
|
||||||
|
|
||||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
|
||||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
|
||||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
|
||||||
exit $?
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
|
||||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
|
||||||
}
|
|
||||||
|
|
||||||
# prepare tmp dir
|
|
||||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
|
||||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
|
||||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
|
||||||
trap {
|
|
||||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
|
||||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
|
||||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
|
||||||
|
|
||||||
# Download and Install Apache Maven
|
|
||||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
|
||||||
Write-Verbose "Downloading from: $distributionUrl"
|
|
||||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
|
||||||
|
|
||||||
$webclient = New-Object System.Net.WebClient
|
|
||||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
|
||||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
|
||||||
}
|
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
||||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
|
||||||
|
|
||||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
|
||||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
|
||||||
if ($distributionSha256Sum) {
|
|
||||||
if ($USE_MVND) {
|
|
||||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
|
||||||
}
|
|
||||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
|
||||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
|
||||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# unzip and move
|
|
||||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
|
||||||
|
|
||||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
|
||||||
$actualDistributionDir = ""
|
|
||||||
|
|
||||||
# First try the expected directory name (for regular distributions)
|
|
||||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
|
||||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
|
||||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
|
||||||
$actualDistributionDir = $distributionUrlNameMain
|
|
||||||
}
|
|
||||||
|
|
||||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
|
||||||
if (!$actualDistributionDir) {
|
|
||||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
|
||||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
|
||||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
|
||||||
$actualDistributionDir = $_.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$actualDistributionDir) {
|
|
||||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
|
||||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
|
||||||
try {
|
|
||||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
|
||||||
} catch {
|
|
||||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
|
||||||
Write-Error "fail to move MAVEN_HOME"
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
|
||||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
|
||||||
7
pom.xml
7
pom.xml
|
|
@ -23,7 +23,7 @@
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<jjwt.version>0.12.3</jjwt.version>
|
<jjwt.version>0.12.3</jjwt.version>
|
||||||
<testcontainers.version>2.0.5</testcontainers.version>
|
<testcontainers.version>2.0.3</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
@ -170,14 +170,15 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||||
<version>${testcontainers.version}</version>
|
<version>2.0.2</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-postgresql</artifactId>
|
<artifactId>testcontainers-postgresql</artifactId>
|
||||||
<version>${testcontainers.version}</version>
|
<version>2.0.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package net.javahippie.fitpub.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central support flag for Komoot integration availability.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class KomootSupport {
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
|
||||||
|
public KomootSupport(@Value("${fitpub.komoot.enabled:false}") boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,7 @@ public class SecurityConfig {
|
||||||
|
|
||||||
// Protected view pages - require authentication
|
// Protected view pages - require authentication
|
||||||
.requestMatchers("/activities", "/activities/upload").authenticated()
|
.requestMatchers("/activities", "/activities/upload").authenticated()
|
||||||
|
.requestMatchers("/komoot-import").authenticated()
|
||||||
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
|
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
|
||||||
.requestMatchers("/notifications").authenticated()
|
.requestMatchers("/notifications").authenticated()
|
||||||
.requestMatchers("/analytics", "/analytics/**").authenticated()
|
.requestMatchers("/analytics", "/analytics/**").authenticated()
|
||||||
|
|
@ -149,6 +150,7 @@ public class SecurityConfig {
|
||||||
|
|
||||||
// Protected endpoints - Batch Import API
|
// Protected endpoints - Batch Import API
|
||||||
.requestMatchers("/api/batch-import/**").authenticated()
|
.requestMatchers("/api/batch-import/**").authenticated()
|
||||||
|
.requestMatchers("/api/komoot-import/**").authenticated()
|
||||||
|
|
||||||
// Protected endpoints - Privacy Zones API
|
// Protected endpoints - Privacy Zones API
|
||||||
.requestMatchers("/api/privacy-zones/**").authenticated()
|
.requestMatchers("/api/privacy-zones/**").authenticated()
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
import net.javahippie.fitpub.service.ActivityImageService;
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
import net.javahippie.fitpub.service.InboxProcessor;
|
import net.javahippie.fitpub.service.InboxProcessor;
|
||||||
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
|
||||||
import net.javahippie.fitpub.util.ActivityFormatter;
|
import net.javahippie.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -30,7 +29,6 @@ import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
|
@ -53,7 +51,6 @@ public class ActivityPubController {
|
||||||
private final HttpSignatureValidator signatureValidator;
|
private final HttpSignatureValidator signatureValidator;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -439,10 +436,9 @@ public class ActivityPubController {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
noteObject.put("attributedTo", actorUri);
|
||||||
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
noteObject.put("published", activity.getCreatedAt().toString());
|
||||||
noteObject.put("content", formatActivityContent(activity));
|
noteObject.put("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", activityUri);
|
noteObject.put("url", activityUri);
|
||||||
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
|
||||||
|
|
||||||
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
||||||
// check above returned 403 for anything else), so audience is always
|
// check above returned 403 for anything else), so audience is always
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package net.javahippie.fitpub.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import net.javahippie.fitpub.config.KomootSupport;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes global model attributes required by shared layouts.
|
||||||
|
*/
|
||||||
|
@ControllerAdvice
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GlobalModelAttributes {
|
||||||
|
|
||||||
|
private final KomootSupport komootSupport;
|
||||||
|
|
||||||
|
@ModelAttribute("komootSupportEnabled")
|
||||||
|
public boolean komootSupportEnabled() {
|
||||||
|
return komootSupport.isEnabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package net.javahippie.fitpub.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import net.javahippie.fitpub.config.KomootSupport;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||||
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
|
import net.javahippie.fitpub.service.KomootImportService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API for loading and importing Komoot activities.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/komoot-import")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class KomootImportController {
|
||||||
|
|
||||||
|
private final KomootSupport komootSupport;
|
||||||
|
private final KomootImportService komootImportService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@PostMapping("/activities")
|
||||||
|
public ResponseEntity<KomootActivitiesResponse> listActivities(
|
||||||
|
@Valid @RequestBody KomootImportRequest request,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
ensureKomootSupportEnabled();
|
||||||
|
|
||||||
|
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
||||||
|
.getId();
|
||||||
|
|
||||||
|
log.info("User {} requested Komoot activity preview for Komoot ID {}",
|
||||||
|
authentication.getName(), request.getUserId());
|
||||||
|
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/activities/import")
|
||||||
|
public ResponseEntity<KomootImportExecutionResponse> importActivity(
|
||||||
|
@Valid @RequestBody KomootActivityImportRequest request,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
ensureKomootSupportEnabled();
|
||||||
|
|
||||||
|
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
||||||
|
.getId();
|
||||||
|
|
||||||
|
log.info("User {} requested Komoot import for activity {}",
|
||||||
|
authentication.getName(), request.getActivityId());
|
||||||
|
|
||||||
|
KomootImportExecutionResponse response = komootImportService.importActivity(
|
||||||
|
request,
|
||||||
|
fitPubUserId
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureKomootSupportEnabled() {
|
||||||
|
if (!komootSupport.isEnabled()) {
|
||||||
|
throw new KomootSupportDisabledException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
|
||||||
|
String message = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request")
|
||||||
|
.orElse("Invalid request");
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalStateException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(KomootSupportDisabledException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleKomootSupportDisabled(KomootSupportDisabledException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Komoot support is disabled."));
|
||||||
|
}
|
||||||
|
|
||||||
|
record ErrorResponse(String error) {}
|
||||||
|
|
||||||
|
static class KomootSupportDisabledException extends RuntimeException {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package net.javahippie.fitpub.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import net.javahippie.fitpub.config.KomootSupport;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the Komoot import preview page.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class KomootImportViewController {
|
||||||
|
|
||||||
|
private final KomootSupport komootSupport;
|
||||||
|
|
||||||
|
@GetMapping("/komoot-import")
|
||||||
|
public String komootImportPage(Model model) {
|
||||||
|
if (!komootSupport.isEnabled()) {
|
||||||
|
model.addAttribute("pageTitle", "Komoot Import Unavailable");
|
||||||
|
model.addAttribute("featureName", "Komoot Import");
|
||||||
|
model.addAttribute("featureMessage", "Komoot support is currently disabled on this instance.");
|
||||||
|
model.addAttribute("featureIcon", "bi bi-signpost-split text-secondary");
|
||||||
|
return "feature-disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
model.addAttribute("pageTitle", "Komoot Import");
|
||||||
|
model.addAttribute("defaultStartDate", today.withDayOfYear(1));
|
||||||
|
model.addAttribute("defaultEndDate", today);
|
||||||
|
return "activities/komoot";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for the Komoot import preview.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class KomootActivitiesResponse {
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
private int totalCount;
|
||||||
|
private List<KomootActivitySummaryDTO> activities;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for importing one specific Komoot activity.
|
||||||
|
*
|
||||||
|
* <p>The password is only used for the current request and is never persisted.</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class KomootActivityImportRequest {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Email
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private Long activityId;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduced activity representation returned by the Komoot import preview.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class KomootActivitySummaryDTO {
|
||||||
|
|
||||||
|
private long id;
|
||||||
|
private String name;
|
||||||
|
private String sport;
|
||||||
|
private String mappedActivityType;
|
||||||
|
private String status;
|
||||||
|
private String type;
|
||||||
|
private OffsetDateTime date;
|
||||||
|
private Double distanceMeters;
|
||||||
|
private Integer durationSeconds;
|
||||||
|
private Integer timeInMotionSeconds;
|
||||||
|
private Double elevationUp;
|
||||||
|
private boolean imported;
|
||||||
|
private UUID fitPubActivityId;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for importing exactly one Komoot activity into FitPub.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class KomootImportExecutionResponse {
|
||||||
|
|
||||||
|
private UUID importedActivityId;
|
||||||
|
private Long importedKomootActivityId;
|
||||||
|
private String status;
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for fetching completed activities from Komoot.
|
||||||
|
*
|
||||||
|
* <p>The password is only used for the current request and is never persisted.</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class KomootImportRequest {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Email
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private LocalDate startDate;
|
||||||
|
|
||||||
|
private LocalDate endDate;
|
||||||
|
|
||||||
|
public KomootImportRequest(String email, String password, String userId, LocalDate startDate, LocalDate endDate) {
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.userId = userId;
|
||||||
|
this.startDate = startDate;
|
||||||
|
this.endDate = endDate;
|
||||||
|
validateDateRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssertTrue(message = "Start date and end date must either both be set or both be empty, and start date must be before or equal to end date.")
|
||||||
|
public boolean isDateRangeConsistent() {
|
||||||
|
try {
|
||||||
|
validateDateRange();
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDateRange() {
|
||||||
|
boolean onlyOneDateProvided = (startDate == null) != (endDate == null);
|
||||||
|
if (onlyOneDateProvided) {
|
||||||
|
throw new IllegalArgumentException("Start date and end date must either both be set or both be empty.");
|
||||||
|
}
|
||||||
|
if (startDate != null && startDate.isAfter(endDate)) {
|
||||||
|
throw new IllegalArgumentException("Start date must be before or equal to end date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package net.javahippie.fitpub.model.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal link between a FitPub activity and its originating Komoot activity.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "komoot_imports",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_komoot_imports_activity_id", columnNames = "activity_id"),
|
||||||
|
@UniqueConstraint(name = "uk_komoot_imports_user_komoot_activity_id", columnNames = {"user_id", "komoot_activity_id"})
|
||||||
|
},
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_komoot_imports_user_id", columnList = "user_id"),
|
||||||
|
@Index(name = "idx_komoot_imports_komoot_activity_id", columnList = "komoot_activity_id")
|
||||||
|
})
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class KomootImport {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@Column(name = "activity_id", nullable = false)
|
||||||
|
private UUID activityId;
|
||||||
|
|
||||||
|
@Column(name = "komoot_activity_id", nullable = false)
|
||||||
|
private Long komootActivityId;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,6 @@ import lombok.NoArgsConstructor;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.Type;
|
import org.hibernate.annotations.Type;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -138,12 +137,6 @@ public class RemoteActivity {
|
||||||
@Column(name = "track_geojson_url", length = 512)
|
@Column(name = "track_geojson_url", length = 512)
|
||||||
private String trackGeojsonUrl;
|
private String trackGeojsonUrl;
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified remote route geometry for local map rendering.
|
|
||||||
*/
|
|
||||||
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
|
|
||||||
private LineString simplifiedTrack;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visibility level of the activity.
|
* Visibility level of the activity.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package net.javahippie.fitpub.repository;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.entity.KomootImport;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface KomootImportRepository extends JpaRepository<KomootImport, UUID> {
|
||||||
|
|
||||||
|
interface KomootImportLinkProjection {
|
||||||
|
UUID getActivityId();
|
||||||
|
Long getKomootActivityId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("SELECT k.komootActivityId FROM KomootImport k WHERE k.userId = :userId")
|
||||||
|
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
|
||||||
|
|
||||||
|
Optional<KomootImport> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
|
||||||
|
|
||||||
|
@Query("SELECT k.activityId AS activityId, k.komootActivityId AS komootActivityId " +
|
||||||
|
"FROM KomootImport k " +
|
||||||
|
"WHERE k.userId = :userId AND k.komootActivityId IN :komootActivityIds")
|
||||||
|
List<KomootImportLinkProjection> findKomootImportLinksByUserIdAndKomootActivityIdIn(
|
||||||
|
@Param("userId") UUID userId,
|
||||||
|
@Param("komootActivityIds") List<Long> komootActivityIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,6 @@ import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -39,7 +38,6 @@ public class ActivityPostProcessingService {
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -201,10 +199,9 @@ public class ActivityPostProcessingService {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
noteObject.put("attributedTo", actorUri);
|
||||||
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
noteObject.put("published", activity.getCreatedAt().toString());
|
||||||
noteObject.put("content", formatActivityContent(activity));
|
noteObject.put("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||||
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
|
||||||
|
|
||||||
// Extract hashtags from user text and add as tags
|
// Extract hashtags from user text and add as tags
|
||||||
List<String> hashtags = extractHashtags(activity);
|
List<String> hashtags = extractHashtags(activity);
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,11 @@ import net.javahippie.fitpub.repository.CommentRepository;
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.LikeRepository;
|
import net.javahippie.fitpub.repository.LikeRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
|
||||||
import org.locationtech.jts.geom.GeometryFactory;
|
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
import org.locationtech.jts.geom.PrecisionModel;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.ZonedDateTime;
|
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -40,9 +31,6 @@ import java.util.UUID;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class InboxProcessor {
|
public class InboxProcessor {
|
||||||
private static final int GEOMETRY_SRID = 4326;
|
|
||||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
|
||||||
new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID);
|
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final FollowRepository followRepository;
|
private final FollowRepository followRepository;
|
||||||
|
|
@ -423,18 +411,15 @@ public class InboxProcessor {
|
||||||
|
|
||||||
// Parse published timestamp
|
// Parse published timestamp
|
||||||
String publishedStr = (String) noteObject.get("published");
|
String publishedStr = (String) noteObject.get("published");
|
||||||
Instant publishedAt = parsePublishedAt(publishedStr);
|
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
||||||
|
|
||||||
// Build RemoteActivity entity
|
// Build RemoteActivity entity
|
||||||
RemoteActivity remoteActivity = RemoteActivity.builder()
|
RemoteActivity remoteActivity = RemoteActivity.builder()
|
||||||
.activityUri(activityUri)
|
.activityUri(activityUri)
|
||||||
.remoteActorUri(actor)
|
.remoteActorUri(actor)
|
||||||
.activityType(stringValue(workoutData.get("activityType")))
|
.activityType((String) workoutData.get("activityType"))
|
||||||
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
||||||
.description(firstNonBlank(
|
.description(stripHtml((String) noteObject.get("content")))
|
||||||
stringValue(workoutData.get("description")),
|
|
||||||
stripHtml((String) noteObject.get("content"))
|
|
||||||
))
|
|
||||||
.publishedAt(publishedAt)
|
.publishedAt(publishedAt)
|
||||||
.totalDistance(parseLong(workoutData.get("distance")))
|
.totalDistance(parseLong(workoutData.get("distance")))
|
||||||
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
||||||
|
|
@ -446,7 +431,6 @@ public class InboxProcessor {
|
||||||
.calories(parseInteger(workoutData.get("calories")))
|
.calories(parseInteger(workoutData.get("calories")))
|
||||||
.mapImageUrl(attachments.get("mapImage"))
|
.mapImageUrl(attachments.get("mapImage"))
|
||||||
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
||||||
.simplifiedTrack(extractRoute(workoutData))
|
|
||||||
.visibility(visibility)
|
.visibility(visibility)
|
||||||
.activityPubObject(serializeToJson(noteObject))
|
.activityPubObject(serializeToJson(noteObject))
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -721,88 +705,6 @@ public class InboxProcessor {
|
||||||
return workoutData;
|
return workoutData;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String stringValue(Object value) {
|
|
||||||
return value != null ? String.valueOf(value) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LineString extractRoute(Map<String, Object> workoutData) {
|
|
||||||
Object routeObj = workoutData.get("route");
|
|
||||||
if (!(routeObj instanceof Map<?, ?> routeMap)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object featuresObj = routeMap.get("features");
|
|
||||||
if (!(featuresObj instanceof java.util.List<?> features) || features.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Object featureObj : features) {
|
|
||||||
if (!(featureObj instanceof Map<?, ?> featureMap)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object geometryObj = featureMap.get("geometry");
|
|
||||||
if (!(geometryObj instanceof Map<?, ?> geometryMap)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!"LineString".equals(geometryMap.get("type"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates"));
|
|
||||||
if (lineString != null) {
|
|
||||||
return lineString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LineString parseLineStringCoordinates(Object coordinatesObj) {
|
|
||||||
if (!(coordinatesObj instanceof java.util.List<?> coordinateList) || coordinateList.size() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
java.util.List<Coordinate> coordinates = new java.util.ArrayList<>();
|
|
||||||
for (Object coordinateObj : coordinateList) {
|
|
||||||
Coordinate coordinate = parseCoordinate(coordinateObj);
|
|
||||||
if (coordinate == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
coordinates.add(coordinate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (coordinates.size() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Coordinate parseCoordinate(Object coordinateObj) {
|
|
||||||
if (!(coordinateObj instanceof java.util.List<?> coordinateValues) || coordinateValues.size() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Double longitude = parseDouble(coordinateValues.get(0));
|
|
||||||
Double latitude = parseDouble(coordinateValues.get(1));
|
|
||||||
if (longitude == null || latitude == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Coordinate(longitude, latitude);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String firstNonBlank(String... values) {
|
|
||||||
for (String value : values) {
|
|
||||||
if (value != null && !value.isBlank()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
||||||
*/
|
*/
|
||||||
|
|
@ -922,44 +824,6 @@ public class InboxProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse ActivityPub published timestamps.
|
|
||||||
*
|
|
||||||
* <p>Preferred input is a full ISO-8601 instant with timezone/offset. Some
|
|
||||||
* remote implementations still send zoneless timestamps, so we accept those
|
|
||||||
* as a compatibility fallback and interpret them as UTC.
|
|
||||||
*/
|
|
||||||
private Instant parsePublishedAt(String publishedStr) {
|
|
||||||
if (publishedStr == null || publishedStr.isBlank()) {
|
|
||||||
return Instant.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Instant.parse(publishedStr);
|
|
||||||
} catch (DateTimeParseException ignored) {
|
|
||||||
// Fall through to compatibility parsers below.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return OffsetDateTime.parse(publishedStr).toInstant();
|
|
||||||
} catch (DateTimeParseException ignored) {
|
|
||||||
// Fall through to compatibility parsers below.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return ZonedDateTime.parse(publishedStr).toInstant();
|
|
||||||
} catch (DateTimeParseException ignored) {
|
|
||||||
// Fall through to compatibility parsers below.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant();
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
log.warn("Failed to parse published timestamp: {}", publishedStr, e);
|
|
||||||
return Instant.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize object to JSON string.
|
* Serialize object to JSON string.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,478 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.KomootImport;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.KomootImportRepository;
|
||||||
|
import net.javahippie.fitpub.util.ByteArrayMultipartFile;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user.
|
||||||
|
*
|
||||||
|
* <p>Komoot does not expose a public API for this use case. This service currently talks to the
|
||||||
|
* same web API endpoints used by the Komoot website and therefore depends on their current
|
||||||
|
* behavior.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class KomootImportService {
|
||||||
|
|
||||||
|
private static final int PAGE_SIZE = 100;
|
||||||
|
private static final String KOMOOT_LANGUAGE = "en";
|
||||||
|
private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX");
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final ActivityRepository activityRepository;
|
||||||
|
private final KomootImportRepository komootImportRepository;
|
||||||
|
private final ActivityFileService activityFileService;
|
||||||
|
private final ActivityPostProcessingService activityPostProcessingService;
|
||||||
|
|
||||||
|
@Value("${fitpub.komoot.base-url:https://www.komoot.com}")
|
||||||
|
private String komootBaseUrl;
|
||||||
|
|
||||||
|
@Value("${fitpub.komoot.paginated-request-delay-ms:1000}")
|
||||||
|
private long paginatedRequestDelayMillis;
|
||||||
|
|
||||||
|
@Value("${fitpub.komoot.detail-to-gpx-delay-ms:500}")
|
||||||
|
private long detailToGpxDelayMillis;
|
||||||
|
|
||||||
|
@Value("${fitpub.komoot.activity-import-delay-ms:3000}")
|
||||||
|
private long activityImportDelayMillis;
|
||||||
|
|
||||||
|
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) {
|
||||||
|
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
|
||||||
|
Set<Long> importedKomootActivityIds = new HashSet<>(
|
||||||
|
komootImportRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
|
||||||
|
Map<Long, UUID> fitPubActivityIdsByKomootId = new HashMap<>();
|
||||||
|
if (!importedKomootActivityIds.isEmpty()) {
|
||||||
|
komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(
|
||||||
|
fitPubUserId,
|
||||||
|
new ArrayList<>(importedKomootActivityIds)
|
||||||
|
)
|
||||||
|
.forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getActivityId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
URI nextUri = buildInitialUri(request);
|
||||||
|
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.getEmail(), request.getPassword()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (nextUri != null) {
|
||||||
|
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||||
|
nextUri, HttpMethod.GET, httpEntity, JsonNode.class);
|
||||||
|
|
||||||
|
JsonNode root = response.getBody();
|
||||||
|
if (root == null) {
|
||||||
|
throw new IllegalStateException("Komoot returned an empty response body.");
|
||||||
|
}
|
||||||
|
extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId);
|
||||||
|
nextUri = extractNextUri(root);
|
||||||
|
if (nextUri != null) {
|
||||||
|
pauseBeforeNextPageRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||||
|
throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e);
|
||||||
|
} catch (HttpClientErrorException.NotFound e) {
|
||||||
|
throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to parse Komoot activity list.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.getUserId());
|
||||||
|
return new KomootActivitiesResponse(request.getUserId(), activities.size(), activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pauseBeforeNextPageRequest() {
|
||||||
|
pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
|
||||||
|
KomootImport existingImport = komootImportRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null);
|
||||||
|
if (existingImport != null) {
|
||||||
|
return new KomootImportExecutionResponse(
|
||||||
|
existingImport.getActivityId(),
|
||||||
|
request.getActivityId(),
|
||||||
|
"SKIPPED_ALREADY_IMPORTED",
|
||||||
|
"Komoot activity " + request.getActivityId() + " was already imported."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode details = fetchActivityDetails(request.getEmail(), request.getPassword(), request.getActivityId());
|
||||||
|
pauseBetweenDetailAndGpxRequest();
|
||||||
|
byte[] gpxData = fetchActivityGpx(request.getEmail(), request.getPassword(), request.getActivityId());
|
||||||
|
|
||||||
|
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
|
||||||
|
"file",
|
||||||
|
"komoot-" + request.getActivityId() + ".gpx",
|
||||||
|
"application/gpx+xml",
|
||||||
|
gpxData
|
||||||
|
);
|
||||||
|
|
||||||
|
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
|
||||||
|
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.getActivityId());
|
||||||
|
String mappedDescription = nullableText(details, "description");
|
||||||
|
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
|
||||||
|
|
||||||
|
Activity importedActivity = activityFileService.processActivityFile(
|
||||||
|
gpxFile,
|
||||||
|
fitPubUserId,
|
||||||
|
mappedTitle,
|
||||||
|
mappedDescription,
|
||||||
|
mappedVisibility
|
||||||
|
);
|
||||||
|
|
||||||
|
importedActivity.setTitle(mappedTitle);
|
||||||
|
importedActivity.setDescription(mappedDescription);
|
||||||
|
importedActivity.setVisibility(mappedVisibility);
|
||||||
|
importedActivity.setActivityType(mappedActivityType);
|
||||||
|
|
||||||
|
importedActivity = activityRepository.save(importedActivity);
|
||||||
|
komootImportRepository.save(KomootImport.builder()
|
||||||
|
.userId(fitPubUserId)
|
||||||
|
.activityId(importedActivity.getId())
|
||||||
|
.komootActivityId(request.getActivityId())
|
||||||
|
.build());
|
||||||
|
activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
|
||||||
|
request.getActivityId(),
|
||||||
|
importedActivity.getId(),
|
||||||
|
importedActivity.getVisibility(),
|
||||||
|
importedActivity.getActivityType()
|
||||||
|
);
|
||||||
|
|
||||||
|
pauseAfterActivityImport();
|
||||||
|
|
||||||
|
return new KomootImportExecutionResponse(
|
||||||
|
importedActivity.getId(),
|
||||||
|
request.getActivityId(),
|
||||||
|
"IMPORTED",
|
||||||
|
"Imported Komoot activity " + request.getActivityId() + " into FitPub activity " + importedActivity.getId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pauseBetweenDetailAndGpxRequest() {
|
||||||
|
pause(detailToGpxDelayMillis, "Interrupted while throttling Komoot detail and GPX requests.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void pauseAfterActivityImport() {
|
||||||
|
pause(activityImportDelayMillis, "Interrupted while throttling Komoot activity imports.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI buildInitialUri(KomootImportRequest request) {
|
||||||
|
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||||
|
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.getUserId() + "/tours/")
|
||||||
|
.queryParam("type", "tour_recorded")
|
||||||
|
.queryParam("sort_field", "date")
|
||||||
|
.queryParam("sort_direction", "desc")
|
||||||
|
.queryParam("limit", PAGE_SIZE);
|
||||||
|
|
||||||
|
if (request.getStartDate() != null && request.getEndDate() != null) {
|
||||||
|
builder.queryParam("start_date", formatKomootStartDate(request.getStartDate()))
|
||||||
|
.queryParam("end_date", formatKomootEndDate(request.getEndDate()));
|
||||||
|
} else {
|
||||||
|
builder.queryParam("status", "private")
|
||||||
|
.queryParam("name", "")
|
||||||
|
.queryParam("hl", KOMOOT_LANGUAGE)
|
||||||
|
.queryParam("page", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build().toUri();
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI buildDetailUri(long activityId) {
|
||||||
|
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||||
|
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/tours/" + activityId)
|
||||||
|
.queryParam("hl", KOMOOT_LANGUAGE)
|
||||||
|
.build()
|
||||||
|
.toUri();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<URI> buildGpxCandidateUris(long activityId) {
|
||||||
|
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||||
|
String apiBaseUrl = normalizedBaseUrl.replace("://www.komoot.com", "://api.komoot.de");
|
||||||
|
|
||||||
|
return List.of(
|
||||||
|
URI.create(normalizedBaseUrl + "/api/v007/tours/" + activityId + ".gpx"),
|
||||||
|
URI.create(apiBaseUrl + "/v007/tours/" + activityId + ".gpx"),
|
||||||
|
URI.create(normalizedBaseUrl + "/tour/" + activityId + ".gpx")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpHeaders buildHeaders(String email, String password) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON));
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH));
|
||||||
|
headers.set(HttpHeaders.USER_AGENT, "FitPub Komoot Import");
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password));
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpHeaders buildGpxHeaders(String email, String password) {
|
||||||
|
HttpHeaders headers = buildHeaders(email, password);
|
||||||
|
headers.setAccept(List.of(
|
||||||
|
MediaType.parseMediaType("application/gpx+xml"),
|
||||||
|
MediaType.APPLICATION_XML,
|
||||||
|
MediaType.TEXT_XML
|
||||||
|
));
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String basicAuth(String email, String password) {
|
||||||
|
String credentials = email + ":" + password;
|
||||||
|
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return "Basic " + encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void extractActivities(
|
||||||
|
JsonNode root,
|
||||||
|
List<KomootActivitySummaryDTO> activities,
|
||||||
|
Set<Long> importedKomootActivityIds,
|
||||||
|
Map<Long, UUID> fitPubActivityIdsByKomootId
|
||||||
|
) {
|
||||||
|
JsonNode tours = root.path("_embedded").path("tours");
|
||||||
|
if (!tours.isArray()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode tour : tours) {
|
||||||
|
long activityId = tour.path("id").asLong();
|
||||||
|
activities.add(new KomootActivitySummaryDTO(
|
||||||
|
activityId,
|
||||||
|
nullableText(tour, "name"),
|
||||||
|
nullableText(tour, "sport"),
|
||||||
|
mapKomootSportToActivityType(nullableText(tour, "sport")).name(),
|
||||||
|
nullableText(tour, "status"),
|
||||||
|
nullableText(tour, "type"),
|
||||||
|
parseDate(tour.path("date").asText(null)),
|
||||||
|
nullableDouble(tour, "distance"),
|
||||||
|
nullableInteger(tour, "duration"),
|
||||||
|
nullableInteger(tour, "time_in_motion"),
|
||||||
|
nullableDouble(tour, "elevation_up"),
|
||||||
|
importedKomootActivityIds.contains(activityId),
|
||||||
|
fitPubActivityIdsByKomootId.get(activityId)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode fetchActivityDetails(String email, String password, long activityId) {
|
||||||
|
try {
|
||||||
|
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||||
|
buildDetailUri(activityId),
|
||||||
|
HttpMethod.GET,
|
||||||
|
new HttpEntity<>(buildHeaders(email, password)),
|
||||||
|
JsonNode.class
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode body = response.getBody();
|
||||||
|
if (body == null) {
|
||||||
|
throw new IllegalStateException("Komoot returned an empty activity detail response.");
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||||
|
throw new IllegalArgumentException("Komoot login failed while loading activity details.", e);
|
||||||
|
} catch (HttpClientErrorException.NotFound e) {
|
||||||
|
throw new IllegalArgumentException("Komoot activity details could not be found.", e);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
throw new IllegalStateException("Failed to reach Komoot while loading activity details.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] fetchActivityGpx(String email, String password, long activityId) {
|
||||||
|
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(email, password));
|
||||||
|
List<URI> candidateUris = buildGpxCandidateUris(activityId);
|
||||||
|
Exception lastException = null;
|
||||||
|
|
||||||
|
for (URI candidateUri : candidateUris) {
|
||||||
|
try {
|
||||||
|
byte[] body = restTemplate.execute(
|
||||||
|
candidateUri,
|
||||||
|
HttpMethod.GET,
|
||||||
|
request -> request.getHeaders().putAll(httpEntity.getHeaders()),
|
||||||
|
response -> {
|
||||||
|
if (response.getBody() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.getBody().readAllBytes();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (body == null || body.length == 0) {
|
||||||
|
throw new IllegalStateException("Komoot returned an empty GPX response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String gpxText = new String(body, StandardCharsets.UTF_8);
|
||||||
|
if (!gpxText.contains("<gpx")) {
|
||||||
|
throw new IllegalStateException("Komoot response did not contain GPX XML.");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Downloaded Komoot GPX for activity {} from {}", activityId, candidateUri);
|
||||||
|
return body;
|
||||||
|
} catch (HttpClientErrorException.NotFound e) {
|
||||||
|
lastException = e;
|
||||||
|
log.debug("Komoot GPX candidate not found for activity {} at {}", activityId, candidateUri);
|
||||||
|
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||||
|
throw new IllegalArgumentException("Komoot login failed while downloading GPX.", e);
|
||||||
|
} catch (RestClientException | IllegalStateException e) {
|
||||||
|
lastException = e;
|
||||||
|
log.debug("Komoot GPX candidate failed for activity {} at {}: {}", activityId, candidateUri, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatKomootStartDate(LocalDate localDate) {
|
||||||
|
return localDate.atStartOfDay(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.atOffset(ZoneOffset.UTC)
|
||||||
|
.format(KOMOOT_DATE_TIME_FORMATTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatKomootEndDate(LocalDate localDate) {
|
||||||
|
return localDate.atTime(LocalTime.of(23, 59, 59, 999_000_000))
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.atOffset(ZoneOffset.UTC)
|
||||||
|
.format(KOMOOT_DATE_TIME_FORMATTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Activity.Visibility mapVisibility(String komootStatus) {
|
||||||
|
if (komootStatus == null) {
|
||||||
|
return Activity.Visibility.PRIVATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (komootStatus.toLowerCase(java.util.Locale.ROOT)) {
|
||||||
|
case "public" -> Activity.Visibility.PUBLIC;
|
||||||
|
case "friends", "followers", "close_friends" -> Activity.Visibility.FOLLOWERS;
|
||||||
|
default -> Activity.Visibility.PRIVATE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Activity.ActivityType mapKomootSportToActivityType(String komootSport) {
|
||||||
|
if (komootSport == null || komootSport.isBlank()) {
|
||||||
|
return Activity.ActivityType.OTHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (komootSport.toLowerCase(java.util.Locale.ROOT)) {
|
||||||
|
case "hike" -> Activity.ActivityType.HIKE;
|
||||||
|
case "walk" -> Activity.ActivityType.WALK;
|
||||||
|
case "run", "trailrunning", "jogging" -> Activity.ActivityType.RUN;
|
||||||
|
case "touringbicycle", "road_bike", "racebike", "bike", "bicycle", "gravel", "mtb", "mtb_easy", "mtb_advanced", "ebike" ->
|
||||||
|
Activity.ActivityType.RIDE;
|
||||||
|
case "alpine_ski" -> Activity.ActivityType.ALPINE_SKI;
|
||||||
|
case "backcountry_ski" -> Activity.ActivityType.BACKCOUNTRY_SKI;
|
||||||
|
case "nordic_ski", "cross_country_ski" -> Activity.ActivityType.NORDIC_SKI;
|
||||||
|
case "snowboard" -> Activity.ActivityType.SNOWBOARD;
|
||||||
|
case "swim" -> Activity.ActivityType.SWIM;
|
||||||
|
case "rowing" -> Activity.ActivityType.ROWING;
|
||||||
|
case "kayak", "kayaking" -> Activity.ActivityType.KAYAKING;
|
||||||
|
case "canoe", "canoeing" -> Activity.ActivityType.CANOEING;
|
||||||
|
case "inline_skate", "inline_skating" -> Activity.ActivityType.INLINE_SKATING;
|
||||||
|
case "rock_climbing" -> Activity.ActivityType.ROCK_CLIMBING;
|
||||||
|
case "mountaineering" -> Activity.ActivityType.MOUNTAINEERING;
|
||||||
|
case "yoga" -> Activity.ActivityType.YOGA;
|
||||||
|
case "workout", "gym" -> Activity.ActivityType.WORKOUT;
|
||||||
|
default -> Activity.ActivityType.OTHER;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String first, String second, String fallback) {
|
||||||
|
if (first != null && !first.isBlank()) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
if (second != null && !second.isBlank()) {
|
||||||
|
return second;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI extractNextUri(JsonNode root) {
|
||||||
|
String nextHref = root.path("_links").path("next").path("href").asText(null);
|
||||||
|
if (nextHref == null || nextHref.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) {
|
||||||
|
return URI.create(nextHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||||
|
String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref;
|
||||||
|
return URI.create(normalizedBaseUrl + normalizedNextHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime parseDate(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return OffsetDateTime.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullableText(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? null : value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double nullableDouble(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? null : value.asDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer nullableInteger(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? null : value.asInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pause(long delayMillis, String interruptedMessage) {
|
||||||
|
if (delayMillis <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(delayMillis);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException(interruptedMessage, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
package net.javahippie.fitpub.service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import net.javahippie.fitpub.model.dto.ActivityDTO;
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
|
||||||
import net.javahippie.fitpub.model.entity.PrivacyZone;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the proprietary workoutData payload for outbound ActivityPub Notes.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class WorkoutDataPayloadBuilder {
|
|
||||||
|
|
||||||
private final PrivacyZoneService privacyZoneService;
|
|
||||||
private final TrackPrivacyFilter trackPrivacyFilter;
|
|
||||||
|
|
||||||
public Map<String, Object> build(Activity activity) {
|
|
||||||
Map<String, Object> workoutData = new HashMap<>();
|
|
||||||
workoutData.put("activityType", activity.getActivityType().name());
|
|
||||||
|
|
||||||
if (activity.getDescription() != null && !activity.getDescription().isBlank()) {
|
|
||||||
workoutData.put("description", activity.getDescription());
|
|
||||||
}
|
|
||||||
if (activity.getTotalDistance() != null) {
|
|
||||||
workoutData.put("distance", activity.getTotalDistance().longValue());
|
|
||||||
}
|
|
||||||
if (activity.getTotalDurationSeconds() != null) {
|
|
||||||
workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString());
|
|
||||||
}
|
|
||||||
if (activity.getElevationGain() != null) {
|
|
||||||
workoutData.put("elevationGain", activity.getElevationGain().intValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityMetrics metrics = activity.getMetrics();
|
|
||||||
if (metrics != null) {
|
|
||||||
if (metrics.getAveragePaceSeconds() != null) {
|
|
||||||
workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString());
|
|
||||||
}
|
|
||||||
if (metrics.getAverageHeartRate() != null) {
|
|
||||||
workoutData.put("averageHeartRate", metrics.getAverageHeartRate());
|
|
||||||
}
|
|
||||||
if (metrics.getAverageSpeed() != null) {
|
|
||||||
workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue());
|
|
||||||
}
|
|
||||||
if (metrics.getMaxSpeed() != null) {
|
|
||||||
workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue());
|
|
||||||
}
|
|
||||||
if (metrics.getCalories() != null) {
|
|
||||||
workoutData.put("calories", metrics.getCalories());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> route = buildRoutePayload(activity);
|
|
||||||
if (route != null) {
|
|
||||||
workoutData.put("route", route);
|
|
||||||
}
|
|
||||||
|
|
||||||
return workoutData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> buildRoutePayload(Activity activity) {
|
|
||||||
List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
|
|
||||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter);
|
|
||||||
|
|
||||||
if (dto.getSimplifiedTrack() == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> feature = new HashMap<>();
|
|
||||||
feature.put("type", "Feature");
|
|
||||||
feature.put("geometry", dto.getSimplifiedTrack());
|
|
||||||
|
|
||||||
Map<String, Object> featureCollection = new HashMap<>();
|
|
||||||
featureCollection.put("type", "FeatureCollection");
|
|
||||||
featureCollection.put("features", List.of(feature));
|
|
||||||
return featureCollection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -98,10 +98,6 @@ public class ActivityFormatter {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
|
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
|
||||||
if (timezone == null || timezone.isBlank()) {
|
|
||||||
return utcDateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return utcDateTime.atZone(ZoneOffset.UTC)
|
return utcDateTime.atZone(ZoneOffset.UTC)
|
||||||
.withZoneSameInstant(ZoneId.of(timezone))
|
.withZoneSameInstant(ZoneId.of(timezone))
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,7 @@ public final class ActivityPubContexts {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
||||||
* that carry both interaction-policy declarations and FitPub's proprietary
|
* that carry interaction-policy declarations. Shape:
|
||||||
* {@code workoutData} extension fields. Shape:
|
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* [
|
* [
|
||||||
|
|
@ -46,20 +45,7 @@ public final class ActivityPubContexts {
|
||||||
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
||||||
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
||||||
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
|
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
|
||||||
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" },
|
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
|
||||||
* "fitpub": "https://fitpub.social/ns#",
|
|
||||||
* "workoutData": "fitpub:workoutData",
|
|
||||||
* "activityType": "fitpub:activityType",
|
|
||||||
* "description": "fitpub:description",
|
|
||||||
* "distance": "fitpub:distance",
|
|
||||||
* "duration": "fitpub:duration",
|
|
||||||
* "elevationGain": "fitpub:elevationGain",
|
|
||||||
* "averagePace": "fitpub:averagePace",
|
|
||||||
* "averageHeartRate": "fitpub:averageHeartRate",
|
|
||||||
* "averageSpeed": "fitpub:averageSpeed",
|
|
||||||
* "maxSpeed": "fitpub:maxSpeed",
|
|
||||||
* "calories": "fitpub:calories",
|
|
||||||
* "route": "fitpub:route"
|
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
* </pre>
|
* </pre>
|
||||||
|
|
@ -70,12 +56,6 @@ public final class ActivityPubContexts {
|
||||||
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
||||||
* receiver compacting our object with its own context will recognise the
|
* receiver compacting our object with its own context will recognise the
|
||||||
* field names and apply the policy.
|
* field names and apply the policy.
|
||||||
*
|
|
||||||
* <p>The {@code fitpub:} prefix is FitPub's own extension namespace
|
|
||||||
* ({@code https://fitpub.social/ns#}). It declares the proprietary
|
|
||||||
* {@code workoutData} object and its structured activity fields so FitPub
|
|
||||||
* instances can exchange machine-readable workout metadata without
|
|
||||||
* overloading the standard ActivityStreams fields.
|
|
||||||
*/
|
*/
|
||||||
public static List<Object> extendedContext() {
|
public static List<Object> extendedContext() {
|
||||||
Map<String, Object> extensions = new LinkedHashMap<>();
|
Map<String, Object> extensions = new LinkedHashMap<>();
|
||||||
|
|
@ -84,19 +64,6 @@ public final class ActivityPubContexts {
|
||||||
extensions.put("canQuote", typedRef("gts:canQuote"));
|
extensions.put("canQuote", typedRef("gts:canQuote"));
|
||||||
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
||||||
extensions.put("manualApproval", typedRef("gts:manualApproval"));
|
extensions.put("manualApproval", typedRef("gts:manualApproval"));
|
||||||
extensions.put("fitpub", "https://fitpub.social/ns#");
|
|
||||||
extensions.put("workoutData", "fitpub:workoutData");
|
|
||||||
extensions.put("activityType", "fitpub:activityType");
|
|
||||||
extensions.put("description", "fitpub:description");
|
|
||||||
extensions.put("distance", "fitpub:distance");
|
|
||||||
extensions.put("duration", "fitpub:duration");
|
|
||||||
extensions.put("elevationGain", "fitpub:elevationGain");
|
|
||||||
extensions.put("averagePace", "fitpub:averagePace");
|
|
||||||
extensions.put("averageHeartRate", "fitpub:averageHeartRate");
|
|
||||||
extensions.put("averageSpeed", "fitpub:averageSpeed");
|
|
||||||
extensions.put("maxSpeed", "fitpub:maxSpeed");
|
|
||||||
extensions.put("calories", "fitpub:calories");
|
|
||||||
extensions.put("route", "fitpub:route");
|
|
||||||
return List.of(
|
return List.of(
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
extensions
|
extensions
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,14 @@ fitpub:
|
||||||
enabled: ${WEATHER_ENABLED:false}
|
enabled: ${WEATHER_ENABLED:false}
|
||||||
api-key: ${OPENWEATHERMAP_API_KEY:}
|
api-key: ${OPENWEATHERMAP_API_KEY:}
|
||||||
|
|
||||||
|
# Komoot settings
|
||||||
|
komoot:
|
||||||
|
enabled: ${KOMOOT_ENABLED:false}
|
||||||
|
base-url: ${KOMOOT_BASE_URL:https://www.komoot.com}
|
||||||
|
paginated-request-delay-ms: ${KOMOOT_PAGINATED_REQUEST_DELAY_MS:1000}
|
||||||
|
detail-to-gpx-delay-ms: ${KOMOOT_DETAIL_TO_GPX_DELAY_MS:500}
|
||||||
|
activity-import-delay-ms: ${KOMOOT_ACTIVITY_IMPORT_DELAY_MS:3000}
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
ALTER TABLE remote_activities
|
|
||||||
ADD COLUMN simplified_track geometry(LineString, 4326);
|
|
||||||
|
|
||||||
CREATE INDEX idx_remote_activity_simplified_track
|
|
||||||
ON remote_activities
|
|
||||||
USING gist (simplified_track);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN remote_activities.simplified_track IS
|
|
||||||
'Simplified remote route geometry for local map rendering';
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Track imported Komoot activities separately from the core activities table.
|
||||||
|
--
|
||||||
|
-- This keeps the import-specific state isolated and allows all import-related
|
||||||
|
-- columns to be strictly non-nullable.
|
||||||
|
|
||||||
|
CREATE TABLE komoot_imports (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
komoot_activity_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uk_komoot_imports_activity_id UNIQUE (activity_id),
|
||||||
|
CONSTRAINT uk_komoot_imports_user_komoot_activity_id UNIQUE (user_id, komoot_activity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_komoot_imports_user_id
|
||||||
|
ON komoot_imports(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_komoot_imports_komoot_activity_id
|
||||||
|
ON komoot_imports(komoot_activity_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE komoot_imports IS
|
||||||
|
'Internal mapping between FitPub activities and their originating Komoot activities';
|
||||||
|
|
@ -92,11 +92,6 @@ p,
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preserve line-breaks */
|
|
||||||
.preserve-linebreaks {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
.navbar {
|
.navbar {
|
||||||
background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important;
|
background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
<span id="activityVisibility"></span>
|
<span id="activityVisibility"></span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p id="activityDescription" class="preserve-linebreaks text-muted"></p>
|
<p id="activityDescription" class="text-muted"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" role="group" id="activityActions" style="display: none;">
|
<div class="btn-group" role="group" id="activityActions" style="display: none;">
|
||||||
<a href="#" id="editBtn" class="btn btn-outline-primary">
|
<a href="#" id="editBtn" class="btn btn-outline-primary">
|
||||||
|
|
|
||||||
535
src/main/resources/templates/activities/komoot.html
Normal file
535
src/main/resources/templates/activities/komoot.html
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en"
|
||||||
|
xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout}">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Komoot Import</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div layout:fragment="content">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<h2 class="mb-4">
|
||||||
|
<i class="bi bi-signpost-split text-success"></i>
|
||||||
|
Komoot Import
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<div class="fw-semibold mb-1">Important</div>
|
||||||
|
<div class="small mb-1">
|
||||||
|
Your Komoot credentials are only used for this request and are not stored in FitPub.
|
||||||
|
</div>
|
||||||
|
<div class="small mb-0">
|
||||||
|
The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="small text-muted ps-3 mb-4">
|
||||||
|
<li>Import starts with the oldest new activities, so progress begins at the bottom of the list.</li>
|
||||||
|
<li>FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.</li>
|
||||||
|
<li>This integration depends on Komoot web endpoints and may stop working if Komoot changes them.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div id="errorAlert" class="alert alert-danger d-none" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form id="komootImportForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">Komoot Email</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="password" class="form-label">Komoot Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="userId" class="form-label">Komoot ID</label>
|
||||||
|
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
|
||||||
|
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="startDate" class="form-label">Start Date</label>
|
||||||
|
<input type="date" class="form-control" id="startDate" name="startDate" th:value="${defaultStartDate}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="endDate" class="form-label">End Date</label>
|
||||||
|
<input type="date" class="form-control" id="endDate" name="endDate" th:value="${defaultEndDate}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Both dates must be set together. Inclusive, day-based filter.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
|
||||||
|
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-success" id="importFirstBtn" disabled>
|
||||||
|
<span id="importFirstText">
|
||||||
|
<i class="bi bi-download"></i> Import Listed Activities
|
||||||
|
</span>
|
||||||
|
<span id="importFirstSpinner" class="d-none">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
<span id="importFirstProgressText">Importing...</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger d-none" id="cancelImportBtn">
|
||||||
|
<i class="bi bi-stop-circle"></i> Stop After Current Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingIndicator" class="text-center py-5 d-none">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-muted mb-0">Loading Komoot activities...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resultsSection" class="mt-4 d-none">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-list-check"></i>
|
||||||
|
Komoot Activities
|
||||||
|
</h4>
|
||||||
|
<span class="badge text-bg-secondary" id="resultCount">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-center" style="width: 1%;"></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Elevation</th>
|
||||||
|
<th class="text-center">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="resultsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<th:block layout:fragment="scripts">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('komootImportForm');
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||||
|
const resultsSection = document.getElementById('resultsSection');
|
||||||
|
const resultsBody = document.getElementById('resultsBody');
|
||||||
|
const resultCount = document.getElementById('resultCount');
|
||||||
|
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
|
||||||
|
const importFirstBtn = document.getElementById('importFirstBtn');
|
||||||
|
const importFirstText = document.getElementById('importFirstText');
|
||||||
|
const importFirstSpinner = document.getElementById('importFirstSpinner');
|
||||||
|
const importFirstProgressText = document.getElementById('importFirstProgressText');
|
||||||
|
const cancelImportBtn = document.getElementById('cancelImportBtn');
|
||||||
|
let currentActivities = [];
|
||||||
|
let importCancellationRequested = false;
|
||||||
|
let importInProgress = false;
|
||||||
|
|
||||||
|
function updateImportButtonState() {
|
||||||
|
importFirstBtn.disabled = importInProgress || currentActivities.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
loadActivitiesBtn.disabled = loading;
|
||||||
|
loadingIndicator.classList.toggle('d-none', !loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImportLoading(loading) {
|
||||||
|
importInProgress = loading;
|
||||||
|
loadActivitiesBtn.disabled = loading;
|
||||||
|
updateImportButtonState();
|
||||||
|
importFirstText.classList.toggle('d-none', loading);
|
||||||
|
importFirstSpinner.classList.toggle('d-none', !loading);
|
||||||
|
cancelImportBtn.classList.toggle('d-none', !loading);
|
||||||
|
cancelImportBtn.disabled = !loading;
|
||||||
|
if (!loading) {
|
||||||
|
importFirstProgressText.textContent = 'Importing...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImportProgress(current, total) {
|
||||||
|
if (current == null || total == null || total <= 0) {
|
||||||
|
importFirstProgressText.textContent = 'Importing...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importFirstProgressText.textContent = `Importing ${current}/${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorAlert.textContent = message;
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorAlert.textContent = '';
|
||||||
|
errorAlert.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message) {
|
||||||
|
errorAlert.textContent = message;
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
errorAlert.classList.remove('alert-danger');
|
||||||
|
errorAlert.classList.add('alert-info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAlertToError() {
|
||||||
|
errorAlert.classList.remove('alert-info');
|
||||||
|
errorAlert.classList.add('alert-danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(meters) {
|
||||||
|
if (meters == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return (meters / 1000).toFixed(1) + ' km';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return [hours, minutes, remainingSeconds]
|
||||||
|
.map((value, index) => index === 0 ? String(value) : String(value).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElevation(elevationUp) {
|
||||||
|
if (elevationUp == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return Math.round(elevationUp) + ' m';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityTypeBadge(activityType) {
|
||||||
|
const normalizedType = String(activityType).toLowerCase().replaceAll('_', '-');
|
||||||
|
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivityTitle(activity) {
|
||||||
|
const title = escapeHtml(activity.name || 'Untitled activity');
|
||||||
|
if (activity.fitPubActivityId) {
|
||||||
|
return `<a href="/activities/${encodeURIComponent(activity.fitPubActivityId)}" class="fw-semibold text-decoration-none">${title}</a>`;
|
||||||
|
}
|
||||||
|
return `<div class="fw-semibold">${title}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImportStatus(activity) {
|
||||||
|
if (activity.uiImportStatus === 'queued') {
|
||||||
|
return '<i class="bi bi-hourglass-split text-warning" title="Queued for import" aria-label="Queued for import"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.uiImportStatus === 'importing') {
|
||||||
|
return '<span class="spinner-border spinner-border-sm text-primary" role="status" aria-label="Importing"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.uiImportStatus === 'error') {
|
||||||
|
const title = escapeHtml(activity.uiImportError || 'Import failed');
|
||||||
|
return `<i class="bi bi-exclamation-circle-fill text-danger" title="${title}" aria-label="${title}"></i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.imported) {
|
||||||
|
return '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisibilityIcon(activity) {
|
||||||
|
const status = String(activity.status || '').toLowerCase();
|
||||||
|
|
||||||
|
if (status === 'public') {
|
||||||
|
return '<i class="bi bi-globe2 visibility-public" title="Public" aria-label="Public"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'friends' || status === 'followers' || status === 'close_friends') {
|
||||||
|
return '<i class="bi bi-people-fill visibility-followers" title="Followers" aria-label="Followers"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<i class="bi bi-lock-fill visibility-private" title="Private" aria-label="Private"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivities(activities) {
|
||||||
|
resultCount.textContent = activities.length;
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
resultsBody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No completed activities found.</td></tr>';
|
||||||
|
resultsSection.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsBody.innerHTML = activities.map(activity => `
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">${renderVisibilityIcon(activity)}</td>
|
||||||
|
<td>${renderActivityTitle(activity)}</td>
|
||||||
|
<td>${formatDate(activity.date)}</td>
|
||||||
|
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
|
||||||
|
<td>${formatDistance(activity.distanceMeters)}</td>
|
||||||
|
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
|
||||||
|
<td>${formatElevation(activity.elevationUp)}</td>
|
||||||
|
<td class="text-center">${renderImportStatus(activity)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
resultsSection.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const startDate = formData.get('startDate');
|
||||||
|
const endDate = formData.get('endDate');
|
||||||
|
return {
|
||||||
|
email: formData.get('email'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
userId: formData.get('userId'),
|
||||||
|
startDate: startDate || null,
|
||||||
|
endDate: endDate || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImportPayload(activityId) {
|
||||||
|
const payload = buildPayload();
|
||||||
|
return {
|
||||||
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
userId: payload.userId,
|
||||||
|
activityId: activityId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIncompleteDateRange(payload) {
|
||||||
|
return Boolean(payload.startDate) !== Boolean(payload.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQueuedActivities() {
|
||||||
|
for (const activity of currentActivities) {
|
||||||
|
if (activity.uiImportStatus === 'queued') {
|
||||||
|
activity.uiImportStatus = null;
|
||||||
|
activity.uiImportError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
clearError();
|
||||||
|
resetAlertToError();
|
||||||
|
resultsSection.classList.add('d-none');
|
||||||
|
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (hasIncompleteDateRange(payload)) {
|
||||||
|
showError('Start date and end date must either both be set or both be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
currentActivities = [];
|
||||||
|
updateImportButtonState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Failed to load Komoot activities.';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
message = body.error || message;
|
||||||
|
} catch (ignored) {
|
||||||
|
// Ignore parse errors and show the generic message.
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
currentActivities = (data.activities || []).map(activity => ({
|
||||||
|
...activity,
|
||||||
|
uiImportStatus: null,
|
||||||
|
uiImportError: null
|
||||||
|
}));
|
||||||
|
updateImportButtonState();
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
} catch (error) {
|
||||||
|
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
importFirstBtn.addEventListener('click', async function() {
|
||||||
|
clearError();
|
||||||
|
resetAlertToError();
|
||||||
|
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (hasIncompleteDateRange(payload)) {
|
||||||
|
showError('Start date and end date must either both be set or both be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentActivities.length === 0) {
|
||||||
|
showError('Load Komoot activities before starting the import.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importCancellationRequested = false;
|
||||||
|
setImportLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activitiesToImport = currentActivities
|
||||||
|
.filter(activity => !activity.imported)
|
||||||
|
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
|
||||||
|
|
||||||
|
if (activitiesToImport.length === 0) {
|
||||||
|
showStatus('All listed Komoot activities are already imported.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const activity of activitiesToImport) {
|
||||||
|
activity.uiImportStatus = 'queued';
|
||||||
|
activity.uiImportError = null;
|
||||||
|
}
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
let rateLimited = false;
|
||||||
|
const totalActivitiesToImport = activitiesToImport.length;
|
||||||
|
|
||||||
|
for (const [index, activity] of activitiesToImport.entries()) {
|
||||||
|
activity.uiImportStatus = 'importing';
|
||||||
|
activity.uiImportError = null;
|
||||||
|
updateImportProgress(index + 1, totalActivitiesToImport);
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: buildImportPayload(activity.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
rateLimited = true;
|
||||||
|
activity.uiImportStatus = null;
|
||||||
|
activity.uiImportError = null;
|
||||||
|
resetQueuedActivities();
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Failed to import Komoot activity.';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
message = body.error || message;
|
||||||
|
} catch (ignored) {
|
||||||
|
// Ignore parse errors and show the generic message.
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
|
||||||
|
activity.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId;
|
||||||
|
activity.uiImportStatus = activity.imported ? 'imported' : null;
|
||||||
|
activity.uiImportError = null;
|
||||||
|
if (data.status === 'IMPORTED') {
|
||||||
|
importedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failedCount += 1;
|
||||||
|
activity.uiImportStatus = 'error';
|
||||||
|
activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
|
||||||
|
if (importCancellationRequested) {
|
||||||
|
cancelled = true;
|
||||||
|
resetQueuedActivities();
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimited) {
|
||||||
|
showStatus('Komoot rate limit reached. Import stopped. Please try again later.');
|
||||||
|
} else if (cancelled) {
|
||||||
|
showStatus(`Import stopped after the current activity. Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
|
||||||
|
} else {
|
||||||
|
showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.';
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setImportLoading(false);
|
||||||
|
importCancellationRequested = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelImportBtn.addEventListener('click', function() {
|
||||||
|
importCancellationRequested = true;
|
||||||
|
cancelImportBtn.disabled = true;
|
||||||
|
showStatus('Import will stop after the current activity finishes.');
|
||||||
|
});
|
||||||
|
|
||||||
|
updateImportButtonState();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
src/main/resources/templates/feature-disabled.html
Normal file
29
src/main/resources/templates/feature-disabled.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en"
|
||||||
|
xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout}">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title th:text="${pageTitle}">Feature Unavailable</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div layout:fragment="content">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h2 class="mb-3">
|
||||||
|
<i th:class="${featureIcon != null ? featureIcon : 'bi bi-slash-circle text-secondary'}"></i>
|
||||||
|
<span th:text="${featureName != null ? featureName : 'Feature'}">Feature</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mb-0"
|
||||||
|
th:text="${featureMessage != null ? featureMessage : 'This feature is currently unavailable on this FitPub instance.'}">
|
||||||
|
This feature is currently unavailable on this FitPub instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -97,6 +97,11 @@
|
||||||
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li th:if="${komootSupportEnabled}">
|
||||||
|
<a class="dropdown-item" th:href="@{/komoot-import}">
|
||||||
|
<i class="bi bi-signpost-split"></i> Komoot Import
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
<p id="bio" class="mb-3"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="followButtonContainer" class="d-none">
|
<div id="followButtonContainer" class="d-none">
|
||||||
<button class="btn btn-primary" id="followBtn">
|
<button class="btn btn-primary" id="followBtn">
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
<p id="bio" class="mb-3"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22,6 +23,8 @@ public class TestcontainersConfiguration {
|
||||||
)
|
)
|
||||||
.withDatabaseName("testdb")
|
.withDatabaseName("testdb")
|
||||||
.withUsername("test")
|
.withUsername("test")
|
||||||
.withPassword("test");
|
.withPassword("test")
|
||||||
|
.waitingFor(new HostPortWaitStrategy())
|
||||||
|
.withReuse(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
package net.javahippie.fitpub.controller;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
|
||||||
import net.javahippie.fitpub.service.InboxProcessor;
|
|
||||||
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("ActivityPubController Tests")
|
|
||||||
class ActivityPubControllerTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ActivityImageService activityImageService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private InboxProcessor inboxProcessor;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FollowRepository followRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private HttpSignatureValidator signatureValidator;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FederationService federationService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private ActivityPubController controller;
|
|
||||||
|
|
||||||
private UUID activityId;
|
|
||||||
private UUID userId;
|
|
||||||
private Activity activity;
|
|
||||||
private User user;
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
activityId = UUID.randomUUID();
|
|
||||||
userId = UUID.randomUUID();
|
|
||||||
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
|
||||||
|
|
||||||
ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example");
|
|
||||||
|
|
||||||
activity = Activity.builder()
|
|
||||||
.id(activityId)
|
|
||||||
.userId(userId)
|
|
||||||
.activityType(Activity.ActivityType.RUN)
|
|
||||||
.title("Lunch Run")
|
|
||||||
.description("Sunny run")
|
|
||||||
.visibility(Activity.Visibility.PUBLIC)
|
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
|
||||||
.totalDurationSeconds(1800L)
|
|
||||||
.createdAt(createdAt)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
user = User.builder()
|
|
||||||
.id(userId)
|
|
||||||
.username("JaneDoe")
|
|
||||||
.email("janedoe@example.com")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.privateKey("private-key")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should serialize activity published timestamp with timezone")
|
|
||||||
void getActivity_ShouldSerializePublishedTimestampWithTimezone() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
|
|
||||||
|
|
||||||
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
|
||||||
assertThat(response.getBody()).isNotNull();
|
|
||||||
assertThat(response.getBody().get("published"))
|
|
||||||
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should include workoutData and FitPub context terms in activity note")
|
|
||||||
void getActivity_ShouldIncludeWorkoutDataAndExtendedContext() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
|
|
||||||
when(workoutDataPayloadBuilder.build(activity)).thenReturn(Map.of(
|
|
||||||
"activityType", "RUN",
|
|
||||||
"description", "Sunny run",
|
|
||||||
"distance", 5000L,
|
|
||||||
"duration", "PT30M",
|
|
||||||
"averagePace", "PT6M",
|
|
||||||
"route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
|
|
||||||
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
|
||||||
assertThat(response.getBody()).isNotNull();
|
|
||||||
assertThat(response.getBody().get("workoutData")).isEqualTo(Map.of(
|
|
||||||
"activityType", "RUN",
|
|
||||||
"description", "Sunny run",
|
|
||||||
"distance", 5000L,
|
|
||||||
"duration", "PT30M",
|
|
||||||
"averagePace", "PT6M",
|
|
||||||
"route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Object> context = (List<Object>) response.getBody().get("@context");
|
|
||||||
assertThat(context).hasSize(2);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> extensions = (Map<String, Object>) context.get(1);
|
|
||||||
assertThat(extensions)
|
|
||||||
.containsEntry("fitpub", "https://fitpub.social/ns#")
|
|
||||||
.containsEntry("workoutData", "fitpub:workoutData")
|
|
||||||
.containsEntry("route", "fitpub:route");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,25 +2,19 @@ package net.javahippie.fitpub.integration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.javahippie.fitpub.config.TestcontainersConfiguration;
|
import net.javahippie.fitpub.config.TestcontainersConfiguration;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Disabled;
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import net.javahippie.fitpub.model.entity.Follow;
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActivity;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.security.JwtTokenProvider;
|
import net.javahippie.fitpub.security.JwtTokenProvider;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
@ -32,21 +26,15 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
|
@ -75,12 +63,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private RemoteActorRepository remoteActorRepository;
|
private RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RemoteActivityRepository remoteActivityRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
|
@ -90,9 +72,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private HttpSignatureValidator signatureValidator;
|
private HttpSignatureValidator signatureValidator;
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private ActivityImageService activityImageService;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
|
|
@ -122,22 +101,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
authToken = jwtTokenProvider.createToken(testUser.getUsername());
|
authToken = jwtTokenProvider.createToken(testUser.getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException {
|
|
||||||
KeyPair keyPair = generateRsaKeyPair();
|
|
||||||
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
|
|
||||||
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
|
|
||||||
|
|
||||||
return userRepository.save(User.builder()
|
|
||||||
.username(username)
|
|
||||||
.email(email)
|
|
||||||
.passwordHash(passwordEncoder.encode("password123"))
|
|
||||||
.displayName(displayName)
|
|
||||||
.publicKey(publicKey)
|
|
||||||
.privateKey(privateKey)
|
|
||||||
.enabled(true)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
|
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
|
||||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||||
keyGen.initialize(2048);
|
keyGen.initialize(2048);
|
||||||
|
|
@ -307,111 +270,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
|
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should import its own exported public activity through inbox")
|
|
||||||
void testActivityRoundtripThroughExportAndInbox() throws Exception {
|
|
||||||
User importingUser = testUser;
|
|
||||||
User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe");
|
|
||||||
|
|
||||||
Activity activity = activityRepository.save(Activity.builder()
|
|
||||||
.userId(exportingUser.getId())
|
|
||||||
.activityType(Activity.ActivityType.RUN)
|
|
||||||
.title("Lunch Run")
|
|
||||||
.description("Sunny run in the city")
|
|
||||||
.startedAt(LocalDateTime.of(2026, 5, 2, 12, 0))
|
|
||||||
.endedAt(LocalDateTime.of(2026, 5, 2, 12, 30))
|
|
||||||
.createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000))
|
|
||||||
.visibility(Activity.Visibility.PUBLIC)
|
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
|
||||||
.totalDurationSeconds(1800L)
|
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
|
||||||
.sourceFileFormat("FIT")
|
|
||||||
.published(true)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername();
|
|
||||||
when(activityImageService.getActivityImageFile(activity.getId()))
|
|
||||||
.thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image"));
|
|
||||||
|
|
||||||
remoteActorRepository.save(RemoteActor.builder()
|
|
||||||
.actorUri(exportingActorUri)
|
|
||||||
.username(exportingUser.getUsername())
|
|
||||||
.domain(java.net.URI.create(baseUrl).getHost())
|
|
||||||
.displayName(exportingUser.getDisplayName())
|
|
||||||
.inboxUrl(exportingActorUri + "/inbox")
|
|
||||||
.outboxUrl(exportingActorUri + "/outbox")
|
|
||||||
.publicKey(exportingUser.getPublicKey())
|
|
||||||
.publicKeyId(exportingActorUri + "#main-key")
|
|
||||||
.lastFetchedAt(Instant.now())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
followRepository.save(Follow.builder()
|
|
||||||
.followerId(importingUser.getId())
|
|
||||||
.followingActorUri(exportingActorUri)
|
|
||||||
.status(Follow.FollowStatus.ACCEPTED)
|
|
||||||
.activityId(baseUrl + "/activities/follow/" + UUID.randomUUID())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId())
|
|
||||||
.accept("application/activity+json"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
|
|
||||||
|
|
||||||
Map<String, Object> createActivity = Map.of(
|
|
||||||
"@context", "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type", "Create",
|
|
||||||
"id", baseUrl + "/activities/create/" + UUID.randomUUID(),
|
|
||||||
"actor", exportingActorUri,
|
|
||||||
"object", exportedNote
|
|
||||||
);
|
|
||||||
|
|
||||||
String privateKeyPem = exportingUser.getPrivateKey();
|
|
||||||
String inboxPath = "/users/" + importingUser.getUsername() + "/inbox";
|
|
||||||
String inboxUrl = "http://localhost" + inboxPath;
|
|
||||||
String body = objectMapper.writeValueAsString(createActivity);
|
|
||||||
HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest(
|
|
||||||
"POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key"
|
|
||||||
);
|
|
||||||
|
|
||||||
mockMvc.perform(post(inboxPath)
|
|
||||||
.contentType("application/activity+json")
|
|
||||||
.header("Host", sigHeaders.host)
|
|
||||||
.header("Date", sigHeaders.date)
|
|
||||||
.header("Digest", sigHeaders.digest)
|
|
||||||
.header("Signature", sigHeaders.signature)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isAccepted());
|
|
||||||
|
|
||||||
RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
|
|
||||||
.orElseThrow();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> workoutData = (Map<String, Object>) exportedNote.get("workoutData");
|
|
||||||
|
|
||||||
assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id"));
|
|
||||||
assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri);
|
|
||||||
assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name",
|
|
||||||
exportedNote.getOrDefault("summary", "Untitled Activity")));
|
|
||||||
assertThat(imported.getDescription()).isEqualTo(workoutData.get("description"));
|
|
||||||
assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published")));
|
|
||||||
assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC);
|
|
||||||
assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType"));
|
|
||||||
assertThat(imported.getTotalDistance()).isEqualTo(5000L);
|
|
||||||
assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L);
|
|
||||||
assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain"));
|
|
||||||
assertThat(imported.getAveragePaceSeconds()).isNull();
|
|
||||||
assertThat(imported.getAverageHeartRate()).isNull();
|
|
||||||
assertThat(imported.getMaxSpeed()).isNull();
|
|
||||||
assertThat(imported.getAverageSpeed()).isNull();
|
|
||||||
assertThat(imported.getCalories()).isNull();
|
|
||||||
assertThat(imported.getMapImageUrl()).isNull();
|
|
||||||
assertThat(imported.getTrackGeojsonUrl()).isNull();
|
|
||||||
assertThat(imported.getSimplifiedTrack()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
||||||
void testInboxRejectsUnsignedRequest() throws Exception {
|
void testInboxRejectsUnsignedRequest() throws Exception {
|
||||||
|
|
@ -452,23 +310,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String stripHtml(String html) {
|
|
||||||
if (html == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
.replaceAll("<br\\s*/?>", "\n")
|
|
||||||
.replaceAll("<p>", "")
|
|
||||||
.replaceAll("</p>", "\n")
|
|
||||||
.replaceAll("<[^>]+>", "")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace(""", "\"")
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace("&", "&")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
||||||
void testProcessUndoFollowActivity() throws Exception {
|
void testProcessUndoFollowActivity() throws Exception {
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||||
/**
|
/**
|
||||||
* Manual test for ActivityImageService.
|
* Manual test for ActivityImageService.
|
||||||
* These tests are disabled by default and should only be run manually.
|
* These tests are disabled by default and should only be run manually.
|
||||||
*
|
|
||||||
* To run this test manually:
|
|
||||||
* mvn test -Dtest=ActivityImageServiceTest
|
|
||||||
*/
|
*/
|
||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
"fitpub.image.osm-tiles.enabled=true"
|
"fitpub.image.osm-tiles.enabled=true"
|
||||||
})
|
})
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(TestcontainersConfiguration.class)
|
@Import(TestcontainersConfiguration.class)
|
||||||
@Disabled("Manual test - run explicitly when needed")
|
|
||||||
class ActivityImageServiceTest {
|
class ActivityImageServiceTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -59,6 +55,7 @@ class ActivityImageServiceTest {
|
||||||
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
|
@Disabled("Manual test - run explicitly when needed")
|
||||||
@DisplayName("Generate activity image from test FIT file")
|
@DisplayName("Generate activity image from test FIT file")
|
||||||
void testGenerateActivityImage_Manual() throws Exception {
|
void testGenerateActivityImage_Manual() throws Exception {
|
||||||
// Load test FIT file
|
// Load test FIT file
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,25 @@
|
||||||
package net.javahippie.fitpub.service;
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
|
||||||
import org.locationtech.jts.geom.GeometryFactory;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.anyBoolean;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.mockito.Mockito.anyString;
|
|
||||||
import static org.mockito.Mockito.doNothing;
|
|
||||||
import static org.mockito.Mockito.doThrow;
|
|
||||||
import static org.mockito.Mockito.eq;
|
|
||||||
import static org.mockito.Mockito.lenient;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for ActivityPostProcessingService.
|
* Unit tests for ActivityPostProcessingService.
|
||||||
|
|
@ -66,9 +49,6 @@ class ActivityPostProcessingServiceTest {
|
||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
@Mock
|
|
||||||
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ActivityPostProcessingService service;
|
private ActivityPostProcessingService service;
|
||||||
|
|
||||||
|
|
@ -76,13 +56,11 @@ class ActivityPostProcessingServiceTest {
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
private Activity testActivity;
|
private Activity testActivity;
|
||||||
private User testUser;
|
private User testUser;
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
activityId = UUID.randomUUID();
|
activityId = UUID.randomUUID();
|
||||||
userId = UUID.randomUUID();
|
userId = UUID.randomUUID();
|
||||||
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
|
||||||
|
|
||||||
// Set baseUrl via reflection (since it's @Value injected)
|
// Set baseUrl via reflection (since it's @Value injected)
|
||||||
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
||||||
|
|
@ -98,39 +76,9 @@ class ActivityPostProcessingServiceTest {
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
.totalDistance(BigDecimal.valueOf(5000))
|
||||||
.totalDurationSeconds(1800L)
|
.totalDurationSeconds(1800L)
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
.elevationGain(BigDecimal.valueOf(100))
|
||||||
.startedAt(createdAt.minusMinutes(30))
|
.startedAt(LocalDateTime.now())
|
||||||
.createdAt(createdAt)
|
.createdAt(LocalDateTime.now())
|
||||||
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
|
||||||
new Coordinate(8.55, 47.37),
|
|
||||||
new Coordinate(8.56, 47.38)
|
|
||||||
}))
|
|
||||||
.build();
|
.build();
|
||||||
testActivity.setMetrics(ActivityMetrics.builder()
|
|
||||||
.averagePaceSeconds(321L)
|
|
||||||
.build());
|
|
||||||
Map<String, Object> workoutData = new HashMap<>();
|
|
||||||
workoutData.put("activityType", "RUN");
|
|
||||||
workoutData.put("description", "Morning jog");
|
|
||||||
workoutData.put("distance", 5000L);
|
|
||||||
workoutData.put("duration", "PT30M");
|
|
||||||
workoutData.put("averagePace", "PT5M21S");
|
|
||||||
workoutData.put("elevationGain", 100);
|
|
||||||
workoutData.put("route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of(
|
|
||||||
Map.of(
|
|
||||||
"type", "Feature",
|
|
||||||
"geometry", Map.of(
|
|
||||||
"type", "LineString",
|
|
||||||
"coordinates", List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
));
|
|
||||||
lenient().when(workoutDataPayloadBuilder.build(testActivity)).thenReturn(workoutData);
|
|
||||||
|
|
||||||
// Create test user
|
// Create test user
|
||||||
testUser = User.builder()
|
testUser = User.builder()
|
||||||
|
|
@ -284,24 +232,6 @@ class ActivityPostProcessingServiceTest {
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should serialize federation note published timestamp with timezone")
|
|
||||||
void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
|
||||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
|
|
||||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
|
|
||||||
service.publishToFederationAsync(activityId, userId);
|
|
||||||
|
|
||||||
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
|
|
||||||
assertThat(noteCaptor.getValue().get("published"))
|
|
||||||
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should skip federation for PRIVATE activity")
|
@DisplayName("Should skip federation for PRIVATE activity")
|
||||||
void testPublishToFederationAsync_PrivateActivity() {
|
void testPublishToFederationAsync_PrivateActivity() {
|
||||||
|
|
@ -387,47 +317,4 @@ class ActivityPostProcessingServiceTest {
|
||||||
// Then: Verify federation was called (content formatting is tested indirectly)
|
// Then: Verify federation was called (content formatting is tested indirectly)
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should include workoutData payload in federation note")
|
|
||||||
void testPublishToFederationAsync_IncludesWorkoutDataPayload() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
|
||||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
|
|
||||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(Map.class);
|
|
||||||
|
|
||||||
service.publishToFederationAsync(activityId, userId);
|
|
||||||
|
|
||||||
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> workoutData = (Map<String, Object>) noteCaptor.getValue().get("workoutData");
|
|
||||||
assertThat(workoutData)
|
|
||||||
.containsEntry("activityType", "RUN")
|
|
||||||
.containsEntry("description", "Morning jog")
|
|
||||||
.containsEntry("distance", 5000L)
|
|
||||||
.containsEntry("duration", "PT30M")
|
|
||||||
.containsEntry("averagePace", "PT5M21S")
|
|
||||||
.containsEntry("elevationGain", 100);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> route = (Map<String, Object>) workoutData.get("route");
|
|
||||||
assertThat(route).containsEntry("type", "FeatureCollection");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
|
|
||||||
assertThat(features).hasSize(1);
|
|
||||||
assertThat(features.get(0)).containsEntry("type", "Feature");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> geometry = (Map<String, Object>) features.get(0).get("geometry");
|
|
||||||
assertThat(geometry).containsEntry("type", "LineString");
|
|
||||||
assertThat(geometry.get("coordinates")).isEqualTo(List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
package net.javahippie.fitpub.service;
|
|
||||||
|
|
||||||
import net.javahippie.fitpub.model.entity.Follow;
|
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActivity;
|
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.CommentRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
|
||||||
import net.javahippie.fitpub.repository.LikeRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("InboxProcessor Tests")
|
|
||||||
class InboxProcessorTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FollowRepository followRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FederationService federationService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private LikeRepository likeRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private CommentRepository commentRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private NotificationService notificationService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private RemoteActivityRepository remoteActivityRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private RemoteActorRepository remoteActorRepository;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private InboxProcessor inboxProcessor;
|
|
||||||
|
|
||||||
private User localUser;
|
|
||||||
private String remoteActorUri;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
localUser = User.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.username("JaneDoe")
|
|
||||||
.email("janedoe@example.com")
|
|
||||||
.passwordHash("irrelevant")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.privateKey("private-key")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
remoteActorUri = "https://fitpub.example.com/users/JohnDoe";
|
|
||||||
|
|
||||||
ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone")
|
|
||||||
void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() {
|
|
||||||
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123"))
|
|
||||||
.thenReturn(false);
|
|
||||||
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
|
|
||||||
.actorUri(remoteActorUri)
|
|
||||||
.username("JohnDoe")
|
|
||||||
.domain("fitpub.example.com")
|
|
||||||
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.build());
|
|
||||||
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
|
|
||||||
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
|
|
||||||
.thenReturn(Optional.of(Follow.builder()
|
|
||||||
.followerId(localUser.getId())
|
|
||||||
.followingActorUri(remoteActorUri)
|
|
||||||
.status(Follow.FollowStatus.ACCEPTED)
|
|
||||||
.build()));
|
|
||||||
|
|
||||||
Map<String, Object> note = Map.of(
|
|
||||||
"id", "https://fitpub.example.com/activities/123",
|
|
||||||
"type", "Note",
|
|
||||||
"name", "Lunch Run",
|
|
||||||
"content", "<p>Sunny run</p>",
|
|
||||||
"published", "2026-05-02T09:24:50.921241",
|
|
||||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public")
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> activity = Map.of(
|
|
||||||
"type", "Create",
|
|
||||||
"actor", remoteActorUri,
|
|
||||||
"object", note
|
|
||||||
);
|
|
||||||
|
|
||||||
ArgumentCaptor<net.javahippie.fitpub.model.entity.RemoteActivity> remoteActivityCaptor =
|
|
||||||
ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class);
|
|
||||||
|
|
||||||
inboxProcessor.processActivity("JaneDoe", activity);
|
|
||||||
|
|
||||||
verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123");
|
|
||||||
verify(federationService).fetchRemoteActor(remoteActorUri);
|
|
||||||
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
|
|
||||||
|
|
||||||
assertThat(remoteActivityCaptor.getValue().getPublishedAt())
|
|
||||||
.isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should prefer workoutData fields over legacy content parsing")
|
|
||||||
void processCreateRemoteActivity_WithWorkoutDataPayload_ShouldPreferWorkoutDataFields() {
|
|
||||||
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/456"))
|
|
||||||
.thenReturn(false);
|
|
||||||
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
|
|
||||||
.actorUri(remoteActorUri)
|
|
||||||
.username("JohnDoe")
|
|
||||||
.domain("fitpub.example.com")
|
|
||||||
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.build());
|
|
||||||
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
|
|
||||||
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
|
|
||||||
.thenReturn(Optional.of(Follow.builder()
|
|
||||||
.followerId(localUser.getId())
|
|
||||||
.followingActorUri(remoteActorUri)
|
|
||||||
.status(Follow.FollowStatus.ACCEPTED)
|
|
||||||
.build()));
|
|
||||||
|
|
||||||
Map<String, Object> workoutData = new HashMap<>();
|
|
||||||
workoutData.put("activityType", "RUN");
|
|
||||||
workoutData.put("description", "Direct workoutData description");
|
|
||||||
workoutData.put("distance", 9800L);
|
|
||||||
workoutData.put("duration", "PT41M9S");
|
|
||||||
workoutData.put("averagePace", "PT4M12S");
|
|
||||||
workoutData.put("elevationGain", 123);
|
|
||||||
workoutData.put("route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of(Map.of(
|
|
||||||
"type", "Feature",
|
|
||||||
"geometry", Map.of(
|
|
||||||
"type", "LineString",
|
|
||||||
"coordinates", List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38),
|
|
||||||
List.of(8.57, 47.39)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
))
|
|
||||||
));
|
|
||||||
|
|
||||||
Map<String, Object> note = Map.of(
|
|
||||||
"id", "https://fitpub.example.com/activities/456",
|
|
||||||
"type", "Note",
|
|
||||||
"name", "Kraremanns Lauf 2026",
|
|
||||||
"content", "<p>Kraremanns Lauf 2026</p><p>Run · 9.80 km · 41:09</p><p>Legacy content fallback</p>",
|
|
||||||
"published", "2026-05-02T09:24:50.921241",
|
|
||||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public"),
|
|
||||||
"workoutData", workoutData
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> activity = Map.of(
|
|
||||||
"type", "Create",
|
|
||||||
"actor", remoteActorUri,
|
|
||||||
"object", note
|
|
||||||
);
|
|
||||||
|
|
||||||
ArgumentCaptor<RemoteActivity> remoteActivityCaptor =
|
|
||||||
ArgumentCaptor.forClass(RemoteActivity.class);
|
|
||||||
|
|
||||||
inboxProcessor.processActivity("JaneDoe", activity);
|
|
||||||
|
|
||||||
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
|
|
||||||
|
|
||||||
RemoteActivity remoteActivity = remoteActivityCaptor.getValue();
|
|
||||||
assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026");
|
|
||||||
assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description");
|
|
||||||
assertThat(remoteActivity.getActivityType()).isEqualTo("RUN");
|
|
||||||
assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L);
|
|
||||||
assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L);
|
|
||||||
assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L);
|
|
||||||
assertThat(remoteActivity.getElevationGain()).isEqualTo(123);
|
|
||||||
LineString simplifiedTrack = remoteActivity.getSimplifiedTrack();
|
|
||||||
assertThat(simplifiedTrack).isNotNull();
|
|
||||||
assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3);
|
|
||||||
assertThat(simplifiedTrack.getSRID()).isEqualTo(4326);
|
|
||||||
assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55);
|
|
||||||
assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.KomootImport;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.KomootImportRepository;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.client.ExpectedCount.once;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
|
||||||
|
class KomootImportServiceTest {
|
||||||
|
|
||||||
|
private static KomootImportRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) {
|
||||||
|
return new KomootImportRepository.KomootImportLinkProjection() {
|
||||||
|
@Override
|
||||||
|
public UUID getActivityId() {
|
||||||
|
return activityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getKomootActivityId() {
|
||||||
|
return komootActivityId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockRestServiceServer server;
|
||||||
|
private KomootImportService service;
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
private KomootImportRepository komootImportRepository;
|
||||||
|
private ActivityFileService activityFileService;
|
||||||
|
private ActivityPostProcessingService activityPostProcessingService;
|
||||||
|
private TimeZone originalTimeZone;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
originalTimeZone = TimeZone.getDefault();
|
||||||
|
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich"));
|
||||||
|
RestTemplate restTemplate = new RestTemplate();
|
||||||
|
server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||||
|
activityRepository = mock(ActivityRepository.class);
|
||||||
|
komootImportRepository = mock(KomootImportRepository.class);
|
||||||
|
activityFileService = mock(ActivityFileService.class);
|
||||||
|
activityPostProcessingService = mock(ActivityPostProcessingService.class);
|
||||||
|
service = new KomootImportService(restTemplate, activityRepository, komootImportRepository, activityFileService, activityPostProcessingService);
|
||||||
|
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
|
||||||
|
ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L);
|
||||||
|
ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L);
|
||||||
|
ReflectionTestUtils.setField(service, "activityImportDelayMillis", 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
TimeZone.setDefault(originalTimeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFetchAndMergePagedCompletedActivities() {
|
||||||
|
String authHeader = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
KomootImportService throttledService = spy(service);
|
||||||
|
doNothing().when(throttledService).pauseBeforeNextPageRequest();
|
||||||
|
UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||||
|
|
||||||
|
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L));
|
||||||
|
when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L)))
|
||||||
|
.thenReturn(List.of(importLink(existingActivityId, 1002L)));
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
{
|
||||||
|
"_embedded": {
|
||||||
|
"tours": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"name": "Evening Ride",
|
||||||
|
"sport": "touringbicycle",
|
||||||
|
"status": "private",
|
||||||
|
"type": "tour_recorded",
|
||||||
|
"date": "2026-04-27T18:15:00+02:00",
|
||||||
|
"distance": 42350.4,
|
||||||
|
"duration": 8120,
|
||||||
|
"time_in_motion": 7800,
|
||||||
|
"elevation_up": 520.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"_links": {
|
||||||
|
"next": {
|
||||||
|
"href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
{
|
||||||
|
"_embedded": {
|
||||||
|
"tours": [
|
||||||
|
{
|
||||||
|
"id": 1002,
|
||||||
|
"name": "Lunch Walk",
|
||||||
|
"sport": "hike",
|
||||||
|
"status": "private",
|
||||||
|
"type": "tour_recorded",
|
||||||
|
"date": "2026-04-26T12:30:00+02:00",
|
||||||
|
"distance": 5120.0,
|
||||||
|
"duration": 3600,
|
||||||
|
"time_in_motion": 3400,
|
||||||
|
"elevation_up": 75.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"_links": {}
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
KomootActivitiesResponse response = throttledService.fetchCompletedActivities(
|
||||||
|
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
|
||||||
|
userId);
|
||||||
|
|
||||||
|
assertThat(response.getTotalCount()).isEqualTo(2);
|
||||||
|
assertThat(response.getActivities()).hasSize(2);
|
||||||
|
assertThat(response.getActivities().get(0).getId()).isEqualTo(1001L);
|
||||||
|
assertThat(response.getActivities().get(0).isImported()).isFalse();
|
||||||
|
assertThat(response.getActivities().get(0).getFitPubActivityId()).isNull();
|
||||||
|
assertThat(response.getActivities().get(0).getTimeInMotionSeconds()).isEqualTo(7800);
|
||||||
|
assertThat(response.getActivities().get(1).getName()).isEqualTo("Lunch Walk");
|
||||||
|
assertThat(response.getActivities().get(1).isImported()).isTrue();
|
||||||
|
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
|
||||||
|
|
||||||
|
verify(throttledService).pauseBeforeNextPageRequest();
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should filter loaded Komoot activities by inclusive date range")
|
||||||
|
void shouldFilterCompletedActivitiesByInclusiveDateRange() {
|
||||||
|
String authHeader = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||||
|
|
||||||
|
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L));
|
||||||
|
when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L)))
|
||||||
|
.thenReturn(List.of(importLink(existingActivityId, 1003L)));
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
{
|
||||||
|
"_embedded": {
|
||||||
|
"tours": [
|
||||||
|
{
|
||||||
|
"id": 1002,
|
||||||
|
"name": "Included Start",
|
||||||
|
"sport": "hike",
|
||||||
|
"status": "private",
|
||||||
|
"type": "tour_recorded",
|
||||||
|
"date": "2026-04-26T00:00:00+02:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1003,
|
||||||
|
"name": "Included End",
|
||||||
|
"sport": "run",
|
||||||
|
"status": "private",
|
||||||
|
"type": "tour_recorded",
|
||||||
|
"date": "2026-04-27T23:59:59+02:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"_links": {}
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
KomootActivitiesResponse response = service.fetchCompletedActivities(
|
||||||
|
new KomootImportRequest(
|
||||||
|
"user@example.com",
|
||||||
|
"secret",
|
||||||
|
"123456",
|
||||||
|
LocalDate.of(2026, 4, 26),
|
||||||
|
LocalDate.of(2026, 4, 27)
|
||||||
|
),
|
||||||
|
userId);
|
||||||
|
|
||||||
|
assertThat(response.getTotalCount()).isEqualTo(2);
|
||||||
|
assertThat(response.getActivities()).extracting("id").containsExactly(1002L, 1003L);
|
||||||
|
assertThat(response.getActivities().get(0).isImported()).isFalse();
|
||||||
|
assertThat(response.getActivities().get(1).isImported()).isTrue();
|
||||||
|
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
|
||||||
|
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should reject incomplete Komoot date range")
|
||||||
|
void shouldRejectIncompleteDateRange() {
|
||||||
|
assertThatThrownBy(() -> new KomootImportRequest(
|
||||||
|
"user@example.com",
|
||||||
|
"secret",
|
||||||
|
"123456",
|
||||||
|
LocalDate.of(2026, 4, 27),
|
||||||
|
null
|
||||||
|
)).isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessage("Start date and end date must either both be set or both be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should import a specific Komoot activity via GPX and override metadata")
|
||||||
|
void shouldImportSpecificKomootActivity() {
|
||||||
|
String authHeader = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||||
|
KomootImportService throttledService = spy(service);
|
||||||
|
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||||
|
doNothing().when(throttledService).pauseAfterActivityImport();
|
||||||
|
|
||||||
|
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
{
|
||||||
|
"id": "2880957035",
|
||||||
|
"name": "Latest Ride",
|
||||||
|
"description": "Imported from Komoot",
|
||||||
|
"status": "public",
|
||||||
|
"sport": "mtb_easy"
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="komoot">
|
||||||
|
<trk><name>Latest Ride</name></trk>
|
||||||
|
</gpx>
|
||||||
|
""", MediaType.APPLICATION_XML));
|
||||||
|
|
||||||
|
Activity importedActivity = Activity.builder()
|
||||||
|
.id(importedActivityId)
|
||||||
|
.userId(userId)
|
||||||
|
.activityType(Activity.ActivityType.OTHER)
|
||||||
|
.title("GPX Title")
|
||||||
|
.description(null)
|
||||||
|
.visibility(Activity.Visibility.PRIVATE)
|
||||||
|
.sourceFileFormat("GPX")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
||||||
|
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
KomootImportExecutionResponse response = throttledService.importActivity(
|
||||||
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
|
||||||
|
assertThat(response.getImportedKomootActivityId()).isEqualTo(2880957035L);
|
||||||
|
assertThat(response.getStatus()).isEqualTo("IMPORTED");
|
||||||
|
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
|
||||||
|
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
|
||||||
|
assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC);
|
||||||
|
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
|
||||||
|
verify(komootImportRepository).save(any(KomootImport.class));
|
||||||
|
|
||||||
|
verify(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||||
|
verify(throttledService).pauseAfterActivityImport();
|
||||||
|
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should skip already imported Komoot activity")
|
||||||
|
void shouldSkipAlreadyImportedKomootActivity() {
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||||
|
|
||||||
|
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
|
||||||
|
Optional.of(KomootImport.builder().activityId(existingActivityId).userId(userId).komootActivityId(3002L).build())
|
||||||
|
);
|
||||||
|
|
||||||
|
KomootImportExecutionResponse response = service.importActivity(
|
||||||
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(response.getImportedActivityId()).isEqualTo(existingActivityId);
|
||||||
|
assertThat(response.getImportedKomootActivityId()).isEqualTo(3002L);
|
||||||
|
assertThat(response.getStatus()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should map Komoot cycling sport racebike to ride")
|
||||||
|
void shouldMapKomootRacebikeToRide() {
|
||||||
|
String authHeader = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||||
|
KomootImportService throttledService = spy(service);
|
||||||
|
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||||
|
doNothing().when(throttledService).pauseAfterActivityImport();
|
||||||
|
|
||||||
|
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957037L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037?hl=en"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
{
|
||||||
|
"id": "2880957037",
|
||||||
|
"name": "Road Ride",
|
||||||
|
"description": "Komoot road cycling type",
|
||||||
|
"status": "private",
|
||||||
|
"sport": "racebike"
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037.gpx"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="komoot">
|
||||||
|
<trk><name>Road Ride</name></trk>
|
||||||
|
</gpx>
|
||||||
|
""", MediaType.APPLICATION_XML));
|
||||||
|
|
||||||
|
Activity importedActivity = Activity.builder()
|
||||||
|
.id(importedActivityId)
|
||||||
|
.userId(userId)
|
||||||
|
.activityType(Activity.ActivityType.OTHER)
|
||||||
|
.title("GPX Title")
|
||||||
|
.description(null)
|
||||||
|
.visibility(Activity.Visibility.PRIVATE)
|
||||||
|
.sourceFileFormat("GPX")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
||||||
|
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
KomootImportExecutionResponse response = throttledService.importActivity(
|
||||||
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957037L),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
|
||||||
|
assertThat(response.getStatus()).isEqualTo("IMPORTED");
|
||||||
|
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
|
||||||
|
verify(komootImportRepository).save(any(KomootImport.class));
|
||||||
|
|
||||||
|
verify(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||||
|
verify(throttledService).pauseAfterActivityImport();
|
||||||
|
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped")
|
||||||
|
void shouldFallbackToOtherForUnknownKomootSport() {
|
||||||
|
String authHeader = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||||
|
KomootImportService throttledService = spy(service);
|
||||||
|
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||||
|
doNothing().when(throttledService).pauseAfterActivityImport();
|
||||||
|
|
||||||
|
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
{
|
||||||
|
"id": "2880957036",
|
||||||
|
"name": "Unknown Sport",
|
||||||
|
"description": "No mapping available",
|
||||||
|
"status": "private",
|
||||||
|
"sport": "space_biking"
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx"))
|
||||||
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||||
|
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
|
||||||
|
.andRespond(withSuccess("""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="komoot">
|
||||||
|
<trk><name>Unknown Sport</name></trk>
|
||||||
|
</gpx>
|
||||||
|
""", MediaType.APPLICATION_XML));
|
||||||
|
|
||||||
|
Activity importedActivity = Activity.builder()
|
||||||
|
.id(importedActivityId)
|
||||||
|
.userId(userId)
|
||||||
|
.activityType(Activity.ActivityType.RIDE)
|
||||||
|
.title("GPX Title")
|
||||||
|
.description(null)
|
||||||
|
.visibility(Activity.Visibility.PUBLIC)
|
||||||
|
.sourceFileFormat("GPX")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
||||||
|
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
KomootImportExecutionResponse response = throttledService.importActivity(
|
||||||
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
|
||||||
|
assertThat(response.getStatus()).isEqualTo("IMPORTED");
|
||||||
|
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
|
||||||
|
verify(komootImportRepository).save(any(KomootImport.class));
|
||||||
|
|
||||||
|
verify(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||||
|
verify(throttledService).pauseAfterActivityImport();
|
||||||
|
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package net.javahippie.fitpub.service;
|
|
||||||
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
|
||||||
import org.locationtech.jts.geom.GeometryFactory;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("WorkoutDataPayloadBuilder Tests")
|
|
||||||
class WorkoutDataPayloadBuilderTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private PrivacyZoneService privacyZoneService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private TrackPrivacyFilter trackPrivacyFilter;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private WorkoutDataPayloadBuilder builder;
|
|
||||||
|
|
||||||
private UUID userId;
|
|
||||||
private Activity activity;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
userId = UUID.randomUUID();
|
|
||||||
activity = Activity.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.userId(userId)
|
|
||||||
.activityType(Activity.ActivityType.RUN)
|
|
||||||
.description("Morning jog")
|
|
||||||
.visibility(Activity.Visibility.PUBLIC)
|
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
|
||||||
.totalDurationSeconds(1800L)
|
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
|
||||||
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
|
||||||
new Coordinate(8.55, 47.37),
|
|
||||||
new Coordinate(8.56, 47.38)
|
|
||||||
}))
|
|
||||||
.build();
|
|
||||||
activity.setMetrics(ActivityMetrics.builder()
|
|
||||||
.averagePaceSeconds(321L)
|
|
||||||
.averageHeartRate(150)
|
|
||||||
.averageSpeed(BigDecimal.valueOf(10.4))
|
|
||||||
.maxSpeed(BigDecimal.valueOf(14.2))
|
|
||||||
.calories(420)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should build workoutData payload with route and metrics")
|
|
||||||
void build_ShouldIncludeWorkoutDataRouteAndMetrics() {
|
|
||||||
when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of());
|
|
||||||
|
|
||||||
Map<String, Object> workoutData = builder.build(activity);
|
|
||||||
|
|
||||||
assertThat(workoutData)
|
|
||||||
.containsEntry("activityType", "RUN")
|
|
||||||
.containsEntry("description", "Morning jog")
|
|
||||||
.containsEntry("distance", 5000L)
|
|
||||||
.containsEntry("duration", "PT30M")
|
|
||||||
.containsEntry("elevationGain", 100)
|
|
||||||
.containsEntry("averagePace", "PT5M21S")
|
|
||||||
.containsEntry("averageHeartRate", 150)
|
|
||||||
.containsEntry("averageSpeed", 10.4)
|
|
||||||
.containsEntry("maxSpeed", 14.2)
|
|
||||||
.containsEntry("calories", 420);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> route = (Map<String, Object>) workoutData.get("route");
|
|
||||||
assertThat(route).containsEntry("type", "FeatureCollection");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
|
|
||||||
assertThat(features).hasSize(1);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> geometry = (Map<String, Object>) features.get(0).get("geometry");
|
|
||||||
assertThat(geometry).containsEntry("type", "LineString");
|
|
||||||
assertThat(geometry.get("coordinates")).isEqualTo(List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
mock-maker-subclass
|
||||||
Loading…
Add table
Add a link
Reference in a new issue