Packages/MoonriseKit/Sources/ObservingLog/Equipment.swift
170 lines · 7315 bytes
1import Foundation
2
3/// One item of the user's gear. The inventory is one Org file with a heading per item.
4public 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 /// Properties added by hand, in file order.
55 public var otherProperties: [OrgProperty]
56
57 static let knownKeys: Set = [":ID:", ":TYPE:", ":APERTURE:", ":FOCAL_LENGTH:", ":FOCAL_RATIO:", ":MAGNIFICATION:", ":FIELD_OF_VIEW:", ":SENSOR:", ":RETIRED:"]
58
59 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, otherProperties: [OrgProperty] = []) {
60 self.id = id
61 self.kind = kind
62 self.name = name
63 self.aperture = aperture
64 self.focalLength = focalLength
65 self.magnification = magnification
66 self.fieldOfView = fieldOfView
67 self.sensor = sensor
68 self.notes = notes
69 self.retired = retired
70 self.otherProperties = otherProperties
71 }
72
73 /// Focal length over aperture, when both are known.
74 public var focalRatio: Double? {
75 guard let aperture, let focalLength, aperture > 0 else { return nil }
76 return focalLength / aperture
77 }
78
79 static func number(_ x: Double) -> String {
80 x.formatted(.number.precision(.fractionLength(0...1)).grouping(.never).locale(Locale(identifier: "en_US_POSIX")))
81 }
82
83 /// Org text for this item: a heading, a property drawer and the notes.
84 public var org: String {
85 var props = [(":ID:", id), (":TYPE:", kind.rawValue)]
86 if let aperture { props.append((":APERTURE:", "\(Self.number(aperture)) mm")) }
87 if let focalLength { props.append((":FOCAL_LENGTH:", "\(Self.number(focalLength)) mm")) }
88 if let focalRatio { props.append((":FOCAL_RATIO:", "f/\(Self.number(focalRatio))")) }
89 if let magnification { props.append((":MAGNIFICATION:", "\(Self.number(magnification))x")) }
90 if let fieldOfView { props.append((":FIELD_OF_VIEW:", "\(Self.number(fieldOfView))°")) }
91 if !sensor.isEmpty { props.append((":SENSOR:", sensor)) }
92 if retired { props.append((":RETIRED:", "t")) }
93 var lines = ["* \(name)", ":PROPERTIES:"]
94 lines += props.map { "\($0.0) \($0.1)" }
95 lines += otherProperties.map(\.line)
96 lines.append(":END:")
97 let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
98 if !body.isEmpty { lines += ["", body] }
99 return lines.joined(separator: "\n") + "\n"
100 }
101
102 /// Parses one heading in the format written by `org`. Returns nil without a heading or an ID.
103 public init?(org text: String) {
104 let lines = text.components(separatedBy: "\n")
105 guard let head = lines.first, head.hasPrefix("* ") else { return nil }
106 var props: [String: String] = [:]
107 var other: [OrgProperty] = []
108 var i = 1
109 if i < lines.count, lines[i] == ":PROPERTIES:" {
110 i += 1
111 while i < lines.count, lines[i] != ":END:" {
112 if let p = OrgProperty.parse(lines[i]) {
113 if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value }
114 }
115 i += 1
116 }
117 i += 1
118 }
119 guard let id = props[":ID:"] else { return nil }
120 func leadingNumber(_ key: String) -> Double? {
121 props[key].flatMap { Double($0.prefix { $0.isNumber || $0 == "." }) }
122 }
123 self.init(
124 id: id,
125 kind: props[":TYPE:"].flatMap(Kind.init(rawValue:)) ?? .other,
126 name: String(head.dropFirst(2)),
127 aperture: leadingNumber(":APERTURE:"),
128 focalLength: leadingNumber(":FOCAL_LENGTH:"),
129 magnification: leadingNumber(":MAGNIFICATION:"),
130 fieldOfView: leadingNumber(":FIELD_OF_VIEW:"),
131 sensor: props[":SENSOR:"] ?? "",
132 notes: lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines),
133 retired: props[":RETIRED:"] == "t",
134 otherProperties: other
135 )
136 }
137}
138
139/// The inventory as a single Org file (the app's Documents/Equipment.org).
140public struct EquipmentInventory: Sendable {
141 public var file: URL
142
143 public init(file: URL) { self.file = file }
144
145 public func all() -> [Equipment] {
146 guard let text = try? String(contentsOf: file, encoding: .utf8) else { return [] }
147 var chunks: [[String]] = []
148 for line in text.components(separatedBy: "\n") {
149 if line.hasPrefix("* ") { chunks.append([line]) } else if !chunks.isEmpty { chunks[chunks.count - 1].append(line) }
150 }
151 return chunks.compactMap { Equipment(org: $0.joined(separator: "\n")) }
152 }
153
154 /// Writes the item, replacing any earlier item with the same ID.
155 public func save(_ item: Equipment) throws {
156 var items = all()
157 if let i = items.firstIndex(where: { $0.id == item.id }) { items[i] = item } else { items.append(item) }
158 try write(items)
159 }
160
161 public func delete(id: String) throws {
162 try write(all().filter { $0.id != id })
163 }
164
165 private func write(_ items: [Equipment]) throws {
166 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
167 let text = (["#+title: Equipment\n"] + items.map(\.org)).joined(separator: "\n")
168 try text.write(to: file, atomically: true, encoding: .utf8)
169 }
170}