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