v2: Observing log (Org files) !23
6 files changed, +305 −4
Layout: unified · split
App/Sources/LogScreen.swift added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | import ObservingLog | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// The observing log lives in Documents/Observations, one Org file per entry, visible in the Files app. | |
| 5 | let observingLog = ObservingLog(folder: URL.documentsDirectory.appending(path: "Observations")) | |
| 6 | ||
| 7 | struct 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 | ||
| 58 | struct 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 @@ | ||
| 1 | 1 | import Library |
| 2 | import ObservingLog | |
| 2 | 3 | import SkyEngine |
| 3 | 4 | import SkyRenderer |
| 4 | 5 | import SwiftUI |
| @@ -12,6 +13,8 @@ struct SkyScreen: View { | ||
| 12 | 13 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" |
| 13 | 14 | @State private var showSettings = false |
| 14 | 15 | @State private var showEvents = false |
| 16 | @State private var showLog = false | |
| 17 | @State private var logEntry: Observation? | |
| 15 | 18 | @AppStorage(SettingsKey.onboarded) private var onboarded = false |
| 16 | 19 | @AppStorage(SettingsKey.nightActive) private var nightActive = false |
| 17 | 20 | @State private var showToday = false |
| @@ -58,6 +61,12 @@ struct SkyScreen: View { | ||
| 58 | 61 | } |
| 59 | 62 | .foregroundStyle(ink) |
| 60 | 63 | .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 | } | |
| 61 | 70 | .sheet(isPresented: $showEvents) { |
| 62 | 71 | EventsScreen(observer: model.observer, placeName: model.placeName) |
| 63 | 72 | .preferredColorScheme(.dark) |
| @@ -153,6 +162,12 @@ struct SkyScreen: View { | ||
| 153 | 162 | } |
| 154 | 163 | .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") |
| 155 | 164 | } |
| 165 | Button { showLog = true } label: { | |
| 166 | Image(systemName: "book.closed") | |
| 167 | .font(.body) | |
| 168 | .frame(width: 44, height: 44) | |
| 169 | } | |
| 170 | .accessibilityLabel("Observing log") | |
| 156 | 171 | Button { showEvents = true } label: { |
| 157 | 172 | Image(systemName: "calendar") |
| 158 | 173 | .font(.body) |
| @@ -193,12 +208,19 @@ struct SkyScreen: View { | ||
| 193 | 208 | case .constellation(let id): |
| 194 | 209 | Text("Constellation · \(id)").font(.footnote).dimmed(0.7) |
| 195 | 210 | } |
| 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)) | |
| 199 | 214 | } |
| 200 | 215 | .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 | } | |
| 202 | 224 | } |
| 203 | 225 | } |
| 204 | 226 | .padding(16) |
| @@ -206,6 +228,11 @@ struct SkyScreen: View { | ||
| 206 | 228 | .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5)) |
| 207 | 229 | } |
| 208 | 230 | |
| 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 | ||
| 209 | 236 | private func libraryPage(for object: SkyObject) -> LibraryPage? { |
| 210 | 237 | switch object { |
| 211 | 238 | case .body(let b): Library.page(body: b.rawValue) |
App/project.yml +4
| @@ -22,6 +22,8 @@ targets: | ||
| 22 | 22 | product: Feeds |
| 23 | 23 | - package: MoonriseKit |
| 24 | 24 | product: Library |
| 25 | - package: MoonriseKit | |
| 26 | product: ObservingLog | |
| 25 | 27 | - target: MoonriseWidgets |
| 26 | 28 | entitlements: |
| 27 | 29 | path: Sources/Moonrise.entitlements |
| @@ -51,6 +53,8 @@ targets: | ||
| 51 | 53 | INFOPLIST_KEY_CFBundleDisplayName: Moonrise |
| 52 | 54 | INFOPLIST_KEY_NSSupportsLiveActivities: YES |
| 53 | 55 | INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO |
| 56 | INFOPLIST_KEY_UIFileSharingEnabled: YES | |
| 57 | INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace: YES | |
| 54 | 58 | ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| 55 | 59 | INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| 56 | 60 | INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent |
Packages/MoonriseKit/Package.swift +3
| @@ -9,6 +9,7 @@ let package = Package( | ||
| 9 | 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), |
| 10 | 10 | .library(name: "Feeds", targets: ["Feeds"]), |
| 11 | 11 | .library(name: "Library", targets: ["Library"]), |
| 12 | .library(name: "ObservingLog", targets: ["ObservingLog"]), | |
| 12 | 13 | ], |
| 13 | 14 | targets: [ |
| 14 | 15 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy |
| @@ -24,9 +25,11 @@ let package = Package( | ||
| 24 | 25 | resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json")] |
| 25 | 26 | ), |
| 26 | 27 | .target(name: "Feeds"), |
| 28 | .target(name: "ObservingLog"), | |
| 27 | 29 | .target(name: "Library", resources: [.copy("Resources/pages")]), |
| 28 | 30 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
| 29 | 31 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]), |
| 32 | .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]), | |
| 30 | 33 | .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]), |
| 31 | 34 | .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]), |
| 32 | 35 | ] |
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One logged observation, stored as an Org file that Emacs and orgstar can read. | |
| 4 | public 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). | |
| 79 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import ObservingLog | |
| 4 | ||
| 5 | let 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 | } | |