Add initial tags, storytags, and tagcloud plugins.
[matthijs/upstream/blosxom-plugins.git] / gavinc / tags
1 # Blosxom Plugin: tags
2 # Author(s): Gavin Carr <gavin@openfusion.com.au>
3 # Version: 0.001000
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($tagroot $tag_cache $tag_entries $tag_counts);
15
16 # --- Configuration variables -----
17
18 # What path prefix is used for tag-based queries?
19 $tagroot = "/tags";
20
21 # What story header to you use for your tags?
22 my $tag_header = 'Tags';
23
24 # Where is our $tag_cache file?
25 my $tag_cache_file = "$blosxom::plugin_state_dir/tag_cache";
26
27 # ---------------------------------
28
29 $tag_cache = {};
30 $tag_entries = {};
31 $tag_counts = {};
32 my $tag_cache_dirty = 0;
33
34 my @path_tags;
35 my $path_tags_op;
36
37 sub start {
38     # Load tag_cache
39     if (-f $tag_cache_file) {
40         my $fh = FileHandle->new( $tag_cache_file, 'r' )
41            or warn "[tags] cannot open cache: $!";
42         {
43             local $/ = undef;
44             eval <$fh>;
45         }
46         close $fh;
47     } 
48
49     1;
50 }
51
52 sub entries {
53     @path_tags = ();
54     my $path_info = "/$blosxom::path_info";
55     # debug(3, "entries, path_info $path_info");
56
57     if ($path_info =~ m!^$tagroot/(.*)!) {
58         my $taglist = $1;
59         # debug(3, "entries, path_info matches tagroot (taglist $taglist)");
60         if ($taglist =~ m/;/) {
61             @path_tags = split /\s*;\s*/, $taglist;
62             $path_tags_op = ';';
63         }
64         else {
65             @path_tags = split /\s*,\s*/, $taglist;
66             $path_tags_op = ',';
67         }
68         # If $path_info matches $tagroot it's a virtual path, so reset
69         $blosxom::path_info = '';
70     }
71
72     return 0;
73 }
74
75 sub filter {
76     my ($pkg, $files_ref) = @_;
77     return 1 unless @path_tags;
78
79     my %tagged = ();
80     for my $tag (@path_tags) {
81         if ($tag_entries->{$tag} && @{ $tag_entries->{$tag} }) {
82             for my $entry ( @{ $tag_entries->{$tag} } ) {
83                 $tagged{"$blosxom::datadir$entry.$blosxom::file_extension"}++;
84             }
85         }
86     }
87     # debug(2, "entries tagged with " . join($path_tags_op,@path_tags) . ":\n  " .  join("\n  ", sort keys %tagged));
88
89     # Now delete all entries from $files_ref except those tagged
90     my $tag_count = scalar @path_tags;
91     for (keys %$files_ref) {
92         if ($path_tags_op eq ';') {
93             # OR semantics - delete unless at least one tag 
94             delete $files_ref->{$_} unless exists $tagged{$_};
95         }
96         else {
97             # AND semantics - delete unless ALL tags
98             delete $files_ref->{$_} unless $tagged{$_} == $tag_count;
99         }
100     }
101
102     return 1;
103 }
104
105 # Update tag cache on new or updated stories
106 sub story {
107     my ($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_;
108
109     my $file = "$blosxom::datadir$path/$filename.$blosxom::file_extension";
110     unless (-f $file) {
111         warn "[tags] cannot find story file '$file'";
112         return 0;
113     }
114
115     # Check story mtime
116     my $st = stat($file) or die "bad stat: $!";
117     my $mtime = $st->mtime; 
118     if ($tag_cache->{"$path/$filename"}->{mtime} == $mtime) {
119         # debug(3, "$path/$filename found up to date in tag_cache - skipping");
120         return 1;
121     }
122
123     # mtime has changed (or story is new) - compare old and new tagsets
124     my $tags_new = $blosxom::meta{$tag_header};
125     my $tags_old = $tag_cache->{"$path/$filename"}->{tags};
126     # debug(2, "tags_new: $tags_new, tags_old: $tags_old");
127
128     return 1 if defined $tags_new && defined $tags_old && $tags_old eq $tags_new;
129
130     # Update tag_cache
131     # debug(2, "updating tag_cache, mtime $mtime, tags '$tags_new'");
132     $tag_cache->{"$path/$filename"} = { mtime => $mtime, tags => $tags_new };
133     $tag_cache_dirty++;
134 }
135
136 # Write tag_cache to disk if updated
137 sub last {
138     if ($tag_cache_dirty) {
139         # Refresh tag entries and tag counts
140         $tag_entries = {};
141         $tag_counts  = {};
142         for my $entry (keys %{$tag_cache}) {
143             next unless $tag_cache->{$entry}->{tags};
144             for (split /\s*,\s*/, $tag_cache->{$entry}->{tags}) {
145                 $tag_entries->{$_} ||= [];
146                 push @{ $tag_entries->{$_} }, $entry;
147                 $tag_counts->{$_}++;
148             }
149         }
150
151         # Save tag caches back to $tag_cache_file
152         my $fh = FileHandle->new( $tag_cache_file, 'w' )
153            or warn "[tags] cannot open cache '$tag_cache_file': $!" 
154              and return 0;
155         print $fh Data::Dumper->Dump([ $tag_cache, $tag_entries, $tag_counts ], 
156                                      [ qw(tag_cache tag_entries tag_counts) ]);
157         close $fh;
158     }
159 }
160
161 1;
162
163 __END__
164
165 =head1 NAME
166
167 tags - blosxom plugin to read tags from story files, maintain a tag cache, 
168 and allow tag-based filtering
169
170 =head1 DESCRIPTION
171
172 L<tags> is a blosxom plugin to read tags from story files, maintain a tag 
173 cache, and allow tag-based filtering. 
174
175 Tags are defined in a comma-separated list in a $tag_header header at the 
176 beginning of a blosxom post, with $tag_header defaulting to 'Tags'. So for 
177 example, your post might look like:
178
179     My Post Title
180     Tags: dogs, cats, pets
181
182     Post text goes here ...
183
184 L<tags> uses the L<metamail> plugin to parse story headers, and stores 
185 the tags in a cache (in $tag_cache_file, which defaults to 
186 $blosxom::plugin_state_dir/tag_cache). The tag cache is only updated 
187 when the mtime of the story file changes, so L<tags> should perform
188 pretty well.
189
190 L<tags> also supports tag-based filtering. If the blosxom $path_info 
191 begins with $tagroot ('/tags', by default, e.g. '/tags/dogs'), then 
192 L<tags> filters the entries list to include only posts with the 
193 specified tag. The following syntaxes are supported (assuming the
194 default '/tags' for $tagroot):
195
196 =over 4
197
198 =item /tags/<tag> e.g. /tags/dog
199
200 Show only posts with the specified tag.
201
202 =item /tags/<tag1>,<tag2>[,<tag3>...] e.g. /tags/dogs,cats
203
204 (Comma-separated) Show only posts with ALL of the specified tags i.e.
205 AND semantics.
206
207 =item /tags/<tag1>;<tag2>[;<tag3>...] e.g. /tags/dogs;cats
208
209 (Comma-separated) Show only posts with ANY of the specified tags i.e.
210 OR semantics.
211
212 =back
213
214 =head1 USAGE
215
216 L<tags> should be loaded early as it modifies blosxom $path_info when 
217 doing tag-based filtering. Specifically, it needs to be loaded BEFORE
218 any C<entries> plugins (e.g. L<entries_index>, L<entries_cache>, 
219 L<entries_timestamp>, etc.)
220
221 L<tags> depends on the L<metamail> plugin to parse the tags header, 
222 though, so it must be loaded AFTER L<metamail>.
223
224 Also, because L<tags> does tag-based filtering, any filter plugins 
225 that you want to have a global view of your entries (like 
226 L<recententries>, for example) should be loaded BEFORE L<tags>. 
227
228 =head1 ACKNOWLEDGEMENTS
229
230 This plugin was inspired by xtaran's excellent L<tagging> plugin.
231 Initially I was just looking to add caching to L<tagging>, but found
232 I wanted to use a more modular approach, and wanted to do slightly 
233 different filtering than L<tagging> offered as well. L<tagging> is
234 still more full-featured.
235
236 =head1 SEE ALSO
237
238 L<tags> only handles maintaining the tags cache and doing tag-based
239 filtering. For displaying tag lists in stories, see L<storytags>.
240 For displaying a tagcloud, see L<tagcloud>.
241
242 L<metamail> is used for the tags header parsing.
243
244 See also xtaran's L<tagging> plugin, which inspired L<tags>.
245
246 Blosxom: http://blosxom.sourceforge.net/
247
248 =head1 AUTHOR
249
250 Gavin Carr <gavin@openfusion.com.au>, http://www.openfusion.net/
251
252 =head1 LICENSE
253
254 Copyright 2007, Gavin Carr.
255
256 This plugin is licensed under the same terms as blosxom itself i.e.
257
258 Permission is hereby granted, free of charge, to any person obtaining a
259 copy of this software and associated documentation files (the "Software"),
260 to deal in the Software without restriction, including without limitation
261 the rights to use, copy, modify, merge, publish, distribute, sublicense,
262 and/or sell copies of the Software, and to permit persons to whom the
263 Software is furnished to do so, subject to the following conditions:
264
265 The above copyright notice and this permission notice shall be included
266 in all copies or substantial portions of the Software.
267
268 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
269 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
270 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
271 THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
272 OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
273 ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
274 OTHER DEALINGS IN THE SOFTWARE.
275
276 =cut
277
278 # vim:ft=perl:sw=4
279