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,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
|
data/lib/whisper/text.rb
ADDED
@@ -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 █ @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,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 %>
|