timely-calendar 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +39 -0
- data/CLAUDE.md +95 -0
- data/README.md +188 -0
- data/bin/timely +41 -0
- data/img/timely.svg +111 -0
- data/lib/timely/astronomy.rb +180 -0
- data/lib/timely/config.rb +93 -0
- data/lib/timely/database.rb +378 -0
- data/lib/timely/event.rb +76 -0
- data/lib/timely/notifications.rb +83 -0
- data/lib/timely/sources/google.rb +276 -0
- data/lib/timely/sources/ics_file.rb +247 -0
- data/lib/timely/sources/outlook.rb +286 -0
- data/lib/timely/sync/poller.rb +119 -0
- data/lib/timely/ui/application.rb +1960 -0
- data/lib/timely/ui/panes.rb +46 -0
- data/lib/timely/ui/views/month.rb +118 -0
- data/lib/timely/version.rb +3 -0
- data/lib/timely/weather.rb +111 -0
- data/lib/timely.rb +45 -0
- data/timely.gemspec +33 -0
- metadata +98 -0
|
@@ -0,0 +1,1960 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'rcurses'
|
|
5
|
+
require 'io/wait'
|
|
6
|
+
require 'date'
|
|
7
|
+
|
|
8
|
+
module Timely
|
|
9
|
+
class Application
|
|
10
|
+
include Rcurses
|
|
11
|
+
include Rcurses::Input
|
|
12
|
+
include Rcurses::Cursor
|
|
13
|
+
include UI::Panes
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@db = Database.new
|
|
17
|
+
@config = Config.new
|
|
18
|
+
@running = false
|
|
19
|
+
@selected_date = Date.today
|
|
20
|
+
@selected_event_index = 0
|
|
21
|
+
now = Time.now
|
|
22
|
+
@selected_slot = now.hour * 2 + (now.min >= 30 ? 1 : 0)
|
|
23
|
+
@slot_offset = [@selected_slot - 5, 0].max # Show a few rows above current time
|
|
24
|
+
@events_by_date = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
Rcurses.init!
|
|
29
|
+
Rcurses.clear_screen
|
|
30
|
+
|
|
31
|
+
setup_display
|
|
32
|
+
create_panes
|
|
33
|
+
|
|
34
|
+
load_events_for_range
|
|
35
|
+
|
|
36
|
+
# Auto-import ICS files from incoming directory
|
|
37
|
+
incoming_count = Sources::IcsFile.watch_incoming(@db)
|
|
38
|
+
load_events_for_range if incoming_count > 0
|
|
39
|
+
|
|
40
|
+
render_all
|
|
41
|
+
|
|
42
|
+
# Start background sync poller for Google Calendar
|
|
43
|
+
@poller = Sync::Poller.new(@db, @config)
|
|
44
|
+
@poller.start
|
|
45
|
+
|
|
46
|
+
# Flush stdin before loop
|
|
47
|
+
$stdin.getc while $stdin.wait_readable(0)
|
|
48
|
+
|
|
49
|
+
@running = true
|
|
50
|
+
loop do
|
|
51
|
+
chr = getchr(2, flush: false)
|
|
52
|
+
if chr
|
|
53
|
+
handle_input(chr)
|
|
54
|
+
else
|
|
55
|
+
# No input received (timeout); check if poller has new data
|
|
56
|
+
if @poller&.needs_refresh?
|
|
57
|
+
@poller.clear_refresh_flag
|
|
58
|
+
load_events_for_range
|
|
59
|
+
render_all
|
|
60
|
+
end
|
|
61
|
+
# Check notifications on idle (runs inexpensively)
|
|
62
|
+
Notifications.check_and_notify(@db) rescue nil
|
|
63
|
+
# Check for Heathrow goto trigger
|
|
64
|
+
check_heathrow_goto
|
|
65
|
+
end
|
|
66
|
+
break unless @running
|
|
67
|
+
end
|
|
68
|
+
ensure
|
|
69
|
+
@poller&.stop
|
|
70
|
+
Cursor.show
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# --- Input handling ---
|
|
76
|
+
|
|
77
|
+
def handle_input(chr)
|
|
78
|
+
case chr
|
|
79
|
+
when 'y'
|
|
80
|
+
@selected_date = safe_date(@selected_date.year + 1, @selected_date.month, @selected_date.day)
|
|
81
|
+
date_changed
|
|
82
|
+
when 'Y'
|
|
83
|
+
@selected_date = safe_date(@selected_date.year - 1, @selected_date.month, @selected_date.day)
|
|
84
|
+
date_changed
|
|
85
|
+
when 'm'
|
|
86
|
+
@selected_date = @selected_date >> 1
|
|
87
|
+
date_changed
|
|
88
|
+
when 'M'
|
|
89
|
+
@selected_date = @selected_date << 1
|
|
90
|
+
date_changed
|
|
91
|
+
when 'w'
|
|
92
|
+
@selected_date += 7
|
|
93
|
+
date_changed
|
|
94
|
+
when 'W'
|
|
95
|
+
@selected_date -= 7
|
|
96
|
+
date_changed
|
|
97
|
+
when 'd', 'l', 'RIGHT'
|
|
98
|
+
@selected_date += 1
|
|
99
|
+
date_changed
|
|
100
|
+
when 'D', 'h', 'LEFT'
|
|
101
|
+
@selected_date -= 1
|
|
102
|
+
date_changed
|
|
103
|
+
when 'DOWN'
|
|
104
|
+
move_slot_down
|
|
105
|
+
when 'UP'
|
|
106
|
+
move_slot_up
|
|
107
|
+
when 'PgDOWN'
|
|
108
|
+
page_slots_down
|
|
109
|
+
when 'PgUP'
|
|
110
|
+
page_slots_up
|
|
111
|
+
when 'HOME'
|
|
112
|
+
go_slot_top
|
|
113
|
+
when 'END'
|
|
114
|
+
go_slot_bottom
|
|
115
|
+
when 'j'
|
|
116
|
+
select_next_event_on_day
|
|
117
|
+
when 'k'
|
|
118
|
+
select_prev_event_on_day
|
|
119
|
+
when 'e'
|
|
120
|
+
jump_to_next_event
|
|
121
|
+
when 'E'
|
|
122
|
+
jump_to_prev_event
|
|
123
|
+
when 't'
|
|
124
|
+
@selected_date = Date.today
|
|
125
|
+
@selected_event_index = 0
|
|
126
|
+
date_changed
|
|
127
|
+
when 'g'
|
|
128
|
+
go_to_date
|
|
129
|
+
when 'n'
|
|
130
|
+
create_event
|
|
131
|
+
when 'ENTER'
|
|
132
|
+
edit_event
|
|
133
|
+
when 'x', 'DEL'
|
|
134
|
+
delete_event
|
|
135
|
+
when 'C-Y'
|
|
136
|
+
copy_event_to_clipboard
|
|
137
|
+
when 'v'
|
|
138
|
+
view_event_popup
|
|
139
|
+
when 'a'
|
|
140
|
+
accept_invite
|
|
141
|
+
when 'r'
|
|
142
|
+
show_feedback("Reply via Heathrow: not yet implemented", 226)
|
|
143
|
+
when 'i'
|
|
144
|
+
import_ics_file
|
|
145
|
+
when 'G'
|
|
146
|
+
setup_google_calendar
|
|
147
|
+
when 'O'
|
|
148
|
+
setup_outlook_calendar
|
|
149
|
+
when 'S'
|
|
150
|
+
manual_sync
|
|
151
|
+
when 'C'
|
|
152
|
+
show_calendars
|
|
153
|
+
when 'P'
|
|
154
|
+
show_preferences
|
|
155
|
+
when '?'
|
|
156
|
+
show_help
|
|
157
|
+
when 'q'
|
|
158
|
+
@running = false
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- Time slot navigation ---
|
|
163
|
+
|
|
164
|
+
# @selected_slot: 0-47 = time slots (00:00-23:30)
|
|
165
|
+
# negative = all-day event rows (-1 = first, -2 = second...)
|
|
166
|
+
def allday_count
|
|
167
|
+
@_allday_count_date ||= nil
|
|
168
|
+
if @_allday_count_date != @selected_date
|
|
169
|
+
events = events_on_selected_day
|
|
170
|
+
@_allday_count = events.count { |e| e['all_day'].to_i == 1 }
|
|
171
|
+
@_allday_count_date = @selected_date
|
|
172
|
+
end
|
|
173
|
+
@_allday_count
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def min_slot
|
|
177
|
+
n = allday_count
|
|
178
|
+
n > 0 ? -n : 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def adjust_slot_offset
|
|
182
|
+
return unless @selected_slot >= 0
|
|
183
|
+
allday_rows = allday_count > 0 ? allday_count + 1 : 0
|
|
184
|
+
available_rows = @panes[:mid].h - 3 - allday_rows
|
|
185
|
+
available_rows = [available_rows, 1].max
|
|
186
|
+
if @selected_slot - @slot_offset >= available_rows
|
|
187
|
+
@slot_offset = @selected_slot - available_rows + 1
|
|
188
|
+
elsif @selected_slot < @slot_offset
|
|
189
|
+
@slot_offset = @selected_slot
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def move_slot_down
|
|
194
|
+
@selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
|
|
195
|
+
@selected_slot = @selected_slot >= 47 ? min_slot : @selected_slot + 1
|
|
196
|
+
@slot_offset = 0 if @selected_slot == min_slot
|
|
197
|
+
adjust_slot_offset
|
|
198
|
+
render_mid_pane
|
|
199
|
+
render_bottom_pane
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def move_slot_up
|
|
203
|
+
@selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
|
|
204
|
+
@selected_slot = @selected_slot <= min_slot ? 47 : @selected_slot - 1
|
|
205
|
+
if @selected_slot == 47
|
|
206
|
+
allday_rows = allday_count > 0 ? allday_count + 1 : 0
|
|
207
|
+
available = [@panes[:mid].h - 3 - allday_rows, 1].max
|
|
208
|
+
@slot_offset = [48 - available, 0].max
|
|
209
|
+
end
|
|
210
|
+
adjust_slot_offset
|
|
211
|
+
render_mid_pane
|
|
212
|
+
render_bottom_pane
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def page_slots_down
|
|
216
|
+
@selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
|
|
217
|
+
@selected_slot = [[@selected_slot + 10, 47].min, min_slot].max
|
|
218
|
+
adjust_slot_offset
|
|
219
|
+
render_mid_pane
|
|
220
|
+
render_bottom_pane
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def page_slots_up
|
|
224
|
+
@selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
|
|
225
|
+
@selected_slot = [[@selected_slot - 10, min_slot].max, min_slot].max
|
|
226
|
+
adjust_slot_offset
|
|
227
|
+
render_mid_pane
|
|
228
|
+
render_bottom_pane
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def go_slot_top
|
|
232
|
+
@selected_slot = min_slot
|
|
233
|
+
@slot_offset = 0
|
|
234
|
+
render_mid_pane
|
|
235
|
+
render_bottom_pane
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def go_slot_bottom
|
|
239
|
+
@selected_slot = 47
|
|
240
|
+
allday_rows = allday_count > 0 ? allday_count + 1 : 0
|
|
241
|
+
available_rows = @panes[:mid].h - 3 - allday_rows
|
|
242
|
+
available_rows = [available_rows, 1].max
|
|
243
|
+
@slot_offset = [48 - available_rows, 0].max
|
|
244
|
+
render_mid_pane
|
|
245
|
+
render_bottom_pane
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# --- Date/event state changes ---
|
|
249
|
+
|
|
250
|
+
def check_heathrow_goto
|
|
251
|
+
goto_file = File.join(TIMELY_HOME, 'goto')
|
|
252
|
+
return unless File.exist?(goto_file)
|
|
253
|
+
content = File.read(goto_file).strip
|
|
254
|
+
File.delete(goto_file)
|
|
255
|
+
return if content.empty?
|
|
256
|
+
date = Date.parse(content) rescue nil
|
|
257
|
+
if date
|
|
258
|
+
@selected_date = date
|
|
259
|
+
@selected_event_index = 0
|
|
260
|
+
load_events_for_range
|
|
261
|
+
render_all
|
|
262
|
+
end
|
|
263
|
+
rescue => e
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def date_changed
|
|
268
|
+
@selected_event_index = 0
|
|
269
|
+
load_events_for_range
|
|
270
|
+
render_all
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def safe_date(year, month, day)
|
|
274
|
+
# Clamp day to valid range for the target month
|
|
275
|
+
last_day = Date.new(year, month, -1).day
|
|
276
|
+
Date.new(year, month, [day, last_day].min)
|
|
277
|
+
rescue Date::Error
|
|
278
|
+
Date.today
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# --- Event navigation ---
|
|
282
|
+
|
|
283
|
+
def events_on_selected_day
|
|
284
|
+
@events_by_date[@selected_date] || []
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def humanize_status(status)
|
|
288
|
+
case status.to_s
|
|
289
|
+
when 'needsAction' then 'Needs response'
|
|
290
|
+
when 'accepted' then 'Accepted'
|
|
291
|
+
when 'declined' then 'Declined'
|
|
292
|
+
when 'tentative', 'tentativelyAccepted' then 'Tentative'
|
|
293
|
+
when 'confirmed' then 'Confirmed'
|
|
294
|
+
when 'cancelled' then 'Cancelled'
|
|
295
|
+
else status.to_s
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def clean_description(desc)
|
|
300
|
+
return nil unless desc
|
|
301
|
+
# Remove common garbage from Google/Outlook descriptions
|
|
302
|
+
desc.gsub(/BC\d+-Color:\s*-?\d+\s*/, '').gsub(/-::~:~::~:~.*$/m, '').strip
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Find the event at the currently selected time slot
|
|
306
|
+
def event_at_selected_slot
|
|
307
|
+
return nil unless @selected_slot
|
|
308
|
+
events = events_on_selected_day
|
|
309
|
+
|
|
310
|
+
if @selected_slot < 0
|
|
311
|
+
# Negative slot: -n = top (first allday), -1 = bottom (last allday)
|
|
312
|
+
allday = events.select { |e| e['all_day'].to_i == 1 }
|
|
313
|
+
n = allday.size
|
|
314
|
+
idx = n - @selected_slot.abs # -n -> 0 (first), -1 -> n-1 (last)
|
|
315
|
+
return allday[idx]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
hour = @selected_slot / 2
|
|
319
|
+
minute = (@selected_slot % 2) * 30
|
|
320
|
+
slot_start = Time.new(@selected_date.year, @selected_date.month, @selected_date.day, hour, minute, 0).to_i
|
|
321
|
+
slot_end = slot_start + 1800
|
|
322
|
+
events.find do |e|
|
|
323
|
+
next if e['all_day'].to_i == 1
|
|
324
|
+
es = e['start_time'].to_i
|
|
325
|
+
ee = e['end_time'].to_i
|
|
326
|
+
es < slot_end && ee > slot_start
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def select_next_event_on_day
|
|
331
|
+
events = events_on_selected_day
|
|
332
|
+
return if events.empty?
|
|
333
|
+
@selected_event_index = (@selected_event_index + 1) % events.length
|
|
334
|
+
render_mid_pane
|
|
335
|
+
render_bottom_pane
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def select_prev_event_on_day
|
|
339
|
+
events = events_on_selected_day
|
|
340
|
+
return if events.empty?
|
|
341
|
+
@selected_event_index = (@selected_event_index - 1) % events.length
|
|
342
|
+
render_mid_pane
|
|
343
|
+
render_bottom_pane
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def jump_to_next_event
|
|
347
|
+
events = events_on_selected_day
|
|
348
|
+
# If there are more events on the current day after the selected one, go to next
|
|
349
|
+
if events.length > 0 && @selected_event_index < events.length - 1
|
|
350
|
+
@selected_event_index += 1
|
|
351
|
+
render_mid_pane
|
|
352
|
+
render_bottom_pane
|
|
353
|
+
return
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Scan forward day by day (up to 365 days)
|
|
357
|
+
(1..365).each do |offset|
|
|
358
|
+
check_date = @selected_date + offset
|
|
359
|
+
day_events = @db.get_events_for_date(check_date)
|
|
360
|
+
if day_events && !day_events.empty?
|
|
361
|
+
@selected_date = check_date
|
|
362
|
+
@selected_event_index = 0
|
|
363
|
+
date_changed
|
|
364
|
+
return
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
show_feedback("No more events found within the next year", 245)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def jump_to_prev_event
|
|
372
|
+
events = events_on_selected_day
|
|
373
|
+
# If there are more events on the current day before the selected one, go to prev
|
|
374
|
+
if events.length > 0 && @selected_event_index > 0
|
|
375
|
+
@selected_event_index -= 1
|
|
376
|
+
render_mid_pane
|
|
377
|
+
render_bottom_pane
|
|
378
|
+
return
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Scan backward day by day (up to 365 days)
|
|
382
|
+
(1..365).each do |offset|
|
|
383
|
+
check_date = @selected_date - offset
|
|
384
|
+
day_events = @db.get_events_for_date(check_date)
|
|
385
|
+
if day_events && !day_events.empty?
|
|
386
|
+
@selected_date = check_date
|
|
387
|
+
@selected_event_index = day_events.length - 1
|
|
388
|
+
date_changed
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
show_feedback("No earlier events found within the past year", 245)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# --- Data loading ---
|
|
397
|
+
|
|
398
|
+
def load_events_for_range
|
|
399
|
+
# Load events covering visible months (a generous range around the selected date)
|
|
400
|
+
range_start = Date.new(@selected_date.year, @selected_date.month, 1) << 3
|
|
401
|
+
range_end = Date.new(@selected_date.year, @selected_date.month, -1) >> 3
|
|
402
|
+
|
|
403
|
+
start_ts = Time.new(range_start.year, range_start.month, range_start.day, 0, 0, 0).to_i
|
|
404
|
+
end_ts = Time.new(range_end.year, range_end.month, range_end.day, 23, 59, 59).to_i
|
|
405
|
+
|
|
406
|
+
raw_events = @db.get_events_in_range(start_ts, end_ts)
|
|
407
|
+
|
|
408
|
+
@events_by_date = {}
|
|
409
|
+
raw_events.each do |evt|
|
|
410
|
+
st = Time.at(evt['start_time'].to_i).to_date
|
|
411
|
+
et = evt['end_time'] ? Time.at(evt['end_time'].to_i).to_date : st
|
|
412
|
+
|
|
413
|
+
(st..et).each do |d|
|
|
414
|
+
next unless d >= range_start && d <= range_end
|
|
415
|
+
@events_by_date[d] ||= []
|
|
416
|
+
@events_by_date[d] << evt
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Sort each day's events once up front
|
|
421
|
+
@events_by_date.each_value { |evts| evts.sort_by! { |e| e['start_time'].to_i } }
|
|
422
|
+
|
|
423
|
+
# Clamp selected event index
|
|
424
|
+
events = events_on_selected_day
|
|
425
|
+
@selected_event_index = 0 if events.empty?
|
|
426
|
+
@selected_event_index = events.length - 1 if @selected_event_index >= events.length
|
|
427
|
+
|
|
428
|
+
# Load weather (cached, refetch every 6 hours)
|
|
429
|
+
if !@weather_forecast || @weather_forecast.empty? || !@_weather_fetched_at || (Time.now.to_i - @_weather_fetched_at) > 21600
|
|
430
|
+
lat = @config.get('location.lat', 59.9139)
|
|
431
|
+
lon = @config.get('location.lon', 10.7522)
|
|
432
|
+
@weather_forecast = Weather.fetch(lat, lon, @db) rescue {}
|
|
433
|
+
@_weather_fetched_at = Time.now.to_i
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# --- Rendering ---
|
|
438
|
+
|
|
439
|
+
def render_all
|
|
440
|
+
# Check for terminal resize
|
|
441
|
+
old_h, old_w = @h, @w
|
|
442
|
+
setup_display
|
|
443
|
+
if @h != old_h || @w != old_w
|
|
444
|
+
Rcurses.clear_screen
|
|
445
|
+
create_panes
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Set terminal window title
|
|
449
|
+
events = events_on_selected_day
|
|
450
|
+
evt_count = events.size
|
|
451
|
+
title_str = "Timely: #{@selected_date.strftime('%a %b %d, %Y')}"
|
|
452
|
+
title_str += " (#{evt_count} event#{evt_count == 1 ? '' : 's'})" if evt_count > 0
|
|
453
|
+
$stdout.print "\033]0;#{title_str}\007"
|
|
454
|
+
|
|
455
|
+
render_info_bar
|
|
456
|
+
render_top_pane
|
|
457
|
+
render_mid_pane
|
|
458
|
+
render_bottom_pane
|
|
459
|
+
render_status_bar
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Info bar: top row with bg color
|
|
463
|
+
def render_info_bar
|
|
464
|
+
title = " Timely".b
|
|
465
|
+
date_str = @selected_date.strftime(" %A, %B %d, %Y")
|
|
466
|
+
phase = Astronomy.moon_phase(@selected_date)
|
|
467
|
+
moon = " #{phase[:symbol]} #{phase[:phase_name]} (#{(phase[:illumination] * 100).round}%)"
|
|
468
|
+
|
|
469
|
+
lat = @config.get('location.lat', 59.9139)
|
|
470
|
+
lon = @config.get('location.lon', 10.7522)
|
|
471
|
+
tz = @config.get('timezone_offset', 1)
|
|
472
|
+
|
|
473
|
+
# Sunrise/sunset (yellow sun)
|
|
474
|
+
sun = Astronomy.sun_times(@selected_date, lat, lon, tz)
|
|
475
|
+
sun_color = Astronomy::BODY_COLORS['sun']
|
|
476
|
+
sun_str = sun ? " " + "\u2600".fg(sun_color) + "\u2191#{sun[:rise]} " + "\u2600".fg(sun_color) + "\u2193#{sun[:set]}" : ""
|
|
477
|
+
|
|
478
|
+
# Visible planets (cached per date)
|
|
479
|
+
@_cached_planets_date ||= nil
|
|
480
|
+
if @_cached_planets_date != @selected_date
|
|
481
|
+
@_cached_planets = Astronomy.visible_planets(@selected_date, lat, lon, tz)
|
|
482
|
+
@_cached_planets_date = @selected_date
|
|
483
|
+
end
|
|
484
|
+
planets = @_cached_planets || []
|
|
485
|
+
planet_str = planets.any? ? " " + planets.map { |p|
|
|
486
|
+
color = Astronomy::BODY_COLORS[p[:name].downcase] || '888888'
|
|
487
|
+
p[:symbol].fg(color)
|
|
488
|
+
}.join(" ") : ""
|
|
489
|
+
|
|
490
|
+
@panes[:info].text = title + date_str + moon + sun_str + planet_str
|
|
491
|
+
@panes[:info].refresh
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Status bar: bottom row with key hints
|
|
495
|
+
def render_status_bar
|
|
496
|
+
keys = "d/D:Day w/W:Week m/M:Month y/Y:Year e/E:Event n:New g:GoTo t:Today i:Import G:Google O:Outlook S:Sync C:Cal P:Prefs ?:Help q:Quit"
|
|
497
|
+
if @syncing
|
|
498
|
+
sync_indicator = " Syncing...".fg(226)
|
|
499
|
+
pad = @w - keys.length - 12
|
|
500
|
+
pad = [pad, 1].max
|
|
501
|
+
@panes[:status].text = " " + keys + " " * pad + sync_indicator
|
|
502
|
+
else
|
|
503
|
+
@panes[:status].text = " " + keys
|
|
504
|
+
end
|
|
505
|
+
@panes[:status].refresh
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Top pane: horizontal strip of mini-month calendars
|
|
509
|
+
def render_top_pane
|
|
510
|
+
today = Date.today
|
|
511
|
+
month_width = 26 # 25 chars + 1 space separator
|
|
512
|
+
months_visible = [@w / month_width, 1].max
|
|
513
|
+
|
|
514
|
+
offset = 3 # Selected month is always the 4th (middle of 8)
|
|
515
|
+
|
|
516
|
+
months = []
|
|
517
|
+
months_visible.times do |i|
|
|
518
|
+
m_offset = i - offset
|
|
519
|
+
d = @selected_date >> m_offset
|
|
520
|
+
months << [d.year, d.month]
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Render each mini-month; current month gets bg color
|
|
524
|
+
rendered = months.map do |year, month|
|
|
525
|
+
sel_day = (year == @selected_date.year && month == @selected_date.month) ? @selected_date.day : nil
|
|
526
|
+
is_current = (year == @selected_date.year && month == @selected_date.month)
|
|
527
|
+
tbg = @config.get('colors.today_bg', 246)
|
|
528
|
+
lines = UI::Views::Month.render_mini_month(year, month, sel_day, today, @events_by_date, month_width - 1, today_bg: tbg)
|
|
529
|
+
# Apply bg to current month
|
|
530
|
+
if is_current
|
|
531
|
+
lines.map { |l| l.bg(@config.get('colors.current_month_bg', 233)) }
|
|
532
|
+
else
|
|
533
|
+
lines
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
max_lines = rendered.map(&:length).max || 0
|
|
538
|
+
combined_lines = [""] # One row top padding
|
|
539
|
+
|
|
540
|
+
max_lines.times do |row|
|
|
541
|
+
parts = rendered.map do |month_lines|
|
|
542
|
+
line = month_lines[row] || ""
|
|
543
|
+
pure_len = Rcurses.display_width(line.respond_to?(:pure) ? line.pure : line)
|
|
544
|
+
padding = (month_width - 1) - pure_len
|
|
545
|
+
padding = 0 if padding < 0
|
|
546
|
+
line + " " * padding
|
|
547
|
+
end
|
|
548
|
+
combined_lines << " " + parts.join(" ")
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
while combined_lines.length < @panes[:top].h
|
|
552
|
+
combined_lines << ""
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
@panes[:top].text = combined_lines.join("\n")
|
|
556
|
+
@panes[:top].full_refresh
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Mid pane: week view with time column + day columns
|
|
560
|
+
def render_mid_pane
|
|
561
|
+
week_start = @selected_date - (@selected_date.cwday - 1)
|
|
562
|
+
time_col = 6 # "HH:MM " width
|
|
563
|
+
gap = 1 # gap between day columns
|
|
564
|
+
day_col = (@w - time_col - gap * 6) / 7 # 7 days, 6 gaps between them
|
|
565
|
+
day_col = [day_col, 8].max
|
|
566
|
+
sel_alt_a = @config.get('colors.selected_bg_a', 235)
|
|
567
|
+
sel_alt_b = @config.get('colors.selected_bg_b', 234)
|
|
568
|
+
alt_bg_a = @config.get('colors.alt_bg_a', 233)
|
|
569
|
+
alt_bg_b = @config.get('colors.alt_bg_b', 0)
|
|
570
|
+
slot_sel_bg = @config.get('colors.slot_selected_bg', 237)
|
|
571
|
+
|
|
572
|
+
lines = []
|
|
573
|
+
|
|
574
|
+
# Weather row above day headers
|
|
575
|
+
weather_parts = [" " * time_col]
|
|
576
|
+
7.times do |i|
|
|
577
|
+
day = week_start + i
|
|
578
|
+
w_str = Weather.short_for_date(@weather_forecast || {}, day)
|
|
579
|
+
w_str ||= ""
|
|
580
|
+
pure_len = Rcurses.display_width(w_str)
|
|
581
|
+
pad = [day_col - pure_len, 0].max
|
|
582
|
+
weather_parts << w_str.fg(245) + " " * pad
|
|
583
|
+
end
|
|
584
|
+
lines << weather_parts.join(" ")
|
|
585
|
+
|
|
586
|
+
# Column headers: week number + time column + day headers
|
|
587
|
+
wk_num = "W#{week_start.cweek}".fg(238)
|
|
588
|
+
header_parts = [wk_num + " " * [time_col - 3, 1].max]
|
|
589
|
+
7.times do |i|
|
|
590
|
+
day = week_start + i
|
|
591
|
+
header = "#{day.strftime('%a')} #{day.day}"
|
|
592
|
+
is_sel = (day == @selected_date)
|
|
593
|
+
is_today = (day == Date.today)
|
|
594
|
+
|
|
595
|
+
# Base color: weekend colors or default gray
|
|
596
|
+
base_color = if day.sunday?
|
|
597
|
+
@config.get('colors.sunday', 167)
|
|
598
|
+
elsif day.saturday?
|
|
599
|
+
@config.get('colors.saturday', 208)
|
|
600
|
+
else
|
|
601
|
+
245
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
today_bg = @config.get('colors.today_bg', 246)
|
|
605
|
+
today_fg = @config.get('colors.today_fg', 232)
|
|
606
|
+
header = if is_sel && is_today
|
|
607
|
+
header.b.u.fg(today_fg).bg(today_bg)
|
|
608
|
+
elsif is_sel
|
|
609
|
+
header.b.u.fg(base_color).bg(sel_alt_a)
|
|
610
|
+
elsif is_today
|
|
611
|
+
header.b.u.fg(today_fg).bg(today_bg)
|
|
612
|
+
else
|
|
613
|
+
header.fg(base_color)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
pure_len = Rcurses.display_width(header.respond_to?(:pure) ? header.pure : header)
|
|
617
|
+
pad = [day_col - pure_len, 0].max
|
|
618
|
+
padding = if is_sel && is_today
|
|
619
|
+
" ".bg(today_bg) * pad
|
|
620
|
+
elsif is_sel
|
|
621
|
+
" ".bg(sel_alt_a) * pad
|
|
622
|
+
elsif is_today
|
|
623
|
+
" ".bg(today_bg) * pad
|
|
624
|
+
else
|
|
625
|
+
" " * pad
|
|
626
|
+
end
|
|
627
|
+
header_parts << header + padding
|
|
628
|
+
end
|
|
629
|
+
lines << header_parts.join(" ")
|
|
630
|
+
lines << ("-" * @w).fg(238)
|
|
631
|
+
|
|
632
|
+
# Gather events for each day, split all-day from timed
|
|
633
|
+
week_events = []
|
|
634
|
+
week_allday = []
|
|
635
|
+
7.times do |i|
|
|
636
|
+
day = week_start + i
|
|
637
|
+
all = @events_by_date[day] || []
|
|
638
|
+
week_allday << all.select { |e| e['all_day'].to_i == 1 }
|
|
639
|
+
week_events << all.reject { |e| e['all_day'].to_i == 1 }
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# All-day event row(s) above time grid
|
|
643
|
+
max_allday = week_allday.map(&:size).max || 0
|
|
644
|
+
if max_allday > 0
|
|
645
|
+
max_allday.times do |row|
|
|
646
|
+
allday_slot = -(max_allday - row) # top row = most negative, bottom = -1
|
|
647
|
+
is_row_selected = (@selected_slot == allday_slot)
|
|
648
|
+
parts = [is_row_selected ? " All".fg(255).b + " " : " " * time_col]
|
|
649
|
+
7.times do |col|
|
|
650
|
+
evt = week_allday[col][row]
|
|
651
|
+
day = week_start + col
|
|
652
|
+
is_sel = (day == @selected_date)
|
|
653
|
+
is_at = is_sel && is_row_selected
|
|
654
|
+
cell_bg = is_at ? slot_sel_bg : (is_sel ? sel_alt_a : nil)
|
|
655
|
+
if evt
|
|
656
|
+
title = evt['title'] || "(No title)"
|
|
657
|
+
color = evt['calendar_color'] || 39
|
|
658
|
+
marker = is_at ? ">" : " "
|
|
659
|
+
entry = "#{marker}#{title}"[0, day_col - 1]
|
|
660
|
+
cell = cell_bg ? entry.fg(color).b.bg(cell_bg) : entry.fg(color)
|
|
661
|
+
else
|
|
662
|
+
cell = cell_bg ? " ".bg(cell_bg) : " "
|
|
663
|
+
end
|
|
664
|
+
pure_len = Rcurses.display_width(cell.respond_to?(:pure) ? cell.pure : cell)
|
|
665
|
+
pad = [day_col - pure_len, 0].max
|
|
666
|
+
pad_str = is_sel ? " ".bg(sel_alt_a) * pad : " " * pad
|
|
667
|
+
parts << cell + pad_str
|
|
668
|
+
end
|
|
669
|
+
lines << parts.join(" ")
|
|
670
|
+
end
|
|
671
|
+
lines << ("-" * @w).fg(238)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Build half-hour time slots with scroll offset
|
|
675
|
+
work_start = @config.get('work_hours.start', 8) rescue 8
|
|
676
|
+
extra_rows = max_allday > 0 ? max_allday + 1 : 0 # allday rows + separator
|
|
677
|
+
available_rows = @panes[:mid].h - 3 - extra_rows
|
|
678
|
+
# Default slot offset to work_start if not set
|
|
679
|
+
@slot_offset ||= work_start * 2
|
|
680
|
+
# Clamp offset
|
|
681
|
+
@slot_offset = [[@slot_offset, 0].max, [48 - available_rows, 0].max].min
|
|
682
|
+
|
|
683
|
+
slots = []
|
|
684
|
+
(@slot_offset...[@slot_offset + available_rows, 48].min).each do |slot|
|
|
685
|
+
hour = slot / 2
|
|
686
|
+
minute = (slot % 2) * 30
|
|
687
|
+
slots << [hour, minute, slot]
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
slots.each_with_index do |(hour, minute, slot_idx), row|
|
|
691
|
+
is_slot_selected = (@selected_slot == slot_idx)
|
|
692
|
+
row_bg = row.even? ? alt_bg_a : alt_bg_b
|
|
693
|
+
|
|
694
|
+
# Time label: highlight if selected
|
|
695
|
+
time_label = format("%02d:%02d ", hour, minute)
|
|
696
|
+
time_label = is_slot_selected ? time_label.fg(255).b : time_label.fg(238)
|
|
697
|
+
|
|
698
|
+
parts = [time_label]
|
|
699
|
+
7.times do |col|
|
|
700
|
+
day = week_start + col
|
|
701
|
+
is_sel = (day == @selected_date)
|
|
702
|
+
cell_bg_base = row.even? ? alt_bg_a : alt_bg_b
|
|
703
|
+
if is_sel && is_slot_selected
|
|
704
|
+
cell_bg = slot_sel_bg
|
|
705
|
+
elsif is_sel
|
|
706
|
+
cell_bg = row.even? ? sel_alt_a : sel_alt_b
|
|
707
|
+
else
|
|
708
|
+
cell_bg = cell_bg_base
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Find event at this time slot
|
|
712
|
+
day_ts_start = Time.new(day.year, day.month, day.day, hour, minute, 0).to_i
|
|
713
|
+
day_ts_end = day_ts_start + 1800 # 30 min slot
|
|
714
|
+
|
|
715
|
+
evt = week_events[col].find do |e|
|
|
716
|
+
es = e['start_time'].to_i
|
|
717
|
+
ee = e['end_time'].to_i
|
|
718
|
+
es < day_ts_end && ee > day_ts_start
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
if evt
|
|
722
|
+
is_at_slot = is_sel && is_slot_selected
|
|
723
|
+
marker = is_at_slot ? ">" : " "
|
|
724
|
+
title = evt['title'] || "(No title)"
|
|
725
|
+
color = evt['calendar_color'] || 39
|
|
726
|
+
entry = "#{marker}#{title}"
|
|
727
|
+
entry = entry[0, day_col - 1] + "." if entry.length > day_col
|
|
728
|
+
cell = is_at_slot ? entry.fg(color).b.bg(cell_bg) : entry.fg(color).bg(cell_bg)
|
|
729
|
+
else
|
|
730
|
+
cell = " ".bg(cell_bg)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
pure_len = Rcurses.display_width(cell.respond_to?(:pure) ? cell.pure : cell)
|
|
734
|
+
pad = [day_col - pure_len, 0].max
|
|
735
|
+
parts << cell + " ".bg(cell_bg) * pad
|
|
736
|
+
end
|
|
737
|
+
lines << parts.join(" ")
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
while lines.length < @panes[:mid].h
|
|
741
|
+
lines << ""
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
@panes[:mid].text = lines.join("\n")
|
|
745
|
+
@panes[:mid].full_refresh
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Bottom pane: event details or day summary
|
|
749
|
+
def render_bottom_pane
|
|
750
|
+
lines = []
|
|
751
|
+
events = events_on_selected_day
|
|
752
|
+
|
|
753
|
+
# Separator
|
|
754
|
+
lines << ("-" * @w).fg(238)
|
|
755
|
+
|
|
756
|
+
evt = event_at_selected_slot
|
|
757
|
+
if evt
|
|
758
|
+
color = evt['calendar_color'] || 39
|
|
759
|
+
max_lines = 50 # Allow scrolling for long descriptions
|
|
760
|
+
|
|
761
|
+
# Line 1: Title + time on same line
|
|
762
|
+
title = evt['title'] || '(No title)'
|
|
763
|
+
if evt['all_day'].to_i == 1
|
|
764
|
+
time_info = @selected_date.strftime('%a %Y-%m-%d') + " All day"
|
|
765
|
+
else
|
|
766
|
+
st = Time.at(evt['start_time'].to_i)
|
|
767
|
+
time_info = st.strftime('%a %Y-%m-%d %H:%M')
|
|
768
|
+
time_info += " - #{Time.at(evt['end_time'].to_i).strftime('%H:%M')}" if evt['end_time']
|
|
769
|
+
end
|
|
770
|
+
lines << " #{title}".fg(color).b + " #{time_info}".fg(252)
|
|
771
|
+
|
|
772
|
+
# Line 2: Location + Organizer + Calendar on same line
|
|
773
|
+
details = []
|
|
774
|
+
if evt['location'] && !evt['location'].to_s.strip.empty?
|
|
775
|
+
details << "Location: #{evt['location'].to_s.strip}"
|
|
776
|
+
end
|
|
777
|
+
if evt['organizer'] && !evt['organizer'].to_s.strip.empty?
|
|
778
|
+
details << "Organizer: #{evt['organizer']}"
|
|
779
|
+
end
|
|
780
|
+
cal_name = evt['calendar_name'] || 'Unknown'
|
|
781
|
+
details << "Calendar: #{cal_name}"
|
|
782
|
+
detail_line = " " + details.join(" | ")
|
|
783
|
+
detail_line = detail_line[0, @w - 2] if detail_line.length > @w - 2
|
|
784
|
+
lines << detail_line.fg(245)
|
|
785
|
+
|
|
786
|
+
# Line 3: Status (if relevant)
|
|
787
|
+
status_parts = []
|
|
788
|
+
status_parts << "Status: #{evt['status']}" if evt['status']
|
|
789
|
+
status_parts << "My status: #{humanize_status(evt['my_status'])}" if evt['my_status']
|
|
790
|
+
lines << " #{status_parts.join(' | ')}".fg(245) unless status_parts.empty?
|
|
791
|
+
|
|
792
|
+
# Remaining lines: Description (wrapped to pane width)
|
|
793
|
+
desc = clean_description(evt['description'])
|
|
794
|
+
if desc && !desc.empty?
|
|
795
|
+
desc = desc.gsub(/\r?\n/, " ")
|
|
796
|
+
remaining = max_lines - lines.size
|
|
797
|
+
if remaining > 1
|
|
798
|
+
lines << ""
|
|
799
|
+
# Word-wrap description to fit remaining lines
|
|
800
|
+
words = desc.split(' ')
|
|
801
|
+
line = " "
|
|
802
|
+
words.each do |word|
|
|
803
|
+
if line.length + word.length + 1 > @w - 2
|
|
804
|
+
lines << line.fg(248)
|
|
805
|
+
break if lines.size >= max_lines
|
|
806
|
+
line = " " + word
|
|
807
|
+
else
|
|
808
|
+
line += (line == " " ? "" : " ") + word
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
lines << line.fg(248) if lines.size < max_lines && line.strip.length > 0
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
else
|
|
816
|
+
# No events: show day summary
|
|
817
|
+
lines << " #{@selected_date.strftime('%A, %B %d, %Y')}".b
|
|
818
|
+
|
|
819
|
+
# Astronomical events (solstices, meteor showers, etc.)
|
|
820
|
+
lat = @config.get('location.lat', 59.9139)
|
|
821
|
+
lon = @config.get('location.lon', 10.7522)
|
|
822
|
+
tz = @config.get('timezone_offset', 1)
|
|
823
|
+
astro = Astronomy.astro_events(@selected_date, lat, lon, tz)
|
|
824
|
+
astro.each { |evt| lines << " #{evt}".fg(180) } if astro.any?
|
|
825
|
+
|
|
826
|
+
lines << ""
|
|
827
|
+
lines << " No events scheduled".fg(240)
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Pad to fill pane
|
|
831
|
+
while lines.length < @panes[:bottom].h
|
|
832
|
+
lines << ""
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
@panes[:bottom].text = lines.join("\n")
|
|
836
|
+
@panes[:bottom].full_refresh
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# --- Actions ---
|
|
840
|
+
|
|
841
|
+
def go_to_date
|
|
842
|
+
input = bottom_ask("Go to: ", "")
|
|
843
|
+
return if input.nil? || input.strip.empty?
|
|
844
|
+
|
|
845
|
+
input = input.strip
|
|
846
|
+
|
|
847
|
+
parsed = parse_go_to_input(input)
|
|
848
|
+
if parsed
|
|
849
|
+
@selected_date = parsed
|
|
850
|
+
@selected_event_index = 0
|
|
851
|
+
date_changed
|
|
852
|
+
else
|
|
853
|
+
show_feedback("Could not parse date: #{input}", 196)
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def parse_go_to_input(input)
|
|
858
|
+
return Date.today if input.downcase == 'today'
|
|
859
|
+
|
|
860
|
+
# Try exact date first
|
|
861
|
+
return Date.parse(input) if input =~ /\d{4}-\d{1,2}-\d{1,2}/
|
|
862
|
+
|
|
863
|
+
# Year only
|
|
864
|
+
return Date.new(input.to_i, 1, 1) if input =~ /\A\d{4}\z/
|
|
865
|
+
|
|
866
|
+
# Month name
|
|
867
|
+
months = %w[jan feb mar apr may jun jul aug sep oct nov dec]
|
|
868
|
+
months.each_with_index do |m, i|
|
|
869
|
+
if input.downcase.start_with?(m)
|
|
870
|
+
return Date.new(@selected_date.year, i + 1, 1)
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# Day number (1-31)
|
|
875
|
+
if input =~ /\A\d{1,2}\z/ && input.to_i.between?(1, 31)
|
|
876
|
+
day = [input.to_i, Date.new(@selected_date.year, @selected_date.month, -1).day].min
|
|
877
|
+
return Date.new(@selected_date.year, @selected_date.month, day)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
Date.parse(input) rescue nil
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def create_event
|
|
884
|
+
default_time = @selected_slot ? format("%02d:%02d", @selected_slot / 2, (@selected_slot % 2) * 30) : "09:00"
|
|
885
|
+
calendars = @db.get_calendars
|
|
886
|
+
default_cal_id = @config.get('default_calendar', 1)
|
|
887
|
+
cal = calendars.find { |c| c['id'] == default_cal_id } || calendars.first
|
|
888
|
+
return show_feedback("No calendars configured", 196) unless cal
|
|
889
|
+
cal_color = cal['color'] || 39
|
|
890
|
+
|
|
891
|
+
# Calendar picker (if multiple)
|
|
892
|
+
if calendars.size > 1
|
|
893
|
+
cal_list = calendars.each_with_index.map { |c, i| "#{i + 1}:#{c['name']}" }.join(" ")
|
|
894
|
+
default_idx = calendars.index(cal) || 0
|
|
895
|
+
blank_bottom(" New Event".fg(cal_color).b)
|
|
896
|
+
pick = bottom_ask(" Calendar (#{cal_list}): ", (default_idx + 1).to_s)
|
|
897
|
+
return cancel_create if pick.nil?
|
|
898
|
+
idx = pick.strip.to_i - 1
|
|
899
|
+
cal = calendars[idx] if idx >= 0 && idx < calendars.size
|
|
900
|
+
cal_color = cal['color'] || 39
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
blank_bottom(" New Event on #{@selected_date.strftime('%A, %B %d, %Y')}".fg(cal_color).b)
|
|
904
|
+
title = bottom_ask(" Title: ", "")
|
|
905
|
+
return cancel_create if title.nil? || title.strip.empty?
|
|
906
|
+
|
|
907
|
+
blank_bottom(" #{title.strip}".fg(cal_color).b)
|
|
908
|
+
time_str = bottom_ask(" Start time (HH:MM or 'all day'): ", default_time)
|
|
909
|
+
return cancel_create if time_str.nil?
|
|
910
|
+
|
|
911
|
+
all_day = (time_str.strip.downcase == 'all day')
|
|
912
|
+
|
|
913
|
+
if all_day
|
|
914
|
+
start_ts = Time.new(@selected_date.year, @selected_date.month, @selected_date.day, 0, 0, 0).to_i
|
|
915
|
+
end_ts = start_ts + 86400
|
|
916
|
+
else
|
|
917
|
+
parts = time_str.strip.split(':')
|
|
918
|
+
hour = parts[0].to_i
|
|
919
|
+
minute = (parts[1] || 0).to_i
|
|
920
|
+
start_ts = Time.new(@selected_date.year, @selected_date.month, @selected_date.day, hour, minute, 0).to_i
|
|
921
|
+
|
|
922
|
+
blank_bottom(" #{title.strip} at #{time_str.strip}".fg(cal_color).b)
|
|
923
|
+
dur_str = bottom_ask(" Duration in minutes: ", "60")
|
|
924
|
+
return cancel_create if dur_str.nil?
|
|
925
|
+
duration = dur_str.strip.to_i
|
|
926
|
+
duration = 60 if duration <= 0
|
|
927
|
+
end_ts = start_ts + duration * 60
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Location
|
|
931
|
+
blank_bottom(" #{title.strip}".fg(cal_color).b)
|
|
932
|
+
location = bottom_ask(" Location (Enter to skip): ", "")
|
|
933
|
+
location = nil if location.nil? || location.strip.empty?
|
|
934
|
+
|
|
935
|
+
# Invitees
|
|
936
|
+
blank_bottom(" #{title.strip}".fg(cal_color).b)
|
|
937
|
+
invitees_str = bottom_ask(" Invite (comma-separated emails, Enter to skip): ", "")
|
|
938
|
+
attendees = nil
|
|
939
|
+
if invitees_str && !invitees_str.strip.empty?
|
|
940
|
+
attendees = invitees_str.strip.split(',').map { |e| { 'email' => e.strip } }
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Attachments via rtfm --pick
|
|
944
|
+
blank_bottom(" #{title.strip}".fg(cal_color).b)
|
|
945
|
+
attach_str = bottom_ask(" Add attachments? (y/N): ", "")
|
|
946
|
+
attachments = nil
|
|
947
|
+
if attach_str&.strip&.downcase == 'y'
|
|
948
|
+
files = run_rtfm_picker
|
|
949
|
+
if files && !files.empty?
|
|
950
|
+
attachments = files.map { |f| { 'path' => f } }
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
@db.save_event(
|
|
955
|
+
title: title.strip,
|
|
956
|
+
start_time: start_ts,
|
|
957
|
+
end_time: end_ts,
|
|
958
|
+
all_day: all_day,
|
|
959
|
+
calendar_id: cal['id'],
|
|
960
|
+
location: location&.strip,
|
|
961
|
+
attendees: attendees,
|
|
962
|
+
metadata: attachments ? { 'attachments' => attachments } : nil,
|
|
963
|
+
status: 'confirmed'
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
load_events_for_range
|
|
967
|
+
render_all
|
|
968
|
+
msg = "Event created: #{title.strip}"
|
|
969
|
+
msg += " (invites will be sent when calendar sync is configured)" if attendees
|
|
970
|
+
show_feedback(msg, cal_color)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def blank_bottom(header = "")
|
|
974
|
+
lines = []
|
|
975
|
+
lines << ("-" * @w).fg(238)
|
|
976
|
+
lines << ""
|
|
977
|
+
lines << header unless header.empty?
|
|
978
|
+
while lines.length < @panes[:bottom].h
|
|
979
|
+
lines << ""
|
|
980
|
+
end
|
|
981
|
+
@panes[:bottom].text = lines.join("\n")
|
|
982
|
+
@panes[:bottom].full_refresh
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def bottom_ask(prompt, default = "")
|
|
986
|
+
# Prompt pane below separator + header + blank line
|
|
987
|
+
prompt_y = @panes[:bottom].y + 3
|
|
988
|
+
prompt_pane = Rcurses::Pane.new(1, prompt_y, @w, 1)
|
|
989
|
+
prompt_pane.border = false
|
|
990
|
+
prompt_pane.scroll = false
|
|
991
|
+
result = prompt_pane.ask(prompt, default)
|
|
992
|
+
result
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def cancel_create
|
|
996
|
+
render_all
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def run_rtfm_picker
|
|
1000
|
+
require 'shellwords'
|
|
1001
|
+
pick_file = "/tmp/timely_pick_#{Process.pid}.txt"
|
|
1002
|
+
File.delete(pick_file) if File.exist?(pick_file)
|
|
1003
|
+
|
|
1004
|
+
system("stty sane 2>/dev/null")
|
|
1005
|
+
Cursor.show
|
|
1006
|
+
system("rtfm --pick=#{Shellwords.escape(pick_file)}")
|
|
1007
|
+
$stdin.raw!
|
|
1008
|
+
$stdin.echo = false
|
|
1009
|
+
Cursor.hide
|
|
1010
|
+
Rcurses.clear_screen
|
|
1011
|
+
setup_display
|
|
1012
|
+
create_panes
|
|
1013
|
+
render_all
|
|
1014
|
+
|
|
1015
|
+
if File.exist?(pick_file)
|
|
1016
|
+
files = File.read(pick_file).lines.map(&:strip).reject(&:empty?)
|
|
1017
|
+
File.delete(pick_file) rescue nil
|
|
1018
|
+
files.select { |f| File.exist?(f) && File.file?(f) }
|
|
1019
|
+
else
|
|
1020
|
+
[]
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
def edit_event
|
|
1025
|
+
evt = event_at_selected_slot
|
|
1026
|
+
return show_feedback("No event at this time slot", 245) unless evt
|
|
1027
|
+
|
|
1028
|
+
blank_bottom(" Edit Event".b)
|
|
1029
|
+
new_title = bottom_ask(" Title: ", evt['title'] || "")
|
|
1030
|
+
return if new_title.nil?
|
|
1031
|
+
|
|
1032
|
+
@db.save_event(
|
|
1033
|
+
id: evt['id'],
|
|
1034
|
+
calendar_id: evt['calendar_id'],
|
|
1035
|
+
external_id: evt['external_id'],
|
|
1036
|
+
title: new_title.strip,
|
|
1037
|
+
description: evt['description'],
|
|
1038
|
+
location: evt['location'],
|
|
1039
|
+
start_time: evt['start_time'],
|
|
1040
|
+
end_time: evt['end_time'],
|
|
1041
|
+
all_day: evt['all_day'].to_i == 1,
|
|
1042
|
+
timezone: evt['timezone'],
|
|
1043
|
+
recurrence_rule: evt['recurrence_rule'],
|
|
1044
|
+
status: evt['status'],
|
|
1045
|
+
organizer: evt['organizer'],
|
|
1046
|
+
attendees: evt['attendees'],
|
|
1047
|
+
my_status: evt['my_status'],
|
|
1048
|
+
alarms: evt['alarms'],
|
|
1049
|
+
metadata: evt['metadata']
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
load_events_for_range
|
|
1053
|
+
render_all
|
|
1054
|
+
show_feedback("Event updated", 156)
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def delete_event
|
|
1058
|
+
events = events_on_selected_day
|
|
1059
|
+
evt = event_at_selected_slot
|
|
1060
|
+
return show_feedback("No event at this time slot", 245) unless evt
|
|
1061
|
+
|
|
1062
|
+
blank_bottom(" Delete Event".b)
|
|
1063
|
+
confirm = bottom_ask(" Delete '#{evt['title']}'? (y/n): ", "")
|
|
1064
|
+
return unless confirm&.strip&.downcase == 'y'
|
|
1065
|
+
|
|
1066
|
+
@db.delete_event(evt['id'])
|
|
1067
|
+
|
|
1068
|
+
load_events_for_range
|
|
1069
|
+
render_all
|
|
1070
|
+
show_feedback("Event deleted", 156)
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
def copy_event_to_clipboard
|
|
1074
|
+
evt = event_at_selected_slot
|
|
1075
|
+
return show_feedback("No event at this time slot", 245) unless evt
|
|
1076
|
+
|
|
1077
|
+
lines = []
|
|
1078
|
+
lines << evt['title'] || '(No title)'
|
|
1079
|
+
if evt['all_day'].to_i == 1
|
|
1080
|
+
lines << "#{@selected_date.strftime('%A, %B %d, %Y')} All day"
|
|
1081
|
+
else
|
|
1082
|
+
st = Time.at(evt['start_time'].to_i)
|
|
1083
|
+
time_str = st.strftime('%A, %B %d, %Y %H:%M')
|
|
1084
|
+
time_str += " - #{Time.at(evt['end_time'].to_i).strftime('%H:%M')}" if evt['end_time']
|
|
1085
|
+
lines << time_str
|
|
1086
|
+
end
|
|
1087
|
+
lines << "Location: #{evt['location']}" if evt['location'] && !evt['location'].to_s.strip.empty?
|
|
1088
|
+
lines << "Organizer: #{evt['organizer']}" if evt['organizer'] && !evt['organizer'].to_s.strip.empty?
|
|
1089
|
+
lines << "Calendar: #{evt['calendar_name'] || 'Unknown'}"
|
|
1090
|
+
lines << "Status: #{humanize_status(evt['status'])}" if evt['status']
|
|
1091
|
+
lines << "My status: #{humanize_status(evt['my_status'])}" if evt['my_status']
|
|
1092
|
+
|
|
1093
|
+
desc = clean_description(evt['description'])
|
|
1094
|
+
if desc && !desc.empty?
|
|
1095
|
+
lines << ""
|
|
1096
|
+
lines << desc
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
text = lines.join("\n")
|
|
1100
|
+
IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) } rescue
|
|
1101
|
+
IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) } rescue nil
|
|
1102
|
+
show_feedback("Event copied to clipboard", 156)
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def view_event_popup
|
|
1106
|
+
evt = event_at_selected_slot
|
|
1107
|
+
return show_feedback("No event at this time slot", 245) unless evt
|
|
1108
|
+
|
|
1109
|
+
rows, cols = IO.console.winsize
|
|
1110
|
+
pw = [cols - 10, 80].min
|
|
1111
|
+
pw = [pw, 50].max
|
|
1112
|
+
ph = [rows - 6, 30].min
|
|
1113
|
+
px = (cols - pw) / 2
|
|
1114
|
+
py = (rows - ph) / 2
|
|
1115
|
+
|
|
1116
|
+
popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
|
|
1117
|
+
popup.border = true
|
|
1118
|
+
popup.scroll = true
|
|
1119
|
+
|
|
1120
|
+
color = evt['calendar_color'] || 39
|
|
1121
|
+
lines = []
|
|
1122
|
+
lines << ""
|
|
1123
|
+
lines << " #{evt['title'] || '(No title)'}".fg(color).b
|
|
1124
|
+
lines << ""
|
|
1125
|
+
|
|
1126
|
+
if evt['all_day'].to_i == 1
|
|
1127
|
+
lines << " #{"When:".fg(51)} #{@selected_date.strftime('%A, %B %d, %Y')} All day".fg(252)
|
|
1128
|
+
else
|
|
1129
|
+
st = Time.at(evt['start_time'].to_i)
|
|
1130
|
+
time_str = st.strftime('%A, %B %d, %Y %H:%M')
|
|
1131
|
+
time_str += " - #{Time.at(evt['end_time'].to_i).strftime('%H:%M')}" if evt['end_time']
|
|
1132
|
+
lines << " #{"When:".fg(51)} #{time_str}".fg(252)
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
if evt['location'] && !evt['location'].to_s.strip.empty?
|
|
1136
|
+
lines << " #{"Location:".fg(51)} #{evt['location']}".fg(252)
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
if evt['organizer'] && !evt['organizer'].to_s.strip.empty?
|
|
1140
|
+
lines << " #{"Organizer:".fg(51)} #{evt['organizer']}".fg(252)
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
cal_name = evt['calendar_name'] || 'Unknown'
|
|
1144
|
+
lines << " #{"Calendar:".fg(51)} #{cal_name}".fg(252)
|
|
1145
|
+
|
|
1146
|
+
status_parts = []
|
|
1147
|
+
status_parts << "Status: #{evt['status']}" if evt['status']
|
|
1148
|
+
status_parts << "My status: #{humanize_status(evt['my_status'])}" if evt['my_status']
|
|
1149
|
+
lines << " #{status_parts.join(' | ')}".fg(245) unless status_parts.empty?
|
|
1150
|
+
|
|
1151
|
+
# Attendees
|
|
1152
|
+
attendees = evt['attendees']
|
|
1153
|
+
attendees = JSON.parse(attendees) if attendees.is_a?(String)
|
|
1154
|
+
if attendees.is_a?(Array) && attendees.any?
|
|
1155
|
+
lines << ""
|
|
1156
|
+
lines << " #{"Attendees:".fg(51)}"
|
|
1157
|
+
attendees.each do |a|
|
|
1158
|
+
name = a['name'] || a['email'] || a['displayName'] || '?'
|
|
1159
|
+
status = a['status'] || a['responseStatus'] || ''
|
|
1160
|
+
lines << " #{name}".fg(252) + (status.empty? ? "" : " (#{status})".fg(245))
|
|
1161
|
+
end
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Description
|
|
1165
|
+
desc = clean_description(evt['description'])
|
|
1166
|
+
if desc && !desc.empty?
|
|
1167
|
+
lines << ""
|
|
1168
|
+
lines << " " + ("-" * [pw - 6, 1].max).fg(238)
|
|
1169
|
+
desc.split("\n").each do |dline|
|
|
1170
|
+
# Word-wrap long lines
|
|
1171
|
+
while dline.length > pw - 6
|
|
1172
|
+
lines << " #{dline[0, pw - 6]}".fg(248)
|
|
1173
|
+
dline = dline[pw - 6..]
|
|
1174
|
+
end
|
|
1175
|
+
lines << " #{dline}".fg(248)
|
|
1176
|
+
end
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
lines << ""
|
|
1180
|
+
lines << " " + "UP/DOWN:scroll ESC/q:close".fg(245)
|
|
1181
|
+
|
|
1182
|
+
popup.text = lines.join("\n")
|
|
1183
|
+
popup.refresh
|
|
1184
|
+
|
|
1185
|
+
loop do
|
|
1186
|
+
k = getchr
|
|
1187
|
+
case k
|
|
1188
|
+
when 'ESC', 'q', 'v'
|
|
1189
|
+
break
|
|
1190
|
+
when 'DOWN', 'j'
|
|
1191
|
+
popup.linedown
|
|
1192
|
+
when 'UP', 'k'
|
|
1193
|
+
popup.lineup
|
|
1194
|
+
when 'PgDOWN'
|
|
1195
|
+
popup.pagedown
|
|
1196
|
+
when 'PgUP'
|
|
1197
|
+
popup.pageup
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
Rcurses.clear_screen
|
|
1202
|
+
create_panes
|
|
1203
|
+
render_all
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def accept_invite
|
|
1207
|
+
evt = event_at_selected_slot
|
|
1208
|
+
return show_feedback("No event at this time slot", 245) unless evt
|
|
1209
|
+
|
|
1210
|
+
show_feedback("Accepting '#{evt['title']}'...", 226)
|
|
1211
|
+
|
|
1212
|
+
@db.save_event(
|
|
1213
|
+
id: evt['id'],
|
|
1214
|
+
calendar_id: evt['calendar_id'],
|
|
1215
|
+
external_id: evt['external_id'],
|
|
1216
|
+
title: evt['title'],
|
|
1217
|
+
description: evt['description'],
|
|
1218
|
+
location: evt['location'],
|
|
1219
|
+
start_time: evt['start_time'],
|
|
1220
|
+
end_time: evt['end_time'],
|
|
1221
|
+
all_day: evt['all_day'].to_i == 1,
|
|
1222
|
+
timezone: evt['timezone'],
|
|
1223
|
+
recurrence_rule: evt['recurrence_rule'],
|
|
1224
|
+
status: evt['status'],
|
|
1225
|
+
organizer: evt['organizer'],
|
|
1226
|
+
attendees: evt['attendees'],
|
|
1227
|
+
my_status: 'accepted',
|
|
1228
|
+
alarms: evt['alarms'],
|
|
1229
|
+
metadata: evt['metadata']
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
# Push RSVP to the original calendar source
|
|
1233
|
+
push_rsvp_to_google(evt)
|
|
1234
|
+
push_rsvp_to_outlook(evt)
|
|
1235
|
+
|
|
1236
|
+
load_events_for_range
|
|
1237
|
+
render_all
|
|
1238
|
+
show_feedback("Invite accepted", 156)
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
def push_rsvp_to_google(evt)
|
|
1242
|
+
cal = @db.get_calendars(false).find { |c| c['id'] == evt['calendar_id'] }
|
|
1243
|
+
return unless cal && cal['source_type'] == 'google' && evt['external_id']
|
|
1244
|
+
|
|
1245
|
+
config = cal['source_config']
|
|
1246
|
+
config = JSON.parse(config) if config.is_a?(String)
|
|
1247
|
+
return unless config.is_a?(Hash)
|
|
1248
|
+
|
|
1249
|
+
google = Sources::Google.new(config['email'], safe_dir: config['safe_dir'] || '~/.config/timely/credentials')
|
|
1250
|
+
return unless google.get_access_token
|
|
1251
|
+
|
|
1252
|
+
gcal_id = config['google_calendar_id'] || config['email']
|
|
1253
|
+
# Google handles RSVP via the attendees list; we patch the event
|
|
1254
|
+
google.update_event(gcal_id, evt['external_id'], {
|
|
1255
|
+
title: evt['title'],
|
|
1256
|
+
start_time: evt['start_time'].to_i,
|
|
1257
|
+
end_time: evt['end_time'].to_i,
|
|
1258
|
+
all_day: evt['all_day'].to_i == 1,
|
|
1259
|
+
attendees: evt['attendees']
|
|
1260
|
+
})
|
|
1261
|
+
rescue => e
|
|
1262
|
+
# Silently fail; local status is already updated
|
|
1263
|
+
nil
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def push_rsvp_to_outlook(evt)
|
|
1267
|
+
cal = @db.get_calendars(false).find { |c| c['id'] == evt['calendar_id'] }
|
|
1268
|
+
return unless cal && cal['source_type'] == 'outlook' && evt['external_id']
|
|
1269
|
+
|
|
1270
|
+
config = cal['source_config']
|
|
1271
|
+
config = JSON.parse(config) if config.is_a?(String)
|
|
1272
|
+
return unless config.is_a?(Hash)
|
|
1273
|
+
|
|
1274
|
+
outlook = Sources::Outlook.new(config)
|
|
1275
|
+
return unless outlook.refresh_access_token
|
|
1276
|
+
|
|
1277
|
+
outlook.respond_to_event(evt['external_id'], 'accepted')
|
|
1278
|
+
rescue => e
|
|
1279
|
+
# Silently fail; local status is already updated
|
|
1280
|
+
nil
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# --- ICS Import ---
|
|
1284
|
+
|
|
1285
|
+
def import_ics_file
|
|
1286
|
+
blank_bottom(" Import ICS File".b)
|
|
1287
|
+
path = bottom_ask(" File path: ", "")
|
|
1288
|
+
return cancel_create if path.nil? || path.strip.empty?
|
|
1289
|
+
|
|
1290
|
+
path = File.expand_path(path.strip)
|
|
1291
|
+
unless File.exist?(path)
|
|
1292
|
+
show_feedback("File not found: #{path}", 196)
|
|
1293
|
+
return
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
result = Sources::IcsFile.import_file(path, @db)
|
|
1297
|
+
load_events_for_range
|
|
1298
|
+
render_all
|
|
1299
|
+
msg = "Imported #{result[:imported]} event(s)"
|
|
1300
|
+
msg += ", skipped #{result[:skipped]}" if result[:skipped] > 0
|
|
1301
|
+
msg += " (#{result[:error]})" if result[:error]
|
|
1302
|
+
show_feedback(msg, result[:error] ? 196 : 156)
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
# --- Google Calendar ---
|
|
1306
|
+
|
|
1307
|
+
def setup_google_calendar
|
|
1308
|
+
blank_bottom(" Google Calendar Setup".b.fg(39))
|
|
1309
|
+
email = bottom_ask(" Google email: ", "")
|
|
1310
|
+
return cancel_create if email.nil? || email.strip.empty?
|
|
1311
|
+
email = email.strip
|
|
1312
|
+
|
|
1313
|
+
safe_dir = @config.get('google.safe_dir', '~/.config/timely/credentials')
|
|
1314
|
+
|
|
1315
|
+
show_feedback("Connecting to Google Calendar...", 226)
|
|
1316
|
+
|
|
1317
|
+
google = Sources::Google.new(email, safe_dir: safe_dir)
|
|
1318
|
+
token = google.get_access_token
|
|
1319
|
+
unless token
|
|
1320
|
+
err = google.last_error || "Check credentials in #{safe_dir}"
|
|
1321
|
+
show_feedback("Token failed: #{err}", 196)
|
|
1322
|
+
return
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
calendars = google.list_calendars
|
|
1326
|
+
if calendars.empty?
|
|
1327
|
+
err = google.last_error || "No calendars found"
|
|
1328
|
+
show_feedback("#{email}: #{err}", 196)
|
|
1329
|
+
return
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
# Show calendars and let user pick
|
|
1333
|
+
cal_list = calendars.each_with_index.map { |c, i| "#{i + 1}:#{c[:summary]}" }.join(" ")
|
|
1334
|
+
blank_bottom(" Found #{calendars.size} calendar(s)".fg(39).b)
|
|
1335
|
+
pick = bottom_ask(" Add which? (#{cal_list}, 'all', or ESC): ", "all")
|
|
1336
|
+
return cancel_create if pick.nil?
|
|
1337
|
+
|
|
1338
|
+
selected = if pick.strip.downcase == 'all'
|
|
1339
|
+
calendars
|
|
1340
|
+
else
|
|
1341
|
+
indices = pick.strip.split(',').map { |s| s.strip.to_i - 1 }
|
|
1342
|
+
indices.filter_map { |i| calendars[i] if i >= 0 && i < calendars.size }
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
selected.each do |gcal|
|
|
1346
|
+
# Check if already added
|
|
1347
|
+
existing = @db.get_calendars(false).find { |c|
|
|
1348
|
+
config = c['source_config']
|
|
1349
|
+
config = JSON.parse(config) if config.is_a?(String)
|
|
1350
|
+
config.is_a?(Hash) && config['google_calendar_id'] == gcal[:id]
|
|
1351
|
+
}
|
|
1352
|
+
next if existing
|
|
1353
|
+
|
|
1354
|
+
@db.save_calendar(
|
|
1355
|
+
name: gcal[:summary],
|
|
1356
|
+
source_type: 'google',
|
|
1357
|
+
source_config: { 'email' => email, 'safe_dir' => safe_dir, 'google_calendar_id' => gcal[:id] },
|
|
1358
|
+
color: source_color_to_256(gcal[:color]),
|
|
1359
|
+
enabled: true
|
|
1360
|
+
)
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
# Start sync immediately
|
|
1364
|
+
manual_sync
|
|
1365
|
+
show_feedback("Added #{selected.size} Google calendar(s). Syncing...", 156)
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
def source_color_to_256(color)
|
|
1369
|
+
return 39 unless color
|
|
1370
|
+
case color.to_s.downcase
|
|
1371
|
+
when '#7986cb', '#4285f4', 'blue', 'lightblue' then 69
|
|
1372
|
+
when '#33b679', '#0b8043', 'green', 'lightgreen' then 35
|
|
1373
|
+
when '#8e24aa', '#9e69af', 'purple', 'lightpurple', 'grape' then 134
|
|
1374
|
+
when '#e67c73', '#d50000', 'red', 'lightred', 'cranberry' then 167
|
|
1375
|
+
when '#f6bf26', '#f4511e', 'yellow', 'lightyellow', 'pumpkin' then 214
|
|
1376
|
+
when '#039be5', '#4fc3f7', 'teal', 'lightteal' then 37
|
|
1377
|
+
when '#616161', '#a79b8e', 'gray', 'lightgray', 'grey' then 245
|
|
1378
|
+
when 'orange', 'lightorange' then 208
|
|
1379
|
+
when 'pink', 'lightpink' then 205
|
|
1380
|
+
when 'auto' then 39
|
|
1381
|
+
else 39
|
|
1382
|
+
end
|
|
1383
|
+
end
|
|
1384
|
+
|
|
1385
|
+
# --- Outlook Calendar ---
|
|
1386
|
+
|
|
1387
|
+
def setup_outlook_calendar
|
|
1388
|
+
blank_bottom(" Outlook/365 Calendar Setup".b.fg(33))
|
|
1389
|
+
|
|
1390
|
+
# Get client_id (from Azure app registration)
|
|
1391
|
+
default_client_id = @config.get('outlook.client_id', '')
|
|
1392
|
+
client_id = bottom_ask(" Azure App client_id: ", default_client_id)
|
|
1393
|
+
return cancel_create if client_id.nil? || client_id.strip.empty?
|
|
1394
|
+
client_id = client_id.strip
|
|
1395
|
+
|
|
1396
|
+
# Get tenant_id (optional, defaults to 'common')
|
|
1397
|
+
default_tenant = @config.get('outlook.tenant_id', 'common')
|
|
1398
|
+
tenant_id = bottom_ask(" Tenant ID (Enter for '#{default_tenant}'): ", default_tenant)
|
|
1399
|
+
tenant_id = (tenant_id || default_tenant).strip
|
|
1400
|
+
tenant_id = 'common' if tenant_id.empty?
|
|
1401
|
+
|
|
1402
|
+
# Save config for next time
|
|
1403
|
+
@config.set('outlook.client_id', client_id)
|
|
1404
|
+
@config.set('outlook.tenant_id', tenant_id)
|
|
1405
|
+
@config.save
|
|
1406
|
+
|
|
1407
|
+
show_feedback("Starting device code authentication...", 226)
|
|
1408
|
+
|
|
1409
|
+
outlook = Sources::Outlook.new({
|
|
1410
|
+
'client_id' => client_id,
|
|
1411
|
+
'tenant_id' => tenant_id
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
auth = outlook.start_device_auth
|
|
1415
|
+
unless auth
|
|
1416
|
+
show_feedback("Device auth failed: #{outlook.last_error}", 196)
|
|
1417
|
+
return
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
# Show user the code and URL
|
|
1421
|
+
user_code = auth['user_code']
|
|
1422
|
+
verify_url = auth['verification_uri'] || 'https://microsoft.com/devicelogin'
|
|
1423
|
+
|
|
1424
|
+
blank_bottom(" Outlook Device Login".b.fg(33))
|
|
1425
|
+
lines = [("-" * @w).fg(238)]
|
|
1426
|
+
lines << ""
|
|
1427
|
+
lines << " Go to: #{verify_url}".fg(51)
|
|
1428
|
+
lines << " Enter code: #{user_code}".fg(226).b
|
|
1429
|
+
lines << ""
|
|
1430
|
+
lines << " Waiting for authorization...".fg(245)
|
|
1431
|
+
while lines.length < @panes[:bottom].h
|
|
1432
|
+
lines << ""
|
|
1433
|
+
end
|
|
1434
|
+
@panes[:bottom].text = lines.join("\n")
|
|
1435
|
+
@panes[:bottom].full_refresh
|
|
1436
|
+
|
|
1437
|
+
# Poll for token in a thread so we can show progress
|
|
1438
|
+
token_result = nil
|
|
1439
|
+
auth_thread = Thread.new do
|
|
1440
|
+
token_result = outlook.poll_for_token(auth['device_code'])
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
# Wait for the auth thread (with a timeout of 5 minutes)
|
|
1444
|
+
auth_thread.join(300)
|
|
1445
|
+
|
|
1446
|
+
unless token_result
|
|
1447
|
+
show_feedback("Auth failed or timed out: #{outlook.last_error}", 196)
|
|
1448
|
+
auth_thread.kill if auth_thread.alive?
|
|
1449
|
+
return
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
show_feedback("Authenticated. Fetching calendars...", 226)
|
|
1453
|
+
|
|
1454
|
+
# List calendars
|
|
1455
|
+
calendars = outlook.list_calendars
|
|
1456
|
+
if calendars.empty?
|
|
1457
|
+
show_feedback("No Outlook calendars found: #{outlook.last_error}", 196)
|
|
1458
|
+
return
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
# Let user pick calendars
|
|
1462
|
+
cal_list = calendars.each_with_index.map { |c, i| "#{i + 1}:#{c[:name]}" }.join(" ")
|
|
1463
|
+
blank_bottom(" Found #{calendars.size} Outlook calendar(s)".fg(33).b)
|
|
1464
|
+
pick = bottom_ask(" Add which? (#{cal_list}, 'all', or ESC): ", "all")
|
|
1465
|
+
return cancel_create if pick.nil?
|
|
1466
|
+
|
|
1467
|
+
selected = if pick.strip.downcase == 'all'
|
|
1468
|
+
calendars
|
|
1469
|
+
else
|
|
1470
|
+
indices = pick.strip.split(',').map { |s| s.strip.to_i - 1 }
|
|
1471
|
+
indices.filter_map { |i| calendars[i] if i >= 0 && i < calendars.size }
|
|
1472
|
+
end
|
|
1473
|
+
|
|
1474
|
+
selected.each do |ocal|
|
|
1475
|
+
# Check if already added
|
|
1476
|
+
existing = @db.get_calendars(false).find { |c|
|
|
1477
|
+
config = c['source_config']
|
|
1478
|
+
config = JSON.parse(config) if config.is_a?(String)
|
|
1479
|
+
config.is_a?(Hash) && config['outlook_calendar_id'] == ocal[:id]
|
|
1480
|
+
}
|
|
1481
|
+
next if existing
|
|
1482
|
+
|
|
1483
|
+
@db.save_calendar(
|
|
1484
|
+
name: ocal[:name],
|
|
1485
|
+
source_type: 'outlook',
|
|
1486
|
+
source_config: {
|
|
1487
|
+
'client_id' => client_id,
|
|
1488
|
+
'tenant_id' => tenant_id,
|
|
1489
|
+
'access_token' => token_result[:access_token],
|
|
1490
|
+
'refresh_token' => token_result[:refresh_token],
|
|
1491
|
+
'outlook_calendar_id' => ocal[:id]
|
|
1492
|
+
},
|
|
1493
|
+
color: source_color_to_256(ocal[:color]),
|
|
1494
|
+
enabled: true
|
|
1495
|
+
)
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
manual_sync
|
|
1499
|
+
show_feedback("Added #{selected.size} Outlook calendar(s). Syncing...", 156)
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
def manual_sync
|
|
1503
|
+
google_cals = @db.get_calendars.select { |c| c['source_type'] == 'google' }
|
|
1504
|
+
outlook_cals = @db.get_calendars.select { |c| c['source_type'] == 'outlook' }
|
|
1505
|
+
|
|
1506
|
+
if google_cals.empty? && outlook_cals.empty?
|
|
1507
|
+
show_feedback("No calendars configured. Press G (Google) or O (Outlook) to set up.", 245)
|
|
1508
|
+
return
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
@syncing = true
|
|
1512
|
+
render_status_bar
|
|
1513
|
+
|
|
1514
|
+
Thread.new do
|
|
1515
|
+
begin
|
|
1516
|
+
total = 0
|
|
1517
|
+
errors = []
|
|
1518
|
+
now = Time.now
|
|
1519
|
+
time_min = (now - 30 * 86400).to_i # 30 days back
|
|
1520
|
+
time_max = (now + 60 * 86400).to_i # 60 days forward
|
|
1521
|
+
|
|
1522
|
+
# --- Google sync ---
|
|
1523
|
+
by_email = google_cals.group_by do |cal|
|
|
1524
|
+
config = cal['source_config']
|
|
1525
|
+
config = JSON.parse(config) if config.is_a?(String)
|
|
1526
|
+
config.is_a?(Hash) ? config['email'] : nil
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
by_email.each do |email, cals|
|
|
1530
|
+
next unless email
|
|
1531
|
+
config = cals.first['source_config']
|
|
1532
|
+
config = JSON.parse(config) if config.is_a?(String)
|
|
1533
|
+
google = Sources::Google.new(email, safe_dir: config['safe_dir'] || '~/.config/timely/credentials')
|
|
1534
|
+
unless google.get_access_token
|
|
1535
|
+
errors << "#{email}: #{google.last_error || 'token failed'}"
|
|
1536
|
+
next
|
|
1537
|
+
end
|
|
1538
|
+
|
|
1539
|
+
cals.each do |cal|
|
|
1540
|
+
cfg = cal['source_config']
|
|
1541
|
+
cfg = JSON.parse(cfg) if cfg.is_a?(String)
|
|
1542
|
+
gcal_id = cfg['google_calendar_id'] || email
|
|
1543
|
+
|
|
1544
|
+
events = google.fetch_events(gcal_id, time_min, time_max)
|
|
1545
|
+
unless events
|
|
1546
|
+
errors << "#{cal['name']}: #{google.last_error || 'fetch failed'}"
|
|
1547
|
+
next
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
events.each do |evt|
|
|
1551
|
+
total += 1 if @db.upsert_synced_event(cal['id'], evt) == :new
|
|
1552
|
+
end
|
|
1553
|
+
@db.update_calendar_sync(cal['id'], Time.now.to_i)
|
|
1554
|
+
end
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1557
|
+
# --- Outlook sync ---
|
|
1558
|
+
outlook_cals.each do |cal|
|
|
1559
|
+
cfg = cal['source_config']
|
|
1560
|
+
cfg = JSON.parse(cfg) if cfg.is_a?(String)
|
|
1561
|
+
next unless cfg.is_a?(Hash)
|
|
1562
|
+
|
|
1563
|
+
outlook = Sources::Outlook.new(cfg)
|
|
1564
|
+
unless outlook.refresh_access_token
|
|
1565
|
+
errors << "#{cal['name']}: #{outlook.last_error || 'token failed'}"
|
|
1566
|
+
next
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
events = outlook.fetch_events(time_min, time_max)
|
|
1570
|
+
unless events
|
|
1571
|
+
errors << "#{cal['name']}: #{outlook.last_error || 'fetch failed'}"
|
|
1572
|
+
next
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
events.each do |evt|
|
|
1576
|
+
total += 1 if @db.upsert_synced_event(cal['id'], evt) == :new
|
|
1577
|
+
end
|
|
1578
|
+
|
|
1579
|
+
# Persist refreshed tokens
|
|
1580
|
+
new_config = cfg.merge(
|
|
1581
|
+
'access_token' => outlook.instance_variable_get(:@access_token),
|
|
1582
|
+
'refresh_token' => outlook.instance_variable_get(:@refresh_token)
|
|
1583
|
+
)
|
|
1584
|
+
@db.update_calendar_sync(cal['id'], Time.now.to_i, new_config)
|
|
1585
|
+
end
|
|
1586
|
+
|
|
1587
|
+
# Signal UI to refresh
|
|
1588
|
+
load_events_for_range
|
|
1589
|
+
if errors.any?
|
|
1590
|
+
show_feedback("Sync: #{total} new. Errors: #{errors.first}", 196)
|
|
1591
|
+
else
|
|
1592
|
+
show_feedback("Sync complete. #{total} new event(s).", 156)
|
|
1593
|
+
end
|
|
1594
|
+
@syncing = false
|
|
1595
|
+
render_all
|
|
1596
|
+
rescue => e
|
|
1597
|
+
@syncing = false
|
|
1598
|
+
File.open('/tmp/timely-sync.log', 'a') { |f| f.puts "#{Time.now} Sync thread error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
1599
|
+
show_feedback("Sync error: #{e.message}", 196)
|
|
1600
|
+
end
|
|
1601
|
+
end
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
# --- Feedback ---
|
|
1605
|
+
|
|
1606
|
+
def show_feedback(message, color = 156)
|
|
1607
|
+
lines = [("-" * @w).fg(238), " #{message}".fg(color)]
|
|
1608
|
+
while lines.length < @panes[:bottom].h
|
|
1609
|
+
lines << ""
|
|
1610
|
+
end
|
|
1611
|
+
@panes[:bottom].text = lines.join("\n")
|
|
1612
|
+
@panes[:bottom].full_refresh
|
|
1613
|
+
end
|
|
1614
|
+
|
|
1615
|
+
# --- Preferences ---
|
|
1616
|
+
|
|
1617
|
+
def pick_color(current = 39)
|
|
1618
|
+
rows, cols = IO.console.winsize
|
|
1619
|
+
# 16 columns x 16 rows = 256 colors, plus border and labels
|
|
1620
|
+
pw = 52 # 16 * 3 + 4
|
|
1621
|
+
ph = 20 # 16 rows + header + footer + borders
|
|
1622
|
+
px = (cols - pw) / 2
|
|
1623
|
+
py = (rows - ph) / 2
|
|
1624
|
+
|
|
1625
|
+
popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
|
|
1626
|
+
popup.border = true
|
|
1627
|
+
popup.scroll = false
|
|
1628
|
+
|
|
1629
|
+
sel = current.to_i.clamp(0, 255)
|
|
1630
|
+
|
|
1631
|
+
build = -> {
|
|
1632
|
+
popup.full_refresh
|
|
1633
|
+
lines = []
|
|
1634
|
+
lines << ""
|
|
1635
|
+
lines << " " + "Pick Color".b + " current: " + "\u2588\u2588".fg(sel) + " #{sel}"
|
|
1636
|
+
lines << ""
|
|
1637
|
+
16.times do |row|
|
|
1638
|
+
line = " "
|
|
1639
|
+
16.times do |col|
|
|
1640
|
+
c = row * 16 + col
|
|
1641
|
+
if c == sel
|
|
1642
|
+
line += "X ".bg(c).fg(255).b
|
|
1643
|
+
else
|
|
1644
|
+
line += " ".bg(c)
|
|
1645
|
+
end
|
|
1646
|
+
line += " "
|
|
1647
|
+
end
|
|
1648
|
+
lines << line
|
|
1649
|
+
end
|
|
1650
|
+
lines << ""
|
|
1651
|
+
lines << " " + "Arrows:move ENTER:select ESC:cancel".fg(245)
|
|
1652
|
+
popup.text = lines.join("\n")
|
|
1653
|
+
popup.ix = 0
|
|
1654
|
+
popup.refresh
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
build.call
|
|
1658
|
+
|
|
1659
|
+
result = nil
|
|
1660
|
+
loop do
|
|
1661
|
+
k = getchr
|
|
1662
|
+
case k
|
|
1663
|
+
when 'ESC', 'q'
|
|
1664
|
+
break
|
|
1665
|
+
when 'ENTER'
|
|
1666
|
+
result = sel
|
|
1667
|
+
break
|
|
1668
|
+
when 'RIGHT', 'l'
|
|
1669
|
+
sel = (sel + 1) % 256
|
|
1670
|
+
build.call
|
|
1671
|
+
when 'LEFT', 'h'
|
|
1672
|
+
sel = (sel - 1) % 256
|
|
1673
|
+
build.call
|
|
1674
|
+
when 'DOWN', 'j'
|
|
1675
|
+
sel = (sel + 16) % 256
|
|
1676
|
+
build.call
|
|
1677
|
+
when 'UP', 'k'
|
|
1678
|
+
sel = (sel - 16) % 256
|
|
1679
|
+
build.call
|
|
1680
|
+
end
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
# Clear picker overlay
|
|
1684
|
+
Rcurses.clear_screen
|
|
1685
|
+
create_panes
|
|
1686
|
+
render_all
|
|
1687
|
+
result
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
def show_calendars
|
|
1691
|
+
rows, cols = IO.console.winsize
|
|
1692
|
+
pw = [cols - 16, 64].min
|
|
1693
|
+
pw = [pw, 50].max
|
|
1694
|
+
|
|
1695
|
+
calendars = @db.get_calendars(false)
|
|
1696
|
+
return show_feedback("No calendars configured", 245) if calendars.empty?
|
|
1697
|
+
|
|
1698
|
+
ph = [calendars.size + 7, rows - 6].min
|
|
1699
|
+
px = (cols - pw) / 2
|
|
1700
|
+
py = (rows - ph) / 2
|
|
1701
|
+
|
|
1702
|
+
popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
|
|
1703
|
+
popup.border = true
|
|
1704
|
+
popup.scroll = false
|
|
1705
|
+
|
|
1706
|
+
sel = 0
|
|
1707
|
+
|
|
1708
|
+
build = -> {
|
|
1709
|
+
popup.full_refresh
|
|
1710
|
+
lines = []
|
|
1711
|
+
lines << ""
|
|
1712
|
+
lines << " " + "Calendars".b
|
|
1713
|
+
lines << " " + ("-" * [pw - 6, 1].max).fg(238)
|
|
1714
|
+
|
|
1715
|
+
calendars.each_with_index do |cal, i|
|
|
1716
|
+
enabled = cal['enabled'].to_i == 1
|
|
1717
|
+
color = cal['color'] || 39
|
|
1718
|
+
swatch = "\u2588\u2588".fg(color)
|
|
1719
|
+
status = enabled ? "on".fg(35) : "off".fg(196)
|
|
1720
|
+
src = cal['source_type'] || 'local'
|
|
1721
|
+
name = cal['name'] || '(unnamed)'
|
|
1722
|
+
display = " #{swatch} %-22s %s [%s]" % [name[0..21], status, src]
|
|
1723
|
+
lines << (i == sel ? display.fg(39).b : display)
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1726
|
+
lines << ""
|
|
1727
|
+
lines << " " + "j/k:nav c:color ENTER:toggle x:remove q:close".fg(245)
|
|
1728
|
+
popup.text = lines.join("\n")
|
|
1729
|
+
popup.ix = 0
|
|
1730
|
+
popup.refresh
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
build.call
|
|
1734
|
+
|
|
1735
|
+
loop do
|
|
1736
|
+
k = getchr
|
|
1737
|
+
case k
|
|
1738
|
+
when 'ESC', 'q'
|
|
1739
|
+
break
|
|
1740
|
+
when 'k', 'UP'
|
|
1741
|
+
sel = (sel - 1) % calendars.size
|
|
1742
|
+
build.call
|
|
1743
|
+
when 'j', 'DOWN'
|
|
1744
|
+
sel = (sel + 1) % calendars.size
|
|
1745
|
+
build.call
|
|
1746
|
+
when 'c'
|
|
1747
|
+
cal = calendars[sel]
|
|
1748
|
+
new_color = pick_color(cal['color'] || 39)
|
|
1749
|
+
if new_color
|
|
1750
|
+
@db.update_calendar_color(cal['id'], new_color)
|
|
1751
|
+
cal['color'] = new_color
|
|
1752
|
+
end
|
|
1753
|
+
build.call
|
|
1754
|
+
when 'ENTER'
|
|
1755
|
+
cal = calendars[sel]
|
|
1756
|
+
@db.toggle_calendar_enabled(cal['id'])
|
|
1757
|
+
cal['enabled'] = cal['enabled'].to_i == 1 ? 0 : 1
|
|
1758
|
+
build.call
|
|
1759
|
+
when 'x'
|
|
1760
|
+
cal = calendars[sel]
|
|
1761
|
+
confirm = popup.ask(" Remove '#{cal['name']}'? (y/n): ", "")
|
|
1762
|
+
if confirm&.strip&.downcase == 'y'
|
|
1763
|
+
@db.delete_calendar_with_events(cal['id'])
|
|
1764
|
+
calendars.delete_at(sel)
|
|
1765
|
+
sel = [sel, calendars.size - 1].min
|
|
1766
|
+
break if calendars.empty?
|
|
1767
|
+
end
|
|
1768
|
+
build.call
|
|
1769
|
+
end
|
|
1770
|
+
end
|
|
1771
|
+
|
|
1772
|
+
Rcurses.clear_screen
|
|
1773
|
+
create_panes
|
|
1774
|
+
load_events_for_range
|
|
1775
|
+
render_all
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
def show_preferences
|
|
1779
|
+
rows, cols = IO.console.winsize
|
|
1780
|
+
pw = [cols - 20, 56].min
|
|
1781
|
+
pw = [pw, 48].max
|
|
1782
|
+
ph = 19
|
|
1783
|
+
px = (cols - pw) / 2
|
|
1784
|
+
py = (rows - ph) / 2
|
|
1785
|
+
|
|
1786
|
+
popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
|
|
1787
|
+
popup.border = true
|
|
1788
|
+
popup.scroll = false
|
|
1789
|
+
|
|
1790
|
+
pref_keys = [
|
|
1791
|
+
['colors.selected_bg_a', 'Sel. alt bg A', 235],
|
|
1792
|
+
['colors.selected_bg_b', 'Sel. alt bg B', 234],
|
|
1793
|
+
['colors.alt_bg_a', 'Row alt bg A', 233],
|
|
1794
|
+
['colors.alt_bg_b', 'Row alt bg B', 0],
|
|
1795
|
+
['colors.current_month_bg','Current month bg', 233],
|
|
1796
|
+
['colors.saturday', 'Saturday color', 208],
|
|
1797
|
+
['colors.sunday', 'Sunday color', 167],
|
|
1798
|
+
['colors.today_fg', 'Today fg', 232],
|
|
1799
|
+
['colors.today_bg', 'Today bg', 246],
|
|
1800
|
+
['colors.slot_selected_bg','Slot selected bg', 237],
|
|
1801
|
+
['colors.info_bg', 'Info bar bg', 235],
|
|
1802
|
+
['colors.status_bg', 'Status bar bg', 235],
|
|
1803
|
+
['work_hours.start', 'Work hours start', 8],
|
|
1804
|
+
['work_hours.end', 'Work hours end', 17],
|
|
1805
|
+
['default_calendar', 'Default calendar', 1]
|
|
1806
|
+
]
|
|
1807
|
+
|
|
1808
|
+
sel = 0
|
|
1809
|
+
|
|
1810
|
+
is_color = ->(key) { key.start_with?('colors.') }
|
|
1811
|
+
|
|
1812
|
+
build_popup = -> {
|
|
1813
|
+
popup.full_refresh
|
|
1814
|
+
inner_w = pw - 4
|
|
1815
|
+
lines = []
|
|
1816
|
+
lines << ""
|
|
1817
|
+
lines << " " + "Preferences".b
|
|
1818
|
+
lines << " " + ("\u2500" * [inner_w - 3, 1].max).fg(238)
|
|
1819
|
+
|
|
1820
|
+
pref_keys.each_with_index do |(key, label, default), i|
|
|
1821
|
+
val = @config.get(key, default)
|
|
1822
|
+
if is_color.call(key)
|
|
1823
|
+
swatch = key.include?('bg') ? " ".bg(val.to_i) : "\u2588\u2588".fg(val.to_i)
|
|
1824
|
+
val_str = val.to_s.rjust(3)
|
|
1825
|
+
display = " %-18s %s %s" % [label, val_str, swatch]
|
|
1826
|
+
elsif key == 'default_calendar'
|
|
1827
|
+
cal = @db.get_calendars(false).find { |c| c['id'] == val.to_i }
|
|
1828
|
+
cal_name = cal ? " (#{cal['name']})" : ""
|
|
1829
|
+
display = " %-18s %s%s" % [label, val.to_s, cal_name]
|
|
1830
|
+
else
|
|
1831
|
+
display = " %-18s %s" % [label, val.to_s]
|
|
1832
|
+
end
|
|
1833
|
+
if i == sel
|
|
1834
|
+
lines << display.fg(39).b
|
|
1835
|
+
else
|
|
1836
|
+
lines << display
|
|
1837
|
+
end
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1840
|
+
lines << ""
|
|
1841
|
+
if is_color.call(pref_keys[sel][0])
|
|
1842
|
+
lines << " " + "j/k:navigate h/l:adjust H/L:x10 ENTER:type q:close".fg(245)
|
|
1843
|
+
else
|
|
1844
|
+
lines << " " + "j/k:navigate ENTER:edit q/ESC:close".fg(245)
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
popup.text = lines.join("\n")
|
|
1848
|
+
popup.ix = 0
|
|
1849
|
+
popup.refresh
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
build_popup.call
|
|
1853
|
+
|
|
1854
|
+
loop do
|
|
1855
|
+
k = getchr
|
|
1856
|
+
case k
|
|
1857
|
+
when 'ESC', 'q'
|
|
1858
|
+
break
|
|
1859
|
+
when 'k', 'UP'
|
|
1860
|
+
sel = (sel - 1) % pref_keys.length
|
|
1861
|
+
build_popup.call
|
|
1862
|
+
when 'j', 'DOWN'
|
|
1863
|
+
sel = (sel + 1) % pref_keys.length
|
|
1864
|
+
build_popup.call
|
|
1865
|
+
when 'h', 'LEFT', 'l', 'RIGHT', 'H', 'L'
|
|
1866
|
+
key, label, default = pref_keys[sel]
|
|
1867
|
+
if is_color.call(key)
|
|
1868
|
+
delta = case k
|
|
1869
|
+
when 'h', 'LEFT' then -1
|
|
1870
|
+
when 'l', 'RIGHT' then 1
|
|
1871
|
+
when 'H' then -10
|
|
1872
|
+
when 'L' then 10
|
|
1873
|
+
end
|
|
1874
|
+
val = (@config.get(key, default).to_i + delta).clamp(0, 255)
|
|
1875
|
+
@config.set(key, val)
|
|
1876
|
+
@config.save
|
|
1877
|
+
build_popup.call
|
|
1878
|
+
end
|
|
1879
|
+
when 'ENTER'
|
|
1880
|
+
key, label, default = pref_keys[sel]
|
|
1881
|
+
current = @config.get(key, default)
|
|
1882
|
+
if is_color.call(key)
|
|
1883
|
+
new_color = pick_color(current.to_i)
|
|
1884
|
+
if new_color
|
|
1885
|
+
@config.set(key, new_color)
|
|
1886
|
+
@config.save
|
|
1887
|
+
end
|
|
1888
|
+
else
|
|
1889
|
+
result = popup.ask("#{label}: ", current.to_s)
|
|
1890
|
+
if result && !result.strip.empty?
|
|
1891
|
+
val = result.strip
|
|
1892
|
+
val = val.to_i if current.is_a?(Integer)
|
|
1893
|
+
@config.set(key, val)
|
|
1894
|
+
@config.save
|
|
1895
|
+
end
|
|
1896
|
+
end
|
|
1897
|
+
build_popup.call
|
|
1898
|
+
end
|
|
1899
|
+
end
|
|
1900
|
+
|
|
1901
|
+
# Re-create panes to apply bar color changes
|
|
1902
|
+
Rcurses.clear_screen
|
|
1903
|
+
create_panes
|
|
1904
|
+
render_all
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
# --- Help ---
|
|
1908
|
+
|
|
1909
|
+
def show_help
|
|
1910
|
+
rows, cols = IO.console.winsize
|
|
1911
|
+
pw = [cols - 16, 68].min
|
|
1912
|
+
pw = [pw, 56].max
|
|
1913
|
+
ph = 24
|
|
1914
|
+
px = (cols - pw) / 2
|
|
1915
|
+
py = (rows - ph) / 2
|
|
1916
|
+
|
|
1917
|
+
popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
|
|
1918
|
+
popup.border = true
|
|
1919
|
+
popup.scroll = false
|
|
1920
|
+
|
|
1921
|
+
k = ->(s) { s.fg(51) } # key color
|
|
1922
|
+
d = ->(s) { s.fg(252) } # description color
|
|
1923
|
+
sep = " " + ("-" * [pw - 6, 1].max).fg(238)
|
|
1924
|
+
|
|
1925
|
+
help = []
|
|
1926
|
+
help << ""
|
|
1927
|
+
help << " " + "Timely - Terminal Calendar".b.fg(156)
|
|
1928
|
+
help << sep
|
|
1929
|
+
help << " " + "Navigation".b.fg(156)
|
|
1930
|
+
help << " #{k['d/RIGHT']} #{d['Next day']} #{k['D/LEFT']} #{d['Prev day']}"
|
|
1931
|
+
help << " #{k['w']} #{d['Next week']} #{k['W']} #{d['Prev week']}"
|
|
1932
|
+
help << " #{k['m']} #{d['Next month']} #{k['M']} #{d['Prev month']}"
|
|
1933
|
+
help << " #{k['y']} #{d['Next year']} #{k['Y']} #{d['Prev year']}"
|
|
1934
|
+
help << " #{k['UP/DOWN']} #{d['Select time slot (scrolls at edges)']}"
|
|
1935
|
+
help << " #{k['PgUp/Dn']} #{d['Jump 10 slots']} #{k['HOME']} #{d['Top/all-day']}"
|
|
1936
|
+
help << " #{k['END']} #{d['Bottom (23:30)']} #{k['j/k']} #{d['Cycle events']}"
|
|
1937
|
+
help << " #{k['e/E']} #{d['Jump to event (next/prev)']}"
|
|
1938
|
+
help << " #{k['t']} #{d['Today']} #{k['g']} #{d['Go to (date, Mon, yyyy)']}"
|
|
1939
|
+
help << sep
|
|
1940
|
+
help << " " + "Events".b.fg(156)
|
|
1941
|
+
help << " #{k['n']} #{d['New event']} #{k['ENTER']} #{d['Edit event']}"
|
|
1942
|
+
help << " #{k['x/DEL']} #{d['Delete event']} #{k['a']} #{d['Accept invite']}"
|
|
1943
|
+
help << " #{k['v']} #{d['View event details (scrollable popup)']}"
|
|
1944
|
+
help << " #{k['r']} #{d['Reply via Heathrow']}"
|
|
1945
|
+
help << sep
|
|
1946
|
+
help << " #{k['i']} #{d['Import ICS']} #{k['G']} #{d['Google setup']} #{k['O']} #{d['Outlook setup']}"
|
|
1947
|
+
help << " #{k['S']} #{d['Sync now']} #{k['C']} #{d['Calendars']} #{k['P']} #{d['Preferences']}"
|
|
1948
|
+
help << " #{k['q']} #{d['Quit']}"
|
|
1949
|
+
help << ""
|
|
1950
|
+
help << " " + "Press any key to close...".fg(245)
|
|
1951
|
+
|
|
1952
|
+
popup.text = help.join("\n")
|
|
1953
|
+
popup.refresh
|
|
1954
|
+
getchr
|
|
1955
|
+
Rcurses.clear_screen
|
|
1956
|
+
create_panes
|
|
1957
|
+
render_all
|
|
1958
|
+
end
|
|
1959
|
+
end
|
|
1960
|
+
end
|