@@ -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,23 @@ 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
@ AppStorage ( " filterByWorkHours " ) private var filterByWorkHours : Bool = false
@ AppStorage ( " workHoursStart " ) private var workHoursStart : Int = 9
@ AppStorage ( " workHoursEnd " ) private var workHoursEnd : Int = 17
@ AppStorage ( " excludedTitleFilters " ) private var excludedTitleFiltersRaw : String = " "
@ AppStorage ( " mirrorAcceptedOnly " ) private var mirrorAcceptedOnly : Bool = false
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 +146,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 ( )
@@ -144,6 +158,14 @@ struct ContentView: View {
return f
} ( )
private static let hourFormatter : NumberFormatter = {
let f = NumberFormatter ( )
f . numberStyle = . none
f . minimum = 0
f . maximum = 24
return f
} ( )
// D e t e r m i n i s t i c o r d e r i n g t o k e e p i n d i c e s s t a b l e a c r o s s r u n s
private func sortedCalendars ( _ cals : [ EKCalendar ] ) -> [ EKCalendar ] {
return cals . sorted { a , b in
@@ -153,6 +175,17 @@ struct ContentView: View {
}
}
private var excludedTitleFilterList : [ String ] {
excludedTitleFiltersRaw
. split { $0 = = " \n " || $0 = = " , " }
. map { $0 . trimmingCharacters ( in : . whitespacesAndNewlines ) }
. filter { ! $0 . isEmpty }
}
private var excludedTitleFilterTerms : [ String ] {
excludedTitleFilterList . map { $0 . lowercased ( ) }
}
private func rebuildSelectionsFromIDs ( ) {
// M a p I D s - > i n d i c e s i n c u r r e n t c a l e n d a r s
var idToIndex : [ String : Int ] = [ : ]
@@ -241,92 +274,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 +400,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 +409,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 +419,11 @@ struct ContentView: View {
. disabled ( isRunning || hideDetails )
Toggle ( " Mirror all-day events " , isOn : $ mirrorAllDay )
. disabled ( isRunning )
Picker ( " Overlap mode " , selectio n: $ overlapMode ) {
Toggle ( " Mirror accepted events only " , isO n: $ mirrorAcceptedOnly )
. disabled ( isRunning )
Picker ( " Overlap mode " , selection : $ overlapModeRaw ) {
ForEach ( OverlapMode . allCases ) { mode in
Text ( mode . rawValue ) . tag ( mode )
Text ( mode . rawValue ) . tag ( mode . rawValue )
}
}
. pickerStyle ( . segmented )
@@ -382,12 +444,64 @@ struct ContentView: View {
. disabled ( isRunning )
}
Toggle ( " Limit mirroring to work hours " , isOn : $ filterByWorkHours )
. disabled ( isRunning )
. onChange ( of : filterByWorkHours ) { _ in saveSettingsToDefaults ( ) }
if filterByWorkHours {
VStack ( alignment : . leading , spacing : 4 ) {
HStack ( spacing : 8 ) {
Text ( " Start hour: " )
TextField ( " 9 " , value : $ workHoursStart , formatter : Self . hourFormatter )
. frame ( width : 48 )
. disabled ( isRunning )
Text ( " End hour: " )
TextField ( " 17 " , value : $ workHoursEnd , formatter : Self . hourFormatter )
. frame ( width : 48 )
. disabled ( isRunning )
Text ( " (local time) " ) . foregroundStyle ( . secondary )
}
Text ( " Events starting outside this range are skipped; end hour is exclusive. " )
. foregroundStyle ( . secondary )
. font ( . footnote )
}
. onChange ( of : workHoursStart ) { _ in
clampWorkHours ( )
saveSettingsToDefaults ( )
}
. onChange ( of : workHoursEnd ) { _ in
clampWorkHours ( )
saveSettingsToDefaults ( )
}
}
VStack ( alignment : . leading , spacing : 6 ) {
Text ( " Skip source titles (one per line) " )
TextEditor ( text : $ excludedTitleFiltersRaw )
. font ( . body )
. frame ( minHeight : 80 )
. disabled ( isRunning )
. overlay (
RoundedRectangle ( cornerRadius : 6 )
. stroke ( Color . secondary . opacity ( 0.3 ) )
)
Text ( " Matches are case-insensitive and apply before mirroring. " )
. foregroundStyle ( . secondary )
. font ( . footnote )
}
. onChange ( of : excludedTitleFiltersRaw ) { _ in saveSettingsToDefaults ( ) }
Toggle ( " Write to calendars (disable for Dry-Run) " , isOn : $ writeEnabled )
. disabled ( isRunning )
// 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 +514,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 +521,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 +564,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 +634,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 +651,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 +707,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 +736,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 ) " )
@@ -630,13 +758,18 @@ struct ContentView: View {
}
// MARK: - P e r m i s s i o n s & C a l e n d a r s
@ MainActor
func requestAccess ( ) {
log ( " Requesting calendar access… " )
if #available ( macOS 14.0 , * ) {
store . requestFullAccessToEvents { granted , _ in
DispatchQueue . main . async {
hasAccess = granted
if granted { reloadCalendars ( ) }
if granted {
// R e i n i t i a l i z e t h e s t o r e a f t e r p e r m i s s i o n c h a n g e s t o e n s u r e s o u r c e s l o a d
store = EKEventStore ( )
reloadCalendars ( )
}
log ( granted ? " Access granted. " : " Access denied. " )
}
}
@@ -644,13 +777,18 @@ struct ContentView: View {
store . requestAccess ( to : . event ) { granted , _ in
DispatchQueue . main . async {
hasAccess = granted
if granted { reloadCalendars ( ) }
if granted {
// R e i n i t i a l i z e t h e s t o r e a f t e r p e r m i s s i o n c h a n g e s t o e n s u r e s o u r c e s l o a d
store = EKEventStore ( )
reloadCalendars ( )
}
log ( granted ? " Access granted. " : " Access denied. " )
}
}
}
}
@ MainActor
func reloadCalendars ( ) {
let fetched = store . calendars ( for : . event )
calendars = sortedCalendars ( fetched )
@@ -704,7 +842,37 @@ struct ContentView: View {
var srcBlocks : [ Block ] = [ ]
var skippedMirrors = 0
let titleFilters = excludedTitleFilterTerms
let enforceWorkHours = filterByWorkHours && workHoursEnd > workHoursStart
let allowedStartMinutes = workHoursStart * 60
let allowedEndMinutes = workHoursEnd * 60
var skippedWorkHours = 0
var skippedTitles = 0
var skippedStatus = 0
for ev in srcEvents {
if mirrorAcceptedOnly , ev . hasAttendees {
// O n l y i n c l u d e e v e n t s w h e r e t h e c u r r e n t u s e r ' s a t t e n d e e s t a t u s i s A c c e p t e d
let attendees = ev . attendees ? ? [ ]
if let me = attendees . first ( where : { $0 . isCurrentUser } ) {
if me . participantStatus != . accepted {
skippedStatus += 1
continue
}
} else {
// I f w e c a n n o t d e t e r m i n e a s e l f a t t e n d e e , t r e a t a s n o t a c c e p t e d
skippedStatus += 1
continue
}
}
if enforceWorkHours , ! ev . isAllDay , let start = ev . startDate ,
isOutsideWorkHours ( start , calendar : cal , startMinutes : allowedStartMinutes , endMinutes : allowedEndMinutes ) {
skippedWorkHours += 1
continue
}
if shouldSkip ( event : ev , filters : titleFilters ) {
skippedTitles += 1
continue
}
if SKIP_ALL_DAY_DEFAULT = = true && mirrorAllDay = = false && ev . isAllDay { continue }
if isMirrorEvent ( ev , prefix : titlePrefix , placeholder : placeholderTitle ) {
// A g g r e g a t e s k i p c o u n t f o r m i r r o r e d - o n - s o u r c e
@@ -720,6 +888,15 @@ struct ContentView: View {
if skippedMirrors > 0 {
log ( " - SKIP mirrored-on-source: \( skippedMirrors ) instance(s) " )
}
if skippedWorkHours > 0 {
log ( " - SKIP outside work hours: \( skippedWorkHours ) event(s) " )
}
if skippedTitles > 0 {
log ( " - SKIP title filter: \( skippedTitles ) event(s) " )
}
if skippedStatus > 0 {
log ( " - SKIP non-accepted status: \( skippedStatus ) event(s) " )
}
// D e d u p l i c a t e s o u r c e b l o c k s t o a v o i d d u p l i c a t e s f r o m E v e n t K i t ( r e c u r r e n c e s / s y n c r a c e s )
srcBlocks = uniqueBlocks ( srcBlocks , trackByID : mergeGapMin = = 0 )
@@ -970,6 +1147,178 @@ 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 filterByWorkHours : Bool = false
var workHoursStart : Int = 9
var workHoursEnd : Int = 17
var excludedTitleFilters : [ String ] = [ ]
var mirrorAcceptedOnly : Bool = false
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 ,
filterByWorkHours : filterByWorkHours ,
workHoursStart : workHoursStart ,
workHoursEnd : workHoursEnd ,
excludedTitleFilters : excludedTitleFilterList ,
mirrorAcceptedOnly : mirrorAcceptedOnly ,
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
filterByWorkHours = s . filterByWorkHours
workHoursStart = s . workHoursStart
workHoursEnd = s . workHoursEnd
excludedTitleFiltersRaw = s . excludedTitleFilters . joined ( separator : " \n " )
mirrorAcceptedOnly = s . mirrorAcceptedOnly
overlapMode = OverlapMode ( rawValue : s . overlapMode ) ? ? . allow
titlePrefix = s . titlePrefix
placeholderTitle = s . placeholderTitle
autoDeleteMissing = s . autoDeleteMissing
routes = s . routes
clampWorkHours ( )
}
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
clampWorkHours ( )
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: - F i l t e r s
private func isOutsideWorkHours ( _ startDate : Date , calendar : Calendar , startMinutes : Int , endMinutes : Int ) -> Bool {
guard endMinutes > startMinutes else { return false }
let comps = calendar . dateComponents ( [ . hour , . minute ] , from : startDate )
guard let hour = comps . hour else { return false }
let minute = comps . minute ? ? 0
let start = hour * 60 + minute
return start < startMinutes || start >= endMinutes
}
private func shouldSkip ( event : EKEvent , filters : [ String ] ) -> Bool {
guard ! filters . isEmpty else { return false }
let rawTitle = ( event . title ? ? " " ) . lowercased ( )
let strippedTitle = stripPrefix ( event . title , prefix : titlePrefix ) . lowercased ( )
return filters . contains { token in
rawTitle . contains ( token ) || strippedTitle . contains ( token )
}
}
private func clampWorkHours ( ) {
let clampedStart = min ( max ( workHoursStart , 0 ) , 23 )
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min ( max ( workHoursEnd , 1 ) , 24 )
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min ( workHoursStart + 1 , 24 )
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
// MARK: - L o g g i n g
func log ( _ s : String ) {
logText . append ( " \n " + s )