Convert remaining gavinc plugins to %config versions.
[blosxom-plugins.git] / gavinc / tags
blob644239efb9ee777d867dacbe88d4b5fa438f48f3
1 # Blosxom Plugin: tags
2 # Author(s): Gavin Carr <gavin@openfusion.com.au>
3 # Version: 0.002000
4 # Documentation: See the bottom of this file or type: perldoc tags
6 package tags;
8 use strict;
9 use File::stat;
11 # Uncomment next line to enable debug output (don't uncomment debug() lines)
12 #use Blosxom::Debug debug_level => 2;
14 use vars qw(%config $tag_cache $tag_entries $tag_counts);
16 # --- Configuration variables -----
18 %config = ();
20 # What path prefix is used for tag-based queries?
21 $config{tagroot} = "/tags";
23 # What story header to you use for your tags?
24 $config{tag_header} = 'Tags';
26 # Where is our $tag_cache file?
27 $config{tag_cache_file} = "$blosxom::plugin_state_dir/tag_cache";
29 # ---------------------------------
30 # __END_CONFIG__
32 $tag_cache = {};
33 $tag_entries = {};
34 $tag_counts = {};
35 my $tag_cache_dirty = 0;
37 my @path_tags;
38 my $path_tags_op;
40 sub start {
41     # Load tag_cache
42     if (-f $config{tag_cache_file}) {
43         my $fh = FileHandle->new( $config{tag_cache_file}, 'r' )
44            or warn "[tags] cannot open cache: $!";
45         {
46             local $/ = undef;
47             eval <$fh>;
48         }
49         close $fh;
50     } 
52     1;
55 sub entries {
56     @path_tags = ();
57     my $path_info = "/$blosxom::path_info";
58     # debug(3, "entries, path_info $path_info");
60     if ($path_info =~ m!^$config{tagroot}/(.*)!) {
61         $blosxom::flavour = '';
62         my $taglist = $1;
63         # debug(3, "entries, path_info matches tagroot (taglist $taglist)");
65         # Allow flavours appended to tags after a slash
66         # Dot-flavour versions are problematic, because tags can include dot e.g. web2.0
67         if ($taglist =~ m! /(\w+)$ !x) {
68             $blosxom::flavour = $1;
69             $taglist =~ s! /$blosxom::flavour$ !!x;
70         }
72         # Split individual tags out of taglist
73         if ($taglist =~ m/;/) {
74             @path_tags = split /\s*;\s*/, $taglist;
75             $path_tags_op = ';';
76         }
77         else {
78             @path_tags = split /\s*,\s*/, $taglist;
79             $path_tags_op = ',';
80         }
81         # If $path_info matches tagroot it's a virtual path, so reset
82         $blosxom::path_info = '';
83         $blosxom::flavour ||= $blosxom::default_flavour;
84     }
86     return 0;
89 sub filter {
90     my ($pkg, $files_ref) = @_;
91     return 1 unless @path_tags;
93     my %tagged = ();
94     for my $tag (@path_tags) {
95         if ($tag_entries->{$tag} && @{ $tag_entries->{$tag} }) {
96             for my $entry ( @{ $tag_entries->{$tag} } ) {
97                 $tagged{"$blosxom::datadir$entry.$blosxom::file_extension"}++;
98             }
99         }
100     }
101     # debug(2, "entries tagged with " . join($path_tags_op,@path_tags) . ":\n  " .  join("\n  ", sort keys %tagged));
103     # Now delete all entries from $files_ref except those tagged
104     my $tag_count = scalar @path_tags;
105     for (keys %$files_ref) {
106         if ($path_tags_op eq ';') {
107             # OR semantics - delete unless at least one tag 
108             delete $files_ref->{$_} unless exists $tagged{$_};
109         }
110         else {
111             # AND semantics - delete unless ALL tags
112             delete $files_ref->{$_} unless $tagged{$_} == $tag_count;
113         }
114     }
116     return 1;
119 # Update tag cache on new or updated stories
120 sub story {
121     my ($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_;
123     my $file = "$blosxom::datadir$path/$filename.$blosxom::file_extension";
124     unless (-f $file) {
125         warn "[tags] cannot find story file '$file'";
126         return 0;
127     }
129     # Check story mtime
130     my $st = stat($file) or die "bad stat: $!";
131     my $mtime = $st->mtime; 
132     if ($tag_cache->{"$path/$filename"}->{mtime} == $mtime) {
133         # debug(3, "$path/$filename found up to date in tag_cache - skipping");
134         return 1;
135     }
137     # mtime has changed (or story is new) - compare old and new tagsets
138     my $tags_new = $blosxom::meta{$config{tag_header}};
139     my $tags_old = $tag_cache->{"$path/$filename"}->{tags};
140     # debug(2, "tags_new: $tags_new, tags_old: $tags_old");
142     return 1 if defined $tags_new && defined $tags_old && $tags_old eq $tags_new;
144     # Update tag_cache
145     # debug(2, "updating tag_cache, mtime $mtime, tags '$tags_new'");
146     $tag_cache->{"$path/$filename"} = { mtime => $mtime, tags => $tags_new };
147     $tag_cache_dirty++;
150 # Write tag_cache to disk if updated
151 sub last {
152     if ($tag_cache_dirty) {
153         # Refresh tag entries and tag counts
154         $tag_entries = {};
155         $tag_counts  = {};
156         for my $entry (keys %{$tag_cache}) {
157             next unless $tag_cache->{$entry}->{tags};
158             for (split /\s*,\s*/, $tag_cache->{$entry}->{tags}) {
159                 $tag_entries->{$_} ||= [];
160                 push @{ $tag_entries->{$_} }, $entry;
161                 $tag_counts->{$_}++;
162             }
163         }
165         # Save tag caches back to $config{tag_cache_file}
166         my $fh = FileHandle->new( $config{tag_cache_file}, 'w' )
167            or warn "[tags] cannot open cache '$config{tag_cache_file}': $!" 
168              and return 0;
169         print $fh Data::Dumper->Dump([ $tag_cache, $tag_entries, $tag_counts ], 
170                                      [ qw(tag_cache tag_entries tag_counts) ]);
171         close $fh;
172     }
177 __END__
179 =head1 NAME
181 tags - blosxom plugin to read tags from story files, maintain a tag cache, 
182 and allow tag-based filtering
184 =head1 DESCRIPTION
186 L<tags> is a blosxom plugin to read tags from story files, maintain a tag 
187 cache, and allow tag-based filtering. 
189 Tags are defined in a comma-separated list in a $config{tag_header} header 
190 at the beginning of a blosxom post, with $tag_header defaulting to 'Tags'. 
191 So for example, your post might look like:
193     My Post Title
194     Tags: dogs, cats, pets
196     Post text goes here ...
198 L<tags> uses the L<metamail> plugin to parse story headers, and stores 
199 the tags in a cache (in $config{tag_cache_file}, which defaults to 
200 $blosxom::plugin_state_dir/tag_cache). The tag cache is only updated 
201 when the mtime of the story file changes, so L<tags> should perform
202 pretty well.
204 L<tags> also supports tag-based filtering. If the blosxom $path_info 
205 begins with $config{tagroot} ('/tags', by default, e.g. '/tags/dogs'), 
206 then L<tags> filters the entries list to include only posts with the 
207 specified tag. The following syntaxes are supported (assuming the
208 default '/tags' for 'tagroot'):
210 =over 4
212 =item /tags/<tag> e.g. /tags/dog
214 Show only posts with the specified tag.
216 =item /tags/<tag1>,<tag2>[,<tag3>...] e.g. /tags/dogs,cats
218 (Comma-separated) Show only posts with ALL of the specified tags i.e.
219 AND semantics.
221 =item /tags/<tag1>;<tag2>[;<tag3>...] e.g. /tags/dogs;cats
223 (Comma-separated) Show only posts with ANY of the specified tags i.e.
224 OR semantics.
226 =back
228 Tag filtering also supports a trailing flavour after the taglist,
229 separated by a slash e.g.
231     /tags/dogs/html
232     /tags/dogs,cats/rss
233     /tags/dogs;cats;pets/atom
235 Note that this is different to L<tagging>, which treats trailing
236 components as additional tags.
238 At this point L<tags> don't support dot-flavour paths e.g.
240     /tags/mysql.html
242 The problem with this is that tags can include dots, so it's
243 ambiguous how to parse C</tags/web2.0>, for instance. If you'd
244 like this supported and have suggestions about how to handle
245 the ambiguities, please get in touch.
247 =head1 USAGE
249 L<tags> should be loaded early as it modifies blosxom $path_info when 
250 doing tag-based filtering. Specifically, it needs to be loaded BEFORE
251 any C<entries> plugins (e.g. L<entries_index>, L<entries_cache>, 
252 L<entries_timestamp>, etc.)
254 L<tags> depends on the L<metamail> plugin to parse the tags header, 
255 though, so it must be loaded AFTER L<metamail>.
257 Also, because L<tags> does tag-based filtering, any filter plugins 
258 that you want to have a global view of your entries (like 
259 L<recententries>, for example) should be loaded BEFORE L<tags>. 
261 =head1 ACKNOWLEDGEMENTS
263 This plugin was inspired by xtaran's excellent L<tagging> plugin.
264 Initially I was just looking to add caching to L<tagging>, but found
265 I wanted to use a more modular approach, and wanted to do slightly 
266 different filtering than L<tagging> offered as well. L<tagging> is
267 still more full-featured.
269 =head1 SEE ALSO
271 L<tags> only handles maintaining the tags cache and doing tag-based
272 filtering. For displaying tag lists in stories, see L<storytags>.
273 For displaying a tagcloud, see L<tagcloud>.
275 L<metamail> is used for the tags header parsing.
277 See also xtaran's L<tagging> plugin, which inspired L<tags>.
279 Blosxom: http://blosxom.sourceforge.net/
281 =head1 AUTHOR
283 Gavin Carr <gavin@openfusion.com.au>, http://www.openfusion.net/
285 =head1 LICENSE
287 Copyright 2007, Gavin Carr.
289 This plugin is licensed under the same terms as blosxom itself i.e.
291 Permission is hereby granted, free of charge, to any person obtaining a
292 copy of this software and associated documentation files (the "Software"),
293 to deal in the Software without restriction, including without limitation
294 the rights to use, copy, modify, merge, publish, distribute, sublicense,
295 and/or sell copies of the Software, and to permit persons to whom the
296 Software is furnished to do so, subject to the following conditions:
298 The above copyright notice and this permission notice shall be included
299 in all copies or substantial portions of the Software.
301 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
302 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
303 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
304 THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
305 OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
306 ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
307 OTHER DEALINGS IN THE SOFTWARE.
309 =cut
311 # vim:ft=perl:sw=4