# Blosxom Plugin: aaa_tags # Author(s): Eli the Bearded # Version: 2020-05-27 # Documentation: See the bottom of this file or type: perldoc aaa_tags package aaa_tags; # if your perl is too old and/or you don't want/need UTF-8 tags, # you can remove the utf8 bits from the next two lines and pick # a suitable value for $cc in config below. use CGI qw/:standard -utf8/; use open qw(:std :utf8); # --- Configurable variables ----- # extension for tags files; tags are one per line, lines starting # with "#" are ignored, leading / trailing whitespace stripped, # internal whitespace normalized to single space. my $tgext = 'tags'; # A regexp to match a single character in a tag. # for ASCII only tags you may want: # my $cc = qr/[\w-]/; # The above is compatible with the original version of this plugin. # # For a more extended set of ASCII maybe: # my $cc = qr'[-!$%&()*+./:;<>=?@\[\\\]_{}0-9A-Za-z]'; # which takes most printable ASCII but leaves out , ^ | and ~ operators # # This is the original ASCII plus all of Unicode high bit after # U+00A0 non-breaking space my $cc = qr/[-_0-9A-Za-z\x{A1}-\x{FFFFFF}]/; # template{flavour}{template_name}; all {template_name}s can also be # flavour files in the regular location(s). my %template = ( html => { # join together entries in $aaa_tags::tags aaa_name_sep => ', ', # join together entries in $aaa_tags::top_tags aaa_tags_sep => '
  • ', # value of $aaa_tags::tags if no tags on a story aaa_no_tags => 'no tags — so sad', # format of each tag in in $aaa_tags::tags # suggested variables to use: # $aaa_tags::this_link search for this tag # $aaa_tags::this_tag text name of this tag # $aaa_tags::this_tagsafe link-safe version of tag name # $aaa_tags::this_count how many articles with this tag # $aaa_tags::tag_count how many unique tags # $aaa_tags::story_count how many stories with tags aaa_tag_link => '$aaa_tags::this_tag ($aaa_tags::this_count)', # just like aaa_tag_link, but used in $aaa_tags::top_tags aaa_tags_link => '$aaa_tags::this_tag: $aaa_tags::this_count', # when there has been a tag search, this will be used to fill out # $aaa_tags::this_search aaa_search_desc => 'Tag search results for $aaa_tags::this_search_terse', # used when a tag filter has removed all posts from the blog aaa_nothing_left => <<'AAA_NOTHING'

    Tag search found nothing

    Out of $aaa_tags::story_count tagged stories, nothing matched the search for $aaa_tags::this_search_terse.

    $aaa_tags::this_search_table
    AAA_NOTHING }, ); # how many tags to show in $top_tags # (this variable can be used in templates) $top_count = 20; # when set, minimum number of tag usage before appearing in $frequent_tags # (this variable can be used in templates) $threshold = 3; # --- end configurable variables - # -------------------------------- # variables for flavour templates, use with $aaa_tags:: in front, eg # $aaa_tags::tags # any templates $top_tags = ''; $frequent_tags = ''; $tag_count = $story_count = 0; $this_search_terse = ''; $this_search = ''; # only story template $tags = ''; $this_count = ''; $this_tag = ''; $this_tagsafe = ''; $this_link = ''; # error template and vars $this_search_verbose = ''; $this_search_table = ''; # --- end template variables ----- # -------------------------------- # file name to tags our %map; # tag to number of matches our %alltags; our $want; our @want_and; our @want_or; our @want_not; our %want_ALL; # created and deleted as a placeholder to fool blosxom into thinking # there is an article; but only used when tag filtering removes all # posts our $errorpage = "$blosxom::plugin_state_dir/aaa-error.$$"; sub start { # look for a tag query my @tags = multi_param("tag"); for my $param (@tags) { for (split(',', $param)) { s/\s+//g; ($want) = (/^([~]?${cc}(?:${cc}+|[|^~-]${cc}+)*)$/); $want ||= ''; $want =~ tr:_: :; $want = lc($want); if ($want =~ /\|/) { for my $t (split(/\|/, $want)) { if ($t =~ s/^[~]//) { push(@want_not, $t); } else { push(@want_or, $t); } } } elsif ($want =~ /\^/) { for my $t (split(/\^/, $want)) { if ($t =~ s/^[~]//) { push(@want_not, $t); } else { push(@want_and, $t); } } } else { if ($want =~ s/^[~]//) { push(@want_not, $want); } else { push(@want_or, $want); } } } } for (@want_or, @want_and, @want_not) { $want_ALL{$_} = 0; } 1; } # the filter() plugin is called after locating files and before story(). The # purpose is to, well, filter what stories will be shown. Such as only # including those matching a particular tag. It also does a pass over the # %others list to find tag files and load them up. sub filter { my %dels; my %keeps; my @parts; for my $other (%blosxom::others) { if ($other =~ /(.+)[.]$tgext$/) { my $story = $1 . '.' . $blosxom::file_extension; if (!exists($blosxom::files{$story})) { # don't process if no story next; } my $all = -3 - (keys %want_ALL); my $and = @want_and + 0; # keeping $story as is would be handy for filtering, # but it's less useful that way for finding tags during # article display $story =~ s,^$blosxom::datadir/+,/,; if (open(my $fd, '<', $other)) { my @tl; while(<$fd>) { chomp; s/^\s+//; s/\s+$//; s/\s+/ /g; next if /^#/; next unless /\S/; $_ = lc($_); push (@tl, $_); $alltags{$_} ++; if (length($want)) { # $want itself is only a flag that some tag is wanted # the @want_and and @want_or have the exact lists. for $want (@want_or) { if ($_ eq $want) { $keeps{"$blosxom::datadir$story"} += $and + 1; } } for $want (@want_and) { if ($_ eq $want) { $keeps{"$blosxom::datadir$story"} ++; } } for $want (@want_not) { if ($_ eq $want) { $keeps{"$blosxom::datadir$story"} = $all; } } if(exists($want_ALL{$_})) { $want_ALL{$_} ++; } } } $story_count ++; $map{$story} = \@tl; } # if open "other" file if($and and defined($keeps{"$blosxom::datadir$story"}) and $keeps{"$blosxom::datadir$story"} < $and) { # not enough anded tags $keeps{"$blosxom::datadir$story"} = undef; } } } # for others # build top tags for my $t (sort { # rev numerical sort, reg alpha sort for ties ( $alltags{$b} <=> $alltags{$a} ) || ($a cmp $b) } (keys %alltags)) { $tag_count ++; next if $tag_count > $top_count; $this_tag = $t; mktag_link('aaa_tags_link'); push(@parts, $tag_link); } # for(my $i = 0; $i < @parts; $i ++) { # $this_tag = $parts[$i]; # mktag_link('aaa_tags_link'); # $parts[$i] = $tag_link; # } $top_tags = join(fill_template('aaa_tags_sep'), @parts); undef(@parts); # build frequent tags tags if(defined($threshold) and $threshold > 0) { for my $t (sort { # reg alpha sort, now with rev numerical sort for ties ($a cmp $b) || ( $alltags{$b} <=> $alltags{$a} ) } (keys %alltags)) { next if $threshold > $alltags{$t}; $this_tag = $t; mktag_link('aaa_tags_link'); push(@parts, $tag_link); } $frequent_tags = join(fill_template('aaa_tags_sep'), @parts); undef(@parts); } # now do filtering if visitor wanted a particular tag if(length($want)) { my $wnter = ''; for my $not (@want_not) { $wnter .= "~$not"; } # discourage tag result pages from being indexed if ($page != 1) { if ($blosxom::plugins{"extrameta"} > 0) { $extrameta::header .= qq(); } } # this is just the value to stuff into tag= $this_search_terse = join(',', join('^', @want_and), join('|', @want_or), $wnter ); $this_search_terse =~ s/^\s*,\s*//g; $this_search_terse =~ s/\s*,\s*$//g; $this_search = fill_template('aaa_search_desc'); # not safe to delete from hash while iterating over it, so # need temp list of deletes foreach (keys %blosxom::files) { if(!exists($keeps{$_}) or !defined($keeps{$_}) or $keeps{$_} < 0) { $dels{$_} = 1; } } foreach (keys %dels) { delete($blosxom::files{$_}); } if(keys(%dels) and ! keys(%blosxom::files)) { # erased everything # include erased everything template here if(@want_and) { $this_search_verbose = 'All of ' . join(', ', @want_and) . ''; if (@want_or) { $this_search_verbose = '(' . $this_search_verbose . ') OR ('; } } if (@want_or) { $this_search_verbose .= 'Any of ' . join('|', @want_or) . ''; if (@want_and) { $this_search_verbose .= ')'; } } if (@want_not) { if(length($this_search_verbose)) { $this_search_verbose .= ' BUT NONE OF ('; } else { $this_search_verbose .= ' Not tagged ('; } $this_search_verbose .= '' . join('|', @want_not) . ')'; } $this_search_verbose .= '. '; $this_search_table = qq(\n\n\n); $this_search_table .= qq(); $this_search_table .= qq(\n\n); for $want (sort { $a cmp $b } (keys %want_ALL)) { $this_search_table .= qq(\n); } $this_search_table .= qq(
    For reference on those tags
    tagusage count
    $want) . qq($want_ALL{$want}
    \n\n); open(CREATE_ONLY, '>', $errorpage) and close CREATE_ONLY; $blosxom::files{$errorpage} = time(); # by-pass directory check with permalink flag $blosxom::use_permalink = 1; } } 1; } # the story() plugin method is called once per story entry. Normally we will # redefine the $tags variable with every post, so that story rendering can then # have the proper tags available. # But if filter() removed all stories, spit out an error page here. # story() gets called with a bunch of args. sub story { my ($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_; $tags = fill_template('aaa_no_tags'); if($filename eq '' and -f $errorpage) { # error page invoked $$title_ref = ''; $$body_ref = ''; $$story_ref = fill_template('aaa_nothing_left'); unlink $errorpage; return 0; } my $use = "$path/$filename.$blosxom::file_extension"; my @parts; foreach $this_tag (@{$map{"$use"}}) { mktag_link('aaa_tag_link'); push(@parts, $tag_link); } $tags = join(fill_template('aaa_name_sep'), @parts); 1; } # from one global, sets a bunch of other globals, used in two places sub mktag_link { my $t = shift; $this_tagsafe = $this_tag; $this_tagsafe =~ tr: :_:; $this_link = "$blosxom::url?tag=$this_tagsafe"; $this_count = $alltags{$this_tag}; $tag_link = fill_template($t); } # Use actual (blosxom or plugin-override) template / interpolate methods sub fill_template { my ($chunk) = @_; my $tmpl = undef; $tmpl = &$blosxom::template("$blosxom::datadir/$curr_path", $chunk, $blosxom::flavour); # fallback to default if ($tmpl eq '') { $tmpl = $template{$blosxom::flavour}{$chunk}; # if that exists $tmpl = '' if(!defined($tmpl)); } $tmpl = &$blosxom::interpolate($tmpl); return $tmpl; } 1; __END__ =head1 NAME Blosxom Plug-in: aaa_tags =head1 SYNOPSIS Searches the "other" list (non-blog entries) for C files. =head1 DESCRIPTION The C<.tags> (suffix configurable) files are read in looking for tags. Tag files which lack a matching story are discarded. Tags are one per line not starting with C<#>, and can be space, hyphen, or underscore separated. But spaces will be changed to underscores for URLs. Tags can be searched via CGI parameters. One or more C CGI params can be used. Each one can contain one or more search fragments joined by a C<,> (comma). The default is to OR the named tags together for filtering the output, but if separated by C<^> (caret) they will ANDed, and if prefixed by C<~> (tilde) they will be NOTed. Tags joined by C<|> (pipe> will also be ORed. There is no way to specify precedence or grouping of the filter. The identified tags are associated with the matching C files. When C is processed, C<$aaa_tags::tags> will be set to use in (and only in) the story template. The C<$aaa_tags::top_tags> variable holds a configurable number of the most popular tags and is available in all templates. If C<$threshold> is set in config, then C<$aaa_tags::frequent_tags> will hold a similar list of tags with $threshold or more usage. When a tag filter removes all posts, the C template will be invoked to display an error message. If the C plugin is available, tag result pages will be flagged as C to reduce duplication in search engines. Here's the complete list of interpolatable variables: =over 4 =item * $aaa_tags::tags Available in stories, and formatted according to the C template. =item * $aaa_tags::top_tags Available anywhere, and formatted according to the C template. =item * $aaa_tags::frequent_tags Available anywhere, and formatted according to the C template. Will be empty unless C<$threshold> is set in config. =item * $aaa_tags::tag_count Available anywhere, this is the number of unique tags. =item * $aaa_tags::this_search Available anywhere, this is a description of the current tag search OR empty if not searching. =item * $aaa_tags::this_search_terse Available anywhere, this is the current tag search in a terse (CGI param-ish) form. =item * $aaa_tags::this_search_table Available in C, this is a table of all tags mentioned in the filter with usage frequency. =item * $aaa_tags::this_search_verbose Available in C, this is the current tag search in a verbose, for humans, form. =item * $aaa_tags::story_count Available anywhere, this is the number of tagged stories. =item * $aaa_tags::this_tag Intended for use in templates used by C it has the display version of the current tag. =item * $aaa_tags::this_link Intended for use in templates used by C it has the URL to search for the current tag. =item * $aaa_tags::this_tagsafe Intended for use in templates used by C it has the URL-safe version of the current tag. =item * $aaa_tags::this_count Intended for use in templates used by C it has the count of stories using the current tag. =item * $aaa_tags::threshold This configuration variable is available for all templates. =item * $aaa_tags::top_count This configuration variable is available for all templates. =back Here are the flavour templates used: =over 4 =item * aaa_name_sep Joins entries together in the per story $aaa_tags::tags, default: ', ' =item * aaa_tags_sep Joins entries together in $aaa_tags::top_tags, default: '
  • ' =item * aaa_no_tags Value of $aaa_tags::tags if no tags on a story, default: 'no tags — so sad' =item * aaa_tag_link Formating for each tag in per story $aaa_tags::tags, default: '$aaa_tags::this_tag ($aaa_tags::this_count)' =item * aaa_tags_link Formating for each tag in $aaa_tags::top_tags, default: '$aaa_tags::this_tag: $aaa_tags::this_count' =item * aaa_search_desc When there has been a tag search, this will be used to fill out $aaa_tags::this_search, default: 'Tag search results for $aaa_tags::this_search_terse' =item * aaa_nothing_left Used to present an error page with a tag filter has removed all posts from the blog. =back =head1 VERSION 2020-05-27 =head1 AUTHOR Eli the Bearded =head1 SEE ALSO Blosxom, http://www.blosxom.com/ =head1 LICENSE Released under the same license as blosxom: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. =cut