feat: server-handled routes and stops

This commit is contained in:
Cilly Leang 2025-08-12 22:43:33 +10:00
parent efba64ea90
commit 58ee095522
Signed by: cilly
GPG key ID: 6500251E087653C9
61 changed files with 1634 additions and 349 deletions

View file

@ -18,9 +18,12 @@ dependencies {
implementation(libs.koin.core)
implementation(libs.koin.ktor)
implementation(libs.kotlinx.serialization.csv)
implementation(libs.ktor.client.core)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.server.contentnegotiation)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.room.runtime)

View file

@ -1,17 +1,24 @@
package moe.lava.banksia.server
import io.ktor.client.HttpClient
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.application.log
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.lava.banksia.di.CommonModules
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.StopDao
import moe.lava.banksia.server.di.ServerModules
import moe.lava.banksia.server.gtfs.GtfsHandler
import org.koin.dsl.module
@ -24,6 +31,9 @@ fun main() {
}
fun Application.module() {
install(ContentNegotiation) {
json()
}
install(Koin) {
modules(module { single { log } })
modules(CommonModules, ServerModules)
@ -40,5 +50,66 @@ fun Application.module() {
handler.update(datasetUrl)
}
}
get("/routes") {
val routes = withContext(context = Dispatchers.IO) {
inject<RouteDao>().value.getAll()
}
val res = routes.map { it.asModel() }
call.respond(res)
}
get("/routes/{route_id}") {
val routeId = call.parameters["route_id"]!!
val route = withContext(context = Dispatchers.IO) {
inject<RouteDao>().value.get(routeId)
}
if (route != null)
call.respond(route.asModel())
else
call.respond(HttpStatusCode.NotFound)
}
get("/stops") {
val routes = withContext(context = Dispatchers.IO) {
inject<StopDao>().value.getAll()
}
val res = routes.map { it.asModel() }
call.respond(res)
}
get("/stops/{stop_id}") {
val stopId = call.parameters["stop_id"]!!
val stop = withContext(context = Dispatchers.IO) {
inject<StopDao>().value.get(stopId)
}
if (stop != null)
call.respond(stop.asModel())
else
call.respond(HttpStatusCode.NotFound)
}
get("/route_stops/{route_id}") {
val routeId = call.parameters["route_id"]!!
val useParent = call.queryParameters["parent"] in listOf("true", "1")
val stops = withContext(Dispatchers.IO) {
val routeDao by inject<RouteDao>()
if (useParent)
routeDao.stopsParent(routeId)
else
routeDao.stops(routeId)
}
call.respond(stops.map { it.asModel() })
// val stops = withContext(Dispatchers.IO) {
// val stopDao by inject<StopDao>()
// val stopTimeDao by inject<StopTimeDao>()
// val tripDao by inject<TripDao>()
//
// tripDao.getByRoute(routeId)
// .map { it.id }
// .let { stopTimeDao.get(it) }
// .flatMap { it.asModel().stopInfos }
// .map { it.stopId }
// .let { stopDao.get(it) }
// .map { it.asModel() }
// }
// call.respond(stops)
}
}
}

View file

@ -12,12 +12,18 @@ import io.ktor.utils.io.copyAndClose
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.modules.EmptySerializersModule
import moe.lava.banksia.model.Route
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.model.Shape
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.ShapeDao
import moe.lava.banksia.model.Stop
import moe.lava.banksia.model.StopTime
import moe.lava.banksia.model.Trip
import moe.lava.banksia.room.Database
import moe.lava.banksia.room.converter.RouteTypeConverter
import moe.lava.banksia.room.entity.asEntity
import moe.lava.banksia.server.gtfs.structures.GtfsRoute
import moe.lava.banksia.server.gtfs.structures.GtfsShape
import moe.lava.banksia.server.gtfs.structures.GtfsStop
import moe.lava.banksia.server.gtfs.structures.GtfsStopTime
import moe.lava.banksia.server.gtfs.structures.GtfsTrip
import moe.lava.banksia.util.Point
import java.io.File
import java.util.zip.ZipFile
@ -25,9 +31,7 @@ import java.util.zip.ZipFile
class GtfsHandler(
private val log: Logger,
private val client: HttpClient,
private val routeDao: RouteDao,
private val shapeDao: ShapeDao,
private val db: Database,
) {
private val csv = CsvFormat(StringDeferringConfig(EmptySerializersModule()))
private val datasetPath = File("/tmp/banksia", "dataset.zip")
@ -49,27 +53,30 @@ class GtfsHandler(
}
log.info("extracting...")
val files = extractAll(datasetPath)
// val files = extractAll(datasetPath)
val files = datasetPath.parentFile
.listFiles { it.isDirectory }
.flatMap { d -> d.listFiles { f -> f.extension == "txt" }.toList() }
addRoutes(files)
addStops(files)
addShapes(files)
addTrips(files)
addStopTimes(files)
log.info("done!")
}
private suspend fun addRoutes(files: List<File>) {
val dao = db.routeDao
log.info("parsing routes...")
val routes = files
.filter { it.name == "routes.txt" }
.flatMap { fd -> parseRoutes(fd) }
log.info("inserting routes...")
routeDao.deleteAll()
routeDao.insertAll(*routes.toTypedArray())
log.info("parsing shapes...")
val shapes = files
.filter { it.name == "shapes.txt" }
.flatMap { fd -> parseShapes(fd) }
log.info("inserting shapes...")
shapeDao.deleteAll()
shapeDao.insertAll(*shapes.toTypedArray())
log.info("done!")
dao.deleteAll()
dao.insertAll(*routes.map { it.asEntity() }.toTypedArray())
}
private fun parseRoutes(fd: File) =
@ -77,12 +84,24 @@ class GtfsHandler(
.map { with(it) {
Route(
id = route_id,
type = RouteType.from(fd.parentFile.name.toInt()),
type = RouteTypeConverter.from(fd.parentFile.name.toInt()),
number = route_short_name,
name = route_long_name,
)
} }
private suspend fun addShapes(files: List<File>) {
val dao = db.shapeDao
log.info("parsing shapes...")
val shapes = files
.filter { it.name == "shapes.txt" }
.flatMap { fd -> parseShapes(fd) }
log.info("inserting shapes...")
dao.deleteAll()
dao.insertAll(*shapes.map { it.asEntity() }.toTypedArray())
}
private fun parseShapes(fd: File) =
fd.parseCsv<GtfsShape>()
.groupBy { it.shape_id }
@ -94,6 +113,95 @@ class GtfsHandler(
Shape(id, points)
}
private suspend fun addStops(files: List<File>) {
val dao = db.stopDao
log.info("parsing stops...")
val stops = files
.filter { it.name == "stops.txt" }
.flatMap { fd -> parseStops(fd) }
log.info("inserting stops...")
dao.deleteAll()
stops
.groupBy { it.id }
.forEach { (id, gstops) ->
if (gstops.size > 1) {
// if (gstops.withIndex().any { (i, stop) -> i != 0 && stop == gstops[i - 1] })
gstops.forEach {
log.info("duplicate $id: $it")
}
}
}
dao.insertOrReplaceAll(*stops.map { it.asEntity() }.toTypedArray())
}
private fun parseStops(fd: File) =
fd.parseCsv<GtfsStop>()
.map { with(it) {
Stop(
id = stop_id,
name = stop_name,
pos = Point(stop_lat, stop_lon),
parent = parent_station,
hasWheelChairBoarding = wheelchair_boarding == "1",
level = level_id,
platformCode = platform_code,
)
} }
private suspend fun addStopTimes(files: List<File>) {
val dao = db.stopTimeDao
log.info("parsing stop times...")
val stopTimes = files
.filter { it.name == "stop_times.txt" }
.flatMap { fd -> parseStopTimes(fd) }
log.info("inserting stop times...")
dao.deleteAll()
dao.insertOrReplaceAll(*stopTimes.map { it.asEntity() }.toTypedArray())
}
private fun parseStopTimes(fd: File) =
fd.parseCsv<GtfsStopTime>()
.map { with(it) {
StopTime(
tripId = trip_id,
stopId = stop_id,
arrivalTime = GtfsStopTime.parseGtfsTime(arrival_time),
departureTime = GtfsStopTime.parseGtfsTime(departure_time),
headsign = stop_headsign,
pickupType = pickup_type,
dropOffType = drop_off_type,
)
} }
private suspend fun addTrips(files: List<File>) {
val dao = db.tripDao
log.info("parsing trips...")
val trips = files
.filter { it.name == "trips.txt" }
.flatMap { fd -> parseTrips(fd) }
log.info("inserting trips...")
dao.deleteAll()
dao.insertOrReplaceAll(*trips.map { it.asEntity() }.toTypedArray())
}
private fun parseTrips(fd: File) =
fd.parseCsv<GtfsTrip>()
.map { with(it) {
Trip(
id = trip_id,
routeId = route_id,
serviceId = service_id,
shapeId = shape_id.ifEmpty { null },
tripHeadsign = trip_headsign,
directionId = direction_id,
blockId = block_id,
wheelchairAccessible = wheelchair_accessible,
)
} }
private fun extract(fd: File): List<File> {
val outputs = mutableListOf<File>()
@ -114,7 +222,7 @@ class GtfsHandler(
private fun extractAll(fd: File) = extract(fd).flatMap(::extract)
private fun <T> File.parseCsv(): List<T> = this
private inline fun <reified T> File.parseCsv(): List<T> = this
.readText()
.replace("\uFEFF", "") // remove bom
.replace("\r\n", "\n") // crlf -> lf

View file

@ -0,0 +1,17 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsStop(
val stop_id: String,
val stop_name: String,
val stop_lat: Double,
val stop_lon: Double,
val location_type: String,
val parent_station: String,
val wheelchair_boarding: String,
val level_id: String,
val platform_code: String,
)

View file

@ -0,0 +1,25 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
import moe.lava.banksia.model.FutureTime
@Suppress("PropertyName")
@Serializable
data class GtfsStopTime(
val trip_id: String,
val arrival_time: String,
val departure_time: String,
val stop_id: String,
val stop_sequence: Int,
val stop_headsign: String,
val pickup_type: Int,
val drop_off_type: Int,
val shape_dist_traveled: String,
) {
companion object {
fun parseGtfsTime(time: String): FutureTime {
val (hour, minute, second) = time.split(":").map { it.toInt() }
return FutureTime.from(hour, minute, second)
}
}
}

View file

@ -0,0 +1,16 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsTrip(
val route_id: String,
val service_id: String,
val trip_id: String,
val shape_id: String,
val trip_headsign: String,
val direction_id: String,
val block_id: String,
val wheelchair_accessible: String,
)