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,44 @@
1
+ require "whisper/common"
2
+ require "whisper/text"
3
+
4
+ module Whisper
5
+
6
+ ## a blog entry. a text plus a title, basically.
7
+ class Entry
8
+ include Loggy
9
+ include Dependency
10
+
11
+ def initialize meta_file, content_file
12
+ @text = Text.new meta_file, content_file
13
+ dependency_init
14
+ end
15
+
16
+ def dependencies; [@text] end
17
+ def title type; content type end
18
+ def build old, type
19
+ case type
20
+ when :html, :rss; build_html_title
21
+ when :txt, :email; build_textile_title
22
+ else raise ArgumentError, "unknown type #{type.inspect}"
23
+ end
24
+ end
25
+
26
+ [:labels, :published, :updated, :id, :author, :meta_file, :content_file].each { |m| define_method(m) { @text.send m } }
27
+ def body format, opts={}; @text.body format, opts end
28
+
29
+ private
30
+
31
+ def build_html_title
32
+ debug "rendering HTML title for #{@text.meta_file.path}"
33
+ RedCloth.new(@text.meta[:title]).to_html.gsub(%r,^<p>|</p>$,, "") # TODO: figure out a better way to remove these
34
+ end
35
+
36
+ ## since the title typically is one line and without any of the textile stuff
37
+ ## we actually want to tweak, we also use this for the text title.
38
+ def build_textile_title
39
+ debug "rendering textile title"
40
+ @text.meta[:title]
41
+ end
42
+ end
43
+
44
+ end
@@ -0,0 +1,41 @@
1
+ require "whisper/common"
2
+ require "whisper/dir_set"
3
+ require "whisper/entry"
4
+
5
+ module Whisper
6
+
7
+ ## the set of all entries
8
+ class EntrySet
9
+ include Loggy
10
+ include Dependency
11
+
12
+ def initialize dir
13
+ @dirset = DirSet.new dir, Entry
14
+ @entries = []
15
+ dependency_init
16
+ end
17
+
18
+ def dependencies; [@dirset] + @entries end
19
+
20
+ %w(entries entries_by_id entries_by_label entries_by_author entries_by_page).each do |f|
21
+ f = f.intern
22
+ define_method(f) { content[f] }
23
+ end
24
+
25
+ def size; entries.size end
26
+
27
+ private
28
+
29
+ def build old
30
+ debug "resorting entries and recompiling entry indices"
31
+
32
+ @entries = @dirset.things.sort_by { |e| e.published }.reverse
33
+ { :entries => @entries,
34
+ :entries_by_id => @entries.map_by { |e| e.id },
35
+ :entries_by_author => @entries.group_by { |e| e.author.obfuscated_name },
36
+ :entries_by_label => @entries.group_by_multiple { |e| e.labels },
37
+ }
38
+ end
39
+ end
40
+
41
+ end
@@ -0,0 +1,99 @@
1
+ require 'cgi'
2
+ require "whisper/common"
3
+ require "whisper/timed_map"
4
+
5
+ module Whisper
6
+
7
+ class InvalidPageError < StandardError; end
8
+
9
+ ## I borrowed a bit from Rails's syntax, but the semantics are not the same.
10
+ ## Anything with + signs will be treated as a substitutable path component.
11
+ ## You can optionally end the path spec with :format or /:page:format.
12
+ class Handler
13
+ include Loggy
14
+
15
+ DEFAULT_FORMAT = "html"
16
+
17
+ def initialize request_method, pathspec, &page_creator
18
+ @request_method = request_method
19
+ @pathspec = pathspec
20
+ @page_creator = page_creator
21
+ @map = TimedMap.new
22
+ @uses_page = @uses_format = false
23
+
24
+ re = @pathspec.gsub(/\+(\w+)/) do
25
+ '([^\.\/][^\/]*?)' # regular path components must start with a non-., non-/, followed by 0 or more non-/es
26
+ end.sub(/\/:page:format$/) do
27
+ @uses_page = @uses_format = true
28
+ '\/?(\d+)?(?:\.([^\/]+))?'
29
+ end.sub(/:format$/) do
30
+ @uses_format = true
31
+ '(?:\.([a-zA-Z]+))?'
32
+ end
33
+ @re = /^#{re}$/
34
+ end
35
+
36
+ def handle path, params, request_method, route
37
+ #debug "comparing #{request_method.inspect} against #{@request_method.inspect}"
38
+ return unless (request_method == @request_method) || # special case:
39
+ (request_method == :head && @request_method == :get) # proceed!
40
+
41
+ #debug "comparing #{path.inspect} against #{@re} => #{@re.match(path) ? true : false}"
42
+ match = @re.match(path) or return
43
+ #debug "have captures: #{match.captures.inspect}"
44
+
45
+ ## assemble the variables to be passed to the caller function
46
+ vars = match.captures
47
+ vars[vars.length - 1] ||= DEFAULT_FORMAT if @uses_format
48
+ vars[vars.length - 2] = (vars[vars.length - 2].to_i || 0) if @uses_page
49
+ vars << params
50
+
51
+ ## make a key from the path and the query parameters, if any. the key is basically
52
+ ## a normalized query string.
53
+ key = path + "?" + params.sort_by { |k, v| [k, v] }.map { |k, v| k + "=" + v }.join("&")
54
+
55
+ case request_method
56
+ when :post # don't cache
57
+ make route, vars
58
+ else
59
+ @map.prune!
60
+ @map[key] ||= begin
61
+ make(route, vars) || :invalid
62
+ rescue SystemCallError => e
63
+ :invalid
64
+ end
65
+ end
66
+ end
67
+
68
+ def contents; @map.values end
69
+
70
+ def url_for opts={}
71
+ url = @pathspec.gsub(/[:+](\w+)/) do
72
+ spec = $1.intern
73
+ val = opts[spec]
74
+ val = CGI.escape(val.to_s) if val
75
+ case spec
76
+ when :format; val.nil? || (val == DEFAULT_FORMAT) ? "" : ".#{val}"
77
+ when :page; val == "0" ? "" : val
78
+ else
79
+ raise ArgumentError, "missing required path spec #{spec}" unless val
80
+ val
81
+ end
82
+ end
83
+ url += "##{opts[:anchor]}" if opts[:anchor]
84
+ url.gsub!(/\/\.([^\/\.]+)$/, ".\\1")
85
+ url
86
+ end
87
+
88
+ private
89
+
90
+ def make route, vars
91
+ begin
92
+ @page_creator.call route, *vars
93
+ rescue InvalidPageError => e
94
+ :invalid
95
+ end
96
+ end
97
+ end
98
+
99
+ end
@@ -0,0 +1,141 @@
1
+ require 'rmail'
2
+ require 'thread'
3
+ require "whisper/common"
4
+ require "whisper/rfc2047"
5
+
6
+ ## stolen from sup
7
+ class RMail::Message
8
+ def charset
9
+ if header.field?("content-type") && header.fetch("content-type") =~ /charset="?(.*?)"?(;|$)/i
10
+ $1
11
+ end
12
+ end
13
+ end
14
+
15
+ module Whisper
16
+
17
+ class Mbox
18
+ include Loggy
19
+
20
+ attr_reader :offset
21
+
22
+ def initialize fn, start_offset
23
+ @fn = fn
24
+ @offset = start_offset
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def eof?
29
+ @mutex.synchronize { @offset >= File.size(@fn) }
30
+ end
31
+
32
+ def next_message
33
+ m = @mutex.synchronize do
34
+ debug "reading #{@fn}@#{@offset}..."
35
+ with_filehandle do |f|
36
+ string = ""
37
+ l = f.gets
38
+ string << l until f.eof? || is_break_line?(l = f.gets)
39
+ debug "read #{string.size} bytes"
40
+ return nil if string.empty?
41
+ @offset += string.size
42
+ begin
43
+ RMail::Parser.read string
44
+ rescue ArgumentError # sigh. invalid utf throws this completely generic exception
45
+ nil
46
+ end
47
+ end
48
+ end
49
+
50
+ return if m.nil? # hit EOF
51
+
52
+ headers = {
53
+ "message-id" => m.header["message-id"],
54
+ "in-reply-to" => m.header["in-reply-to"],
55
+ "date" => m.header["date"],
56
+ "from" => Rfc2047.decode_to("utf-8", m.header["from"]),
57
+ }
58
+
59
+ text_part = find_text_part m
60
+ debug "text part of message has size #{text_part.body.size}: #{text_part.body[0..50].inspect}..."
61
+ body = if text_part
62
+ x = text_part.decode
63
+ x = Iconv.easy_decode("utf-8", text_part.charset, x) if text_part.charset
64
+ munge_body x
65
+ else
66
+ "_[This message contains no text/plain part. Bad commenter, no cookie! --ed.]_\n"
67
+ end
68
+
69
+ [body, headers]
70
+ end
71
+
72
+ private
73
+
74
+ ## recurse through MIME structure looking for text
75
+ def find_text_part m
76
+ if m.header.content_type.nil? || m.header.content_type == "text/plain"
77
+ m
78
+ elsif m.multipart?
79
+ m.body.find { |p| find_text_part p }
80
+ end
81
+ end
82
+
83
+ def with_filehandle
84
+ begin
85
+ f = File.open @fn
86
+ f.seek @offset
87
+ correction = correct_offset f
88
+ if correction > 0
89
+ @offset += correction
90
+ f.seek @offset
91
+ debug "corrected offset forward by #{correction} bytes"
92
+ end
93
+ x = yield f
94
+ f.close
95
+ x
96
+ rescue SystemCallError => e
97
+ warn "can't open mbox: #{e.message}"
98
+ nil
99
+ end
100
+ end
101
+
102
+ def munge_body body
103
+ ## ok, not sure why this is happening, but at least in gmail messages
104
+ ## random spaces seem to turn into =A0's in a quoted-printable
105
+ ## encoding, which then turn into \240's when decoded. so i'm manually
106
+ ## forcing these back to spaces. whether this is a good idea or not
107
+ ## i don't know.
108
+ body#.
109
+ #gsub("\240", " ").
110
+ #gsub("\302", " ") # here's another one. what is this shit?
111
+ end
112
+
113
+ private
114
+
115
+ BREAK_RE = /^From \S+ (.+)$/ # stolen from sup
116
+ require 'time'
117
+ def is_break_line? l # stolen from sup
118
+ time = begin
119
+ l =~ BREAK_RE or return false
120
+ $1
121
+ rescue ArgumentError # happens with invalid utf-8, for example
122
+ return false
123
+ end
124
+ begin
125
+ ## hack -- make Time.parse fail when trying to substitute values from Time.now
126
+ Time.parse time, 0
127
+ true
128
+ rescue NoMethodError
129
+ false
130
+ end
131
+ end
132
+
133
+ def correct_offset f # stolen from sup
134
+ string = ""
135
+ until f.eof? || is_break_line?(l = f.gets)
136
+ string << l
137
+ end
138
+ string.size
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,118 @@
1
+ require 'erb'
2
+ require 'cgi'
3
+ require "whisper/common"
4
+
5
+ module Whisper
6
+
7
+ class Page
8
+ include Loggy
9
+ include Dependency
10
+
11
+ ## kind of insane the amount of stuff this thing actually needs
12
+ def initialize opts, &rebuilder
13
+ @format = opts[:format]
14
+ @url_vars = opts[:url_vars] # used for making links to related pages
15
+ @master_template = opts[:master_template]
16
+ @template = opts[:template]
17
+ @helper = opts[:helper]
18
+ @rebuilder = rebuilder
19
+ @dependencies = opts[:extra_deps] + [@master_template, @template, @helper]
20
+ @config = opts[:config]
21
+ @router = opts[:router]
22
+ @entryset = opts[:entryset]
23
+
24
+ dependency_init
25
+ end
26
+
27
+ attr_reader :dependencies
28
+
29
+ def content_type
30
+ case @format
31
+ when "html"; "text/html"
32
+ when "rss"; "application/rss+xml"
33
+ when "txt"; "text/plain"
34
+ else raise "unknown format #@format"
35
+ end
36
+ end
37
+
38
+ ## an environment for erb
39
+ class HTMLBuilder
40
+ attr_accessor :content, :entryset
41
+
42
+ def initialize entryset, config, router, page_vars, url_vars, format
43
+ @entryset = entryset
44
+ @config = config
45
+ @router = router
46
+ @page_vars = page_vars
47
+ @url_vars = url_vars
48
+ @format = format
49
+ @content = nil
50
+ end
51
+
52
+ def method_missing m, *a
53
+ if @page_vars.member?(m) && a.empty?
54
+ @page_vars[m]
55
+ elsif @config.respond_to? m
56
+ @config.send m, *a
57
+ else
58
+ raise NoMethodError, "undefined method '#{m}' for #{self.class}. Possible ERB error."
59
+ end
60
+ end
61
+
62
+ ## utility methods for templates. TODO: move to router
63
+ def url_for opts={}; @router.url_for({ :format => @format }.merge(opts)) end
64
+ def full_url_for opts={}; @config.public_url_root + url_for(opts) end
65
+
66
+ def url_for_current opts={}; url_for @url_vars.merge(opts) end
67
+ def full_url_for_current opts={}; @config.public_url_root + url_for_current(opts) end
68
+
69
+ def link_to text, opts={}
70
+ raise "bad: #{text.inspect}, #{opts.inspect}" unless opts.is_a?(Hash)
71
+ html_opts = (opts[:html_opts] || {}).map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
72
+ "<a #{html_opts} href=\"" + url_for(opts) + "\">#{text}</a>"
73
+ end
74
+
75
+ def link_to_current text, opts={}
76
+ html_opts = (opts[:html_opts] || {}).map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
77
+ "<a #{html_opts} href=\"" + url_for_current(opts) + "\">#{text}</a>"
78
+ end
79
+
80
+ def my_binding; binding end
81
+
82
+ def self.install content, filename; eval content, binding, filename end
83
+ end
84
+
85
+ private
86
+
87
+ def build old
88
+ debug "building erb -> #{@format} from #{@template.path}"
89
+ bc = case @format
90
+ when "html"; HTMLBuilder
91
+ when "rss"; HTMLBuilder
92
+ when "txt"; HTMLBuilder
93
+ else raise "unknown format #@format"
94
+ end
95
+ erb = ERB.new @template.content, nil, "%-"
96
+ erb.filename = @template.path
97
+
98
+ bc.install @helper.content, @helper.path
99
+ b = bc.new @entryset, @config, @router, @rebuilder.call, @url_vars, @format
100
+ b.content = rewrite_image_links_in erb.result(b.my_binding)
101
+
102
+ erb = ERB.new @master_template.content, nil, "%-"
103
+ erb.filename = @master_template.path
104
+ erb.result b.my_binding
105
+ end
106
+
107
+ ## rewrite any image HTML links in here to the static location
108
+ ## (these links are created by redcloth)
109
+ def rewrite_image_links_in text
110
+ text.gsub(/<img src=\"(.*?)\"/) do |x|
111
+ path = $1
112
+ next x if path =~ /^\w+:/
113
+ "<img src=\"/static/#{path}\""
114
+ end
115
+ end
116
+ end
117
+
118
+ end
@@ -0,0 +1,79 @@
1
+ ## from: http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/101949
2
+
3
+ # $Id: rfc2047.rb,v 1.4 2003/04/18 20:55:56 sam Exp $
4
+ # MODIFIED slightly by William Morgan
5
+ #
6
+ # An implementation of RFC 2047 decoding.
7
+ #
8
+ # This module depends on the iconv library by Nobuyoshi Nakada, which I've
9
+ # heard may be distributed as a standard part of Ruby 1.8. Many thanks to him
10
+ # for helping with building and using iconv.
11
+ #
12
+ # Thanks to "Josef 'Jupp' Schugt" <jupp / gmx.de> for pointing out an error with
13
+ # stateful character sets.
14
+ #
15
+ # Copyright (c) Sam Roberts <sroberts / uniserve.com> 2004
16
+ #
17
+ # This file is distributed under the same terms as Ruby.
18
+
19
+ require 'iconv'
20
+
21
+ ## stolen from sup
22
+ class Iconv
23
+ def self.easy_decode target, charset, text
24
+ return text if charset =~ /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i
25
+ charset = case charset
26
+ when /UTF[-_ ]?8/i; "utf-8"
27
+ when /(iso[-_ ])?latin[-_ ]?1$/i; "ISO-8859-1"
28
+ when /iso[-_ ]?8859[-_ ]?15/i; 'ISO-8859-15'
29
+ when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i; "utf-7"
30
+ else charset
31
+ end
32
+
33
+ # Convert:
34
+ #
35
+ # Remember - Iconv.open(to, from)!
36
+ Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
37
+ end
38
+ end
39
+
40
+ module Rfc2047
41
+ WORD = %r{=\?([!\#$%&'*+-/0-9A-Z\\^\`a-z{|}~]+)\?([BbQq])\?([!->@-~]+)\?=} # :nodoc: 'stupid ruby-mode
42
+ WORDSEQ = %r{(#{WORD.source})\s+(?=#{WORD.source})}
43
+
44
+ def Rfc2047.is_encoded? s; s =~ WORD end
45
+
46
+ # Decodes a string, +from+, containing RFC 2047 encoded words into a target
47
+ # character set, +target+. See iconv_open(3) for information on the
48
+ # supported target encodings. If one of the encoded words cannot be
49
+ # converted to the target encoding, it is left in its encoded form.
50
+ def Rfc2047.decode_to(target, from)
51
+ from = from.gsub(WORDSEQ, '\1')
52
+ out = from.gsub(WORD) do |word|
53
+ charset, encoding, text = $1, $2, $3
54
+
55
+ # B64 or QP decode, as necessary:
56
+ case encoding
57
+ when 'b', 'B'
58
+ text = text.unpack('m*')[0]
59
+
60
+ when 'q', 'Q'
61
+ # RFC 2047 has a variant of quoted printable where a ' ' character
62
+ # can be represented as an '_', rather than =32, so convert
63
+ # any of these that we find before doing the QP decoding.
64
+ text = text.tr("_", " ")
65
+ text = text.unpack('M*')[0]
66
+
67
+ # Don't need an else, because no other values can be matched in a
68
+ # WORD.
69
+ end
70
+
71
+ begin
72
+ Iconv.easy_decode target, charset, text
73
+ rescue Iconv::InvalidCharacter => e
74
+ puts "ICONV ERROR: #{e.message}"
75
+ text
76
+ end
77
+ end
78
+ end
79
+ end