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.
@@ -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
@@ -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