krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Packages/MoonriseKit/Sources/Library/Library.swift

208 lines · 8353 bytes

21 symbols in this file
  1import Foundation
  2
  3/// A reference page. Schema and writing rules: docs/library-content.org.
  4public struct LibraryPage: Codable, Sendable, Identifiable, Hashable {
  5    public struct Fact: Codable, Sendable, Hashable {
  6        public var label: String
  7        public var value: String
  8        /// Index into `sources`.
  9        public var source: Int
 10    }
 11
 12    public struct Source: Codable, Sendable, Hashable {
 13        public var title: String
 14        public var url: URL
 15    }
 16
 17    public struct Image: Codable, Sendable, Hashable {
 18        public var url: URL
 19        public var credit: String
 20        public var licence: String
 21        /// Page the image is published on.
 22        public var page: URL
 23        public var caption: String
 24    }
 25
 26    /// Parameters for the page's generated drawing; which fields are set depends on `kind`.
 27    public struct Drawing: Codable, Sendable, Hashable {
 28        public var kind: String
 29        public var radiusKm: Double?
 30        public var rings: Bool?
 31        public var axialTiltDeg: Double?
 32        public var color: String?
 33        public var spectralType: String?
 34        public var colorIndex: Double?
 35        public var type: String?
 36        public var sizeArcmin: Double?
 37        public var status: String?
 38        public var launch: String?
 39        public var radiusEarth: Double?
 40        public var orbitAU: Double?
 41        public var starTempK: Double?
 42        public var heightM: Double?
 43        public var diameterM: Double?
 44    }
 45
 46    /// What the page describes elsewhere in the app.
 47    public struct Link: Codable, Sendable, Hashable {
 48        public var body: String?
 49        public var constellation: String?
 50        public var hr: Int?
 51        public var messier: Int?
 52        public var spacecraft: String?
 53    }
 54
 55    public var id: String
 56    public var section: LibrarySection
 57    public var title: String
 58    public var subtitle: String
 59    public var facts: [Fact]
 60    public var essay: String
 61    public var image: Image?
 62    public var drawing: Drawing?
 63    public var link: Link
 64    public var sources: [Source]
 65
 66    public var paragraphs: [String] {
 67        essay.components(separatedBy: "\n\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
 68    }
 69
 70    public var wordCount: Int {
 71        essay.split { $0.isWhitespace }.count
 72    }
 73}
 74
 75public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiable {
 76    case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", exoplanets, missions, rockets
 77
 78    public var id: String { rawValue }
 79
 80    public var title: String {
 81        switch self {
 82        case .sun: "The Sun"
 83        case .planets: "Planets"
 84        case .dwarfPlanets: "Dwarf planets"
 85        case .moons: "Moons"
 86        case .constellations: "Constellations"
 87        case .stars: "Stars"
 88        case .deepSky: "Deep sky"
 89        case .exoplanets: "Exoplanets"
 90        case .missions: "Missions"
 91        case .rockets: "Rockets"
 92        }
 93    }
 94}
 95
 96public enum Library {
 97    /// All pages from the bundled `pages/*.json` files, in file order within each section.
 98    public static let pages: [LibraryPage] = {
 99        guard let dir = Bundle.module.url(forResource: "pages", withExtension: nil),
100              let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] }
101        return files
102            .filter { $0.pathExtension == "json" }
103            .sorted { $0.lastPathComponent < $1.lastPathComponent }
104            .flatMap { url -> [LibraryPage] in
105                do {
106                    return try JSONDecoder().decode([LibraryPage].self, from: Data(contentsOf: url))
107                } catch {
108                    assertionFailure("\(url.lastPathComponent): \(error)")
109                    return []
110                }
111            }
112    }()
113
114    public static func pages(in section: LibrarySection) -> [LibraryPage] {
115        pages.filter { $0.section == section }
116    }
117
118    public static func page(id: String) -> LibraryPage? {
119        pages.first { $0.id == id }
120    }
121
122    public static func page(body: String) -> LibraryPage? {
123        pages.first { $0.link.body == body }
124    }
125
126    public static func page(constellation: String) -> LibraryPage? {
127        pages.first { $0.link.constellation == constellation }
128    }
129
130    public static func page(spacecraft: String) -> LibraryPage? {
131        pages.first { $0.link.spacecraft == spacecraft }
132    }
133
134    public static func page(hr: Int) -> LibraryPage? {
135        pages.first { $0.link.hr == hr }
136    }
137
138    /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay.
139    public static func search(_ query: String) -> [LibraryPage] {
140        let q = fold(query.trimmingCharacters(in: .whitespaces))
141        guard !q.isEmpty else { return [] }
142        var strong: [LibraryPage] = [], weak: [LibraryPage] = []
143        for entry in searchIndex {
144            if entry.head.contains(q) || entry.page.id == q { strong.append(entry.page) } else if entry.body.contains(q) { weak.append(entry.page) }
145        }
146        return strong + weak
147    }
148
149    /// Lowercased, diacritic-free text per page, built once: title and subtitle, then essay and facts.
150    private static let searchIndex: [(page: LibraryPage, head: String, body: String)] = pages.map { p in
151        (p, fold(p.title + "\n" + p.subtitle), fold(p.essay + "\n" + p.facts.map(\.value).joined(separator: "\n")))
152    }
153
154    static func fold(_ s: String) -> String {
155        s.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
156    }
157
158    private static let relatedLock = NSLock()
159    nonisolated(unsafe) private static var relatedCache: [String: [LibraryPage]] = [:]
160
161    /// `page.related()` computed once per page.
162    static func cachedRelated(_ page: LibraryPage, compute: () -> [LibraryPage]) -> [LibraryPage] {
163        relatedLock.lock()
164        if let hit = relatedCache[page.id] { relatedLock.unlock(); return hit }
165        relatedLock.unlock()
166        let value = compute()
167        relatedLock.lock()
168        relatedCache[page.id] = value
169        relatedLock.unlock()
170        return value
171    }
172}
173
174extension LibraryPage {
175    /// Pages named in this page's facts, then in its essay, then pages that mention this one, up to `limit`.
176    /// A title also matches by its part before a dash or bracket ("Cassini" for "Cassini–Huygens").
177    /// Titles shorter than five characters ("Sun", "Moon", "Io") only count when they are mentioned here.
178    public func related(limit: Int = 10) -> [LibraryPage] {
179        Library.cachedRelated(self) { uncachedRelated(limit: limit) }
180    }
181
182    private func uncachedRelated(limit: Int) -> [LibraryPage] {
183        func mentions(_ text: String, _ title: String) -> Bool {
184            var search = text.startIndex..<text.endIndex
185            while let r = text.range(of: title, range: search) {
186                let before = r.lowerBound == text.startIndex ? nil : text[text.index(before: r.lowerBound)]
187                let after = r.upperBound == text.endIndex ? nil : text[r.upperBound]
188                if !(before?.isLetter ?? false), !(after?.isLetter ?? false) { return true }
189                search = r.upperBound..<text.endIndex
190            }
191            return false
192        }
193        func names(_ title: String) -> [String] {
194            let short = title.components(separatedBy: CharacterSet(charactersIn: "–(")).first!.trimmingCharacters(in: .whitespaces)
195            return short.count >= 5 && short != title ? [title, short] : [title]
196        }
197        let others = Library.pages.filter { $0.id != id }
198        let factText = facts.map(\.value).joined(separator: " ")
199        let values = Set(facts.map(\.value))
200        let inFacts = others.filter { p in names(p.title).contains { mentions(factText, $0) } }
201            .sorted { values.contains($0.title) && !values.contains($1.title) }
202        let inEssay = others.filter { p in !inFacts.contains(p) && names(p.title).contains { mentions(essay, $0) } }
203        let mentioned = inFacts + inEssay
204        let mine = names(title).filter { $0.count >= 5 }
205        let mentioning = others.filter { p in !mentioned.contains(p) && mine.contains { mentions(p.essay, $0) } }
206        return Array((mentioned + mentioning).prefix(limit))
207    }
208}