File Coverage

blib/lib/Sweat.pm
Criterion Covered Total %
statement 165 295 55.9
branch 32 86 37.2
condition 3 15 20.0
subroutine 41 51 80.3
pod 0 15 0.0
total 241 462 52.1


line stmt bran cond sub pod time code
1             package Sweat;
2              
3             our $VERSION = 202101030;
4              
5 3     3   5322 use v5.10;
  3         23  
6              
7 3     3   137 use warnings;
  3         10  
  3         115  
8 3     3   19 use strict;
  3         14  
  3         88  
9 3     3   1833 use Types::Standard qw(Int ArrayRef Maybe Str Bool HashRef);
  3         237174  
  3         33  
10              
11 3     3   4260 use List::Util qw(shuffle);
  3         8  
  3         275  
12 3     3   1410 use YAML;
  3         21638  
  3         160  
13 3     3   2404 use File::Temp qw(tmpnam);
  3         58511  
  3         192  
14 3     3   1554 use Web::NewsAPI;
  3         4045047  
  3         140  
15 3     3   2383 use MediaWiki::API;
  3         46918  
  3         104  
16 3     3   24 use LWP;
  3         9  
  3         74  
17 3     3   15 use Try::Tiny;
  3         7  
  3         178  
18 3     3   1645 use utf8::all;
  3         50133  
  3         22  
19 3     3   6547 use Term::ReadKey;
  3         6246  
  3         231  
20 3     3   24 use POSIX qw(uname);
  3         6  
  3         29  
21 3     3   1863 use File::Which;
  3         3266  
  3         171  
22              
23 3     3   1382 use Sweat::Group;
  3         12  
  3         119  
24 3     3   2757 use Sweat::Article;
  3         12  
  3         135  
25 3     3   1407 use Sweat::ArticleHandler;
  3         14  
  3         155  
26              
27 3     3   26 use Moo;
  3         6  
  3         18  
28 3     3   8687 use namespace::clean;
  3         6  
  3         26  
29              
30             BEGIN {
31 3     3   13664 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 2     2 0 72 my ($self, $args) = @_;
194              
195 2         4 my $config;
196              
197             try {
198 2     2   154 $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 2         30 };
205              
206 2         542 foreach ( $args, $config ) {
207 4         16 _mangle_args( $_ );
208             }
209              
210 2         10 for my $method( qw(shuffle entertainment chair jumping refocus)) {
211 10 100       28 next if defined $args->{$method};
212 8 50       20 if ( defined $config->{$method} ) {
213 0   0     0 my $value = $config->{$method} // 0;
214 0         0 $self->$method($value);
215             }
216             }
217              
218 2         8 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 16 100       44 next if defined $args->{$method};
225 8 50       98 if ( defined $config->{$method} ) {
226 0         0 $self->$method($config->{$method});
227             }
228             }
229              
230 2 50       10 if ( my $group_data = $config->{groups} ) {
231 0         0 $self->group_config( $group_data );
232             }
233              
234 2 50       16 if ( $config->{no_news} ) {
235 0         0 $self->newsapi_key( undef );
236             }
237              
238 2         64 $Sweat::Article::language = $self->language;
239             $Sweat::Article::mw->{config}->{api_url} =
240 2         50 "https://"
241             . $self->language
242             . ".wikipedia.org/w/api.php";
243              
244 2         44 $self->_check_resources;
245             }
246              
247             sub _check_resources {
248 2     2   4 my $self = shift;
249              
250 2         40 my $speech_program = $self->speech_program;
251 2         26 my $bare_speech_program = (split /\s+/, $speech_program)[0];
252              
253 2 50       16 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 2 50       204 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 2     2   6 my $self = shift;
278 2 50       52 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 clean_up();
321 0         0 die "Sweat ran into a problem fetching articles. Check your internet\n"
322             . "connection, or run with --no-entertainment to use sweat without\n"
323             . "loading any online distractions.\n\n(Error text: $_)\n";
324             }
325 0         0 }
326              
327             my $temp_file = tmpnam();
328              
329             sub sweat {
330 2     2 0 2040 my $self = shift;
331              
332 2         16 ReadMode 3;
333              
334 2         120 $self->_load_entertainment;
335              
336 2         18 for my $drill (@{ $self->drills }) {
  2         42  
337 14         1210 $self->order($drill);
338             }
339              
340 1         114 $self->cool_down;
341 1         23 $self->clean_up;
342             }
343              
344             sub order {
345 14     14 0 56 my ( $self, $drill ) = @_;
346              
347 14         351 $self->drill_counter( $self->drill_counter + 1 );
348              
349 14         723 $self->rudely_speak(
350             'Prepare for '
351             . $drill->name
352             . '. Drill '
353             . $self->drill_counter
354             . '.'
355             );
356 13         552 $self->countdown($self->rest_length);
357              
358 13         50 my ($extra_text, $url, $article) = $self->entertainment_for_drill( $drill );
359 13   50     126 $extra_text //= q{};
360              
361             # XXX Set the binmode to raw temporarily if we're side-switching (and thus
362             # reporting weather.) This is a hack to get around a strange encoding
363             # issue in this case only.
364 13 100       65 binmode STDOUT, ':raw' if $drill->requires_side_switching;
365 13         71 $self->rudely_speak( "Start now. $extra_text");
366 13 100       81 binmode STDOUT, ':utf8' if $drill->requires_side_switching;
367              
368 13         66 my $url_tempfile;
369 13 50       36 if ( defined $url ) {
370 0 0       0 if ( $url =~ m{\Wyoutube.com/} ) {
371 0         0 $url_tempfile = $self->mangle_youtube_url( $article );
372 0         0 $url = "file://$url_tempfile";
373             }
374 0 0       0 if ( defined $self->url_program ) {
375 0         0 system( split (/\s+/, $self->url_program), $url );
376 0 0       0 if ( defined $self->refocus_program ) {
377 0         0 system $self->refocus_program;
378             }
379             }
380             }
381              
382 13 100       44 if ( $drill->requires_side_switching ) {
383 1         44 $self->countdown( $self->drill_length / 2 );
384 1         19 $self->speak( 'Switch sides.');
385 1         41 $self->countdown( $self->side_switch_length );
386 1         5 $self->speak( 'Resume.' );
387 1         40 $self->countdown( $self->drill_length / 2 );
388             }
389             else {
390 12         285 $self->countdown( $self->drill_length );
391             }
392              
393 13         46 $self->rudely_speak( 'Rest.' );
394 13         299 sleep $self->drill_prep_length;
395             }
396              
397             sub countdown {
398 28     28 0 224 my ($self, $seconds) = @_;
399 28 50       85 return unless $seconds;
400              
401 0         0 my $seconds_label_cutoff = 10;
402 0         0 my @spoken_seconds = (20, 15, 10, 5, 4, 3, 2, 1);
403 0         0 my $label = 'seconds left';
404              
405 0         0 for my $current_second (reverse(0..$seconds)) {
406 0         0 my $keystroke = ReadKey (1);
407 0 0       0 if ( $keystroke ) {
408 0         0 $self->pause;
409             }
410 0 0 0     0 if (
      0        
411 0         0 ( grep {$_ == $current_second} @spoken_seconds )
412             || ( $current_second && not ( $current_second % 10 ) )
413             ) {
414 0 0       0 if ( $current_second >= $seconds_label_cutoff ) {
415 0         0 $self->speak( "$current_second $label." );
416 0         0 $label = 'seconds';
417             }
418             else {
419 0         0 $self->shut_up; # Final countdown, so interrupt any chattiness
420 0         0 $self->speak( $current_second );
421             }
422             }
423             }
424             }
425              
426             sub shut_up {
427 40     40 0 55 my $self = shift;
428              
429             # We check for drill-length here for the sake of testing, which usually
430             # sets this to 0, and also runs under the `prove` program... which will
431             # get killed by the code below, oops. Something to improve later.
432 40 50 66     1340 if ( -e $temp_file && $self->drill_length ) {
433 0         0 my $group = getpgrp;
434 0         0 unlink $temp_file;
435 0         0 $SIG{TERM} = 'IGNORE';
436 0         0 kill ('TERM', -$group);
437 0         0 $SIG{TERM} = 'DEFAULT';
438             }
439             }
440              
441             sub pause {
442 0     0 0 0 my $self = shift;
443              
444 0         0 $self->shut_up;
445 0         0 say "***PAUSED*** Press any key to resume.";
446 0         0 $self->leisurely_speak( 'Paused.' );
447 0         0 ReadKey (0);
448 0         0 say "Resuming...";
449 0         0 $self->leisurely_speak( 'Resuming.' );
450             }
451              
452             sub entertainment_for_drill {
453 13     13 0 39 my ( $self, $drill ) = @_;
454              
455 13 50       248 unless ($self->entertainment) {
456 13         114 return (undef, undef);
457             }
458              
459 0         0 my $text;
460             my $url;
461 0         0 my $article;
462              
463 0 0       0 if ( $drill->requires_side_switching ) {
464 0         0 $text = $self->weather;
465             }
466             else {
467 0         0 $article = $self->next_article;
468 0 0       0 if ( $article ) {
469 0         0 $text = $article->text;
470 0         0 $url = $article->url;
471             }
472             else {
473 0         0 $text = "Couldn't fetch article, so have this instead: "
474             . $self->fortune;
475             }
476             }
477              
478 0         0 return ( $text, $url, $article );
479             }
480              
481              
482             sub fortune {
483 1     1 0 4 my $self = shift;
484              
485 1 50       74 return q{} unless $self->entertainment;
486              
487 0         0 my $exec = $self->fortune_program;
488              
489 3     3   27 no warnings;
  3         13  
  3         177  
490 0   0     0 my $text = `$exec` // '';
491 3     3   19 use warnings;
  3         6  
  3         6373  
492              
493 0 0       0 unless (length $text) {
494 0         0 warn "Sweat tried to fetch a fortune by running `$exec`, but didn't "
495             . "get any output. Sorry!\n";
496             }
497              
498 0         0 return $text;
499             }
500              
501             sub _build_weather {
502 0     0   0 my $self = shift;
503              
504 0         0 my $weather;
505 0         0 my $ua = LWP::UserAgent->new;
506 0         0 my $response = $ua->get('http://wttr.in?format=3');
507 0 0       0 if ( $response->is_success ) {
508 0         0 $weather = 'The current weather in ' . $response->content;
509             }
510             else {
511 0         0 $weather = 'Could not fetch the current weather. Sorry about that.';
512             }
513 0         0 return $weather;
514             }
515              
516             sub cool_down {
517 1     1 0 19 my $self = shift;
518              
519 1         12 $self->speak(
520             'Workout complete. ' . $self->fortune
521             );
522             }
523              
524             sub speak {
525 43     43 0 104 my ( $self, $message ) = @_;
526              
527 43         160 say $message;
528              
529 43 100       548 return if -e $temp_file;
530              
531 2         5745 my $pid = fork;
532 2 100       219 if ( $pid ) {
533 1         304 $self->speaker_pid( $pid );
534             }
535             else {
536 1         549 open my $fh, '>', $temp_file;
537 1         41 print $fh $pid;
538 1         240 system ( split (/\s+/, $self->speech_program), $message );
539 1         8215 unlink $temp_file;
540 1         509 exit;
541             }
542             }
543              
544             sub leisurely_speak {
545 0     0 0 0 my ( $self, $message ) = @_;
546              
547 0         0 system ( split (/\s+/, $self->speech_program), $message );
548             }
549              
550             sub rudely_speak {
551 40     40 0 159 my ( $self, $message ) = @_;
552              
553 40         96 $self->shut_up;
554 40         374 $self->speak( $message );
555             }
556              
557             # mangle_youtube_url: create a local file that just embeds the youtube
558             # video, preventing autoplay.
559             sub mangle_youtube_url {
560 0     0 0 0 my ( $self, $article ) = @_;
561 0         0 my $tempfile = File::Temp->new( SUFFIX => '.html' );
562 0         0 binmode $tempfile, ":utf8";
563 0         0 my $title = $article->title;
564 0         0 my $description = $article->text;
565 0         0 my $url = $article->url;
566              
567 0         0 my ($video_id) = $url =~ m{v=(\w+)};
568              
569 0         0 print $tempfile qq{<html><head><title>$title</title></head>}
570             . qq{<body><h1>$title</h1>}
571             . qq{<p>$description</p>}
572             . qq{<div><iframe src="https://www.youtube.com/embed/$video_id" }
573             . q{frameborder="0" allow="accelerometer; autoplay; }
574             . q{encrypted-media; gyroscope; picture-in-picture" }
575             . q{allowfullscreen></iframe></div>}
576             . q{<p><em>This article was mangled by Sweat so that its video }
577             . q{didn&#8217;t autoplay. You&#8217;re welcome.</em></p>}
578             . qq{</body></html>\n};
579              
580 0         0 return $tempfile;
581             }
582              
583             sub _build_drills {
584 2     2   28 my $self = shift;
585              
586 2         6 my @final_drills;
587              
588 2         38 while (@final_drills < $self->drill_count) {
589 8         156 for my $group ( @{ $self->groups } ) {
  8         128  
590 26         236 my @drills = $group->unused_drills;
591              
592 26 100       178 unless ( @drills ) {
593 2         12 $group->reset_drills;
594 2         20 @drills = $group->unused_drills;
595             }
596              
597 26 50       498 if ( $self->shuffle ) {
598 0         0 @drills = shuffle(@drills);
599             }
600              
601 26         510 $drills[0]->is_used( 1 );
602 26         550 push @final_drills, $drills[0];
603              
604 26 100       446 last if @final_drills == $self->drill_count;
605             }
606             }
607              
608 2         78 return \@final_drills;
609             }
610              
611             sub clean_up {
612 1     1 0 35 ReadMode 0;
613             }
614              
615             sub _build_groups {
616 2     2   26 my $self = shift;
617              
618 2         36 return [ Sweat::Group->new_from_config_data( $self, $self->group_config ) ];
619              
620             }
621              
622             sub _default_group_config {
623 2     2   6 my $self = shift;
624 2         12 return Load(<<END);
625             - name: aerobic
626             drills:
627             - name: jumping jacks
628             requires_jumping: 1
629             - name: high knees
630             requires_jumping: 1
631             - name: step-ups
632             requires_a_chair: 1
633             - name: lower-body
634             drills:
635             - name: wall sit
636             - name: squats
637             - name: knee lunges
638             - name: upper-body
639             drills:
640             - name: push-ups
641             - name: tricep dips
642             requires_a_chair: 1
643             - name: rotational push-ups
644             - name: core
645             drills:
646             - name: abdominal crunches
647             - name: plank
648             - name: side plank
649             requires_side_switching: 1
650             END
651             }
652              
653             sub _mangle_args {
654 6     6   16 my ( $args ) = @_;
655              
656 6         30 for my $key (keys %$args) {
657 28 50       66 if ( $key =~ /-/ ) {
658 0           my $new_key = $key;
659 0           my $value = $$args{$key};
660 0           $new_key =~ s/-/_/g;
661 0           $$args{$new_key} = $value;
662 0           delete $$args{$key};
663             }
664             }
665             }
666              
667             sub _build_refocus_program {
668 0     0     my $self = shift;
669              
670 0 0         return unless $self->refocus;
671              
672 0           my $pstree = `pstree -p $$`;
673              
674 0 0         if ( (uname())[0] eq 'Darwin') {
675 0 0         unless ( which( 'pstree' ) ) {
676 0           warn "The refocus feature requires the 'pstree' program to be "
677             . "installed. I can't find it, so I won't be able to "
678             . "refocus the window during the workout. Sorry...\n";
679 0           return;
680             }
681              
682 0           my ($app) = $pstree =~ /(\S+Applications\S+)/;
683 0 0         if ($app) {
684 0           return "open -a $app";
685             }
686             else {
687 0           warn "I can't figure out what terminal I'm running in?! "
688             . "I won't be able to refocus the window during the "
689             . "workout. Sorry...\n";
690 0           return;
691             }
692             }
693             else {
694 0 0         unless ( which( 'xdotool' ) ) {
695 0           warn "The refocus feature requires the 'xdotool' program to be "
696             . "installed. I can't find it, so I won't be able to "
697             . "refocus the window during the workout. Sorry...\n";
698 0           return;
699             }
700              
701 0           my $window_id = `xdotool getactivewindow`;
702 0 0         if ( $window_id ) {
703 0           return "xdotool windowactivate $window_id";
704             }
705             else {
706 0           warn "I can't figure out what window I'm running in?! "
707             . "I won't be able to refocus the window during the "
708             . "workout. Sorry...\n";
709 0           return;
710             }
711             }
712              
713             }
714              
715             1;
716              
717             =head1 NAME
718              
719             Sweat - Library for the `sweat` command-line program
720              
721             =head1 DESCRIPTION
722              
723             This library is intended for internal use by the L<sweat> command-line program,
724             and as such offers no publicly documented methods.
725              
726             =head1 SEE ALSO
727              
728             L<sweat>
729              
730             =head1 AUTHOR
731              
732             Jason McIntosh <jmac@jmac.org>