File Coverage

blib/lib/App/news.pm
Criterion Covered Total %
statement 66 81 81.4
branch 17 22 77.2
condition 5 9 55.5
subroutine 5 5 100.0
pod 0 4 0.0
total 93 121 76.8


line stmt bran cond sub pod time code
1             # Copyright (C) 2023 Alex Schroeder <alex@gnu.org>
2             #
3             # This program is free software; you can redistribute it and/or modify
4             # it under the terms of the GNU General Public License as published by
5             # the Free Software Foundation; either version 3 of the License, or
6             # (at your option) any later version.
7             #
8             # This program is distributed in the hope that it will be useful,
9             # but WITHOUT ANY WARRANTY; without even the implied warranty of
10             # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11             # GNU General Public License for more details.
12             #
13             # You should have received a copy of the GNU General Public License
14             # along with this program. If not, see <http://www.gnu.org/licenses/>.
15              
16             package App::news;
17              
18 7     7   365520 use Modern::Perl '2018';
  7         69  
  7         233  
19              
20             require Exporter;
21             our @ISA = qw(Exporter);
22             our @EXPORT_OK = qw(wrap html_unwrap ranges sranges);
23              
24             our $VERSION = 1.09;
25              
26             =head1 NAME
27              
28             App::news - a web front-end for a news server
29              
30             =head1 DESCRIPTION
31              
32             This is a collection of functions for F<script/news>, which see.
33              
34             use App::news qw(wrap);
35             $body = wrap($body);
36              
37             B<wrap> does text wrapping appropriate for plain text message bodies as used in
38             mail and news articles.
39              
40             If a line is shorter than 50 characters, it is not wrapped.
41              
42             Lines are wrapped to be 72 characters or shorter.
43              
44             Quotes are handled as long as only ">" is used for quoting.
45              
46             B<ranges> translates a list of message numbers into an array of numbers or
47             arrays suitable for XOVER.
48              
49             B<sranges> translates the output of I<ranges> into a string for humans to read,
50             i.e. "1-2,4".
51              
52             =head1 AUTHOR
53              
54             Alex Schroeder
55              
56             =head1 LICENSE
57              
58             GNU Affero General Public License
59              
60             =cut
61              
62             # Some US-ASCII coded characters 00-1F and 7F hexadecimal are excluded; the
63             # space character and all whitespace is excluded; angle-bracket "<" and ">" and
64             # double-quote (") characters are excluded; and the "unwise" characters are
65             # excluded. This regular expression is case-sensitive, so the scheme most be
66             # lower-case!
67             my $iri_re = qr((\b[a-z]+:[^\x00-\x1F\x7F[:space:]<>"{}|\\^\[\]`]+));
68              
69             sub wrap {
70 4     4 0 237325 my @lines = split(/\n/, shift);
71 4         9 my @result;
72 4         9 my $min = 50;
73 4         7 my $max = 72;
74 4         6 my $buffer;
75 4         8 my $prefix = '';
76 4         10 for (@lines) {
77 14         56 my ($new_prefix) = /^([> ]*)/;
78 14   66     223 my $no_wrap = (/^$prefix\s*$/ or length() < $min);
79             # end old paragraph with the old prefix if the prefix changed or a short line
80             # came up
81 14 50 33     43 if ($buffer and ($new_prefix ne $prefix or $no_wrap)) {
      66        
82 1         6 push(@result, $prefix . $buffer);
83 1         3 $buffer = '';
84             }
85             # set new prefix
86 14         26 $prefix = $new_prefix;
87             # print short lines without stripping trailing whitespace
88 14 100       26 if ($no_wrap) {
89 11         20 push(@result, $_);
90 11         27 next;
91             }
92             # continue old paragraph
93 3 50       9 $buffer .= " " if $buffer;
94             # strip the prefix
95 3         12 $buffer .= substr($_, length($prefix));
96             # wrap what we have
97 3         10 while (length($buffer) > $max) {
98             # if there's a word boundary at $max, break before
99 2 50       33 if (substr($buffer, 0, $max - length($prefix) + 1) =~ /(\s+(\S+))\S$/) {
100 2         11 push(@result, $prefix . substr($buffer, 0, $max - length($prefix) - length($1)));
101 2         12 $buffer = substr($buffer, $max - length($prefix) - length($2));
102             } else {
103 0         0 my $line = substr($buffer, 0, $max - length($prefix));
104 0         0 $line =~ s/\s+$//;
105 0         0 push(@result, $prefix . $line);
106 0         0 $buffer = substr($buffer, $max - length($prefix));
107 0         0 $buffer =~ s/^\s+//;
108             }
109             }
110             }
111 4 100       13 push(@result, $prefix . $buffer) if $buffer;
112 4         42 return join("\n", @result) . "\n";
113             }
114              
115             sub html_unwrap {
116 2     2 0 9 my @lines = split(/\n/, shift);
117 2         5 my $result;
118 2         5 my $depth = 0;
119 2         5 my @escapes;
120 2         6 for (@lines) {
121 6         15 chomp;
122 6         41 s!$iri_re!push(@escapes, qq(<a href="$1">$1</a>)); "\x1e$#escapes\x1e"!ge;
  0         0  
  0         0  
123 6         40 my ($prefix) = /^([> ]*)/;
124 6         16 my $new_depth = () = $prefix =~ />/g;
125 6         22 s/^([> ]*)//;
126 6         13 my $closed = 0;
127 6         18 while ($new_depth < $depth) {
128 0         0 $result .= "</blockquote>";
129 0         0 $depth--;
130 0         0 $closed = 1;
131             }
132 6 50       21 $result .= "\n" unless $closed; # closing blockquote already added a break
133 6         17 while ($depth < $new_depth) {
134 0         0 $result .= '<blockquote>';
135 0         0 $depth++;
136             }
137 6         13 s/&/&amp;/g;
138 6         12 s/</&lt;/g;
139 6         13 s/>/&gt;/g;
140 6         15 $result .= $_;
141             }
142 2         10 while ($depth > 0) {
143 0         0 $result .= "</blockquote>";
144 0         0 $depth--;
145             }
146 2         42 for my $i (0 .. $#escapes) {
147 0         0 $result =~ s/\x1e$i\x1e/$escapes[$i]/;
148             }
149 2         30 return $result;
150             }
151              
152             sub ranges {
153 9 50   9 0 267904 return [] unless @_;
154 9         54 my $last = shift;
155 9         18 my $curr = $last;
156 9         16 my $ranges = [];
157 9         20 for my $n (@_) {
158 17 100       53 if ($n == $curr + 1) {
    100          
159 11         19 $curr = $n;
160             } elsif ($last == $curr) {
161 3         6 push(@$ranges, $last);
162 3         7 $last = $curr = $n;
163             } else {
164 3         6 push(@$ranges, [$last, $curr]);
165 3         6 $last = $curr = $n;
166             }
167             }
168 9 100       18 if ($curr > $last) {
169 5         13 push(@$ranges, [$last, $curr]);
170             } else {
171 4         7 push(@$ranges, $curr);
172             }
173 9         29 return $ranges;
174             }
175              
176             sub sranges {
177 8     8 0 12 my $ranges = shift;
178 8 100       17 join(",", map { ref ? join("-", @$_) : $_ } @$ranges);
  13         79  
179             }
180              
181             1;