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