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,93 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module Timely
|
|
5
|
+
class Config
|
|
6
|
+
attr_accessor :settings
|
|
7
|
+
|
|
8
|
+
def initialize(config_path = TIMELY_CONFIG)
|
|
9
|
+
@config_path = config_path
|
|
10
|
+
@settings = load_config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load_config
|
|
14
|
+
if File.exist?(@config_path)
|
|
15
|
+
YAML.load_file(@config_path) || create_default_config
|
|
16
|
+
else
|
|
17
|
+
create_default_config
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_default_config
|
|
22
|
+
default = {
|
|
23
|
+
'version' => Timely::VERSION,
|
|
24
|
+
'location' => {
|
|
25
|
+
'lat' => 59.9139,
|
|
26
|
+
'lon' => 10.7522
|
|
27
|
+
},
|
|
28
|
+
'timezone' => 'Europe/Oslo',
|
|
29
|
+
'default_view' => 'month',
|
|
30
|
+
'work_hours' => {
|
|
31
|
+
'start' => 8,
|
|
32
|
+
'end' => 17
|
|
33
|
+
},
|
|
34
|
+
'week_starts_on' => 'monday',
|
|
35
|
+
'google' => {
|
|
36
|
+
'safe_dir' => '~/.config/timely/credentials',
|
|
37
|
+
'sync_interval' => 300
|
|
38
|
+
},
|
|
39
|
+
'outlook' => {
|
|
40
|
+
'client_id' => '',
|
|
41
|
+
'tenant_id' => 'common'
|
|
42
|
+
},
|
|
43
|
+
'notifications' => {
|
|
44
|
+
'enabled' => true,
|
|
45
|
+
'default_alarm' => 15
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
save_config(default)
|
|
49
|
+
default
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get(key_path, default = nil)
|
|
53
|
+
keys = key_path.to_s.split('.')
|
|
54
|
+
value = @settings
|
|
55
|
+
keys.each do |key|
|
|
56
|
+
break unless value.is_a?(Hash)
|
|
57
|
+
value = value[key]
|
|
58
|
+
end
|
|
59
|
+
value.nil? ? default : value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set(key_path, value)
|
|
63
|
+
keys = key_path.to_s.split('.')
|
|
64
|
+
last_key = keys.pop
|
|
65
|
+
parent = @settings
|
|
66
|
+
keys.each { |k| parent[k] ||= {}; parent = parent[k] }
|
|
67
|
+
parent[last_key] = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def save
|
|
71
|
+
save_config
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reload
|
|
75
|
+
@settings = load_config
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def [](key)
|
|
79
|
+
@settings[key]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def []=(key, value)
|
|
83
|
+
@settings[key] = value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def save_config(config = @settings)
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(@config_path))
|
|
90
|
+
File.write(@config_path, config.to_yaml)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
require 'sqlite3'
|
|
2
|
+
require 'time'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Timely
|
|
6
|
+
class Database
|
|
7
|
+
attr_reader :db
|
|
8
|
+
|
|
9
|
+
SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
def initialize(db_path = TIMELY_DB)
|
|
12
|
+
@db_path = db_path
|
|
13
|
+
@db = SQLite3::Database.new(@db_path)
|
|
14
|
+
@db.results_as_hash = true
|
|
15
|
+
@db.execute("PRAGMA journal_mode=WAL")
|
|
16
|
+
@db.execute("PRAGMA busy_timeout=5000")
|
|
17
|
+
setup_schema
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setup_schema
|
|
21
|
+
# Schema version tracking
|
|
22
|
+
@db.execute <<-SQL
|
|
23
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
24
|
+
version INTEGER PRIMARY KEY,
|
|
25
|
+
applied_at INTEGER NOT NULL
|
|
26
|
+
)
|
|
27
|
+
SQL
|
|
28
|
+
|
|
29
|
+
# Calendars table
|
|
30
|
+
@db.execute <<-SQL
|
|
31
|
+
CREATE TABLE IF NOT EXISTS calendars (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
name TEXT NOT NULL,
|
|
34
|
+
source_type TEXT NOT NULL,
|
|
35
|
+
source_config TEXT,
|
|
36
|
+
color INTEGER DEFAULT 39,
|
|
37
|
+
enabled INTEGER DEFAULT 1,
|
|
38
|
+
sync_token TEXT,
|
|
39
|
+
last_synced_at INTEGER,
|
|
40
|
+
created_at INTEGER NOT NULL
|
|
41
|
+
)
|
|
42
|
+
SQL
|
|
43
|
+
|
|
44
|
+
@db.execute "CREATE INDEX IF NOT EXISTS idx_calendars_enabled ON calendars(enabled)"
|
|
45
|
+
|
|
46
|
+
# Events table
|
|
47
|
+
@db.execute <<-SQL
|
|
48
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
calendar_id INTEGER NOT NULL,
|
|
51
|
+
external_id TEXT,
|
|
52
|
+
title TEXT NOT NULL,
|
|
53
|
+
description TEXT,
|
|
54
|
+
location TEXT,
|
|
55
|
+
start_time INTEGER NOT NULL,
|
|
56
|
+
end_time INTEGER,
|
|
57
|
+
all_day INTEGER DEFAULT 0,
|
|
58
|
+
timezone TEXT,
|
|
59
|
+
recurrence_rule TEXT,
|
|
60
|
+
series_master_id INTEGER,
|
|
61
|
+
status TEXT DEFAULT 'confirmed',
|
|
62
|
+
organizer TEXT,
|
|
63
|
+
attendees TEXT,
|
|
64
|
+
my_status TEXT,
|
|
65
|
+
alarms TEXT,
|
|
66
|
+
metadata TEXT,
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
updated_at INTEGER NOT NULL,
|
|
69
|
+
FOREIGN KEY(calendar_id) REFERENCES calendars(id) ON DELETE CASCADE
|
|
70
|
+
)
|
|
71
|
+
SQL
|
|
72
|
+
|
|
73
|
+
@db.execute "CREATE INDEX IF NOT EXISTS idx_events_calendar ON events(calendar_id)"
|
|
74
|
+
@db.execute "CREATE INDEX IF NOT EXISTS idx_events_start ON events(start_time)"
|
|
75
|
+
@db.execute "CREATE INDEX IF NOT EXISTS idx_events_end ON events(end_time)"
|
|
76
|
+
@db.execute "CREATE INDEX IF NOT EXISTS idx_events_range ON events(start_time, end_time)"
|
|
77
|
+
@db.execute "CREATE INDEX IF NOT EXISTS idx_events_external ON events(calendar_id, external_id)"
|
|
78
|
+
|
|
79
|
+
# Settings table
|
|
80
|
+
@db.execute <<-SQL
|
|
81
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
82
|
+
key TEXT PRIMARY KEY,
|
|
83
|
+
value TEXT NOT NULL,
|
|
84
|
+
updated_at INTEGER NOT NULL
|
|
85
|
+
)
|
|
86
|
+
SQL
|
|
87
|
+
|
|
88
|
+
# Weather cache
|
|
89
|
+
@db.execute <<-SQL
|
|
90
|
+
CREATE TABLE IF NOT EXISTS weather_cache (
|
|
91
|
+
date TEXT NOT NULL,
|
|
92
|
+
hour INTEGER,
|
|
93
|
+
data TEXT,
|
|
94
|
+
fetched_at INTEGER NOT NULL,
|
|
95
|
+
PRIMARY KEY(date, hour)
|
|
96
|
+
)
|
|
97
|
+
SQL
|
|
98
|
+
|
|
99
|
+
# Astronomy cache
|
|
100
|
+
@db.execute <<-SQL
|
|
101
|
+
CREATE TABLE IF NOT EXISTS astronomy_cache (
|
|
102
|
+
date TEXT PRIMARY KEY,
|
|
103
|
+
moon_phase REAL,
|
|
104
|
+
moon_phase_name TEXT,
|
|
105
|
+
events TEXT,
|
|
106
|
+
fetched_at INTEGER NOT NULL
|
|
107
|
+
)
|
|
108
|
+
SQL
|
|
109
|
+
|
|
110
|
+
migrate
|
|
111
|
+
create_default_calendar
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def migrate
|
|
115
|
+
current_version = @db.get_first_value("SELECT MAX(version) FROM schema_version") || 0
|
|
116
|
+
|
|
117
|
+
if current_version < SCHEMA_VERSION
|
|
118
|
+
@db.transaction do
|
|
119
|
+
@db.execute("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
120
|
+
[SCHEMA_VERSION, Time.now.to_i])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def create_default_calendar
|
|
126
|
+
count = @db.get_first_value("SELECT COUNT(*) FROM calendars")
|
|
127
|
+
return if count && count > 0
|
|
128
|
+
|
|
129
|
+
now = Time.now.to_i
|
|
130
|
+
@db.execute(
|
|
131
|
+
"INSERT INTO calendars (name, source_type, color, enabled, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
132
|
+
['Personal', 'local', 39, 1, now]
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Event operations
|
|
137
|
+
|
|
138
|
+
def get_events_in_range(start_time, end_time)
|
|
139
|
+
start_ts = start_time.is_a?(Time) ? start_time.to_i : start_time.to_i
|
|
140
|
+
end_ts = end_time.is_a?(Time) ? end_time.to_i : end_time.to_i
|
|
141
|
+
|
|
142
|
+
rows = @db.execute(
|
|
143
|
+
"SELECT e.*, c.name as calendar_name, c.color as calendar_color
|
|
144
|
+
FROM events e
|
|
145
|
+
JOIN calendars c ON e.calendar_id = c.id
|
|
146
|
+
WHERE c.enabled = 1
|
|
147
|
+
AND e.start_time < ?
|
|
148
|
+
AND (e.end_time > ? OR e.end_time IS NULL)
|
|
149
|
+
ORDER BY e.start_time, e.title",
|
|
150
|
+
[end_ts, start_ts]
|
|
151
|
+
)
|
|
152
|
+
rows.map { |row| normalize_event_row(row) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def get_events_for_date(date)
|
|
156
|
+
# date is a Date object; get all events that overlap this day
|
|
157
|
+
start_ts = Time.new(date.year, date.month, date.day, 0, 0, 0).to_i
|
|
158
|
+
end_ts = start_ts + 86400
|
|
159
|
+
get_events_in_range(start_ts, end_ts)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def save_event(event_data)
|
|
163
|
+
now = Time.now.to_i
|
|
164
|
+
attendees_val = json_field(event_data[:attendees])
|
|
165
|
+
alarms_val = json_field(event_data[:alarms])
|
|
166
|
+
metadata_val = json_field(event_data[:metadata])
|
|
167
|
+
|
|
168
|
+
if event_data[:id]
|
|
169
|
+
@db.execute(
|
|
170
|
+
"UPDATE events SET calendar_id=?, external_id=?, title=?, description=?,
|
|
171
|
+
location=?, start_time=?, end_time=?, all_day=?, timezone=?,
|
|
172
|
+
recurrence_rule=?, series_master_id=?, status=?, organizer=?, attendees=?, my_status=?,
|
|
173
|
+
alarms=?, metadata=?, updated_at=? WHERE id=?",
|
|
174
|
+
[
|
|
175
|
+
event_data[:calendar_id], event_data[:external_id],
|
|
176
|
+
event_data[:title], event_data[:description],
|
|
177
|
+
event_data[:location], event_data[:start_time], event_data[:end_time],
|
|
178
|
+
event_data[:all_day] ? 1 : 0, event_data[:timezone],
|
|
179
|
+
event_data[:recurrence_rule], event_data[:series_master_id],
|
|
180
|
+
event_data[:status],
|
|
181
|
+
event_data[:organizer],
|
|
182
|
+
attendees_val,
|
|
183
|
+
event_data[:my_status],
|
|
184
|
+
alarms_val,
|
|
185
|
+
metadata_val,
|
|
186
|
+
now, event_data[:id]
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
event_data[:id]
|
|
190
|
+
else
|
|
191
|
+
@db.execute(
|
|
192
|
+
"INSERT INTO events (calendar_id, external_id, title, description,
|
|
193
|
+
location, start_time, end_time, all_day, timezone,
|
|
194
|
+
recurrence_rule, series_master_id, status, organizer, attendees, my_status,
|
|
195
|
+
alarms, metadata, created_at, updated_at)
|
|
196
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
197
|
+
[
|
|
198
|
+
event_data[:calendar_id] || 1, event_data[:external_id],
|
|
199
|
+
event_data[:title], event_data[:description],
|
|
200
|
+
event_data[:location], event_data[:start_time], event_data[:end_time],
|
|
201
|
+
event_data[:all_day] ? 1 : 0, event_data[:timezone],
|
|
202
|
+
event_data[:recurrence_rule], event_data[:series_master_id],
|
|
203
|
+
event_data[:status] || 'confirmed',
|
|
204
|
+
event_data[:organizer],
|
|
205
|
+
attendees_val,
|
|
206
|
+
event_data[:my_status],
|
|
207
|
+
alarms_val,
|
|
208
|
+
metadata_val,
|
|
209
|
+
now, now
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
@db.last_insert_row_id
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def delete_event(event_id)
|
|
217
|
+
@db.execute("DELETE FROM events WHERE id = ?", [event_id])
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Calendar operations
|
|
221
|
+
|
|
222
|
+
def get_calendars(enabled_only = true)
|
|
223
|
+
query = enabled_only ? "SELECT * FROM calendars WHERE enabled = 1 ORDER BY id" : "SELECT * FROM calendars ORDER BY id"
|
|
224
|
+
@db.execute(query)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def save_calendar(cal_data)
|
|
228
|
+
now = Time.now.to_i
|
|
229
|
+
config_val = json_field(cal_data[:source_config])
|
|
230
|
+
if cal_data[:id]
|
|
231
|
+
@db.execute(
|
|
232
|
+
"UPDATE calendars SET name=?, source_type=?, source_config=?, color=?, enabled=?, sync_token=?, last_synced_at=? WHERE id=?",
|
|
233
|
+
[cal_data[:name], cal_data[:source_type], config_val,
|
|
234
|
+
cal_data[:color], cal_data[:enabled] ? 1 : 0,
|
|
235
|
+
cal_data[:sync_token], cal_data[:last_synced_at], cal_data[:id]]
|
|
236
|
+
)
|
|
237
|
+
else
|
|
238
|
+
@db.execute(
|
|
239
|
+
"INSERT INTO calendars (name, source_type, source_config, color, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
240
|
+
[cal_data[:name], cal_data[:source_type] || 'local',
|
|
241
|
+
config_val, cal_data[:color] || 39,
|
|
242
|
+
cal_data[:enabled] ? 1 : 0, now]
|
|
243
|
+
)
|
|
244
|
+
@db.last_insert_row_id
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Settings operations
|
|
249
|
+
|
|
250
|
+
def get_setting(key, default = nil)
|
|
251
|
+
result = @db.get_first_value("SELECT value FROM settings WHERE key = ?", [key])
|
|
252
|
+
return default unless result
|
|
253
|
+
begin
|
|
254
|
+
JSON.parse(result)
|
|
255
|
+
rescue JSON::ParserError
|
|
256
|
+
result
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def set_setting(key, value)
|
|
261
|
+
now = Time.now.to_i
|
|
262
|
+
value_str = value.is_a?(String) ? value : value.to_json
|
|
263
|
+
@db.execute(
|
|
264
|
+
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
|
|
265
|
+
[key, value_str, now]
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Lookup helpers
|
|
270
|
+
|
|
271
|
+
def event_exists?(calendar_id, external_id)
|
|
272
|
+
return false unless external_id
|
|
273
|
+
count = @db.get_first_value(
|
|
274
|
+
"SELECT COUNT(*) FROM events WHERE calendar_id = ? AND external_id = ?",
|
|
275
|
+
[calendar_id, external_id]
|
|
276
|
+
)
|
|
277
|
+
count.to_i > 0
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Check if a matching event exists on ANY calendar (cross-source dedup)
|
|
281
|
+
# Matches on title + start_time (within 60s tolerance)
|
|
282
|
+
def event_duplicate?(title, start_time)
|
|
283
|
+
return false unless title && start_time
|
|
284
|
+
count = @db.get_first_value(
|
|
285
|
+
"SELECT COUNT(*) FROM events WHERE title = ? AND start_time BETWEEN ? AND ?",
|
|
286
|
+
[title, start_time.to_i - 60, start_time.to_i + 60]
|
|
287
|
+
)
|
|
288
|
+
count.to_i > 0
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def find_event_by_external_id(calendar_id, external_id)
|
|
292
|
+
@db.execute(
|
|
293
|
+
"SELECT * FROM events WHERE calendar_id = ? AND external_id = ? LIMIT 1",
|
|
294
|
+
[calendar_id, external_id]
|
|
295
|
+
).first
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def delete_event_by_external_id(calendar_id, external_id)
|
|
299
|
+
@db.execute(
|
|
300
|
+
"DELETE FROM events WHERE calendar_id = ? AND external_id = ?",
|
|
301
|
+
[calendar_id, external_id]
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Sync helpers
|
|
306
|
+
|
|
307
|
+
def upsert_synced_event(calendar_id, evt)
|
|
308
|
+
existing = find_event_by_external_id(calendar_id, evt[:external_id])
|
|
309
|
+
if existing
|
|
310
|
+
save_event(id: existing['id'], calendar_id: calendar_id, **evt)
|
|
311
|
+
return :updated
|
|
312
|
+
elsif event_duplicate?(evt[:title], evt[:start_time])
|
|
313
|
+
return :skipped
|
|
314
|
+
else
|
|
315
|
+
save_event(calendar_id: calendar_id, **evt)
|
|
316
|
+
return :new
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Calendar update helpers
|
|
321
|
+
|
|
322
|
+
def update_calendar_color(id, color)
|
|
323
|
+
@db.execute("UPDATE calendars SET color = ? WHERE id = ?", [color, id])
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def toggle_calendar_enabled(id)
|
|
327
|
+
@db.execute("UPDATE calendars SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END WHERE id = ?", [id])
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def delete_calendar_with_events(id)
|
|
331
|
+
@db.execute("DELETE FROM events WHERE calendar_id = ?", [id])
|
|
332
|
+
@db.execute("DELETE FROM calendars WHERE id = ?", [id])
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def update_calendar_sync(id, last_synced_at, source_config = nil)
|
|
336
|
+
if source_config
|
|
337
|
+
@db.execute("UPDATE calendars SET source_config = ?, last_synced_at = ? WHERE id = ?",
|
|
338
|
+
[source_config.is_a?(String) ? source_config : JSON.generate(source_config), last_synced_at, id])
|
|
339
|
+
else
|
|
340
|
+
@db.execute("UPDATE calendars SET last_synced_at = ? WHERE id = ?", [last_synced_at, id])
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# General operations
|
|
345
|
+
|
|
346
|
+
def execute(query, params = [])
|
|
347
|
+
@db.execute(query, params)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def transaction(&block)
|
|
351
|
+
@db.transaction(&block)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def close
|
|
355
|
+
@db.close if @db
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
private
|
|
359
|
+
|
|
360
|
+
# Convert a value to JSON string for storage.
|
|
361
|
+
# Handles values that are already JSON strings, arrays, or hashes.
|
|
362
|
+
def json_field(value)
|
|
363
|
+
return nil if value.nil?
|
|
364
|
+
return value if value.is_a?(String)
|
|
365
|
+
value.to_json
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def normalize_event_row(row)
|
|
369
|
+
r = row.dup
|
|
370
|
+
%w[attendees alarms metadata].each do |field|
|
|
371
|
+
r[field] = JSON.parse(r[field]) if r.key?(field) && r[field].is_a?(String)
|
|
372
|
+
end
|
|
373
|
+
r
|
|
374
|
+
rescue JSON::ParserError
|
|
375
|
+
row.dup
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
data/lib/timely/event.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Timely
|
|
2
|
+
class Event
|
|
3
|
+
ATTRS = %i[
|
|
4
|
+
id calendar_id external_id title description location
|
|
5
|
+
start_time end_time all_day timezone recurrence_rule
|
|
6
|
+
status organizer attendees my_status alarms metadata
|
|
7
|
+
calendar_name calendar_color
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor *ATTRS
|
|
11
|
+
|
|
12
|
+
def initialize(attrs = {})
|
|
13
|
+
attrs.each do |key, value|
|
|
14
|
+
sym = key.to_sym
|
|
15
|
+
send(:"#{sym}=", value) if respond_to?(:"#{sym}=")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
ATTRS.each_with_object({}) do |attr, hash|
|
|
21
|
+
hash[attr] = send(attr)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_h(hash)
|
|
26
|
+
new(hash)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.from_row(row)
|
|
30
|
+
attrs = {}
|
|
31
|
+
row.each do |key, value|
|
|
32
|
+
attrs[key.to_sym] = value
|
|
33
|
+
end
|
|
34
|
+
new(attrs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def duration_minutes
|
|
38
|
+
return 0 unless start_time && end_time
|
|
39
|
+
((end_time.to_i - start_time.to_i) / 60.0).round
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start_date
|
|
43
|
+
return nil unless start_time
|
|
44
|
+
Time.at(start_time.to_i).to_date
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def end_date
|
|
48
|
+
return nil unless end_time
|
|
49
|
+
Time.at(end_time.to_i).to_date
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def time_range_str
|
|
53
|
+
return "All day" if all_day && all_day != 0
|
|
54
|
+
return "" unless start_time
|
|
55
|
+
|
|
56
|
+
st = Time.at(start_time.to_i)
|
|
57
|
+
result = st.strftime("%H:%M")
|
|
58
|
+
|
|
59
|
+
if end_time
|
|
60
|
+
et = Time.at(end_time.to_i)
|
|
61
|
+
result += " - #{et.strftime('%H:%M')}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def date_str
|
|
68
|
+
return "" unless start_time
|
|
69
|
+
Time.at(start_time.to_i).strftime("%Y-%m-%d")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def title_str
|
|
73
|
+
title || "(No title)"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Timely
|
|
2
|
+
module Notifications
|
|
3
|
+
# Check for upcoming events and send desktop notifications.
|
|
4
|
+
# alarm_minutes: how many minutes before event to notify.
|
|
5
|
+
def self.check_and_notify(db, default_alarm: 15)
|
|
6
|
+
now = Time.now.to_i
|
|
7
|
+
# Check events starting within the next hour
|
|
8
|
+
upcoming = db.get_events_in_range(now, now + 3600)
|
|
9
|
+
|
|
10
|
+
# Ensure notification_log table exists
|
|
11
|
+
ensure_notification_table(db)
|
|
12
|
+
|
|
13
|
+
upcoming.each do |evt|
|
|
14
|
+
next if evt['all_day'].to_i == 1
|
|
15
|
+
|
|
16
|
+
start_ts = evt['start_time'].to_i
|
|
17
|
+
minutes_until = (start_ts - now) / 60
|
|
18
|
+
|
|
19
|
+
# Get alarm offsets (default to configured alarm if not set)
|
|
20
|
+
alarms = evt['alarms']
|
|
21
|
+
alarms = JSON.parse(alarms) if alarms.is_a?(String)
|
|
22
|
+
alarm_offsets = alarms.is_a?(Array) ? alarms : [default_alarm]
|
|
23
|
+
|
|
24
|
+
alarm_offsets.each do |offset|
|
|
25
|
+
offset = offset.to_i
|
|
26
|
+
# Trigger if we are within 1 minute of the alarm time
|
|
27
|
+
next unless minutes_until >= offset - 1 && minutes_until <= offset + 1
|
|
28
|
+
|
|
29
|
+
# Check if we already notified for this alarm
|
|
30
|
+
notified = db.db.get_first_value(
|
|
31
|
+
"SELECT COUNT(*) FROM notification_log WHERE event_id = ? AND alarm_offset = ?",
|
|
32
|
+
[evt['id'], offset]
|
|
33
|
+
).to_i > 0
|
|
34
|
+
|
|
35
|
+
unless notified
|
|
36
|
+
send_notification(evt, minutes_until)
|
|
37
|
+
db.db.execute(
|
|
38
|
+
"INSERT OR REPLACE INTO notification_log (event_id, alarm_offset, notified_at) VALUES (?, ?, ?)",
|
|
39
|
+
[evt['id'], offset, now]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
rescue => e
|
|
45
|
+
# Never crash on notification errors
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.send_notification(evt, minutes_until)
|
|
50
|
+
title = evt['title'] || '(No title)'
|
|
51
|
+
time_str = Time.at(evt['start_time'].to_i).strftime('%H:%M')
|
|
52
|
+
body = if minutes_until <= 1
|
|
53
|
+
"Starting now (#{time_str})"
|
|
54
|
+
else
|
|
55
|
+
"In #{minutes_until.to_i} minutes (#{time_str})"
|
|
56
|
+
end
|
|
57
|
+
loc = evt['location']
|
|
58
|
+
body += "\n#{loc}" if loc && !loc.to_s.empty?
|
|
59
|
+
|
|
60
|
+
system("notify-send", "-a", "Timely", "-u", "normal", "-i", "calendar", title, body)
|
|
61
|
+
rescue => e
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Create the notification_log table if it does not exist.
|
|
66
|
+
def self.ensure_notification_table(db)
|
|
67
|
+
return if @notification_table_created
|
|
68
|
+
db.db.execute <<-SQL
|
|
69
|
+
CREATE TABLE IF NOT EXISTS notification_log (
|
|
70
|
+
event_id INTEGER NOT NULL,
|
|
71
|
+
alarm_offset INTEGER NOT NULL,
|
|
72
|
+
notified_at INTEGER NOT NULL,
|
|
73
|
+
PRIMARY KEY(event_id, alarm_offset)
|
|
74
|
+
)
|
|
75
|
+
SQL
|
|
76
|
+
# Clean old entries (older than 24 hours)
|
|
77
|
+
db.db.execute("DELETE FROM notification_log WHERE notified_at < ?", [Time.now.to_i - 86400])
|
|
78
|
+
@notification_table_created = true
|
|
79
|
+
rescue => e
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|