File Coverage

blib/lib/Sweat.pm
Criterion Covered Total %
statement 165 294 56.1
branch 32 86 37.2
condition 3 15 20.0
subroutine 41 51 80.3
pod 0 15 0.0
total 241 461 52.2


line stmt bran cond sub pod time code
1             package Sweat;
2              
3             our $VERSION = 202004250;
4              
5 4     4   7530 use v5.10;
  4         33  
6              
7 4     4   20 use warnings;
  4         5  
  4         96  
8 4     4   53 use strict;
  4         7  
  4         113  
9 4     4   2357 use Types::Standard qw(Int ArrayRef Maybe Str Bool HashRef);
  4         317452  
  4         45  
10              
11 4     4   5834 use List::Util qw(shuffle);
  4         11  
  4         403  
12 4     4   1855 use YAML;
  4         28233  
  4         224  
13 4     4   2980 use File::Temp qw(tmpnam);
  4         75313  
  4         262  
14 4     4   1929 use Web::NewsAPI;
  4         5242291  
  4         196  
15 4     4   3006 use MediaWiki::API;
  4         64481  
  4         158  
16 4     4   34 use LWP;
  4         11  
  4         84  
17 4     4   23 use Try::Tiny;
  4         9  
  4         252  
18 4     4   2216 use utf8::all;
  4         66523  
  4         23  
19 4     4   9027 use Term::ReadKey;
  4         8321  
  4         315  
20 4     4   30 use POSIX qw(uname);
  4         8  
  4         39  
21 4     4   2364 use File::Which;
  4         4314  
  4         218  
22              
23 4     4   1862 use Sweat::Group;
  4         12  
  4         163  
24 4     4   2484 use Sweat::Article;
  4         16  
  4         183  
25 4     4   1961 use Sweat::ArticleHandler;
  4         13  
  4         196  
26              
27 4     4   40 use Moo;
  4         11  
  4         21  
28 4     4   11438 use namespace::clean;
  4         8  
  4         38  
29              
30             BEGIN {
31 4     4   18230 binmode STDOUT, ":utf8";
32             }
33              
34             sub DEMOLISH {
35 0     0 0 0 \&clean_up;
36             }
37              
38             has 'groups' => (
39             is => 'lazy',
40             isa => ArrayRef,
41             );
42              
43             has 'group_config' => (
44             is => 'rw',
45             isa => ArrayRef,
46             default => sub { _default_group_config( @_ ) },
47             );
48              
49             has 'drills' => (
50             is => 'lazy',
51             isa => ArrayRef,
52             );
53              
54             has 'drill_count' => (
55             is => 'rw',
56             isa => Int,
57             required => 1,
58             default => 12,
59             );
60              
61             has 'drill_counter' => (
62             is => 'rw',
63             isa => Int,
64             default => 0,
65             );
66              
67             has 'config' => (
68             is => 'ro',
69             isa => Maybe[Str],
70             );
71              
72             has 'shuffle' => (
73             is => 'rw',
74             default => 0,
75             isa => Bool,
76             );
77              
78             has 'entertainment' => (
79             is => 'rw',
80             default => 1,
81             isa => Bool,
82             );
83              
84             has 'chair' => (
85             is => 'rw',
86             default => 1,
87             isa => Bool,
88             );
89              
90             has 'jumping' => (
91             is => 'rw',
92             default => 1,
93             isa => Bool,
94             );
95              
96             has 'drill_length' => (
97             is => 'rw',
98             isa => Int,
99             default => 30,
100             );
101              
102             has 'rest_length' => (
103             is => 'rw',
104             isa => Int,
105             default => 10,
106             );
107              
108             has 'drill_prep_length' => (
109             is => 'rw',
110             isa => Int,
111             default => 1,
112             );
113              
114             has 'side_switch_length' => (
115             is => 'rw',
116             isa => Int,
117             default => 4,
118             );
119              
120             has 'speech_program' => (
121             is => 'rw',
122             isa => Str,
123             default => sub { (uname())[0] eq 'Darwin'? 'say' : 'espeak' },
124             );
125              
126             has 'url_program' => (
127             is => 'rw',
128             isa => Maybe[Str],
129             default => sub { (uname())[0] eq 'Darwin'? 'open' : undef },
130             );
131              
132             has 'fortune_program' => (
133             is => 'rw',
134             isa => Maybe[Str],
135             default => 'fortune',
136             );
137              
138             has 'refocus_program' => (
139             is => 'lazy',
140             isa => Maybe[Str],
141             );
142              
143             has 'refocus' => (
144             is => 'rw',
145             isa => Bool,
146             default => 0,
147             );
148              
149             has 'newsapi_key' => (
150             is => 'rw',
151             isa => Maybe[Str],
152             );
153              
154             has 'country' => (
155             is => 'rw',
156             isa => Str,
157             default => 'us',
158             );
159              
160             has 'language' => (
161             is => 'rw',
162             isa => Str,
163             default => 'en',
164             );
165              
166             has 'article_handler' => (
167             is => 'ro',
168             isa => sub { $_[0]->isa('Sweat::ArticleHandler') },
169             default => sub { Sweat::ArticleHandler->new },
170             handles => [ qw( add_article next_article ) ],
171             );
172              
173             has 'weather' => (
174             is => 'lazy',
175             isa => Str,
176             );
177              
178             has 'speaker_pid' => (
179             is => 'rw',
180             isa => Int,
181             );
182              
183             around BUILDARGS => sub {
184             my ( $orig, $class, %args ) = @_;
185              
186             _mangle_args( \%args );
187              
188             return $class->$orig(%args);
189              
190             };
191              
192             sub BUILD {
193 3     3 0 123 my ($self, $args) = @_;
194              
195 3         6 my $config;
196              
197             try {
198 3     3   231 $config = Load( $self->config );
199             }
200             catch {
201 0     0   0 die "Can't load the config file! Here's the error from the YAML parser: "
202             . $_
203             . "\n";
204 3         45 };
205              
206 3         921 foreach ( $args, $config ) {
207 6         27 _mangle_args( $_ );
208             }
209              
210 3         9 for my $method( qw(shuffle entertainment chair jumping refocus)) {
211 15 100       42 next if defined $args->{$method};
212 12 50       24 if ( defined $config->{$method} ) {
213 0   0     0 my $value = $config->{$method} // 0;
214 0         0 $self->$method($value);
215             }
216             }
217              
218 3         9 for my $method (
219             qw(
220             newsapi_key country fortune_program speech_program url_program
221             rest_length drill_length drill_count
222             )
223             ) {
224 24 100       54 next if defined $args->{$method};
225 12 50       27 if ( defined $config->{$method} ) {
226 0         0 $self->$method($config->{$method});
227             }
228             }
229              
230 3 50       15 if ( my $group_data = $config->{groups} ) {
231 0         0 $self->group_config( $group_data );
232             }
233              
234 3 50       18 if ( $config->{no_news} ) {
235 0         0 $self->newsapi_key( undef );
236             }
237              
238 3         84 $Sweat::Article::language = $self->language;
239             $Sweat::Article::mw->{config}->{api_url} =
240 3         78 "https://"
241             . $self->language
242             . ".wikipedia.org/w/api.php";
243              
244 3         60 $self->_check_resources;
245             }
246              
247             sub _check_resources {
248 3     3   12 my $self = shift;
249              
250 3         60 my $speech_program = $self->speech_program;
251 3         33 my $bare_speech_program = (split /\s+/, $speech_program)[0];
252              
253 3 50       24 unless ( which( $bare_speech_program) ) {
254 0         0 die "ERROR: Sweat's 'speech-program' configuration is set to "
255             . "'$speech_program', but there doesn't seem to be a program "
256             . "there. I can't run without a speech program... sorry!\n";
257             }
258              
259 3 50       306 if ( $self->entertainment ) {
260 0         0 foreach (qw (url fortune) ) {
261 0         0 my $method = "${_}_program";
262 0         0 my $program = $self->$method;
263 0 0       0 next unless defined $program;
264 0         0 my $bare_program = (split /\s+/, $program)[0];
265 0 0       0 unless ( which( $bare_program ) ) {
266 0         0 $self->$method( undef );
267 0         0 warn "WARNING: Sweat's '$_-program' configuration is set to "
268             . "'$program', but there doesn't seem to be a program there. "
269             . "Going ahead without $_-opening.\n";
270             }
271             }
272             }
273             }
274              
275              
276             sub _load_entertainment {
277 3     3   12 my $self = shift;
278 3 50       81 if ( $self->entertainment ) {
279 0         0 local $| = 1;
280 0         0 say "Loading entertainment, please wait...";
281 0         0 $self->_load_articles;
282 0         0 $self->weather;
283 0         0 say "...done.";
284             }
285              
286             }
287              
288             sub _load_articles {
289 0     0   0 my $self = shift;
290              
291             try {
292 0 0   0   0 if ( $self->newsapi_key ) {
293 0         0 my $newsapi = Web::NewsAPI->new(
294             api_key => $self->newsapi_key,
295             );
296 0         0 my $result = $newsapi->top_headlines(
297             country => $self->country,
298             pageSize => $self->drill_count,
299             );
300 0         0 foreach ( $result->articles ) {
301 0         0 $self->add_article(
302             Sweat::Article->new_from_newsapi_article($_)
303             );
304             }
305             }
306             else {
307 0         0 my $article = Sweat::Article->new_from_random_wikipedia_article;
308 0 0       0 unless ( fork ) {
309 0         0 $self->add_article( $article );
310 0         0 for (1..$self->drill_count) {
311 0         0 $article = Sweat::Article->
312             new_from_linked_wikipedia_article($article);
313 0         0 $self->add_article( $article );
314             }
315 0         0 exit;
316             }
317             }
318             }
319             catch {
320 0     0   0 die "Sweat ran into a problem fetching articles. Check your internet\n"
321             . "connection, or run with --no-entertainment to use sweat without\n"
322             . "loading any online distractions.\n";
323             }
324 0         0 }
325              
326             my $temp_file = tmpnam();
327              
328             sub sweat {
329 3     3 0 3312 my $self = shift;
330              
331 3         21 ReadMode 3;
332              
333 3         177 $self->_load_entertainment;
334              
335 3         30 for my $drill (@{ $self->drills }) {
  3         60  
336 27         2511 $self->order($drill);
337             }
338              
339 1         164 $self->cool_down;
340 1         30 $self->clean_up;
341             }
342              
343             sub order {
344 27     27 0 145 my ( $self, $drill ) = @_;
345              
346 27         733 $self->drill_counter( $self->drill_counter + 1 );
347              
348 27         1422 $self->rudely_speak(
349             'Prepare for '
350             . $drill->name
351             . '. Drill '
352             . $self->drill_counter
353             . '.'
354             );
355 25         1339 $self->countdown($self->rest_length);
356              
357 25         133 my ($extra_text, $url, $article) = $self->entertainment_for_drill( $drill );
358 25   50     311 $extra_text //= q{};
359              
360             # XXX Set the binmode to raw temporarily if we're side-switching (and thus
361             # reporting weather.) This is a hack to get around a strange encoding
362             # issue in this case only.
363 25 100       139 binmode STDOUT, ':raw' if $drill->requires_side_switching;
364 25         145 $self->rudely_speak( "Start now. $extra_text");
365 25 100       145 binmode STDOUT, ':utf8' if $drill->requires_side_switching;
366              
367 25         78 my $url_tempfile;
368 25 50       66 if ( defined $url ) {
369 0 0       0 if ( $url =~ m{\Wyoutube.com/} ) {
370 0         0 $url_tempfile = $self->mangle_youtube_url( $article );
371 0         0 $url = "file://$url_tempfile";
372             }
373 0 0       0 if ( defined $self->url_program ) {
374 0         0 system( split (/\s+/, $self->url_program), $url );
375 0 0       0 if ( defined $self->refocus_program ) {
376 0         0 system $self->refocus_program;
377             }
378             }
379             }
380              
381 25 100       110 if ( $drill->requires_side_switching ) {
382 2         82 $self->countdown( $self->drill_length / 2 );
383 2         42 $self->speak( 'Switch sides.');
384 2         70 $self->countdown( $self->side_switch_length );
385 2         8 $self->speak( 'Resume.' );
386 2         50 $self->countdown( $self->drill_length / 2 );
387             }
388             else {
389 23         513 $self->countdown( $self->drill_length );
390             }
391              
392 25         93 $self->rudely_speak( 'Rest.' );
393 25         590 sleep $self->drill_prep_length;
394             }
395              
396             sub countdown {
397 54     54 0 464 my ($self, $seconds) = @_;
398 54 50       164 return unless $seconds;
399              
400 0         0 my $seconds_label_cutoff = 10;
401 0         0 my @spoken_seconds = (20, 15, 10, 5, 4, 3, 2, 1);
402 0         0 my $label = 'seconds left';
403              
404 0         0 for my $current_second (reverse(0..$seconds)) {
405 0         0 my $keystroke = ReadKey (1);
406 0 0       0 if ( $keystroke ) {
407 0         0 $self->pause;
408             }
409 0 0 0     0 if (
      0        
410 0         0 ( grep {$_ == $current_second} @spoken_seconds )
411             || ( $current_second && not ( $current_second % 10 ) )
412             ) {
413 0 0       0 if ( $current_second >= $seconds_label_cutoff ) {
414 0         0 $self->speak( "$current_second $label." );
415 0         0 $label = 'seconds';
416             }
417             else {
418 0         0 $self->shut_up; # Final countdown, so interrupt any chattiness
419 0         0 $self->speak( $current_second );
420             }
421             }
422             }
423             }
424              
425             sub shut_up {
426 77     77 0 154 my $self = shift;
427              
428             # We check for drill-length here for the sake of testing, which usually
429             # sets this to 0, and also runs under the `prove` program... which will
430             # get killed by the code below, oops. Something to improve later.
431 77 50 66     2841 if ( -e $temp_file && $self->drill_length ) {
432 0         0 my $group = getpgrp;
433 0         0 unlink $temp_file;
434 0         0 $SIG{TERM} = 'IGNORE';
435 0         0 kill ('TERM', -$group);
436 0         0 $SIG{TERM} = 'DEFAULT';
437             }
438             }
439              
440             sub pause {
441 0     0 0 0 my $self = shift;
442              
443 0         0 $self->shut_up;
444 0         0 say "***PAUSED*** Press any key to resume.";
445 0         0 $self->leisurely_speak( 'Paused.' );
446 0         0 ReadKey (0);
447 0         0 say "Resuming...";
448 0         0 $self->leisurely_speak( 'Resuming.' );
449             }
450              
451             sub entertainment_for_drill {
452 25     25 0 89 my ( $self, $drill ) = @_;
453              
454 25 50       550 unless ($self->entertainment) {
455 25         238 return (undef, undef);
456             }
457              
458 0         0 my $text;
459             my $url;
460 0         0 my $article;
461              
462 0 0       0 if ( $drill->requires_side_switching ) {
463 0         0 $text = $self->weather;
464             }
465             else {
466 0         0 $article = $self->next_article;
467 0 0       0 if ( $article ) {
468 0         0 $text = $article->text;
469 0         0 $url = $article->url;
470             }
471             else {
472 0         0 $text = "Couldn't fetch article, so have this instead: "
473             . $self->fortune;
474             }
475             }
476              
477 0         0 return ( $text, $url, $article );
478             }
479              
480              
481             sub fortune {
482 1     1 0 27 my $self = shift;
483              
484 1 50       45 return q{} unless $self->entertainment;
485              
486 0         0 my $exec = $self->fortune_program;
487              
488 4     4   40 no warnings;
  4         5  
  4         213  
489 0   0     0 my $text = `$exec` // '';
490 4     4   24 use warnings;
  4         9  
  4         8363  
491              
492 0 0       0 unless (length $text) {
493 0         0 warn "Sweat tried to fetch a fortune by running `$exec`, but didn't "
494             . "get any output. Sorry!\n";
495             }
496              
497 0         0 return $text;
498             }
499              
500             sub _build_weather {
501 0     0   0 my $self = shift;
502              
503 0         0 my $weather;
504 0         0 my $ua = LWP::UserAgent->new;
505 0         0 my $response = $ua->get('http://wttr.in?format=3');
506 0 0       0 if ( $response->is_success ) {
507 0         0 $weather = 'The current weather in ' . $response->content;
508             }
509             else {
510 0         0 $weather = 'Could not fetch the current weather. Sorry about that.';
511             }
512 0         0 return $weather;
513             }
514              
515             sub cool_down {
516 1     1 0 28 my $self = shift;
517              
518 1         27 $self->speak(
519             'Workout complete. ' . $self->fortune
520             );
521             }
522              
523             sub speak {
524 82     82 0 225 my ( $self, $message ) = @_;
525              
526 82         329 say $message;
527              
528 82 100       1159 return if -e $temp_file;
529              
530 5         10705 my $pid = fork;
531 5 100       440 if ( $pid ) {
532 3         898 $self->speaker_pid( $pid );
533             }
534             else {
535 2         992 open my $fh, '>', $temp_file;
536 2         89 print $fh $pid;
537 2         455 system ( split (/\s+/, $self->speech_program), $message );
538 2         13623 unlink $temp_file;
539 2         992 exit;
540             }
541             }
542              
543             sub leisurely_speak {
544 0     0 0 0 my ( $self, $message ) = @_;
545              
546 0         0 system ( split (/\s+/, $self->speech_program), $message );
547             }
548              
549             sub rudely_speak {
550 77     77 0 298 my ( $self, $message ) = @_;
551              
552 77         221 $self->shut_up;
553 77         823 $self->speak( $message );
554             }
555              
556             # mangle_youtube_url: create a local file that just embeds the youtube
557             # video, preventing autoplay.
558             sub mangle_youtube_url {
559 0     0 0 0 my ( $self, $article ) = @_;
560 0         0 my $tempfile = File::Temp->new( SUFFIX => '.html' );
561 0         0 binmode $tempfile, ":utf8";
562 0         0 my $title = $article->title;
563 0         0 my $description = $article->text;
564 0         0 my $url = $article->url;
565              
566 0         0 my ($video_id) = $url =~ m{v=(\w+)};
567              
568 0         0 print $tempfile qq{<html><head><title>$title</title></head>}
569             . qq{<body><h1>$title</h1>}
570             . qq{<p>$description</p>}
571             . qq{<div><iframe src="https://www.youtube.com/embed/$video_id" }
572             . q{frameborder="0" allow="accelerometer; autoplay; }
573             . q{encrypted-media; gyroscope; picture-in-picture" }
574             . q{allowfullscreen></iframe></div>}
575             . q{<p><em>This article was mangled by Sweat so that its video }
576             . q{didn&#8217;t autoplay. You&#8217;re welcome.</em></p>}
577             . qq{</body></html>\n};
578              
579 0         0 return $tempfile;
580             }
581              
582             sub _build_drills {
583 3     3   48 my $self = shift;
584              
585 3         6 my @final_drills;
586              
587 3         57 while (@final_drills < $self->drill_count) {
588 12         243 for my $group ( @{ $self->groups } ) {
  12         183  
589 39         348 my @drills = $group->unused_drills;
590              
591 39 100       321 unless ( @drills ) {
592 3         12 $group->reset_drills;
593 3         30 @drills = $group->unused_drills;
594             }
595              
596 39 50       606 if ( $self->shuffle ) {
597 0         0 @drills = shuffle(@drills);
598             }
599              
600 39         750 $drills[0]->is_used( 1 );
601 39         840 push @final_drills, $drills[0];
602              
603 39 100       624 last if @final_drills == $self->drill_count;
604             }
605             }
606              
607 3         129 return \@final_drills;
608             }
609              
610             sub clean_up {
611 1     1 0 43 ReadMode 0;
612             }
613              
614             sub _build_groups {
615 3     3   42 my $self = shift;
616              
617 3         57 return [ Sweat::Group->new_from_config_data( $self, $self->group_config ) ];
618              
619             }
620              
621             sub _default_group_config {
622 3     3   9 my $self = shift;
623 3         21 return Load(<<END);
624             - name: aerobic
625             drills:
626             - name: jumping jacks
627             requires_jumping: 1
628             - name: high knees
629             requires_jumping: 1
630             - name: step-ups
631             requires_a_chair: 1
632             - name: lower-body
633             drills:
634             - name: wall sit
635             - name: squats
636             - name: knee lunges
637             - name: upper-body
638             drills:
639             - name: push-ups
640             - name: tricep dips
641             requires_a_chair: 1
642             - name: rotational push-ups
643             - name: core
644             drills:
645             - name: abdominal crunches
646             - name: plank
647             - name: side plank
648             requires_side_switching: 1
649             END
650             }
651              
652             sub _mangle_args {
653 9     9   24 my ( $args ) = @_;
654              
655 9         45 for my $key (keys %$args) {
656 42 50       99 if ( $key =~ /-/ ) {
657 0           my $new_key = $key;
658 0           my $value = $$args{$key};
659 0           $new_key =~ s/-/_/g;
660 0           $$args{$new_key} = $value;
661 0           delete $$args{$key};
662             }
663             }
664             }
665              
666             sub _build_refocus_program {
667 0     0     my $self = shift;
668              
669 0 0         return unless $self->refocus;
670              
671 0           my $pstree = `pstree -p $$`;
672              
673 0 0         if ( (uname())[0] eq 'Darwin') {
674 0 0         unless ( which( 'pstree' ) ) {
675 0           warn "The refocus feature requires the 'pstree' program to be "
676             . "installed. I can't find it, so I won't be able to "
677             . "refocus the window during the workout. Sorry...\n";
678 0           return;
679             }
680              
681 0           my ($app) = $pstree =~ /(\S+Applications\S+)/;
682 0 0         if ($app) {
683 0           return "open -a $app";
684             }
685             else {
686 0           warn "I can't figure out what terminal I'm running in?! "
687             . "I won't be able to refocus the window during the "
688             . "workout. Sorry...\n";
689 0           return;
690             }
691             }
692             else {
693 0 0         unless ( which( 'xdotool' ) ) {
694 0           warn "The refocus feature requires the 'xdotool' program to be "
695             . "installed. I can't find it, so I won't be able to "
696             . "refocus the window during the workout. Sorry...\n";
697 0           return;
698             }
699              
700 0           my $window_id = `xdotool getactivewindow`;
701 0 0         if ( $window_id ) {
702 0           return "xdotool windowactivate $window_id";
703             }
704             else {
705 0           warn "I can't figure out what window I'm running in?! "
706             . "I won't be able to refocus the window during the "
707             . "workout. Sorry...\n";
708 0           return;
709             }
710             }
711              
712             }
713              
714             1;
715              
716             =head1 NAME
717              
718             Sweat - Library for the `sweat` command-line program
719              
720             =head1 DESCRIPTION
721              
722             This library is intended for internal use by the L<sweat> command-line program,
723             and as such offers no publicly documented methods.
724              
725             =head1 SEE ALSO
726              
727             L<sweat>
728              
729             =head1 AUTHOR
730              
731             Jason McIntosh <jmac@jmac.org>