Compare commits

..

24 commits

Author SHA1 Message Date
8b3016004b
feat: basic departures support
also a huge refactor to simplify modules
2026-06-23 00:07:10 +10:00
b31067992d
feat(server/gtfsr): compress after every day 2026-05-27 16:35:23 +10:00
102c028407
refactor: optimisation around stoptimes
- moved stoptime related functionality into new core:data:stoptime module
  - will feature all the different realtime stoptime sources to be
      integrated later
- create proper database schema for future migrations
- deduplicate trips into stoppingpatterns, since many trips share the
  exact same stopping pattern
  - stoptimes are now linked to stoppingpatterns instead
  - stoppingpattern ids are generated from a hash composed of all stoptimes
- stoptimes now use deltas for arrival time to save space
2026-05-05 03:23:11 +10:00
f1770744db
refactor(core): switch from room to sqldelight
sqldelight provides far more control over the sql and allows me to make
more optimisations such as removing generated rowid etc. sql also just
looks better than the annotation hell from room.
2026-05-02 02:31:18 +10:00
ff2af310fb
feat(server): use envvar for devmode constant 2026-04-30 17:26:41 +10:00
ef630b6d58
fix(server/gtfs): resolve duplicate stops more intelligently 2026-04-12 20:43:45 +10:00
415ce8d88f
fix(core/room): use null instead of empty parent 2026-04-12 01:02:53 +10:00
29a804b0fb
fix(core/room/server): ignore non-existing files in file operations 2026-04-12 00:32:28 +10:00
27f2a08d77
feat(server): add fixup endpoint and move update endpoint 2026-04-12 00:31:41 +10:00
38bcdc54bc
fix(server/gtfs): null empty parents 2026-04-11 23:34:47 +10:00
0524eda5d2
feat(server): use lazy swappable database 2026-04-11 21:59:26 +10:00
959b022cf9
feat(core/room): swappable databases 2026-04-11 21:33:33 +10:00
e7caeca356
refactor: core:data -> core:data:client 2026-04-02 21:55:45 +11:00
4cdb9a305c
fix(ui): make setting camera position work again 2026-04-02 02:28:10 +11:00
c912723c78
refactor: shared -> core 2026-04-02 01:57:08 +11:00
104a77b27e
refactor: split up room into a module, and move client module 2026-04-01 22:48:04 +11:00
c55e3a3232
feat(server): better support for parent stops
- add datafixer to add parent stops for likely candidates
  - this is mainly for bus hubs, the heuristic is the existence of a
    platform code and missing parent
- use parent stops as default in route_stops
- support parent stops for stoptime querying
2026-04-01 20:37:58 +11:00
58649b6171
feat(server/gtfs): service exception support 2026-04-01 19:31:31 +11:00
c9aeeb99c1
fix(server/gtfs): chunk stop times into smaller blocks 2026-04-01 17:23:59 +11:00
91d4fe25a6
feat(server/gtfs): use transaction for imports 2026-04-01 17:00:35 +11:00
b568ed547a
chore: bump and cleanup dependencies 2026-04-01 16:43:02 +11:00
ed9d294afc
refactor(server): move gtfsrt to separate module 2026-04-01 16:32:01 +11:00
0181497420
refactor(server): split gtfs into its own module 2026-03-31 23:12:54 +11:00
aad5ae4024
refactor(ui/info): split up info panel state 2026-03-31 20:53:21 +11:00
177 changed files with 2637 additions and 4898 deletions

2
.gitignore vendored
View file

@ -18,6 +18,6 @@ captures
**/xcshareddata/WorkspaceSettings.xcsettings
secrets.properties
shared/src/commonMain/kotlin/moe/lava/banksia/Constants.kt
/core/src/commonMain/kotlin/moe/lava/banksia/core/Constants.kt
/data/
/data

View file

@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.sqldelight) apply false
alias(libs.plugins.wire) apply false
}

View file

@ -1,11 +0,0 @@
package moe.lava.banksia.client.data.route
import moe.lava.banksia.model.Route
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.entity.asEntity
class RouteLocalDataSource(private val dao: RouteDao) {
suspend fun get(id: String) = dao.get(id)
suspend fun getAll() = dao.getAll()
suspend fun save(vararg routes: Route) = dao.insertOrReplaceAll(*routes.map { it.asEntity() }.toTypedArray())
}

View file

@ -1,11 +0,0 @@
package moe.lava.banksia.client.data.route
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import moe.lava.banksia.model.Route
class RouteRemoteDataSource(val client: HttpClient) {
suspend fun get(id: String) = client.get("routes/${id}").body<Route>()
suspend fun getAll() = client.get("routes").body<List<Route>>()
}

View file

@ -1,12 +0,0 @@
package moe.lava.banksia.client.data.stop
import moe.lava.banksia.model.Stop
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.StopDao
import moe.lava.banksia.room.entity.asEntity
class StopLocalDataSource(private val dao: StopDao, private val routeDao: RouteDao) {
suspend fun get(id: String) = dao.get(id)
suspend fun getByRoute(id: String) = routeDao.stops(id)
suspend fun save(vararg stops: Stop) = dao.insertOrReplaceAll(*stops.map { it.asEntity() }.toTypedArray())
}

View file

@ -1,28 +0,0 @@
package moe.lava.banksia.client.data.stoptime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.model.StopTimeDated
import moe.lava.banksia.model.atDate
import moe.lava.banksia.room.dao.StopTimeDao
import moe.lava.banksia.util.serialise
import kotlin.time.Clock
class StopTimeLocalDataSource(
private val stopTimeDao: StopTimeDao,
) {
suspend fun getAtStop(
stopId: String,
date: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTimeDated> {
return stopTimeDao
.getForStopDated(
stopId,
listOf(date.dayOfWeek).serialise(),
date.toEpochDays().toInt(),
)
.map { it.asModel().atDate(date) }
.sortedBy { it.departureTime }
}
}

View file

@ -1,36 +0,0 @@
package moe.lava.banksia.client.data.stoptime
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.model.StopTimeDated
import kotlin.time.Clock
class StopTimeRemoteDataSource(
private val client: HttpClient,
) {
suspend fun getAtStop(
stopId: String,
date: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTimeDated> {
return client.get("stoptimes/by_stop/${stopId}") {
parameter("date", date)
}.body<List<StopTimeDated>>()
}
/*suspend fun get(
stop: String? = null,
trip: String? = null,
day: DayOfWeek? = Clock.System.todayIn(TimeZone.currentSystemDefault()).dayOfWeek,
): List<StopTime> {
return client.get("stoptimes") {
stop?.let { parameter("stop", it) }
trip?.let { parameter("trip", it) }
day?.let { parameter("day", it) }
}.body<List<StopTime>>()
}*/
}

View file

@ -1,18 +0,0 @@
package moe.lava.banksia.client.data.trip
import io.ktor.client.HttpClient
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.model.Trip
import kotlin.time.Clock
class TripRemoteDataSource(
private val client: HttpClient,
) {
suspend fun get(
day: DayOfWeek? = Clock.System.todayIn(TimeZone.currentSystemDefault()).dayOfWeek,
): List<Trip> {
return listOf()
}
}

View file

@ -1,25 +0,0 @@
package moe.lava.banksia.client.repository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.client.data.route.RouteLocalDataSource
import moe.lava.banksia.client.data.route.RouteRemoteDataSource
class RouteRepository(
private val local: RouteLocalDataSource,
private val remote: RouteRemoteDataSource,
) {
private val mutex = Mutex()
suspend fun getAll() = mutex.withLock {
local
.getAll()
.map { it.asModel() }
.ifEmpty {
remote
.getAll()
.also { local.save(*it.toTypedArray()) }
}
}
suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
}

View file

@ -1,22 +0,0 @@
package moe.lava.banksia.client.repository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.client.data.stop.StopLocalDataSource
import moe.lava.banksia.client.data.stop.StopRemoteDataSource
class StopRepository(
private val local: StopLocalDataSource,
private val remote: StopRemoteDataSource,
) {
private val mutex = Mutex()
suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
suspend fun getByRoute(id: String) = mutex.withLock {
local
.getByRoute(id)
.map { it.asModel() }
.ifEmpty { null }
?: remote.getByRoute(id)
}
}

View file

@ -1,16 +0,0 @@
package moe.lava.banksia.client.repository
import moe.lava.banksia.client.data.stoptime.StopTimeLocalDataSource
import moe.lava.banksia.client.data.stoptime.StopTimeRemoteDataSource
import moe.lava.banksia.model.StopTimeDated
class StopTimeRepository(
private val local: StopTimeLocalDataSource,
private val remote: StopTimeRemoteDataSource,
) {
suspend fun getForStop(id: String): List<StopTimeDated> {
return local
.getAtStop(id)
.ifEmpty { remote.getAtStop(id) }
}
}

View file

@ -4,18 +4,11 @@ plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
alias(libs.plugins.wire)
}
room {
schemaDirectory("$projectDir/schemas")
}
kotlin {
android {
namespace = "moe.lava.banksia.shared"
namespace = "moe.lava.banksia.core"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
@ -47,25 +40,9 @@ kotlin {
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(libs.room.runtime)
implementation(libs.sqlite.bundled)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
dependencies {
add("kspAndroid", libs.room.compiler)
add("kspIosArm64", libs.room.compiler)
add("kspIosSimulatorArm64", libs.room.compiler)
add("kspJvm", libs.room.compiler)
}
wire {
sourcePath {
srcDir("src/commonMain/proto")
}
kotlin {}
}

View file

@ -0,0 +1,64 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.data"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
val clientMain by creating {
dependsOn(commonMain.get())
}
androidMain.get().dependsOn(clientMain)
iosArm64Main.get().dependsOn(clientMain)
iosSimulatorArm64Main.get().dependsOn(clientMain)
commonMain.dependencies {
implementation(libs.koin.core)
implementation(projects.core)
api(projects.core.stoptime)
}
androidMain.dependencies {
implementation(libs.koin.compose)
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(projects.core)
implementation(projects.core.sqld)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.client.di
package moe.lava.banksia.core.data
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpSend
@ -7,22 +7,22 @@ import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.plugin
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import moe.lava.banksia.Constants
import moe.lava.banksia.client.data.route.RouteLocalDataSource
import moe.lava.banksia.client.data.route.RouteRemoteDataSource
import moe.lava.banksia.client.data.stop.StopLocalDataSource
import moe.lava.banksia.client.data.stop.StopRemoteDataSource
import moe.lava.banksia.client.data.stoptime.StopTimeLocalDataSource
import moe.lava.banksia.client.data.stoptime.StopTimeRemoteDataSource
import moe.lava.banksia.client.repository.RouteRepository
import moe.lava.banksia.client.repository.StopRepository
import moe.lava.banksia.client.repository.StopTimeRepository
import moe.lava.banksia.core.Constants
import moe.lava.banksia.core.data.repositories.ClientRouteRepository
import moe.lava.banksia.core.data.repositories.ClientStopRepository
import moe.lava.banksia.core.data.repositories.RouteRepository
import moe.lava.banksia.core.data.repositories.StopRepository
import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource
import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource
import moe.lava.banksia.core.data.sources.stop.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource
import moe.lava.banksia.core.util.log
import moe.lava.banksia.data.ptv.PtvService
import moe.lava.banksia.util.log
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val ClientModule = module {
actual val platformModule = module {
// HTTP Clients
singleOf(::PtvService)
single {
@ -49,11 +49,8 @@ val ClientModule = module {
singleOf(::RouteRemoteDataSource)
singleOf(::StopLocalDataSource)
singleOf(::StopRemoteDataSource)
singleOf(::StopTimeLocalDataSource)
singleOf(::StopTimeRemoteDataSource)
// Repositories
singleOf(::RouteRepository)
singleOf(::StopRepository)
singleOf(::StopTimeRepository)
singleOf(::ClientRouteRepository) bind RouteRepository::class
singleOf(::ClientStopRepository) bind StopRepository::class
}

View file

@ -0,0 +1,36 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource
import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.sqld.mappers.asModel
internal class ClientRouteRepository internal constructor(
private val local: RouteLocalDataSource,
private val remote: RouteRemoteDataSource,
) : RouteRepository {
private val mutex = Mutex()
override suspend fun getAll() = mutex.withLock {
local
.getAll()
.map { it.asModel() }
.ifEmpty {
remote
.getAll()
.also { local.save(*it.toTypedArray()) }
}
}
private val tripRouteMap = mutableMapOf<Long, Route>()
override suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
override suspend fun getByPattern(patternId: Long) = mutex.withLock {
tripRouteMap[patternId]
?: remote.getByPattern(patternId).also {
local.save(it)
tripRouteMap[patternId] = it
}
}
}

View file

@ -0,0 +1,23 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.core.data.sources.stop.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource
import moe.lava.banksia.core.sqld.mappers.asModel
internal class ClientStopRepository internal constructor(
private val local: StopLocalDataSource,
private val remote: StopRemoteDataSource,
) : StopRepository {
private val mutex = Mutex()
override suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
override suspend fun getByRoute(id: String) = mutex.withLock {
local
.getByRoute(id)
.map { it.asModel() }
.ifEmpty { null }
?: remote.getByRoute(id)
}
}

View file

@ -0,0 +1,23 @@
package moe.lava.banksia.core.data.sources.route
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.sqld.RouteQueries
import moe.lava.banksia.core.sqld.mappers.asDb
internal class RouteLocalDataSource(private val queries: RouteQueries) {
suspend fun get(id: String) = withContext(Dispatchers.IO) { queries.get(id).executeAsOneOrNull() }
suspend fun getAll() = withContext(Dispatchers.IO) { queries.getAll().executeAsList() }
// suspend fun getByTrip(tripId: String) = dao.getByTrip(tripId)
suspend fun save(vararg routes: Route) {
withContext(Dispatchers.IO) {
queries.transaction {
routes.forEach {
queries.insert(it.asDb())
}
}
}
}
}

View file

@ -0,0 +1,12 @@
package moe.lava.banksia.core.data.sources.route
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import moe.lava.banksia.core.model.Route
internal class RouteRemoteDataSource(val client: HttpClient) {
suspend fun get(id: String) = client.get("routes/${id}").body<Route>()
suspend fun getByPattern(patternId: Long) = client.get("routes/by_pattern/${patternId}").body<Route>()
suspend fun getAll() = client.get("routes").body<List<Route>>()
}

View file

@ -0,0 +1,22 @@
package moe.lava.banksia.core.data.sources.stop
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.sqld.StopQueries
import moe.lava.banksia.core.sqld.mappers.asDb
internal class StopLocalDataSource(private val queries: StopQueries) {
suspend fun get(id: String) = withContext(Dispatchers.IO) { queries.get(id).executeAsOneOrNull() }
suspend fun getByRoute(id: String) = withContext(Dispatchers.IO) { queries.getByRoute(id).executeAsList() }
suspend fun save(vararg stops: Stop) {
withContext(Dispatchers.IO) {
queries.transaction {
stops.forEach {
queries.insert(it.asDb())
}
}
}
}
}

View file

@ -1,11 +1,11 @@
package moe.lava.banksia.client.data.stop
package moe.lava.banksia.core.data.sources.stop
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import moe.lava.banksia.model.Stop
import moe.lava.banksia.core.model.Stop
class StopRemoteDataSource(val client: HttpClient) {
internal class StopRemoteDataSource(val client: HttpClient) {
suspend fun get(id: String) = client.get("stops/${id}").body<Stop>()
suspend fun getByRoute(id: String) = client.get("route_stops/${id}").body<List<Stop>>()
}

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.sqld.sqldDiModule
import org.koin.core.module.Module
import org.koin.dsl.module
internal expect val platformModule: Module
val dataDiModule = module {
includes(platformModule)
includes(sqldDiModule)
includes(stopTimeDataDiModule)
}

View file

@ -0,0 +1,9 @@
package moe.lava.banksia.core.data.repositories
import moe.lava.banksia.core.model.Route
interface RouteRepository {
suspend fun get(id: String): Route?
suspend fun getByPattern(patternId: Long): Route?
suspend fun getAll(): List<Route>
}

View file

@ -0,0 +1,8 @@
package moe.lava.banksia.core.data.repositories
import moe.lava.banksia.core.model.Stop
interface StopRepository {
suspend fun get(id: String): Stop
suspend fun getByRoute(id: String): List<Stop>
}

View file

@ -0,0 +1,7 @@
package moe.lava.banksia.core.data
import org.koin.dsl.module
internal actual val platformModule = module {
}

View file

@ -4,11 +4,12 @@ plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.sqldelight)
}
kotlin {
android {
namespace = "moe.lava.banksia.client"
namespace = "moe.lava.banksia.core.sqld"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
@ -16,28 +17,37 @@ kotlin {
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
androidMain.dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.play.services.location)
implementation(libs.sqldelight.driver.android)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(projects.shared)
implementation(projects.core)
}
nativeMain.dependencies {
implementation(libs.sqldelight.driver.native)
}
jvmMain.dependencies {
implementation(libs.sqldelight.driver.jvm)
}
}
}
sqldelight {
databases {
register("BanksiaDatabase") {
packageName.set("moe.lava.banksia.core.sqld")
schemaOutputDirectory.set(file("src/commonMain/sqldelight/schema"))
}
}
}

View file

@ -0,0 +1,14 @@
package moe.lava.banksia.core.sqld
import android.content.Context
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
actual class DatabaseManager : KoinComponent {
actual val database by lazy {
val ctx = get<Context>().applicationContext
val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "${DBNAME}.db")
BanksiaDatabase(driver)
}
}

View file

@ -0,0 +1,7 @@
package moe.lava.banksia.core.sqld
internal const val DBNAME = "timetable"
expect class DatabaseManager() {
val database: BanksiaDatabase
}

View file

@ -0,0 +1,17 @@
package moe.lava.banksia.core.sqld
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val sqldDiModule = module {
singleOf(::DatabaseManager)
factory { get<DatabaseManager>().database }
factory { get<BanksiaDatabase>().routeQueries }
factory { get<BanksiaDatabase>().serviceQueries }
factory { get<BanksiaDatabase>().serviceExceptionQueries }
factory { get<BanksiaDatabase>().shapeQueries }
factory { get<BanksiaDatabase>().stopQueries }
factory { get<BanksiaDatabase>().stoppingPatternQueries }
factory { get<BanksiaDatabase>().stopTimeQueries }
factory { get<BanksiaDatabase>().tripQueries }
}

View file

@ -0,0 +1,14 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.sqld.Route as DbRoute
fun DbRoute.asModel() = Route(
id = id,
type = RouteType.from(type.toInt()),
number = number,
name = name,
)
fun Route.asDb() = DbRoute(id, type.value.toLong(), number, name)

View file

@ -0,0 +1,21 @@
package moe.lava.banksia.core.sqld.mappers
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.util.deserialiseDaysBitflag
import moe.lava.banksia.core.util.serialise
import moe.lava.banksia.core.sqld.Service as DbService
fun DbService.asModel() = Service(
id = id,
days = days.toInt().deserialiseDaysBitflag(),
start = LocalDate.fromEpochDays(start),
end = LocalDate.fromEpochDays(end),
)
fun Service.asDb() = DbService(
id = id,
days = days.serialise().toLong(),
start = start.toEpochDays(),
end = end.toEpochDays(),
)

View file

@ -0,0 +1,17 @@
package moe.lava.banksia.core.sqld.mappers
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.ServiceException
import moe.lava.banksia.core.sqld.ServiceException as DbServiceException
fun DbServiceException.asModel() = ServiceException(
serviceId = serviceId,
date = LocalDate.fromEpochDays(date),
type = type.toInt(),
)
fun ServiceException.asDb() = DbServiceException(
serviceId = serviceId,
type = date.toEpochDays(),
date = type.toLong(),
)

View file

@ -0,0 +1,52 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.ShapePath
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.core.sqld.Shape as DbShape
fun DbShape.asModel() = Shape(
id = id,
path = bytesToPath(path),
)
fun Shape.asDb() = DbShape(
id = id,
path = bytesFromPath(path),
)
private fun bytesToPath(value: ByteArray): ShapePath {
return value
.asSequence()
.asIterable()
.chunked(8) {
(it[0].toLong() and 0xFF) or
(it[1].toLong() and 0xFF shl 8) or
(it[2].toLong() and 0xFF shl 16) or
(it[3].toLong() and 0xFF shl 24) or
(it[4].toLong() and 0xFF shl 32) or
(it[5].toLong() and 0xFF shl 40) or
(it[6].toLong() and 0xFF shl 48) or
(it[7].toLong() and 0xFF shl 56)
}
.map { Double.fromBits(it) }
.chunked(2)
.map { (lat, lng) -> Point(lat, lng) }
.toList()
}
private fun bytesFromPath(path: ShapePath): ByteArray {
return path
.flatMap { (lat, lng) -> listOf(lat.toBits(), lng.toBits()) }
.flatMap { i -> listOf(
i.toByte(),
(i shr 8).toByte(),
(i shr 16).toByte(),
(i shr 24).toByte(),
(i shr 32).toByte(),
(i shr 40).toByte(),
(i shr 48).toByte(),
(i shr 56).toByte(),
) }
.toByteArray()
}

View file

@ -0,0 +1,26 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.core.sqld.Stop as DbStop
fun DbStop.asModel() = Stop(
id = id,
name = name,
pos = Point(lat, lng),
parent = parent,
hasWheelChairBoarding = hasWheelChairBoarding == 1L,
level = level,
platformCode = platformCode,
)
fun Stop.asDb() = DbStop(
id = id,
name = name,
lat = pos.lat,
lng = pos.lng,
parent = parent,
hasWheelChairBoarding = if (hasWheelChairBoarding) 1L else 0L,
level = level,
platformCode = platformCode
)

View file

@ -0,0 +1,27 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.FutureTime
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.sqld.StopTime as DbStopTime
fun DbStopTime.asModel() = StopTime(
patternId = patternId,
stopId = stopId,
time = TimeType.Undated(
arrival = FutureTime.fromInt((departureTime + arrivalDelta).toInt()),
departure = FutureTime.fromInt(departureTime.toInt()),
),
pickupType = pickupType.toInt(),
dropOffType = dropOffType.toInt(),
)
fun StopTime.Undated.asDb() = DbStopTime(
patternId = patternId,
stopId = stopId,
arrivalDelta = (time.arrival.asInt() - time.departure.asInt()).toLong(),
departureTime = time.departure.asInt().toLong(),
pickupType = pickupType.toLong(),
dropOffType = dropOffType.toLong(),
)

View file

@ -0,0 +1,23 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.sqld.StoppingPattern as DbStoppingPattern
fun <T: TimeType> DbStoppingPattern.asModel(stoptimes: List<StopTime<T>>) = StoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,
headsign = headsign,
wheelchairAccessible = wheelchairAccessible == 1L,
stoptimes = stoptimes,
)
fun StoppingPattern<*>.asDb() = DbStoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,
headsign = headsign,
wheelchairAccessible = if (wheelchairAccessible) 1L else 0L,
)

View file

@ -0,0 +1,27 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.sqld.Trip as DbTrip
fun DbTrip.asModel(pattern: StoppingPattern.Undated, service: Service): Trip.Undated {
if (serviceId != service.id) {
throw IllegalArgumentException("trip and service id mismatch (${serviceId} != ${service.id})")
}
return Trip(
id = gtfsId,
pattern = pattern,
service = service,
directionId = directionId.toInt(),
blockId = blockId.toString(),
)
}
fun Trip.Undated.asDb() = DbTrip(
gtfsId = id,
patternId = pattern.id,
serviceId = service.id,
directionId = directionId.toLong(),
blockId = blockId?.toLong(),
)

View file

@ -0,0 +1,20 @@
CREATE TABLE Route (
id TEXT PRIMARY KEY NOT NULL,
type INTEGER NOT NULL,
number TEXT,
name TEXT NOT NULL
);
getAll:
SELECT * FROM Route;
get:
SELECT * FROM Route WHERE id == ?;
getByPattern:
SELECT Route.* FROM Route
INNER JOIN StoppingPattern ON Route.id == StoppingPattern.routeId
WHERE StoppingPattern.id == :patternId;
insert:
INSERT OR REPLACE INTO Route VALUES ?;

View file

@ -0,0 +1,11 @@
CREATE TABLE Service (
id TEXT PRIMARY KEY NOT NULL,
days INTEGER NOT NULL,
start INTEGER NOT NULL,
end INTEGER NOT NULL
);
CREATE INDEX idx_Service_days ON Service (days);
insert:
INSERT INTO Service VALUES ?;

View file

@ -0,0 +1,9 @@
CREATE TABLE ServiceException (
serviceId TEXT NOT NULL,
type INTEGER NOT NULL,
date INTEGER NOT NULL,
PRIMARY KEY (serviceId, type)
);
insert:
INSERT INTO ServiceException VALUES ?;

View file

@ -0,0 +1,7 @@
CREATE TABLE Shape (
id TEXT PRIMARY KEY NOT NULL,
path BLOB NOT NULL
);
insert:
INSERT INTO Shape VALUES ?;

View file

@ -0,0 +1,54 @@
CREATE TABLE Stop (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
parent TEXT REFERENCES Stop(id),
hasWheelChairBoarding INTEGER NOT NULL,
level TEXT,
platformCode TEXT
);
CREATE INDEX idx_Stop_parent ON Stop (parent);
getAll:
SELECT * FROM Stop;
getAllParentless:
SELECT * FROM Stop WHERE platformCode IS NOT NULL AND parent IS NULL;
get:
SELECT * FROM Stop WHERE id == ?;
getMany:
SELECT * FROM Stop WHERE id IN ?;
insert:
INSERT INTO Stop VALUES ?;
updateParents:
UPDATE Stop SET parent = ? WHERE id IN ?;
getByRoute:
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN StoppingPattern ON StoppingPattern.id == StopTime.patternId
WHERE StoppingPattern.routeId == :id
GROUP BY Stop.id;
-- I vibecoded this, sorry
getParentsByRoute:
WITH RECURSIVE Tree AS (
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN StoppingPattern ON StoppingPattern.id == StopTime.patternId
WHERE StoppingPattern.routeId == :id
GROUP BY Stop.id
UNION ALL
SELECT s.*
FROM Stop s
INNER JOIN Tree t ON s.id = t.parent
)
SELECT DISTINCT * FROM Tree WHERE parent IS NULL;

View file

@ -0,0 +1,45 @@
CREATE TABLE StopTime (
patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
stopId TEXT NOT NULL REFERENCES Stop (id),
arrivalDelta INTEGER NOT NULL,
departureTime INTEGER NOT NULL,
pickupType INTEGER NOT NULL,
dropOffType INTEGER NOT NULL,
PRIMARY KEY (patternId, stopId)
) WITHOUT ROWID;
CREATE INDEX idx_StopTime_stopId ON StopTime (stopId);
insert:
INSERT OR REPLACE INTO StopTime VALUES ?;
getForStopDated:
SELECT DISTINCT StopTime.* FROM StopTime
INNER JOIN Service ON Service.days & :days = :days AND :date BETWEEN Service.start AND Service.`end`
LEFT JOIN ServiceException ON ServiceException.serviceId == Service.id AND ServiceException.date == :date
INNER JOIN Trip ON Trip.serviceId == Service.id
INNER JOIN StoppingPattern ON StoppingPattern.id == Trip.patternId
WHERE StopTime.patternId == StoppingPattern.id
AND StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL;
getExtendedForStop:
SELECT DISTINCT
StopTime.patternId,
StopTime.arrivalDelta,
StopTime.departureTime,
StoppingPattern.headsign,
Route.type AS routeType,
Route.number AS routeNumber,
Route.name AS routeName,
Stop.platformCode AS stopPlatformCode
FROM StopTime
INNER JOIN Service ON Service.days & :days = :days AND :date BETWEEN Service.start AND Service.`end`
LEFT JOIN ServiceException ON ServiceException.serviceId == Service.id AND ServiceException.date == :date
INNER JOIN Trip ON Trip.serviceId == Service.id
INNER JOIN StoppingPattern ON StoppingPattern.id == Trip.patternId
INNER JOIN Route ON Route.id == StoppingPattern.routeId
INNER JOIN Stop ON Stop.id == StopTime.stopId
WHERE StopTime.patternId == StoppingPattern.id
AND StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL;

View file

@ -0,0 +1,13 @@
CREATE TABLE StoppingPattern (
id INTEGER PRIMARY KEY NOT NULL,
routeId TEXT NOT NULL REFERENCES Route (id),
shapeId TEXT NOT NULL REFERENCES Shape (id),
headsign TEXT NOT NULL,
wheelchairAccessible INTEGER NOT NULL
);
insert:
INSERT OR REPLACE INTO StoppingPattern VALUES ?;
get:
SELECT * FROM StoppingPattern WHERE id == :id;

View file

@ -0,0 +1,13 @@
CREATE TABLE Trip (
gtfsId TEXT PRIMARY KEY NOT NULL,
patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
serviceId TEXT NOT NULL REFERENCES Service (id),
blockId INTEGER,
directionId INTEGER NOT NULL
);
CREATE INDEX idx_Trip_patternId ON Trip (patternId);
CREATE INDEX idx_Trip_serviceId ON Trip (serviceId);
insert:
INSERT OR REPLACE INTO Trip VALUES ?;

Binary file not shown.

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import org.koin.core.component.KoinComponent
actual class DatabaseManager : KoinComponent {
actual val database by lazy {
val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "${DBNAME}.db")
BanksiaDatabase(driver)
}
}

View file

@ -0,0 +1,56 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.lava.banksia.core.util.error
import org.koin.core.component.KoinComponent
import java.io.File
import java.util.Properties
import kotlin.system.exitProcess
actual class DatabaseManager : KoinComponent {
private var driver = connect()
actual val database get() = BanksiaDatabase(driver)
private fun connect(path: String = "./data/${DBNAME}.db") =
JdbcSqliteDriver("jdbc:sqlite:${path}", Properties(), BanksiaDatabase.Schema)
.apply { execute(null, "PRAGMA journal_mode = OFF;", 0) }
fun makeAlt() = run {
File("./data/${DBNAME}_alt.db").takeIf { it.exists() }?.delete()
val driver = connect("./data/${DBNAME}_alt.db")
BanksiaDatabase(driver) to { driver.close() }
}
fun swap(scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) {
val live = File("./data/${DBNAME}.db")
val alt = File("./data/${DBNAME}_alt.db")
val old = File("./data/${DBNAME}_old.db")
if (live.takeIf { it.exists() }?.renameTo(old) == false) {
error("DatabaseManager", "Failed to rename database from live to old (${live.absolutePath} -> ${old.absolutePath})")
return
}
if (alt.takeIf { it.exists() }?.renameTo(live) == false) {
error("DatabaseManager", "Failed to rename database from alt to live, trying to undo.. (${alt.absolutePath} -> ${live.absolutePath})")
if (!live.renameTo(old)) {
error("DatabaseManager", "Failed to undo, critical failure, exiting..")
exitProcess(1)
}
return
}
val oldDriver = driver
driver = connect()
scope.launch {
delay(5000)
if (old.takeIf { it.exists() }?.delete() == false) {
error("DatabaseManager", "Failed to unlink old database, stray files! (${old.absolutePath})")
}
oldDriver.close()
}
}
}

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
import android.util.Log

View file

@ -6,7 +6,7 @@ object Constants {
const val opendataKey: String = ""
const val serverUrl: String = "https://banksia.lava.moe/api/"
// TODO
const val devMode: Boolean = false
var devMode: Boolean = false
const val updateKey: String = ""
const val protomapsKey: String = ""
}

View file

@ -0,0 +1,3 @@
package moe.lava.banksia.core.endpoints
object Endpoint

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@ -12,7 +12,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import moe.lava.banksia.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
@Serializable(FutureTimeSerialiser::class)
data class FutureTime(

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@ -13,4 +13,8 @@ enum class RouteType(val value: Int) {
SkyBus(11),
Interstate(10),
;
companion object {
fun from(value: Int) = entries.first { it.value == value }
}
}

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
data class Run(
val ref: String,

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
@Serializable
data class ServiceException(
val serviceId: String,
val date: LocalDate,
val type: Int,
)

View file

@ -1,7 +1,7 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
import moe.lava.banksia.util.Point
import moe.lava.banksia.core.util.Point
typealias ShapePath = List<Point>

View file

@ -1,15 +1,15 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
import moe.lava.banksia.util.Point
import moe.lava.banksia.core.util.Point
@Serializable
data class Stop(
val id: String,
val name: String,
val pos: Point,
val parent: String,
val parent: String?,
val hasWheelChairBoarding: Boolean,
val level: String,
val platformCode: String,
val level: String?,
val platformCode: String?,
)

View file

@ -0,0 +1,45 @@
package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
data class StopTime<T: TimeType>(
val patternId: Long,
val stopId: String,
val time: T,
val pickupType: Int,
val dropOffType: Int,
) {
typealias Dated = StopTime<TimeType.Dated>
typealias Undated = StopTime<TimeType.Undated>
}
@Serializable
sealed class TimeType {
@Serializable
data class Undated(
val arrival: FutureTime,
val departure: FutureTime,
) : TimeType()
@Serializable
data class Dated(
val arrival: LocalDateTime,
val departure: LocalDateTime,
) : TimeType()
}
fun TimeType.Undated.atDate(date: LocalDate) = TimeType.Dated(
arrival = arrival.atDate(date),
departure = departure.atDate(date),
)
fun StopTime<TimeType.Undated>.atDate(date: LocalDate) = StopTime(
patternId = patternId,
stopId = stopId,
time = time.atDate(date),
pickupType = pickupType,
dropOffType = dropOffType,
)

View file

@ -0,0 +1,16 @@
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@Serializable
data class StoppingPattern<T: TimeType>(
val id: Long,
val routeId: String,
val shapeId: String,
val headsign: String,
val wheelchairAccessible: Boolean,
val stoptimes: List<StopTime<T>>,
) {
typealias Dated = StoppingPattern<TimeType.Dated>
typealias Undated = StoppingPattern<TimeType.Undated>
}

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@Serializable
data class Trip<T: TimeType>(
val id: String,
val pattern: StoppingPattern<T>,
val service: Service,
val directionId: Int,
val blockId: String?,
) {
typealias Dated = Trip<TimeType.Dated>
typealias Undated = Trip<TimeType.Undated>
}

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.model
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
/** Wraps an arbitrary value, such that equality checks are forced to be done by reference */
class BoxedValue<T>(val value: T) {

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
import kotlinx.datetime.DayOfWeek

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
fun error(tag: String, throwable: Throwable) = error(tag, "", throwable)
expect fun log(tag: String, msg: String)

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
import kotlinx.serialization.Serializable

View file

@ -16,7 +16,12 @@ import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import moe.lava.banksia.Constants
import moe.lava.banksia.core.Constants
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.util.LoopFlow.Companion.initWith
import moe.lava.banksia.core.util.error
import moe.lava.banksia.core.util.log
import moe.lava.banksia.core.util.loopFlow
import moe.lava.banksia.data.ptv.structures.PtvDeparture
import moe.lava.banksia.data.ptv.structures.PtvDirection
import moe.lava.banksia.data.ptv.structures.PtvRoute
@ -24,11 +29,6 @@ import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.data.ptv.structures.PtvRouteType.Companion.asPtvType
import moe.lava.banksia.data.ptv.structures.PtvRun
import moe.lava.banksia.data.ptv.structures.PtvStop
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.util.LoopFlow.Companion.initWith
import moe.lava.banksia.util.error
import moe.lava.banksia.util.log
import moe.lava.banksia.util.loopFlow
import okio.ByteString.Companion.encodeUtf8
import kotlin.random.Random

View file

@ -2,7 +2,7 @@ package moe.lava.banksia.data.ptv.structures
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.core.model.RouteType
@Serializable
data class PtvRoute(

View file

@ -7,7 +7,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.core.model.RouteType
object PtvRouteTypeSerialiser : KSerializer<PtvRouteType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
actual fun log(tag: String, msg: String) {
TODO("Not yet implemented")

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.util
package moe.lava.banksia.core.util
actual fun log(tag: String, msg: String) {
println("[$tag] $msg")

View file

@ -0,0 +1,64 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.ksp)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.stoptime"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexpect-actual-classes")
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
val clientMain by creating {
dependsOn(commonMain.get())
}
androidMain.get().dependsOn(clientMain)
iosArm64Main.get().dependsOn(clientMain)
iosSimulatorArm64Main.get().dependsOn(clientMain)
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(projects.core)
implementation(projects.core.sqld)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
jvmMain.dependencies {
implementation(libs.koin.ktor)
implementation(libs.ktor.server.core)
}
}
}

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import moe.lava.banksia.core.data.sources.stoptime.StopTimeRemoteDataSource
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal actual val platformModule = module {
singleOf(::StopTimeRepository)
singleOf(::StopTimeRemoteDataSource)
}

View file

@ -0,0 +1,19 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
import moe.lava.banksia.core.data.sources.stoptime.StopTimeRemoteDataSource
actual class StopTimeRepository internal constructor(
private val local: StopTimeLocalDataSource,
private val remote: StopTimeRemoteDataSource,
) {
actual suspend fun getForStop(id: String, date: LocalDate) = flow {
emit(local.getAtStop(id, date))
remote.getAtStop(id, date)
.takeIf { it.isNotEmpty() }
?.let { emit(it) }
}
}

View file

@ -0,0 +1,26 @@
package moe.lava.banksia.core.data.sources.stoptime
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import moe.lava.banksia.core.endpoints.Endpoint
import moe.lava.banksia.core.endpoints.stopTimeByStop
import kotlin.time.Clock
internal class StopTimeRemoteDataSource(
private val client: HttpClient,
) {
suspend fun getAtStop(
stopId: String,
date: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<ExtendedStopTime> {
return client.get(Endpoint.stopTimeByStop(stopId)) {
parameter("date", date)
}.body<List<ExtendedStopTime>>()
}
}

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal expect val platformModule: Module;
val stopTimeDataDiModule = module {
includes(platformModule)
singleOf(::StopTimeLocalDataSource)
}

View file

@ -0,0 +1,34 @@
package moe.lava.banksia.core.data.dto
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import moe.lava.banksia.core.model.FutureTime
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.model.atDate
import moe.lava.banksia.core.sqld.GetExtendedForStop
@Serializable
data class ExtendedStopTime(
val patternId: Long,
val stopPlatformCode: String?,
val time: TimeType.Dated,
val headsign: String?,
val routeType: RouteType,
val routeNumber: String?,
val routeName: String,
)
// TODO: This probably doesn't belong here
fun GetExtendedForStop.asModel(date: LocalDate) = ExtendedStopTime(
patternId = patternId,
stopPlatformCode = stopPlatformCode,
time = TimeType.Undated(
arrival = FutureTime.fromInt((departureTime + arrivalDelta).toInt()),
departure = FutureTime.fromInt(departureTime.toInt()),
).atDate(date),
headsign = headsign,
routeType = RouteType.from(routeType.toInt()),
routeNumber = routeNumber,
routeName = routeName,
)

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import kotlin.time.Clock
expect class StopTimeRepository {
suspend fun getForStop(
id: String,
date: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): Flow<List<ExtendedStopTime>>
}

View file

@ -0,0 +1,30 @@
package moe.lava.banksia.core.data.sources.stoptime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import moe.lava.banksia.core.data.dto.asModel
import moe.lava.banksia.core.sqld.StopTimeQueries
import moe.lava.banksia.core.util.serialise
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
internal class StopTimeLocalDataSource : KoinComponent {
private val queries get() = get<StopTimeQueries>()
suspend fun getAtStop(stopId: String, date: LocalDate): List<ExtendedStopTime> {
return withContext(context = Dispatchers.IO) {
queries
.getExtendedForStop(
listOf(date.dayOfWeek).serialise().toLong(),
date.toEpochDays(),
stopId,
)
.executeAsList()
.map { it.asModel(date) }
.sortedBy { it.time.departure }
}
}
}

View file

@ -0,0 +1,3 @@
package moe.lava.banksia.core.endpoints
fun Endpoint.stopTimeByStop(stopId: String) = "stoptimes/by_stop/${stopId}"

View file

@ -0,0 +1,9 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal actual val platformModule = module {
singleOf(::StopTimeRepository)
}

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
actual class StopTimeRepository internal constructor(
private val local: StopTimeLocalDataSource,
) {
actual suspend fun getForStop(id: String, date: LocalDate) = flow {
emit(local.getAtStop(id, date))
}
}

View file

@ -0,0 +1,27 @@
package moe.lava.banksia.server.routes
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import moe.lava.banksia.core.endpoints.Endpoint
import moe.lava.banksia.core.endpoints.stopTimeByStop
import org.koin.ktor.ext.inject
import kotlin.time.Clock
fun Route.stopTimeRoutes() {
val repo by inject<StopTimeRepository>()
get(Endpoint.stopTimeByStop("{stop_id}")) {
val stopId = call.parameters["stop_id"]!!
val date = call.queryParameters["date"]
?.let { LocalDate.parse(it, LocalDate.Formats.ISO) }
?: Clock.System.todayIn(TimeZone.currentSystemDefault())
val data = repo.getForStop(stopId, date).first()
call.respond(data)
}
}

View file

@ -1,40 +1,36 @@
[versions]
agp = "9.1.0"
android-compileSdk = "36"
android-compileSdk = "37"
android-minSdk = "24"
android-targetSdk = "36"
androidx-activityCompose = "1.12.4"
androidx-appcompat = "1.7.0"
androidx-constraintlayout = "2.2.1"
androidx-core-ktx = "1.15.0"
androidx-espresso-core = "3.6.1"
androidx-lifecycle = "2.9.6"
androidx-material = "1.12.0"
androidx-test-junit = "1.2.1"
compose-multiplatform = "1.11.0-alpha02"
android-targetSdk = "37"
androidx-activity= "1.13.0"
androidx-lifecycle = "2.10.0"
compose-multiplatform = "1.12.0-alpha02"
composeunstyled = "1.49.6"
coroutines = "1.10.2"
geo = "0.8.0"
junit = "4.13.2"
koin = "4.1.1"
kotlin = "2.3.10"
koin = "4.2.0"
kotlin = "2.3.20"
kotlinxDatetime = "0.7.1"
kotlinxSerializationCsv = "0.2.18"
kotlinxSerialization = "1.10.0"
ksp = "2.3.4"
ktor = "3.4.0"
ktor = "3.4.1"
logback = "1.5.32"
maplibre = "0.12.1"
material = "1.7.3"
material3 = "1.11.0-alpha02"
okio = "3.16.4"
material3 = "1.11.0-alpha07"
okio = "3.17.0"
playServicesLocation = "21.3.0"
sqlite = "2.6.2"
room = "2.8.4"
secretsGradlePlugin = "2.0.1"
wire = "5.5.0"
sqldelight = "2.3.2"
wire = "6.1.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material" }
@ -44,18 +40,11 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu
compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" }
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
composeunstyled = { module = "com.composables:composeunstyled", version.ref = "composeunstyled" }
moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" }
moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
@ -73,14 +62,14 @@ ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "k
ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre" }
moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" }
moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
room-common = { group = "androidx.room", name = "room-common", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" }
secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" }
[plugins]
@ -93,6 +82,6 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
room = { id = "androidx.room", version.ref = "room" }
secretsGradle = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
wire = { id = "com.squareup.wire", version.ref = "wire" }

View file

@ -5,15 +5,27 @@ plugins {
application
}
group = "moe.lava.banksia"
group = "moe.lava.banksia.server"
version = "1.0.0"
application {
mainClass.set("moe.lava.banksia.server.ApplicationKt")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=${extra["io.ktor.development"] ?: "false"}")
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexplicit-backing-fields")
}
}
dependencies {
implementation(projects.shared)
implementation(projects.core)
implementation(projects.core.data)
implementation(projects.core.sqld)
implementation(projects.core.stoptime)
implementation(projects.server.gtfs)
implementation(projects.server.gtfsRt)
implementation(libs.logback)
implementation(libs.koin.core)
implementation(libs.koin.ktor)
@ -26,8 +38,6 @@ dependencies {
implementation(libs.ktor.server.contentnegotiation)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.room.runtime)
implementation(libs.sqlite.bundled)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.kotlin.test.junit)
}

View file

@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexplicit-backing-fields")
}
}
dependencies {
implementation(projects.core)
implementation(libs.kotlinx.serialization.csv)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
}

View file

@ -0,0 +1,388 @@
package moe.lava.banksia.server.gtfs
import com.lightningkite.kotlinx.serialization.csv.CsvFormat
import com.lightningkite.kotlinx.serialization.csv.StringDeferringConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.util.cio.writeChannel
import io.ktor.util.logging.Logger
import io.ktor.utils.io.copyAndClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.modules.EmptySerializersModule
import kotlinx.serialization.serializer
import moe.lava.banksia.core.Constants
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.model.ServiceException
import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.server.gtfs.structures.GtfsRoute
import moe.lava.banksia.server.gtfs.structures.GtfsService
import moe.lava.banksia.server.gtfs.structures.GtfsServiceException
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 java.io.File
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.zip.ZipFile
import kotlin.time.ExperimentalTime
private typealias StopWithSource = Pair<String, Stop>
sealed class GtfsData {
data class RouteChunk(val routes: List<Route>) : GtfsData()
data class ServiceChunk(val services: List<Service>) : GtfsData()
data class ServiceExceptionChunk(val exceptions: List<ServiceException>) : GtfsData()
data class ShapeChunk(val shapes: List<Shape>) : GtfsData()
data class StopChunk(val stops: List<Stop>) : GtfsData()
data class TripChunk(val trips: List<Trip.Undated>) : GtfsData()
}
class GtfsParser(
private val log: Logger,
private val client: HttpClient,
) {
private val csv = CsvFormat(StringDeferringConfig(EmptySerializersModule()))
private val datasetPath = File("/tmp/banksia", "dataset.zip")
@OptIn(ExperimentalTime::class)
suspend fun update(datasetUrl: String): Flow<GtfsData> {
val parentDir = datasetPath.parentFile
@Suppress("SimplifyBooleanWithConstants", "KotlinConstantConditions")
if (parentDir.exists() && !Constants.devMode)
parentDir.deleteRecursively()
parentDir.mkdirs()
log.info("fetching..")
client.prepareRequest {
url(datasetUrl)
}.execute { resp ->
if (!datasetPath.exists())
resp.bodyAsChannel().copyAndClose(datasetPath.writeChannel())
log.info("fetched!")
}
log.info("extracting...")
@Suppress("KotlinConstantConditions")
val files = if (Constants.devMode) {
datasetPath.parentFile
.listFiles { it.isDirectory }
.flatMap { d -> d.listFiles { f -> f.extension == "txt" }.toList() }
.ifEmpty { extractAll(datasetPath) }
// .filter { it.parentFile.name == "2" }
} else {
extractAll(datasetPath)
}
log.info("parsing...")
return parse(files)
.onCompletion {
@Suppress("KotlinConstantConditions")
if (!Constants.devMode) {
parentDir.deleteRecursively()
}
log.info("done!")
}
}
private fun parse(files: List<File>) = flow {
files
.filter { it.name == "routes.txt" }
.forEach { emit(GtfsData.RouteChunk(parseRoutes(it))) }
files
.filter { it.name == "stops.txt" }
.flatMap { parseStops(it) }
.let { emit(GtfsData.StopChunk(fixupDuplicateStops(it))) }
files
.filter { it.name == "shapes.txt" }
.forEach { emit(GtfsData.ShapeChunk(parseShapes(it))) }
val services = files
.filter { it.name == "calendar.txt" }
.flatMap { fd ->
parseServices(fd)
.also { emit(GtfsData.ServiceChunk(it)) }
}
.associateBy { it.id }
files
.filter { it.name == "calendar_dates.txt" }
.forEach { emit(GtfsData.ServiceExceptionChunk(parseServiceExceptions(it))) }
val trips = files
.filter { it.name == "trips.txt" }
.flatMap { fd ->
parseTrips(fd, services)
}
.associateBy { it.id }
files
.filter { it.name == "stop_times.txt" }
.forEach { fd ->
log.info("parsing stop times for ${fd.parent}...")
parseStopTimes(fd) { seq ->
val times = ArrayList<Pair<String, StopTime.Undated>>(1000100)
seq.forEach { pair ->
val (_, stoptime) = pair
if (times.size > 1000000 && stoptime.patternId == 1L) {
emit(GtfsData.TripChunk(processStoptimes(trips, times)))
times.clear()
}
times.add(pair)
}
emit(GtfsData.TripChunk(processStoptimes(trips, times)))
}
}
}
private fun hashCalc(headsign: String, stops: List<StopTime.Undated>): Long {
val inst = MessageDigest.getInstance("SHA-256")
inst.update(headsign.toByteArray())
stops.forEach {
inst.update(it.stopId.toByteArray())
val dint = it.time.departure.asInt()
inst.update((dint).toByte())
inst.update((dint shr 8).toByte())
val aint = it.time.arrival.asInt()
inst.update((aint).toByte())
inst.update((aint shr 8).toByte())
}
val buf = inst.digest().slice(0..7).toByteArray()
buf[0] = 0
buf[1] = 0
return ByteBuffer.wrap(buf).long
}
private fun processStoptimes(trips: Map<String, Trip.Undated>, times: ArrayList<Pair<String, StopTime.Undated>>) =
times.groupBy { it.first }
.map { (tripId, pairs) ->
val trip = trips[tripId]!!
val stoptimes = pairs.map { it.second }
val hash = hashCalc(trip.pattern.headsign, stoptimes)
trip.copy(pattern = trip.pattern.copy(
id = hash,
stoptimes = stoptimes.map { it.copy(patternId = hash) }
))
}
private fun parseRoutes(fd: File) =
fd.parseCsv<GtfsRoute>()
.map { with(it) {
Route(
id = route_id,
type = RouteType.from(fd.parentFile.name.toInt()),
number = route_short_name,
name = route_long_name,
)
} }
private fun parseShapes(fd: File) =
fd.parseCsv<GtfsShape>()
.groupBy { it.shape_id }
.map { (id, group) ->
val points = group
.sortedBy { it.shape_pt_sequence }
.map { Point(it.shape_pt_lat, it.shape_pt_lon) }
Shape(id, points)
}
private fun parseStops(fd: File): List<StopWithSource> =
fd.parseCsv<GtfsStop>()
.map { with(it) {
fd.parentFile.name to Stop(
id = stop_id,
name = stop_name,
pos = Point(stop_lat, stop_lon),
parent = parent_station.ifEmpty { null },
hasWheelChairBoarding = wheelchair_boarding == "1",
level = level_id.ifEmpty { null },
platformCode = platform_code.ifEmpty { null },
)
} }
private inline fun parseStopTimes(fd: File, block: (Sequence<Pair<String, StopTime.Undated>>) -> Unit) =
fd.parseCsvSequence<GtfsStopTime> { seq ->
seq
.map { with(it) {
it.trip_id to StopTime(
patternId = stop_sequence,
stopId = stop_id,
time = TimeType.Undated(
arrival = GtfsStopTime.parseGtfsTime(arrival_time),
departure = GtfsStopTime.parseGtfsTime(departure_time),
),
pickupType = pickup_type,
dropOffType = drop_off_type,
)
} }
.let { block(it) }
}
private fun parseServices(fd: File) =
fd.parseCsv<GtfsService>()
.map { with(it) {
val days = buildList {
if (monday == 1) add(DayOfWeek.MONDAY)
if (tuesday == 1) add(DayOfWeek.TUESDAY)
if (wednesday == 1) add(DayOfWeek.WEDNESDAY)
if (thursday == 1) add(DayOfWeek.THURSDAY)
if (friday == 1) add(DayOfWeek.FRIDAY)
if (saturday == 1) add(DayOfWeek.SATURDAY)
if (sunday == 1) add(DayOfWeek.SUNDAY)
}
Service(
id = "${fd.parentFile.name}_${service_id}",
days = days,
start = LocalDate.parse(start_date, LocalDate.Formats.ISO_BASIC),
end = LocalDate.parse(end_date, LocalDate.Formats.ISO_BASIC),
)
} }
private fun parseServiceExceptions(fd: File) =
fd.parseCsv<GtfsServiceException>()
.map { with(it) {
ServiceException(
serviceId = "${fd.parentFile.name}_${service_id}",
date = LocalDate.parse(date, LocalDate.Formats.ISO_BASIC),
type = exception_type,
)
} }
private fun parseTrips(fd: File, services: Map<String, Service>) =
fd.parseCsv<GtfsTrip>()
.map { with(it) {
Trip.Undated(
id = trip_id,
pattern = StoppingPattern(
id = 0,
routeId = route_id,
shapeId = shape_id,
headsign = trip_headsign,
wheelchairAccessible = wheelchair_accessible == "1",
stoptimes = listOf()
),
service = services["${fd.parentFile.name}_${service_id}"]!!,
directionId = direction_id.toInt(),
blockId = block_id.ifEmpty { null },
)
} }
private fun extract(fd: File): List<File> {
val outputs = mutableListOf<File>()
ZipFile(fd).use { zip ->
for (entry in zip.entries()) {
zip.getInputStream(entry).use { input ->
val out = File(fd.parentFile, entry.name)
out.parentFile.mkdirs()
out.outputStream().use { output ->
input.copyTo(output)
}
outputs.add(out)
}
}
}
return outputs
}
private fun extractAll(fd: File) = extract(fd).flatMap(::extract)
private inline fun <reified T> File.parseCsv(): List<T> = this
.readText()
.replace("\uFEFF", "") // remove bom
.replace("\r\n", "\n") // crlf -> lf
.let { csv.decodeFromString(it) }
private inline fun <reified T> File.parseCsvSequence(block: (Sequence<T>) -> Unit) = this
.bufferedReader()
.use { reader ->
val iter = object : CharIterator() {
var next: Char? = null
override fun nextChar(): Char {
if (!hasNext()) {
throw NoSuchElementException()
}
val ret = next!!
next = null
return ret
}
override fun hasNext(): Boolean {
if (next == null) {
do {
next = null
val new = reader.read()
if (new != -1) {
next = new.toChar()
}
} while (next == '\uFEFF' || next == '\r')
}
return next != null
}
}
block(csv.decodeToSequence(iter, csv.serializersModule.serializer()))
}
// Type priority used to resolve duplicates, preferring the first one in the chain
private val typePriorityRanking = listOf(
RouteType.MetroTrain,
RouteType.RegionalTrain,
RouteType.MetroTram,
RouteType.MetroBus,
RouteType.RegionalBus,
RouteType.SkyBus,
).map { it.value.toString() }
@Suppress("LoggingStringTemplateAsArgument") // ?
private fun fixupDuplicateStops(stops: List<StopWithSource>): List<Stop> {
return stops
.groupBy { (_, stops) -> stops.id }
.map { (id, stops) ->
// Just return it if no duplicate
if (stops.size == 1) return@map stops[0].second
// Just return the first one if all the stops' data match
if (stops.withIndex().all { (idx, stop) -> idx == 0 || stop.second == stops[idx - 1].second })
return@map stops[0].second
// Find first stop ordered by the types
val res = typePriorityRanking
.firstNotNullOfOrNull { type ->
stops.find { it.first == type }
}
val (_, stop) = if (res == null) {
log.warn("Cannot resolve duplicate stop ${id}, using first one")
stops.forEach { (type, stop) -> log.warn(" - ($type): $stop") }
stops[0]
} else {
log.debug("Resolving $id for type ${res.first}")
stops.forEach { (type, stop) -> log.debug("${if (res.first == type) "*" else " "} - ($type): $stop") }
res
}
stop
}
}
}

View file

@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsRoute(
internal data class GtfsRoute(
val route_id: String,
val agency_id: String,
val route_short_name: String,

View file

@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsService(
internal data class GtfsService(
val service_id: String,
val monday: Int,
val tuesday: Int,

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
internal data class GtfsServiceException(
val service_id: String,
val date: String,
val exception_type: Int,
)

View file

@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsShape(
internal data class GtfsShape(
val shape_id: String,
val shape_pt_lat: Double,
val shape_pt_lon: Double,

View file

@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsStop(
internal data class GtfsStop(
val stop_id: String,
val stop_name: String,
val stop_lat: Double,

View file

@ -1,16 +1,16 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
import moe.lava.banksia.model.FutureTime
import moe.lava.banksia.core.model.FutureTime
@Suppress("PropertyName")
@Serializable
data class GtfsStopTime(
internal 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_sequence: Long,
val stop_headsign: String,
val pickup_type: Int,
val drop_off_type: Int,

Some files were not shown because too many files have changed in this diff Show more