heathrow 0.7.3 → 0.7.5
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 +4 -4
- data/lib/heathrow/database.rb +13 -0
- data/lib/heathrow/ui/application.rb +317 -50
- data/lib/heathrow/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5276cc34b418c9d3b080eb711a1870857679ca87681c58436e6207ff37e7106
|
|
4
|
+
data.tar.gz: 8a10f16633f2ac610bc191434566f883319b570a2ad773297373a8d5a4e477de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c98df39af32e449f46f7a4d7c58436d88cfd6e32499394624ad74c2b16c4e390512ecbe18fff5fbe0462c1bb76b18921b806a72403aa08e7e39ccfc7ac69ccc
|
|
7
|
+
data.tar.gz: 9fb82e3608cb1fd6d63078b8584aad96deb3e43072421468b4c712d8c3ead53c192b7378c4d1cef75ada0f5f8d430e81d5847d71c27a04accfdf333f59fe01c8
|
data/lib/heathrow/database.rb
CHANGED
|
@@ -358,6 +358,19 @@ module Heathrow
|
|
|
358
358
|
query += " AND source_id = ?"
|
|
359
359
|
params << filters[:source_id]
|
|
360
360
|
end
|
|
361
|
+
|
|
362
|
+
if filters[:source_ids].is_a?(Array) && !filters[:source_ids].empty?
|
|
363
|
+
ph = filters[:source_ids].map { '?' }.join(',')
|
|
364
|
+
query += " AND source_id IN (#{ph})"
|
|
365
|
+
params += filters[:source_ids]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if filters[:source_name]
|
|
369
|
+
patterns = filters[:source_name].split('|').map(&:strip)
|
|
370
|
+
conditions = patterns.map { "name LIKE ?" }.join(' OR ')
|
|
371
|
+
query += " AND source_id IN (SELECT id FROM sources WHERE #{conditions})"
|
|
372
|
+
params += patterns.map { |p| "%#{p}%" }
|
|
373
|
+
end
|
|
361
374
|
|
|
362
375
|
# Handle sender pattern (supports regex via pipe separation)
|
|
363
376
|
if filters[:sender_pattern]
|
|
@@ -419,9 +419,10 @@ module Heathrow
|
|
|
419
419
|
@initial_load_done = false
|
|
420
420
|
Thread.new do
|
|
421
421
|
begin
|
|
422
|
+
@load_limit = 200
|
|
422
423
|
case @default_view
|
|
423
424
|
when 'N'
|
|
424
|
-
@filtered_messages = @db.get_messages({is_read: false},
|
|
425
|
+
@filtered_messages = @db.get_messages({is_read: false}, @load_limit, 0, light: true)
|
|
425
426
|
when /^[0-9]$/, /^F\d+$/
|
|
426
427
|
view = @views[@default_view]
|
|
427
428
|
if view && view[:filters] && !view[:filters].empty?
|
|
@@ -430,16 +431,17 @@ module Heathrow
|
|
|
430
431
|
end
|
|
431
432
|
apply_view_filters(view)
|
|
432
433
|
else
|
|
433
|
-
@filtered_messages = @db.get_messages({},
|
|
434
|
+
@filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
|
|
434
435
|
@current_view = 'A'
|
|
435
436
|
end
|
|
436
437
|
else
|
|
437
|
-
@filtered_messages = @db.get_messages({},
|
|
438
|
+
@filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
|
|
438
439
|
@current_view = 'A'
|
|
439
440
|
end
|
|
440
441
|
sort_messages
|
|
441
442
|
@index = 0
|
|
442
443
|
reset_threading if respond_to?(:reset_threading)
|
|
444
|
+
restore_view_thread_mode if respond_to?(:restore_view_thread_mode)
|
|
443
445
|
@initial_load_done = true
|
|
444
446
|
@needs_redraw = true
|
|
445
447
|
# Preload the heavy mail gem so 'v' (attachments) doesn't lag
|
|
@@ -508,9 +510,17 @@ module Heathrow
|
|
|
508
510
|
render_all
|
|
509
511
|
@needs_redraw = false
|
|
510
512
|
end
|
|
513
|
+
# Check for mailto trigger (from wezterm or external script)
|
|
514
|
+
check_mailto_trigger
|
|
515
|
+
|
|
511
516
|
chr = getchr(2, flush: false) # 2s timeout to check for new mail
|
|
512
517
|
begin
|
|
513
518
|
if chr
|
|
519
|
+
# Clear sticky feedback (errors, "message sent") on any keypress
|
|
520
|
+
if @feedback_sticky
|
|
521
|
+
@feedback_sticky = false
|
|
522
|
+
@feedback_expires_at = Time.now # Expire now so render_bottom_bar clears it
|
|
523
|
+
end
|
|
514
524
|
@needs_redraw = false # Handlers that need redraw call render_all directly
|
|
515
525
|
handle_input_key(chr)
|
|
516
526
|
else
|
|
@@ -2144,6 +2154,11 @@ module Heathrow
|
|
|
2144
2154
|
# Colorize email content (quote levels + signature)
|
|
2145
2155
|
content = colorize_email_content(content)
|
|
2146
2156
|
|
|
2157
|
+
# Store maildir file path for calendar parser
|
|
2158
|
+
meta = msg['metadata']
|
|
2159
|
+
meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
|
|
2160
|
+
@_current_render_msg_file = meta['maildir_file'] if meta.is_a?(Hash)
|
|
2161
|
+
|
|
2147
2162
|
# Attachment list under header, before body
|
|
2148
2163
|
att_text = format_attachments(msg['attachments'])
|
|
2149
2164
|
|
|
@@ -2165,10 +2180,14 @@ module Heathrow
|
|
|
2165
2180
|
end
|
|
2166
2181
|
html_hint = message_has_html?(msg) ? "HTML mail, press x to open in browser".fg(39) : nil
|
|
2167
2182
|
|
|
2183
|
+
# Parse calendar invites (ICS attachments)
|
|
2184
|
+
cal_text = format_calendar_event(msg['attachments'])
|
|
2185
|
+
|
|
2168
2186
|
full_text = header.join("\n")
|
|
2169
2187
|
full_text += "\n" + att_text if att_text
|
|
2170
2188
|
full_text += "\n" + image_hint if image_hint
|
|
2171
2189
|
full_text += "\n" + html_hint if html_hint
|
|
2190
|
+
full_text += "\n\n" + cal_text if cal_text
|
|
2172
2191
|
full_text += "\n\n" + content
|
|
2173
2192
|
|
|
2174
2193
|
@panes[:right].text = full_text
|
|
@@ -2254,7 +2273,13 @@ module Heathrow
|
|
|
2254
2273
|
def set_feedback(message, color = 156, duration = 3)
|
|
2255
2274
|
@feedback_message = message
|
|
2256
2275
|
@feedback_color = color
|
|
2257
|
-
|
|
2276
|
+
# duration 0 or errors: persist until next user action (cleared by clear_sticky_feedback)
|
|
2277
|
+
@feedback_sticky = (duration == 0 || color == 196)
|
|
2278
|
+
@feedback_expires_at = if @feedback_sticky
|
|
2279
|
+
nil # Never auto-expire; cleared on keypress
|
|
2280
|
+
else
|
|
2281
|
+
Time.now + duration
|
|
2282
|
+
end
|
|
2258
2283
|
if @panes[:bottom]
|
|
2259
2284
|
@panes[:bottom].text = " #{message}".fg(color)
|
|
2260
2285
|
@panes[:bottom].refresh
|
|
@@ -2344,8 +2369,8 @@ module Heathrow
|
|
|
2344
2369
|
if @current_folder
|
|
2345
2370
|
light_cols = "id, source_id, external_id, thread_id, parent_id, sender, sender_name, recipients, subject, substr(content, 1, 200) as content, timestamp, received_at, read AS is_read, starred AS is_starred, archived, labels, metadata, attachments, folder, replied"
|
|
2346
2371
|
results = @db.execute(
|
|
2347
|
-
"SELECT #{light_cols} FROM messages WHERE folder
|
|
2348
|
-
@current_folder, @
|
|
2372
|
+
"SELECT #{light_cols} FROM messages WHERE folder = ? ORDER BY timestamp DESC LIMIT ?",
|
|
2373
|
+
@current_folder, @load_limit
|
|
2349
2374
|
)
|
|
2350
2375
|
@filtered_messages = results
|
|
2351
2376
|
elsif @current_view == 'A'
|
|
@@ -2446,6 +2471,7 @@ module Heathrow
|
|
|
2446
2471
|
when 'subject' then db_filters[:subject_pattern] = value
|
|
2447
2472
|
when 'folder' then db_filters[:maildir_folder] = value
|
|
2448
2473
|
when 'label' then db_filters[:label] = value
|
|
2474
|
+
when 'source' then db_filters[:source_name] = value
|
|
2449
2475
|
end
|
|
2450
2476
|
end
|
|
2451
2477
|
end
|
|
@@ -2498,8 +2524,8 @@ module Heathrow
|
|
|
2498
2524
|
if @current_folder
|
|
2499
2525
|
light_cols = "id, source_id, external_id, thread_id, parent_id, sender, sender_name, recipients, subject, substr(content, 1, 200) as content, timestamp, received_at, read AS is_read, starred AS is_starred, archived, labels, metadata, attachments, folder, replied"
|
|
2500
2526
|
@filtered_messages = @db.execute(
|
|
2501
|
-
"SELECT #{light_cols} FROM messages WHERE folder
|
|
2502
|
-
@current_folder, @
|
|
2527
|
+
"SELECT #{light_cols} FROM messages WHERE folder = ? ORDER BY timestamp DESC LIMIT ?",
|
|
2528
|
+
@current_folder, @load_limit
|
|
2503
2529
|
)
|
|
2504
2530
|
elsif @current_view == 'A'
|
|
2505
2531
|
@filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
|
|
@@ -2649,6 +2675,7 @@ module Heathrow
|
|
|
2649
2675
|
when 'subject' then db_filters[:subject_pattern] = value
|
|
2650
2676
|
when 'folder' then db_filters[:maildir_folder] = value
|
|
2651
2677
|
when 'label' then db_filters[:label] = value
|
|
2678
|
+
when 'source' then db_filters[:source_name] = value
|
|
2652
2679
|
end
|
|
2653
2680
|
end
|
|
2654
2681
|
end
|
|
@@ -3027,6 +3054,7 @@ module Heathrow
|
|
|
3027
3054
|
msg = current_message
|
|
3028
3055
|
return unless msg
|
|
3029
3056
|
return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
|
|
3057
|
+
msg = ensure_full_message(msg)
|
|
3030
3058
|
|
|
3031
3059
|
# Mark as read
|
|
3032
3060
|
if msg['is_read'].to_i == 0
|
|
@@ -3719,8 +3747,8 @@ module Heathrow
|
|
|
3719
3747
|
def folder_message_count(folder_name)
|
|
3720
3748
|
# Use range query to leverage folder index (5x faster than OR + LIKE)
|
|
3721
3749
|
row = @db.db.get_first_row(
|
|
3722
|
-
"SELECT COUNT(*) as total, SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) as unread FROM messages WHERE folder
|
|
3723
|
-
[folder_name
|
|
3750
|
+
"SELECT COUNT(*) as total, SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) as unread FROM messages WHERE folder = ?",
|
|
3751
|
+
[folder_name]
|
|
3724
3752
|
)
|
|
3725
3753
|
{ total: (row && row['total']) || 0, unread: (row && row['unread']) || 0 }
|
|
3726
3754
|
rescue
|
|
@@ -3968,8 +3996,8 @@ module Heathrow
|
|
|
3968
3996
|
@load_limit = 200
|
|
3969
3997
|
light_cols = "id, source_id, external_id, thread_id, parent_id, sender, sender_name, recipients, subject, substr(content, 1, 200) as content, timestamp, received_at, read AS is_read, starred AS is_starred, archived, labels, metadata, attachments, folder, replied"
|
|
3970
3998
|
results = @db.execute(
|
|
3971
|
-
"SELECT #{light_cols} FROM messages WHERE folder
|
|
3972
|
-
folder_name,
|
|
3999
|
+
"SELECT #{light_cols} FROM messages WHERE folder = ? ORDER BY timestamp DESC LIMIT ?",
|
|
4000
|
+
folder_name, @load_limit
|
|
3973
4001
|
)
|
|
3974
4002
|
|
|
3975
4003
|
@filtered_messages = results
|
|
@@ -4847,47 +4875,83 @@ module Heathrow
|
|
|
4847
4875
|
# Notmuch full-text search
|
|
4848
4876
|
def notmuch_search
|
|
4849
4877
|
require_relative '../notmuch'
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4878
|
+
has_notmuch = Heathrow::Notmuch.available?
|
|
4879
|
+
|
|
4880
|
+
# Source picker: let user scope the search
|
|
4881
|
+
sources = @db.get_sources(true) # enabled only
|
|
4882
|
+
scope_hint = sources.each_with_index.map { |s, i| "#{i + 1}:#{s['name']}" }.join(' ')
|
|
4883
|
+
scope = bottom_ask("Search in (Enter=all, #{scope_hint}): ", "")
|
|
4884
|
+
return if scope == 'ESC'
|
|
4885
|
+
|
|
4886
|
+
selected_source_ids = nil
|
|
4887
|
+
scope_label = "all"
|
|
4888
|
+
if scope && !scope.strip.empty?
|
|
4889
|
+
# Parse source selection (comma-separated numbers or name fragment)
|
|
4890
|
+
selected = []
|
|
4891
|
+
scope.split(',').each do |part|
|
|
4892
|
+
part = part.strip
|
|
4893
|
+
if part =~ /^\d+$/
|
|
4894
|
+
idx = part.to_i - 1
|
|
4895
|
+
selected << sources[idx] if idx >= 0 && idx < sources.size
|
|
4896
|
+
else
|
|
4897
|
+
# Name fragment match
|
|
4898
|
+
sources.each { |s| selected << s if s['name'].downcase.include?(part.downcase) }
|
|
4899
|
+
end
|
|
4900
|
+
end
|
|
4901
|
+
if selected.any?
|
|
4902
|
+
selected_source_ids = selected.map { |s| s['id'] }
|
|
4903
|
+
scope_label = selected.map { |s| s['name'] }.join(', ')
|
|
4904
|
+
end
|
|
4853
4905
|
end
|
|
4854
4906
|
|
|
4855
|
-
query = bottom_ask("Search
|
|
4907
|
+
query = bottom_ask("Search#{scope_label != 'all' ? " [#{scope_label}]" : ''}: ", "")
|
|
4856
4908
|
return if query.nil? || query.strip.empty?
|
|
4857
4909
|
|
|
4858
4910
|
@panes[:bottom].text = " Searching...".fg(226)
|
|
4859
4911
|
@panes[:bottom].refresh
|
|
4860
4912
|
|
|
4861
|
-
files = Heathrow::Notmuch.search_files(query)
|
|
4862
|
-
if files.empty?
|
|
4863
|
-
set_feedback("No results for: #{query}", 226, 3)
|
|
4864
|
-
return
|
|
4865
|
-
end
|
|
4866
|
-
|
|
4867
|
-
# Map file paths back to Heathrow messages via metadata.maildir_file
|
|
4868
|
-
# Use basename matching since paths may differ slightly
|
|
4869
|
-
basenames = files.map { |f| File.basename(f) }
|
|
4870
|
-
placeholders = basenames.map { '?' }.join(',')
|
|
4871
|
-
|
|
4872
|
-
# Search by external_id (which is the filename basename)
|
|
4873
4913
|
results = []
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4914
|
+
|
|
4915
|
+
# Use notmuch for Maildir sources (fast indexed search)
|
|
4916
|
+
if has_notmuch && (selected_source_ids.nil? || sources.any? { |s| selected_source_ids&.include?(s['id']) && s['plugin_type'] == 'maildir' })
|
|
4917
|
+
files = Heathrow::Notmuch.search_files(query)
|
|
4918
|
+
unless files.empty?
|
|
4919
|
+
basenames = files.map { |f| File.basename(f) }
|
|
4920
|
+
basenames.each_slice(100) do |batch|
|
|
4921
|
+
ph = batch.map { '?' }.join(',')
|
|
4922
|
+
sql = "SELECT * FROM messages WHERE external_id IN (#{ph})"
|
|
4923
|
+
params = batch.dup
|
|
4924
|
+
if selected_source_ids
|
|
4925
|
+
sid_ph = selected_source_ids.map { '?' }.join(',')
|
|
4926
|
+
sql += " AND source_id IN (#{sid_ph})"
|
|
4927
|
+
params += selected_source_ids
|
|
4928
|
+
end
|
|
4929
|
+
rows = @db.execute(sql, *params)
|
|
4930
|
+
rows.each do |row|
|
|
4931
|
+
row['recipients'] = JSON.parse(row['recipients']) if row['recipients'].is_a?(String)
|
|
4932
|
+
row['metadata'] = JSON.parse(row['metadata']) if row['metadata'].is_a?(String)
|
|
4933
|
+
row['labels'] = JSON.parse(row['labels']) if row['labels'].is_a?(String)
|
|
4934
|
+
row['attachments'] = JSON.parse(row['attachments']) if row['attachments'].is_a?(String)
|
|
4935
|
+
end
|
|
4936
|
+
results.concat(rows)
|
|
4937
|
+
end
|
|
4885
4938
|
end
|
|
4886
|
-
|
|
4939
|
+
end
|
|
4940
|
+
|
|
4941
|
+
# DB search for non-Maildir sources (or if notmuch unavailable)
|
|
4942
|
+
non_maildir_ids = if selected_source_ids
|
|
4943
|
+
selected_source_ids.select { |sid| sources.find { |s| s['id'] == sid && s['plugin_type'] != 'maildir' } }
|
|
4944
|
+
else
|
|
4945
|
+
sources.select { |s| s['plugin_type'] != 'maildir' }.map { |s| s['id'] }
|
|
4946
|
+
end
|
|
4947
|
+
if non_maildir_ids.any?
|
|
4948
|
+
db_filters = { search: query, source_ids: non_maildir_ids }
|
|
4949
|
+
db_results = @db.get_messages(db_filters, 500, 0, light: false)
|
|
4950
|
+
results.concat(db_results)
|
|
4887
4951
|
end
|
|
4888
4952
|
|
|
4889
4953
|
if results.empty?
|
|
4890
|
-
set_feedback("#{
|
|
4954
|
+
set_feedback("No results for: #{query}", 226, 3)
|
|
4891
4955
|
return
|
|
4892
4956
|
end
|
|
4893
4957
|
|
|
@@ -4895,11 +4959,12 @@ module Heathrow
|
|
|
4895
4959
|
@current_view = 'A'
|
|
4896
4960
|
@in_source_view = false
|
|
4897
4961
|
@panes[:right].content_update = true
|
|
4898
|
-
@current_source_filter = "Search: #{query}"
|
|
4962
|
+
@current_source_filter = "Search: #{query}#{scope_label != 'all' ? " [#{scope_label}]" : ''}"
|
|
4899
4963
|
@filtered_messages = results
|
|
4900
4964
|
sort_messages
|
|
4901
4965
|
@index = 0
|
|
4902
|
-
|
|
4966
|
+
reset_threading if respond_to?(:reset_threading)
|
|
4967
|
+
set_feedback("#{results.size} results for: #{query}", 156, 0)
|
|
4903
4968
|
render_all
|
|
4904
4969
|
end
|
|
4905
4970
|
|
|
@@ -5324,7 +5389,24 @@ module Heathrow
|
|
|
5324
5389
|
render_bottom_bar
|
|
5325
5390
|
end
|
|
5326
5391
|
|
|
5327
|
-
|
|
5392
|
+
# Check for mailto trigger file (written by wezterm or external scripts)
|
|
5393
|
+
def check_mailto_trigger
|
|
5394
|
+
mailto_file = File.join(HEATHROW_HOME, 'mailto')
|
|
5395
|
+
return unless File.exist?(mailto_file)
|
|
5396
|
+
addr = File.read(mailto_file).strip
|
|
5397
|
+
File.delete(mailto_file)
|
|
5398
|
+
return if addr.empty?
|
|
5399
|
+
|
|
5400
|
+
# Find the first mail source
|
|
5401
|
+
mail_source = @source_manager.sources.values.find do |s|
|
|
5402
|
+
s['enabled'] && %w[maildir gmail imap].include?(s['plugin_type'] || s['type'])
|
|
5403
|
+
end
|
|
5404
|
+
return unless mail_source
|
|
5405
|
+
|
|
5406
|
+
compose_new_mail(mail_source, mailto: addr)
|
|
5407
|
+
end
|
|
5408
|
+
|
|
5409
|
+
def compose_new_mail(source, mailto: nil)
|
|
5328
5410
|
require_relative '../message_composer'
|
|
5329
5411
|
identity = current_identity
|
|
5330
5412
|
|
|
@@ -5347,7 +5429,7 @@ module Heathrow
|
|
|
5347
5429
|
@panes[:bottom].text = " Opening editor for new message...".fg(226)
|
|
5348
5430
|
@panes[:bottom].refresh
|
|
5349
5431
|
|
|
5350
|
-
composed = composer.compose_new
|
|
5432
|
+
composed = composer.compose_new(mailto)
|
|
5351
5433
|
end
|
|
5352
5434
|
setup_display
|
|
5353
5435
|
create_panes
|
|
@@ -5732,8 +5814,8 @@ module Heathrow
|
|
|
5732
5814
|
if composed[:attachments] && !composed[:attachments].empty?
|
|
5733
5815
|
msg += " (#{composed[:attachments].size} attachment(s))"
|
|
5734
5816
|
end
|
|
5735
|
-
set_feedback(msg, 156,
|
|
5736
|
-
|
|
5817
|
+
set_feedback(msg, 156, 0)
|
|
5818
|
+
render_message_list if orig_id
|
|
5737
5819
|
else
|
|
5738
5820
|
set_feedback(result[:message], 196, 4)
|
|
5739
5821
|
end
|
|
@@ -7349,7 +7431,16 @@ Required: URL, optional CSS selector
|
|
|
7349
7431
|
new_rules << { 'field' => 'subject', 'op' => 'like', 'value' => subject_input }
|
|
7350
7432
|
end
|
|
7351
7433
|
|
|
7352
|
-
# 6.
|
|
7434
|
+
# 6. Source filter
|
|
7435
|
+
current_source = current_vals['source_like'] || ''
|
|
7436
|
+
source_names = @db.get_sources(true).map { |s| s['name'] }.join(', ')
|
|
7437
|
+
source_input = bottom_ask("Source (#{source_names} - ESC cancel): ", current_source)
|
|
7438
|
+
return render_all if source_input.nil?
|
|
7439
|
+
unless source_input.empty?
|
|
7440
|
+
new_rules << { 'field' => 'source', 'op' => 'like', 'value' => source_input }
|
|
7441
|
+
end
|
|
7442
|
+
|
|
7443
|
+
# 7. Read status
|
|
7353
7444
|
current_read = current_vals['read_=']
|
|
7354
7445
|
default_read = current_read == false ? 'y' : (current_read == true ? 'n' : '')
|
|
7355
7446
|
read_input = bottom_ask("Unread only? (y/n/Enter for all, ESC cancel): ", default_read)
|
|
@@ -7743,8 +7834,10 @@ Required: URL, optional CSS selector
|
|
|
7743
7834
|
color = quote_colors[[level - 1, quote_colors.length - 1].min]
|
|
7744
7835
|
result << colorize_links(stripped, color, link_color)
|
|
7745
7836
|
else
|
|
7746
|
-
# Detect
|
|
7747
|
-
|
|
7837
|
+
# Detect attribution lines (start of indented quote block)
|
|
7838
|
+
# Matches: "wrote:", "skrev ...:", "schrieb ...:", "a écrit :", or "date ... <email>:"
|
|
7839
|
+
if stripped =~ /\b(wrote|skrev|schrieb|geschreven|scrisse|escribi[oó]|a\s+[eé]crit)\b.*:\s*$/ ||
|
|
7840
|
+
stripped =~ /\d\d[:\.]\d\d\s.*<[^>]+>:\s*$/
|
|
7748
7841
|
indent_quote_level += 1
|
|
7749
7842
|
color = quote_colors[[indent_quote_level - 1, quote_colors.length - 1].min]
|
|
7750
7843
|
result << colorize_links(stripped, color, link_color)
|
|
@@ -7972,6 +8065,180 @@ Required: URL, optional CSS selector
|
|
|
7972
8065
|
end
|
|
7973
8066
|
|
|
7974
8067
|
# Format attachment list for display
|
|
8068
|
+
# Parse and format calendar events from ICS attachments
|
|
8069
|
+
def format_calendar_event(attachments)
|
|
8070
|
+
attachments = [] unless attachments.is_a?(Array)
|
|
8071
|
+
# Find ICS data from attachments or inline MIME parts
|
|
8072
|
+
ics_data = nil
|
|
8073
|
+
begin
|
|
8074
|
+
require 'mail'
|
|
8075
|
+
|
|
8076
|
+
# First check attachments array
|
|
8077
|
+
ics_att = attachments.is_a?(Array) && attachments.find do |att|
|
|
8078
|
+
ct = (att['content_type'] || '').downcase
|
|
8079
|
+
name = (att['name'] || att['filename'] || '').downcase
|
|
8080
|
+
ct.include?('calendar') || ct.include?('ics') || name.end_with?('.ics')
|
|
8081
|
+
end
|
|
8082
|
+
|
|
8083
|
+
# Get the maildir file path (from attachment or message metadata)
|
|
8084
|
+
file = ics_att['source_file'] if ics_att
|
|
8085
|
+
file ||= @_current_render_msg_file # Set by render_message_content
|
|
8086
|
+
return nil unless file && File.exist?(file)
|
|
8087
|
+
|
|
8088
|
+
# Parse MIME parts for calendar data
|
|
8089
|
+
mail = Mail.read(file)
|
|
8090
|
+
if mail.multipart?
|
|
8091
|
+
mail.parts.each do |part|
|
|
8092
|
+
ct = (part.content_type || '').downcase
|
|
8093
|
+
if ct.include?('calendar') || ct.include?('ics')
|
|
8094
|
+
ics_data = part.decoded
|
|
8095
|
+
break
|
|
8096
|
+
end
|
|
8097
|
+
if part.multipart?
|
|
8098
|
+
part.parts.each do |sub|
|
|
8099
|
+
sct = (sub.content_type || '').downcase
|
|
8100
|
+
if sct.include?('calendar') || sct.include?('ics')
|
|
8101
|
+
ics_data = sub.decoded
|
|
8102
|
+
break
|
|
8103
|
+
end
|
|
8104
|
+
end
|
|
8105
|
+
break if ics_data
|
|
8106
|
+
end
|
|
8107
|
+
end
|
|
8108
|
+
end
|
|
8109
|
+
ics_data ||= File.read(file) if file.end_with?('.ics')
|
|
8110
|
+
return nil unless ics_data && ics_data.include?('BEGIN:')
|
|
8111
|
+
|
|
8112
|
+
# Use VcalView parser
|
|
8113
|
+
# Use basic inline ICS parser
|
|
8114
|
+
event = parse_ics_basic(ics_data)
|
|
8115
|
+
return nil unless event
|
|
8116
|
+
|
|
8117
|
+
# Format the event for display
|
|
8118
|
+
lines = []
|
|
8119
|
+
lines << ("─" * 50).fg(238)
|
|
8120
|
+
lines << "Calendar Event".b.fg(226)
|
|
8121
|
+
lines << ""
|
|
8122
|
+
lines << "WHAT: #{event[:summary]}".fg(156) if event[:summary]
|
|
8123
|
+
if event[:dates]
|
|
8124
|
+
when_str = event[:dates]
|
|
8125
|
+
when_str += " (#{event[:weekday]})" if event[:weekday]
|
|
8126
|
+
when_str += ", #{event[:times]}" if event[:times]
|
|
8127
|
+
lines << "WHEN: #{when_str}".fg(39)
|
|
8128
|
+
end
|
|
8129
|
+
lines << "WHERE: #{event[:location]}".fg(45) if event[:location] && !event[:location].to_s.empty?
|
|
8130
|
+
lines << "RECUR: #{event[:recurrence]}".fg(180) if event[:recurrence]
|
|
8131
|
+
lines << "STATUS: #{event[:status]}".fg(245) if event[:status]
|
|
8132
|
+
lines << ""
|
|
8133
|
+
lines << "ORGANIZER: #{event[:organizer]}".fg(2) if event[:organizer]
|
|
8134
|
+
if event[:participants] && !event[:participants].to_s.strip.empty?
|
|
8135
|
+
lines << "PARTICIPANTS:".fg(2)
|
|
8136
|
+
lines << event[:participants].fg(245)
|
|
8137
|
+
end
|
|
8138
|
+
# Skip description (email body already shows it, and ICS descriptions
|
|
8139
|
+
# often contain raw URLs that can overflow the pane)
|
|
8140
|
+
lines << ("─" * 50).fg(238)
|
|
8141
|
+
lines.join("\n")
|
|
8142
|
+
rescue => e
|
|
8143
|
+
nil # Don't crash on calendar parse errors
|
|
8144
|
+
end
|
|
8145
|
+
end
|
|
8146
|
+
|
|
8147
|
+
# Basic ICS parsing fallback (when VcalView is not available)
|
|
8148
|
+
def parse_ics_basic(ics)
|
|
8149
|
+
# Extract only the VEVENT section (ignore VTIMEZONE which has dummy dates)
|
|
8150
|
+
vevent = ics[/BEGIN:VEVENT(.*?)END:VEVENT/m, 1]
|
|
8151
|
+
return nil unless vevent
|
|
8152
|
+
|
|
8153
|
+
# Unfold continuation lines (RFC 5545: lines starting with space are continuations)
|
|
8154
|
+
vevent = vevent.gsub(/\r?\n[ \t]/, '')
|
|
8155
|
+
|
|
8156
|
+
event = {}
|
|
8157
|
+
|
|
8158
|
+
# SUMMARY (strip LANGUAGE= and other params before the colon-value)
|
|
8159
|
+
if vevent =~ /^SUMMARY[^:]*:(.*)$/i
|
|
8160
|
+
event[:summary] = $1.strip
|
|
8161
|
+
end
|
|
8162
|
+
|
|
8163
|
+
# DTSTART with TZID
|
|
8164
|
+
if vevent =~ /^DTSTART;TZID=[^:]*:(\d{8})T?(\d{4,6})?/i
|
|
8165
|
+
d = $1; t = $2
|
|
8166
|
+
event[:dates] = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
|
|
8167
|
+
event[:times] = t ? "#{t[0,2]}:#{t[2,2]}" : "All day"
|
|
8168
|
+
begin
|
|
8169
|
+
dobj = Time.parse(event[:dates])
|
|
8170
|
+
event[:weekday] = dobj.strftime('%A')
|
|
8171
|
+
rescue; end
|
|
8172
|
+
elsif vevent =~ /^DTSTART;VALUE=DATE:(\d{8})/i
|
|
8173
|
+
d = $1
|
|
8174
|
+
event[:dates] = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
|
|
8175
|
+
event[:times] = "All day"
|
|
8176
|
+
elsif vevent =~ /^DTSTART:(\d{8})T?(\d{4,6})?(Z)?/i
|
|
8177
|
+
d = $1; t = $2; utc = $3
|
|
8178
|
+
event[:dates] = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
|
|
8179
|
+
if t
|
|
8180
|
+
# Convert UTC times to local
|
|
8181
|
+
if utc
|
|
8182
|
+
utc_time = Time.utc(d[0,4].to_i, d[4,2].to_i, d[6,2].to_i, t[0,2].to_i, t[2,2].to_i)
|
|
8183
|
+
local = utc_time.localtime
|
|
8184
|
+
event[:dates] = local.strftime('%Y-%m-%d')
|
|
8185
|
+
event[:times] = local.strftime('%H:%M')
|
|
8186
|
+
event[:weekday] = local.strftime('%A')
|
|
8187
|
+
else
|
|
8188
|
+
event[:times] = "#{t[0,2]}:#{t[2,2]}"
|
|
8189
|
+
begin
|
|
8190
|
+
event[:weekday] = Time.parse(event[:dates]).strftime('%A')
|
|
8191
|
+
rescue; end
|
|
8192
|
+
end
|
|
8193
|
+
else
|
|
8194
|
+
event[:times] = "All day"
|
|
8195
|
+
end
|
|
8196
|
+
end
|
|
8197
|
+
|
|
8198
|
+
# DTEND
|
|
8199
|
+
if vevent =~ /^DTEND;TZID=[^:]*:(\d{8})T?(\d{4,6})?/i
|
|
8200
|
+
d = $1; t = $2
|
|
8201
|
+
end_date = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
|
|
8202
|
+
end_time = t ? "#{t[0,2]}:#{t[2,2]}" : nil
|
|
8203
|
+
elsif vevent =~ /^DTEND:(\d{8})T?(\d{4,6})?(Z)?/i
|
|
8204
|
+
d = $1; t = $2; utc = $3
|
|
8205
|
+
if t && utc
|
|
8206
|
+
utc_time = Time.utc(d[0,4].to_i, d[4,2].to_i, d[6,2].to_i, t[0,2].to_i, t[2,2].to_i)
|
|
8207
|
+
local = utc_time.localtime
|
|
8208
|
+
end_date = local.strftime('%Y-%m-%d')
|
|
8209
|
+
end_time = local.strftime('%H:%M')
|
|
8210
|
+
else
|
|
8211
|
+
end_date = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
|
|
8212
|
+
end_time = t ? "#{t[0,2]}:#{t[2,2]}" : nil
|
|
8213
|
+
end
|
|
8214
|
+
event[:dates] += " - #{end_date}" if end_date && end_date != event[:dates]
|
|
8215
|
+
event[:times] += " - #{end_time}" if end_time && end_time != event[:times]
|
|
8216
|
+
end
|
|
8217
|
+
|
|
8218
|
+
# LOCATION (strip params)
|
|
8219
|
+
if vevent =~ /^LOCATION[^:]*:(.*)$/i
|
|
8220
|
+
event[:location] = $1.strip
|
|
8221
|
+
end
|
|
8222
|
+
|
|
8223
|
+
# ORGANIZER
|
|
8224
|
+
if vevent =~ /^ORGANIZER.*CN=([^;:]+)/i
|
|
8225
|
+
event[:organizer] = $1.strip
|
|
8226
|
+
elsif vevent =~ /^ORGANIZER.*MAILTO:(.+)$/i
|
|
8227
|
+
event[:organizer] = $1.strip
|
|
8228
|
+
end
|
|
8229
|
+
|
|
8230
|
+
# ATTENDEES
|
|
8231
|
+
attendees = vevent.scan(/^ATTENDEE.*CN=([^;:]+)/i).flatten
|
|
8232
|
+
if attendees.any?
|
|
8233
|
+
event[:participants] = attendees.map { |a| " #{a.strip}" }.join("\n")
|
|
8234
|
+
end
|
|
8235
|
+
|
|
8236
|
+
# STATUS
|
|
8237
|
+
event[:status] = $1.strip.capitalize if vevent =~ /^STATUS:(.*)$/i
|
|
8238
|
+
|
|
8239
|
+
event.empty? ? nil : event
|
|
8240
|
+
end
|
|
8241
|
+
|
|
7975
8242
|
def format_attachments(attachments)
|
|
7976
8243
|
return nil unless attachments.is_a?(Array) && !attachments.empty?
|
|
7977
8244
|
lines = []
|
data/lib/heathrow/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: heathrow
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Geir Isene
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-03-
|
|
12
|
+
date: 2026-03-20 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rcurses
|