File Coverage

script/moku-pona
Criterion Covered Total %
statement 241 307 78.5
branch 78 154 50.6
condition 34 77 44.1
subroutine 25 31 80.6
pod n/a
total 378 569 66.4


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2             # Copyright (C) 2018–2021 Alex Schroeder
3              
4             # This program is free software: you can redistribute it and/or modify it under
5             # the terms of the GNU General Public License as published by the Free Software
6             # Foundation, either version 3 of the License, or (at your option) any later
7             # version.
8             #
9             # This program is distributed in the hope that it will be useful, but WITHOUT
10             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11             # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
12             #
13             # You should have received a copy of the GNU General Public License along with
14             # this program. If not, see .
15              
16             =encoding utf8
17              
18             =head1 Moku Pona
19              
20             Moku Pona is a Gemini based feed reader. It can monitor URLs to feeds or regular
21             pages for changes and keeps and updated list of these in a Gemini list. Moku
22             Pona knows how to fetch Gopher URLs, Gemini URLs, and regular web URLs.
23              
24             You manage your subscriptions using the command-line, with Moku Pona.
25              
26             You serve the resulting file using a Gemini server.
27              
28             You read it all using your Gemini client.
29              
30             =head2 Limitations
31              
32             Moku Pona only detects changes. Thus, if there is an item that points to a phlog
33             or blog, that's great. Sometimes people put their phlog in a folder per year. If
34             the Gopher menu lists each folder and a date with the latest change, then that's
35             great, you can use it. Without it, you're in trouble: you need to subscribe to
36             the item for the current year in order to see changes, but when the next year
37             comes around, you're subscribed to the wrong item. Sometimes you're lucky and
38             there will be a menu somewhere with a timestamp for the last change. Use that
39             instead. Good luck!
40              
41             =head2 License
42              
43             GNU Affero General Public License
44              
45             =head2 Installation
46              
47             Using C:
48              
49             cpan App::mokupona
50              
51             Manual install:
52              
53             perl Makefile.PL
54             make
55             make install
56              
57             =head2 Dependencies
58              
59             There are some Perl dependencies you need to satisfy in order to run this
60             program:
61              
62             =over
63              
64             =item L, or C
65              
66             =item L, or C
67              
68             =item L, or C
69              
70             =item L, or C
71              
72             =item L, or c
73              
74             =back
75              
76             =cut
77              
78 9     9   9500 use Modern::Perl '2018';
  9         32297  
  9         93  
79 9     9   5363 use File::Copy qw(copy);
  9         15565  
  9         700  
80 9     9   3955 use Encode::Locale qw(decode_argv);
  9         80958  
  9         504  
81 9     9   53 use Encode qw(decode_utf8);
  9         21  
  9         479  
82 9     9   3608 use Mojo::IOLoop;
  9         2153925  
  9         94  
83 9     9   5496 use XML::LibXML;
  9         239160  
  9         52  
84 9     9   4896 use URI::Escape;
  9         11821  
  9         41178  
85              
86             decode_argv();
87              
88             if (-t) {
89             binmode(STDIN, ":encoding(console_in)");
90             binmode(STDOUT, ":encoding(console_out)");
91             binmode(STDERR, ":encoding(console_out)");
92             }
93              
94             =head2 The Data Directory
95              
96             Moku Pona keeps the list of URLs you are subscribed to in directory. It's
97             probably C<~/.moku-pona> on your system.
98              
99             =over
100              
101             =item If you have the C environment variable set, then that's your data
102             directory.
103              
104             =item If you have the C environment variable set, then your data
105             directory is F<$XDG_DATA_HOME/moku-pona>.
106              
107             =item If you you have the C environment variable set, and you have a
108             F<$HOME/.local> directory, then your data directory is
109             F<$HOME/.local/moku-pona>.
110              
111             =item If you have the C environment variable set, then your data directory
112             is F<$HOME/.moku-pona>.
113              
114             =item If you have the C environment variable set (Windows), then your
115             data directory is F<$APPDATA/moku-pona>.
116              
117             =item The last option is to have the C environment variable set.
118              
119             =back
120              
121             The data directory contains a copy of the latest resources. The names of these
122             cache files are simply the URL with all the slashes replaced by a hyphen.
123              
124             =cut
125              
126             our $data_dir = data_dir();
127             # say "Using $data_dir";
128              
129             sub data_dir {
130 9 50   9   32 return $ENV{MOKU_PONA} if $ENV{MOKU_PONA};
131             # find an existing directory
132 9 50 33     37 return $ENV{XDG_DATA_HOME} . '/moku-pona' if $ENV{XDG_DATA_HOME} and -d $ENV{XDG_DATA_HOME} . '/moku-pona';
133 9 50 33     161 return $ENV{HOME} . '/.local/moku-pona' if $ENV{HOME} and -d $ENV{HOME} . '/.local/moku-pona';
134 9 50 33     148 return $ENV{HOME} . '/.moku-pona' if $ENV{HOME} and -d $ENV{HOME} . '/.moku-pona';
135 9 50 33     44 return $ENV{APPDATA} . '/moku-pona' if $ENV{APPDATA} and -d $ENV{APPDATA} . '/.moku-pona';
136 9 50 33     34 return $ENV{LOGDIR} . '/.moku-pona' if $ENV{LOGDIR} and -d $ENV{LOGDIR} . '/.moku-pona';
137             # or use a new one
138 9 50       23 return $ENV{XDG_DATA_HOME} . '/moku-pona' if $ENV{XDG_DATA_HOME};
139 9 50 33     111 return $ENV{HOME} . '/.local/moku-pona' if $ENV{HOME} and -d $ENV{HOME} . '/.local';
140 9 50       43 return $ENV{HOME} . '/.moku-pona' if $ENV{HOME};
141 0 0       0 return $ENV{APPDATA} . '/moku-pona' if $ENV{APPDATA};
142 0 0       0 return $ENV{LOGDIR} . '/.moku-pona' if $ENV{LOGDIR};
143 0         0 die "Please set the MOKU_PONA environment variable to a directory name\n";
144             }
145              
146             =pod
147              
148             The C file is a file containing a gemtext list of links, i.e. entries
149             such as these:
150              
151             => gemini://alexschroeder.ch Alex Schroeder
152              
153             =cut
154              
155             our $site_list = $data_dir . '/sites.txt';
156              
157             =pod
158              
159             The C file is a file containing a gemtext list of links based on
160             C, but with a timestamp of their last change, and with new updates
161             moved to the top. The ISO date is simply inserted after the URL:
162              
163             => gemini://alexschroeder.ch 2020-11-07 Alex Schroeder
164              
165             =cut
166              
167             our $updated_list = $data_dir . '/updates.txt';
168              
169             =pod
170              
171             In order to be at least somewhat backwards compatible with Moku Pona versions
172             1.1 and earlier, C may contain Gopher menu items. These are converted
173             to Gemini URLs during processing and thus the C file still contains
174             regular gemtext.
175              
176             1Alex Schroeder ⭾ ⭾ alexschroeder.ch ⭾ 70
177              
178             =cut
179              
180             sub convert {
181 25     25   85 for (@_) {
182 44 100       148 next if /^=> /; # is already a gemini link
183 14         70 my ($type, $desc, $selector, $host, $port) = /^([^\t])([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t\r]*)/;
184 14 100 66     45 if ($host and $port) {
185 11   50     15 $port //= 0;
186 11         43 $_ = "=> gopher://$host:$port/$type$selector $desc";
187             }
188             }
189 25         108 return @_;
190             }
191              
192             =pod
193              
194             As was said above, however, the recommended format is the use of URLs. Moku Pona
195             supports Gemini, Gopher, and the web (gemini, gopher, gophers, http, and https
196             schemes).
197              
198             =cut
199              
200             sub query_gemini {
201 0     0   0 my $url = shift;
202 0         0 my $responses = shift;
203 0         0 my($scheme, $authority, $path, $query, $fragment) =
204             $url =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
205 0 0 0     0 die "⚠ The URL '$url' must use the gemini scheme\n" unless $scheme and $scheme eq 'gemini';
206 0 0       0 die "⚠ The URL '$url' must have an authority\n" unless $authority;
207 0         0 my ($host, $port) = split(/:/, $authority, 2);
208 0   0     0 $port //= 1965;
209             Mojo::IOLoop->client(
210             {port => $port, address => $host, tls => 1, tls_verify => 0 }
211             => sub {
212 0     0   0 my ($loop, $err, $stream) = @_;
213 0 0       0 if (not $stream) {
214 0         0 warn "Cannot connect to $url\n";
215 0         0 return;
216             }
217 0         0 my $header;
218             $stream->on(read => sub {
219 0         0 my ($stream, $bytes) = @_;
220 0         0 $responses->{$url} .= $bytes;
221 0 0       0 $header = $responses->{$url} =~ s/^.*\r\n// unless $header});
  0         0  
222 0         0 $stream->write("$url\r\n")})
223 0         0 }
224              
225             sub query_gopher {
226 10     10   13 my $url = shift;
227 10         11 my $responses = shift;
228 10         15 my ($selector, $host, $port) = url_to_gopher($url);
229 10         18 my $tls = $url =~ /^gophers/;
230             Mojo::IOLoop->client(
231             {port => $port, address => $host, tls => $tls }
232             => sub {
233 10     10   10092 my ($loop, $err, $stream) = @_;
234 10 50       15 if (not $stream) {
235 0         0 warn "Cannot connect to $url\n";
236 0         0 return;
237             }
238             $stream->on(
239             read => sub {
240 10         5341 my ($stream, $bytes) = @_;
241 10         46 $responses->{$url} .= $bytes});
  10         38  
242 10         60 $stream->write("$selector\r\n")})
243 10         122 }
244              
245             sub url_to_gopher {
246 10     10   9 my $url = shift;
247 10   33     30 my $name = shift||$url;
248 10         57 my($scheme, $authority, $path, $query, $fragment) =
249             $url =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
250 10   50     23 $scheme ||= "gopher";
251 10 50       26 return unless $scheme =~ /^gophers?$/;
252 10 50       38 $path = substr($path, 1) if substr($path, 0, 1) eq "/";
253 10 50       15 my $type = $path ? substr($path, 0, 1) : "1";
254 10 50       17 my $selector = $path ? substr($path, 1) : "";
255 10         29 my ($host, $port) = split(/:/, $authority, 2);
256 10   50     35 return ($selector, $host, $port||70);
257             }
258              
259             sub query_web {
260 0     0   0 my $url = shift;
261 0         0 my $responses = shift;
262 0         0 my($scheme, $authority, $path, $query, $fragment) =
263             $url =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
264 0         0 my $tls = $scheme eq "https";
265 0         0 my ($host, $port) = split(/:/, $authority, 2);
266 0   0     0 $path ||= "/";
267 0         0 my $selector = $path;
268 0 0       0 $selector .= "?" . $query if $query;
269             # ignore the fragment
270 0 0 0     0 $port ||= $tls ? 443 : 80;
271             Mojo::IOLoop->client(
272             {port => $port, address => $host, tls => $tls }
273             => sub {
274 0     0   0 my ($loop, $err, $stream) = @_;
275 0         0 my $header;
276             $stream->on(read => sub {
277 0         0 my ($stream, $bytes) = @_;
278 0         0 $responses->{$url} .= $bytes;
279 0 0       0 $header = $responses->{$url} =~ s/^.*\r\n\r\n//s unless $header});
  0         0  
280 0         0 $stream->write("GET $selector HTTP/1.0\r\n"
281             . "Host: $host:$port\r\n"
282             . "User-Agent: moku-pona\r\n"
283 0         0 . "\r\n")});
284             }
285              
286             =head2 Migration from 1.1
287              
288             The best way to migrate your setup is probably to use the C subcommand
289             explained later, and to recreate your list of subscriptions. Then your
290             C file will use gemtext format.
291              
292             moku-pona list | grep "moku-pona add" > commands
293             mv ~/.moku-pona/sites.txt ~/.moku-pona/sites.txt~
294             sh commands
295              
296             =cut
297              
298             sub load_site {
299 29     29   1141 my $file = $site_list;
300 29 100       439 return [] if not -f $file;
301 21 50       595 open(my $fh, "<:encoding(UTF-8)", $file) or die "Cannot read $file: $!\n";
302 21         2559 my @lines = <$fh>;
303 21         356 chomp(@lines);
304 21         53 @lines = grep(/^=> /, convert(@lines)); # from gopher
305 21         295 return \@lines;
306             }
307              
308             sub load_file {
309 17     17   2688 my $file = shift;
310 17 100       261 return "" if not -f $file;
311 14 50       494 open(my $fh, "<:encoding(UTF-8)", $file)
312             or die "Cannot read $file: $!\n";
313 14         834 local $/ = undef;
314 14         340 return <$fh>;
315             }
316              
317             sub save_file {
318 22     22   1832 my $file = shift;
319 22         23 my $data = shift;
320 22 100       369 mkdir $data_dir unless -d $data_dir;
321 4 50   4   23 open(my $fh, ">:encoding(UTF-8)", $file)
  4         4  
  4         28  
  22         26438  
322             or die "Cannot write $file: $!\n";
323 22         6683 print $fh $data;
324             }
325              
326             =head2 List your subscriptions
327              
328             moku-pona list
329              
330             This lists all your current subscriptions in a format that is suitable for a
331             shell script!
332              
333             Example:
334              
335             moku-pona list | grep "alexschroeder"
336              
337             In this particular case, since I'm testing my own server, the result would be:
338              
339             moku-pona add https://alexschroeder.ch/wiki?action=rss "rss"
340             moku-pona add gemini://alexschroeder.ch/ "gemini"
341             moku-pona add gopher://alexschroeder.ch/ "gopher"
342             moku-pona add gophers://alexschroeder.ch:7443/ "gophers"
343              
344             =cut
345              
346             sub do_list {
347 1     1   1320 my $site = load_site();
348 1         7 print("Subscribed items in $site_list:\n");
349 1 50       3 print("none\n") unless @$site;
350 1         3 for my $line (@$site) {
351             # skip item type
352 2         11 my ($uri, $name) = $line =~ /^=> (\S+)\s+(.*)/;
353 2         9 print(qq{moku-pona add $uri "$name"\n});
354             }
355             }
356              
357             =head2 Add a subscription
358              
359             moku-pona add url [description]
360              
361             This adds a URL to the list of subscribed items. If the target is an Atom or RSS
362             feed, then that's also supported. You can provide an optional description for
363             this URL. If you don't provide a description, the URL will be used as the item's
364             description.
365              
366             Example:
367              
368             moku-pona add gemini://alexschroeder.ch kensanata
369              
370             =cut
371              
372             sub do_add {
373 8     8   1151 my $uri = shift;
374 8         9 my $name = shift;
375 8   33     20 $name ||= $uri;
376 8         15 my $line = "=> $uri $name";
377 8         24 my $site = load_site();
378 8         25 my $uri_re = quotemeta($uri);
379 8         14 my $name_re = quotemeta($name);
380 8 50       123 if (grep(/^=> $uri_re /, @$site)) {
    50          
381 0         0 warn("$uri already exists in $site_list\n");
382             } elsif (grep(/^=> \S+ $name_re/, @$site)) {
383 0         0 warn("$name already exists in $site_list\n");
384             } else {
385 8         16 push(@$site, $line);
386             }
387 8         26 save_file($site_list, join("\n", @$site, ""));
388             }
389              
390             =head2 Remove a subscription
391              
392             moku-pona remove description
393              
394             This removes one or more URLs from the list of subscribed items.
395              
396             Example:
397              
398             moku-pona remove kensanata
399              
400             =cut
401              
402             sub do_remove {
403 2     2   1306 my @args = @_;
404 2         6 my $site = load_site();
405 2         4 my $count = 0;
406 2         4 my $i = 0;
407 2   66     11 while (@args and $i < @$site) {
408 4         7 my $line = $site->[$i];
409 4         22 my ($uri, $name) = $line =~ /^=> (\S+)\s+(.*)/;
410 4         6 my $found = 0;
411 4         4 my $j = 0;
412 4         7 while ($j < @args) {
413 4 100       10 if ($name eq $args[$j]) {
414 2         3 $count++;
415 2         2 $found = 1;
416 2         5 splice(@$site, $i, 1); # remove the site found
417 2         4 splice(@args, $j, 1); # remove the arg found
418             } else {
419 2         4 $j++;
420             }
421             }
422 4 100       14 $i++ unless $found;
423             }
424 2 50       6 if ($count) {
425 2 50       77 printf("Removed %d %s\n", $count,
426             $count == 1 ? "subscription" : "subscriptions");
427 2         15 save_file($site_list, join("\n", @$site, ""));
428             } else {
429 0         0 warn("No subscriptions matching @args found\n");
430 0         0 warn("Use moku-pona list to find the correct descriptions.\n");
431             }
432             }
433              
434             =head2 Clean up the data directory
435              
436             moku-pona cleanup [--confirm]
437              
438             When Moku Pona updates, copies of the URL targets are saved in the data
439             directory. If you remove a subscription (see above), that leaves a cache file in
440             the data directory that is no longer used – and it leaves an entry in
441             C that is no longer wanted. The cleanup command fixes this. It
442             deletes all the cached pages that you are no longer subscribed to, and it
443             removes those entries from C as well.
444              
445             Actually, just to be sure, if you run it without the C<--confirm> argument, it
446             simply prints which files it would trash. Rerun it with the C<--confirm>
447             argument to actually do it.
448              
449             Example:
450              
451             moku-pona cleanup
452              
453             =cut
454              
455             sub do_cleanup {
456 2   100 2   2381 my $confirm = shift||'' eq '--confirm';
457 2         2 my $todo = 0;
458             # get a hash map telling us the cache files we expect based on our sites
459 2         4 my $site = load_site();
460             my %caches = map {
461 2         5 my ($uri, $name) = /^=> (\S+)\s+(.*)/;
  2         12  
462 2         12 $uri =~ s/[\/:]/-/g;
463 2         11 "$data_dir/$uri" => 1;
464             } @$site;
465             # get a list of text files in the directory
466 2 50       61 opendir(my $dh, $data_dir) or die "Cannot read $data_dir: $!\n";
467 2         48 my @files = map { "$data_dir/$_" } grep { /^[^.]/ } readdir($dh);
  8         16  
  12         24  
468 2         23 closedir($dh);
469             # remove unnecessary cache files
470 2         5 for my $file (@files) {
471 8 100       13 next if $file eq $site_list;
472 6 100       10 next if $file eq $updated_list;
473 4 100       9 next if $caches{$file};
474 2 100       4 if ($confirm) {
475 1         37 unlink $file;
476             } else {
477 1         5 print "trash $file\n";
478 1         1 $todo++;
479             }
480             }
481             # check updates list
482 2 50       24 if (-f $updated_list) {
483 2 50       54 open(my $fh, "<:encoding(UTF-8)", $updated_list)
484             or die "Cannot read $updated_list: $!\n";
485 2         141 my @lines = <$fh>;
486 2         32 chomp(@lines);
487             # decide what to do about each line in updates, looking just at the names
488 2         5 my %sites = map { s/^=> (\S+)\s+(.*)/$2/; $_ => 1 } @$site;
  2         20  
  2         10  
489 2         4 my @deletes;
490             my @keeps;
491 2         9 for my $line (@lines) {
492 4 100 66     32 if ($line =~ /^=> \S+ \d\d\d\d-\d\d-\d\d (.+)/ and not $sites{$1}) {
493 2         3 push(@deletes, $line);
494 2         4 $todo++;
495             } else {
496 2         5 push(@keeps, $line);
497             }
498             }
499 2 100 66     10 print "Removing these entries from updates:\n"
500             . join("\n", @deletes, "") if @deletes and not $confirm;
501             # save
502 2 100       18 save_file($updated_list, join("\n", @keeps, "")) if $confirm;
503             }
504 2 100 66     28 if ($todo && !$confirm) {
505 1         3 print "\n";
506 1         4 print "Use moku-pona cleanup --confirm to do it.\n";
507             }
508             }
509              
510             =head2 Update
511              
512             moku-pona update [--quiet] [names...]
513              
514             This updates all the subscribed items and generates a new local page for you to
515             visit: C.
516              
517             Example:
518              
519             moku-pona update
520              
521             If you call it from a cron job, you might want to use the C<--quiet> argument to
522             prevent it from printing all the sites it's contacting (since cron will then
523             mail this to you and you might not care for it unless there's a problem). If
524             there's a problem, you'll still get a message.
525              
526             This is how I call it from my C, for example
527              
528             #m h dom mon dow command
529             11 7,14 * * * /home/alex/bin/moku-pona update --quiet
530              
531             If you're testing things, you can also fetch just a limited number of items by
532             listing them.
533              
534             Example:
535              
536             moku-pona update "RPG Planet"
537              
538             The C files may contain lines that are not links at the top. These
539             will remain untouched. The rest is links. New items are added at the beginning
540             of the links and older copies of such items are removed from the links.
541              
542             =cut
543              
544             sub add_update {
545 4     4   6 my $line = shift;
546 4         29 my ($uri, $name) = $line =~ /^=> (\S+)\s+(.*)/;
547             # add current date
548 4         24 my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(); # UTC
549 4         21 my $date = sprintf('%4d-%02d-%02d', $year + 1900, $mon + 1, $mday);
550 4         12 $line = "=> $uri $date $name";
551             # load file
552 4         5 my @lines;
553 4 100       54 if (-f $updated_list) {
554 3 50       90 open(my $fh, "<:encoding(UTF-8)", $updated_list)
555             or die "Cannot read $updated_list: $!\n";
556 3         215 @lines = convert(<$fh>); # from gohper
557 3         44 chomp(@lines);
558             }
559             # start the new list with the non-list links
560 4         23 my @new = grep(!/^=>/, @lines);
561             # add the line to the new list
562 4         4 push(@new, $line);
563             # add the remaining links to the new list, except for the ones matching the name of the new line
564 4         60 push(@new, grep(!/\d\d\d\d-\d\d-\d\d $name$/, grep(/^=>/, @lines)));
565             # save
566 4         16 save_file($updated_list, join("\n", @new, ""));
567             }
568              
569             sub do_update {
570 5     5   5391 my $quiet = grep { $_ eq '--quiet' } @_;
  0         0  
571 5         9 my @sites = grep { $_ ne '--quiet' } @_;
  0         0  
572 5         11 my $site = load_site();
573 5         16 my %responses;
574             my @uris;
575 5         0 my %names;
576 5         0 my %lines;
577 5         9 for my $line (@$site) {
578 10         784 my ($uri, $name) = $line =~ /^=> (\S+)(?:[ \t]+(.*))?/;
579 10   33     20 $name ||= $uri;
580 10 50 33     19 next unless @sites == 0 or grep { $_ eq $name } @sites;
  0         0  
581 10 50       1014 say("Fetching $name...") unless $quiet;
582 10         45 push(@uris, $uri);
583 10         29 $names{$uri} = $name;
584 10         15 $lines{$uri} = $line;
585 10 50       42 if ($uri =~ /^gopher/) {
    0          
    0          
586 10         21 query_gopher($uri, \%responses);
587             } elsif ($uri =~ /^gemini/) {
588 0         0 query_gemini($uri, \%responses);
589             } elsif ($uri =~ /^http/) {
590 0         0 query_web($uri, \%responses);
591             } else {
592 0         0 warn "Don't know how to fetch $uri\n";
593             }
594             }
595              
596 5 50       1239 Mojo::IOLoop->start unless Mojo::IOLoop->is_running;
597              
598 5         1407 for my $uri (keys %responses) {
599 10         30 my $name = $names{$uri};
600             # decode the UTF-8 when we have the entire response
601 10         31 my $new = decode_utf8($responses{$uri});
602 10 50       220 if (not $new) {
603 0         0 warn("$name returned an empty document\n");
604 0         0 next;
605             }
606 10         15 my $filename = $uri;
607 10         74 $filename =~ s/[\/:]/-/g;
608 10         24 my $cache = "$data_dir/$filename";
609 10 100       31 if ($new =~ /^<(\?xml|rss)/) {
610 2         5 $new = to_gemini($new);
611 2         67 my $encoded = uri_escape_utf8($filename);
612 2         53 $lines{$uri} = "=> $encoded $name"; # now referring to the local cache file
613             }
614 10         18 my $old = load_file($cache);
615 10 100       188 if ($new ne $old) {
616 4 50       400 say "$name updated" unless $quiet;
617 4         25 add_update($lines{$uri});
618 4         23 save_file($cache, $new);
619             } else {
620 6 50       712 say "$name unchanged" unless $quiet;
621             }
622             }
623             }
624              
625             =head2 Subscribing to feeds
626              
627             When the result of an update is an XML document, then it is parsed and the links
628             of its items (if RSS) or entries (if Atom) are extracted and saved in the cache
629             file in the data directory. The effect is this:
630              
631             =over
632              
633             =item If you subscribe to a regular page, then the link to it in C
634             moves to the top when it changes.
635              
636             =item If you subscribe to a feed, then the link in C moves to the
637             top when it changes and it links to a file in the data directory that links to
638             the individual items in the feed.
639              
640             =back
641              
642             Example:
643              
644             moku-pona add https://campaignwiki.org/rpg/feed.xml "RPG"
645             moku-pona update
646              
647             This adds the RPG entry to C as follows:
648              
649             => https%3A--campaignwiki.org-rpg-feed.xml 2020-11-07 RPG
650              
651             And if you check the file C, you'll see
652             that it's a regular Gemini list. You'll find 100 links like the following:
653              
654             => https://alexschroeder.ch/wiki/2020-11-05_Episode_34 Episode 34
655              
656             Now use C (see below) to move the files to the correct
657             directory where your Gemini server expects them.
658              
659             =cut
660              
661             # Convert a RSS or Atom feed to Gemini links
662             sub to_gemini {
663 5     5   63 my $xml = shift;
664 5         7 my $dom = eval {
665 5         26 require XML::LibXML;
666 5         25 my $parser = XML::LibXML->new(recover => 2); # no errors, no warnings
667 5         370 $parser->load_xml(string => $xml);
668             };
669 5 50       1832 if ($@) {
670 0         0 warn "$@\n";
671 0         0 return '';
672             }
673 5         32 my $root = $dom->documentElement();
674 5         158 my $xpc = XML::LibXML::XPathContext->new;
675 5         29 $xpc->registerNs('atom', 'http://www.w3.org/2005/Atom');
676 5   66     19 my $nodes = $xpc->findnodes('//atom:entry', $root) || $root->findnodes('//item');
677 5         981 my @lines;
678 5         20 for my $node ($nodes->get_nodelist) {
679 7   66     54 my $titles = $xpc->findnodes('atom:title', $node) || $node->getChildrenByTagName('title');
680 7         941 my $title = $titles->shift->textContent; # take the first
681 7         72 $title =~ s/\s+$//; # trim right
682 7         192 $title =~ s/^\s+//; # trim left
683 7   100     18 my $links = $xpc->findnodes('atom:link', $node) || $node->getChildrenByTagName('link');
684 7 100       779 next unless $links;
685 6         112 my $link = $links->shift; # take the first
686 6   66     29 my $href = $link->getAttribute('href') || $link->textContent;
687 6         99 push(@lines, "=> $href $title");
688             }
689 5         103 return join("\n", @lines, "");
690             }
691              
692             =head2 Publishing your subscription
693              
694             moku-pona publish
695              
696             This takes the important files from your data directory and copies them to a
697             target directory. You could just use symbolic links for C and
698             C, of course. But if you've subscribed to actual feeds as described
699             above, then the cache files need to get copied as well!
700              
701             Example:
702              
703             mkdir ~/subs
704             moku-pona publish ~/subs
705              
706             =head2 Serving your subscriptions via Gemini
707              
708             This depends entirely on your Gemini server. If you like it really simple, you
709             can use L. Here's how to create the certificate and key
710             files, copy them to the C<~/subs> directory created above, and run C
711             for a quick test.
712              
713             make cert
714             cp *.pem ~/subs
715             cd ~/subs
716             lupa-pona
717              
718             =cut
719              
720             sub do_publish {
721 1     1   448 my $target = shift;
722 1 50       16 die "Target $target is not a directory\n" unless -d $target;
723 1 50       12 die "Source $site_list does not exist\n" unless -f $site_list;
724 1 50       12 die "Source $updated_list does not exist\n" unless -f $updated_list;
725 1         1 my $path;
726             # copy site list
727 1         6 copy($site_list, "$target/sites.txt");
728             # copy updates but with local links for the feed files
729 1 50   2   301 open(my $in, "<:encoding(UTF-8)", $updated_list)
  2         11  
  2         5  
  2         34  
730             or die "Cannot read $updated_list: $!\n";
731 1 50       1030 open(my $out, ">:encoding(UTF-8)", "$target/updates.txt")
732             or die "Cannot write $target/updates.txt: $!\n";
733 1         77 for my $line (<$in>) {
734 1         26 chomp($line);
735 1         4 ($line) = convert($line);
736 1         6 my ($uri, $name) = $line =~ /^=> file:\/\/\/(\S+)\s+(.*)/;
737             # if the target is a local file, then that's because it is the result of a
738             # to_gemini call in do_update, so we need to copy it as well
739 1         3 $uri =~ s/[\/:]/-/g;
740 1 50       15 if (-f "$data_dir/$uri") {
741 1         6 copy("$data_dir/$uri", "$target/$uri");
742             }
743 1         284 print $out "$line\n";
744             }
745             }
746              
747             sub do_help {
748 0     0   0 my $parser = Pod::Text->new();
749 0         0 $parser->parse_file($0);
750             }
751              
752             sub main {
753 0   0 0   0 my $command = shift(@ARGV) || "help";
754 0 0       0 if ($command eq "add") { do_add(@ARGV) }
  0 0       0  
    0          
    0          
    0          
    0          
    0          
755 0         0 elsif ($command eq "remove") { do_remove(@ARGV) }
756 0         0 elsif ($command eq "list") { do_list() }
757 0         0 elsif ($command eq "cleanup") { do_cleanup(@ARGV) }
758 0         0 elsif ($command eq "update") { do_update(@ARGV) }
759 0         0 elsif ($command eq "convert") { do_convert() }
760 0         0 elsif ($command eq "publish") { do_publish(@ARGV) }
761 0         0 else { do_help() }
762             }
763              
764             main() if $0 =~ /\bmoku-pona$/;
765              
766             1;