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