v2: Observing log (Org files) !23

merged merged by cmc on 2026-10-18 08:40 UTC · krz/moonrise:v2-observing-log into main

6 files changed, +305 −4

Layout: unified · split

App/Sources/LogScreen.swift added +111
@@ -0,0 +1,111 @@
1import ObservingLog
2import SwiftUI
3
4/// The observing log lives in Documents/Observations, one Org file per entry, visible in the Files app.
5let observingLog = ObservingLog(folder: URL.documentsDirectory.appending(path: "Observations"))
6
7struct LogScreen: View {
8 @State private var entries: [Observation] = []
9 @State private var editing: Observation?
10 var newEntry: () -> Observation
11 @Environment(\.dismiss) private var dismiss
12
13 var body: some View {
14 NavigationStack {
15 List {
16 if entries.isEmpty {
17 Text("No observations yet. Add one here, or use Log on an object's card in the Sky tab.")
18 .font(.footnote).foregroundStyle(.secondary)
19 }
20 ForEach(entries) { o in
21 Button { editing = o } label: {
22 VStack(alignment: .leading, spacing: 3) {
23 Text(o.object).font(.body)
24 Text("\(o.date.formatted(date: .abbreviated, time: .shortened)) · \(o.place)").font(.footnote).foregroundStyle(.secondary)
25 if !o.notes.isEmpty { Text(o.notes).font(.footnote).lineLimit(3) }
26 }
27 }
28 .buttonStyle(.plain)
29 }
30 .onDelete { idx in
31 for i in idx { try? observingLog.delete(id: entries[i].id) }
32 reload()
33 }
34 }
35 .navigationTitle("Observing log")
36 .navigationBarTitleDisplayMode(.inline)
37 .toolbar {
38 ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
39 ToolbarItem(placement: .primaryAction) {
40 Button { editing = newEntry() } label: { Image(systemName: "plus") }
41 .accessibilityLabel("New observation")
42 }
43 }
44 .safeAreaInset(edge: .bottom) {
45 Text("Saved as Org files in Files › On My iPhone › Moonrise › Observations.")
46 .font(.caption2).foregroundStyle(.secondary).padding(8)
47 }
48 .sheet(item: $editing) { o in
49 ObservationEditor(observation: o) { reload() }
50 }
51 }
52 .onAppear(perform: reload)
53 }
54
55 private func reload() { entries = observingLog.all() }
56}
57
58struct ObservationEditor: View {
59 @State var observation: Observation
60 var onSave: () -> Void
61 @Environment(\.dismiss) private var dismiss
62 @State private var error: String?
63
64 private static let equipmentChoices = ["Naked eye", "Binoculars", "Telescope", "Camera"]
65
66 var body: some View {
67 NavigationStack {
68 Form {
69 Section {
70 TextField("Object", text: $observation.object)
71 DatePicker("Time", selection: $observation.date)
72 TextField("Place", text: $observation.place)
73 }
74 Section("Conditions") {
75 Picker("Equipment", selection: $observation.equipment) {
76 Text("Not noted").tag("")
77 ForEach(Self.equipmentChoices, id: \.self) { Text($0).tag($0) }
78 if !observation.equipment.isEmpty, !Self.equipmentChoices.contains(observation.equipment) {
79 Text(observation.equipment).tag(observation.equipment)
80 }
81 }
82 Picker("Seeing", selection: $observation.seeing) {
83 Text("Not noted").tag(Int?.none)
84 ForEach(1...5, id: \.self) { Text("\($0) of 5").tag(Int?.some($0)) }
85 }
86 }
87 Section("Notes") {
88 TextEditor(text: $observation.notes).frame(minHeight: 140)
89 }
90 if let error { Text(error).foregroundStyle(.red).font(.footnote) }
91 }
92 .navigationTitle(observation.object.isEmpty ? "New observation" : observation.object)
93 .navigationBarTitleDisplayMode(.inline)
94 .toolbar {
95 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
96 ToolbarItem(placement: .confirmationAction) {
97 Button("Save") {
98 do {
99 try observingLog.save(observation)
100 onSave()
101 dismiss()
102 } catch {
103 self.error = "Couldn't save: \(error.localizedDescription)"
104 }
105 }
106 .disabled(observation.object.trimmingCharacters(in: .whitespaces).isEmpty)
107 }
108 }
109 }
110 }
111}
App/Sources/SkyScreen.swift +31 −4
@@ -1,4 +1,5 @@
11import Library
2import ObservingLog
23import SkyEngine
34import SkyRenderer
45import SwiftUI
@@ -12,6 +13,8 @@ struct SkyScreen: View {
1213 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
1314 @State private var showSettings = false
1415 @State private var showEvents = false
16 @State private var showLog = false
17 @State private var logEntry: Observation?
1518 @AppStorage(SettingsKey.onboarded) private var onboarded = false
1619 @AppStorage(SettingsKey.nightActive) private var nightActive = false
1720 @State private var showToday = false
@@ -58,6 +61,12 @@ struct SkyScreen: View {
5861 }
5962 .foregroundStyle(ink)
6063 .environment(\.nightMode, night)
64 .sheet(isPresented: $showLog) {
65 LogScreen { newObservation("") }.preferredColorScheme(.dark).tint(.blue)
66 }
67 .sheet(item: $logEntry) { o in
68 ObservationEditor(observation: o) {}.preferredColorScheme(.dark).tint(.blue)
69 }
6170 .sheet(isPresented: $showEvents) {
6271 EventsScreen(observer: model.observer, placeName: model.placeName)
6372 .preferredColorScheme(.dark)
@@ -153,6 +162,12 @@ struct SkyScreen: View {
153162 }
154163 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
155164 }
165 Button { showLog = true } label: {
166 Image(systemName: "book.closed")
167 .font(.body)
168 .frame(width: 44, height: 44)
169 }
170 .accessibilityLabel("Observing log")
156171 Button { showEvents = true } label: {
157172 Image(systemName: "calendar")
158173 .font(.body)
@@ -193,12 +208,19 @@ struct SkyScreen: View {
193208 case .constellation(let id):
194209 Text("Constellation · \(id)").font(.footnote).dimmed(0.7)
195210 }
196 if let page = libraryPage(for: object) {
197 Button { libraryPage = page } label: {
198 Label("Read more", systemImage: "book").font(.footnote.weight(.medium))
211 HStack(spacing: 20) {
212 Button { logEntry = newObservation(title(for: object)) } label: {
213 Label("Log", systemImage: "square.and.pencil").font(.footnote.weight(.medium))
199214 }
200215 .buttonStyle(.plain)
201 .padding(.top, 4)
216 .frame(minHeight: 44)
217 if let page = libraryPage(for: object) {
218 Button { libraryPage = page } label: {
219 Label("Read more", systemImage: "book").font(.footnote.weight(.medium))
220 }
221 .buttonStyle(.plain)
222 .frame(minHeight: 44)
223 }
202224 }
203225 }
204226 .padding(16)
@@ -206,6 +228,11 @@ struct SkyScreen: View {
206228 .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5))
207229 }
208230
231 private func newObservation(_ object: String) -> Observation {
232 Observation(object: object, date: model.displayedDate, place: model.placeName == "Your location" ? "" : model.placeName,
233 latitude: model.observer.latitude, longitude: model.observer.longitude)
234 }
235
209236 private func libraryPage(for object: SkyObject) -> LibraryPage? {
210237 switch object {
211238 case .body(let b): Library.page(body: b.rawValue)
App/project.yml +4
@@ -22,6 +22,8 @@ targets:
2222 product: Feeds
2323 - package: MoonriseKit
2424 product: Library
25 - package: MoonriseKit
26 product: ObservingLog
2527 - target: MoonriseWidgets
2628 entitlements:
2729 path: Sources/Moonrise.entitlements
@@ -51,6 +53,8 @@ targets:
5153 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
5254 INFOPLIST_KEY_NSSupportsLiveActivities: YES
5355 INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
56 INFOPLIST_KEY_UIFileSharingEnabled: YES
57 INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace: YES
5458 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
5559 INFOPLIST_KEY_UILaunchScreen_Generation: YES
5660 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent
Packages/MoonriseKit/Package.swift +3
@@ -9,6 +9,7 @@ let package = Package(
99 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),
1010 .library(name: "Feeds", targets: ["Feeds"]),
1111 .library(name: "Library", targets: ["Library"]),
12 .library(name: "ObservingLog", targets: ["ObservingLog"]),
1213 ],
1314 targets: [
1415 // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy
@@ -24,9 +25,11 @@ let package = Package(
2425 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json")]
2526 ),
2627 .target(name: "Feeds"),
28 .target(name: "ObservingLog"),
2729 .target(name: "Library", resources: [.copy("Resources/pages")]),
2830 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
2931 .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]),
32 .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]),
3033 .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]),
3134 .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]),
3235 ]
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift added +112
@@ -0,0 +1,112 @@
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}
Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift added +44
@@ -0,0 +1,44 @@
1import Foundation
2import Testing
3@testable import ObservingLog
4
5let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
6
7@Test func orgRoundTrip() throws {
8 let o = Observation(object: "Saturn", date: when, place: "Chicago", latitude: 41.8781, longitude: -87.6298, equipment: "10x50 binoculars", seeing: 3, notes: "Rings nearly edge-on.\n\nTitan visible to the east.")
9 let text = o.org
10 #expect(text.hasPrefix("* Saturn\n["))
11 #expect(text.contains(":EQUIPMENT: 10x50 binoculars"))
12 #expect(text.contains(":SEEING: 3/5"))
13 let back = try #require(Observation(org: text))
14 #expect(back.id == o.id)
15 #expect(back.object == "Saturn")
16 #expect(abs(back.date.timeIntervalSince(when)) < 60)
17 #expect(back.latitude == 41.8781)
18 #expect(back.seeing == 3)
19 #expect(back.notes == o.notes)
20}
21
22@Test func minimalEntryRoundTrips() throws {
23 let o = Observation(object: "M31", date: when, place: "Backyard")
24 let back = try #require(Observation(org: o.org))
25 #expect(back.equipment == "" && back.seeing == nil && back.notes == "")
26}
27
28@Test func folderSaveListDelete() throws {
29 let dir = FileManager.default.temporaryDirectory.appending(path: "obs-\(UUID().uuidString)")
30 defer { try? FileManager.default.removeItem(at: dir) }
31 let log = ObservingLog(folder: dir)
32 var a = Observation(object: "Moon", date: when, place: "Chicago")
33 let b = Observation(object: "Jupiter", date: when.addingTimeInterval(3600), place: "Chicago")
34 try log.save(a)
35 try log.save(b)
36 #expect(log.all().map(\.object) == ["Jupiter", "Moon"])
37 a.notes = "Earthshine."
38 try log.save(a)
39 #expect(log.all().count == 2)
40 #expect(log.all().first { $0.id == a.id }?.notes == "Earthshine.")
41 try log.delete(id: b.id)
42 #expect(log.all().map(\.object) == ["Moon"])
43 #expect(log.fileURL(for: a).lastPathComponent.hasSuffix("-moon.org"))
44}