crossmate

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

PuzzleCatalog.swift (5534B)


      1 import Foundation
      2 
      3 /// Knows about packaged `.xd` puzzle resources. Used by the new-game picker
      4 /// to list available puzzles. The catalog is built from each bundle's
      5 /// `manifest.json` (written by the Crossmake `Bundlemake` tool); raw `.xd`
      6 /// source is read only when a specific puzzle is opened.
      7 struct PuzzleCatalog {
      8     struct Entry: Identifiable {
      9         let id: String          // resource name (e.g. "cm-starter-0001")
     10         let title: String       // display title from the manifest
     11         let publisher: String?  // publisher from the manifest, if any
     12         let gridWidth: Int      // grid columns, for the row thumbnail
     13         let gridHeight: Int     // grid rows, for the row thumbnail
     14         /// Row-major block mask (`true` == block). Drives `GridThumbnailView`
     15         /// so the puzzle list can show the same preview as the game list.
     16         let blockCells: [Bool]
     17         /// The puzzle's `.xd` resource. The catalog is built from manifests
     18         /// and never holds source text; callers read it on demand.
     19         let sourceURL: URL
     20 
     21         /// Reads the puzzle's raw `.xd` source from the app bundle.
     22         func loadSource() throws -> String {
     23             try String(contentsOf: sourceURL, encoding: .utf8)
     24         }
     25     }
     26 
     27     /// A bundle of puzzles under `Puzzles/`, e.g. "Crossmate Starter".
     28     struct PuzzleBundle: Identifiable {
     29         let id: String       // bundle id, e.g. "cm-starter"
     30         let name: String     // display name, e.g. "Crossmate Starter"
     31         let puzzles: [Entry]
     32     }
     33 
     34     /// Bundle id of the debug-only fixture set.
     35     private static let debugBundleID = "debug"
     36 
     37     /// Manifests are immutable for the life of the process, so the catalog is
     38     /// read once, on first access. SwiftUI re-evaluates the picker's body
     39     /// freely without paying a re-read each time.
     40     private static let allBundles: [PuzzleBundle] = loadAllBundles()
     41 
     42     /// The puzzle bundles shown in the picker. Debug fixtures join the regular
     43     /// bundles when the app's debug mode is enabled.
     44     static func bundles(includeDebug: Bool = false) -> [PuzzleBundle] {
     45         includeDebug ? allBundles : allBundles.filter { $0.id != debugBundleID }
     46     }
     47 
     48     static func source(
     49         matchingResourceID resourceID: String?,
     50         title: String?
     51     ) -> Entry? {
     52         let entries = allPuzzles()
     53         if let resourceID {
     54             return entries.first { $0.id == resourceID }
     55         } else if let title {
     56             return entries.first { $0.title == title }
     57         } else {
     58             return nil
     59         }
     60     }
     61 
     62     /// Finds the catalog id of a bundled puzzle whose `.xd` source matches
     63     /// `source` exactly. Pre-filtered by title so a non-bundled source (an
     64     /// NYT or imported puzzle) reads no `.xd` files at all.
     65     static func resourceID(matching source: String) -> String? {
     66         guard let title = try? XD.parse(source).title else { return nil }
     67         return allPuzzles()
     68             .filter { $0.title == title }
     69             .first { (try? $0.loadSource()) == source }?
     70             .id
     71     }
     72 
     73     /// Every cataloged puzzle — bundled and debug — flattened for lookup.
     74     private static func allPuzzles() -> [Entry] {
     75         allBundles.flatMap(\.puzzles)
     76     }
     77 
     78     // MARK: - Manifest loading
     79 
     80     private struct Manifest: Decodable {
     81         let version: Int
     82         let bundleID: String
     83         let name: String
     84         let puzzles: [ManifestPuzzle]
     85     }
     86 
     87     private struct ManifestPuzzle: Decodable {
     88         let id: String
     89         let title: String
     90         let publisher: String?
     91         let gridWidth: Int
     92         let gridHeight: Int
     93         let blockMask: String
     94     }
     95 
     96     private static func loadAllBundles() -> [PuzzleBundle] {
     97         guard let puzzlesURL = Bundle.main.resourceURL?
     98             .appendingPathComponent("Puzzles", isDirectory: true) else {
     99             return []
    100         }
    101         let directories = (try? FileManager.default.contentsOfDirectory(
    102             at: puzzlesURL,
    103             includingPropertiesForKeys: [.isDirectoryKey]
    104         )) ?? []
    105 
    106         return directories
    107             .compactMap { url -> PuzzleBundle? in
    108                 guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?
    109                     .isDirectory == true else {
    110                     return nil
    111                 }
    112                 return bundle(in: url)
    113             }
    114             .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
    115     }
    116 
    117     /// Builds a bundle from its `manifest.json`. Directories without a
    118     /// readable manifest are skipped — `Bundlemake` writes the manifest as
    119     /// part of authoring a bundle.
    120     private static func bundle(in directoryURL: URL) -> PuzzleBundle? {
    121         let manifestURL = directoryURL.appendingPathComponent("manifest.json")
    122         guard let data = try? Data(contentsOf: manifestURL),
    123               let manifest = try? JSONDecoder().decode(Manifest.self, from: data) else {
    124             return nil
    125         }
    126         let entries = manifest.puzzles.map { puzzle in
    127             Entry(
    128                 id: puzzle.id,
    129                 title: puzzle.title,
    130                 publisher: puzzle.publisher,
    131                 gridWidth: puzzle.gridWidth,
    132                 gridHeight: puzzle.gridHeight,
    133                 blockCells: puzzle.blockMask.map { $0 == "#" },
    134                 sourceURL: directoryURL.appendingPathComponent("\(puzzle.id).xd")
    135             )
    136         }
    137         return PuzzleBundle(id: manifest.bundleID, name: manifest.name, puzzles: entries)
    138     }
    139 }