Last active
August 21, 2025 03:45
-
-
Save hikaMaeng/7aad4a8410f76ab3d9b6b56e4722b709 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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