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,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
|