#!/usr/bin/perl -w

#  persephone -- XML-RPC based blogging-tool
#
#  Simple tool to blog a text file and hopefully avoid the clutter of
#  a graphical web browser. This script might evolve into something
#  more flexible {when,if} given a chance. Using HTTPS is strongly
#  recommanded if available on your weblog's platform(s).
#
#  Note: on first run, the program will ask you a few questions
#  regarding your blog in order to create an appropriate config file.
#  Please, as your password will only be base64 encoded, this file
#  should really not be made public anytime.
#
#  This program requires:
#           - MIME::Base64
#           - Encode
#           - Getopt::Long
#           - RPC::XML      (librpc-xml-perl on Debian)
#           - Curses        (libcurses-perl on Debian)
#
#  Copyright (C) 2005  --  oz <oz@tuxaco.net>
#
#  This script is free software; you can redistribute it and/or modify
#  it under the same terms as Perl itself.

use strict;
use warnings;

use Curses;
use Encode;
use Getopt::Long;
use MIME::Base64;
use RPC::XML qw($ENCODING);
use RPC::XML::Client;

my $APP_NAME = 'persephone';
my $VERSION = '0.1-dev';
my $DEBUG = 0;
my $CONF_FILE = $ENV{'HOME'} . '/.persephonerc';

# -------------------------------------------------------------------[ XMLRPC ]
# Send new post
sub new_post($$;$)
{
    my ( $conf, $content, $publish ) = @_;
    my $cli = $conf->{'client'};
    $publish = $conf->{'auto_publish'} unless defined $publish;

    # First line is post title.
    if ( $content =~ /^(.*?)\n\n/ ) {
        my $title = $1;
        $content =~ s/^(.*?)\n\n//;
        $content = $content;
        $content = "<title>$title</title>$content";
    }
    $content = reformat_text( \$content );
    return blogger_new_post( $conf, $content, $publish );
}

# Get user blog id : use first one, if > 1
sub get_blogid($)
{
    my ($conf) = @_;
    return blogger_get_blogid($conf);
}

# Get blog recents posts
#
# Returns an array ref:
#   [
#       {
#           'postid'      => INT,
#           'userid'      => INT,
#           'content'     => STRING,
#           'dateCreated' => ISO8601-DATE,
#           'title'       => STRING,
#           'category'    => STRING,
#       },
#       ...
#   ]
# 
# FIXME: should be less Blogger specific.
sub get_recent_posts($)
{
    my ($conf) = @_;
    my @list   = ();

    # Let's copy this, and add/change some fields.
    @list = @{ &blogger_get_recent_posts( $conf ) };
    map {
        # Get title then remove it from content
        if ( $_->{'content'} =~ /<title>(.*?)<\/title>/i ) {
            $_->{'title'} = $1;
            $_->{'content'} =~ s/<title>.*?<\/title>//g;
        }
        # Same with post category (this is a comma separated list if multiple)
        if ( $_->{'content'} =~ /<category>(.*?)<\/category>/i ) {
            $_->{'category'} = $1;
            $_->{'content'} =~ s/<category>.*?<\/category>//g;
        }
    } @list;
    return \@list;
}

# Delete a post
sub delete_post($$)
{
    my ( $conf, $id ) = @_;
    my $list = $conf->{'list'};
    my $cli  = $conf->{'client'};

    return 0 unless defined $list->[$id];
    return blogger_delete_post( $conf, $list->[$id]->{'postid'} );
}

# Edit a post
sub edit_post($$$$)
{
    my ( $oldpost, $conf, $content, $pub ) = @_;
    return 0 unless defined($oldpost) && defined($conf) &&
                    defined($content) && defined($pub);
    my $cli  = $conf->{'client'};

    # First line is post title.
    if ( $content =~ /^(.*?)\n\n/ ) {
        my $title = $1;
        $content =~ s/^(.*?)\n\n//;
        $oldpost->{'content'} = $content;
        $content = "<title>$title</title>"
                   . "<category>$oldpost->{'category'}</category>$content";
    }
    $content = reformat_text( \$content );
    return blogger_edit_post( $conf, $oldpost->{'postid'}, $content, $pub );
}

# ----------------------------------------------------------[ Config handling ]
# Gen. conf
# FIXME:
#   * re-write this correctly, with check points, and a curses UI maybe.
#   * or simply delete it
sub gen_conf()
{
    my ( $url, $user, $pass, $pub, $kept_pass ) = undef;

    print "File not found: $CONF_FILE\nGenerating...\n";

    # Get URL
    print 'xmlrpc API (eg. http://foo.net/bar/xmlrpc.cgi): ';
    chomp( $url = <STDIN> );

    # Get login
    print 'Login: ';
    chomp( $user = <STDIN> );

    # Get password (not echo-ing)
    system( 'stty -echo' );
    print 'Password: ';
    chomp( $pass = <STDIN> );
    print "\n";
    system( 'stty echo' );
    $kept_pass = $pass;
    $pass = encode_base64($pass);

    # Auto-publish
    $pub = confirm( 'Auto-publish submitted text?', 'y' );

    # Write conf
    umask(0077);
    open( CFG, ">" . $CONF_FILE ) or die("Can't write config: $!\n");
    print CFG <<EOF;
# $APP_NAME config.

# Weblog xmlrpc config.
url = $url
login = $user
pass = $pass
# Should posted entries be automatically published ?
auto_publish = $pub

# How many posts should be fetched when running UI ?
#   Please note that Blogger API is pretty intensive on bandwidth as
#   it does not allow to fetch only post titles, but forces you into
#   getting whole posts. Thus, fetching many posts may not be a good
#   option if (for some reason) you're trying to minimize bandwidth
#   usage.
recent_posts = 10

# Temp dir
temp_dir = /tmp

# Encoding: ISO-8859-15, UTF-8...
encoding = ISO-8859-15

# Editor defaults to vim
editor = '/usr/bin/vim'
EOF
    close(CFG);

    print "Created config in $CONF_FILE, ";
    print "now let's get back to that blogging thing.\n\n";
    return {
        'url'          => $url,
        'login'        => $user,
        'pass'         => $kept_pass,
        'auto_publish' => $pub,
        'recent_posts' => 10,
        'temp_dir'     => '/tmp',
        'encoding'     => 'ISO-8859-15',
    };
}

# Load conf. from CONF_FILE
sub load_conf()
{
    my ( $url, $user, $pass, $pub ) = undef;
    my %conf;

    # Generate config if CONF_FILE is missing
    unless ( -f $CONF_FILE ) { return gen_conf() }

    # Or just load
    open( CFG, $CONF_FILE ) or die("Config file ? $!\n");
    while ( my $line = <CFG> ) {
        ( $line =~ /^\s*([a-z_]+)\s*=\s*(.+)$/ ) or next;
        $conf{$1} = $2;
    }
    close(CFG);
    $conf{'pass'} = decode_base64( $conf{'pass'} ) if exists( $conf{'pass'} );
    $conf{'encoding'} = 'us-ascii' if ( !$conf{'encoding'} );
    $RPC::XML::ENCODING = $conf{'encoding'};
    $conf{'appkey'} = 42;
    $conf{'scroll_offset'} = 0;
    if ( !$conf{'editor'} ) {
        $conf{'editor'} = $ENV{'EDITOR'} || die("Editor program?\n");
    }
    return \%conf;
}

# Confirm yes/no...
sub confirm($$)
{
    my ( $question, $default ) = @_;
    my $ans;

    my $choice = ( $default =~ /n/i ) ? '(y/N)' : '(Y/n)';
    do {
        print "$question $choice";
        $ans = <STDIN>;
        $ans = $default if ( $ans eq "\n" );
    } while ( $ans !~ /^y(es)?|no?$/i );
    return ( $ans =~ /y/ ) ? 1 : 0;
}

# -------------------------------------------------------------------[ Curses ]
# Start curses
sub start_curses()
{
    initscr();
    keypad( stdscr, 1 );
    intrflush( stdscr, 0 );
    nonl();
    cbreak();
    curs_set(0);
    noecho();
}

# End run clean-up
sub end_curses()
{
    erase();
    refresh();
    endwin();
}

# Draw main UI
sub draw_ui(;$$)
{
    my ( $top_text, $bot_text ) = @_;
    my $win = newwin( $LINES - 2, $COLS, 1, 0 );

    if (!defined $top_text) {
        $top_text = " [ $APP_NAME $VERSION  ]";
    }
    if (!defined $bot_text) {
        $bot_text = ' q:Quit  n:New  e:Edit  d:Delete  r:Refresh  a:About';
    }
    $win->keypad(1);
    $win->refresh();
    ui_bars($top_text, $bot_text);
    return $win;
}

# Draw UI to and bottom bars
sub ui_bars($$)
{
    my ($top_text, $bot_text) = @_;

    attrset(A_REVERSE);
    addstr( 0,          0, ' ' x $COLS );
    addstr( $LINES - 1, 0, ' ' x $COLS );
    addstr( 0,          0, $top_text );
    addstr( $LINES - 1, 0, $bot_text );
    attroff(A_REVERSE);
}

# Show about window
sub show_about($$$)
{
    my $height = 15;
    my $width  = 42;
    my $win    = newwin(
        $height, $width,
        ( $LINES - $height ) / 2,
        ( $COLS - $width ) / 2
    );
    return unless defined $win;

    $win->addstr( 2, 2, "$APP_NAME v$VERSION" );
    $win->addstr( 3, 2, "Contact: oz <oz\@tuxaco.net>" );
    $win->addstr( 5, 2, "All your blog are belong to us." );
    $win->box( 0, 0 );
    $win->addstr( 0, 1, "About" );
    $win->refresh();
    getch();
    $win->border( ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' );
    $win->erase();
    $win->refresh();
    $win->delwin();
}

# Quit program
# FIXME confirm dialog ?
sub show_exit($$$)
{
    end_curses();
    exit(0);
}

# Generate code depending the way ('up' or 'down' typically) we want to
# scroll.
#
# FIXME Close your eyes and scroll, so that you can ignore that mess.
sub gen_scroll($$$$)
{
    my ( $way, $win, $list, $conf ) = @_;

    # If located at then beginning or end of list, don't move.
    my $fail_cond = ( 'up' eq $way )
      ? '($list_index + 1 >= $conf->{\'recent_posts\'})
           || !defined $list->[$list_index + 1]'
      : '$list_index <= 0';

    # Ok, so we can move, now what about scrolling ?
    my $can_move =
      ( 'up' eq $way )
      ? '!defined( $list->[ $list_index + 1 ] ) or ( $lin >= $LINES - 3 )'
      : '!defined( $list->[ $list_index - 1 ] ) or ( $lin < 1 )';

    # $redraw_op defines where we want to move: up or down.
    # $higlight defines which line is to be hilighted after scrolling.
    my $redraw_op = ( 'up' eq $way ) ? '+' : '-';
    my $hilight   = ( 'up' eq $way ) ? '0' : '$LINES - 3';

    # The funniest part.
    my $code = '
        $win->getyx( my $lin, my $col );
        my $list_index = $lin + $conf->{\'scroll_offset\'};
        my $encoding = $conf->{\'encoding\'};
        return if ( ' . $fail_cond . ' );

        # Can move cursor without scrolling ?
        unless ( ' . $can_move . ' )
        {
            redraw_item( $win, $list->[$list_index], $lin, $encoding );
            $win->move( $lin ' . $redraw_op . ' 1, $col );
            redraw_item( $win, $list->[ $list_index ' . $redraw_op . ' 1 ],
                         $lin ' . $redraw_op . ' 1, $encoding, 1 );
            return 1;
        }

        # If we got here, then we have to scroll window content one page:
        # Update scroll offset...
        $conf->{\'scroll_offset\'} ' . $redraw_op . '= $LINES - 3;

        # ... then redraw list.
        draw_posts(
            $win, $list, $conf,
            $conf->{\'scroll_offset\'},
            $conf->{\'scroll_offset\'} + $LINES - 2, ' . $hilight . '
        );
        $win->move( ' . $hilight . ', 0 );
    ';
    return $code;
}

# "Scroll" window up or down:
#   Redraw current line without hilight, then move cursor (up|down),
#   then hilight next item. This is all done^Wgenerated in gen_scroll.
#
# Hmm... looking at gen_scroll is far from advised.
sub scroll_list($$$)
{
    my ( $win, $key, $conf ) = @_;
    my $list = $conf->{'list'};

    return eval( &gen_scroll( 'up',   $win, $list, $conf ) ) if ( 'j' eq $key );
    return eval( &gen_scroll( 'down', $win, $list, $conf ) ) if ( 'k' eq $key );
}

# Show recent posts on main UI
sub draw_recent_posts($$$)
{
    my ( $win, $list, $conf ) = @_;
    return 0 if ( !defined $list );

    return draw_posts( $win, $list, $conf, 0, $LINES - 1 );
}

# Redraw posts from list, from $start to $stop
sub draw_posts($$$$$;$)
{
    my ( $win, $list, $conf, $start, $stop, $hiline ) = @_;
    my $count = $start;
    return 0 if ( !defined $list );
    $win->getyx( my $lin, my $col );

    $win->erase;

    # If hiline is specified, this line is drawn in reverse style.
    # If not, we just use cursor's current position.
    ( !defined $hiline ) && ( $hiline = $lin );

    while ( ( $count < $conf->{'recent_posts'} ) && ( $count < $stop ) ) {
        my $draw_line = $count - $conf->{'scroll_offset'};
        last unless defined $list->[$count];

        # hilights only current line
        redraw_item( $win, $list->[$count], $draw_line, $conf->{'encoding'},
            ( $draw_line == $hiline ) );
        $count += 1;
    }
    return 1;
}

# Redraw item date + title on stdscr.
sub redraw_item($$$$;$)
{
    my ( $w, $entry, $line, $encoding, $hilight ) = @_;
    return if ( !defined $entry );
    my $title = $entry->{'title'} || 'No title';
    my $date  = $entry->{'dateCreated'};
    my $desc  = undef;

    # Format date
    if ( $entry->{'dateCreated'} =~
        /^(\d{4})(\d{2})(\d{2})T(\d{2}):(\d{2}):(\d{2})$/ )
    {
        $date = "$2-$3-$1 $4:$4";
    }

    # Make sure description only holds one line
    if ( length( $date . '  ' . $title ) > $COLS ) {
        $title =
          substr( $title, 0, $COLS - ( length( $date . '  ' ) + 6 ) ) . '...';
    }
    $desc = encode( $encoding, $date . '  ' . $title )
                || 'Badly encoded string';
    $w->attrset(A_REVERSE) if ($hilight);
    $w->addstr( $line, 0, ' ' x $COLS );
    $w->addstr( $line, 1, $desc );
    $w->attroff(A_REVERSE) if ($hilight);
}

# UI to delete post
# (if no_confirm is _set_, no confirmation is asked to delete post)
sub ui_delete_post($$$;$)
{
    my ( $win, $key, $conf, $no_confirm ) = @_;
    my $list = $conf->{'list'};
    my $cli  = $conf->{'client'};
    $win->getyx( my $lin, my $col );
    my $postid = $lin + $conf->{'scroll_offset'};

    # don't show post title if it has not one, duh.
    my $title =
      $list->[$postid]->{'title'}
      ? "'$list->[$postid]->{'title'}'"
      : 'this post';

    # Deletion confirm dialog if 'no_confirm' is not defined.
    unless ( defined($no_confirm) ) {
        return if not sure("Delete $title ?");
    }
    return 0 unless delete_post( $conf, $postid );

    # Update post list, and redraw.
    $conf->{'list'} = get_recent_posts($conf);
    return 0 unless draw_recent_posts( $win, $conf->{'list'}, $conf );
    $win->move( $lin, $col );
    return 1;
}

# Delete post without confirmation dialog
sub ui_delete_post_force($$$)
{
    my ( $win, $key, $conf ) = @_;
    return ui_delete_post( $win, $key, $conf, 1 );
}

# Multiple choice question (returns lowercased pressed-key)
sub choose($$)
{
    my ( $question, $choices ) = @_;
    defined($question) && defined($choices) or return 0;
    $choices =~ s/\B./|$&/g;
    my $key = '';
    my $win = newwin( 1, $COLS, $LINES - 2, 0 );

    $win->attrset(A_REVERSE);
    $win->addstr( 0, 0, ' ' x $COLS );
    $win->addstr( 0, 0, '* ' . $question );
    $win->attroff(A_REVERSE);
    do { $key = $win->getch(); } while ( $key !~ /$choices/i );
    $win->erase();
    $win->refresh();
    $win->delwin();
    return lc($key);
}

# Ask confirmation to a yes-no question using curses
sub sure($)
{
    my ($question) = @_;
    defined $question or return 0;
    my $key = '';
    my $win = newwin( 1, $COLS, $LINES - 2, 0 );

    $win->attrset(A_REVERSE);
    $win->addstr( 0, 0, ' ' x $COLS );
    $win->addstr( 0, 0, '* ' . $question . ' (y/n)' );
    $win->attroff(A_REVERSE);
    do { $key = $win->getch(); } while ( $key !~ /y|n/i );
    $win->erase();
    $win->refresh();
    $win->delwin();
    return ( lc($key) eq 'y' ) ? 1 : 0;
}

# Refresh post list
sub refresh_list($$$)
{
    my ( $win, $key, $conf ) = @_;

    $conf->{'list'} = get_recent_posts($conf);
    defined( $conf->{'list'} ) or return 0;
    refresh();
    $win->move( 0, 0 );
    draw_recent_posts( $win, $conf->{'list'}, $conf );
    $win->move( 0, 0 );
}

# UI for new post
sub ui_new_post($$$)
{
    my ( $win, $key, $conf ) = @_;
    my $list = $conf->{'list'};
    my $publish;
    my $content;
    $win->getyx( my $lin, my $col );

    # Edit in a temp file
    my $tmp_file = tempfile( $conf->{'temp_dir'} );
    return 0 if ( 0 != shellout( $conf->{'editor'} . ' ' . $tmp_file ) );

    # Slurp this into that
    $content = ${ slurp($tmp_file) } if ( -f $tmp_file );
    return 0 unless $content;
    unlink($tmp_file);
    $content = encode( $conf->{'encoding'}, $content )
        || cdie('Invalid content encoding.');

    # What shall we do with $content ?
    $key = choose( "New post: (C)ancel, (S)ave, (P)ublish ?", 'csp' );
    $publish = 1                       if ( $key eq 'p' );
    $publish = $conf->{'auto_publish'} if ( $key eq 's' );
    return unlink($tmp_file)           if ( $key eq 'c' );

    # Then save
    if ( new_post( $conf, $content, $publish ) ) {
        refresh_list( $win, $key, $conf );
    }
}

# UI for post edition : launch editor, and send back data
sub ui_edit_post($$$)
{
    my ( $win, $key, $conf ) = @_;
    my $list = $conf->{'list'};
    $win->getyx( my $lin, my $col );
    my $post_id = $lin + $conf->{'scroll_offset'};
    my $post    = $list->[$post_id];
    my $publish;
    my $tmp_file = tempfile( $conf->{'temp_dir'} );

    open( TMP, ">$tmp_file" ) or cdie("Temp file: $!\n");
    print TMP $post->{'title'} . "\n\n";
    print TMP $post->{'content'};
    close(TMP);

    # Edit entry
    return 0 if ( 0 != shellout( $conf->{'editor'} . ' ' . $tmp_file ) );

    # Avoid saving if unsure.
    $key = choose(
        "\"$post->{'title'}\" has changed. " . "(C)ancel, (S)ave, (P)ublish ?",
        'csp'
    );
    $publish = 1                       if ( $key eq 'p' );
    $publish = $conf->{'auto_publish'} if ( $key eq 's' );
    return unlink($tmp_file)           if ( $key eq 'c' );

    # Read temp file, then edit.
    my $new_content = ${ slurp($tmp_file) } || cdie('wtf?');
    unlink($tmp_file);
    return edit_post( $post, $conf, $new_content, $publish );
}

# Run external command (stop curses, fork, restart curses)
sub shellout($)
{
    my ($command) = @_;
    my $cmd_return = 0;
    defined $command or return 0;

    curs_set(1);
    def_prog_mode();
    endwin();
    $cmd_return = system( $command );
    refresh();
    curs_set(0);
    return $cmd_return;
}

# Resize term and redraw interface
sub ui_resize($$)
{
    my ($ui_win, $conf) = @_;

    endwin();
    refresh();
    curs_set(0);
    $ui_win = draw_ui();
    refresh();
    $conf->{'scroll_offset'} = 0;
    $ui_win->move( 0, 0 );
    draw_recent_posts( $ui_win, $conf->{'list'}, $conf );
    $ui_win->move( 0, 0 );
    return $ui_win;
}

# --------------------------------------------------------------[ Blogger API ]
# BloggerAPI: New post
sub blogger_new_post($$$)
{
    my ( $conf, $content, $publish ) = @_;

    my $resp =
      $conf->{'client'}->send_request( 'blogger.newPost', $conf->{'appkey'},
        $conf->{'blogid'}, $conf->{'login'}, $conf->{'pass'}, $content,
        $publish );
    debug("blogger.newPost:\n" . (defined $resp ? $resp->as_string : undef) ."\n");
    return ( !ref $resp || $resp->is_fault ) ? 0 : 1;
}

# BloggerAPI: Edit post
sub blogger_edit_post($$$$)
{
    my ( $conf, $id, $content, $publish ) = @_;

    my $resp = $conf->{'client'}->send_request( 'blogger.editPost',
        $conf->{'appkey'}, $id, $conf->{'login'}, $conf->{'pass'},
        $content, $publish );
    return ( !ref $resp || $resp->is_fault ) ? 0 : 1;
}

# BloggerAPI: Deletes post
sub blogger_delete_post($$)
{
    my ( $conf, $id ) = @_;

    my $resp =
      $conf->{'client'}->send_request( 'blogger.deletePost', $conf->{'appkey'},
        $id, $conf->{'login'}, $conf->{'pass'}, $conf->{'auto_publish'} );
    debug("blogger.deletePost: " . (defined $resp ? $resp : undef) ."\n");
    return ( !ref $resp || $resp->is_fault ) ? 0 : 1;
}

# BloggerAPI: Get blog ID(s)
sub blogger_get_blogid($)
{
    my ($conf) = @_;

    my $resp = $conf->{'client'}->send_request( 'blogger.getUsersBlogs',
            $conf->{'appkey'}, $conf->{'login'}, $conf->{'pass'} );
    debug("blogger.getUsersBlogs: " . (defined $resp ? $resp : undef) ."\n");
    return undef if ( !ref $resp || $resp->is_fault );
    return int $resp->value->[0]->{'blogid'};
}

# BloggerAPI: get recent posts list
sub blogger_get_recent_posts($)
{
    my ($conf) = @_;

    my $resp = $conf->{'client'}->send_request( 'blogger.getRecentPosts',
        $conf->{'appkey'}, $conf->{'blogid'}, $conf->{'login'},
        $conf->{'pass'}  , $conf->{'recent_posts'} );
    debug("blogger.getRecentPosts: " . (defined $resp ? $resp : undef) ."\n");
    return [] if ( !ref $resp || $resp->is_fault );
    return $resp->value;
}

# --------------------------------------------------------------------[ Stuff ]
# Slurp file, and returns a _ref_ to its content.
sub slurp($)
{
    my ($file) = @_;
    defined $file or return undef;
    my $content = undef;

    open( FH, $file ) or return undef;
    {
        local $/ = undef;
        $content = <FH>;
    }
    close(FH);
    return \$content;
}

# Reformat text removing text-width limitations
# Note: $text should be a ref.
sub reformat_text($)
{
    my ($text) = @_;
    defined $text or return undef;

    $$text =~ s/(.)\n([^\n])/$1 $2/g;    # Unset text width
    $$text =~ s/\r//g;                   # Remove carriage returns
    return $$text;
}

# Generate temp filename based on time && pseudo-random bits.
sub tempfile(;$)
{
    my $tmp_dir = shift || '';

    my $filename = "$tmp_dir/$APP_NAME-$ENV{'USER'}-" . time();
    srand( time ^ $$ ^ unpack "%L*", `ps axww | gzip` );
    $filename .= int( rand(42) );
}

# Try to clean curses lib before dying.
sub cdie($) {
    end_curses unless isendwin;
    die("Error: @_");
}

sub debug($) { print STDERR @_ if ($DEBUG); }

sub help {
    print <<EOF;
$APP_NAME $VERSION

usage: $APP_NAME [ -c <config file> ] [ -f <file> ]
options:
    -c, --config            specify an alternate persephonerc file
    -f, --file              send this file, then exit
    -d, --debug             debug
    -h, --help              show help

EOF
    exit(1);
}

# --------------------------------------------------------------------[ Core ]
# Command line options + config thingies
my $just_one_file;
my $is_resized;
$SIG{'WINCH'} = sub { $is_resized = 1 };

my $result = GetOptions( 'config=s'     => \$CONF_FILE,
                         'file=s'       => \$just_one_file,
                         'debug'        => \$DEBUG,
                         'help|version' => \&help );
my $conf = load_conf();

# Main UI key bindings:
#
#   Each function gets 4 arguments:
#         - $window: actual window ref.
#         - $key: pressed key
#         - $list: blog data array ref.
#         - $conf: global config ref.
my %ui_keys = (
    'a' => \&show_about,
    'j' => \&scroll_list,
    'k' => \&scroll_list,
    'q' => \&show_exit,
    'd' => \&ui_delete_post,
    'D' => \&ui_delete_post_force,
    'r' => \&refresh_list,
    'e' => \&ui_edit_post,
    'n' => \&ui_new_post,
);

# Get an XML-RPC client
$conf->{'client'} = RPC::XML::Client->new(
    $conf->{'url'},
    'useragent' => [ 'env_proxy' => 1 ],
);
$conf->{'blogid'} = get_blogid($conf)
    or die( "Can't get blog Id for user '" . $conf->{'login'} . "'.\n" );

# Just post this file if specified on command line, then exit.
if ( $just_one_file ) {
    if ( new_post( $conf, ${ slurp($just_one_file) } ) ) {
        print "Post saved. Goodbye.\n\n";
        exit(0);
    }
    cdie( "Failed saving post !\n" );
}

# Not a one-shot call, let's go curses then.
$conf->{'list'} = get_recent_posts($conf) or die("Can't get post list.\n");
start_curses();
my $list_win = draw_ui();
draw_recent_posts( $list_win, $conf->{'list'}, $conf );
refresh();
$list_win->move( 0, 0 );

# Main UI loop
my $key = undef;
while (1) {
    # SIGWINCH
    if ( $is_resized ) {
        if ( not $list_win = ui_resize($list_win, $conf) ) {
            cdie("I don't like this term.\n")
        }
        $is_resized = 0;
    }
    $key = $list_win->getch();
    ( defined $key ) or next;
    &{ $ui_keys{$key} }( $list_win, $key, $conf ) if ( exists $ui_keys{$key} );
    # Redraw interface
    touchwin() if ( is_wintouched() );
    $list_win->touchwin();
}