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,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
|