Move SSD box type resolution to typed Kotlin

ober

7de68e696df8e70e3335984c7ffed8667d6392c1

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 6705473..137c49b 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -13,6 +13,72 @@
             (string-append (BoxType-id box) " ")
             (BoxType-name box)))))
 
+    (typed-kotlin-file "com/sfb/ssdreview/BoxTypeResolve.kt"
+      (typed-library (com sfb ssdreview)
+        (export resolveBoxType)
+        (type Int32)
+        (record BoxTypeMatch
+          ((count : Int32)
+           (found : (Nullable BoxType))))
+        (def (boxTypeCompact (value : String)) : String
+          (string-filter-letter-or-digit (string-lowercase value)))
+        (def (boxTypeExactMatch (box : BoxType)
+                                (text : String)
+                                (lowered : String)
+                                (normalized : String)) : Bool
+          (or (equal? (BoxType-id box) text)
+              (or (equal? (string-lowercase (box-type-display box)) lowered)
+                  (or (equal? (string-lowercase (BoxType-name box)) lowered)
+                      (equal? (boxTypeCompact (BoxType-name box)) normalized)))))
+        (def (boxTypePartialMatch (box : BoxType)
+                                  (lowered : String)
+                                  (normalized : String)) : Bool
+          (or (string-contains?
+                (string-lowercase (box-type-display box))
+                lowered)
+              (string-contains? (boxTypeCompact (BoxType-name box)) normalized)))
+        (def (findExactBoxType (items : (List BoxType))
+                               (text : String)
+                               (lowered : String)
+                               (normalized : String)) : (Nullable BoxType)
+          (for/fold ((found (nullable-none BoxType)))
+                    ((i (in-range (int32 0) (list-size items))))
+            (if (nullable-null? found)
+                (let ((box (list-ref items i)))
+                  (if (boxTypeExactMatch box text lowered normalized)
+                      (nullable-some box)
+                      found))
+                found)))
+        (def (findPartialBoxTypes (items : (List BoxType))
+                                  (lowered : String)
+                                  (normalized : String)) : BoxTypeMatch
+          (for/fold ((match (make-BoxTypeMatch
+                              (int32 0)
+                              (nullable-none BoxType))))
+                    ((i (in-range (int32 0) (list-size items))))
+            (let ((box (list-ref items i)))
+              (if (boxTypePartialMatch box lowered normalized)
+                  (make-BoxTypeMatch
+                    (+ (BoxTypeMatch-count match) (int32 1))
+                    (nullable-some box))
+                  match))))
+        (def (singleBoxTypeMatch (match : BoxTypeMatch)) : (Nullable BoxType)
+          (if (= (BoxTypeMatch-count match) (int32 1))
+              (BoxTypeMatch-found match)
+              (nullable-none BoxType)))
+        (def (resolveBoxType (items : (List BoxType))
+                             (raw : String)) : (Nullable BoxType)
+          (let ((text (string-trim raw)))
+            (if (string-blank? text)
+                (nullable-none BoxType)
+                (let ((normalized (boxTypeCompact text))
+                      (lowered (string-lowercase text)))
+                  (let ((exact (findExactBoxType items text lowered normalized)))
+                    (if (nullable-null? exact)
+                        (singleBoxTypeMatch
+                          (findPartialBoxTypes items lowered normalized))
+                        exact))))))))
+
     (typed-kotlin-file "com/sfb/ssdreview/OcrWord.kt"
       (typed-library (com sfb ssdreview)
         (export make-OcrWord OcrWord? OcrWord-text OcrWord-conf OcrWord-bbox)
@@ -2628,22 +2694,7 @@
        "    }"
        ""
        "    fun resolve(context: Context, raw: String): BoxType? {"
-       "        val text = raw.trim()"
-       "        if (text.isBlank()) return null"
-       "        val normalized = compact(text)"
-       "        val lowered = text.lowercase()"
-       "        val items = load(context)"
-       "        items.firstOrNull {"
-       "            it.id == text ||"
-       "                box_type_display(it).lowercase() == lowered ||"
-       "                it.name.lowercase() == lowered ||"
-       "                compact(it.name) == normalized"
-       "        }?.let { return it }"
-       "        val matches = items.filter {"
-       "            box_type_display(it).lowercase().contains(lowered) ||"
-       "                compact(it.name).contains(normalized)"
-       "        }"
-       "        return matches.singleOrNull()"
+       "        return resolveBoxType(load(context), raw)"
        "    }"
        ""
        "    fun isWeapon(context: Context, raw: String): Boolean {"