# -*- perl -*- # Blosxom Plugin: tagging # Author(s): Axel Beckert , http://noone.org/blog # Version: 0.04 # Licensing: GPL v2 or newer, http://www.gnu.org/licenses/gpl.txt # Tagging plugin web page: http://noone.org/blog?-tags=Tagging # Tagging plugin download: http://noone.org/blosxom/tagging # Blosxom web page: http://blosxom.ookee.com/ ### Documentation: # # This is a plugin for blosxom. # # Installation: # # Just drop it into your blosxoms plugin directory and it should start # working. If you want, change some of the configuration variables # below. # # What it does: # # It allows you to tag Blosxom postings with keywords, filter # postings based on that tags and show how often which tag was # used. Should work together with Technorati Tags as described on # http://www.technorati.com/help/tags.html although this feature is # yet untested. (Feedback regarding this and other features is # welcome!) # # Configuration: # # The only configuration option which may be necessary to make the # tag cloud work with very less stories or very less tags, is # $min_tag_no. Set it to 1 and you see every tag you every tag you # used in the cloud. Set it to higher values, if you have a lot of # tags in use. YMMV. # # The same counts for the related stories and # $min_story_relations. By default, a story is seen as related if it # shares at least two tags with the current story. Set it to 1, if # you want to use it with not so many tags or stories. # # How to use it: # # Add an additional line after the title, starting with "Tags:". # Between this Tag line and the body text there should be a blank # line. (This is in conformance with other Plugins, e.g. the one for # meta tags, which work the same way.) After this keyword, the tags # should be listed and separated by commata. # # Examples: # # The follwing two examples have the same effect. # # | Story Headline # | Tags: X, Y, Z # | Tags: A, B, C # | # | Story Body [...] # # | Story Headline # | Tags: A, X, B, Y, C, Z # | # | Story Body [...] # # Including the tags into templates: # # Use $tagging::tag_list for the story tag list, # $tagging::global_tag_list for a global tag list (also called tag # cloud, e.g for head.html or foot.html), $tagging::current_filter # for the currently used tagging filter (if any) and # $tagging::related_tags for a list of tags related to the current # filter. # # Filtering by tags: # # If you want to filter by tags, append a CGI parameter named "-tags" # with a comma-seperated list of tags to the URL of the blog. By # default any post having at least one of the tags will be shown. If # you set the CGI parameter "-conj" to "and", only posts with all of # the tags will be shown. # # Technorati don't seem to accept URLs with tag names in the query # string as tagref URLs, so with the following Apache configuration, # you can do technorati accepted tagref URLs: # # RewriteEngine On # RewriteRule ^/cgi-bin/blosxom.cgi/tags/(.*)$ /cgi-bin/blosxom.cgi?-tags=$1 [PT] # # Then you can use the prefined blosxom_tags as base URL for tag links. # # Another, less performant but simplier option is to install the # plugin pathbasedtagging, available at # http://noone.org/blosxom/pathbasedtagging # # Examples: # # http://blog/cgi-bin/blosxom.cgi?-tags=X,Y,Z will show you all # posts which have at least one of the tags X, Y _or_ Z. # # http://blog/cgi-bin/blosxom.cgi?-tags=X,Y,Z&-conj=and will show # you all posts which have _all_ of the tags X, Y _and_ Z. # # Known bugs and other ugly obstacles: # # + Being not as performant as I would like it to be, especially when # using -conj=and. # + Related stories are not sorted by recentness when having same # number of shared tags. # + Tags must be written without HTML entities. # + Technorati style tags currently don't work well in the tag cloud. # # Version History: # # 0.01: Initial release, based on Rael Dornfest's meta plugin. # 0.01.1: Additional documentation, small compatibility fix for newer # Perl versions # 0.02: Showing how often a tag has been used (in 3 different ways) # 0.02.1: Fixed an XSS issue # 0.02.2: Fixed documentation (removed multcat left-overs) and simple # Technorati Tag support, see http://www.technorati.com/help/tags.html # 0.03: New feature: related stories based on the already given tags # (Idea by Wim de Jonge) # 0.03.1: Bugfix release: Missing "/" and some minor issues # 0.04: Tag blacklist for tag cloud (suggestion by Wim), boolean # "and" conjunction for filtering with several tags, linked # current filter, related tags, option to link to technorati # tags instead of own tags, several bugfixes (Thanks to Wim # and blathijs!), added a lot of documentation, renamed some # config variables to have more consistent names # # TODO LIST: # # + Generalise $base_url{wikipedia_XX}, maybe with tie. # + Option to accept case insensitiv tags # + Option to link all tags in lower case # package tagging; use File::Basename; use CGI; ### ### Config ### # Where to link story tags (URLs defined below) our $link_tag = 'blosxom' unless defined $link_tag; # Where to link tags in the tag cloud (URLs defined below) our $link_cloud = 'blosxom' unless defined $link_cloud; # Where to link related tags (URLs defined below) our $link_rtag = 'blosxom' unless defined $link_rtag; our %base_url = ( blosxom => "$blosxom::url?-tags=", blosxom_tags => "$blosxom::url/tags/", technorati => "http://www.technorati.com/tags/", flickr => "http://flickr.com/photos/tags/", delicious => 'http://del.icio.us/tag/', delicious_popular => 'http://del.icio.us/popular/', delirious => 'http://de.lirio.us/rubric/entries/tags/', suprcilious => 'http://supr.c.ilio.us/tag/', buzznet => 'http://www.buzznet.com/buzzwords/', shadows => 'http://www.shadows.com/tags/', wikipedia => "http://en.wikipedia.org/wiki/", wikipedia_de => "http://de.wikipedia.org/wiki/", wikipedia_fr => "http://fr.wikipedia.org/wiki/", wikipedia_pl => "http://pl.wikipedia.org/wiki/", wikipedia_ja => "http://ja.wikipedia.org/wiki/", wikipedia_nl => "http://nl.wikipedia.org/wiki/", wikipedia_it => "http://it.wikipedia.org/wiki/", wikipedia_sv => "http://sv.wikipedia.org/wiki/", wikipedia_pt => "http://pt.wikipedia.org/wiki/", wikipedia_es => "http://es.wikipedia.org/wiki/", wikipedia_da => "http://da.wikipedia.org/wiki/", wikipedia_hu => "http://hu.wikipedia.org/wiki/", wikipedia_no => "http://no.wikipedia.org/wiki/", wikipedia_nn => "http://nn.wikipedia.org/wiki/", wikipedia_lb => "http://lb.wikipedia.org/wiki/", wikipedia_simple => "http://simple.wikipedia.org/wiki/", ) unless defined %base_url; # Regular expressions our $tag_re = qr/Tags:\s*/i unless defined $tag_re; our $split_re = qr/\s*,\s*/ unless defined $split_re; # Texts for tags our $tag_prefix = 'Tagged as: ' unless defined $tag_prefix; our $tag_suffix = '' unless defined $tag_suffix; #' » ' our $global_tag_prefix = '

' unless defined $global_tag_prefix; # '

Available tags: ' our $global_tag_suffix = '

' unless defined $global_tag_suffix; our $current_filter_prefix = '

Current filter: »' unless defined $current_filter_prefix; our $current_filter_suffix = '« (Click tag to exclude it or click a conjunction to switch them.)

' unless defined $current_filter_suffix; # Displaying the tag cloud our $min_tag_no = 2 unless defined $min_tag_no; our $show_tag_no = 0 unless defined $show_tag_no; our $show_tag_no_by_size = 1 unless defined $show_tag_no_by_size; our $show_tag_no_by_color = 1 unless defined $show_tag_no_by_color; our $max_size = 250 unless defined $max_size; our $min_size = 75 unless defined $min_size; our @tag_cloud_blacklist = ('Now Playing', 'Other Blogs', 'Screenshot') unless defined @tag_cloud_blacklist; our $start_color = 'ff9900' unless defined $start_color; our $end_color = '991100' unless defined $end_color; #our $start_color = '0000ff' unless defined $start_color; #our $end_color = 'ff0000' unless defined $end_color; #our $start_color = 'ff9900' unless defined $start_color; #our $end_color = '0000ff' unless defined $end_color; # Texts for related stories our @related_stories_tag_blacklist = ('Now Playing', 'Other Blogs', 'Screenshot') unless defined @related_stories_tag_blacklist; our $min_story_relations = 2 unless defined $min_story_relations; our $max_related_stories = 5 unless defined $max_related_stories; our $show_shared_tags = 0 unless defined $show_shared_tags; our $show_number_of_shared_tags = 1 unless defined $show_number_of_shared_tags; our $related_stories_prefix = '
\n" unless defined $related_stories_suffix; our $related_story_join = "\n" unless defined $related_story_join; our $related_story_prefix = '' unless defined $related_story_suffix; our $related_story_class = 'related_stories' unless defined $related_story_class; # Use the title of the post for the link instead of the filename our $related_story_title = 0 unless defined $related_story_title; our $shared_tags_text = 'shared tags' unless defined $shared_tags_text; # Related Tags our $min_tag_relations = 2 unless defined $min_tag_relations; our $max_related_tags = 5 unless defined $max_related_tags; # 0 to disable our $show_tag_shares = 0 unless defined $show_tag_shares; our @related_tags_tag_blacklist = ('Now Playing', 'Other Blogs', 'Screenshot') unless defined @related_tags_tag_blacklist; our $related_tags_prefix = '\n" unless defined $related_tags_suffix; our $related_tag_join = ", " unless defined $related_tag_join; our $related_tag_class = 'related_tags' unless defined $related_tag_class; ### ### Init (You can use these variables in templates prefixed with "$tagging::".) ### $tag_list = ''; $global_tag_list = ''; $current_filter = ''; $current_filter_short = ''; $related_stories = ''; $related_tags = ''; %tags = (); %titles = (); %related_tags = (); sub start { 1; } sub story { my($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_; my %localtags = (); my $body = ''; my $in_header = 1; foreach (split /\n/, $$body_ref) { if (/^\s*$/) { $in_header = 0; $body .= "$_\n"; next; } if ($in_header && /^$tag_re(.+?)$/) { foreach my $tag (split($split_re, $1)) { $localtags{$tag} = 1; } next; } $body .= "$_\n"; } $$body_ref = $body; $tag_list = ''; my %other_stories = (); foreach my $tag (sort { lc($a) cmp lc($b) } keys %localtags) { $tag_list .= " " . make_tag_link($link_tag, $tag, (rel => "tag")) . ","; # $tag_list .= qq! ! if $add_plugin_tags; # Looking for similar stories next if grep { $_ eq $tag } @related_stories_tag_blacklist; foreach my $other (@{$tags{$tag}}) { next if $other eq "$blosxom::datadir$path/$filename.$blosxom::file_extension"; if (exists $other_stories{$other}) { push(@{$other_stories{$other}}, $tag); } else { $other_stories{$other} = [$tag]; } } } $tag_list =~ s/,$//; $tag_list = "$tag_prefix$tag_list $tag_suffix" if $tag_list; $related_stories = ''; my $i = 0; foreach my $other (sort { scalar @{$other_stories{$b}} <=> scalar @{$other_stories{$a}} } keys %other_stories) { last if scalar(@{$other_stories{$other}}) < $min_story_relations; last if $i++ >= $max_related_stories; $related_stories .= $related_story_join if $related_stories; my $opath = $other; $opath =~ s!\Q$blosxom::datadir\E!$blosxom::url!; $opath =~ s!\Q$blosxom::file_extension\E$!$blosxom::default_flavour!; my $title; if ($related_story_title) { $title = $titles{$other}; } else { $title = $other; $title =~ s!^.*/([^/]+)\.$blosxom::file_extension$!$1!; } my $shared_tags_list = join(', ', @{$other_stories{$other}}); my $shared_tags_number = scalar(@{$other_stories{$other}}); my $attr_title = blosxom::blosxom_html_escape("$shared_tags_number $shared_tags_text: $shared_tags_list"); my $attr_href = blosxom::blosxom_html_escape($opath); my $html_title = blosxom::blosxom_html_escape($title); $related_stories .= qq($related_story_prefix$html_title); $related_stories .= ' (' if $show_shared_tags || $show_number_of_shared_tags; $related_stories .= "$shared_tags_number " if $show_number_of_shared_tags; $related_stories .= $shared_tags_text if $show_shared_tags || $show_number_of_shared_tags; $related_stories .= blosxom::blosxom_html_escape(": $shared_tags_list") if $show_shared_tags; $related_stories .= ')' if $show_shared_tags || $show_number_of_shared_tags; #use Data::Dumper; #$related_stories .= qw|$other: |.Dumper($other_stories{$other}); $related_stories .= $related_story_suffix; } $related_stories = "$related_stories_prefix$related_stories$related_stories_suffix" if $related_stories; return 1; } sub filter { my ($pkg, $files_ref) = @_; my $filter_tags = CGI::param('-tags'); my $filter_conj = CGI::param('-conj'); $filter_tags =~ s//]/gs; # No XSS here my @filter_tags = split(/\s*,\s*/, $filter_tags); foreach my $key (keys %$files_ref) { next if -l $key; open(FILE, $key) or do { warn "Can't open $key: $!"; next; }; my $tags_found = 0; my $empty_line_found = 0; while ($_ = ) { # Take the title from the first line if (not defined $titles{$key}) { $titles{$key} = $_; } last if /^\s*$/; if (m!^$tag_re(.+?)$!) { my @localtags = split($split_re, $1); foreach my $tag (@localtags) { if (ref $tags{$tag}) { push(@{$tags{$tag}}, $key); } else { $tags{$tag} = [$key]; } # Related tags next unless ($filter_tags and grep { $_ eq $tag } @filter_tags); foreach my $rtag (@localtags) { next if ($rtag eq $tag); if ($related_tags{$rtag}) { $related_tags{$rtag}++; } else { $related_tags{$rtag} = 1; } } } } } } my $max = 1; my $min = 0; foreach my $tag (keys %tags) { next if grep { $_ eq $tag } @tag_cloud_blacklist; my $list = $tags{$tag}; my $no = scalar @$list; next if $no < $min_tag_no; $max = $no if $max < $no; $min = $no if $min > $no || !$min; } my $diff = $max - $min; foreach my $tag (sort { lc($a) cmp lc($b) } keys %tags) { next if grep { $_ eq $tag } @tag_cloud_blacklist; (my $url_tag = $tag) =~ s/\&/\%26/g; (my $html_tag = $tag) =~ s/\&/\&/g; my $tag_no = scalar @{$tags{$tag}}; next if $tag_no < $min_tag_no; my $tag_no_display = $show_tag_no ? " ($tag_no)" : ''; my $title = $tag_no == 1 ? "1 posting tagged" : "$tag_no postings tagged"; my $tag_percent = $diff ? int($min_size+((($max_size-$min_size)/$diff)*($tag_no-$min+1))) : 100; my $color = $diff ? &color_calc($tag_no, $min, $max) : ''; my $style = ''; $style .= qq!font-size: $tag_percent%;! if $show_tag_no_by_size && $diff; $style .= qq!color: #$color;! if $show_tag_no_by_color && $diff; $global_tag_list .= make_tag_link($link_cloud, $tag, (title => $title, style => $style)). qq|$tag_no_display,\n|; } $global_tag_list =~ s/,$//; $global_tag_list = "$global_tag_prefix$global_tag_list$global_tag_suffix" if $global_tag_list; return 1 unless $filter_tags; my @tags = split($split_re, $filter_tags); my %localfiles = (); foreach my $tag (@tags) { my $files = $tags{$tag}; next unless ref $files; FILES: foreach my $file (@$files) { # If all tags should match if ($filter_conj eq 'and') { foreach my $ctag (@tags) { if (!grep { $_ eq $file } @{$tags{$ctag}}) { next FILES; } } } $localfiles{$file} = $files_ref->{$file}; } } %$files_ref = %localfiles; $current_filter_short = blosxom::blosxom_html_escape( join($filter_conj eq 'and' ? ' + ' : ' | ', sort { lc($a) cmp lc($b) } @tags )); my $l_filter_tags = &url_escape($filter_tags); $conj = ($filter_conj eq 'and' ? qq! and ! : qq! or !); $current_filter = ($current_filter_prefix. join($conj, map { make_tag_link('blosxom', $_); } sort { lc($a) cmp lc($b) } @tags). $current_filter_suffix); # Related tags if ($max_related_tags) { $related_tags = ''; my $i = 1; foreach my $rtag (sort { $related_tags{$b} <=> $related_tags{$a} or $a cmp $b } keys %related_tags) { next if ((grep { $_ eq $rtag } @related_tags_tag_blacklist) or $related_tags{$rtag} < $min_tag_relations); my $rel_no = $show_tag_shares ? " ($related_tags{$rtag})" : ''; $related_tags .= make_tag_link($link_rtag, $rtag, (rel => "tag", class => $related_tag_class, title => "Coincided $related_tags{$rtag} times")). "$rel_no$related_tag_join"; last if $i++ >= $max_related_tags; } $related_tags =~ s/\Q$related_tag_join\E$//; #use Data::Dumper; $related_tags = "$related_tags_prefix$related_tags$related_tags_suffix" if $related_tags; #.'
'.Dumper(\%related_tags).'
'; } # use Data::Dumper; # $debug = Dumper $filter_tags, $files_ref, \@tags, \%localfiles, \%tags; 1; } # Create the url for a given tag. Depending on the tag and link type given, # this adds to, removes or replaces the current tag list. The link type given # is one of the keys of %base_url sub make_tag_link { my ($link, $tag, %attrs) = @_; my $filter_tags = CGI::param('-tags'); my $conj = (CGI::param('-conj') eq 'and' ? '&-conj=and' : ''); # If we're linking to ourselves, the currently selected tag list is not # empty, and this tag is not in there yet, prefix the link with the # current filter list. my $tags; if (($link_cloud eq 'blosxom') and $filter_tags) { if ($filter_tags =~ /(^|,)\Q$tag\E($|,)/) { # The tag is already in there, remove it $tags = $filter_tags; $tags =~ s/\Q,$tag,\E/,/ || $tags =~ s/(^|,)\Q$tag\E($|,)//; } else { # The tag is not in there, add it $tags = "$filter_tags,$tag"; } $tags = &url_escape($tags); $tags .= $conj; } else { # We're linking externally, or don't have a filter yet. Just use the # selected tag as the filter $tags = &url_escape($tag); } # Set the href attribute $attrs{href} = "$base_url{$link}$tags"; # Generate attribute values my $attrs = join('', map { $val = blosxom::blosxom_html_escape($attrs{$_}); qq! $_="$val"!; } keys %attrs); return "" . blosxom::blosxom_html_escape($tag) . ""; } sub color_calc { my ($tag_no, $min, $max) = @_; my $diff = $max - $min; my $result = []; foreach my $i (0..2) { my $s = &get_dec($start_color, $i*2); my $e = &get_dec($end_color, $i*2); my $diff_se = abs($s-$e); my $rogob = ($diff_se/$diff)*($tag_no-$min); $rogob = int($s < $e ? $s + $rogob : $s - $rogob); $result->[$i] = sprintf('%02x', $rogob); } #use Data::Dumper; return join('', @$result); } sub get_dec { my ($color, $offset) = @_; return hex(substr($color, $offset, 2)); } sub url_escape { my $s = shift; $s =~ s/[^0-9A-Za-z,.:]/sprintf('%%%02X', ord($&))/seg; return $s; } 1;