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}