heathrow 0.7.4 → 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: 5157385f8be9722ca5d2920debecea14751c93ba6bdba10840004ee6a9ae301a
4
- data.tar.gz: f4eae36abc3446bc257c49ff60aca3ed6ef6b1bb981bb753aa9cc5884a74796d
3
+ metadata.gz: e5276cc34b418c9d3b080eb711a1870857679ca87681c58436e6207ff37e7106
4
+ data.tar.gz: 8a10f16633f2ac610bc191434566f883319b570a2ad773297373a8d5a4e477de
5
5
  SHA512:
6
- metadata.gz: dd461568292aef77abb3ef404d6a7d4d8472ef225f4da407a0dedb604ce8c590b0649455053a56c2a18e4d6023bcba81fcfa5bafaf5498eefe1756e6ee5870a1
7
- data.tar.gz: 97503edee0d70789701ccdccf02cbf7eff5aa91311deeca2c3d8d28197581ae243f1e04fb693b0780d34c1ae38162e3c1deb89feb34e8210105c76c246004f96
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]
@@ -2154,6 +2154,11 @@ module Heathrow
2154
2154
  # Colorize email content (quote levels + signature)
2155
2155
  content = colorize_email_content(content)
2156
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
+
2157
2162
  # Attachment list under header, before body
2158
2163
  att_text = format_attachments(msg['attachments'])
2159
2164
 
@@ -2175,10 +2180,14 @@ module Heathrow
2175
2180
  end
2176
2181
  html_hint = message_has_html?(msg) ? "HTML mail, press x to open in browser".fg(39) : nil
2177
2182
 
2183
+ # Parse calendar invites (ICS attachments)
2184
+ cal_text = format_calendar_event(msg['attachments'])
2185
+
2178
2186
  full_text = header.join("\n")
2179
2187
  full_text += "\n" + att_text if att_text
2180
2188
  full_text += "\n" + image_hint if image_hint
2181
2189
  full_text += "\n" + html_hint if html_hint
2190
+ full_text += "\n\n" + cal_text if cal_text
2182
2191
  full_text += "\n\n" + content
2183
2192
 
2184
2193
  @panes[:right].text = full_text
@@ -2360,8 +2369,8 @@ module Heathrow
2360
2369
  if @current_folder
2361
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"
2362
2371
  results = @db.execute(
2363
- "SELECT #{light_cols} FROM messages WHERE folder >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
2364
- @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
2365
2374
  )
2366
2375
  @filtered_messages = results
2367
2376
  elsif @current_view == 'A'
@@ -2462,6 +2471,7 @@ module Heathrow
2462
2471
  when 'subject' then db_filters[:subject_pattern] = value
2463
2472
  when 'folder' then db_filters[:maildir_folder] = value
2464
2473
  when 'label' then db_filters[:label] = value
2474
+ when 'source' then db_filters[:source_name] = value
2465
2475
  end
2466
2476
  end
2467
2477
  end
@@ -2514,8 +2524,8 @@ module Heathrow
2514
2524
  if @current_folder
2515
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"
2516
2526
  @filtered_messages = @db.execute(
2517
- "SELECT #{light_cols} FROM messages WHERE folder >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
2518
- @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
2519
2529
  )
2520
2530
  elsif @current_view == 'A'
2521
2531
  @filtered_messages = @db.get_messages({}, @load_limit, 0, light: true)
@@ -2665,6 +2675,7 @@ module Heathrow
2665
2675
  when 'subject' then db_filters[:subject_pattern] = value
2666
2676
  when 'folder' then db_filters[:maildir_folder] = value
2667
2677
  when 'label' then db_filters[:label] = value
2678
+ when 'source' then db_filters[:source_name] = value
2668
2679
  end
2669
2680
  end
2670
2681
  end
@@ -3736,8 +3747,8 @@ module Heathrow
3736
3747
  def folder_message_count(folder_name)
3737
3748
  # Use range query to leverage folder index (5x faster than OR + LIKE)
3738
3749
  row = @db.db.get_first_row(
3739
- "SELECT COUNT(*) as total, SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) as unread FROM messages WHERE folder >= ? AND folder < ?",
3740
- [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]
3741
3752
  )
3742
3753
  { total: (row && row['total']) || 0, unread: (row && row['unread']) || 0 }
3743
3754
  rescue
@@ -3985,8 +3996,8 @@ module Heathrow
3985
3996
  @load_limit = 200
3986
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"
3987
3998
  results = @db.execute(
3988
- "SELECT #{light_cols} FROM messages WHERE folder >= ? AND folder < ? ORDER BY timestamp DESC LIMIT ?",
3989
- 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
3990
4001
  )
3991
4002
 
3992
4003
  @filtered_messages = results
@@ -4864,47 +4875,83 @@ module Heathrow
4864
4875
  # Notmuch full-text search
4865
4876
  def notmuch_search
4866
4877
  require_relative '../notmuch'
4867
- unless Heathrow::Notmuch.available?
4868
- set_feedback("notmuch not found", 196, 3)
4869
- 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
4870
4905
  end
4871
4906
 
4872
- query = bottom_ask("Search (notmuch): ", "")
4907
+ query = bottom_ask("Search#{scope_label != 'all' ? " [#{scope_label}]" : ''}: ", "")
4873
4908
  return if query.nil? || query.strip.empty?
4874
4909
 
4875
4910
  @panes[:bottom].text = " Searching...".fg(226)
4876
4911
  @panes[:bottom].refresh
4877
4912
 
4878
- files = Heathrow::Notmuch.search_files(query)
4879
- if files.empty?
4880
- set_feedback("No results for: #{query}", 226, 3)
4881
- return
4882
- end
4883
-
4884
- # Map file paths back to Heathrow messages via metadata.maildir_file
4885
- # Use basename matching since paths may differ slightly
4886
- basenames = files.map { |f| File.basename(f) }
4887
- placeholders = basenames.map { '?' }.join(',')
4888
-
4889
- # Search by external_id (which is the filename basename)
4890
4913
  results = []
4891
- basenames.each_slice(100) do |batch|
4892
- ph = batch.map { '?' }.join(',')
4893
- rows = @db.execute(
4894
- "SELECT * FROM messages WHERE external_id IN (#{ph})",
4895
- *batch
4896
- )
4897
- rows.each do |row|
4898
- row['recipients'] = JSON.parse(row['recipients']) if row['recipients']
4899
- row['metadata'] = JSON.parse(row['metadata']) if row['metadata']
4900
- row['labels'] = JSON.parse(row['labels']) if row['labels']
4901
- 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
4902
4938
  end
4903
- 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)
4904
4951
  end
4905
4952
 
4906
4953
  if results.empty?
4907
- set_feedback("#{files.size} files found but none in DB", 226, 3)
4954
+ set_feedback("No results for: #{query}", 226, 3)
4908
4955
  return
4909
4956
  end
4910
4957
 
@@ -4912,11 +4959,12 @@ module Heathrow
4912
4959
  @current_view = 'A'
4913
4960
  @in_source_view = false
4914
4961
  @panes[:right].content_update = true
4915
- @current_source_filter = "Search: #{query}"
4962
+ @current_source_filter = "Search: #{query}#{scope_label != 'all' ? " [#{scope_label}]" : ''}"
4916
4963
  @filtered_messages = results
4917
4964
  sort_messages
4918
4965
  @index = 0
4919
- 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)
4920
4968
  render_all
4921
4969
  end
4922
4970
 
@@ -7383,7 +7431,16 @@ Required: URL, optional CSS selector
7383
7431
  new_rules << { 'field' => 'subject', 'op' => 'like', 'value' => subject_input }
7384
7432
  end
7385
7433
 
7386
- # 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
7387
7444
  current_read = current_vals['read_=']
7388
7445
  default_read = current_read == false ? 'y' : (current_read == true ? 'n' : '')
7389
7446
  read_input = bottom_ask("Unread only? (y/n/Enter for all, ESC cancel): ", default_read)
@@ -8008,6 +8065,180 @@ Required: URL, optional CSS selector
8008
8065
  end
8009
8066
 
8010
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
+
8011
8242
  def format_attachments(attachments)
8012
8243
  return nil unless attachments.is_a?(Array) && !attachments.empty?
8013
8244
  lines = []
@@ -1,3 +1,3 @@
1
1
  module Heathrow
2
- VERSION = '0.7.4'
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.4
4
+ version: 0.7.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene