whisperblog 0.6

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