#!/usr/local/bin/ruby18

#######################################################################
# Raggle - Console RSS aggregator                                     #
# by Paul Duncan <pabs@pablotron.org>,                                #
#    Richard Lowe <richlowe@richlowe.net>,                            #
#    Ville Aine <vaine@cs.helsinki.fi>, and                           #
#    Thomas Kirchner <redshift@halffull.org>                          #
#                                                                     #
#                                                                     #
# Please see the Raggle page at http://www.raggle.org/ for the latest #
# version of this software.                                           #
#                                                                     #
#                                                                     #
# Copyright (C) 2003-2005 Paul Duncan, and various contributors.      #
#                                                                     #
# Permission is hereby granted, free of charge, to any person         #
# obtaining a copy of this software and associated documentation      #
# files (the "Software"), to deal in the Software without             #
# restriction, including without limitation the rights to use, copy,  #
# modify, merge, publish, distribute, sublicense, and/or sell copies  #
# of the Software, and to permit persons to whom the Software is      #
# furnished to do so, subject to the following conditions:            #
#                                                                     #
# The above copyright notice and this permission notice shall be      #
# included in all copies of the Software, its documentation and       #
# marketing & publicity materials, and acknowledgment shall be given  #
# in the documentation, materials and software packages that this     #
# Software was used.                                                  #
#                                                                     #
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,     #
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF  #
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND               #
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY    #
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF          #
# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION  #
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.     #
#######################################################################

# Raggle version
$VERSION = '0.4.4'

# installed data directory
# (note: don't touch this line, it's automatically converted by 
# the Makefile on install)
$DATADIR = "/usr/obj/ports/raggle-0.4.4/fake-hppa/usr/local/share/raggle"

# As early as possible, ^C and ^\ are common, and dumping a trace is
# ugly On the other hand, dumping trace is very useful when running
# tests, therefore disable these unless this file is executed.
if __FILE__ == $0
  trap('INT') {
    if $config['run_http_server']
      puts 'Interrupted: Starting shutdown (this may take a few minutes).';
    else
      puts 'Interrupted';
    end
    exit(-1)
  }

  # win32 doesn't support these signals
  if RUBY_PLATFORM !~ /win32/i
    trap('QUIT') { puts 'Quit'; exit(-1) }
    # trap('TERM') { puts 'Term'; exit(-1) } # don't trap -TERM

    # HUP invalidates all feeds and forces a refresh immediately
    trap('HUP') { Engine::invalidate_feed(-1); $feed_thread.run }

    # trap ALRM so it doesn't kill the process, but it will interrupt
    # the feed grabbing sleep
    trap('ALRM') { $feed_thread.run }
  end
end

#########################
# load built-in modules #
#########################
require 'getoptlong'
require 'net/http'
require 'pstore'
require 'thread'
require 'time'
require 'uri'
require 'rubygems'

#########################
# load external modules #
#########################
{ # key => [path, title, required]
  'ncurses'     => ['ncurses',        'Ncurses-Ruby', false],
  'rexml'       => ['rexml/document', 'REXML',        true ],
  'yaml'        => ['yaml',           'YAML',         true ],
  'logger'      => ['logger',         'Logger',       true ],
  'ssl'         => ['net/https',      'OpenSSL-Ruby', false],
  'webrick'     => ['webrick',        'WEBrick',      false],
  'drb'         => ['drb',            'DRb',          false],
  'xmlrpc'      => ['xmlrpc/client',  'xmlrpc4r',     false],
  'iconv'       => ['iconv',          'Iconv',        false],
  'gettext'     => ['gettext',        'GetText',      false],
  'fileutils'   => ['fileutils',      'FileUtils',    false],
  'sqlite'      => ['sqlite',         'SQLite' ,      false],
  'mysql'       => ['mysql',          'MySQL',        false],
}.each { |key, info|
  path, title, required = info[0, 3]
  $HAVE_LIB ||= {}

  begin
    require path
    $HAVE_LIB[key] = true
  rescue LoadError
    if required
      $stderr.puts <<-ENDERR
ERROR: #$!
You're missing the #{title} module.  For installation
instructions, Please see the file README or visit the following URL:
  http://www.raggle.org/docs/install/
ENDERR
      exit(-1)
    else
      $HAVE_LIB[key] = false
    end
  end
}

#
# define this as soon as possible
#
def _(str)
  $HAVE_LIB['gettext'] ? GetText::_(str) : str
end

if $HAVE_LIB['gettext']
  GetText::bindtextdomain('raggle', nil)
end

unless $HAVE_LIB['webrick'] || $HAVE_LIB['ncurses']
  $stderr.puts _(<<-ENDERR
ERROR: No interfaces available.
You're missing the both the Ncurses-Ruby and WEBrick modules.  You need
one or the other in order to run Raggle.  For installation instructions,
Please see the file README or visit the following URL:
  http://www.raggle.org/docs/install/
ENDERR
)
  exit(-1)
end

# load win32/registry as well in windows
require 'win32/registry' if RUBY_PLATFORM =~ /win32/i

###################
# utility methods #
###################

#
# Print out error and exit unconditionally.
#
def die(*args)
  errstr = "#$0: FATAL: #{args.join("\n")}"
  errstr << ": #$!" if $!
  errstr.escape_format! if errstr =~ /%/
  at_exit { $stderr.puts errstr }
  exit(-1)
end

# 
# Are we running in a screen session?
#
def in_screen?
  ENV['WINDOW'] != nil
end

#
# Is the passed string a URI?
#
def uri?(str)
  uri_chars = "a-zA-Z0-9;\/?:@&=+$,_.!~*'()%-"  # see RFC 2396
  str =~ /^[a-zA-Z0-9]+:\/\/[#{uri_chars}]+/ or str =~ /^([#{uri_chars}]+?\.)*[#{uri_chars}]+?\.(com|org|net|us|au|uk)/ or str =~ /(\d{1,3}\.){3}\d{1,3}/
end

#
# The following bit of magic allows us to Marshal Procs, or get
# their code from proc.source; use the proc method with %{} rather
# than {} for the block.  (This passes a string literal, stored in
# the Proc.  Be careful creating these in hashes - spaces kill)
# (this is used to create the help window)
#
class Proc
  attr_accessor :source

  # can't use 1.8-style marshal_{dump,load}
  # because Procs lack allocators
  def Proc._load(input)
    result = eval input
    result.source = input
    result
  end

  def _dump(depth)
    @source or raise "Proc needs source to dump"
  end

  # strips off proc{}
  alias :old_to_s :to_s
  def to_s()
    if @source
      @source[6..-2].strip
    else
      old_to_s
    end
  end
end

module Kernel
  alias :old_proc :proc

  def Kernel.proc(string=nil, &block)
    raise "string or block, not both" if string and block
    if string
      Proc._load("proc {#{string}}")
    else
      old_proc(&block)
    end
  end

  def proc(string=nil, &block)
    Kernel.proc(string, &block)
  end
end


##########################
# String utility methods #
##########################
class String
  # 
  # Get the number of lines in this string.
  #
  # Example:
  #  "a\nb".lines #=> 2
  #
  def lines
    count("\n") + 1
  end
  
  #
  # Return a copy of this string with quotes and backslashes escaped.
  #
  # Example:
  #   'a\b'.escape #=> 'a\\b'
  #
  def escape
    gsub(/(["\\])/, "\\\&")
  end

  #
  # Escape the quotes and backslashes in this string.  Returns self.
  #
  # Example:
  #   'a\b'.escape! #=> 'a\\b'
  #
  def escape!
    gsub!(/(["\\])/, "\\\&")
    self
  end

  #
  # Return a copy of this string with '%' characters escaped.
  #
  # Example:
  #   'a%b'.escape_format #=> 'a%%b'
  #
  def escape_format
    gsub(/%/, "\&\&")
  end

  #
  # Escape '%' characters in this string.  Returns self.
  #
  # Example:
  #   a = 'a%b'.escape_format!
  #   a.escape_format!
  #   a #=> 'a%%b'
  #
  def escape_format!
    gsub!(/%/, "\&\&")
    self
  end

  # 
  # HTML Entity Lookup Table
  #
  # source of this LUT: 
  #   http://www.htmlcodetutorial.com/characterentities_famsupp_69.html
  #
  HTML_ENTITY_LUT = {
    'quot'    => '"', # quote
    'amp'     => '&', # amperstand
    'lt'      => '<', # less-than
    'gt'      => '>', # greater-than
    'nbsp'    => ' ', # nonbreaking space
    'cent'    => '[cent]', # cent
    'pound'   => '[pound]', # pound
    'yen'     => '[yen]', # yen
    'brvbar'  => '|', # broken vertical bar
    'copy'    => '(c)', # copyright
    'laquo'   => '<<', # left-pointing double angle quotation mark
    'not'     => '[not]', # not
    'reg'     => '(r)', # registered trademark
    'deg'     => '[degree]', # degree
    'plusmn'  => '[plus or minus]', # plus or minus
    'sup2'    => '[squared]', # superscript 2, squared
    'sup3'    => '[cubed]', # superscript 3, cubed
    'acute'   => "'", # accute accent
    'micro'   => "[mu]", # micro (aka mu)
    'raquo'   => ">>", # right-pointing double angle quotation mark
    'frac14'  => '[1/4]', # vulgar fraction one quarter
    'frac34'  => '[3/4]', # vulgar fraction three quarters
    'times'   => 'x', # times
    'aelig'   => '[ae]', # latin small ligature ae
    'divide'  => '/', # divide
    'OElig'   => '[OE]', # latin capital ligature OE
    'oelig'   => '[oe]', # latin small ligature OE
    'fnof'    => '[f()]', # latin small f with hook = function = florin
    'circ'    => '^', # circumflex
    'tilde'   => '~', # small tilde
    'trade'   => '[tm]', # trademark
    'ndash'   => '-', # en dash
    'mdash'   => '--', # em dash
  }

  # 
  # Unicode Entity Lookup Table
  #
  # source of this LUT:
  #   http://www.pemberley.com/janeinfo/latin1.html
  #
  UNICODE_LUT = {
    732  => '~', # small tilde
    733  => '~', # double-small acute tilde
    913  => '[ALPHA]', # capital alpha
    914  => '[BETA]', # capital beta
    915  => '[GAMMA]', # capital gamma
    916  => '[DELTA]', # capital delta
    917  => '[EPSILON]', # capital epsilon
    918  => '[ZETA]', # capital zeta
    919  => '[ETA]', # capital eta
    920  => '[THETA]', # capital theta
    921  => '[IOTA]', # capital iota
    922  => '[KAPPA]', # capital kappa
    923  => '[LAMDA]', # capital lamda
    924  => '[MU]', # capital mu
    925  => '[NU]', # capital nu
    926  => '[XI]', # capital xi
    927  => '[OMICRON]', # capital omicron
    928  => '[PI]', # capital pi
    929  => '[RHO]', # capital rho
    931  => '[SIGMA]', # capital sigma
    932  => '[TAU]', # capital tau
    933  => '[UPSILON]', # capital upsilon
    934  => '[PHI]', # capital phi
    935  => '[CHI]', # capital chi
    936  => '[PSI]', # capital psi
    937  => '[OMEGA]', # capital omega
    945  => '[alpha]', # small alpha
    946  => '[beta]', # small beta
    947  => '[gamma]', # small gamma
    948  => '[delta]', # small delta
    949  => '[epsilon]', # small epsilon
    950  => '[zeta]', # small zeta
    951  => '[eta]', # small eta
    952  => '[theta]', # small theta
    953  => '[iota]', # small iota
    954  => '[kappa]', # small kappa
    955  => '[lamda]', # small lamda
    956  => '[mu]', # small mu
    957  => '[nu]', # small nu
    958  => '[xi]', # small xi
    959  => '[omicron]', # small omicron
    960  => '[pi]', # small pi
    961  => '[rho]', # small rho
    962  => '[final]', # small final
    963  => '[sigma]', # small sigma
    964  => '[tau]', # small tau
    965  => '[upsilon]', # small upsilon
    966  => '[phi]', # small phi
    967  => '[chi]', # small chi
    968  => '[psi]', # small psi
    969  => '[omega]', # small omega
    8208 => '-', # hyphen
    8211 => '--', # en dash
    8212 => '--', # em dash
    8213 => '-', # horizontal bar
    8216 => "`", # left single quotation mark
    8217 => "'", # right single quotation mark
    8218 => ',', #  single low-9 quotation mark
    8219 => '`', # single high-reversed-9 quotation mark
    8220 => '``', # left double quotation mark
    8221 => '"', # right double quotation mark
    8222 => ',,', # double low-9 quotation mark
    8226 => '*', # bullet
    8242 => "'", # prime
    8243 => "''", # double prime
    8249 => '<', # single left-pointing angle quotation mark
    8250 => '>', # single right-pointing angle quotation mark
    8252 => '!!', # double exclamation point
    8250 => '/', # fraction slash
    8355 => '[franc]', # french franc sign
    8356 => '[lira]', # lira sign
    8359 => '[peseta]', # peseta sign
    8453 => '[c/o]', # care of
    8364 => '[euro]', # euro sign
    8470 => '[numero]', # numero sign
    8482 => '[tm]', # trademark sign
    8486 => '[ohm]', # ohm sign
    8494 => '[est.]', # estimated symbol
    8539 => '[1/8]', # vulgar fraction one eighth
    8540 => '[3/8]', # vulgar fraction three eighths
    8541 => '[5/8]', # vulgar fraction five eighths
    8542 => '[7/8]', # vulgar fraction seven eighths
    8722 => '-', # minus sign
    8729 => '*', # bullet operator
    8730 => '[sqrt]', # square root
    8734 => '[inf]', # infinity
    8735 => '[right angle]', # right angle
    8745 => '[intersection]', # intersection
    8747 => '[integral]', # integal
    8776 => '[almost equal to]', # almost equal to
    8800 => '[not equal to]', # not equal to
    8801 => '[identical to]', # identical to
    8804 => '[< or =]', # less-than or equal to
    8805 => '[> or =]', # greater-than or equal to
    64257 => '[fl]', # latin small ligature fi
    64258 => '[FL]', # latin small ligature fi
  }

  #
  # Return copy of string with HTML entities converted into ASCII
  # characters.
  # 
  # Note: this bit of love is originally from the pickaxe (augmented by
  # me, of course).
  #
  # Example:
  #   '&amp;&lt;br /&gt;'.unescape_html #=> '&<br />'
  #
  def unescape_html
    # if we're uing iconv, disable unicode munging unless the override
    # use_iconv_munge is set
    munge_uni = !($HAVE_LIB['iconv'] && $config['use_iconv']) || 
                 $config['use_iconv_munge']
    munge_str = $config['unicode_munge_str']

    str = self.dup
    str.gsub!(/&(.*?);/n) {
      m = $1.dup.downcase
      if HTML_ENTITY_LUT.key?(m)
        HTML_ENTITY_LUT[m]
      else
        case m
        when 'amp':   '&'
        when 'nbsp'   ' '
        when /^quot$/ni:  '"'
        when /^lt$/ni:    '<'
        when /^gt$/ni:    '>'
        when /^copy/:     '(c)'
        when /^trade/:    '(tm)'
        when /^#8212$/n:  ","
        when /^#8217$/n:  "'"
        when /^#8218$/n:  ","
        when /^#(\d+)$/n
          r = $1.to_i # Integer() interprets leading zeros as octal
          if !r.between?(0, 255) && munge_uni
            UNICODE_LUT[r] ? UNICODE_LUT[r] : munge_str
          else
            r.chr
          end
        when /^#x([0-9a-f]+)$/ni
          r = $1.hex
          if !r.between?(0, 255) && munge_uni
            UNICODE_LUT[r] ? UNICODE_LUT[r] : munge_str
          else
            r.chr
          end
        end
      end
    }
    str
  end

  #
  # return a copy of this string with HTML special characters
  # escaped
  #
  # Example:
  #   str = '<b>Rip &amp "Burn"</b>'.escape_html
  #   str #=> "&lt;b&gt;Rip &amp;amp &quot;Burn&quot;&lt;/b&gt;"
  #
  def escape_html
    [ [/&/, '&amp;'], [/</, '&lt;'], [/>/, '&gt;'], [/"/, '&quot;']
    ].inject(self) do |ret, ary|
      ret.gsub(*ary)
    end
  end

  #
  # Return copy of string with all HTML tags stripped.
  #
  # Example:
  #   "<a href='sadf'>blargh</a>".strip_tags #=> 'blargh'
  #
  def strip_tags
    gsub(/<[^>]+?>/, '')
  end

  #
  # Return copy of string with lines reflowed for the given terminal
  # width.
  #
  # Example: 
  #   str = "this is a really long string it won't fit"
  #   str.reflow(22) #=> "this is a really long\nstring it won't fit"
  #
  def reflow(width = 72, force = false, line_delim_regex = %r!^(<br[^/]*>|\.)$!)
    text = ''
    curr_line = ''
    strip.split(/\s+/).each { |word|
      if line_delim_regex && word =~ line_delim_regex
        text << curr_line << "\n" 
        curr_line = ''
      elsif curr_line.length + word.length > width
        if curr_line.length > width and force
          fline = curr_line[0, width - 2] << "\\\n"
          text << fline
          curr_line = curr_line[width - 2, curr_line.length]
        else
          text << curr_line << "\n"
          curr_line = "#{word} "
        end
      else
        curr_line << "#{word} "
      end
    }
    text << curr_line << "\n"
    text
  end
end

#
# Raggle engine and interface container module.
#
module Raggle
  #
  # Basic path searching methods.
  #
  module Path
    #
    # Find an application in your $PATH.  Returns nil if the app
    # couldn't be found.
    # 
    # Example:
    #   Path::find_app('ls') #=> '/bin/ls'
    #
    def Path::find_app(app)
      re = (RUBY_PLATFORM =~ /win32/i) ? ';' : ':'
      ENV['PATH'].split(re).find { |path| test ?x, "#{path}/#{app}" }
    end
    
    #
    # Find home directory (different env vars on unix and win32).
    #
    # Note: checks for ENV['HOME'], ENV['USERPROFILE'], then
    # ENV['HOMEPATH'], and returns nil if none were found.
    #
    # Example:
    #   Path::find_home #=> '/home/pabs'
    #
    def Path::find_home
      ENV['HOME'] || ENV['USERPROFILE'] || ENV['HOMEPATH']
    end
    
    #
    # Find web root (in win32 it's located in the program dir by default) 
    #
    # Example:
    #   Path::find_web_ui_root #=> '/home/pabs/.raggle/web_ui'
    # 
    def Path::find_web_ui_root
      if ENV['RAGGLE_WEB_DATA']
        ENV['RAGGLE_WEB_DATA']
      elsif RUBY_PLATFORM =~ /win32/i
        ENV['PROGRAMFILES'] + '/Raggle/web_ui'
      else
        '${config_dir}/web_ui'
      end
    end
    
    BROWSERS = %w{links elinks w3m lynx iexplore.exe explorer.exe}
    
    #
    # Find browser, or exit if a browser cannot be found.
    #
    # Checks for ENV['RAGGLE_BROWSER'], ENV['BROWSER'], then looks for
    # "links", "elinks", "w3m", "lynx", then finally "explorer.exe".  If 
    # all those fail, then dies with an error message.
    #
    # Example:
    #   Path::find_browser #=> '/usr/bin/links'
    #
    def Path::find_browser
      ret = ENV['RAGGLE_BROWSER']
      ret ||= ENV['BROWSER']
      ret ||= BROWSERS.find { |app| find_app(app) }

      # die if we couldn't find a browser
      unless ret
        $stderr.puts "WARNING: Couldn't find suitable browser, opening external links.",
          "WARNING: has been disabled.  Please set $RAGGLE_BROWSER, $BROWSER, or install",
          "WARNING: one of the following browsers: ELinks, Links, Lynx. or W3M."
        $stdin.gets
      end

      # return browser
      ret
    end
  end

  #
  # Proxy-related methods for Raggle.
  #
  module Proxy
    #
    # Find a proxy in Windows by checking the registry.
    #
    # Example:
    #   Proxy::find_win32_proxy #=> {"host"=>"proxy", "port"=>3128}
    #
    def Proxy::find_win32_proxy
      ret = nil

      Win32::Registry::open(
        Win32::Registry::HKEY_CURRENT_USER,
        'Software\Microsoft\Windows\CurrentVersion\Internet Settings'
      ) do |reg|
        # check and see if proxy is enabled
        if reg.read('ProxyEnable')[1] != 0
          # get server, port, and no_proxy (overrides)
          server = reg.read('ProxyServer')[1]
          np = reg.read('ProxyOverride')[1]

          server =~ /^([^:]+):(.+)$/
          ret = {
            'host'      => $1,
            'port'      => $2,
          }

          ret['no_proxy'] = np.tr(';', ',') if np && np.length > 0
        end
      end
      
      # dump proxy debug
      # ret && ret.each { |key, val| puts "DEBUG: #{key} => #{val}" }
      
      ret
    end

    #
    # Get host, port, and no_proxy proxy information for a given host.
    #
    # Checks $config['proxy'], then ENV['http_proxy'] / ENV['no_proxy'].
    # Also calls Proxy::find_win32_proxy if we're in Windows.
    #
    # Example:
    #   Proxy::find_proxy #=> {"host"=>"proxy", "port"=>3128}
    #
    def Proxy::find_proxy(host)
      ret = nil

      # if we're in windows, check the registry as well
      ret = Proxy::find_win32_proxy if RUBY_PLATFORM =~ /win32/

      # get proxy settings from $config, and if they're not there, then
      # check ENV
      if $config['proxy'] && $config['proxy']['host']
        ret = $config['proxy']
      elsif ENV['http_proxy'] && ENV['http_proxy'] =~ /http:\/\/([^:]+):(\d+)/
        ret = { 'host' => $1, 'port' => $2 }
        no_proxy = ENV['no_proxy']
        ret['no_proxy'] = (no_proxy ? no_proxy.split(/\s*,\s*/) : [])
      end

      # return nil if host is in no_proxy list
      ret = nil if ret && ret['no_proxy'] && 
                   ret['no_proxy'].find { |i| /#{i}/ =~ host }

      ret
    end
  end

  #
  # Basic command-line HTML renderer.
  #
  module HTML
    #
    # Tag set defines all tags that the renderer can handle.
    #
    # Each tag can modify the context by defining +:context+ key (see pre).
    #
    # Tag's can also have actions which will be executed sequentially
    # when the tag occurs in the token stream.  Actions must be defined
    # to occurences of start tags and end tags separately.
    #
    class TagSet
      class Tag
        #
        # Raggle::HTML::TagSet::Tag constructor.
        #
        def initialize
          @context = nil
          @start_actions = []
          @end_actions = []
        end

        attr_accessor :context
        attr_reader :start_actions, :end_actions

        #
        # TODO: Raggle::HTML::TagSet::Tag#start_actions= placeholder
        #
        def start_actions=(*actions)
          @start_actions = actions.flatten
        end

        #
        # TODO: Raggle::HTML::TagSet::Tag#end_actions= placeholder
        #
        def end_actions=(*actions)
          @end_actions = actions.flatten
        end
      end
      
      #
      # TagSet constructor.
      #
      def initialize
        @tags = Hash.new
        yield self if block_given?
      end

      #
      # TODO: Raggle::HTML::TagSet::define_tag placeholder
      #
      def define_tag(*names)
        yield tag_spec = Tag.new
        names.each do |name|
          yield @tags[name] = tag_spec
        end
      end

      #
      # Is the given tag defined?
      #
      # Example:
      #   tag_set.defined?('p') #=> true
      #
      def defined?(name)
        @tags.has_key?(name)
      end
      
      #
      # Get a tag by name.
      #
      def [](name)
        @tags[name]
      end
    end

    TAG_SET = TagSet.new 

    #
    # initialize HTML::TAG_SET, must be called before HTML::render_html
    #
    # Note: the only reason I'm doing things this way is to keep RDoc
    # from barfing (it was doing exactly that witht he old way of
    # initializing HTML::TAG_SET :()
    #
    def self.init_tagset
      tag_set = TAG_SET
      tag_set.define_tag 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'  do |tag|
        tag.start_actions = :maybe_new_paragraph
        tag.end_actions = :new_paragraph
      end

      tag_set.define_tag 'pre' do |tag|
        tag.context = :in_pre
        tag.start_actions = :maybe_new_paragraph
        tag.end_actions = :new_paragraph
      end

      tag_set.define_tag 'br', 'br/' do |tag|
        tag.start_actions = :force_line_break
      end

      tag_set.define_tag 'a' do |tag|
        tag.start_actions = :save_href
        tag.end_actions = :insert_link_ref
      end

      tag_set.define_tag 'img' do |tag|
        tag.start_actions = :handle_img
      end
    end

    #
    # Render HTML for a given terminal width.
    #
    # Example:
    #   HTML::render_html('<br/><br/>test') #=> "\n\ntest"
    #
    def self.render_html(source, width=72)
      r = Renderer.new(source, width)
      r.rendered_text
    end
    
    #
    # Text-mode HTML renderer.
    #
    class Renderer
      #
      # Raggle::HTML::Renderer constructor. 
      #
      def initialize(source, width)
        @source, @width = source, width
        @links = []
        $links = []
        @images = []
        @rendered_text = render
      end

      attr_reader :rendered_text
      
      #
      # Execute actions defined for +tag+ in this +phase+.
      #
      # actions:: actions set
      # tag::     tag from tag set
      # phase::   either +:start+ or +:end+
      # params::  params passed to the actions
      #
      def call_actions(tag, phase, *params)
        actions = phase == :start ? tag.start_actions : tag.end_actions
        actions.each do |action|
          self.send("action_#{action}", *params)
        end
      end
      
      #
      # Enter to context defined by tag (if any)
      #
      # context:: current context stack
      # tag::     tag from tag set
      #
      def context_enter(context, tag)
        if tag.context
          context << tag.context
        end
      end
      
      #
      # Exit from context defined by tag (if any)
      #
      # context:: current context stack
      # tag::     tag from tag set
      #
      def context_exit(context, tag)
        if tag.context
          context.pop
        end
      end
      
      #
      # Reflow +text+, and append the result to
      # +lines+
      #
      # lines:: line array representing lines on screen
      # text::  text to be reflown
      # width:: maximum width of each line on screen
      #
      def reflow_text(text)
        cur_line = @lines.pop || ''
        text.scan(/\s+|[^\s]+/) do |chunk|
          if chunk =~ /^\s+/
            cur_line << " " unless cur_line.empty? || cur_line[-1] == ?\ # fix emacs
          else
            if cur_line.length + chunk.length > @width
              @lines << cur_line
              cur_line = ""
            end
            cur_line << chunk.chomp
          end
        end
        @lines << cur_line
      end

      #
      # Renders HTML in +src+ to a screen with maximum width +width+.
      # Returns +String+ containing rendered text.
      #
      # src::   HTML source
      # width:: Screen width
      #
      def render
        @lines = []
        @context = []
        
        Parser::each_token(@source) do |token, data, attributes|
        #puts "C: #{@context[-1]} T: #{token} D: <#{data}> L: #{@lines.inspect}"
        case token
        when :TEXT
          if @context[-1] == :in_pre
            tmp = data.split("\n", -1)
            if @lines[-1]
              @lines[-1] << tmp.shift
            end
            @lines += tmp
          else
            reflow_text(data)
          end
        when :START_TAG
          @current_attributes = attributes
          tag = TAG_SET[data]
          next unless tag
          context_enter(@context, tag)
          call_actions(tag, :start)
        when :END_TAG
          tag = TAG_SET[data]
          next unless tag
          context_exit(@context, tag)
          call_actions(tag, :end)
        end
        end

        # trim trailine new lines
        until @lines[-1] != ''
          @lines.delete_at(@lines.size - 1)
        end

        # If there are links, insert them here
        unless @links.empty?
          @lines << ''
          @lines << $config['msg_links']
          padding = @links.size / 10 + 1
          @links.each_with_index do |link, index|
            @lines << "%#{padding}d. %s" % [index + 1, link]
          end
        end

        # If there are images, insert references
        unless @images.empty?
          @lines << ''
          @lines << $config['msg_images']
          padding = @images.size / 10 + 1
          @images.each_with_index do |image,index|
            @lines << "%#{padding}d. %s" % [index + 1,image]
          end
        end

        rendered_text = @lines.join("\n") + "\n"
        $config['unescape_html'] ? rendered_text.unescape_html : rendered_text
      end

      # Actions used in the tag set

      #
      # Append paragraph break
      #
      def action_new_paragraph
        @lines.push(*['', ''])
      end

      #
      # Append paragraph break, unless there is one already.
      #
      def action_maybe_new_paragraph
        unless @lines[-1] == '' || @lines.empty?
          @lines.push(*['', ''])
        end
      end

      #
      # Force line break.
      #
      def action_force_line_break
        @lines << ''
      end

      #
      # Save href of the current tag (if it exists)
      # unless duplicate link exists.
      #
      def action_save_href
        href = @current_attributes['href']
        if href_index = @links.index(href)
          @last_link_index = href_index + 1
        else
          @links << href
          $links << href
          @last_link_index = @links.size
        end
      end

      #
      # Insert reference to the latest link into the rendered text.
      #
      def action_insert_link_ref
        if @lines[-1] && @last_link_index
          @lines[-1] << '[%d]' % @last_link_index
        end
      end

      def action_handle_img
        return if not @current_attributes['src']
        @images << @current_attributes['src']
        if @lines[-1]
          title = ''
          if @current_attributes['title'] and @current_attributes['title'].length > 0
            title = @current_attributes['title']
          elsif @current_attributes['alt'] and @current_attributes['alt'].length > 0
            title = @current_attributes['alt']
          end
          if title == ''
            @lines[-1] << '[img %d]' % @images.size
          else
            @lines[-1] << '[img %d: %s]' % [ @images.size, title ]
          end
        end
      end
    end  # module HTML

    #
    # Basic HTML parser.
    #
    module Parser
      NO_ATTRIBUTES = {}.freeze
      ATTRIBUTE_LIST_RE = /\s*([^>=\s]+)\s*(?:=\s*(?:(?:['"]([^'">]*)['"])|([^'"\s>]+)))?/ #'
      PARSER_RE = %r!<(/?\w+[^>]*?/?)>|([^<]*)!m
      
      #
      # Parses tag's attributes and returns them in a hash.
      #
      def self.parse_attributes(source)
        #puts "SOURCE: ", source
        attributes = {}
        source.scan(ATTRIBUTE_LIST_RE) do |name, value, unquoted_value|
          attributes[name] = value || unquoted_value || ''
        end
        attributes
      end
      
      #
      # Parses HTML in +source+ and invokes block with each token as a
      # paramater.
      #
      # Parameters to the block:
      #  token id   | data        | attributes
      #  :TEXT      | text        | NO_ATTRIBUTES
      #  :START_TAG | tag's name  | attributes of current tag
      #  :END_TAG   | tag's name  | NO_ATTRIBUTES
      #
      # source:: HTML source
      #
      def self.each_token(source)
        if source 
          source.scan(PARSER_RE) do |tag, text|
            #p tag, text
            if tag
              if tag[0] == ?/
                yield :END_TAG, tag[1..-1], NO_ATTRIBUTES
              else
                if tag =~ /\A(\w+)\s*(.*)\z/m
                  attributes = NO_ATTRIBUTES
                  attributes = parse_attributes($2) if $2
                  yield :START_TAG, $1, attributes
                end
              end
            else
              yield :TEXT, text, NO_ATTRIBUTES unless text == ""
            end
          end
        end
      end
    end
  end

  #
  # Very simple OPML importer/exporter.
  #
  module OPML
    #
    # Import OPML file
    #
    # file_name::  OPML file
    # refresh::    refresh rate used for all imported feeds.
    #              If nil, then value of option +default_feed_refresh+
    #              is used.
    # lock_title:: If true, then all titles of imported feeds
    #              will be locked
    # save_items:: If true, then all imported feeds' items will be saved.
    #
    def OPML::import(file_name, refresh=nil, lock_title=nil, save_items=nil, force=false)
      begin
        contents = if uri?(file_name)
          begin
            Raggle::Engine::get_url(file_name)[0]
          rescue
            raise "Couldn't get URI \"#{file_name}\"."
          end
        else
          begin
            ret = ''
            file = (file_name == '-') ? $stdin : File::open(file_name)
            ret = file.read
          rescue
            raise "Couldn't open input file \"#{file_name}\"."
          ensure
            file.close
            ret
          end
        end

        if contents && contents.size > 0
          # escape CDATA elements and merge them in to the text
          # (ths fixes REXML's braindead CDATA behavior) 
          contents = contents.gsub(/<!\[CDATA\[(.*?)\]\]>/m) do |m| 
            $1.dup.escape_html
          end

          dup_added = false
          doc = REXML::Document.new(contents)
          doc.root.elements.each('//outline') do |outline|
            title = outline.attributes['title'] || $config['default_feed_title']
            url = outline.attributes['xmlUrl'] || outline.attributes['xmlurl'] || outline.attributes['url']
            site = outline.attributes['htmlUrl'] || outline.attributes['htmlurl'] || ''
            desc = outline.attributes['description'] || ''
            cat = outline.attributes['category'] || nil
            image = outline.attributes['image'] || nil
            max_items = outline.attributes['max_items'] || nil
            priority = outline.attributes['priority'].to_i || $config['default_feed_priority']
            # the following properties take effect unless overridden on CLI
            if outline.attributes['refresh']: opml_refresh = outline.attributes['refresh'].to_i 
            else opml_refresh = nil; end  # fixes clobberation
            frefresh = refresh || opml_refresh || $config['default_feed_refresh']
            fsave_items = save_items || outline.attributes['save_items'] || false
            flock_title = lock_title || outline.attributes['lock_title'] || false

            if url.nil?
              $stderr.puts "Warning: skipping incomplete OPML entry: #{outline}"
              next
            end

            if (url =~ /^exec:/)
              if $wins  # in ncurses mode
                Interfaces::NcursesInterface::set_status($config['msg_exec_url'])
                return 1
              else
                raise "WARNING!!! EXEC URL IN OPML IMPORT" 
              end
            end

            # build and add feed
            feed = {'title' => title, 'url' => url, 'refresh' => frefresh,
                    'lock_title?' => flock_title, 'save_items?' => fsave_items,
                    'site' => site, 'desc' => desc, 'items' => [],
                    'image' => image, 'category' => cat, 'force' => force,
                    'priority' => priority, 'max_items' => max_items }
            dup_added = true unless Engine::add_feed(feed)
          end
          if $wins  # skip unless using ncurses
            Interfaces::NcursesInterface::populate_feed_win
            Interfaces::NcursesInterface::set_status($config['msg_opml_imported']) unless dup_added
          end
        else
          raise "Empty input file \"#{file_name}\"."
        end
      rescue REXML::ParseException => parse_err
        raise if $wins
        die "Parsing #{file_name} failed: #{parse_err.message}" unless $wins
      rescue => err
        if $wins: Interfaces::NcursesInterface::set_status($config['msg_bad_uri'])
        else die err.message; end
      end
    end

    #
    # Export all feeds to OPML format.
    #
    def OPML::export(file_name)
      begin
        opml = REXML::Element.new("opml")
        opml.attributes['version'] = '1.1'

        opml.add_element(opml_head)
        
        body = REXML::Element.new('body')
        opml.add_element(body)
        $config['feeds'].each do |feed|
          body.add_element(feed_to_outline(feed))
        end

        doc = REXML::Document.new
        doc << REXML::XMLDecl.new
        doc.add(opml)

        file = (file_name == '-') ? $stdout : File::open(file_name, 'w')
        if file
          begin
            doc.write(file, 0)
            Interfaces::NcursesInterface::set_status($config['msg_opml_exported']) if $wins
          ensure
            file.close unless file_name == '-'
          end
        else
          raise "Couldn't open output file \"#{file_name}\"."
        end
      rescue => err
        if $wins: Interfaces::NcursesInterface::set_status($config['msg_bad_uri'])
        else die err.message; end
      end
    end

    #
    # Generate OPML header.
    #
    def OPML::opml_head
      head = REXML::Element.new('head')
      title = REXML::Element.new('title')
      title.text = 'Raggle subscriptions'
      head.add_element(title)
      head
    end

    #
    # Convert feed hash to outline-element.
    #
    def OPML::feed_to_outline(feed)
      outline = REXML::Element.new('outline')
      outline.attributes['title'] = feed['title'] || ''
      outline.attributes['xmlUrl'] = feed['url'] || ''
      outline.attributes['htmlUrl'] = feed['site'] if feed.has_key?('site')
      outline.attributes['description'] = feed['desc'] || ''
      outline.attributes['category'] = feed['category'] || ''
      outline.attributes['image'] = feed['image'] if feed.has_key?('image')
      outline.attributes['priority'] = feed['priority'].to_i || $config['default_feed_priority']
      outline.attributes['refresh'] = feed['refresh'] || $config['default_feed_refresh']
      outline.attributes['save_items'] = feed['save_items?'] if feed.has_key?('save_items?')
      outline.attributes['max_items'] = feed['max_items'] if feed.has_key?('max_items')
      outline.attributes['lock_title'] = feed['lock_title?'] if feed.has_key?('lock_title?')
      outline.attributes['version'] = 'RSS'
      outline.attributes['type'] = 'rss'
      outline
    end
  end

  #
  # Syndic8 XML-RPC interface (used to search for new feeds).
  #
  # This code was shamelessly stolen from syndic8-ruby (see
  # http://www.pablotron.org/software/syndic8-ruby/).
  #
  class Syndic8
    attr_accessor :keys, :max_results
    VERSION = '0.1.0'

    #
    # Connect to Syndic8
    #
    def initialize
      @keys = %w{sitename siteurl dataurl description}
      @max_results = -1
      @rpc = XMLRPC::Client.new('www.syndic8.com', '/xmlrpc.php', 80)
    end

    #
    # Call syndic8 method
    #
    def call(meth, *args)
      @rpc.call("syndic8.#{meth}", *args)
    end
    private :call

    #
    # Find feeds matching a given search string.
    #
    def find(str)
      begin
        feeds = call('FindFeeds', str, 'sitename', @max_results)
        (feeds && feeds.size > 0) ? call('GetFeedInfo', feeds, @keys) : []
      rescue XMLRPC::FaultException => e
        raise "Syndic8 Error #{e.faultCode}: #{e.faultString}"
      end
    end

    #
    # Get a list of fields returned by Syndic8.
    #
    def fields
      call 'GetFeedFields'
    end
  end

  #
  # Basic feed handling classes.
  #
  module Feed
    #
    # Feed::Item struct definition
    #
    Item = Struct.new :title, :link, :desc, :date

    #
    # Raggle::Feed::List object.  The list which contains all of
    # the feeds and feed items for Raggle.
    #
    class List
      attr_accessor :feeds

      #
      # Raggle::Feed::List constructor.
      #
      # Example:
      #   feeds = Feed::list.new 
      #
      def initialize
        @feeds = []
      end

      #
      # Return the number of feeds in this Raggle::Feed::List object.
      #
      # Example:
      #   feeds.size #=> 100
      #
      def size
        @feeds.size
      end
      
      #
      # Add a new feed to this feed list.
      #
      # Note: the parameters of this method _will_ change in the near
      # future.
      #
      # Example:
      #   feeds.add('pablotron', 'http://www.pablotron.org/rss/', 120)
      #
      def add(title, url, refresh, lock_title=false, save_items=false, site='',
              desc='', items=[], image = nil, category = nil, force=false,
              priority=0, max_items=nil)
        refresh_interval_check(refresh) unless force || url =~ /^(file|exec):/
        lock_title = false if !lock_title
        save_items = false if !save_items
        @feeds << {
          'title'       => title,
          'url'         => url,
          'refresh'     => refresh,
          'site'        => site,
          'desc'        => desc,
          'updated'     => 0,
          'image'       => image,
          'items'       => items,
          'category'    => category, 
          'lock_title?' => lock_title,
          'save_items?' => save_items,
          'max_items'   => max_items,
          'priority'    => priority,
        }
      end

      #
      # Append a feed to this feed list.
      #
      # Example:
      #   feeds << feed
      #
      def <<(feed)
        @feeds << feed
      end

      #
      # Get a list of available categories, sorted alphabetically.
      # 
      # Note: categories are case-insensitive.
      #
      # Example:
      #   feeds.categories #=> ["News", "Blog"]
      #
      def categories
        category = Struct::new(:title, :count)
        cats = {}
        @feeds.each { |feed|
          if feed['category']
            feed['category'].split(/\s*\b\s*/).each { |cat|
              if cat == ','
              elsif cats.has_key? cat.downcase
                cats[cat.downcase].count += 1
              else
                cats[cat.downcase] = category.new(cat, 1)
              end
            }
          end
        }
        cats.values.sort { |a, b| a.title.downcase <=> b.title.downcase }
      end

      #
      # Return a list of feeds in the given category.
      #
      def category(cat = 'all')
        if cat !~ /\ball\b/i
          re = /\b#{cat}\b/i
          @feeds.select { |feed| feed['category'] =~ re }
        else
          @feeds
        end
      end

      #
      # Get a feed by id.
      #
      def [](*n)
        @feeds[*n]
      end
      
      #
      # Get a feed by id (index).
      # 
      # Raises an exception if the ID is invalid.
      #
      def get(id)
        raise "Invalid feed id: #{id || '<nil>'}" unless id && id < @feeds.size
        @feeds[id]
      end
      
      
      #
      # Delete a feed by id (index).
      # 
      # Raises an exception if the ID is invalid.
      #
      def delete(id)
        raise "Invalid feed id: #{id}" unless id < @feeds.size
        @feeds.delete_at(id)
      end

      #
      # Mark a feed for updating.
      #
      # Raises an exception if the ID is invalid.
      #
      def invalidate(id)
        if id == -1
          @feeds.each { |feed| feed['updated'] = 0 }
        else
          raise "Invalid feed id: #{id}" unless id < @feeds.size
          @feeds[id]['updated'] = 0
        end
      end

      #
      # Iterate over every feed with its index.
      #
      def each_with_index
        @feeds.each_with_index { |feed, i| yield feed, i }
      end

      #
      # Iterate over every feed.
      #
      def each
        @feeds.each { |feed| yield feed }
      end

      #
      # Iterate over every feed index.
      #
      def each_index
        @feeds.each_index { |i| yield i }
      end

      #
      # Sort feeds by priority then title (case-insensitive).
      #
      def sort
        @feeds.sort! { |a, b|
          a['title'] ||= $config['default_feed_title']
          b['title'] ||= $config['default_feed_title']
          a['priority'] ||= $config['default_feed_priority']
          b['priority'] ||= $config['default_feed_priority']
          
          if a['priority'] != b['priority']  # higher priorities first
            b['priority'] <=> a['priority']
          else  # just sort by title
            rePrefix = /^\s*(a|the)\s+/i
            reSpaces = /^\s*(.*?)\s*$/s

            a_c, b_c = a['title'].gsub(rePrefix, '').gsub(reSpaces, '\1').downcase, 
                       b['title'].gsub(rePrefix, '').gsub(reSpaces, '\1').downcase

            a_c <=> b_c
          end
        }
      end

      #
      # Edit a feed by ID.  
      # Raises an exception if the ID is invalid.
      #
      def edit(id, opts)
        die "Invalid feed id: #{id}" unless id < @feeds.size
        refresh_interval_check opts['refresh'] if !opts['force'] &&
                                                  opts.has_key?('refresh')
        if id == -1
          0.upto($config['feeds'].size - 1) do |id|
            feed = $config['feeds'].get(id)
            ourl = feed['url']
            otitle = feed['title']
            %w{title url site refresh priority category lock_title? save_items? max_items}.each { |key|
              feed[key] = opts[key] if opts.has_key? key
            }
            $config['feeds'].invalidate(id) if opts.has_key?('url') and opts['url'] != ourl
            feed['lock_title?'] = true if opts.has_key?('title') and opts['title'] != otitle
          end
        else 
          feed = $config['feeds'].get(id)
          ourl = feed['url']
          otitle = feed['title']
          %w{title url site refresh priority category lock_title? save_items? max_items}.each { |key|
            feed[key] = opts[key] if opts.has_key? key
          }
          $config['feeds'].invalidate(id) if opts.has_key?('url') and opts['url'] != ourl
          feed['lock_title?'] = true if opts.has_key?('title') and opts['title'] != otitle
        end
      end

      #
      # Move a feed up in the feed list.  Wraps to bottom at top of
      # list.
      #
      def move_up(id)
        if id > 0
          pitem = @feeds[id - 1]
          @feeds[id - 1] = @feeds[id]
          @feeds[id] = pitem
          id - 1
        else
          id
        end
      end

      #
      # Move a feed down in the feed list.  Wraps to top at bottom of
      # list.
      #
      def move_down(id)
        if id < @feeds.size - 1
          nitem = @feeds[id + 1]
          @feeds[id + 1] = @feeds[id]
          @feeds[id] = nitem
          id + 1
        else
          id
        end
      end

      #
      # Print out description of feed in description window (Ncurses
      # interface).
      #
      def describe(id)
        # clear desc window
        win = $wins[Raggle::Interfaces::NcursesInterface::get_win_id('desc')]
        win.clear
        
        win.items.clear # This should only ever have one entry
        feed = $config['feeds'].get(id)
        win.items << {
          # 'real_title' => feed['title'],
          'title'      => feed['title'],
          'content'    => feed['desc'],
          'date'       => feed['date'],
          'url'        => feed['url'],
          'site'       => feed['site'],
          'date'       => Time.at(feed['updated']).strftime('%c'),
          'read?'      => false
        }

        win.draw_items
      end
        
      #
      # Sanity check on refresh interval of feed.
      #
      def refresh_interval_check(interval)
        if interval > 0 && interval < $config['feed_refresh_warn']
            err = <<ENDWARNING
Using a refresh interval of less than #{$config['feed_refresh_warn']} minutes
_really_ irritates some system administrators.  If you're sure you want to do
this, use the '--force' option to bypass this warning.
ENDWARNING
# '
          $stderr.puts err.reflow
          exit(-1)
        end
      end
      private :refresh_interval_check
    end

    #
    # Basic RSS Channel object.  Used by RSS parser.
    #
    class Channel
      attr_accessor :title, :link, :desc, :lang, :image, :items, :last_modified

      #
      # Raggle::Feed::Channel constructor.
      #
      def initialize(url, last_modified = nil)
        parse_rss_url(url, last_modified)
      end

      def handle_rss_enclosure(item_hash, enclosure_element) 
        return unless $config['enclosure_hook_cmd']
      
        # create new child process
        pid = Kernel.fork {
          # close standard input, output, and error
          $stdin.close; $stdout.close; $stderr.close

          # build arguments for exec
          args = [
            $config['enclosure_hook_cmd'],
            @title,
            @link,
            item_hash['title'],
            item_hash['link'],
            enclosure_element.attributes['url'],
            enclosure_element.attributes['type'],
            enclosure_element.attributes['length'],
          ]

          # call exec, and exit immediately if it failed
          Kernel.exec(*args)
          Kernel.exit!
        }

        # detach from child process
        Process.detach(pid)

        # return nil
        nil
      end
      private :handle_rss_enclosure

      def parse_atom_entry(entry_elem)
        ret = {}

        entry_elem.elements['title']
        %w{title modified issued content}.each do |key|
          ret[key] = ''
          if elem = entry_elem.elements[key]
            ret[key] = fix_character_encoding(elem)
          end
        end

        # expand description element
        ret['description'] = ret['content']

        # expand link element
        ret['link'] = ''
        if elem = entry_elem.elements['link']
          ret['link'] = elem.attributes['href']
        end

        # fix date element
        ret['date'] = ''
        str = ret['modified'] || ret['issued']
        if str
          # parse date string and convert it into an http date
          # (which I like less than XML dates, but Raggle already
          # handles them)
          date_str = Time.parse(str).httpdate rescue ''
          ret['date'] = date_str
        end

        # at this point we have the title, content, link, and date, so
        # go ahead and return the converted item hash
        ret
      end
      private :parse_atom_entry

      # 
      # Parse an RSS URL and populate this Raggle::Feed::Channel object.
      # The actual feed items are stored as Raggle::Feed::Item objects
      # in @items.
      #
      def parse_rss_url(url, last_modified = nil)
        begin
          content, modified = Engine::get_url(url, last_modified)
          if $config['log_content']
            meth = 'Raggle::Channel#parse_rss_url'
            $log.debug(meth) {
              content_str = content ? content[0, 80] : '<nil>'
              "modified: #{modified}, content: #{content_str}"
            }
          end
        rescue
          raise "Couldn't get URL \"#{url}\": #$!."
        end

        @last_modified = modified
        if content && (!modified || modified != last_modified)
          # strip external entities (work-around for bug in REXML 2.7.1)
          content.gsub!(/<!ENTITY %.*?>/m, '') if \
            $config['strip_external_entities'] && content =~ /<!ENTITY %.*?>/m

          # parse URL content
          doc = REXML::Document.new content
          is_atom = (doc.root.name == 'feed')

      
          # get channel info
          e = nil

          if is_atom
            # atom channel elements
            @title = e.text if e = doc.root.elements['//feed/title']
            @link = e.attributes['href'] if e = doc.root.elements['//feed/link']
            @desc = e.text if e = doc.root.elements['//feed/tagline']
          else
            # RSS support
            @title = e.text if e = doc.root.elements['//channel/title']
            @link = e.text if e = doc.root.elements['//channel/link']
            @desc = e.text if e = doc.root.elements['//channel/description']
            @image = e.text if e = doc.root.elements['//image/url']
            @lang = e.text if e = doc.root.elements['//channel/language']
          end
      
      
          # build list of feed items
          @items = []

          item_element_path = is_atom ? '//feed/entry' : '//item'
          doc.root.elements.each(item_element_path) { |e| 
            # get item attributes (the ones that are set, anyway... stupid
            # RSS)
            h = {}

            if is_atom
              # parse atom entry
              h = parse_atom_entry(e)
            else
              # basic item attribute element check
              ['title', 'link', 'date', 'description'].each { |val|
                h[val] = (t_e = e.elements[val]) ? fix_character_encoding(t_e) : ''
              }

              # more elaborate xpath checks for item attribute elements
              ['link', 'date', 'description'].each { |key|
                h[key] = find_element(e, key)
              }

              # handle RSS enclosures for this element
              if $config['enclosure_hook_cmd']
                
                e.elements.each('enclosure') do |enclosure_element|
                  handle_rss_enclosure(h, enclosure_element)
                end
              end
            end

            # insert new item
            @items << Feed::Item.new(h['title'], h['link'],
                                     h['description'], h['date'])
          }
        end
      end

      #
      # Find element in content by using XPath specified in
      # $config['item_element_xpaths'][name].
      #
      def find_element(elem, name)
        if $config['item_element_xpaths'][name]
          $config['item_element_xpaths'][name].each { |xpath|
            e = elem.elements[xpath]
            return fix_character_encoding(e) if e
          }
        end
        ''
      end

      #
      # Use REXML to decode the specified element based the character
      # encoding in $config['character_encoding'] (defaults to 
      # 'ISO-8859-1' if unset).
      #
      def fix_character_encoding(element)
        meth = 'Channel#fix_character_encoding'

        # get character encoding
        enc = $config['character_encoding'] || 'ISO-8859-1'

        # check and see if we have iconv support
        if $HAVE_LIB['iconv'] && $config['use_iconv']
          unless $iconv
            # $iconv hasn't been intialized; create it
            enc += '//TRANSLIT' if $config['use_iconv_translit']
            $iconv = Iconv.new(enc, 'UTF-8')
          end

          # decode element using iconv
          element_text = element.text.to_s
          begin
            ret = $iconv.iconv(element_text) << $iconv.iconv(nil)
          rescue Iconv::IllegalSequence => e
            success_str = e.success
            ch, pending_str = e.failed.split(//, 2)
            ch_int = ch.to_i
            t = Time.now

            if String::UNICODE_LUT.has_key?(ch_int)
              err_str = _('converting unicode')
              $log.warn(meth) { "#{err_str} ##{ch_int}" }
              element_text = success_str + UNICODE_LUT[ch_int] + pending_str
            else
              if $config['iconv_munge_illegal']
                err_str = _('munging unicode')
                $log.warn(meth) { "#{err_str} ##{ch_int}" }
                munge_str = $config['unicode_munge_str']
                element_text = success_str + munge_str + pending_str
              else
                err_str = _('dropping unicode')
                $log.warn(meth) { "#{err_str} ##{ch_int}" }
                element_text = success_str + pending_str
              end
            end
            retry
          end                                                                 
        else
          # iconv isn't installed, or it's disabled; fall back to
          # the old (oogly) encoding behavior
          ret = (REXML::Output.new('', enc) << element.text).to_s
        end

        # return result
        ret.unescape_html
      end
    end
  end

  #
  # Command-line methods (CLI argument handling, etc).
  #
  module CLI
    #
    # Print version, usage summary, and command-line flags, then exit.
    #
    def CLI::print_usage
      puts <<ENDUSAGE
Raggle - A Console RSS feed aggregator, written in Ruby.
Version #$VERSION, by Paul Duncan <pabs@pablotron.org>, 
                      Richard Lowe <richlowe@richlowe.net>,
                      Ville Aine <vaine@cs.helsinki.fi>, and
                      Thomas Kirchner <redshift@halffull.org>

Usage:
  #$0 [options]

Options:
  -a, --add                     Add a new feed (requires '--url').
  -A, --ascii                   Use ASCII characters instead of ANSI for
                                window borders.
  -c, --config                  Specify an alternate config file.
  -d, --delete                  Delete an existing feed.
  --diag                        Run raggle in diagnostics/debug mode.
  -e, --edit                    Edit an existing feed.
  --export-opml                 Export feeds to OPML.  
  -f, --find                    Find feeds containing a string, via Syndic8
  --force                       Force behavior Raggle won't normally allow.
                                Use this option with caution.
  -h, --help, --usage           Display this usage screen.
  --import-opml                 Import feeds from an OPML file.
  -i, --invalidate              Invalidate a feed (force an update).
  -l, --list                    List existing feeds (use '--verbose' to 
                                show URLs as well).
  --lock-title                  Lock Title attribute of feed (for '--add'
                                and '--edit').
  --max                         Set the maximum number of items for a feed
                                (for '--add' and '--edit').
  -p, --priority                Feed sorting priority: 0 by default, higher
                                values will sort feeds to the top (for
                                '--add' and '--edit').
  --purge                       Purge deleted feeds from feed cache.
  -r, --refresh                 Refresh attribute of feed (for '--add' and
                                '--edit').
  --save-items                  Save old items of feed (for '--add' and
                                '--edit').
  --server                      Run Raggle in HTTP server mode.
  --sort                        Sort feeds by priority then title
                                (case-insensitive).
  -t, --title                   Title attribute of feed (for '--add' and
                                '--edit').
  --unlock-title                Unlock Title attribute of feed (for '--add'
                                and '--edit').
  --unsave-items                Don't save old items of feed (for '--add'
                                and '--edit').
  --update                      Update feed (or all feeds, if unspecified).
  -u, --url                     URL attribute of feed (for '--add' and
                                '--edit').
  -v, --verbose                 Turn on verbose output.
  -V, --version                 Display version information.

Examples:
  # add a new feed (hello world!)
  #$0 -a -u http://www.example.com/rss.xml

  # add a new feed that will sort to the top
  #$0 -a -u http://www.example.com/rss.xml -p 10

  # add a new feed called 'test feed', refresh every 30 minutes
  #$0 -a -t 'test feed' -u http://www.example.com/feed.rss -r 30

  # list feeds and their ids
  #$0 --list

  # delete an existing feed
  #$0 --delete 12

  # set the title and refresh of an existing feed
  #$0 --edit 10 --title 'hi there!' -r 20

  # set and lock the title of feed 12
  #$0 -e 12 -t 'short title' --lock-title

  # run as HTTP server on port 2345
  #$0 --server 2345
  
  # update all feeds, then exit
  #$0 --update all
  
  # update feed 10, then exit
  #$0 --update 10
   
About the Authors:
  Paul Duncan <pabs@pablotron.org>
  http://pablotron.org/  (RSS: http://pablotron.org/rss/)
  http://paulduncan.org/ (RSS: http://paulduncan.org/rss/)

  Richard Lowe <richlowe@richlowe.net>
  http://www.richlowe.net/
  http://www.richlowe.net/ (RSS: http://www.richlowe.net/diary/index.rss)

  Ville Aine <vaine@cs.helsinki.fi>

  Thomas Kirchner <redshift@halffull.org>
  http://halffull.org/ (RSS: http://halffull.org/feed/rss/)
ENDUSAGE

      exit(0)
    end

    #
    # Parse command-line options.  Returns a hash of options.
    # 
    def CLI::parse_cli_options(opts)
      ret = { 'mode' => 'view' }
    
      gopts = GetoptLong.new(
        ['--add', '-a',                             GetoptLong::OPTIONAL_ARGUMENT],
        ['--ascii', '-A',                           GetoptLong::NO_ARGUMENT],
        ['--config', '-c',                          GetoptLong::REQUIRED_ARGUMENT],
        ['--delete', '-d',                          GetoptLong::REQUIRED_ARGUMENT],
        ['--default-config',                        GetoptLong::NO_ARGUMENT],
        ['--diag',                                  GetoptLong::NO_ARGUMENT],
        ['--drb-server', '--drb',                   GetoptLong::NO_ARGUMENT],
        ['--edit', '-e',                            GetoptLong::REQUIRED_ARGUMENT],
        ['--export-opml',                           GetoptLong::REQUIRED_ARGUMENT],
        ['--find', '-f',                            GetoptLong::REQUIRED_ARGUMENT],
        ['--force',                                 GetoptLong::NO_ARGUMENT], 
        ['--help', '-h', '--usage',                 GetoptLong::NO_ARGUMENT],
        ['--import-opml',                           GetoptLong::REQUIRED_ARGUMENT],
        ['--invalidate', '-i',                      GetoptLong::REQUIRED_ARGUMENT],
        ['--list', '-l',                            GetoptLong::NO_ARGUMENT],
        ['--lock-title',                            GetoptLong::NO_ARGUMENT],
        ['--max',                                   GetoptLong::REQUIRED_ARGUMENT],
        ['--priority', '-p',                        GetoptLong::REQUIRED_ARGUMENT],
        ['--purge',                                 GetoptLong::NO_ARGUMENT],
        ['--refresh', '-r',                         GetoptLong::REQUIRED_ARGUMENT],
        ['--save-items',                            GetoptLong::NO_ARGUMENT],
        ['--server',                                GetoptLong::OPTIONAL_ARGUMENT],
        ['--sort',                                  GetoptLong::NO_ARGUMENT],
        ['--title', '-t',                           GetoptLong::REQUIRED_ARGUMENT],
        ['--unlock-title',                          GetoptLong::NO_ARGUMENT],
        ['--unsave-items',                          GetoptLong::NO_ARGUMENT],
        ['--update', '-U',                          GetoptLong::OPTIONAL_ARGUMENT],
        ['--url', '-u',                             GetoptLong::REQUIRED_ARGUMENT],
        ['--verbose', '-v',                         GetoptLong::NO_ARGUMENT],
        ['--version', '-V',                         GetoptLong::NO_ARGUMENT]
      )
    
      begin
        gopts.each do |opt, arg|
          case opt
            when '--add'
              ret['mode'] = 'add'
              ret['url'] = arg if arg && arg.size > 0
            when '--edit'
              ret['mode'] = 'edit'
              ret['id'] = arg.to_i
            when'--delete'
              ret['mode'] = 'delete'
              ret['id'] = arg.to_i
            when '--invalidate'
              ret['mode'] = 'invalidate'
              ret['id'] = arg.to_i
            when '--update'
              ret['mode'] = 'update'
              ret['id'] = (arg && (arg != 'all')) ? arg.to_i : 'all'
            when '--config':  $config['config_path'] = arg
            when '--force':   ret['force'] = true
            when '--list':    ret['mode'] = 'list'
            when '--title':   ret['title'] = arg
            when '--url':     ret['url'] = arg
            when '--refresh': ret['refresh'] = arg.to_i
            when '--version'
              puts "Raggle v#$VERSION"
              exit(0)
            when '--priority':      ret['priority'] = arg.to_i
            when '--purge':         ret['mode'] = 'purge'
            when '--sort'
              ret['mode'] = 'sort'
            when '--lock-title':    ret['lock_title?'] = true
            when '--unlock-title':  ret['lock_title?'] = false
            when '--save-items':    ret['save_items?'] = true
            when '--max'
              ret['max_items'] = arg.to_i
              ret['save_items?'] = true if ret['max_items'] > 0
            when '--unsave-items':  ret['save_items?'] = false
            when '--verbose':       $config['verbose'] = true
            when '--import-opml'
              ret['mode'] = 'import_opml'
              ret['opml_file'] = arg
            when '--export-opml'
              ret['mode'] = 'export_opml'
              ret['opml_file'] = arg
            when '--diag':          $config['diag'] = true
            when '--default-config'
              ret['mode'] = 'default_config'
            when '--ascii':         ret['ascii'] = true
            when '--server'
              if $HAVE_LIB['webrick']
                $config['run_http_server'] = true
                $config['http_server']['port'] = arg.to_i \
                  if $config['http_server'] && arg && arg.to_i > 0
              else
                die 'Missing WEBrick, can\'t run HTTP Server.'
              end
            when '--drb-server'
              if $HAVE_LIB['drb']
                $config['run_drb_server'] = true
              else
                die "Missing DRb, can't run DRb Server."
              end
            when '--find'
              ret['mode'] = 'find_feeds'
              ret['find_str'] = arg
            when '--help'
              CLI::print_usage
          end
        end
      rescue GetoptLong::InvalidOption 
        exit(-1)
      end
    
           
      # check options
      if ret['mode'] == 'add'
        ['url'].each { |val| # removed 'title' and 'refresh' for now
          unless ret[val]
            die "Missing '--#{val}'."
            exit(-1)
          end
        }
      elsif ret['mode'] == 'edit'
        unless ret['title'] || ret['url'] || ret['refresh'] ||
               ret.has_key?('lock_title?') || ret.has_key?('save_items?')
          die "Please specify a feed change to make."
        end
      end
    
      # return options
      ret
    end

    #
    # Print a list of feeds to standard output.
    #
    def CLI::list_feeds
      i = -1
      if $config['verbose']
        puts 'ID, Title, URL, Refresh'
        $config['feeds'].each { |feed|
          title = feed['title'].escape_format
          url = feed['url'].escape_format
          puts "%2d, #{title}, #{url}, #{feed['refresh']}" % (i += 1)
        }
      else 
        puts 'ID, Title'
        $config['feeds'].each { |feed|
          title = (feed['title'] || '').escape_format
          puts "%2d, #{title}" % (i += 1)
        }
      end
    end

    #
    # Find feeds with Syndic8, print the title and URL of each result to
    # standard output.
    #
    def CLI::find_feeds(str)
      results = Engine::find_feeds(str)
      results.each_with_index do |feed, i|
        puts "% 2d. %s - %s" % [i + 1, feed['sitename'], feed['dataurl']]
      end
    end

    #
    # print the default config hash to stdout
    #
    def CLI::default_config
      File.readlines($0).inject(false) do |state, line|
        if state
          puts line
          exit(0) if line =~ /^\}/
        else
          puts line if (state = (line =~ /^\$config = \{/))
        end
        state
      end
    end

    #
    # Handle mode returned by CLI::parse_options.
    #
    def CLI::handle_mode(opts)
      case opts['mode']
      when 'list'
        CLI::list_feeds
        exit(0)
      when 'add'
        added = Engine::add_feed opts
        Engine::save_feed_list if added
        exit(0)
      when 'delete'
        Engine::delete_feed opts['id']
        Engine::save_feed_list
        exit(0)
      when 'invalidate'
        Engine::invalidate_feed opts['id']
        Engine::save_feed_list
        exit(0)
      when 'edit'
        Engine::edit_feed opts['id'], opts
        Engine::save_feed_list
        exit(0)
      when 'sort'
        Engine::sort_feeds
        Engine::save_feed_list
        exit(0)
      when 'update'
        # start grab log
        Engine.start_grab_log

        grab_meth = Engine.method(:grab_feed)
        if opts['id'] == 'all'
          $config['feeds'].each { |feed| grab_meth.call(feed) }
        else
          feed = $config['feeds'].get(opts['id'])
          grab_meth.call(feed)
          ids = [opts['id']]
        end

        # save changes to feed list
        Engine::save_feed_list

        exit(0)
      when 'purge'
        Engine::purge_feed_cache
        exit(0)
      when 'import_opml'
        OPML::import opts['opml_file'], opts['refresh'], opts['lock_title?'], opts['save_items?'],
          opts['force']
        Engine::save_feed_list
        exit(0)
      when 'export_opml'
        OPML::export opts['opml_file']
        exit(0)
      when 'find_feeds'
        CLI::find_feeds(opts['find_str'])
        exit(0)
      when 'default_config'
        CLI::default_config
        exit(0)
      end
      
      # enable ascii mode if requested
      $config['use_ascii_only?'] = true if opts['ascii']
    end
  end

  #
  # Interface-specific components of Raggle.
  #
  module Interfaces
    #
    # NcursesInterface - methods and classes specific to the Ncurses
    # interface of Raggle.
    #
    module NcursesInterface
      #
      # Keyboard wrappers for common functions
      #
      module Key
        #
        # Generic key wrapper for scrolling up a line
        #
        def Key::scroll_up
          $wins[$a_win].scroll_up if $wins[$a_win]
        end

        #
        # Generic key wrapper for scrolling down a line
        #
        def Key::scroll_down
          $wins[$a_win].scroll_down if $wins[$a_win]
        end

        #
        # Generic key wrapper for scrolling to the top of the list
        #
        def Key::scroll_top
          $wins[$a_win].scroll_top if $wins[$a_win]
        end

        #
        # Generic key wrapper for scrolling to the bottom of the list
        #
        def Key::scroll_bottom
          $wins[$a_win].scroll_bottom if $wins[$a_win]
        end

        #
        # Generic key wrapper for scrolling up a page
        #
        def Key::scroll_up_page
          $wins[$a_win].scroll_up_page if $wins[$a_win]
        end

        #
        # Generic key wrapper for scrolling down a page
        #
        def Key::scroll_down_page
          $wins[$a_win].scroll_down_page if $wins[$a_win]
        end

        #
        # Generic key wrapper for quitting raggle
        #
        def Key::quit
          $done = true
        end

        #
        # Generic key wrapper for adding a feed.
        #
        def Key::add_feed
          if $a_win == NcursesInterface::get_win_id('find')
            # add the feed selected in find window
            win = $wins[NcursesInterface::get_win_id('find')]
            item = win.items[win.active_item]
            NcursesInterface::add_feed({:url => item['find'], :title => item[:otitle]})
          else  # ask via ncurses and add given feed
            NcursesInterface::add_feed
          end
        end

        #
        # import (if file exists) or export (to file) OPML
        #
        def Key::opml
          file = NcursesInterface::get_input('opml_input')

          if file && file.length > 0
            if uri?(file)  # don't test URIs for existence (yet)
              OPML::import file
            elsif test(?f, file) and test(?r, file)  # is a readable file
              OPML::import file
            else  # not an existing file or URI
              OPML::export file
            end
          else
            NcursesInterface::set_status('')
          end
        end

        #
        # Generic key wrapper for skipping to next window
        #
        def Key::next_window
          win_id = ($a_win + 1) % $wins.size
          NcursesInterface::set_active_win(win_id)
        end

        #
        # Generic key wrapper for skipping to previous window
        #
        def Key::prev_window
          win_id = (($a_win - 1 < 0) ? $wins.size : $a_win) - 1
          NcursesInterface::set_active_win(win_id)
        end

        #
        # Dispatch closing of extraneous windows
        #
        def Key::close_window
          case $a_win
          when NcursesInterface::get_win_id('find'): $wins[$a_win].close(true)
          when NcursesInterface::get_win_id('cat'): $wins[$a_win].close(true)
          when NcursesInterface::get_win_id('keys'): $wins[$a_win].close(true)
          when NcursesInterface::get_win_id('edit'): $wins[$a_win].close(true)
          end
        end

        #
        # Generic key wrapper for selecting the current item
        #
        def Key::select_item
          $wins[$a_win].select_win_item if $wins[$a_win]
        end

        #
        # Generic key wrapper for moving the current feed up
        #
        def Key::move_feed_up
          win = $wins[NcursesInterface::get_win_id('feed')]
          $a_feed = $config['feeds'].move_up(win.active_item)
          win.move_item_up
          NcursesInterface::populate_feed_win
        end

        #
        # Generic key wrapper for moving the current feed down
        #
        def Key::move_feed_down
          win = $wins[NcursesInterface::get_win_id('feed')]
          $a_feed = $config['feeds'].move_down(win.active_item)
          win.move_item_down
          NcursesInterface::populate_feed_win
        end

        #
        # Delete a feed or hide an item, based on current window
        #
        def Key::delete
          feed_win = NcursesInterface::get_win_id('feed')
          item_win = NcursesInterface::get_win_id('item')
          desc_win = NcursesInterface::get_win_id('desc')
          if $a_win == feed_win and $config['feeds'].size > 0
            resp = NcursesInterface::get_input('confirm_delete') if $config['confirm_delete']
            if $config['confirm_delete'] and resp =~ /^y/i
              win = $wins[feed_win]
              # delete feed from feedlist and window
              $config['feeds'].delete(win.active_item)
              win.delete_item
              if $config['feeds'].size == 0  # empty list, clear stagnant info
                $wins[item_win].items.clear; $wins[item_win].draw_items
                $wins[desc_win].items.clear; $wins[desc_win].draw_items
                return
              end
            else  # they changed their mind
              NcursesInterface::set_status('')
              return
            end
          elsif $a_win == item_win and  # if we're on a visible item...
                $wins[item_win].items.size > 0
            win = $wins[item_win]

            # set item visibility to false, won't be shown in window
            selected = win.items[win.active_item]['item']
            $config['feeds'].get($a_feed)['items'][selected]['visible'] = false

            # recreate feed/item wins
            NcursesInterface::populate_item_win($a_feed)
            NcursesInterface::populate_feed_win
          else
            return 1  # nothing to delete
          end
          win.active_item = (win.active_item > 0) ? win.active_item - 1 : 0
          win.draw_items
          win.select_win_item
          win.activate(win.active_item)  # scroll to item if it's offscreen
        end

        #
        # Undelete all items in the current feed
        #
        def Key::undelete_all
          item_win = NcursesInterface::get_win_id('item')

          feed = $config['feeds'].get($a_feed) rescue return
          feed['items'].each {|item| item['visible'] = true }

          NcursesInterface::populate_item_win($a_feed)
          NcursesInterface::populate_feed_win

          $wins[item_win].draw_items
          $wins[item_win].select_win_item if $a_win != NcursesInterface::get_win_id('feed')
        end

        #
        # Purge all deleted items from the current feed.
        #
        def Key::purge_deleted
          item_win = NcursesInterface::get_win_id('item')

          # grab feed, clear all deleted items
          feed = $config['feeds'].get($a_feed) rescue return
          feed['items'] = feed['items'].delete_if { |item| 
            item['visible']  == false
          }

          # refresh item window
          NcursesInterface::populate_item_win($a_feed)
          NcursesInterface::populate_feed_win

          # draw items in feed window
          $wins[item_win].draw_items
          $wins[item_win].select_win_item if $a_win != NcursesInterface::get_win_id('feed')
        end


        #
        # Invalidate the current feed, forcing it to refresh at next update
        #
        def Key::invalidate_feed
          feed = $config['feeds'].get($a_feed) rescue return
          name = feed['title']
          $config['feeds'].invalidate($a_feed)
          NcursesInterface::set_status " Forcing update for \"#{name}\""
        end

        #
        # Edit a feed's options from the Ncurses interface
        #
        def Key::edit_feed
          id = NcursesInterface::get_win_id('edit')

          if id == -1  # new window
            w, h = $config['w'] - 15, 10  # get height/width
            feed = $config['feeds'].get($a_feed) rescue return

            # create window and add it to window list
            $wins << win = NcursesInterface::ListWindow::new({
              'title'   => $config['msg_edit_title'],
              'key'     => 'edit',
              'coords'  => [7, 5, w, h],
              'colors'  => $wins[NcursesInterface::get_win_id('item')].colors,
            })

            # build feed options list and draw
            %w{Title Category Priority URL Site Refresh Save_Items? Max_Items}.each { |key|
              lowkey = key.downcase
              len = feed[lowkey].to_s.length
              maxlen = w - 7 - key.length
              val = if len > maxlen  # must shorten
                      feed[lowkey].to_s[0..(maxlen - 3)] + '...'
                    else feed[lowkey].to_s
              end
              val = 'None' if feed[lowkey].nil?  # take care of empties
              title = ' ' + key + ' '*(w - key.length - val.length - 4) + val
              win.items << { 'title' => title, 'feedopt' => lowkey }
            }
            win.draw_items
            NcursesInterface::set_active_win(NcursesInterface::get_win_id('edit'))
          else
            if id == $a_win  # window open; toggle to closed
              $wins[id].close(true)
            else  # window open but not selected; select it
              NcursesInterface::set_active_win(id)
            end
          end
        end

        #
        # Raises a feed's sort priority
        #
        def Key::raise_feed_priority
          feed = $config['feeds'].get($a_feed) rescue return
          feed['priority'] += 1 rescue feed['priority'] = 1
        end

        #
        # Lowers a feed's sort priority
        #
        def Key::lower_feed_priority
          feed = $config['feeds'].get($a_feed) rescue return
          feed['priority'] -= 1 rescue feed['priority'] = -1
        end

        #
        # Sort feeds by priority and then title, case insensitive
        #
        def Key::sort_feeds
          win = $wins[NcursesInterface::get_win_id('feed')]

          # get old active window
          a_win = $a_win

          # sort list, repopulate window
          $config['feeds'].sort
          NcursesInterface::populate_feed_win
          
          # redraw feed window
          win.draw_items
          win.select_win_item

          # reselect previous window
          NcursesInterface::set_active_win(a_win)
        end
        
        #
        # Yank the contents of the item link into the description
        # (for items with truncated or empty descriptions).
        #
        def Key::yank_link
          return unless feed = $config['feeds'].get($a_feed)
          return unless item = feed['items'][$a_item]
          return unless url = item['url']
          return unless data = (Engine.get_url(url) rescue [])
          return unless ((data = data[0]) && data)

          # filter source HTML
          filter = $config['yank_filter_proc']
          data = filter.call(data)

          # build yank prefix, add text to item description
          yank_prefix = Time.now.strftime($config['yank_prefix'] || '')
          item['desc'] ||= ''
          item['desc'] +=  yank_prefix + data

          # select current item (to display new contents)
          NcursesInterface.select_item($a_item)
        end

        #
        # opens a story link by number, or item link by default
        #
        def Key::open_link(num = 0)
          win = $wins[NcursesInterface::get_win_id('desc')]

          if num == 0  # open default item link
            win.select_win_item
          elsif $links[num - 1]  # open link from inside story
            item = { 'title'  => win.items[win.active_item]['title'],
                     'url'    => $links[num - 1] }
            NcursesInterface::open_link(item)
          end
        end

        #
        # Generic key wrapper for forcing a feed upate
        #
        def Key::manual_update
          $feed_thread.run
        end

        #
        # Generic key wrapper for forcing a feed list save
        #
        def Key::manual_save
          Engine::save_config(quit=false)
        end

        #
        # Generic key wrapper for finding an entry in the current window
        #
        def Key::find_entry(win)
          NcursesInterface::find_entry(win)
        end

        #
        # toggle view (html) source mode
        #
        def Key::view_source
          $view_source = !$view_source  # toggle global

          desc_win = $wins[NcursesInterface::get_win_id('desc')]
          if desc_win.items[0]
            # clear formatted content to force refresh
            desc_win.items[0]['fmt_content'] = nil
            desc_win.draw_items
          end
        end

        def Key::build_cat_title(cat, count, width)
          ret, count_str = "#{cat.capitalize}  ", "(#{count})"
          ret << ' ' * (width - ret.size - count_str.size) << count_str
        end

        #
        # Get a list of categories and select one
        #
        def Key::gui_cat_list
          id = NcursesInterface::get_win_id('cat')
          if id == -1
            cats = $config['feeds'].categories

            # determine window width/height
            w = $config['w'] - 40
            h = $config['h'] - 5 
            h = cats.size + 3 if h > cats.size + 3

            # create window and add it to window list
            $wins << win = NcursesInterface::ListWindow::new({
              'title'   => $config['msg_cat_title'],
              'key'     => 'cat',
              'coords'  => [20, 3, w, h],
              'colors'  => $wins[NcursesInterface::get_win_id('item')].colors,
            })

            # build item list
            win.items << { 
              'title' => build_cat_title('all', $config['feeds'].size, w - 3),
              'cat'   => 'all'
            }

            # iterate and append
            cats.each_with_index do |c, i| 
              win.items << {
                'title' => build_cat_title(c.title, c.count, w - 3), 
                'cat'   => c.title 
              }
              win.activate(i + 1) if $category == c.title
            end

            # add window to window list, draw and activate
            win.draw_items
            NcursesInterface::set_active_win(NcursesInterface::get_win_id('cat'))
          else
            if id == $a_win # window open; toggle to closed
              $wins[id].close(true)
            else # window open but not selected; select it
              NcursesInterface::set_active_win(id)
            end
          end
        end

        #
        # Find a feed using Syndic8.
        #
        def Key::gui_find_feed
          if $HAVE_LIB['xmlrpc']
            NcursesInterface::find_feeds
          else
            $new_status = _("Feed searching requires the ruby xmlrpc library.")
          end
        end

        #
        # Displays a window showing current key bindings
        #
        def Key::show_key_bindings
          id = NcursesInterface::get_win_id('keys')

          if id == -1     # new window; will activate it either way
            keys = NcursesInterface::get_key_bindings

            # determine window width/height
            w, h = $config['w'] - 40, $config['h'] - 5
            h = keys.length + 3 if h > keys.length + 3

            # create window and add it to window list
            $wins << win = NcursesInterface::ListWindow::new({
              'title'   => $config['msg_keys_title'],
              'key'     => 'keys',
              'coords'  => [20, 3, w, h],
              'colors'  => $wins[NcursesInterface::get_win_id('item')].colors,
            })

            # build item list and draw items
            keys.each { |key, value|
              title = ' ' + key + ' '*(w - key.length - value.length - 4) + value
              win.items <<  { 'title' => title, 'key' => key }
            }
            win.draw_items
            NcursesInterface::set_active_win(NcursesInterface::get_win_id('keys'))
          else
            if id == $a_win # window open; toggle to closed
              $wins[id].close(true)
            else # window open but not selected; select it
              NcursesInterface::set_active_win(id)
            end
          end
        end


        # 
        # basic key binding to save bookmark for current item 
        #
        def Key::save_bookmark
          NcursesInterface::save_bookmark
        end
      end

      #
      # Window - basic window class
      #
      class Window
        attr_accessor :win, :title, :key, :colors, :active, :items, :active_item, :offset

        #
        # window constructor
        #
        def initialize(opts)
          coords = opts['coords'].dup
          coords[2] = $config['w'] - coords[0] if coords[2] == -1
          coords[3] = $config['h'] - coords[1] if coords[3] == -1

          @active_item = 0
          @active = false
          @offset = 0
          @items = []

          @title = _(opts['title'])
          
          @key = opts['key']
          @colors = opts['colors'].dup

          @win = Ncurses::newwin coords[3], coords[2], coords[1], coords[0]
          refresh
        end

        #
        # set ncurses color
        #
        def set_color(color_name = 'text')
          col = @colors[color_name]
          col_ary = []
          if col.is_a? Array
            col_ary = col
            col = col_ary[0]
            col_ary.each_index { |i|
              @win.attron $config['attr_palette'][col_ary[i]] if i > 0
            }
          end

          # disable attributes for normal text (this is a hack for now)
          @win.attrset Ncurses::A_NORMAL if color_name == 'text'

          # set color
          @win.color_set col, nil
        end

        #
        # refresh window title and border (but not the contents)
        # 
        def refresh(refresh_contents = false)
          set_color(@active ? 'a_box' : 'box')

          if $config['use_ascii_only?']
            @win.box ?|, ?-
            @win.border ?|, ?|, ?-, ?-, ?+, ?+, ?+, ?+
          else
            @win.box 0, 0
          end

          set_color(@active ? 'a_title' : 'title')
          Ncurses::mvwprintw @win, 0, 1, " #{@title.escape_format} "
          set_color('text')

          Ncurses::wrefresh @win
        end

        #
        # Clear lines start to fini in the current window.
        # 
        def clearrange(start, fini)
          w = dimensions[0]
          set_color('text')
          # Ncurses::color_set 0, nil
          start.upto(fini) do |line|
            Ncurses::mvwprintw @win, line, 1, ' ' * (w - 2)
          end
          refresh(true)
        end
        
        #
        # clear window contents
        #
        def clear
          set_color('text')
          h = dimensions[1]
          clearrange(1, h - 2)
        end

        def draw_text(text, x, y, reflow, offset)
          if text && text.size > 0
            w,h = dimensions
            i = 0

            text = reflow_string text if reflow
            text.each_line do |line|
              line = line.chomp

              i += 1
              if i >= offset && y < (h - 1)
                Ncurses::mvwprintw @win, y, x,
                                   line.escape_format.slice(0, w - x - 1)
                y += 1
              end
            end
          end

          y
        end

        #
        # draw text in window
        #
        def draw(text, x = 1, y = 1, color = 'text', refresh_win = true,
                 reflow_text = true, offset = 0)
          w, h = dimensions

          set_color(color)
          y = draw_text(text, x, y, reflow_text, offset)
          set_color('text')

          refresh if refresh_win
          y 
        end

        #
        # Close and delete an Ncurses window
        #
        def close(switchwin=false)
          old_wins = $wins.dup
          @win.del; $wins.delete_at(NcursesInterface::get_win_id(@key))
          NcursesInterface::goto_old_win if switchwin unless old_wins == $wins
          $wins.each { |w| w.refresh }
        end

        #
        # get window width and height
        #
        def dimensions
          ha = []; wa = []
          Ncurses::getmaxyx @win, ha, wa
          [wa[0], ha[0]]
        end

        #
        # select currently highlighted window item
        #
        # XXX: This really should be split between the window classes as
        #      Appropriate -- richlowe 2003-06-23
        def select_win_item
          if @items && @items.size > 0 && @items[@active_item]
            NcursesInterface::set_status @items[@active_item]['title'].split(/\s+/).join(' ') if @items[@active_item].has_key? 'title'
            if @items[@active_item].has_key? 'feed'
              NcursesInterface::select_feed @items[@active_item]['feed']
            elsif @items[@active_item].has_key? 'item'
              NcursesInterface::select_item @active_item
            elsif @items[@active_item].has_key? 'cat'
              NcursesInterface::select_cat @items[@active_item]['cat']
            elsif @items[@active_item].has_key? 'find'
              item = @items[@active_item]

              # add the new feed
              Engine::add_feed({ 
                'url'    => item['find'], 
                'title'  => item[:otitle],
              })

              # repopulate the feed window
              NcursesInterface::populate_feed_win
              NcursesInterface::set_status($config['msg_feed_added'])
            elsif @items[@active_item].has_key? 'feedopt'
              NcursesInterface::edit_feed @items[@active_item]['feedopt']
            elsif @items[@active_item].has_key? 'url'
              NcursesInterface::open_link @items[@active_item]
            elsif @items[@active_item].has_key? 'item'
              NcursesInterface::select_item @active_item
            end
          end
        end

        #
        # reflow a string for this window
        #
        def reflow_string(str)
          w = dimensions[0]
          str.reflow(w - 4)
        end
        
        #
        # Debugging method
        #
        def method_missing(id)
          raise "Please implement #{self.class}##{id.id2name}"
        end
      end
        
      #
      # ListWindow - window with a list of items in it.
      #
      class ListWindow < Window
        #
        # redraw window items
        #
        def draw_items
          y = 0
          w, h = dimensions
          ic = 0
          @items.each { |i| 
            t = i['title'].strip_tags || ''
            text = t + ((t.length < w) ? (' ' * (w - t.length)) : '')
            if i.has_key?('item_count') && i['item_count'] == 0 &&
                i['updated'] > 0
              color = (ic == @active_item) ? 'h_empty' : 'empty'
            elsif i.has_key?('read?') && i['read?'] == false
              color = (ic == @active_item) ? 'h_unread' : 'unread'
            else
              color = (ic == @active_item) ? 'h_text' : 'text'
            end
            draw(text, 1, y += 1, color, true, false) if ic >= @offset
            ic += 1
          }
          clearrange(@items.size + 1 - @offset, h - 2)
          refresh(true)
        end
        
        #
        # Adjust the window to show the selected item
        #
        def adjust_to(item)
          # scroll window (if necessary)
          w, h = dimensions
          if item < @offset || item >= @offset + (h - 3)
            @offset = @active_item
            @offset = 0 if @offset < 0
            if item + h - 3 > @items.size - 1
              @offset = @active_item - (h - 3)
              @offset = 0 if @offset < 0
            end
          end
          draw_items

          if $config['describe_hilited_feed'] == true &&
              @items[item].has_key?('feed')
            $config['feeds'].describe(item)
          end
          
          status = ' ' << @items[@active_item]['title']
          status = status.split(/\s+/).join(' ')
          NcursesInterface::set_status status

          select_win_item if $config['focus'] == 'auto' && 
            !%w{cat edit find}.include?(@key)
        end
        private :adjust_to

        #
        # Activate an item
        #
        def activate(item)
          if @items.size > 0
            if item >= @items.size or item < 0
              throw "Window#activate item out of range (#{item}/#{@items.size})"
            end
            @active_item = item
            adjust_to item
          end
        end
        
        #
        # Scroll one page up.
        #
        def scroll_up_page
          if $config['page_step'] < 0
            w,h = dimensions
            step = (h - 2) + $config['page_step']
          else
            step = $config['page_step']
          end
            
          pos = @active_item - step
          pos < 0 ? activate(0) : activate(pos)
        end

        #
        # Scroll one page down.
        #
        def scroll_down_page
          if $config['page_step'] < 0
            w,h = dimensions
            step = (h - 2) + $config['page_step']
          else
            step = $config['page_step']
          end

          pos = @active_item + step
          pos >= @items.size ? activate(@items.size - 1) : activate(pos)
        end
             
        #
        # Bound scrolling based on $config['scroll_wrapping']
        #
        def scroll_bound(point)
          if point >= @items.size
            $config['scroll_wrapping'] ? 0 : @items.size - 1
          elsif point < 0
            $config['scroll_wrapping'] ? @items.size - 1 : 0
          else
            point
          end
        end

        #
        # Scroll the window up one item
        #
        def scroll_up
            activate scroll_bound(@active_item - 1)
        end
        
        #
        # Scroll window down.
        #
        def scroll_down
            activate scroll_bound(@active_item + 1)
        end
        
        #
        # Scroll to the bottom.
        #
        def scroll_bottom
          activate @items.size - 1
        end

        #
        # Scroll to the top item
        #
        def scroll_top
          activate 0
        end

        #
        # move selected item down one spot
        #
        def move_item_down
          if @active_item < @items.size - 1 
            nitem = @items[@active_item + 1]
            @items[@active_item + 1] = @items[@active_item]
            @items[@active_item] = nitem
            @active_item = @active_item + 1
            draw_items
          end
        end

        #
        # move selected item up one spot
        #
        def move_item_up
          if @active_item > 0
            pitem = @items[@active_item - 1]
            @items[@active_item - 1] = @items[@active_item]
            @items[@active_item] = pitem
            @active_item = @active_item - 1
            draw_items
          end
        end

        #
        # delete active item
        #
        def delete_item
          @items.delete_at(@active_item)
          NcursesInterface::populate_feed_win
        end
      end

      #
      # TextWindow - window with text in it
      #
      class TextWindow < Window
        def reflow_string(str)
          w = dimensions[0]
          $view_source ? str.reflow(w - 4) : Raggle::HTML::render_html(str, w - 4)
        end
             
        def draw_items
          item = @items[0] # There can be only one
          item = {} if item == nil  # no items = draw a blank
          y = 1
          w, h = dimensions

          # draw title (if it's set)
          if item['title'] && item['title'] != ''
            draw(item['title'].strip.ljust(w - 2), 1, y, 'f_title', false, false)
            y += 1
          end

          # draw link
          draw("Link: ", 1, y, 'text', false, false)
          if item['site']
            draw(item['site'].ljust(w - 2), 7, y, 'url', false, false)
          elsif item['url']
            draw(item['url'].ljust(w - 2), 7, y, 'url', false, false)
          end

          # don't draw the date if it isn't set (silly RSS)
          if item['date'] && item['date'] != '' && item['date'] != "0"
            y += 1
            draw("Date: ", 1, y, 'text', false, false)
            
            # handle date string shenanigans
            if (item['date'] =~ /^\d+$/ && (i_val = item['date'].to_i) > 0 &&
                i_val.is_a?(Fixnum))
              d_str = Time.at(i_val).strftime($config['desc_date_format'])
            else
              d_str = item['date']
            end
            
            # draw date string
            draw(d_str.ljust(w - 2), 7, y, 'date', false, false)
          end

          # draw blank line
          y += 1
          draw(''.ljust(w - 2), 1, y, 'text', false, false)

          # draw item content
          if item['content']
          
            # clear the description window before drawing the text
            win = $wins[Raggle::Interfaces::NcursesInterface::get_win_id('desc')]
            win.clearrange(y, h - 2)

            y += 1
            if !item['fmt_content']
              str = item['content'] || ''
              str = str.strip_tags if $config['strip_html_tags']

              # cache this stuff so the desc window renders faster
              item['fmt_content'] = str
              item['fmt_content_reflow'] = reflow_string(str)
              item['fmt_content_lines'] = item['fmt_content_reflow'].lines
            end

            str = item['fmt_content_reflow']
            num_lines = item['fmt_content_lines']
           
            # Offset + 1, so we scroll on the *first* press
            draw(str, 1, y, 'text', false, false, @offset + 1)
            y += (num_lines - @offset)
          end

          # *Much* faster than a total clear each time it scrolls.
          clearrange(y - 1, h - 2)
          
          refresh(true)
        end

        def scroll_up_page
          if $config['page_step'] < 0
            w, h = dimensions
            step = (h - 2) + $config['page_step']
          else
            step = $config['page_step']
          end

          @offset = @offset - step
          draw_items
        end

        def scroll_down_page
          if $config['page_step'] < 0
            w, h = dimensions
            step = (h - 2) + $config['page_step']
          else
            step = $config['page_step']
          end

          @offset = @offset + step
          draw_items
        end
        
        def scroll_up
          @offset -= 1 if @offset > 0
          draw_items
        end

        def scroll_down
          # This is a *terrible* way to figure out how many lines of text we'll end
          # up with.., XXX put me somewhere better.
          item = @items[0]
          item = {} if item == nil
          s = item['fmt_content_num_lines'] ||
              reflow_string(item['content']).lines
          @offset += 1 if @offset < s - 1
          draw_items
        end

        def scroll_bottom
          # This is a *terrible* way to figure out how many lines of text we'll end
          # up with.., XXX put me somewhere better.
          item = @items[0]
          item = {} if item == nil
          h,s = dimensions[1], 0

          %w{title url date}.each do |key|
            s += 1 if item.has_key?(key)
          end
          s += item['fmt_content_num_lines'] ||
               reflow_string(item['content']).lines
          s += 1
          @offset = s - (h - 2)
          draw_items
        end

        def scroll_top
          @offset = 0
          draw_items
        end

        def move_item_down
          if @active_item < @items.size - 1 
            nitem = @items[@active_item + 1]
            @items[@active_item + 1] = @items[@active_item]
            @items[@active_item] = nitem
            @active_item = @active_item + 1
            draw_items
          end
        end

        def move_item_up
          if @active_item > 0
            pitem = @items[@active_item - 1]
            @items[@active_item - 1] = @items[@active_item]
            @items[@active_item] = pitem
            @active_item = @active_item - 1
            draw_items
          end
        end

        def resize_width
          # XXX
        end
      end

      #
      # set status bar message (or log message, if running as daemon)
      #
      def NcursesInterface::set_status(str)
        str ||= ''

        if $config['run_http_server'] || $config['run_drb_server']
          $stderr.puts "Raggle: #{str}"
        else
          w, e_msg = $config['w'], $config['msg_exit']
          $status, $new_status = str, str

          # fix message length
          if str.length > (w - e_msg.length)
            str = str.slice(0, w - e_msg.length)
          else
            str += ' ' * (w - e_msg.length - str.length)
          end
          msg = str << e_msg

          Ncurses::stdscr.color_set $config['theme']['status_bar_cols'], nil
          Ncurses::mvprintw $config['h'], 0, msg.escape_format
          Ncurses::refresh
        end
      end

      # 
      # set active window 
      #
      def NcursesInterface::set_active_win(a_win)
        if $wins[$a_win]
          $wins[$a_win].active = false
          $wins[$a_win].refresh
        end

        if $a_win  # append previous window to old_win list
          $old_win << $a_win
        else  # feed window by default
          $old_win << NcursesInterface::get_win_id('feed')
        end
        $a_win = a_win

        $wins[$a_win].active = true
        $wins[$a_win].refresh

        if $config['focus'] == 'auto' && 
           ![NcursesInterface::get_win_id('desc'), 
             NcursesInterface::get_win_id('cat'),
             NcursesInterface::get_win_id('edit'),
             NcursesInterface::get_win_id('find')].include?($a_win) && 
           $wins[$a_win].items && $wins[$a_win].items.size > 0
          $wins[$a_win].select_win_item
        end
      end

      #
      # Go to last selected window (that exists)
      #
      def NcursesInterface::goto_old_win
        $old_win.reverse.each { |win|
          if not $wins[win].nil?
            NcursesInterface::set_active_win(win)
            return 0
          end
        }
      end

      #
      # get window id by key
      #
      def NcursesInterface::get_win_id(str)
        $wins.each_index { |i| return i if $wins[i].key == str }
        -1
      end

      #
      # set active feed by id
      #
      def NcursesInterface::select_feed(id)
        $old_feed = $a_feed || 0
        $a_feed = id

        # clear item window
        item_win = $wins[NcursesInterface::get_win_id('item')]
        # don't reset active item unless feed actually changed
        unless $old_feed == $a_feed
          $a_item = 0
          item_win.offset = 0
          item_win.active_item = 0
        end

        $wins[NcursesInterface::get_win_id('desc')].offset = 0
        
        # unused.. remove it?
        fmt = $config['item_date_format']
      
        # wtf is happening here?
        # redshift: block variables in populate_feed_win were being
        # clobbered, passing random objects to item.feed
        raise "id.class.to_s = #{id.class.to_s}" unless id.class == Fixnum

        # build item list
        NcursesInterface::populate_item_win(id)
        
        # redraw item window
        item_win.draw_items
      
        $config['feeds'].describe($a_feed)
        
        # activate item window
        set_active_win(get_win_id('item')) if $config['focus'] == 'select' ||
                                              $config['focus'] == 'select_first'
      end

      #
      # edit the current feed's options
      #
      def NcursesInterface::edit_feed(feedopt)
        str = NcursesInterface::get_input('new_value')

        error = false
        if str and str.length > 0
          error = true if (feedopt == 'priority' or feedopt == 'refresh') and
                          str !~ /^-?\d+$/
          error = true if feedopt == 'max_items' and
                          not (str =~ /^\d+$/ or str =~ /none|nil/i)
          error = true if (feedopt == 'url' or feedopt == 'site') and
                          not uri?(str)
          error = true if feedopt == 'save_items?' and
                          not (str =~ /true/i or str =~ /false/i)
          error = true if feedopt == 'refresh' and
                          str.to_i < $config['feed_refresh_warn']
          if error  # bail if user entered nonsense via basic checks
            NcursesInterface::set_status($config['msg_bad_option'] % feedopt)
            return 1
          end

          case feedopt  # create options hash to pass to engine
          when 'save_items?'
            if str =~ /true/i: newopts = {feedopt => true}
            else newopts = {feedopt => false}; end
          when 'priority', 'refresh'
            newopts = {feedopt => str.to_i}
          when 'max_items'
            if str =~ /none|nil/i: newopts = {feedopt => nil}
            else newopts = {feedopt => str.to_i}; end
          when 'category'
            if str =~ /\bnone\b|\bnil\b/i: newopts = {feedopt => nil}
            else newopts = {feedopt => str}; end
          else newopts = {feedopt => str}
          end

          Engine::edit_feed($a_feed, newopts)  # change given option

          # update info and close edit window
          $wins[NcursesInterface::get_win_id('edit')].close(true)
          NcursesInterface::populate_feed_win
          NcursesInterface::set_status($config['msg_edit_success'])
          Key::edit_feed
        else  # if no change
          NcursesInterface::set_status('')
        end
      end

      #
      # populate item window
      #
      def NcursesInterface::populate_item_win(feed_id)
        item_win = $wins[NcursesInterface::get_win_id('item')]
        item_win.items.clear

        # iterate through feed items
        $config['feeds'].get(feed_id)['items'].each_with_index { |item, i|
          # take care of visibility if not yet assigned
          item['visible'] = true unless item['visible'] == false

          # can't depend on the date being in a consistent format, or
          # being defined at all, for that matter :( :( :(
          # title = item['title'] + ' (' +
          #         Time.at(item['date'].to_i).strftime(fmt) << ')'
      
          # build title string
          if item['date'] && item['date'].size > 0
            d_str = item['date']
            title = item['title'] ? "#{item['title'].strip} (#{d_str})" : d_str
          elsif item['title'] !~ /^\s*$/ 
            title = item['title'] ? item['title'].strip : ''
          elsif item['desc'] !~ /^\s*$/ 
            # fall back on cleaned up and truncated description if we're
            # missing a title
            w, h = item_win.dimensions
            title = item['desc'].strip.strip_tags.unescape_html.split(/\s+/).join(' ')
      
            if title.length > w - 5
              title = title.slice 0, w - 5
              title << '...'
            end
          else
            # if title is garbage and we have no description, then fall back
            # on formatted and truncated link (silly broken RSS feeds)
            title = '[' << item['url'].strip << ']'
      
            w, h = item_win.dimensions
            title = title.strip_tags.unescape_html.split(/\s+/).join(' ')
      
            if title.length > w - 5
              title = title.slice 0, w - 5
              title << '...'
            end
          end unless item['visible'] == false  # skip for invisible

          item_win.items << {
            'title'   => title,
            'item'    => i,
            'visible' => item['visible'],
            'read?'   => item['read?']
          } unless item['visible'] == false  # skip invisible
        }
      end

      #
      # populate feed window
      # 
      def NcursesInterface::populate_feed_win 
        win = $wins[NcursesInterface::get_win_id('feed')]
        return if win == -1  # no windows open - why populate?
      
        win.items.clear
        
        $config['feeds'].each_with_index { |feed, i|
          if $category && $category !~ /all/i
            next unless feed['category'] =~ /#$category/i
          end
        
          # build title
          if feed['title']
            title = feed['title'].strip
          else
            title = ($config['default_feed_title'] || _('Untitled Feed')).dup
          end

          # count unread items and total size
          unread_count = size = 0
          feed['items'].each { |item|
            unread_count += 1 unless item['read?'] or item['visible'] == false
            size += 1 unless item['visible'] == false
          } if feed['items']
          title << " (#{unread_count}/#{size})"
      
          win.items << {
            'title'       => title,
            'feed'        => i,
            'read?'       => unread_count == 0,
            'item_count'  => feed['items'].size,
            'updated'     => feed['updated'],
          }
        }
        win.draw_items
        $wins[$a_win].draw_items  # so feed window doesn't cover active win
      end

      #
      # set active item by id in feeds list
      #
      def NcursesInterface::select_id(id)
        item_win = $wins[NcursesInterface::get_win_id('item')]
        item_no = -1
        item_win.items.each_with_index {|item, i| item_no = i if item['id'] == id }
        NcursesInterface::select_item(item_no) if item_no >= 0
      end
      
      #
      # set active item by id in item window list
      #
      def NcursesInterface::select_item(item)
        desc_win = $wins[NcursesInterface::get_win_id('desc')]
        item_win = $wins[NcursesInterface::get_win_id('item')]

        # "item" refers to its place in the item window list
        # "id" refers to its place in the feed listing itself
        id = item_win.items[item]['item']

        $old_item = $a_item || 0
        $a_item = item
      
        # clear description window
        desc_win.active_item = 0
        desc_win.items.clear
      
        # get feed
        feed = $config['feeds'].get($a_feed) rescue return
      
        # mark item as read
        item_win.items[item]['read?'] = true
        feed['items'][id]['read?'] = true
      
        # redraw item window items (to mark selected item as read)
        item_win.draw_items
      
        # fix host-relative URLs
        # TODO: fix this stuff with URI#merge
        item_content = feed['items'][id]['desc']
        if $config['repair_relative_urls']
          # parse feed URL
          host_uri = URI::parse(feed['url'])
      
          item_content = item_content.gsub(/href\s*=\s*["']([^'"]+?)['"]/m) {
            m = $1.dup
            new_url = case m
              when (/^(\w+):\/\//): m
              else 
                # attempt to merge URI with host_uri.  if that fails,
                # log a warning, then fall back to the unmodified string
                begin
                  host_uri.merge(m).to_s 
                rescue
                  meth = 'NcursesInterface.select_item'
                  feed_title = feed['title']

                  $log.warn(meth) {
                    "feed \"#{feed_title}\": " + 
                    _('repairing relative url failed')
                  }

                  m
                end
            end
            "href='#{new_url}'"
          }
      
          # fix host-relative item URL
          item_url = feed['items'][id]['url'].dup
          item_url = case item_url
            when (/(\w+):\/\//):  item_url
            else [host_uri, item_url].join('/')
          end
        end
        
        # load item
        desc_win.items << {
          'content'     => item_content,
          'title'       => feed['items'][id]['title'],
          'url'         => item_url,
          'date'        => feed['items'][id]['date'],
          'read?'       => feed['items'][id]['read?'],
        }

        # redraw feed window
        NcursesInterface::populate_feed_win
      
        # redraw description window
        desc_win.offset = 0 unless $old_item == $a_item
        desc_win.draw_items
      
        # activate description window
        if (($config['focus'] == 'select' ||
             $config['focus'] == 'select_first') &&
            !$config['no_desc_auto_focus'])
          NcursesInterface::set_active_win(get_win_id('desc'))
        end
      end
      
      #
      # select the specified category
      #
      def NcursesInterface::select_cat(str)
        $old_cat = $category || 'all'
        $category = str
      
        unless $old_cat == $category  # rebuild feeds in new category
          # rebuild feed window
          NcursesInterface::populate_feed_win

          # select feed window and select top feed
          w_id = NcursesInterface::get_win_id('feed')
          NcursesInterface::set_active_win(w_id)
          $wins[w_id].activate 0 
        else  # just go back to old window if category didn't change
          NcursesInterface::goto_old_win
        end
        
        # delete category window
        $wins[NcursesInterface::get_win_id('cat')].close
      end

      #
      # open an item's url in Ncurses mode
      #
      def NcursesInterface::open_link(item)
        return unless item['title'] and (item['url'] or item['site'])
        return unless $config['browser']
        url = item['site'] || item['url']

        # are we opening new screen windows?
        use_screen = $config['use_screen'] && in_screen?

        # get browser command
        cmd = $config['browser_cmd'].map { |cmd_part|
        case cmd_part
          when /%s/:  cmd_part % url.escape
          when '${browser}':  $config['browser']
          else cmd_part
          end
        }

        if use_screen  # prepend screen command if we're using it
          screen_cmd = $config['screen_cmd'].map { |cmd_part|
            if cmd_part =~ /%s/
              cmd_part % item['title']
            else
              cmd_part
            end
          }
          cmd.unshift(*screen_cmd)  # build new command
        end

        # drop out of curses mode
        NcursesInterface::save_screen unless use_screen

        system(*cmd)
  
        NcursesInterface::restore_screen unless use_screen  # reset screen
      end
      
      #
      # mark all items in feed as read
      # 
      def NcursesInterface::mark_items_as_read
        win = $wins[NcursesInterface::get_win_id('item')]
        # mark item as read
        win.items.each { |item| item['read?'] = true }
        $config['feeds'].get($a_feed)['items'].each { |item| item['read?'] = true }
      
        NcursesInterface::populate_feed_win
        win.draw_items
      end

      #
      # mark current item as unread
      #
      def NcursesInterface::mark_current_as_unread
        win = $wins[NcursesInterface::get_win_id('item')]
        win.items[$a_item]['read?'] = false
        $config['feeds'].get($a_feed)['items'][$a_item]['read?'] = false

        win.scroll_down
        NcursesInterface::populate_feed_win
        win.draw_items
      end

      #
      # mark all items in feed as unread
      #
      def NcursesInterface::mark_items_as_unread
        win = $wins[NcursesInterface::get_win_id('item')]
        win.items.each { |item| item['read?'] = false }
        $config['feeds'].get($a_feed)['items'].each { |item| item['read?'] = false }

        NcursesInterface::populate_feed_win
        win.draw_items
      end
      
      DIRECTION_BACKWARD = -1
      DIRECTION_FORWARD = 1

      #
      # select next unread item
      #
      def NcursesInterface::select_next_unread
        NcursesInterface::select_unread_item NcursesInterface::DIRECTION_FORWARD
      end
      
      #
      # select previous unread item
      #
      def NcursesInterface::select_prev_unread
        NcursesInterface::select_unread_item NcursesInterface::DIRECTION_BACKWARD
      end

      #
      # Select next unread item in direction +direction+. Direction
      # can be either +DIRECTION_FORWARD+ or +DIRECTION_BACKWARD+
      #
      def NcursesInterface::select_unread_item(direction)
        item_win = $wins[NcursesInterface::get_win_id('item')]
        feed_win = $wins[NcursesInterface::get_win_id('feed')]
        feed = $a_feed; item = $a_item  # start at current feed/item
        max = $config['feeds'].get(feed)['items'].size

        begin  # until we get back to original feed
          begin  # until we hit a window border
            cur_item = $config['feeds'].get(feed)['items'][item]
            # activate and return if unread visible item found
            if cur_item and cur_item['read?'] == false and cur_item['visible'] != false
              NcursesInterface::select_feed(feed) unless feed == $a_feed
              NcursesInterface::select_id(item)
              feed_win.active_item = feed
              item_win.activate(item)
              return 0
            end
            item += direction
          end until item < 0 or item >= max  # switch feeds to find prev/next
          feed = (feed + direction) % $config['feeds'].size
          max = $config['feeds'].get(feed)['items'].size
          (direction > 0) ? item = 0 : item = max - 1  # set sensible search start
        end until feed == $a_feed
      end

      #
      # save theme
      #
      def NcursesInterface::save_theme(quit=true)
        File::open($config['theme_path'], 'w') { |f|
          if quit
            $stdout.puts $config['msg_save_theme']
            $stdout.flush
          else
            $new_status = $config['msg_save_theme']
          end
          f.puts $config['theme'].to_yaml
        }
      end

      #
      # handle mouse events
      #
      def NcursesInterface::handle_mouse(ev) 
        puts 'mouse'
      end

      #
      # get input from the user via the status line
      #
      def NcursesInterface::get_input(msg = 'default_input')
        e_msg = $config["msg_#{msg}"]

        # print entry message
        msg = e_msg + (' ' * ($config['w'] - e_msg.length - 1))
        Ncurses::stdscr.color_set $config['theme']['status_bar_cols'], nil
        Ncurses::mvprintw $config['h'], 0, msg.escape_format

        # get string
        str = ''
        Ncurses::echo if $config['use_noecho']
        Ncurses::mvprintw $config['h'], e_msg.size + 1, ''
        Ncurses::getstr(str)
        Ncurses::noecho if $config['use_noecho']
        Ncurses::refresh

        str  # return the input
      end

      #
      # add a new feed from ncurses
      #
      def NcursesInterface::add_feed(opts = nil)
        if not opts.nil? and opts[:url]
          url = opts[:url]
          title = opts[:title]
          title = $config['default_feed_title'] if not title or title == ''
        else
          url = NcursesInterface::get_input('add_feed')
        end

        # if it's not nil, then add it to our list
        added = false
        if url && url.length > 0
          if title: added = Engine::add_feed({ 'url' => url, 'title' => title })
          else added = Engine::add_feed({ 'url' => url }); end
          NcursesInterface::populate_feed_win
        else
          NcursesInterface::set_status('')  # no change
        end

        if added
          if (find_win = NcursesInterface::get_win_id('find')) != -1
            $wins[find_win].close(true)  # close find window if it's still up
          end
          # redraw status bar
          NcursesInterface::set_status($config['msg_feed_added'])
          $feed_thread.run if $config['update_after_add']  # force update now
        end
      end
      
      #
      # search for feeds from ncurses
      # 
      def NcursesInterface::find_feeds
        str = NcursesInterface::get_input('find_feed')

        # if it's not nil, then add it to our list
        if str && str.length > 0
          NcursesInterface::set_status($config['msg_searching'])
          th = Thread.new do
            results = Engine::find_feeds(str)
            if results.length == 0
              $new_status = $config['msg_find_nomatches']
              return 1
            end
            id = NcursesInterface::get_win_id('find')
      
            # determine window height
            w = $config['w'] - 10
            h = $config['h'] - 5 
            h = results.size + 3 if h > results.size + 3
      
            new_win = {
              'title' => $config['msg_find_title'] % [ str, results.size ],
              'key'     => 'find',
              'coords'  => [5, 3, w, h],
              'colors'  => $wins[NcursesInterface::get_win_id('item')].colors,
            }
            if id == -1
              # create window and add it to window list
              $wins << win = NcursesInterface::ListWindow::new(new_win)
            else
              # use existing window
              $wins[id] = win = NcursesInterface::ListWindow::new(new_win)
            end
      
            # build item list
            win.items.clear
            win.items << { 'title' => $config['msg_find_desc'] }
            results.each do |feed| 
              title = [feed['sitename'],
                       feed['description'], 
                       feed['dataurl']
                      ].join ' - '
              win.items << { 'title' => title, 'find' => feed['dataurl'], :otitle => feed['sitename'] }
            end
      
            # add window to window list and draw item list
            win.draw_items
      
            # activate category window
            NcursesInterface::set_active_win(get_win_id('find'))
          end
          th.priority = $config['thread_priority_find']
        else
          NcursesInterface::set_status($config['msg_find_nomatches'])
        end
      end
      
      #
      # find entry in window
      # 
      def NcursesInterface::find_entry(win, direction = 1)
        if win && win.items && win.items.size > 0
          str = NcursesInterface::get_input('find_entry')

          # if it's not nil, then add it to our list
          if ((str && str.size > 0) || ($last_search && $last_search.size > 0))
            new_index = -1
            
            str = $last_search unless str && str.size > 0
            regex = /#{str}/i
      
            # search forward from item
            (win.active_item + 1).upto(win.items.size - 1) { |i|
              if win.items[i]['title'] =~ regex
                new_index = i
                break
              end
            }
            
            # wrap search (should this be an option?)
            if new_index < 0
              0.upto(win.active_item - 1) { |i|
                if win.items[i]['title'] =~ regex
                  new_index = i
                  break
                end
              }
            end

            if new_index >= 0
              win.activate new_index 
            else
              NcursesInterface::set_status($config['msg_find_nomatches'])
            end
            $last_search = str
          end
        end
      end

      #
      # save bookmark for current item
      #
      def NcursesInterface::save_bookmark

        # grab bookmark settings
        bm = $config['bookmark']
        return unless bm && bm.kind_of?(Array)
        
        # was the bookmark saved?
        was_saved = true

        # get feed categories
        item = $config['feeds'].get($a_feed)
        feed_cats = ' ' << (item['category'] || '')

        # get actual item
        desc_win = $wins[NcursesInterface::get_win_id('desc')]
        item = desc_win.items[desc_win.active_item]

        # get item information
        return unless item && item['title'] && (item['url'] || item['site'])
        title = item['title']
        time = Time.now.xmlschema
        url = item['site'] || item['url']

        NcursesInterface::set_status($config['msg_bm_saving'] % [title])

        # check to see if we need to get extended information
        get_ext = bm.any? { |bmb| !bmb[:no_desc] }
        get_tag = bm.any? { |bmb| !bmb[:no_tags] }

        ext, user_tags = '', ''        
        ext = NcursesInterface::get_input('bm_desc') if get_ext
        user_tags = NcursesInterface::get_input('bm_tag') if get_tag

        # iterate over bookmark backends and save bookmark to each one
        bm.each do |bmb|
          # get tags, add feed categories (unless disabled)
          tags = user_tags
          unless bmb.key?(:inherit_tags) && bmb[:inherit_tags] == false
            tags += feed_cats 
          end

          case bmb[:type]
          when :csv_file
            # expand bookmark file path
            path = Engine::expand_str(bmb[:path])
            
            # save title and bookmark to file
            begin
              # create CSV line from title, time, and URL
              csv_line = [title, url, time, ext, tags].map { |str| 
                '"' << str.gsub(/"/, '""') << '"' 
              }.join(',')
              
              # if the CSV doesn't exist, then prepend the columns
              unless File::exists?(path)
                csv_line = "title,url,time,description,tags\n" << csv_line
              end

              # append to CSV file
              File::open(path, 'a') { |file| file.puts(csv_line) }
            rescue Exception => err
              # couldn't write to bookmark file, set status
              was_saved = false
              status = $config['msg_bm_file_err'] % [path, err.to_s]
              NcursesInterface::set_status(status)
            end
          when :exec
            fork {
              system(bmb[:path], title, url, tags, ext)
            }
          when :db
            # build query
            cols, args = [], []
            { :title  => title,
              :url    => url,
              :tags   => tags,
              :desc   => ext,
            }.each_pair do |col_key, val|
              if bmb[:fields][col_key]
                cols << bmb[:fields][col_key]
                args << val
              end
            end

            begin 
              db, quote_meth = nil, nil

              # database-specific connection stuff
              case bmb[:dbtype]
              when :mysql
                unless $HAVE_LIB['mysql']
                  raise $config['msg_bm_db_missing'] % 'Mysql'
                end

                # connect to database, set quote method
                db = Mysql::connect(bmb[:host], bmb[:user], bmb[:pass])
                db.select_db(bmb[:dbname])
                quote_meth = Mysql::method(:escape_string)
              when :sqlite
                unless $HAVE_LIB['sqlite']
                  raise $config['msg_bm_db_missing'] % 'SQLite'
                end

                # connect to database, set quote method
                path = Engine::expand_str(bmb[:path])
                db = SQLite::Database.new(path)
                quote_meth = SQLite::Database.method(:quote)
              else
                # unknown database type
                raise $config['msg_bm_bad_db_type']
              end

              # build query
              query = "INSERT INTO #{bmb[:table]}(#{cols.join(',')}) 
                       VALUES " << '(' << args.map { |arg| 
                "'" << quote_meth.call(arg) << "'"
              }.join(',') << ')'

              # execute query
              db.query(query)
            rescue Exception => err
              was_saved = false
              status = $config['msg_bm_db_err'] % [err.to_s]
              NcursesInterface::set_status(status)
            end
          else
            # bad bookmark type
            was_saved = false
            status = $config['msg_bm_bad_type'] % [bmb[:type].to_s]
            NcursesInterface::set_status(status)
          end

          # set status indicating success
          status = $config['msg_bm_saved'] % [title]
          NcursesInterface::set_status(status) if was_saved
        end
      end

      #
      # Formats info about key bindings into a hash
      #
      def NcursesInterface::get_key_bindings
        # Create our integer <=> name array, first Ncurses keys
        key_names = Ncurses::constants.grep(/^KEY/).inject([]) { |ret, name|
          ret[Ncurses::const_get(name)] = name
          ret
        }
        # then standard ASCII keys
        0.upto(255) { |i| key_names[i] = i.chr }

        # create keys hash, stores key names => method called
        keys = {}
        $config['keys'].each { |key, value| keys[key_names[key]] = value }
        # make the key names useful
        keys.each_key { |key|
          old_key = key
          key = key.gsub(/^KEY_/, '')
          case key
          when 'DC':        key = 'Delete'
          when ' ':         key = 'Space'
          when 'NPAGE':     key = 'Page Down'
          when 'PPAGE':     key = 'Page Up'
          when '':        key = 'Control-L'
          when '	':  key = 'Tab'
          when /^\d$/:      key = '1-9'
          end
          unless key == old_key  # remove cruft if key info updated
            keys[key] = keys[old_key]
            keys.delete(old_key) 
          end
        }
        # strip off extraneous method info and return hash
        keys.each { |key, value|
          keys[key] = value.to_s.sub(/^\|.*\|\s?/, '').sub(/^.*::/, '').sub(/\(.*\)$/, '').tr('_', ' ').capitalize
        }
        # Note: I can't get \n to display properly in the help window, so:
        keys.delete_if { |key, value| value == 'Select item' and key != 'Space' }
      end

      #
      # terminal resize handler
      #
      def NcursesInterface::resize_term
        # force refresh (to get new screen coords)
        Ncurses::refresh

        # get screen coordinates
        h = []; w = []
        Ncurses::getmaxyx Ncurses::stdscr, h, w
        $config['w'] = w[0]
        $config['h'] = h[0] - 1
      
        # resize each window
        $config['theme']['window_order'].each { |key|
          win = $wins[NcursesInterface::get_win_id(key)]
      
          # determine new coordinates
          coords = $config['theme']['win_' << key]['coords'].dup
          coords[2] = $config['w'] - coords[0] if coords[2] == -1
          coords[3] = $config['h'] - coords[1] if coords[3] == -1
      
          win.win.move(coords[1], coords[0])
          win.win.resize(coords[3], coords[2])
      
          # refresh window
          win.refresh
          win.draw_items
        }
      
        set_status " #{$config['msg_term_resize']}#{w[0]}x#{h[0]}"
      
        # refresh full screen
        # Ncurses::refresh
      end
      
      #
      # save screen mode, drop out of ncurses
      #
      def NcursesInterface::save_screen
        Ncurses::def_prog_mode
        Ncurses::endwin
      end
      
      #
      # restore saved screen settings
      #
      def NcursesInterface::restore_screen
        Ncurses::reset_prog_mode
        Ncurses::refresh
        # Ncurses::getch
      end
      
      #
      # drop to sub-shell
      #
      def NcursesInterface::drop_to_shell
        NcursesInterface::save_screen
        system ENV['SHELL']
        NcursesInterface::restore_screen
      end
      
      #
      # initialize Ncurses interface
      #
      def NcursesInterface::init
        # immediately set up resize queue and resize thread
        $resize_queue ||= Queue.new
        $resize_thread ||= Thread.new($resize_queue) { |q| 
          loop {
            q.pop
            Ncurses::endwin
            # Ncurses::refresh
            # Ncurses::initscr
            NcursesInterface::resize_term
            $update_wins = true
          }
        }

        # trap window change events
        trap('WINCH') { $resize_queue << '1' }

        # initialize screen & keyboard
        Ncurses::initscr
        Ncurses::use_default_colors if Ncurses.respond_to?(:use_default_colors)
        Ncurses::raw if $config['use_raw_mode']
        Ncurses::keypad Ncurses::stdscr, 1
        Ncurses::noecho if $config['use_noecho']
        Ncurses::start_color
        # Ncurses::mousemask(Ncurses::ALL_MOUSE_EVENTS | Ncurses::REPORT_MOUSE_POSITION, [])
        
        # exit(-1) unless Ncurses::has_colors?
        
        # initialize color pairs
        $config['color_palette'].each { |ary| Ncurses::init_pair(*ary) }
        
        # get screen coordinates
        h = []; w = []
        Ncurses::getmaxyx Ncurses::stdscr, h, w
        $config['w'] = w[0]
        $config['h'] = h[0] - 1
        
        # draw menu bar
        # c_msg = $config['msg_close']
        # msg = (' ' * ($config['w'] - c_msg.length)) << c_msg
        # Ncurses::wcolor_set Ncurses::stdscr, $config['menu_bar_cols'], nil
        # Ncurses::mvprintw 0, 0, msg.escape_format
        # Ncurses::refresh
        
        # draw status bar
        $new_status = ''
        NcursesInterface::set_status($config['msg_welcome'] % [$VERSION])
        
        # create windows
        $a_win = 0
        $wins = []
        $config['theme']['window_order'].each { |i|
          case i
            when /feed/:  cl = NcursesInterface::ListWindow
            when /item/:  cl = NcursesInterface::ListWindow
            when /desc/:  cl = NcursesInterface::TextWindow
          else
            raise "Unknown window #{i}"
          end
          $wins << cl.new($config['theme']["win_#{i}"])
          # $wins[-1].draw LONG_STRING
        }
        NcursesInterface::set_active_win(0)
        
        $a_feed, $a_item = 0, 0
        
        # populate feed window
        NcursesInterface::populate_feed_win
        
        NcursesInterface::select_feed(0)
        NcursesInterface::set_active_win(0)
      end

      #
      # main input loop for Ncurses interface
      #
      def NcursesInterface::main_loop
        meth = 'NcursesInterface::main_loop'
        timeout = $config['input_select_timeout']
        $done = false

        until $done
          # handle keyboard input
          r = select [$stdin], nil, nil, timeout
          if r && r.size > 0
            c = Ncurses::getch
            case c
            when Ncurses::KEY_MOUSE
              mev = Ncurses::MEVENT.new
              Ncurses.getmouse(mev)

              # build event string
              ev_str = Ncurses.constants.grep(/^BUTTON._/).find_all { |c_str|
                mev.bstate & Ncurses.const_get(c_str)
              }.map { |str| str.downcase }.join(',') || ''

              $log.warn(meth) { "mouse event: #{mev.x}x#{mev.y}: #{ev_str}" }
            else
              $config['keys'][c].call($wins[$a_win],c) \
                if c != Ncurses::KEY_RESIZE && $config['keys'].has_key?(c) 
            end
          end
          
          # refresh window contents if there's been a feed update
          if $update_wins
            $update_wins = false
            Raggle::Interfaces::NcursesInterface::populate_feed_win
            # wins.each { |win| win.draw_items }
          end
          
          NcursesInterface::set_status $new_status if $new_status != $status
        end
      end
    end

    #
    # HTTPServerInterface - module containing classes and functions
    # specific to the Raggle WEBrick interface.
    #
    module HTTPServerInterface
      #
      # HTTPServer - HTTP server interface class
      #
      class HTTPServer
        attr_accessor :server, :opts, :templates

        #
        # Create a new HTTPServer object
        #
        def initialize(opts)
          @opts = opts

          # grab web UI root
          root = $config['web_ui_root_path'],
          # if it doesn't exist, then eset it to the data directory
          
          s = WEBrick::HTTPServer::new(
            :BindAddress  => @opts['bind_address'],
            :Port         => @opts['port'],
            :DocumentRoot => $config['web_ui_root_path'],
            :Logger       => WEBrick::Log::new($config['web_ui_log_path'])
          )

          # load templates
          puts "Loading templates from \"#{$config['web_ui_root_path']}/inc\"."
          @templates = Hash::new
          %w{feed feed_list item item_list desc feed_image quit}.each { |tmpl|
            path = $config['web_ui_root_path'] + "/inc/#{tmpl}.html"
            @templates[tmpl] = File::open(path).readlines.join if test(?e, path)
          }

          s.mount_proc('/') { |req, res| res['Location'] = '/index.html' }

          # set up mount points
          s.mount('/', WEBrick::HTTPServlet::FileHandler,
                  $config['web_ui_root_path'], true)

          # set up quit mount point
          s.mount_proc('/raggle/quit') { |req, res|
            quit_thread = Thread::new {
              $http_server.shutdown
              sleep opts['shutdown_sleep'];
              $feed_thread.kill if $feed_thread && $feed_thread.alive?
              $http_thread.kill if $http_thread && $http_thread.alive?
            }

            res['Content-Type'] = 'text/html'

            # handle quit template
            ret = @templates['quit'].dup
            { 'VERSION'   => $VERSION,
            }.each { |key, val|
              ret.gsub!(/__#{key}__/, val) if ret =~ /__#{key}__/
            }

            res.body = ret
          }

          # set up feed list mount point
          s.mount_proc('/raggle/feed') { |req, res|
            body = @templates['feed_list'].dup
            ret = ''

            mode = 'default'
            mode = $1 if req.query_string =~ /mode=([^&]+)/

            cat = 'all'
            cat = $1 if req.query_string =~ /category=([^&]+)/

            case mode
              when 'sort'
                Engine::sort_feeds
              when 'invalidate_all'
                Engine::invalidate_feed(-1)
                $feed_thread.run
              when 'add'
                url = $1 if req.query_string =~ /url=(.*)$/
                added = Engine::add_feed({ 'url' => url })
                $feed_thread.run if added and $config['update_after_add']
              when 'delete'
                if req.query_string =~ /id=(\d+)/
                  id = $1.to_i
                  Engine::delete_feed(id)
                end
              when 'invalidate'
                if req.query_string =~ /id=(\d+)/
                  id = $1.to_i
                  Engine::invalidate_feed(id)
                  $feed_thread.run 
                end
              when 'save_feed_list'
                Engine::save_feed_list
            end
              
            # build feed list title
            list_title = 'Feeds ' << ((cat && cat !~ /\ball\b/i) ? "(#{cat})" : '')

            # build each feed title
            $config['feeds'].category(cat).each_with_index { |feed, i|
              str = @templates['feed'].dup
              title = feed['title'] || $config['default_feed_title']
              
              # count unread items
              unread_count = 0
              feed['items'].each { |item| unread_count += 1 unless item['read?'] }

              # convert updated to minutes
              updated = (Time.now.to_i - feed['updated'] / 60).to_i

              # handle feed list item template
              { 'TITLE'         => title || 'Untitled Feed',
                'URL'           => feed['url'],
                'ITEM_COUNT'    => feed['items'].size.to_s,
                'UNREAD_COUNT'  => unread_count.to_s,
                'INDEX'         => i.to_s,
                'READ'          => ((unread_count == 0) ? 'read' : 'unread'),
                'EVEN'          => ((i % 2 == 0) ? 'even' : 'odd'),
                'UPDATED'       => updated,
                'CATEGORY'      => cat,
              }.each { |key, val|
                str.gsub!(/__#{key}__/, val) if str =~ /__#{key}__/
              }
              ret << str
            }

            # handle feed list template
            { 'FEEDS'           => ret,
              'REFRESH'         => opts['page_refresh'].to_s, 
              'VERSION'         => $VERSION,
              'CATEGORY'        => cat,
              'FEED_LIST_TITLE' => list_title,
              'CATEGORY_SELECT' => build_category_select(cat),
            }.each { |key, val|
              body.gsub!(/__#{key}__/, val) if body =~ /__#{key}__/
            }
            
            res['Content-Type'] = 'text/html'
            res.body = body
          }

          # set up feed item list mount point
          s.mount_proc('/raggle/item') { |req, res|
            body = @templates['item_list'].dup
            ret = ''

            feed_id = 0
            feed_id = $1.to_i if req.query_string =~ /feed_id=(\d+)/

            cat = 'all'
            cat = $1 if req.query_string =~ /category=([^&]+)/

            feed = $config['feeds'].category(cat)[feed_id]

            # get feed image
            feed_image = build_feed_image(feed)

            # convert updated to minutes
            updated = ((Time.now.to_i - feed['updated']) / 60).to_i

            # build each feed item title
            feed['items'].each_with_index { |item, i|
              str = @templates['item'].dup
              title = item['title']
              
              # handle item template
              { 'TITLE'         => title,
                'URL'           => item['url'],
                'DATE'          => item['date'].to_s,
                'EVEN'          => ((i % 2 == 0) ? 'even' : 'odd'),
                'READ'          => item['read?'] ? 'read' : 'unread',
                'FEED_ID'       => feed_id.to_s,
                'INDEX'         => i.to_s,
                'CATEGORY'      => cat,
              }.each { |key, val|
                str.gsub!(/__#{key}__/, val) if str =~ /__#{key}__/
              }
              ret << str
            }

            # handle item list template
            { 'ITEMS'           => ret,
              'FEED_ID'         => feed_id.to_s,
              'FEED_TITLE'      => feed['title'] || 'Untitled Feed',
              'FEED_SITE'       => feed['site'] || '',
              'FEED_URL'        => feed['url'],
              'FEED_DESC'       => feed['desc'] || '',
              'FEED_UPDATED'    => updated.to_s,
              'FEED_IMAGE'      => feed_image,
              'REFRESH'         => opts['page_refresh'].to_s,
              'VERSION'         => $VERSION,
              'CATEGORY'        => cat,
            }.each { |key, val|
              body.gsub!(/__#{key}__/, val) if body =~ /__#{key}__/
            }
            
            res['Content-Type'] = 'text/html'
            res.body = body
          }

          # set up desc window moutn point
          s.mount_proc('/raggle/desc') { |req, res|
            body = @templates['desc'].dup
            ret = ''

            feed_id = 0
            item_id = 0
            cat = 'all'
            feed_id = $1.to_i if req.query_string =~ /feed_id=([-\d]+)/
            item_id = $1.to_i if req.query_string =~ /item_id=([-\d]+)/
            cat = $1 if req.query_string =~ /category=([^&]+)/

            # get feed image
            feed = $config['feeds'].category(cat)[feed_id]
            feed_image = build_feed_image(feed)

            if item_id == -1
              title = feed['title']
              body = "description of feed #{title}"
            else 
              item = feed['items'][item_id] || opts['empty_item']
              title = item['title'] || 'Untitled Item'
              item['read?'] = true
              
              { 'TITLE'         => title,
                'DATE'          => item['date'].to_s,
                'READ'          => item['read?'] ? 'read' : 'unread',
                'URL'           => item['url'], 
                'FEED_IMAGE'    => feed_image,
                'DESC'          => item['desc'],
                'REFRESH'       => opts['refresh'], 
                'CATEGORY'      => cat,
              }.each { |key, val|
                body.gsub!(/__#{key}__/, val) if body =~ /__#{key}__/
              }
            end

            res['Content-Type'] = 'text/html'
            res.body = body
          }

          @server = s
        end

        #
        # Start this HTTPServer object
        #
        def start
          @server.start
        end

        #
        # Shut down (stop) this HTTPServer object.
        #
        def shutdown
          @server.shutdown
        end

        #
        # Build feed image string from feed_image template.
        #
        def build_feed_image(feed)
          # get feed image
          feed_image = ''
          if feed['image']
            feed_image = @templates['feed_image'].dup
            { 'SITE'        => feed['site'],
              'IMAGE_TITLE' => '',
              'IMAGE_URL'   => feed['image'],
            }.each { |key, val|
              feed_image.gsub!(/__#{key}__/, val) if feed_image =~ /__#{key}__/
            }
          end

          # return feed image
          feed_image
        end

        #
        # Build category select list.
        #
        def build_category_select(cat = nil)
          cat = 'all' unless cat
          
          ret = "<select name='category' onChange='form.submit()'>\n" <<
                "  <option value='all' " <<
                ((cat =~ /\ball\b/i) ? ' selected="1"' : '') <<
                ">All</option>\n"

          $config['feeds'].categories.each { |cat| 
            ret << "  <option value='#{cat.title}'" <<
                   ((cat =~ /\b#{cat.title}\b/i) ? ' selected="1"' : '') <<
                   ">#{cat.title}</option>\n"
          }
          ret << "</select>\n"

          ret
        end
      end

      #
      # initialize HTTP server interface
      #
      def HTTPServerInterface::init
        # check to make sure web ui dir exists
        path = $config['web_ui_root_path']
        unless test ?e, path
          if $HAVE_LIB['fileutils']
            # if the web_ui directory doesn't exist, but the data
            # directory does, and we have fileutils installed, then go
            # ahead and copy it to our web_ui directory
            data_path = File::join($DATADIR, 'extras', 'web_ui')

            # check to see if the data dir exists, if it doesnt, then
            # bail out
            die "Missing Web UI Root directory (checked \"#{$config['web_ui_root_path']}\" and \"#{path}\")." unless test ?e, data_path

            $stderr.puts "Warning: Web UI Root (\"#{path}\") doesn't exist; copying it from \"#{data_path}\"..."
            # attempt to copy data directry to given root
            begin
              FileUtils::cp_r(data_path, path)
            rescue 
              die "Couldn't copy from data directory (\"#{data_path}\") to Web UI Root (\"#{path}\"): #$!"
            end
          else
            die "Missing Web UI Root directory \"#{path}\"."
          end
        end

        # start HTTP server thread
        Thread.new do
          $http_server = HTTPServer::new($config['http_server'])
          $http_server.start
        end
      end

      #
      # Main loop for HTTP server interface.
      #
      def HTTPServerInterface::main_loop
        # block until http thread exits
        $http_thread.join
      end
    end

    module DRbServerInterface
      class DRbInterface
        def config(key)
          $config[key]
        end

        def set_config(key, val)
          $config[key] = val
        end

        def config_keys
          $config.keys
        end

        def feeds
          $config['feeds'].feeds
        end

        def feeds_size
          $config['feeds'].feeds.size
        end

        def add_feed(opts)
          Engine::add_feed(opts)
        end

        def [](id)
          $config['feeds'].get(id)
        end

        def version
          $VERSION
        end

        def quit
          exit(0)
        end
      end

      class DRbServer
        attr_accessor :opts, :interface
        def initialize(opts)
          @opts = opts
          @interface = DRbInterface.new
        end

        def start
          DRb::start_service(@opts['bind_url'], @interface)
          DRb::thread.join
        end
      end
    end

    def DRbServerInterface::init
      # start DRb server thread
      Thread::new do
        $drb_server = DRbServer::new($config['drb_server'])
      end
    end

    #
    # DRb server interface main loop.
    #
    def DRbServerInterface::main_loop
      # wait until drb server exits
      $drb_server.start # (this does a join)
    end
  end
    
  # 
  # Module containing Raggle functions and classes specific to grabbing,
  # parsing, and storing feeds.
  #
  module Engine
    #
    # expand individual string in config
    #
    def Engine::expand_str(str)
      if str && str =~ /\$\{(.+?)\}/
        str.gsub(/\$\{(.+?)\}/) { $config[$1] }
      else
        str
      end
    end

    # 
    # expand path macros in $config
    #
    def Engine::expand_config
      $config.each { |key, val| 
        next unless val.class == String && val =~ /\$\{(.+?)\}/
        orig_val = val.dup
        val.gsub!(/\$\{(.+?)\}/, $config[$1])
        # puts "\"#{orig_val}\" expands to \"#{val}\""
      }
    end

    #
    # load feed cache
    #
    def Engine::load_feed_cache
      Raggle::Interfaces::NcursesInterface::set_status "Loading feed cache..."
      store = PStore::new $config['feed_cache_path']
      store.transaction { |s|
        $config['feeds'].each { |feed|
          feed['items'] = s.root?(feed['url']) ? s[feed['url']] : []
        }
      }
    
      unless $config['run_http_server'] || $config['run_drb_server']
        Raggle::Interfaces::NcursesInterface::populate_feed_win
        Raggle::Interfaces::NcursesInterface::select_feed 0
        Raggle::Interfaces::NcursesInterface::set_active_win(Raggle::Interfaces::NcursesInterface::get_win_id('feed'))
        Raggle::Interfaces::NcursesInterface::set_status "Loading feed cache... done"
      end
    end
    
    #
    # save feed cache 
    #
    def Engine::save_feed_cache
      store = PStore::new $config['feed_cache_path']
      store.transaction { |s|
        $config['feeds'].each { |feed| s[feed['url']] = feed['items'] }
      }
    end
    
    #
    # save feed list 
    #
    def Engine::save_feed_list(quit=true)
      path = $config['feed_list_path']
      new_path = path + '~'
    
      # rename old feed list
      begin 
        File::rename path, new_path if test ?e, path
      rescue
      end
      
      # save feed list
      File::open(path, 'w') do |f|
        if quit
          $stdout.puts $config['msg_save_list'] if $config['raggle_mode'] == 'view'
          $stdout.flush
        else $new_status = $config['msg_save_list'] if $config['raggle_mode'] == 'view'
        end

        # save feed items before clearing them (for web interface)
        tmp = []
        $config['feeds'].each { |feed| tmp << feed['items']; feed['items'] = [] }
    
        f.puts $config['feeds'].feeds.to_yaml
    
        # restore feed items (again, for web interface)
        tmp.each_with_index { |t, i| $config['feeds'].get(i)['items'] = t }
      end
    end

    #
    # add a new feed
    #
    def Engine::add_feed(opts)
      matching_feed = false
      $config['feeds'].each { |feed|
        matching_feed = true if feed['url'] == opts['url']
      }
      # do not add duplicate feeds
      unless matching_feed
        %w{title refresh priority}.each { |i| opts[i] ||= $config["default_feed_#{i}"] }
        $config['feeds'].add(opts['title'], opts['url'], opts['refresh'],
                             opts['lock_title?'], opts['save_items?'],
                             (opts['site'] || ''), (opts['desc'] || ''),
                             (opts['items'] || []), opts['image'],
                             opts['category'], opts['force'],
                             opts['priority'], opts['max_items'])
    
        # can't keep adding params to Feed::List#add forever :/
        $config['feeds'].get(-1)['max_items'] = opts['max_items'] if opts['max_items']
      else
        $new_status = $config['msg_added_existing']
      end
      # return whether we successfully added the feed
      !matching_feed
    end

    #
    # Load default config, handle command-line arguments, load user
    # config file.
    #
    def Engine::load_config
      # expand the default config hash
      Engine::expand_config

      # parse command-line options
      opts = Raggle::CLI::parse_cli_options ARGV
      
      # if $HOME/.raggle doesn't exist, then create it
      Dir::mkdir $config['config_dir'] unless test ?d , $config['config_dir']
      
      # save default config
      default_config = $config

      # load user config ($HOME/.raggle/config.rb)
      puts $config['msg_load_config'] if opts['mode'] == 'view'
      load $config['config_path'], false if test ?e, $config['config_path']

      # save user config, switch to default config, and update from
      # user config
      user_config = $config
      $config = default_config
      $config.update user_config
      
      # expand config again (to handle macros in user config)
      Engine::expand_config
      
      # load feed list
      puts $config['msg_load_list'] if opts['mode'] == 'view'
      if $config['load_feed_list'] && test(?e, $config['feed_list_path'])
        # load feed list
        feeds_str = File.read($config['feed_list_path'])

        # check if feed list is empty
        if feeds_str.size > 0
          # if it's a pre-0.4.0 feed list, then strip out the ruby/object
          # nonsense and shift all lines over
          if feeds_str =~ /^--- !ruby\/object:/
            feeds_str = feeds_str.select { |line| 
              line !~ /^(feeds:|--- !ruby\/object:)/ 
            }.map { |line| line.gsub(/^  /, '') }.join
          end

          # make sure the feed list exists, deserialize our feed list
          $config['feeds'] ||= Raggle::Feed::List.new
          $config['feeds'].feeds = YAML::load(feeds_str)
        end
      end

      # if there is no feed list, then load the default one
      if $config['feeds'] && $config['feeds'].size == 0
        $config['default_feeds'].each do |feed|
          $config['feeds'].add(feed['title'], feed['url'], feed['refresh'],
                               feed['lock_title?'], feed['save_items?'],
                               feed['site'], feed['desc'], feed['items'],
                               feed['image'], feed['category'], false,
                               feed['priority'])
        end
      end

      opts
    end

    #
    # Set thread priorities 
    #
    def Engine::set_thread_priorities
      Thread::main.priority = $config['thread_priority_main'] || 5
      $feed_thread.priority = $config['thread_priority_feed'] || 0
      $save_thread.priority = $config['thread_priority_save'] || 0

      if $config['run_http_server']
        $http_thread.priority  = $config['thread_priority_http'] || 1
      end

      if $config['run_drb_server']
        $drb_thread.priority = $config['thread_priority_drb'] || 1
      end

      # set abort on exception for saving and feed grabbing threads
      $feed_thread.abort_on_exception = $config['abort_on_exception']
      $save_thread.abort_on_exception = $config['abort_on_exception']
    end

    #
    # Save configuration (feed cache, feed list, and possibly theme)
    #
    def Engine::save_config(quit=true)
      Raggle::Engine::save_feed_cache if $config['save_feed_cache']
      Raggle::Engine::save_feed_list(quit) if $config['save_feed_list']
      Raggle::Interfaces::NcursesInterface::save_theme(quit) if $config['save_theme']
      $new_status = $config['msg_save_done'] unless quit
    end

    #
    # purge extra feeds in cache
    #
    def Engine::purge_feed_cache
      urls = {}
      $config['feeds'].each { |feed| urls[feed['url']] = true }
      PStore::new($config['feed_cache_path']) { |cache|
        cache.roots.each { |root| 
          unless urls[root]
            $stderr.puts "Purging \"#{root}\"" if $config['verbose']
            cache[root] = nil
          end
        }
      }
    end
    
    # 
    # delete a feed by id
    #
    def Engine::delete_feed(id)
      $config['feeds'].delete(id)
    end
    
    #
    # Invalidate a feed by id
    #
    def Engine::invalidate_feed(id)
      $config['feeds'].invalidate(id)
    end
    
    #
    # Sort feeds 
    #
    def Engine::sort_feeds
      $config['feeds'].sort
    end

    #
    # edit an existing feed
    #
    def Engine::edit_feed(id, opts)
      $config['feeds'].edit(id, opts)
    end
    
    # 
    # create a lock file for raggle
    #
    def Engine::create_cache_lock
      path = $config['cache_lock_path']
      
      # open cache lock for writing
      unless $config['cache_lock'] = File::open(path, 'w')
        die "Couldn't open \"#{path}\"."
      end
    
      # obtain cache lock
      unless $config['cache_lock'].flock(File::LOCK_EX | File::LOCK_NB)
        $stderr.puts "WARNING: Couldn't obtain cache lock: " << 
                     "Another instance of Raggle is running.\n"
        $stderr.puts "WARNING: Disabling feed caching for this instance."
        $stderr.puts "WARNING: Press enter to continue."
    
        # wait for user response
        $stdin.gets
    
        # disable feed caching and theme saving
        $config['use_cache_lock'] = false
        $config['save_feed_list'] = false
        $config['save_feed_cache'] = false
        $config['save_theme'] = false
      end
    end
    
    #
    # destroy raggle lock file
    #
    def Engine::destroy_cache_lock
      path = $config['cache_lock_path']
    
      # unlock cache lock
      unless $config['cache_lock'].flock(File::LOCK_UN | File::LOCK_NB)
        $stderr.puts "WARNING: Couldn't unlock \"#{path}\"."
      end
    
      # close cache lock
      $config['cache_lock'].flush
      $config['cache_lock'].close
      $config['cache_lock'] = nil
    
      # start garbage collection (flush out file descriptor)
      GC.start
    
      # unlink cache lock
      unless File::unlink(path)
        $stderr.puts "WARNING: Couldn't unlock \"#{path}\"."
      end
    end

    #
    # Return the contents of a URL
    #
    def Engine::get_url(url, last_modified = nil)
      ret = [nil, nil]

      if url =~ /^(\w+?):/
        key = $1.downcase
        if $config['url_handlers'][key]
          ret = $config['url_handlers'][key].call(url, last_modified)
        else
          if $config['strict_url_handling']
            raise "Missing handler for URL \"#{url}\"."
          else
            key = $config['default_url_handler']
            ret = $config['url_handlers'][key].call(url, last_modified)
          end
        end
      else
        if $config['strict_url_handling']
          raise "Malformed URL: #{url}"
        else
          key = $config['default_url_handler']
          ret = $config['url_handlers'][key].call(url, last_modified)
        end
      end

      ret
    end

    #
    # Return the contents of a HTTP URL
    #
    def Engine::get_http_url(url, last_modified = nil)
      port = 80
      use_ssl = false
      user, pass = nil, nil

      # work with a copy of the url
      url = url.dup

      # check for ssl
      if url =~ /^https:/
        raise 'HTTPS support requires OpenSSL-Ruby' unless $HAVE_LIB['ssl']
        use_ssl, port = true, 443
      end

      # strip 'http://' prefix from URL
      url.gsub!(/^\w+?:\/\//, '') if url =~ /^\w+?:\/\//

      # get user and pass from URL
      if url =~ /^([^:]*):([^@]*)@/
        user, pass = $1, $2
        url.gsub!(/^.+?@/, '')
      end

      # get host and path portions of url
      raise "Couldn't parse URL: \"#{url}\"" unless url =~ /^(.+?)\/(.*)$/
      host, path = $1, $2


      # check for port in URL
      if host =~ /:(\d+)$/
        port = $1.to_i
        host.gsub!(/:(\d+)$/, '')
      end

      # start up proxy support
      proxy = Raggle::Proxy::find_proxy(host) || { }
      
      # initialize http connection
      http = Net::HTTP::new(host, port, proxy['host'], proxy['port'])

      # if we have SSL support and this is an https connection, then
      # enable SSL/TLS and disable peer verification (to turn off the
      # warning that's printed to the console)
      #
      # (Note: if we were doing this correctly, we'd have a proper CA
      # path)
      if $HAVE_LIB['ssl'] && use_ssl
        http.use_ssl = use_ssl
        http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      end

      # set http options
      http.open_timeout = $config['http_open_timeout'] || 30
      # http.read_timeout = $config['http_read_timeout'] || 120

      # start HTTP session
      http.start

      # raise an exception unless we could conenct
      raise "Couldn't connect to host \"#{host}:#{port}\"" unless http

      begin 
        # check last-modified header first
        if last_modified
          # build headers
          headers = $config['use_http_headers'] ? $config['http_headers'] : {}
          headers['If-Modified-Since'] = last_modified
          
          # handle basic http authentication
          if user
            pass ||= ''
            headers['Authorization'] = 'Basic ' <<
                                       ["#{user}:#{pass}"].pack('m').strip
          end

          # get response
          resp = http.head('/' << path, headers)

          # check header
          if last_modified == resp['last-modified']
            # it's the same as the old content
            ret = [nil, last_modified]
          else
            # it's changed, get it again
            ret = get_http_url(url, nil)
          end
        else
          # no cache, just get the result
          headers = $config['use_http_headers'] ? $config['http_headers'] : {}

          # handle basic http authentication
          if user
            pass ||= ''
            headers['Authorization'] = 'Basic ' <<
                                       ["#{user}:#{pass}"].pack('m').strip
          end

          # get the result
          resp, content = http.get(('/' << path), headers)

          if resp.code !~ /2\d{2}/
            # if we got anything other than success, raise an exception
            # (this might be a little overzealous)
            raise "HTTP Error: #{resp.code} #{resp.message}"
          end

          # build return array
          ret = [content, resp['last-modified']]
        end
      rescue 
        resp = $!.respond_to?(:response) ? $!.response : $!

        # handle unchanged content and redirects
        if resp.code.to_i == 304      # hasn't changed
          ret = [nil, last_modified]
        elsif resp.code =~ /3\d{2}/   # redirect
          location = resp['location']
          if location =~ /^\//
            # handle relative host, absolute path redirects
            location = host << ':' << port.to_s << location
            location = 'http' << (http.use_ssl ? 's' : '') << '://' << location
          elsif location !~ /^http/
            # handle relative host, relative path redirects
            path.gsub!(/[^\/]+$/, '') if path =~ /[^\/]+$/
            location = 'http' << (http.use_ssl ? 's' : '') << '://' << 
                       host << ':' << port.to_s << '/' << path << '/' <<
                       location
          end

          ret = Engine::get_http_url(location, last_modified)
        else
          raise "HTTP Error: #$!"
        end
      ensure
        # close HTTP connection
        # Note: if we don't specify this, then the connection is pooled
        # for the HTTP/1.1 spec (do we prefer that kind of behavior?
        # maybe I should make it an option)
        http.finish if http.active?
      end


      # return URL content and last-modified header
      ret
    end

    #
    # Return the contents of a file URL
    #
    def Engine::get_file_url(url, last_modified = nil)
      ret = [nil, nil]

      # work with a copy of the url
      path = url.dup

      # strip 'file:' prefix from URL
      path.gsub!(/^\w+?:/, '') if path =~ /^\w+?:/

      if stat = File::stat(path)
        stat_mtime = stat.mtime.to_s
        
        if last_modified
          # check last-modified header first
          if last_modified == stat_mtime
            # it's the same as the old content
            ret = [nil, last_modified]
          else
            # it's changed, get it again
            ret = get_file_url(url, nil)
          end
        else
          # no cache, just get the result
          if file = File::open(path)
            ret = [file.read, stat_mtime]
            file.close
          else
            raise "Couldn't open file URL: #$!"
          end
        end
      else
        raise "File URL Error: #$!"
      end

      # return URL content and last-modified header
      ret
    end

    #
    # Return the contents of an exec URL
    #
    def Engine::get_exec_url(url, last_modified = nil)
      ret = [nil, nil]

      # work with a copy of the url
      cmd = url.dup

      # strip 'exec:' prefix from URL
      cmd.gsub!(/^[^:]+?:/, '') if cmd =~ /^[^:]+?:/

      # no cache, just get the result
      begin 
        pipe = IO::popen(cmd, 'r')
        ret = [pipe.read, nil]
        pipe.close
      rescue
        raise "Couldn't read exec URL: #$!"
      end

      # return URL content and last-modified header
      ret
    end

    #
    # grab individual feed (called by feed grabbing thread)
    #
    def Engine::grab_feed(feed)
      meth = 'Engine.grab_feed'
      t = Time.now
      
      # set the name and priority of the current thread
      Thread::current['feed'] = feed
      Thread::current.priority = $config['thread_priority_feed']
      
      msg = _("Checking feed")
      $new_status = " #{msg} \"#{feed['title']}\"..."
      $log.info(meth) { "#{msg} \"#{feed['title']}\"" }
      return unless feed['refresh'] > 0 && 
        feed['updated'] + (feed['refresh'] * 60) < t.to_i
      
      # update statusbar and grab log
      msg, src_msg = _("Updating feed"), _('from')
      $new_status = " #{msg} \"#{feed['title']}\"..."
      $log.info(meth) { "#{msg} \"#{feed['title']}\" #{src_msg} \"#{feed['url']}\"" }
      
      # get channel
      begin
        chan = Raggle::Feed::Channel.new(feed['url'], feed['last_modified'])
        feed.delete 'error'
      rescue
        err_msg = _('Error updating')
        err = feed['error'] = "#$!"

        if $config['mode'] == 'view'
          Raggle::Interfaces::NcursesInterface::set_status "#{err_msg} \"#{feed['title']}\"."
        end
        $log.error(meth) { "#{err_msg}: #{err}" }

        # mark this feed as updated so it doesn't get constantly polled
        feed['updated'] = t.to_i 
        return
      end
      
      # update feed attributes
      if !chan.last_modified ||
         chan.last_modified != feed['last_modified']
        Thread::critical = $config['use_critical_regions']
        feed['title'] = chan.title unless $config['lock_feed_title'] ||
                                          feed['lock_title?'] == true
        feed['desc'] = chan.desc
        feed['site'] = chan.link
        feed['image'] = chan.image
        feed['updated'] = t.to_i
    
        # Note: last_modified is slightly different than modified,
        # because we're using it as a token, not as a timestamp
        # -- paul
        feed['last_modified'] = chan.last_modified
        
        # hash the urls, save the read and visibility status
        status = {}
        feed['items'].each { |item| status[item['url']] = { :read => item['read?'], :visible => item['visible'] } }
    
        # clear the list if we're not saving feed items
        Thread::critical = $config['use_critical_regions']
        feed['items'].clear unless $config['save_feed_items'] ||
                                   feed['save_items?']
        Thread::critical = false
        
        # insert new items
        new_items = []
        chan.items.each { |item|
          unless (($config['save_feed_items'] ||
                   feed['save_items?']) &&
                  status.has_key?(item.link))
            if status.has_key?(item.link)
              was_read = status[item.link][:read]
              is_visible = status[item.link][:visible]
            else  # new item
              was_read = false; is_visible = true
            end
            new_items << {
              'title'   => item.title,
              'date'    => item.date,
              'url'     => item.link,
              'desc'    => item.desc,
              'read?'   => was_read,
              'visible' => is_visible,
              'added'   => Time.now.to_i,
            }
          end
        }
        
        # if we're saving old items, then prepend the new items
        # otherwise, replace the old list
        Thread::critical = $config['use_critical_regions']
        if $config['save_feed_items'] || feed['save_items?']
          feed['items'] = new_items + feed['items']
    
          max = $config['max_feed_items'] || feed['max_items']
          if max && max > 0
            old = (feed['items'].slice!(max .. -1) || []).size
            rm_msg = _('Removing %d old items.')
            $log.info(meth) { rm_msg % [old] }
          end
        else
          feed['items'] = new_items
        end

        if $config['do_beep']
          print "\a"
        end
        
        Thread::critical = false
      else
        feed['updated'] = t.to_i
        same_msg = _("Feed \"%s\" hasn't changed.")
        $log.info(meth) { same_msg % [feed['title']] }
      end
      
      # if this is the currently selected feed, then re-select it
      # so it's contents are updated
      # TODO: make this select the currently selected item again
      # (assuming it still exists)
      if $config['mode'] == 'view' && 
        !$config['run_http_server'] && !$config['run_drb_server'] &&
         $config['feeds'].get($a_feed)['url'] == feed['url']
         feed_win_id = Raggle::Interfaces::NcursesInterface::get_win_id('feed')
        $wins[feed_win_id].select_win_item
      end
    
      # redraw feed / item windows
      $update_wins = true
    end

    def Engine::start_grab_log
      meth = 'Engine.start_grab_log'

      # create new log file
      log = Logger.new($config['grab_log_path'], 
                       $config['grab_log_age'] || 'weekly', 
                       $config['grab_log_size'] || 1048576)
      log.info(meth) { _('Starting grab log.') }

      # set log level
      level_msg = _('Setting log level to "%s".')
      log_level_str = $config['grab_log_level'] || 'INFO'
      log.info(meth) { level_msg % [log_level_str] }
      log.level = Logger.const_get(log_level_str)
      $log = log
    end
  
    #
    # Start and return the feed-grabbing thread
    #
    def Engine::start_feed_thread
      meth = 'Engine.start_feed_thread'

      ret = Thread.new do
        Engine::start_grab_log

        first_iteration = true
        loop do
          if !first_iteration || $config['update_on_startup']
            begin
              threads = []
              $config['feeds'].each do |the_feed|
                threads << Thread::new(the_feed) do |feed| 
                  Raggle::Engine::grab_feed(feed)
                end

                until Thread::list.size < ($config['max_threads'] || 60)
                  $log.debug(meth) { _('DEBUG: Waiting for threads') }
                  sleep 5
                end
              end

              # wait for outstanding feed grabbing threads to finish
              threads.each do |th| 
                begin
                  if th && th.status && th.alive?
                    th_title = th['feed'] ? th['feed']['title'] : th
                    th_msg = _('Waiting on "%s"')
                    $log.debug(meth) { th_msg % [th_title] }
                    th.join($config['thread_reap_timeout'] || 2)
                  end
                rescue Exception
                  # log error message
                  err_msg = _('Feed Thread Reap Error:')
                  $log.error(meth) { "#{err_msg} #$!" }

                  # if debugging is enabled, then log a backtrace too
                  $log.debug(meth) { 
                    bt = ($!.backtrace || []).join rescue ''
                    "#{err_msg} #$!: (backtrace): #{bt}" 
                  }
                  next
                end
              end
            rescue 
              # log error message
              err_msg = _('Feed Error: ')
              $log.error(meth) { "#{err_msg} #$!" }

              # if debugging is enabled, then log a backtrace too
              $log.debug(meth) { 
                bt = $!.backtrace.join rescue ''
                "#{err_msg} #{$!} backtrace:  #{bt}" 
              }
            end
          end

          first_iteration = false
          
          # finish log entries
          interval = $config['feed_sleep_interval']
          $new_status = $config['msg_grab_done'] % [ $VERSION ]
          if $config['use_manual_update']
            $log.info(meth) { _('Done checking.') }
            Thread::stop
          else
            Thread::list.each do |th|
              if th && th.status && th.alive? && th['feed']
                $log.debug(meth) { "threads: #{th['feed']['title']}" }
              end
            end
            done_msg = _('Done checking.  Sleeping for %d seconds.')
            $log.info(meth) { done_msg % [interval] }
            sleep interval
          end
        end  # loop do
      end  # Thread.new

      # return thread
      ret 
    end

    def Engine::start_save_thread
      ret = Thread.new do
        first_iteration = true
        interval = $config['save_sleep_interval'] || 300
        loop do
          unless first_iteration
            Raggle::Engine::save_config(quit=false)
            $new_status = $config['msg_save_done'] unless $config['msg_save_done'].empty?
          end
          first_iteration = false
          sleep interval
        end
      end

      # return thread
      ret 
    end

    #
    # search for feeds, return array of hashes w/ results
    #
    def Engine::find_feeds(str)
      syn = Raggle::Syndic8.new
    
      syn.keys -= ['siteurl']
      syn.max_results = $config['syndic8_max_results']
    
      # query syndic8
      ret = syn.find(str)
    
      # find empty and duplicate results
      cruft = []
      ret.each do |feed| 
        cruft << feed if feed['dataurl'].size == 0
        cruft << feed if feed['sitename'].size == 0 &&
        feed['description'].size == 0
        dups = ret.find_all { |f| f['dataurl'] == feed['dataurl'] }
        if dups.size > 1
          dups.shift
          dups.each { |dup| cruft << dup }
        end
      end
    
      # delete cruft
      cruft.each { |feed| ret.delete(feed) }
    
      ret
    end
  end
end

#######################################################################
# End Shenanigans, Begin Actual Code                                  #
#######################################################################

#
# main program
#
def main
  Raggle::HTML::init_tagset

  # load config files (config.rb, and feeds.yaml)
  opts = Raggle::Engine::load_config
  
  # create cache lock
  Raggle::Engine::create_cache_lock if $config['use_cache_lock']
  
  # handle command-line mode
  $config['raggle_mode'] = opts['mode']
  Raggle::CLI::handle_mode(opts)

  # load theme
  puts $config['msg_load_theme']
  if $config['load_theme'] && test(?e, $config['theme_path'])
    $config['theme'] = YAML::load(File::open($config['theme_path']))
  end

  $config['run_http_server'] = true unless $HAVE_LIB['ncurses'] ||
                                           $HAVE_LIB['drb']
  # initialize default globals
  $view_source = false
  $update_wins = false
  $old_win = [0]

  # Draw windows and such.
  # (unless we're running as a daemon)
  Raggle::Interfaces::NcursesInterface::init unless \
    $config['run_http_server'] || $config['run_drb_server']
  
  # load feed cache
  Raggle::Engine::load_feed_cache if $config['load_feed_cache'] &&
                                     test(?e, $config['feed_cache_path'])
  
  # set up Net::HTTP module
  Net::HTTP.version_1_1

  # start feed grabbing thread
  $feed_thread = Raggle::Engine::start_feed_thread

  # start config saving thread
  $save_thread = Raggle::Engine::start_save_thread

  # set up HTTP or DRb server
  if $HAVE_LIB['webrick'] && $config['run_http_server']
    $http_thread = Raggle::Interfaces::HTTPServerInterface::init
  elsif $HAVE_LIB['drb'] && $config['run_drb_server']
    $drb_thread = Raggle::Interfaces::DRbServerInterface::init
  end

  # set thread priorities
  Raggle::Engine::set_thread_priorities

  begin
    if $config['run_http_server']
      Raggle::Interfaces::HTTPServerInterface::main_loop
    elsif $config['run_drb_server']
      Raggle::Interfaces::DRbServerInterface::main_loop
    else # ncurses interface
      Raggle::Interfaces::NcursesInterface::main_loop
    end
    
    # clean up screen on exit (unless running HTTP server)
    Ncurses::endwin unless $config['run_http_server'] ||
                           $config['run_drb_server']

    # stop feed grabbing thread
    $feed_thread.exit if $feed_thread.alive?

    # stop config-saving thread
    $save_thread.exit if $save_thread.alive?

    # save feed cache, feed list, and theme
    Raggle::Engine::save_config
  ensure
    unless $done # if done is set then we're exiting cleanly
      # clean up either screen or HTTP server on exit
      if $config['run_http_server']
        $http_server.shutdown
      elsif $config['run_drb_server']
        nil
      else
        Ncurses::endwin 
      end
      
      # save feed cache, feed list, and theme (if requested)
      Raggle::Engine::save_config if $config['save_on_crash']
    end
      
    # unlock everything
    Raggle::Engine::destroy_cache_lock if $config['use_cache_lock'] &&
                                          $config['cache_lock']
    
    $stdout.puts $config['msg_thanks']
  end
end

##################
# default config #
##################
$config = {
  'config_dir'            => Raggle::Path::find_home + '/.raggle',
  'config_path'           => '${config_dir}/config.rb',
  'feed_list_path'        => '${config_dir}/feeds.yaml',
  'feed_cache_path'       => '${config_dir}/feed_cache.store',
  'theme_path'            => '${config_dir}/theme.yaml',
  'grab_log_path'         => '${config_dir}/grab.log',
  'cache_lock_path'       => '${config_dir}/lock',
  'web_ui_root_path'      => Raggle::Path::find_web_ui_root,
  'web_ui_log_path'       => '${config_dir}/webrick.log',

  # feed list handling
  'load_feed_list'        => true,
  'save_feed_list'        => true,

  # feed cache handling
  'load_feed_cache'       => true,
  'save_feed_cache'       => true,

  # Log file rotation schedule.
  #
  # (valid values are 'daily', 'weekly', 'monthly', or an integer
  # indecating the number of days).
  'grab_log_age'          => 'weekly',

  # Log file rotation size.
  #
  # If the log file exceeds this size, then it will be automatically
  # rotated.
  'grab_log_size'         => 1048576,

  # Log file verbosity.
  #
  # Valid values are 'DEBUG', 'INFO', ''WARN', 'ERROR', and 'FATAL'.
  # Defaults to 'INFO' if unspecified.
  'grab_log_level'        => 'INFO',

  # save old feed items indefinitely?
  # Note: doing this with a lot of high-traffic feeds can make
  # your feed cache grow very large, very fast.  It's probably better
  # to use the per-feed --save-items command-line option.
  'save_feed_items'       => false,

  # confirm feed deletion?
  'confirm_delete'        => true,

  # theme handling
  'load_theme'            => true,
  'save_theme'            => true,

  # save stuff on crash?
  'save_on_crash'         => false,

  # abort feed thread on exception?
  'abort_on_exception'    => false,

  # feed list, feed cache, and theme lock handling
  'use_cache_lock'        => true,

  # ui options
  'focus'                 => 'auto', # ['none', 'select', 'select_first', 'auto']
  'no_desc_auto_focus'    => true,
  'scroll_wrapping'       => true,

  # log exerpt of content from feed header? (useful for debugging)
  'log_content'           => false,

  # grab in parallel (grab threads in parallel instead of serial)
  'grab_in_parallel'      => false,

  # use ASCII for window borders instead of ANSI?
  'use_ascii_only?'       => false,

  # maximum number of threads (don't set to less than 5!)
  'max_threads'           => 10,

  # thread priorities (best to leave these alone)
  'thread_priority_main'  => 10,
  'thread_priority_feed'  => 1, # parent feed grabbing thread
  'thread_priority_grab'  => 0, # child grabbing threads
  'thread_priority_gc'    => 1,
  'thread_priority_http'  => 1,
  'thread_priority_find'  => 1,
  'thread_priority_save'  => 0,

  # grab thread reap timeout (wait up to N seconds)
  'thread_reap_timeout'   => 120,

  # don't check every 60 seconds, wait for the update
  # key to be pressed (for modem users, slow computers, etc)
  'use_manual_update'     => false,

  # update feed list on startup?
  'update_on_startup'     => true,

  # proxy settings
  'proxy' => {
    'host'      => nil,
    'port'      => nil,
    'no_proxy'  => nil,
  },
  
  # send the http headers?
  'use_http_headers'      => true,

  # URL handlers
  'url_handlers'          => {
    'http'      => proc { |url, last_mod| Raggle::Engine::get_http_url(url, last_mod) },
    'https'     => proc { |url, last_mod| Raggle::Engine::get_http_url(url, last_mod) },
    'file'      => proc { |url, last_mod| Raggle::Engine::get_file_url(url, last_mod) },
    'exec'      => proc { |url, last_mod| Raggle::Engine::get_exec_url(url, last_mod) },
  },

  # RSS Enclosure Hook
  # 
  # If enabled, this command is called for each RSS enclosure Raggle
  # encounters.  Note that this command is responsible for maintaining
  # it's own cache of downloaded enclosures; Raggle passes the enclosure
  # every time it parses the feed, before it checks whether or not the
  # element was cached.  The arguments passed to the command are as
  # follows:
  #
  # * Feed Title
  # * Feed Link
  # * Item Title
  # * Item Link
  # * Enclosure URL
  # * Enclosure MIME Type
  # * Enclosure Length (in bytes)
  #
  # So, a blatantly naive implementation of an enclosure handler would 
  # probably look something like this:
  # 
  #   require 'pstore'
  #   
  #   class PStore
  #     def has_key?(key)
  #       roots.include?(key)
  #     end
  #   end
  #   
  #   # load URL cache, parse arguments
  #   cache = PStore.new('url_cache')
  #   feed_title, feed_link, title, link, url, mime_type, len = ARGV
  #   
  #   # check cache for URL
  #   exit 0 if cache.transaction { cache.has_key?(url) }
  #
  #   # generate safe filename for output file
  #   # (you'd have to write this)
  #   safe_name = gen_safe_name(url)
  #   
  #   # cache output name 
  #   cache.transaction { cache[url] = safe_name }
  #
  #   # call wget (this could just as easily be a call to
  #   # net/http, curl, or whatever else suites your fancy)
  #   Kernel.exec('wget', '-q', '-O', safe_name, url)
  #   Kernel.exit! # shouldn't ever get here
  #
  # So anyway, without any further ado, an example of the actual
  # enclosure config directive:
  #
  # 'enclosure_hook_cmd' => '/home/pabs/bin/handle_enclosures.rb',

  # Raise an exception (which generally means crash) if the URL type is
  # unknown.  You probably DON'T want to enable this.  If disabled, then
  # fall back to the default_url_handler for unknown URL types.
  'strict_url_handling'    => false,

  # if strict_url_handling is disabled, then fall back to this type 
  # when the URL handler is unknown.
  #
  # WARNING: DO NOT CHANGE THIS TO 'exec' OR 'file'. DOING SO HAS
  # POTENTIALLY SERIOUS SECURITY RAMIFICATIONS.
  'default_url_handler'   => 'http',

  # default http headers
  'http_headers'  => {
    'Accept'              => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,*/*;q=0.1',
    # yes Richard, there is a reason the following line looks so
    # ugly. -- Paul
    'Accept-Charset'      =>'ISO-8859-1,UTF-8;q=0.7,*;q=0.7',
    'User-Agent'          => "Raggle/#$VERSION (#{RUBY_PLATFORM}; Ruby/#{RUBY_VERSION})",
  },

  # Number of list items per "page" (wrt page up/down)
  # (if < 0, then the height of the window, minus N items)
  'page_step'             => -3,

  # date formats
  'item_date_format'      => '%c',
  'desc_date_format'      => '%c',

  # raggle http daemon settings
  'run_http_server'       => false,
  'http_server' => {
    'bind_address'        => '127.0.0.1', # localhost only
    'port'                => 2222,        # port to bind to
    'page_refresh'        => 120,         # refresh interval (feed & item wins)
    'shutdown_sleep'      => 0.5,         # seconds to wait for shutdown
    'empty_item'          => {
      'title'             => '',
      'url'               => '',
      'desc'              => '',
    },
    # NOTE:
    # document root is set as $config['web_ui_root_path'], and the
    # access log is set via $config['web_ui_log_path']
  },

  # raggle drb server settings
  'run_drb_server'        => false,
  'drb_server'            => {
    'bind_url'            => 'druby://localhost:1234',
  },

  # messages
  'msg_welcome'           => _(' Welcome to Raggle %s.'),
  'msg_exit'              => _('| Press Q to exit '),
  'msg_close'             => _('[X] '),
  'msg_grab_done'         => _(' Raggle %s'),
  'msg_load_config'       => _('Raggle: Loading config...'),
  'msg_load_list'         => _('Raggle: Loading feed list...'),
  'msg_save_list'         => _('Raggle: Saving feed list...'),
  'msg_load_cache'        => _('Raggle: Loading feed cache...'),
  'msg_save_cache'        => _('Raggle: Saving feed cache...'),
  'msg_load_theme'        => _('Raggle: Loading theme...'),
  'msg_save_theme'        => _('Raggle: Saving theme...'),
  'msg_thanks'            => _('Thanks for using Raggle!'),
  'msg_default_input'     => _('Input:'),
  'msg_new_value'         => _('New value:'),
  'msg_term_resize'       => _('Terminal Resize: '),
  'msg_links'             => _('Links:'),
  'msg_images'            => _('Images:'),
  'msg_add_feed'          => _('Enter URL:'),
  'msg_feed_added'        => _('Feed added'),
  'msg_confirm_delete'    => _('Delete current feed? (y/n)'),
  'msg_find_entry'        => _('Find:'),
  'msg_cat_title'         => _('Display Category'),
  'msg_find_feed'         => _('Find Feeds:'),
  'msg_searching'         => _(' Searching...'),
  'msg_find_title'        => _('Search Results for "%s" - %s matching feeds'),
  'msg_find_desc'         => _('Please select a feed...'),
  'msg_find_nomatches'    => _('No results found'),
  'msg_keys_title'        => _('Current Key Bindings'),
  'msg_added_existing'    => _('Warning: Added existing feed'),
  'msg_edit_title'        => _('Edit Feed Options'),
  'msg_bad_option'        => _('Warning: Bad option for %s'),
  'msg_edit_success'      => _('New option saved'),
  'msg_save_done'         => _('Configuration saved'),
  'msg_opml_input'        => _('OPML file or URI:'),
  'msg_opml_exported'     => _('OPML exported'),
  'msg_opml_imported'     => _('OPML imported'),
  'msg_bad_uri'           => _('Error: bad or empty URI'),
  'msg_exec_url'          => _('WARNING: exec url found!'),

  # bookmark messages
  'msg_bm_saving'         => _('Saving bookmark for "%s"'),
  'msg_bm_saved'          => _('Bookmark saved for "%s".'),
  'msg_bm_desc'           => _('Bookmark Description:'),
  'msg_bm_tag'            => _('Bookmark Tags (space-separated):'),
  'msg_bm_bad_type'       => _('Bad bookmark type: %s'),
  'msg_bm_bad_db_type'    => _('Unknown database type: %s'),
  'msg_bm_db_err'         => _('Database Error: %s'),
  'msg_bm_db_missing'     => _('Missing Database Engine: %s'),
  'msg_bm_del_err'        => _('Delicious Error: %s'),
  'msg_bm_del_saving'     => _('Saving Bookmark to Delicious...'),
  'msg_bm_del_missing'    => _('Error: Rubilicious not installed.'),
  'msg_bm_file_err'       => _('Error saving to \"%s\": %s'),

  # menu bar color
  'menu_bar_cols'         => 24,

  # strip external entity declarations
  # (workaround for bug in REXML 2.7.1)
  'strip_external_entities' => true,

  # input select timeout (in seconds)
  'input_select_timeout'  => 0.2,

  # http timeouts (in seconds)
  'http_open_timeout'     => 10,
  'http_read_timeout'     => 10,

  # thread sleep intervals (in seconds)
  'feed_sleep_interval'   => 60,

  # save thread sleep interval (in seconds)
  'save_sleep_interval'   => 300,

  # gc thread sleep interval (in seconds)
  'gc_sleep_interval'     => 600,

  # max results to return from syndic8
  'syndic8_max_results'   => 100,

  # grab log mode (a == append, w == write)
  'grab_log_mode'         => 'w',

  # update feeds after adding a new one?
  'update_after_add'      => true,

  # strip html from item contents?
  'strip_html_tags'       => false,

  # repair relative URLs in feed items?
  'repair_relative_urls'  => true,

  # decode html escape sequences?
  'unescape_html'         => true,

  # Force wrapping of generally unwrappable lines?
  'force_text_wrap'       => false,

  # replace unicode chars with what?
  #
  # Note: this option is meaningless when iconv character encoding
  # translation is enabled, unless 'use_iconv_munge' is true.
  'unicode_munge_str'     => '!u!',

  # character encoding used to display text from RSS feeds.
  #
  # The allowed values for 'character_encoding' vary depending on the
  # character encoding method.  If you're using the pre-0.4.0
  # REXML-style encoding translation (and you really shouldn't be unless
  # you're having problems; see 'use_iconv' below for additional
  # information), then the supported values are as follows:
  # 
  #   ISO-8859-1, UTF-8, UTF-16 and UNILE
  #
  # On the other hand, if you're using iconv-style encoding translation,
  # the list of allowed values is any character encoding supported by 
  # your version of iconv (use "iconv --list" for a full list).  Be sure
  # to omit the trailing '//' from your character_encoding value; Raggle
  # automatically appends it if it's necessary.
  #
  'character_encoding'    => 'ISO-8859-1',

  # iconv support 
  # 
  # This is the new character encoding support.  If iconv is installed
  # and iconv support is enabled (with 'use_iconv'), then use iconv
  # instead of REXML to do character encoding translations.  If
  # 'use_iconv_translit' is enabled, then use iconv transliteration to
  # approximate characters that cannot be directly represented in the
  # character encoding (specified above in 'character_encoding').
  #
  # Both 'use_iconv' and 'use_iconv_translit' default to true.
  #
  # If you've got iconv installed, you really should be using it to do
  # character conversions.  It's faster than REXML, supports a much
  # broader range of character encodings, and has intelligent built-in 
  # transliteration (as opposed to the unicode_munge_str chicanery
  # Raggle uses for the REXML-style encoding translation).
  #
  # It's probably a good idea to leave transliteration enabled.  It will
  # prevent iconv from barfing on characters it can't translate, and,
  # since Ncurses-Ruby doesn't have wide character support, it will keep
  # Ncurses from printing garbage all over the screen. 
  # 
  'use_iconv'             => true,
  'use_iconv_translit'    => true,
  'use_iconv_munge'       => true,
  'iconv_munge_illegal'   => true,

  # warn if feed refresh is set to less than this (in minutes)
  'feed_refresh_warn'     => 60,

  # default feed name and refresh rate (in minutes)
  'default_feed_title'    => _('Untitled Feed'),
  'default_feed_refresh'  => 120,
  'default_feed_priority' => 0,

  # open new screen window for browser?
  'use_screen'            => true,

  # screen command
  'screen_cmd'            => ['screen', '-t', '%s'],
  
  # browser options
  'browser'               => Raggle::Path::find_browser,
  'browser_cmd'           => ['${browser}', '%s'],

  # beep on new articles?
  'do_beep'               => false,

  # Force raggle to accept shell metacharacters in urls.
  'force_url_meta'        => false,
  # Regular expression matching shell metacharacters to not allow in URLs
  # 'shell_meta_regex'       => /([\`\$]|\#\{)/, # the #{ is to stop ruby
                                              # expansion.
                                              # Is that necessary?

  # lock feed names (don't update feed title from feed)
  # (you can lock individual feed titles with the --lock-title command)
  'lock_feed_title'       => false,

  # feed info on highlight
  'describe_hilited_feed' => true,
  'desc_show_site'        => false,
  'desc_show_url'         => false,
  'desc_show_divider'     => false,


  #################
  # yank settings #
  #################

  # a list of stuff to filter out of text that's yanked
  # you can either expand this list, or define a whole new filter
  # method with $config['yank_filter_proc'] below
  'yank_filters'          => [
    /<!--.*?-->/mi, 
    /.*<body[^>]*>/mi, 
    /<script.*?<\/script.*?>/mi,
    /<style.*?<\/style.*?>/mi,
  ],

  # filter to pass content through before appending.  the default strips
  # out the HTML header junk and comments (using the contents of
  # $config['yank_filters'] above),, hopefully getting us much closer to
  # the actual content
  'yank_filter_proc'      => proc { |html|
    filters = $config['yank_filters']
    filters.inject(html) { |ret, re| ret.gsub(re, '') }
  },

  # prefix to append before content (passed through strftime so you can
  # timestamp it)
  'yank_prefix'           => "<br/>----<p>Yanked by Raggle on %Y-%m-%d </p>----<br/>",
  
  # xpaths to item elements
  'item_element_xpaths'  => {
    'description' => [
      "./[local-name() = 'encoded' and namespace-uri() = 'http://purl.org/rss/1.0/modules/content/']",
      'description',
    ],
    'link'  => [
      'link',
      'guid', # (this needs tob e guid/attribute, isPermaLink=true)
    ],
    'date'  => [
      'date',
      "./[local-name() = 'date' and namespace-uri() = 'http://purl.org/dc/elements/1.1/']",
      'pubDate',
    ],
  },

  # key bindings
  'keys'            => ($HAVE_LIB['ncurses'] ? {
    Ncurses::KEY_RIGHT  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::next_window} ),
    ?\t                 => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::next_window} ),

    Ncurses::KEY_LEFT   => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::prev_window} ),
    ?\\                 => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::view_source} ),

    Ncurses::KEY_F12    => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::quit} ),
    ?q                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::quit} ),

    Ncurses::KEY_UP     => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_up} ),
    Ncurses::KEY_DOWN   => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_down} ),
    Ncurses::KEY_HOME   => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_top} ),
    ?0                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_top} ),
    Ncurses::KEY_END    => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_bottom} ),
    ?$                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_bottom} ),
    Ncurses::KEY_PPAGE  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_up_page} ),
    Ncurses::KEY_NPAGE  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_down_page} ),

    # vi keybindings
    ?h                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::prev_window} ),
    ?j                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_down} ),
    ?k                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_up} ),
    ?l                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::next_window} ),
    ?g                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_top} ),
    ?G                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::scroll_bottom} ),

    ?\n                 => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::select_item} ),
    ?\                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::select_item} ),

    ?u                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::move_feed_up} ),
    ?d                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::move_feed_down} ),

    ?I                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::invalidate_feed} ),
    ?e                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::edit_feed} ),

    ?/                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::find_entry(win)} ),

    Ncurses::KEY_DC     => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::delete} ),
    ?y                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::undelete_all} ),
    ?P                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::purge_deleted} ),
    ##
    # XXX: Meta can be dropped after spawned browser exits
    # So A, B, C or D should *not* be bound until this is fixed
    # -- richlowe 2003-06-22 (actually --pabs 2003-06-21)
    # ?D                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::delete} ),


    # Literal control L is horrid -- richlowe 2003-06-26
    ?\                => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::resize_term} ),

    ?s                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::sort_feeds} ),

    ?o                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link} ),

    ?m                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::mark_items_as_read} ),
    ?M                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::mark_items_as_unread} ),
    ?N                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::mark_current_as_unread} ),

    ?!                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::drop_to_shell} ),

    ?p                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::select_prev_unread} ),
    ?n                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::select_next_unread} ),

    ?r                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::lower_feed_priority} ),
    ?R                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::raise_feed_priority} ),

    ?U                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::manual_update} ),
    ?S                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::manual_save} ),
    ?a                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::add_feed} ),
    ?O                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::opml} ),

    # i know this is using 'c'.. we'll see if this fucks us (see note
    # about 'C' above)
    # -- pabs (Sat Mar 20 21:10:55 2004)
    ?c                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::gui_cat_list} ),
    ?f                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::gui_find_feed} ),
    ?C                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::close_window} ),
    ?                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::close_window} ),

    ??                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::show_key_bindings} ),
    ?B                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::save_bookmark} ),

    ?1                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(1)} ),
    ?2                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(2)} ),
    ?3                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(3)} ),
    ?4                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(4)} ),
    ?5                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(5)} ),
    ?6                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(6)} ),
    ?7                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(7)} ),
    ?8                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(8)} ),
    ?9                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::open_link(9)} ),
    ?Y                  => proc( %{|win, key| Raggle::Interfaces::NcursesInterface::Key::yank_link } ),
  } : {}),

  # color palette (referenced by themes)
  'color_palette'         => ($HAVE_LIB['ncurses'] ? [
    [  1, Ncurses::COLOR_WHITE,    Ncurses::COLOR_BLACK   ],
    [  2, Ncurses::COLOR_RED,      Ncurses::COLOR_BLACK   ],
    [  3, Ncurses::COLOR_GREEN,    Ncurses::COLOR_BLACK   ],
    [  4, Ncurses::COLOR_BLUE,     Ncurses::COLOR_BLACK   ],
    [  5, Ncurses::COLOR_MAGENTA,  Ncurses::COLOR_BLACK   ],
    [  6, Ncurses::COLOR_CYAN,     Ncurses::COLOR_BLACK   ],
    [  7, Ncurses::COLOR_YELLOW,   Ncurses::COLOR_BLACK   ],
    [ 11, Ncurses::COLOR_BLACK,    Ncurses::COLOR_WHITE   ],
    [ 12, Ncurses::COLOR_BLACK,    Ncurses::COLOR_RED     ],
    [ 13, Ncurses::COLOR_BLACK,    Ncurses::COLOR_GREEN   ],
    [ 14, Ncurses::COLOR_BLACK,    Ncurses::COLOR_BLUE    ],
    [ 15, Ncurses::COLOR_BLACK,    Ncurses::COLOR_MAGENTA ],
    [ 16, Ncurses::COLOR_BLACK,    Ncurses::COLOR_CYAN    ],
    [ 17, Ncurses::COLOR_BLACK,    Ncurses::COLOR_YELLOW  ],
    [ 21, Ncurses::COLOR_BLACK,    Ncurses::COLOR_WHITE   ],
    [ 22, Ncurses::COLOR_WHITE,    Ncurses::COLOR_RED     ],
    [ 23, Ncurses::COLOR_WHITE,    Ncurses::COLOR_GREEN   ],
    [ 24, Ncurses::COLOR_WHITE,    Ncurses::COLOR_BLUE    ],
    [ 25, Ncurses::COLOR_WHITE,    Ncurses::COLOR_MAGENTA ],
    [ 26, Ncurses::COLOR_WHITE,    Ncurses::COLOR_CYAN    ],
    [ 27, Ncurses::COLOR_WHITE,    Ncurses::COLOR_YELLOW  ],
    [ 31, Ncurses::COLOR_WHITE,    Ncurses::COLOR_CYAN    ],
    [ 32, Ncurses::COLOR_RED,      Ncurses::COLOR_CYAN    ],
    [ 33, Ncurses::COLOR_GREEN,    Ncurses::COLOR_CYAN    ],
    [ 34, Ncurses::COLOR_BLUE,     Ncurses::COLOR_CYAN    ],
    [ 35, Ncurses::COLOR_MAGENTA,  Ncurses::COLOR_CYAN    ],
    [ 36, Ncurses::COLOR_BLACK,    Ncurses::COLOR_CYAN    ],
    [ 37, Ncurses::COLOR_YELLOW,   Ncurses::COLOR_CYAN    ],
  ] : []),

  'attr_palette'          => ($HAVE_LIB['ncurses'] ? {
    'normal'        => Ncurses::A_NORMAL,
    'normal'        => Ncurses::A_NORMAL,
    'standout'      => Ncurses::A_STANDOUT,
    'underline'     => Ncurses::A_UNDERLINE,
    'reverse'       => Ncurses::A_REVERSE,
    'blink'         => Ncurses::A_BLINK,
    'dim'           => Ncurses::A_DIM,
    'bold'          => Ncurses::A_BOLD,
    'protect'       => Ncurses::A_PROTECT,
    'invis'         => Ncurses::A_INVIS,
    'altcharset'    => Ncurses::A_ALTCHARSET,
    'chartext'      => Ncurses::A_CHARTEXT,
  } : {}),

  # default theme settings
  'theme'           => {
    # theme information
    'name'          => 'Default Theme',
    'author'        => 'Paul Duncan <pabs@pablotron.org>',
    'url'           => 'http://www.raggle.org/',

    # window order (order for window changes, etc)
    'window_order'  => ['feed', 'item', 'desc'],
    
    # status bar color
    'status_bar_cols'       => 24,

    # feed window attributes
    'win_feed'      => {
      'key'         => 'feed',
      'title'       => _('Feeds'),
      'coords'      => [0, 0, 25, -1],
      'type'        => 'list',
      'colors'      => { 
        'title'     => 1,
        'text'      => 1,
        'h_text'    => 16,
        'box'       => 4,
        'a_title'   => 21,
        # 'a_title'   => 36,
        'a_box'     => 3,
        'unread'    => 6,
        'h_unread'  => 36,
        'empty'     => 2,
        'h_empty'     => 32,
      },
    },

    # item window attributes
    'win_item'      => {
      'key'         => 'item',
      'title'       => _('Items'),
      'coords'      => [25, 0, -1, 15],
      'type'        => 'list',
      'colors'      => {
        'title'     => 1,
        'text'      => 1,
        'h_text'    => 16,
        'box'       => 4,
        'a_title'   => 21,
        'a_box'     => 3,
        'unread'    => 6,
        'h_unread'  => 36,
      },
    },

    # desc window attributes
    'win_desc'      => {
      'key'         => 'desc',
      'title'       => _('Description'),
      'coords'      => [25, 15, -1, -1],
      'type'        => 'text',
      'colors'      => {
        'title'     => 1,
        'text'      => 1,
        'h_text'    => 16,
        'box'       => 4,
        'a_title'   => 21,
        'a_box'     => 3,
        'url'       => 6,
        'date'      => 6,

        'f_title'   => [1, 'bold'],
        'f_update'  => 1,
        'f_url'     => 1,
        'f_site'    => 1,
        'f_error'   => 2,
        'f_desc'    => 1,
      },
    },
  },

  # bookmark settings
  # how bookmarks are saved when you press 'B' in the
  # Ncurses interface
  'bookmark' => [
    # basic CSV bookmark file
    { :type => :csv_file,
      :path => '${config_dir}/bookmarks.csv', 

      # optional settings
      # don't prompt for item description (default: false)
      :no_desc => true,
      # don't prompt for tags (default: false)
      # :no_tags => false,
      # inherit tags from parent feed? (default: true)
      # :inherit_tags => true,
    },

    # pass bookmark to an arbitrary file
    # note: doesn't check for success on exit just yet
# 
#     { :type => :exec,
#       # path to file to execute
#       # arguments are passed in the following order:
#       #   title, url, tags, desc
#       :path => File::join(ENV['HOME'], 'bin', 'raggle_delicious.rb'),
# 
#       # optional settings
#       # don't prompt for item description (default: false)
#       :no_desc => true,
#       # don't prompt for tags (default: false)
#       # :no_tags => false,
#       # inherit tags from parent feed? (default: true)
#       # :inherit_tags => true,
#     },
# 

    # save bookmarks to sqlite
    # note: requires the sqlite-ruby library, and the bookmark
    # database must exist, with the appropriate table
# 
#     { :type   => :db,
#       :dbtype => :sqlite,
#     
#       # path to database file
#       # note: database file MUST already exist!
#       :path   => '${config_dir}/bookmarks.db',
# 
#       # name of table to save bookmarks into
#       # note: this table MUST already exist!
#       :table  => 'raggle_bookmarks',
# 
#       # list of fields (note: you can omit any of these)
#       :fields => {
#         :title  => 'title',
#         :desc   => 'description',
#         :url    => 'url',
#         :tags   => 'tags',
#       },
# 
#       # optional settings
#       # don't prompt for descriptions (default: false)
#       # :no_desc => false,
#       # don't prompt for tags (default: false)
#       # :no_tags => false,
#       # inherit tags from parent feed? (default: true)
#       # :inherit_tags => true,
#     },
# 

    # save bookmarks to mysql
    # note: requires the sqlite-ruby library, and the bookmark
    # database must exist, with the appropriate table
# 
#     { :type   => :db,
#       :dbtype => :mysql,
#     
#       # db server, username, password, and name of database to use
#       :host   => 'HOSTNAME', # (use 'localhost' if it's on this machine)
#       :user   => 'USERNAME',
#       :pass   => 'PASSWORD',
#       :dbname => 'raggle_database',
# 
#       # name of table to save bookmarks into
#       # note: this table MUST already exist!
#       :table  => 'raggle_bookmarks',
# 
#       # list of fields (note: you can omit any of these)
#       :fields => {
#         :title  => 'title',
#         :desc   => 'description',
#         :url    => 'url',
#         :tags   => 'tags',
#       },
# 
#       # optional settings
#       # don't prompt for descriptions (default: false)
#       # :no_desc => false,
#       # don't prompt for tags (default: false)
#       # :no_tags => false,
#       # inherit tags from parent feed? (default: true)
#       # :inherit_tags => true,
#     },
# 
  ],

  # live feeds
  'feeds'    => Raggle::Feed::List.new,

  # debugging / internal options (don't touch)
  'use_raw_mode'  => true,
  'use_noecho'    => true,

  'default_feeds' => [
    { 'title'     => 'Raggle Help',
      'url'       => "http://raggle.org/rss/help/",
      'site'      => 'http://raggle.org/',
      'refresh'   => 240,
      'updated'   => 0,
      'desc'      => '',
      'category'  => 'Raggle',
      'items'     => [ ],
      'priority'  => 2,
    },
    { 'title'     => 'Alternet',
      'url'       => 'http://alternet.org/module/feed/rss/',
      'site'      => 'http://alternet.org/',
      'desc'      => 'Alternative News and Information.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Politics News',
      'items'     => [ ],
    },
    { 'title'     => 'Daily Daemon News',
      'url'       => 'http://daily.daemonnews.org/ddn.rdf.php3',
      'site'      => 'http://daemonnews.org/',
      'desc'      => 'Daily Daemon News',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'FreshMeat',
      'url'       => 'http://freshmeat.net/backend/fm-releases-global.xml',
      'site'      => 'http://freshmeat.net/',
      'desc'      => 'FreshMeat.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Halffull.org',
      'url'       => 'http://halffull.org/feed/',
      'site'      => 'http://halffull.org/',
      'desc'      => 'Thomas Kirchner\'s personal site.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs Tech',
      'items'     => [ ],
    },
    { 'title'     => 'KernelTrap',
      'url'       => 'http://kerneltrap.org/node/feed',
      'site'      => 'http://kerneltrap.org/',
      'desc'      => 'KernelTrap',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Kuro5hin',
      'url'       => 'http://kuro5hin.org/backend.rdf',
      'site'      => 'http://kuro5hin.org/',
      'desc'      => 'Kuro5hin',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech Politics',
      'items'     => [ ],
    },
    { 'title'     => 'Linux Weekly News',
      'url'       => 'http://www.lwn.net/headlines/newrss',
      'site'      => 'http://www.lwn.net/',
      'desc'      => 'Linux Weekly News',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Linuxbrit',
      'url'       => 'http://linuxbrit.co.uk/feed/rss2/',
      'site'      => 'http://linuxbrit.co.uk/',
      'desc'      => 'Tom Gilbert\'s personal site.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs',
      'items'     => [ ],
    },
    { 'title'     => 'Pablotron',
      'url'       => 'http://www.pablotron.org/rss/',
      'site'      => 'http://www.pablotron.org/',
      'desc'      => 'Paul Duncan\'s personal site.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Paul Duncan.org',
      'url'       => 'http://paulduncan.org/rss/',
      'site'      => 'http://paulduncan.org/',
      'desc'      => 'Paul Duncan\'s other personal site.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs',
      'items'     => [ ],
    },
    { 'title'     => 'Raggle: News',
      'url'       => 'http://raggle.org/rss/',
      'site'      => 'http://raggle.org/',
      'desc'      => 'Raggle News',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech Raggle',
      'items'     => [ ],
      'priority'  => 1,
    },
    { 'title'     => 'Slashdot',
      'url'       => 'http://slashdot.org/slashdot.rss',
      'site'      => 'http://slashdot.org/',
      'desc'      => 'Slashdot',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech News',
      'items'     => [ ],
    },
    { 'title'     => 'Reuters: Oddly Enough',
      'url'       => 'http://www.microsite.reuters.com/rss/oddlyEnoughNews',
      'site'      => 'http://reuters.com/',
      'desc'      => 'Reuters oddly enough.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Funny News',
      'items'     => [ ],
    },
    { 'title'     => 'This Modern World',
      'url'       => 'http://www.thismodernworld.com/index.rdf',
      'site'      => 'http://www.thismodernworld.com/',
      'desc'      => 'This Modern World',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs Politics',
      'items'     => [ ],
    },
    { 'title'     => 'W3C',
      'url'       => 'http://www.w3.org/2000/08/w3c-synd/home.rss',
      'site'      => 'http://www.w3.org/',
      'desc'      => 'W3C',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Yahoo! News - Tech',
      'url'       => 'http://rss.news.yahoo.com/rss/tech',
      'site'      => 'http://news.yahoo.com/',
      'desc'      => 'yahoo tech',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech News',
      'items'     => [ ],
    },
    { 'title'     => 'Yahoo! News - Top Stories',
      'url'       => 'http://rss.news.yahoo.com/rss/topstories',
      'site'      => 'http://news.yahoo.com/',
      'desc'      => 'yahoo top stories',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'News',
      'items'     => [ ],
    },
    { 'title'     => 'Yahoo! News - World',
      'url'       => 'http://rss.news.yahoo.com/rss/world',
      'site'      => 'http://news.yahoo.com/',
      'desc'      => 'yahoo world',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Politics News',
      'items'     => [ ],
    },
  ],
}

if __FILE__ == $0
  if $config['diag']
    begin
      main
    rescue => err
      puts "#{err.message} (#{err.class})"
      #err.backtrace.collect! { |name| name = "[#{name}]" }
      #puts err.backtrace.reverse.join(" -> ")
      err.backtrace.each { |frame| puts frame }
    end
  else
    main
  end
end

# -*- Mode: Ruby -*- vim: sts=2 sw=2 expandtab
