sup 0.19.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.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +12 -0
  4. data/CONTRIBUTORS +84 -0
  5. data/Gemfile +3 -0
  6. data/HACKING +42 -0
  7. data/History.txt +361 -0
  8. data/LICENSE +280 -0
  9. data/README.md +70 -0
  10. data/Rakefile +12 -0
  11. data/ReleaseNotes +231 -0
  12. data/bin/sup +434 -0
  13. data/bin/sup-add +118 -0
  14. data/bin/sup-config +243 -0
  15. data/bin/sup-dump +43 -0
  16. data/bin/sup-import-dump +101 -0
  17. data/bin/sup-psych-ify-config-files +21 -0
  18. data/bin/sup-recover-sources +87 -0
  19. data/bin/sup-sync +210 -0
  20. data/bin/sup-sync-back-maildir +127 -0
  21. data/bin/sup-tweak-labels +140 -0
  22. data/contrib/colorpicker.rb +100 -0
  23. data/contrib/completion/_sup.zsh +114 -0
  24. data/devel/console.sh +3 -0
  25. data/devel/count-loc.sh +3 -0
  26. data/devel/load-index.rb +9 -0
  27. data/devel/profile.rb +12 -0
  28. data/devel/start-console.rb +5 -0
  29. data/doc/FAQ.txt +119 -0
  30. data/doc/Hooks.txt +79 -0
  31. data/doc/Philosophy.txt +69 -0
  32. data/lib/sup.rb +467 -0
  33. data/lib/sup/account.rb +90 -0
  34. data/lib/sup/buffer.rb +768 -0
  35. data/lib/sup/colormap.rb +239 -0
  36. data/lib/sup/contact.rb +67 -0
  37. data/lib/sup/crypto.rb +461 -0
  38. data/lib/sup/draft.rb +119 -0
  39. data/lib/sup/hook.rb +159 -0
  40. data/lib/sup/horizontal_selector.rb +59 -0
  41. data/lib/sup/idle.rb +42 -0
  42. data/lib/sup/index.rb +882 -0
  43. data/lib/sup/interactive_lock.rb +89 -0
  44. data/lib/sup/keymap.rb +140 -0
  45. data/lib/sup/label.rb +87 -0
  46. data/lib/sup/logger.rb +77 -0
  47. data/lib/sup/logger/singleton.rb +10 -0
  48. data/lib/sup/maildir.rb +257 -0
  49. data/lib/sup/mbox.rb +187 -0
  50. data/lib/sup/message.rb +803 -0
  51. data/lib/sup/message_chunks.rb +328 -0
  52. data/lib/sup/mode.rb +140 -0
  53. data/lib/sup/modes/buffer_list_mode.rb +50 -0
  54. data/lib/sup/modes/completion_mode.rb +55 -0
  55. data/lib/sup/modes/compose_mode.rb +38 -0
  56. data/lib/sup/modes/console_mode.rb +125 -0
  57. data/lib/sup/modes/contact_list_mode.rb +148 -0
  58. data/lib/sup/modes/edit_message_async_mode.rb +110 -0
  59. data/lib/sup/modes/edit_message_mode.rb +728 -0
  60. data/lib/sup/modes/file_browser_mode.rb +109 -0
  61. data/lib/sup/modes/forward_mode.rb +82 -0
  62. data/lib/sup/modes/help_mode.rb +19 -0
  63. data/lib/sup/modes/inbox_mode.rb +85 -0
  64. data/lib/sup/modes/label_list_mode.rb +138 -0
  65. data/lib/sup/modes/label_search_results_mode.rb +38 -0
  66. data/lib/sup/modes/line_cursor_mode.rb +203 -0
  67. data/lib/sup/modes/log_mode.rb +57 -0
  68. data/lib/sup/modes/person_search_results_mode.rb +12 -0
  69. data/lib/sup/modes/poll_mode.rb +19 -0
  70. data/lib/sup/modes/reply_mode.rb +228 -0
  71. data/lib/sup/modes/resume_mode.rb +52 -0
  72. data/lib/sup/modes/scroll_mode.rb +252 -0
  73. data/lib/sup/modes/search_list_mode.rb +204 -0
  74. data/lib/sup/modes/search_results_mode.rb +59 -0
  75. data/lib/sup/modes/text_mode.rb +76 -0
  76. data/lib/sup/modes/thread_index_mode.rb +1033 -0
  77. data/lib/sup/modes/thread_view_mode.rb +941 -0
  78. data/lib/sup/person.rb +134 -0
  79. data/lib/sup/poll.rb +272 -0
  80. data/lib/sup/rfc2047.rb +56 -0
  81. data/lib/sup/search.rb +110 -0
  82. data/lib/sup/sent.rb +58 -0
  83. data/lib/sup/service/label_service.rb +45 -0
  84. data/lib/sup/source.rb +244 -0
  85. data/lib/sup/tagger.rb +50 -0
  86. data/lib/sup/textfield.rb +253 -0
  87. data/lib/sup/thread.rb +452 -0
  88. data/lib/sup/time.rb +93 -0
  89. data/lib/sup/undo.rb +38 -0
  90. data/lib/sup/update.rb +30 -0
  91. data/lib/sup/util.rb +747 -0
  92. data/lib/sup/util/ncurses.rb +274 -0
  93. data/lib/sup/util/path.rb +9 -0
  94. data/lib/sup/util/query.rb +17 -0
  95. data/lib/sup/util/uri.rb +15 -0
  96. data/lib/sup/version.rb +3 -0
  97. data/sup.gemspec +53 -0
  98. data/test/dummy_source.rb +61 -0
  99. data/test/gnupg_test_home/gpg.conf +1 -0
  100. data/test/gnupg_test_home/pubring.gpg +0 -0
  101. data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
  102. data/test/gnupg_test_home/receiver_secring.gpg +0 -0
  103. data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
  104. data/test/gnupg_test_home/secring.gpg +0 -0
  105. data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
  106. data/test/gnupg_test_home/trustdb.gpg +0 -0
  107. data/test/integration/test_label_service.rb +18 -0
  108. data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
  109. data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
  110. data/test/messages/missing-line.eml +9 -0
  111. data/test/test_crypto.rb +109 -0
  112. data/test/test_header_parsing.rb +168 -0
  113. data/test/test_helper.rb +7 -0
  114. data/test/test_message.rb +532 -0
  115. data/test/test_messages_dir.rb +147 -0
  116. data/test/test_yaml_migration.rb +85 -0
  117. data/test/test_yaml_regressions.rb +17 -0
  118. data/test/unit/service/test_label_service.rb +19 -0
  119. data/test/unit/test_horizontal_selector.rb +40 -0
  120. data/test/unit/util/test_query.rb +46 -0
  121. data/test/unit/util/test_string.rb +57 -0
  122. data/test/unit/util/test_uri.rb +19 -0
  123. metadata +423 -0
@@ -0,0 +1,134 @@
1
+ module Redwood
2
+
3
+ class Person
4
+ attr_accessor :name, :email
5
+
6
+ def initialize name, email
7
+ raise ArgumentError, "email can't be nil" unless email
8
+
9
+ email.fix_encoding!
10
+
11
+ @name = if name
12
+ name.fix_encoding!
13
+ name = name.strip.gsub(/\s+/, " ")
14
+ name =~ /^(['"]\s*)(.*?)(\s*["'])$/ ? $2 : name
15
+ name.gsub('\\\\', '\\')
16
+ end
17
+
18
+ @email = email.strip.gsub(/\s+/, " ")
19
+ end
20
+
21
+ def to_s; "#@name <#@email>" end
22
+
23
+ # def == o; o && o.email == email; end
24
+ # alias :eql? :==
25
+ # def hash; [name, email].hash; end
26
+
27
+ def shortname
28
+ case @name
29
+ when /\S+, (\S+)/
30
+ $1
31
+ when /(\S+) \S+/
32
+ $1
33
+ when nil
34
+ @email
35
+ else
36
+ @name
37
+ end
38
+ end
39
+
40
+ def longname
41
+ if @name && @email
42
+ "#@name <#@email>"
43
+ else
44
+ @email
45
+ end
46
+ end
47
+
48
+ def mediumname; @name || @email; end
49
+
50
+ def Person.full_address name, email
51
+ if name && email
52
+ if name =~ /[",@]/
53
+ "#{name.inspect} <#{email}>" # escape quotes
54
+ else
55
+ "#{name} <#{email}>"
56
+ end
57
+ else
58
+ email
59
+ end
60
+ end
61
+
62
+ def full_address
63
+ Person.full_address @name, @email
64
+ end
65
+
66
+ ## when sorting addresses, sort by this
67
+ def sort_by_me
68
+ case @name
69
+ when /^(\S+), \S+/
70
+ $1
71
+ when /^\S+ \S+ (\S+)/
72
+ $1
73
+ when /^\S+ (\S+)/
74
+ $1
75
+ when nil
76
+ @email
77
+ else
78
+ @name
79
+ end.downcase
80
+ end
81
+
82
+ ## return "canonical" person using contact manager or create one if
83
+ ## not found or contact manager not available
84
+ def self.from_name_and_email name, email
85
+ ContactManager.instantiated? && ContactManager.person_for(email) || Person.new(name, email)
86
+ end
87
+
88
+ def self.from_address s
89
+ return nil if s.nil?
90
+
91
+ ## try and parse an email address and name
92
+ name, email = case s
93
+ when /(.+?) ((\S+?)@\S+) \3/
94
+ ## ok, this first match cause is insane, but bear with me. email
95
+ ## addresses are stored in the to/from/etc fields of the index in a
96
+ ## weird format: "name address first-part-of-address", i.e. spaces
97
+ ## separating those three bits, and no <>'s. this is the output of
98
+ ## #indexable_content. here, we reverse-engineer that format to extract
99
+ ## a valid address.
100
+ ##
101
+ ## we store things this way to allow searches on a to/from/etc field to
102
+ ## match any of those parts. a more robust solution would be to store a
103
+ ## separate, non-indexed field with the proper headers. but this way we
104
+ ## save precious bits, and it's backwards-compatible with older indexes.
105
+ [$1, $2]
106
+ when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/
107
+ a, b = $1, $2
108
+ [a.gsub('\"', '"'), b]
109
+ when /<((\S+?)@\S+?)>/
110
+ [$2, $1]
111
+ when /((\S+?)@\S+)/
112
+ [$2, $1]
113
+ else
114
+ [nil, s]
115
+ end
116
+
117
+ from_name_and_email name, email
118
+ end
119
+
120
+ def self.from_address_list ss
121
+ return [] if ss.nil?
122
+ ss.dup.split_on_commas.map { |s| self.from_address s }
123
+ end
124
+
125
+ ## see comments in self.from_address
126
+ def indexable_content
127
+ [name, email, email.split(/@/).first].join(" ")
128
+ end
129
+
130
+ def eql? o; email.eql? o.email end
131
+ def hash; email.hash end
132
+ end
133
+
134
+ end
@@ -0,0 +1,272 @@
1
+ require 'thread'
2
+
3
+ module Redwood
4
+
5
+ class PollManager
6
+ include Redwood::Singleton
7
+
8
+ HookManager.register "before-add-message", <<EOS
9
+ Executes immediately before a message is added to the index.
10
+ Variables:
11
+ message: the new message
12
+ EOS
13
+
14
+ HookManager.register "before-poll", <<EOS
15
+ Executes immediately before a poll for new messages commences.
16
+ No variables.
17
+ EOS
18
+
19
+ HookManager.register "after-poll", <<EOS
20
+ Executes immediately after a poll for new messages completes.
21
+ Variables:
22
+ num: the total number of new messages added in this poll
23
+ num_inbox: the number of new messages added in this poll which
24
+ appear in the inbox (i.e. were not auto-archived).
25
+ num_total: the total number of messages
26
+ num_inbox_total: the total number of new messages in the inbox.
27
+ num_inbox_total_unread: the total number of unread messages in the inbox
28
+ num_updated: the total number of updated messages
29
+ num_deleted: the total number of deleted messages
30
+ labels: the labels that were applied
31
+ from_and_subj: an array of (from email address, subject) pairs
32
+ from_and_subj_inbox: an array of (from email address, subject) pairs for
33
+ only those messages appearing in the inbox
34
+ EOS
35
+
36
+ def initialize
37
+ @delay = $config[:poll_interval] || 300
38
+ @mutex = Mutex.new
39
+ @thread = nil
40
+ @last_poll = nil
41
+ @polling = Mutex.new
42
+ @poll_sources = nil
43
+ @mode = nil
44
+ @should_clear_running_totals = false
45
+ clear_running_totals # defines @running_totals
46
+ UpdateManager.register self
47
+ end
48
+
49
+ def poll_with_sources
50
+ @mode ||= PollMode.new
51
+
52
+ if HookManager.enabled? "before-poll"
53
+ HookManager.run("before-poll")
54
+ else
55
+ BufferManager.flash "Polling for new messages..."
56
+ end
57
+
58
+ num, numi, numu, numd, from_and_subj, from_and_subj_inbox, loaded_labels = @mode.poll
59
+ clear_running_totals if @should_clear_running_totals
60
+ @running_totals[:num] += num
61
+ @running_totals[:numi] += numi
62
+ @running_totals[:numu] += numu
63
+ @running_totals[:numd] += numd
64
+ @running_totals[:loaded_labels] += loaded_labels || []
65
+
66
+
67
+ if HookManager.enabled? "after-poll"
68
+ hook_args = { :num => num, :num_inbox => numi,
69
+ :num_total => @running_totals[:num], :num_inbox_total => @running_totals[:numi],
70
+ :num_updated => @running_totals[:numu],
71
+ :num_deleted => @running_totals[:numd],
72
+ :labels => @running_totals[:loaded_labels],
73
+ :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox,
74
+ :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] } }
75
+
76
+ HookManager.run("after-poll", hook_args)
77
+ else
78
+ if @running_totals[:num] > 0
79
+ flash_msg = "Loaded #{@running_totals[:num].pluralize 'new message'}, #{@running_totals[:numi]} to inbox. " if @running_totals[:num] > 0
80
+ flash_msg += "Updated #{@running_totals[:numu].pluralize 'message'}. " if @running_totals[:numu] > 0
81
+ flash_msg += "Deleted #{@running_totals[:numd].pluralize 'message'}. " if @running_totals[:numd] > 0
82
+ flash_msg += "Labels: #{@running_totals[:loaded_labels].map{|l| l.to_s}.join(', ')}." if @running_totals[:loaded_labels].size > 0
83
+ BufferManager.flash flash_msg
84
+ else
85
+ BufferManager.flash "No new messages."
86
+ end
87
+ end
88
+
89
+ end
90
+
91
+ def poll
92
+ if @polling.try_lock
93
+ @poll_sources = SourceManager.usual_sources
94
+ num, numi = poll_with_sources
95
+ @polling.unlock
96
+ [num, numi]
97
+ else
98
+ debug "poll already in progress."
99
+ return
100
+ end
101
+ end
102
+
103
+ def poll_unusual
104
+ if @polling.try_lock
105
+ @poll_sources = SourceManager.unusual_sources
106
+ num, numi = poll_with_sources
107
+ @polling.unlock
108
+ [num, numi]
109
+ else
110
+ debug "poll_unusual already in progress."
111
+ return
112
+ end
113
+ end
114
+
115
+ def start
116
+ @thread = Redwood::reporting_thread("periodic poll") do
117
+ while true
118
+ sleep @delay / 2
119
+ poll if @last_poll.nil? || (Time.now - @last_poll) >= @delay
120
+ end
121
+ end
122
+ end
123
+
124
+ def stop
125
+ @thread.kill if @thread
126
+ @thread = nil
127
+ end
128
+
129
+ def do_poll
130
+ total_num = total_numi = total_numu = total_numd = 0
131
+ from_and_subj = []
132
+ from_and_subj_inbox = []
133
+ loaded_labels = Set.new
134
+
135
+ @mutex.synchronize do
136
+ @poll_sources.each do |source|
137
+ begin
138
+ yield "Loading from #{source}... "
139
+ rescue SourceError => e
140
+ warn "problem getting messages from #{source}: #{e.message}"
141
+ next
142
+ end
143
+
144
+ msg = ""
145
+ num = numi = numu = numd = 0
146
+ poll_from source do |action,m,old_m,progress|
147
+ if action == :delete
148
+ yield "Deleting #{m.id}"
149
+ loaded_labels.merge m.labels
150
+ numd += 1
151
+ elsif action == :update
152
+ yield "Message at #{m.source_info} is an update of an old message. Updating labels from #{old_m.labels.to_a * ','} => #{m.labels.to_a * ','}"
153
+ loaded_labels.merge m.labels
154
+ numu += 1
155
+ elsif action == :add
156
+ if old_m
157
+ new_locations = (m.locations - old_m.locations)
158
+ if not new_locations.empty?
159
+ yield "Message at #{new_locations[0].info} has changed its source location. Updating labels from #{old_m.labels.to_a * ','} => #{m.labels.to_a * ','}"
160
+ numu += 1
161
+ else
162
+ yield "Skipping already-imported message at #{m.locations[-1].info}"
163
+ end
164
+ else
165
+ yield "Found new message at #{m.source_info} with labels #{m.labels.to_a * ','}"
166
+ loaded_labels.merge m.labels
167
+ num += 1
168
+ from_and_subj << [m.from && m.from.longname, m.subj]
169
+ if (m.labels & [:inbox, :spam, :deleted, :killed]) == Set.new([:inbox])
170
+ from_and_subj_inbox << [m.from && m.from.longname, m.subj]
171
+ numi += 1
172
+ end
173
+ end
174
+ else fail
175
+ end
176
+ end
177
+ msg += "Found #{num} messages, #{numi} to inbox. " unless num == 0
178
+ msg += "Updated #{numu} messages. " unless numu == 0
179
+ msg += "Deleted #{numd} messages." unless numd == 0
180
+ yield msg unless msg == ""
181
+ total_num += num
182
+ total_numi += numi
183
+ total_numu += numu
184
+ total_numd += numd
185
+ end
186
+
187
+ loaded_labels = loaded_labels - LabelManager::HIDDEN_RESERVED_LABELS - [:inbox, :killed]
188
+ yield "Done polling; loaded #{total_num} new messages total"
189
+ @last_poll = Time.now
190
+ end
191
+ [total_num, total_numi, total_numu, total_numd, from_and_subj, from_and_subj_inbox, loaded_labels]
192
+ end
193
+
194
+ ## like Source#poll, but yields successive Message objects, which have their
195
+ ## labels and locations set correctly. The Messages are saved to or removed
196
+ ## from the index after being yielded.
197
+ def poll_from source, opts={}
198
+ debug "trying to acquire poll lock for: #{source}..."
199
+ if source.try_lock
200
+ begin
201
+ source.poll do |sym, args|
202
+ case sym
203
+ when :add
204
+ m = Message.build_from_source source, args[:info]
205
+ old_m = Index.build_message m.id
206
+ m.labels += args[:labels]
207
+ m.labels.delete :inbox if source.archived?
208
+ m.labels.delete :unread if source.read?
209
+ m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
210
+ m.labels.each { |l| LabelManager << l }
211
+ m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m
212
+ m.locations = old_m.locations + m.locations if old_m
213
+ HookManager.run "before-add-message", :message => m
214
+ yield :add, m, old_m, args[:progress] if block_given?
215
+ Index.sync_message m, true
216
+
217
+ if Index.message_joining_killed? m
218
+ m.labels += [:killed]
219
+ Index.sync_message m, true
220
+ end
221
+
222
+ ## We need to add or unhide the message when it either did not exist
223
+ ## before at all or when it was updated. We do *not* add/unhide when
224
+ ## the same message was found at a different location
225
+ if old_m
226
+ UpdateManager.relay self, :updated, m
227
+ elsif !old_m or not old_m.locations.member? m.location
228
+ UpdateManager.relay self, :added, m
229
+ end
230
+ when :delete
231
+ Index.each_message({:location => [source.id, args[:info]]}, false) do |m|
232
+ m.locations.delete Location.new(source, args[:info])
233
+ Index.sync_message m, false
234
+ if m.locations.size == 0
235
+ yield :delete, m, [source,args[:info]], args[:progress] if block_given?
236
+ Index.delete m.id
237
+ UpdateManager.relay self, :location_deleted, m
238
+ end
239
+ end
240
+ when :update
241
+ Index.each_message({:location => [source.id, args[:old_info]]}, false) do |m|
242
+ old_m = Index.build_message m.id
243
+ m.locations.delete Location.new(source, args[:old_info])
244
+ m.locations.push Location.new(source, args[:new_info])
245
+ ## Update labels that might have been modified remotely
246
+ m.labels -= source.supported_labels?
247
+ m.labels += args[:labels]
248
+ yield :update, m, old_m if block_given?
249
+ Index.sync_message m, true
250
+ UpdateManager.relay self, :updated, m
251
+ end
252
+ end
253
+ end
254
+
255
+ rescue SourceError => e
256
+ warn "problem getting messages from #{source}: #{e.message}"
257
+
258
+ ensure
259
+ source.go_idle
260
+ source.unlock
261
+ end
262
+ else
263
+ debug "source #{source} is already being polled."
264
+ end
265
+ end
266
+
267
+ def handle_idle_update sender, idle_since; @should_clear_running_totals = false; end
268
+ def handle_unidle_update sender, idle_since; @should_clear_running_totals = true; clear_running_totals; end
269
+ def clear_running_totals; @running_totals = {:num => 0, :numi => 0, :numu => 0, :numd => 0, :loaded_labels => Set.new}; end
270
+ end
271
+
272
+ end
@@ -0,0 +1,56 @@
1
+ ## from: http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/101949
2
+
3
+ # $Id: rfc2047.rb,v 1.4 2003/04/18 20:55:56 sam Exp $
4
+ # MODIFIED slightly by William Morgan
5
+ #
6
+ # An implementation of RFC 2047 decoding.
7
+ #
8
+ # This module depends on the iconv library by Nobuyoshi Nakada, which I've
9
+ # heard may be distributed as a standard part of Ruby 1.8. Many thanks to him
10
+ # for helping with building and using iconv.
11
+ #
12
+ # Thanks to "Josef 'Jupp' Schugt" <jupp / gmx.de> for pointing out an error with
13
+ # stateful character sets.
14
+ #
15
+ # Copyright (c) Sam Roberts <sroberts / uniserve.com> 2004
16
+ #
17
+ # This file is distributed under the same terms as Ruby.
18
+
19
+ module Rfc2047
20
+ WORD = %r{=\?([!\#$%&'*+-/0-9A-Z\\^\`a-z{|}~]+)\?([BbQq])\?([!->@-~]+)\?=} # :nodoc: 'stupid ruby-mode
21
+ WORDSEQ = %r{(#{WORD.source})\s+(?=#{WORD.source})}
22
+
23
+ def Rfc2047.is_encoded? s; s =~ WORD end
24
+
25
+ # Decodes a string, +from+, containing RFC 2047 encoded words into a target
26
+ # character set, +target+. See iconv_open(3) for information on the
27
+ # supported target encodings. If one of the encoded words cannot be
28
+ # converted to the target encoding, it is left in its encoded form.
29
+ def Rfc2047.decode_to(target, from)
30
+ from = from.gsub(WORDSEQ, '\1')
31
+ out = from.gsub(WORD) do
32
+ |word|
33
+ charset, encoding, text = $1, $2, $3
34
+
35
+ # B64 or QP decode, as necessary:
36
+ case encoding
37
+ when 'b', 'B'
38
+ #puts text
39
+ text = text.unpack('m*')[0]
40
+ #puts text.dump
41
+
42
+ when 'q', 'Q'
43
+ # RFC 2047 has a variant of quoted printable where a ' ' character
44
+ # can be represented as an '_', rather than =32, so convert
45
+ # any of these that we find before doing the QP decoding.
46
+ text = text.tr("_", " ")
47
+ text = text.unpack('M*')[0]
48
+
49
+ # Don't need an else, because no other values can be matched in a
50
+ # WORD.
51
+ end
52
+
53
+ text.transcode(target, charset)
54
+ end
55
+ end
56
+ end