Commit 63ddcc6d55
Unsigned
Layout: unified · split
App/Sources/EquipmentScreen.swift added +327
| @@ -0,0 +1,327 @@ | ||
| 1 | import Charts | |
| 2 | import ObservingLog | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// The equipment inventory lives in Documents/Equipment.org, one heading per item. | |
| 6 | let equipmentInventory = EquipmentInventory(file: URL.documentsDirectory.appending(path: "Equipment.org")) | |
| 7 | ||
| 8 | extension Equipment { | |
| 9 | /// One line of the specs that apply to this kind, e.g. "203 mm · 1200 mm · f/5.9". | |
| 10 | var specLine: String { | |
| 11 | var parts: [String] = [] | |
| 12 | for spec in kind.specs { | |
| 13 | switch spec { | |
| 14 | case .aperture: if let aperture { parts.append("\(Self.format(aperture)) mm aperture") } | |
| 15 | case .focalLength: if let focalLength { parts.append("\(Self.format(focalLength)) mm") } | |
| 16 | case .magnification: if let magnification { parts.append("\(Self.format(magnification))×") } | |
| 17 | case .fieldOfView: if let fieldOfView { parts.append("\(Self.format(fieldOfView))°") } | |
| 18 | case .sensor: if !sensor.isEmpty { parts.append(sensor) } | |
| 19 | } | |
| 20 | } | |
| 21 | if kind == .telescope, let focalRatio { parts.append("f/\(Self.format(focalRatio))") } | |
| 22 | return parts.joined(separator: " · ") | |
| 23 | } | |
| 24 | ||
| 25 | static func format(_ x: Double) -> String { x.formatted(.number.precision(.fractionLength(0...1))) } | |
| 26 | } | |
| 27 | ||
| 28 | extension [Equipment] { | |
| 29 | /// Grouped by kind in `Equipment.Kind` order, then by name. | |
| 30 | var byKind: [Equipment] { | |
| 31 | let order = Dictionary(uniqueKeysWithValues: Equipment.Kind.allCases.enumerated().map { ($1, $0) }) | |
| 32 | return sorted { order[$0.kind]! != order[$1.kind]! ? order[$0.kind]! < order[$1.kind]! : $0.name.localizedStandardCompare($1.name) == .orderedAscending } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | struct EquipmentScreen: View { | |
| 37 | @State private var items: [Equipment] = [] | |
| 38 | @State private var editing: Equipment? | |
| 39 | ||
| 40 | var body: some View { | |
| 41 | List { | |
| 42 | if items.isEmpty { | |
| 43 | Text("No equipment yet. Add your telescopes, binoculars, eyepieces and other gear, then pick them when you log an observation.") | |
| 44 | .font(.footnote).foregroundStyle(.secondary) | |
| 45 | } | |
| 46 | ForEach(Equipment.Kind.allCases) { kind in | |
| 47 | let group = items.filter { $0.kind == kind && !$0.retired } | |
| 48 | if !group.isEmpty { | |
| 49 | Section(kind.title) { rows(group) } | |
| 50 | } | |
| 51 | } | |
| 52 | let retired = items.filter(\.retired) | |
| 53 | if !retired.isEmpty { | |
| 54 | Section("Retired") { rows(retired) } | |
| 55 | } | |
| 56 | } | |
| 57 | .navigationTitle("Equipment") | |
| 58 | .navigationBarTitleDisplayMode(.inline) | |
| 59 | .toolbar { | |
| 60 | ToolbarItem(placement: .primaryAction) { | |
| 61 | Button { editing = Equipment(kind: .telescope, name: "") } label: { Image(systemName: "plus") } | |
| 62 | .accessibilityLabel("New equipment") | |
| 63 | } | |
| 64 | } | |
| 65 | .sheet(item: $editing) { item in | |
| 66 | EquipmentEditor(item: item) { reload() } | |
| 67 | } | |
| 68 | .onAppear(perform: reload) | |
| 69 | } | |
| 70 | ||
| 71 | private func rows(_ group: [Equipment]) -> some View { | |
| 72 | ForEach(group) { item in | |
| 73 | NavigationLink { EquipmentDetail(item: item, onChange: reload) } label: { | |
| 74 | VStack(alignment: .leading, spacing: 3) { | |
| 75 | Text(item.name) | |
| 76 | let line = item.retired ? [item.kind.title, item.specLine].filter { !$0.isEmpty }.joined(separator: " · ") : item.specLine | |
| 77 | if !line.isEmpty { Text(line).font(.footnote).foregroundStyle(.secondary) } | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | .onDelete { idx in | |
| 82 | for i in idx { try? equipmentInventory.delete(id: group[i].id) } | |
| 83 | reload() | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | private func reload() { items = equipmentInventory.all().byKind } | |
| 88 | } | |
| 89 | ||
| 90 | struct EquipmentDetail: View { | |
| 91 | @State var item: Equipment | |
| 92 | var onChange: () -> Void | |
| 93 | @State private var usage: EquipmentUsage? | |
| 94 | @State private var names: [String: String] = [:] | |
| 95 | @State private var editing: Equipment? | |
| 96 | ||
| 97 | var body: some View { | |
| 98 | List { | |
| 99 | Section { | |
| 100 | LabeledContent("Type", value: item.kind.title) | |
| 101 | if !item.specLine.isEmpty { LabeledContent("Specs", value: item.specLine) } | |
| 102 | if item.retired { LabeledContent("Status", value: "Retired") } | |
| 103 | if !item.notes.isEmpty { Text(item.notes).font(.footnote) } | |
| 104 | } | |
| 105 | if let usage { usageSections(usage) } | |
| 106 | } | |
| 107 | .navigationTitle(item.name) | |
| 108 | .navigationBarTitleDisplayMode(.inline) | |
| 109 | .toolbar { | |
| 110 | ToolbarItem(placement: .primaryAction) { Button("Edit") { editing = item } } | |
| 111 | } | |
| 112 | .sheet(item: $editing) { draft in | |
| 113 | EquipmentEditor(item: draft) { | |
| 114 | if let saved = equipmentInventory.all().first(where: { $0.id == draft.id }) { item = saved } | |
| 115 | onChange() | |
| 116 | } | |
| 117 | } | |
| 118 | .onAppear(perform: reload) | |
| 119 | } | |
| 120 | ||
| 121 | @ViewBuilder private func usageSections(_ u: EquipmentUsage) -> some View { | |
| 122 | Section("Usage") { | |
| 123 | LabeledContent("Observations", value: "\(u.observations)") | |
| 124 | if let first = u.first { LabeledContent("First used", value: first.formatted(date: .abbreviated, time: .omitted)) } | |
| 125 | if let last = u.last { LabeledContent("Last used", value: last.formatted(date: .abbreviated, time: .omitted)) } | |
| 126 | if u.observations > 0 { LabeledContent("Objects", value: "\(u.distinctObjects)") } | |
| 127 | if let seeing = u.averageSeeing { LabeledContent("Average seeing", value: "\(seeing.formatted(.number.precision(.fractionLength(1)))) of 5") } | |
| 128 | } | |
| 129 | if u.observations > 0 { | |
| 130 | Section("Most observed") { | |
| 131 | ForEach(u.topObjects.prefix(5), id: \.key) { LabeledContent($0.key, value: "\($0.count)") } | |
| 132 | } | |
| 133 | Section("By month") { | |
| 134 | Chart(Array(u.byMonth.enumerated()), id: \.offset) { m, count in | |
| 135 | BarMark(x: .value("Month", Calendar.current.shortMonthSymbols[m]), y: .value("Observations", count)) | |
| 136 | } | |
| 137 | .frame(height: 140) | |
| 138 | .accessibilityLabel("Observations by month") | |
| 139 | ForEach(EquipmentUsage.Season.allCases, id: \.self) { s in | |
| 140 | LabeledContent(s.title, value: "\(u.bySeason[s] ?? 0)") | |
| 141 | } | |
| 142 | } | |
| 143 | if !u.usedWith.isEmpty { | |
| 144 | Section("Used with") { | |
| 145 | ForEach(u.usedWith.prefix(5), id: \.key) { LabeledContent(names[$0.key] ?? "Deleted item", value: "\($0.count)") } | |
| 146 | } | |
| 147 | } | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | private func reload() { | |
| 152 | names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a }) | |
| 153 | usage = EquipmentUsage(of: item.id, in: observingLog.all()) | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | struct EquipmentEditor: View { | |
| 158 | @State var item: Equipment | |
| 159 | var onSave: () -> Void | |
| 160 | @Environment(\.dismiss) private var dismiss | |
| 161 | @State private var error: String? | |
| 162 | ||
| 163 | var body: some View { | |
| 164 | NavigationStack { | |
| 165 | Form { | |
| 166 | Section { | |
| 167 | Picker("Type", selection: $item.kind) { | |
| 168 | ForEach(Equipment.Kind.allCases) { Text($0.title).tag($0) } | |
| 169 | } | |
| 170 | TextField("Name", text: $item.name) | |
| 171 | } | |
| 172 | if !item.kind.specs.isEmpty { | |
| 173 | Section("Specs") { | |
| 174 | ForEach(item.kind.specs, id: \.self) { field($0) } | |
| 175 | if item.kind == .telescope, let ratio = item.focalRatio { | |
| 176 | LabeledContent("Focal ratio", value: "f/\(Equipment.format(ratio))") | |
| 177 | } | |
| 178 | } | |
| 179 | } | |
| 180 | Section("Notes") { | |
| 181 | TextEditor(text: $item.notes).frame(minHeight: 100) | |
| 182 | } | |
| 183 | Section { | |
| 184 | Toggle("Retired", isOn: $item.retired) | |
| 185 | } footer: { | |
| 186 | Text("Retired items leave the picker but stay on the observations that used them.") | |
| 187 | } | |
| 188 | if let error { Text(error).foregroundStyle(.red).font(.footnote) } | |
| 189 | } | |
| 190 | .navigationTitle(item.name.isEmpty ? "New equipment" : item.name) | |
| 191 | .navigationBarTitleDisplayMode(.inline) | |
| 192 | .toolbar { | |
| 193 | ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } | |
| 194 | ToolbarItem(placement: .confirmationAction) { | |
| 195 | Button("Save") { | |
| 196 | do { | |
| 197 | item.name = item.name.trimmingCharacters(in: .whitespaces) | |
| 198 | try equipmentInventory.save(item) | |
| 199 | onSave() | |
| 200 | dismiss() | |
| 201 | } catch { | |
| 202 | self.error = "Couldn't save: \(error.localizedDescription)" | |
| 203 | } | |
| 204 | } | |
| 205 | .disabled(item.name.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 206 | } | |
| 207 | } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | @ViewBuilder private func field(_ spec: Equipment.Spec) -> some View { | |
| 212 | switch spec { | |
| 213 | case .aperture: number("Aperture", unit: "mm", $item.aperture) | |
| 214 | case .focalLength: number("Focal length", unit: "mm", $item.focalLength) | |
| 215 | case .magnification: number(item.kind == .barlow ? "Factor" : "Magnification", unit: "×", $item.magnification) | |
| 216 | case .fieldOfView: number(item.kind == .eyepiece ? "Apparent field" : "Field of view", unit: "°", $item.fieldOfView) | |
| 217 | case .sensor: TextField("Sensor", text: $item.sensor) | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | private func number(_ title: String, unit: String, _ value: Binding<Double?>) -> some View { | |
| 222 | LabeledContent(title) { | |
| 223 | HStack(spacing: 4) { | |
| 224 | NumberField(title: title, value: value) | |
| 225 | Text(unit).foregroundStyle(.secondary) | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | /// Decimal entry that updates its binding on every keystroke, so Save never misses an uncommitted value. | |
| 232 | private struct NumberField: View { | |
| 233 | var title: String | |
| 234 | @Binding var value: Double? | |
| 235 | @State private var text = "" | |
| 236 | ||
| 237 | var body: some View { | |
| 238 | TextField("", text: $text) | |
| 239 | .keyboardType(.decimalPad) | |
| 240 | .multilineTextAlignment(.trailing) | |
| 241 | .accessibilityLabel(title) | |
| 242 | .onAppear { text = value.map { $0.formatted(.number.grouping(.never)) } ?? "" } | |
| 243 | .onChange(of: text) { value = try? Double(text, format: .number) } | |
| 244 | } | |
| 245 | } | |
| 246 | ||
| 247 | /// Multi-select over the inventory for one observation. | |
| 248 | struct EquipmentPicker: View { | |
| 249 | @Binding var observation: Observation | |
| 250 | @State private var items: [Equipment] = [] | |
| 251 | @State private var adding: Equipment? | |
| 252 | ||
| 253 | static let nakedEye = "Naked eye" | |
| 254 | ||
| 255 | /// Free text from an entry logged before the inventory existed. | |
| 256 | private var legacyText: String? { | |
| 257 | observation.equipmentIDs.isEmpty && !observation.equipment.isEmpty && observation.equipment != Self.nakedEye ? observation.equipment : nil | |
| 258 | } | |
| 259 | ||
| 260 | var body: some View { | |
| 261 | List { | |
| 262 | if let legacyText { | |
| 263 | Section { | |
| 264 | Text(legacyText) | |
| 265 | } header: { | |
| 266 | Text("Recorded") | |
| 267 | } footer: { | |
| 268 | Text("Picking items below replaces this.") | |
| 269 | } | |
| 270 | } | |
| 271 | Section { | |
| 272 | row(Self.nakedEye, selected: observation.equipmentIDs.isEmpty && observation.equipment == Self.nakedEye) { | |
| 273 | observation.equipmentIDs = [] | |
| 274 | observation.equipment = observation.equipment == Self.nakedEye ? "" : Self.nakedEye | |
| 275 | } | |
| 276 | } | |
| 277 | ForEach(Equipment.Kind.allCases) { kind in | |
| 278 | let group = items.filter { $0.kind == kind && (!$0.retired || observation.equipmentIDs.contains($0.id)) } | |
| 279 | if !group.isEmpty { | |
| 280 | Section(kind.title) { | |
| 281 | ForEach(group) { item in | |
| 282 | row(item.name, detail: item.specLine, selected: observation.equipmentIDs.contains(item.id)) { toggle(item) } | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
| 286 | } | |
| 287 | Section { | |
| 288 | Button { adding = Equipment(kind: .telescope, name: "") } label: { Label("Add equipment", systemImage: "plus") } | |
| 289 | } | |
| 290 | } | |
| 291 | .navigationTitle("Equipment") | |
| 292 | .navigationBarTitleDisplayMode(.inline) | |
| 293 | .sheet(item: $adding) { item in | |
| 294 | EquipmentEditor(item: item) { | |
| 295 | reload() | |
| 296 | if let saved = items.first(where: { $0.id == item.id }) { toggle(saved) } | |
| 297 | } | |
| 298 | } | |
| 299 | .onAppear(perform: reload) | |
| 300 | } | |
| 301 | ||
| 302 | private func row(_ title: String, detail: String = "", selected: Bool, action: @escaping () -> Void) -> some View { | |
| 303 | Button(action: action) { | |
| 304 | HStack { | |
| 305 | VStack(alignment: .leading, spacing: 3) { | |
| 306 | Text(title) | |
| 307 | if !detail.isEmpty { Text(detail).font(.footnote).foregroundStyle(.secondary) } | |
| 308 | } | |
| 309 | Spacer() | |
| 310 | if selected { Image(systemName: "checkmark").foregroundStyle(.tint) } | |
| 311 | } | |
| 312 | .contentShape(.rect) | |
| 313 | } | |
| 314 | .buttonStyle(.plain) | |
| 315 | .accessibilityAddTraits(selected ? .isSelected : []) | |
| 316 | } | |
| 317 | ||
| 318 | private func toggle(_ item: Equipment) { | |
| 319 | var ids = Set(observation.equipmentIDs) | |
| 320 | if ids.contains(item.id) { ids.remove(item.id) } else { ids.insert(item.id) } | |
| 321 | let chosen = items.filter { ids.contains($0.id) } | |
| 322 | observation.equipmentIDs = chosen.map(\.id) | |
| 323 | observation.equipment = chosen.map(\.name).joined(separator: ", ") | |
| 324 | } | |
| 325 | ||
| 326 | private func reload() { items = equipmentInventory.all().byKind } | |
| 327 | } | |
App/Sources/LogScreen.swift +16 −7
| @@ -36,6 +36,10 @@ struct LogScreen: View { | ||
| 36 | 36 | .navigationBarTitleDisplayMode(.inline) |
| 37 | 37 | .toolbar { |
| 38 | 38 | ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } |
| 39 | ToolbarItem(placement: .primaryAction) { | |
| 40 | NavigationLink { EquipmentScreen() } label: { Image(systemName: "binoculars") } | |
| 41 | .accessibilityLabel("Equipment") | |
| 42 | } | |
| 39 | 43 | ToolbarItem(placement: .primaryAction) { |
| 40 | 44 | Button { editing = newEntry() } label: { Image(systemName: "plus") } |
| 41 | 45 | .accessibilityLabel("New observation") |
| @@ -60,8 +64,14 @@ struct ObservationEditor: View { | ||
| 60 | 64 | var onSave: () -> Void |
| 61 | 65 | @Environment(\.dismiss) private var dismiss |
| 62 | 66 | @State private var error: String? |
| 67 | @State private var names: [String: String] = [:] | |
| 63 | 68 | |
| 64 | private static let equipmentChoices = ["Naked eye", "Binoculars", "Telescope", "Camera"] | |
| 69 | /// Current inventory names for linked items, else the text stored with the entry. | |
| 70 | private var equipmentSummary: String { | |
| 71 | let current = observation.equipmentIDs.compactMap { names[$0] } | |
| 72 | if !current.isEmpty, current.count == observation.equipmentIDs.count { return current.joined(separator: ", ") } | |
| 73 | return observation.equipment.isEmpty ? "Not noted" : observation.equipment | |
| 74 | } | |
| 65 | 75 | |
| 66 | 76 | var body: some View { |
| 67 | 77 | NavigationStack { |
| @@ -72,12 +82,8 @@ struct ObservationEditor: View { | ||
| 72 | 82 | TextField("Place", text: $observation.place) |
| 73 | 83 | } |
| 74 | 84 | Section("Conditions") { |
| 75 | Picker("Equipment", selection: $observation.equipment) { | |
| 76 | Text("Not noted").tag("") | |
| 77 | ForEach(Self.equipmentChoices, id: \.self) { Text($0).tag($0) } | |
| 78 | if !observation.equipment.isEmpty, !Self.equipmentChoices.contains(observation.equipment) { | |
| 79 | Text(observation.equipment).tag(observation.equipment) | |
| 80 | } | |
| 85 | NavigationLink { EquipmentPicker(observation: $observation) } label: { | |
| 86 | LabeledContent("Equipment", value: equipmentSummary) | |
| 81 | 87 | } |
| 82 | 88 | Picker("Seeing", selection: $observation.seeing) { |
| 83 | 89 | Text("Not noted").tag(Int?.none) |
| @@ -106,6 +112,9 @@ struct ObservationEditor: View { | ||
| 106 | 112 | .disabled(observation.object.trimmingCharacters(in: .whitespaces).isEmpty) |
| 107 | 113 | } |
| 108 | 114 | } |
| 115 | .onAppear { | |
| 116 | names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a }) | |
| 117 | } | |
| 109 | 118 | } |
| 110 | 119 | } |
| 111 | 120 | } |
Packages/MoonriseKit/Sources/ObservingLog/Equipment.swift added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One item of the user's gear. The inventory is one Org file with a heading per item. | |
| 4 | public struct Equipment: Sendable, Identifiable, Equatable, Hashable { | |
| 5 | public enum Kind: String, Sendable, CaseIterable, Identifiable { | |
| 6 | case telescope, binoculars, mount, eyepiece, barlow, camera, filter, other | |
| 7 | public var id: String { rawValue } | |
| 8 | ||
| 9 | public var title: String { | |
| 10 | switch self { | |
| 11 | case .telescope: "Telescope" | |
| 12 | case .binoculars: "Binoculars" | |
| 13 | case .mount: "Mount" | |
| 14 | case .eyepiece: "Eyepiece" | |
| 15 | case .barlow: "Barlow" | |
| 16 | case .camera: "Camera" | |
| 17 | case .filter: "Filter" | |
| 18 | case .other: "Other" | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | /// The spec fields that apply to this kind, in display order. | |
| 23 | public var specs: [Spec] { | |
| 24 | switch self { | |
| 25 | case .telescope: [.aperture, .focalLength] | |
| 26 | case .binoculars: [.magnification, .aperture, .fieldOfView] | |
| 27 | case .eyepiece: [.focalLength, .fieldOfView] | |
| 28 | case .barlow: [.magnification] | |
| 29 | case .camera: [.sensor] | |
| 30 | case .mount, .filter, .other: [] | |
| 31 | } | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | public enum Spec: Sendable { | |
| 36 | case aperture, focalLength, magnification, fieldOfView, sensor | |
| 37 | } | |
| 38 | ||
| 39 | public var id: String | |
| 40 | public var kind: Kind | |
| 41 | public var name: String | |
| 42 | /// Millimetres. | |
| 43 | public var aperture: Double? | |
| 44 | /// Millimetres. | |
| 45 | public var focalLength: Double? | |
| 46 | /// Binocular power, or the Barlow factor. | |
| 47 | public var magnification: Double? | |
| 48 | /// Degrees: true field for binoculars, apparent field for eyepieces. | |
| 49 | public var fieldOfView: Double? | |
| 50 | public var sensor: String | |
| 51 | public var notes: String | |
| 52 | /// Retired items stay in the inventory for old entries but leave the picker. | |
| 53 | public var retired: Bool | |
| 54 | ||
| 55 | public init(id: String = UUID().uuidString, kind: Kind, name: String, aperture: Double? = nil, focalLength: Double? = nil, magnification: Double? = nil, fieldOfView: Double? = nil, sensor: String = "", notes: String = "", retired: Bool = false) { | |
| 56 | self.id = id | |
| 57 | self.kind = kind | |
| 58 | self.name = name | |
| 59 | self.aperture = aperture | |
| 60 | self.focalLength = focalLength | |
| 61 | self.magnification = magnification | |
| 62 | self.fieldOfView = fieldOfView | |
| 63 | self.sensor = sensor | |
| 64 | self.notes = notes | |
| 65 | self.retired = retired | |
| 66 | } | |
| 67 | ||
| 68 | /// Focal length over aperture, when both are known. | |
| 69 | public var focalRatio: Double? { | |
| 70 | guard let aperture, let focalLength, aperture > 0 else { return nil } | |
| 71 | return focalLength / aperture | |
| 72 | } | |
| 73 | ||
| 74 | static func number(_ x: Double) -> String { | |
| 75 | x.formatted(.number.precision(.fractionLength(0...1)).grouping(.never).locale(Locale(identifier: "en_US_POSIX"))) | |
| 76 | } | |
| 77 | ||
| 78 | /// Org text for this item: a heading, a property drawer and the notes. | |
| 79 | public var org: String { | |
| 80 | var props = [(":ID:", id), (":TYPE:", kind.rawValue)] | |
| 81 | if let aperture { props.append((":APERTURE:", "\(Self.number(aperture)) mm")) } | |
| 82 | if let focalLength { props.append((":FOCAL_LENGTH:", "\(Self.number(focalLength)) mm")) } | |
| 83 | if let focalRatio { props.append((":FOCAL_RATIO:", "f/\(Self.number(focalRatio))")) } | |
| 84 | if let magnification { props.append((":MAGNIFICATION:", "\(Self.number(magnification))x")) } | |
| 85 | if let fieldOfView { props.append((":FIELD_OF_VIEW:", "\(Self.number(fieldOfView))°")) } | |
| 86 | if !sensor.isEmpty { props.append((":SENSOR:", sensor)) } | |
| 87 | if retired { props.append((":RETIRED:", "t")) } | |
| 88 | var lines = ["* \(name)", ":PROPERTIES:"] | |
| 89 | lines += props.map { "\($0.0) \($0.1)" } | |
| 90 | lines.append(":END:") | |
| 91 | let body = notes.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 92 | if !body.isEmpty { lines += ["", body] } | |
| 93 | return lines.joined(separator: "\n") + "\n" | |
| 94 | } | |
| 95 | ||
| 96 | /// Parses one heading in the format written by `org`. Returns nil without a heading or an ID. | |
| 97 | public init?(org text: String) { | |
| 98 | let lines = text.components(separatedBy: "\n") | |
| 99 | guard let head = lines.first, head.hasPrefix("* ") else { return nil } | |
| 100 | var props: [String: String] = [:] | |
| 101 | var i = 1 | |
| 102 | if i < lines.count, lines[i] == ":PROPERTIES:" { | |
| 103 | i += 1 | |
| 104 | while i < lines.count, lines[i] != ":END:" { | |
| 105 | let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init) | |
| 106 | if parts.count == 2 { props[parts[0]] = parts[1] } | |
| 107 | i += 1 | |
| 108 | } | |
| 109 | i += 1 | |
| 110 | } | |
| 111 | guard let id = props[":ID:"] else { return nil } | |
| 112 | func leadingNumber(_ key: String) -> Double? { | |
| 113 | props[key].flatMap { Double($0.prefix { $0.isNumber || $0 == "." }) } | |
| 114 | } | |
| 115 | self.init( | |
| 116 | id: id, | |
| 117 | kind: props[":TYPE:"].flatMap(Kind.init(rawValue:)) ?? .other, | |
| 118 | name: String(head.dropFirst(2)), | |
| 119 | aperture: leadingNumber(":APERTURE:"), | |
| 120 | focalLength: leadingNumber(":FOCAL_LENGTH:"), | |
| 121 | magnification: leadingNumber(":MAGNIFICATION:"), | |
| 122 | fieldOfView: leadingNumber(":FIELD_OF_VIEW:"), | |
| 123 | sensor: props[":SENSOR:"] ?? "", | |
| 124 | notes: lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines), | |
| 125 | retired: props[":RETIRED:"] == "t" | |
| 126 | ) | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | /// The inventory as a single Org file (the app's Documents/Equipment.org). | |
| 131 | public struct EquipmentInventory: Sendable { | |
| 132 | public var file: URL | |
| 133 | ||
| 134 | public init(file: URL) { self.file = file } | |
| 135 | ||
| 136 | public func all() -> [Equipment] { | |
| 137 | guard let text = try? String(contentsOf: file, encoding: .utf8) else { return [] } | |
| 138 | var chunks: [[String]] = [] | |
| 139 | for line in text.components(separatedBy: "\n") { | |
| 140 | if line.hasPrefix("* ") { chunks.append([line]) } else if !chunks.isEmpty { chunks[chunks.count - 1].append(line) } | |
| 141 | } | |
| 142 | return chunks.compactMap { Equipment(org: $0.joined(separator: "\n")) } | |
| 143 | } | |
| 144 | ||
| 145 | /// Writes the item, replacing any earlier item with the same ID. | |
| 146 | public func save(_ item: Equipment) throws { | |
| 147 | var items = all() | |
| 148 | if let i = items.firstIndex(where: { $0.id == item.id }) { items[i] = item } else { items.append(item) } | |
| 149 | try write(items) | |
| 150 | } | |
| 151 | ||
| 152 | public func delete(id: String) throws { | |
| 153 | try write(all().filter { $0.id != id }) | |
| 154 | } | |
| 155 | ||
| 156 | private func write(_ items: [Equipment]) throws { | |
| 157 | try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 158 | let text = (["#+title: Equipment\n"] + items.map(\.org)).joined(separator: "\n") | |
| 159 | try text.write(to: file, atomically: true, encoding: .utf8) | |
| 160 | } | |
| 161 | } | |
Packages/MoonriseKit/Sources/ObservingLog/EquipmentUsage.swift added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// How one inventory item has been used across the log. | |
| 4 | public struct EquipmentUsage: Sendable, Equatable { | |
| 5 | public struct Tally: Sendable, Equatable { | |
| 6 | public var key: String | |
| 7 | public var count: Int | |
| 8 | } | |
| 9 | ||
| 10 | public enum Season: String, Sendable, CaseIterable { | |
| 11 | case winter, spring, summer, autumn | |
| 12 | public var title: String { rawValue.capitalized } | |
| 13 | } | |
| 14 | ||
| 15 | public var observations: Int | |
| 16 | public var first: Date? | |
| 17 | public var last: Date? | |
| 18 | public var distinctObjects: Int | |
| 19 | /// Object names, most observed first. | |
| 20 | public var topObjects: [Tally] | |
| 21 | /// Observations in each calendar month, January first. | |
| 22 | public var byMonth: [Int] | |
| 23 | /// Meteorological seasons for the observer's hemisphere (southern when latitude is negative). | |
| 24 | public var bySeason: [Season: Int] | |
| 25 | /// Mean of the observations with seeing noted. | |
| 26 | public var averageSeeing: Double? | |
| 27 | /// IDs of other items logged alongside this one, most frequent first. | |
| 28 | public var usedWith: [Tally] | |
| 29 | ||
| 30 | /// Usage of the item `id` in `log`. `calendar` decides month and season boundaries. | |
| 31 | public init(of id: String, in log: [Observation], calendar: Calendar = .current) { | |
| 32 | let mine = log.filter { $0.equipmentIDs.contains(id) } | |
| 33 | observations = mine.count | |
| 34 | first = mine.map(\.date).min() | |
| 35 | last = mine.map(\.date).max() | |
| 36 | ||
| 37 | var objects: [String: (name: String, count: Int)] = [:] | |
| 38 | for o in mine { | |
| 39 | let name = o.object.trimmingCharacters(in: .whitespaces) | |
| 40 | objects[name.lowercased(), default: (name, 0)].count += 1 | |
| 41 | } | |
| 42 | distinctObjects = objects.count | |
| 43 | topObjects = objects.values.map { Tally(key: $0.name, count: $0.count) }.sorted(by: Self.order) | |
| 44 | ||
| 45 | var months = Array(repeating: 0, count: 12) | |
| 46 | var seasons: [Season: Int] = [:] | |
| 47 | for o in mine { | |
| 48 | let m = calendar.component(.month, from: o.date) | |
| 49 | months[m - 1] += 1 | |
| 50 | seasons[Self.season(month: m, southern: (o.latitude ?? 0) < 0), default: 0] += 1 | |
| 51 | } | |
| 52 | byMonth = months | |
| 53 | bySeason = seasons | |
| 54 | ||
| 55 | let seeing = mine.compactMap(\.seeing) | |
| 56 | averageSeeing = seeing.isEmpty ? nil : Double(seeing.reduce(0, +)) / Double(seeing.count) | |
| 57 | ||
| 58 | var others: [String: Int] = [:] | |
| 59 | for o in mine { for other in Set(o.equipmentIDs) where other != id { others[other, default: 0] += 1 } } | |
| 60 | usedWith = others.map { Tally(key: $0.key, count: $0.value) }.sorted(by: Self.order) | |
| 61 | } | |
| 62 | ||
| 63 | static func order(_ a: Tally, _ b: Tally) -> Bool { | |
| 64 | a.count != b.count ? a.count > b.count : a.key < b.key | |
| 65 | } | |
| 66 | ||
| 67 | static func season(month: Int, southern: Bool) -> Season { | |
| 68 | let north: Season = switch month { | |
| 69 | case 12, 1, 2: .winter | |
| 70 | case 3...5: .spring | |
| 71 | case 6...8: .summer | |
| 72 | default: .autumn | |
| 73 | } | |
| 74 | guard southern else { return north } | |
| 75 | return switch north { | |
| 76 | case .winter: .summer | |
| 77 | case .spring: .autumn | |
| 78 | case .summer: .winter | |
| 79 | case .autumn: .spring | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift +7 −2
| @@ -8,13 +8,15 @@ public struct Observation: Sendable, Identifiable, Equatable { | ||
| 8 | 8 | public var place: String |
| 9 | 9 | public var latitude: Double? |
| 10 | 10 | public var longitude: Double? |
| 11 | /// e.g. "Naked eye", "10x50 binoculars", "8-inch Dobsonian". | |
| 11 | /// Readable names, e.g. "Naked eye" or "8-inch Dobsonian, 25 mm Plössl". Older entries have only this. | |
| 12 | 12 | public var equipment: String |
| 13 | /// Inventory item IDs, in the order of `equipment`. | |
| 14 | public var equipmentIDs: [String] | |
| 13 | 15 | /// 1 (poor) to 5 (excellent); nil when not noted. |
| 14 | 16 | public var seeing: Int? |
| 15 | 17 | public var notes: String |
| 16 | 18 | |
| 17 | public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", seeing: Int? = nil, notes: String = "") { | |
| 19 | public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", equipmentIDs: [String] = [], seeing: Int? = nil, notes: String = "") { | |
| 18 | 20 | self.id = id |
| 19 | 21 | self.object = object |
| 20 | 22 | self.date = date |
| @@ -22,6 +24,7 @@ public struct Observation: Sendable, Identifiable, Equatable { | ||
| 22 | 24 | self.latitude = latitude |
| 23 | 25 | self.longitude = longitude |
| 24 | 26 | self.equipment = equipment |
| 27 | self.equipmentIDs = equipmentIDs | |
| 25 | 28 | self.seeing = seeing |
| 26 | 29 | self.notes = notes |
| 27 | 30 | } |
| @@ -33,6 +36,7 @@ public struct Observation: Sendable, Identifiable, Equatable { | ||
| 33 | 36 | var props = [(":ID:", id), (":PLACE:", place)] |
| 34 | 37 | if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) } |
| 35 | 38 | if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) } |
| 39 | if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) } | |
| 36 | 40 | if let seeing { props.append((":SEEING:", "\(seeing)/5")) } |
| 37 | 41 | var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"] |
| 38 | 42 | lines += props.map { "\($0.0) \($0.1)" } |
| @@ -69,6 +73,7 @@ public struct Observation: Sendable, Identifiable, Equatable { | ||
| 69 | 73 | latitude: coords?.count == 2 ? coords![0] : nil, |
| 70 | 74 | longitude: coords?.count == 2 ? coords![1] : nil, |
| 71 | 75 | equipment: props[":EQUIPMENT:"] ?? "", |
| 76 | equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [], | |
| 72 | 77 | seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) }, |
| 73 | 78 | notes: notes |
| 74 | 79 | ) |
Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift +82
| @@ -42,3 +42,85 @@ let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z | ||
| 42 | 42 | #expect(log.all().map(\.object) == ["Moon"]) |
| 43 | 43 | #expect(log.fileURL(for: a).lastPathComponent.hasSuffix("-moon.org")) |
| 44 | 44 | } |
| 45 | ||
| 46 | @Test func equipmentIDsRoundTrip() throws { | |
| 47 | let o = Observation(object: "M13", date: when, place: "Backyard", equipment: "8-inch Dobsonian, 25 mm Plössl", equipmentIDs: ["scope-1", "ep-2"]) | |
| 48 | #expect(o.org.contains(":EQUIPMENT: 8-inch Dobsonian, 25 mm Plössl\n:EQUIPMENT_IDS: scope-1 ep-2\n")) | |
| 49 | let back = try #require(Observation(org: o.org)) | |
| 50 | #expect(back.equipment == o.equipment) | |
| 51 | #expect(back.equipmentIDs == ["scope-1", "ep-2"]) | |
| 52 | } | |
| 53 | ||
| 54 | @Test func freeTextEquipmentStillParses() throws { | |
| 55 | let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:EQUIPMENT: 10x50 binoculars\n:END:\n" | |
| 56 | let o = try #require(Observation(org: text)) | |
| 57 | #expect(o.equipment == "10x50 binoculars") | |
| 58 | #expect(o.equipmentIDs.isEmpty) | |
| 59 | } | |
| 60 | ||
| 61 | @Test func equipmentOrgRoundTrip() throws { | |
| 62 | let scope = Equipment(kind: .telescope, name: "8-inch Dobsonian", aperture: 203, focalLength: 1200, notes: "Collimated in May.") | |
| 63 | let text = scope.org | |
| 64 | #expect(text.hasPrefix("* 8-inch Dobsonian\n:PROPERTIES:\n")) | |
| 65 | #expect(text.contains(":TYPE: telescope\n")) | |
| 66 | #expect(text.contains(":APERTURE: 203 mm\n")) | |
| 67 | #expect(text.contains(":FOCAL_LENGTH: 1200 mm\n")) | |
| 68 | #expect(text.contains(":FOCAL_RATIO: f/5.9\n")) | |
| 69 | #expect(!text.contains(":RETIRED:")) | |
| 70 | let back = try #require(Equipment(org: text)) | |
| 71 | #expect(back == scope) | |
| 72 | ||
| 73 | let binos = Equipment(kind: .binoculars, name: "10x50", aperture: 50, magnification: 10, fieldOfView: 6.5, retired: true) | |
| 74 | #expect(binos.org.contains(":MAGNIFICATION: 10x\n")) | |
| 75 | #expect(binos.org.contains(":FIELD_OF_VIEW: 6.5°\n")) | |
| 76 | #expect(binos.org.contains(":RETIRED: t\n")) | |
| 77 | #expect(Equipment(org: binos.org) == binos) | |
| 78 | ||
| 79 | let cam = Equipment(kind: .camera, name: "ZWO", sensor: "IMX585") | |
| 80 | #expect(Equipment(org: cam.org) == cam) | |
| 81 | } | |
| 82 | ||
| 83 | @Test func inventorySaveListDelete() throws { | |
| 84 | let file = FileManager.default.temporaryDirectory.appending(path: "eq-\(UUID().uuidString)/Equipment.org") | |
| 85 | defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } | |
| 86 | let inv = EquipmentInventory(file: file) | |
| 87 | #expect(inv.all().isEmpty) | |
| 88 | var scope = Equipment(kind: .telescope, name: "Dob", aperture: 203, notes: "Line one.\n\nLine two.") | |
| 89 | let ep = Equipment(kind: .eyepiece, name: "25 mm Plössl", focalLength: 25, fieldOfView: 52) | |
| 90 | try inv.save(scope) | |
| 91 | try inv.save(ep) | |
| 92 | #expect(inv.all() == [scope, ep]) | |
| 93 | scope.retired = true | |
| 94 | try inv.save(scope) | |
| 95 | #expect(inv.all() == [scope, ep]) | |
| 96 | #expect(try String(contentsOf: file, encoding: .utf8).hasPrefix("#+title: Equipment\n")) | |
| 97 | try inv.delete(id: scope.id) | |
| 98 | #expect(inv.all() == [ep]) | |
| 99 | } | |
| 100 | ||
| 101 | @Test func equipmentUsage() throws { | |
| 102 | var cal = Calendar(identifier: .gregorian) | |
| 103 | cal.timeZone = TimeZone(identifier: "UTC")! | |
| 104 | func day(_ y: Int, _ m: Int, _ d: Int) -> Date { cal.date(from: DateComponents(year: y, month: m, day: d, hour: 3))! } | |
| 105 | let log = [ | |
| 106 | Observation(object: "Saturn", date: day(2026, 1, 10), place: "", equipmentIDs: ["scope", "ep25"], seeing: 4), | |
| 107 | Observation(object: "saturn ", date: day(2026, 7, 10), place: "", equipmentIDs: ["scope", "ep25"], seeing: 2), | |
| 108 | Observation(object: "M31", date: day(2026, 10, 10), place: "", equipmentIDs: ["scope", "ep10"]), | |
| 109 | Observation(object: "Moon", date: day(2026, 10, 11), place: "", latitude: -33.9, longitude: 18.4, equipmentIDs: ["scope"], seeing: 3), | |
| 110 | Observation(object: "Jupiter", date: day(2026, 3, 1), place: "", equipmentIDs: ["binos"]), | |
| 111 | Observation(object: "Mars", date: day(2026, 3, 2), place: "", equipment: "Telescope"), | |
| 112 | ] | |
| 113 | let u = EquipmentUsage(of: "scope", in: log, calendar: cal) | |
| 114 | #expect(u.observations == 4) | |
| 115 | #expect(u.first == day(2026, 1, 10)) | |
| 116 | #expect(u.last == day(2026, 10, 11)) | |
| 117 | #expect(u.distinctObjects == 3) | |
| 118 | #expect(u.topObjects.first == .init(key: "Saturn", count: 2)) | |
| 119 | #expect(u.byMonth == [1, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]) | |
| 120 | #expect(u.bySeason == [.winter: 1, .summer: 1, .autumn: 1, .spring: 1]) | |
| 121 | #expect(u.averageSeeing == 3) | |
| 122 | #expect(u.usedWith == [.init(key: "ep25", count: 2), .init(key: "ep10", count: 1)]) | |
| 123 | ||
| 124 | let none = EquipmentUsage(of: "camera", in: log, calendar: cal) | |
| 125 | #expect(none.observations == 0 && none.first == nil && none.averageSeeing == nil && none.usedWith.isEmpty) | |
| 126 | } | |