crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

NYTToXDConverter.swift (19624B)


      1 import Foundation
      2 
      3 /// Converts NYT puzzle JSON (from `/v6/puzzle/daily/{date}.json`) to `.xd` format.
      4 enum NYTToXDConverter {
      5     struct ConversionError: LocalizedError {
      6         let message: String
      7         var errorDescription: String? { message }
      8     }
      9 
     10     /// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string.
     11     static func convert(jsonData: Data) throws -> String {
     12         guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
     13             throw ConversionError(message: "Invalid JSON root.")
     14         }
     15 
     16         // -- Metadata --
     17 
     18         let publicationDate = root["publicationDate"] as? String ?? ""
     19         let constructors = root["constructors"] as? [String] ?? []
     20         let editor = root["editor"] as? String
     21         let copyright = root["copyright"] as? String
     22 
     23         guard let bodyArray = root["body"] as? [[String: Any]],
     24               let body = bodyArray.first else {
     25             throw ConversionError(message: "Missing body in puzzle JSON.")
     26         }
     27 
     28         guard let dimensions = body["dimensions"] as? [String: Int],
     29               let width = dimensions["width"],
     30               let height = dimensions["height"] else {
     31             throw ConversionError(message: "Missing dimensions.")
     32         }
     33 
     34         guard let cells = body["cells"] as? [Any] else {
     35             throw ConversionError(message: "Missing cells.")
     36         }
     37 
     38         guard cells.count == width * height else {
     39             throw ConversionError(message: "Cell count (\(cells.count)) does not match dimensions (\(width)x\(height)).")
     40         }
     41 
     42         guard let clues = body["clues"] as? [[String: Any]] else {
     43             throw ConversionError(message: "Missing clues.")
     44         }
     45 
     46         // -- Parse cells into answers --
     47 
     48         // Each cell is either an empty dict (block) or a dict with "answer", "type", etc.
     49         var answers: [String?] = []  // nil = block, String = answer
     50         var acceptedAnswersByCellIndex: [Int: [String]] = [:]
     51         var nextRebusKey: Character = "1"
     52 
     53         for (index, cell) in cells.enumerated() {
     54             guard let dict = cell as? [String: Any], !dict.isEmpty else {
     55                 answers.append(nil)
     56                 continue
     57             }
     58 
     59             let answer = dict["answer"] as? String ?? ""
     60             if answer.isEmpty {
     61                 answers.append(nil)
     62             } else {
     63                 answers.append(answer)
     64             }
     65 
     66             if let moreAnswers = dict["moreAnswers"] as? [String: Any],
     67                let valid = moreAnswers["valid"] as? [String] {
     68                 let cleaned = valid.filter { !$0.isEmpty && $0 != answer }
     69                 if !cleaned.isEmpty {
     70                     acceptedAnswersByCellIndex[index] = cleaned
     71                 }
     72             }
     73         }
     74 
     75         // -- Build rebus header if needed --
     76 
     77         // Check for multi-character answers.
     78         var rebusEntries: [(key: Character, value: String)] = []
     79         var rebusLookup: [String: Character] = [:]
     80 
     81         for answer in answers {
     82             guard let answer, answer.count > 1 else { continue }
     83             if rebusLookup[answer] == nil {
     84                 let key = nextRebusKey
     85                 rebusLookup[answer] = key
     86                 rebusEntries.append((key: key, value: answer))
     87                 // Advance to next digit/letter for rebus key
     88                 nextRebusKey = Character(UnicodeScalar(nextRebusKey.asciiValue! + 1))
     89             }
     90         }
     91 
     92         // -- Find special (shaded/circled) cells from the SVG --
     93 
     94         let (specialCells, specialKind) = specialCellInfo(body: body)
     95 
     96         // -- Build grid lines --
     97 
     98         var gridLines: [String] = []
     99         for row in 0..<height {
    100             var line = ""
    101             for col in 0..<width {
    102                 let index = row * width + col
    103                 guard let answer = answers[index] else {
    104                     line += "#"
    105                     continue
    106                 }
    107                 let isSpecial = specialCells.contains(index)
    108                 if answer.count > 1 {
    109                     // Rebus: use the placeholder character. Lowercase marking
    110                     // only applies to single-letter cells per the .xd spec, so
    111                     // shading on a rebus cell would be lost here — no puzzle
    112                     // we've seen combines the two.
    113                     line += String(rebusLookup[answer]!)
    114                 } else {
    115                     line += isSpecial ? answer.lowercased() : answer
    116                 }
    117             }
    118             gridLines.append(line)
    119         }
    120 
    121         // -- Build clue lines --
    122 
    123         // Sort clues: Across first, then Down; within each group, by label number.
    124         let sortedClues = clues.sorted { a, b in
    125             let dirA = (a["direction"] as? String) ?? ""
    126             let dirB = (b["direction"] as? String) ?? ""
    127             if dirA != dirB { return dirA == "Across" }
    128             let labelA = intValue(a["label"]) ?? 0
    129             let labelB = intValue(b["label"]) ?? 0
    130             return labelA < labelB
    131         }
    132 
    133         var acrossClueLines: [String] = []
    134         var downClueLines: [String] = []
    135 
    136         for clue in sortedClues {
    137             let direction = clue["direction"] as? String ?? ""
    138             let label = intValue(clue["label"]) ?? 0
    139 
    140             // Extract clue text from the nested structure:
    141             // "text": [{"plain": "Clue text"}]
    142             let clueText: String
    143             if let textArray = clue["text"] as? [[String: Any]],
    144                let firstText = textArray.first,
    145                let plain = firstText["plain"] as? String {
    146                 clueText = plain
    147             } else {
    148                 clueText = ""
    149             }
    150 
    151             // Build answer from cell indices
    152             let cellIndices = clue["cells"] as? [Int] ?? []
    153             let answerStr = cellIndices.compactMap { answers[$0] }.joined()
    154 
    155             let prefix = direction == "Across" ? "A" : "D"
    156             let line = "\(prefix)\(label). \(clueText) ~ \(answerStr)"
    157             let acceptLine: String?
    158             let acceptedAnswers = acceptedAnswerVariants(
    159                 cellIndices: cellIndices,
    160                 answers: answers,
    161                 acceptedAnswersByCellIndex: acceptedAnswersByCellIndex
    162             )
    163             if acceptedAnswers.isEmpty {
    164                 acceptLine = nil
    165             } else {
    166                 let escaped = acceptedAnswers.map(escapeAcceptToken).joined(separator: " ")
    167                 acceptLine = "\(prefix)\(label) ^Accept: \(escaped)"
    168             }
    169 
    170             if direction == "Across" {
    171                 acrossClueLines.append(line)
    172                 if let acceptLine { acrossClueLines.append(acceptLine) }
    173             } else {
    174                 downClueLines.append(line)
    175                 if let acceptLine { downClueLines.append(acceptLine) }
    176             }
    177         }
    178 
    179         // -- Assemble .xd source --
    180 
    181         var sections: [String] = []
    182 
    183         // Metadata section
    184         var metadata: [String] = []
    185         metadata.append("Title: \(title(forPublicationDate: publicationDate))")
    186         metadata.append("CmVer: \(XD.currentCmVersion)")
    187         metadata.append("Publisher: New York Times")
    188         if !publicationDate.isEmpty {
    189             metadata.append("Date: \(publicationDate)")
    190         }
    191         if !constructors.isEmpty {
    192             metadata.append("Author: \(constructors.joined(separator: ", "))")
    193         }
    194         if let editor {
    195             metadata.append("Editor: \(editor)")
    196         }
    197         if let copyright {
    198             metadata.append("Copyright: \(copyright)")
    199         }
    200 
    201         if !rebusEntries.isEmpty {
    202             let rebusStr = rebusEntries.map { "\($0.key)=\($0.value)" }.joined(separator: " ")
    203             metadata.append("Rebus: \(rebusStr)")
    204         }
    205 
    206         if !specialCells.isEmpty, let specialKind {
    207             metadata.append("Special: \(specialKind)")
    208         }
    209 
    210         let relatives = buildRelativeGroups(clues: clues)
    211         if !relatives.isEmpty {
    212             let joined = relatives
    213                 .map { $0.joined(separator: ",") }
    214                 .joined(separator: "; ")
    215             metadata.append("Relatives: \(joined)")
    216         }
    217 
    218         sections.append(metadata.joined(separator: "\n"))
    219 
    220         // Grid section
    221         sections.append(gridLines.joined(separator: "\n"))
    222 
    223         // Clue sections (across then down, separated by blank line)
    224         let allClueLines = acrossClueLines + [""] + downClueLines
    225         sections.append(allClueLines.joined(separator: "\n"))
    226 
    227         // The .xd parser splits sections on two or more consecutive blank lines,
    228         // so we need two blank lines (three newlines) between sections.
    229         return sections.joined(separator: "\n\n\n")
    230     }
    231 
    232     private static func acceptedAnswerVariants(
    233         cellIndices: [Int],
    234         answers: [String?],
    235         acceptedAnswersByCellIndex: [Int: [String]]
    236     ) -> [String] {
    237         var variants: [String] = []
    238         var seen = Set<String>()
    239         let canonicalParts = cellIndices.map { answers.indices.contains($0) ? answers[$0] ?? "" : "" }
    240         let canonicalAnswer = canonicalParts.joined()
    241 
    242         for (partIndex, cellIndex) in cellIndices.enumerated() {
    243             guard let accepted = acceptedAnswersByCellIndex[cellIndex] else { continue }
    244             for value in accepted {
    245                 var parts = canonicalParts
    246                 parts[partIndex] = value
    247                 let variant = parts.joined()
    248                 guard variant != canonicalAnswer, seen.insert(variant).inserted else { continue }
    249                 variants.append(variant)
    250             }
    251         }
    252 
    253         return variants
    254     }
    255 
    256     private static func escapeAcceptToken(_ token: String) -> String {
    257         var escaped = ""
    258         for ch in token {
    259             if ch == "\\" || ch.isWhitespace {
    260                 escaped.append("\\")
    261             }
    262             escaped.append(ch)
    263         }
    264         return escaped
    265     }
    266 
    267     private static func title(forPublicationDate publicationDate: String) -> String {
    268         guard let date = date(fromPublicationDate: publicationDate) else {
    269             return "NYT Crossword"
    270         }
    271 
    272         let formatter = DateFormatter()
    273         formatter.calendar = Calendar(identifier: .gregorian)
    274         formatter.locale = Locale(identifier: "en_US_POSIX")
    275         formatter.timeZone = TimeZone(identifier: "America/New_York")
    276         formatter.dateFormat = "EEEE"
    277         return "\(formatter.string(from: date)) Crossword"
    278     }
    279 
    280     private static func date(fromPublicationDate publicationDate: String) -> Date? {
    281         let trimmed = publicationDate.trimmingCharacters(in: .whitespaces)
    282         guard let match = trimmed.firstMatch(of: /^(\d{4})-(\d{2})-(\d{2})$/),
    283               let year = Int(match.1),
    284               let month = Int(match.2),
    285               let day = Int(match.3)
    286         else { return nil }
    287 
    288         var calendar = Calendar(identifier: .gregorian)
    289         calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt
    290         var comps = DateComponents()
    291         comps.calendar = calendar
    292         comps.timeZone = calendar.timeZone
    293         comps.year = year
    294         comps.month = month
    295         comps.day = day
    296         return calendar.date(from: comps)
    297     }
    298 
    299     /// Themer/revealer groups: the structured `relatives` field plus
    300     /// italics-flagged theme answers. These are the connections the
    301     /// constructor did *not* surface in clue text — typically the trick
    302     /// underlying a theme — so they're suitable for catalog/analysis but
    303     /// should not drive any in-grid highlighting that would spoil the solve.
    304     /// Cross-references that live in clue prose ("See 11-Down") are derived
    305     /// at puzzle-load time in `Puzzle.init` instead.
    306     private static func buildRelativeGroups(clues: [[String: Any]]) -> [[String]] {
    307         var groups = buildRelatives(clues: clues)
    308         groups.append(contentsOf: buildFormattedClueGroups(clues: clues))
    309         var seen = Set<Set<String>>()
    310         return groups.filter { group in
    311             let key = Set(group)
    312             guard !key.isEmpty, !seen.contains(key) else { return false }
    313             seen.insert(key)
    314             return true
    315         }
    316     }
    317 
    318     /// Builds groups of cross-referenced clues from the v6 per-clue
    319     /// `relatives` arrays. Two rules admit a group, everything else is
    320     /// discarded:
    321     ///
    322     /// 1. **Revealer** — a clue with ≥2 relatives defines a group consisting
    323     ///    of itself plus every clue it references. The revealer's list is
    324     ///    treated as canonical.
    325     /// 2. **Mutual pair** — two clues that each list the other as their sole
    326     ///    relative form a group of two (the classic "See 14-Across" pattern).
    327     ///
    328     /// Single-direction 1-relative edges (where A references B but B does
    329     /// not reference A back) are dropped. This guards against NYT data
    330     /// errors where a leaf clue points at the wrong revealer.
    331     private static func buildRelatives(clues: [[String: Any]]) -> [[String]] {
    332         // Extract each clue's (label, direction) and relatives array.
    333         var tokens: [String?] = []
    334         var relativeIndices: [[Int]] = []
    335         tokens.reserveCapacity(clues.count)
    336         relativeIndices.reserveCapacity(clues.count)
    337         for clue in clues {
    338             let direction = clue["direction"] as? String ?? ""
    339             let label = intValue(clue["label"]) ?? 0
    340             if label > 0, direction == "Across" || direction == "Down" {
    341                 tokens.append("\(label)\(direction == "Across" ? "A" : "D")")
    342             } else {
    343                 tokens.append(nil)
    344             }
    345             let raw = clue["relatives"] as? [Int] ?? []
    346             let cleaned = Array(Set(raw.filter { $0 >= 0 && $0 < clues.count }))
    347             relativeIndices.append(cleaned)
    348         }
    349 
    350         var groups: [[String]] = []
    351         var seen = Set<Set<Int>>()
    352 
    353         func emit(_ members: Set<Int>) {
    354             guard members.count >= 2, !seen.contains(members) else { return }
    355             seen.insert(members)
    356             let sorted = members.sorted { a, b in
    357                 // Order by (number, direction-is-across-first). Extract from
    358                 // the stored token; fallback to index if a token is missing.
    359                 guard let ta = tokens[a], let tb = tokens[b] else { return a < b }
    360                 let (na, da) = (Int(ta.dropLast()) ?? 0, ta.last!)
    361                 let (nb, db) = (Int(tb.dropLast()) ?? 0, tb.last!)
    362                 if na != nb { return na < nb }
    363                 return da == "A" && db == "D"
    364             }
    365             let toks = sorted.compactMap { tokens[$0] }
    366             if toks.count >= 2 { groups.append(toks) }
    367         }
    368 
    369         // Rule 1: revealers.
    370         for (i, refs) in relativeIndices.enumerated() where refs.count >= 2 {
    371             var members = Set<Int>()
    372             members.insert(i)
    373             for r in refs { members.insert(r) }
    374             emit(members)
    375         }
    376 
    377         // Rule 2: mutual pairs. Only consider clues with exactly one relative
    378         // — revealer-formed groups already cover the multi-relative cases.
    379         for (i, refs) in relativeIndices.enumerated() where refs.count == 1 {
    380             let j = refs[0]
    381             guard j != i, relativeIndices.indices.contains(j) else { continue }
    382             if relativeIndices[j] == [i] {
    383                 emit(Set([i, j]))
    384             }
    385         }
    386 
    387         return groups
    388     }
    389 
    390     /// NYT marks some theme clues by supplying formatted clue text, commonly
    391     /// `<i>...</i>`, without adding `relatives`. Group all such clue refs so
    392     /// their answer cells can be highlighted by Crossmate's thematic mask.
    393     private static func buildFormattedClueGroups(clues: [[String: Any]]) -> [[String]] {
    394         let tokens = clues.compactMap { clue -> String? in
    395             guard clueHasFormattedText(clue) else { return nil }
    396             return clueToken(clue)
    397         }
    398         return tokens.isEmpty ? [] : [tokens]
    399     }
    400 
    401     private static func clueHasFormattedText(_ clue: [String: Any]) -> Bool {
    402         guard let textArray = clue["text"] as? [[String: Any]] else { return false }
    403         return textArray.contains { textPart in
    404             guard let formatted = textPart["formatted"] as? String else { return false }
    405             return !formatted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    406         }
    407     }
    408 
    409     private static func clueToken(_ clue: [String: Any]) -> String? {
    410         let direction = clue["direction"] as? String ?? ""
    411         let label = intValue(clue["label"]) ?? 0
    412         guard label > 0, direction == "Across" || direction == "Down" else { return nil }
    413         return "\(label)\(direction == "Across" ? "A" : "D")"
    414     }
    415 
    416     /// Walks the structured SVG tree under `body.SVG` to find cells that
    417     /// carry a theme marker. The cells group is a `<g data-group="cells">`
    418     /// whose children are per-cell `<g>` nodes in `body.cells` order — so the
    419     /// child's array index is the cell index. Two markers are recognised:
    420     ///
    421     /// * Shaded cells — the cell's `<rect>` background has `fill="lightgray"`.
    422     /// * Circled cells — the cell group contains a `<circle>` element.
    423     ///
    424     /// The `.xd` format only supports one `Special:` kind per puzzle. If a
    425     /// grid mixes both (not seen in the wild yet), circles take precedence
    426     /// since they're the more visually distinct marker.
    427     private static func specialCellInfo(body: [String: Any]) -> (indices: Set<Int>, kind: String?) {
    428         guard let svg = body["SVG"] as? [String: Any],
    429               let topChildren = svg["children"] as? [[String: Any]] else {
    430             return ([], nil)
    431         }
    432 
    433         let cellsGroup = topChildren.first { node in
    434             guard (node["name"] as? String) == "g",
    435                   let attrs = node["attributes"] as? [[String: Any]] else {
    436                 return false
    437             }
    438             return attrs.contains { attr in
    439                 (attr["name"] as? String) == "data-group"
    440                     && (attr["value"] as? String) == "cells"
    441             }
    442         }
    443 
    444         guard let cellsGroup,
    445               let cellGroups = cellsGroup["children"] as? [[String: Any]] else {
    446             return ([], nil)
    447         }
    448 
    449         var shaded: Set<Int> = []
    450         var circled: Set<Int> = []
    451         for (index, cellGroup) in cellGroups.enumerated() {
    452             guard let inner = cellGroup["children"] as? [[String: Any]] else {
    453                 continue
    454             }
    455             if let rect = inner.first,
    456                (rect["name"] as? String) == "rect",
    457                let attrs = rect["attributes"] as? [[String: Any]] {
    458                 let fill = attrs.first {
    459                     ($0["name"] as? String) == "fill"
    460                 }?["value"] as? String
    461                 if fill == "lightgray" {
    462                     shaded.insert(index)
    463                 }
    464             }
    465             if inner.contains(where: { ($0["name"] as? String) == "circle" }) {
    466                 circled.insert(index)
    467             }
    468         }
    469 
    470         if !circled.isEmpty {
    471             return (circled.union(shaded), "circle")
    472         }
    473         if !shaded.isEmpty {
    474             return (shaded, "shaded")
    475         }
    476         return ([], nil)
    477     }
    478 
    479     /// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.
    480     private static func intValue(_ value: Any?) -> Int? {
    481         if let n = value as? Int { return n }
    482         if let s = value as? String { return Int(s) }
    483         if let n = value as? NSNumber { return n.intValue }
    484         if let n = value as? Double { return Int(n) }
    485         return nil
    486     }
    487 }