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   11982 use Modern::Perl '2018';
  9         41252  
  9         112  
79 9     9   7253 use File::Copy qw(copy);
  9         19744  
  9         872  
80 9     9   5577 use Encode::Locale qw(decode_argv);
  9         102381  
  9         718  
81 9     9   88 use Encode qw(decode_utf8);
  9         26  
  9         647  
82 9     9   4400 use Mojo::IOLoop;
  9         2741509  
  9         117  
83 9     9   7066 use XML::LibXML;
  9         302974  
  9         67  
84 9     9   6587 use URI::Escape;
  9         15083  
  9         51850  
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   48 return $ENV{MOKU_PONA} if $ENV{MOKU_PONA};
131             # find an existing directory
132 9 50 33     58 return $ENV{XDG_DATA_HOME} . '/moku-pona' if $ENV{XDG_DATA_HOME} and -d $ENV{XDG_DATA_HOME} . '/moku-pona';
133 9 50 33     252 return $ENV{HOME} . '/.local/moku-pona' if $ENV{HOME} and -d $ENV{HOME} . '/.local/moku-pona';
134 9 50 33     216 return $ENV{HOME} . '/.moku-pona' if $ENV{HOME} and -d $ENV{HOME} . '/.moku-pona';
135 9 50 33     57 return $ENV{APPDATA} . '/moku-pona' if $ENV{APPDATA} and -d $ENV{APPDATA} . '/.moku-pona';
136 9 50 33     43 return $ENV{LOGDIR} . '/.moku-pona' if $ENV{LOGDIR} and -d $ENV{LOGDIR} . '/.moku-pona';
137             # or use a new one
138 9 50       38 return $ENV{XDG_DATA_HOME} . '/moku-pona' if $ENV{XDG_DATA_HOME};
139 9 50 33     150 return $ENV{HOME} . '/.local/moku-pona' if $ENV{HOME} and -d $ENV{HOME} . '/.local';
140 9 50       73 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   131 for (@_) {
182 44 100       212 next if /^=> /; # is already a gemini link
183 14         115 my ($type, $desc, $selector, $host, $port) = /^([^\t])([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t\r]*)/;
184 14 100 66     65 if ($host and $port) {
185 11   50     28 $port //= 0;
186 11         54 $_ = "=> gopher://$host:$port/$type$selector $desc";
187             }
188             }
189 25         159 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,
211             tls_options => { SSL_verify_mode => 0x00 }}
212             => sub {
213 0     0   0 my ($loop, $err, $stream) = @_;
214 0 0       0 if (not $stream) {
215 0         0 warn "Cannot connect to $url\n";
216 0         0 return;
217             }
218 0         0 my $header;
219             $stream->on(read => sub {
220 0         0 my ($stream, $bytes) = @_;
221 0         0 $responses->{$url} .= $bytes;
222 0 0       0 $header = $responses->{$url} =~ s/^.*\r\n// unless $header});
  0         0  
223 0         0 $stream->write("$url\r\n")})
224 0         0 }
225              
226             sub query_gopher {
227 10     10   20 my $url = shift;
228 10         13 my $responses = shift;
229 10         21 my ($selector, $host, $port) = url_to_gopher($url);
230 10         26 my $tls = $url =~ /^gophers/;
231             Mojo::IOLoop->client(
232             {port => $port, address => $host, tls => $tls }
233             => sub {
234 10     10   14760 my ($loop, $err, $stream) = @_;
235 10 50       37 if (not $stream) {
236 0         0 warn "Cannot connect to $url\n";
237 0         0 return;
238             }
239             $stream->on(
240             read => sub {
241 10         6623 my ($stream, $bytes) = @_;
242 10         72 $responses->{$url} .= $bytes});
  10         107  
243 10         102 $stream->write("$selector\r\n")})
244 10         229 }
245              
246             sub url_to_gopher {
247 10     10   17 my $url = shift;
248 10   33     38 my $name = shift||$url;
249 10         75 my($scheme, $authority, $path, $query, $fragment) =
250             $url =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
251 10   50     35 $scheme ||= "gopher";
252 10 50       40 return unless $scheme =~ /^gophers?$/;
253 10 50       63 $path = substr($path, 1) if substr($path, 0, 1) eq "/";
254 10 50       24 my $type = $path ? substr($path, 0, 1) : "1";
255 10 50       22 my $selector = $path ? substr($path, 1) : "";
256 10         44 my ($host, $port) = split(/:/, $authority, 2);
257 10   50     52 return ($selector, $host, $port||70);
258             }
259              
260             sub query_web {
261 0     0   0 my $url = shift;
262 0         0 my $responses = shift;
263 0         0 my($scheme, $authority, $path, $query, $fragment) =
264             $url =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
265 0         0 my $tls = $scheme eq "https";
266 0         0 my ($host, $port) = split(/:/, $authority, 2);
267 0   0     0 $path ||= "/";
268 0         0 my $selector = $path;
269 0 0       0 $selector .= "?" . $query if $query;
270             # ignore the fragment
271 0 0 0     0 $port ||= $tls ? 443 : 80;
272             Mojo::IOLoop->client(
273             {port => $port, address => $host, tls => $tls }
274             => sub {
275 0     0   0 my ($loop, $err, $stream) = @_;
276 0         0 my $header;
277             $stream->on(read => sub {
278 0         0 my ($stream, $bytes) = @_;
279 0         0 $responses->{$url} .= $bytes;
280 0 0       0 $header = $responses->{$url} =~ s/^.*\r\n\r\n//s unless $header});
  0         0  
281 0         0 $stream->write("GET $selector HTTP/1.0\r\n"
282             . "Host: $host:$port\r\n"
283             . "User-Agent: moku-pona\r\n"
284 0         0 . "\r\n")});
285             }
286              
287             =head2 Migration from 1.1
288              
289             The best way to migrate your setup is probably to use the C subcommand
290             explained later, and to recreate your list of subscriptions. Then your
291             C file will use gemtext format.
292              
293             moku-pona list | grep "moku-pona add" > commands
294             mv ~/.moku-pona/sites.txt ~/.moku-pona/sites.txt~
295             sh commands
296              
297             =cut
298              
299             sub load_site {
300 29     29   1274 my $file = $site_list;
301 29 100       589 return [] if not -f $file;
302 21 50       845 open(my $fh, "<:encoding(UTF-8)", $file) or die "Cannot read $file: $!\n";
303 21         3414 my @lines = <$fh>;
304 21         468 chomp(@lines);
305 21         86 @lines = grep(/^=> /, convert(@lines)); # from gopher
306 21         422 return \@lines;
307             }
308              
309             sub load_file {
310 17     17   3691 my $file = shift;
311 17 100       418 return "" if not -f $file;
312 14 50       546 open(my $fh, "<:encoding(UTF-8)", $file)
313             or die "Cannot read $file: $!\n";
314 14         1242 local $/ = undef;
315 14         408 return <$fh>;
316             }
317              
318             sub save_file {
319 22     22   2247 my $file = shift;
320 22         32 my $data = shift;
321 22 100       479 mkdir $data_dir unless -d $data_dir;
322 4 50   4   34 open(my $fh, ">:encoding(UTF-8)", $file)
  4         8  
  4         33  
  22         138739  
323             or die "Cannot write $file: $!\n";
324 22         166635 print $fh $data;
325             }
326              
327             =head2 List your subscriptions
328              
329             moku-pona list
330              
331             This lists all your current subscriptions in a format that is suitable for a
332             shell script!
333              
334             Example:
335              
336             moku-pona list | grep "alexschroeder"
337              
338             In this particular case, since I'm testing my own server, the result would be:
339              
340             moku-pona add https://alexschroeder.ch/wiki?action=rss "rss"
341             moku-pona add gemini://alexschroeder.ch/ "gemini"
342             moku-pona add gopher://alexschroeder.ch/ "gopher"
343             moku-pona add gophers://alexschroeder.ch:7443/ "gophers"
344              
345             =cut
346              
347             sub do_list {
348 1     1   1576 my $site = load_site();
349 1         7 print("Subscribed items in $site_list:\n");
350 1 50       5 print("none\n") unless @$site;
351 1         2 for my $line (@$site) {
352             # skip item type
353 2         12 my ($uri, $name) = $line =~ /^=> (\S+)\s+(.*)/;
354 2         12 print(qq{moku-pona add $uri "$name"\n});
355             }
356             }
357              
358             =head2 Add a subscription
359              
360             moku-pona add url [description]
361              
362             This adds a URL to the list of subscribed items. If the target is an Atom or RSS
363             feed, then that's also supported. You can provide an optional description for
364             this URL. If you don't provide a description, the URL will be used as the item's
365             description.
366              
367             Example:
368              
369             moku-pona add gemini://alexschroeder.ch kensanata
370              
371             =cut
372              
373             sub do_add {
374 8     8   1576 my $uri = shift;
375 8         17 my $name = shift;
376 8   33     25 $name ||= $uri;
377 8         25 my $line = "=> $uri $name";
378 8         33 my $site = load_site();
379 8         29 my $uri_re = quotemeta($uri);
380 8         21 my $name_re = quotemeta($name);
381 8 50       189 if (grep(/^=> $uri_re /, @$site)) {
    50          
382 0         0 warn("$uri already exists in $site_list\n");
383             } elsif (grep(/^=> \S+ $name_re/, @$site)) {
384 0         0 warn("$name already exists in $site_list\n");
385             } else {
386 8         36 push(@$site, $line);
387             }
388 8         42 save_file($site_list, join("\n", @$site, ""));
389             }
390              
391             =head2 Remove a subscription
392              
393             moku-pona remove description
394              
395             This removes one or more URLs from the list of subscribed items.
396              
397             Example:
398              
399             moku-pona remove kensanata
400              
401             =cut
402              
403             sub do_remove {
404 2     2   2073 my @args = @_;
405 2         8 my $site = load_site();
406 2         8 my $count = 0;
407 2         5 my $i = 0;
408 2   66     18 while (@args and $i < @$site) {
409 4         29 my $line = $site->[$i];
410 4         30 my ($uri, $name) = $line =~ /^=> (\S+)\s+(.*)/;
411 4         7 my $found = 0;
412 4         5 my $j = 0;
413 4         12 while ($j < @args) {
414 4 100       12 if ($name eq $args[$j]) {
415 2         4 $count++;
416 2         5 $found = 1;
417 2         7 splice(@$site, $i, 1); # remove the site found
418 2         5 splice(@args, $j, 1); # remove the arg found
419             } else {
420 2         7 $j++;
421             }
422             }
423 4 100       17 $i++ unless $found;
424             }
425 2 50       7 if ($count) {
426 2 50       98 printf("Removed %d %s\n", $count,
427             $count == 1 ? "subscription" : "subscriptions");
428 2         21 save_file($site_list, join("\n", @$site, ""));
429             } else {
430 0         0 warn("No subscriptions matching @args found\n");
431 0         0 warn("Use moku-pona list to find the correct descriptions.\n");
432             }
433             }
434              
435             =head2 Clean up the data directory
436              
437             moku-pona cleanup [--confirm]
438              
439             When Moku Pona updates, copies of the URL targets are saved in the data
440             directory. If you remove a subscription (see above), that leaves a cache file in
441             the data directory that is no longer used – and it leaves an entry in
442             C that is no longer wanted. The cleanup command fixes this. It
443             deletes all the cached pages that you are no longer subscribed to, and it
444             removes those entries from C as well.
445              
446             Actually, just to be sure, if you run it without the C<--confirm> argument, it
447             simply prints which files it would trash. Rerun it with the C<--confirm>
448             argument to actually do it.
449              
450             Example:
451              
452             moku-pona cleanup
453              
454             =cut
455              
456             sub do_cleanup {
457 2   100 2   3665 my $confirm = shift||'' eq '--confirm';
458 2         5 my $todo = 0;
459             # get a hash map telling us the cache files we expect based on our sites
460 2         8 my $site = load_site();
461             my %caches = map {
462 2         9 my ($uri, $name) = /^=> (\S+)\s+(.*)/;
  2         18  
463 2         18 $uri =~ s/[\/:]/-/g;
464 2         16 "$data_dir/$uri" => 1;
465             } @$site;
466             # get a list of text files in the directory
467 2 50       78 opendir(my $dh, $data_dir) or die "Cannot read $data_dir: $!\n";
468 2         58 my @files = map { "$data_dir/$_" } grep { /^[^.]/ } readdir($dh);
  8         21  
  12         31  
469 2         28 closedir($dh);
470             # remove unnecessary cache files
471 2         7 for my $file (@files) {
472 8 100       19 next if $file eq $site_list;
473 6 100       28 next if $file eq $updated_list;
474 4 100       10 next if $caches{$file};
475 2 100       7 if ($confirm) {
476 1         63 unlink $file;
477             } else {
478 1         47 print "trash $file\n";
479 1         4 $todo++;
480             }
481             }
482             # check updates list
483 2 50       32 if (-f $updated_list) {
484 2 50       67 open(my $fh, "<:encoding(UTF-8)", $updated_list)
485             or die "Cannot read $updated_list: $!\n";
486 2         191 my @lines = <$fh>;
487 2         42 chomp(@lines);
488             # decide what to do about each line in updates, looking just at the names
489 2         7 my %sites = map { s/^=> (\S+)\s+(.*)/$2/; $_ => 1 } @$site;
  2         21  
  2         10  
490 2         5 my @deletes;
491             my @keeps;
492 2         39 for my $line (@lines) {
493 4 100 66     40 if ($line =~ /^=> \S+ \d\d\d\d-\d\d-\d\d (.+)/ and not $sites{$1}) {
494 2         4 push(@deletes, $line);
495 2         5 $todo++;
496             } else {
497 2         5 push(@keeps, $line);
498             }
499             }
500 2 100 66     17 print "Removing these entries from updates:\n"
501             . join("\n", @deletes, "") if @deletes and not $confirm;
502             # save
503 2 100       27 save_file($updated_list, join("\n", @keeps, "")) if $confirm;
504             }
505 2 100 66     46 if ($todo && !$confirm) {
506 1         3 print "\n";
507 1         6 print "Use moku-pona cleanup --confirm to do it.\n";
508             }
509             }
510              
511             =head2 Update
512              
513             moku-pona update [--quiet] [names...]
514              
515             This updates all the subscribed items and generates a new local page for you to
516             visit: C.
517              
518             Example:
519              
520             moku-pona update
521              
522             If you call it from a cron job, you might want to use the C<--quiet> argument to
523             prevent it from printing all the sites it's contacting (since cron will then
524             mail this to you and you might not care for it unless there's a problem). If
525             there's a problem, you'll still get a message.
526              
527             This is how I call it from my C, for example
528              
529             #m h dom mon dow command
530             11 7,14 * * * /home/alex/bin/moku-pona update --quiet
531              
532             If you're testing things, you can also fetch just a limited number of items by
533             listing them.
534              
535             Example:
536              
537             moku-pona update "RPG Planet"
538              
539             The C files may contain lines that are not links at the top. These
540             will remain untouched. The rest is links. New items are added at the beginning
541             of the links and older copies of such items are removed from the links.
542              
543             =cut
544              
545             sub add_update {
546 4     4   13 my $line = shift;
547 4         80 my ($uri, $name) = $line =~ /^=> (\S+)\s+(.*)/;
548             # add current date
549 4         64 my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(); # UTC
550 4         47 my $date = sprintf('%4d-%02d-%02d', $year + 1900, $mon + 1, $mday);
551 4         24 $line = "=> $uri $date $name";
552             # load file
553 4         8 my @lines;
554 4 100       89 if (-f $updated_list) {
555 3 50       155 open(my $fh, "<:encoding(UTF-8)", $updated_list)
556             or die "Cannot read $updated_list: $!\n";
557 3         440 @lines = convert(<$fh>); # from gohper
558 3         60 chomp(@lines);
559             }
560             # start the new list with the non-list links
561 4         34 my @new = grep(!/^=>/, @lines);
562             # add the line to the new list
563 4         10 push(@new, $line);
564             # add the remaining links to the new list, except for the ones matching the name of the new line
565 4         103 push(@new, grep(!/\d\d\d\d-\d\d-\d\d $name$/, grep(/^=>/, @lines)));
566             # save
567 4         90 save_file($updated_list, join("\n", @new, ""));
568             }
569              
570             sub do_update {
571 5     5   6935 my $quiet = grep { $_ eq '--quiet' } @_;
  0         0  
572 5         17 my @sites = grep { $_ ne '--quiet' } @_;
  0         0  
573 5         22 my $site = load_site();
574 5         33 my %responses;
575             my @uris;
576 5         0 my %names;
577 5         0 my %lines;
578 5         35 for my $line (@$site) {
579 10         1321 my ($uri, $name) = $line =~ /^=> (\S+)(?:[ \t]+(.*))?/;
580 10   33     29 $name ||= $uri;
581 10 50 33     38 next unless @sites == 0 or grep { $_ eq $name } @sites;
  0         0  
582 10 50       419 say("Fetching $name...") unless $quiet;
583 10         55 push(@uris, $uri);
584 10         37 $names{$uri} = $name;
585 10         25 $lines{$uri} = $line;
586 10 50       51 if ($uri =~ /^gopher/) {
    0          
    0          
587 10         30 query_gopher($uri, \%responses);
588             } elsif ($uri =~ /^gemini/) {
589 0         0 query_gemini($uri, \%responses);
590             } elsif ($uri =~ /^http/) {
591 0         0 query_web($uri, \%responses);
592             } else {
593 0         0 warn "Don't know how to fetch $uri\n";
594             }
595             }
596              
597 5 50       1650 Mojo::IOLoop->start unless Mojo::IOLoop->is_running;
598              
599 5         2024 for my $uri (keys %responses) {
600 10         47 my $name = $names{$uri};
601             # decode the UTF-8 when we have the entire response
602 10         64 my $new = decode_utf8($responses{$uri});
603 10 50       352 if (not $new) {
604 0         0 warn("$name returned an empty document\n");
605 0         0 next;
606             }
607 10         63 my $filename = $uri;
608 10         131 $filename =~ s/[\/:]/-/g;
609 10         42 my $cache = "$data_dir/$filename";
610 10 100       55 if ($new =~ /^<(\?xml|rss)/) {
611 2         10 $new = to_gemini($new);
612 2         145 my $encoded = uri_escape_utf8($filename);
613 2         105 $lines{$uri} = "=> $encoded $name"; # now referring to the local cache file
614             }
615 10         34 my $old = load_file($cache);
616 10 100       272 if ($new ne $old) {
617 4 50       214 say "$name updated" unless $quiet;
618 4         42 add_update($lines{$uri});
619 4         20 save_file($cache, $new);
620             } else {
621 6 50       284 say "$name unchanged" unless $quiet;
622             }
623             }
624             }
625              
626             =head2 Subscribing to feeds
627              
628             When the result of an update is an XML document, then it is parsed and the links
629             of its items (if RSS) or entries (if Atom) are extracted and saved in the cache
630             file in the data directory. The effect is this:
631              
632             =over
633              
634             =item If you subscribe to a regular page, then the link to it in C
635             moves to the top when it changes.
636              
637             =item If you subscribe to a feed, then the link in C moves to the
638             top when it changes and it links to a file in the data directory that links to
639             the individual items in the feed.
640              
641             =back
642              
643             Example:
644              
645             moku-pona add https://campaignwiki.org/rpg/feed.xml "RPG"
646             moku-pona update
647              
648             This adds the RPG entry to C as follows:
649              
650             => https%3A--campaignwiki.org-rpg-feed.xml 2020-11-07 RPG
651              
652             And if you check the file C, you'll see
653             that it's a regular Gemini list. You'll find 100 links like the following:
654              
655             => https://alexschroeder.ch/wiki/2020-11-05_Episode_34 Episode 34
656              
657             Now use C (see below) to move the files to the correct
658             directory where your Gemini server expects them.
659              
660             =cut
661              
662             # Convert a RSS or Atom feed to Gemini links
663             sub to_gemini {
664 5     5   86 my $xml = shift;
665 5         8 my $dom = eval {
666 5         55 require XML::LibXML;
667 5         45 my $parser = XML::LibXML->new(recover => 2); # no errors, no warnings
668 5         480 $parser->load_xml(string => $xml);
669             };
670 5 50       2614 if ($@) {
671 0         0 warn "$@\n";
672 0         0 return '';
673             }
674 5         49 my $root = $dom->documentElement();
675 5         210 my $xpc = XML::LibXML::XPathContext->new;
676 5         38 $xpc->registerNs('atom', 'http://www.w3.org/2005/Atom');
677 5   66     32 my $nodes = $xpc->findnodes('//atom:entry', $root) || $root->findnodes('//item');
678 5         1366 my @lines;
679 5         26 for my $node ($nodes->get_nodelist) {
680 7   66     73 my $titles = $xpc->findnodes('atom:title', $node) || $node->getChildrenByTagName('title');
681 7         1338 my $title = $titles->shift->textContent; # take the first
682 7         102 $title =~ s/\s+$//; # trim right
683 7         288 $title =~ s/^\s+//; # trim left
684 7   100     23 my $links = $xpc->findnodes('atom:link', $node) || $node->getChildrenByTagName('link');
685 7 100       1003 next unless $links;
686 6         138 my $link = $links->shift; # take the first
687 6   66     37 my $href = $link->getAttribute('href') || $link->textContent;
688 6         128 push(@lines, "=> $href $title");
689             }
690 5         94 return join("\n", @lines, "");
691             }
692              
693             =head2 Publishing your subscription
694              
695             moku-pona publish
696              
697             This takes the important files from your data directory and copies them to a
698             target directory. You could just use symbolic links for C and
699             C, of course. But if you've subscribed to actual feeds as described
700             above, then the cache files need to get copied as well!
701              
702             Example:
703              
704             mkdir ~/subs
705             moku-pona publish ~/subs
706              
707             =head2 Serving your subscriptions via Gemini
708              
709             This depends entirely on your Gemini server. If you like it really simple, you
710             can use L. Here's how to create the certificate and key
711             files, copy them to the C<~/subs> directory created above, and run C
712             for a quick test.
713              
714             make cert
715             cp *.pem ~/subs
716             cd ~/subs
717             lupa-pona
718              
719             =cut
720              
721             sub do_publish {
722 1     1   473 my $target = shift;
723 1 50       18 die "Target $target is not a directory\n" unless -d $target;
724 1 50       14 die "Source $site_list does not exist\n" unless -f $site_list;
725 1 50       14 die "Source $updated_list does not exist\n" unless -f $updated_list;
726 1         2 my $path;
727             # copy site list
728 1         8 copy($site_list, "$target/sites.txt");
729             # copy updates but with local links for the feed files
730 1 50   2   365 open(my $in, "<:encoding(UTF-8)", $updated_list)
  2         21  
  2         4  
  2         34  
731             or die "Cannot read $updated_list: $!\n";
732 1 50       1241 open(my $out, ">:encoding(UTF-8)", "$target/updates.txt")
733             or die "Cannot write $target/updates.txt: $!\n";
734 1         90 for my $line (<$in>) {
735 1         24 chomp($line);
736 1         3 ($line) = convert($line);
737 1         8 my ($uri, $name) = $line =~ /^=> file:\/\/\/(\S+)\s+(.*)/;
738             # if the target is a local file, then that's because it is the result of a
739             # to_gemini call in do_update, so we need to copy it as well
740 1         3 $uri =~ s/[\/:]/-/g;
741 1 50       21 if (-f "$data_dir/$uri") {
742 1         8 copy("$data_dir/$uri", "$target/$uri");
743             }
744 1         379 print $out "$line\n";
745             }
746             }
747              
748             sub do_help {
749 0     0   0 my $parser = Pod::Text->new();
750 0         0 $parser->parse_file($0);
751             }
752              
753             sub main {
754 0   0 0   0 my $command = shift(@ARGV) || "help";
755 0 0       0 if ($command eq "add") { do_add(@ARGV) }
  0 0       0  
    0          
    0          
    0          
    0          
    0          
756 0         0 elsif ($command eq "remove") { do_remove(@ARGV) }
757 0         0 elsif ($command eq "list") { do_list() }
758 0         0 elsif ($command eq "cleanup") { do_cleanup(@ARGV) }
759 0         0 elsif ($command eq "update") { do_update(@ARGV) }
760 0         0 elsif ($command eq "convert") { do_convert() }
761 0         0 elsif ($command eq "publish") { do_publish(@ARGV) }
762 0         0 else { do_help() }
763             }
764              
765             main() if $0 =~ /\bmoku-pona$/;
766              
767             1;