@@ -6,7 +6,7 @@ import AppKit
private let SAME_TIME_TOL_MIN : Double = 5
private let SKIP_ALL_DAY_DEFAULT = true
enum OverlapMode : String , CaseIterable , Identifiable {
enum OverlapMode : String , CaseIterable , Identifiable , Codable {
case allow , skipCovered , fillGaps
var id : String { rawValue }
}
@@ -68,8 +68,13 @@ private func parseMirrorURL(_ url: URL?) -> (srcEventID: String?, occ: Date?, st
var sDate : Date ? = nil
var eDate : Date ? = nil
if parts . count >= 3 { srcID = String ( parts [ 2 ] ) }
if parts . count >= 4 , let ts = TimeInterval ( parts [ 3 ] ) { occDate = Date ( timeIntervalSince1970 : ts ) }
if parts . count >= 6 , let sTS = TimeInterval ( parts [ 4 ] ) , let eTS = TimeInterval ( parts [ 5 ] ) {
if parts . count >= 4 ,
let t s = TimeInterval ( String ( parts [ 3 ] ) ) {
occDate = Date ( timeIntervalSince1970 : ts )
}
if parts . count >= 6 ,
let sTS = TimeInterval ( String ( parts [ 4 ] ) ) ,
let eTS = TimeInterval ( String ( parts [ 5 ] ) ) {
sDate = Date ( timeIntervalSince1970 : sTS )
eDate = Date ( timeIntervalSince1970 : eTS )
}
@@ -94,7 +99,7 @@ struct Block: Hashable {
let occurrence : Date ? // o c c u r r e n c e D a t e f o r r e c u r r i n g i n s t a n c e s
}
struct Route : Identifiable , Hashable {
struct Route : Identifiable , Hashable , Codable {
let id = UUID ( )
var sourceID : String
var targetIDs : Set < String >
@@ -115,14 +120,18 @@ struct ContentView: View {
@ State private var sourceID : String ? = nil
@ State private var targetIDs = Set < String > ( )
@ State private var routes : [ Route ] = [ ]
@ State private var daysForward : Int = 7
@ State private var daysBack : Int = 1
@ AppStorage ( " daysForward " ) private var daysForward : Int = 7
@ AppStorage ( " daysBack " ) private var daysBack : Int = 1
@ State private var mergeGapMin : Int = 0
@ State private var mergeGapHours : Int = 0
@ State private var hideDetails = true // P r i v a c y O N b y d e f a u l t - > u s e " B u s y "
@ State private var copyDescription = false // O n l y a p p l i e s w h e n h i d e D e t a i l s = = f a l s e
@ State private var overlapMode : OverlapMode = . allow
@ State private var mirrorAllDay = fals e
@ AppStorage ( " mergeGapHours " ) private var mergeGapHours : Int = 0
@ AppStorage ( " hideDetails " ) private var hideDetails : Bool = true // P r i v a c y O N b y d e f a u l t - > u s e " B u s y "
@ AppStorage ( " copyDescription " ) private var copyDescription : Bool = false // O n l y a p p l i e s w h e n h i d e D e t a i l s = = f a l s e
@ AppStorage ( " mirrorAllDay " ) private var mirrorAllDay : Bool = false
@ AppStorage ( " overlapMode " ) private var overlapModeRaw : String = OverlapMode . allow . rawValu e
var overlapMode : OverlapMode {
get { OverlapMode ( rawValue : overlapModeRaw ) ? ? . allow }
nonmutating set { overlapModeRaw = newValue . rawValue }
}
@ State private var writeEnabled = false // d r y - r u n u n l e s s c h e c k e d
@ State private var logText = " Ready. "
@ State private var isRunning = false
@@ -132,9 +141,9 @@ struct ContentView: View {
// i n t o t h e s a m e t a r g e t m o r e t h a n o n c e a c r o s s m u l t i p l e r o u t e s w i t h i n a
// s i n g l e " M i r r o r N o w " c l i c k .
@ State private var sessionGuard = Set < String > ( )
@ State private var titlePrefix : String = " 🪞 " // g l o b a l t i t l e p r e f i x f o r m i r r o r e d p l a c e h o l d e r s
@ State private var placeholderTitle : String = " Busy " // g l o b a l c u s t o m i z a b l e p l a c e h o l d e r t i t l e
@ State private var autoDeleteMissing : Bool = true // d e l e t e m i r r o r s w h o s e s o u r c e i n s t a n c e n o l o n g e r e x i s t s
@ AppStorage ( " titlePrefix " ) private var titlePrefix : String = " 🪞 " // g l o b a l t i t l e p r e f i x f o r m i r r o r e d p l a c e h o l d e r s
@ AppStorage ( " placeholderTitle " ) private var placeholderTitle : String = " Busy " // g l o b a l c u s t o m i z a b l e p l a c e h o l d e r t i t l e
@ AppStorage ( " autoDeleteMissing " ) private var autoDeleteMissing : Bool = true // d e l e t e m i r r o r s w h o s e s o u r c e i n s t a n c e n o l o n g e r e x i s t s
private static let intFormatter : NumberFormatter = {
let f = NumberFormatter ( )
@@ -241,92 +250,119 @@ struct ContentView: View {
overlap : overlapMode ,
allDay : mirrorAllDay )
routes . append ( r )
} . disabled ( isRunning || calendars . isEmpty || targetIDs . isEmpty )
Button ( " Clear " ) { routes . removeAll ( ) } . disabled ( isRunning || route s. isEmpty )
}
. disabled ( isRunning || calendars . isEmpty || targetID s. isEmpty )
Button ( " Clear " ) { routes . removeAll ( ) }
. disabled ( isRunning || routes . isEmpty )
}
if routes . isEmpty {
Text ( " No routes yet. Pick a Source and Targets above, then click ‘ Add from current selection’ . " )
. foregroundStyle ( . secondary )
} else {
ForEach ( Array ( routes . enumerated ( ) ) , id : \ . element . id ) { idx , route in
VStack ( alignment : . leading , spacing : 4 ) {
HStack ( alignment : . firstTextBaseline , spacing : 8 ) {
Text ( " Source: " )
if let sCal = calendars . first ( where : { $0 . calendarIdentifier = = route . sourceID } ) {
HStack ( spacing : 6 ) {
Circle ( ) . fill ( calColor ( sCal ) ) . frame ( width : 10 , height : 10 )
Text ( calLabel ( sCal ) ) . bold ( )
}
} else {
Text ( labelForCalendar ( id : route . sourceID ) ) . bold ( )
}
Text ( " → Targets: " )
ScrollView ( . horizontal , showsIndicators : false ) {
HStack ( spacing : 10 ) {
ForEach ( route . targetIDs . sorted ( by : < ) , id : \ . self ) { tid in
if let tCal = calendars . first ( where : { $0 . calendarIdentifier = = tid } ) {
HStack ( spacing : 6 ) {
Circle ( ) . fill ( calColor ( tCal ) ) . frame ( width : 10 , height : 10 )
Text ( calLabel ( tCal ) )
}
} else {
Text ( labelForCalendar ( id : tid ) )
}
}
}
}
. frame ( maxWidth : 420 )
Spacer ( )
Toggle ( " Private " , isOn : Binding ( get : { routes [ idx ] . privacy } , set : { routes [ idx ] . privacy = $0 } ) )
. help ( " If ON, mirror as ‘ \( titlePrefix ) \( placeholderTitle ) ’ with no notes. If OFF, mirror source title (and optionally notes)." )
Text ( " · " ) . foregroundStyle ( . secondary )
HStack ( spacing : 6 ) {
Text ( " Merge gap: " )
TextField ( " 0 " , value : Binding (
get : { routes [ idx ] . mergeGapHours } ,
set : { routes [ idx ] . mergeGapHours = max ( 0 , $0 ) }
) , formatter : Self . intFormatter )
. frame ( width : 48 )
. disabled ( isRunning )
. help ( " Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge. " )
Text ( " h " ) . foregroundStyle ( . secondary )
}
Toggle ( " Copy desc " , isOn : Binding (
get : { routes [ idx ] . copyNotes } ,
set : { routes [ idx ] . copyNotes = $0 }
) )
. disabled ( isRunning || routes [ idx ] . privacy )
. help ( " If ON and Private is OFF, copy the source event’ s notes/description into the placeholder. " )
HStack ( spacing : 6 ) {
Text ( " Overlap: " )
Picker ( " Overlap " , selection : Binding (
get : { routes [ idx ] . overlap } ,
set : { routes [ idx ] . overlap = $0 }
) ) {
ForEach ( OverlapMode . allCases ) { m in Text ( m . rawValue ) . tag ( m ) }
}
. frame ( width : 160 )
. help ( " allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block. " )
}
. disabled ( isRunning )
Toggle ( " All‑ day " , isOn : Binding (
get : { routes [ idx ] . allDay } ,
set : { routes [ idx ] . allDay = $0 }
) )
. disabled ( isRunning )
. help ( " Mirror all‑ day events for this source. " )
Text ( " · " ) . foregroundStyle ( . secondary )
Button ( role : . destructive ) { routes . remove ( at : idx ) } label : { Text ( " Remove " ) }
}
}
. padding ( 6 )
. background ( . quaternary . opacity ( 0.1 ) )
. cornerRadius ( 6 )
ForEach ( $ routes , id : \ . id ) { routeBinding in
routeCard ( for : routeBinding )
}
}
}
}
@ ViewBuilder
private func routeCard ( for routeBinding : Binding < Route > ) -> some View {
let route = routeBinding . wrappedValue
VStack ( alignment : . leading , spacing : 4 ) {
HStack ( alignment : . firstTextBaseline , spacing : 8 ) {
sourceSummaryView ( for : route )
Text ( " → Targets: " )
targetSummaryView ( for : route )
Spacer ( )
Toggle ( " Private " , isOn : routeBinding . privacy )
. help ( " If ON, mirror as ‘ \( titlePrefix ) \( placeholderTitle ) ’ with no notes. If OFF, mirror source title (and optionally notes)." )
separatorDot ( )
mergeGapField ( for : routeBinding )
Toggle ( " Copy desc " , isOn : routeBinding . copyNotes )
. disabled ( isRunning || route . privacy )
. help ( " If ON and Private is OFF, copy the source event’ s notes/description into the placeholder. " )
overlapPicker ( for : routeBinding )
. disabled ( isRunning )
Toggle ( " All-day " , isOn : routeBinding . allDay )
. disabled ( isRunning )
. help ( " Mirror all-day events for this source. " )
separatorDot ( )
Button ( role : . destructive ) { removeRoute ( id : route . id ) } label : { Text ( " Remove " ) }
}
}
. padding ( 6 )
. background ( . quaternary . opacity ( 0.1 ) )
. cornerRadius ( 6 )
}
@ ViewBuilder
private func sourceSummaryView ( for route : Route ) -> some View {
Text ( " Source: " )
if let sCal = calendars . first ( where : { $0 . calendarIdentifier = = route . sourceID } ) {
HStack ( spacing : 6 ) {
Circle ( ) . fill ( calColor ( sCal ) ) . frame ( width : 10 , height : 10 )
Text ( calLabel ( sCal ) ) . bold ( )
}
} else {
Text ( labelForCalendar ( id : route . sourceID ) ) . bold ( )
}
}
@ ViewBuilder
private func targetSummaryView ( for route : Route ) -> some View {
ScrollView ( . horizontal , showsIndicators : false ) {
HStack ( spacing : 10 ) {
ForEach ( route . targetIDs . sorted ( by : < ) , id : \ . self ) { tid in
if let tCal = calendars . first ( where : { $0 . calendarIdentifier = = tid } ) {
HStack ( spacing : 6 ) {
Circle ( ) . fill ( calColor ( tCal ) ) . frame ( width : 10 , height : 10 )
Text ( calLabel ( tCal ) )
}
} else {
Text ( labelForCalendar ( id : tid ) )
}
}
}
}
. frame ( maxWidth : 420 )
}
@ ViewBuilder
private func mergeGapField ( for routeBinding : Binding < Route > ) -> some View {
HStack ( spacing : 6 ) {
Text ( " Merge gap: " )
TextField ( " 0 " , value : routeBinding . mergeGapHours , formatter : Self . intFormatter )
. frame ( width : 48 )
. disabled ( isRunning )
. help ( " Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge. " )
Text ( " h " ) . foregroundStyle ( . secondary )
}
}
@ ViewBuilder
private func overlapPicker ( for routeBinding : Binding < Route > ) -> some View {
HStack ( spacing : 6 ) {
Text ( " Overlap: " )
Picker ( " Overlap " , selection : routeBinding . overlap ) {
ForEach ( OverlapMode . allCases ) { mode in
Text ( mode . rawValue ) . tag ( mode )
}
}
. frame ( width : 160 )
. help ( " allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block. " )
}
}
@ ViewBuilder
private func separatorDot ( ) -> some View {
Text ( " · " ) . foregroundStyle ( . secondary )
}
private func removeRoute ( id : UUID ) {
routes . removeAll { $0 . id = = id }
}
@ ViewBuilder
private func optionsSection ( ) -> some View {
VStack ( alignment : . leading , spacing : 8 ) {
@@ -340,8 +376,8 @@ struct ContentView: View {
. frame ( width : 60 )
. disabled ( isRunning )
}
. onChange ( of : daysBack ) { _ , v in daysBack = max ( 0 , v ) }
. onChange ( of : daysForward ) { _ , v in daysForward = max ( 0 , v ) }
. onChange ( of : daysBack ) { v in daysBack = max ( 0 , v ) }
. onChange ( of : daysForward ) { v in daysForward = max ( 0 , v ) }
HStack ( spacing : 8 ) {
Text ( " Default merge gap: " )
TextField ( " 0 " , value : $ mergeGapHours , formatter : Self . intFormatter )
@@ -349,7 +385,7 @@ struct ContentView: View {
. disabled ( isRunning )
Text ( " hours " ) . foregroundStyle ( . secondary )
}
. onChange ( of : mergeGapHours ) { _ , newVal in
. onChange ( of : mergeGapHours ) { newVal in
mergeGapMin = max ( 0 , newVal * 60 )
}
Toggle ( " Hide details (use \" Busy \" title) " , isOn : $ hideDetails )
@@ -359,9 +395,9 @@ struct ContentView: View {
. disabled ( isRunning || hideDetails )
Toggle ( " Mirror all-day events " , isOn : $ mirrorAllDay )
. disabled ( isRunning )
Picker ( " Overlap mode " , selection : $ overlapMode ) {
Picker ( " Overlap mode " , selection : $ overlapModeRaw ) {
ForEach ( OverlapMode . allCases ) { mode in
Text ( mode . rawValue ) . tag ( mode )
Text ( mode . rawValue ) . tag ( mode . rawValue )
}
}
. pickerStyle ( . segmented )
@@ -388,6 +424,11 @@ struct ContentView: View {
// I n s e r t a u t o - d e l e t e t o g g l e a f t e r w r i t e E n a b l e d
Toggle ( " Auto-delete mirrors if source is removed " , isOn : $ autoDeleteMissing )
. disabled ( isRunning )
HStack ( spacing : 12 ) {
Button ( " Export Settings… " ) { exportSettings ( ) }
Button ( " Import Settings… " ) { importSettings ( ) }
}
. padding ( . vertical , 4 )
HStack {
Button ( isRunning ? " Running… " : " Mirror Now " ) {
@@ -400,11 +441,6 @@ struct ContentView: View {
for r in routes {
// R e s o l v e s o u r c e i n d e x a n d t a r g e t I D s f o r t h i s r o u t e
if let sIdx = indexForCalendar ( id : r . sourceID ) {
sourceIndex = sIdx
sourceID = r . sourceID
targetIDs = r . targetIDs
// B e l t - a n d - s u s p e n d e r s : e n s u r e s o u r c e i s n o t i n t a r g e t s e v e n i f U I s t a t e i s s t a l e
targetIDs . remove ( r . sourceID )
// S a v e g l o b a l s
let prevPrivacy = hideDetails
let prevCopy = copyDescription
@@ -412,21 +448,30 @@ struct ContentView: View {
let prevGapM = mergeGapMin
let prevOverlap = overlapMode
let prevAllDay = mirrorAllDay
// A p p l y p e r - r o u t e
hideDetails = r . privacy
copyDescription = r . copyNotes
mergeGapHours = max ( 0 , r . mergeGapHours )
mergeGapMin = mergeGapHours * 60
overlapMode = r . overlap
mirrorAllDay = r . allDay
// A p p l y p e r - r o u t e s t a t e c h a n g e s o n M a i n A c t o r
await MainActor . run {
sourceIndex = sIdx
sourceID = r . sourceID
targetIDs = r . targetIDs
// B e l t - a n d - s u s p e n d e r s : e n s u r e s o u r c e i s n o t i n t a r g e t s e v e n i f U I s t a t e i s s t a l e
targetIDs . remove ( r . sourceID )
hideDetails = r . privacy
copyDescription = r . copyNotes
mergeGapHours = max ( 0 , r . mergeGapHours )
mergeGapMin = mergeGapHours * 60
overlapModeRaw = r . overlap . rawValue
mirrorAllDay = r . allDay
}
await runMirror ( )
// R e s t o r e g l o b a l s
hideDetails = prevPrivacy
copyDescription = prevCop y
mergeGapHours = prevGapH
mergeGapMin = prevGapM
overlapMode = prevOverl ap
mirrorAllDay = prevAllDay
await MainActor . run {
// R e s t o r e g l o b a l s
hideDetails = prevPrivac y
copyDescription = prevCopy
mergeGapHours = prevGapH
mergeGapMin = prevG apM
overlapModeRaw = prevOverlap . rawValue
mirrorAllDay = prevAllDay
}
}
}
}
@@ -446,9 +491,11 @@ struct ContentView: View {
} else {
for r in routes {
if let sIdx = indexForCalendar ( id : r . sourceID ) {
sourceIndex = sIdx
sourceID = r . sourceID
target IDs = r . target IDs
await MainActor . run {
sourceIndex = sIdx
source ID = r . source ID
targetIDs = r . targetIDs
}
await runCleanup ( )
}
}
@@ -514,9 +561,11 @@ struct ContentView: View {
} else {
for r in routes {
if let sIdx = indexForCalendar ( id : r . sourceID ) {
sourceIndex = sIdx
sourceID = r . sourceID
target IDs = r . target IDs
await MainActor . run {
sourceIndex = sIdx
source ID = r . source ID
targetIDs = r . targetIDs
}
await runCleanup ( )
}
}
@@ -529,23 +578,27 @@ struct ContentView: View {
}
. onAppear {
requestAccess ( )
mergeGapHours = mergeGapMin / 60
loadSettingsFromDefaults ( )
mergeGapMin = max ( 0 , mergeGapHours * 60 )
tryRunCLIIfPresent ( )
enforceNoSourceInTargets ( )
}
. onChange ( of : sourceIndex ) { oldValue , newValue in
. onChange ( of : sourceIndex ) { newValue in
// T r a c k s e l e c t e d s o u r c e b y p e r s i s t e n t I D a n d e n s u r e i t i s n o t a t a r g e t
if newValue < calendars . count { sourceID = calendars [ newValue ] . calendarIdentifier }
enforceNoSourceInTargets ( )
}
. onChange ( of : targetSelections ) { _ , _ in
. onChange ( of : targetSelections ) { _ in
// I f t h e n e w s o u r c e i s a c c i d e n t a l l y i n c l u d e d , d r o p i t
enforceNoSourceInTargets ( )
}
. onChange ( of : targetIDs ) { _ , _ in
. onChange ( of : targetIDs ) { _ in
// I f I D s c o n t a i n t h e s o u r c e ’ s I D , d r o p i t
enforceNoSourceInTargets ( )
}
. onChange ( of : routes ) { _ in
saveSettingsToDefaults ( )
}
}
// MARK: - C L I s u p p o r t
@@ -581,9 +634,9 @@ struct ContentView: View {
mergeGapMin = max ( 0 , mergeGapHours * 60 )
if let modeStr = strArg ( " --mode " ) ? . lowercased ( ) {
switch modeStr {
case " allow " : overlapMode = . allow
case " skipcovered " , " skip " : overlapMode = . skipCovered
case " fillgaps " , " gaps " : overlapMode = . fillGaps
case " allow " : overlapModeRaw = OverlapMode . allow . rawValue
case " skipcovered " , " skip " : overlapModeRaw = OverlapMode . skipCovered . rawValue
case " fillgaps " , " gaps " : overlapModeRaw = OverlapMode . fillGaps . rawValue
default : break
}
}
@@ -610,10 +663,12 @@ struct ContentView: View {
let srcIdx0 = max ( 0 , s1 - 1 )
let tgtIdxs0 : [ Int ] = lr [ 1 ] . split ( separator : " , " ) . compactMap { Int ( $0 . trimmingCharacters ( in : . whitespaces ) ) ? . advanced ( by : - 1 ) } . filter { $0 >= 0 }
if srcIdx0 >= calendars . count { continue }
sourceIndex = srcIdx0
sourceID = calendars [ srcIdx0 ] . calendarIdentifier
targetSelections = Set ( tgt Idxs 0)
targetID s = Set ( tgtIdxs0 . compactMap { i in calendars . indices . contains ( i ) ? calendars [ i ] . calendarIdentifier : nil } )
await MainActor . run {
sourceIndex = srcIdx0
sourceID = calendars [ src Idx0] . calendarIdentifier
targetSelection s = Set ( tgtIdxs0 )
targetIDs = Set ( tgtIdxs0 . compactMap { i in calendars . indices . contains ( i ) ? calendars [ i ] . calendarIdentifier : nil } )
}
if boolArg ( " --cleanup-only " , default : false ) {
log ( " CLI: cleanup route \( part ) " )
@@ -970,6 +1025,130 @@ struct ContentView: View {
}
}
// MARK: - E x p o r t / I m p o r t S e t t i n g s
private struct SettingsPayload : Codable {
var daysBack : Int
var daysForward : Int
var mergeGapHours : Int
var hideDetails : Bool
var copyDescription : Bool
var mirrorAllDay : Bool
var overlapMode : String
var titlePrefix : String
var placeholderTitle : String
var autoDeleteMissing : Bool
var routes : [ Route ]
// o p t i o n a l m e t a d a t a
var appVersion : String ?
var exportedAt : Date = Date ( )
}
private func makeSnapshot ( ) -> SettingsPayload {
SettingsPayload (
daysBack : daysBack ,
daysForward : daysForward ,
mergeGapHours : mergeGapHours ,
hideDetails : hideDetails ,
copyDescription : copyDescription ,
mirrorAllDay : mirrorAllDay ,
overlapMode : overlapMode . rawValue ,
titlePrefix : titlePrefix ,
placeholderTitle : placeholderTitle ,
autoDeleteMissing : autoDeleteMissing ,
routes : routes ,
appVersion : Bundle . main . object ( forInfoDictionaryKey : " CFBundleShortVersionString " ) as ? String ,
exportedAt : Date ( )
)
}
private func applySnapshot ( _ s : SettingsPayload ) {
daysBack = s . daysBack
daysForward = s . daysForward
mergeGapHours = s . mergeGapHours
mergeGapMin = max ( 0 , s . mergeGapHours * 60 )
hideDetails = s . hideDetails
copyDescription = s . copyDescription
mirrorAllDay = s . mirrorAllDay
overlapMode = OverlapMode ( rawValue : s . overlapMode ) ? ? . allow
titlePrefix = s . titlePrefix
placeholderTitle = s . placeholderTitle
autoDeleteMissing = s . autoDeleteMissing
routes = s . routes
}
private func exportSettings ( ) {
let panel = NSSavePanel ( )
panel . allowedFileTypes = [ " json " ]
panel . nameFieldStringValue = " BusyMirror-Settings.json "
panel . canCreateDirectories = true
panel . isExtensionHidden = false
if panel . runModal ( ) = = . OK , let url = panel . url {
do {
let encoder = JSONEncoder ( )
encoder . outputFormatting = [ . prettyPrinted , . sortedKeys , . withoutEscapingSlashes ]
let data = try encoder . encode ( makeSnapshot ( ) )
try data . write ( to : url , options : Data . WritingOptions . atomic )
log ( " ✓ Exported settings to \( url . path ) " )
} catch {
log ( " ✗ Export failed: \( error . localizedDescription ) " )
}
}
}
private func importSettings ( ) {
let panel = NSOpenPanel ( )
panel . allowedFileTypes = [ " json " ]
panel . allowsMultipleSelection = false
panel . canChooseDirectories = false
if panel . runModal ( ) = = . OK , let url = panel . url {
do {
let data = try Data ( contentsOf : url )
let snap = try JSONDecoder ( ) . decode ( SettingsPayload . self , from : data )
applySnapshot ( snap )
saveSettingsToDefaults ( )
log ( " ✓ Imported settings from \( url . lastPathComponent ) " )
} catch {
log ( " ✗ Import failed: \( error . localizedDescription ) " )
}
}
}
// MARK: - S e t t i n g s p e r s i s t e n c e ( U s e r D e f a u l t s )
private let settingsDefaultsKey = " settings.v2 "
private let legacyRoutesDefaultsKey = " routes.v1 "
private func saveSettingsToDefaults ( ) {
do {
let data = try JSONEncoder ( ) . encode ( makeSnapshot ( ) )
UserDefaults . standard . set ( data , forKey : settingsDefaultsKey )
} catch {
log ( " ✗ Failed to save settings: \( error . localizedDescription ) " )
}
}
private func loadSettingsFromDefaults ( ) {
let defaults = UserDefaults . standard
if let data = defaults . data ( forKey : settingsDefaultsKey ) {
do {
let snap = try JSONDecoder ( ) . decode ( SettingsPayload . self , from : data )
applySnapshot ( snap )
} catch {
log ( " ✗ Failed to load settings: \( error . localizedDescription ) " )
}
return
}
// L e g a c y f a l l b a c k : r o u t e s - o n l y p a y l o a d
guard let legacyData = defaults . data ( forKey : legacyRoutesDefaultsKey ) else { return }
do {
let decodedRoutes = try JSONDecoder ( ) . decode ( [ Route ] . self , from : legacyData )
routes = decodedRoutes
saveSettingsToDefaults ( ) // u p g r a d e s t o r e d f o r m a t
} catch {
log ( " ✗ Failed to load routes: \( error . localizedDescription ) " )
}
}
// MARK: - L o g g i n g
func log ( _ s : String ) {
logText . append ( " \n " + s )