Move SSD box label policy to typed Kotlin

ober

2e50210c0f43564183a0932b5a1ea7727e3df69a

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 4d44d9d..c13d708 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -1203,6 +1203,7 @@
     (typed-kotlin-file "com/sfb/ssdreview/UiTextHelpers.kt"
       (typed-library (com sfb ssdreview)
         (export normalizeFiringArc normalizeLabel shouldReplaceGuessLabel
+                shouldReplaceBoxTypeLabel resolvedBoxTypeLabel
                 isGenericGuessLabel isSsdPdfName
                 sourceBaseName canonicalSourceName isUsefulGuessLabel isErrorStatus
                 formatActivityDuration)
@@ -1231,6 +1232,26 @@
                 (if (string-ends-with? text "?")
                   #t
                   (string-matches-regex? text "^\\d+\\s+boxes$"))))))
+        (def (labelWithoutQuestion (label : String)) : String
+          (string-trim (string-replace-regex label "\\?$" "")))
+        (def (shouldReplaceBoxTypeLabel (label : String) (matchesPrevious : Bool)) : Bool
+          (let ((trimmed (string-trim label)))
+            (if (or (string-blank? trimmed) (isGenericGuessLabel trimmed))
+              #t
+              (if (string-ends-with? trimmed "?")
+                #t
+                matchesPrevious))))
+        (def (resolvedBoxTypeLabel (label : String)
+                                   (defaultLabel : String)
+                                   (matchesPrevious : Bool)) : String
+          (let ((trimmed (string-trim label)))
+            (if (and (not (string-blank? trimmed))
+                     (not (shouldReplaceBoxTypeLabel trimmed matchesPrevious)))
+              (labelWithoutQuestion trimmed)
+              (let ((fallback (string-trim defaultLabel)))
+                (if (string-blank? fallback)
+                  (labelWithoutQuestion trimmed)
+                  fallback)))))
         (def (isSsdPdfName (name : String)) : Bool
           (let ((lower (string-lowercase name)))
             (and (string-ends-with? lower ".pdf")
@@ -1785,10 +1806,7 @@
        ""
        "    private fun resolvedLabelForBoxType(label: String, boxType: String, previousBoxType: String = \"\"): String {"
        "        val trimmed = label.trim()"
-       "        if (trimmed.isNotBlank() && !shouldReplaceLabelForBoxType(trimmed, previousBoxType)) {"
-       "            return trimmed.removeSuffix(\"?\").trim()"
-       "        }"
-       "        return defaultLabelForBoxType(boxType).ifBlank { trimmed.removeSuffix(\"?\").trim() }"
+       "        return resolvedBoxTypeLabel(label, defaultLabelForBoxType(boxType), labelMatchesBoxType(trimmed, previousBoxType))"
        "    }"
        ""
        "    private fun replaceAutoLabelForBoxType(label: EditText, boxType: String, previousBoxType: String) {"
@@ -1801,9 +1819,7 @@
        ""
        "    private fun shouldReplaceLabelForBoxType(label: String, previousBoxType: String): Boolean {"
        "        val trimmed = label.trim()"
-       "        if (trimmed.isBlank() || isGenericGuessLabel(trimmed)) return true"
-       "        if (trimmed.endsWith(\"?\")) return true"
-       "        return labelMatchesBoxType(trimmed, previousBoxType)"
+       "        return shouldReplaceBoxTypeLabel(label, labelMatchesBoxType(trimmed, previousBoxType))"
        "    }"
        ""
        "    private fun labelMatchesBoxType(label: String, boxType: String): Boolean {"