feat(server): store and expose last updated date of gtfs data

This commit is contained in:
Cilly Leang 2025-12-19 19:16:22 +11:00
parent 25e5282ea8
commit 5535034fd7
Signed by: cilly
GPG key ID: 6500251E087653C9
8 changed files with 430 additions and 2 deletions

View file

@ -19,6 +19,7 @@ import moe.lava.banksia.Constants
import moe.lava.banksia.di.CommonModules
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.StopDao
import moe.lava.banksia.room.dao.VersionMetadataDao
import moe.lava.banksia.server.di.ServerModules
import moe.lava.banksia.server.gtfs.GtfsHandler
import moe.lava.banksia.server.gtfsr.GtfsrService
@ -61,6 +62,22 @@ fun Application.module() {
}
}
get("/metadata/{type?}") {
val dao by inject<VersionMetadataDao>()
val type = call.parameters["type"]
if (type == null) {
call.respond(dao.getAll().map { it.asModel() })
return@get
}
val data = dao.get(type)?.asModel()
if (data == null) {
call.respond(HttpStatusCode.NotFound)
} else {
call.respond(data)
}
}
get("/routes") {
val routes = withContext(context = Dispatchers.IO) {
inject<RouteDao>().value.getAll()

View file

@ -29,6 +29,8 @@ import moe.lava.banksia.server.gtfs.structures.GtfsTrip
import moe.lava.banksia.util.Point
import java.io.File
import java.util.zip.ZipFile
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
class GtfsHandler(
private val log: Logger,
@ -38,7 +40,8 @@ class GtfsHandler(
private val csv = CsvFormat(StringDeferringConfig(EmptySerializersModule()))
private val datasetPath = File("/tmp/banksia", "dataset.zip")
suspend fun update(datasetUrl: String) {
@OptIn(ExperimentalTime::class)
suspend fun update(datasetUrl: String, date: Long? = null) {
val parentDir = datasetPath.parentFile
@Suppress("SimplifyBooleanWithConstants", "KotlinConstantConditions")
if (parentDir.exists() && !Constants.devMode)
@ -72,6 +75,8 @@ class GtfsHandler(
addTrips(files)
addStopTimes(files)
updateMetadata(date ?: Clock.System.now().epochSeconds)
@Suppress("KotlinConstantConditions")
if (!Constants.devMode) {
parentDir.deleteRecursively()
@ -80,6 +85,12 @@ class GtfsHandler(
log.info("done!")
}
private suspend fun updateMetadata(date: Long) {
val dao = db.versionMetadataDao
log.info("updating metadata...")
dao.update(date, listOf("routes", "stops", "shapes", "trips", "stop_times"))
}
private suspend fun addRoutes(files: List<File>) {
val dao = db.routeDao
log.info("parsing routes...")

View file

@ -0,0 +1,339 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "5a7252ab3bcae4d0d0950024b19ba002",
"entities": [
{
"tableName": "Route",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` INTEGER NOT NULL, `number` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "number",
"columnName": "number",
"affinity": "TEXT"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "Shape",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `path` BLOB NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "Stop",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `parent` TEXT NOT NULL, `hasWheelChairBoarding` INTEGER NOT NULL, `level` TEXT NOT NULL, `platformCode` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lat",
"columnName": "lat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lng",
"columnName": "lng",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasWheelChairBoarding",
"columnName": "hasWheelChairBoarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "platformCode",
"columnName": "platformCode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Stop_parent",
"unique": false,
"columnNames": [
"parent"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Stop_parent` ON `${TABLE_NAME}` (`parent`)"
}
]
},
{
"tableName": "StopTime",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tripId` TEXT NOT NULL, `stopId` TEXT NOT NULL, `arrivalTime` INTEGER NOT NULL, `departureTime` INTEGER NOT NULL, `headsign` TEXT, `pickupType` INTEGER NOT NULL, `dropOffType` INTEGER NOT NULL, PRIMARY KEY(`tripId`, `stopId`), FOREIGN KEY(`tripId`) REFERENCES `Trip`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`stopId`) REFERENCES `Stop`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "tripId",
"columnName": "tripId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "stopId",
"columnName": "stopId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "arrivalTime",
"columnName": "arrivalTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "departureTime",
"columnName": "departureTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "headsign",
"columnName": "headsign",
"affinity": "TEXT"
},
{
"fieldPath": "pickupType",
"columnName": "pickupType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dropOffType",
"columnName": "dropOffType",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"tripId",
"stopId"
]
},
"foreignKeys": [
{
"table": "Trip",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tripId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Stop",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stopId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Trip",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `routeId` TEXT NOT NULL, `serviceId` TEXT NOT NULL, `shapeId` TEXT, `tripHeadsign` TEXT NOT NULL, `directionId` TEXT NOT NULL, `blockId` TEXT NOT NULL, `wheelchairAccessible` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`routeId`) REFERENCES `Route`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`shapeId`) REFERENCES `Shape`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "routeId",
"columnName": "routeId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shapeId",
"columnName": "shapeId",
"affinity": "TEXT"
},
{
"fieldPath": "tripHeadsign",
"columnName": "tripHeadsign",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "directionId",
"columnName": "directionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockId",
"columnName": "blockId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wheelchairAccessible",
"columnName": "wheelchairAccessible",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Trip_routeId",
"unique": false,
"columnNames": [
"routeId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Trip_routeId` ON `${TABLE_NAME}` (`routeId`)"
}
],
"foreignKeys": [
{
"table": "Route",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"routeId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Shape",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"shapeId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "VersionMetadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`type`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"type"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a7252ab3bcae4d0d0950024b19ba002')"
]
}
}

View file

@ -7,6 +7,7 @@ val CommonModules = module {
includes(PlatformModule)
single { Database.build(get<PlatformDatabaseBuilder>().getBuilder()) }
single { get<Database>().versionMetadataDao }
single { get<Database>().routeDao }
single { get<Database>().shapeDao }
single { get<Database>().stopDao }

View file

@ -0,0 +1,9 @@
package moe.lava.banksia.model
import kotlinx.serialization.Serializable
@Serializable
data class VersionMetadata(
val type: String,
val lastUpdated: Long,
)

View file

@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import moe.lava.banksia.room.converter.RouteTypeConverter
import moe.lava.banksia.room.dao.VersionMetadataDao
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.ShapeDao
import moe.lava.banksia.room.dao.StopDao
@ -17,23 +18,27 @@ import moe.lava.banksia.room.entity.ShapeEntity
import moe.lava.banksia.room.entity.StopEntity
import moe.lava.banksia.room.entity.StopTimeEntity
import moe.lava.banksia.room.entity.TripEntity
import moe.lava.banksia.room.entity.VersionMetadataEntity
import androidx.room.Database as DatabaseAnnotation
@DatabaseAnnotation(
version = 2,
version = 3,
entities = [
RouteEntity::class,
ShapeEntity::class,
StopEntity::class,
StopTimeEntity::class,
TripEntity::class,
VersionMetadataEntity::class,
],
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
]
)
@TypeConverters(RouteTypeConverter::class)
abstract class Database : RoomDatabase() {
abstract val versionMetadataDao: VersionMetadataDao
abstract val routeDao: RouteDao
abstract val shapeDao: ShapeDao
abstract val stopDao: StopDao

View file

@ -0,0 +1,27 @@
package moe.lava.banksia.room.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import moe.lava.banksia.room.entity.VersionMetadataEntity
@Dao
interface VersionMetadataDao {
@Query("SELECT * FROM VersionMetadata WHERE type == :type")
suspend fun get(type: String): VersionMetadataEntity?
@Query("SELECT * FROM VersionMetadata")
suspend fun getAll(): List<VersionMetadataEntity>
@Insert(onConflict = REPLACE)
suspend fun update(vararg data: VersionMetadataEntity)
suspend fun update(vararg data: Pair<String, Long>) {
update(*data.map { (type, lastUpdated) -> VersionMetadataEntity(type, lastUpdated) }.toTypedArray())
}
suspend fun update(lastUpdated: Long, types: Collection<String>) {
update(*types.map { VersionMetadataEntity(it, lastUpdated) }.toTypedArray())
}
}

View file

@ -0,0 +1,19 @@
package moe.lava.banksia.room.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import moe.lava.banksia.model.VersionMetadata
@Entity(
"VersionMetadata",
)
data class VersionMetadataEntity(
/** Entity type this metadata applies to */
@PrimaryKey val type: String,
/** Last updated */
val lastUpdated: Long,
) {
fun asModel() = VersionMetadata(type, lastUpdated)
}
fun VersionMetadata.asEntity() = VersionMetadataEntity(type, lastUpdated)