diff --git a/design/icon.svg b/design/icon.svg index 57de5d0..02f16d5 100644 --- a/design/icon.svg +++ b/design/icon.svg @@ -15,7 +15,10 @@ version="1.1" id="svg8" inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)" - sodipodi:docname="icon.svg"> + sodipodi:docname="icon.svg" + inkscape:export-filename="/home/andrew/github-andrewlalis/Gymboard/design/icon_128.png" + inkscape:export-xdpi="384" + inkscape:export-ydpi="384"> image/svg+xml - + @@ -55,55 +58,55 @@ id="layer1" transform="translate(0,-288.53333)"> + width="1.8913983" + height="3.0603466" + x="0.13103379" + y="290.25571" + rx="0.32213143" /> + width="3.809514" + height="1.0980167" + x="2.3285766" + y="291.17575" /> + style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.96639419;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> + style="opacity:1;fill:#646464;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.96639419;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> + width="2.9306607" + height="0.71159816" + x="2.7680032" + y="292.98926" /> + style="opacity:1;fill:#a8a8a8;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.96639419;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> + width="2.0534277" + height="0.71159816" + x="3.2066195" + y="294.41245" /> diff --git a/design/icon_128.png b/design/icon_128.png new file mode 100644 index 0000000..ddb7238 Binary files /dev/null and b/design/icon_128.png differ diff --git a/design/icon_16.png b/design/icon_16.png new file mode 100644 index 0000000..73f7e1a Binary files /dev/null and b/design/icon_16.png differ diff --git a/design/icon_256.png b/design/icon_256.png new file mode 100644 index 0000000..eb6d73f Binary files /dev/null and b/design/icon_256.png differ diff --git a/design/icon_32.png b/design/icon_32.png new file mode 100644 index 0000000..b84b89a Binary files /dev/null and b/design/icon_32.png differ diff --git a/design/icon_64.png b/design/icon_64.png new file mode 100644 index 0000000..2db39d7 Binary files /dev/null and b/design/icon_64.png differ diff --git a/design/icon_96.png b/design/icon_96.png new file mode 100644 index 0000000..88a7faf Binary files /dev/null and b/design/icon_96.png differ diff --git a/gymboard-api/README.md b/gymboard-api/README.md index 74e6b1a..827ec32 100644 --- a/gymboard-api/README.md +++ b/gymboard-api/README.md @@ -1,3 +1,7 @@ # Gymboard API -An HTTP/REST API powered by Java and Spring Boot. This API serves as the main entrypoint +An HTTP/REST API powered by Java and Spring Boot. This API serves as the main entrypoint for all data processing, and is generally the first point-of-contact for the web app or other services that consume Gymboard data. + +## Development + +To ease development, `nl.andrewlalis.gymboard_api.model.SampleDataLoader` will run on startup and populate the database with some sample entities. You can regenerate this data by manually deleting the database, and deleting the `.sample_data` marker file that's generated in the project directory. diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java index 576fb8d..357068b 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java @@ -13,7 +13,6 @@ import java.io.IOException; * Controller for accessing a particular gym. */ @RestController -@RequestMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}") public class GymController { private final GymService gymService; private final UploadService uploadService; @@ -23,7 +22,7 @@ public class GymController { this.uploadService = uploadService; } - @GetMapping + @GetMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}") public GymResponse getGym( @PathVariable String countryCode, @PathVariable String cityCode, @@ -32,7 +31,7 @@ public class GymController { return gymService.getGym(new RawGymId(countryCode, cityCode, gymName)); } - @PostMapping(path = "/submissions") + @PostMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}/submissions") public ExerciseSubmissionResponse createSubmission( @PathVariable String countryCode, @PathVariable String cityCode, @@ -43,7 +42,7 @@ public class GymController { } @PostMapping( - path = "/submissions/upload", + path = "/gyms/{countryCode}/{cityCode}/{gymName}/submissions/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) public UploadedFileResponse uploadVideo( diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/GymRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/GymRepository.java index f0b0c03..961f9ce 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/GymRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/GymRepository.java @@ -3,13 +3,14 @@ package nl.andrewlalis.gymboard_api.dao; import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.GymId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository -public interface GymRepository extends JpaRepository { +public interface GymRepository extends JpaRepository, JpaSpecificationExecutor { @Query("SELECT g FROM Gym g " + "WHERE g.id.shortName = :gym AND " + "g.id.city.id.shortName = :city AND " + diff --git a/gymboard-app/public/favicon.ico b/gymboard-app/public/favicon.ico index ae7bbdb..0ec0447 100644 Binary files a/gymboard-app/public/favicon.ico and b/gymboard-app/public/favicon.ico differ diff --git a/gymboard-app/public/icons/favicon-128x128.png b/gymboard-app/public/icons/favicon-128x128.png index 1401176..ddb7238 100644 Binary files a/gymboard-app/public/icons/favicon-128x128.png and b/gymboard-app/public/icons/favicon-128x128.png differ diff --git a/gymboard-app/public/icons/favicon-16x16.png b/gymboard-app/public/icons/favicon-16x16.png index 679063a..73f7e1a 100644 Binary files a/gymboard-app/public/icons/favicon-16x16.png and b/gymboard-app/public/icons/favicon-16x16.png differ diff --git a/gymboard-app/public/icons/favicon-256x256.png b/gymboard-app/public/icons/favicon-256x256.png new file mode 100644 index 0000000..eb6d73f Binary files /dev/null and b/gymboard-app/public/icons/favicon-256x256.png differ diff --git a/gymboard-app/public/icons/favicon-32x32.png b/gymboard-app/public/icons/favicon-32x32.png index fd1fbc6..b84b89a 100644 Binary files a/gymboard-app/public/icons/favicon-32x32.png and b/gymboard-app/public/icons/favicon-32x32.png differ diff --git a/gymboard-app/public/icons/favicon-64x64.png b/gymboard-app/public/icons/favicon-64x64.png new file mode 100644 index 0000000..2db39d7 Binary files /dev/null and b/gymboard-app/public/icons/favicon-64x64.png differ diff --git a/gymboard-app/public/icons/favicon-96x96.png b/gymboard-app/public/icons/favicon-96x96.png index e93b80a..88a7faf 100644 Binary files a/gymboard-app/public/icons/favicon-96x96.png and b/gymboard-app/public/icons/favicon-96x96.png differ diff --git a/gymboard-app/src/api/gymboard-api.ts b/gymboard-app/src/api/gymboard-api.ts index d81b566..5af27b7 100644 --- a/gymboard-app/src/api/gymboard-api.ts +++ b/gymboard-app/src/api/gymboard-api.ts @@ -3,7 +3,7 @@ import axios from 'axios'; export const BASE_URL = 'http://localhost:8080'; // TODO: Figure out how to get the base URL from environment. -const api = axios.create({ +const api = axios.create({ baseURL: BASE_URL }); diff --git a/gymboard-app/src/api/gymboard-search.ts b/gymboard-app/src/api/gymboard-search.ts new file mode 100644 index 0000000..4df48c6 --- /dev/null +++ b/gymboard-app/src/api/gymboard-search.ts @@ -0,0 +1,30 @@ +/** + * Module for interacting with the Gymboard search service's API. + */ + +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'http://localhost:8081' +}); + +export interface GymSearchResult { + shortName: string, + displayName: string, + cityShortName: string, + cityName: string, + countryCode: string, + countryName: string, + streetAddress: string, + latitude: number, + longitude: number +} + +/** + * Searches for gyms using the given query, and eventually returns results. + * @param query The query to use. + */ +export async function searchGyms(query: string): Promise> { + const response = await api.get('/search/gyms?q=' + query); + return response.data; +} diff --git a/gymboard-app/src/components/SimpleGymItem.vue b/gymboard-app/src/components/SimpleGymItem.vue new file mode 100644 index 0000000..eac3b5e --- /dev/null +++ b/gymboard-app/src/components/SimpleGymItem.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/gymboard-app/src/components/SlimForm.vue b/gymboard-app/src/components/SlimForm.vue new file mode 100644 index 0000000..fb60851 --- /dev/null +++ b/gymboard-app/src/components/SlimForm.vue @@ -0,0 +1,9 @@ + diff --git a/gymboard-app/src/components/StandardCenteredPage.vue b/gymboard-app/src/components/StandardCenteredPage.vue new file mode 100644 index 0000000..7878a7c --- /dev/null +++ b/gymboard-app/src/components/StandardCenteredPage.vue @@ -0,0 +1,18 @@ + + diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index d555d3f..174a701 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -1,7 +1,18 @@ -// This is just an example, -// so you can safely delete all default props below - export default { - failed: 'Action failed', - success: 'Action was successful' + mainLayout: { + language: 'Language', + pages: 'Pages' + }, + gymPage: { + home: 'Home', + submit: 'Submit', + leaderboard: 'Leaderboard', + submitPage: { + exercise: 'Exercise', + weight: 'Weight', + reps: 'Repetitions', + date: 'Date', + submit: 'Submit' + } + } }; diff --git a/gymboard-app/src/i18n/index.ts b/gymboard-app/src/i18n/index.ts index 5851f87..965bfc2 100644 --- a/gymboard-app/src/i18n/index.ts +++ b/gymboard-app/src/i18n/index.ts @@ -1,5 +1,7 @@ import enUS from './en-US'; +import nlNL from './nl-NL'; export default { - 'en-US': enUS + 'en-US': enUS, + 'nl-NL': nlNL }; diff --git a/gymboard-app/src/i18n/nl-NL/index.ts b/gymboard-app/src/i18n/nl-NL/index.ts new file mode 100644 index 0000000..0a09c65 --- /dev/null +++ b/gymboard-app/src/i18n/nl-NL/index.ts @@ -0,0 +1,18 @@ +export default { + mainLayout: { + language: 'Taal', + pages: 'Pagina\'s' + }, + gymPage: { + home: 'Thuis', + submit: 'Indienen', + leaderboard: 'Scorebord', + submitPage: { + exercise: 'Oefening', + weight: 'Gewicht', + reps: 'Repetities', + date: 'Datum', + submit: 'Sturen' + } + } +} diff --git a/gymboard-app/src/layouts/MainLayout.vue b/gymboard-app/src/layouts/MainLayout.vue index 9117847..12072f3 100644 --- a/gymboard-app/src/layouts/MainLayout.vue +++ b/gymboard-app/src/layouts/MainLayout.vue @@ -14,6 +14,23 @@ Gymboard + @@ -26,7 +43,7 @@ - Pages + {{ $t('mainLayout.pages') }} Gyms Global Leaderboard @@ -39,21 +56,19 @@ - diff --git a/gymboard-app/src/pages/IndexPage.vue b/gymboard-app/src/pages/IndexPage.vue index 9189fbe..d54e529 100644 --- a/gymboard-app/src/pages/IndexPage.vue +++ b/gymboard-app/src/pages/IndexPage.vue @@ -1,20 +1,77 @@ - diff --git a/gymboard-app/src/pages/gym/GymHomePage.vue b/gymboard-app/src/pages/gym/GymHomePage.vue new file mode 100644 index 0000000..ea284c3 --- /dev/null +++ b/gymboard-app/src/pages/gym/GymHomePage.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/gymboard-app/src/pages/gym/GymLeaderboardsPage.vue b/gymboard-app/src/pages/gym/GymLeaderboardsPage.vue new file mode 100644 index 0000000..404e098 --- /dev/null +++ b/gymboard-app/src/pages/gym/GymLeaderboardsPage.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/gymboard-app/src/pages/gym/GymPage.vue b/gymboard-app/src/pages/gym/GymPage.vue new file mode 100644 index 0000000..ddef734 --- /dev/null +++ b/gymboard-app/src/pages/gym/GymPage.vue @@ -0,0 +1,54 @@ + + + diff --git a/gymboard-app/src/pages/gym/GymSubmissionPage.vue b/gymboard-app/src/pages/gym/GymSubmissionPage.vue new file mode 100644 index 0000000..1751a8a --- /dev/null +++ b/gymboard-app/src/pages/gym/GymSubmissionPage.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/gymboard-app/src/router/gym-routing.ts b/gymboard-app/src/router/gym-routing.ts new file mode 100644 index 0000000..c478ea0 --- /dev/null +++ b/gymboard-app/src/router/gym-routing.ts @@ -0,0 +1,31 @@ +import {useRoute} from 'vue-router'; +import {getGym, Gym} from 'src/api/gymboard-api'; + +/** + * Any object that contains the properties needed to identify a single gym. + */ +export interface GymRoutable { + countryCode: string; + cityShortName: string; + shortName: string +} + +/** + * Gets the route that can be used to navigate to a particular gym's page. + * @param gym The gym to get the route for. + */ +export function getGymRoute(gym: GymRoutable): string { + return `/gyms/${gym.countryCode}/${gym.cityShortName}/${gym.shortName}`; +} + +/** + * Gets the gym that's referred to by the current route's path params. + */ +export async function getGymFromRoute(): Promise { + const route = useRoute(); + return await getGym( + route.params.countryCode as string, + route.params.cityShortName as string, + route.params.gymShortName as string + ); +} diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts index 0e2f990..8fcb6fc 100644 --- a/gymboard-app/src/router/routes.ts +++ b/gymboard-app/src/router/routes.ts @@ -1,20 +1,25 @@ import { RouteRecordRaw } from 'vue-router'; import MainLayout from 'layouts/MainLayout.vue'; import IndexPage from 'pages/IndexPage.vue'; -import GymPage from 'pages/GymPage.vue'; +import GymPage from 'pages/gym/GymPage.vue'; +import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue'; +import GymHomePage from 'pages/gym/GymHomePage.vue'; +import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue'; const routes: RouteRecordRaw[] = [ { path: '/', component: MainLayout, children: [ + { path: '', component: IndexPage }, { - path: '', - component: IndexPage - }, - { - path: 'g/:countryCode/:cityShortName/:gymShortName', - component: GymPage + path: 'gyms/:countryCode/:cityShortName/:gymShortName', + component: GymPage, + children: [ + { path: '', component: GymHomePage }, + { path: 'submit', component: GymSubmissionPage }, + { path: 'leaderboard', component: GymLeaderboardsPage } + ] } ], }, diff --git a/gymboard-search/.gitignore b/gymboard-search/.gitignore new file mode 100644 index 0000000..d97d9f0 --- /dev/null +++ b/gymboard-search/.gitignore @@ -0,0 +1,35 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +gym-index/ diff --git a/gymboard-search/.mvn/wrapper/maven-wrapper.jar b/gymboard-search/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/gymboard-search/.mvn/wrapper/maven-wrapper.jar differ diff --git a/gymboard-search/.mvn/wrapper/maven-wrapper.properties b/gymboard-search/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/gymboard-search/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/gymboard-search/README.md b/gymboard-search/README.md new file mode 100644 index 0000000..7f7f5e7 --- /dev/null +++ b/gymboard-search/README.md @@ -0,0 +1,9 @@ +# Gymboard Search + +A simple search API for Gymboard, backed by Apache Lucene. This application includes both indexing of Gyms and other searchable entities, and a public web interface for searching those indexes. + +This application is configured with read-only access to the central Gymboard database, for its indexing operations. + +## Developing + +Currently, this application is designed to boot up and immediately read the latest data from the Gymboard API's database to rebuild its indexes. diff --git a/gymboard-search/mvnw b/gymboard-search/mvnw new file mode 100755 index 0000000..8a8fb22 --- /dev/null +++ b/gymboard-search/mvnw @@ -0,0 +1,316 @@ +#!/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 +# +# https://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/gymboard-search/mvnw.cmd b/gymboard-search/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/gymboard-search/mvnw.cmd @@ -0,0 +1,188 @@ +@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 https://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 Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/gymboard-search/pom.xml b/gymboard-search/pom.xml new file mode 100644 index 0000000..1efe854 --- /dev/null +++ b/gymboard-search/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.0.2 + + + nl.andrewlalis + gymboard-search + 0.0.1-SNAPSHOT + gymboard-search + Search API for Gymboard + + 17 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.postgresql + postgresql + runtime + + + + + + org.apache.lucene + lucene-core + 9.4.2 + + + + org.apache.lucene + lucene-spatial + 8.4.1 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/DbUtils.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/DbUtils.java new file mode 100644 index 0000000..90e4123 --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/DbUtils.java @@ -0,0 +1,15 @@ +package nl.andrewlalis.gymboardsearch; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class DbUtils { + public static String loadClasspathString(String resourceName) throws IOException { + InputStream in = DbUtils.class.getResourceAsStream(resourceName); + if (in == null) throw new IOException("Resource " + resourceName + " not found."); + String s = new String(in.readAllBytes(), StandardCharsets.UTF_8); + in.close(); + return s; + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/GymboardSearchApplication.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/GymboardSearchApplication.java new file mode 100644 index 0000000..0e7f79d --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/GymboardSearchApplication.java @@ -0,0 +1,24 @@ +package nl.andrewlalis.gymboardsearch; + +import nl.andrewlalis.gymboardsearch.index.GymIndexGenerator; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class GymboardSearchApplication implements CommandLineRunner { + private final GymIndexGenerator gymIndexGenerator; + + public GymboardSearchApplication(GymIndexGenerator gymIndexGenerator) { + this.gymIndexGenerator = gymIndexGenerator; + } + + public static void main(String[] args) { + SpringApplication.run(GymboardSearchApplication.class, args); + } + + @Override + public void run(String... args) throws Exception { + gymIndexGenerator.generateIndex(); + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/SearchController.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/SearchController.java new file mode 100644 index 0000000..ed90431 --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/SearchController.java @@ -0,0 +1,23 @@ +package nl.andrewlalis.gymboardsearch; + +import nl.andrewlalis.gymboardsearch.dto.GymResponse; +import nl.andrewlalis.gymboardsearch.index.GymIndexSearcher; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +public class SearchController { + private final GymIndexSearcher gymIndexSearcher; + + public SearchController(GymIndexSearcher gymIndexSearcher) { + this.gymIndexSearcher = gymIndexSearcher; + } + + @GetMapping(path = "/search/gyms") + public List searchGyms(@RequestParam(name = "q", required = false) String query) { + return gymIndexSearcher.searchGyms(query); + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java new file mode 100644 index 0000000..d61c7c4 --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java @@ -0,0 +1,26 @@ +package nl.andrewlalis.gymboardsearch.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; + +@Configuration +public class WebConfig { + + @Bean + public CorsFilter corsFilter() { + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + final CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + // Don't do this in production, use a proper list of allowed origins + config.addAllowedOriginPattern("*"); + config.setAllowedHeaders(Arrays.asList("Origin", "Content-Type", "Accept")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS", "DELETE", "PATCH")); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java new file mode 100644 index 0000000..2767560 --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java @@ -0,0 +1,29 @@ +package nl.andrewlalis.gymboardsearch.dto; + +import org.apache.lucene.document.Document; + +public record GymResponse( + String shortName, + String displayName, + String cityShortName, + String cityName, + String countryCode, + String countryName, + String streetAddress, + double latitude, + double longitude +) { + public GymResponse(Document doc) { + this( + doc.get("short_name"), + doc.get("display_name"), + doc.get("city_short_name"), + doc.get("city_name"), + doc.get("country_code"), + doc.get("country_name"), + doc.get("street_address"), + doc.getField("latitude").numericValue().doubleValue(), + doc.getField("longitude").numericValue().doubleValue() + ); + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/GymIndexGenerator.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/GymIndexGenerator.java new file mode 100644 index 0000000..d231c6b --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/GymIndexGenerator.java @@ -0,0 +1,74 @@ +package nl.andrewlalis.gymboardsearch.index; + +import nl.andrewlalis.gymboardsearch.DbUtils; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.document.*; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.FileSystemUtils; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +@Service +public class GymIndexGenerator { + private static final Logger log = LoggerFactory.getLogger(GymIndexGenerator.class); + + public void generateIndex() throws Exception { + log.info("Starting Gym index generation."); + Path gymIndexDir = Path.of("gym-index"); + FileSystemUtils.deleteRecursively(gymIndexDir); + Files.createDirectory(gymIndexDir); + long count = 0; + try ( + Connection conn = DriverManager.getConnection("jdbc:postgresql://localhost:5432/gymboard-api-dev", "gymboard-api-dev", "testpass"); + PreparedStatement stmt = conn.prepareStatement(DbUtils.loadClasspathString("/sql/select-gyms.sql")); + ResultSet resultSet = stmt.executeQuery(); + + Analyzer analyzer = new StandardAnalyzer(); + Directory indexDir = FSDirectory.open(gymIndexDir); + IndexWriter indexWriter = new IndexWriter(indexDir, new IndexWriterConfig(analyzer)) + ) { + while (resultSet.next()) { + String shortName = resultSet.getString("short_name"); + String displayName = resultSet.getString("display_name"); + String cityShortName = resultSet.getString("city_short_name"); + String cityName = resultSet.getString("city_name"); + String countryCode = resultSet.getString("country_code"); + String countryName = resultSet.getString("country_name"); + String streetAddress = resultSet.getString("street_address"); + BigDecimal latitude = resultSet.getBigDecimal("latitude"); + BigDecimal longitude = resultSet.getBigDecimal("longitude"); + String gymCompoundId = String.format("%s/%s/%s", countryCode, cityShortName, shortName); + + Document doc = new Document(); + doc.add(new StoredField("compound_id", gymCompoundId)); + doc.add(new TextField("short_name", shortName, Field.Store.YES)); + doc.add(new TextField("display_name", displayName, Field.Store.YES)); + doc.add(new TextField("city_short_name", cityShortName, Field.Store.YES)); + doc.add(new TextField("city_name", cityName, Field.Store.YES)); + doc.add(new TextField("country_code", countryCode, Field.Store.YES)); + doc.add(new TextField("country_name", countryName, Field.Store.YES)); + doc.add(new TextField("street_address", streetAddress, Field.Store.YES)); + doc.add(new DoublePoint("latitude_point", latitude.doubleValue())); + doc.add(new StoredField("latitude", latitude.doubleValue())); + doc.add(new DoublePoint("longitude_point", longitude.doubleValue())); + doc.add(new StoredField("longitude", longitude.doubleValue())); + indexWriter.addDocument(doc); + count++; + } + } + log.info("Gym index generation complete. {} gyms indexed.", count); + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/GymIndexSearcher.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/GymIndexSearcher.java new file mode 100644 index 0000000..83b13cc --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/GymIndexSearcher.java @@ -0,0 +1,63 @@ +package nl.andrewlalis.gymboardsearch.index; + +import nl.andrewlalis.gymboardsearch.dto.GymResponse; +import org.apache.lucene.document.Document; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.*; +import org.apache.lucene.store.FSDirectory; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; + +/** + * Searcher that uses a Lucene {@link IndexSearcher} to search for gyms using + * a query that's built from a weighted list of wildcard search terms. + *
    + *
  1. If the query is blank, return an empty list.
  2. + *
  3. Split the query into words, append the wildcard '*' to each word.
  4. + *
  5. For each word, add a boosted wildcard query for each weighted field.
  6. + *
+ */ +@Service +public class GymIndexSearcher { + public List searchGyms(String rawQuery) { + if (rawQuery == null || rawQuery.isBlank()) return Collections.emptyList(); + String[] terms = rawQuery.split("\\s+"); + BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); + Map fieldWeights = new HashMap<>(); + fieldWeights.put("short_name", 3f); + fieldWeights.put("display_name", 3f); + fieldWeights.put("city_short_name", 1f); + fieldWeights.put("city_name", 1f); + fieldWeights.put("country_code", 0.25f); + fieldWeights.put("country_name", 0.5f); + fieldWeights.put("street_address", 0.1f); + for (String term : terms) { + String searchTerm = term.strip() + "*"; + for (var entry : fieldWeights.entrySet()) { + Query baseQuery = new WildcardQuery(new Term(entry.getKey(), searchTerm)); + queryBuilder.add(new BoostQuery(baseQuery, entry.getValue()), BooleanClause.Occur.SHOULD); + } + } + BooleanQuery query = queryBuilder.build(); + Path gymIndexDir = Path.of("gym-index"); + try ( + var reader = DirectoryReader.open(FSDirectory.open(gymIndexDir)) + ) { + IndexSearcher searcher = new IndexSearcher(reader); + List results = new ArrayList<>(10); + TopDocs topDocs = searcher.search(query, 10, Sort.RELEVANCE, false); + for (ScoreDoc scoreDoc : topDocs.scoreDocs) { + Document doc = searcher.doc(scoreDoc.doc); + results.add(new GymResponse(doc)); + } + return results; + } catch (IOException e) { + e.printStackTrace(); + return Collections.emptyList(); + } + } +} diff --git a/gymboard-search/src/main/resources/application-development.properties b/gymboard-search/src/main/resources/application-development.properties new file mode 100644 index 0000000..4d360de --- /dev/null +++ b/gymboard-search/src/main/resources/application-development.properties @@ -0,0 +1 @@ +server.port=8081 diff --git a/gymboard-search/src/main/resources/application.properties b/gymboard-search/src/main/resources/application.properties new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/gymboard-search/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/gymboard-search/src/main/resources/sql/select-gyms.sql b/gymboard-search/src/main/resources/sql/select-gyms.sql new file mode 100644 index 0000000..010ae8d --- /dev/null +++ b/gymboard-search/src/main/resources/sql/select-gyms.sql @@ -0,0 +1,14 @@ +SELECT + gym.short_name as short_name, + gym.display_name as display_name, + city.short_name as city_short_name, + city.name as city_name, + country.code as country_code, + country.name as country_name, + gym.street_address as street_address, + gym.latitude as latitude, + gym.longitude as longitude +FROM gym +LEFT JOIN city on gym.city_short_name = city.short_name +LEFT JOIN country on gym.city_country_code = country.code +ORDER BY gym.created_at; diff --git a/gymboard-search/src/test/java/nl/andrewlalis/gymboardsearch/GymboardSearchApplicationTests.java b/gymboard-search/src/test/java/nl/andrewlalis/gymboardsearch/GymboardSearchApplicationTests.java new file mode 100644 index 0000000..0773c0b --- /dev/null +++ b/gymboard-search/src/test/java/nl/andrewlalis/gymboardsearch/GymboardSearchApplicationTests.java @@ -0,0 +1,13 @@ +package nl.andrewlalis.gymboardsearch; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class GymboardSearchApplicationTests { + + @Test + void contextLoads() { + } + +}