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/Observation.swift

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/Packages/MoonriseKit/Sources/ObservingLog/Observation.swift history · blame · raw

191 lines · 10123 bytes

12 symbols in this file
  1import Foundation
  2
  3/// A property-drawer entry the app does not use, kept so saving does not drop it.
  4public struct OrgProperty: Sendable, Equatable, Hashable {
  5    /// With colons, e.g. ":TRANSPARENCY:".
  6    public var key: String
  7    public var value: String
  8
  9    public init(key: String, value: String) {
 10        self.key = key
 11        self.value = value
 12    }
 13
 14    var line: String { value.isEmpty ? key : "\(key) \(value)" }
 15
 16    /// Splits a drawer line into key and value; nil for lines that are not a property.
 17    static func parse(_ line: String) -> OrgProperty? {
 18        let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
 19        guard let key = parts.first, key.count > 2, key.hasPrefix(":"), key.hasSuffix(":") else { return nil }
 20        return OrgProperty(key: key, value: parts.count == 2 ? parts[1] : "")
 21    }
 22}
 23
 24/// One logged observation, stored as an Org file that Emacs and orgstar can read.
 25public struct Observation: Sendable, Identifiable, Equatable {
 26    public var id: String
 27    public var object: String
 28    public var date: Date
 29    public var place: String
 30    public var latitude: Double?
 31    public var longitude: Double?
 32    /// Readable names, e.g. "Naked eye" or "8-inch Dobsonian, 25 mm Plössl". Older entries have only this.
 33    public var equipment: String
 34    /// Inventory item IDs, in the order of `equipment`.
 35    public var equipmentIDs: [String]
 36    /// 1 (poor) to 5 (excellent); nil when not noted.
 37    public var seeing: Int?
 38    public var notes: String
 39    /// Catalogue IDs, e.g. ["M31"]; see `CatalogResolver`.
 40    public var catalog: [String]
 41    /// Seconds east of UTC that the timestamp is written in. Nil for older entries, which are read in the device's zone.
 42    public var utcOffset: Int?
 43    /// Properties added by hand, in file order.
 44    public var otherProperties: [OrgProperty]
 45
 46    static let knownKeys: Set = [":ID:", ":CATALOG:", ":PLACE:", ":COORDINATES:", ":UTC_OFFSET:", ":EQUIPMENT:", ":EQUIPMENT_IDS:", ":SEEING:"]
 47
 48    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 = "", catalog: [String] = [], utcOffset: Int? = nil, otherProperties: [OrgProperty] = []) {
 49        self.id = id
 50        self.object = object
 51        self.date = date
 52        self.place = place
 53        self.latitude = latitude
 54        self.longitude = longitude
 55        self.equipment = equipment
 56        self.equipmentIDs = equipmentIDs
 57        self.seeing = seeing
 58        self.notes = notes
 59        self.catalog = catalog
 60        self.utcOffset = utcOffset
 61        self.otherProperties = otherProperties
 62    }
 63
 64    static func orgDate(_ zone: TimeZone) -> Date.VerbatimFormatStyle {
 65        .init(format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits) \(weekday: .abbreviated) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)", locale: Locale(identifier: "en_US_POSIX"), timeZone: zone, calendar: Calendar(identifier: .gregorian))
 66    }
 67
 68    private var zone: TimeZone { utcOffset.flatMap(TimeZone.init(secondsFromGMT:)) ?? .current }
 69
 70    /// "-05:00" for -18000.
 71    static func offsetText(_ seconds: Int) -> String {
 72        let m = abs(seconds) / 60
 73        return String(format: "%@%02d:%02d", seconds < 0 ? "-" : "+", m / 60, m % 60)
 74    }
 75
 76    static func offset(_ text: String) -> Int? {
 77        guard let m = text.wholeMatch(of: /([+-])(\d\d):(\d\d)/), let h = Int(m.2), let min = Int(m.3) else { return nil }
 78        return (m.1 == "-" ? -1 : 1) * (h * 3600 + min * 60)
 79    }
 80
 81    /// Org text: one heading with an inactive timestamp, a property drawer and the notes.
 82    public var org: String {
 83        var props = [(":ID:", id)]
 84        if !catalog.isEmpty { props.append((":CATALOG:", catalog.joined(separator: " "))) }
 85        props.append((":PLACE:", place))
 86        if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) }
 87        if let utcOffset { props.append((":UTC_OFFSET:", Self.offsetText(utcOffset))) }
 88        if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) }
 89        if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) }
 90        if let seeing { props.append((":SEEING:", "\(seeing)/5")) }
 91        var lines = ["* \(object)", "[\(date.formatted(Self.orgDate(zone)))]", ":PROPERTIES:"]
 92        lines += props.map { "\($0.0) \($0.1)" }
 93        lines += otherProperties.map(\.line)
 94        lines.append(":END:")
 95        let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
 96        if !body.isEmpty { lines += ["", body] }
 97        return lines.joined(separator: "\n") + "\n"
 98    }
 99
100    /// Parses the format written by `org`. Returns nil for files it did not write.
101    public init?(org text: String) {
102        let lines = text.components(separatedBy: "\n")
103        guard let head = lines.first, head.hasPrefix("* "),
104              lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]") else { return nil }
105        var props: [String: String] = [:]
106        var other: [OrgProperty] = []
107        var i = 2
108        if i < lines.count, lines[i] == ":PROPERTIES:" {
109            i += 1
110            while i < lines.count, lines[i] != ":END:" {
111                if let p = OrgProperty.parse(lines[i]) {
112                    if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value }
113                }
114                i += 1
115            }
116            i += 1
117        }
118        let offset = props[":UTC_OFFSET:"].flatMap(Self.offset)
119        guard let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate(offset.flatMap(TimeZone.init(secondsFromGMT:)) ?? .current).parseStrategy) else { return nil }
120        let notes = lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
121        let coords = props[":COORDINATES:"]?.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
122        self.init(
123            id: props[":ID:"] ?? UUID().uuidString,
124            object: String(head.dropFirst(2)),
125            date: date,
126            place: props[":PLACE:"] ?? "",
127            latitude: coords?.count == 2 ? coords![0] : nil,
128            longitude: coords?.count == 2 ? coords![1] : nil,
129            equipment: props[":EQUIPMENT:"] ?? "",
130            equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [],
131            seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
132            notes: notes,
133            catalog: props[":CATALOG:"]?.split(separator: " ").map(String.init) ?? [],
134            utcOffset: offset,
135            otherProperties: other
136        )
137    }
138}
139
140/// Observations as one Org file each in a folder (the app's Documents/Observations).
141public struct ObservingLog: Sendable {
142    public var folder: URL
143
144    public init(folder: URL) { self.folder = folder }
145
146    public func fileURL(for o: Observation) -> URL {
147        let stamp = o.date.formatted(.verbatim("\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)-\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(minute: .twoDigits)", locale: Locale(identifier: "en_US_POSIX"), timeZone: .current, calendar: Calendar(identifier: .gregorian)))
148        let slug = o.object.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }.reduce(into: "") { s, c in if !(c == "-" && s.last == "-") { s.append(c) } }
149        return folder.appending(path: "\(stamp)-\(slug.trimmingCharacters(in: CharacterSet(charactersIn: "-"))).org")
150    }
151
152    public func all() -> [Observation] {
153        let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
154        return files.filter { $0.pathExtension == "org" }
155            .compactMap { (try? String(contentsOf: $0, encoding: .utf8)).flatMap(Observation.init(org:)) }
156            .sorted { $0.date > $1.date }
157    }
158
159    /// Writes the observation, replacing any earlier file with the same ID. Sets `utcOffset` from the device's zone
160    /// when missing, and `catalog` when the object's name resolves to an ID not already in it; a name that doesn't
161    /// resolve keeps the IDs already set.
162    public func save(_ o: Observation, resolver: CatalogResolver = .messier) throws {
163        var o = o
164        if o.utcOffset == nil { o.utcOffset = TimeZone.current.secondsFromGMT(for: o.date) }
165        if let id = resolver.id(for: o.object), !o.catalog.contains(where: { (CatalogResolver.designation($0) ?? $0) == id }) { o.catalog = [id] }
166        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
167        try delete(id: o.id)
168        try o.org.write(to: fileURL(for: o), atomically: true, encoding: .utf8)
169    }
170
171    /// User-defined program lists, one Org file each; see `ObservingProgram`.
172    public var programsFolder: URL { folder.appending(path: "Programs") }
173
174    /// Messier, then the files in `programsFolder` by title.
175    public func programs() -> [ObservingProgram] {
176        let files = (try? FileManager.default.contentsOfDirectory(at: programsFolder, includingPropertiesForKeys: nil)) ?? []
177        let mine = files.filter { $0.pathExtension == "org" }.compactMap { url in
178            (try? String(contentsOf: url, encoding: .utf8)).map { ObservingProgram(org: $0, id: url.deletingPathExtension().lastPathComponent) }
179        }
180        return [.messier] + mine.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
181    }
182
183    public func delete(id: String) throws {
184        let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
185        for f in files where f.pathExtension == "org" {
186            if let text = try? String(contentsOf: f, encoding: .utf8), Observation(org: text)?.id == id {
187                try FileManager.default.removeItem(at: f)
188            }
189        }
190    }
191}