@@ -129,17 +129,17 @@ tasks.processResources {
129129// ./gradlew generateLanguageChangelog
130130//
131131// It compares the current project version against the latest published GitHub
132- // release. If the build is newer it prepends a new skeleton entry to
133- // core/src/main/resources/language/CHANGELOG.txt – the human-readable file
134- // that is extracted to plugins/SmartSpawner/ language/CHANGELOG.txt on every
135- // server start so admins know which language keys changed .
132+ // release. If the build is newer it fetches the old en_US language files from
133+ // GitHub, diffs their keys against the local ones, and prepends an entry to
134+ // core/src/main/resources/ language/CHANGELOG.txt showing exactly which keys
135+ // were ADDED, CHANGED, or REMOVED .
136136//
137137// The task is skipped gracefully when GitHub is unreachable (offline / CI
138138// without network access).
139139// ─────────────────────────────────────────────────────────────────────────────
140140tasks.register(" generateLanguageChangelog" ) {
141141 group = " documentation"
142- description = " Prepends a new skeleton entry to language/CHANGELOG.txt when the build version exceeds the latest GitHub release."
142+ description = " Diffs en_US language keys vs the latest GitHub release and prepends a changelog entry ."
143143
144144 val changelogFile = project.file(" src/main/resources/language/CHANGELOG.txt" )
145145
@@ -148,6 +148,8 @@ tasks.register("generateLanguageChangelog") {
148148
149149 doLast {
150150 val currentVersion = project.version.toString()
151+ val locale = " en_US"
152+ val langFiles = listOf (" messages.yml" , " gui.yml" , " items.yml" , " formatting.yml" , " command_messages.yml" )
151153
152154 // ── 1. Fetch latest GitHub release tag ───────────────────────────────
153155 val githubVersion: String = try {
@@ -198,28 +200,172 @@ tasks.register("generateLanguageChangelog") {
198200 return @doLast
199201 }
200202
201- // ── 4. Build plain-text entry (matches CHANGELOG.txt style) ──────────
202- val today = LocalDate .now().toString()
203+ // ── 3b. Resolve the "to" ref: use the version tag if it exists, else main ──
204+ // Tags in this repo have no "v" prefix (e.g. "1.6.2"), so we check
205+ // both the bare version and the v-prefixed form.
206+ val toRef: String = run {
207+ listOf (currentVersion, " v$currentVersion " ).firstOrNull { tag ->
208+ try {
209+ val tagConn = URI .create(
210+ " https://api.github.com/repos/NighterDevelopment/SmartSpawner/git/refs/tags/$tag "
211+ ).toURL().openConnection() as java.net.HttpURLConnection
212+ tagConn.requestMethod = " GET"
213+ tagConn.setRequestProperty(" Accept" , " application/vnd.github.v3+json" )
214+ tagConn.setRequestProperty(" User-Agent" , " SmartSpawner-Changelog-Bot/1.0" )
215+ tagConn.connectTimeout = 6_000
216+ tagConn.readTimeout = 6_000
217+ tagConn.responseCode == 200
218+ } catch (e: Exception ) { false }
219+ } ? : " main"
220+ }
221+ println (" [changelog] Compare range: $githubVersion ...$toRef " )
222+
223+ // ── 4. YAML flat-key extractor ───────────────────────────────────────
224+ // Returns map of dotPath → Pair(1-based lineNumber, rawValue)
225+ // Handles comments, blank lines, list items, and block scalars.
226+ fun extractKeys (yaml : String ): LinkedHashMap <String , Pair <Int , String >> {
227+ val result = LinkedHashMap <String , Pair <Int , String >>()
228+ // stack entries: indent-level → key-name
229+ val stack = ArrayDeque <Pair <Int , String >>()
230+ var blockScalarIndent = - 1 // >=0 while inside a | or > scalar
231+
232+ yaml.lines().forEachIndexed { idx, raw ->
233+ val lineNo = idx + 1
234+ val trimmed = raw.trimStart()
235+ if (trimmed.isEmpty() || trimmed.startsWith(' #' )) return @forEachIndexed
236+
237+ val indent = raw.length - trimmed.length
238+
239+ // Leaving a block scalar when indentation returns to its level
240+ if (blockScalarIndent >= 0 ) {
241+ if (indent > blockScalarIndent) return @forEachIndexed
242+ else blockScalarIndent = - 1
243+ }
244+
245+ // List items are not individually tracked as keys
246+ if (trimmed.startsWith(" - " ) || trimmed == " -" ) return @forEachIndexed
247+
248+ val colonIdx = trimmed.indexOf(' :' )
249+ if (colonIdx < 0 ) return @forEachIndexed
250+
251+ val key = trimmed.substring(0 , colonIdx).trim()
252+ if (key.isEmpty() || key.startsWith(' #' )) return @forEachIndexed
253+
254+ var value = trimmed.substring(colonIdx + 1 ).trim()
255+
256+ // Strip trailing inline comment (only outside quoted values)
257+ if (! value.startsWith(' "' ) && ! value.startsWith(' \' ' )) {
258+ val ci = value.indexOf(" #" )
259+ if (ci >= 0 ) value = value.substring(0 , ci).trim()
260+ }
261+
262+ // Pop stack entries whose indent >= current level
263+ while (stack.isNotEmpty() && stack.last().first >= indent) stack.removeLast()
264+
265+ val path = if (stack.isEmpty()) key
266+ else stack.joinToString(" ." ) { it.second } + " .$key "
267+
268+ if (value == " |" || value == " >" || value == " |-" || value == " >-"
269+ || value == " |+" || value == " >+" ) {
270+ result[path] = lineNo to value
271+ blockScalarIndent = indent
272+ } else {
273+ result[path] = lineNo to value
274+ }
275+
276+ stack.addLast(indent to key)
277+ }
278+ return result
279+ }
280+
281+ // Truncate long values for display
282+ fun truncate (s : String , max : Int = 70) = if (s.length > max) s.take(max) + " …" else s
283+
284+ // ── 5. Fetch the old language files from GitHub and diff ─────────────
285+ fun fetchRaw (tag : String , file : String ): String? = try {
286+ val url = " https://raw.githubusercontent.com/NighterDevelopment/" +
287+ " SmartSpawner/$tag /core/src/main/resources/language/$locale /$file "
288+ val conn = URI .create(url).toURL().openConnection() as java.net.HttpURLConnection
289+ conn.requestMethod = " GET"
290+ conn.setRequestProperty(" User-Agent" , " SmartSpawner-Changelog-Bot/1.0" )
291+ conn.connectTimeout = 8_000
292+ conn.readTimeout = 8_000
293+ if (conn.responseCode == 200 ) conn.inputStream.bufferedReader().readText() else null
294+ } catch (e: Exception ) { null }
295+
296+ // Per-file diffs: maps file name → pre-formatted entry lines
297+ val addedPerFile = mutableMapOf<String , List <String >>()
298+ val changedPerFile = mutableMapOf<String , List <String >>()
299+ val removedPerFile = mutableMapOf<String , List <String >>()
300+
301+ for (file in langFiles) {
302+ val oldYaml = fetchRaw(githubVersion, file)
303+ if (oldYaml == null ) {
304+ println (" [changelog] ⚠ Could not fetch $file @ $githubVersion – skipping diff." )
305+ continue
306+ }
307+ val newFile = project.file(" src/main/resources/language/$locale /$file " )
308+ if (! newFile.exists()) continue
309+
310+ val oldKeys = extractKeys(oldYaml)
311+ val newKeys = extractKeys(newFile.readText())
312+
313+ val added = newKeys.entries
314+ .filter { it.key !in oldKeys }
315+ .sortedBy { it.key }
316+ .map { (k, e) -> " - $k (L${e.first} ): ${truncate(e.second)} " }
317+
318+ val removed = oldKeys.entries
319+ .filter { it.key !in newKeys }
320+ .sortedBy { it.key }
321+ .map { (k, e) -> " - $k (L${e.first} ): ${truncate(e.second)} " }
322+
323+ val changed = newKeys.entries
324+ .filter { it.key in oldKeys && oldKeys[it.key]!! .second != it.value.second }
325+ .sortedBy { it.key }
326+ .map { (k, newE) ->
327+ val oldVal = truncate(oldKeys[k]!! .second)
328+ val newVal = truncate(newE.second)
329+ " - $k (L${newE.first} ): $oldVal → $newVal "
330+ }
331+
332+ if (added.isNotEmpty()) addedPerFile[file] = added
333+ if (changed.isNotEmpty()) changedPerFile[file] = changed
334+ if (removed.isNotEmpty()) removedPerFile[file] = removed
335+
336+ println (" [changelog] $file – +${added.size} ~${changed.size} -${removed.size} " )
337+ }
338+
339+ // ── 6. Build the CHANGELOG entry ─────────────────────────────────────
340+ // perFile values are already fully-formatted lines (" - key (Lnn): value")
341+ fun formatSection (label : String , perFile : Map <String , List <String >>): String {
342+ if (perFile.isEmpty()) return " $label :\n (none)"
343+ val sb = StringBuilder (" $label :\n " )
344+ for ((file, lines) in perFile) {
345+ sb.appendLine(" $file :" )
346+ lines.forEach { sb.appendLine(it) }
347+ }
348+ return sb.toString().trimEnd()
349+ }
350+
351+ val today = LocalDate .now().toString()
203352 val separator = " ─" .repeat(80 - " ── v$currentVersion ($today ) " .length)
204- val newEntry = buildString {
353+ val newEntry = buildString {
205354 appendLine(" ── v$currentVersion ($today ) $separator " )
206355 appendLine()
207356 appendLine(" Summary: Version $currentVersion released – fill in details here." )
208- appendLine(" Compare: https://github.com/NighterDevelopment/SmartSpawner/compare/v $githubVersion ...v $currentVersion " )
357+ appendLine(" Compare: https://github.com/NighterDevelopment/SmartSpawner/compare/$githubVersion ...$toRef " )
209358 appendLine()
210- appendLine(" ADDED:" )
211- appendLine(" (none)" )
359+ appendLine(formatSection(" ADDED" , addedPerFile))
212360 appendLine()
213- appendLine(" CHANGED:" )
214- appendLine(" (none)" )
361+ appendLine(formatSection(" CHANGED" , changedPerFile))
215362 appendLine()
216- appendLine(" REMOVED:" )
217- appendLine(" (none)" )
363+ appendLine(formatSection(" REMOVED" , removedPerFile))
218364 appendLine()
219365 }
220366
221- // ── 5 . Insert before the first existing version entry ────────────────
222- val marker = " \n ──"
367+ // ── 7 . Insert before the first existing version entry ────────────────
368+ val marker = " \n ──"
223369 val updated = if (existing.contains(marker)) {
224370 existing.replaceFirst(marker, " \n $newEntry ──" )
225371 } else {
0 commit comments