#!/usr/bin/env ruby
# -*- Mode: Ruby -*-

#######################################################################
# Raggle - Console RSS aggregator                                     #
# by Paul Duncan <pabs@pablotron.org>,                                #
#    Richard Lowe <richlowe@richlowe.net>, and                        #
#    Ville Aine <vaine@cs.helsinki.fi>                                #
#                                                                     #
#                                                                     #
# Please see the Raggle page at http://www.raggle.org/ for the latest #
# version of this software.                                           #
#                                                                     #
#                                                                     #
# Copyright (C) 2003, 2004 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.3.1'

# 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') { 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'

#########################
# load external modules #
#########################
{ # key => [path, title, required]
  'ncurses' => ['ncurses',        'Ncurses-Ruby', false],
  'rexml'   => ['rexml/document', 'REXML',        true ],
  'yaml'    => ['yaml',           'YAML',         true ],
  'ssl'     => ['net/https',      'OpenSSL-Ruby', false],
  'webrick' => ['webrick',        'WEBrick',      false],
  'drb'     => ['drb',            'DRb',          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
}

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

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

module Path
  #
  # find an application in your $PATH
  #
  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)
  #
  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) 
  # 
  def Path::find_web_ui_root
    if RUBY_PLATFORM =~ /win32/i
      ENV['PROGRAMFILES'] + '/Raggle/web_ui'
    else
      '${config_dir}/web_ui'
    end
  end
  
  #
  # Find browser
  #
  def Path::find_browser
    ret = ENV['RAGGLE_BROWSER']
    ret ||= ENV['BROWSER']
    ret ||= %w{links elinks w3m lynx explorer.exe}.find { |app| find_app(app) }

    # die if we couldn't find a browser
    die "Couldn't find suitable browser.",
        "Please set $RAGGLE_BROWSER, $BROWSER, or install one of the following",
        "console browsers: ELinks, Links, Lynx. or W3M." unless ret

    # return browser
    ret
  end
end

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

#
# find a proxy in windows by checking the registry
#
def 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.gsub(/;/, ',') if np && np.length > 0
    end
  end
  
  # dump proxy debug
  # ret && ret.each { |key, val| puts "DEBUG: #{key} => #{val}" }
  
  ret
end

#
# get the proxy host and port from $config['proxy'], or
# ENV['http_proxy'] / ENV['no_proxy'] if $config['proxy'] isn't set
#
def find_proxy(host)
  ret = nil

  # if we're in windows, check the registry as well
  ret = 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

class String
  def lines
    self.split(/\n/).size
  end
  
  def escape
    gsub(/(["\\])/, "\\$1")
    self
  end

  def escape!
    gsub!(/(["\\])/, "\\$1")
    self
  end

  def escape_format
    gsub(/%/, '%%')
  end

  def escape_format!
    gsub!(/%/, '%%')
  end

  # this bit of love is from the pickaxe
  # (augmented by me, of course)
  def unescape_html
    str = self.dup
    str.gsub!(/&(.*?);/n) {
      m = $1.dup
      case m
      when /^amp$/ni
        '&'
      when /^nbsp$/ni
        ' '
      when /^quot$/ni
        '"'
      when /^lt$/ni
        '<'
      when /^gt$/ni
        '>'
      when /^copy/
        '(c)'
      when /^trade/
        '(tm)'
      when /^#8212$/n
        ","
      when /^#8217$/n
        "'"
      when /^#(\d+)$/n
        r = $1.to_i # Integer() interprets leading zeros as octal
        r.between?(0, 255) ? r.chr : $config['unicode_munge_str']
      when /^#x([0-9a-f]+)$/ni
        r = $1.hex
        r.between?(0, 255) ? r.chr : $config['unicode_munge_str']
      end
    }
    str
  end

  def strip_tags
    gsub(/<[^>]+?>/, '')
  end

  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

module Key
  def Key::scroll_up
    $wins[$a_win].scroll_up
  end

  def Key::scroll_down
    $wins[$a_win].scroll_down
  end

  def Key::scroll_top
    $wins[$a_win].scroll_top
  end

  def Key::scroll_bottom
    $wins[$a_win].scroll_bottom
  end

  def Key::scroll_up_page
    $wins[$a_win].scroll_up_page
  end

  def Key::scroll_down_page
    $wins[$a_win].scroll_down_page
  end

  def Key::quit
    $done = true
  end

  def Key::gui_add_feed
    ncurses_add_feed
  end

  def Key::next_window
    set_active_win(($a_win + 1) % $wins.size)
  end

  def Key::prev_window
    set_active_win((($a_win - 1 < 0) ? $wins.size : $a_win) - 1)
  end

  def Key::select_item
    $wins[$a_win].select_win_item
  end

  def Key::move_item_up
    win = $wins[get_win_id('feed')]
    $a_feed = $config['feeds'].move_up(win.active_item)
    win.move_item_up
  end

  def Key::move_item_down
    win = $wins[get_win_id('feed')]
    $a_feed = $config['feeds'].move_down(win.active_item)
    win.move_item_down
  end

  def Key::delete
    if $config['feeds'].size > 0
      win = $wins[get_win_id('feed')]
      $config['feeds'].delete(win.active_item)
      win.delete_item
      win.active_item = (win.active_item > 0) ? win.active_item - 1 : 0
      win.draw_items
      win.clearrange(win.items.size + 1, win.dimensions[1] - 2)
    end
  end

  def Key::invalidate_feed
    id = $wins[get_win_id('feed')].active_item
    name = $config['feeds'].get(id)['title']
    $config['feeds'].invalidate(id)
    set_status " Forcing update for \"#{name}\""
  end

  def Key::sort_feeds
    win = $wins[get_win_id('feed')]

    # get old active window
    a_win = $a_win

    # sort list, repopulate window
    $config['feeds'].sort
    populate_feed_win
    
    # select first feed, redraw feed window
    win.active_item = 0
    win.draw_items
    win.select_win_item

    # reselect previous window
    set_active_win a_win
  end
  
  def Key::open_link
    win = $wins[get_win_id('desc')]
    win.select_win_item
  end

  def Key::manual_update
    $feed_thread.run
  end

  def Key::find_entry(win)
    ncurses_find_entry(win)
  end

  #
  # toggle view (html) source mode
  #
  def Key::view_source
    # toggle global
    $view_source = !$view_source
    win = $wins[$a_win]

    if $a_win == get_win_id('desc')
      if win.items 
        # clear formatted content to force refresh
        # (evil, but it works ;D)
        win.items[0]['fmt_content'] = nil
        win.draw_items
      end
    else
      # if we're not in the desc window, just reselect the current
      # item to force a refresh
      win.select_win_item 
    end
  end
end

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
      def initialize
	@context = nil
	@start_actions = []
	@end_actions = []
      end

      attr_accessor :context
      attr_reader :start_actions
      attr_reader :end_actions

      def start_actions=(*actions)
	@start_actions = actions.flatten
      end

      def end_actions=(*actions)
	@end_actions = actions.flatten
      end
    end
    
    def initialize
      @tags = Hash.new
      yield self
    end

    def define_tag(*names)
      yield tag_spec = Tag.new
      names.each do |name|
	yield @tags[name] = tag_spec
      end
    end

    def defined?(name)
      @tags.has_key?(name)
    end
    
    def [](name)
      @tags[name]
    end
  end

  TAG_SET = TagSet.new do |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
  end

  def self.render_html(source, width=72)
    r = Renderer.new(source, width)
    r.rendered_text
  end
  
  class Renderer
    def initialize(source, width)
      @source, @width = source, width
      @links = []
      @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.split(/\s+/).each do |word|
	if cur_line.length + word.length > @width
	  @lines << cur_line
	  cur_line = ""
	end
	cur_line << " " unless cur_line.empty? || cur_line[-1] == ?\ # fix emacs
	cur_line << word.chomp
      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
          @lines.pop if @lines[-1] == ""
          @lines += data.split("\n", -1)
        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 the 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
      
      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)
    def action_save_href
      @links << @current_attributes['href']
    end

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

  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=false, save_items=false, force=false)
    refresh = $config['default_feed_refresh'] unless refresh
    feed_list = $config['feeds']

    begin
      contents = if file_name =~ /:\/\//
        Feed::get_url(file_name)[0]
      else
        file = (file_name == '-') ? $stdin : File::open(file_name)
        raise "Couldn't open input file \"#{file_name}\"." unless file
        file.read
      end

      if contents && contents.size > 0
        doc = REXML::Document.new(contents)
        doc.root.elements.each('//outline') do |outline|
          title = outline.attributes['title']
          url = outline.attributes['xmlUrl']
          site = outline.attributes['htmlUrl'] || ''
          desc = outline.attributes['description'] || ''

          if title.nil? || url.nil?
            $stderr.puts "Warning: skipping incomplete OPML entry: #{outline}"
            next
          end
          raise "WARNING!!! EXEC URL IN OPML IMPORT" if (url =~ /^exec:/)
          feed_list.add(title, url, refresh, lock_title, save_items,
                        site, desc, [], nil, nil, force)
        end
      else
        raise "Empty input file \"#{file_name}\"."
      end
    rescue REXML::ParseException
      die "Parsing #{file_name} failed: #{$!.message}"
    rescue
      die $!.message
    end
  end


  # Export all feeds to OPML format
  def OPML::export(file_name)
    begin
      feed_list = $config['feeds']

      opml = REXML::Element.new("opml")
      opml.attributes['version'] = '1.1'

      opml.add_element(opml_head)
      
      body = REXML::Element.new('body')
      opml.add_element(body)
      feed_list.each do |feed|
        body.add_element(feed_to_outline(feed))
      end

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

      file = (file_name == '-') ? $stdout : File::open(file_name, 'w')
      if file
        doc.write(file, 0)
      else
        raise "Couldn't open output file \"#{file_name}\"."
      end
    rescue
      die $!.message
    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['description'] = feed['desc'] || ''
    outline.attributes['xmlUrl'] = feed['url'] || ''
    outline.attributes['htmlUrl'] = feed['site'] if feed.has_key?('site')
    outline.attributes['version'] = 'RSS'
    outline.attributes['type'] = 'rss'
    outline
  end
end

class FeedList
  attr_accessor :feeds
  def initialize
    @feeds = []
  end

  def size
    @feeds.size
  end
  
  def add(title, url, refresh, lock_title=false, save_items=false, site='',
          desc='', items=[], image = nil, category = nil, force=true)
    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,
    }
  end

  def <<(feed)
    @feeds << feed
  end

  def categories
    cats = {}
    @feeds.each { |feed|
      if feed['category']
        feed['category'].split(/\s*,\s*/).each { |cat|
          cats[cat.downcase] = cat
        }
      end
    }
    cats.values.compact.sort
  end

  def category(cat = 'all')
    if cat !~ /\ball\b/i
      re = /\b#{cat}\b/i
      @feeds.map { |feed| feed['category'] =~ re ? feed : nil }.compact
    else
      @feeds
    end
  end

  def [](*n)
    @feeds[*n]
  end
  
  def get(id)
    raise "Invalid feed id: #{id}" unless id < @feeds.size
    @feeds[id]
  end
  
  def delete(id)
    raise "Invalid feed id: #{id}" unless id < @feeds.size
    @feeds.delete_at(id)
  end

  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

  def each_with_index
    @feeds.each_with_index { |feed, i| yield feed, i }
  end

  def each
    @feeds.each { |feed| yield feed }
  end

  def each_index
    @feeds.each_index { |i| yield i }
  end

  #
  # Sort feeds by title (case-insensitive).
  #
  def sort
    @feeds.sort! { |a, b|
      re = /^\s*(a|the)\s+/i
      
      a_c, b_c = a['title'].gsub(re, '').downcase, 
                 b['title'].gsub(re, '').downcase

      a_c <=> b_c
    }
  end

  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|
        ourl = $config['feeds'].get(id)['url']
        %w{title url refresh lock_title? save_items? max_items}.each { |key|
          $config['feeds'].get(id)[key] = opts[key] if opts.has_key? key
        }
        if opts.has_key?('url') and opts['url'] != ourl
          $config['feeds'].invalidate(id)
        end
      end
    else 
      ourl = $config['feeds'].get(id)['url']
      %w{title url refresh lock_title? save_items? max_items}.each { |key|
        $config['feeds'].get(id)[key] = opts[key] if opts.has_key? key
      }
      if opts.has_key?('url') and opts['url'] != ourl
        $config['feeds'].invalidate(id)
      end
    end
  end

  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

  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

  def describe(id)
    # clear desc window
    win = $wins[get_win_id('desc')]
    win.clear
    
    win.items.clear # This should only ever have one entry
    win.items << {
      # 'real_title' => $config['feeds'][id]['title'],
      'title'      => $config['feeds'][id]['title'],
      'content'    => $config['feeds'][id]['desc'],
      'date'       => $config['feeds'][id]['date'],
      'url'        => $config['feeds'][id]['url'],
      'date'       => Time.at($config['feeds'][id]['updated']).strftime('%c'),
      'read?'      => false
    }

    win.draw_items
  end
    
  
  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

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

  #
  # Return the contents of a URL
  #
  def Feed::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 Feed::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 = true
    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 = find_proxy(host) || { }
    
    # initialize http connection
    http = Net::HTTP::new(host, port, proxy['host'], proxy['port'])
    if $HAVE_LIB['ssl'] && use_ssl
      http.use_ssl = use_ssl
    end
    http.open_timeout = $config['http_open_timeout'] || 30
    # http.read_timeout = $config['http_read_timeout'] || 120
    http.start
    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.to_i != 200
          # if we got anythign other than success, raise an exception
          # (this might be a little overzealous, anyone?
          raise "HTTP Error: #{resp.code} #{resp.message}" \
        end

        # build return array
        ret = [content, resp['last-modified']]
      end
    rescue 
      resp = $!.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 = 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 in 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 Feed::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 Feed::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


  class Channel
    attr_accessor :title, :link, :desc, :lang, :image, :items, :last_modified

    def initialize(url, last_modified = nil)
      parse_rss_url url
    end

    # 
    # Parse an RSS URL and return a FeedChannel object (which contains,
    # among other things, an array of feed_item structs).  If the last
    #
    def parse_rss_url(url, last_modified = nil)
      begin
        content, modified = Feed::get_url(url, last_modified)
        $log.puts "content: #{content[0,80]}",
                  "modified: #{modified}"
      rescue
        raise "Couldn't get URL \"#{url}\": #$!."
      end

      @last_modified = modified
      if !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

        # get channel info
        e = nil
        @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']
    
        # build list of feed items
        @items = []
        doc.root.elements.each('//item') { |e| 
          # get item attributes (the ones that are set, anyway... stupid
          # RSS)
          h = {}

          # 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)
          }

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

    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

    def fix_character_encoding(element)
      tmp = ''
      encoding = $config['character_encoding'] || 'ISO-8859-1'
      text = element.get_text
      text.write(REXML::Output.new(tmp, encoding)) if text
      tmp.unescape_html
    end
  end
end


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

  def clearrange(start, fini)
    w = dimensions[0]
    set_color('text')
    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|
        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

  # 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
      if @items[@active_item].has_key? 'feed'
        select_feed @items[@active_item]['feed']
      elsif @items[@active_item].has_key? 'item'
        select_item @items[@active_item]['item']
      elsif @items[@active_item].has_key? 'url'
        # are we opening new screen windows?
        use_screen = $config['use_screen'] && in_screen?

        # drop out of curses mode
        save_screen unless use_screen

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

        # prepend screen command if we're using it
        if use_screen
	  screen_cmd = $config['screen_cmd'].map do |cmd_part|
	    if cmd_part =~ /%s/
	      cmd_part % @items[@active_item]['title']
	    else
	      cmd_part
	    end
	  end

          # build new command
          cmd.unshift(*screen_cmd) 
        end

	system(*cmd)
	
        # reset screen
        restore_screen unless use_screen
      end
    end
  end

  def reflow_string(str)
    w = dimensions[0]
    str.reflow(w - 4)
  end
  
  def method_missing(id)
    raise "Please implement #{self.class}##{id.id2name}"
  end
end
  
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, 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(' ')
    set_status status

    select_win_item if $config['focus'] == 'auto'
  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
  
  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

  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 delete_item
    @items.delete_at(@active_item)
    populate_feed_win
  end
end

class TextWindow < Window
  def reflow_string(str)
    w = dimensions[0]
    $view_source ? str.reflow(w - 4) : HTML.render_html(str, w - 4)
  end
       
  def draw_items
    item = @items[0] # There can be only one
    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)
    draw(item['url'].ljust(w - 2), 7, y, 'url', false, false)

    # 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']
      y += 1
      if !item['fmt_content']
        str = item['content'] || ''
        str = str.strip_tags if $config['strip_html_tags']

        # strip CDATA from text (seems like this might cause some problems
        # anyone have any thoughts?) -- pabs
        str = str.gsub(/<!\[CDATA\[(.*?)\]\]>/m, '\1')

        # 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, 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]
    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]
    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 delete_item
    @items.delete_at(@active_item)
    populate_feed_win
  end

  def resize_width
    # XXX
  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 delete_item
    @items.delete_at(@active_item)
    populate_feed_win
  end

  def resize_width
    # XXX
  end
end

module Raggle
  class HTTPServer
    attr_accessor :server, :opts, :templates

    def initialize(opts)
      @opts = opts
      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'
            sort_feeds
          when 'invalidate_all'
            invalidate_feed(-1)
            $feed_thread.run
          when 'add'
            url = $1 if req.query_string =~ /url=(.*)$/
            add_feed({ 'url' => url })
            $feed_thread.run if $config['update_after_add']
          when 'delete'
            if req.query_string =~ /id=(\d+)/
              id = $1.to_i
              delete_feed(id)
            end
          when 'invalidate'
            if req.query_string =~ /id=(\d+)/
              id = $1.to_i
              invalidate_feed(id)
              $feed_thread.run 
            end
          when 'save_feed_list'
            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

    def start
      @server.start
    end

    def shutdown
      @server.shutdown
    end

    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

    def build_category_select(cat)
      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 { |opt| 
        ret << "  <option value='#{opt}'" <<
               ((cat =~ /\b#{opt}\b/i) ? ' selected="1"' : '') <<
               ">#{opt}</option>\n"
      }
      ret << "</select>\n"

      ret
    end
  end

  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(oots)
      main::add_feed(opts)
    end

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

    def version
      $VERSION
    end

    def quit
      exit 0
    end
  end

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

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

#
# set status bar message (or log message, if running as daemon)
#
def 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 set_active_win(a_win)
  $wins[$a_win].active = false
  $wins[$a_win].refresh

  $a_win = a_win

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

  if $config['focus'] == 'auto' && $a_win != get_win_id('desc') && 
     $wins[$a_win].items && $wins[$a_win].items.size > 0
    $wins[$a_win].select_win_item
  end
end

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

#
# check config for old, broken directives
#
def check_config
  if $config['browser_cmd'].is_a? String
    $config['browser_cmd'] = ['${browser}', '%s']

    $stderr.puts "WARNING: Ignoring old-style $config['browser_cmd'].",
                 "WARNING: Press enter to continue."

    # wait for user response
    $stdin.gets
  end
end

# 
# expand path macros in $config
#
def 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


# set active feed by id
#
def select_feed(id)
  $a_feed = id
  $a_item = 0

  # clear item window
  item_win = $wins[get_win_id('item')]
  item_win.offset = 0
  item_win.active_item = 0
  item_win.items.clear

  $wins[get_win_id('desc')].offset = 0
  
  # unused.. remove it?
  fmt = $config['item_date_format']

  # wtf is happening here?
  raise "id.class.to_s = #{id.class.to_s}" unless id.class == Fixnum
  
  # iterate through feed items
  $config['feeds'].get(id)['items'].each_with_index { |item, i|
    # 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']
      if item['date'] =~ /^\d+$/ && item['date'].to_i > 0
        d_str = Time.at(d_str.to_i).strftime(fmt)
      end
      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

    item_win.items << {
      'title' => title,
      'item'  => i,
      'read?' => item['read?']
    }
  }

  # 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

#
# populate feed window
# 
def populate_feed_win 
  win = $wins[get_win_id('feed')]

  win.items.clear
  
  $config['feeds'].each_with_index { |feed, i|
    # count unread items
    unread_count = 0
    feed['items'].each { |item| unread_count += 1 unless item['read?'] }

    # build title
    if feed['title']
      title = feed['title'].strip
    else
      title = ($config['default_feed_title'] || 'Untitled Feed').dup
    end
    title << " (#{unread_count}/#{feed['items'].size})"

    win.items << {
      'title'       => title,
      'feed'        => i,
      'read?'       => unread_count == 0,
      'item_count'  => feed['items'].size,
      'updated'     => feed['updated'],
    }
  }
  win.draw_items
end

#
# set active item by id
#
def select_item(id)
  $a_item = id

  desc_win = $wins[get_win_id('desc')]
  item_win = $wins[get_win_id('item')]

  # clear description window
  desc_win.active_item = 0
  desc_win.items.clear

  # mark item as read
  item_win.items[$a_item]['read?'] = true
  $config['feeds'].get($a_feed)['items'][id]['read?'] = true

  # redraw item window items (to mark selected item as read)
  item_win.draw_items

  # get feed
  feed = $config['feeds'].get($a_feed)

  # fix host-relative URLs
  item_content = $config['feeds'].get($a_feed)['items'][id]['desc']
  if $config['repair_relative_urls']
    # get host portion of original URL
    host_url = feed['url']
    host_url = host_url.gsub(/^((?:\w+):\/\/)?([^\/]+)(\/.*)?$/, '\1\2')

    item_content = item_content.gsub(/href\s*=\s*["']([^'"]+?)['"]/m) {
      m = $1.dup
      new_url = case m
        when (/^(\w+):\/\//)
          m
        else
          [host_url, m].join('/')
      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_url, 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
  populate_feed_win

  # redraw description window
  desc_win.offset = 0
  desc_win.draw_items

  # activate description window
  if (($config['focus'] == 'select' ||
       $config['focus'] == 'select_first') &&
      !$config['no_desc_auto_focus'])
    set_active_win(get_win_id('desc'))
  end
end

#
# mark all items in feed as read
# 
def mark_items_as_read
  win = $wins[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 }

  populate_feed_win
  win.draw_items
end

DIRECTION_BACKWARD = -1
DIRECTION_FORWARD = 1

#
# Select next unread item in
# direction +direction+. Direction
# can be either +DIRECTION_FORWARD+
# or +DIRECTION_BACKWARD+
#
def select_unread_item(direction)
  win = $wins[get_win_id('item')]
  size = win.items.size

  return unless size > 0
  
  i = ($a_item + direction) % size
  while i != $a_item
    if win.items[i]['read?'] == false
      select_item i
      win.activate i
      win.draw_items
      return
    end

    i = (i + direction) % size
  end
end

#
# select next unread item
#
def select_next_unread
  select_unread_item DIRECTION_FORWARD
end

#
# select previous unread item
#
def select_prev_unread
  select_unread_item DIRECTION_BACKWARD
end

#
# load feed cache
#
def load_feed_cache
  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']
    populate_feed_win
    select_feed 0
    set_active_win get_win_id('feed')
  end

  set_status "Loading feed cache... done"
end

#
# save feed cache 
#
def 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 save_feed_list
  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') { |f|
    $stdout.puts $config['msg_save_list'] if $config['raggle_mode'] == 'view'
    $stdout.flush

    # save feed items before clearing htem (for web interface)
    tmp = []
    $config['feeds'].each { |feed| tmp << feed['items']; feed['items'] = [] }

    f.puts $config['feeds'].to_yaml

    # restore feed items (again, for web interface)
    tmp.each_with_index { |t, i| $config['feeds'][i]['items'] = t }
  }
end

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

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

def print_usage
  puts <<ENDUSAGE
Raggle - Console RSS feed aggregator.
Version #$VERSION, by Paul Duncan <pabs@pablotron.org> and
                      Richard Lowe <richlowe@richlowe.net>

Usage:
  #$0 [options]

Options:
  -a, --add, --add-feed         Add a new feed (requires '--url').
  --ascii                       Use ASCII characters instead of ANSI for
                                window borders.
  -c, --config                  Specify an alternate config file.
  -d, --delete, --delete-feed   Delete an existing feed.
  -e, --edit, --edit-feed       Edit an existing feed.
  --export-opml                 Export feeds to OPML.  
  --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-items                   Set the maximum number of items for a feed
                                (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          Sort feeds by 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').
  -u, --url                     URL attribute of feed (for '--add' and
                                '--edit').
  --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 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
  
About the Authors:
Paul Duncan <pabs@pablotron.org>
http://www.pablotron.org/  (RSS: http://www.pablotron.org/rss/)
http://www.paulduncan.org/ (RSS: http://www.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>
ENDUSAGE

  exit(0)
end

#
# handle command-line options
# 
def handle_command_line(opts)
  ret = { 'mode' => 'view' }

  gopts = GetoptLong.new(
    ['--add', '-a',        '--add-feed',        GetoptLong::OPTIONAL_ARGUMENT],
    ['--edit', '-e',       '--edit-feed',       GetoptLong::REQUIRED_ARGUMENT],
    ['--delete', '-d',     '--delete-feed',     GetoptLong::REQUIRED_ARGUMENT],
    ['--invalidate', '-i', '--invalidate-feed', GetoptLong::REQUIRED_ARGUMENT],
    ['--config', '-c',     '--config-file',     GetoptLong::REQUIRED_ARGUMENT],
    ['--force',                                 GetoptLong::NO_ARGUMENT],		 
    ['--list', '-l',                            GetoptLong::NO_ARGUMENT],
    ['--title', '-t',                           GetoptLong::REQUIRED_ARGUMENT],
    ['--url', '-u',                             GetoptLong::REQUIRED_ARGUMENT],
    ['--refresh', '-r',                         GetoptLong::REQUIRED_ARGUMENT],
    ['--version', '-v',                         GetoptLong::NO_ARGUMENT],
    ['--purge',                                 GetoptLong::NO_ARGUMENT],
    ['--sort', '--sort-feeds',                  GetoptLong::NO_ARGUMENT],
    ['--lock-title',                            GetoptLong::NO_ARGUMENT],
    ['--unlock-title',                          GetoptLong::NO_ARGUMENT],
    ['--save-items',                            GetoptLong::NO_ARGUMENT],
    ['--unsave-items',                          GetoptLong::NO_ARGUMENT],
    ['--verbose',                               GetoptLong::NO_ARGUMENT],
    ['--import-opml',                           GetoptLong::REQUIRED_ARGUMENT],
    ['--export-opml',                           GetoptLong::REQUIRED_ARGUMENT],
    ['--diag',                                  GetoptLong::NO_ARGUMENT],
    ['--server',                                GetoptLong::OPTIONAL_ARGUMENT],
    ['--drb-server', '--drb',                   GetoptLong::NO_ARGUMENT],
    ['--help', '-h', '--usage',                 GetoptLong::NO_ARGUMENT],
    ['--ascii', '-A',                           GetoptLong::NO_ARGUMENT],
    ['--max-items', '--max',                    GetoptLong::REQUIRED_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 '--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 '--purge'
          ret['mode'] = 'purge'
        when '--sort', '--sort-feeds'
          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-items'
          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 '--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 '-h', '--help', '--usage'
          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 stdout
#
def 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

#
# add a new feed
#
def add_feed(opts)
    %w{title refresh}.each { |i| opts[i] ||= $config["default_feed_#{i}"] }
    $config['feeds'].add(opts['title'], opts['url'], opts['refresh'],
                         opts['lock_title?'], opts['save_items?'],
			                   '', '', [], nil, nil, opts['force'])

    # can't keep adding params to FeedList#add forever :/
    if opts['max_items']
      $config['feeds'][-1]['max_items'] = opts['max_items']
    end
end

#
# add a new feed from ncurses
# 
def ncurses_add_feed
  w, e_msg = $config['w'], $config['msg_add_feed']

  # print entry message
  msg = e_msg + (' ' * (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

  # if it's not nil, then add it to our list
  if str && str.length > 0
    add_feed({ 'url' => str })
    populate_feed_win
  end
    
  # redraw status bar
  $new_status = $status
  $status = msg
  set_status($new_status)

  # force update now
  $feed_thread.run if $config['update_after_add']
end

# 
# #
# # select a category (disabled for now)
# #
# def ncurses_select_category
#   $category = 'all' unless $category
# 
#   cats = ['all']
#   cats  $config['feeds'].categories.sort
# end
# 

#
# find entry in window
# 
def ncurses_find_entry(win, direction = 1)
  if win && win.items && win.items.size > 0
    w, e_msg = $config['w'], $config['msg_find_entry']

    # print entry message
    msg = e_msg + (' ' * (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

    # 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

      win.activate new_index if new_index >= 0
      $last_search = str
    end
      
    # redraw status bar
    $new_status = $status
    $status = msg
    set_status($new_status)
  end
end

#
# purge extra feeds in cache
#
def 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 delete_feed(id)
  $config['feeds'].delete(id)
end

#
# Invalidate a feed by id
#
def invalidate_feed(id)
  $config['feeds'].invalidate(id)
end

#
# Sort feeds 
#
def sort_feeds
  $config['feeds'].sort
end

#
# edit an existing feed
#
def edit_feed(id, opts)
  $config['feeds'].edit(id, opts)
end

# 
# create a lock file for raggle
#
def 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 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


def resize_term
  # 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[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 save_screen
  Ncurses::def_prog_mode
  Ncurses::endwin
end

#
# restore saved screen settings
#
def restore_screen
  Ncurses::reset_prog_mode
  Ncurses::refresh
  # Ncurses::getch
end

#
# drop to sub-shell
#
def drop_to_shell
  save_screen
  system ENV['SHELL']
  restore_screen
end

##
# Ugly
# -- richlowe 2003-07-01
def setup_win
      # initialize screen & keyboard
    Ncurses::initscr
    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, [])
    
    # 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 = ''
    set_status $config['msg_welcome']
    
    # create windows
    $a_win = 0
    $wins = []
    $config['theme']['window_order'].each { |i|
      case i
        when /feed/
          cl = ListWindow
        when /item/
          cl = ListWindow
        when /desc/
          cl = TextWindow
      else
        raise "Unknown window #{i}"
      end
      $wins << cl.new($config['theme']["win_#{i}"])
      # $wins[-1].draw LONG_STRING
    }
    set_active_win(0)
    
    $a_feed, $a_item = 0, 0
    
    # populate feed window
    populate_feed_win
    
    select_feed(0)
    set_active_win(0)
end

#
# grab individual feed Ccalled by feed grabbing thread)
#
def grab_feed(feed)
  t = Time.now
  
  # set the name and priority of the current thread
  Thread::current['feed'] = feed
  Thread::current.priority = $config['thread_priority_feed']
  
  $new_status = " Checking feed \"#{feed['title']}\"..."
  $log.puts "#{Time.now}: Checking feed \"#{feed['title']}\""
  return unless feed['refresh'] > 0 && 
    feed['updated'] + (feed['refresh'] * 60) < t.to_i
  
  # update statusbar and grab log
  $new_status = " Updating feed \"#{feed['title']}\"..."
  $log.puts "#{t}: Updating feed \"#{feed['title']}\" from " +
            "\"#{feed['url']}\""
  
  # get channel
  begin
    chan = Feed::Channel.new(feed['url'], feed['last_modified'])
    feed.delete 'error'
  rescue
    # set_status "Error updating \"#{feed['title']}\"."
    $log.puts "#{t}: Error: #$!"
    feed['error'] = "#$!"
    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 status
    urls = {}
    feed['items'].each { |item| urls[item['url']] = item['read?'] }

    # 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?']) &&
              urls.has_key?(item.link))
        was_read = urls.has_key?(item.link) && urls[item.link]
        new_items << {
          'title' => item.title,
          'date'  => item.date,
          'url'   => item.link,
          'desc'  => item.desc,
          'read?' => was_read,
          '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
        $log.puts "#{t}: Removing #{old} old items."
      end
    else
      feed['items'] = new_items
    end
    Thread::critical = false
  else 
    feed['updated'] = t.to_i
    $log.puts "Feed \"#{feed['title']}\" hasn't changed."
  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['run_http_server'] && !$config['run_drb_server'] &&
     $config['feeds'][$a_feed]['url'] == feed['url']
    $wins[get_win_id('feed')].select_win_item
  end

  # redraw feed / item windows
  $update_wins = true
end


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

def main
  # expand the default config hash
  expand_config

  # parse command-line options
  opts = handle_command_line 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
  
  check_config
  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'])
    $config['feeds'] = YAML::load(File::open($config['feed_list_path']))
  end

  if $config['feeds'] && $config['feeds'].size == 0
    $config['default_feeds'].each {|feed|
      $config['feeds'].add(feed['title'], feed['url'], feed['refresh'],
                           feed['lock_title?'], feed['save_items?'],
                           feed['site'], feed['desc'], feed['items'],
                           nil, feed['category'])
    }
  end
  
  # create cache lock
  create_cache_lock if $config['use_cache_lock']
  
  # handle command-line options
  $config['raggle_mode'] = opts['mode']
  case opts['mode']
  when 'list'
    list_feeds
    exit 0
  when 'add'
    add_feed opts
    save_feed_list
    exit 0
  when 'delete'
    delete_feed opts['id']
    save_feed_list
    exit 0
  when 'invalidate'
    invalidate_feed opts['id']
    save_feed_list
    exit 0
  when 'edit'
    edit_feed opts['id'], opts
    save_feed_list
    exit 0
  when 'sort'
    sort_feeds
    save_feed_list
    exit 0
  when 'purge'
    purge_feed_cache
    exit 0
  when 'import_opml'
    OPML::import opts['opml_file'], opts['refresh'], opts['lock_title?'], opts['save_items'],
      opts['force']
    save_feed_list
    exit 0
  when 'export_opml'
    OPML::export opts['opml_file']
    exit 0
  end
  
  # enable ascii mode if requested
  $config['use_ascii_only?'] = true if opts['ascii']

  # 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']

  # Draw windows and such.
  # (unless we're running as a daemon)
  setup_win unless $config['run_http_server'] || $config['run_drb_server']
  
  # load feed cache
  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 = Thread.new {
    File.open($config['grab_log_path'], $config['grab_log_mode']) { |log|
      log.sync = true
      log.puts "#{Time.now}: Starting grab log."
      $log = log

      first_iteration = true
      loop {
        if !first_iteration || $config['update_on_startup']
          begin
            threads = []
            $config['feeds'].each { |the_feed|
              threads << Thread::new(the_feed) { |feed| grab_feed(feed) }
              until Thread::list.size < ($config['max_threads'] || 60)
                $log.puts 'DEBUG: Waiting for threads'
                sleep 5
              end
            }

            # wait for outstanding feed grabbing threads to finish
            threads.each { |th| 
              begin
                if th && th.status && th.alive?
                  th_title = th['feed'] ? th['feed']['title'] : th
                  $log.puts "#{Time.now}: Waiting on \"#{th_title}\""
                  th.join($config['thread_reap_timeout'] || 2)
                end
              rescue Exception
                $log.puts "Feed Thread Reap Error: \"#$!\"."
                next
              end
            }
          rescue
            log.puts 'Feed Error: ' << $!.to_s
          end
        end

        first_iteration = false
        
        # finish log entries
        interval = $config['feed_sleep_interval']
        $new_status = $config['msg_grab_done']
        if $config['use_manual_update']
          log.puts "#{Time.now}: Done checking."
          Thread::stop
        else
          Thread::list.each { |th|
            if th && th.status && th.alive? && th['feed']
              $log.puts "DEBUG: thread list: #{th['feed']['title']}"
            end
          }
          log.puts "#{Time.now}: Done checking.  Sleeping for #{interval}s."
          sleep interval
        end
      }
    }
  }

  # set up HTTP server
  if $HAVE_LIB['webrick'] && $config['run_http_server']
    # check to make sure web ui dir exists
    die "Missing Web UI Root directory \"#{$config['web_ui_root_path']}\"." \
      unless test ?e, $config['web_ui_root_path']

    # start HTTP server thread
    $http_thread = Thread.new {
      $http_server = Raggle::HTTPServer::new($config['http_server'])
      $http_server.start
    }
  elsif $HAVE_LIB['drb'] && $config['run_drb_server']
    # start DRb server thread
    $drb_thread = Thread::new {
      $drb_server = Raggle::DRbServer::new($config['drb_server'])
    }
  end

  #########################
  # set thread priorities #
  #########################
  Thread::main.priority = $config['thread_priority_main'] || 5
  $feed_thread.priority = $config['thread_priority_feed'] || 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 feed grabbing thread
  $feed_thread.abort_on_exception = $config['abort_on_exception']

  begin
    if $config['run_http_server']
      $http_thread.join
    elsif $config['run_drb_server']
      $drb_server.start # (this does a join)
    else # ncurses interface
      # main input 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
          $config['keys'][c].call($wins[$a_win],c) \
            if $config['keys'].has_key? c
        end
        
        # refresh window contents if there's been a feed update
        if $update_wins
          $update_wins = false
          populate_feed_win
          # wins.each { |win| win.draw_items }
        end
        
        set_status $new_status if $new_status != $status
      end
    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?

    # save feed cache, feed list, and theme
    save_feed_cache if $config['save_feed_cache']
    save_feed_list if $config['save_feed_list']
    save_theme if $config['save_theme']
  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)
      if $config['save_on_crash']
        save_feed_cache if $config['save_feed_cache']
        save_feed_list if $config['save_feed_list']
        save_theme if $config['save_theme']
      end
    end
      
    # unlock everything
    destroy_cache_lock if $config['use_cache_lock'] && $config['cache_lock']
    
    $stdout.puts $config['msg_thanks']
  end
end

##################
# default config #
##################
$config = {
  'config_dir'            => 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'      => 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,

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

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

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

  # 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| Feed::get_http_url(url, last_mod) },
    'https'     => proc { |url, last_mod| Feed::get_http_url(url, last_mod) },
    'file'      => proc { |url, last_mod| Feed::get_file_url(url, last_mod) },
    'exec'      => proc { |url, last_mod| Feed::get_exec_url(url, last_mod) },
  },

  # 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 (#{PLATFORM}; 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 #{$VERSION}.",
  'msg_exit'              => "| Press Q to exit ",
  'msg_close'             => '[X] ',
  'msg_grab_done'         => " Raggle #{$VERSION}",
  '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_term_resize'       => 'Terminal Resize: ',
  'msg_links'             => 'Links:',
  'msg_add_feed'          => 'Enter URL:',
  'msg_find_entry'        => 'Find:',

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

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

  # 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?
  'unicode_munge_str'     => '!u!',

  # character encoding used to display text
  # from RSS feeds.
  #
  # Supported encodings are: ISO-8859-1, UTF-8, UTF-16 and UNILE
  'character_encoding'    => 'ISO-8859-1',

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

  # default feed name and refresh rate
  'default_feed_title'    => 'Untitled Feed',
  'default_feed_refresh'  => 120,

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

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

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

  
  # 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| Key::next_window },
    ?\t                 => proc { |win, key| Key::next_window },

    Ncurses::KEY_LEFT   => proc { |win, key| Key::prev_window },
    ?\\                 => proc { |win, key| Key::view_source },

    Ncurses::KEY_F12    => proc { |win, key| Key::quit },
    ?q                  => proc { |win, key| Key::quit },

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

    # vi keybindings
    ?h                  => proc { |win, key| Key::prev_window },
    ?j                  => proc { |win, key| Key::scroll_down },
    ?k                  => proc { |win, key| Key::scroll_up },
    ?l                  => proc { |win, key| Key::next_window },

    ?\n                 => proc { |win, key| Key::select_item },
    ?\                  => proc { |win, key| Key::select_item },

    ?u                  => proc { |win, key| Key::move_item_up },
    ?d                  => proc { |win, key| Key::move_item_down },

    ?I                  => proc { |win, key| Key::invalidate_feed },

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

    Ncurses::KEY_DC     => proc { |win, key| Key::delete },
    ##
    # 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| Key::delete },


    # Literal control L is horrid -- richlowe 2003-06-26
    ?\                => proc { |win, key| resize_term },
    Ncurses::KEY_RESIZE => proc { |win, key| resize_term },

    ?s                  => proc { |win, key| Key::sort_feeds },

    ?o                  => proc { |win, key| Key::open_link },

    ?m                  => proc { |win, key| mark_items_as_read },

    ?!                  => proc { |win, key| drop_to_shell },

    ?p                  => proc { |win, key| select_prev_unread },
    ?n                  => proc { |win, key| select_next_unread },

    ?U                  => proc { |win, key| Key::manual_update },
    ?a                  => proc { |win, key| Key::gui_add_feed },
  } : {}),

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

  # live feeds
  'feeds'    => FeedList.new,

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

  'default_feeds' => [
    { 'title'     => '  Raggle Help', # add a space so sorting puts it at top
      'url'       => "http://www.raggle.org/rss/help/",
      'site'      => 'http://www.raggle.org/',
      'refresh'   => 240,
      'updated'   => -1,
      'desc'      => '',
      'category'  => 'Raggle',
      'items'     => [ ],
    },
    { 'title'     => 'Alternet',
      'url'       => 'http://www.alternet.org/rss/rss.xml',
      'site'      => 'http://www.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://themes.freshmeat.net/backend/fm-newsletter.rdf',
      'site'      => 'http://www.freshmeat.net/',
      'desc'      => 'FreshMeat.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'KernelTrap',
      'url'       => 'http://kerneltrap.org/node/feed',
      'site'      => 'http://www.kerneltrap.org/',
      'desc'      => 'KernelTrap',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Kuro5hin',
      'url'       => 'http://www.kuro5hin.org/backend.rdf',
      'site'      => 'http://www.kuro5hin.org/',
      'desc'      => 'Kuro5hin',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Linux Weekly News',
      'url'       => 'http://www.lwn.net/headlines/rss',
      'site'      => 'http://www.lwn.net/',
      'desc'      => 'Linux Weekly News',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech',
      'items'     => [ ],
    },
    { 'title'     => 'Linuxbrit.co.uk',
      'url'       => 'http://www.linuxbrit.co.uk/rss.php',
      'site'      => 'http://www.linuxbrit.co.uk/',
      'desc'      => 'Linuxbrit.co.uk',
      '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',
      'items'     => [ ],
    },
    { 'title'     => 'Paul Duncan.org',
      'url'       => 'http://www.paulduncan.org/rss/',
      'site'      => 'http://www.paulduncan.org/',
      'desc'      => 'Paul Duncan\'s other personal site.',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs',
      'items'     => [ ],
    },
    { 'title'     => 'Pigdog Journal',
      'url'       => 'http://www.pigdog.org/pigdog.rdf',
      'site'      => 'http://www.pigdog.org/',
      'desc'      => 'Pigdog Journal',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Politics,News',
      'items'     => [ ],
    },
    { 'title'     => 'Raggle: News',
      'url'       => 'http://raggle.org/rss/',
      'site'      => 'http://www.raggle.org/',
      'desc'      => 'Raggle News',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech,Raggle',
      'items'     => [ ],
    },
    { 'title'     => 'Richlowe.net',
      'url'       => 'http://richlowe.net/diary/index.rss',
      'site'      => 'http://www.richlowe.net/',
      'desc'      => 'Richlowe.net',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs',
      'items'     => [ ],
    },
    { 'title'     => 'Slashdot',
      'url'       => 'http://slashdot.org/slashdot.rss',
      'site'      => 'http://www.slashdot.org/',
      'desc'      => 'Slashdot',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Tech,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'     => 'Tynian.net',
      'url'       => 'http://tynian.net/rss/',
      'site'      => 'http://tynian.net/',
      'desc'      => 'tynian.net',
      'refresh'   => 120,
      'updated'   => 0,
      'category'  => 'Blogs',
      '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
