heathrow 0.7.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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +58 -0
  3. data/README.md +205 -0
  4. data/bin/heathrow +42 -0
  5. data/bin/heathrowd +283 -0
  6. data/docs/ARCHITECTURE.md +1172 -0
  7. data/docs/DATABASE_SCHEMA.md +685 -0
  8. data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
  9. data/docs/DISCORD_SETUP.md +142 -0
  10. data/docs/GMAIL_OAUTH_SETUP.md +120 -0
  11. data/docs/PLUGIN_SYSTEM.md +1370 -0
  12. data/docs/PROJECT_PLAN.md +1022 -0
  13. data/docs/README.md +417 -0
  14. data/docs/REDDIT_SETUP.md +174 -0
  15. data/docs/REPLY_FORWARD.md +182 -0
  16. data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
  17. data/heathrow.gemspec +34 -0
  18. data/heathrowd.service +21 -0
  19. data/img/heathrow.svg +95 -0
  20. data/img/rss_threaded.png +0 -0
  21. data/img/sources.png +0 -0
  22. data/lib/heathrow/address_book.rb +42 -0
  23. data/lib/heathrow/config.rb +332 -0
  24. data/lib/heathrow/database.rb +731 -0
  25. data/lib/heathrow/database_new.rb +392 -0
  26. data/lib/heathrow/event_bus.rb +175 -0
  27. data/lib/heathrow/logger.rb +122 -0
  28. data/lib/heathrow/message.rb +176 -0
  29. data/lib/heathrow/message_composer.rb +399 -0
  30. data/lib/heathrow/message_organizer.rb +774 -0
  31. data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
  32. data/lib/heathrow/notmuch.rb +45 -0
  33. data/lib/heathrow/oauth2_smtp.rb +254 -0
  34. data/lib/heathrow/plugin/base.rb +212 -0
  35. data/lib/heathrow/plugin_manager.rb +141 -0
  36. data/lib/heathrow/poller.rb +93 -0
  37. data/lib/heathrow/smtp_sender.rb +204 -0
  38. data/lib/heathrow/source.rb +39 -0
  39. data/lib/heathrow/sources/base.rb +74 -0
  40. data/lib/heathrow/sources/discord.rb +357 -0
  41. data/lib/heathrow/sources/gmail.rb +294 -0
  42. data/lib/heathrow/sources/imap.rb +198 -0
  43. data/lib/heathrow/sources/instagram.rb +307 -0
  44. data/lib/heathrow/sources/instagram_fetch.py +101 -0
  45. data/lib/heathrow/sources/instagram_send.py +55 -0
  46. data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
  47. data/lib/heathrow/sources/maildir.rb +606 -0
  48. data/lib/heathrow/sources/messenger.rb +212 -0
  49. data/lib/heathrow/sources/messenger_fetch.js +297 -0
  50. data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
  51. data/lib/heathrow/sources/messenger_send.js +32 -0
  52. data/lib/heathrow/sources/messenger_send.py +100 -0
  53. data/lib/heathrow/sources/reddit.rb +461 -0
  54. data/lib/heathrow/sources/rss.rb +299 -0
  55. data/lib/heathrow/sources/slack.rb +375 -0
  56. data/lib/heathrow/sources/source_manager.rb +328 -0
  57. data/lib/heathrow/sources/telegram.rb +498 -0
  58. data/lib/heathrow/sources/webpage.rb +207 -0
  59. data/lib/heathrow/sources/weechat.rb +479 -0
  60. data/lib/heathrow/sources/whatsapp.rb +474 -0
  61. data/lib/heathrow/ui/application.rb +8098 -0
  62. data/lib/heathrow/ui/navigation.rb +8 -0
  63. data/lib/heathrow/ui/panes.rb +8 -0
  64. data/lib/heathrow/ui/source_wizard.rb +567 -0
  65. data/lib/heathrow/ui/threaded_view.rb +780 -0
  66. data/lib/heathrow/ui/views.rb +8 -0
  67. data/lib/heathrow/version.rb +3 -0
  68. data/lib/heathrow/wizards/discord_wizard.rb +193 -0
  69. data/lib/heathrow/wizards/slack_wizard.rb +140 -0
  70. data/lib/heathrow.rb +55 -0
  71. metadata +147 -0
@@ -0,0 +1,8 @@
1
+ # Navigation helpers for Heathrow
2
+ module Heathrow
3
+ module UI
4
+ module Navigation
5
+ # Navigation helper methods
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # Pane management extensions for Heathrow
2
+ module Heathrow
3
+ module UI
4
+ module Panes
5
+ # Additional pane helpers if needed
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ module Heathrow
5
+ class SourceWizard
6
+ include Rcurses
7
+ include Rcurses::Input
8
+ include Rcurses::Cursor
9
+
10
+ attr_reader :source_manager, :panes
11
+
12
+ def initialize(source_manager, bottom_pane, right_pane)
13
+ @source_manager = source_manager
14
+ @panes = {
15
+ bottom: bottom_pane,
16
+ right: right_pane
17
+ }
18
+ end
19
+
20
+ def run_wizard
21
+ # Show source type selection with arrow navigation
22
+ source_type = select_source_type
23
+ return nil unless source_type
24
+
25
+ # Get source configuration
26
+ source_info = @source_manager.get_source_types[source_type]
27
+ return nil unless source_info
28
+
29
+ # Run type-specific wizard
30
+ config = case source_type
31
+ when 'web'
32
+ configure_webwatch
33
+ when 'rss'
34
+ configure_rss
35
+ when 'messenger'
36
+ configure_messenger
37
+ when 'instagram'
38
+ configure_instagram
39
+ when 'weechat'
40
+ configure_weechat
41
+ else
42
+ configure_source(source_type, source_info)
43
+ end
44
+ return nil unless config
45
+
46
+ # Add the source
47
+ source_id = create_source(source_type, config)
48
+
49
+ @panes[:bottom].clear
50
+ @panes[:bottom].text = " Source '#{config[:name] || config['name']}' added!".fg(156)
51
+ @panes[:bottom].refresh
52
+ sleep(1)
53
+
54
+ source_id
55
+ end
56
+
57
+ def select_source_type
58
+ source_types = @source_manager.get_source_types
59
+ keys = source_types.keys
60
+ selected = 0
61
+
62
+ loop do
63
+ # Render list with arrow marker
64
+ lines = []
65
+ lines << "ADD SOURCE".b.fg(226)
66
+ lines << ""
67
+
68
+ keys.each_with_index do |key, i|
69
+ info = source_types[key]
70
+ marker = i == selected ? "→ " : " "
71
+ name_line = "#{marker}#{info[:icon]} #{info[:name]}"
72
+ desc_line = " #{info[:description]}"
73
+
74
+ if i == selected
75
+ lines << name_line.b.fg(39)
76
+ lines << desc_line.fg(245)
77
+ else
78
+ lines << name_line.fg(250)
79
+ lines << desc_line.fg(240)
80
+ end
81
+ lines << ""
82
+ end
83
+
84
+ lines << "j/k or arrows to navigate, Enter to select, ESC to cancel".fg(245)
85
+
86
+ @panes[:right].clear
87
+ @panes[:right].text = lines.join("\n")
88
+ @panes[:right].refresh
89
+
90
+ @panes[:bottom].clear
91
+ @panes[:bottom].text = " Select source type...".fg(245)
92
+ @panes[:bottom].refresh
93
+
94
+ chr = getchr
95
+ case chr
96
+ when 'j', 'DOWN'
97
+ selected = (selected + 1) % keys.size
98
+ when 'k', 'UP'
99
+ selected = (selected - 1) % keys.size
100
+ when 'ENTER', "\r", "\n"
101
+ return keys[selected]
102
+ when 'ESC', "\e", 'q'
103
+ return nil
104
+ end
105
+ end
106
+ end
107
+
108
+ # Web Watch: simple prompts for URL, title, selector
109
+ def configure_webwatch
110
+ pages = []
111
+
112
+ @panes[:right].clear
113
+ @panes[:right].text = [
114
+ "WEB PAGE MONITOR SETUP".b.fg(226),
115
+ "",
116
+ "Add pages to monitor for changes.",
117
+ "Each page is checked on refresh (C-R).",
118
+ "",
119
+ "Optional CSS selector limits monitoring",
120
+ "to a specific part of the page:",
121
+ " #content - element with id=\"content\"",
122
+ " .main - elements with class=\"main\"",
123
+ " article - all <article> elements",
124
+ "",
125
+ "Enter pages one at a time.",
126
+ "Leave URL empty when done.",
127
+ ].join("\n")
128
+ @panes[:right].refresh
129
+
130
+ loop do
131
+ url = @panes[:bottom].ask("URL (Enter when done, ESC cancel): ", "")
132
+ return nil if url.nil?
133
+ break if url.empty?
134
+
135
+ title = @panes[:bottom].ask("Title (optional): ", "")
136
+ return nil if title.nil?
137
+
138
+ selector = @panes[:bottom].ask("CSS selector (optional): ", "")
139
+ return nil if selector.nil?
140
+
141
+ tags_input = @panes[:bottom].ask("Tags (comma-separated, optional): ", "")
142
+ return nil if tags_input.nil?
143
+ tags = tags_input.empty? ? [] : tags_input.split(',').map(&:strip)
144
+
145
+ page = { 'url' => url }
146
+ page['title'] = title unless title.empty?
147
+ page['selector'] = selector unless selector.empty?
148
+ page['tags'] = tags unless tags.empty?
149
+ pages << page
150
+
151
+ @panes[:bottom].clear
152
+ @panes[:bottom].text = " Added: #{title.empty? ? url : title} (#{pages.size} total)".fg(156)
153
+ @panes[:bottom].refresh
154
+ sleep(0.5)
155
+ end
156
+
157
+ return nil if pages.empty?
158
+
159
+ { name: 'Web Watch', pages: pages, enabled: true }
160
+ end
161
+
162
+ # RSS: prompts for feed URLs with optional title/tags
163
+ def configure_rss
164
+ feeds = []
165
+
166
+ @panes[:right].clear
167
+ @panes[:right].text = [
168
+ "RSS/ATOM FEED SETUP".b.fg(226),
169
+ "",
170
+ "Add feeds one at a time.",
171
+ "Leave URL empty when done.",
172
+ "",
173
+ "Feeds from newsboat can be imported",
174
+ "with: ruby setup_rss.rb",
175
+ ].join("\n")
176
+ @panes[:right].refresh
177
+
178
+ loop do
179
+ url = @panes[:bottom].ask("Feed URL (Enter when done, ESC cancel): ", "")
180
+ return nil if url.nil?
181
+ break if url.empty?
182
+
183
+ title = @panes[:bottom].ask("Title (optional): ", "")
184
+ return nil if title.nil?
185
+
186
+ tags_input = @panes[:bottom].ask("Tags (comma-separated, optional): ", "")
187
+ return nil if tags_input.nil?
188
+ tags = tags_input.empty? ? [] : tags_input.split(',').map(&:strip)
189
+
190
+ feed = { 'url' => url }
191
+ feed['title'] = title unless title.empty?
192
+ feed['tags'] = tags unless tags.empty?
193
+ feeds << feed
194
+
195
+ @panes[:bottom].clear
196
+ @panes[:bottom].text = " Added: #{title.empty? ? url : title} (#{feeds.size} total)".fg(156)
197
+ @panes[:bottom].refresh
198
+ sleep(0.5)
199
+ end
200
+
201
+ return nil if feeds.empty?
202
+
203
+ { name: 'RSS Feeds', feeds: feeds, enabled: true }
204
+ end
205
+
206
+ # Messenger: extract cookies from Firefox or manual entry
207
+ def configure_messenger
208
+ configure_meta_source(
209
+ 'FACEBOOK MESSENGER',
210
+ 'messenger',
211
+ 'messenger.com',
212
+ %w[c_user xs],
213
+ %w[datr fr],
214
+ [
215
+ "This connects to Messenger using your browser session.",
216
+ "",
217
+ "HOW IT WORKS:".b.fg(39),
218
+ " Heathrow reads your Firefox cookies to authenticate",
219
+ " with messenger.com — no password needed, no extra app.",
220
+ "",
221
+ "IMPORTANT:".b.fg(196),
222
+ " - Cookies expire when you log out of Facebook",
223
+ " - Sessions typically last 30-90 days",
224
+ " - If messages stop appearing, refresh cookies",
225
+ " - To refresh: press S → select Messenger → e → r",
226
+ "",
227
+ "PRIVACY:".b.fg(226),
228
+ " Cookies are stored locally in ~/.heathrow/cookies/",
229
+ " with restricted permissions (owner-only read/write).",
230
+ " They never leave your machine.",
231
+ ]
232
+ )
233
+ end
234
+
235
+ # Instagram: extract cookies from Firefox or manual entry
236
+ def configure_instagram
237
+ configure_meta_source(
238
+ 'INSTAGRAM DMs',
239
+ 'instagram',
240
+ 'instagram.com',
241
+ %w[sessionid csrftoken],
242
+ %w[ds_user_id ig_did mid],
243
+ [
244
+ "This connects to Instagram DMs using your browser session.",
245
+ "",
246
+ "HOW IT WORKS:".b.fg(39),
247
+ " Heathrow reads your Firefox cookies to authenticate",
248
+ " with instagram.com — no password needed, no extra app.",
249
+ "",
250
+ "IMPORTANT:".b.fg(196),
251
+ " - Cookies expire when you log out of Instagram",
252
+ " - Sessions typically last 30-90 days",
253
+ " - If messages stop appearing, refresh cookies",
254
+ " - Two-factor auth stays active — this is read-only",
255
+ "",
256
+ "PRIVACY:".b.fg(226),
257
+ " Cookies are stored locally in ~/.heathrow/cookies/",
258
+ " with restricted permissions (owner-only read/write).",
259
+ ]
260
+ )
261
+ end
262
+
263
+ # WeeChat Relay setup with connection test
264
+ def configure_weechat
265
+ @panes[:right].clear
266
+ @panes[:right].text = [
267
+ "WEECHAT RELAY SETUP".b.fg(226),
268
+ "",
269
+ "Connect to a running WeeChat instance via",
270
+ "its relay protocol to read IRC, Slack, and",
271
+ "other chat buffers.",
272
+ "",
273
+ "PREREQUISITES:".b.fg(39),
274
+ " WeeChat must have a relay enabled:",
275
+ " /relay add weechat 8001",
276
+ " /set relay.network.password \"yourpassword\"",
277
+ "",
278
+ "For remote servers, use an SSH tunnel:",
279
+ " ssh -L 8001:localhost:8001 user@host",
280
+ "",
281
+ "BUFFER FILTER:".b.fg(39),
282
+ " Comma-separated glob patterns to select",
283
+ " which buffers to import:",
284
+ " irc.* - all IRC",
285
+ " python.slack.* - all Slack",
286
+ " irc.oftc.* - just OFTC network",
287
+ " (empty = import all message buffers)",
288
+ ].join("\n")
289
+ @panes[:right].refresh
290
+
291
+ host = @panes[:bottom].ask("Relay host [localhost]: ", "localhost")
292
+ return nil if host.nil?
293
+ host = 'localhost' if host.empty?
294
+
295
+ port = @panes[:bottom].ask("Relay port [8001]: ", "8001")
296
+ return nil if port.nil?
297
+ port = port.empty? ? 8001 : port.to_i
298
+
299
+ password = @panes[:bottom].ask("Relay password: ", "")
300
+ return nil if password.nil?
301
+
302
+ buffer_filter = @panes[:bottom].ask("Buffer filter (empty = all): ", "")
303
+ return nil if buffer_filter.nil?
304
+
305
+ # Test connection
306
+ @panes[:bottom].clear
307
+ @panes[:bottom].text = " Testing connection to #{host}:#{port}...".fg(226)
308
+ @panes[:bottom].refresh
309
+
310
+ require_relative '../sources/weechat'
311
+ test_config = { 'host' => host, 'port' => port, 'password' => password }
312
+ test_instance = Heathrow::Sources::Weechat.new('WeeChat', test_config, @source_manager.db)
313
+ result = test_instance.test_connection
314
+
315
+ if result[:success]
316
+ @panes[:bottom].clear
317
+ @panes[:bottom].text = " #{result[:message]}".fg(156)
318
+ @panes[:bottom].refresh
319
+ sleep(1)
320
+
321
+ config = {
322
+ name: 'WeeChat',
323
+ host: host,
324
+ port: port,
325
+ password: password,
326
+ enabled: true
327
+ }
328
+ config[:buffer_filter] = buffer_filter unless buffer_filter.empty?
329
+ config
330
+ else
331
+ @panes[:bottom].clear
332
+ @panes[:bottom].text = " Connection failed: #{result[:message]}".fg(196)
333
+ @panes[:bottom].refresh
334
+ sleep(2)
335
+
336
+ choice = @panes[:bottom].ask("Retry? (Enter = retry, ESC = cancel): ", "")
337
+ return nil if choice.nil?
338
+ configure_weechat # Retry
339
+ end
340
+ end
341
+
342
+ # Shared Meta cookie setup for Messenger/Instagram
343
+ def configure_meta_source(title, source_type, domain, required_cookies, optional_cookies, help_lines)
344
+ @panes[:right].clear
345
+ @panes[:right].text = ([
346
+ "#{title} SETUP".b.fg(226),
347
+ "",
348
+ ] + help_lines).join("\n")
349
+ @panes[:right].refresh
350
+
351
+ # Try auto-extraction from Firefox
352
+ @panes[:bottom].clear
353
+ @panes[:bottom].text = " Checking Firefox for #{domain} cookies...".fg(226)
354
+ @panes[:bottom].refresh
355
+
356
+ require_relative '../sources/messenger'
357
+ cookies = if source_type == 'messenger'
358
+ Heathrow::Sources::Messenger.extract_firefox_cookies
359
+ else
360
+ Heathrow::Sources::Messenger.extract_instagram_cookies_from_firefox
361
+ end
362
+
363
+ loop do
364
+ if cookies && required_cookies.all? { |k| cookies[k] && !cookies[k].empty? }
365
+ @panes[:bottom].clear
366
+ @panes[:bottom].text = " Found #{domain} cookies in Firefox!".fg(156)
367
+ @panes[:bottom].refresh
368
+ sleep(1)
369
+ save_meta_cookies(source_type, cookies)
370
+ return { name: title.split(' ').first.capitalize, cookies: cookies, enabled: true }
371
+ end
372
+
373
+ # Not found — ask user to log in and retry
374
+ @panes[:right].clear
375
+ @panes[:right].text = ([
376
+ "#{title} SETUP".b.fg(226),
377
+ "",
378
+ ] + help_lines + [
379
+ "",
380
+ "NEXT STEP:".b.fg(39),
381
+ " 1. Open #{domain} in Firefox and log in",
382
+ " 2. Come back here and press Enter to retry",
383
+ "",
384
+ "Heathrow will automatically read your",
385
+ "Firefox cookies — no copying needed.",
386
+ ]).join("\n")
387
+ @panes[:right].refresh
388
+
389
+ choice = @panes[:bottom].ask("Press Enter after logging in (ESC to cancel): ", "")
390
+ return nil if choice.nil?
391
+
392
+ @panes[:bottom].clear
393
+ @panes[:bottom].text = " Checking Firefox for #{domain} cookies...".fg(226)
394
+ @panes[:bottom].refresh
395
+
396
+ cookies = if source_type == 'messenger'
397
+ Heathrow::Sources::Messenger.extract_firefox_cookies
398
+ else
399
+ Heathrow::Sources::Messenger.extract_instagram_cookies_from_firefox
400
+ end
401
+ end
402
+ end
403
+
404
+ def save_meta_cookies(source_type, cookies)
405
+ cookie_dir = File.join(Dir.home, '.heathrow', 'cookies')
406
+ Dir.mkdir(cookie_dir) unless Dir.exist?(cookie_dir)
407
+ file = File.join(cookie_dir, "#{source_type}.json")
408
+ File.write(file, cookies.to_json)
409
+ File.chmod(0600, file)
410
+ end
411
+
412
+ # Generic source configuration via field definitions
413
+ def configure_source(source_type, source_info)
414
+ config = {}
415
+
416
+ form_text = []
417
+ form_text << "CONFIGURE #{source_info[:name].upcase}".b.fg(226)
418
+ form_text << ""
419
+ form_text << source_info[:description]
420
+ form_text << ""
421
+
422
+ source_info[:fields].each do |field|
423
+ form_text << "#{field[:label]}:".b.fg(39)
424
+ form_text << " #{field[:help]}".fg(240) if field[:help]
425
+
426
+ @panes[:right].clear
427
+ @panes[:right].text = form_text.join("\n")
428
+ @panes[:right].refresh
429
+
430
+ value = get_field_value(field)
431
+ return nil if value.nil?
432
+
433
+ # Store value
434
+ unless value.empty?
435
+ case field[:type]
436
+ when 'number'
437
+ config[field[:key].to_sym] = value.to_i
438
+ when 'boolean'
439
+ config[field[:key].to_sym] = %w[y yes true 1].include?(value.downcase)
440
+ else
441
+ config[field[:key].to_sym] = value
442
+ end
443
+ end
444
+
445
+ # Use default if empty
446
+ if config[field[:key].to_sym].nil? && field[:default]
447
+ config[field[:key].to_sym] = field[:default]
448
+ end
449
+
450
+ display_val = field[:type] == 'password' ? '****' : (value.empty? ? "(default: #{field[:default]})" : value)
451
+ form_text << " → #{display_val}".fg(156)
452
+ form_text << ""
453
+ end
454
+
455
+ # Validate required fields
456
+ missing = source_info[:fields].select { |f| f[:required] && config[f[:key].to_sym].nil? }
457
+ unless missing.empty?
458
+ @panes[:bottom].clear
459
+ @panes[:bottom].text = " Missing required: #{missing.map { |f| f[:label] }.join(', ')}".fg(196)
460
+ @panes[:bottom].refresh
461
+ sleep(2)
462
+ return nil
463
+ end
464
+
465
+ config
466
+ end
467
+
468
+ private
469
+
470
+ def create_source(source_type, config)
471
+ db = @source_manager.db
472
+
473
+ # Check if source of this type already exists (for web/rss, merge pages/feeds)
474
+ existing = db.get_sources.find { |s| s['plugin_type'] == source_type }
475
+
476
+ if existing && (source_type == 'web' || source_type == 'rss')
477
+ # Merge into existing source
478
+ old_config = existing['config']
479
+ old_config = JSON.parse(old_config) if old_config.is_a?(String)
480
+
481
+ case source_type
482
+ when 'web'
483
+ old_pages = old_config['pages'] || []
484
+ new_pages = config[:pages] || config['pages'] || []
485
+ new_pages.each do |p|
486
+ old_pages << p unless old_pages.any? { |op| op['url'] == p['url'] }
487
+ end
488
+ old_config['pages'] = old_pages
489
+ when 'rss'
490
+ old_feeds = old_config['feeds'] || []
491
+ new_feeds = config[:feeds] || config['feeds'] || []
492
+ new_feeds.each do |f|
493
+ url = f.is_a?(Hash) ? f['url'] : f
494
+ old_feeds << f unless old_feeds.any? { |of| (of.is_a?(Hash) ? of['url'] : of) == url }
495
+ end
496
+ old_config['feeds'] = old_feeds
497
+ end
498
+
499
+ db.execute("UPDATE sources SET config = ? WHERE id = ?", [old_config.to_json, existing['id']])
500
+ # Take initial snapshot / sync
501
+ initial_sync(source_type, old_config, db, existing['id'])
502
+ return existing['id']
503
+ end
504
+
505
+ # Create new source
506
+ now = Time.now.to_i
507
+ config_hash = config.is_a?(Hash) ? config : {}
508
+ config_json = config_hash.transform_keys(&:to_s).to_json
509
+
510
+ name = config_hash[:name] || config_hash['name'] || source_type.capitalize
511
+ db.execute(
512
+ "INSERT INTO sources (plugin_type, name, config, enabled, capabilities, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
513
+ [source_type, name, config_json, 1, '["read"]', now, now]
514
+ )
515
+
516
+ source_id = db.get_sources.find { |s| s['plugin_type'] == source_type }['id']
517
+ initial_sync(source_type, config_hash.transform_keys(&:to_s), db, source_id)
518
+ source_id
519
+ end
520
+
521
+ def initial_sync(source_type, config, db, source_id)
522
+ @panes[:bottom].clear
523
+ @panes[:bottom].text = " Syncing #{source_type}...".fg(226)
524
+ @panes[:bottom].refresh
525
+
526
+ case source_type
527
+ when 'web'
528
+ require_relative '../sources/webpage'
529
+ instance = Heathrow::Sources::Webpage.new('Web Watch', config, db)
530
+ count = instance.sync(source_id)
531
+ @panes[:bottom].text = " #{count} changes detected (first run stores baselines)".fg(156)
532
+ when 'rss'
533
+ require_relative '../sources/rss'
534
+ instance = Heathrow::Sources::RSS.new('RSS Feeds', config, db)
535
+ count = instance.sync(source_id)
536
+ @panes[:bottom].text = " Imported #{count} articles".fg(156)
537
+ when 'weechat'
538
+ require_relative '../sources/weechat'
539
+ instance = Heathrow::Sources::Weechat.new('WeeChat', config, db)
540
+ count = instance.sync(source_id)
541
+ @panes[:bottom].text = " Imported #{count} messages from WeeChat".fg(156)
542
+ end
543
+ @panes[:bottom].refresh
544
+ sleep(1)
545
+ rescue => e
546
+ @panes[:bottom].text = " Sync error: #{e.message}".fg(196)
547
+ @panes[:bottom].refresh
548
+ sleep(2)
549
+ end
550
+
551
+ def get_field_value(field)
552
+ prompt = field[:label]
553
+ prompt += " (#{field[:placeholder]})" if field[:placeholder]
554
+ prompt += field[:required] ? " *: " : " (optional): "
555
+
556
+ case field[:type]
557
+ when 'boolean'
558
+ default = field[:default] ? "y" : "n"
559
+ @panes[:bottom].ask("#{field[:label]} (y/n): ", default)
560
+ when 'password'
561
+ @panes[:bottom].ask(prompt, "")
562
+ else
563
+ @panes[:bottom].ask(prompt, field[:default]&.to_s || "")
564
+ end
565
+ end
566
+ end
567
+ end