Packages/MoonriseKit/Sources/ObservingLog/Observation.swift
147 lines · 7446 bytes
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 /// Properties added by hand, in file order.
40 public var otherProperties: [OrgProperty]
41
42 static let knownKeys: Set = [":ID:", ":PLACE:", ":COORDINATES:", ":EQUIPMENT:", ":EQUIPMENT_IDS:", ":SEEING:"]
43
44 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 = "", otherProperties: [OrgProperty] = []) {
45 self.id = id
46 self.object = object
47 self.date = date
48 self.place = place
49 self.latitude = latitude
50 self.longitude = longitude
51 self.equipment = equipment
52 self.equipmentIDs = equipmentIDs
53 self.seeing = seeing
54 self.notes = notes
55 self.otherProperties = otherProperties
56 }
57
58 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))
59
60 /// Org text: one heading with an inactive timestamp, a property drawer and the notes.
61 public var org: String {
62 var props = [(":ID:", id), (":PLACE:", place)]
63 if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) }
64 if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) }
65 if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) }
66 if let seeing { props.append((":SEEING:", "\(seeing)/5")) }
67 var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"]
68 lines += props.map { "\($0.0) \($0.1)" }
69 lines += otherProperties.map(\.line)
70 lines.append(":END:")
71 let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
72 if !body.isEmpty { lines += ["", body] }
73 return lines.joined(separator: "\n") + "\n"
74 }
75
76 /// Parses the format written by `org`. Returns nil for files it did not write.
77 public init?(org text: String) {
78 let lines = text.components(separatedBy: "\n")
79 guard let head = lines.first, head.hasPrefix("* "),
80 lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"),
81 let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil }
82 var props: [String: String] = [:]
83 var other: [OrgProperty] = []
84 var i = 2
85 if i < lines.count, lines[i] == ":PROPERTIES:" {
86 i += 1
87 while i < lines.count, lines[i] != ":END:" {
88 if let p = OrgProperty.parse(lines[i]) {
89 if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value }
90 }
91 i += 1
92 }
93 i += 1
94 }
95 let notes = lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
96 let coords = props[":COORDINATES:"]?.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
97 self.init(
98 id: props[":ID:"] ?? UUID().uuidString,
99 object: String(head.dropFirst(2)),
100 date: date,
101 place: props[":PLACE:"] ?? "",
102 latitude: coords?.count == 2 ? coords![0] : nil,
103 longitude: coords?.count == 2 ? coords![1] : nil,
104 equipment: props[":EQUIPMENT:"] ?? "",
105 equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [],
106 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
107 notes: notes,
108 otherProperties: other
109 )
110 }
111}
112
113/// Observations as one Org file each in a folder (the app's Documents/Observations).
114public struct ObservingLog: Sendable {
115 public var folder: URL
116
117 public init(folder: URL) { self.folder = folder }
118
119 public func fileURL(for o: Observation) -> URL {
120 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)))
121 let slug = o.object.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }.reduce(into: "") { s, c in if !(c == "-" && s.last == "-") { s.append(c) } }
122 return folder.appending(path: "\(stamp)-\(slug.trimmingCharacters(in: CharacterSet(charactersIn: "-"))).org")
123 }
124
125 public func all() -> [Observation] {
126 let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
127 return files.filter { $0.pathExtension == "org" }
128 .compactMap { (try? String(contentsOf: $0, encoding: .utf8)).flatMap(Observation.init(org:)) }
129 .sorted { $0.date > $1.date }
130 }
131
132 /// Writes the observation, replacing any earlier file with the same ID.
133 public func save(_ o: Observation) throws {
134 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
135 try delete(id: o.id)
136 try o.org.write(to: fileURL(for: o), atomically: true, encoding: .utf8)
137 }
138
139 public func delete(id: String) throws {
140 let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
141 for f in files where f.pathExtension == "org" {
142 if let text = try? String(contentsOf: f, encoding: .utf8), Observation(org: text)?.id == id {
143 try FileManager.default.removeItem(at: f)
144 }
145 }
146 }
147}