#!/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();
}