# Tagwire - A plugin for listing and handling "tags" # # $Id$ # # This software is provided as-is. You may use it for commercial or # personal use. If you distribute it, please keep this notice intact. # # Copyright (c) 2005,2006 Hirotaka Ogawa package MT::Plugin::Tagwire; use strict; use MT; use base qw(MT::Plugin); use MT::Template::Context; use MT::Entry; use MT::Request; # DEBUG my $FORCE_PD_REFRESH = 0; our $HAVE_MT_PLUGINDATA = 0; our $HAVE_MT_XSEARCH = 0; our $cache; my $plugin; BEGIN { our $VERSION = '0.26'; $plugin = __PACKAGE__->new({ name => 'Tagwire Plugin', description => 'A plugin for listing and handling blog-wide tags and entry tags.', doc_link => 'http://as-is.net/hacks/2005/06/tagwire_plugin.html', author_name => 'Hirotaka Ogawa', author_link => 'http://profile.typekey.com/ogawa/', version => $VERSION, }); MT->add_plugin($plugin); eval { require MT::PluginData; $HAVE_MT_PLUGINDATA = 1 }; if ($HAVE_MT_PLUGINDATA) { MT::Entry->add_callback('post_save', 10, $plugin, \&post_save); MT::Entry->add_callback('post_remove', 10, $plugin, \&post_remove); } $cache = MT::Request->instance; MT::Template::Context->add_container_tag(Tags => \&tags); MT::Template::Context->add_container_tag(EntryTags => \&entry_tags); MT::Template::Context->add_container_tag(RelatedTags => \&related_tags); MT::Template::Context->add_tag(Tag => \&tag); MT::Template::Context->add_tag(TagCount => \&tag_count); MT::Template::Context->add_tag(TagDate => \&tag_date); MT::Template::Context->add_tag(TagsTotal => \&tags_total); MT::Template::Context->add_tag(TagsTotalSum => \&tags_total_sum); MT::Template::Context->add_container_tag(EntriesWithTags => \&entries); MT::Template::Context->add_container_tag(MostRelatedEntries => \&most_related_entries); MT::Template::Context->add_global_filter(encode_urlplus => \&encode_urlplus); # For compatibility (this plugin was formerly named 'AllKeywords') MT::Template::Context->add_container_tag(AllKeywords => \&tags); MT::Template::Context->add_container_tag(EntryAllKeywords => \&entry_tags); MT::Template::Context->add_tag(AllKeyword => \&tag); MT::Template::Context->add_tag(AllKeywordCount => \&tag_count); MT::Template::Context->add_tag(AllKeywordsTotal => \&tags_total); MT::Template::Context->add_tag(AllKeywordsTotalSum => \&tags_total_sum); MT::Template::Context->add_container_tag(EntriesWithKeywords => \&entries); eval { require MT::XSearch; $HAVE_MT_XSEARCH = 1 }; if ($HAVE_MT_XSEARCH) { MT::XSearch->add_search_plugin('Tagwire', { label => 'Tag(Keyword) Search', description => 'Tag(Keyword) Search plugin for MT-XSearch', on_execute => \&xsearch_on_execute, on_stash => \&xsearch_on_stash, }); MT::Template::Context->add_container_tag(XSearchTags => \&xsearch_tags); } } sub post_save { my ($eh, $dummy, $obj) = @_; update_plugindata($obj); } sub post_remove { my ($eh, $obj) = @_; # set it to be "draft" to remove $obj->status(MT::Entry::HOLD()) if $obj->status == MT::Entry::RELEASE(); update_plugindata($obj); } sub update_plugindata { return unless $HAVE_MT_PLUGINDATA && $plugin; my ($entry) = @_; my $refresh = $FORCE_PD_REFRESH || 0; my $version = $plugin->version; my $blog_id = $entry->blog_id; my $pd = MT::PluginData->load({ plugin => $plugin->name, key => $blog_id }); if (!$pd) { $pd = new MT::PluginData(); $pd->plugin($plugin->name); $pd->key($blog_id); $refresh = 1; } my $data = $pd->data() || {}; $refresh = 1 if !exists $data->{version} || ${$data->{version}} ne $version; my (%eindex, %tindex); if ($refresh) { my $iter = MT::Entry->load_iter({ blog_id => $blog_id, status => MT::Entry::RELEASE() }); while (my $e = $iter->()) { my @tags = split_tags($e->keywords, 1) or next; $eindex{$e->id} = { tags => \@tags, created_on => $e->created_on }; } } else { my $eid = $entry->id; %eindex = %{$data->{eindex}}; delete $eindex{$eid} if exists $eindex{$eid}; if ($entry->status == MT::Entry::RELEASE()) { my @tags = split_tags($entry->keywords, 1); $eindex{$eid} = { tags => \@tags, created_on => $entry->created_on }; } } foreach my $eid (keys %eindex) { my $ts = $eindex{$eid}->{created_on}; foreach (@{$eindex{$eid}->{tags}}) { push @{$tindex{$_}->{eids}}, $eid; $tindex{$_}->{ts} = $ts if !exists $tindex{$_}->{ts} || $tindex{$_}->{ts} < $ts; } } $data->{version} = \$version; $data->{eindex} = \%eindex; $data->{tindex} = \%tindex; $pd->data($data); $pd->save or die $pd->errstr; $cache->cache('Tagwire::Cache::' . $blog_id, undef); } sub split_args { my ($string, $delimiter, $case_sensitive) = @_; return unless $string; $string =~ s/\#.*$//g; $string =~ s/(^\s+|\s+$)//g; $string = lc $string unless $case_sensitive; return split(/\s+/, $string) unless $delimiter; my @tags; foreach my $tag (split($delimiter, $string)) { $tag =~ s/(^\s+|\s+$)//g; push @tags, $tag if $tag; } @tags; } sub split_tags { my ($string, $case_sensitive) = @_; return unless $string; my @tags; $string =~ s/\#.*$//g; $string =~ s/(^\s+|\s+$)//g; $string = lc $string unless $case_sensitive; # $string =~ s/\[[^[]+\]//g; # uncomment this to discard [short title] if ($string =~ m/[;,|]/) { # tags separated by non-whitespaces while ($string =~ m/(\[[^]]+\]|"[^"]+"|'[^']+'|[^;,|]+)/g) { my $tag = $1; $tag =~ s/(^[\["'\s;,|]+|[\]"'\s;,|]+$)//g; push @tags, $tag if $tag; } } else { # tags separated by whitespaces while ($string =~ m/(\[[^]]+\]|"[^"]+"|'[^']+'|[^\s]+)/g) { my $tag = $1; $tag =~ s/(^[\["'\s]+|[\]"'\s]+$)//g; push @tags, $tag if $tag; } } @tags; } sub get_indexes { my $blog_id = shift or return; my $cname = 'Tagwire::Cache::' . $blog_id; return $cache->cache($cname) if defined $cache->cache($cname); $cache->cache($cname, undef); my $data; if ($HAVE_MT_PLUGINDATA && $plugin) { my $pd = MT::PluginData->load({ plugin => $plugin->name, key => $blog_id }); $data = $pd->data() if $pd; $data = undef if (!exists $data->{version} || ${$data->{version}} ne $plugin->version); } $data = get_indexes_from_db($blog_id) unless defined $data; $cache->cache($cname, $data) if $data; } sub get_indexes_from_db { my $blog_id = shift or return; my ($eindex, $tindex); my $iter = MT::Entry->load_iter({ blog_id => $blog_id, status => MT::Entry::RELEASE() }); while (my $e = $iter->()) { my @tags = split_tags($e->keywords, 1) or next; $eindex->{$e->id} = { tags => \@tags, created_on => $e->created_on }; } foreach my $eid (keys %$eindex) { my $eidx = $eindex->{$eid}; my $ts = $eidx->{created_on}; foreach (@{$eidx->{tags}}) { push @{$tindex->{$_}->{eids}}, $eid; $tindex->{$_}->{ts} = $ts if !exists $tindex->{$_}->{ts} || $tindex->{$_}->{ts} < $ts; } } my $data = { eindex => $eindex, tindex => $tindex }; $data; } sub tags { my ($ctx, $args, $cond) = @_; # sort_by (tag/tag-case/count, default = tag) my $sort_by = $args->{sort_by} || 'tag'; # sort_order (ascend/descend, default = ascend) my $sort_order = $args->{sort_order} || 'ascend'; # lastn (default = 0, no cutoff) my $lastn = $args->{lastn} || 0; # case_sensitive (0/1, default = 1) my $case_sensitive = defined $args->{case_sensitive} ? $args->{case_sensitive} : 1; my $data = get_indexes($ctx->stash('blog_id')) or return ''; my $tindex = $data->{tindex}; my %tags; if ($case_sensitive) { foreach (keys %$tindex) { my $tidx = $tindex->{$_}; $tags{$_} = [ scalar @{$tidx->{eids}}, $tidx->{ts} ]; } } else { foreach (keys %$tindex) { my $tidx = $tindex->{$_}; my $t = lc $_; if (!exists $tags{$t}) { $tags{$t} = [ scalar @{$tidx->{eids}}, $tidx->{ts} ]; } else { $tags{$t}[0] += scalar @{$tidx->{eids}}; $tags{$t}[1] = $tidx->{ts} if $tags{$t}[1] < $tidx->{ts}; } } } my @list; if ($sort_by eq 'tag' || $sort_by eq 'keyword' ) { @list = $sort_order eq 'ascend' ? sort { lc $a cmp lc $b } keys %tags : sort { lc $b cmp lc $a } keys %tags; } elsif ($sort_by eq 'tag-case' || $sort_by eq 'keyword-case') { @list = $sort_order eq 'ascend' ? sort keys %tags : sort reverse keys %tags; } else { @list = $sort_order eq 'ascend' ? sort { $tags{$a}[0] <=> $tags{$b}[0] } keys %tags : sort { $tags{$b}[0] <=> $tags{$a}[0] } keys %tags; } $ctx->stash('Tagwire::tags_total', scalar @list); my $total_sum = 0; $total_sum += $tags{$_}[0] foreach (@list); $ctx->stash('Tagwire::tags_total_sum', $total_sum); my @res; my $builder = $ctx->stash('builder'); my $tokens = $ctx->stash('tokens'); my $i = 0; foreach (@list) { last if $lastn && $i >= $lastn; local $ctx->{__stash}{'Tagwire::tag'} = $_; local $ctx->{__stash}{'Tagwire::tag_count'} = $tags{$_}[0]; local $ctx->{__stash}{'Tagwire::tag_date'} = $tags{$_}[1]; defined(my $out = $builder->build($ctx, $tokens)) or return $ctx->error($ctx->errstr); push @res, $out; $i++; } my $glue = $args->{glue} || ''; join $glue, @res; } sub entry_tags { my ($ctx, $args, $cond) = @_; my $e = $ctx->stash('entry') or return $ctx->_no_entry_error('MT' . $ctx->stash('tag')); return '' unless $e->keywords; # case_sensitive (0/1, default = 1) my $case_sensitive = defined $args->{case_sensitive} ? $args->{case_sensitive} : 1; my @tags = split_tags($e->keywords, $case_sensitive); my @res; my $builder = $ctx->stash('builder'); my $tokens = $ctx->stash('tokens'); foreach (@tags) { local $ctx->{__stash}{'Tagwire::tag'} = $_; defined(my $out = $builder->build($ctx, $tokens)) or return $ctx->error($ctx->errstr); push @res, $out; } my $glue = $args->{glue} || ''; join $glue, @res; } sub related_tags { my ($ctx, $args, $cond) = @_; my $tag = $ctx->stash('Tagwire::tag') or return ''; my $sort_by = $args->{sort_by} || 'tag'; # sort_order (ascend/descend, default = ascend) my $sort_order = $args->{sort_order} || 'ascend'; # lastn (default = 0, no cutoff) my $lastn = $args->{lastn} || 0; # case_sensitive (0/1, default = 1) my $case_sensitive = defined $args->{case_sensitive} ? $args->{case_sensitive} : 1; my $data = get_indexes($ctx->stash('blog_id')) or return ''; my ($tindex, $eindex) = ($data->{tindex}, $data->{eindex}); my %tags; if ($case_sensitive) { foreach my $eid (@{$tindex->{$tag}->{eids}}) { foreach (@{$eindex->{$eid}->{tags}}) { next if $_ eq $tag; if (!exists $tags{$_}) { $tags{$_} = [ 1, $tindex->{$_}->{ts} ]; } else { $tags{$_}[0]++; } } } } else { $tag = lc $tag; foreach my $nctag (grep { lc $_ eq $tag } keys %$tindex) { foreach my $eid (@{$tindex->{$nctag}->{eids}}) { foreach (@{$eindex->{$eid}->{tags}}) { my $t = lc $_; next if $t eq $tag; if (!exists $tags{$t}) { $tags{$t} = [ 1, $tindex->{$_}->{ts} ]; } else { $tags{$t}[0]++; $tags{$t}[1] = $tindex->{$_}->{ts} if $tags{$t}[1] < $tindex->{$_}->{ts}; } } } } } my @list; if ($sort_by eq 'tag' || $sort_by eq 'keyword' ) { @list = $sort_order eq 'ascend' ? sort { lc $a cmp lc $b } keys %tags : sort { lc $b cmp lc $a } keys %tags; } elsif ($sort_by eq 'tag-case' || $sort_by eq 'keyword-case') { @list = $sort_order eq 'ascend' ? sort keys %tags : sort reverse keys %tags; } else { @list = $sort_order eq 'ascend' ? sort { $tags{$a}[0] <=> $tags{$b}[0] } keys %tags : sort { $tags{$b}[0] <=> $tags{$a}[0] } keys %tags; } $ctx->stash('Tagwire::tags_total', scalar @list); my $total_sum = 0; $total_sum += $tags{$_}[0] foreach (@list); $ctx->stash('Tagwire::tags_total_sum', $total_sum); my @res; my $builder = $ctx->stash('builder'); my $tokens = $ctx->stash('tokens'); my $i = 0; foreach (@list) { last if $lastn && $i >= $lastn; local $ctx->{__stash}{'Tagwire::tag'} = $_; local $ctx->{__stash}{'Tagwire::tag_count'} = $tags{$_}[0]; local $ctx->{__stash}{'Tagwire::tag_date'} = $tags{$_}[1]; defined(my $out = $builder->build($ctx, $tokens)) or return $ctx->error($ctx->errstr); push @res, $out; $i++; } my $glue = $args->{glue} || ''; join $glue, @res; } sub tag { $_[0]->stash('Tagwire::tag') || ''; } sub tag_count { $_[0]->stash('Tagwire::tag_count') || 0; } sub tag_date { my ($ctx, $args) = @_; $args->{ts} = $ctx->stash('Tagwire::tag_date') or return ''; MT::Template::Context::_hdlr_date($ctx, $args); } sub tags_total { $_[0]->stash('Tagwire::tags_total') || 0; } sub tags_total_sum { $_[0]->stash('Tagwire::tags_total_sum') || 0; } sub entries { my ($ctx, $args, $cond) = @_; # tags(keywords) (REQUIRED) my $search = $args->{tags} || $args->{keywords} or return ''; # delimiter for "tags" argument (default = space) my $delimiter = $args->{delimiter} || ''; # case_sensitive (0/1, default = 1) my $case_sensitive = defined $args->{case_sensitive} ? $args->{case_sensitive} : 1; # sort_by (created_on, default = created_on) my $sort_by = $args->{sort_by} || 'created_on'; # sort_order (ascend/descend, default = descend) my $sort_order = $args->{sort_order} || 'descend'; # lastn (default = 0, no cutoff) my $lastn = $args->{lastn} || 0; my @tags = split_args($search, $delimiter, $case_sensitive) or return ''; my $data = get_indexes($ctx->stash('blog_id')) or return ''; my ($tindex, $eindex) = ($data->{tindex}, $data->{eindex}); my %match; if ($case_sensitive) { foreach my $tag (@tags) { foreach (@{$tindex->{$tag}->{eids}}) { $match{$_} = exists $match{$_} ? $match{$_} + 1 : 1; } } } else { foreach my $tag (@tags) { foreach my $t (grep { lc $_ eq $tag } keys %$tindex) { foreach (@{$tindex->{$t}->{eids}}) { $match{$_} = exists $match{$_} ? $match{$_} + 1 : 1; } } } } my $count = scalar @tags; my @eids = grep { $match{$_} == $count } keys %match or return ''; @eids = $sort_order eq 'descend' ? sort { $eindex->{$b}->{created_on} <=> $eindex->{$a}->{created_on} } @eids : sort { $eindex->{$a}->{created_on} <=> $eindex->{$b}->{created_on} } @eids; splice(@eids, $lastn) if $lastn && (scalar @eids > $lastn); my @entries; map { push @entries, MT::Entry->load($_) } @eids; my $res = ''; my $tokens = $ctx->stash('tokens'); my $builder = $ctx->stash('builder'); my $i = 0; for my $e (@entries) { local $ctx->{__stash}{entry} = $e; local $ctx->{current_timestamp} = $e->created_on; local $ctx->{modification_timestamp} = $e->modified_on; my $out = $builder->build($ctx, $tokens, { %$cond, EntryIfExtended => $e->text_more ? 1 : 0, EntryIfAllowComments => $e->allow_comments, EntryIfCommentsOpen => $e->allow_comments && $e->allow_comments eq '1', EntryIfAllowPings => $e->allow_pings, EntriesHeader => !$i, EntriesFooter => !defined $entries[$i+1] }); return $ctx->error($ctx->errstr) unless defined $out; $res .= $out; $i++; } $res; } sub most_related_entries { my ($ctx, $args, $cond) = @_; my $entry = $ctx->stash('entry') or return $ctx->_no_entry_error('MT' . $ctx->stash('tag')); return '' unless $entry->keywords; # case_sensitive (0/1, default = 1) my $case_sensitive = defined $args->{case_sensitive} ? $args->{case_sensitive} : 1; # sort_order (ascend/descend, default = descend) my $sort_order = $args->{sort_order} || 'descend'; # lastn (default = 0, no cutoff) my $lastn = $args->{lastn} || 0; my @tags = split_tags($entry->keywords, $case_sensitive) or return ''; my $data = get_indexes($ctx->stash('blog_id')) or return ''; my ($tindex, $eindex) = ($data->{tindex}, $data->{eindex}); my %match; my $entry_id = $entry->id; if ($case_sensitive) { foreach my $tag (@tags) { foreach (@{$tindex->{$tag}->{eids}}) { next if $_ == $entry_id; $match{$_} = exists $match{$_} ? $match{$_} + 1 : 1; } } } else { foreach my $tag (@tags) { foreach my $t (grep { lc $_ eq $tag } keys %$tindex) { foreach (@{$tindex->{$t}->{eids}}) { next if $_ == $entry_id; $match{$_} = exists $match{$_} ? $match{$_} + 1 : 1; } } } } my @eids = keys %match or return ''; @eids = $sort_order eq 'descend' ? sort { $eindex->{$b}->{created_on} <=> $eindex->{$a}->{created_on} } @eids : sort { $eindex->{$a}->{created_on} <=> $eindex->{$b}->{created_on} } @eids; @eids = sort { $match{$b} <=> $match{$a} } @eids; splice(@eids, $lastn) if $lastn && (scalar @eids > $lastn); my @entries; map { push @entries, MT::Entry->load($_) } @eids; my $res = ''; my $tokens = $ctx->stash('tokens'); my $builder = $ctx->stash('builder'); my $i = 0; for my $e (@entries) { local $ctx->{__stash}{entry} = $e; local $ctx->{current_timestamp} = $e->created_on; local $ctx->{modification_timestamp} = $e->modified_on; my $out = $builder->build($ctx, $tokens, { %$cond, EntryIfExtended => $e->text_more ? 1 : 0, EntryIfAllowComments => $e->allow_comments, EntryIfCommentsOpen => $e->allow_comments && $e->allow_comments eq '1', EntryIfAllowPings => $e->allow_pings, EntriesHeader => !$i, EntriesFooter => !defined $entries[$i+1] }); return $ctx->error($ctx->errstr) unless defined $out; $res .= $out; $i++; } $res; } use MT::Util; sub encode_urlplus { my $s = $_[0]; return $s unless $_[1]; $s =~ tr/ /+/; MT::Util::encode_url($s); } sub xsearch_tags { my ($ctx, $args, $cond) = @_; return '' unless defined $ctx->stash('xsearch_tags'); my $tags = $ctx->stash('xsearch_tags'); my @res; my $builder = $ctx->stash('builder'); my $tokens = $ctx->stash('tokens'); foreach (@$tags) { local $ctx->{__stash}{'Tagwire::tag'} = $_; defined(my $out = $builder->build($ctx, $tokens)) or return $ctx->error($ctx->errstr); push @res, $out; } my $glue = $args->{glue} || ''; join $glue, @res; } sub xsearch_on_stash { my ($ctx, $val, $self) = @_; $ctx->stash('entry', $val); $ctx->{current_timestamp} = $val->created_on; $ctx->{modification_timestamp} = $val->modified_on; $ctx->stash('xsearch_tags', $self->{xsearch_tags}); } sub xsearch_on_execute { my ($args, $self) = @_; MT->error('Blog ID is required.') unless $args->{blog_id}; my $delimiter = $args->{delimiter} || ''; my $case_sensitive = defined $args->{case_sensitive} ? $args->{case_sensitive} : 1; my $sort_by = $args->{sort_by} || 'created_on'; my $sort_order = $args->{sort_order} || 'descend'; my $lastn = $args->{lastn} || 0; my @tags = split_args($args->{search}, $delimiter, $case_sensitive) or return []; $self->{xsearch_tags} = \@tags; my $data = get_indexes($args->{blog_id}) or return []; my ($tindex, $eindex) = ($data->{tindex}, $data->{eindex}); my %match; if ($case_sensitive) { foreach my $tag (@tags) { foreach (@{$tindex->{$tag}->{eids}}) { $match{$_} = exists $match{$_} ? $match{$_} + 1 : 1; } } } else { foreach my $tag (@tags) { foreach my $t (grep { lc $_ eq $tag } keys %$tindex) { foreach (@{$tindex->{$t}->{eids}}) { $match{$_} = exists $match{$_} ? $match{$_} + 1 : 1; } } } } my $count = scalar @tags; my @eids = grep { $match{$_} == $count } keys %match or return []; @eids = $sort_order eq 'descend' ? sort { $eindex->{$b}->{created_on} <=> $eindex->{$a}->{created_on} } @eids : sort { $eindex->{$a}->{created_on} <=> $eindex->{$b}->{created_on} } @eids; splice(@eids, $lastn) if $lastn && (scalar @eids > $lastn); my @entries; map { push @entries, MT::Entry->load($_) } @eids; \@entries; } 1;