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,65 @@
|
|
1
|
+
require 'mime/types'
|
2
|
+
require "whisper/common"
|
3
|
+
|
4
|
+
module Whisper
|
5
|
+
|
6
|
+
## just a simple ol' thing that just holds a lazily-loaded cached copy of a
|
7
|
+
## File in memory, updates itself upon command, and exposes a timestamp for
|
8
|
+
## dependeny calculation purposes.
|
9
|
+
##
|
10
|
+
## TODO: do we actually need min_scan_interval? might be taken care of by
|
11
|
+
## Dependency.min_refresh_interval. Liekwise for DirScanner.
|
12
|
+
class CachedFile
|
13
|
+
include Loggy
|
14
|
+
include Dependency
|
15
|
+
attr_reader :path
|
16
|
+
|
17
|
+
class << self; attr_accessor :min_scan_interval end
|
18
|
+
|
19
|
+
def initialize path
|
20
|
+
@path = path
|
21
|
+
@content_type = nil
|
22
|
+
@mtime = File.mtime @path
|
23
|
+
@last_check = Time.at 0
|
24
|
+
|
25
|
+
dependency_init
|
26
|
+
CachedFile.min_scan_interval ||= 10
|
27
|
+
end
|
28
|
+
|
29
|
+
def dependencies; [] end
|
30
|
+
def build old_content
|
31
|
+
debug "reading #{@path} from disk"
|
32
|
+
@mtime = File.mtime(@path)
|
33
|
+
IO.read @path
|
34
|
+
end
|
35
|
+
def check
|
36
|
+
return false unless @last_check + CachedFile.min_scan_interval < Time.now
|
37
|
+
|
38
|
+
changed = begin
|
39
|
+
File.mtime(@path) > @mtime
|
40
|
+
rescue SystemCallError => e
|
41
|
+
warn e.message
|
42
|
+
true
|
43
|
+
end
|
44
|
+
@last_check = Time.now
|
45
|
+
#debug "checking mtime for #{@path}: #{changed}"
|
46
|
+
changed
|
47
|
+
end
|
48
|
+
def body; content end
|
49
|
+
|
50
|
+
def content_type; @content_type ||= CachedFile.content_type_for @path end
|
51
|
+
|
52
|
+
def self.content_type_for path
|
53
|
+
type = MIME::Types.type_for(path).first
|
54
|
+
if type
|
55
|
+
type.content_type
|
56
|
+
elsif path =~ /\.js$/i
|
57
|
+
"application/x-javascript"
|
58
|
+
else
|
59
|
+
warn "unknown MIME type for #{path}"
|
60
|
+
"application/binary"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require "whisper/common"
|
2
|
+
require "whisper/text"
|
3
|
+
|
4
|
+
module Whisper
|
5
|
+
|
6
|
+
class Comment
|
7
|
+
include Loggy
|
8
|
+
include Dependency
|
9
|
+
|
10
|
+
RESEND_SETTING = "send future comments to me"
|
11
|
+
URL_SETTING = "my url"
|
12
|
+
|
13
|
+
def initialize meta_file, content_file
|
14
|
+
@text = Text.new meta_file, content_file
|
15
|
+
dependency_init
|
16
|
+
end
|
17
|
+
|
18
|
+
def dependencies; [@text] end
|
19
|
+
def build old, type
|
20
|
+
## nothing yet
|
21
|
+
end
|
22
|
+
|
23
|
+
[:id, :entry_id, :published, :updated, :id, :author, :meta_file, :content_file, :parent_id, :message_id].each { |m| define_method(m) { @text.send m } }
|
24
|
+
|
25
|
+
## these next two may not exist in imported and older comments, so we provide defaults here
|
26
|
+
def resend_setting; @text.meta[:resend_setting] || "none" end
|
27
|
+
def url_setting; @text.meta[:url_setting] end
|
28
|
+
|
29
|
+
def body format, opts={}; @text.body format, opts end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "whisper/common"
|
2
|
+
require "whisper/dir_set"
|
3
|
+
require "whisper/comment"
|
4
|
+
|
5
|
+
module Whisper
|
6
|
+
|
7
|
+
## the set of all entries
|
8
|
+
class CommentSet
|
9
|
+
include Loggy
|
10
|
+
include Dependency
|
11
|
+
|
12
|
+
def initialize dir
|
13
|
+
@dirset = DirSet.new dir, Comment
|
14
|
+
dependency_init
|
15
|
+
end
|
16
|
+
|
17
|
+
def dependencies; [@dirset] end
|
18
|
+
|
19
|
+
%w(comments comments_by_id comments_by_entry_id comment_tree_by_entry_id comments_by_message_id).each do |f|
|
20
|
+
f = f.intern
|
21
|
+
define_method(f) { content[f] }
|
22
|
+
end
|
23
|
+
|
24
|
+
def size; comments.size end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def build old
|
29
|
+
debug "recompiling comment indices"
|
30
|
+
|
31
|
+
comments = @dirset.things.sort_by { |c| c.published }.reverse
|
32
|
+
{ :comments => comments,
|
33
|
+
:comments_by_id => comments.map_by { |c| c.id },
|
34
|
+
:comments_by_entry_id => comments.group_by { |c| c.entry_id },
|
35
|
+
:comments_by_message_id => comments.map_by { |c| c.message_id },
|
36
|
+
:comment_tree_by_entry_id => comments.group_by { |c| c.entry_id }.maphash { |eid, cs| cs.thread(:id, :parent_id) },
|
37
|
+
}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,276 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
class Time
|
5
|
+
alias :old_to_yaml :to_yaml
|
6
|
+
def to_yaml *a
|
7
|
+
utc.old_to_yaml(*a)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class Array
|
12
|
+
def paginate page_size
|
13
|
+
(0 .. ((size - 1) / page_size)).map { |p| self[(p * page_size) ... ((p + 1) * page_size)] }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module Enumerable
|
18
|
+
def map_by
|
19
|
+
inject({}) { |h, e| h[yield(e)] = e; h }
|
20
|
+
end
|
21
|
+
|
22
|
+
def group_by
|
23
|
+
inject({}) { |h, e| x = yield e; h[x] = (h[x] || []) << e; h }
|
24
|
+
end
|
25
|
+
|
26
|
+
def group_by_multiple
|
27
|
+
inject({}) { |h, e| yield(e).each { |x| h[x] = (h[x] || []) << e }; h }
|
28
|
+
end
|
29
|
+
|
30
|
+
def max_of &b; map(&b).max end
|
31
|
+
|
32
|
+
def argfind
|
33
|
+
each { |e| x = yield(e) and return x; }; nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def sum; inject(0) { |a, b| a + b } end
|
37
|
+
|
38
|
+
def thread id_method, parent_id_method
|
39
|
+
branches = { :root => [:root, []] }
|
40
|
+
|
41
|
+
each do |e|
|
42
|
+
id = e.send(id_method) or next
|
43
|
+
parent_id = e.send(parent_id_method) || :root
|
44
|
+
if branches[id]
|
45
|
+
thing, children = branches[id]
|
46
|
+
raise ArgumentError, "multiple objects with #{id_method} #{id.inspect}" if thing
|
47
|
+
branches[id] = [e, children]
|
48
|
+
else
|
49
|
+
branches[id] = [e, []]
|
50
|
+
end
|
51
|
+
branches[parent_id] ||= [nil, []]
|
52
|
+
branches[parent_id][1] << e
|
53
|
+
end
|
54
|
+
|
55
|
+
tree = {}
|
56
|
+
branches.each { |label, (thing, children)| tree[thing] = children }
|
57
|
+
tree
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class Hash
|
62
|
+
def maphash
|
63
|
+
each { |k, v| self[k] = yield(k, v) }
|
64
|
+
self
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class Numeric
|
69
|
+
def pluralize s
|
70
|
+
nicenice + " " + (self == 1 ? s : s.plural_form)
|
71
|
+
end
|
72
|
+
|
73
|
+
def nicenice
|
74
|
+
%w(no one two three four five six)[self] || to_s
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
class String
|
79
|
+
EMAIL_RE = /"?(.+?)"? <(\S+@\S+?)>/
|
80
|
+
|
81
|
+
def plural_form
|
82
|
+
case self
|
83
|
+
when "reply"; "replies"
|
84
|
+
else self + "s"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def email_name
|
89
|
+
self =~ EMAIL_RE && $1
|
90
|
+
end
|
91
|
+
|
92
|
+
def email_address
|
93
|
+
self =~ EMAIL_RE && $2
|
94
|
+
end
|
95
|
+
|
96
|
+
def obfuscated_name
|
97
|
+
email_name
|
98
|
+
end
|
99
|
+
|
100
|
+
def escape
|
101
|
+
gsub("&", "&").gsub(">", ">").gsub("<", "<")
|
102
|
+
end
|
103
|
+
|
104
|
+
def unescape
|
105
|
+
gsub(">", ">").gsub("<", "<").gsub("&", "&")
|
106
|
+
end
|
107
|
+
|
108
|
+
def reformat width
|
109
|
+
lines = []
|
110
|
+
body = self.clone
|
111
|
+
|
112
|
+
if body =~ /\A(\n+)/ # preserve any leading newlines
|
113
|
+
newlines = $1
|
114
|
+
lines << newlines
|
115
|
+
body = body[newlines.size .. -1]
|
116
|
+
end
|
117
|
+
|
118
|
+
while body
|
119
|
+
newline_point = body.index(/\n([\n\*\#])/) || width
|
120
|
+
|
121
|
+
split_point, appendage = if newline_point < width
|
122
|
+
[newline_point + 1, "\n#{$1}"]
|
123
|
+
elsif body.length < width
|
124
|
+
[body.length, "\n"]
|
125
|
+
else # regular: find a split point
|
126
|
+
split_point = body.rindex(/\s/, width) # look backwards
|
127
|
+
split_point ||= body.index(/\s/, width) # nope, look forward
|
128
|
+
split_point ||= body.length # nope, use everything (don't think this will ever occur)
|
129
|
+
[split_point, "\n"]
|
130
|
+
end
|
131
|
+
|
132
|
+
start_split = split_point - 1
|
133
|
+
start_split -= 1 while start_split > 0 && [?\ , ?\n].include?(body[start_split])
|
134
|
+
|
135
|
+
lines << body[0 .. start_split].chomp.gsub(/\n/, " ") + appendage
|
136
|
+
body = body[(split_point + 1) .. -1]
|
137
|
+
end
|
138
|
+
|
139
|
+
lines << body if body
|
140
|
+
lines.pop if lines.last == "\n"
|
141
|
+
lines.join("")
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
class Module # oh ruby
|
146
|
+
def bool_reader *args
|
147
|
+
args.each { |sym| class_eval %{ def #{sym}?; @#{sym}; end } }
|
148
|
+
end
|
149
|
+
def bool_writer *args; attr_writer(*args); end
|
150
|
+
def bool_accessor *args
|
151
|
+
bool_reader(*args)
|
152
|
+
bool_writer(*args)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
module Whisper
|
157
|
+
|
158
|
+
## quick replacement for Ruby's crappy Logger class
|
159
|
+
class Logger
|
160
|
+
LOG_LEVELS = %w(debug info warn error fatal)
|
161
|
+
|
162
|
+
## keep one instance around
|
163
|
+
class << self; attr_accessor :instance end
|
164
|
+
|
165
|
+
def initialize stream, level
|
166
|
+
level = level.to_s
|
167
|
+
raise ArgumentError, "log level must be one of #{LOG_LEVELS.join ', '}" unless LOG_LEVELS.include? level
|
168
|
+
|
169
|
+
@stream = stream
|
170
|
+
@level = level
|
171
|
+
@level_index = LOG_LEVELS.index(@level)
|
172
|
+
|
173
|
+
LOG_LEVELS[0 ... @level_index].each do |m|
|
174
|
+
self.class.instance_eval do
|
175
|
+
define_method(m) { |s, where=nil| }
|
176
|
+
define_method("#{m}?") { false }
|
177
|
+
define_method("if_#{m}?") { |&b| }
|
178
|
+
end
|
179
|
+
end
|
180
|
+
LOG_LEVELS[@level_index .. -1].each do |m|
|
181
|
+
self.class.instance_eval do
|
182
|
+
define_method(m) { |s, where=nil| send_message m, s, where }
|
183
|
+
define_method("#{m}?") { true }
|
184
|
+
define_method("if_#{m}?") { |&b| b.call }
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
Logger.instance ||= self
|
189
|
+
end
|
190
|
+
|
191
|
+
def send_message level, msg, where=nil
|
192
|
+
m = format_message level, msg, where
|
193
|
+
@stream.print m
|
194
|
+
@stream.flush
|
195
|
+
end
|
196
|
+
|
197
|
+
def format_message level, msg, where
|
198
|
+
where_s = where.nil? ? "" : "(#{where.sub("Whisper::", "")}) "
|
199
|
+
"[#{Time.now}] #{where_s}#{level.upcase}: #{msg}\n"
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
module Loggy
|
204
|
+
Logger::LOG_LEVELS.each { |m| define_method(m) { |s| Logger.instance.send(m, s, self.class.name) if Logger.instance } }
|
205
|
+
end
|
206
|
+
|
207
|
+
## model dependency behaviors.
|
208
|
+
##
|
209
|
+
## methods provided:
|
210
|
+
## #content -- get the latest content, built lazily
|
211
|
+
## #refresh! -- do a check, invalidate content if necessary
|
212
|
+
##
|
213
|
+
## methods you must define:
|
214
|
+
##
|
215
|
+
## #dependencies: returns the set of dependencies, i.e. the set of objects
|
216
|
+
## which, if they change, this object should change too.
|
217
|
+
## #build(old_content): rebuild the content (i.e. from the dependencies' contents)
|
218
|
+
##
|
219
|
+
## optional methods you can define:
|
220
|
+
## #check: check for dirtiness (from external sources; we take care
|
221
|
+
## of the rest), returning true iff dirty. if this is an expensive method,
|
222
|
+
## you should limit operation to once every N seconds or something like that.
|
223
|
+
##
|
224
|
+
## this module requires initialization, so you should call
|
225
|
+
## #dependency_init in your constructor
|
226
|
+
##
|
227
|
+
## also, no cycle checking, so DAGs only.
|
228
|
+
##
|
229
|
+
## also, allows for different types of content, if you pass arguments to #content
|
230
|
+
module Dependency
|
231
|
+
include Loggy
|
232
|
+
|
233
|
+
class << self; attr_accessor :min_refresh_interval end
|
234
|
+
|
235
|
+
def dependency_init
|
236
|
+
@mutex = Mutex.new
|
237
|
+
@timestamp = Time.now
|
238
|
+
@last_refresh = Time.at 0
|
239
|
+
@old_content = {}
|
240
|
+
@content = {}
|
241
|
+
|
242
|
+
Dependency.min_refresh_interval ||= 10
|
243
|
+
end
|
244
|
+
|
245
|
+
def timestamp; @mutex.synchronize { @timestamp } end
|
246
|
+
|
247
|
+
def refresh!
|
248
|
+
@mutex.synchronize do
|
249
|
+
return if (@last_refresh + Dependency.min_refresh_interval) > Time.now
|
250
|
+
#debug "[dep] refresh called, checking #{dependencies.size} deps (content empty is #{@content.empty?}; respond_to check is #{respond_to?(:check)})"
|
251
|
+
dependencies.each { |d| d.refresh! }
|
252
|
+
dep_changed = dependencies.find { |d| d.timestamp > @timestamp }
|
253
|
+
i_changed = !dep_changed && respond_to?(:check) && check
|
254
|
+
if !@content.empty? && (dep_changed || i_changed)
|
255
|
+
#debug "[dep #{self.class}:#{object_id}] dep #{dep_changed.class}:#{dep_changed.object_id} changed (#{dep_changed.timestamp} vs #{@timestamp}), invalidating content" if dep_changed
|
256
|
+
#debug "[dep #{self.class}:#{object_id}] i changed, invalidating content" if i_changed
|
257
|
+
@timestamp = Time.now
|
258
|
+
@old_content = @content
|
259
|
+
@content = {}
|
260
|
+
end
|
261
|
+
@last_refresh = Time.now
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def content *a
|
266
|
+
@mutex.synchronize do
|
267
|
+
unless @content.member?(a)
|
268
|
+
@content[a] = build @old_content[a], *a
|
269
|
+
#debug "[dep #{self.class}:#{object_id}] content was rebuilt"
|
270
|
+
end
|
271
|
+
end
|
272
|
+
@content[a]
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Whisper
|
4
|
+
|
5
|
+
class Config
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
REQUIRED = [ :whisper_dir, :title, :tagline, :root, :public_url_root, :from_email_address, :comment_mbox ]
|
9
|
+
OPTIONAL = [ :post_dir, :comment_dir, :template_dir, :static_dir, :helper_dir, :draft_dir, :sendmail,
|
10
|
+
:development_port, :port, :page_size, :rack_handler, :mbox_offset_filename, :author_dir,
|
11
|
+
:x_accel_redirect, :formatter_dir]
|
12
|
+
ALL = REQUIRED + OPTIONAL
|
13
|
+
|
14
|
+
ALL.each { |k| define_method(k) { @hash[k] } }
|
15
|
+
|
16
|
+
def initialize fn, base_dir, mode=:development
|
17
|
+
@mode = mode
|
18
|
+
@hash = YAML.load_file fn
|
19
|
+
@hash[:whisper_dir] = base_dir
|
20
|
+
REQUIRED.each { |k| raise Error, "missing required configuration value '#{k}'" unless @hash[k] }
|
21
|
+
@hash.keys.each { |k| raise Error, "unknown configuration value '#{k}'" unless ALL.member? k }
|
22
|
+
fill_defaults!
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :mode
|
26
|
+
|
27
|
+
def use_this_port; @mode == :development ? @hash[:development_port] : @hash[:port] end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def fill_defaults!
|
32
|
+
@hash[:post_dir] ||= File.join @hash[:whisper_dir], "posts"
|
33
|
+
@hash[:comment_dir] ||= File.join @hash[:whisper_dir], "comments"
|
34
|
+
@hash[:template_dir] ||= File.join @hash[:whisper_dir], "templates"
|
35
|
+
@hash[:static_dir] ||= File.join @hash[:whisper_dir], "static"
|
36
|
+
@hash[:draft_dir] ||= File.join @hash[:whisper_dir], "drafts"
|
37
|
+
@hash[:author_dir] ||= File.join @hash[:whisper_dir], "authors"
|
38
|
+
@hash[:helper_dir] ||= @hash[:whisper_dir]
|
39
|
+
@hash[:formatter_dir] ||= @hash[:whisper_dir]
|
40
|
+
@hash[:mbox_offset_filename] ||= File.join @hash[:whisper_dir], "offset.txt"
|
41
|
+
@hash[:sendmail] ||= "/usr/sbin/sendmail -oem -ti"
|
42
|
+
@hash[:port] ||= 80
|
43
|
+
@hash[:development_port] ||= 9292
|
44
|
+
@hash[:page_size] ||= 10
|
45
|
+
@hash[:rack_handler] ||= "Thin"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|