M5: Library code !13
12 files changed, +686 −1
Layout: unified · split
App/Sources/LibraryScreen.swift added +145
| @@ -0,0 +1,145 @@ | |||
| 1 | import Library | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | ||
| 5 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | ||
| 6 | private let pageBackground = Color(red: 0.043, green: 0.078, blue: 0.141) | ||
| 7 | |||
| 8 | struct LibraryScreen: View { | ||
| 9 | @State private var query = "" | ||
| 10 | |||
| 11 | var body: some View { | ||
| 12 | NavigationStack { | ||
| 13 | List { | ||
| 14 | if query.isEmpty { | ||
| 15 | ForEach(LibrarySection.allCases) { section in | ||
| 16 | let pages = Library.pages(in: section) | ||
| 17 | if !pages.isEmpty { | ||
| 18 | NavigationLink(value: section) { | ||
| 19 | HStack { | ||
| 20 | Text(section.title).font(.system(size: 17)) | ||
| 21 | Spacer() | ||
| 22 | Text("\(pages.count)").font(.system(size: 13).monospacedDigit()).opacity(0.5) | ||
| 23 | } | ||
| 24 | } | ||
| 25 | .listRowBackground(pageBackground) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } else { | ||
| 29 | ForEach(Library.search(query)) { page in | ||
| 30 | NavigationLink(value: page) { PageRow(page: page) } | ||
| 31 | .listRowBackground(pageBackground) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | } | ||
| 35 | .listStyle(.plain) | ||
| 36 | .scrollContentBackground(.hidden) | ||
| 37 | .background(pageBackground.ignoresSafeArea()) | ||
| 38 | .navigationTitle("Library") | ||
| 39 | .searchable(text: $query, prompt: "Saturn, Orion, M42, Voyager") | ||
| 40 | .navigationDestination(for: LibrarySection.self) { SectionList(section: $0) } | ||
| 41 | .navigationDestination(for: LibraryPage.self) { LibraryPageView(page: $0) } | ||
| 42 | } | ||
| 43 | .foregroundStyle(ink) | ||
| 44 | .tint(ink) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | struct SectionList: View { | ||
| 49 | var section: LibrarySection | ||
| 50 | |||
| 51 | var body: some View { | ||
| 52 | List(Library.pages(in: section)) { page in | ||
| 53 | NavigationLink(value: page) { PageRow(page: page) } | ||
| 54 | .listRowBackground(pageBackground) | ||
| 55 | } | ||
| 56 | .listStyle(.plain) | ||
| 57 | .scrollContentBackground(.hidden) | ||
| 58 | .background(pageBackground.ignoresSafeArea()) | ||
| 59 | .navigationTitle(section.title) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | struct PageRow: View { | ||
| 64 | var page: LibraryPage | ||
| 65 | |||
| 66 | var body: some View { | ||
| 67 | VStack(alignment: .leading, spacing: 2) { | ||
| 68 | Text(page.title).font(.system(size: 17)) | ||
| 69 | Text(page.subtitle).font(.system(size: 12)).opacity(0.6).lineLimit(1) | ||
| 70 | } | ||
| 71 | .padding(.vertical, 2) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | struct LibraryPageView: View { | ||
| 76 | var page: LibraryPage | ||
| 77 | |||
| 78 | var body: some View { | ||
| 79 | ScrollView { | ||
| 80 | VStack(alignment: .leading, spacing: 0) { | ||
| 81 | Text(page.section.title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) | ||
| 82 | Text(page.title).font(.system(size: 34, weight: .medium)).padding(.top, 4) | ||
| 83 | Text(page.subtitle).font(.system(size: 15)).opacity(0.75).padding(.top, 2) | ||
| 84 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16) | ||
| 85 | |||
| 86 | PageDrawing(page: page, ink: ink, accent: accent) | ||
| 87 | |||
| 88 | facts.padding(.top, 16) | ||
| 89 | |||
| 90 | VStack(alignment: .leading, spacing: 12) { | ||
| 91 | ForEach(page.paragraphs, id: \.self) { Text($0).font(.system(size: 16)).lineSpacing(3) } | ||
| 92 | } | ||
| 93 | .padding(.top, 20) | ||
| 94 | |||
| 95 | if let image = page.image { photo(image).padding(.top, 24) } | ||
| 96 | |||
| 97 | sources.padding(.top, 24).padding(.bottom, 32) | ||
| 98 | } | ||
| 99 | .padding(.horizontal, 20) | ||
| 100 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 101 | } | ||
| 102 | .foregroundStyle(ink) | ||
| 103 | .background(pageBackground.ignoresSafeArea()) | ||
| 104 | .navigationBarTitleDisplayMode(.inline) | ||
| 105 | } | ||
| 106 | |||
| 107 | private var facts: some View { | ||
| 108 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { | ||
| 109 | ForEach(page.facts, id: \.self) { f in | ||
| 110 | GridRow(alignment: .firstTextBaseline) { | ||
| 111 | Text(f.label).font(.system(size: 12)).opacity(0.6) | ||
| 112 | Text(f.value).font(.system(size: 15).monospacedDigit()) | ||
| 113 | } | ||
| 114 | Divider().overlay(ink.opacity(0.15)) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | private func photo(_ image: LibraryPage.Image) -> some View { | ||
| 120 | VStack(alignment: .leading, spacing: 6) { | ||
| 121 | AsyncImage(url: image.url) { phase in | ||
| 122 | switch phase { | ||
| 123 | case .success(let img): img.resizable().scaledToFit() | ||
| 124 | case .failure: Color.black.frame(height: 120).overlay(Text("Image unavailable").font(.system(size: 12)).opacity(0.6)) | ||
| 125 | default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink)) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | Text(image.caption).font(.system(size: 12)).opacity(0.75) | ||
| 129 | Link(destination: image.page) { | ||
| 130 | Text("\(image.credit) · \(image.licence)").font(.system(size: 10)).opacity(0.55) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | private var sources: some View { | ||
| 136 | VStack(alignment: .leading, spacing: 6) { | ||
| 137 | Text("Sources").font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) | ||
| 138 | ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in | ||
| 139 | Link(destination: s.url) { | ||
| 140 | Text("\(i + 1). \(s.title)").font(.system(size: 12)).underline().opacity(0.8) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | } | ||
| 145 | } | ||
App/Sources/MoonriseApp.swift +1
| @@ -15,6 +15,7 @@ struct MoonriseApp: App { | |||
| 15 | Tab("System", systemImage: "circle.circle") { SystemScreen() } | 15 | Tab("System", systemImage: "circle.circle") { SystemScreen() } |
| 16 | Tab("Sun", systemImage: "sun.max") { SunScreen() } | 16 | Tab("Sun", systemImage: "sun.max") { SunScreen() } |
| 17 | Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() } | 17 | Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() } |
| 18 | Tab("Library", systemImage: "books.vertical") { LibraryScreen() } | ||
| 18 | } | 19 | } |
| 19 | .preferredColorScheme(.dark) | 20 | .preferredColorScheme(.dark) |
| 20 | } | 21 | } |
App/Sources/PageDrawing.swift added +182
| @@ -0,0 +1,182 @@ | |||
| 1 | import Library | ||
| 2 | import simd | ||
| 3 | import SkyRenderer | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | /// The generated drawing at the top of a Library page, built from the page's data. | ||
| 7 | struct PageDrawing: View { | ||
| 8 | var page: LibraryPage | ||
| 9 | var ink: Color | ||
| 10 | var accent: Color | ||
| 11 | |||
| 12 | var body: some View { | ||
| 13 | Group { | ||
| 14 | switch page.drawing?.kind { | ||
| 15 | case "planet": planet | ||
| 16 | case "constellation": constellation | ||
| 17 | case "star": star | ||
| 18 | case "deep-sky": deepSky | ||
| 19 | case "mission": mission | ||
| 20 | default: EmptyView() | ||
| 21 | } | ||
| 22 | } | ||
| 23 | .frame(maxWidth: .infinity) | ||
| 24 | } | ||
| 25 | |||
| 26 | // MARK: Planets, moons, the Sun | ||
| 27 | |||
| 28 | /// The body to scale with Earth beside it (or the Sun with Earth as a dot). | ||
| 29 | private var planet: some View { | ||
| 30 | let d = page.drawing! | ||
| 31 | let radius = d.radiusKm ?? 6371 | ||
| 32 | let fill = Color(hex: d.color) ?? ink.opacity(0.6) | ||
| 33 | return Canvas { context, size in | ||
| 34 | let earthKm = 6371.0 | ||
| 35 | let ringed = d.rings == true | ||
| 36 | // Rings reach about 2.2 body radii; leave room for the Earth label underneath. | ||
| 37 | let extent = ringed ? 2.25 : 1.0 | ||
| 38 | let maxR = min(size.height / 2 - 16, (size.width - 24) / (2 * extent)) | ||
| 39 | let scale = maxR / max(radius, earthKm) | ||
| 40 | let r = radius * scale, er = max(earthKm * scale, 1.5) | ||
| 41 | let c = CGPoint(x: size.width / 2, y: size.height / 2 - 8) | ||
| 42 | if ringed { | ||
| 43 | let ring = Path(ellipseIn: CGRect(x: c.x - r * 2.2, y: c.y - r * 0.45, width: r * 4.4, height: r * 0.9)) | ||
| 44 | context.stroke(ring, with: .color(ink.opacity(0.7)), lineWidth: 1) | ||
| 45 | } | ||
| 46 | context.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(fill)) | ||
| 47 | context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.8)), lineWidth: 0.8) | ||
| 48 | if let tilt = d.axialTiltDeg { | ||
| 49 | let a = tilt * .pi / 180 | ||
| 50 | var axis = Path() | ||
| 51 | axis.move(to: CGPoint(x: c.x - sin(a) * r * 1.25, y: c.y - cos(a) * r * 1.25)) | ||
| 52 | axis.addLine(to: CGPoint(x: c.x + sin(a) * r * 1.25, y: c.y + cos(a) * r * 1.25)) | ||
| 53 | context.stroke(axis, with: .color(accent), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3])) | ||
| 54 | } | ||
| 55 | if page.id != "earth" { | ||
| 56 | let ec = CGPoint(x: size.width - er - 6, y: size.height - er - 16) | ||
| 57 | context.fill(Path(ellipseIn: CGRect(x: ec.x - er, y: ec.y - er, width: 2 * er, height: 2 * er)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95))) | ||
| 58 | context.draw(Text("Earth").font(.system(size: 9)).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: size.width - 2, y: size.height - 6), anchor: .trailing) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | .frame(height: 200) | ||
| 62 | .accessibilityLabel("\(page.title) drawn to scale with Earth") | ||
| 63 | } | ||
| 64 | |||
| 65 | // MARK: Constellations | ||
| 66 | |||
| 67 | private var constellation: some View { | ||
| 68 | let lines = Catalog.constellations.first { $0.id == page.link.constellation }?.lines ?? [] | ||
| 69 | let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) }) | ||
| 70 | let stars = Set(lines.joined()).compactMap { byHR[$0] } | ||
| 71 | return Canvas { context, size in | ||
| 72 | guard !stars.isEmpty else { return } | ||
| 73 | // Gnomonic projection about the figure's centre. | ||
| 74 | func unit(_ s: Star) -> SIMD3<Double> { | ||
| 75 | let ra = s.ra * .pi / 180, dec = s.dec * .pi / 180 | ||
| 76 | return SIMD3(cos(dec) * cos(ra), cos(dec) * sin(ra), sin(dec)) | ||
| 77 | } | ||
| 78 | var c = stars.map(unit).reduce(SIMD3<Double>(repeating: 0), +) | ||
| 79 | c /= (c * c).sum().squareRoot() | ||
| 80 | let e0 = SIMD3(-c.y, c.x, 0) | ||
| 81 | let east = simd_length(e0) < 1e-6 ? SIMD3<Double>(0, 1, 0) : simd_normalize(e0) | ||
| 82 | let north = SIMD3(c.y * east.z - c.z * east.y, c.z * east.x - c.x * east.z, c.x * east.y - c.y * east.x) | ||
| 83 | func plane(_ s: Star) -> SIMD2<Double> { | ||
| 84 | let v = unit(s), k = (v * c).sum() | ||
| 85 | // East to the left, as on the sky. | ||
| 86 | return SIMD2(-(v * east).sum() / k, (v * north).sum() / k) | ||
| 87 | } | ||
| 88 | let pts = Dictionary(uniqueKeysWithValues: stars.map { ($0.hr, plane($0)) }) | ||
| 89 | let xs = pts.values.map(\.x), ys = pts.values.map(\.y) | ||
| 90 | let span = max(xs.max()! - xs.min()!, ys.max()! - ys.min()!, 1e-6) | ||
| 91 | let scale = (min(size.width, size.height) - 24) / span | ||
| 92 | let mid = SIMD2((xs.max()! + xs.min()!) / 2, (ys.max()! + ys.min()!) / 2) | ||
| 93 | func point(_ hr: Int) -> CGPoint? { | ||
| 94 | pts[hr].map { CGPoint(x: size.width / 2 + ($0.x - mid.x) * scale, y: size.height / 2 - ($0.y - mid.y) * scale) } | ||
| 95 | } | ||
| 96 | var path = Path() | ||
| 97 | for line in lines { | ||
| 98 | for (a, b) in zip(line, line.dropFirst()) { | ||
| 99 | guard let pa = point(a), let pb = point(b) else { continue } | ||
| 100 | path.move(to: pa) | ||
| 101 | path.addLine(to: pb) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | context.stroke(path, with: .color(ink.opacity(0.45)), lineWidth: 0.8) | ||
| 105 | for s in stars { | ||
| 106 | guard let p = point(s.hr) else { continue } | ||
| 107 | let r = max(1.2, 4.2 - 0.6 * s.magnitude) | ||
| 108 | context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: 2 * r, height: 2 * r)), with: .color(ink)) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | .frame(height: 240) | ||
| 112 | .accessibilityLabel("Figure of \(page.title)") | ||
| 113 | } | ||
| 114 | |||
| 115 | // MARK: Stars | ||
| 116 | |||
| 117 | private var star: some View { | ||
| 118 | let bv = page.drawing?.colorIndex ?? 0.6 | ||
| 119 | let t = min(max((bv + 0.3) / 2.0, 0), 1) | ||
| 120 | let color = Color(red: 0.75 + 0.25 * t, green: 0.82 - 0.1 * t, blue: 1.0 - 0.45 * t) | ||
| 121 | return VStack(spacing: 8) { | ||
| 122 | Circle().fill(color).frame(width: 90, height: 90) | ||
| 123 | if let sp = page.drawing?.spectralType { | ||
| 124 | Text("Spectral type \(sp) · B−V \(String(format: "%.2f", bv))").font(.system(size: 11)).opacity(0.6) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | .frame(height: 140) | ||
| 128 | } | ||
| 129 | |||
| 130 | // MARK: Deep sky | ||
| 131 | |||
| 132 | /// The object's apparent size beside the full Moon (about 31′). | ||
| 133 | private var deepSky: some View { | ||
| 134 | let d = page.drawing! | ||
| 135 | let size = d.sizeArcmin ?? 5 | ||
| 136 | return Canvas { context, canvas in | ||
| 137 | let moon = 31.0 | ||
| 138 | let scale = (canvas.height - 30) / max(size, moon) | ||
| 139 | let r = size * scale / 2, mr = moon * scale / 2 | ||
| 140 | let c = CGPoint(x: canvas.width / 2 - mr - 10, y: canvas.height / 2 - 8) | ||
| 141 | let m = CGPoint(x: canvas.width / 2 + r + 10, y: canvas.height / 2 - 8) | ||
| 142 | let rect = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r) | ||
| 143 | switch d.type { | ||
| 144 | case "galaxy": | ||
| 145 | context.fill(Path(ellipseIn: rect.insetBy(dx: 0, dy: r * 0.6)), with: .color(ink.opacity(0.5))) | ||
| 146 | case "planetary": | ||
| 147 | context.stroke(Path(ellipseIn: rect), with: .color(ink.opacity(0.8)), lineWidth: max(1, r * 0.25)) | ||
| 148 | case "globular", "open": | ||
| 149 | context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(d.type == "globular" ? 0.45 : 0.15))) | ||
| 150 | default: | ||
| 151 | context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(0.3))) | ||
| 152 | } | ||
| 153 | context.stroke(Path(ellipseIn: CGRect(x: m.x - mr, y: m.y - mr, width: 2 * mr, height: 2 * mr)), with: .color(ink.opacity(0.5)), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3])) | ||
| 154 | context.draw(Text("\(Int(size.rounded()))′").font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: canvas.height - 10)) | ||
| 155 | context.draw(Text("Full Moon").font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: canvas.height - 10)) | ||
| 156 | } | ||
| 157 | .frame(height: 170) | ||
| 158 | .accessibilityLabel("\(page.title) apparent size compared with the full Moon") | ||
| 159 | } | ||
| 160 | |||
| 161 | // MARK: Missions | ||
| 162 | |||
| 163 | private var mission: some View { | ||
| 164 | let d = page.drawing! | ||
| 165 | return HStack(spacing: 10) { | ||
| 166 | Text((d.status ?? "").uppercased()) | ||
| 167 | .font(.system(size: 10, weight: .medium)).tracking(0.6) | ||
| 168 | .padding(.horizontal, 6).padding(.vertical, 2) | ||
| 169 | .foregroundStyle(d.status == "active" ? Color.black : ink) | ||
| 170 | .background(d.status == "active" ? ink : ink.opacity(0.15), in: .rect(cornerRadius: 2)) | ||
| 171 | if let launch = d.launch { Text("Launched \(launch)").font(.system(size: 13).monospacedDigit()) } | ||
| 172 | Spacer() | ||
| 173 | } | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | extension Color { | ||
| 178 | init?(hex: String?) { | ||
| 179 | guard let hex, hex.hasPrefix("#"), hex.count == 7, let v = UInt32(hex.dropFirst(), radix: 16) else { return nil } | ||
| 180 | self.init(red: Double(v >> 16 & 0xFF) / 255, green: Double(v >> 8 & 0xFF) / 255, blue: Double(v & 0xFF) / 255) | ||
| 181 | } | ||
| 182 | } | ||
App/Sources/SkyScreen.swift +19
| @@ -1,3 +1,4 @@ | |||
| 1 | import Library | ||
| 1 | import SkyEngine | 2 | import SkyEngine |
| 2 | import SkyRenderer | 3 | import SkyRenderer |
| 3 | import SwiftUI | 4 | import SwiftUI |
| @@ -10,6 +11,7 @@ struct SkyScreen: View { | |||
| 10 | @State private var fieldOfView = 100.0 | 11 | @State private var fieldOfView = 100.0 |
| 11 | @State private var night = false | 12 | @State private var night = false |
| 12 | @State private var showToday = false | 13 | @State private var showToday = false |
| 14 | @State private var libraryPage: LibraryPage? | ||
| 13 | @State private var selection: SkyObject? | 15 | @State private var selection: SkyObject? |
| 14 | @State private var details: BodyDetails? | 16 | @State private var details: BodyDetails? |
| 15 | @GestureState private var drag: CGSize = .zero | 17 | @GestureState private var drag: CGSize = .zero |
| @@ -44,6 +46,9 @@ struct SkyScreen: View { | |||
| 44 | } | 46 | } |
| 45 | } | 47 | } |
| 46 | .foregroundStyle(ink) | 48 | .foregroundStyle(ink) |
| 49 | .sheet(item: $libraryPage) { page in | ||
| 50 | NavigationStack { LibraryPageView(page: page) } | ||
| 51 | } | ||
| 47 | .sheet(isPresented: $showToday) { | 52 | .sheet(isPresented: $showToday) { |
| 48 | TodaySheet(model: model, ink: ink, background: style.ground) | 53 | TodaySheet(model: model, ink: ink, background: style.ground) |
| 49 | .presentationDetents([.medium, .large]) | 54 | .presentationDetents([.medium, .large]) |
| @@ -149,12 +154,26 @@ struct SkyScreen: View { | |||
| 149 | case .constellation(let id): | 154 | case .constellation(let id): |
| 150 | Text("Constellation · \(id)").font(.system(size: 13)).opacity(0.7) | 155 | Text("Constellation · \(id)").font(.system(size: 13)).opacity(0.7) |
| 151 | } | 156 | } |
| 157 | if let page = libraryPage(for: object) { | ||
| 158 | Button { libraryPage = page } label: { | ||
| 159 | Label("Read more", systemImage: "book").font(.system(size: 13, weight: .medium)) | ||
| 160 | } | ||
| 161 | .buttonStyle(.plain) | ||
| 162 | .padding(.top, 4) | ||
| 163 | } | ||
| 152 | } | 164 | } |
| 153 | .padding(16) | 165 | .padding(16) |
| 154 | .background(style.background.opacity(0.92), in: .rect(cornerRadius: 12)) | 166 | .background(style.background.opacity(0.92), in: .rect(cornerRadius: 12)) |
| 155 | .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5)) | 167 | .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5)) |
| 156 | } | 168 | } |
| 157 | 169 | ||
| 170 | private func libraryPage(for object: SkyObject) -> LibraryPage? { | ||
| 171 | switch object { | ||
| 172 | case .body(let b): Library.page(body: b.rawValue) | ||
| 173 | case .constellation(let id): Library.page(constellation: id) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 158 | private func title(for object: SkyObject) -> String { | 177 | private func title(for object: SkyObject) -> String { |
| 159 | switch object { | 178 | switch object { |
| 160 | case .body(let b): b.rawValue.capitalized | 179 | case .body(let b): b.rawValue.capitalized |
App/Sources/SystemScreen.swift +10 −1
| @@ -1,3 +1,4 @@ | |||
| 1 | import Library | ||
| 1 | import SkyRenderer | 2 | import SkyRenderer |
| 2 | import SwiftUI | 3 | import SwiftUI |
| 3 | 4 | ||
| @@ -5,6 +6,7 @@ struct SystemScreen: View { | |||
| 5 | /// Days from now shown by the slider. | 6 | /// Days from now shown by the slider. |
| 6 | @State private var offsetDays = 0.0 | 7 | @State private var offsetDays = 0.0 |
| 7 | @State private var playing = false | 8 | @State private var playing = false |
| 9 | @State private var libraryPage: LibraryPage? | ||
| 8 | 10 | ||
| 9 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | 11 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 10 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | 12 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| @@ -53,7 +55,11 @@ struct SystemScreen: View { | |||
| 53 | .font(.system(size: 11)) | 55 | .font(.system(size: 11)) |
| 54 | ForEach(Spacecraft.all) { craft in | 56 | ForEach(Spacecraft.all) { craft in |
| 55 | GridRow { | 57 | GridRow { |
| 56 | Text(craft.name) | 58 | if let page = Library.page(spacecraft: craft.name) { |
| 59 | Button { libraryPage = page } label: { Text(craft.name).underline() }.buttonStyle(.plain) | ||
| 60 | } else { | ||
| 61 | Text(craft.name) | ||
| 62 | } | ||
| 57 | if let d = craft.distance(at: date) { | 63 | if let d = craft.distance(at: date) { |
| 58 | Text(String(format: "%.1f AU", d)).monospacedDigit() | 64 | Text(String(format: "%.1f AU", d)).monospacedDigit() |
| 59 | Text(lightTime(au: d)).monospacedDigit() | 65 | Text(lightTime(au: d)).monospacedDigit() |
| @@ -73,6 +79,9 @@ struct SystemScreen: View { | |||
| 73 | } | 79 | } |
| 74 | .foregroundStyle(ink) | 80 | .foregroundStyle(ink) |
| 75 | .background(background.ignoresSafeArea()) | 81 | .background(background.ignoresSafeArea()) |
| 82 | .sheet(item: $libraryPage) { page in | ||
| 83 | NavigationStack { LibraryPageView(page: page) } | ||
| 84 | } | ||
| 76 | .task(id: playing) { | 85 | .task(id: playing) { |
| 77 | while playing && !Task.isCancelled { | 86 | while playing && !Task.isCancelled { |
| 78 | try? await Task.sleep(for: .milliseconds(33)) | 87 | try? await Task.sleep(for: .milliseconds(33)) |
App/project.yml +2
| @@ -18,6 +18,8 @@ targets: | |||
| 18 | product: SkyRenderer | 18 | product: SkyRenderer |
| 19 | - package: MoonriseKit | 19 | - package: MoonriseKit |
| 20 | product: Feeds | 20 | product: Feeds |
| 21 | - package: MoonriseKit | ||
| 22 | product: Library | ||
| 21 | - target: MoonriseWidgets | 23 | - target: MoonriseWidgets |
| 22 | entitlements: | 24 | entitlements: |
| 23 | path: Sources/Moonrise.entitlements | 25 | path: Sources/Moonrise.entitlements |
Packages/MoonriseKit/Package.swift +3
| @@ -8,6 +8,7 @@ let package = Package( | |||
| 8 | .library(name: "SkyEngine", targets: ["SkyEngine"]), | 8 | .library(name: "SkyEngine", targets: ["SkyEngine"]), |
| 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), | 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), |
| 10 | .library(name: "Feeds", targets: ["Feeds"]), | 10 | .library(name: "Feeds", targets: ["Feeds"]), |
| 11 | .library(name: "Library", targets: ["Library"]), | ||
| 11 | ], | 12 | ], |
| 12 | targets: [ | 13 | targets: [ |
| 13 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy | 14 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy |
| @@ -23,8 +24,10 @@ let package = Package( | |||
| 23 | resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json")] | 24 | resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json")] |
| 24 | ), | 25 | ), |
| 25 | .target(name: "Feeds"), | 26 | .target(name: "Feeds"), |
| 27 | .target(name: "Library", resources: [.copy("Resources/pages")]), | ||
| 26 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), | 28 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
| 27 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"]), | 29 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"]), |
| 30 | .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]), | ||
| 28 | .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]), | 31 | .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]), |
| 29 | ] | 32 | ] |
| 30 | ) | 33 | ) |
Packages/MoonriseKit/Sources/Library/Library.swift added +138
| @@ -0,0 +1,138 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A reference page. Schema and writing rules: docs/library-content.org. | ||
| 4 | public 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 | } | ||
| 40 | |||
| 41 | /// What the page describes elsewhere in the app. | ||
| 42 | public struct Link: Codable, Sendable, Hashable { | ||
| 43 | public var body: String? | ||
| 44 | public var constellation: String? | ||
| 45 | public var hr: Int? | ||
| 46 | public var messier: Int? | ||
| 47 | public var spacecraft: String? | ||
| 48 | } | ||
| 49 | |||
| 50 | public var id: String | ||
| 51 | public var section: LibrarySection | ||
| 52 | public var title: String | ||
| 53 | public var subtitle: String | ||
| 54 | public var facts: [Fact] | ||
| 55 | public var essay: String | ||
| 56 | public var image: Image? | ||
| 57 | public var drawing: Drawing? | ||
| 58 | public var link: Link | ||
| 59 | public var sources: [Source] | ||
| 60 | |||
| 61 | public var paragraphs: [String] { | ||
| 62 | essay.components(separatedBy: "\n\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } | ||
| 63 | } | ||
| 64 | |||
| 65 | public var wordCount: Int { | ||
| 66 | essay.split { $0.isWhitespace }.count | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiable { | ||
| 71 | case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", missions | ||
| 72 | |||
| 73 | public var id: String { rawValue } | ||
| 74 | |||
| 75 | public var title: String { | ||
| 76 | switch self { | ||
| 77 | case .sun: "The Sun" | ||
| 78 | case .planets: "Planets" | ||
| 79 | case .dwarfPlanets: "Dwarf planets" | ||
| 80 | case .moons: "Moons" | ||
| 81 | case .constellations: "Constellations" | ||
| 82 | case .stars: "Stars" | ||
| 83 | case .deepSky: "Deep sky" | ||
| 84 | case .missions: "Missions" | ||
| 85 | } | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | public enum Library { | ||
| 90 | /// All pages from the bundled `pages/*.json` files, in file order within each section. | ||
| 91 | public static let pages: [LibraryPage] = { | ||
| 92 | guard let dir = Bundle.module.url(forResource: "pages", withExtension: nil), | ||
| 93 | let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] } | ||
| 94 | return files | ||
| 95 | .filter { $0.pathExtension == "json" } | ||
| 96 | .sorted { $0.lastPathComponent < $1.lastPathComponent } | ||
| 97 | .flatMap { url -> [LibraryPage] in | ||
| 98 | do { | ||
| 99 | return try JSONDecoder().decode([LibraryPage].self, from: Data(contentsOf: url)) | ||
| 100 | } catch { | ||
| 101 | assertionFailure("\(url.lastPathComponent): \(error)") | ||
| 102 | return [] | ||
| 103 | } | ||
| 104 | } | ||
| 105 | }() | ||
| 106 | |||
| 107 | public static func pages(in section: LibrarySection) -> [LibraryPage] { | ||
| 108 | pages.filter { $0.section == section } | ||
| 109 | } | ||
| 110 | |||
| 111 | public static func page(id: String) -> LibraryPage? { | ||
| 112 | pages.first { $0.id == id } | ||
| 113 | } | ||
| 114 | |||
| 115 | public static func page(body: String) -> LibraryPage? { | ||
| 116 | pages.first { $0.link.body == body } | ||
| 117 | } | ||
| 118 | |||
| 119 | public static func page(constellation: String) -> LibraryPage? { | ||
| 120 | pages.first { $0.link.constellation == constellation } | ||
| 121 | } | ||
| 122 | |||
| 123 | public static func page(spacecraft: String) -> LibraryPage? { | ||
| 124 | pages.first { $0.link.spacecraft == spacecraft } | ||
| 125 | } | ||
| 126 | |||
| 127 | /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay. | ||
| 128 | public static func search(_ query: String) -> [LibraryPage] { | ||
| 129 | let q = query.trimmingCharacters(in: .whitespaces) | ||
| 130 | guard !q.isEmpty else { return [] } | ||
| 131 | let opts: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] | ||
| 132 | let strong = pages.filter { $0.title.range(of: q, options: opts) != nil || $0.subtitle.range(of: q, options: opts) != nil || $0.id == q.lowercased() } | ||
| 133 | let weak = pages.filter { p in | ||
| 134 | !strong.contains(p) && (p.essay.range(of: q, options: opts) != nil || p.facts.contains { $0.value.range(of: q, options: opts) != nil }) | ||
| 135 | } | ||
| 136 | return strong + weak | ||
| 137 | } | ||
| 138 | } | ||
Packages/MoonriseKit/Sources/Library/Resources/pages/.gitkeep added
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift added +49
| @@ -0,0 +1,49 @@ | |||
| 1 | import Foundation | ||
| 2 | import SkyEngine | ||
| 3 | import Testing | ||
| 4 | @testable import Library | ||
| 5 | @testable import SkyRenderer | ||
| 6 | |||
| 7 | // Checks every bundled page against the rules in docs/library-content.org. | ||
| 8 | |||
| 9 | let allowedLicences: Set<String> = ["Public domain", "CC BY 4.0", "CC BY-SA 3.0 IGO"] | ||
| 10 | |||
| 11 | @Test func idsAreUnique() { | ||
| 12 | let ids = Library.pages.map(\.id) | ||
| 13 | #expect(Set(ids).count == ids.count, "duplicates: \(Dictionary(grouping: ids, by: { $0 }).filter { $1.count > 1 }.keys.sorted())") | ||
| 14 | } | ||
| 15 | |||
| 16 | @Test(arguments: Library.pages) | ||
| 17 | func pageFollowsTheBrief(_ p: LibraryPage) { | ||
| 18 | #expect(!p.title.isEmpty && !p.subtitle.isEmpty, "\(p.id): title/subtitle") | ||
| 19 | #expect((1...5).contains(p.sources.count), "\(p.id): \(p.sources.count) sources") | ||
| 20 | #expect((3...9).contains(p.facts.count), "\(p.id): \(p.facts.count) facts") | ||
| 21 | for f in p.facts { | ||
| 22 | #expect(p.sources.indices.contains(f.source), "\(p.id): fact '\(f.label)' cites missing source \(f.source)") | ||
| 23 | } | ||
| 24 | #expect(!p.sources.contains { $0.url.host()?.contains("wikipedia.org") == true }, "\(p.id): Wikipedia as a source") | ||
| 25 | #expect((120...300).contains(p.wordCount), "\(p.id): essay \(p.wordCount) words") | ||
| 26 | if let image = p.image { | ||
| 27 | #expect(allowedLicences.contains(image.licence), "\(p.id): licence \(image.licence)") | ||
| 28 | #expect(image.url.scheme == "https", "\(p.id): image URL") | ||
| 29 | #expect(!image.credit.isEmpty, "\(p.id): image credit") | ||
| 30 | } else { | ||
| 31 | #expect([.constellations, .stars, .missions].contains(p.section), "\(p.id): missing image") | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | @Test(arguments: Library.pages) | ||
| 36 | func linksPointAtThingsTheAppKnows(_ p: LibraryPage) { | ||
| 37 | let l = p.link | ||
| 38 | if let body = l.body { #expect(Body(rawValue: body) != nil, "\(p.id): body \(body)") } | ||
| 39 | if let c = l.constellation { #expect(constellationNames[c] != nil, "\(p.id): constellation \(c)") } | ||
| 40 | if let hr = l.hr { #expect(Catalog.stars.contains { $0.hr == hr }, "\(p.id): HR \(hr)") } | ||
| 41 | if let m = l.messier { #expect((1...110).contains(m), "\(p.id): M\(m)") } | ||
| 42 | if let s = l.spacecraft { #expect(Spacecraft.all.contains { $0.name == s }, "\(p.id): spacecraft \(s)") } | ||
| 43 | } | ||
| 44 | |||
| 45 | @Test func searchFindsByTitleBeforeEssay() { | ||
| 46 | guard let first = Library.pages.first else { return } | ||
| 47 | #expect(Library.search(first.title).first?.id == first.id) | ||
| 48 | #expect(Library.search(" ").isEmpty) | ||
| 49 | } | ||
docs/library-content.org added +76
| @@ -0,0 +1,76 @@ | |||
| 1 | #+title: Library content brief | ||
| 2 | |||
| 3 | Pages live in =Packages/MoonriseKit/Sources/Library/Resources/pages/<section>.json=, | ||
| 4 | one file per section, each a JSON array of pages. | ||
| 5 | |||
| 6 | * Page schema | ||
| 7 | #+begin_src json | ||
| 8 | { | ||
| 9 | "id": "mars", | ||
| 10 | "section": "planets", | ||
| 11 | "title": "Mars", | ||
| 12 | "subtitle": "Fourth planet from the Sun", | ||
| 13 | "facts": [ | ||
| 14 | { "label": "Mean distance from the Sun", "value": "1.524 AU (227.9 million km)", "source": 0 } | ||
| 15 | ], | ||
| 16 | "essay": "Plain text, paragraphs separated by a blank line.", | ||
| 17 | "image": { | ||
| 18 | "url": "https://… direct .jpg or .png", | ||
| 19 | "credit": "NASA/JPL-Caltech", | ||
| 20 | "licence": "Public domain", | ||
| 21 | "page": "https://… page the image is published on", | ||
| 22 | "caption": "One sentence on what the image shows." | ||
| 23 | }, | ||
| 24 | "drawing": { "kind": "planet", "radiusKm": 3389.5, "rings": false, "axialTiltDeg": 25.19, "color": "#C1440E" }, | ||
| 25 | "link": { "body": "mars" }, | ||
| 26 | "sources": [ | ||
| 27 | { "title": "NASA Mars Fact Sheet", "url": "https://nssdc.gsfc.nasa.gov/planetary/factsheet/marsfact.html" } | ||
| 28 | ] | ||
| 29 | } | ||
| 30 | #+end_src | ||
| 31 | |||
| 32 | - =id=: lowercase, hyphenated, unique across all sections (e.g. =m42=, =ori=, =sirius=, =voyager-1=). | ||
| 33 | - =section=: one of =sun=, =planets=, =dwarf-planets=, =moons=, =constellations=, =stars=, =deep-sky=, =missions=. | ||
| 34 | - =facts=: 4–8 items. Every fact has =source=, an index into =sources=. Units in SI or AU, with conversions where helpful. Use tabular style: "10 h 33 m", "29.4 Earth years". | ||
| 35 | - =sources=: 1–5 items, primary or authoritative (NASA NSSDC fact sheets, NASA science pages, JPL, IAU, ESA, NOIRLab, SIMBAD). Every URL must return HTTP 200 when checked. | ||
| 36 | - =image=: optional only for constellations, stars and missions when no verifiable image exists (set to =null=). | ||
| 37 | - =drawing=: per section: | ||
| 38 | - planets, dwarf planets, moons, sun: ={"kind": "planet", "radiusKm", "rings", "axialTiltDeg", "color"}=; =color= is a representative hex. | ||
| 39 | - constellations: ={"kind": "constellation"}= (the app draws the figure from its line data). | ||
| 40 | - stars: ={"kind": "star", "spectralType": "A1V", "colorIndex": 0.0}= (B−V). | ||
| 41 | - deep sky: ={"kind": "deep-sky", "type": "galaxy|globular|open|nebula|planetary|supernova-remnant", "sizeArcmin": 90}=. | ||
| 42 | - missions: ={"kind": "mission", "status": "active|complete|lost", "launch": "1977-09-05"}=. | ||
| 43 | - =link=: what the page describes in the rest of the app: ={"body": "mars"}=, ={"constellation": "Ori"}= (IAU abbreviation), ={"hr": 2491}= (Bright Star Catalogue number), ={"messier": 42}=, ={"spacecraft": "Voyager 1"}=, or ={}=. | ||
| 44 | |||
| 45 | * Essay voice | ||
| 46 | - 150–250 words, two or three short paragraphs. | ||
| 47 | - Plain, direct English, as a knowledgeable colleague would write. No hype, | ||
| 48 | no dramatic framing, no rhetorical questions, no "fascinating", | ||
| 49 | "stunning", "mysterious", "breathtaking". | ||
| 50 | - Concrete facts over adjectives: what it is, how we know, one or two | ||
| 51 | things that make it distinctive, and for observable objects how to find | ||
| 52 | it or what it looks like to the eye or a small telescope. | ||
| 53 | - Original wording. Draw on public-domain NASA/JPL text and on facts | ||
| 54 | from other sources, but never copy sentences from Wikipedia (CC BY-SA) | ||
| 55 | or any copyrighted text. | ||
| 56 | - Present tense for current facts; dates as "1977", "2034". | ||
| 57 | |||
| 58 | * Images and licences | ||
| 59 | Allowed, in order of preference: | ||
| 60 | 1. NASA (images-api.nasa.gov, images-assets.nasa.gov, science.nasa.gov, | ||
| 61 | photojournal.jpl.nasa.gov): =licence: "Public domain"=. Skip images | ||
| 62 | whose NASA page credits a third party or says they are copyrighted. | ||
| 63 | 2. ESA/Hubble (esahubble.org), ESA/Webb (esawebb.org), NOIRLab | ||
| 64 | (noirlab.edu), ESO (eso.org): =licence: "CC BY 4.0"=, =credit= copied | ||
| 65 | exactly from the image page. No images showing identifiable people. | ||
| 66 | 3. Historical star atlas plates in the public domain (Library of | ||
| 67 | Congress, Linda Hall Library) for constellations: =licence: "Public domain"=. | ||
| 68 | |||
| 69 | Use a direct file URL of a moderate size (roughly 800–2000 px; NASA | ||
| 70 | =~medium.jpg= or =~large.jpg=, ESA "large"/"screen" sizes). Check every | ||
| 71 | image URL with =curl -sI= and keep only HTTP 200 with an image content type. | ||
| 72 | |||
| 73 | * Do not | ||
| 74 | - Invent values. If a fact cannot be sourced, leave it out. | ||
| 75 | - Use Wikipedia as a =sources= entry. | ||
| 76 | - Touch any file other than your own section file. No git commands. | ||
scripts/check-library.py added +61
| @@ -0,0 +1,61 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Check Library pages: JSON shape, and that every source and image URL resolves. | ||
| 3 | |||
| 4 | Usage: scripts/check-library.py [--offline] | ||
| 5 | """ | ||
| 6 | import json | ||
| 7 | import pathlib | ||
| 8 | import sys | ||
| 9 | import urllib.request | ||
| 10 | from concurrent.futures import ThreadPoolExecutor | ||
| 11 | |||
| 12 | PAGES = pathlib.Path(__file__).resolve().parent.parent / "Packages/MoonriseKit/Sources/Library/Resources/pages" | ||
| 13 | UA = {"User-Agent": "moonrise-library-check (sh.krz.moonrise)"} | ||
| 14 | |||
| 15 | |||
| 16 | def status(url, image=False): | ||
| 17 | for method in ("HEAD", "GET"): | ||
| 18 | try: | ||
| 19 | req = urllib.request.Request(url, method=method, headers=UA) | ||
| 20 | with urllib.request.urlopen(req, timeout=20) as r: | ||
| 21 | kind = r.headers.get("content-type", "") | ||
| 22 | if image and not kind.startswith("image/"): | ||
| 23 | return f"{r.status} {kind}" | ||
| 24 | return r.status | ||
| 25 | except Exception as e: | ||
| 26 | err = getattr(e, "code", None) or type(e).__name__ | ||
| 27 | return err | ||
| 28 | |||
| 29 | |||
| 30 | def main(): | ||
| 31 | offline = "--offline" in sys.argv | ||
| 32 | pages, problems = [], [] | ||
| 33 | for f in sorted(PAGES.glob("*.json")): | ||
| 34 | for p in json.loads(f.read_text()): | ||
| 35 | pages.append((f.name, p)) | ||
| 36 | print(f"{len(pages)} pages in {len(list(PAGES.glob('*.json')))} files") | ||
| 37 | if offline: | ||
| 38 | return 0 | ||
| 39 | checks = [] | ||
| 40 | for name, p in pages: | ||
| 41 | for s in p.get("sources", []): | ||
| 42 | checks.append((p["id"], "source", s["url"], False)) | ||
| 43 | if p.get("image"): | ||
| 44 | checks.append((p["id"], "image", p["image"]["url"], True)) | ||
| 45 | checks.append((p["id"], "image page", p["image"]["page"], False)) | ||
| 46 | with ThreadPoolExecutor(8) as pool: | ||
| 47 | results = list(pool.map(lambda c: (c, status(c[2], c[3])), checks)) | ||
| 48 | for (pid, kind, url, _), st in results: | ||
| 49 | # loc.gov item pages send scripts to a bot check (403); the items exist. | ||
| 50 | if st == 403 and kind == "image page" and url.startswith("https://www.loc.gov/item/"): | ||
| 51 | continue | ||
| 52 | if st != 200: | ||
| 53 | problems.append(f"{pid}: {kind} {st} {url}") | ||
| 54 | print(f"{len(checks)} URLs checked, {len(problems)} problems") | ||
| 55 | for line in problems: | ||
| 56 | print(" " + line) | ||
| 57 | return 1 if problems else 0 | ||
| 58 | |||
| 59 | |||
| 60 | if __name__ == "__main__": | ||
| 61 | sys.exit(main()) | ||