diff --git a/.whitesource b/.whitesource new file mode 100644 index 0000000..a1eafe2 --- /dev/null +++ b/.whitesource @@ -0,0 +1,8 @@ +{ + "generalSettings": { + "shouldScanRepo": true + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure" + } + } diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6a400c --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +pm-cb is Copyright (C) 2017-2020, E. Choroba + +PerlMonks ChatterBox Client +== + +DESCRIPTION +-- + +There are two executable programs, `pm-cb` and `pm-cb-g`. The former +doesn't implement a full chat client (you can't use it to post to the +ChatterBox) and is no longer supported. The latter is a graphical +client to PerlMonks' ChatterBox written in Perl and Tk. + +Pull requests welcome! + +PREQUISITES +-- +Install required modules using +``` +cpanm --installdeps . +``` + +If your `perl` has been compiled with thread support: + +``` +perl -MConfig -E 'say "Threads supported" if $Config{useithreads}' +``` +you can simply start the program with + +``` +perl pm-cb-g + +``` +If threads are not supported, either compile a new perl with threads enabled, +e.g. + +``` +perlbrew install perl-5.30.0 --as=5.30.0-threads -Dusethreads +perlbrew use 5.30.0-threads +``` +or run the program using `MCE::Hobo` with +``` +perl pm-cb-g --mce_hobo +``` +or with `MCE::Child` +``` +perl pm-cb-g --mce_child +``` + + +LICENSE INFORMATION +-- + +This code is free software; you can redistribute it and/or modify it +under the same terms as Perl 5.30 (see [the Perl Artistic +License](https://perldoc.pl/perlartistic) and [the GNU General Public +License, version 1](https://perldoc.pl/perlgpl)). + diff --git a/alert.xpm b/alert.xpm new file mode 100644 index 0000000..d46a71f --- /dev/null +++ b/alert.xpm @@ -0,0 +1,39 @@ +/* XPM */ +static char * pm_xpm[] = { +"32 32 4 1", +" c black", +". c red", +"X c yellow", +"o c brown", +" ", +" .............................. ", +" .............................. ", +" .............................. ", +" XXXXX......................XXX ", +" XXXXXX....................XXXX ", +" ....XXX..................XX... ", +" ......X..................X.... ", +" ......XX................XX.... ", +" ......XX................X..... ", +" ...X...XX..............XX..... ", +" ..XXX..XX............XXX...X.. ", +" ...X...XX...........XX....XoX. ", +" ........XX.........XX......XX. ", +" ........XX.........XXXX....... ", +" .........XX...........X....... ", +" .........XX...........XX...... ", +" ......XXXX............XXXX.... ", +" .....XX..................XX... ", +" ..XXXX....................XX.. ", +" .XX.......................XX.. ", +" ..XXXX...................XX... ", +" .....XXX.............XXXXX.... ", +" ......XX.............XX....... ", +" ..X..XX...............XXX..... ", +" XXXX.XX.................XXX... ", +" XX..XX....................XXX. ", +" X...........................XX ", +" .............................X ", +" .............................X ", +" .............................. ", +" "}; diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000..9fde648 --- /dev/null +++ b/cpanfile @@ -0,0 +1,31 @@ +use Config; + +# The MCE 1.841 distribution includes MCE::Channel and MCE::Child. +# Thus, suggesting or requiring MCE::Child will pick up minimally MCE 1.841. +# MCE::Hobo is included with MCE::Shared. + +on build => sub { + if ($Config{usethreads}) { + suggests 'MCE::Child'; + suggests 'MCE::Hobo'; + } else { + requires 'MCE::Child'; + requires 'MCE::Hobo'; + } + suggests 'MCE::Child'; + suggests 'MCE::Channel'; +}; + +requires 'FindBin'; +requires 'Getopt::Long'; +requires 'Pod::Usage'; +requires 'Time::HiRes'; +requires 'Time::Piece'; +requires 'charnames'; + +requires 'List::Util'; +requires 'LWP::Protocol::https'; +requires 'Syntax::Construct'; +requires 'Tk'; +requires 'WWW::Mechanize'; +requires 'XML::LibXML'; diff --git a/lib/PM/CB/Common.pm b/lib/PM/CB/Common.pm new file mode 100644 index 0000000..b93c778 --- /dev/null +++ b/lib/PM/CB/Common.pm @@ -0,0 +1,16 @@ +package PM::CB::Common; + +use warnings; +use strict; + +use Exporter qw{ import }; +our @EXPORT_OK = qw{ to_entities }; + +sub to_entities { + my ($message) = @_; + $message =~ s/(.)/ord $1 > 127 ? '&#' . ord($1) . ';' : $1/ge; + return $message +} + + +__PACKAGE__ diff --git a/lib/PM/CB/Communication.pm b/lib/PM/CB/Communication.pm index 5013353..36e8cf5 100644 --- a/lib/PM/CB/Communication.pm +++ b/lib/PM/CB/Communication.pm @@ -3,50 +3,61 @@ package PM::CB::Communication; use warnings; use strict; +use Encode; +use Time::HiRes; +use WWW::Mechanize; +use XML::LibXML; +use PM::CB::Common qw{ to_entities }; + use constant { - FREQ => 7, - REPEAT_THRESHOLD => 3, + FREQ => 10, + REPEAT_THRESHOLD => 5, + NOT_DELETABLE => 0, # Node ids: LOGIN => 109, CB => 207304, + DELETE => 50772, SEND => 227820, PRIVATE => 15848, MONKLIST => 15851, + SHORTCUT => 11136513, + RANDOM_SHORT => 3193, }; sub new { - $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME} = 0; my ($class, $struct) = @_; bless $struct, $class } -sub url { "https://$_[0]{pm_url}/bare/?node_id=" } +sub url { "https://$_[0]{pm_url}/bare/index.pl?node_id=" } sub communicate { my ($self) = @_; - require XML::LibXML; - require WWW::Mechanize; - require Time::HiRes; - my $mech = $self->{mech} = - 'WWW::Mechanize'->new( timeout => 16, autocheck => 0 ); + my $mech = $self->{mech} + = 'WWW::Mechanize'->new( + timeout => 120, + autocheck => 0, + ssl_opts => $self->ssl_opts); my ($from_id, $previous, %seen); my $last_update = -1; my ($message, $command); my %dispatch = ( - login => sub { $self->login(@$message) + login => sub { $self->login(@$message) or $self->{to_gui}->enqueue(['login']) }, - send => sub { $message->[0] =~ tr/\x00-\x20/ /s; - $self->send_message($message->[0]) }, - title => sub { $self->get_title(@$message) }, - url => sub { $self->handle_url(@$message) }, - list => sub { $self->get_monklist }, - quit => sub { no warnings 'exiting'; last }, + send => sub { $message->[0] =~ tr/\x00-\x20/ /s; + $self->send_message($message->[0]) }, + title => sub { $self->get_title(@$message) }, + shortcut => sub { $self->get_shortcut(@$message) }, + deletemsg => sub { $self->delete_msg(@$message) }, + url => sub { $self->handle_url(@$message) }, + list => sub { $self->get_monklist }, + quit => sub { no warnings 'exiting'; last }, ); while (1) { @@ -91,14 +102,14 @@ sub communicate { $previous = $xml; } else { - warn $@; + warn "PMCB: $@"; } } my @private = $self->get_all_private(\%seen); for my $msg (@private) { $self->{to_gui}->enqueue([ - private => @$msg{qw{ author time text }} + private => @$msg{qw{ author time text id }} ]) unless exists $seen{"p$msg->{id}"}; undef $seen{"p$msg->{id}"}; } @@ -116,11 +127,10 @@ sub get_monklist { $self->get_monklist($repeat + 1); } else { - warn "Can't get monklist.\n"; + warn "PMCB: Can't get monklist.\n"; } return } - require XML::LibXML; my $dom; eval { $dom = 'XML::LibXML'->load_xml(string => $self->mech_content); @@ -134,6 +144,7 @@ sub handle_url { my ($self, @message) = @_; if (@message && $message[0] ne $self->{pm_url}) { $self->{pm_url} = $message[0]; + $self->{mech}->ssl_opts(%{ $self->ssl_opts }); $self->{to_gui}->enqueue(['send_login']); } else { $self->{to_gui}->enqueue(['url', $self->{pm_url}]); @@ -158,19 +169,54 @@ sub handle_url { return } - require XML::LibXML; + # Only use the surrogate title when the page works but has no + # title. Locked users, for example, don't return anything, so + # surrogate title isn't used. my $dom; eval { - $dom = 'XML::LibXML'->load_xml(string => $self->mech_content) + $dom = 'XML::LibXML'->load_xml(string => $self->mech_content); } or return; - $titles{$id} = $title = $dom->findvalue('/node/@title'); + $title = $dom->findvalue('/node/@title'); + $title = "untitled node, ID $id" unless length $title; + $titles{$id} = $title; } + $self->{to_gui}->enqueue(['title', $id, $name, $title]); } } +{ my %link; + sub get_shortcut { + my ($self, $shortcut, $title, $repeat) = @_; + my $url = $link{$shortcut}; + unless (defined $url) { + my $response; + eval { + $response = $self->{mech}->get($self->url . SHORTCUT + . ";link=$shortcut"); + }; + if (! $response || $response->is_error) { + $repeat //= 0; + $self->get_shortcut($shortcut, $repeat + 1) + unless $repeat > REPEAT_THRESHOLD; + return + } + + my $dom; + eval { + $dom = 'XML::LibXML'->load_xml(string => $self->mech_content) + } or return; + + $link{$shortcut} = $url + = $dom->findvalue('/links/link/url') =~ s/\n//r; + } + $self->{to_gui}->enqueue(['shortcut', $shortcut, $url, $title]); + } +} + + sub login { my ($self, $username, $password) = @_; my $response = $self->{mech}->get($self->url . LOGIN); @@ -191,8 +237,7 @@ sub send_message { my ($self, $message, $repeat) = @_; return unless length $message; - ( my $msg = $message ) - =~ s/(.)/ord $1 > 127 ? '&#' . ord($1) . ';' : $1/ge; + my $msg = to_entities($message); my $response; eval { $response = $self->{mech}->post( $self->url . SEND, @@ -211,9 +256,30 @@ sub send_message { return } my $content = $response->content; - return if $content =~ /^Chatter accepted/; + if ($content =~ /^Chatter accepted/) { + if ($message =~ m{^/msg\s+(\S+)\s+(.*)}) { + $self->{to_gui}->enqueue( + [private => "-> $1", undef, $2, NOT_DELETABLE]); + } + return + } + + $self->{to_gui}->enqueue( + [private => '', undef, $content, NOT_DELETABLE]); +} - $self->{to_gui}->enqueue([ private => '', undef, $content ]); + +sub delete_msg { + my ($self, $id) = @_; + my $response; + eval { $response = $self->{mech}->post( + $self->url . DELETE, + Content => { op => 'message', + node => RANDOM_SHORT, + "deletemsg_$id" => 'yup', + perlisgood => 'delete'})}; + $self->{to_gui}->enqueue(['delete', $id]) + if $response && $response->is_success; } @@ -227,7 +293,7 @@ sub get_all_private { while (1) { my $response; eval { $response = $self->{mech}->get($url) }; - next unless $response && $response->is_success; + last unless $response && $response->is_success; my $content = $self->mech_content; last unless $content =~ /url . PRIVATE . "&prior_to=$first"; } + @private = sort { $a->{time} cmp $b->{time} } @private; return @private } sub mech_content { my ($self) = @_; - my $content = $self->{mech}->content; # libxml respects encoding, but mech returns the page in unicode, # not windows-1252. + my $content = Encode::encode('UTF-8', $self->{mech}->content); $content =~ s/windows-1252/utf-8/i; return $content } +sub ssl_opts { + {verify_hostname => $_[0]->is_url_verifiable ? 1 : 0} +} + + +sub is_url_verifiable { + $_[0]{pm_url} =~ /^(?:www\.)?perlmonks\.(?:com|net|org)$/ +} + + __PACKAGE__ diff --git a/lib/PM/CB/Control.pm b/lib/PM/CB/Control.pm index 99e4721..3bd1841 100644 --- a/lib/PM/CB/Control.pm +++ b/lib/PM/CB/Control.pm @@ -15,7 +15,10 @@ sub new { sub start_comm { my ($self) = @_; $self->{communicate_t} = $self->{worker_class}->create(sub { - my $communication = PM::CB::Communication->new({ + if ($INC{'threads.pm'}) { + $SIG{QUIT} = sub { threads->exit }; + } + my $communication = 'PM::CB::Communication'->new({ to_gui => $self->{to_gui}, from_gui => $self->{to_comm}, pm_url => $self->{pm_url}, @@ -42,9 +45,11 @@ sub start_comm { $self->{to_comm}->enqueue(['url']); }; } - $self->{to_comm}->insert(0, ['quit']); - $self->{communicate_t}->join; - $self->{to_gui}->insert(0, ['quit']); + if ($^O ne 'MSWin32' || ! $INC{'MCE/Util.pm'}) { + $self->{communicate_t}->kill('QUIT'); + $self->{communicate_t}->join; + } + $self->{to_gui}->enqueue(['quit']); } @@ -57,7 +62,7 @@ sub heartbeat { my ($self) = @_; unless ($self->{communicate_t}->is_running) { - warn "Restarting worker...\n"; + warn "PMCB: Restarting worker...\n"; eval { $self->{communicate_t}->join }; $self->start_comm; $self->{to_gui}->enqueue(['send_login']); diff --git a/lib/PM/CB/GUI.pm b/lib/PM/CB/GUI.pm index f6d77c2..1170967 100644 --- a/lib/PM/CB/GUI.pm +++ b/lib/PM/CB/GUI.pm @@ -2,15 +2,20 @@ package PM::CB::GUI; use warnings; use strict; -use Syntax::Construct qw{ // }; +use charnames (); +use Time::Piece; +use List::Util qw{ shuffle }; +use PM::CB::Common qw{ to_entities }; use constant { - TITLE => 'PM::CB::G', - PUBLIC => 0, - PRIVATE => 1, - GESTURE => 2, - HISTORY_SIZE => 100, + TITLE => 'PM::CB::G', + PUBLIC => 0, + PRIVATE => 1, + GESTURE => 2, + REASK_THRESHOLD => 10, + HISTORY_SIZE => 100, + CHAR_LIMIT => 255, }; @@ -21,9 +26,10 @@ sub new { sub url { - my ($self, $url) = @_; + my ($self, $url, $part) = @_; $url //= '__PM_CB_URL__'; - $url =~ s{__PM_CB_URL__}{https://$self->{browse_url}/?node=}; + $part //= "?node="; + $url =~ s{__PM_CB_URL__}{https://$self->{browse_url}/index.pl$part}; return $url } @@ -31,7 +37,6 @@ sub url { sub gui { my ($self) = @_; - require Time::Piece; my $tzoffset = Time::Piece::localtime()->tzoffset; $self->{last_date} = q(); @@ -43,7 +48,12 @@ sub gui { $self->{mw} = my $mw = 'MainWindow'->new(-title => TITLE); $mw->protocol(WM_DELETE_WINDOW => sub { $self->quit }); + $mw->geometry($self->{geometry}) if $self->{geometry}; $mw->optionAdd('*font', "$self->{font_name} $self->{char_size}"); + $self->{icon} = $mw->Pixmap(-file => "$self->{icon_path}/pm.xpm"); + $self->{icon_alert} = $mw->Pixmap(-file => "$self->{icon_path}/alert.xpm"); + $mw->iconimage($self->{icon}); + $mw->iconname('normal'); my $read_f = $mw->Frame->pack(-expand => 1, -fill => 'both'); $self->{read} = my $read @@ -67,17 +77,38 @@ sub gui { my $write_f = $mw->Frame->pack(-fill => 'x'); $self->{write} = my $write = $write_f->Text( - -height => 3, - -background => $self->{bg_color}, - -foreground => $self->{fg_color}, - -wrap => 'word', + -height => 3, + -background => $self->{bg_color}, + -foreground => $self->{fg_color}, + -insertbackground => $self->{fg_color}, + -wrap => 'word', )->pack(-fill => 'x'); + $self->{write}->bind('<>', sub { + return unless $self->{write}->editModified; + + $self->{write}->editModified(0); + + # We can't check the length immediately, because when deleting + # characters, the old length is returned. + $mw->after(10, sub { + if (CHAR_LIMIT < length to_entities($self->{write}->Contents)) { + $self->{write}->configure(-foreground => $self->{warn_color}) + unless $self->{write}->cget('-foreground') + eq $self->{warn_color}; + } else { + $self->{write}->configure(-foreground => $self->{fg_color}) + if $self->{write}->cget('-foreground') + eq $self->{warn_color}; + } + }); + }); - $write->bind('', sub { + my $cb_paste = sub { my $paste = eval { $write->SelectionGet } // eval { $write->SelectionGet(-selection => 'CLIPBOARD') }; $write->insert('insert', $paste) if length $paste; - }); + }; + $write->bind($_, $cb_paste) for split m/\s+/ => $self->{paste_keys}; my $button_f = $mw->Frame->pack; my $send_b = $button_f->Button(-text => 'Send', @@ -116,7 +147,8 @@ sub gui { )->pack(-side => 'left'); $mw->bind('', sub { $list_b->invoke }); - my $help_b = $self->{opt_h} = $button_f->Button(-text => 'Help', + my $help_b = $self->{opt_h} = $button_f->Button( + -text => 'Help', -command => sub { $self->help }, -underline => 0, )->pack(-side => 'left'); @@ -152,19 +184,17 @@ sub gui { ); }); - my ($username, $password); - $mw->repeat(1000, sub { my $msg; my %dispatch = ( time => sub { $self->update_time($msg->[0], $tzoffset, $msg->[1]) }, login => sub { $self->login_dialog }, - chat => sub { $self->show_message($tzoffset, @$msg); - $self->increment_unread; }, - private => sub { $self->show_private(@$msg, $tzoffset); - $self->increment_unread; }, + chat => sub { $self->show_message($tzoffset, @$msg) }, + private => sub { $self->show_private(@$msg, $tzoffset) }, + delete => sub { $self->deleted(@$msg) }, title => sub { $self->show_title(@$msg) }, + shortcut => sub { $self->show_shortcut(@$msg) }, send_login => sub { $self->send_login }, url => sub { $self->{pm_url} = $msg->[0] }, list => sub { $self->show_list(@$msg) }, @@ -175,8 +205,60 @@ sub gui { my $type = shift @$msg; $dispatch{$type}->(); } + if ($self->{alert} && defined $self->{mw}->focusCurrent) { + delete $self->{alert}; + $self->blink_icon(1); + } + $self->blink_icon if $self->{alert}; }); + $mw->repeat(10_000, sub { + # Ask just one not to overload the server. + if (my $id = (shuffle(keys %{ $self->{ids} }))[0]) { + warn "PMCB: Reasking id $id"; + $self->ask_title($id, $self->{ids}{$id}{name}); + if (++$self->{ids}{$id}{count} > REASK_THRESHOLD) { + warn "PMCB: Asked 10 times for $id ($self->{ids}{$id}{name})"; + $self->show_title( + $id, $self->{ids}{$id}{name}, $self->{ids}{$id}{name}); + } + } + + if (my $shortcut = (shuffle(keys %{ $self->{shortcuts} }))[0]) { + warn "PMCB: Reasking shortcut $shortcut"; + $self->ask_shortcut($shortcut, + $self->{shortcuts}{$shortcut}{title}); + if (++$self->{shortcuts}{$shortcut}{count} > REASK_THRESHOLD) { + warn "PMCB: Asked 10 times for $shortcut " + . "($self->{shortcuts}{$shortcut}{title})"; + $self->show_shortcut( + $shortcut, $self->{shortcuts}{$shortcut}{title}, + $self->{shortcuts}{$shortcut}{title}); + } + } + }); + + if (my $hf = $self->{history_file}) { + $hf =~ s/~/$ENV{HOME}/; + if (open my $fh, '<:encoding(utf-8)', $hf) { + local $/ = "\x{2028}"; + chomp (my @hist = <$fh>); + my $hl = $self->{history_size} || 0; + $hl > 0 && @hist > $hl and splice @hist, 0, $#hist - $hl; + my $text = $self->{read}; + for (@hist) { + my ($time, $author, $msg) = split m/\x{2063}/ => $_; + $text->insert(end => "$time$author$msg", ['seen']); + } + $self->{read}->see('end'); + } + + if (open my $fh, '>>:encoding(utf-8)', $hf) { + select((select($fh), $| = 1)[0]); + $self->{log_fh} = $fh; + } + } + $mw->after(1, sub { $self->login_dialog; $self->{write}->focus; }); Tk::MainLoop(); @@ -221,7 +303,10 @@ sub show_options { [ 'Gesture Color' => 'gesture_color' ], [ 'Timestamp Color' => 'time_color' ], [ 'Seen Color' => 'seen_color' ], + [ 'Warn Color' => 'warn_color' ], [ 'Browser URL' => 'browse_url' ], + [ 'Copy Link' => 'copy_link' ], + [ 'Paste keys' => 'paste_keys' ], ); my $new; @@ -233,22 +318,33 @@ sub show_options { )->pack(-side => 'right'); } - my $old_url = $self->{pm_url} // q(); + my $old_pm_url = $self->{pm_url} // q(); my $old_random = $self->{random_url}; + my $new_random = $old_random; my $f = $opt_f->Frame->pack(-fill => 'x'); $f->Label(-text => 'PerlMonks URL')->pack(-side => 'left'); my $e; $f->Checkbutton( - -variable => \$self->{random_url}, + -variable => \$new_random, -text => 'Random', -command => sub { - $e->configure(-state => $self->{random_url} + $e->configure(-state => $new_random ? 'disabled' : 'normal' ) } )->pack(-side => 'left'); - $e = $f->Entry(-textvariable => \$self->{pm_url}, - -state => $self->{random_url} ? 'disabled' : 'normal') + $e = $f->Entry(-textvariable => \ my $new_pm_url, + -state => $new_random ? 'disabled' : 'normal') ->pack(-side => 'right'); + my $wait_for_url; + $wait_for_url = $self->{mw}->repeat(250, sub { + if (defined $self->{pm_url}) { + $wait_for_url->cancel; + $old_pm_url = $self->{pm_url} + if "" eq ($old_pm_url // 'closed too quickly'); + $new_pm_url = $old_pm_url + if "" eq ($new_pm_url // ""); + } + }); my $time_f = $opt_f->Frame->pack(-fill => 'x'); $opt_f->Label(-text => 'Show Timestamps')->pack(-side => 'left'); @@ -257,16 +353,25 @@ sub show_options { my $info_f = $opt_w->Frame(-relief => 'groove', -borderwidth => 2) ->pack(-padx => 5, -pady => 5); + + my @version; + $info_f->Label( -justify => 'left', -text => join "\n", 'Threading model:', - ($self->{mce} ? ('MCE::Hobo ' . $MCE::Hobo::VERSION, - 'MCE::Shared ' . $MCE::Shared::VERSION) - : ('threads ' . $threads::VERSION, - 'Thread::Queue ' . $Thread::Queue::VERSION) + ( $self->{mce} && $self->{mce}{hobo} + ? (' MCE::Hobo ' . $MCE::Hobo::VERSION, + ' MCE::Shared ' . $MCE::Shared::VERSION) + : $self->{mce} && $self->{mce}{child} + ? (' MCE::Child ' . $MCE::Child::VERSION, + ' MCE::Channel ' . $MCE::Channel::VERSION) + : (' threads ' . $threads::VERSION, + ' Thread::Queue ' . $Thread::Queue::VERSION) ), - 'Stack size: ' . 2 ** $self->{stack_size} + (' Stack size: ' . 2 ** $self->{stack_size}) x ! $self->{mce}, + 'Geometry: ' . $self->{mw}->geometry, + $self->{log_fh} ? 'Log file: ' . $self->{history_file} : () )->pack(-side => 'left', -padx => 5); my $button_f = $opt_w->Frame->pack(-padx => 5, -pady => 5); @@ -274,7 +379,9 @@ sub show_options { -text => 'Apply', -underline => 0, -command => sub{ - $new->{pm_url} = $self->{pm_url} if $self->{pm_url} ne $old_url; + $new->{random_url} = $new_random if $new_random != $old_random; + $new->{pm_url} = $new_pm_url + if length $new_pm_url && $old_pm_url ne $new_pm_url; $self->update_options($show_time, $new); $opt_w->destroy; $self->{opt_b}->configure(-state => 'normal'); @@ -285,8 +392,6 @@ sub show_options { my $cancel_b = $button_f->Button( -text => 'Cancel', -command => my $cancel_s = sub { - $self->{pm_url} = $old_url; - $self->{random_url} = $old_random; $opt_w->destroy; $self->{opt_b}->configure(-state => 'normal'); }, @@ -299,12 +404,32 @@ sub show_options { sub update_options { my ($self, $show_time, $new) = @_; - my $old_url = $self->{pm_url}; + my %old = (pm_url => $self->{pm_url}, + random_url => $self->{random_url}, + fg_color => $self->{fg_color}, + warn_color => $self->{warn_color}, + map {($_ => [ split m/\s+/ => $self->{$_} ])} + qw( copy_link paste_keys )); for my $opt (keys %$new) { $self->{$opt} = $new->{$opt} if ! exists $self->{$opt} || $self->{$opt} ne $new->{$opt}; } + for my $tag (grep /^browse:/, $self->{read}->tagNames) { + for my $old_event (@{ $old{copy_link} }) { + my $binding = $self->{read}->tagBind($tag, $old_event); + $self->{read}->tagBind($tag, $old_event, ""); + $self->{read}->tagBind($tag, $_, $binding) + for split m/\s+/ => $self->{copy_link}; + } + } + for my $old_event (@{ $old{paste_keys} }) { + my $binding = $self->{write}->bind($old_event); + $self->{write}->bind($old_event, ""); + $self->{write}->bind($_, $binding) + for split m/\s+/ => $self->{paste_keys}; + } + $self->{mw}->optionAdd('*font', "$self->{font_name} $self->{char_size}"); for my $part (qw( read write last_update )) { $self->{$part}->configure( @@ -324,15 +449,26 @@ sub update_options { $self->{no_time} = ! $show_time; $self->{to_control}->enqueue(['random_url', $self->{random_url}]); - if ($old_url ne $self->{pm_url}) { + if (($old{pm_url} // "") ne ($self->{pm_url} // "")) { $self->{to_comm}->enqueue(['url', $self->{pm_url}]); $self->send_login; } + + if ($self->{warn_color} eq $self->{fg_color}) { + $self->{warn_color} + = ($old{warn_color} eq $self->{warn_color}) + ? $old{fg_color} : $old{warn_color}; + } + + if ($self->{warn_color} ne $old{warn_color}) { + $self->{write}->editModified(1); + } } sub show_title { my ($self, $id, $name, $title) = @_; + delete $self->{ids}{$id}; my $tag = "browse:$id|$name"; my ($from, $to) = ('1.0'); while (($from, $to) = $self->{read}->tagNextrange($tag, $from)) { @@ -343,6 +479,24 @@ sub show_title { } +sub show_shortcut { + my ($self, $shortcut, $url, $title) = @_; + $url = "https://$self->{browse_url}/index.pl?node=$shortcut" + if 0 == length $url; + delete $self->{shortcuts}{$shortcut}; + my $old_tag = "shortcut:$shortcut|$title"; + my $new_tag = "browse:$url|$title"; + my ($from, $to) = ('1.0'); + while (($from, $to) = $self->{read}->tagNextrange($old_tag, $from)) { + $self->{read}->tagRemove("[$old_tag]", $from, $to); + $self->{read}->delete($from, $to); + + $self->add_clickable($title, $new_tag, $from, $url); + $from = $to; + } +} + + sub save { my ($self) = @_; my $file = $self->{mw}->getSaveFile(-title => 'Save the history to a file'); @@ -378,75 +532,122 @@ sub seen { $self->{read}->tagRemove('unseen', $from, $to); $self->{read}->tagAdd('seen', $from, $to); } + $self->{last_update}->configure(-foreground => $self->{seen_color}); $self->{mw}->configure(-title => TITLE); } sub decode { - require charnames; my ($msg) = @_; $msg =~ s/&#(x?)([0-9a-f]+);/$1 ? chr hex $2 : chr $2/gei; - $msg =~ s/([^\0-\x{FFFF}])/ - "\x{2997}" . charnames::viacode(ord $1) . "\x{2998}"/ge - if 'MSWin32' eq $^O; + $msg =~ s{([^\0-\x{FFFF}])}{ + "\x{2997}" + . (charnames::viacode(ord $1) + // sprintf 'U+%X', ord $1) + . "\x{2998}"}ge + if grep $_ eq $^O, qw( MSWin32 darwin ); return $msg } sub show { - my ($self, $timestamp, $author, $message, $type) = @_; + my ($self, $timestamp, $author, $message, $type, $id) = @_; my $text = $self->{read}; - $text->insert(end => "<$timestamp> ", ['time']) unless $self->{no_time}; + $text->insert(end => $timestamp, ['time']) unless $self->{no_time}; my $author_separator = $type == GESTURE ? "" : ': '; - $text->insert(end => "[$author]$author_separator", - { (PRIVATE) => 'private', - (PUBLIC) => 'author', - (GESTURE) => 'gesture' }->{$type}); + my $s_author = sprintf ($self->{author_format}, $author) . $author_separator; + $text->insert(end => $s_author, + { (PRIVATE) => ['private', + $id ? ("msg_$author", "deletemsg_$id") : ()], + (PUBLIC) => ['author', "mention_$author"], + (GESTURE) => ['gesture', "mention_$author"] }->{$type}); + if ($id) { + $self->{read}->tagBind("deletemsg_$id", '', + sub { $self->{to_comm}->enqueue(['deletemsg', $id]) }); + $self->{read}->tagBind("msg_$author", '', + sub { $self->{write}->insert('1.0' => "/msg $author ") }) + unless $self->{read}->tagBind("msg_$author"); + } else { + $self->{read}->tagBind("mention_$author", '', + sub { $self->{write}->insert('1.0' => "[$author]: ") }) + unless $self->{read}->tagBind("mention_$author"); + } my ($line, $column) = split /\./, $text->index('end'); --$line; - $column += (3 + length($timestamp)) * ! $self->{no_time} + 2 - + length($author_separator) + length $author; + $column += length($timestamp) * ! $self->{no_time} + length $s_author; $text->insert(end => "$message\n", ['unseen']); + my $lh = $self->{log_fh}; + $lh and $lh->printflush(join "\x{2063}" => $timestamp, $s_author, $message =~ s/\n*\z/\n\x{2028}/r); + + $self->{alert} = 1 if $id || $message =~ /$self->{username}/i; my $fix_length = 0; - while ($message =~ m{\[(\s*(?: + my $start_pos = 0; + while ($message =~ m{ + (.*?(?=($|))) # Non-greedy up to or end of line + ( + ($ | # followed by end of line + <(c|code)> # or or + .*? # some stuff + # and or as per above + ) + )? + }gx) { + my $not_code = $1; + while ($not_code =~ m{\[(\s*(?: https? | (?:meta)?mod | doc - | id - | wp - )://.+?\s*|\S+)\]}gx - ) { - my $orig = $1; - my ($url, $name) = split /\|/, $orig; - my $from = $line . '.' - . ($column + pos($message) - - length(length $name ? "[$url|$name]" : "[$url]") - - $fix_length); - my $to = $line . '.' . ($column - $fix_length + pos $message); - $text->delete($from, $to); - - $name = $url unless length $name; - s/^\s+//, s/\s+$// for $name, $url; - $url =~ s{^(?:(?:meta)?mod|doc)://}{http://p3rl.org/}; - $url =~ s{^wp://}{https://en.wikipedia.org/wiki/}; - - my $tag = "browse:$url|$name"; - - if ($url =~ m{^id://([0-9]+)}) { - my $id = $1; - $self->ask_title($id, $url) if $name eq $url; - $url = '__PM_CB_URL__' . $id; - $tag = "browse:$id|$name"; - } elsif ($url eq $orig) { - substr $url, 0, 0, '__PM_CB_URL__'; - $tag = "browse:$url|$name"; + | id | node | href + | pad + )://.+?\s*|[^\]]+)\]}gix + ) { + my $orig = $1; + my ($url, $name) = split /\|/, $orig; + my $pos = $start_pos + pos $not_code; + my $from = $line . '.' + . ($column + $pos + - length(length $name ? "[$url|$name]" : "[$url]") + - $fix_length); + my $to = $line . '.' . ($column - $fix_length + $pos); + $text->delete($from, $to); + + $name = $url unless length $name; + s/^\s+//, s/\s+$// for $name, $url; + $url =~ s{^(?:(?:meta)?mod|doc)://}{http://p3rl.org/}i; + $url =~ s{^pad://([^|\]]*)} + {length $1 + ? $self->url("__PM_CB_URL__$1's+scratchpad") + : $self->url("__PM_CB_URL__$author\'s+scratchpad")}ie; + $url =~ s{^href://}{ $self->url("__PM_CB_URL__", "") }ie; + $url =~ s{^node://}{ $self->url("__PM_CB_URL__") }ie; + + my $tag = "browse:$url|$name"; + + if ($url =~ m{^id://([0-9]+)}i) { + my $id = $1; + $self->ask_title($id, $url) if $name eq $url; + $url =~ s{^id://[0-9]+}{ $self->url("__PM_CB_URL__$id", '?node_id=') }ie; + $tag = "browse:$id|$name"; + + } elsif ($url =~ m{://} && $url !~ m{^https?://}i + && $orig =~ /^\Q$url\E\|?/ + ) { + $name =~ s{^.+?://}{} if $name eq $url; + $self->ask_shortcut($url, $name); + $tag = "shortcut:$url|$name"; + + } else { + substr $url, 0, 0, '__PM_CB_URL__' unless $url =~ m{^https?://}i; + $tag = "browse:$url|$name"; + } + + $fix_length += length($orig) - length($name); + + $self->add_clickable($name, $tag, $from, $url); } - - $fix_length += length($orig) - length($name); - - $self->add_clickable($name, $tag, $from, $url); + $start_pos = pos $message; } $text->see('end'); } @@ -467,8 +668,17 @@ sub add_clickable { sub { $self->{balloon}->detach($text) }); $text->tagBind($tag, '', sub { browse($self->url($url)) }); - $text->tagBind($tag, '', - sub { $text->clipboardAppend($self->url($url)) }); + $text->tagBind($tag, $_, + sub { $text->clipboardClear; + $text->clipboardAppend($self->url($url)) }) + for split m/\s+/ => $self->{copy_link}; +} + + +sub deleted { + my ($self, $id) = @_; + $self->{read}->tagConfigure("deletemsg_$id" => -overstrike => 1); + $self->{read}->tagBind("deletemsg_$id", '', undef); } @@ -481,15 +691,24 @@ sub show_list { $self->url("__PM_CB_URL__$monk")); } $self->{read}->insert('end', "\n"); + $self->{read}->see('end'); } sub ask_title { my ($self, $id, $name) = @_; + $self->{ids}{$id}{name} = $name; $self->{to_comm}->enqueue(['title', $id, $name]); } +sub ask_shortcut { + my ($self, $shortcut, $title) = @_; + $self->{shortcuts}{$shortcut}{title} = $title; + $self->{to_comm}->enqueue(['shortcut', $shortcut, $title]); +} + + sub browse { my ($url) = @_; my $action = { @@ -510,11 +729,12 @@ sub show_message { substr $timestamp, 0, 11, q() if 0 == index $timestamp, $self->{last_date}; $self->show($timestamp, $author, $message, $type); + $self->increment_unread; } sub show_private { - my ($self, $author, $time, $msg, $tzoffset) = @_; + my ($self, $author, $time, $msg, $id, $tzoffset) = @_; $msg = decode($msg); $msg =~ s/[\n\r]//g; @@ -526,9 +746,10 @@ sub show_private { } else { $time = Time::Piece::localtime(); } - $time = $time->strftime('%Y-%m-%d %H:%M:%S'); + $time = $time->strftime('%Y-%m-%d %H:%M:%S '); - $self->show($time, $author, $msg, PRIVATE); + $self->show($time, $author, $msg, PRIVATE, $id); + $self->increment_unread if $id; } @@ -546,7 +767,8 @@ sub update_time { my $local_time = convert_time($server_time, $tzoffset); $self->{last_update}->configure( -text => 'Last update: ' - . $local_time->strftime('%Y-%m-%d %H:%M:%S')); + . $local_time->strftime('%Y-%m-%d %H:%M:%S'), + -foreground => 'black'); $self->{last_date} = $local_time->strftime('%Y-%m-%d') if $should_update; } @@ -554,12 +776,20 @@ sub update_time { { my ($login, $password); sub send_login { my ($self) = @_; + $self->{username} = $login; $self->{to_comm}->enqueue([ 'login', $login, $password ]); } + sub login_dialog { my ($self) = @_; + if ($self->{username} && $self->{password}) { + ($login, $password) = ($self->{username}, $self->{password}); + $self->send_login; + return; + } + my $dialog = $self->{mw}->Dialog( -title => 'Login', -default_button => 'Login', @@ -574,7 +804,8 @@ sub update_time { my $password_f = $dialog->Frame->pack(-fill => 'both'); $password_f->Label(-text => 'Password: ') ->pack(-side => 'left', -fill => 'x'); - my $password_e = $password_f->Entry(-show => '*')->pack(-side => 'right'); + my $password_e = $password_f->Entry(-show => '*') + ->pack(-side => 'right'); my $reply = $dialog->Show; if ('Cancel' eq $reply) { @@ -587,22 +818,50 @@ sub update_time { } } +sub blink_icon { + my ($self, $to_normal) = @_; + $self->{mw}->idletasks; + my $current_icon = $self->{mw}->iconname; + return if $to_normal && 'normal' eq $current_icon; + + my $new_icon = qw( alert normal )[ $current_icon eq 'alert' ]; + $self->{mw}->idletasks; + $self->{mw}->iconimage($self->{ + qw( icon icon_alert )[ $new_icon eq 'alert'] }); + $self->{mw}->idletasks; + $self->{mw}->iconname($new_icon); +} sub quit { my ($self) = @_; print STDERR "Quitting...\n"; $self->{to_control}->insert(0, ['quit']); + if ('MSWin32' ne $^O && $self->{mce}{child}) { + $self->{control_t}->kill('QUIT'); + Tk::exit() + } } sub help { my ($self) = @_; + + my @help = ( + ' previous history item', + ' next history item', + '<{paste_keys}> paste clipboard', + '<{copy_link}> copy link', + ' to delete a private message', + ' to reply to a message (both private and public)', + ' to exit help', + ); $self->{opt_h}->configure(-state => 'disabled'); my $top = $self->{mw}->Toplevel(-title => TITLE . ' Help'); - my $text = $top->ROText(height => 4)->pack; - $text->insert('end', " previous history item\n"); - $text->insert('end', " next history item\n"); - $text->insert('end', "\n to exit help"); + my $text = $top->ROText(height => 1 + @help)->pack; + s/\{(.+?)\}/$self->{$1}/g for @help; + $text->insert('end', "$_\n") for @help[ 0 .. $#help - 1 ]; + $text->insert('end', "\n$help[-1]"); + $top->bind('', my $end = sub { $top->DESTROY; $self->{opt_h}->configure(-state => 'normal'); diff --git a/pm-cb b/pm-cb index 9da7cbd..815dfff 100755 --- a/pm-cb +++ b/pm-cb @@ -11,7 +11,7 @@ use WWW::Mechanize; my $debug = @ARGV && '-d' eq shift; use constant { - PM_URL => 'http://www.perlmonks.org/bare/?node_id=', + PM_URL => 'http://www.perlmonks.org/bare/index.pl?node_id=', FREQ => 7, # Node ids: LOGIN => 109, diff --git a/pm-cb-g b/pm-cb-g index 11f0879..74e26a3 100755 --- a/pm-cb-g +++ b/pm-cb-g @@ -1,96 +1,164 @@ -#!/usr/bin/perl -use warnings; -use strict; -use Syntax::Construct qw{ // }; +#!/pro/bin/perl -use FindBin; -use lib "$FindBin::Bin/lib"; +use 5.14.1; +use warnings; use Getopt::Long qw( :config no_ignore_case ); use Pod::Usage; +use FindBin; +use lib "$FindBin::Bin/lib"; use PM::CB::GUI; use PM::CB::Control; -my ($bg_color, $fg_color, $author_color, $private_color, $gesture_color, - $time_color, $font_name, $char_size, $stack_size, $seen_color, $mce, - $no_time, $help, $pm_url, $browse_url, $random_url); +my %conf; BEGIN { - ($bg_color, $fg_color, $author_color, $private_color, $gesture_color, - $time_color, $seen_color, $font_name, $char_size, $stack_size, $mce, - $no_time, $help, $pm_url, $random_url) - = qw( white black blue magenta darkgreen darkcyan darkgray Helvetica - 12 15 0 0 0 www.perlmonks.org 0 ); + my $pid = fork; + $pid < 0 and die "Cannot fork: $!\n"; + $pid and exit 0; + + %conf = ( + bg_color => 'white', + fg_color => 'black', + author_color => 'blue', + author_format => '[%s]', + private_color => 'magenta', + gesture_color => 'darkgreen', + time_color => 'darkcyan', + seen_color => 'darkgray', + warn_color => 'red', + font_name => 'Helvetica', + char_size => 12, + stack_size => 15, + mce => undef, + no_time => 0, + pm_url => 'www.perlmonks.org', + date_format => 'YYYY-MM-DD hh:mm:ss', + time_format => '', + copy_link => 'Control-Button-1', + paste_keys => 'Shift-Insert', + random_url => 0, + icon_path => $FindBin::Bin, + ); + $^O eq 'MSWin32' or $conf{paste_keys} .= ' XF86Paste'; + + # /me considers File::HomeDir overly complicated + my $home = $ENV{HOME} || $ENV{USERPROFILE} || $ENV{HOMEPATH}; + foreach my $rcf (grep { -s } + "$home/pm-cb.rc", "$home/.pm-cbrc", "$home/.config/pm-cb") { + my $mode = (stat $rcf)[2]; + $mode & 022 and next; + open my $fh, "<", $rcf or next; + while (<$fh>) { + m/^\s*[;#]/ and next; + $mode & 044 && m/password/i and next; + my ($k, $v) = (m/^\s*([-\w]+)\s*[:=]\s*(.*\S)/) or next; + $conf{ lc $k + =~ s{-}{_}gr + =~ s{[-_]colou?r$}{_color}ir + =~ s{background}{bg}ir + =~ s{foreground}{fg}ir + =~ s{^(?:unicode|utf-?8?)$}{utf8}ir + =~ s{^use_}{}ir + =~ s{font_size}{char_size}ir + =~ s{font_family}{char_name}ir + =~ s{show_time(?:stamps?)}{show_time}ir + =~ s{copy_url$}{copy_link}ir + } = $v + =~ s{U\+?([0-9A-Fa-f]{2,7})}{chr hex $1}ger + =~ s{^(?:no|false)$}{0}ir + =~ s{^(?:yes|true)$}{1}ir; + } + } GetOptions( - 'a|author_color=s' => \$author_color, - 'b|bg_color=s' => \$bg_color, - 'c|char_size=i' => \$char_size, - 'f|fg_color=s' => \$fg_color, - 'F|font_name=s' => \$font_name, - 'g|gesture_color=s' => \$gesture_color, - 'h|help' => \$help, - 'm|mce' => \$mce, - 'n|no_time' => \$no_time, - 'p|private_color=s' => \$private_color, - 'r|random_url' => \$random_url, - 's|stack_size=i' => \$stack_size, - 'S|seen_color=s' => \$seen_color, - 't|time_color=s' => \$time_color, - 'u|url=s' => \$pm_url, - 'U|browse_url=s' => \$browse_url, + 'a|author_color=s' => \$conf{author_color}, + 'b|bg_color=s' => \$conf{bg_color}, + 'c|char_size=i' => \$conf{char_size}, + 'C|copy_link=s' => \$conf{copy_link}, + 'f|fg_color=s' => \$conf{fg_color}, + 'F|font_name=s' => \$conf{font_name}, + 'g|gesture_color=s' => \$conf{gesture_color}, + 'G|geometry=s' => \$conf{geometry}, + 'h|help' => \my $help, + 'l|log=s' => \$conf{history_file}, + 'm|mce_hobo' => \$conf{mce_hobo}, + 'M|mce_child' => \$conf{mce_child}, + 'n|no_time' => \$conf{no_time}, + 'p|private_color=s' => \$conf{private_color}, + 'P|paste_keys=s' => \$conf{paste_keys}, + 'r|random_url' => \$conf{random_url}, + 's|stack_size=i' => \$conf{stack_size}, + 'S|seen_color=s' => \$conf{seen_color}, + 'w|warn_color=s' => \$conf{warn_color}, + 't|time_color=s' => \$conf{time_color}, + 'u|url=s' => \$conf{pm_url}, + 'U|browse_url=s' => \$conf{browse_url}, ) or pod2usage(-verbose => 0, -exitval => 1); - $browse_url //= $pm_url; + $conf{browse_url} //= $conf{pm_url}; + + defined $conf{mce} and $conf{mce_child} = delete $conf{mce}; + defined $conf{mce_hobo} and $conf{mce}{hobo} = delete $conf{mce_hobo}; + defined $conf{mce_child} and $conf{mce}{child} = delete $conf{mce_child}; + $conf{mce}{hobo} && $conf{mce}{child} and + die "Can't combine mce_hobo and mce_child.\n"; + $conf{mce}{hobo} || $conf{mce}{child} or undef $conf{mce}; + + exists $conf{show_time} and $conf{no_time} = !delete $conf{show_time}; + $conf{font_name} =~ m/\s/ and $conf{font_name} = "{".$conf{font_name}."}"; + foreach my $attr (qw( copy_link paste_keys )) { + $conf{$attr} = join " " => map { s{^<*(.*?)>*$}{<$1>}r } + split m/\s+/ => $conf{$attr}; + } + exists $conf{xmodifiers} && !$conf{xmodifiers} and delete $ENV{XMODIFIERS}; + + $conf{warn_color} eq $conf{fg_color} and $conf{warn_color} = "red"; pod2usage(-verbose => 1, -exitval => 0) if $help; } +use if $conf{mce}{hobo} => 'MCE::Hobo'; +use if $conf{mce}{hobo} => 'MCE::Shared'; -use if $mce => 'MCE::Hobo'; -use if $mce => 'MCE::Shared'; +use if $conf{mce}{child} => 'MCE::Child'; +use if $conf{mce}{child} => 'MCE::Channel'; -use if ! $mce => threads => (stack_size => 2 ** $stack_size); -use if ! $mce => 'Thread::Queue'; +use if !($conf{mce}{child} || $conf{mce}{hobo}) => threads => (stack_size => 2 ** $conf{stack_size}); +use if !($conf{mce}{child} || $conf{mce}{hobo}) => 'Thread::Queue'; -my ($queue_class, $queue_constructor, $worker_class) - = $mce - ? ('MCE::Shared', 'queue', 'MCE::Hobo') - : ('Thread::Queue', 'new', 'threads'); +my $mce_idx = ($conf{mce}{hobo} || 0) + 2 * ($conf{mce}{child} || 0); +my ($queue_class, $queue_constructor, $worker_class) = @{{ + 0 => [qw[ Thread::Queue new threads ]], + 1 => [qw[ MCE::Shared queue MCE::Hobo ]], + 2 => [qw[ MCE::Channel new MCE::Child ]], + }->{$mce_idx}}; my ($to_gui, $to_comm, $to_control) = map $queue_class->$queue_constructor, 1, 2, 3; my $control_t = $worker_class->create(sub { - my $control = PM::CB::Control->new({to_gui => $to_gui, - to_comm => $to_comm, - from_gui => $to_control, - worker_class => $worker_class, - pm_url => $pm_url, - random_url => $random_url}); + my $control = 'PM::CB::Control'->new({to_gui => $to_gui, + to_comm => $to_comm, + from_gui => $to_control, + worker_class => $worker_class, + pm_url => $conf{pm_url}, + random_url => $conf{random_url}}); $control->start_comm; }); my $gui = 'PM::CB::GUI'->new({ - bg_color => $bg_color, - fg_color => $fg_color, - author_color => $author_color, - private_color => $private_color, - gesture_color => $gesture_color, - time_color => $time_color, - font_name => $font_name, - char_size => $char_size, - stack_size => $stack_size, - seen_color => $seen_color, - mce => $mce, - no_time => $no_time, - from_comm => $to_gui, - to_comm => $to_comm, - to_control => $to_control, - control_t => $control_t, - browse_url => $browse_url, - random_url => $random_url}); + %conf, + from_comm => $to_gui, + to_comm => $to_comm, + to_control => $to_control, + control_t => $control_t}); $gui->gui; +# MCE::Channel can only enqueue, it has no insert method. +sub MCE::Channel::insert { + my ($self, undef, $messages) = @_; + $self->enqueue($messages); +} =head1 NAME @@ -100,12 +168,15 @@ pm-cb-g - A GUI client to PerlMonks' Chatter Box pm-cb-g -a blue -b white -c 12 -f black -F Helvetica -p magenta -s 15 -S darkgray -t darkcyan -g darkgreen - -u www.perlmonks.org -U www.perlmonks.org [ -h -m -n ] + -u www.perlmonks.org -U www.perlmonks.org + -C Control-Button-1 -l "" + [ -h -m/-M -n ] =head1 OPTIONS -Use an integer for I, color name or C<#RRGGBB> code for -I. The default values are shown in the Synopsis above. +The default values are shown in the Synopsis above. + +For colors, use a color name or C<#RRGGBB> code. =over @@ -119,7 +190,16 @@ The background color of the application. =item B I -The size of all the characters. +The size of all the characters (integer). + +=item B I + +The event that copies the link under mouse cursor to the clipboard. + +=item B I + +The event(s) that pastes the clipboard content to the current position. +Events may be separated by spaces. =item B I @@ -133,13 +213,27 @@ The font for all the characters. The foreground colour to display the names of gesture authors (C). +=item B IxI[+I+I] + +Geometry of the main window. Use the optimal geometry if none given. + =item B Prints options and arguments. -=item B +=item B I + +Save all messages to the given log file. Don't save any messages if +the filename is empty. + +=item B + +Use L and L instead of L and +L. + +=item B -Use L and L instead of L and +Use L and L instead of L and L. =item B @@ -176,4 +270,18 @@ The address to use to communicate with PerlMonks. The address to use to open PerlMonks links in the browser. Same as B if not specified. +=item B I + +The color that indicates a too long message. + =back + +=head1 AUTHOR + +E. Choroba + +=head2 Contributors + +H.Merijn Brand, LorenzoTa, Mario Roy, Nick Tonkin, Steve Rogerson + +=cut diff --git a/pm-log.pl b/pm-log.pl new file mode 100755 index 0000000..b1a8a55 --- /dev/null +++ b/pm-log.pl @@ -0,0 +1,64 @@ +#!/pro/bin/perl + +use 5.14.2; +use warnings; +use Term::ANSIColor; + +my $pat = shift // "."; + +my %conf; +my $home = $ENV{HOME} || $ENV{USERPROFILE} || $ENV{HOMEPATH}; +foreach my $rcf (grep { -s } + "$home/pm-cb.rc", "$home/.pm-cbrc", "$home/.config/pm-cb") { + my $mode = (stat $rcf)[2]; + $mode & 022 and next; + open my $fh, "<", $rcf or next; + while (<$fh>) { + m/^\s*[;#]/ and next; + $mode & 044 && m/password/i and next; + my ($k, $v) = (m/^\s*([-\w]+)\s*[:=]\s*(.*\S)/) or next; + $conf{ lc $k + =~ s{-}{_}gr + =~ s{[-_]colou?r$}{_color}ir + =~ s{background}{bg}ir + =~ s{foreground}{fg}ir + =~ s{^(?:unicode|utf-?8?)$}{utf8}ir + =~ s{^use_}{}ir + =~ s{font_size}{char_size}ir + =~ s{font_family}{char_name}ir + =~ s{show_time(?:stamps?)}{show_time}ir + =~ s{copy_url$}{copy_link}ir + } = $v + =~ s{U\+?([0-9A-Fa-f]{2,7})}{chr hex $1}ger + =~ s{^(?:no|false)$}{0}ir + =~ s{^(?:yes|true)$}{1}ir; + } + } +exists $conf{show_time} and $conf{no_time} = !delete $conf{show_time}; +$conf{font_name} =~ m/\s/ and $conf{font_name} = "{".$conf{font_name}."}"; +$conf{copy_link} =~ s{^<*(.*?)>*$}{<$1>}; + +my $ct = color ("grey15"); # color ($conf{time_color} || "darkcyan") || color ("grey15"); +my $ca = color ("bright_blue"); # color ($conf{author_color} || "blue") || color ("bright_blue"); +my $cu = color ("red"); # color ($conf{self_color} || "red") || color ("red"); +my $cr = color ("reset"); + +my $user = lc ($conf{username} || $ENV{logname}); + +my %seen; +foreach my $hf (sort + grep { !$seen{$_}++ } + map { glob ("$_*"), glob (s/(?=\.log$)/*/r) } + map { s{^~}{$ENV{HOME}}r } + $conf{history_file}) { + open my $fh, '<:encoding(utf-8)', $hf or next; + say $hf; + local $/ = "\x{2028}"; + chomp (my @hist = <$fh>); + for (grep m/$pat/i => @hist) { + my ($time, $author, $msg) = split m/\x{2063}/ => $_; + $msg =~ s/[\r\n\s]*\z//; + my $dc = $user eq lc $author =~ s/^\W+//r =~ s/\W+$//r ? $cu : $ca; + say $ct, $time, $dc, $author, $cr, $msg; + } + } diff --git a/pm.xpm b/pm.xpm new file mode 100644 index 0000000..8026439 --- /dev/null +++ b/pm.xpm @@ -0,0 +1,39 @@ +/* XPM */ +static char * pm_xpm[] = { +"32 32 4 1", +" c black", +". c #000080", +"X c yellow", +"o c brown", +" ", +" .............................. ", +" .............................. ", +" .............................. ", +" XXXXX......................XXX ", +" XXXXXX....................XXXX ", +" ....XXX..................XX... ", +" ......X..................X.... ", +" ......XX................XX.... ", +" ......XX................X..... ", +" ...X...XX..............XX..... ", +" ..XXX..XX............XXX...X.. ", +" ...X...XX...........XX....XoX. ", +" ........XX.........XX......XX. ", +" ........XX.........XXXX....... ", +" .........XX...........X....... ", +" .........XX...........XX...... ", +" ......XXXX............XXXX.... ", +" .....XX..................XX... ", +" ..XXXX....................XX.. ", +" .XX.......................XX.. ", +" ..XXXX...................XX... ", +" .....XXX.............XXXXX.... ", +" ......XX.............XX....... ", +" ..X..XX...............XXX..... ", +" XXXX.XX.................XXX... ", +" XX..XX....................XXX. ", +" X...........................XX ", +" .............................X ", +" .............................X ", +" .............................. ", +" "};