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.
Files changed (48) hide show
  1. data/Changelog +23 -0
  2. data/LICENSE +661 -0
  3. data/README +59 -0
  4. data/bin/whisper +91 -0
  5. data/bin/whisper-init +40 -0
  6. data/bin/whisper-post +88 -0
  7. data/bin/whisper-process-email +54 -0
  8. data/lib/whisper.rb +52 -0
  9. data/lib/whisper/author_tracker.rb +46 -0
  10. data/lib/whisper/blog.rb +156 -0
  11. data/lib/whisper/cached_file.rb +65 -0
  12. data/lib/whisper/comment.rb +35 -0
  13. data/lib/whisper/comment_set.rb +41 -0
  14. data/lib/whisper/common.rb +276 -0
  15. data/lib/whisper/config.rb +49 -0
  16. data/lib/whisper/dir_scanner.rb +76 -0
  17. data/lib/whisper/dir_set.rb +50 -0
  18. data/lib/whisper/email_receiver.rb +212 -0
  19. data/lib/whisper/email_sender.rb +114 -0
  20. data/lib/whisper/entry.rb +44 -0
  21. data/lib/whisper/entry_set.rb +41 -0
  22. data/lib/whisper/handler.rb +99 -0
  23. data/lib/whisper/mbox.rb +141 -0
  24. data/lib/whisper/page.rb +118 -0
  25. data/lib/whisper/rfc2047.rb +79 -0
  26. data/lib/whisper/router.rb +50 -0
  27. data/lib/whisper/server.rb +88 -0
  28. data/lib/whisper/text.rb +252 -0
  29. data/lib/whisper/timed_map.rb +43 -0
  30. data/lib/whisper/version.rb +3 -0
  31. data/share/whisper/comment-email.rtxt +36 -0
  32. data/share/whisper/config.yaml +53 -0
  33. data/share/whisper/entry-email.rtxt +35 -0
  34. data/share/whisper/entry.rhtml +88 -0
  35. data/share/whisper/entry.rtxt +19 -0
  36. data/share/whisper/formatter.rb +52 -0
  37. data/share/whisper/helper.rb +147 -0
  38. data/share/whisper/list.rhtml +51 -0
  39. data/share/whisper/list.rrss +9 -0
  40. data/share/whisper/list.rtxt +25 -0
  41. data/share/whisper/master.rhtml +81 -0
  42. data/share/whisper/master.rrss +21 -0
  43. data/share/whisper/master.rtxt +5 -0
  44. data/share/whisper/mootools.js +282 -0
  45. data/share/whisper/rss-badge.png +0 -0
  46. data/share/whisper/spinner.gif +0 -0
  47. data/share/whisper/style.css +314 -0
  48. metadata +239 -0
@@ -0,0 +1,50 @@
1
+ require "whisper/common"
2
+ require "whisper/handler"
3
+
4
+ module Whisper
5
+
6
+ ## nothing special. maintains a list of handlers, translates
7
+ ## from paths to content, and from url descriptions to urls
8
+ class Router
9
+ include Loggy
10
+
11
+ def initialize root
12
+ root = root.sub(/\/$/, "")
13
+ @root_re = /^#{Regexp.escape root}/
14
+ @handlers = []
15
+ @handlers_by_route = {}
16
+ end
17
+
18
+ attr_reader :handlers
19
+
20
+ def add_handler route, request_method, path, &block
21
+ handler = Handler.new request_method, path, &block
22
+ @handlers_by_route[route] = handler
23
+ @handlers << [handler, route]
24
+ end
25
+
26
+ def expire!; @handlers.each { |h| h.expire! } end
27
+
28
+ def get_content_for path, params, request_method
29
+ path = path.sub @root_re, ""
30
+ case(result = @handlers.argfind { |h, route| h.handle path, params, request_method, route })
31
+ when nil, :invalid; nil # a handler matched but didn't like the request
32
+ when Array; result
33
+ else
34
+ result.refresh!
35
+ begin
36
+ [result.content, result.content_type]
37
+ rescue InvalidPageError => e
38
+ nil
39
+ end
40
+ end
41
+ end
42
+
43
+ def url_for opts={}
44
+ route = opts[:route] or raise ArgumentError, "no :route specified"
45
+ handler = @handlers_by_route[opts[:route]] or raise ArgumentError, "no such route: #{route.inspect} (have #{@handlers_by_route.keys.join(', ')})"
46
+ handler.url_for opts
47
+ end
48
+ end
49
+
50
+ end
@@ -0,0 +1,88 @@
1
+ require 'rack'
2
+ require 'cgi'
3
+ require "whisper/common"
4
+
5
+ class Rack::Response
6
+ attr_reader :length
7
+ def content_type= t; headers["Content-Type"] = t end
8
+ def content_length= t; headers["Content-Length"] = t.to_s end
9
+ end
10
+
11
+ module Whisper
12
+
13
+ class Server
14
+ include Loggy
15
+
16
+ PRODUCTION_REFRESH_DELAY = 60 # seconds
17
+
18
+ def initialize blog, mode, static_dir, x_accel_redirect
19
+ @blog = blog
20
+ @static_dir = static_dir
21
+ @x_accel_redirect = x_accel_redirect
22
+ @production = mode == :production
23
+ end
24
+
25
+ def call env
26
+ start = Time.now
27
+ req = Rack::Request.new env
28
+ res = Rack::Response.new
29
+
30
+ deny = false
31
+ path = CGI.unescape req.path_info
32
+
33
+ ok = if req.scheme != "http"
34
+ false
35
+ elsif path == "/favicon.ico"
36
+ handle_static "favicon.ico", res
37
+ elsif path =~ /^\/static\/(.+)$/
38
+ handle_static $1, res
39
+ elsif(x = @blog.get_content_for path, req.params, req.request_method.downcase.intern)
40
+ content, content_type = x # oh ruby!
41
+
42
+ if req.request_method == "HEAD"
43
+ res.content_length = content.size
44
+ res.content_type = content_type
45
+ else
46
+ res.write content
47
+ res.content_type = content_type
48
+ end
49
+ true
50
+ else
51
+ false
52
+ end
53
+
54
+ unless ok
55
+ res.content_type = "text/plain"
56
+ res.status = 404
57
+ res.write "No such intarweb resource"
58
+ end
59
+
60
+ result = res.finish
61
+ elapsed = (Time.now - start) * 1000000
62
+ info "#{res.status} #{sprintf '%5dus', elapsed} #{sprintf '%5.1fk', res.length / 1024.to_f} #{req.request_method} #{req.path_info} #{deny ? '' : res.content_type}"
63
+ result
64
+ end
65
+
66
+ private
67
+
68
+ def handle_static path, res
69
+ if @production && @x_accel_redirect
70
+ res.header["X-Accel-Redirect"] = File.join @x_accel_redirect, path
71
+ res.content_type = CachedFile.content_type_for path
72
+ true
73
+ else
74
+ begin
75
+ fn = File.join @static_dir, path.gsub("../", "")
76
+ return false unless File.file? fn
77
+ res.content_type = CachedFile.content_type_for fn
78
+ res.write IO.read(fn)
79
+ true
80
+ rescue SystemCallError => e
81
+ warn "static file unreadable: #{e.message}"
82
+ false
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ end
@@ -0,0 +1,252 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'yaml'
4
+ require 'redcloth'
5
+ require "whisper/common"
6
+
7
+ module Whisper
8
+
9
+ ## a text object, capable of converting itself from "enhanced textile" to html,
10
+ ## plain text, and other formats.
11
+ ##
12
+ ## this class deals with the gory details of textile, comments, and
13
+ ## format-specific regions, each of which conflict with each other in horrible
14
+ ## ways.
15
+ ##
16
+ ## comment and post format is basically textile, EXCEPT:
17
+ ## 1. there can be quoted regions, prefixed with "> ", which can be nested
18
+ ## (a la email)
19
+ ## 2. there can be arbitrary, non-nested regions, which the user can define
20
+ ## their own boundary markers and formatters for, which cannot be nested.
21
+ class Text
22
+ include Loggy
23
+ include Dependency
24
+
25
+ class << self
26
+ def formatters; @formatters ||= [] end
27
+ attr_reader :quote_formatter
28
+
29
+ def format start_tag, end_tag, &block
30
+ @formatters ||= []
31
+ @formatters << [start_tag, end_tag, block]
32
+ end
33
+
34
+ def format_quote &block; @quote_formatter = block end
35
+ end
36
+
37
+ attr_reader :meta_file, :body_file
38
+
39
+ def initialize meta_file, body_file
40
+ @meta_file = meta_file
41
+ @body_file = body_file
42
+
43
+ dependency_init
44
+ end
45
+
46
+ def dependencies; [@meta_file, @body_file] end
47
+ def build old, meta_or_body, format
48
+ case meta_or_body
49
+ when :meta; build_metas
50
+ when :body; build_body format
51
+ else raise ArgumentError, "unknown selector #{meta_or_body.inspect}"
52
+ end
53
+ end
54
+
55
+ def body format, opts={}
56
+ c = content :body, format
57
+ if opts[:prefix]
58
+ c.lines.map { |l| opts[:prefix] + l }
59
+ else
60
+ c
61
+ end
62
+ end
63
+ def meta; content(:meta, nil) end
64
+
65
+ ## pass all these through to the metainfo container
66
+ [:labels, :entry_id, :published, :updated, :id, :author, :content_file, :parent_id, :message_id].each do |m|
67
+ define_method(m) { meta[m] }
68
+ end
69
+
70
+ private
71
+
72
+ def build_metas
73
+ #debug "loading meta-info from #{@meta_file.path}"
74
+ meta = YAML.load @meta_file.body
75
+ meta[:id] = meta[:id].to_s
76
+ meta
77
+ end
78
+
79
+ ## the grunt work of formatting the body. general approach is: first,
80
+ ## separate into quoted regions and the main text region. these have a
81
+ ## recursive structure (i.e. you can embed quotes within quotes). within each
82
+ ## of those, replace all formatted regions within each of those with a
83
+ ## sentinel value. run each of these results through RedCloth. finally,
84
+ ## substitute in the output of the custom formatters in the result of that
85
+ ## process for the sentinels.
86
+ def build_body format
87
+ debug "rendering #{format} body for #{@body_file.path}"
88
+ body = @body_file.body
89
+
90
+ region_tree = parse_body body
91
+
92
+ ## drop large comments (typically just entire quotes)
93
+ unquoted_length = region_tree.map { |n| n.is_a?(Hash) ? n[:content].length : 0 }.sum
94
+ elide_large_quotes region_tree, (unquoted_length * 5)
95
+
96
+ ## if the last thing is a quote, elide it too
97
+ l = region_tree.last
98
+ if l.is_a?(Array) && l.size == 1
99
+ l.first[:content] = "_[Useless final quote elided. Bad commenter, no cookie! -- ed.]_"
100
+ end
101
+
102
+ format_region_tree format, region_tree, 0
103
+ end
104
+
105
+ def elide_large_quotes t, max_length, depth=0
106
+ t.each do |n|
107
+ if n.is_a?(Hash) && (depth == 1) && n[:content].length > max_length
108
+ n[:content] = "_[Excessively-large quote elided. Bad commenter, no cookie! -- ed.]_"
109
+ elsif n.is_a?(Array) && (depth == 0)
110
+ elide_large_quotes n, max_length, 1
111
+ end
112
+ end
113
+ end
114
+
115
+ def format_region_tree format, t, depth
116
+ result = t.map do |n|
117
+ case n
118
+ when Hash; format_region format, n[:content]
119
+ when Array; format_region_tree format, n, depth + 1
120
+ end
121
+ end.join
122
+
123
+ if depth > 0 # it's a quote
124
+ Text.quote_formatter.call format, result, (t.first.is_a?(Hash) && t.first[:attribution])
125
+ else
126
+ result
127
+ end
128
+ end
129
+
130
+ def format_region format, content
131
+ sentinels = {}
132
+ self.class.formatters.each do |start_tag, end_tag, block|
133
+ start_tag = Regexp::escape start_tag unless start_tag.is_a?(Regexp)
134
+ end_tag = Regexp::escape end_tag unless end_tag.is_a?(Regexp)
135
+ re = /#{start_tag}(.*?)#{end_tag}/m
136
+ content = content.gsub(re) do |r|
137
+ ## horrible, but we need to know which portions correpond to which
138
+ ## regexp and this is the only way i can figure out how to do it.
139
+ all_matches = /^#{start_tag}(.*?)#{end_tag}$/m.match r
140
+ start_matches = /^#{start_tag}/.match r
141
+ end_matches = /#{end_tag}$/.match r
142
+
143
+ inner = all_matches[start_matches.size]
144
+ sentinel = "WHISPERSENTINEL#{sentinels.size}"
145
+
146
+ ## if the block returns nil, we substitute in the original value
147
+ ## so not handling a particular format is the same as passing it through
148
+ sentinels[sentinel] = block.call(format, inner, start_matches, end_matches) || inner
149
+ sentinel
150
+ end
151
+ end
152
+
153
+ content = case format
154
+ when :txt, :email; plaintextify content
155
+ when :html, :rss; redclothify content
156
+ else; raise ArgumentError, "unknown format #{format.inspect}"
157
+ end
158
+
159
+ ## now resubstitute
160
+ content.gsub(/WHISPERSENTINEL\d+/) { |x| sentinels[x] }
161
+ end
162
+
163
+ TEXT_WIDTH = 78
164
+ def plaintextify body
165
+ ## replace urls with footnotes
166
+ urls = []
167
+ body.gsub!(/"([^"]+?)":(\S+[^\.,\?!\s])/m) do |x|
168
+ text, url = $1, $2
169
+ urls << url
170
+ "#{text} [#{urls.size}]"
171
+ end
172
+
173
+ ## replace image markers
174
+ body.gsub!(/!(.*?)!/) { |x| "[image: #{$1}]" }
175
+
176
+ ## wrap
177
+ body = body.reformat TEXT_WIDTH
178
+
179
+ ## prettify blockquotes by rewrapping them
180
+ body.gsub!(/bq.\s+(.+?)\n\n/m) do |x|
181
+ text = "\"#{$1}\""
182
+ text.reformat(TEXT_WIDTH - 8).gsub(/^/m, " ")[1..-1] + "\n"
183
+ end
184
+
185
+ ## rewrite #'s into numbers. fucks up if there's more than
186
+ ## one separate list. fixing that would be complicated.
187
+ num = 0
188
+ body.gsub!(/^# /) { "#{num += 1}. " }
189
+
190
+ unless urls.empty?
191
+ body << "\n"
192
+ urls.each_with_index { |u, i| body << "[#{i + 1}] #{u}\n" }
193
+ end
194
+
195
+ body
196
+ end
197
+
198
+ def redclothify body
199
+ rc = RedCloth.new body
200
+ rc.hard_breaks = false
201
+ rc.filter_html = true
202
+ html = rc.to_html
203
+ ## rewrite some of the redcloth HTML output
204
+ html.sub! "<p>", "<p class='first'>" if html.length > 256
205
+ html.gsub! /(^|[>\s])(http:\/\/[^\s<]+)([\s<]|$)/, "\\1<a href='\\2'>\\2</a>\\3"
206
+ html
207
+ end
208
+
209
+ def parse_quoting_for_line s
210
+ s =~ /^((>\s*)*)(.*)$/ or raise "unparseable: #{s.inspect}"
211
+ quoting, content = $1, $3
212
+ [quoting.count(">"), content]
213
+ end
214
+
215
+ def parse_body content
216
+ regions, last_line = parse_regions content.lines.to_a, 0, 0, nil
217
+ regions
218
+ end
219
+
220
+ ## make a tree of content from nested comments
221
+ def parse_regions lines, quote_level, i, attribution
222
+ lines ||= []
223
+ regions = []
224
+ while i < lines.length
225
+ l, next_l = lines[i], lines[i + 1]
226
+ this_quote_level, content = parse_quoting_for_line l
227
+ is_attribution = next_l && content =~ /:\s*$/ && begin
228
+ next_quote_level, next_content = parse_quoting_for_line next_l
229
+ next_quote_level == quote_level + 1
230
+ end
231
+ if is_attribution
232
+ attribution = content
233
+ ## and don't do anything else
234
+ else
235
+ if this_quote_level == quote_level
236
+ regions << {content: "", attribution: attribution} unless regions.last.is_a? Hash
237
+ regions.last[:content] << (content + "\n")
238
+ elsif this_quote_level > quote_level
239
+ nested_regions, i = parse_regions(lines, quote_level + 1, i, attribution)
240
+ regions << nested_regions
241
+ attribution = nil
242
+ else # less than
243
+ break
244
+ end
245
+ end
246
+ i += 1
247
+ end
248
+ [regions, i - 1]
249
+ end
250
+
251
+ end
252
+ end
@@ -0,0 +1,43 @@
1
+ module Whisper
2
+
3
+ class TimedMap
4
+ include Loggy
5
+
6
+ class << self
7
+ attr_accessor :expiration_interval
8
+ end
9
+
10
+ def initialize
11
+ @h = {}
12
+ TimedMap.expiration_interval ||= 60
13
+ end
14
+
15
+ def []= k, v
16
+ @h[k] = [v, Time.now]; v
17
+ end
18
+
19
+ def [] k
20
+ @h[k] && @h[k][0]
21
+ end
22
+
23
+ def each
24
+ @h.each { |k, (a, b)| yield k, a }
25
+ end
26
+
27
+ def values
28
+ @h.values.map { |a, b| a }
29
+ end
30
+
31
+ def prune!
32
+ @h.each do |k, v|
33
+ if (v[1] + TimedMap.expiration_interval) < Time.now
34
+ debug "expiring #{k.inspect} because it was generated more than #{TimedMap.expiration_interval}s ago"
35
+ @h.delete k
36
+ end
37
+ end
38
+ end
39
+
40
+ def method_missing(m, *a); @h.send(m, *a) end
41
+ end
42
+
43
+ end
@@ -0,0 +1,3 @@
1
+ module Whisper
2
+ VERSION = "0.6"
3
+ end
@@ -0,0 +1,36 @@
1
+ From: <%= author.email_name %> (on <%= blog_title %>) <<%= from_address %>>
2
+ To: <%= to_address %>
3
+ Subject: Re: [<%= blog_title %>] <%= entry.title(:email) %>
4
+ Date: <%= Time.now %>
5
+ Message-Id: <<%= message_id %>>
6
+ In-Reply-To: <<%= reply_to_message_id %>>
7
+
8
+ <% if web_request -%>
9
+ ; Hi there! This is an email containing the post or a comment from
10
+ ; <%= full_url_for :route => :entry, :id => entry.id %>. Someone (hopefully you!)
11
+ ; requested this email in order to leave a comment.
12
+ ;
13
+ ; To leave a comment, simply reply to this message, quoting as normal.
14
+ ; You can use Textile markup: http://hobix.com/textile/
15
+ ; DO NOT top-post: http://en.wikipedia.org/wiki/Top-posting#Top-posting
16
+ ;
17
+ ; Future comments on this post may also be sent to you via email, depending on
18
+ ; the setting below.
19
+ ;
20
+ ; SETTINGS:
21
+ ;
22
+ ; Lines starting with a ;, like these, will be stripped out, even if quoted.
23
+ ; Lines starting with a !, like those below, allow you to configure settings.
24
+ ; Just edit them in place and leave them in your reply (quoted is fine).
25
+ ;
26
+ ! <%= Comment::RESEND_SETTING %>: <%= settings["send future comments to me"] || "all" %>
27
+ ; (one of "all", "replies-only", or "none")
28
+ ! <%= Comment::URL_SETTING %>: <%= settings["my url"] || "" %>
29
+ ; (a url to link your name to)
30
+ <% else -%>
31
+ ; A reply to this message will be posted at
32
+ ; <%= full_url_for :route => :entry, :id => entry.id %>
33
+ ; and emailed to any subscribers to this thread.
34
+ <% end -%>
35
+
36
+ <%= comment.body :email %>