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

112 lines · 5741 bytes

  1import Foundation
  2
  3/// One logged observation, stored as an Org file that Emacs and orgstar can read.
  4public struct Observation: Sendable, Identifiable, Equatable {
  5    public var id: String
  6    public var object: String
  7    public var date: Date
  8    public var place: String
  9    public var latitude: Double?
 10    public var longitude: Double?
 11    /// e.g. "Naked eye", "10x50 binoculars", "8-inch Dobsonian".
 12    public var equipment: String
 13    /// 1 (poor) to 5 (excellent); nil when not noted.
 14    public var seeing: Int?
 15    public var notes: String
 16
 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 = "") {
 18        self.id = id
 19        self.object = object
 20        self.date = date
 21        self.place = place
 22        self.latitude = latitude
 23        self.longitude = longitude
 24        self.equipment = equipment
 25        self.seeing = seeing
 26        self.notes = notes
 27    }
 28
 29    static let orgDate: Date.VerbatimFormatStyle = .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: .current, calendar: Calendar(identifier: .gregorian))
 30
 31    /// Org text: one heading with an inactive timestamp, a property drawer and the notes.
 32    public var org: String {
 33        var props = [(":ID:", id), (":PLACE:", place)]
 34        if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) }
 35        if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) }
 36        if let seeing { props.append((":SEEING:", "\(seeing)/5")) }
 37        var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"]
 38        lines += props.map { "\($0.0) \($0.1)" }
 39        lines.append(":END:")
 40        let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
 41        if !body.isEmpty { lines += ["", body] }
 42        return lines.joined(separator: "\n") + "\n"
 43    }
 44
 45    /// Parses the format written by `org`. Returns nil for files it did not write.
 46    public init?(org text: String) {
 47        let lines = text.components(separatedBy: "\n")
 48        guard let head = lines.first, head.hasPrefix("* "),
 49              lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"),
 50              let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil }
 51        var props: [String: String] = [:]
 52        var i = 2
 53        if i < lines.count, lines[i] == ":PROPERTIES:" {
 54            i += 1
 55            while i < lines.count, lines[i] != ":END:" {
 56                let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init)
 57                if parts.count == 2 { props[parts[0]] = parts[1] }
 58                i += 1
 59            }
 60            i += 1
 61        }
 62        let notes = lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
 63        let coords = props[":COORDINATES:"]?.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
 64        self.init(
 65            id: props[":ID:"] ?? UUID().uuidString,
 66            object: String(head.dropFirst(2)),
 67            date: date,
 68            place: props[":PLACE:"] ?? "",
 69            latitude: coords?.count == 2 ? coords![0] : nil,
 70            longitude: coords?.count == 2 ? coords![1] : nil,
 71            equipment: props[":EQUIPMENT:"] ?? "",
 72            seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
 73            notes: notes
 74        )
 75    }
 76}
 77
 78/// Observations as one Org file each in a folder (the app's Documents/Observations).
 79public struct ObservingLog: Sendable {
 80    public var folder: URL
 81
 82    public init(folder: URL) { self.folder = folder }
 83
 84    public func fileURL(for o: Observation) -> URL {
 85        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)))
 86        let slug = o.object.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }.reduce(into: "") { s, c in if !(c == "-" && s.last == "-") { s.append(c) } }
 87        return folder.appending(path: "\(stamp)-\(slug.trimmingCharacters(in: CharacterSet(charactersIn: "-"))).org")
 88    }
 89
 90    public func all() -> [Observation] {
 91        let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
 92        return files.filter { $0.pathExtension == "org" }
 93            .compactMap { (try? String(contentsOf: $0, encoding: .utf8)).flatMap(Observation.init(org:)) }
 94            .sorted { $0.date > $1.date }
 95    }
 96
 97    /// Writes the observation, replacing any earlier file with the same ID.
 98    public func save(_ o: Observation) throws {
 99        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
100        try delete(id: o.id)
101        try o.org.write(to: fileURL(for: o), atomically: true, encoding: .utf8)
102    }
103
104    public func delete(id: String) throws {
105        let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
106        for f in files where f.pathExtension == "org" {
107            if let text = try? String(contentsOf: f, encoding: .utf8), Observation(org: text)?.id == id {
108                try FileManager.default.removeItem(at: f)
109            }
110        }
111    }
112}