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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 123f2c7c6134d4ea1add0517f649b94353918d1e76583a9f714a1e49d7879f96
4
- data.tar.gz: 26e26497ec050fc246cb2ee1eff2389ceb3088dccb8c83d09257425dd152888a
3
+ metadata.gz: e5276cc34b418c9d3b080eb711a1870857679ca87681c58436e6207ff37e7106
4
+ data.tar.gz: 8a10f16633f2ac610bc191434566f883319b570a2ad773297373a8d5a4e477de
5
5
  SHA512:
6
- metadata.gz: 2130ee038cbc2d9bc8ee4b7855809afaa1c171aba9b8b0967a85d71c9397b39b2ef86afcef58b3301d15556dfc3d07ab8d91d8d12ed77d4a90879904562829a5
7
- data.tar.gz: 3557ffc9cf7af99441c6c4062e63d7bc9c18145b1f537d10607d420f059221f36c33d1f1a86cad76036a69d003eedb39f26c471013c2dba839187350a10c6aff
6
+ metadata.gz: 4c98df39af32e449f46f7a4d7c58436d88cfd6e32499394624ad74c2b16c4e390512ecbe18fff5fbe0462c1bb76b18921b806a72403aa08e7e39ccfc7ac69ccc
7
+ data.tar.gz: 9fb82e3608cb1fd6d63078b8584aad96deb3e43072421468b4c712d8c3ead53c192b7378c4d1cef75ada0f5f8d430e81d5847d71c27a04accfdf333f59fe01c8
@@ -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}, 1000, 0, light: true)
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({}, 1000, 0, light: true)
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({}, 1000, 0, light: true)
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
- @feedback_expires_at = duration > 0 ? Time.now + duration : Time.now + 86400
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 >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
2348
- @current_folder, @current_folder.chomp('.') + '/', @load_limit
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 >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
2502
- @current_folder, @current_folder.chomp('.') + '/', @load_limit
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 >= ? AND folder < ?",
3723
- [folder_name, folder_name.chomp('.') + '/']
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 >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
3972
- folder_name, folder_name.chomp('.') + '/', @load_limit
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
- unless Heathrow::Notmuch.available?
4851
- set_feedback("notmuch not found", 196, 3)
4852
- return
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 (notmuch): ", "")
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
- basenames.each_slice(100) do |batch|
4875
- ph = batch.map { '?' }.join(',')
4876
- rows = @db.execute(
4877
- "SELECT * FROM messages WHERE external_id IN (#{ph})",
4878
- *batch
4879
- )
4880
- rows.each do |row|
4881
- row['recipients'] = JSON.parse(row['recipients']) if row['recipients']
4882
- row['metadata'] = JSON.parse(row['metadata']) if row['metadata']
4883
- row['labels'] = JSON.parse(row['labels']) if row['labels']
4884
- row['attachments'] = JSON.parse(row['attachments']) if row['attachments']
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
- results.concat(rows)
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("#{files.size} files found but none in DB", 226, 3)
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
- set_feedback("#{results.size} results for: #{query}", 156, 3)
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
- def compose_new_mail(source)
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, 3)
5736
- render_left_pane if orig_id
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. Read status
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 "wrote:" attribution lines (start of indented quote block)
7747
- if stripped =~ /\bwrote:\s*$/
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 = []
@@ -1,3 +1,3 @@
1
1
  module Heathrow
2
- VERSION = '0.7.3'
2
+ VERSION = '0.7.5'
3
3
  end
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.3
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-18 00:00:00.000000000 Z
12
+ date: 2026-03-20 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses