whisperblog 0.6

Sign up to get free protection for your applications and to get access to all the features.
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 %>