root/trunk/lib/MT/Tag.pm @ 3082

Revision 3082, 17.8 kB (checked in by bchoate, 14 months ago)

Merging fireball branch changes to-date to trunk: svn merge -r2974:3081 http://code.sixapart.com/svn/movabletype/branches/fireball .

  • Property svn:keywords set to Id Revision
Line 
1# Movable Type (r) Open Source (C) 2001-2008 Six Apart, Ltd.
2# This program is distributed under the terms of the
3# GNU General Public License, version 2.
4#
5# $Id$
6
7package MT::Tag;
8
9use strict;
10use base qw( MT::Object );
11
12__PACKAGE__->install_properties({
13    column_defs => {
14        'id' => 'integer not null auto_increment primary key',
15        'name' => 'string(255) not null',
16        'n8d_id' => 'integer',
17        'is_private' => 'boolean'
18    },
19    indexes => {
20        name => 1,
21        n8d_id => 1,
22        name_id => {
23            columns => ['name', 'id'],
24        },
25        # for MTTags
26        private_id_name => {
27            columns => ['is_private', 'id', 'name'],
28        },
29    },
30    defaults => {
31        n8d_id => 0,
32        is_private => 0,
33    },
34    child_classes => ['MT::ObjectTag'],
35    datasource => 'tag',
36    primary_key => 'id',
37});
38
39sub class_label {
40    return MT->translate('Tag');
41}
42
43sub class_label_plural {
44    return MT->translate('Tags');
45}
46
47sub save {
48    my $tag = shift;
49    my $name = $tag->name;
50    return $tag->error(MT->translate("Tag must have a valid name"))
51        unless defined($name) && length($name);
52    my $n8d = $tag->normalize;
53    return $tag->error(MT->translate("Tag must have a valid name"))
54        unless defined($n8d) && length($n8d);
55    if ($n8d ne $name) {
56        my $n8d_tag = MT::Tag->load({ name => $n8d });
57        if (!$n8d_tag) {
58            $n8d_tag = new MT::Tag;
59            $n8d_tag->name($n8d);
60            $n8d_tag->save;
61        }
62        if (!$tag->n8d_id || ($tag->n8d_id != $n8d_tag->id)) {
63            $tag->n8d_id($n8d_tag->id);
64        }
65    } else {
66        $tag->n8d_id(0) if $tag->n8d_id;
67    }
68    # maintain the private flag...
69    $tag->is_private( $name =~ m/^@/ ? 1 : 0 );
70    $tag->SUPER::save();
71}
72
73sub normalize {
74    my $tag = shift;
75    my ($str) = @_;
76    if (!@_ && !(ref $tag)) {
77        $str = $tag;
78    } elsif (!$str && (ref $tag)) {
79        $str = $tag->name;
80    }
81
82    my $private = $str =~ m/^@/;
83    $str = MT::I18N::encode_text( $str, MT->instance->config->PublishCharset, 'utf-8' );
84    $str =~ s/[@!`\\<>\*&#\/~\?'"\.\,=\(\)\${}\[\];:\ \+\-\r\n]+//gs;
85    $str = lc $str;
86    $str = '@' . $str if $private;
87    $str = MT::I18N::encode_text( $str, 'utf-8', MT->instance->config->PublishCharset );
88    $str;
89}
90
91sub remove {
92    my $tag = shift;
93    my $n8d_tag;
94    if (ref $tag) {
95        if (!$tag->n8d_id) {
96            # normalized tag! we can't delete if others reference us
97            my $child_tags = MT::Tag->exist({n8d_id => $tag->id});
98            return $tag->error(MT->translate("This tag is referenced by others."))
99                if $child_tags;
100        } else {
101            $n8d_tag = MT::Tag->load($tag->n8d_id);
102        }
103    }
104    $tag->remove_children({key => 'tag_id'});
105    $tag->SUPER::remove(@_)
106        or return $tag->error($tag->errstr);
107    # check for an orphaned normalized tag and delete if necessary
108    if ($n8d_tag) {
109        # Normalized tag, no longer referenced by other tags...
110        if (!MT::Tag->exist({n8d_id => $n8d_tag->id})) {
111            # Noramlized tag that no longer has any object tag associations
112            require MT::ObjectTag;
113            if (!MT::ObjectTag->exist({tag_id => $n8d_tag->id})) {
114                $n8d_tag->remove
115                    or return $tag->error($n8d_tag->errstr);
116            }
117        }
118    }
119    1;
120}
121
122sub split {
123    my $pkg = shift;
124    my ($delim, $str) = @_;
125    $delim = quotemeta($delim);
126    my @tags;
127    $str =~ s/(^\s+|\s+$)//gs;
128    while (length($str) && ($str =~ m/^(((['"])(.*?)\3[^$delim]*?|.*?)($delim\s*|$))/s)) {
129        $str = substr($str, length($1));
130        my $tag = defined $4 ? $4 : $2;
131        #$tag =~ s/(^[\s,]+|[\s,]+$)//gs;
132        $tag =~ s/(^\s+|\s+$)//gs;
133        $tag =~ s/\s+/ /gs;
134        my $n8d_tag = MT::Tag->normalize($tag);
135        next if $n8d_tag eq '';
136        push @tags, $tag if $tag ne '';
137    }
138    @tags;
139}
140
141sub join {
142    my $obj = shift;
143    my ($delim, @tags) = @_;
144    my $tags = '';
145    foreach (@tags) {
146        $tags .= $delim . ($delim eq ' ' ? '' : ' ') if $tags ne '';
147        if (m/\Q$delim\E/) {
148            if (m/"/) {
149                $tags .= "'" . $_ . "'";
150            } else {
151                $tags .= '"' . $_ . '"';
152            }
153        } else {
154            $tags .= $_;
155        }
156    }
157    $tags;
158}
159
160sub load_by_datasource {
161    my $pkg = shift;
162    my ($datasource, $terms, $args) = @_;
163    $args ||= {};
164    $args->{'sort'} ||= 'name';
165    my $blog_id;
166    my %jargs;
167    if ($terms->{blog_id}) {
168        $blog_id = $terms->{blog_id};
169        delete $terms->{blog_id};
170        if ($args->{not} && $args->{not}{blog_id}) {
171            $jargs{not}{blog_id} = 1;
172        }
173    }
174    $args->{'join'} ||= MT::ObjectTag->join_on('tag_id', {
175        $blog_id ? (blog_id => $blog_id) : (),
176        object_datasource => $datasource
177    }, { unique => 1, %jargs });
178    my @tags;
179    my $iter = MT::Tag->load_iter($terms, $args);
180    while ( my $tag = $iter->() ) {
181        push @tags, $tag;
182    }
183    @tags;
184}
185
186# static method for tag cache control
187sub cache_obj {
188    my $pkg = shift;
189    my (%param) = @_;
190    my $user_id = $param{user_id};
191    my $blog_id = $param{blog_id};
192    my $ds = $param{datasource};
193
194    require MT::Session;
195    # Clear any tag cache if tags were modified upon saving
196    my $sess_id = ($user_id ? 'user:' . $user_id . ';' : '') . ($blog_id ? 'blog:' . $blog_id . ';' : '') . 'datasource:' . $ds . ($param{private} ? ';private' : '');
197    my $tag_cache = MT::Session::get_unexpired_value(60 * 60, {
198        kind => 'TC',  # tag cache
199        id => $sess_id
200    });
201    if (!$tag_cache) {
202        $tag_cache = new MT::Session;
203        $tag_cache->kind('TC');
204        $tag_cache->id($sess_id);
205        $tag_cache->start(time);
206    }
207    $tag_cache;
208}
209
210sub clear_cache {
211    my $pkg = shift;
212    my (%param) = @_;
213    my $blog_id = $param{blog_id};
214    my $user_id = $param{user_id};
215    my $ds = $param{datasource};
216
217    my $tag_cache;
218    my $sess_id = ($user_id ? 'user:' . $user_id . ';' : '') . ($blog_id ? 'blog:' . $blog_id . ';' : '') . 'datasource:' . $ds . ($param{private} ? ';private' : '');
219    require MT::Session;
220    $tag_cache = MT::Session->load({
221        kind => 'TC',
222        id => $sess_id});
223    $tag_cache->remove if $tag_cache;
224
225    $sess_id = ($blog_id ? 'blog:' . $blog_id . ';' : '') . 'datasource:' . $ds . ';private';
226    $tag_cache = MT::Session->load({
227        kind => 'TC',
228        id => $sess_id});
229    $tag_cache->remove if $tag_cache;
230}
231
232sub cache {
233    my $pkg = shift;
234    my (%param) = @_;
235    my $user_id = $param{user_id};
236    my $blog_id = $param{blog_id};
237    my $class = $param{class};
238    if (ref($class) eq 'SCALAR') {
239        $class = eval "use $class;";
240        if (my $err = $@) {
241            $class = eval 'use MT::Entry;';
242        }
243    }
244    my $ds = $class->datasource;
245    $param{datasource} = $ds;
246
247    my $tag_cache = $pkg->cache_obj(%param);
248    my $data = $tag_cache->get('tag_cache');
249
250    if (ref($data) ne 'ARRAY') {
251        my $private = $param{private};
252        my $class_column = $class->properties->{class_column};
253        # FIXME: this should be a parameter; breaks MVC model
254        my $limit = MT->config->MaxTagAutoCompletionItems;
255        require MT::ObjectTag;
256        my @tags = map { $_->name } MT::Tag->load(
257            undef,
258            {
259                ( $private ? () : ( 'name' => { not_like => '@%' } ) ),
260                join => MT::ObjectTag->join_on( undef, {
261                    tag_id => \'= tag_id',
262                    ( $blog_id ? ( blog_id => $blog_id ) : () ),
263                    object_datasource => $ds,
264                }, {
265                    unique => 1,
266                    join => $class->join_on( undef, {
267                        'id' => \'= objecttag_object_id',
268                        ( $blog_id ? ( blog_id => $blog_id ) : () ),
269                        ( $class_column ? ( $class_column => $class->class_type ) : () ),
270                    }, {
271                        sort => ($class eq 'MT::Entry' ? 'authored_on' : 'modified_on'),
272                        direction => 'descend'
273                    })
274                }),
275                limit => $limit,
276                fetchonly => [ 'name' ]
277            }
278        );
279        if (@tags) {
280            $data = \@tags;
281            $tag_cache->set('tag_cache', \@tags);
282            $tag_cache->save;
283        }
284    }
285    $data || [];
286}
287
288# An interface for any MT::Object that wishes to utilize tags themselves
289
290package MT::Taggable;
291
292use constant TAG_CACHE_TIME => 7 * 24 * 60 * 60;  ## 1 week
293
294sub install_properties {
295    my $pkg = shift;
296    my ($class) = @_;
297
298    # synchronize tags if necessary
299    $class->add_trigger( post_save => \&post_save_tags );
300    $class->add_trigger( pre_remove => \&pre_remove_tags );
301}
302
303# post_save trigger for MT::Taggable objects to synchronize tags upon save.
304sub post_save_tags {
305    my $class = shift;
306    my ($obj) = @_;
307    $obj->save_tags;
308}
309
310sub pre_remove_tags {
311    my $class = shift;
312    my ($obj) = @_;
313    $obj->remove_tags if ref $obj;
314}
315
316sub tag_cache_key {
317    my $obj = shift;
318    return undef unless $obj->id;
319    return sprintf "%stags-%d", $obj->datasource, $obj->id;
320}
321
322sub __load_tags {
323    my $obj = shift;
324    my $t = MT->get_timer;
325    $t->pause_partial if $t;
326
327    if (!$obj->id) {
328        $obj->{__tags} = [];
329        return $obj->{__tag_objects} = [];
330    }
331    return if exists $obj->{__tag_objects};
332
333    require MT::Memcached;
334    my $cache = MT::Memcached->instance;
335    my $memkey = $obj->tag_cache_key;
336    my @tags;
337    if (my $tag_ids = $cache->get($memkey)) {
338        @tags = grep { defined } @{ MT::Tag->lookup_multi($tag_ids) };
339    } else {
340        require MT::ObjectTag;
341        my $iter = MT::Tag->load_iter(undef, {
342            sort => 'name', 
343            join => [ 'MT::ObjectTag', 'tag_id', { object_id => $obj->id,
344                object_datasource => $obj->datasource }, { unique => 1 } ],       
345        });
346        while ( my $tag = $iter->() ) {
347            push @tags, $tag;
348        }
349        $cache->set($memkey, [ map { $_->id } @tags ], TAG_CACHE_TIME);
350    }
351    $obj->{__tags} = [ map { $_->name } @tags ];
352    $t->mark('MT::Tag::__load_tags') if $t;
353    $obj->{__tag_objects} = \@tags;
354}
355
356sub get_tags {
357    my $obj = shift;
358    $obj->__load_tags unless $obj->{__tags} && @{ $obj->{__tags} };
359    return @{ $obj->{__tags} };
360}
361
362sub get_tag_objects {
363    my $obj = shift;
364    $obj->__load_tags;
365    return $obj->{__tag_objects};
366}
367
368sub set_tags {
369    my $obj = shift;
370    $obj->{__tags} = [ sort @_ ];
371    $obj->{__save_tags} = 1;
372}
373
374sub save_tags {
375    my $obj = shift;
376    return 1 unless $obj->{__save_tags};
377    require MT::ObjectTag;
378    my $clear_cache = 0;
379    my @tags = @{ $obj->{__tags} };
380    return 1 unless @tags;
381
382    my $t = MT->get_timer;
383    $t->pause_partial if $t;
384
385    $obj->{__tag_objects} = [];
386    my $blog_id = $obj->has_column('blog_id') ? $obj->blog_id : 0;
387    my @existing_tags = MT::ObjectTag->load({object_id => $obj->id,
388        object_datasource => $obj->datasource });
389    my %existing_tags = map { $_->tag_id => $_ } @existing_tags;
390    foreach my $tag_name (@tags) {
391        my $tag = MT::Tag->load({ name => $tag_name },
392            { binary => { name => 1 } } );
393        if ($tag) {
394            if (exists $existing_tags{$tag->id}) {
395                $existing_tags{$tag->id} = 0;
396                push @{ $obj->{__tag_objects} }, $tag;
397                next;
398            }
399        } else {
400            # new tag
401            $tag = new MT::Tag;
402            $tag->name($tag_name);
403            $tag->save or next;
404            $clear_cache = 1;
405        }
406        my $otag = new MT::ObjectTag;
407        $otag->blog_id($blog_id);
408        $otag->tag_id($tag->id);
409        $otag->object_id($obj->id);
410        $otag->object_datasource($obj->datasource);
411        $otag->save or return $obj->error($otag->errstr);
412        $existing_tags{$tag->id} = 0;
413        $clear_cache = 1;
414
415        push @{ $obj->{__tag_objects} }, $tag;
416    }
417
418    foreach my $otag (values %existing_tags) {
419        next unless ref $otag;
420        my $this_tag_id = $otag->tag_id;
421        $otag->remove;
422        if (! MT::ObjectTag->exist({tag_id => $this_tag_id})) {
423            # no more references to this tag... just delete it now
424            if (my $tag = MT::Tag->load($this_tag_id)) {
425                $tag->remove;
426            }
427        }
428        $clear_cache = 1;
429    }
430    delete $obj->{__save_tags};
431    if ($clear_cache) {
432        MT::Tag->clear_cache(datasource => $obj->datasource, ($blog_id ? (blog_id => $blog_id) : ()));
433
434        require MT::Memcached;
435        MT::Memcached->instance->delete( $obj->tag_cache_key );
436    }
437    $t->mark('MT::Tag::save_tags') if $t;
438    1;
439}
440
441sub tags {
442    my $obj = shift;
443    $obj->set_tags(@_) if @_;
444    $obj->get_tags;
445}
446
447sub add_tags {
448    my $obj = shift;
449    my (@tags) = @_;
450    my @etags = $obj->tags;
451    push @tags, @etags;
452    my %uniq;
453    @uniq{@tags} = ();
454    $obj->set_tags(keys %uniq);
455}
456
457sub remove_tags {
458    my $obj = shift;
459    my (@tags) = @_;
460    if (@tags) {
461        my @etags = $obj->tags;
462        my %uniq;
463        @uniq{@etags} = ();
464        delete $uniq{$_} for @tags;
465        if (keys %uniq) {
466            $obj->set_tags(keys %uniq);
467            return;
468        }
469    }
470    require MT::ObjectTag;
471    my @et = MT::ObjectTag->load({ object_id => $obj->id,
472                                   object_datasource => $obj->datasource });
473    $_->remove for @et;
474    $obj->{__tags} = [];
475    delete $obj->{__save_tags};
476    MT::Tag->clear_cache(datasource => $obj->datasource,
477        ($obj->blog_id ? (blog_id => $obj->blog_id) : ())) if @et;
478
479    require MT::Memcached;
480    MT::Memcached->instance->delete( $obj->tag_cache_key );
481}
482
483sub has_tag {
484    my $obj = shift;
485    my ($tag) = @_;
486    # this should also check normalized versions
487    $tag = $tag->name if ref $tag;
488    my $n8d_tag = MT::Tag->normalize($tag);
489    my @tags = $obj->tags;
490    foreach (@tags) {
491        return 1 if $tag eq $_;
492        return 1 if ($tag ne $n8d_tag) && ($n8d_tag eq MT::Tag->normalize($_));
493    }
494    0;
495}
496
497# counts number of tags
498sub tag_count {
499    my $obj = shift;
500    my ($terms) = @_;
501    my $pkg = ref $obj ? ref $obj : $obj;
502    $terms ||= {};
503    my $jterms = {};
504    if (ref $obj) {
505        $terms->{object_id} = $obj->id if $obj->id;
506        $jterms->{blog_id} = $obj->blog_id if $obj->column('blog_id');
507    }
508    if ($terms->{blog_id}) {
509        $jterms->{blog_id} = $terms->{blog_id};
510        delete $terms->{blog_id};
511    }
512    $jterms->{object_datasource} = $obj->datasource;
513    my $pkg_terms = {};
514    $pkg_terms->{id} = \'=objecttag_object_id';
515    if ( $pkg->class_type eq 'entry' or $pkg->class_type eq 'page' ) {
516        $pkg_terms->{class} = $pkg->class_type;
517    }
518    require MT::ObjectTag;
519    MT::Tag->count(
520        undef,
521        {
522            join => MT::ObjectTag->join_on(
523                'tag_id', $jterms,
524                { unique => 1, join => $pkg->join_on( undef, $pkg_terms ) }
525            )
526        }
527    );
528}
529
530# counts number of objects tagged with a given tag
531sub tagged_count {
532    my $obj = shift;
533    my ($tag_id, $terms) = @_;
534    $terms ||= {};
535    my $jterms = {};
536    my $pkg = ref $obj ? ref $obj : $obj;
537    if (defined $tag_id && ($tag_id =~ m/\D/)) {
538        my $n8d_tag = MT::Tag->normalize($tag_id);
539        my $tag = MT::Tag->load({ name => [ $tag_id, $n8d_tag ] },
540            { binary => { name => 1 } });
541        return 0 unless $tag;
542        $tag_id = $tag->id;
543    }
544    if (ref $obj) {
545        $terms->{object_id} = $obj->id if $obj->id;
546        $jterms->{blog_id} = $obj->blog_id if $obj->column('blog_id');
547    } else {
548        $jterms->{blog_id} = $terms->{blog_id} if $terms->{blog_id};
549    }
550    $jterms->{object_datasource} = $pkg->datasource;
551    $jterms->{tag_id} = $tag_id if $tag_id;
552    my $args = { join => ['MT::ObjectTag', 'object_id', $jterms, { unique => 1 }] };
553    require MT::ObjectTag;
554    $pkg->count($terms, $args);
555}
556
5571;
558__END__
559
560=head1 NAME
561
562MT::Tag - Movable Type tag record and methods
563
564=head1 SYNOPSIS
565
566    use MT::Tag;
567    my $tag = MT::Tag->new;
568    $tag->name('favorite');
569    $tag->save
570        or die $tag->errstr;
571
572=head1 DATA ACCESS METHODS
573
574The I<MT::Tag> object holds the following pieces of data. These fields
575can be accessed and set using the standard data access methods described in
576the L<MT::Object> documentation.
577
578=over 4
579
580=item * id
581
582The numeric ID of the tag.
583
584=item * name
585
586The name of the tag.
587
588=item * n8d_id
589
590The ID of a "normalized" version of this tag. If undef or 0, it would
591signifiy this tag is a normalized tag name.
592
593=back
594
595=head1 DATA LOOKUP
596
597In addition to numeric ID lookup, you can look up or sort records by any
598combination of the following fields. See the I<load> documentation in
599I<MT::Object> for more information.
600
601=over 4
602
603=item * name
604
605=back
606
607=head1 OTHER METHODS
608
609=head2 cache
610
611Return the entry tags. If there are no cached tags, they are loaded
612first. If the tags have already been loaded with this method, that
613data is returned instead.
614
615=head2 cache_obj
616
617Cache the session tags.
618
619=head2 clear_cache
620
621Remove the tag cache.
622
623=head2 join($seperator, @tags)
624
625Return the given I<tags> as a string with the defined I<seperator>.
626
627=head2 split($seperator, $tags)
628
629Split-up the given I<tags> string by the given I<seperator>.
630
631=head2 normalize($tag)
632
633Sanitize the text (remove potentially characters) and lower-case the
634I<tag>. The I<tag> may be given as a string or as a tag object. In the
635case of the latter, C<$tag-E<gt>name> attribute is used.
636
637=head2 load_by_datasource($datasource, $terms, $args)
638
639Return a list of tags given by an object I<datasource> type, selection
640I<terms> and I<arguments>.
641
642=head2 $tag->save()
643
644Save the literal as well as a normalized copy if one does not exist.
645
646=head2 $tag->remove()
647
648Remove the tag and all its children unless it is referenced by another
649entry.
650
651=head1 NOTES
652
653=over 4
654
655=item *
656
657When you remove a tag using I<MT::Tag::remove>, in addition to
658removing the tag record, all of the related I<MT::ObjectTag> records are
659removed as well.
660
661=back
662
663=head1 AUTHOR & COPYRIGHT
664
665Please see L<MT/AUTHOR & COPYRIGHT>.
666
667=cut
Note: See TracBrowser for help on using the browser.