2 # Blosxom Plugin: tagging
3 # Author(s): Axel Beckert <blosxom@deuxchevaux.org>, http://noone.org/blog
5 # Licensing: GPL v2 or newer, http://www.gnu.org/licenses/gpl.txt
6 # Tagging plugin web page: http://noone.org/blog?-tags=Tagging
7 # Tagging plugin download: http://noone.org/blosxom/tagging
8 # Blosxom web page: http://blosxom.ookee.com/
12 # This is a plugin for blosxom.
16 # Just drop it into your blosxoms plugin directory and it should start
17 # working. If you want, change some of the configuration variables
22 # It allows you to tag Blosxom postings with keywords, filter
23 # postings based on that tags and show how often which tag was
24 # used. Should work together with Technorati Tags as described on
25 # http://www.technorati.com/help/tags.html although this feature is
26 # yet untested. (Feedback regarding this and other features is
31 # The only configuration option which may be necessary to make the
32 # tag cloud work with very less stories or very less tags, is
33 # $min_tag_no. Set it to 1 and you see every tag you every tag you
34 # used in the cloud. Set it to higher values, if you have a lot of
37 # The same counts for the related stories and
38 # $min_story_relations. By default, a story is seen as related if it
39 # shares at least two tags with the current story. Set it to 1, if
40 # you want to use it with not so many tags or stories.
44 # Add an additional line after the title, starting with "Tags:".
45 # Between this Tag line and the body text there should be a blank
46 # line. (This is in conformance with other Plugins, e.g. the one for
47 # meta tags, which work the same way.) After this keyword, the tags
48 # should be listed and separated by commata.
52 # The follwing two examples have the same effect.
61 # | Tags: A, X, B, Y, C, Z
65 # Including the tags into templates:
67 # Use $tagging::tag_list for the story tag list,
68 # $tagging::global_tag_list for a global tag list (also called tag
69 # cloud, e.g for head.html or foot.html), $tagging::current_filter
70 # for the currently used tagging filter (if any) and
71 # $tagging::related_tags for a list of tags related to the current
76 # If you want to filter by tags, append a CGI parameter named "-tags"
77 # with a comma-seperated list of tags to the URL of the blog. By
78 # default any post having at least one of the tags will be shown. If
79 # you set the CGI parameter "-conj" to "and", only posts with all of
80 # the tags will be shown.
82 # Technorati don't seem to accept URLs with tag names in the query
83 # string as tagref URLs, so with the following Apache configuration,
84 # you can do technorati accepted tagref URLs:
87 # RewriteRule ^/cgi-bin/blosxom.cgi/tags/(.*)$ /cgi-bin/blosxom.cgi?-tags=$1 [PT]
89 # Then you can use the prefined blosxom_tags as base URL for tag links.
91 # Another, less performant but simplier option is to install the
92 # plugin pathbasedtagging, available at
93 # http://noone.org/blosxom/pathbasedtagging
97 # http://blog/cgi-bin/blosxom.cgi?-tags=X,Y,Z will show you all
98 # posts which have at least one of the tags X, Y _or_ Z.
100 # http://blog/cgi-bin/blosxom.cgi?-tags=X,Y,Z&-conj=and will show
101 # you all posts which have _all_ of the tags X, Y _and_ Z.
103 # Known bugs and other ugly obstacles:
105 # + Being not as performant as I would like it to be, especially when
107 # + Related stories are not sorted by recentness when having same
108 # number of shared tags.
109 # + Tags must be written without HTML entities.
110 # + Technorati style tags currently don't work well in the tag cloud.
114 # 0.01: Initial release, based on Rael Dornfest's meta plugin.
115 # 0.01.1: Additional documentation, small compatibility fix for newer
117 # 0.02: Showing how often a tag has been used (in 3 different ways)
118 # 0.02.1: Fixed an XSS issue
119 # 0.02.2: Fixed documentation (removed multcat left-overs) and simple
120 # Technorati Tag support, see http://www.technorati.com/help/tags.html
121 # 0.03: New feature: related stories based on the already given tags
122 # (Idea by Wim de Jonge)
123 # 0.03.1: Bugfix release: Missing "/" and some minor issues
124 # 0.04: Tag blacklist for tag cloud (suggestion by Wim), boolean
125 # "and" conjunction for filtering with several tags, linked
126 # current filter, related tags, option to link to technorati
127 # tags instead of own tags, several bugfixes (Thanks to Wim
128 # and blathijs!), added a lot of documentation, renamed some
129 # config variables to have more consistent names
133 # + Generalise $base_url{wikipedia_XX}, maybe with tie.
134 # + Option to accept case insensitiv tags
135 # + Option to link all tags in lower case
147 # Where to link story tags (URLs defined below)
148 my $link_tag = 'blosxom';
150 # Where to link tags in the tag cloud (URLs defined below)
151 my $link_cloud = 'blosxom';
153 # Where to link related tags (URLs defined below)
154 my $link_rtag = 'blosxom';
157 blosxom => "$blosxom::url?-tags=",
158 blosxom_tags => "$blosxom::url/tags/",
159 technorati => "http://www.technorati.com/tags/",
160 flickr => "http://flickr.com/photos/tags/",
161 delicious => 'http://del.icio.us/tag/',
162 delicious_popular => 'http://del.icio.us/popular/',
163 delirious => 'http://de.lirio.us/rubric/entries/tags/',
164 suprcilious => 'http://supr.c.ilio.us/tag/',
165 buzznet => 'http://www.buzznet.com/buzzwords/',
166 shadows => 'http://www.shadows.com/tags/',
167 wikipedia => "http://en.wikipedia.org/wiki/",
168 wikipedia_de => "http://de.wikipedia.org/wiki/",
169 wikipedia_fr => "http://fr.wikipedia.org/wiki/",
170 wikipedia_pl => "http://pl.wikipedia.org/wiki/",
171 wikipedia_ja => "http://ja.wikipedia.org/wiki/",
172 wikipedia_nl => "http://nl.wikipedia.org/wiki/",
173 wikipedia_it => "http://it.wikipedia.org/wiki/",
174 wikipedia_sv => "http://sv.wikipedia.org/wiki/",
175 wikipedia_pt => "http://pt.wikipedia.org/wiki/",
176 wikipedia_es => "http://es.wikipedia.org/wiki/",
177 wikipedia_da => "http://da.wikipedia.org/wiki/",
178 wikipedia_hu => "http://hu.wikipedia.org/wiki/",
179 wikipedia_no => "http://no.wikipedia.org/wiki/",
180 wikipedia_nn => "http://nn.wikipedia.org/wiki/",
181 wikipedia_lb => "http://lb.wikipedia.org/wiki/",
182 wikipedia_simple => "http://simple.wikipedia.org/wiki/",
185 # Regular expressions
187 my $tag_re = qr/Tags:\s*/i;
188 my $split_re = qr/\s*,\s*/;
192 my $tag_prefix = 'Tagged as: ';
193 my $tag_suffix = ''; #' » ';
194 my $global_tag_prefix = '<p style="text-align: justify;">'; # '<p>Available tags: ';
195 my $global_tag_suffix = '</p>';
196 my $current_filter_prefix = '<p><em>Current filter:</em> »';
197 my $current_filter_suffix = '« (Click tag to exclude it or click a conjunction to switch them.)</p>';
199 # Displaying the tag cloud
203 my $show_tag_no_by_size = 1;
204 my $show_tag_no_by_color = 1;
208 my @tag_cloud_blacklist = ('Now Playing', 'Other Blogs', 'Screenshot');
210 my $start_color = 'ff9900';
211 my $end_color = '991100';
212 #my $start_color = '0000ff';
213 #my $end_color = 'ff0000';
214 #my $start_color = 'ff9900';
215 #my $end_color = '0000ff';
217 # Texts for related stories
219 my @related_stories_tag_blacklist = ('Now Playing', 'Other Blogs', 'Screenshot');
221 my $min_story_relations = 2;
222 my $max_related_stories = 5;
223 my $show_shared_tags = 0;
224 my $show_number_of_shared_tags = 1;
226 my $related_stories_prefix = '<div class="blosxomstoryfoot" align="left"><h4 class="related_stories">Related stories</h4><ul class="related_stories">'."\n";
227 my $related_stories_suffix = "\n</ul></div>\n";
228 my $related_story_join = "\n";
229 my $related_story_prefix = '<li class="related_stories">';
230 my $related_story_suffix = '</li>';
231 my $related_story_class = 'related_stories';
233 my $shared_tags_text = 'shared tags';
237 my $min_tag_relations = 2;
238 my $max_related_tags = 5; # 0 to disable;
239 my $show_tag_shares = 0;
241 my @related_tags_tag_blacklist = ('Now Playing', 'Other Blogs', 'Screenshot');
243 my $related_tags_prefix = '<p class="related_tags"><em>Related tags:</em> ';
244 my $related_tags_suffix = "\n</p>\n";
245 my $related_tag_join = ", ";
246 my $related_tag_class = 'related_tags';
249 ### Init (You can use these variables in templates prefixed with "$tagging::".)
253 $global_tag_list = '';
254 $current_filter = '';
255 $current_filter_short = '';
256 $related_stories = '';
267 my($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_;
272 foreach (split /\n/, $$body_ref) {
279 if ($in_header && /^$tag_re(.+?)$/) {
280 foreach my $tag (split($split_re, $1)) {
281 $localtags{$tag} = 1;
291 my %other_stories = ();
292 foreach my $tag (sort { lc($a) cmp lc($b) } keys %localtags) {
293 my $l_tag = &url_escape($tag);
295 qq! <a href="$base_url{$link_tag}$l_tag" rel="tag">$tag</a>,!;
297 # $tag_list .= qq! <a href="$base_url{blosxom}$tag&-technorati-hack=/$tag" rel="tag" title="Look for tag $tag in this blog"!.($invisible_plugin_tags ? qq! style="display:none;"! : '').qq!>$tag</a>! if $add_plugin_tags;
299 # Looking for similar stories
300 next if grep { $_ eq $tag } @related_stories_tag_blacklist;
301 foreach my $other (@{$tags{$tag}}) {
302 next if $other eq "$blosxom::datadir$path/$filename.$blosxom::file_extension";
303 if (exists $other_stories{$other}) {
304 push(@{$other_stories{$other}}, $tag);
306 $other_stories{$other} = [$tag];
311 $tag_list = "$tag_prefix$tag_list $tag_suffix" if $tag_list;
313 $related_stories = '';
315 foreach my $other (sort { scalar @{$other_stories{$b}} <=>
316 scalar @{$other_stories{$a}} }
317 keys %other_stories) {
318 last if scalar(@{$other_stories{$other}}) < $min_story_relations;
319 last if $i++ >= $max_related_stories;
321 $related_stories .= $related_story_join if $related_stories;
324 $opath =~ s!\Q$blosxom::datadir\E!$blosxom::url!;
325 $opath =~ s!\Q$blosxom::file_extension\E$!$blosxom::default_flavour!;
328 $title =~ s!^.*/([^/]+)\.$blosxom::file_extension$!$1!;
330 my $shared_tags_list = join(', ', @{$other_stories{$other}});
331 my $shared_tags_number = scalar(@{$other_stories{$other}});
333 my $attr_title = "$shared_tags_number $shared_tags_text: $shared_tags_list";
335 $related_stories .= qq($related_story_prefix<a href="$opath" class="$related_story_class" title="$attr_title">$title</a>);
337 $related_stories .= ' ('
338 if $show_shared_tags || $show_number_of_shared_tags;
339 $related_stories .= "$shared_tags_number "
340 if $show_number_of_shared_tags;
341 $related_stories .= $shared_tags_text
342 if $show_shared_tags || $show_number_of_shared_tags;
343 $related_stories .= ": $shared_tags_list"
344 if $show_shared_tags;
345 $related_stories .= ')'
346 if $show_shared_tags || $show_number_of_shared_tags;
349 #$related_stories .= qw|$other: |.Dumper($other_stories{$other});
351 $related_stories .= $related_story_suffix;
353 $related_stories = "$related_stories_prefix$related_stories$related_stories_suffix" if $related_stories;
360 my ($pkg, $files_ref) = @_;
361 my $filter_tags = CGI::param('-tags');
362 my $filter_conj = CGI::param('-conj');
363 $filter_tags =~ s/</[/gs; # No XSS here
364 $filter_tags =~ s/>/]/gs; # No XSS here
365 my @filter_tags = split(/\s*,\s*/, $filter_tags);
367 foreach my $key (keys %$files_ref) {
369 open(FILE, $key) or do { warn "Can't open $key: $!"; next; };
371 my $empty_line_found = 0;
372 while ($_ = <FILE>) {
374 if (m!^$tag_re(.+?)$!) {
375 my @localtags = split($split_re, $1);
376 foreach my $tag (@localtags) {
377 if (ref $tags{$tag}) {
378 push(@{$tags{$tag}}, $key);
380 $tags{$tag} = [$key];
384 next unless ($filter_tags and
385 grep { $_ eq $tag } @filter_tags);
387 foreach my $rtag (@localtags) {
388 next if ($rtag eq $tag);
390 if ($related_tags{$rtag}) {
391 $related_tags{$rtag}++;
393 $related_tags{$rtag} = 1;
403 foreach my $tag (keys %tags) {
404 next if grep { $_ eq $tag } @tag_cloud_blacklist;
406 my $list = $tags{$tag};
407 my $no = scalar @$list;
408 next if $no < $min_tag_no;
409 $max = $no if $max < $no;
410 $min = $no if $min > $no || !$min;
413 my $diff = $max - $min;
414 my $conj = ($filter_conj eq 'and' ? '&-conj=and' : '');
415 my $l_filter_tags = &url_escape($filter_tags);
417 foreach my $tag (sort { lc($a) cmp lc($b) } keys %tags) {
418 next if grep { $_ eq $tag } @tag_cloud_blacklist;
420 (my $url_tag = $tag) =~ s/\&/\%26/g;
421 (my $html_tag = $tag) =~ s/\&/\&/g;
422 my $tag_no = scalar @{$tags{$tag}};
423 next if $tag_no < $min_tag_no;
424 my $tag_no_display = $show_tag_no ? " ($tag_no)" : '';
425 my $title = $tag_no == 1 ? "1 posting tagged" : "$tag_no postings tagged";
426 my $tag_percent = $diff ? int($min_size+((($max_size-$min_size)/$diff)*($tag_no-$min+1))) : 100;
427 my $color = $diff ? &color_calc($tag_no, $min, $max) : '';
429 $style .= qq!font-size: $tag_percent%;! if $show_tag_no_by_size && $diff;
430 $style .= qq!color: #$color;! if $show_tag_no_by_color && $diff;
432 my $l_tag = &url_escape($tag);
434 qq| <a href="$base_url{$link_cloud}|.
435 ((($link_cloud eq 'blosxom') and
436 ($filter_tags !~ /(^|,)\Q$tag\E($|,)/) and
438 "$l_filter_tags," : '').
439 qq|$l_tag$conj" title="$title" style="$style">$tag</a>|.
440 qq|$tag_no_display,\n|;
443 $global_tag_list =~ s/,$//;
444 $global_tag_list = "$global_tag_prefix$global_tag_list$global_tag_suffix"
447 return 1 unless $filter_tags;
449 my @tags = split($split_re, $filter_tags);
451 foreach my $tag (@tags) {
452 my $files = $tags{$tag};
453 next unless ref $files;
455 foreach my $file (@$files) {
456 # If all tags should match
458 foreach my $ctag (@tags) {
459 if (!grep { $_ eq $file } @{$tags{$ctag}}) {
465 $localfiles{$file} = $files_ref->{$file};
469 %$files_ref = %localfiles;
471 $current_filter_short = join($conj ? ' + ' : ' | ',
472 map { s/\&/\&/g; $_; }
473 sort { lc($a) cmp lc($b) }
477 qq! <em><a href="$base_url{blosxom}$l_filter_tags">and</a></em> ! :
478 qq! <em><a href="$base_url{blosxom}$l_filter_tags&-conj=and">or</a></em> !);
479 $current_filter = ($current_filter_prefix.
482 my $tags = $filter_tags;
483 $tags =~ s/\Q$_,\E// || $tags =~ s/\Q,$_\E// || $tags =~ s/\Q$_\E//;
487 my $l_tags = &url_escape($tags);
488 qq!<a href="$base_url{blosxom}$l_tags">$_</a>!;
490 sort { lc($a) cmp lc($b) }
492 $current_filter_suffix);
495 if ($max_related_tags) {
498 foreach my $rtag (sort { $related_tags{$b} <=> $related_tags{$a} or
500 keys %related_tags) {
501 next if ((grep { $_ eq $rtag } @related_tags_tag_blacklist) or
502 $related_tags{$rtag} < $min_tag_relations);
503 my $l_rtag = &url_escape($rtag);
504 my $rel_no = $show_tag_shares ? " ($related_tags{$rtag})" : '';
506 qq!<a href="$base_url{$link_rtag}$l_rtag" rel="tag" class="$related_tag_class" title="Coincided $related_tags{$rtag} times">$rtag</a>$rel_no$related_tag_join!;
507 last if $i++ >= $max_related_tags;
509 $related_tags =~ s/\Q$related_tag_join\E$//;
513 "$related_tags_prefix$related_tags$related_tags_suffix"
515 #.'<pre>'.Dumper(\%related_tags).'</pre>';
519 # $debug = Dumper $filter_tags, $files_ref, \@tags, \%localfiles, \%tags;
525 my ($tag_no, $min, $max) = @_;
526 my $diff = $max - $min;
530 foreach my $i (0..2) {
531 my $s = &get_dec($start_color, $i*2);
532 my $e = &get_dec($end_color, $i*2);
533 my $diff_se = abs($s-$e);
535 my $rogob = ($diff_se/$diff)*($tag_no-$min);
536 $rogob = int($s < $e ? $s + $rogob : $s - $rogob);
537 $result->[$i] = sprintf('%02x', $rogob);
541 return join('', @$result);
545 my ($color, $offset) = @_;
546 return hex(substr($color, $offset, 2));
551 $s =~ s/[^0-9A-Za-z,.:]/sprintf('%%%02X', ord($&))/seg;