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.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/README.md +205 -0
- data/bin/heathrow +42 -0
- data/bin/heathrowd +283 -0
- data/docs/ARCHITECTURE.md +1172 -0
- data/docs/DATABASE_SCHEMA.md +685 -0
- data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
- data/docs/DISCORD_SETUP.md +142 -0
- data/docs/GMAIL_OAUTH_SETUP.md +120 -0
- data/docs/PLUGIN_SYSTEM.md +1370 -0
- data/docs/PROJECT_PLAN.md +1022 -0
- data/docs/README.md +417 -0
- data/docs/REDDIT_SETUP.md +174 -0
- data/docs/REPLY_FORWARD.md +182 -0
- data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
- data/heathrow.gemspec +34 -0
- data/heathrowd.service +21 -0
- data/img/heathrow.svg +95 -0
- data/img/rss_threaded.png +0 -0
- data/img/sources.png +0 -0
- data/lib/heathrow/address_book.rb +42 -0
- data/lib/heathrow/config.rb +332 -0
- data/lib/heathrow/database.rb +731 -0
- data/lib/heathrow/database_new.rb +392 -0
- data/lib/heathrow/event_bus.rb +175 -0
- data/lib/heathrow/logger.rb +122 -0
- data/lib/heathrow/message.rb +176 -0
- data/lib/heathrow/message_composer.rb +399 -0
- data/lib/heathrow/message_organizer.rb +774 -0
- data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
- data/lib/heathrow/notmuch.rb +45 -0
- data/lib/heathrow/oauth2_smtp.rb +254 -0
- data/lib/heathrow/plugin/base.rb +212 -0
- data/lib/heathrow/plugin_manager.rb +141 -0
- data/lib/heathrow/poller.rb +93 -0
- data/lib/heathrow/smtp_sender.rb +204 -0
- data/lib/heathrow/source.rb +39 -0
- data/lib/heathrow/sources/base.rb +74 -0
- data/lib/heathrow/sources/discord.rb +357 -0
- data/lib/heathrow/sources/gmail.rb +294 -0
- data/lib/heathrow/sources/imap.rb +198 -0
- data/lib/heathrow/sources/instagram.rb +307 -0
- data/lib/heathrow/sources/instagram_fetch.py +101 -0
- data/lib/heathrow/sources/instagram_send.py +55 -0
- data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
- data/lib/heathrow/sources/maildir.rb +606 -0
- data/lib/heathrow/sources/messenger.rb +212 -0
- data/lib/heathrow/sources/messenger_fetch.js +297 -0
- data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
- data/lib/heathrow/sources/messenger_send.js +32 -0
- data/lib/heathrow/sources/messenger_send.py +100 -0
- data/lib/heathrow/sources/reddit.rb +461 -0
- data/lib/heathrow/sources/rss.rb +299 -0
- data/lib/heathrow/sources/slack.rb +375 -0
- data/lib/heathrow/sources/source_manager.rb +328 -0
- data/lib/heathrow/sources/telegram.rb +498 -0
- data/lib/heathrow/sources/webpage.rb +207 -0
- data/lib/heathrow/sources/weechat.rb +479 -0
- data/lib/heathrow/sources/whatsapp.rb +474 -0
- data/lib/heathrow/ui/application.rb +8098 -0
- data/lib/heathrow/ui/navigation.rb +8 -0
- data/lib/heathrow/ui/panes.rb +8 -0
- data/lib/heathrow/ui/source_wizard.rb +567 -0
- data/lib/heathrow/ui/threaded_view.rb +780 -0
- data/lib/heathrow/ui/views.rb +8 -0
- data/lib/heathrow/version.rb +3 -0
- data/lib/heathrow/wizards/discord_wizard.rb +193 -0
- data/lib/heathrow/wizards/slack_wizard.rb +140 -0
- data/lib/heathrow.rb +55 -0
- 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
|