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 }