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