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,76 @@
|
|
1
|
+
require 'find'
|
2
|
+
require "whisper/common"
|
3
|
+
|
4
|
+
module Whisper
|
5
|
+
|
6
|
+
## watches a directory tree, keeps a list of filenames, reports modifications,
|
7
|
+
## additions and removals. scans at most every +min_scan_interval+ seconds.
|
8
|
+
##
|
9
|
+
## TODO: rename to cacheddir
|
10
|
+
class DirScanner
|
11
|
+
include Loggy
|
12
|
+
include Dependency
|
13
|
+
|
14
|
+
class << self; attr_accessor :min_scan_interval end
|
15
|
+
|
16
|
+
def initialize path, filename_regex
|
17
|
+
@path = path
|
18
|
+
@filename_regex = filename_regex
|
19
|
+
@last_scan = Time.at 0
|
20
|
+
|
21
|
+
@files = {}
|
22
|
+
@added, @removed, @updated = [], [], []
|
23
|
+
|
24
|
+
DirScanner.min_scan_interval ||= 30
|
25
|
+
dependency_init
|
26
|
+
end
|
27
|
+
|
28
|
+
def dependencies; [] end
|
29
|
+
|
30
|
+
attr_reader :path, :timestamp
|
31
|
+
|
32
|
+
## returns the diffs since the last time diff was called
|
33
|
+
def diff; content end
|
34
|
+
|
35
|
+
def build old
|
36
|
+
#debug "recording changes for myself"
|
37
|
+
rescan!
|
38
|
+
added, removed, updated = @added, @removed, @updated
|
39
|
+
added.each { |fn| @files[fn] = true }
|
40
|
+
removed.each { |fn| @files.delete fn }
|
41
|
+
@added, @removed, @updated = [], [], []
|
42
|
+
[added, removed, updated]
|
43
|
+
end
|
44
|
+
|
45
|
+
def check; rescan! end
|
46
|
+
|
47
|
+
def rescan!
|
48
|
+
return false unless @last_scan + DirScanner.min_scan_interval < Time.now
|
49
|
+
@added, @removed, @updated = [], [], []
|
50
|
+
|
51
|
+
current = {}
|
52
|
+
Find.find(@path) do |f|
|
53
|
+
next unless f =~ @filename_regex
|
54
|
+
current[f] = true
|
55
|
+
end
|
56
|
+
|
57
|
+
current.keys.each do |fn|
|
58
|
+
if @files[fn]
|
59
|
+
@updated << fn if File.mtime(fn) > @last_scan
|
60
|
+
next
|
61
|
+
end
|
62
|
+
@added << fn
|
63
|
+
end
|
64
|
+
|
65
|
+
@files.keys.each do |fn|
|
66
|
+
next if current[fn]
|
67
|
+
@removed << fn
|
68
|
+
end
|
69
|
+
|
70
|
+
debug "found #{@added.size} added, #{@removed.size} removed, and #{@updated.size} modified files in #{@path}"
|
71
|
+
@last_scan = Time.now
|
72
|
+
!@added.empty? || !@removed.empty? || !@updated.empty?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "whisper/common"
|
2
|
+
require "whisper/dir_scanner"
|
3
|
+
|
4
|
+
module Whisper
|
5
|
+
|
6
|
+
## maintains a set of things from a directory, where each thing is has both a
|
7
|
+
## .yaml and .textile file.
|
8
|
+
class DirSet
|
9
|
+
include Loggy
|
10
|
+
include Dependency
|
11
|
+
|
12
|
+
def initialize dir, klass
|
13
|
+
@scanner = DirScanner.new dir, /(#{Regexp.escape ENTRY_METADATA_EXTENSION}|#{Regexp.escape ENTRY_CONTENT_EXTENSION})$/
|
14
|
+
@klass = klass
|
15
|
+
|
16
|
+
dependency_init
|
17
|
+
end
|
18
|
+
|
19
|
+
def dependencies; [@scanner] end
|
20
|
+
def things; content end
|
21
|
+
def size; things.size end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def build items
|
26
|
+
items ||= []
|
27
|
+
added, deleted, modified = @scanner.diff
|
28
|
+
|
29
|
+
added.each do |fn|
|
30
|
+
next unless fn =~ /#{Regexp::escape ENTRY_METADATA_EXTENSION}$/
|
31
|
+
content_fn = fn.sub(/#{Regexp::escape ENTRY_METADATA_EXTENSION}$/, Whisper::ENTRY_CONTENT_EXTENSION)
|
32
|
+
unless File.exist?(content_fn)
|
33
|
+
warn "found yaml file but no textile file for #{content_fn}"
|
34
|
+
next
|
35
|
+
end
|
36
|
+
|
37
|
+
debug "adding new item: #{fn}"
|
38
|
+
items << @klass.new(CachedFile.new(fn), CachedFile.new(content_fn))
|
39
|
+
end
|
40
|
+
|
41
|
+
deleted.each do |fn|
|
42
|
+
debug "deleting item: #{fn}"
|
43
|
+
items.delete_if { |e| e.meta_file.path == fn }
|
44
|
+
end
|
45
|
+
|
46
|
+
items
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,212 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'set'
|
3
|
+
require 'time'
|
4
|
+
require 'digest/md5'
|
5
|
+
require "whisper/common"
|
6
|
+
require "whisper/mbox"
|
7
|
+
require "whisper/comment"
|
8
|
+
|
9
|
+
module Whisper
|
10
|
+
|
11
|
+
class EmailReceiver
|
12
|
+
include Loggy
|
13
|
+
|
14
|
+
DELAY = 20
|
15
|
+
|
16
|
+
def initialize entryset, commentset, email_sender, authors, mbox_fn, offset_fn, comment_dir
|
17
|
+
@entryset = entryset
|
18
|
+
@commentset = commentset
|
19
|
+
@email_sender = email_sender
|
20
|
+
@offset_fn = offset_fn
|
21
|
+
@authors = authors
|
22
|
+
@comment_dir = comment_dir
|
23
|
+
offset = IO.read(offset_fn).to_i rescue 0
|
24
|
+
@mbox_fn = mbox_fn
|
25
|
+
@mbox = Mbox.new mbox_fn, offset
|
26
|
+
end
|
27
|
+
|
28
|
+
def offset= offset
|
29
|
+
@mbox = Mbox.new @mbox_fn, offset
|
30
|
+
end
|
31
|
+
|
32
|
+
def start!
|
33
|
+
Thread.new do
|
34
|
+
while true
|
35
|
+
begin
|
36
|
+
sleep DELAY
|
37
|
+
step
|
38
|
+
rescue Exception => e
|
39
|
+
warn ["(#{e.class}) #{e.message}", e.backtrace].flatten.join("\n")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def done?; @mbox.eof? end
|
46
|
+
|
47
|
+
def step
|
48
|
+
return false if @mbox.eof?
|
49
|
+
|
50
|
+
content, vars, entry, parent_comment = process_one_message
|
51
|
+
if content
|
52
|
+
comment = write_one_message content, vars
|
53
|
+
resend_comments comment, parent_comment, entry
|
54
|
+
end
|
55
|
+
update_offset!
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
## resend a new comment to everyone who should get it
|
60
|
+
def resend_comments comment, parent_comment, entry
|
61
|
+
sent_to = Set.new [comment.author.email_address] # never send the comment back to the original author
|
62
|
+
|
63
|
+
if parent_comment
|
64
|
+
addr = parent_comment.author.email_address
|
65
|
+
resend = parent_comment.resend_setting
|
66
|
+
debug "for parent comment, resend for #{addr} is #{resend.inspect}"
|
67
|
+
if resend == "replies-only"
|
68
|
+
info "scheduling a resend of #{comment.id} to #{addr} because they're the author of the parent comment and resend is #{resend}"
|
69
|
+
sent_to << addr
|
70
|
+
@email_sender.send_comment comment, addr
|
71
|
+
end
|
72
|
+
else
|
73
|
+
## resend to entry author if it's a top-level comment
|
74
|
+
info "scheduling a resend of top-level comment #{comment.id} to entry author #{entry.author.email_address}"
|
75
|
+
@email_sender.send_comment comment, entry.author.email_address
|
76
|
+
end
|
77
|
+
|
78
|
+
(@commentset.comments_by_entry_id[entry.id] || []).each do |c| # iterate over all comments for the entry
|
79
|
+
addr = c.author.email_address
|
80
|
+
next if sent_to.member? addr # we've already seen him
|
81
|
+
sent_to << addr
|
82
|
+
resend = c.resend_setting
|
83
|
+
debug "for thread, resend for #{addr} comment #{c.id} is #{resend.inspect}"
|
84
|
+
if resend == "all"
|
85
|
+
info "scheduling a resend of #{comment.id} to #{addr} because they're in the thread and resend is #{resend}"
|
86
|
+
@email_sender.send_comment comment, addr
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def process_one_message
|
92
|
+
offset = @mbox.offset
|
93
|
+
body, headers = @mbox.next_message
|
94
|
+
return unless body
|
95
|
+
|
96
|
+
message_id, reply_to, from, date = %w(message-id in-reply-to from date).map do |f|
|
97
|
+
headers[f] or begin
|
98
|
+
warn "no #{f} for message at offset #{offset}. have headers: #{headers.keys.inspect}"
|
99
|
+
return
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
if message_id =~ /^<(.+?)>$/
|
104
|
+
message_id = $1
|
105
|
+
end
|
106
|
+
|
107
|
+
if reply_to =~ /^<(.+?)>$/
|
108
|
+
reply_to = $1
|
109
|
+
end
|
110
|
+
|
111
|
+
@commentset.refresh! # force comment set to refresh itself before we look for parents
|
112
|
+
entry, parent_comment = case reply_to
|
113
|
+
when /whisper-post-(\S+)@/
|
114
|
+
unless(entry = @entryset.entries_by_id[$1])
|
115
|
+
warn "invalid entry id in message-id for message at offset #{offset}: #{$1.inspect}"
|
116
|
+
return
|
117
|
+
end
|
118
|
+
debug "found new message from #{headers['from']} on entry #{entry.id}"
|
119
|
+
[entry, nil]
|
120
|
+
else
|
121
|
+
unless(parent_comment = @commentset.comments_by_message_id[reply_to])
|
122
|
+
warn "no such comment id in in-reply-to for message at offset #{offset}: #{reply_to.inspect}"
|
123
|
+
return
|
124
|
+
end
|
125
|
+
unless(entry = @entryset.entries_by_id[parent_comment.entry_id])
|
126
|
+
warn "comment #{comment.id} doesn't reference a real entry? wtf (offset #{offset})"
|
127
|
+
return
|
128
|
+
end
|
129
|
+
debug "found new message from #{headers['from']} on entry #{entry.id} replying to a comment by #{parent_comment.author}"
|
130
|
+
[entry, parent_comment]
|
131
|
+
end
|
132
|
+
|
133
|
+
## pull out variables and comments
|
134
|
+
author_vars = {}
|
135
|
+
content, author_vars = pull_out_content_and_vars body
|
136
|
+
@authors[from.email_address] = author_vars
|
137
|
+
|
138
|
+
vars = { :author => from, :offset => offset, :entry_id => entry.id, :message_id => message_id,
|
139
|
+
:resend_setting => (author_vars[Comment::RESEND_SETTING] || @authors[from.email_address][Comment::RESEND_SETTING]),
|
140
|
+
:url_setting => (author_vars[Comment::URL_SETTING] || @authors[from.email_address][Comment::RESEND_SETTING]),
|
141
|
+
:published => Time.parse(date), :parent_id => (parent_comment ? parent_comment.id : nil) }
|
142
|
+
|
143
|
+
[content, vars, entry, parent_comment]
|
144
|
+
end
|
145
|
+
|
146
|
+
def write_one_message content, vars
|
147
|
+
id = Digest::MD5.hexdigest [vars[:author], vars[:entry_id], vars[:message_id]].join("|")
|
148
|
+
|
149
|
+
dir = File.join @comment_dir, vars[:entry_id]
|
150
|
+
Dir.mkdir dir unless File.exist? dir
|
151
|
+
|
152
|
+
name = File.join dir, id
|
153
|
+
yaml_fn = name + ENTRY_METADATA_EXTENSION
|
154
|
+
warn "overwriting comment yaml file: #{yaml_fn}" if File.exist? yaml_fn
|
155
|
+
File.open(yaml_fn, "w") { |f| f.puts vars.merge({ :id => id }).to_yaml }
|
156
|
+
|
157
|
+
textile_fn = name + ENTRY_CONTENT_EXTENSION
|
158
|
+
warn "overwriting comment textile file: #{textile_fn}" if File.exist? textile_fn
|
159
|
+
File.open(textile_fn, "w") { |f| f.print content }
|
160
|
+
|
161
|
+
info "wrote comment #{yaml_fn}"
|
162
|
+
|
163
|
+
Comment.new CachedFile.new(yaml_fn), CachedFile.new(textile_fn)
|
164
|
+
end
|
165
|
+
|
166
|
+
def pull_out_content_and_vars body
|
167
|
+
vars = {}
|
168
|
+
last_was_comment = false
|
169
|
+
attribution = nil
|
170
|
+
|
171
|
+
lines = body.gsub(/^-- \n.*\Z/m, "").lines.to_a
|
172
|
+
content = lines.zip(lines[1 .. -1] || []).map do |l, next_l|
|
173
|
+
case l
|
174
|
+
when /^[^>].*:$/
|
175
|
+
if next_l =~ /^> ;/
|
176
|
+
## top-level attribution followed by comments. may need to consume it.
|
177
|
+
# puts "have attribution: #{l}"
|
178
|
+
attribution = l
|
179
|
+
nil
|
180
|
+
else
|
181
|
+
l
|
182
|
+
end
|
183
|
+
when /^> ! (.*?):\s*(.*?)(\s*;.*)?$/
|
184
|
+
vars[$1] = $2
|
185
|
+
nil
|
186
|
+
when /^> ;/
|
187
|
+
last_was_comment = true
|
188
|
+
nil
|
189
|
+
when /^>\s*$/
|
190
|
+
last_was_comment ? nil : l
|
191
|
+
when /^> /
|
192
|
+
if attribution
|
193
|
+
both = [attribution, l]
|
194
|
+
attribution = nil
|
195
|
+
both
|
196
|
+
else
|
197
|
+
l
|
198
|
+
end
|
199
|
+
else
|
200
|
+
l
|
201
|
+
end
|
202
|
+
end.flatten.compact.join
|
203
|
+
[content, vars]
|
204
|
+
end
|
205
|
+
|
206
|
+
def update_offset!
|
207
|
+
File.open(@offset_fn, "w") { |f| f.puts @mbox.offset }
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
end
|
212
|
+
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'socket'
|
3
|
+
require 'thread'
|
4
|
+
require "whisper/common"
|
5
|
+
require "whisper/timed_map"
|
6
|
+
|
7
|
+
module Whisper
|
8
|
+
|
9
|
+
class EmailSender
|
10
|
+
class Error < StandardError; end
|
11
|
+
|
12
|
+
include Loggy
|
13
|
+
|
14
|
+
def initialize entryset, commentset, router, authors, entry_template, comment_template, config
|
15
|
+
@entryset = entryset
|
16
|
+
@commentset = commentset
|
17
|
+
@authors = authors
|
18
|
+
@router = router
|
19
|
+
@entry_template = entry_template
|
20
|
+
@comment_template = comment_template
|
21
|
+
@config = config
|
22
|
+
@hostname = Socket.gethostname
|
23
|
+
@map = TimedMap.new
|
24
|
+
@q = Queue.new
|
25
|
+
end
|
26
|
+
|
27
|
+
def done?; @q.empty? end
|
28
|
+
def q_size; @q.size end
|
29
|
+
|
30
|
+
def start!
|
31
|
+
Thread.new do
|
32
|
+
while true
|
33
|
+
begin
|
34
|
+
process
|
35
|
+
rescue Exception => e
|
36
|
+
warn e.message
|
37
|
+
warn e.backtrace.join("\n")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def from_address; @config.from_email_address end
|
44
|
+
def blog_title; @config.title end
|
45
|
+
def full_url_for(*a); @config.public_url_root + @router.url_for(*a) end
|
46
|
+
|
47
|
+
def process
|
48
|
+
thing, to_address, web_request = @q.pop
|
49
|
+
|
50
|
+
@map.prune!
|
51
|
+
content, timestamp = @map[thing.id]
|
52
|
+
|
53
|
+
if timestamp.nil? || @entry_template.timestamp > timestamp || @comment_template.timestamp > timestamp
|
54
|
+
content = case thing
|
55
|
+
when Entry; get_content_for_entry thing, to_address, web_request
|
56
|
+
when Comment; get_content_for_comment thing, to_address, web_request
|
57
|
+
else raise "wtf: #{thing.inspect}"
|
58
|
+
end
|
59
|
+
@map[thing.id] = [content, timestamp]
|
60
|
+
end
|
61
|
+
|
62
|
+
debug "sending content to #{to_address} from #{from_address} via #{@config.sendmail}"
|
63
|
+
IO.popen(@config.sendmail, "w") { |sm| sm.puts content; true }
|
64
|
+
debug "done sending content to #{to_address}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def handle_send_request id, params
|
68
|
+
email = params["email"]
|
69
|
+
raise Error, "invalid email address" unless email =~ /^\S+@\S+$/
|
70
|
+
|
71
|
+
if params["comment-id"]
|
72
|
+
c = @commentset.comments_by_id[params["comment-id"]]
|
73
|
+
raise Error, "can't find comment" unless c
|
74
|
+
@q.push [c, email, true]
|
75
|
+
else
|
76
|
+
e = @entryset.entries_by_id[id]
|
77
|
+
raise Error, "can't find entry" unless e
|
78
|
+
@q.push [e, email, true]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def send_comment comment, to_addr; @q.push [comment, to_addr, false] end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def get_content_for_entry entry, to_address, web_request
|
87
|
+
@entry_template.refresh!
|
88
|
+
debug "creating email from template #{@entry_template.path} and entry #{entry.id}"
|
89
|
+
message_id = message_id_for_entry entry
|
90
|
+
author = entry.author
|
91
|
+
settings = @authors[to_address]
|
92
|
+
ERB.new(@entry_template.content, nil, "%-").result binding
|
93
|
+
end
|
94
|
+
|
95
|
+
def get_content_for_comment comment, to_address, web_request
|
96
|
+
@comment_template.refresh!
|
97
|
+
debug "creating email from template #{@comment_template.path} and comment #{comment.id}"
|
98
|
+
entry = @entryset.entries_by_id[comment.entry_id]
|
99
|
+
message_id = comment.message_id
|
100
|
+
|
101
|
+
reply_to_message_id = if comment.parent_id && (pc = @commentset.comments_by_id[comment.parent_id])
|
102
|
+
pc.message_id
|
103
|
+
else
|
104
|
+
message_id_for_entry entry
|
105
|
+
end
|
106
|
+
|
107
|
+
author = comment.author
|
108
|
+
settings = @authors[to_address]
|
109
|
+
ERB.new(@comment_template.content, nil, "%-").result binding
|
110
|
+
end
|
111
|
+
|
112
|
+
def message_id_for_entry entry; "whisper-post-#{entry.id}@#{@hostname}" end
|
113
|
+
end
|
114
|
+
end
|