root/branches/release-40/plugins/StyleCatcher/lib/StyleCatcher/CMS.pm @ 2576

Revision 2576, 22.3 kB (checked in by bchoate, 18 months ago)

Touch blog when applying a new style.

  • Property svn:keywords set to Id Revision
Line 
1# Movable Type (r) Open Source (C) 2005-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 StyleCatcher::CMS;
8
9use strict;
10use File::Basename qw(basename);
11
12our $DEFAULT_STYLE_LIBRARY;
13
14sub style_library {
15    return MT->registry("stylecatcher_libraries");
16}
17
18sub file_mgr {
19    my $app = MT->instance;
20    require MT::FileMgr;
21    my $filemgr = MT::FileMgr->new('Local')
22      or return $app->error( MT::FileMgr->errstr );
23    $filemgr;
24}
25
26sub listify {
27    my ($data) = @_;
28    my @list;
29    foreach my $k (keys %$data) {
30        my %entry = %{ $data->{$k} };
31        $entry{key} = $k;
32        delete $entry{plugin};
33        $entry{label} = $entry{label}->() if ref($entry{label});
34        $entry{description_label} = $entry{description_label}->() if ref($entry{description_label});
35        push @list, \%entry;
36    }
37    @list = sort { $a->{order} <=> $b->{order} } @list;
38    \@list;
39}
40
41sub view {
42    my $app     = shift;
43    my $blog_id = $app->param('blog_id');
44    $app->return_to_dashboard( redirect => 1 ) unless $blog_id;
45
46    my $blog = MT::Blog->load($blog_id);
47    return $app->errtrans("Invalid request") unless $blog;
48
49    my $static_path = $app->static_file_path;
50    if (! -d $static_path ) {
51        return $app->errtrans("Your mt-static directory could not be found. Please configure 'StaticFilePath' to continue.");
52    }
53
54    my $themeroot =
55      File::Spec->catdir( $app->static_file_path, 'support', 'themes' );
56    my $webthemeroot = $app->static_path . 'support/themes';
57    my $stylelibrary = listify(style_library());
58    my $theme_data   = make_themes();
59    my $styled_blogs = fetch_blogs();
60
61    my $config = plugin()->get_config_hash();
62
63    my @blog_loop;
64    my %current_themes;
65    my ($blog_theme, $blog_layout);
66    foreach my $blog (@$styled_blogs) {
67        my $curr_theme = $config->{"current_theme_" . $blog->id} || '';
68        my $curr_layout = $config->{"current_layout_" . $blog->id} || 'layout-wtt';
69        push @blog_loop,
70          {
71            blog_id   => $blog->id,
72            blog_name => $blog->name,
73            layout    => $curr_layout,
74            theme_id  => $curr_theme,
75            view_link => $blog->site_url,
76          };
77        if ($blog->id == $blog_id) {
78            $blog_theme = $curr_theme;
79            $blog_layout = $curr_layout;
80        }
81        if ( $theme_data->{themes} && $curr_theme ) {
82            foreach my $theme ( @{ $theme_data->{themes} } ) {
83                if ( ($theme->{prefix} || '') . ':' . $theme->{name} eq $curr_theme ) {
84                    push @{ $theme->{blogs} }, $blog->id;
85                    next if exists $current_themes{ $theme->{name} };
86                    $current_themes{ $theme->{name} } = 1;
87                    push @{ $theme->{tags} }, 'collection:current';
88                }
89            }
90        }
91    }
92
93    push @{ $theme_data->{categories} }, 'current'
94      if %current_themes;
95
96    require JSON;
97    my $url   = $app->param('url');
98    my %param = (
99        version     => plugin()->version,
100        # blog_loop   => \@blog_loop,
101        blog_id => $blog_id,
102        themes_json => JSON::objToJson(
103            $theme_data, { pretty => 1, indent => 2, delimiter => 1 }
104        ),
105        auto_fetch => $url ? 1 : 0,
106        style_library => $stylelibrary,
107        current_theme => $blog_theme || '',
108        current_layout => $blog_layout || 'layout-wtt',
109        dynamic_blog => (($blog->custom_dynamic_templates || '') eq 'all'),
110    );
111
112    if ( $blog_id && @$styled_blogs ) {
113        my $blog = $styled_blogs->[0];
114        $param{blog_name} = $blog->name;
115        $param{blog_url}  = $blog->site_url;
116    }
117
118    my $path = $app->static_path;
119    $path .= '/' unless $path =~ m!/$!;
120    $path .= plugin()->envelope . "/";
121    $path = $app->base . $path if $path =~ m!^/!;
122    $param{plugin_static_uri} = $path;
123
124    $app->build_page( 'view.tmpl', \%param );
125}
126
127# AJAX/JSON modes
128
129# returns a json structure of styles given a particular url
130sub js {
131    # ydnar's remixer uses javascript files for each collection of styles -
132    # we generate these js files from css metadata
133    # StyleCatcher will pick up any metadata in the theme css file in the
134    # format of 'key: value' in comment-space
135    # The remixer only uses name, author, description at the moment.
136    my $app = shift;
137
138    my $data = fetch_themes($app->param('url'))
139        or return $app->json_error( $app->errstr );
140    return $app->json_result( $data );
141}
142
143# does the work after user selects a particular theme to apply to a blog
144sub apply {
145    my $app = shift;
146
147    my $blog_id = $app->param('blog_id');
148    my $url     = $app->param('url');
149    my $layout  = $app->param('layout');
150    my $name    = $app->param('name');
151
152    # Load the default stylesheet for this blog
153    my $tmpl = load_style_template($blog_id);
154
155    $app->validate_magic or return $app->json_error($app->translate("Invalid request"));
156    return $app->json_error($app->translate("Invalid request"))
157      unless $blog_id && $url && $tmpl;
158
159    my $static_path = $app->static_file_path;
160    if (! -d $static_path ) {
161        return $app->json_error($app->translate("Your mt-static directory could not be found. Please configure 'StaticFilePath' to continue."));
162    }
163
164    my $themeroot =
165      File::Spec->catdir( $static_path, 'support', 'themes' );
166    my $webthemeroot = $app->static_path . 'support/themes/';
167    my $mtthemeroot  = $app->static_path . 'themes/';
168    my $mtthemebase  = $app->static_path . 'themes-base/';
169
170    # Break up the css url in to a couple useful pieces
171    my @url = split( /\//, $url );
172
173    # if this isn't a local url, then we have to grab some files from
174    # yonder...
175    my $filemgr = file_mgr()
176      or return $app->json_error( MT::FileMgr->errstr );
177
178    if ( $url !~ m/^(\Q$webthemeroot\E|\Q$mtthemeroot\E)/ ) {
179        my $new_url = '';
180
181        for (0..(scalar(@url)-2)) {
182            $new_url .= $url[$_] . '/';
183        }
184        my ( $basename, $extension ) = split( /\./, $url[-1] );
185        if ($basename eq 'screen') {
186            $basename = $url[-2];
187        }
188
189        # Pick up the stylesheet
190        my $user_agent  = $app->new_ua;
191        my $css_request = HTTP::Request->new( GET => $url );
192        my $response    = $user_agent->request($css_request);
193
194        # Pick up the thumbnail and thumbnail-large
195        my $thumbnail_request =
196          HTTP::Request->new( GET => $new_url . "thumbnail.gif" );
197        my $thumbnail_response = $user_agent->request($thumbnail_request);
198        my $thumbnail_large_request =
199          HTTP::Request->new( GET => $new_url . "thumbnail-large.gif" );
200        my $thumbnail_large_response =
201          $user_agent->request($thumbnail_large_request);
202
203        # Parse out image filenames in the css and then write out the css file
204        # and thumbnails to our theme folder
205        my $content = $response->content;
206        $content =~ s!/\*.*?\*/!!gs;    # strip all comments first
207        my @images = $content =~
208          m/\b(?:url\(\s*)([a-zA-Z0-9_.-]+\.(?:gif|jpe?g|png))(?:\s*?\))/gi;
209        $filemgr->mkpath( File::Spec->catdir( $themeroot, $basename ) )
210          or return $app->json_error(
211            $app->translate(
212"Could not create [_1] folder - Check that your 'themes' folder is webserver-writable.",
213                $basename
214            )
215          );
216        $filemgr->put_data( $response->content,
217            File::Spec->catfile( $themeroot, $basename, $basename . '.css' ) );
218        if (($thumbnail_response->code >= 200) && ($thumbnail_response->code < 400)) {
219        $filemgr->put_data( $thumbnail_response->content,
220            File::Spec->catfile( $themeroot, $basename, "thumbnail.gif" ),
221            'upload' );
222        } else {
223            return $app->json_error($app->translate("Error downloading image: [_1]", $new_url . 'thumbnail.gif'))
224        }
225        if (($thumbnail_large_response->code >= 200) && ($thumbnail_large_response->code < 400)) {
226            $filemgr->put_data(
227                $thumbnail_large_response->content,
228                File::Spec->catfile( $themeroot, $basename, "thumbnail-large.gif" ),
229                'upload'
230            );
231        } else {
232            return $app->json_error($app->translate("Error downloading image: [_1]", $new_url . 'thumbnail-large.gif'))
233        }
234
235       # Pick up the images we parsed earlier and write them to the theme folder
236        for my $image_url (@images) {
237            my $image_request =
238              HTTP::Request->new( GET => $new_url . $image_url );
239            my $image_response = $user_agent->request($image_request);
240
241            my @image_url = split( /\//, $image_url );
242            my $image_filename = $image_url[-1];
243
244            if (($response->code >= 200) && ($response->code < 400)) {
245                $filemgr->put_data( $image_response->content,
246                    File::Spec->catfile( $themeroot, $basename, $image_filename ),
247                    'upload' )
248                  or return $app->json_error( $filemgr->errstr );
249            } else {
250                return $app->json_error($app->translate("Error downloading image: [_1]", $new_url . $image_url));
251            }
252        }
253        $url = "$webthemeroot$basename/$basename.css";
254    }
255   
256
257    my $url2 = $mtthemebase . "blog.css";
258
259    # Replacing the theme import or adding a new one at the beginning
260    my $template_text  = $tmpl->text();
261    my $replaced       = 0;
262    my $header =
263'/* This is the StyleCatcher theme addition. Do not remove this block. */';
264    my $footer = '/* end StyleCatcher imports */';
265    my $styles = $header . "\n" . <<"EOT" . $footer;
266\@import url($url2);
267\@import url($url);
268EOT
269    if ($template_text =~ s/\Q$header\E.*\Q$footer\E/$styles/s) {
270        $tmpl->text( $template_text );
271        $replaced = 1;
272    }
273    unless ($replaced) {
274
275        # we're dealing with a template that wasn't modified before now
276        # we will need to backup the existing one to make sure the new
277        # style is applied properly.
278        my @ts = MT::Util::offset_time_list( time, $blog_id );
279        my $ts = sprintf "%04d-%02d-%02d %02d:%02d:%02d", $ts[5] + 1900,
280          $ts[4] + 1, @ts[ 3, 2, 1, 0 ];
281        my $backup = $tmpl->clone;
282        delete $backup->{column_values}
283          {id};    # make sure we don't overwrite original
284        delete $backup->{changed_cols}{id};
285        $backup->name( $backup->name . ' (Backup from ' . $ts . ')' );
286        $backup->outfile('');
287        $backup->linked_file( $tmpl->linked_file );
288        $backup->rebuild_me(0);
289        $backup->build_dynamic(0);
290        $backup->identifier(undef);
291        $backup->type('backup');
292        $backup->save;
293        $tmpl->linked_file('');    # make sure this one isn't linked now
294        $tmpl->identifier('styles');
295        $tmpl->text($styles);
296    }
297
298    # Putting the stylesheet back together again
299    $tmpl->save or return $app->json_error( $tmpl->errstr );
300
301    my $blog = MT->model('blog')->load($blog_id)   
302      or return $app->json_error( $app->translate('No such blog [_1]', $blog_id) );
303    $blog->page_layout($layout);
304    $blog->touch();
305    $blog->save();
306
307    # rebuild only the stylesheet! forcibly. with prejudice.
308    $app->rebuild_indexes(
309        BlogID   => $tmpl->blog_id,
310        Template => $tmpl,
311        Force    => 1
312    );
313
314    my $p = plugin();
315    $name =~ s/^repo_\d+:/local:/;
316    $name =~ s/\.css$//;
317    $p->set_config_value('current_theme_' . $blog_id, $name);
318    if ($layout) {
319        $p->set_config_value('current_layout_' . $blog_id, $layout);
320    } else {
321        $p->set_config_value('current_layout_' . $blog_id, undef);
322    }
323
324    return $app->json_result(
325        {
326            message =>
327              $app->translate("Successfully applied new theme selection.")
328        }
329    );
330}
331
332# Utility methods
333
334sub fetch_blogs {
335    my $app     = MT->app;
336    my $user    = $app->user;
337    my $blog_id = $app->param('blog_id');
338
339    my @blogs;
340    if ($blog_id) {
341        @blogs = MT::Blog->load($blog_id);
342    } else {
343        if ( $user->is_superuser() ) {
344            if ($blog_id) {
345                @blogs = MT::Blog->load($blog_id);
346            }
347        }
348        else {
349            my $args = { author_id => $user->id };
350            $args->{blog_id} = $blog_id if $blog_id;
351            require MT::Permission;
352            my @perms = MT::Permission->load( { author_id => $user->id } );
353            foreach my $perm (@perms) {
354                next unless $perm->can_edit_templates;
355                push @blogs, MT::Blog->load( $perm->blog_id );
356            }
357        }
358    }
359    my @styled_blogs;
360    foreach my $blog (@blogs) {
361        my $tmpl = load_style_template( $blog->id );
362        if ($tmpl) {
363            push @styled_blogs, $blog;
364        }
365    }
366    @styled_blogs = sort { $a->name cmp $b->name } @styled_blogs;
367
368    \@styled_blogs;
369}
370
371sub load_style_template {
372    my ($blog_id) = @_;
373
374    require MT::Template;
375    my $tmpl;
376
377    $tmpl = MT::Template->load(
378        {
379            blog_id    => $blog_id,
380            identifier => 'styles'
381        }
382    );
383
384    $tmpl ||= MT::Template->load(
385        {
386            blog_id => $blog_id,
387            outfile => "styles.css"
388        }
389    );
390
391    # MT 3.x era stylesheet file
392    $tmpl ||= MT::Template->load(
393        {
394            blog_id => $blog_id,
395            outfile => "styles-site.css"
396        }
397    );
398
399    unless ($tmpl) {
400
401        # Create one since we didn't find a candidate
402        $tmpl = new MT::Template;
403        $tmpl->blog_id($blog_id);
404        $tmpl->identifier('styles');
405        $tmpl->outfile("styles.css");
406        $tmpl->text(<<'EOT');
407@import url(<$MTStaticWebPath$>themes-base/blog.css);
408@import url(<$MTStaticWebPath$>themes/minimalist-red/styles.css);
409EOT
410        $tmpl->save();
411    }
412
413    $tmpl;
414}
415
416# pulls a list of themes available from a particular url
417sub fetch_themes {
418    my $app = MT->app;
419    my ($url) = @_;
420    return undef unless $url;
421
422    my $blog_id = $app->param('blog_id');
423    my $data    = {};
424
425  # If we have a url then we're specifying a specific theme (css) or repo (html)
426    # Pick up the file (html with <link>s or a css file with metadata)
427    my $user_agent = $app->new_ua;
428    my $request    = HTTP::Request->new( GET => $url );
429    my $response   = $user_agent->request($request);
430
431    # Make a repo if you've got a ton of links or an automagic entry if
432    # you're a css file
433    my $type = $response->headers->{'content-type'};
434    $type = shift @$type if ref $type eq 'ARRAY';
435    if ( $type =~ m!^text/css! ) {
436        $data->{auto}{url} = $url;
437        my $theme = fetch_theme( $url, ['collection:auto'] );
438        $data->{themes} = [$theme];
439    }
440    elsif ( $type =~ m!^text/html! ) {
441        my @repo_themes;
442        for my $link (
443            ref( $response->headers->{'link'} ) eq 'ARRAY'
444            ? @{ $response->headers->{'link'} }
445            : $response->headers->{'link'}
446          )
447        {
448            my ( $css, @parsed_link ) = split( /;/, $link );
449            $css =~ s/[<>]//g;
450            my %attr;
451            foreach (@parsed_link) {
452                my ( $name, $val ) = split /=/, $_, 2;
453                $name =~ s/^ //;
454                $val  =~ s/^['"]|['"]$//g;
455                next if $name eq '/';
456                $attr{ lc($name) } = $val;
457            }
458            next unless lc $attr{rel}  eq 'theme';
459            next unless lc $attr{type} eq 'text/x-theme';
460
461            # Fix for relative theme locations
462            if ($css !~ m!^https?://!) {
463                my $new_css = $url;
464                $new_css =~ s!/[a-z0-9_-]+\.[a-z]+?$|/$!/!;
465                $new_css .= $css;
466                $css = $new_css;
467            }
468            push @repo_themes, $css;
469        }
470
471        my $themes = [];
472        for my $repo_theme (@repo_themes) {
473            my $theme = fetch_theme( $repo_theme, [] );
474            push @$themes, $theme if $theme;
475        }
476        $data->{themes} = $themes;
477        if ( $data->{repo}{display_name} = $response->headers->{'title'} ) {
478            $data->{repo}{name} =
479              MT::Util::dirify( $data->{repo}{display_name} );
480        }
481        else {
482            $data->{repo}{display_name} = $url;
483            $data->{repo}{name}         = MT::Util::dirify($url);
484        }
485        $data->{repo}{url} = $url;
486    }
487    else {
488        return $app->error( $app->translate('Invalid URL: [_1]', $url) );
489    }
490
491    $data;
492}
493
494# sets up the object structure we return through json to populate
495# the mixer.
496sub make_themes {
497    my $app = MT->instance;
498
499    # categories
500    #   current    (for active theme)
501    #   repo       (for themes found at repo link)
502    #   my-designs (for themes that are stored locally)
503    #   mt-designs (for themes that are local and installed by default)
504    #   auto       (for link to a single css file)
505
506    # structure of "data"
507    #   categories => [ one, two, three ]  ie: 'current', 'repo'
508    #   themes => [
509    #       { theme }
510    #   ]
511    #   repo => {
512    #       display_name => 'display name',
513    #       name => 'repo name',
514    #       url => 'url of repo',
515    #   }
516
517# structure of "theme"
518#   theme => {
519#       name => 'theme_dir',
520#       imageSmall => 'link_to/thumbnail.gif',
521#       imageBig => 'link_to/thumbnail-large.gif',
522#       title => 'Theme Title',
523#       description => 'Theme description.',
524#       url_css => 'link_to/theme.css',
525#       url_zip => 'link_to/theme.zip',
526#       author => 'Author Name',
527#       author_url => 'http://author.com/'
528#       author_affiliation => 'Author Co.',
529#       layouts => "comma,delimited,layout,list"
530#       sort => 'theme_sortable_name',
531#       tags => ['association:tag']  ie, 'color:blue', 'designer:author', 'collection:repo'
532#   }
533
534    my ( $categories, $themes );
535    my $sys_root = File::Spec->catdir( $app->static_file_path, 'themes' );
536
537    # Generate our list of themes within the themeroot directory
538    my @sys_list = glob( File::Spec->catfile( $sys_root, "*" ) );
539    $categories->{'mt-designs'} = 1 if @sys_list;
540    for my $theme (@sys_list) {
541        my $theme_dir = $theme;
542        my $theme_url = $app->static_path . 'themes';
543        next unless -d $theme;
544        $theme =~ s/.*[\\\/]//;
545        $themes->{$theme} =
546          fetch_theme( $theme_dir, ['collection:mt-designs'], $theme_url,
547            $theme_dir );
548        $themes->{$theme}{name} = $themes->{$theme}{name};
549        $themes->{$theme}{prefix} = 'default';
550    }
551
552    my $themeroot =
553      File::Spec->catdir( $app->static_file_path, 'support', 'themes' );
554
555    # Generate our list of themes within the themeroot directory
556    my @themeroot_list = glob( File::Spec->catfile( $themeroot, "*" ) );
557    $categories->{'my-designs'} = 1 if @themeroot_list;
558    for my $theme (@themeroot_list) {
559        my $theme_dir = $theme;
560        next unless -d $theme;
561        $theme =~ s/.*[\\\/]//;
562        $themes->{$theme} =
563          fetch_theme( $theme_dir, ['collection:my-designs'] );
564        $themes->{$theme}{prefix} = 'local';
565    }
566
567    my $data = {
568        categories => [ keys %$categories ],
569        themes     => [ values %$themes ]
570    };
571
572    $data;
573}
574
575sub fetch_theme {
576    my $app = MT->app;
577    my ( $url, $tags, $baseurl, $basepath ) = @_;
578
579    my $theme;
580    my $stylesheet;
581    my $new_url;
582    my $themeroot;
583    if ( $url =~ m/^https?:/i ) {
584
585        # Pick up the css file
586        my $user_agent  = $app->new_ua;
587        my $css_request = HTTP::Request->new( GET => $url );
588        my $response    = $user_agent->request($css_request);
589        $stylesheet = $response->content if ($response->code >= 200) && ($response->code < 400);
590        return unless $stylesheet;
591
592# Break up the css url in to a couple useful pieces (generalize and break me out)
593        $theme = $url;
594        # discard any generic 'screen.css' filename
595        $theme =~ s!/screen.css$!!;
596        $theme =~ s/.*[\\\/]//;
597        my @url = split( /\//, $url );
598        for ( 0 .. ( scalar(@url) - 2 ) ) {
599            $new_url .= $url[$_] . '/';
600        }
601    }
602    else {
603        $themeroot = $basepath
604          || File::Spec->catdir( $app->static_file_path, 'support', 'themes' );
605        my $webthemeroot = $baseurl || $app->static_path . 'support/themes';
606
607        $theme = $url;
608        $theme =~ s/.*[\\\/]//;
609        my $file = File::Spec->catfile( $url, "$theme.css" );
610        $new_url = "$webthemeroot/$theme/";
611        if ( -e $file ) {
612            $stylesheet = file_mgr()->get_data($file);
613            $url        = $new_url . "$theme.css";
614        }
615        else {
616            $file = File::Spec->catfile( $url, "screen.css" );
617            if ( -e $file ) {
618                $stylesheet = file_mgr()->get_data($file);
619                $url        = $new_url . "screen.css";
620            }
621        }
622    }
623
624    # Pick up the metadata from the css
625    my @css_lines = split( /\r?\n/, $stylesheet || '' );
626    my $commented = 0;
627    my @comments;
628    for my $line (@css_lines) {
629        my $pos;
630        $pos = index( $line, "/*" );
631        unless ( $pos == -1 ) {
632            $line = substr( $line, $pos + 2 );
633            $commented = 1;
634        }
635        if ($commented) {
636            $pos = index( $line, "*/" );
637            unless ( $pos == -1 ) {
638                $line = substr( $line, 0, $pos );
639                $commented = 0;
640            }
641            push @comments, $line;
642        }
643    }
644
645    my $comment;
646    my %metadata;
647
648    # Trim me white space, yarr
649    for (@comments) {
650
651        # TBD: strip any "risky" content; we don't want any
652        # XSS in this content.
653        # Strip any null bytes
654        tr/\x00//d;
655        s/^\s+|\s+$//g;
656        my ( $key, $value ) = split( /:/, $_, 2 ) or next;
657        next unless defined $value;
658        $value =~ s/^\s+//;
659        $metadata{ lc $key } = $value;
660    }
661
662    my $thumbnail_link;
663    $thumbnail_link = $new_url . 'thumbnail.gif';
664    my $thumbnail_large_link;
665    $thumbnail_large_link = $new_url . 'thumbnail-large.gif';
666
667    require MT::Util;
668    my $data = {
669        name        => $theme,
670        description => $metadata{description} || '',
671        title       => $metadata{name} || '(Untitled)',
672        url         => $url,
673        imageSmall  => $thumbnail_link,
674        imageBig    => $thumbnail_large_link,
675        author      => $metadata{designer} || $metadata{author} || '',
676        author_url  => $metadata{designer_url} || $metadata{author_url} || '',
677        author_affiliation => $metadata{author_affiliation} || '',
678        layouts            => $metadata{layouts} || '',
679        'sort'             => $metadata{name} || '',
680        tags               => $tags,
681        blogs              => [],
682    };
683    $data;
684}
685
686sub plugin {
687    return MT->component('StyleCatcher');
688}
689
6901;
Note: See TracBrowser for help on using the browser.