whisperblog 0.6
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.
- data/Changelog +23 -0
- data/LICENSE +661 -0
- data/README +59 -0
- data/bin/whisper +91 -0
- data/bin/whisper-init +40 -0
- data/bin/whisper-post +88 -0
- data/bin/whisper-process-email +54 -0
- data/lib/whisper.rb +52 -0
- data/lib/whisper/author_tracker.rb +46 -0
- data/lib/whisper/blog.rb +156 -0
- data/lib/whisper/cached_file.rb +65 -0
- data/lib/whisper/comment.rb +35 -0
- data/lib/whisper/comment_set.rb +41 -0
- data/lib/whisper/common.rb +276 -0
- data/lib/whisper/config.rb +49 -0
- data/lib/whisper/dir_scanner.rb +76 -0
- data/lib/whisper/dir_set.rb +50 -0
- data/lib/whisper/email_receiver.rb +212 -0
- data/lib/whisper/email_sender.rb +114 -0
- data/lib/whisper/entry.rb +44 -0
- data/lib/whisper/entry_set.rb +41 -0
- data/lib/whisper/handler.rb +99 -0
- data/lib/whisper/mbox.rb +141 -0
- data/lib/whisper/page.rb +118 -0
- data/lib/whisper/rfc2047.rb +79 -0
- data/lib/whisper/router.rb +50 -0
- data/lib/whisper/server.rb +88 -0
- data/lib/whisper/text.rb +252 -0
- data/lib/whisper/timed_map.rb +43 -0
- data/lib/whisper/version.rb +3 -0
- data/share/whisper/comment-email.rtxt +36 -0
- data/share/whisper/config.yaml +53 -0
- data/share/whisper/entry-email.rtxt +35 -0
- data/share/whisper/entry.rhtml +88 -0
- data/share/whisper/entry.rtxt +19 -0
- data/share/whisper/formatter.rb +52 -0
- data/share/whisper/helper.rb +147 -0
- data/share/whisper/list.rhtml +51 -0
- data/share/whisper/list.rrss +9 -0
- data/share/whisper/list.rtxt +25 -0
- data/share/whisper/master.rhtml +81 -0
- data/share/whisper/master.rrss +21 -0
- data/share/whisper/master.rtxt +5 -0
- data/share/whisper/mootools.js +282 -0
- data/share/whisper/rss-badge.png +0 -0
- data/share/whisper/spinner.gif +0 -0
- data/share/whisper/style.css +314 -0
- metadata +239 -0
@@ -0,0 +1,44 @@
|
|
1
|
+
require "whisper/common"
|
2
|
+
require "whisper/text"
|
3
|
+
|
4
|
+
module Whisper
|
5
|
+
|
6
|
+
## a blog entry. a text plus a title, basically.
|
7
|
+
class Entry
|
8
|
+
include Loggy
|
9
|
+
include Dependency
|
10
|
+
|
11
|
+
def initialize meta_file, content_file
|
12
|
+
@text = Text.new meta_file, content_file
|
13
|
+
dependency_init
|
14
|
+
end
|
15
|
+
|
16
|
+
def dependencies; [@text] end
|
17
|
+
def title type; content type end
|
18
|
+
def build old, type
|
19
|
+
case type
|
20
|
+
when :html, :rss; build_html_title
|
21
|
+
when :txt, :email; build_textile_title
|
22
|
+
else raise ArgumentError, "unknown type #{type.inspect}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
[:labels, :published, :updated, :id, :author, :meta_file, :content_file].each { |m| define_method(m) { @text.send m } }
|
27
|
+
def body format, opts={}; @text.body format, opts end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def build_html_title
|
32
|
+
debug "rendering HTML title for #{@text.meta_file.path}"
|
33
|
+
RedCloth.new(@text.meta[:title]).to_html.gsub(%r,^<p>|</p>$,, "") # TODO: figure out a better way to remove these
|
34
|
+
end
|
35
|
+
|
36
|
+
## since the title typically is one line and without any of the textile stuff
|
37
|
+
## we actually want to tweak, we also use this for the text title.
|
38
|
+
def build_textile_title
|
39
|
+
debug "rendering textile title"
|
40
|
+
@text.meta[:title]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "whisper/common"
|
2
|
+
require "whisper/dir_set"
|
3
|
+
require "whisper/entry"
|
4
|
+
|
5
|
+
module Whisper
|
6
|
+
|
7
|
+
## the set of all entries
|
8
|
+
class EntrySet
|
9
|
+
include Loggy
|
10
|
+
include Dependency
|
11
|
+
|
12
|
+
def initialize dir
|
13
|
+
@dirset = DirSet.new dir, Entry
|
14
|
+
@entries = []
|
15
|
+
dependency_init
|
16
|
+
end
|
17
|
+
|
18
|
+
def dependencies; [@dirset] + @entries end
|
19
|
+
|
20
|
+
%w(entries entries_by_id entries_by_label entries_by_author entries_by_page).each do |f|
|
21
|
+
f = f.intern
|
22
|
+
define_method(f) { content[f] }
|
23
|
+
end
|
24
|
+
|
25
|
+
def size; entries.size end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def build old
|
30
|
+
debug "resorting entries and recompiling entry indices"
|
31
|
+
|
32
|
+
@entries = @dirset.things.sort_by { |e| e.published }.reverse
|
33
|
+
{ :entries => @entries,
|
34
|
+
:entries_by_id => @entries.map_by { |e| e.id },
|
35
|
+
:entries_by_author => @entries.group_by { |e| e.author.obfuscated_name },
|
36
|
+
:entries_by_label => @entries.group_by_multiple { |e| e.labels },
|
37
|
+
}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require "whisper/common"
|
3
|
+
require "whisper/timed_map"
|
4
|
+
|
5
|
+
module Whisper
|
6
|
+
|
7
|
+
class InvalidPageError < StandardError; end
|
8
|
+
|
9
|
+
## I borrowed a bit from Rails's syntax, but the semantics are not the same.
|
10
|
+
## Anything with + signs will be treated as a substitutable path component.
|
11
|
+
## You can optionally end the path spec with :format or /:page:format.
|
12
|
+
class Handler
|
13
|
+
include Loggy
|
14
|
+
|
15
|
+
DEFAULT_FORMAT = "html"
|
16
|
+
|
17
|
+
def initialize request_method, pathspec, &page_creator
|
18
|
+
@request_method = request_method
|
19
|
+
@pathspec = pathspec
|
20
|
+
@page_creator = page_creator
|
21
|
+
@map = TimedMap.new
|
22
|
+
@uses_page = @uses_format = false
|
23
|
+
|
24
|
+
re = @pathspec.gsub(/\+(\w+)/) do
|
25
|
+
'([^\.\/][^\/]*?)' # regular path components must start with a non-., non-/, followed by 0 or more non-/es
|
26
|
+
end.sub(/\/:page:format$/) do
|
27
|
+
@uses_page = @uses_format = true
|
28
|
+
'\/?(\d+)?(?:\.([^\/]+))?'
|
29
|
+
end.sub(/:format$/) do
|
30
|
+
@uses_format = true
|
31
|
+
'(?:\.([a-zA-Z]+))?'
|
32
|
+
end
|
33
|
+
@re = /^#{re}$/
|
34
|
+
end
|
35
|
+
|
36
|
+
def handle path, params, request_method, route
|
37
|
+
#debug "comparing #{request_method.inspect} against #{@request_method.inspect}"
|
38
|
+
return unless (request_method == @request_method) || # special case:
|
39
|
+
(request_method == :head && @request_method == :get) # proceed!
|
40
|
+
|
41
|
+
#debug "comparing #{path.inspect} against #{@re} => #{@re.match(path) ? true : false}"
|
42
|
+
match = @re.match(path) or return
|
43
|
+
#debug "have captures: #{match.captures.inspect}"
|
44
|
+
|
45
|
+
## assemble the variables to be passed to the caller function
|
46
|
+
vars = match.captures
|
47
|
+
vars[vars.length - 1] ||= DEFAULT_FORMAT if @uses_format
|
48
|
+
vars[vars.length - 2] = (vars[vars.length - 2].to_i || 0) if @uses_page
|
49
|
+
vars << params
|
50
|
+
|
51
|
+
## make a key from the path and the query parameters, if any. the key is basically
|
52
|
+
## a normalized query string.
|
53
|
+
key = path + "?" + params.sort_by { |k, v| [k, v] }.map { |k, v| k + "=" + v }.join("&")
|
54
|
+
|
55
|
+
case request_method
|
56
|
+
when :post # don't cache
|
57
|
+
make route, vars
|
58
|
+
else
|
59
|
+
@map.prune!
|
60
|
+
@map[key] ||= begin
|
61
|
+
make(route, vars) || :invalid
|
62
|
+
rescue SystemCallError => e
|
63
|
+
:invalid
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def contents; @map.values end
|
69
|
+
|
70
|
+
def url_for opts={}
|
71
|
+
url = @pathspec.gsub(/[:+](\w+)/) do
|
72
|
+
spec = $1.intern
|
73
|
+
val = opts[spec]
|
74
|
+
val = CGI.escape(val.to_s) if val
|
75
|
+
case spec
|
76
|
+
when :format; val.nil? || (val == DEFAULT_FORMAT) ? "" : ".#{val}"
|
77
|
+
when :page; val == "0" ? "" : val
|
78
|
+
else
|
79
|
+
raise ArgumentError, "missing required path spec #{spec}" unless val
|
80
|
+
val
|
81
|
+
end
|
82
|
+
end
|
83
|
+
url += "##{opts[:anchor]}" if opts[:anchor]
|
84
|
+
url.gsub!(/\/\.([^\/\.]+)$/, ".\\1")
|
85
|
+
url
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def make route, vars
|
91
|
+
begin
|
92
|
+
@page_creator.call route, *vars
|
93
|
+
rescue InvalidPageError => e
|
94
|
+
:invalid
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
data/lib/whisper/mbox.rb
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'rmail'
|
2
|
+
require 'thread'
|
3
|
+
require "whisper/common"
|
4
|
+
require "whisper/rfc2047"
|
5
|
+
|
6
|
+
## stolen from sup
|
7
|
+
class RMail::Message
|
8
|
+
def charset
|
9
|
+
if header.field?("content-type") && header.fetch("content-type") =~ /charset="?(.*?)"?(;|$)/i
|
10
|
+
$1
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module Whisper
|
16
|
+
|
17
|
+
class Mbox
|
18
|
+
include Loggy
|
19
|
+
|
20
|
+
attr_reader :offset
|
21
|
+
|
22
|
+
def initialize fn, start_offset
|
23
|
+
@fn = fn
|
24
|
+
@offset = start_offset
|
25
|
+
@mutex = Mutex.new
|
26
|
+
end
|
27
|
+
|
28
|
+
def eof?
|
29
|
+
@mutex.synchronize { @offset >= File.size(@fn) }
|
30
|
+
end
|
31
|
+
|
32
|
+
def next_message
|
33
|
+
m = @mutex.synchronize do
|
34
|
+
debug "reading #{@fn}@#{@offset}..."
|
35
|
+
with_filehandle do |f|
|
36
|
+
string = ""
|
37
|
+
l = f.gets
|
38
|
+
string << l until f.eof? || is_break_line?(l = f.gets)
|
39
|
+
debug "read #{string.size} bytes"
|
40
|
+
return nil if string.empty?
|
41
|
+
@offset += string.size
|
42
|
+
begin
|
43
|
+
RMail::Parser.read string
|
44
|
+
rescue ArgumentError # sigh. invalid utf throws this completely generic exception
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
return if m.nil? # hit EOF
|
51
|
+
|
52
|
+
headers = {
|
53
|
+
"message-id" => m.header["message-id"],
|
54
|
+
"in-reply-to" => m.header["in-reply-to"],
|
55
|
+
"date" => m.header["date"],
|
56
|
+
"from" => Rfc2047.decode_to("utf-8", m.header["from"]),
|
57
|
+
}
|
58
|
+
|
59
|
+
text_part = find_text_part m
|
60
|
+
debug "text part of message has size #{text_part.body.size}: #{text_part.body[0..50].inspect}..."
|
61
|
+
body = if text_part
|
62
|
+
x = text_part.decode
|
63
|
+
x = Iconv.easy_decode("utf-8", text_part.charset, x) if text_part.charset
|
64
|
+
munge_body x
|
65
|
+
else
|
66
|
+
"_[This message contains no text/plain part. Bad commenter, no cookie! --ed.]_\n"
|
67
|
+
end
|
68
|
+
|
69
|
+
[body, headers]
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
## recurse through MIME structure looking for text
|
75
|
+
def find_text_part m
|
76
|
+
if m.header.content_type.nil? || m.header.content_type == "text/plain"
|
77
|
+
m
|
78
|
+
elsif m.multipart?
|
79
|
+
m.body.find { |p| find_text_part p }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def with_filehandle
|
84
|
+
begin
|
85
|
+
f = File.open @fn
|
86
|
+
f.seek @offset
|
87
|
+
correction = correct_offset f
|
88
|
+
if correction > 0
|
89
|
+
@offset += correction
|
90
|
+
f.seek @offset
|
91
|
+
debug "corrected offset forward by #{correction} bytes"
|
92
|
+
end
|
93
|
+
x = yield f
|
94
|
+
f.close
|
95
|
+
x
|
96
|
+
rescue SystemCallError => e
|
97
|
+
warn "can't open mbox: #{e.message}"
|
98
|
+
nil
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def munge_body body
|
103
|
+
## ok, not sure why this is happening, but at least in gmail messages
|
104
|
+
## random spaces seem to turn into =A0's in a quoted-printable
|
105
|
+
## encoding, which then turn into \240's when decoded. so i'm manually
|
106
|
+
## forcing these back to spaces. whether this is a good idea or not
|
107
|
+
## i don't know.
|
108
|
+
body#.
|
109
|
+
#gsub("\240", " ").
|
110
|
+
#gsub("\302", " ") # here's another one. what is this shit?
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
BREAK_RE = /^From \S+ (.+)$/ # stolen from sup
|
116
|
+
require 'time'
|
117
|
+
def is_break_line? l # stolen from sup
|
118
|
+
time = begin
|
119
|
+
l =~ BREAK_RE or return false
|
120
|
+
$1
|
121
|
+
rescue ArgumentError # happens with invalid utf-8, for example
|
122
|
+
return false
|
123
|
+
end
|
124
|
+
begin
|
125
|
+
## hack -- make Time.parse fail when trying to substitute values from Time.now
|
126
|
+
Time.parse time, 0
|
127
|
+
true
|
128
|
+
rescue NoMethodError
|
129
|
+
false
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def correct_offset f # stolen from sup
|
134
|
+
string = ""
|
135
|
+
until f.eof? || is_break_line?(l = f.gets)
|
136
|
+
string << l
|
137
|
+
end
|
138
|
+
string.size
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
data/lib/whisper/page.rb
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'cgi'
|
3
|
+
require "whisper/common"
|
4
|
+
|
5
|
+
module Whisper
|
6
|
+
|
7
|
+
class Page
|
8
|
+
include Loggy
|
9
|
+
include Dependency
|
10
|
+
|
11
|
+
## kind of insane the amount of stuff this thing actually needs
|
12
|
+
def initialize opts, &rebuilder
|
13
|
+
@format = opts[:format]
|
14
|
+
@url_vars = opts[:url_vars] # used for making links to related pages
|
15
|
+
@master_template = opts[:master_template]
|
16
|
+
@template = opts[:template]
|
17
|
+
@helper = opts[:helper]
|
18
|
+
@rebuilder = rebuilder
|
19
|
+
@dependencies = opts[:extra_deps] + [@master_template, @template, @helper]
|
20
|
+
@config = opts[:config]
|
21
|
+
@router = opts[:router]
|
22
|
+
@entryset = opts[:entryset]
|
23
|
+
|
24
|
+
dependency_init
|
25
|
+
end
|
26
|
+
|
27
|
+
attr_reader :dependencies
|
28
|
+
|
29
|
+
def content_type
|
30
|
+
case @format
|
31
|
+
when "html"; "text/html"
|
32
|
+
when "rss"; "application/rss+xml"
|
33
|
+
when "txt"; "text/plain"
|
34
|
+
else raise "unknown format #@format"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
## an environment for erb
|
39
|
+
class HTMLBuilder
|
40
|
+
attr_accessor :content, :entryset
|
41
|
+
|
42
|
+
def initialize entryset, config, router, page_vars, url_vars, format
|
43
|
+
@entryset = entryset
|
44
|
+
@config = config
|
45
|
+
@router = router
|
46
|
+
@page_vars = page_vars
|
47
|
+
@url_vars = url_vars
|
48
|
+
@format = format
|
49
|
+
@content = nil
|
50
|
+
end
|
51
|
+
|
52
|
+
def method_missing m, *a
|
53
|
+
if @page_vars.member?(m) && a.empty?
|
54
|
+
@page_vars[m]
|
55
|
+
elsif @config.respond_to? m
|
56
|
+
@config.send m, *a
|
57
|
+
else
|
58
|
+
raise NoMethodError, "undefined method '#{m}' for #{self.class}. Possible ERB error."
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
## utility methods for templates. TODO: move to router
|
63
|
+
def url_for opts={}; @router.url_for({ :format => @format }.merge(opts)) end
|
64
|
+
def full_url_for opts={}; @config.public_url_root + url_for(opts) end
|
65
|
+
|
66
|
+
def url_for_current opts={}; url_for @url_vars.merge(opts) end
|
67
|
+
def full_url_for_current opts={}; @config.public_url_root + url_for_current(opts) end
|
68
|
+
|
69
|
+
def link_to text, opts={}
|
70
|
+
raise "bad: #{text.inspect}, #{opts.inspect}" unless opts.is_a?(Hash)
|
71
|
+
html_opts = (opts[:html_opts] || {}).map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
|
72
|
+
"<a #{html_opts} href=\"" + url_for(opts) + "\">#{text}</a>"
|
73
|
+
end
|
74
|
+
|
75
|
+
def link_to_current text, opts={}
|
76
|
+
html_opts = (opts[:html_opts] || {}).map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
|
77
|
+
"<a #{html_opts} href=\"" + url_for_current(opts) + "\">#{text}</a>"
|
78
|
+
end
|
79
|
+
|
80
|
+
def my_binding; binding end
|
81
|
+
|
82
|
+
def self.install content, filename; eval content, binding, filename end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def build old
|
88
|
+
debug "building erb -> #{@format} from #{@template.path}"
|
89
|
+
bc = case @format
|
90
|
+
when "html"; HTMLBuilder
|
91
|
+
when "rss"; HTMLBuilder
|
92
|
+
when "txt"; HTMLBuilder
|
93
|
+
else raise "unknown format #@format"
|
94
|
+
end
|
95
|
+
erb = ERB.new @template.content, nil, "%-"
|
96
|
+
erb.filename = @template.path
|
97
|
+
|
98
|
+
bc.install @helper.content, @helper.path
|
99
|
+
b = bc.new @entryset, @config, @router, @rebuilder.call, @url_vars, @format
|
100
|
+
b.content = rewrite_image_links_in erb.result(b.my_binding)
|
101
|
+
|
102
|
+
erb = ERB.new @master_template.content, nil, "%-"
|
103
|
+
erb.filename = @master_template.path
|
104
|
+
erb.result b.my_binding
|
105
|
+
end
|
106
|
+
|
107
|
+
## rewrite any image HTML links in here to the static location
|
108
|
+
## (these links are created by redcloth)
|
109
|
+
def rewrite_image_links_in text
|
110
|
+
text.gsub(/<img src=\"(.*?)\"/) do |x|
|
111
|
+
path = $1
|
112
|
+
next x if path =~ /^\w+:/
|
113
|
+
"<img src=\"/static/#{path}\""
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
@@ -0,0 +1,79 @@
|
|
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
|
+
require 'iconv'
|
20
|
+
|
21
|
+
## stolen from sup
|
22
|
+
class Iconv
|
23
|
+
def self.easy_decode target, charset, text
|
24
|
+
return text if charset =~ /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i
|
25
|
+
charset = case charset
|
26
|
+
when /UTF[-_ ]?8/i; "utf-8"
|
27
|
+
when /(iso[-_ ])?latin[-_ ]?1$/i; "ISO-8859-1"
|
28
|
+
when /iso[-_ ]?8859[-_ ]?15/i; 'ISO-8859-15'
|
29
|
+
when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i; "utf-7"
|
30
|
+
else charset
|
31
|
+
end
|
32
|
+
|
33
|
+
# Convert:
|
34
|
+
#
|
35
|
+
# Remember - Iconv.open(to, from)!
|
36
|
+
Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
module Rfc2047
|
41
|
+
WORD = %r{=\?([!\#$%&'*+-/0-9A-Z\\^\`a-z{|}~]+)\?([BbQq])\?([!->@-~]+)\?=} # :nodoc: 'stupid ruby-mode
|
42
|
+
WORDSEQ = %r{(#{WORD.source})\s+(?=#{WORD.source})}
|
43
|
+
|
44
|
+
def Rfc2047.is_encoded? s; s =~ WORD end
|
45
|
+
|
46
|
+
# Decodes a string, +from+, containing RFC 2047 encoded words into a target
|
47
|
+
# character set, +target+. See iconv_open(3) for information on the
|
48
|
+
# supported target encodings. If one of the encoded words cannot be
|
49
|
+
# converted to the target encoding, it is left in its encoded form.
|
50
|
+
def Rfc2047.decode_to(target, from)
|
51
|
+
from = from.gsub(WORDSEQ, '\1')
|
52
|
+
out = from.gsub(WORD) do |word|
|
53
|
+
charset, encoding, text = $1, $2, $3
|
54
|
+
|
55
|
+
# B64 or QP decode, as necessary:
|
56
|
+
case encoding
|
57
|
+
when 'b', 'B'
|
58
|
+
text = text.unpack('m*')[0]
|
59
|
+
|
60
|
+
when 'q', 'Q'
|
61
|
+
# RFC 2047 has a variant of quoted printable where a ' ' character
|
62
|
+
# can be represented as an '_', rather than =32, so convert
|
63
|
+
# any of these that we find before doing the QP decoding.
|
64
|
+
text = text.tr("_", " ")
|
65
|
+
text = text.unpack('M*')[0]
|
66
|
+
|
67
|
+
# Don't need an else, because no other values can be matched in a
|
68
|
+
# WORD.
|
69
|
+
end
|
70
|
+
|
71
|
+
begin
|
72
|
+
Iconv.easy_decode target, charset, text
|
73
|
+
rescue Iconv::InvalidCharacter => e
|
74
|
+
puts "ICONV ERROR: #{e.message}"
|
75
|
+
text
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|