File Coverage

blib/lib/WWW/Noss.pm
Criterion Covered Total %
statement 71 990 7.1
branch 0 562 0.0
condition 0 212 0.0
subroutine 24 67 35.8
pod 18 19 94.7
total 113 1850 6.1


line stmt bran cond sub pod time code
1             package WWW::Noss;
2 1     1   375853 use 5.016;
  1         4  
3 1     1   8 use strict;
  1         2  
  1         50  
4 1     1   10 use warnings;
  1         2  
  1         129  
5             our $VERSION = '2.02';
6              
7 1     1   9 use Cwd;
  1         2  
  1         115  
8 1     1   908 use Getopt::Long qw(GetOptionsFromArray);
  1         18796  
  1         6  
9 1     1   259 use File::Basename;
  1         3  
  1         84  
10 1     1   751 use File::Copy;
  1         8622  
  1         93  
11 1     1   12 use File::Spec;
  1         3  
  1         34  
12 1     1   1102 use File::Temp qw(tempfile);
  1         30488  
  1         1390  
13 1     1   13 use List::Util qw(max uniq);
  1         3  
  1         88  
14 1     1   698 use POSIX qw(strftime);
  1         13055  
  1         8  
15 1     1   2910 use Pod::Usage;
  1         80734  
  1         223  
16 1     1   1029 use Term::ANSIColor;
  1         12765  
  1         148  
17              
18 1     1   3268 use JSON;
  1         16174  
  1         9  
19              
20 1     1   2308 use WWW::Noss::Curl qw(curl curl_error http_status_string);
  1         4  
  1         109  
21 1     1   2147 use WWW::Noss::DB;
  1         5  
  1         58  
22 1     1   3010 use WWW::Noss::FeedConfig;
  1         5  
  1         56  
23 1     1   8 use WWW::Noss::FeedReader qw(discover_feeds);
  1         3  
  1         63  
24 1     1   724 use WWW::Noss::GroupConfig;
  1         4  
  1         54  
25 1     1   617 use WWW::Noss::Home qw(home);
  1         4  
  1         80  
26 1     1   739 use WWW::Noss::Lynx qw(lynx_dump);
  1         4  
  1         78  
27 1     1   1823 use WWW::Noss::OPML;
  1         4  
  1         50  
28 1     1   9 use WWW::Noss::TextToHtml qw(escape_html);
  1         2  
  1         69  
29 1     1   7 use WWW::Noss::Util qw(dir resolve_url);
  1         2  
  1         27369  
30              
31             my $PRGNAM = 'noss';
32             my $PRGVER = $VERSION;
33              
34             # TODO: Command aliases?
35             # TODO: "open" feed setting? (command to use for opening post URLs)
36              
37             # TODO: Have list --limit ... only show the latest posts rather than earliest
38              
39             my %COMMANDS = (
40             'update' => \&update,
41             'reload' => \&reload,
42             'read' => \&read_post,
43             'open' => \&open_post,
44             'cat' => \&cat,
45             'list' => \&look,
46             'unread' => \&unread,
47             'mark' => \&mark,
48             'post' => \&post,
49             'feeds' => \&feeds,
50             'groups' => \&groups,
51             'clean' => \&clean,
52             'discover' => \&discover,
53             'export' => \&export_opml,
54             'import' => \&import_opml,
55             'help' => \&help,
56             );
57              
58             my $DOT_LOCAL = File::Spec->catfile(home, '.local/share');
59             my $DOT_CONFIG = File::Spec->catfile(home, '.config');
60              
61             my $DEFAULT_AGENT = "$PRGNAM/$PRGVER ($^O; perl $^V)";
62             my $DEFAULT_PAGER = $^O eq 'MSWin32' ? 'more' : 'less';
63             my $DEFAULT_FORKS = 10;
64             my $DEFAULT_WIDTH = 80;
65              
66             my %VALID_SORTS = map { $_ => 1 } qw(
67             feed
68             title
69             date
70             );
71              
72             my $Z_FMT = '%c';
73             my $Z_UNK = strftime($Z_FMT, localtime 0) =~ s/\w/?/gr;
74              
75             my $RATE_RX = qr/^\d+[kmg]?$/i;
76              
77             my %POST_FMT_CODES = (
78             '%' => sub { '%' },
79             'f' => sub { $_[0]->{ feed } },
80             'i' => sub { $_[0]->{ nossid } },
81             't' => sub { $_[0]->{ displaytitle } // ''},
82             'u' => sub { $_[0]->{ link } // 'N/A' },
83             'a' => sub { $_[0]->{ author } // 'N/A' },
84             'c' => sub { join ', ', @{ $_[0]->{ category } } },
85             's' => sub { $_[0]->{ status } eq 'read' ? 'r' : 'U' },
86             'S' => sub { $_[0]->{ status } eq 'read' ? 'read' : 'unread' },
87             'P' => sub { $_[0]->{ summary } // '' },
88             'C' => sub {
89             strftime('%c', localtime($_[0]->{ updated } // $_[0]->{ published } // return 'N/A'))
90             },
91             'd' => sub {
92             strftime('%d', localtime($_[0]->{ updated } // $_[0]->{ published } // return '??'))
93             },
94             'w' => sub {
95             strftime('%a', localtime($_[0]->{ updated } // $_[0]->{ published } // return '???'))
96             },
97             'W' => sub {
98             strftime('%A', localtime($_[0]->{ updated } // $_[0]->{ published } // return '???'))
99             },
100             'm' => sub {
101             strftime('%b', localtime($_[0]->{ updated } // $_[0]->{ published } // return '???'))
102             },
103             'M' => sub {
104             strftime('%B', localtime($_[0]->{ updated } // $_[0]->{ published } // return '???'))
105             },
106             'n' => sub {
107             strftime('%m', localtime($_[0]->{ updated } // $_[0]->{ published } // return '??'))
108             },
109             'y' => sub {
110             strftime('%g', localtime($_[0]->{ updated } // $_[0]->{ published } // return '??'))
111             },
112             'Y' => sub {
113             strftime('%G', localtime($_[0]->{ updated } // $_[0]->{ published } // return '????'))
114             },
115             'z' => sub {
116             my $t = $_[0]->{ updated } // $_[0]->{ published };
117             if (defined $t) {
118             return strftime($Z_FMT, localtime $t);
119             } else {
120             return $Z_UNK;
121             }
122             },
123             );
124              
125             my %FEED_FMT_CODES = (
126             '%' => sub { '%' },
127             'f' => sub { $_[0]->{ nossname } },
128             'l' => sub { $_[0]->{ nosslink } },
129             't' => sub { $_[0]->{ title } // '' },
130             'u' => sub { $_[0]->{ link } // 'N/A' },
131             'e' => sub { $_[0]->{ description } // '' },
132             'a' => sub { $_[0]->{ author } // 'N/A' },
133             'c' => sub { join ', ', @{ $_[0]->{ category } // [] } },
134             'p' => sub { $_[0]->{ posts } // 0},
135             'r' => sub { ($_[0]->{ posts } // 0) - ($_[0]->{ unread } // 0) },
136             'U' => sub { $_[0]->{ unread } // 0},
137             'C' => sub {
138             strftime('%c', localtime($_[0]->{ updated } // return 'N/A'))
139             },
140             'd' => sub {
141             strftime('%d', localtime($_[0]->{ updated } // return '??'))
142             },
143             'w' => sub {
144             strftime('%a', localtime($_[0]->{ updated } // return '???'))
145             },
146             'W' => sub {
147             strftime('%A', localtime($_[0]->{ updated } // return '???'))
148             },
149             'm' => sub {
150             strftime('%b', localtime($_[0]->{ updated } // return '???'))
151             },
152             'M' => sub {
153             strftime('%B', localtime($_[0]->{ updated } // return '???'))
154             },
155             'n' => sub {
156             strftime('%m', localtime($_[0]->{ updated } // return '??'))
157             },
158             'y' => sub {
159             strftime('%g', localtime($_[0]->{ updated } // return '??'))
160             },
161             'Y' => sub {
162             strftime('%G', localtime($_[0]->{ updated } // return '????'))
163             },
164             'z' => sub {
165             my $t = $_[0]->{ updated };
166             if (defined $t) {
167             return strftime($Z_FMT, localtime $t);
168             } else {
169             return $Z_UNK;
170             }
171             },
172             );
173              
174             my $DEFAULT_READ_FMT = <<'HERE';
175            

%f - %t

176              
177            
178             %P
179            
180              
181            

182             Link: %u
183            

184              
185            

186             Updated: %z
187            

188              
189             HERE
190              
191             my $DEFAULT_POST_FMT = <<'HERE';
192             <14>%f<0>:<15>%i<0>
193             <16>Title<0>: %t
194             <16>Link<0>: %u
195             <16>Author<0>: %a
196             <16>Tags<0>: %c
197             <16>Updated<0>: %z
198             <16>Status<0>: %S
199             HERE
200              
201             my $DEFAULT_FEED_FMT = <<'HERE';
202             <14>%f<0>
203             <16>Title<0>: %t
204             <16>Source<0>: %l
205             <16>Link<0>: %u
206             <16>Author<0>: %a
207             <16>Updated<0>: %z
208             <16>Posts<0>: %p
209             <16>Unread<0>: %U/%p
210              
211             HERE
212              
213             my %DOESNT_NEED_FEED = map { $_ => 1 } qw(
214             discover import help
215             );
216              
217             my $COLOR_CODE_RX = qr/(?:1[0-6]|[0-9])/;
218              
219             my %COLOR_CODES = (
220             0 => 'clear',
221             1 => 'black',
222             2 => 'red',
223             3 => 'green',
224             4 => 'yellow',
225             5 => 'blue',
226             6 => 'magenta',
227             7 => 'cyan',
228             8 => 'white',
229             9 => 'bold black',
230             10 => 'bold red',
231             11 => 'bold green',
232             12 => 'bold yellow',
233             13 => 'bold blue',
234             14 => 'bold magenta',
235             15 => 'bold cyan',
236             16 => 'bold white',
237             );
238              
239             sub _HELP {
240              
241 0     0     my ($fh, $rt) = @_;
242              
243 0           pod2usage(
244             -exitval => 'NOEXIT',
245             -verbose => 99,
246             -sections => 'SYNOPSIS',
247             -output => \$fh,
248             );
249              
250 0 0         if (defined $rt) {
251 0           exit $rt;
252             }
253              
254             }
255              
256             sub _VER {
257              
258 0     0     my ($fh, $rt) = @_;
259              
260 0           print { $fh } <<"HERE";
  0            
261             $PRGNAM - $PRGVER
262              
263             Copyright (C) 2025-2026 Samuel Young
264              
265             This program is free software: you can redistribute it and/or modify
266             it under the terms of the GNU General Public License as published by
267             the Free Software Foundation, either version 3 of the License, or
268             (at your option) any later version.
269             HERE
270              
271 0 0         if (defined $rt) {
272 0           exit $rt;
273             }
274              
275             }
276              
277             sub _set_z_fmt {
278              
279 0     0     my ($z) = @_;
280              
281 0           $Z_FMT = $z;
282 0           $Z_UNK = strftime($Z_FMT, localtime 0) =~ s/\w/?/gr;
283              
284             }
285              
286             sub _default_data_dir {
287              
288 0     0     my $data;
289              
290 0 0 0       if (exists $ENV{ NOSS_DATA }) {
    0          
    0          
291 0           $data = $ENV{ NOSS_DATA };
292             } elsif (exists $ENV{ XDG_DATA_HOME } and -d $ENV{ XDG_DATA_HOME }) {
293 0           $data = File::Spec->catfile($ENV{ XDG_DATA_HOME }, $PRGNAM);
294             } elsif (-d $DOT_LOCAL) {
295 0           $data = File::Spec->catfile($DOT_LOCAL, $PRGNAM);
296             } else {
297 0           $data = File::Spec->catfile(home, ".$PRGNAM");
298             }
299              
300 0           return $data;
301              
302             }
303              
304             sub _default_config {
305              
306 0     0     my $cf;
307              
308 0 0         if (exists $ENV{ NOSS_CONFIG }) {
309 0           return $ENV{ NOSS_CONFIG };
310             }
311              
312 0 0         if (exists $ENV{ XDG_CONFIG_HOME }) {
313              
314             $cf = File::Spec->catfile(
315             $ENV{ XDG_CONFIG_HOME },
316 0           $PRGNAM,
317             "$PRGNAM.conf"
318             );
319 0 0         return $cf if -f $cf;
320              
321             $cf = File::Spec->catfile(
322             $ENV{ XDG_CONFIG_HOME },
323 0           "$PRGNAM.conf"
324             );
325 0 0         return $cf if -f $cf;
326              
327             }
328              
329 0 0         if (-d $DOT_CONFIG) {
330              
331 0           $cf = File::Spec->catfile(
332             $DOT_CONFIG,
333             $PRGNAM,
334             "$PRGNAM.conf"
335             );
336 0 0         return $cf if -f $cf;
337              
338 0           $cf = File::Spec->catfile(
339             $DOT_CONFIG,
340             "$PRGNAM.conf"
341             );
342 0 0         return $cf if -f $cf;
343              
344             }
345              
346 0           $cf = File::Spec->catfile(home, ".$PRGNAM.conf");
347              
348 0 0         return $cf if -f $cf;
349              
350 0           return undef;
351              
352             }
353              
354             sub _default_feeds {
355              
356 0     0     my $ff;
357              
358 0 0         if (exists $ENV{ NOSS_FEEDS }) {
359 0           return $ENV{ NOSS_FEEDS };
360             }
361              
362 0 0         if (exists $ENV{ XDG_CONFIG_HOME }) {
363              
364             $ff = File::Spec->catfile(
365             $ENV{ XDG_CONFIG_HOME },
366 0           $PRGNAM,
367             "$PRGNAM.feeds"
368             );
369 0 0         return $ff if -f $ff;
370              
371             $ff = File::Spec->catfile(
372             $ENV{ XDG_CONFIG_HOME },
373 0           "$PRGNAM.feeds"
374             );
375 0 0         return $ff if -f $ff;
376              
377             }
378              
379 0 0         if (-d $DOT_CONFIG) {
380              
381 0           $ff = File::Spec->catfile(
382             $DOT_CONFIG,
383             $PRGNAM,
384             "$PRGNAM.feeds"
385             );
386 0 0         return $ff if -f $ff;
387              
388 0           $ff = File::Spec->catfile(
389             $DOT_CONFIG,
390             "$PRGNAM.feeds"
391             );
392 0 0         return $ff if -f $ff;
393              
394             }
395              
396 0           $ff = File::Spec->catfile(home, ".$PRGNAM.feeds");
397 0 0         return $ff if -f $ff;
398              
399 0           return undef;
400              
401             }
402              
403             sub _read_config {
404              
405 0     0     my ($self) = @_;
406              
407 0           my $cd = dirname(File::Spec->rel2abs($self->{ ConfFile }));
408              
409             open my $fh, '<', $self->{ ConfFile }
410 0 0         or die "Failed to open $self->{ ConfFile } for reading: $!\n";
411 0           my $slurp = do { local $/ = undef; readline $fh };
  0            
  0            
412 0           close $fh;
413              
414 0           my $json_obj = JSON->new->relaxed;
415 0           my $json = $json_obj->decode($slurp);
416              
417 0 0         unless (ref $json eq 'HASH') {
418 0           die "$self->{ ConfFile } is not a valid $PRGNAM configuration file\n";
419             }
420              
421 0 0         if (defined $json->{ feeds }) {
422 0 0         if (not ref $json->{ feeds }) {
423 0           my $p = $json->{ feeds } =~ s/^~/@{[ home ]}/r;
  0            
424             $self->{ FeedFile } //=
425             File::Spec->file_name_is_absolute($p)
426             ? $json->{ feeds }
427 0 0 0       : File::Spec->catfile($cd, $p);
428             } else {
429 0           warn "'feeds' is not a string, ignoring\n";
430             }
431             }
432              
433 0 0         if (defined $json->{ data }) {
434 0 0         if (not ref $json->{ data }) {
435 0           my $p = $json->{ data } =~ s/^~/@{[ home ]}/r;
  0            
436             $self->{ DataDir } //=
437             File::Spec->file_name_is_absolute($p)
438             ? $json->{ data }
439 0 0 0       : File::Spec->catfile($cd, $p);
440             } else {
441 0           warn "'data' is not a string, ignoring\n";
442             }
443             }
444              
445 0 0         if (defined $json->{ downloads }) {
446 0 0         if ($json->{ downloads } =~ /^\d+$/) {
447 0   0       $self->{ Forks } //= $json->{ downloads };
448             } else {
449 0           warn "'downloads' ($json->{ downloads }) is not an integar, ignoring\n";
450             }
451             }
452              
453 0 0         if (defined $json->{ pager }) {
454 0 0         if (not ref $self->{ pager }) {
455 0   0       $self->{ Pager } //= $json->{ pager };
456             } else {
457 0           warn "'pager' is not a string, ignoring\n";
458             }
459             }
460              
461 0 0         if (defined $json->{ browser }) {
462 0 0         if (not ref $self->{ browser }) {
463 0   0       $self->{ Browser } //= $json->{ browser };
464             } else {
465 0           warn "'browser' is not a string, ignoring\n";
466             }
467             }
468              
469 0 0         if (defined $json->{ limit_rate }) {
470 0 0         if ($self->{ RateLimit } =~ $RATE_RX) {
471 0   0       $self->{ RateLimit } //= $json->{ limit_rate };
472             } else {
473 0           warn "limit_rate' ($json->{ limit_rate }) is not a valid speed, ignoring\n";
474             }
475             }
476              
477 0 0         if (defined $json->{ user_agent }) {
478 0 0         if (ref $json->{ user_agent }) {
479 0           warn "'user_agent' is not a string, ignoring\n";
480             } else {
481 0   0       $self->{ UserAgent } //= $json->{ user_agent };
482             }
483             }
484              
485 0 0         if (defined $json->{ timeout }) {
486 0 0         if ($json->{ timeout } =~ /^\d+(\.\d+)?$/) {
487 0   0       $self->{ Timeout } //= $json->{ timeout };
488             } else {
489 0           warn "'timeout' ($json->{ timeout }) is not numerical, ignoring\n";
490             }
491             }
492              
493 0 0         if (defined $json->{ proxy }) {
494 0 0         if (ref $json->{ proxy }) {
495 0           warn "'proxy' is not a string, ignoring\n";
496             } else {
497 0   0       $self->{ Proxy } //= $json->{ proxy };
498             }
499             }
500              
501 0 0         if (defined $json->{ proxy_user }) {
502 0 0         if ($json->{ proxy_user } =~ /^[^:]+:[^:]+$/) {
503 0   0       $self->{ ProxyUser } //= $json->{ proxy_user };
504             } else {
505 0           warn "'proxy_user' ($json->{ proxy_user }) is not a valid proxy user string, ignoring\n";
506             }
507             }
508              
509 0 0         if (defined $json->{ sort }) {
510 0 0         if (exists $VALID_SORTS{ $json->{ sort } }) {
511 0   0       $self->{ Sort } //= $json->{ sort };
512             } else {
513 0           warn sprintf "'sort' must be one of the following: %s\n", join(', ', sort keys %VALID_SORTS);
514             }
515             }
516              
517 0 0         if (defined $json->{ line_width }) {
518 0 0 0       if ($json->{ line_width } =~ /^\d+$/ and $json->{ line_width } > 0) {
519 0   0       $self->{ LineWidth } //= $json->{ line_width };
520             } else {
521 0           warn "'line_width' must be an integar greater than 0, ignoring\n";
522             }
523             }
524              
525 0 0         if (defined $json->{ list_format }) {
526 0 0         if (ref $json->{ list_format }) {
527 0           warn "'list_format' is not a format string, ignoring\n";
528             } else {
529 0   0       $self->{ ListFmt } //= $json->{ list_format };
530             }
531             }
532              
533 0 0         if (defined $json->{ read_format }) {
534 0 0         if (ref $json->{ read_format }) {
535 0           warn "'read_format' is not a format string, ignoring\n";
536             } else {
537 0   0       $self->{ ReadFmt } //= $json->{ read_format };
538             }
539             }
540              
541 0 0         if (defined $json->{ post_format }) {
542 0 0         if (ref $json->{ post_format }) {
543 0           warn "'post_format' is not a format string, ignoring\n";
544             } else {
545 0   0       $self->{ PostFmt } //= $json->{ post_format };
546             }
547             }
548              
549 0 0         if (defined $json->{ feeds_format }) {
550 0 0         if (ref $json->{ feeds_format }) {
551 0           warn "'feeds_format' is not a format string, ignoring\n";
552             } else {
553 0   0       $self->{ FeedsFmt } //= $json->{ feeds_fmt };
554             }
555             }
556              
557 0 0         if (defined $json->{ autoclean }) {
558 0   0       $self->{ AutoClean } //= !! $json->{ autoclean };
559             }
560              
561 0 0         if (defined $json->{ time_format }) {
562 0 0         if (ref $json->{ time_format }) {
563 0           warn "'time_format' is not a format string, ignoring\n";
564             } else {
565 0   0       $self->{ TimeFmt } //= $json->{ time_format };
566             }
567             }
568              
569 0 0         if (defined $json->{ list_limit }) {
570 0 0         if ($json->{ list_limit } =~ /^-?\d+$/) {
571 0   0       $self->{ ListLimit } //= $json->{ list_limit };
572             } else {
573 0           warn "'list_limit' ($json->{ list_limit }) is not an integar, ignoring\n";
574             }
575             }
576              
577 0 0         if (defined $json->{ colors }) {
578 0 0         if (ref $json->{ colors } ne 'HASH') {
579 0           warn "'colors' is not a key-value map, ignoring\n";
580             } else {
581 0           for my $k (keys %{ $json->{ colors } }) {
  0            
582 0 0         if (not exists $self->{ ColorMap }{ $k }) {
583 0           warn "'$k' is not a valid color code, ignoring\n";
584 0           next;
585             }
586 0           $self->{ ColorMap }{ $k } = $json->{ colors }{ $k };
587             }
588             }
589             }
590              
591 0 0         if (defined $json->{ list_unread_format }) {
592 0 0         if (ref $json->{ list_unread_format }) {
593 0           warn "'list_unread_format' is not a format string, ignoring\n";
594             } else {
595 0   0       $self->{ ListUnreadFmt } //= $json->{ list_unread_format };
596             }
597             }
598              
599 0 0         if (defined $json->{ colored_output }) {
600             # If true, set to undef so that noss can automatically disable the use
601             # of color when not writing to a terminal.
602 0 0 0       $self->{ UseColor } //= $json->{ colored_output } ? undef : 0;
603             }
604              
605 0           return 1;
606              
607             }
608              
609             # Note to a confused future self:
610             # When adding a new feed parameter, the following locations should be updated:
611             # * This _feed_params subroutine
612             # * BaseConfig attributes
613             # * FeedConfig group attribute initialization
614             # * (Base|Feed|Group)Config documentation
615             # * FeedConfig tests
616             # * Feed configuration section in manual
617             sub _feed_params {
618              
619 0     0     my ($ref) = @_;
620              
621 0           my %params;
622              
623 0 0         if (defined $ref->{ limit }) {
624 0 0         if ($ref->{ limit } =~ /^\d+$/) {
625 0           $params{ limit } = $ref->{ limit };
626             } else {
627 0           warn "'limit' ($ref->{ limit }) is not an integar, ignoring\n";
628             }
629             }
630              
631 0 0         if (defined $ref->{ respect_skip }) {
632 0           $params{ respect_skip } = !! $ref->{ respect_skip };
633             }
634              
635 0 0         if (defined $ref->{ include_title }) {
636 0 0         if (ref $ref->{ include_title } eq 'ARRAY') {
    0          
637 0           $params{ include_title } = [ map { _arg2rx($_) } @{ $ref->{ include_title } } ];
  0            
  0            
638             } elsif (not ref $ref->{ include_title }) {
639 0           $params{ include_title } = [ _arg2rx($ref->{ include_title }) ];
640             } else {
641 0           warn "'include_title' is not an array or string, ignoring\n";
642             }
643             }
644              
645 0 0         if (defined $ref->{ exclude_title }) {
646 0 0         if (ref $ref->{ exclude_title } eq 'ARRAY') {
    0          
647 0           $params{ exclude_title } = [ map { _arg2rx($_) } @{ $ref->{ exclude_title } } ];
  0            
  0            
648             } elsif (not ref $ref->{ exclude_title }) {
649 0           $params{ exclude_title } = [ _arg2rx($ref->{ exclude_title }) ];
650             } else {
651 0           warn "'exclude_title' is not an array or string, ignoring\n";
652             }
653             }
654              
655 0 0         if (defined $ref->{ include_content }) {
656 0 0         if (ref $ref->{ include_content } eq 'ARRAY') {
    0          
657 0           $params{ include_content } = [ map { _arg2rx($_) } @{ $ref->{ include_content } } ];
  0            
  0            
658             } elsif (not ref $ref->{ include_content }) {
659 0           $params{ include_content } = [ _arg2rx($ref->{ include_content }) ];
660             } else {
661 0           warn "'include_content' is not an array or string, ignoring\n";
662             }
663             }
664              
665 0 0         if (defined $ref->{ exclude_content }) {
666 0 0         if (ref $ref->{ exclude_content } eq 'ARRAY') {
    0          
667 0           $params{ exclude_content } = [ map { _arg2rx($_) } @{ $ref->{ exclude_content } } ];
  0            
  0            
668             } elsif (not ref $ref->{ exclude_content }) {
669 0           $params{ exclude_content } = [ _arg2rx($ref->{ exclude_content }) ];
670             } else {
671 0           warn "'exclude_content' is not an array or string, ignoring\n";
672             }
673             }
674              
675 0 0         if (defined $ref->{ include_tags }) {
676 0 0         if (ref $ref->{ include_tags } eq 'ARRAY') {
    0          
677 0           $params{ include_tags } = $ref->{ include_tags };
678             } elsif (not ref $ref->{ include_tags }) {
679 0           $params{ include_tags } = [ $ref->{ include_tags } ];
680             } else {
681 0           warn "'include_tags' is not an array or string, ignoring\n";
682             }
683             }
684              
685 0 0         if (defined $ref->{ exclude_tags }) {
686 0 0         if (ref $ref->{ exclude_tags } eq 'ARRAY') {
    0          
687 0           $params{ exclude_tags } = $ref->{ exclude_tags };
688             } elsif (not ref $ref->{ exclude_tags }) {
689 0           $params{ exclude_tags } = [ $ref->{ exclude_tags } ];
690             } else {
691 0           warn "'exclude_tags' is not an array or string, ignoring\n";
692             }
693             }
694              
695 0 0         if (defined $ref->{ autoread }) {
696 0           $params{ autoread } = !! $ref->{ autoread };
697             }
698              
699 0 0         if (defined $ref->{ default_update }) {
700 0           $params{ default_update } = !! $ref->{ default_update };
701             }
702              
703 0 0         if (defined $ref->{ hidden }) {
704 0           $params{ hidden } = !! $ref->{ hidden };
705             }
706              
707 0           return %params;
708              
709             }
710              
711             sub _read_feed_file {
712              
713 0     0     my ($self) = @_;
714              
715             open my $fh, '<', $self->{ FeedFile }
716 0 0         or die "Failed to open $self->{ FeedFile } for reading: $!\n";
717 0           my $slurp = do { local $/ = undef; readline $fh };
  0            
  0            
718 0           close $fh;
719              
720 0           my $json_obj = JSON->new->relaxed;
721 0           my $json = $json_obj->decode($slurp);
722              
723 0 0         unless (ref $json eq 'HASH') {
724 0           die "$self->{ FeedFile } is not a valid feed file\n";
725             }
726              
727 0 0         unless (exists $json->{ feeds }) {
728 0           die "Failed to read $self->{ FeedFile }: missing 'feeds' list\n";
729             }
730              
731 0           my $feeds = $json->{ feeds };
732 0   0       my $groups = $json->{ groups } // {};
733 0   0       my $default = $json->{ default } // {};
734              
735 0 0         unless (ref $feeds eq 'HASH') {
736 0           die "Failed to read $self->{ FeedFile }: 'feeds' must be a key-value map\n";
737             }
738              
739 0 0         unless (ref $groups eq 'HASH') {
740 0           die "Failed to read $self->{ FeedFile }: 'groups' must be a key-value map\n";
741             }
742              
743 0 0         unless (ref $default eq 'HASH') {
744 0           die "Failed to read $self->{ FeedFile }: 'default' must be a key-value map\n";
745             }
746              
747 0           for my $k (keys %$groups) {
748 0 0         unless ($k =~ /^\w+$/) {
749 0           warn "'$k' is not a valid feed group: name contains invalid characters, ignoring\n";
750 0           delete $groups->{ $k };
751             }
752 0 0         if (exists $feeds->{ $k }) {
753 0           die "'$k' is both the name of a feed and group\n";
754             }
755             }
756              
757 0           for my $k (keys %$feeds) {
758 0 0         unless ($k =~ /^\w+$/) {
759 0           warn "'$k' is not a valid feed name: contains invalid characters, ignoring\n";
760 0           delete $feeds->{ $k };
761             }
762             }
763              
764              
765 0 0         if (%$default) {
766 0           my %params = _feed_params($default);
767 0           $self->{ DefaultGroup } = WWW::Noss::GroupConfig->new(
768             name => ':all',
769             feeds => [ keys %$feeds ],
770             %params
771             );
772             }
773              
774 0           for my $k (keys %$groups) {
775 0           my $g = $groups->{ $k };
776              
777 0 0         if (ref $g eq 'ARRAY') {
    0          
778 0           $g = { feeds => $g };
779             } elsif (ref $g ne 'HASH') {
780 0           warn "'$k' is neither a feed list or key-value map, skipping\n";
781 0           next;
782             }
783              
784 0 0         unless (ref $g->{ feeds } eq 'ARRAY') {
785 0           warn "'$k' group does not contain a feed list, skipping\n";
786 0           next;
787             }
788              
789 0           my %params = _feed_params($g);
790              
791             $self->{ Groups }{ $k } = WWW::Noss::GroupConfig->new(
792             name => $k,
793             feeds => $g->{ feeds },
794 0           %params
795             );
796              
797             }
798              
799 0           for my $k (keys %$feeds) {
800 0           my $f = $feeds->{ $k };
801              
802 0 0 0       if (not ref $f and defined $f) {
    0          
803 0           $f = { feed => $f };
804             } elsif (ref $f ne 'HASH') {
805 0           warn "'$k' is neither a feed link or a key-value map, skipping\n";
806 0           next;
807             }
808              
809 0 0         unless (exists $f->{ feed }) {
810 0           warn "'$k' feed does not contain a feed link, skipping\n";
811 0           next;
812             }
813              
814 0 0 0       if (ref $f->{ feed } or not defined $f->{ feed }) {
815 0           warn "'$k' feed link is not a string, skipping\n";
816 0           next;
817             }
818              
819 0           my @groups = grep { $_->has_feed($k) } values %{ $self->{ Groups } };
  0            
  0            
820              
821 0           my %params = _feed_params($f);
822              
823             $self->{ Feeds }{ $k } = WWW::Noss::FeedConfig->new(
824             name => $k,
825             feed => $f->{ feed },
826             default => $self->{ DefaultGroup },
827             groups => \@groups,
828             path => File::Spec->catfile($self->{ FeedDir }, "$k.feed"),
829             etag => File::Spec->catfile($self->{ EtagDir }, "$k.etag"),
830 0           retry_cache => File::Spec->catfile($self->{ RetryDir }, "$k.retry"),
831             %params
832             );
833              
834             }
835              
836 0 0         unless (%{ $self->{ Feeds } }) {
  0            
837 0           die "$PRGNAM found no feeds in $self->{ FeedFile }\n";
838             }
839              
840 0           return 1;
841              
842             }
843              
844             sub _arg2rx {
845              
846 0     0     my ($str) = @_;
847              
848 0 0         if ($str =~ /^\/(.*)\/$/) {
849 0           return qr/$1/i;
850             } else {
851 0           return qr/\Q$str\E/i;
852             }
853              
854             }
855              
856             sub _fmt {
857              
858 0     0     my ($fmt, $codes, $colors) = @_;
859 0   0       $colors //= {};
860              
861 0 0         $fmt .= "\n" unless $fmt =~ /\n$/;
862              
863 0           my @subs;
864 0           my $colored = 0;
865              
866 0           $fmt =~ s{(?%(?:[+\-]?\d+)?.)|(?<$COLOR_CODE_RX>)}{
867 0 0         if (defined $+{ Fmt }) {
868 0           my $code = substr $+{ Fmt }, 1;
869 0           my $c = chop $code;
870 0 0         unless (exists $codes->{ $c }) {
871 0           die "'%$code$c' is not a valid formatting code\n";
872             }
873 0           push @subs, $codes->{ $c };
874 0           '%' . $code . 's';
875             } else {
876 0           my $code = substr $+{ Color }, 1, -1;
877 0 0         if (not exists $colors->{ $code }) {
878 0           $+{ Color };
879             } else {
880 0           $colored = 1;
881 0           color($colors->{ $code });
882             }
883             }
884             }ge;
885              
886 0 0         if ($colored) {
887 0           $fmt .= color('reset');
888             }
889              
890 0     0     return sub { sprintf $fmt, map { $_->($_[0]) } @subs };
  0            
  0            
891              
892             }
893              
894             sub _rm_color_codes {
895              
896 0     0     my ($str) = @_;
897              
898 0           return $str =~ s/<$COLOR_CODE_RX>//gr;
899              
900             }
901              
902             sub err {
903              
904 0     0 0   my ($self, @args) = @_;
905              
906 0           my $err = join '', @args;
907              
908 0 0 0       if ($self->{ UseColor } and -t STDERR) {
909 0           warn colored($err, 'bold red') . "\n";
910             } else {
911 0           warn "$err\n";
912             }
913              
914             }
915              
916             sub _get_feed {
917              
918 0     0     my ($self, $feed) = @_;
919              
920 0 0         if ($feed->feed =~ /^file:\/\//) {
    0          
921              
922 0           my $f = $feed->feed =~ s/^file:\/\///r;
923              
924 0           $f =~ s/^~/@{[ home ]}/;
  0            
925              
926 0 0         unless (File::Spec->file_name_is_absolute($f)) {
927             $f = File::Spec->catfile(
928 0           dirname($self->{ FeedFile }),
929             $f
930             );
931             }
932              
933 0 0         copy($f, $feed->path)
934             or die sprintf "Failed to copy %s to %s: %s\n", $f, $feed->path, $!;
935             # Copy over access and mod times
936 0           utime((stat($f))[8, 9], $feed->path);
937              
938 0           return $feed->path;
939              
940             } elsif ($feed->feed =~ /^shell:\/\//) {
941              
942 0           my $cmd = $feed->feed =~ s/^shell:\/\///r;
943              
944 0 0         open my $fh, '>', $feed->path
945             or die sprintf "Failed to open %s for writing: %s\n", $feed->path, $!;
946              
947             # cd into feed file directory, so that shell command is ran from said
948             # directory.
949 0           my $cwd = cwd;
950              
951             chdir dirname($self->{ FeedFile })
952 0 0         or die "Failed to chdir to $self->{ FeedFile }: $!\n";
953              
954 0           my $qx = qx/$cmd/;
955              
956 0 0         unless ($? >> 8 == 0) {
957 0 0         chdir $cwd or die "Failed to chdir to $cwd: $!\n";
958 0           die "Failed to execute '$cmd'\n";
959             }
960              
961 0           print { $fh } $qx;
  0            
962              
963 0           close $fh;
964              
965 0 0         chdir $cwd or die "Failed to chdir to $cwd: $!\n";
966              
967 0           return $feed->path;
968              
969             # Otherwise, just try to curl the URL
970             } else {
971              
972             my ($rt, $resp, $head) = curl(
973             $feed->feed,
974             $feed->path,
975             verbose => 0,
976             remote_time => 1,
977             etag_save => $feed->etag,
978             limit_rate => $self->{ RateLimit },
979             user_agent => $self->{ UserAgent },
980             timeout => $self->{ Timeout },
981             fail => 1,
982             proxy => $self->{ Proxy },
983             proxy_user => $self->{ ProxyUser },
984             (
985 0 0 0       !$self->{ Unconditional } && -f $feed->path
    0          
986             ? (
987             time_cond => $feed->path,
988             etag_compare => (-s $feed->etag ? $feed->etag : undef),
989             )
990             : ()
991             ),
992             redirect => 1,
993             compressed => 1,
994             );
995              
996 0 0 0       if (defined $resp and $resp->[1] eq '429') {
997 0           my $retry = $head->{ 'retry-after' };
998 0 0 0       if (defined $retry and $retry =~ /^\d+$/) {
999 0           $feed->set_retry($retry + time);
1000             }
1001             }
1002              
1003 0 0         if ($rt != 0) {
1004 0           my $e;
1005 0 0 0       if (defined $resp and $resp->[1] =~ /^[45]/) {
1006 0   0       $e = "$resp->[1] " . ($resp->[2] || http_status_string($resp->[1]));
1007             } else {
1008 0           $e = curl_error($rt);
1009             }
1010 0           die "$e\n";
1011             }
1012              
1013 0           return $feed->path;
1014              
1015             }
1016              
1017             }
1018              
1019             sub update {
1020              
1021 0     0 1   my ($self) = @_;
1022              
1023 0           require Parallel::ForkManager;
1024              
1025             # --hard implies --unconditional
1026 0 0         if ($self->{ HardReload }) {
1027 0           $self->{ Unconditional } = 1;
1028             }
1029              
1030 0           my @updates;
1031              
1032 0 0         if (@{ $self->{ Args } }) {
  0 0          
1033 0           my %feedset;
1034 0           for my $arg (@{ $self->{ Args } }) {
  0            
1035 0 0         if (exists $self->{ Feeds }{ $arg }) {
    0          
1036 0           $feedset{ $arg } = 1;
1037             } elsif ($self->{ Groups }{ $arg }) {
1038 0           for my $k (@{ $self->{ Groups }{ $arg }->feeds }) {
  0            
1039 0           $feedset{ $k } = 1;
1040             }
1041             } else {
1042 0           warn "'$arg' is not the name of a feed or feed group, skipping\n";
1043             }
1044             }
1045 0           @updates = keys %feedset;
1046             } elsif ($self->{ NonDefaults }) {
1047 0           @updates = keys %{ $self->{ Feeds } };
  0            
1048             } else {
1049             @updates =
1050 0           grep { $self->{ Feeds }{ $_ }->default_update }
1051 0           keys %{ $self->{ Feeds } };
  0            
1052             }
1053              
1054 0 0         if ($self->{ NewOnly }) {
1055 0           @updates = grep { !$self->{ DB }->has_feed($_) } @updates;
  0            
1056             }
1057              
1058 0 0         unless (@updates) {
1059 0           die "No feeds can be updated\n";
1060             }
1061              
1062 0           @updates = map { [ $_, $self->{ DB }->skip($_) ] } @updates;
  0            
1063              
1064 0           my @change;
1065              
1066 0           my $pm = Parallel::ForkManager->new($self->{ Forks });
1067             $pm->run_on_finish(sub {
1068 0 0   0     push @change, ${ $_[5] } if defined $_[5];
  0            
1069 0           });
1070 0           DOWNLOAD: for my $u (@updates) {
1071              
1072 0 0         $pm->start and next DOWNLOAD;
1073              
1074 0           my ($name, $skip) = @$u;
1075 0           my $feed = $self->{ Feeds }{ $name };
1076              
1077 0 0 0       if ($feed->respect_skip and !$self->{ Unconditional } and $skip) {
      0        
1078 0           say "Skipping $name";
1079 0           $pm->finish;
1080 0           last;
1081             }
1082              
1083 0 0 0       if ($feed->respect_skip and !$self->{ Unconditional } and !$feed->can_we_retry) {
      0        
1084 0           say "Skipping $name; performed too many requests";
1085 0           $pm->finish;
1086 0           last;
1087             }
1088              
1089 0           my $changed = 0;
1090              
1091 0 0         my $oldmod = -f $feed->path ? (stat($feed->path))[9] : 0;
1092              
1093 0           eval { $self->_get_feed($feed) };
  0            
1094              
1095 0 0 0       if ($@ ne '' or not -f $feed->path) {
1096 0   0       my $e = $@ || 'unknown error';
1097 0           chomp $e;
1098 0           $self->err(sprintf "Failed to fetch %s: %s", $feed->feed, $e);
1099             } else {
1100 0           printf "Fetched %s\n", $feed->feed;
1101 0           my $newmod = (stat($feed->path))[9];
1102 0           $changed = $newmod != $oldmod;
1103             }
1104              
1105 0 0         if ($self->{ HardReload }) {
1106 0           $pm->finish(0, \$name);
1107             } else {
1108 0 0         $pm->finish(0, $changed ? \$name : undef);
1109             }
1110              
1111             }
1112              
1113 0           $pm->wait_all_children;
1114              
1115 0           my %feed_updates;
1116              
1117 0           for my $c (@change) {
1118              
1119 0           my $new = eval {
1120 0 0         if ($self->{ HardReload }) {
1121 0           $self->{ DB }->del_feeds($c);
1122             }
1123 0           $self->{ DB }->load_feed($self->{ Feeds }{ $c });
1124             };
1125              
1126 0 0         if ($@ ne '') {
1127 0           my $e = $@;
1128 0           chomp $e;
1129 0           $self->err("Error updating $c: $e, skipping");
1130 0           next;
1131             }
1132              
1133 0 0         next if $new == 0;
1134 0           $feed_updates{ $c } = $new;
1135              
1136             }
1137              
1138 0 0         if (%feed_updates) {
1139 0           for my $k (sort keys %feed_updates) {
1140 0           say "$k: $feed_updates{ $k } new posts";
1141             }
1142             } else {
1143 0           say "No new posts";
1144             }
1145              
1146 0           return 1;
1147              
1148             }
1149              
1150             sub reload {
1151              
1152 0     0 1   my ($self) = @_;
1153              
1154 0           my @reloads;
1155              
1156 0 0         if (@{ $self->{ Args } }) {
  0            
1157 0           my %feedset;
1158 0           for my $arg (@{ $self->{ Args } }) {
  0            
1159 0 0         if (exists $self->{ Feeds }{ $arg }) {
    0          
1160 0           $feedset{ $arg } = 1;
1161             } elsif (exists $self->{ Groups }{ $arg }) {
1162 0           for my $k (@{ $self->{ Groups }{ $arg }->feeds }) {
  0            
1163 0           $feedset{ $k } = 1;
1164             }
1165             } else {
1166 0           warn "'$arg' is not the name of a feed or feed group, skipping\n";
1167             }
1168             }
1169              
1170 0           for my $f (keys %feedset) {
1171 0 0         if (-f $self->{ Feeds }{ $f }->path) {
1172 0           push @reloads, $f;
1173             } else {
1174 0           $self->err("'$f' does not have a local feed file, skipping");
1175             }
1176             }
1177              
1178             } else {
1179             @reloads =
1180 0           grep { -f $self->{ Feeds }{ $_ }->path }
1181 0           keys %{ $self->{ Feeds } };
  0            
1182             }
1183              
1184 0 0         unless (@reloads) {
1185 0           say "No feeds to reload";
1186 0           return 1;
1187             }
1188              
1189 0           my %feed_updates;
1190              
1191 0           for my $r (@reloads) {
1192              
1193 0           my $new = eval {
1194 0 0         if ($self->{ HardReload }) {
1195 0           $self->{ DB }->del_feeds($r);
1196             }
1197 0           $self->{ DB }->load_feed($self->{ Feeds }{ $r });
1198             };
1199              
1200 0 0         unless (defined $new) {
1201 0           my $e = $@;
1202 0           chomp $e;
1203 0 0         if ($e ne '') {
1204 0           $self->err("Failed to reload $r: $e, skipping");
1205             } else {
1206 0           $self->err("Failed to reload $r, skipping");
1207             }
1208 0           next;
1209             }
1210              
1211 0 0         next if $new == 0;
1212 0           $feed_updates{ $r } = $new;
1213              
1214             }
1215              
1216 0 0         if (%feed_updates) {
1217 0           for my $k (sort keys %feed_updates) {
1218 0           say "$k: $feed_updates{ $k } new posts";
1219             }
1220             } else {
1221 0           say "No new posts";
1222             }
1223              
1224 0           return 1;
1225              
1226             }
1227              
1228             sub read_post {
1229              
1230 0     0 1   my ($self) = @_;
1231              
1232 0           my $feed_name = shift @{ $self->{ Args} };
  0            
1233              
1234 0 0         unless (defined $feed_name) {
1235 0           die "'$self->{ Cmd }' requires a feed name as argument\n";
1236             }
1237              
1238 0 0         unless (exists $self->{ Feeds }{ $feed_name }) {
1239 0           die "'$feed_name' is not the name of a feed\n";
1240             }
1241              
1242 0           my $id = shift @{ $self->{ Args } };
  0            
1243              
1244 0           my $post;
1245              
1246 0 0         if (defined $id) {
1247 0 0         if ($id !~ /^-?\d+$/) {
1248 0           die "Post ID must be an integar\n";
1249             }
1250 0           $post = $self->{ DB }->post($feed_name, $id);
1251 0 0         unless (defined $post) {
1252 0           die "'$feed_name:$id' does not exist\n";
1253             }
1254             } else {
1255 0           $post = $self->{ DB }->first_unread($feed_name);
1256 0 0         unless (defined $post) {
1257 0           say "$feed_name has no unread posts, please manually specify a post ID";
1258 0           return 1;
1259             }
1260             }
1261              
1262 0           $self->{ ReadFmt } = _rm_color_codes($self->{ ReadFmt });
1263              
1264 0           my $fmt = do {
1265 0           my %fmt_codes = %POST_FMT_CODES;
1266 0           for my $f (keys %fmt_codes) {
1267 0 0         next if $f eq 'P';
1268             $fmt_codes{ $f } = sub {
1269 0     0     escape_html($POST_FMT_CODES{ $f }->($_[0]))
1270 0           };
1271             }
1272 0           _fmt($self->{ ReadFmt }, \%fmt_codes);
1273             };
1274              
1275 0           my $dump;
1276              
1277 0 0         if ($self->{ ReadHtml }) {
1278              
1279 0           $dump = $fmt->($post);
1280              
1281             } else {
1282              
1283 0           my ($tmp_html_fh, $tmp_html_nm) = tempfile(UNLINK => 1);
1284 0           print { $tmp_html_fh } $fmt->($post);
  0            
1285 0           close $tmp_html_fh;
1286              
1287 0           $dump = lynx_dump($tmp_html_nm, width => $self->{ LineWidth });
1288              
1289             }
1290              
1291              
1292 0 0         if ($self->{ Stdout }) {
1293              
1294 0           say $dump;
1295              
1296             } else {
1297              
1298 0           my ($tmp_lynx_fh, $tmp_lynx_nm) = tempfile(UNLINK => 1);
1299 0           print { $tmp_lynx_fh } $dump;
  0            
1300 0           close $tmp_lynx_fh;
1301              
1302 0           system "$self->{ Pager } $tmp_lynx_nm";
1303              
1304 0 0         unless ($? >> 8 == 0) {
1305 0           die "Failed to run less on $tmp_lynx_nm\n";
1306             }
1307              
1308             }
1309              
1310 0 0         unless ($self->{ NoMark }) {
1311             $self->{ DB }->mark('read', $feed_name, $post->{ nossid })
1312 0 0         or die "Failed to mark '$feed_name:$post->{ nossid }' as read";
1313             }
1314              
1315 0           return 1;
1316              
1317              
1318             }
1319              
1320             sub open_post {
1321              
1322 0     0 1   my ($self) = @_;
1323              
1324 0           my $feed_name = shift @{ $self->{ Args} };
  0            
1325              
1326 0 0         unless (defined $feed_name) {
1327 0           die "'open' requires a feed name as argument\n";
1328             }
1329              
1330 0 0         unless (exists $self->{ Feeds }{ $feed_name }) {
1331 0           die "'$feed_name' is not the name of a feed\n";
1332             }
1333              
1334 0           my $id = shift @{ $self->{ Args } };
  0            
1335              
1336 0           my $post;
1337             my $url;
1338              
1339 0 0         if (not defined $id) {
1340 0           my $feed_info = $self->{ DB }->feed($feed_name);
1341 0 0         if (not defined $feed_info) {
1342 0           die "$feed_name does not exist in noss's database, perhaps try running the update command?\n";
1343             }
1344 0           $url = $feed_info->{ link };
1345 0 0         if (not defined $url) {
1346 0           die "$feed_name does not have a homepage URL\n";
1347             }
1348             } else {
1349 0 0         if ($id !~ /^-?\d+$/) {
1350 0           die "Post ID must be an integar\n";
1351             }
1352 0           $post = $self->{ DB }->post($feed_name, $id);
1353 0 0         if (not defined $post) {
1354 0           die "'$feed_name:$id' does not exist\n";
1355             }
1356 0           $url = $post->{ link };
1357 0 0         if (not defined $url) {
1358 0           die "Cannot open $feed_name:$id: Has no post URL\n";
1359             }
1360             }
1361              
1362 0           system "$self->{ Browser } $url";
1363              
1364 0 0         unless ($? >> 8 == 0) {
1365 0           die "Failed to open $url with $self->{ Browser }\n";
1366             }
1367              
1368 0 0 0       if (defined $id and not $self->{ NoMark }) {
1369             $self->{ DB }->mark('read', $feed_name, $post->{ nossid })
1370 0 0         or die "Failed to mark '$feed_name:$id' as read";
1371             }
1372              
1373 0           return 1;
1374              
1375             }
1376              
1377             sub cat {
1378              
1379 0     0 1   my ($self) = @_;
1380              
1381 0           $self->{ Stdout } = 1;
1382 0           $self->read_post;
1383              
1384 0           return 1;
1385              
1386             }
1387              
1388             sub look {
1389              
1390 0     0 1   my ($self) = @_;
1391              
1392 0           my @feeds;
1393              
1394 0 0         if (@{ $self->{ Args } }) {
  0 0          
1395 0           my %feedset;
1396 0           for my $arg (@{ $self->{ Args } }) {
  0            
1397 0 0         if (exists $self->{ Feeds }{ $arg }) {
    0          
1398 0           $feedset{ $arg } = 1;
1399             } elsif (exists $self->{ Groups }{ $arg }) {
1400 0           for my $k (@{ $self->{ Groups }{ $arg }->feeds }) {
  0            
1401 0           $feedset{ $k } = 1;
1402             }
1403             } else {
1404 0           warn "'$arg' is not the name of a feed or feed group, skipping\n";
1405             }
1406             }
1407 0           @feeds = keys %feedset;
1408             } elsif ($self->{ ShowHidden }) {
1409 0           @feeds = keys %{ $self->{ Feeds } };
  0            
1410             } else {
1411             @feeds =
1412 0           grep { not $self->{ Feeds }{ $_ }->hidden }
1413 0           keys %{ $self->{ Feeds } };
  0            
1414             }
1415              
1416             my $titlerx =
1417             defined $self->{ Title }
1418             ? _arg2rx($self->{ Title })
1419 0 0         : undef;
1420 0           my @contrx = map { _arg2rx($_) } @{ $self->{ Content } };
  0            
  0            
1421              
1422 0 0         unless (@feeds) {
1423 0           return 1;
1424             }
1425              
1426 0   0       my $idlen = length($self->{ DB }->largest_id(@feeds) // 0);
1427 0   0       my $feedlen = max(map { length } @feeds) // 1;
  0            
1428              
1429 0           my $readfmt = do {
1430 0           my $fmt = $self->{ ListFmt };
1431 0 0         if (not defined $fmt) {
1432 0           $fmt = sprintf "<7>%%s <6>%%-%df <3>%%%di <8>%%t", $feedlen, $idlen;
1433             }
1434 0 0         if (!$self->{ UseColor }) {
1435 0           $fmt = _rm_color_codes($fmt);
1436             }
1437 0           _fmt($fmt, \%POST_FMT_CODES, $self->{ ColorMap });
1438             };
1439 0           my $unreadfmt = do {
1440 0           my $fmt = $self->{ ListUnreadFmt };
1441 0 0         if (not defined $fmt) {
1442 0 0         if (defined $self->{ ListFmt }) {
1443 0           $fmt = $self->{ ListFmt };
1444             } else {
1445 0           $fmt = sprintf "<15>%%s <14>%%-%df <11>%%%di <16>%%t", $feedlen, $idlen;
1446             }
1447             }
1448 0 0         if (!$self->{ UseColor }) {
1449 0           $fmt = _rm_color_codes($fmt);
1450             }
1451 0           _fmt($fmt, \%POST_FMT_CODES, $self->{ ColorMap });
1452             };
1453              
1454             my $callback = sub {
1455 0 0   0     print $_[0]->{ status } eq 'read'
1456             ? $readfmt->($_[0])
1457             : $unreadfmt->($_[0]);
1458 0           };
1459              
1460             $self->{ DB }->look(
1461             title => $titlerx,
1462             feeds => \@feeds,
1463             status => $self->{ Status },
1464 0           tags => [ map { qr/\Q$_\E/i } @{ $self->{ Tags } } ],
  0            
1465             content => \@contrx,
1466             order => $self->{ Sort },
1467             reverse => $self->{ Reverse },
1468             limit => $self->{ ListLimit },
1469 0           callback => $callback,
1470             );
1471              
1472 0           return 1;
1473              
1474             }
1475              
1476             sub unread {
1477              
1478 0     0 1   my ($self) = @_;
1479              
1480 0           $self->{ Status } = 'unread';
1481              
1482 0           $self->look;
1483              
1484 0           return 1;
1485              
1486             }
1487              
1488             sub mark {
1489              
1490 0     0 1   my ($self) = @_;
1491              
1492 0           my $status = shift @{ $self->{ Args } };
  0            
1493              
1494 0 0         unless (defined $status) {
1495 0           die "'mark' requires a status as argument\n";
1496             }
1497              
1498 0 0         unless ($status =~ /^(un)?read$/) {
1499 0           die "status must either be 'read' or 'unread'\n";
1500             }
1501              
1502 0           my @feeds;
1503             my @posts;
1504              
1505 0           my $targ = shift @{ $self->{ Args } };
  0            
1506              
1507 0 0 0       if (not defined $targ and not $self->{ MarkAll }) {
    0 0        
1508 0           die "mark requires a feed name or group as argument\n";
1509             } elsif (defined $targ and $self->{ MarkAll }) {
1510 0           die "mark --all should not be given a feed name or group as argument\n";
1511             }
1512              
1513 0 0         if ($self->{ MarkAll }) {
    0          
    0          
1514 0           @feeds = keys %{ $self->{ Feeds } };
  0            
1515 0           @posts = ();
1516             } elsif (exists $self->{ Groups }{ $targ }) {
1517 0           @feeds = @{ $self->{ Groups }{ $targ }->feeds };
  0            
1518 0           @posts = ();
1519             } elsif (exists $self->{ Feeds }{ $targ }) {
1520 0           @feeds = ($targ);
1521 0           for my $p (@{ $self->{ Args } }) {
  0            
1522 0 0         unless ($p =~ /^(?\d+)(-(?\d+))?$/) {
1523 0           die "'$p' is not a post argument\n";
1524             }
1525 0   0       push @posts, $+{ from } .. $+{ to } // $+{ from };
1526             }
1527             } else {
1528 0           die "'$targ' is not the name of a feed or group\n";
1529             }
1530              
1531 0           my $num = 0;
1532              
1533 0           for my $f (@feeds) {
1534 0           my $n = $self->{ DB }->mark($status, $f, @posts);
1535 0           $num += $n;
1536             }
1537              
1538 0           say "$num posts updated";
1539              
1540 0           return 1;
1541              
1542             }
1543              
1544             sub post {
1545              
1546 0     0 1   my ($self) = @_;
1547              
1548 0           my $feed = shift @{ $self->{ Args } };
  0            
1549 0           my $id = shift @{ $self->{ Args } };
  0            
1550              
1551 0 0 0       if (not defined $feed or not defined $id) {
1552 0           die "post requires a feed name and post ID as argument\n";
1553             }
1554              
1555 0 0         unless (exists $self->{ Feeds }{ $feed }) {
1556 0           die "'$feed' is not the name of a feed\n";
1557             }
1558              
1559 0 0         unless ($id =~ /^-?\d+$/) {
1560 0           die "Post ID must be an integar\n";
1561             }
1562              
1563 0           my $post = $self->{ DB }->post($feed, $id);
1564              
1565 0 0         unless (defined $post) {
1566 0           die "'$feed:$id' does not exist\n";
1567             }
1568              
1569 0 0         if (!$self->{ UseColor }) {
1570 0           $self->{ PostFmt } = _rm_color_codes($self->{ PostFmt });
1571             }
1572              
1573             my $fmt = _fmt(
1574             $self->{ PostFmt },
1575             \%POST_FMT_CODES,
1576             $self->{ ColorMap }
1577 0           );
1578              
1579 0           print $fmt->($post);
1580              
1581 0           return 1;
1582              
1583             }
1584              
1585             sub feeds {
1586              
1587 0     0 1   my ($self) = @_;
1588              
1589 0           my @feeds;
1590              
1591 0 0         if (@{ $self->{ Args } }) {
  0            
1592 0           my %feedset;
1593 0           for my $a (@{ $self->{ Args } }) {
  0            
1594 0 0         if (exists $self->{ Feeds }{ $a }) {
    0          
1595 0           $feedset{ $a } = 1;
1596             } elsif (exists $self->{ Groups }{ $a }) {
1597 0           for my $f (@{ $self->{ Groups }{ $a }->feeds }) {
  0            
1598 0           $feedset{ $f } = 1;
1599             }
1600             } else {
1601 0           warn "'$a' is not the name of a feed or group, skipping\n";
1602             }
1603             }
1604 0           @feeds = sort keys %feedset;
1605             } else {
1606 0           @feeds = sort keys %{ $self->{ Feeds } };
  0            
1607             }
1608              
1609 0 0         unless (@feeds) {
1610 0           die "No feeds can be printed\n";
1611             }
1612              
1613 0 0         if (!$self->{ UseColor }) {
1614 0           $self->{ FeedsFmt } = _rm_color_codes($self->{ FeedsFmt });
1615             }
1616              
1617 0           my $cb = _fmt($self->{ FeedsFmt }, \%FEED_FMT_CODES, $self->{ ColorMap });
1618              
1619 0           for my $n (@feeds) {
1620              
1621 0           my $f = $self->{ DB }->feed($n, post_info => 1);
1622              
1623             $f //= {
1624             nossname => $self->{ Feeds }{ $n }->name,
1625 0   0       nosslink => $self->{ Feeds }{ $n }->feed,
1626             };
1627              
1628 0           print $cb->($f);
1629              
1630             }
1631              
1632 0           return 1;
1633              
1634             }
1635              
1636             sub groups {
1637              
1638 0     0 1   my ($self) = @_;
1639              
1640 0           my @groups;
1641              
1642 0 0         if (@{ $self->{ Args } }) {
  0            
1643 0           for my $a (@{ $self->{ Args } }) {
  0            
1644 0 0         if (exists $self->{ Groups }{ $a }) {
1645 0           push @groups, $a;
1646             } else {
1647 0           warn "'$a' is not the name of a feed group, skipping\n";
1648             }
1649             }
1650             } else {
1651 0           @groups = sort keys %{ $self->{ Groups } };
  0            
1652             }
1653              
1654 0 0         unless (@groups) {
1655 0           die "No feed groups can be printed\n";
1656             }
1657              
1658 0           for my $i (0 .. $#groups) {
1659              
1660             my @feeds =
1661 0           grep { exists $self->{ Feeds }{ $_ } }
1662 0           @{ $self->{ Groups }{ $groups[$i] }->feeds };
  0            
1663              
1664 0 0         @feeds = ('(none)') unless @feeds;
1665              
1666 0           say $groups[$i];
1667              
1668 0 0         unless ($self->{ Brief }) {
1669 0           for my $f (@feeds) {
1670 0           say " $f";
1671             }
1672 0 0         print "\n" unless $i == $#groups;
1673             }
1674              
1675             }
1676              
1677 0           return 1;
1678              
1679             }
1680              
1681             sub clean {
1682              
1683 0     0 1   my ($self) = @_;
1684              
1685 0           for my $f (dir($self->{ FeedDir })) {
1686              
1687 0 0         next unless $f =~ /\.feed$/;
1688              
1689 0           my $feed = (fileparse($f, qr/\.[^.]*/))[0];
1690              
1691 0 0         unless (exists $self->{ Feeds }{ $feed }) {
1692 0 0         unlink $f or warn "Failed to unlink $f\n";
1693             }
1694              
1695             }
1696              
1697 0           for my $f (dir($self->{ EtagDir })) {
1698              
1699 0 0         next unless $f =~ /\.etag/;
1700              
1701 0           my $feed = (fileparse($f, qr/\.[^.]*/))[0];
1702              
1703 0 0         unless (exists $self->{ Feeds }{ $feed }) {
1704 0 0         unlink $f or warn "Failed to unlink $f\n";
1705             }
1706              
1707             }
1708              
1709 0           my @dbfeeds = $self->{ DB }->feeds;
1710              
1711             my @clean =
1712 0           grep { not exists $self->{ Feeds }{ $_ } }
1713 0           map { $_->{ nossname } }
1714 0           $self->{ DB }->feeds;
1715              
1716 0 0         if (@clean) {
1717 0           $self->{ DB }->del_feeds(@clean);
1718             }
1719              
1720 0           $self->{ DB }->vacuum;
1721              
1722 0           return 1;
1723              
1724             }
1725              
1726             sub discover {
1727              
1728 0     0 1   my ($self) = @_;
1729              
1730 0           my $url = shift @{ $self->{ Args } };
  0            
1731 0 0         if (not defined $url) {
1732 0           die "discover requires a URL as argument\n";
1733             }
1734              
1735 0           my $tmp = do {
1736 0           my ($h, $p) = tempfile(UNLINK => 1);
1737 0           close $h;
1738 0           $p;
1739             };
1740              
1741             my ($rt, undef, undef) = curl(
1742             $url, $tmp,
1743             user_agent => $self->{ UserAgent },
1744             limit_rate => $self->{ RateLimit },
1745             timeout => $self->{ Timeout },
1746             fail => 1,
1747             proxy => $self->{ Proxy },
1748             proxy_user => $self->{ ProxyUser },
1749 0           redirect => 1,
1750             );
1751              
1752 0 0         if ($rt != 0) {
1753 0           die sprintf "Failed to curl %s: %s\n", $url, curl_error($rt);
1754             }
1755              
1756 0           my @feeds = discover_feeds($tmp);
1757 0 0         if (!@feeds) {
1758 0           say "No feeds found in $url";
1759 0           return 1;
1760             }
1761              
1762 0           @feeds = uniq sort map { resolve_url($_, $url) } @feeds;
  0            
1763 0           for my $f (@feeds) {
1764 0           say $f;
1765             }
1766              
1767 0           return 1;
1768              
1769             }
1770              
1771             sub export_opml {
1772              
1773 0     0 1   my ($self) = @_;
1774              
1775 0           my $to = shift @{ $self->{ Args } };
  0            
1776              
1777 0           my @feeds;
1778              
1779 0           for my $f (values %{ $self->{ Feeds } }) {
  0            
1780 0 0 0       next if $f->feed =~ /^(file|shell):\/\// and !$self->{ ExportSpec };
1781             push @feeds, {
1782             title => $f->name,
1783             xml_url => $f->feed,
1784 0           groups => [ map { $_->name } @{ $f->groups } ],
  0            
  0            
1785             };
1786             }
1787              
1788 0           my $opml = WWW::Noss::OPML->from_perl(
1789             title => "$PRGNAM Feed List",
1790             feeds => \@feeds,
1791             );
1792              
1793 0 0         if (defined $to) {
1794 0           $opml->to_file($to, folders => !$self->{ NoGroups });
1795 0           say "Wrote OPML to $to";
1796             } else {
1797 0           $opml->to_fh(*STDOUT, folders => !$self->{ NoGroups });
1798             }
1799              
1800 0           return 1;
1801              
1802             }
1803              
1804             # TODO: --merge option?
1805             sub import_opml {
1806              
1807 0     0 1   my ($self) = @_;
1808              
1809 0           my $file = shift @{ $self->{ Args } };
  0            
1810              
1811 0 0         unless (defined $file) {
1812 0           die "import requires an OPML file as argument\n";
1813             }
1814              
1815 0           my $to = shift @{ $self->{ Args } };
  0            
1816              
1817 0           my $json = {
1818             default => {},
1819             groups => {},
1820             feeds => {},
1821             };
1822              
1823 0           my $opml = WWW::Noss::OPML->from_xml($file);
1824              
1825             my %groupset =
1826 0           map { $_ =~ s/\W//gr => {} }
1827 0   0       map { @{ $_->{ groups } // [] } }
  0            
1828 0           @{ $opml->feeds };
  0            
1829              
1830 0           for my $f (@{ $opml->feeds }) {
  0            
1831              
1832 0           my $name = $f->{ title } =~ s/\W//gr;
1833              
1834 0 0 0       if (exists $json->{ feeds }{ $name } and $f->{ xml_url } ne $json->{ feeds }{ $name }) {
1835 0           warn "'$name' feed name conflict, $json->{ feeds }{ $name } will be lost\n";
1836             }
1837              
1838 0 0         if (exists $groupset{ $name }) {
1839 0           warn "'$name' group name conflict, $name group will be lost\n";
1840 0           delete $groupset{ $name };
1841             }
1842              
1843 0           $json->{ feeds }{ $name } = $f->{ xml_url };
1844              
1845 0   0       for my $g (@{ $f->{ groups } // [] }) {
  0            
1846 0           $g =~ s/\W//g;
1847 0 0         next unless exists $groupset{ $g };
1848 0           $groupset{ $g }->{ $name } = 1;
1849             }
1850              
1851             }
1852              
1853 0 0         unless ($self->{ NoGroups }) {
1854 0           for my $g (keys %groupset) {
1855 0           $json->{ groups }{ $g } = [ sort keys %{ $groupset{ $g } } ];
  0            
1856             }
1857             }
1858              
1859 0           my $json_obj = JSON->new->pretty->canonical;
1860              
1861 0 0         if (defined $to) {
1862 0 0         open my $fh, '>', $to
1863             or die "Failed to open $to for writing: $!\n";
1864 0           print { $fh } $json_obj->encode($json);
  0            
1865 0           close $fh;
1866 0           say "Wrote JSON to $to";
1867             } else {
1868 0           print $json_obj->encode($json);
1869             }
1870              
1871 0           return 1;
1872              
1873             }
1874              
1875             sub help {
1876              
1877 0     0 1   my ($self) = @_;
1878              
1879 0           my $cmd = shift @{ $self->{ Args } };
  0            
1880              
1881 0 0         if (not defined $cmd) {
1882 0           pod2usage(
1883             -exitval => 'NOEXIT',
1884             -verbose => 99,
1885             -sections => [
1886             'NAME', 'SYNOPSIS', 'DESCRIPTION', 'COMMANDS',
1887             'GLOBAL OPTIONS', 'CONFIGURATION', 'ENVIRONMENT'
1888             ],
1889             -output => \*STDOUT,
1890             );
1891 0           return 1;
1892             }
1893              
1894 0           $cmd = lc $cmd;
1895              
1896 0 0         if (not exists $COMMANDS{ $cmd }) {
1897 0           die "'$cmd' is not a command\n";
1898             }
1899              
1900             pod2usage(
1901 0           -exitval => 'NOEXIT',
1902             -verbose => 99,
1903             -sections => "COMMANDS/$cmd",
1904             -output => \*STDOUT,
1905             );
1906              
1907 0           return 1;
1908              
1909             }
1910              
1911             sub init {
1912              
1913 0     0 1   my ($class, @argv) = @_;
1914              
1915 0           my $self = {
1916             Cmd => undef,
1917             Args => [],
1918             DataDir => undef,
1919             FeedDir => undef,
1920             EtagDir => undef,
1921             FeedFile => undef,
1922             ConfFile => undef,
1923             Feeds => {},
1924             Groups => {},
1925             DefaultGroup => undef,
1926             DB => undef,
1927             AutoClean => undef,
1928             TimeFmt => undef,
1929             UseColor => undef,
1930             ColorMap => { %COLOR_CODES },
1931             RetryDir => undef,
1932             # update
1933             NewOnly => 0,
1934             NonDefaults => 0,
1935             Forks => undef,
1936             Unconditional => 0,
1937             RateLimit => undef,
1938             UserAgent => undef,
1939             Timeout => undef,
1940             Proxy => undef,
1941             ProxyUser => undef,
1942             HardReload => 0, # reload, too
1943             # read
1944             Pager => undef,
1945             NoMark => 0, # open, too
1946             Stdout => 0,
1947             LineWidth => undef,
1948             ReadFmt => undef,
1949             ReadHtml => 0,
1950             # open
1951             Browser => undef,
1952             # look/unread
1953             Title => undef,
1954             Tags => [],
1955             Status => undef, # look only
1956             Content => [],
1957             Sort => undef,
1958             Reverse => 0,
1959             ListLimit => undef,
1960             ShowHidden => 0,
1961             ListFmt => undef,
1962             ListUnreadFmt => undef,
1963             # mark
1964             MarkAll => 0,
1965             # post
1966             PostFmt => undef,
1967             # feeds
1968             Brief => 0, # groups, too
1969             FeedsFmt => undef,
1970             # export/import
1971             NoGroups => 0,
1972             ExportSpec => 0,
1973             };
1974              
1975 0           Getopt::Long::config('bundling');
1976 0           Getopt::Long::config('pass_through');
1977             GetOptionsFromArray(\@argv,
1978             'config|c=s' => \$self->{ ConfFile },
1979             'data|D=s' => \$self->{ DataDir },
1980             'feeds|f=s' => \$self->{ FeedFile },
1981             'autoclean|A:s' => sub {
1982 0 0 0 0     if ($_[1] eq '' or $_[1] eq '1') {
    0          
1983 0           $self->{ AutoClean } = 1;
1984             } elsif ($_[1] eq '0') {
1985 0           $self->{ AutoClean } = 0;
1986             } else {
1987 0           $self->{ AutoClean } = 1;
1988 0           unshift @argv, $_[1];
1989             }
1990             },
1991             'time-format|z=s' => \$self->{ TimeFmt },
1992             'color|C:s' => sub {
1993 0 0 0 0     if ($_[1] eq '' or $_[1] eq '1') {
    0          
1994 0           $self->{ UseColor } = 1;
1995             } elsif ($_[1] eq '0') {
1996 0           $self->{ UseColor } = 0;
1997             } else {
1998 0           $self->{ UseColor } = 1;
1999 0           unshift @argv, $_[1];
2000             }
2001             },
2002 0     0     'no-color' => sub { $self->{ UseColor } = 0 },
2003             # update
2004             'new-only' => \$self->{ NewOnly },
2005             'non-defaults' => \$self->{ NonDefaults },
2006             'downloads=i' => \$self->{ Forks },
2007             'unconditional' => \$self->{ Unconditional },
2008             'limit-rate=s' => \$self->{ RateLimit },
2009             'user-agent=s' => \$self->{ UserAgent },
2010             'timeout=f' => \$self->{ Timeout },
2011             'proxy=s' => \$self->{ Proxy },
2012             'proxy-user=s' => \$self->{ ProxyUser },
2013             'hard' => \$self->{ HardReload },
2014             # read
2015             'pager=s' => \$self->{ Pager },
2016             'no-mark' => \$self->{ NoMark }, # open, too
2017             'stdout' => \$self->{ Stdout },
2018             'width=i' => \$self->{ LineWidth },
2019             'read-format=s' => \$self->{ ReadFmt },
2020             'html' => \$self->{ ReadHtml },
2021             # open
2022             'browser=s' => \$self->{ Browser },
2023             # look/unread
2024             'title=s' => \$self->{ Title },
2025             'tag=s' => $self->{ Tags },
2026             'status=s' => \$self->{ Status }, # look only
2027             'content=s' => $self->{ Content },
2028             'sort=s' => \$self->{ Sort },
2029             'reverse' => \$self->{ Reverse },
2030             'list-limit=i' => \$self->{ ListLimit },
2031             'hidden' => \$self->{ ShowHidden },
2032             'list-format=s' => sub {
2033 0     0     $self->{ ListFmt } = $_[1];
2034 0           $self->{ ListUnreadFmt } = $_[1];
2035             },
2036             # mark
2037             'all' => \$self->{ MarkAll },
2038             # post
2039             'post-format=s' => \$self->{ PostFmt },
2040             # feeds
2041             'brief' => \$self->{ Brief }, # groups, too
2042             'feeds-format=s' => \$self->{ FeedsFmt },
2043             # export/import
2044             'no-groups' => \$self->{ NoGroups },
2045             'export-special' => \$self->{ ExportSpec },
2046             # misc
2047 0     0     'help|h' => sub { _HELP(*STDOUT, 0) },
2048 0     0     'version|v' => sub { _VER(*STDOUT, 0) },
2049             '<>' => sub {
2050 0 0 0 0     if (not defined $self->{ Cmd } and $_[0] !~ /^-/) {
2051 0           $self->{ Cmd } = $_[0];
2052             } else {
2053 0 0         if ($_[0] =~ /^-\d+$/) {
    0          
2054             # So that negative post arguments (-1, -2, etc.) do not get
2055             # treated like CLI flags.
2056 0           push @{ $self->{ Args } }, $_[0];
  0            
2057             } elsif ($_[0] !~ /^-/) {
2058 0           push @{ $self->{ Args } }, $_[0];
  0            
2059             } else {
2060 0           warn "Unknown option: $_[0]\n";
2061 0           _HELP(*STDERR, 1);
2062             }
2063             }
2064             },
2065 0 0         ) or _HELP(*STDERR, 1);
2066              
2067 0           bless $self, $class;
2068              
2069 0 0         if (not defined $self->{ Cmd }) {
2070 0           _HELP(*STDERR, 0);
2071             }
2072              
2073 0 0         unless (exists $COMMANDS{ $self->{ Cmd } }) {
2074 0           die "'$self->{ Cmd }' is not a valid command\n";
2075             }
2076              
2077 0   0       $self->{ ConfFile } //= _default_config;
2078              
2079 0 0 0       if ($self->{ Brief } and $self->{ Cmd } eq 'feeds') {
2080 0           $self->{ FeedsFmt } = '%f';
2081             }
2082              
2083 0 0         if (defined $self->{ ConfFile }) {
2084 0           $self->_read_config;
2085             }
2086              
2087 0   0       $self->{ DataDir } //= _default_data_dir;
2088              
2089 0 0         unless (-d $self->{ DataDir }) {
2090             mkdir $self->{ DataDir }
2091 0 0         or die "Failed to mkdir $self->{ DataDir }: $!\n";
2092             }
2093              
2094             $self->{ FeedDir } = File::Spec->catfile(
2095             $self->{ DataDir },
2096 0           'feeds'
2097             );
2098              
2099 0 0         unless (-d $self->{ FeedDir }) {
2100             mkdir $self->{ FeedDir }
2101 0 0         or die "Failed to mkdir $self->{ FeedDir }: $!\n";
2102             }
2103              
2104             $self->{ EtagDir } = File::Spec->catfile(
2105 0           $self->{ DataDir }, 'etag'
2106             );
2107 0 0         unless (-d $self->{ EtagDir }) {
2108             mkdir $self->{ EtagDir }
2109 0 0         or die "Failed to mkdir $self->{ EtagDir }: $!\n";
2110             }
2111              
2112             $self->{ RetryDir } = File::Spec->catfile(
2113 0           $self->{ DataDir }, 'retry'
2114             );
2115 0 0         if (not -d $self->{ RetryDir }) {
2116 0 0         unlink $self->{ RetryDir } if -f $self->{ RetryDir };
2117             mkdir $self->{ RetryDir }
2118 0 0         or die "Failed to mkdir $self->{ RetryDir }: $!\n";
2119             }
2120              
2121 0 0         unless (exists $DOESNT_NEED_FEED{ $self->{ Cmd } }) {
2122 0   0       $self->{ FeedFile } //= _default_feeds;
2123 0 0         unless (defined $self->{ FeedFile }) {
2124 0           die "$PRGNAM could not find a feeds file to read a feed list from\n";
2125             }
2126 0 0         unless (-f $self->{ FeedFile }) {
2127 0           die "$self->{ FeedFile } does not exist\n";
2128             }
2129             # For _get_url 'file://' links, to know the file's relative directory if
2130             # the url is not absolute.
2131 0           $self->{ FeedFile } = File::Spec->rel2abs($self->{ FeedFile });
2132 0           $self->_read_feed_file;
2133             }
2134              
2135 0   0       $self->{ Forks } //= $DEFAULT_FORKS;
2136              
2137 0 0         unless ($self->{ Forks } > 0) {
2138 0           die "Download count must be greater than 0\n";
2139             }
2140              
2141 0   0       $self->{ AutoClean } //= 0;
2142 0   0       $self->{ UserAgent } //= $DEFAULT_AGENT;
2143 0   0       $self->{ Pager } //= $ENV{ PAGER } // $DEFAULT_PAGER;
      0        
2144 0   0       $self->{ Browser } //= $ENV{ BROWSER } // 'lynx';
      0        
2145 0   0       $self->{ ListLimit } //= 0;
2146 0   0       $self->{ LineWidth } //= $DEFAULT_WIDTH;
2147 0   0       $self->{ ReadFmt } //= $DEFAULT_READ_FMT;
2148 0   0       $self->{ PostFmt } //= $DEFAULT_POST_FMT;
2149 0   0       $self->{ FeedsFmt } //= $DEFAULT_FEED_FMT;
2150              
2151 0 0         unless ($self->{ LineWidth } > 0) {
2152 0           die "width must be greater than 0\n";
2153             }
2154              
2155 0 0 0       if (defined $self->{ Status } and $self->{ Status } !~ /^(un)?read$/) {
2156 0           die "status must either be 'read' or 'unread'\n";
2157             }
2158              
2159 0   0       $self->{ Sort } //= 'date';
2160              
2161 0 0         unless (exists $VALID_SORTS{ $self->{ Sort } }) {
2162 0           die sprintf
2163             "--sort must be one of the following: %s\n",
2164             join(', ', sort keys %VALID_SORTS);
2165             }
2166              
2167 0 0 0       if (defined $self->{ RateLimit } and $self->{ RateLimit } !~ $RATE_RX) {
2168 0           die "Invalid argument to --limit-rate\n";
2169             }
2170              
2171 0 0         unless ($DOESNT_NEED_FEED{ $self->{ Cmd } }) {
2172             $self->{ DB } = WWW::Noss::DB->new(
2173 0           File::Spec->catfile($self->{ DataDir }, 'database.sqlite3')
2174             );
2175             }
2176              
2177 0 0         if (defined $self->{ TimeFmt }) {
2178 0           _set_z_fmt($self->{ TimeFmt });
2179             }
2180              
2181 0 0         if (not defined $self->{ UseColor }) {
2182 0 0         if (-t STDOUT) {
2183 0           $self->{ UseColor } = 1;
2184             } else {
2185 0           $self->{ UseColor } = 0;
2186             }
2187             }
2188              
2189             # Windows terminals do not support ANSI color codes.
2190 0 0         if ($^O eq 'MSWin32') {
2191 0           $self->{ UseColor } = 0;
2192             }
2193              
2194 0 0         if (not defined $self->{ Timeout }) {
2195 0           $self->{ Timeout } = 45;
2196             }
2197              
2198 0           return $self;
2199              
2200             }
2201              
2202             sub run {
2203              
2204 0     0 1   my ($self) = @_;
2205              
2206 0           $COMMANDS{ $self->{ Cmd } }->($self);
2207              
2208 0 0 0       if ($self->{ AutoClean } and not $DOESNT_NEED_FEED{ $self->{ Cmd } }) {
2209 0           $self->clean;
2210             }
2211              
2212             # Delete outdated retry caches
2213 0           my $now = time;
2214 0           for my $f (values %{ $self->{ Feeds } }) {
  0            
2215 0           my $retry = $f->retry;
2216 0 0         next if not defined $retry;
2217 0 0         if ($now >= $retry) {
2218 0 0         unlink $f->retry_cache
2219             or warn sprintf "Failed to unlink %s: %s\n", $f->retry_cache, $!;
2220             }
2221             }
2222              
2223 0           return 1;
2224              
2225             }
2226              
2227             1;
2228              
2229             =head1 NAME
2230              
2231             WWW::Noss - RSS/Atom feed reader and aggregator
2232              
2233             =head1 USAGE
2234              
2235             use WWW::Noss;
2236              
2237             my $noss = WWW::Noss->init(@ARGV);
2238             $noss->run;
2239              
2240             =head1 DESCRIPTION
2241              
2242             B is the backend module providing L's functionality. This is
2243             a private module, please consult the L manual for user documentation.
2244              
2245             =head1 METHODS
2246              
2247             =over 4
2248              
2249             =item $noss = WWW::Noss->init(@argv)
2250              
2251             Reads command-line arguments from C<@argv> and returns a blessed B
2252             object. You would usually pass C<@ARGV> to it.
2253              
2254             Consult the L manual for documentation on what options/arguments are
2255             available.
2256              
2257             =item $noss->run()
2258              
2259             Runs L based on the parameters processed during C.
2260              
2261             =item $noss->update()
2262              
2263             Method implementing the C command.
2264              
2265             =item $noss->reload()
2266              
2267             Method implementing the C command.
2268              
2269             =item $noss->read_post()
2270              
2271             Method implementing the C command.
2272              
2273             =item $noss->open_post()
2274              
2275             Method implementing the C command.
2276              
2277             =item $noss->cat()
2278              
2279             Method implementing the C command.
2280              
2281             =item $noss->look()
2282              
2283             Method implementing the C command.
2284              
2285             =item $noss->unread()
2286              
2287             Method implementing the C command.
2288              
2289             =item $noss->mark()
2290              
2291             Method implementing the C command.
2292              
2293             =item $noss->post()
2294              
2295             Method implementing the C command.
2296              
2297             =item $noss->feeds()
2298              
2299             Method implementing the C command.
2300              
2301             =item $noss->groups()
2302              
2303             Method implementing the C command.
2304              
2305             =item $noss->clean()
2306              
2307             Method implementing the C command.
2308              
2309             =item $noss->discover()
2310              
2311             Method implementing the C command.
2312              
2313             =item $noss->export_opml()
2314              
2315             Method implementing the C command.
2316              
2317             =item $noss->import_opml()
2318              
2319             Method implementing the C command.
2320              
2321             =item $noss->help()
2322              
2323             Method implementing the C command.
2324              
2325             =back
2326              
2327             =head1 AUTHOR
2328              
2329             Written by Samuel Young, Esamyoung12788@gmail.comE.
2330              
2331             This project's source can be found on its
2332             L. Comments and pull
2333             requests are welcome!
2334              
2335             =head1 COPYRIGHT
2336              
2337             Copyright (C) 2025-2026 Samuel Young
2338              
2339             This program is free software: you can redistribute it and/or modify
2340             it under the terms of the GNU General Public License as published by
2341             the Free Software Foundation, either version 3 of the License, or
2342             (at your option) any later version.
2343              
2344             =head1 SEE ALSO
2345              
2346             L
2347              
2348             =cut
2349              
2350             # vim: expandtab shiftwidth=4