Move SSD UI text helpers to typed Kotlin

ober

f05f37985d787561451d400cf7e4f5510713d34d

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 19d2fab..c347b74 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -437,6 +437,25 @@
                    (and (>= cy (float-array-ref rect (int32 1)))
                         (<= cy (float-array-ref rect (int32 3))))))))))
 
+    (typed-kotlin-file "com/sfb/ssdreview/UiTextHelpers.kt"
+      (typed-library (com sfb ssdreview)
+        (export normalizeFiringArc normalizeLabel isGenericGuessLabel)
+        (def (normalizeFiringArc (value : String)) : String
+          (string-uppercase (string-trim value)))
+        (def (normalizeLabel (value : String)) : String
+          (string-filter-letter-or-digit (string-lowercase value)))
+        (def (isGenericGuessLabel (label : String)) : Bool
+          (let ((normalized
+                  (string-trim
+                    (string-replace-regex
+                      (string-lowercase (string-trim label))
+                      "\\?$"
+                      ""))))
+            (or (string-matches-regex? normalized "^\\d+\\s+boxes$")
+                (or (or (equal? normalized "boxes")
+                        (equal? normalized "unknown"))
+                    (equal? normalized "unclassified")))))))
+
     (kotlin-file-lines "com/sfb/ssdreview/BoxTypes.kt"
       (
        "package com.sfb.ssdreview"
@@ -933,9 +952,6 @@
        "        update()"
        "    }"
        ""
-       "    private fun normalizeFiringArc(value: String): String ="
-       "        value.trim().uppercase()"
-       ""
        "    private fun resolvedLabelForBoxType(label: String, boxType: String, previousBoxType: String = \"\"): String {"
        "        val trimmed = label.trim()"
        "        if (trimmed.isNotBlank() && !shouldReplaceLabelForBoxType(trimmed, previousBoxType)) {"
@@ -970,23 +986,12 @@
        "        ).filterNotNull().any { normalizeLabel(it) == normalized }"
        "    }"
        ""
-       "    private fun isGenericGuessLabel(label: String): Boolean {"
-       "        val normalized = label.trim().removeSuffix(\"?\").trim().lowercase()"
-       "        return Regex(\"\"\"^\\d+\\s+boxes$\"\"\").matches(normalized) ||"
-       "            normalized == \"boxes\" ||"
-       "            normalized == \"unknown\" ||"
-       "            normalized == \"unclassified\""
-       "    }"
-       ""
        "    private fun defaultLabelForBoxType(boxType: String): String {"
        "        val resolved = BoxTypes.resolve(this, boxType)"
        "        val raw = resolved?.name ?: boxType.substringAfter(' ', boxType).trim()"
        "        return raw.lowercase()"
        "    }"
        ""
-       "    private fun normalizeLabel(value: String): String ="
-       "        value.lowercase().removeSuffix(\"?\").filter { it.isLetterOrDigit() }"
-       ""
        "    private fun finalizeGroupLabel(group: SsdGroup) {"
        "        val typeDisplay = BoxTypes.load(this).firstOrNull { it.id == group.boxTypeId }?.let { box_type_display(it) } ?: group.boxTypeId"
        "        val resolved = resolvedLabelForBoxType(group.label, typeDisplay, group.boxTypeId)"