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/ObservingLog/Equipment.swift

dda3950a2e0fd0cc0d97262ff3b6646d6871181b
moonrise/Packages/MoonriseKit/Sources/ObservingLog/Equipment.swift history · blame · raw

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}