Skip to content

Instantly share code, notes, and snippets.

@hikaMaeng
Last active August 21, 2025 03:45
Show Gist options
  • Save hikaMaeng/7aad4a8410f76ab3d9b6b56e4722b709 to your computer and use it in GitHub Desktop.
Save hikaMaeng/7aad4a8410f76ab3d9b6b56e4722b709 to your computer and use it in GitHub Desktop.
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import java.net.URL
import java.nio.charset.StandardCharsets
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.LocalDateTime
import java.time.Month
import java.time.ZoneOffset
import java.util.Locale
buildscript {
repositories {
mavenCentral()
}
dependencies {
// tar.gz 압축 해제를 위한 라이브러리
classpath("org.apache.commons:commons-compress:1.21")
}
}
// =====================================================================================
// Helper 함수들
// =====================================================================================
private fun toEnumName(id: String) = id.replace(Regex("[^A-Za-z0-9_]"), "_").uppercase(Locale.ROOT)
private fun parseLatLon(raw: String): Pair<Double, Double> {
fun parsePart(s: String, isLat: Boolean): Double {
val sign = if (s.first() == '-') -1.0 else 1.0
val digits = s.substring(1)
val degLen = if (isLat) 2 else 3
val deg = digits.substring(0, degLen).toDouble()
val rest = digits.substring(degLen)
val (min, sec) = when (rest.length) {
0 -> 0.0 to 0.0
2 -> rest.toDouble() to 0.0
4 -> rest.substring(0, 2).toDouble() to rest.substring(2).toDouble()
else -> 0.0 to 0.0
}
return sign * (deg + min / 60.0 + sec / 3600.0)
}
val splitIndex = raw.indexOf('+', 1).takeIf { it != -1 } ?: raw.indexOf('-', 1)
if (splitIndex == -1) return 0.0 to 0.0
val latStr = raw.substring(0, splitIndex)
val lonStr = raw.substring(splitIndex)
return "%.6f".format(parsePart(latStr, true)).toDouble() to "%.6f".format(parsePart(lonStr, false)).toDouble()
}
private fun offsetStringToSeconds(offsetStr: String): Int {
if (offsetStr == "0" || offsetStr == "-") return 0
val sign = if (offsetStr.startsWith('-')) -1 else 1
val parts = offsetStr.removePrefix("-").split(':').map { it.toDouble() }
var seconds = 0.0
if (parts.isNotEmpty()) seconds += parts[0] * 3600
if (parts.size > 1) seconds += parts[1] * 60
if (parts.size > 2) seconds += parts[2]
return (seconds * sign).toInt()
}
private val MONTH_MAP = Month.values().associateBy { it.name.substring(0, 3).uppercase(Locale.ROOT) }
private fun untilStringToEpochSeconds(untilStr: String?, currentOffset: Int): Long {
if (untilStr == null || untilStr.isEmpty()) return Long.MAX_VALUE
val parts = untilStr.split(Regex("\\s+"))
val year = parts.getOrNull(0)?.toIntOrNull() ?: return Long.MAX_VALUE
val month = MONTH_MAP[parts.getOrNull(1)?.uppercase(Locale.ROOT)] ?: Month.JANUARY
val day = parts.getOrNull(2)?.toIntOrNull() ?: 1
val time = parts.getOrNull(3) ?: "00:00:00"
val timeParts = time.split(':').map { it.toIntOrNull() ?: 0 }
val hour = timeParts.getOrNull(0) ?: 0
val minute = timeParts.getOrNull(1) ?: 0
val second = timeParts.getOrNull(2) ?: 0
return try {
LocalDateTime.of(year, month, day, hour, minute, second).toEpochSecond(ZoneOffset.ofTotalSeconds(currentOffset))
} catch (_: Exception) { Long.MAX_VALUE }
}
private fun untarWithCommons(tarGzStream: InputStream, destDir: File) {
TarArchiveInputStream(GzipCompressorInputStream(BufferedInputStream(tarGzStream))).use { tarIn ->
var entry = tarIn.nextTarEntry
while (entry != null) {
val destPath = File(destDir, entry.name)
if (!entry.isDirectory) {
destPath.parentFile.mkdirs()
Files.copy(tarIn, destPath.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
entry = tarIn.nextTarEntry
}
}
}
// =====================================================================================
// 메인 Gradle Task
// =====================================================================================
tasks.register("generateFullTimezoneData") {
description = "Generates complete, platform-independent timezone data from IANA db."
group = "generation"
val baseDir = project.file("kore/koreCommon/src/commonMain/kotlin/kore/time")
val countryCodeFile = project.file("$baseDir/CountryCode.kt")
val zoneEnumFile = project.file("$baseDir/Zone.kt")
outputs.files(countryCodeFile, zoneEnumFile)
doLast {
val logger = { msg: String -> println(msg) }
logger("============================================================")
logger("▶️ Starting 'generateFullTimezoneData' task...")
logger("============================================================")
try {
// --- 1. 데이터 준비 (다운로드 및 압축 해제) ---
logger("\n[Step 1] Preparing IANA Data...")
val localTarball = project.file("tzdata2024a.tar.gz")
if (!localTarball.exists()) {
logger(" Local file not found. Downloading...")
URL("https://data.iana.org/time-zones/releases/tzdata2024a.tar.gz").openStream().use {
Files.copy(it, localTarball.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
}
val tzdbDir = File(project.buildDir, "tzdb_full")
untarWithCommons(localTarball.inputStream(), tzdbDir)
// --- 2. 데이터 파싱 ---
logger("\n[Step 2] Parsing IANA Data Files with comment stripping...")
data class RawZoneHistory(val stdOffset: String, val rules: String, val format: String, val until: String?)
val allZones = mutableMapOf<String, MutableList<RawZoneHistory>>()
val allLinks = mutableMapOf<String, String>()
val tzdbFileNames = listOf("africa", "antarctica", "asia", "australasia", "europe", "northamerica", "southamerica", "etcetera", "backward")
var currentZoneName: String? = null
tzdbFileNames.forEach { fileName ->
File(tzdbDir, fileName).readLines().forEach { rawLine ->
// [핵심 수정] 라인을 처리하기 전에 주석을 먼저 제거합니다.
val line = rawLine.substringBefore('#').trim()
if (line.isBlank()) return@forEach // 주석만 있거나 빈 줄은 건너뜁니다.
if (rawLine.first().isWhitespace()) { // 원본 라인의 들여쓰기 여부로 연속 줄 판단
currentZoneName?.let {
val p = line.split(Regex("\\s+"), 4)
if(p.size >= 3) allZones.getOrPut(it) { mutableListOf() }.add(RawZoneHistory(p[0], p[1], p[2], p.getOrNull(3)))
}
} else {
currentZoneName = null
val p = line.split(Regex("\\s+"), 2)
if (p.size < 2) return@forEach
when(p[0]) {
"Zone" -> {
val zp = p[1].split(Regex("\\s+"), 4)
if (zp.size >= 4) {
currentZoneName = zp[0]
allZones.getOrPut(currentZoneName!!) { mutableListOf() }.add(RawZoneHistory(zp[1], zp[2], zp[3], zp.getOrNull(4)))
}
}
"Link" -> {
val lp = p[1].split(Regex("\\s+"))
if (lp.size >= 2) allLinks[lp[1]] = lp[0]
}
}
}
}
}
val locations = URL("https://data.iana.org/time-zones/tzdb-2024a/zone1970.tab").readText().lines()
.map { it.substringBefore('#').trim() }
.filterNot { it.isBlank()}
.mapNotNull { val p = it.split('\t'); if(p.size<3) null else p[2] to (p[0] to p[1]) }
.toMap()
logger(" => Success. Parsed ${allZones.size} zones, ${allLinks.size} links, ${locations.size} locations.")
logger(" Parsing country names from iso3166.tab...")
val countryNames = URL("https://data.iana.org/time-zones/tzdb-2024a/iso3166.tab").readText().lines()
.map { it.substringBefore('#').trim() }
.filterNot { it.isBlank() }
.mapNotNull {
val parts = it.split('\t')
if (parts.size >= 2) parts[0] to parts[1] else null
}
.toMap()
logger(" => Success. Parsed ${countryNames.size} country names.")
// --- 3. CountryCode.kt 파일 생성 ---
logger("\n[Step 3] Generating CountryCode.kt...")
val countryCodes = locations.values.map { it.first.split(',').first() }.toSortedSet()
countryCodeFile.writeText(buildString {
appendLine("package kore.time\n")
appendLine("enum class CountryCode(val code:String, val fullName:String) {")
countryCodes.forEach {code ->
val name = countryNames[code]?.replace("\"", "\\\"") ?: code
appendLine(" $code(\"$code\", \"$name\"),")
}
if (isNotEmpty() && lastIndexOf(',') != -1) replace(lastIndexOf(','), lastIndexOf(',') + 1, ";")
appendLine("\n companion object {")
appendLine(" private var codeMap:Map<String, CountryCode>? = null")
appendLine(" private var countryMap:Map<String, CountryCode>? = null")
appendLine(" fun fromCode(code: String): CountryCode?")
appendLine(" = (codeMap ?: entries.associateBy{it.code}.also{codeMap = it})[code]")
appendLine(" fun fromCountry(fullName: String): CountryCode?")
appendLine(" = (countryMap ?: entries.associateBy{ it.fullName.lowercase() }.also{countryMap = it})[fullName.lowercase()]")
appendLine(" }")
appendLine("}")
})
// --- 4. ZoneDataStructures.kt 파일 생성 ---
logger("\n[Step 4] Generating ZoneDataStructures.kt...")
// --- 5. Zone.kt 파일 생성 ---
logger("\n[Step 5] Generating Zone.kt with full period data...")
zoneEnumFile.writeText(buildString {
appendLine("package kore.time\n")
appendLine("enum class Zone(val info: ZoneInfo) {")
(allZones.keys + allLinks.keys).toSortedSet().forEach { zoneName ->
val targetZone = allLinks[zoneName] ?: zoneName
val loc = locations[targetZone]
val history = allZones[targetZone]
if (loc == null || history == null) return@forEach
val (country, coords) = loc
val (lat, lon) = parseLatLon(coords)
val currentOffset = offsetStringToSeconds(history.last().stdOffset)
// Period 생성 로직
val periodsConstructor = buildString {
append("listOf(")
if (history.isNotEmpty()) {
history.forEach { entry ->
val stdOffset = offsetStringToSeconds(entry.stdOffset)
val until = untilStringToEpochSeconds(entry.until, stdOffset)
val dstSave = if (entry.rules.contains(":")) offsetStringToSeconds(entry.rules) else 0
append("\n ZonePeriod($stdOffset, $dstSave, \"${entry.format}\", ${until}L),")
}
deleteCharAt(length - 1)
}
append("\n )")
}
appendLine(" ${toEnumName(zoneName)}(ZoneInfo(\"$zoneName\",CountryCode.${country.split(',').first()},$lat,$lon,$currentOffset,")
appendLine(" $periodsConstructor")
appendLine(" )),")
}
if (toString().trimEnd().endsWith(',')) replace(lastIndexOf(','), lastIndexOf(',') + 1, ";")
appendLine(" val area get() = info.area")
appendLine(" val countryCode get() = info.countryCode")
appendLine(" val lat get() = info.lat")
appendLine(" val lon get() = info.lon")
appendLine(" val utcOffset get() = info.utcOffset")
appendLine("}")
})
logger(" => Success. Final Zone.kt generated.")
logger("\n============================================================")
logger("✅ Task 'generateFullTimezoneData' finished successfully.")
logger("============================================================")
} catch (e: Exception) {
project.logger.error("❌ Task failed.", e)
throw e
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment