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,332 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+
4
+ module Heathrow
5
+ class Config
6
+ attr_accessor :settings
7
+
8
+ HEATHROWRC = File.expand_path('~/.heathrow/heathrowrc')
9
+
10
+ def self.instance
11
+ @@instance rescue nil
12
+ end
13
+
14
+ def initialize(config_path = HEATHROW_CONFIG, db = nil)
15
+ @@instance = self
16
+ @config_path = config_path
17
+ @db = db
18
+ @settings = load_config
19
+ ensure_heathrow_directory
20
+
21
+ # DSL state — populated by loading heathrowrc
22
+ @identities = {}
23
+ @folder_hooks = []
24
+ @global_headers = {}
25
+ @rc_settings = {}
26
+
27
+ load_rc
28
+
29
+ # Register as singleton so class methods work
30
+ self.class.instance = self
31
+ end
32
+
33
+ def ensure_heathrow_directory
34
+ dir = File.dirname(@config_path)
35
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
36
+ end
37
+
38
+ # ── RC file DSL methods (called from heathrowrc) ──
39
+
40
+ def set(key, value)
41
+ key_s = key.to_s
42
+ if key_s.include?('.')
43
+ # Dot-notation path: save to YAML settings (e.g. 'ui.color_theme')
44
+ keys = key_s.split('.')
45
+ last_key = keys.pop
46
+ parent = @settings
47
+ keys.each { |k| parent[k] ||= {}; parent = parent[k] }
48
+ parent[last_key] = value
49
+ else
50
+ # Simple key: RC setting (e.g. :color_theme)
51
+ @rc_settings[key_s] = value
52
+ end
53
+ end
54
+
55
+ def identity(name, from:, signature: nil, smtp: nil, headers: nil)
56
+ @identities[name.to_s] = {
57
+ from: from,
58
+ signature: signature ? File.expand_path(signature) : nil,
59
+ smtp: smtp ? File.expand_path(smtp) : nil,
60
+ headers: headers || {}
61
+ }
62
+ end
63
+
64
+ def folder_hook(pattern, identity_name)
65
+ @folder_hooks << { pattern: pattern, identity: identity_name.to_s }
66
+ end
67
+
68
+ def header(name, value)
69
+ @global_headers[name.to_s] = value.to_s
70
+ end
71
+
72
+ # Define a custom color theme (or override a built-in)
73
+ # Usage in heathrowrc:
74
+ # theme 'MyTheme', unread: 226, read: 249, accent: 10, thread: 255,
75
+ # dm: 201, tag: 14, quote1: 114, quote2: 180,
76
+ # quote3: 139, quote4: 109, sig: 242
77
+ def theme(name, **colors)
78
+ @custom_themes ||= {}
79
+ @custom_themes[name.to_s] = colors
80
+ end
81
+
82
+ def custom_themes
83
+ @custom_themes || {}
84
+ end
85
+
86
+ # Define a custom view (replaces hardcoded views in database.rb)
87
+ # Usage: view '1', 'Personal', folder: 'Personal'
88
+ # view '4', 'Work', folder: 'Work.Archive'
89
+ # view '5', 'RSS', source_type: 'rss'
90
+ def view(key, name, **opts)
91
+ @custom_views ||= []
92
+ filters = if opts[:folder]
93
+ { 'rules' => [{ 'field' => 'folder', 'op' => 'like', 'value' => opts[:folder] }] }
94
+ elsif opts[:source_type]
95
+ { 'rules' => [{ 'field' => 'source_type', 'op' => '=', 'value' => opts[:source_type] }] }
96
+ elsif opts[:filters]
97
+ opts[:filters]
98
+ else
99
+ { 'rules' => [] }
100
+ end
101
+ @custom_views << { key: key.to_s, name: name, filters: filters,
102
+ sort_order: opts[:sort_order] }
103
+ end
104
+
105
+ def custom_views
106
+ @custom_views || []
107
+ end
108
+
109
+ # Map Discord channel IDs to display names
110
+ # Usage: channel_names '123456789' => 'MyServer#general',
111
+ # '987654321' => 'MyServer#dev'
112
+ def channel_names(mapping)
113
+ @channel_name_map ||= {}
114
+ @channel_name_map.merge!(mapping.transform_keys(&:to_s))
115
+ end
116
+
117
+ def channel_name_map
118
+ @channel_name_map || {}
119
+ end
120
+
121
+ # Bind a key to a custom action
122
+ # Usage:
123
+ # bind 'C-S', shell: 'mailq' # Run shell command, show output
124
+ # bind 'C-N', shell: 'notmuch search %q' # %q = prompted query
125
+ # bind 'C-O', action: :open_in_browser # Call a Heathrow method
126
+ # bind '\\', shell: 'my-script %f' # %f = current message file path
127
+ def bind(key, shell: nil, action: nil, prompt: nil, description: nil)
128
+ @custom_bindings ||= {}
129
+ @custom_bindings[key.to_s] = {
130
+ shell: shell,
131
+ action: action,
132
+ prompt: prompt,
133
+ description: description
134
+ }
135
+ end
136
+
137
+ def custom_bindings
138
+ @custom_bindings || {}
139
+ end
140
+
141
+ # ── Load the RC file ──
142
+
143
+ def load_rc(path = HEATHROWRC)
144
+ return unless File.exist?(path)
145
+
146
+ # Evaluate in the context of this Config instance so DSL methods work
147
+ instance_eval(File.read(path), path)
148
+ rescue => e
149
+ STDERR.puts "Error loading #{path}: #{e.message}"
150
+ STDERR.puts e.backtrace.first(3).join("\n") if ENV['DEBUG']
151
+ end
152
+
153
+ # Reload the RC file (for interactive 'R' key)
154
+ def reload_rc
155
+ @identities = {}
156
+ @folder_hooks = []
157
+ @global_headers = {}
158
+ @rc_settings = {}
159
+ @custom_themes = nil
160
+ @custom_views = nil
161
+ @custom_bindings = nil
162
+ @channel_name_map = nil
163
+ load_rc
164
+ end
165
+
166
+ # ── Identity resolution ──
167
+
168
+ def identities
169
+ @identities
170
+ end
171
+
172
+ def folder_hooks
173
+ @folder_hooks
174
+ end
175
+
176
+ def global_headers
177
+ @global_headers
178
+ end
179
+
180
+ # Get identity for a given folder name (first matching folder_hook wins)
181
+ def identity_for_folder(folder_name)
182
+ folder_name ||= 'INBOX'
183
+ hook = @folder_hooks.find { |h| folder_name.match?(h[:pattern]) }
184
+ identity_name = hook ? hook[:identity] : 'default'
185
+ id = @identities[identity_name] || @identities['default']
186
+ return nil unless id
187
+
188
+ # Merge global headers into identity headers
189
+ merged_headers = @global_headers.merge(id[:headers] || {})
190
+ id.merge(headers: merged_headers)
191
+ end
192
+
193
+ def identity_name_for_folder(folder_name)
194
+ folder_name ||= 'INBOX'
195
+ hook = @folder_hooks.find { |h| folder_name.match?(h[:pattern]) }
196
+ hook ? hook[:identity] : 'default'
197
+ end
198
+
199
+ # Class-level shortcuts that delegate to the singleton
200
+ class << self
201
+ attr_accessor :instance
202
+
203
+ def identity_for_folder(folder_name)
204
+ instance&.identity_for_folder(folder_name)
205
+ end
206
+
207
+ def identity_name_for_folder(folder_name)
208
+ instance&.identity_name_for_folder(folder_name)
209
+ end
210
+ end
211
+
212
+ # ── RC settings access ──
213
+ # RC settings override YAML config values
214
+
215
+ def rc(key, default = nil)
216
+ val = @rc_settings[key.to_s]
217
+ val.nil? ? default : val
218
+ end
219
+
220
+ # ── YAML config (legacy, still used for some things) ──
221
+
222
+ def load_config
223
+ if File.exist?(@config_path)
224
+ YAML.load_file(@config_path)
225
+ else
226
+ create_default_config
227
+ end
228
+ end
229
+
230
+ def create_default_config
231
+ default = {
232
+ 'version' => Heathrow::VERSION,
233
+ 'ui' => {},
234
+ 'polling' => {
235
+ 'enabled' => true,
236
+ 'default_interval' => 60
237
+ },
238
+ 'notifications' => {
239
+ 'enabled' => false,
240
+ 'sound' => false
241
+ }
242
+ }
243
+
244
+ save_config(default)
245
+ default
246
+ end
247
+
248
+ def save_config(config = @settings)
249
+ File.write(@config_path, config.to_yaml)
250
+ end
251
+
252
+ def save
253
+ save_config
254
+ end
255
+
256
+ # Get config value — YAML overrides (from interactive settings) beat RC defaults
257
+ def get(path, default = nil)
258
+ # First check YAML (interactive changes persist here)
259
+ keys = path.split('.')
260
+ yaml_val = @settings
261
+ keys.each do |key|
262
+ break unless yaml_val.is_a?(Hash)
263
+ yaml_val = yaml_val[key]
264
+ end
265
+ return yaml_val unless yaml_val.nil?
266
+
267
+ # Then check RC settings
268
+ rc_key = path.sub(/^ui\./, '')
269
+ val = @rc_settings[rc_key]
270
+ return val unless val.nil?
271
+
272
+ default
273
+ end
274
+
275
+ def has?(path)
276
+ keys = path.split('.')
277
+ value = @settings
278
+
279
+ keys.each do |key|
280
+ return false unless value.is_a?(Hash) && value.key?(key)
281
+ value = value[key]
282
+ end
283
+
284
+ true
285
+ end
286
+
287
+ def [](key)
288
+ @settings[key]
289
+ end
290
+
291
+ def []=(key, value)
292
+ @settings[key] = value
293
+ end
294
+
295
+ # ── Database-backed settings ──
296
+
297
+ def set_db_setting(key, value)
298
+ return unless @db
299
+
300
+ now = Time.now.to_i
301
+ value_str = value.is_a?(String) ? value : value.to_json
302
+
303
+ @db.execute <<-SQL, [key, value_str, now]
304
+ INSERT OR REPLACE INTO settings (key, value, updated_at)
305
+ VALUES (?, ?, ?)
306
+ SQL
307
+ end
308
+
309
+ def get_db_setting(key, default = nil)
310
+ return default unless @db
311
+
312
+ result = @db.get_first_value("SELECT value FROM settings WHERE key = ?", key)
313
+ return default unless result
314
+
315
+ begin
316
+ JSON.parse(result)
317
+ rescue JSON::ParserError
318
+ result
319
+ end
320
+ end
321
+
322
+ def has_db_setting?(key)
323
+ return false unless @db
324
+ @db.get_first_value("SELECT COUNT(*) FROM settings WHERE key = ?", key).to_i > 0
325
+ end
326
+
327
+ def delete_db_setting(key)
328
+ return unless @db
329
+ @db.execute("DELETE FROM settings WHERE key = ?", key)
330
+ end
331
+ end
332
+ end