tagging: Allow using titles in for related stories.
[matthijs/upstream/blosxom-plugins.git] / gavinc / tags
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
5
6 package tags;
7
8 use strict;
9 use File::stat;
10
11 # Uncomment next line to enable debug output (don't uncomment debug() lines)
12 #use Blosxom::Debug debug_level => 2;
13
14 use vars qw(%config $tag_cache $tag_entries $tag_counts);
15
16 # --- Configuration variables -----
17
18 %config = ();
19
20 # What path prefix is used for tag-based queries?
21 $config{tagroot} = "/tags";
22
23 # What story header to you use for your tags?
24 $config{tag_header} = 'Tags';
25
26 # Where is our $tag_cache file?
27 $config{tag_cache_file} = "$blosxom::plugin_state_dir/tag_cache";
28
29 # ---------------------------------
30 # __END_CONFIG__
31
32 $tag_cache = {};
33 $tag_entries = {};
34 $tag_counts = {};
35 my $tag_cache_dirty = 0;
36
37 my @path_tags;
38 my $path_tags_op;
39
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     } 
51
52     1;
53 }
54
55 sub entries {
56     @path_tags = ();
57     my $path_info = "/$blosxom::path_info";
58     # debug(3, "entries, path_info $path_info");
59
60     if ($path_info =~ m!^$config{tagroot}/(.*)!) {
61         $blosxom::flavour = '';
62         my $taglist = $1;
63         # debug(3, "entries, path_info matches tagroot (taglist $taglist)");
64
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         }
71
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     }
85
86     return 0;
87 }
88
89 sub filter {
90     my ($pkg, $files_ref) = @_;
91     return 1 unless @path_tags;
92
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));
102
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     }
115
116     return 1;
117 }
118
119 # Update tag cache on new or updated stories
120 sub story {
121     my ($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_;
122
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     }
128
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     }
136
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");
141
142     return 1 if defined $tags_new && defined $tags_old && $tags_old eq $tags_new;
143
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++;
148 }
149
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         }
164
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     }
173 }
174
175 1;
176
177 __END__
178
179 =head1 NAME
180
181 tags - blosxom plugin to read tags from story files, maintain a tag cache, 
182 and allow tag-based filtering
183
184 =head1 DESCRIPTION
185
186 L<tags> is a blosxom plugin to read tags from story files, maintain a tag 
187 cache, and allow tag-based filtering. 
188
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:
192
193     My Post Title
194     Tags: dogs, cats, pets
195
196     Post text goes here ...
197
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.
203
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'):
209
210 =over 4
211
212 =item /tags/<tag> e.g. /tags/dog
213
214 Show only posts with the specified tag.
215
216 =item /tags/<tag1>,<tag2>[,<tag3>...] e.g. /tags/dogs,cats
217
218 (Comma-separated) Show only posts with ALL of the specified tags i.e.
219 AND semantics.
220
221 =item /tags/<tag1>;<tag2>[;<tag3>...] e.g. /tags/dogs;cats
222
223 (Comma-separated) Show only posts with ANY of the specified tags i.e.
224 OR semantics.
225
226 =back
227
228 Tag filtering also supports a trailing flavour after the taglist,
229 separated by a slash e.g.
230
231     /tags/dogs/html
232     /tags/dogs,cats/rss
233     /tags/dogs;cats;pets/atom
234
235 Note that this is different to L<tagging>, which treats trailing
236 components as additional tags.
237
238 At this point L<tags> don't support dot-flavour paths e.g.
239
240     /tags/mysql.html
241
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.
246
247 =head1 USAGE
248
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.)
253
254 L<tags> depends on the L<metamail> plugin to parse the tags header, 
255 though, so it must be loaded AFTER L<metamail>.
256
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>. 
260
261 =head1 ACKNOWLEDGEMENTS
262
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.
268
269 =head1 SEE ALSO
270
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>.
274
275 L<metamail> is used for the tags header parsing.
276
277 See also xtaran's L<tagging> plugin, which inspired L<tags>.
278
279 Blosxom: http://blosxom.sourceforge.net/
280
281 =head1 AUTHOR
282
283 Gavin Carr <gavin@openfusion.com.au>, http://www.openfusion.net/
284
285 =head1 LICENSE
286
287 Copyright 2007, Gavin Carr.
288
289 This plugin is licensed under the same terms as blosxom itself i.e.
290
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:
297
298 The above copyright notice and this permission notice shall be included
299 in all copies or substantial portions of the Software.
300
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.
308
309 =cut
310
311 # vim:ft=perl:sw=4
312