1# by Stefan "tommie" Tomanek
2#
3use strict;
4
5use vars qw($VERSION %IRSSI);
6$VERSION = '2017040101';
7%IRSSI = (
8    authors     => 'Stefan \'tommie\' Tomanek',
9    contact     => 'stefan@pico.ruhr.de',
10    name        => 'Newsline',
11    description => 'brings various newstickers to Irssi (Slashdot, Freshmeat, Heise etc.)',
12    license     => 'GPLv2',
13    changed     => $VERSION,
14    modules     => 'Data::Dumper XML::RSS LWP::UserAgent Unicode::String Text::Wrap',
15    depends     => 'openurl',
16    sbitems     => 'newsline_ticker',
17    commands	=> 'newsline'
18);
19
20use Irssi 20020324;
21use Irssi::TextUI;
22
23use Data::Dumper;
24use XML::RSS;
25use LWP::UserAgent;
26use POSIX;
27use Unicode::String qw(utf8 latin1);
28use Text::Wrap;
29
30use vars qw(@ticker $timestamp $slide $index $timer_cycle $timer_update %sites $forked);
31
32$index = 0;
33# Just to have some data for the first startup
34%sites = ( Heise=>{page => 'http://www.heise.de/newsticker/heise.rdf', enable => 1, title=>'', description=>'', maxnews=>0},
35           'Freshmeat'=>{'page' => 'http://freshmeat.net/backend/fm.rdf', 'enable' => 1, title=>'', description=>'', maxnews=>0}
36);
37
38sub show_help() {
39    my $help = "newsline $VERSION
40/newsline
41    List the downloaded headlines
42/newsline <number>
43    Open the entry indicated by <number> via openurl.
44    Openurl.pl is available at http://irssi.org/scripts/.
45/newsline description <number>
46    Display a brief summary of the article if available
47/newsline paste <number>
48    Write the headline and link to the current channel or query,
49    add 'description' to a diplay the description as well
50/newsline fetch
51    Retrieve new data from all enabled sources
52/newsline reload
53    Reload configuration and sites
54/newsline save
55    Save configration to ~/.irssi/newsline_sites
56/newsline list
57    List all available sources
58/newsline toggle <Source>
59    Enable or disable the source
60/newsline add <name> <url-to-rdf>
61    Add a new source
62";
63    my $text='';
64    foreach (split(/\n/, $help)) {
65	$_ =~ s/^\/(.*)$/%9\/$1%9/;
66	$text .= $_."\n";
67    }
68    print CLIENTCRAP &draw_box("Newsline", $text, "newsline help", 1);
69}
70
71sub fork_get() {
72    my ($rh, $wh);
73    pipe($rh, $wh);
74    return if $forked;
75    $forked = 1;
76    my $pid = fork();
77    if ($pid > 0) {
78	close $wh;
79	Irssi::pidwait_add($pid);
80	my $pipetag;
81	my @args = ($rh, \$pipetag);
82	$pipetag = Irssi::input_add(fileno($rh), INPUT_READ, \&pipe_input, \@args);
83    } else {
84	my (%siteinfo, @items);
85	eval {
86	    foreach (sort keys %sites) {
87		eval {
88		my $site = $sites{$_};
89		next unless $site->{'enable'};
90		my $maxnews = -1;
91		$maxnews = $site->{maxnews} if defined $site->{maxnews};
92		my $url = $site->{'page'};
93		my $ua = LWP::UserAgent->new(env_proxy=>1, keep_alive=>1, timeout=>30);
94		my $request = HTTP::Request->new('GET', $url);
95		#$request->if_modified_since($timestamp) if $timestamp;
96		my $response = $ua->request($request);
97		if ($response->is_success) {
98		    my $data = $response->content();
99		    ### FIXME I hate myself for this :)
100		    $data =~ s/encoding="ISO-8859-15"/encoding="ISO-8859-1"/i;
101		    my $rss = new XML::RSS();
102		    $rss->parse($data);
103		    my $title = $rss->{channel}->{title};
104		    my $description = de_umlaut($rss->{channel}->{description});
105		    my $link = de_umlaut($rss->{channel}->{link});
106		    $siteinfo{$_} = {title=>$title, description=>$description, link=>$link};
107		    foreach my $item (@{$rss->{items}}) {
108			next unless defined($item->{title}) && defined($item->{'link'});
109			my $title = de_umlaut($item->{title});
110			$title =~ s/\n/ /g;
111			my %story = ('title' => $title, 'link' => $item->{link}, 'source' => $_);
112			$story{description} = de_umlaut($item->{description}) if $item->{description};
113			push @items, \%story;
114			$maxnews--;
115			last if $maxnews == 0;
116		    }
117		};
118		}
119	    }
120	    my %result = (news=>\@items, siteinfo=>\%siteinfo);
121	    my $dumper = Data::Dumper->new([\%result]);
122	    $dumper->Purity(1)->Deepcopy(1);
123	    my $data = $dumper->Dump;
124	    print($wh $data);
125	};
126	close($wh);
127	POSIX::_exit(1);
128    }
129}
130
131sub pipe_input {
132    my ($rh, $pipetag) = @{$_[0]};
133    my $text;
134    $text .= $_ foreach (<$rh>);
135    close($rh);
136    Irssi::input_remove($$pipetag);
137    return unless($text);
138    no strict;
139    my %result = %{ eval "$text" };
140    my @items = @{$result{news}};
141    my %siteinfo = %{$result{siteinfo}};
142    @ticker = @items;
143    foreach (sort keys %siteinfo) {
144	$sites{$_}->{title} = $siteinfo{$_}->{title};
145	$sites{$_}->{description} = $siteinfo{$_}->{description};
146	$sites{$_}->{link} = $siteinfo{$_}->{link};
147    }
148    $forked = 0;
149}
150
151sub draw_box ($$$$) {
152    my ($title, $text, $footer, $colour) = @_;
153    my $box = '';
154    $box .= '%R,--[%n%9%U'.$title.'%U%9%R]%n'."\n";
155    foreach (split(/\n/, $text)) {
156	$box .= '%R|%n '.$_."\n";
157    }
158    $box .= '%R`--<%n'.$footer.'%R>->%n';
159    $box =~ s/%.//g unless $colour;
160    return $box;
161}
162
163sub cmd_newsline ($$$) {
164    my ($args, $server, $witem) = @_;
165    $args =~ s/^\ +//;
166    my @arg = split(/\ +/, $args);
167    if (scalar(@arg) == 0) {
168	show_ticker(@ticker);
169    } elsif ($arg[0] eq 'paste') {
170	# paste tickernews
171	shift(@arg);
172	my $desc = 0;
173	if (defined $arg[0] && $arg[0] eq 'description') {
174	    $desc = 1;
175	    shift(@arg);
176	}
177	foreach (@arg) {
178	    if (defined $ticker[$_-1]) {
179		my $message = $ticker[$_-1]->{'title'};
180		my $text = '['.$ticker[$_-1]->{'source'}.'] "'.$message.'" -> '.$ticker[$_-1]->{'link'};
181		$Text::Wrap::columns = 50;
182		my $article = wrap("","",$ticker[$_-1]->{description}) if ($desc && defined $ticker[$_-1]->{description});
183		my $text2 = draw_box($message, $article, $ticker[$_-1]->{source}, 0) if (defined $article);
184		if (($witem) and (($witem->{type} eq "CHANNEL") or ($witem->{type} eq "QUERY"))) {
185		    $witem->command("MSG ".$witem->{name}." ".$text);
186		    if (defined $text2) {
187			$witem->command("MSG ".$witem->{name}." ".$_) foreach (split /\n/, $text2);
188		    }
189		}
190	    }
191	}
192    } elsif ($arg[0] eq 'description') {
193	shift(@arg);
194	foreach (@arg) {
195	    next unless defined $ticker[$_-1] and defined $ticker[$_-1]->{description};
196	    $Text::Wrap::columns = 50;
197	    my $filter = $ticker[$_-1]->{description};
198	    $filter =~ s/<.*?>//g;
199	    my $article = wrap("", "", $filter);
200	    my $text = '';
201	    print CLIENTCRAP draw_box($ticker[$_-1]->{title}, $article, $ticker[$_-1]->{source}, 1);
202	}
203    } elsif ($arg[0] eq 'help') {
204	show_help();
205    } elsif ($arg[0] eq 'fetch') {
206	fork_get()
207    } elsif ($arg[0] eq 'reload') {
208	reload_config();
209    } elsif ($arg[0] eq 'save') {
210	save_config();
211    } elsif ($arg[0] eq 'add') {
212	if (defined($arg[1]) && defined($arg[2])) {
213	    my $source = $arg[1];
214	    my $page = $arg[2];
215	    $sites{$source} = {page => $page, enable => 1, maxnews=>0};
216	    print CLIENTCRAP '%R>>%n Added new source "'.$arg[1].'"';
217	    $timestamp = undef;
218	}
219    } elsif ($arg[0] eq 'delete') {
220	if (defined $arg[1] && defined $sites{$arg[1]}) {
221	    delete $sites{$arg[1]};
222	    print CLIENTCRAP "%R>>%n ".$arg[1]." deleted";
223	}
224    } elsif ($arg[0] eq 'toggle') {
225	# Toggle site
226	if (defined $arg[1] && defined $sites{$arg[1]}) {
227	    if ($sites{$arg[1]}{'enable'} == 0) {
228		$sites{$arg[1]}{'enable'} = 1;
229		print CLIENTCRAP "%R>>%n ".$arg[1]." enabled";
230	    } else {
231		$sites{$arg[1]}{'enable'} = 0;
232		print CLIENTCRAP "%R>>%n ".$arg[1]." disabled";
233	    }
234	}
235    } elsif ($arg[0] eq 'limit') {
236        if (defined $arg[1] && defined $sites{$arg[1]}) {
237	    if (defined $arg[2] && $arg[2] =~ /\d+/) {
238                $sites{$arg[1]}{'maxnews'} = $arg[2];
239                print CLIENTCRAP "%R>>%n ".$arg[1]." limited to ".$arg[2]." articles";
240            }
241        }
242    } elsif ($arg[0] eq 'list') {
243	my $text = "";
244	foreach (sort keys %sites) {
245	    my %site = %{$sites{$_}};
246	    $text .= "%9[".$_.']%9'."\n";
247	    $text .= " %9|-[page  ]->%9 ".$site{'page'}."\n";
248	    #$text .= " %9|-[desc  ]->%9 ".$site{'description'}."\n" if defined $site{'description'};
249	    $Text::Wrap::columns = 60;
250	    my $filter = $site{'description'};
251	    $filter =~ s/<.*?>//;
252	    my $desc = wrap(" %9|-[desc  ]->%9 ",' %9|%9<tab>', $filter);
253	    $desc =~ s/<tab>/            /g;
254	    $text .= $desc."\n" if $site{'description'};
255	    $text .= " %9|-[limit ]->%9 ".$site{'maxnews'}."\n";
256	    $text .= " %9`-[enable]->%9 ".$site{'enable'}."\n";
257	}
258	print CLIENTCRAP draw_box("Newsline", $text, "newsline sources", 1);
259
260    } else {
261	foreach (@arg) {
262	    if (defined $sites{$_}) {
263		call_openurl($sites{$_}->{'link'}) if defined $sites{$_}->{'link'};
264	    } elsif (/\d+/ && defined $ticker[$_-1]) {
265		call_openurl($ticker[$_-1]->{'link'});
266	    }
267	}
268    }
269}
270
271sub show_ticker (@) {
272    my (@ticker) = @_;
273    my $i = 1;
274    my $text = '';
275    foreach (@ticker) {
276	my $space = ' 'x(length(scalar(@ticker))-length($i));
277	my $newsitem = '%r'.$space.$i.'->%n['.$$_{source}.'] %9'.$$_{title}.'%9';
278	$newsitem .= ' %9[*]%9' if defined($$_{description});
279	$text .= $newsitem."\n";
280	$text .= "  %B`->%n%U".$$_{link}."%U \n" if Irssi::settings_get_bool('newsline_show_url');
281	$i++;
282    }
283    print CLIENTCRAP draw_box("Newsline", $text, "headlines", 1);
284}
285
286sub call_openurl ($) {
287    my ($url) = @_;
288    no strict "refs";
289    # check for a loaded openurl
290    if (my $code = Irssi::Script::openurl::->can('launch_url')) {
291        $code->($url);
292    } else {
293        print CLIENTCRAP "%R>>%n Please install openurl.pl";
294    }
295    use strict "refs";
296}
297sub newsline_ticker ($$) {
298    my ($item, $get_size_only) = @_;
299    if (Irssi::settings_get_bool('newsline_ticker_scroll')) {
300	draw_tape($item, $get_size_only);
301    } else {
302	draw_ticker($item, $get_size_only);
303    }
304}
305
306sub draw_ticker ($$) {
307    my ($item, $get_size_only) = @_;
308    if ($index >= scalar(@ticker)) {
309	$index = 0
310    }
311    my $tape;
312    $tape .= '%F%Y<Fetching>%n' if $forked;
313    if (scalar(@ticker) > 0) {
314	my $title = $ticker[$index]->{'title'};
315	my $source = $ticker[$index]->{'source'};
316	$tape .= '>'.($index+1).': ['.$source.'] '.$title;
317	$tape .= ' [*]' if defined($ticker[$index]->{description});
318	$tape .= '<';
319    } else {
320	$tape .= '>Enter "/newsline fetch" to retrieve tickerdata>' unless $forked;
321    }
322    $tape = substr($tape, 0, Irssi::settings_get_int('newsline_ticker_max_width'));
323    my $format = "{sb ".$tape."}";
324    $item->{min_size} = $item->{max_size} = length($tape)+2;
325    $item->default_handler($get_size_only, $format, 0, 1);
326}
327
328sub rotate ($$) {
329    my ($text, $rot) = @_;
330    return($text) if length($text) < 1;
331    for (0..$rot) {
332	my $letter = substr($text, 0, 1);
333	$text = substr($text, 1);
334	$text = $text.$letter;
335    }
336    return($text);
337}
338
339sub draw_tape ($$) {
340    my ($item, $get_size_only) = @_;
341    my $tape;
342    if (scalar(@ticker) > 0) {
343	my $i=1;
344	foreach (@ticker) {
345	    my $title = $_->{'title'};
346	    my $source = $_->{'source'};
347	    $tape .= '>'.($i).': ['.$source.'] '.$title.'|';
348	    $i++;
349	}
350	$tape = $tape;
351	$slide = 0 if $slide >= length($tape);
352	$tape = rotate($tape, $slide);
353	$tape = substr($tape, 0, Irssi::settings_get_int('newsline_ticker_max_width'));
354    } else {
355	$tape .= 'Use "/newsline -f" to fetch tickerdata';
356    }
357    my $format = "{sb ".$tape."}";
358    $item->{min_size} = $item->{max_size} = length($tape)+2;
359    $item->default_handler($get_size_only, $format, 0, 1);
360}
361
362sub cycle_ticker () {
363    $index++;
364    if ($index >= scalar(@ticker)) {
365	$index = 0
366    }
367    $slide++;
368    Irssi::statusbar_items_redraw('newsline_ticker');
369}
370
371sub update_ticker () {
372    fork_get();
373}
374
375sub reload_config() {
376    my $filename = Irssi::settings_get_str('newsline_sites_file');
377    my $text;
378    if (-e $filename) {
379	local *F;
380	open F, "<",$filename;
381	$text .= $_ foreach (<F>);
382	close F;
383	if ($text) {
384	    no strict;
385	    my %pages = %{ eval "$text" };
386	    if (%pages) {
387		%sites = ();
388		foreach (keys %pages) {
389		    $sites{$_} = $pages{$_};
390		}
391	    }
392	}
393    }
394    Irssi::timeout_remove($timer_cycle) if defined $timer_cycle;
395    Irssi::timeout_remove($timer_update) if defined $timer_update;
396    $timer_cycle = Irssi::timeout_add(Irssi::settings_get_int('newsline_ticker_cycle_delay'), 'cycle_ticker', undef) if Irssi::settings_get_int('newsline_ticker_cycle_delay') > 0;
397    $timer_update = Irssi::timeout_add(Irssi::settings_get_int('newsline_fetch_interval')*1000, 'update_ticker', undef) if Irssi::settings_get_int('newsline_fetch_interval') > 0;
398    Irssi::statusbar_items_redraw('newsline_ticker');
399    print CLIENTCRAP '%R>>%n Newsline sites loaded from '.$filename;
400}
401
402sub save_config() {
403    local *F;
404    my $filename = Irssi::settings_get_str('newsline_sites_file');
405    open(F, '>',$filename);
406    my $dumper = Data::Dumper->new([\%sites], ['sites']);
407    $dumper->Purity(1)->Deepcopy(1);
408    my $data = $dumper->Dump;
409    print (F $data);
410    close(F);
411    print CLIENTCRAP '%R>>%n Newsline sites saved to '.$filename;
412}
413
414sub de_umlaut ($) {
415    my ($data) = @_;
416    Unicode::String->stringify_as('utf8');
417    my $s = new Unicode::String($data);
418    my $result = $s->latin1();
419    return($result);
420}
421
422sub sig_complete_word ($$$$$) {
423    my ($list, $window, $word, $linestart, $want_space) = @_;
424    return unless $linestart =~ /^.newsline (toggle|delete|add|limit)/;
425    foreach (keys %sites) {
426	push @$list, $_ if /^(\Q$word\E.*)?$/;
427    }
428    Irssi::signal_stop();
429}
430
431Irssi::signal_add_first('complete word', \&sig_complete_word);
432Irssi::signal_add('setup saved', \&save_config);
433
434Irssi::command_bind('newsline', \&cmd_newsline);
435foreach my $cmd ('description', 'paste', 'paste description', 'fetch', 'reload', 'save', 'list', 'toggle', 'add', 'delete', 'help', 'limit') {
436    Irssi::command_bind('newsline '.$cmd =>
437	sub { cmd_newsline("$cmd ".$_[0], $_[1], $_[2]); } );
438}
439
440Irssi::settings_add_int($IRSSI{'name'}, 'newsline_fetch_interval', 600);
441
442Irssi::settings_add_int($IRSSI{'name'}, 'newsline_ticker_max_width', 50);
443
444Irssi::settings_add_int($IRSSI{'name'}, 'newsline_ticker_cycle_delay', 3000);
445Irssi::settings_add_str($IRSSI{'name'}, 'newsline_sites_file', Irssi::get_irssi_dir()."/newsline_sites");
446Irssi::settings_add_bool($IRSSI{'name'}, 'newsline_show_url', 1);
447Irssi::settings_add_bool($IRSSI{'name'}, 'newsline_ticker_scroll', 0);
448
449Irssi::statusbar_item_register('newsline_ticker', 0, 'newsline_ticker');
450
451reload_config();
452update_ticker();
453print CLIENTCRAP '%B>>%n '.$IRSSI{name}.' '.$VERSION.' loaded: /newsline help for help';
454