diff --git a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt index ec5b77b..4ae3398 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt @@ -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() + 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().value.getAll() diff --git a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt index 7bc493e..fa8d4ed 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt @@ -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) { val dao = db.routeDao log.info("parsing routes...") diff --git a/shared/schemas/moe.lava.banksia.room.Database/3.json b/shared/schemas/moe.lava.banksia.room.Database/3.json new file mode 100644 index 0000000..e769926 --- /dev/null +++ b/shared/schemas/moe.lava.banksia.room.Database/3.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt index 769d461..823174b 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt @@ -7,6 +7,7 @@ val CommonModules = module { includes(PlatformModule) single { Database.build(get().getBuilder()) } + single { get().versionMetadataDao } single { get().routeDao } single { get().shapeDao } single { get().stopDao } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/VersionMetadata.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/VersionMetadata.kt new file mode 100644 index 0000000..1770b23 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/VersionMetadata.kt @@ -0,0 +1,9 @@ +package moe.lava.banksia.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VersionMetadata( + val type: String, + val lastUpdated: Long, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt index 2690ece..163461a 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/VersionMetadataDao.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/VersionMetadataDao.kt new file mode 100644 index 0000000..b96102e --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/VersionMetadataDao.kt @@ -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 + + @Insert(onConflict = REPLACE) + suspend fun update(vararg data: VersionMetadataEntity) + + suspend fun update(vararg data: Pair) { + update(*data.map { (type, lastUpdated) -> VersionMetadataEntity(type, lastUpdated) }.toTypedArray()) + } + + suspend fun update(lastUpdated: Long, types: Collection) { + update(*types.map { VersionMetadataEntity(it, lastUpdated) }.toTypedArray()) + } +} diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/VersionMetadataEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/VersionMetadataEntity.kt new file mode 100644 index 0000000..fc00b44 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/VersionMetadataEntity.kt @@ -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)