]> git.decadent.org.uk Git - memories.git/commitdiff
Merge commit 'trunk'
authorBen Hutchings <ben@decadent.org.uk>
Tue, 4 Nov 2008 04:37:45 +0000 (04:37 +0000)
committerBen Hutchings <ben@decadent.org.uk>
Tue, 4 Nov 2008 04:37:45 +0000 (04:37 +0000)
41 files changed:
META.yml
Memories.pm [new file with mode: 0644]
Memories/Config.pm [deleted file]
Memories/Config.pm.example [new file with mode: 0644]
Memories/DBI.pm
Memories/Photo.pm
Memories/SystemTag.pm
Memories/Tag.pm
README
Tagtools.pm
memories.sql
templates/album/list
templates/calendar
templates/custom/list
templates/frontpage
templates/header
templates/macros
templates/nav
templates/photo/comment
templates/photo/paged [new file with mode: 0644]
templates/photo/quarantine [new file with mode: 0644]
templates/photo/recent [deleted file]
templates/photo/similar
templates/photo/sized [new file with mode: 0644]
templates/photo/tagedit
templates/photo/upload
templates/photo/view
templates/recent.rss
templates/rhs
templates/static/alt_star.gif [new file with mode: 0644]
templates/static/memories.css
templates/static/prototype.js [new file with mode: 0644]
templates/static/star.gif [new file with mode: 0644]
templates/static/star_rating.css [new file with mode: 0644]
templates/static/star_small.gif [new file with mode: 0644]
templates/static/tagcloud.css [new file with mode: 0644]
templates/static/upload.js
templates/systemtag/list
templates/tag/list
templates/user/album_list
templates/user/list

index 44f500bee9073df8401e603e36e3a86d145948b9..4f5b8c627d43f56454059f5647b5e8e7cbdef725 100644 (file)
--- a/META.yml
+++ b/META.yml
@@ -5,8 +5,11 @@ version:      1.2
 version_from: Memories.pm
 installdirs:  site
 requires:
+    Apache::Session::File:         0
+    Archive::Any:                  0
     Cache::MemoryCache:            0
     Calendar::Simple:              0
+    Class::DBI::mysql:             0
     Class::DBI::Plugin::AbstractCount: 0
     Class::DBI::Plugin::Pager:     0
     File::Path:                    0
@@ -14,7 +17,10 @@ requires:
     Image::ExifTool:               0
     Image::Imlib2:                 0
     Image::Info:                   0
+    Image::Seek:                   0
+    Image::Size:                   0
     Maypole:                       1.1
+    Maypole::Plugin::Authentication::UserSessionCookie: 0
     Text::Balanced:                0
     Time::Piece:                   0
     URI::Escape:                   0
diff --git a/Memories.pm b/Memories.pm
new file mode 100644 (file)
index 0000000..c3f1127
--- /dev/null
@@ -0,0 +1,137 @@
+package Memories;
+use strict;
+our $VERSION = "1.2";
+use Maypole::Application qw(Authentication::UserSessionCookie);
+use HTML::TagCloud;
+use URI;
+use Tagtools;
+use Memories::Config;
+use Memories::DBI;
+use Memories::Photo;
+use Memories::Comment;
+use Memories::Tag;
+use Memories::SystemTag;
+use Memories::User;
+use Memories::Album;
+use URI::Escape;
+use XML::RSS;
+
+Memories->config->auth->{ user_field } = "name";
+Memories->config->model("Maypole::Model::CDBI::Plain");
+Memories->setup([qw/ Memories::Photo Memories::User Memories::Tag
+Memories::Album Memories::SystemTag/]);
+Memories->setup_tagging("photo");
+Memories->setup_tagging("photo", "system_tag");
+
+sub message {
+    my ($self, $message) = @_;
+    push @{$self->{template_args}{messages}}, $message;
+}
+
+sub check_credentials {
+    my $r = shift;
+    my ($uid, $user) = $r->SUPER::check_credentials;
+    #if (!$uid) { return (-1, undef) }
+    return ($uid, $user);
+}
+
+sub do_rss {
+    my $r = shift;
+    $r->model_class->process($r);
+    my $photos = $r->get_photos;
+    my $rss = XML::RSS->new(version => "2.0");
+    $rss->channel(
+        title => ($r->config->{application_name}. " : ".ucfirst($r->action)." ".ucfirst($r->table)." ".($r->objects||[])->[0]) ,
+        link  => $r->config->{uri_base}."/".$r->path
+    );
+    for my $item (@$photos) { 
+        my $link = $r->config->{uri_base}."photo/view/".$item->id;
+        $rss->add_item( title => $item->title, link => $link,
+            description => 
+    "<a href=\"$link\">
+    <img src=\"". $item->thumb_url."\" alt=\"".$item->title."\"></a>",
+            dc => { subject => join " ", $item->tags },
+            pubDate => $item->uploaded->strftime("%a, %d %b %Y %H:%M:%S %z")
+        )
+    }
+    $r->output($rss->as_string);
+    $r->content_type("application/rss+xml");
+    return
+}
+
+sub get_photos {
+    my $r = shift;
+    my $maybe_photos = $r->{objects}||[];
+    return (@$maybe_photos && $maybe_photos->[0]->isa("Memories::Photo")) 
+            ? $maybe_photos :
+            ($r->{template_args}->{photos} || []);
+}
+
+sub last_search {
+    my $r = shift;
+    my $photos = $r->get_photos; 
+    $r->{session}{last_search} = join ",", map { $_->id } @$photos 
+        if @$photos > 1;
+}
+
+sub additional_data { 
+    my $r = shift;
+    if ($r->params->{view_cal}) { 
+    $r->{template_args}{view_cal} = eval {
+            Time::Piece->strptime($r->{params}{view_cal}, "%Y-%m-%d") }; 
+    }
+    $r->{template_args}{now} = Time::Piece->new;
+    if ($r->session) {
+        (tied %{$r->session})->{lock_manager}->clean(Memories->config->{auth}{session_args}{LockDirectory}, 3600) #remove files older than 1 hour
+    }
+    return $r->do_rss if ($r->params->{format} =~ /rss/)
+}
+
+use Maypole::Constants;
+sub authenticate {
+   my ($self, $r) = @_;
+   return DECLINED if $self->path =~/static|store/; # XXX
+   $r->get_user;
+   if (!$r->user and $self->path =~ /upload/) { $r->template("login"); }
+   # Don't let 'em go until they've fixed it
+   if ($r->session and $r->session->{quarantined} and $self->path !~ /js$/) { 
+       $r->table("photo"); $r->action("quarantine");
+       $r->model_class("Memories::Photo");
+   }
+   return OK; 
+}
+
+for my $how (qw(random recent interesting popular)) {
+    no strict;
+    my $method = "search_$how";
+    *{"_$how"} = sub { Memories::Photo->$method };
+    *{$how} = sub { shift->do_cached(\&{"_$how"})};
+}
+
+sub tag_select {
+    my ($r, $tags) = @_;
+    my %counter;
+    my @photos = Memories::Photo->sth_to_objects(Memories::Tag->multi_search(@$tags));
+    for (map {$_->tags} @photos) { 
+        $counter{$_->name}++; 
+    } 
+    delete $counter{$_->name} for @$tags;
+    my @super;
+
+    my $cloud = HTML::TagCloud->new();
+    my $base = $r->config->uri_base.$r->path."/";
+    my $tags;
+    for my $name (sort {$a cmp $b} keys %counter) {
+        if ($counter{$name} == @photos) {
+            push @super, $name;
+        } else {
+            $cloud->add($name, $base.uri_escape($name), $counter{$name});
+            $tags++;
+        }
+    }
+    my %res;
+    if (@super) { $res{super} = \@super }
+    if ($tags) { $res{cloud} = $cloud }
+    \%res;
+}
+1;
diff --git a/Memories/Config.pm b/Memories/Config.pm
deleted file mode 100644 (file)
index 9796ab0..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-package Memories::Config;
-# PATHS AND URLS
-
-# This parameter should be the external address of your Memories
-# installation
-Memories->config->uri_base("http://memories.simon-cozens.org/");
-
-# This is where your templates will live.
-Memories->config->{template_root} = "/home/simon/maypole-sites/memories/templates";
-
-# Here is where uploaded photos will be stored. Your web server user
-# should own this directory.
-Memories->config->{data_store} = "/web/photostore/";
-
-# You also need to configure your web server so that it serves files out
-# of the data store; this URL should be where that data store directory
-# is exposed on the web.
-Memories->config->{data_store_external} = "http://memories.simon-cozens.org/store/";
-
-# Your database server: the first part should always be "dbi"; the
-# second, the name of the DBD driver you're using (usually mysql unless
-# you want to do your own thing); the final part, the name of the
-# database.
-#
-# Initialize this database from memories.sql and give www-data (or
-# equivalent) read/write access to all tables.
-Memories->config->{dsn} = "dbi:mysql:memories";
-
-# SESSION MANAGEMENT
-#
-# The name of any cookies this application should give out
-Memories->config->{auth}{cookie_name} = "memories";
-
-# Session file storage. Create these directories and have them owned by
-# www-data
-Memories->config->{auth}{session_args} = {
-        Directory => "/var/lib/memories/sessions",
-        LockDirectory => "/var/lib/memories/sessionlock",
-    };
-
-# This is where your Image::Seek library will be stored.
-Memories->config->{image_seek} = "/var/lib/memories/imageseek.db";
-
-# DISPLAY PARAMETERS
-#
-# It's OK to leave these as they are.
-# 
-# The size of thumbnails to generate
-Memories->config->{thumb_size} = "90x90";
-
-# Sizes you want to scale to. "full" is special.
-Memories->config->{sizes} = 
-    [ qw/ 150x100 300x200 640x480 800x600 1024x768 1280x1024 full /];
-
-# Number of photos on a page. It's best if this is a multiple of three
-Memories->config->{photos_per_page} = 21;
-
-1;
diff --git a/Memories/Config.pm.example b/Memories/Config.pm.example
new file mode 100644 (file)
index 0000000..fb22d82
--- /dev/null
@@ -0,0 +1,61 @@
+package Memories::Config;
+# PATHS AND URLS
+
+# This parameter should be the external address of your Memories
+# installation
+Memories->config->uri_base("http://memories.simon-cozens.org/");
+
+# This is where your templates will live.
+Memories->config->{template_root} = [
+    "/web/memories/mytemplates/", # User-defined templates
+    "/etc/memories/templates/",   # Factory templates
+];
+
+# Here is where uploaded photos will be stored. Your web server user
+# should own this directory.
+Memories->config->{data_store} = "/web/photostore/";
+
+# You also need to configure your web server so that it serves files out
+# of the data store; this URL should be where that data store directory
+# is exposed on the web.
+Memories->config->{data_store_external} = "http://memories.simon-cozens.org/store/";
+
+# Your database server: the first part should always be "dbi"; the
+# second, the name of the DBD driver you're using (usually mysql unless
+# you want to do your own thing); the final part, the name of the
+# database.
+#
+# Initialize this database from memories.sql and give www-data (or
+# equivalent) read/write access to all tables.
+Memories->config->{dsn} = "dbi:mysql:memories";
+
+# SESSION MANAGEMENT
+#
+# The name of any cookies this application should give out
+Memories->config->{auth}{cookie_name} = "memories";
+
+# Session file storage. Create these directories and have them owned by
+# www-data
+Memories->config->{auth}{session_args} = {
+        Directory => "/var/lib/memories/sessions",
+        LockDirectory => "/var/lib/memories/sessionlock",
+    };
+
+# This is where your Image::Seek library will be stored.
+Memories->config->{image_seek} = "/var/lib/memories/imageseek.db";
+
+# DISPLAY PARAMETERS
+#
+# It's OK to leave these as they are.
+# 
+# The size of thumbnails to generate
+Memories->config->{thumb_size} = "90x90";
+
+# Sizes you want to scale to. "full" is special.
+Memories->config->{sizes} = 
+    [ qw/ 150x100 300x200 640x480 800x600 1024x768 1280x1024 full /];
+
+# Number of photos on a page. It's best if this is a multiple of three
+Memories->config->{photos_per_page} = 21;
+
+1;
index 594d91e2dd60a1447db4412a849573a06c65ccbb..5523460aa3671a7597ef8f445434ae6b2c6d44ed 100644 (file)
@@ -1,7 +1,7 @@
 package Memories::DBI;
 #use base qw(Class::DBI::Sweet Class::DBI::mysql);
 use base qw(Class::DBI::mysql);
-Memories::DBI->connection(Memories->config->{dsn});
+Memories::DBI->connection(Memories->config->{dsn}, Memories->config->{db_user}, Memories->config->{db_pass});
 Memories::DBI->autoupdate(1);
 use Carp qw(carp);
 
index 4e2a59dea02f719315ab98fee116b84bb80ff2cc..76375a576dd9bf26774836e36f37f8315da42404 100644 (file)
@@ -1,20 +1,48 @@
 package Memories::Photo;
+#use Apache2::Upload;
+use File::Basename;
+use File::Copy;
+use Archive::Any;
+use File::Temp qw(tempdir tmpnam);
+use File::Path qw(rmtree);
+use File::Find;
+use File::MMagic;
+use Image::Size qw(imgsize);
 use strict;
 use Carp qw(cluck confess);
 use base qw(Memories::DBI Maypole::Model::CDBI::Plain);
+use constant INTERESTINGNESS_ALGORITHM => '((rating+3)/(rated+1))*(1+rated/hit_count)*(1+hit_count/(select max(hit_count) from photo))';
 use Time::Piece;
 use Image::Seek;
 use constant PAGER_SYNTAX => "LimitXY";
-__PACKAGE__->columns(Essential => qw(id title uploader uploaded x y));
+__PACKAGE__->columns(Essential => qw(id title uploader uploaded x y rating rated hit_count format));
 __PACKAGE__->untaint_columns(printable => [qw/title/]);
 __PACKAGE__->columns(TEMP => qw/exif_object/);
-__PACKAGE__->set_sql(recent => q{
-SELECT __ESSENTIAL__
-FROM __TABLE__
-ORDER BY uploaded DESC
-LIMIT 4
-});
 
+BEGIN {
+my %order_by = (
+     recent => "uploaded",
+     popular => "hit_count",
+     loved => "rating/(1+rated)",
+     interesting => INTERESTINGNESS_ALGORITHM,
+     random => "rand()"
+);
+
+while (my($label, $how) = each %order_by) {
+    __PACKAGE__->set_sql($label => qq{
+    SELECT __ESSENTIAL__
+    FROM __TABLE__
+    ORDER BY $how DESC
+    LIMIT 4
+    });
+    no strict;
+    *$label = sub {
+        my ($self, $r) = @_;
+        $self->view_paged_ordered($r, "$how desc");
+    };
+    __PACKAGE__->MODIFY_CODE_ATTRIBUTES(*{$label}{CODE}, "Exported"); # Hack
+}
+}
 __PACKAGE__->has_many(comments => "Memories::Comment");
 __PACKAGE__->has_a(uploader => "Memories::User");
 __PACKAGE__->has_a(uploaded => "Time::Piece",
@@ -24,52 +52,145 @@ __PACKAGE__->has_a(uploaded => "Time::Piece",
 
 sub do_upload :Exported {
     my ($self, $r) = @_;
-    my %upload = $r->upload("photo");
+    my $upload = $r->{ar}->upload("photo");
+    # Check $self->type
+    my @photos = ($self->upload_file($r, $upload->tempname, $upload->filename));
+    my @quarantined = grep { !$_->tags } @photos;
+    # Set it up to go again
+    if (@quarantined) { 
+        $r->{session}{quarantined} = join ",", sort map { $_->id} @quarantined;
+        $r->objects(\@quarantined);
+        $r->template("quarantine");
+        return;
+    }
+    $r->objects(\@photos);
+    if (@photos == 0) { $r->template("upload"); return  }
+    if (@photos > 1) { $r->template_args->{title} = "This upload"; $r->template("paged") } 
+    else { $r->template("view"); }
+    $r->message("Thanks for the upload!"); 
+}
 
-    # XXX Stop anonymous uploads!
+sub quarantine :Exported {
+    my ($self, $r) = @_;
+    my @quarantined = split /,/, $r->{session}{quarantined};
+    my %q = map { $_ => 1 } @quarantined;
+    for (map /(\d+)/,grep /tags\d+/, keys %{$r->{params}}) {
+        my $tags = $r->{params}{"tags$_"};
+        next unless $tags;
+        if (my $photo = $self->retrieve($_)) {
+            $photo->add_tags($tags);
+            delete $q{$_};
+        }
+    }
+    $r->{session}{quarantined} = join ",", sort keys %q;
+    if (!$r->{session}{quarantined}) {
+        $r->template_args->{title} = "This upload"; $r->template("paged");
+        $r->objects([ map { $self->retrieve($_) } @quarantined ]);
+    } else {
+        $r->objects([ map { $self->retrieve($_) } sort keys %q ]);
+    }
+}
+
+sub upload_file {
+    my ($self, $r, $filename, $offered_name) = @_;
+    my $mm = File::MMagic->new;
+    my $res = $mm->checktype_filename($filename);
+    if ($res =~ m{/x-zip} or $offered_name =~ /t(ar\.)?gz$/i) {
+        return $self->upload_archive($r, $filename);
+    } elsif ($offered_name =~ /\.(raw|nef|dng|cr2)/i) {
+        return $self->upload_raw($r, $filename, $offered_name);
+    } elsif ($res =~ m{image/jpeg}) {
+        return $self->upload_jpeg($r, $filename, $offered_name);
+    } else {
+        $r->message(basename($offered_name).": I can't handle $res files yet");
+        return ();
+    }
+}
+
+sub upload_archive {
+    my ($self, $r, $filename, $tags) = @_;
+    $r->{params}{title} = ""; # Kill that dead.
+    my $archive = Archive::Any->new($filename);
+    my $dir = tempdir();
+    $archive->extract($dir);
+    my @results; 
+    find({ wanted   => sub { return unless -f $_; 
+                             push @results, $self->upload_file($r, $_, $_) }, 
+           no_chdir => 1}, $dir);
+    rmtree($dir);
+    return @results;
+}
+
+sub upload_raw {
+    my ($self, $r, $filename, $offered_name) = @_;
+    my $jpg = tmpnam().".jpg";
+    system("dcraw -c $filename | convert - $jpg");
+    $filename =~ /\.(.*)$/;
+    my $format = $1;
+    # Put the file in place
+    my $photo = $self->upload_jpeg($r, $jpg, $offered_name);
+    $photo->format($format);
+    copy($filename, 
+         Memories->config->{data_store}."/".$photo->id.".".$format);
+    return $photo;
+}
+
+sub upload_jpeg {
+    my ($self, $r, $filename, $offered_name) = @_;
     my $photo = $self->create({
         uploader => $r->user,
         uploaded => Time::Piece->new(),
-        title => ($r->params->{title} || $upload{filename})
+        title => ($r->{params}{title} || basename($offered_name)),
+        hit_count => 0,
+        rating => 0,
+        rated => 0,
     });
-
-    # Dump content
-    if (!open OUT, ">". $photo->path("file")) {
-        die "Can't write ".$photo->path("file")." because $!";
+    if (!copy($filename, $photo->path("file"))) {
+       warn "Couldn't copy photo to ".$photo->path("file").": $!";
+        $photo->delete(); die;
     }
-    # XXX Check it's a JPEG, etc.
-    # XXX Unzip ZIP file
-    print OUT $upload{content};
-    close OUT;
-    my ($x, $y) = dim(image_info($photo->path));
+    my ($x, $y, undef) = imgsize($photo->path);
     $photo->x($x); $photo->y($y);
 
     # Rotate?
-    $photo->unrotate();
+    $photo->unrotate(); 
+    if (!$photo->title){ 
+        $photo->title($photo->title_exif || basename($filename));
+    }
 
     $photo->make_thumb;
-    $photo->add_tags($r->{params}{tags});
+    my $tags = $r->{params}{tags}.join " ", map { qq{"$_"} } $photo->tags_exif;
+    $photo->add_tags($tags);
     $photo->add_to_imageseek_library;
     Memories->zap_cache();
-
     # Add system tags here
     my $tag = "date:".$photo->shot->ymd;
-    $photo->add_to_system_tags({tag => Memories::SystemTag->find_or_create({name =>$tag}) });
+    $photo->add_to_system_tags({system_tag => Memories::SystemTag->find_or_create({name =>$tag}) });
+    return $photo;
+}
 
-    # Set it up to go again
-    $r->objects([$photo]);
-    $r->template("view");
-    $r->message("Thanks for the upload! ".
-        ($r->{params}{tags} ? "" 
-        : "Don't forget to <a href=\"?".$r->config->uri_base."photo/view/".$photo->id."?active=tagedit\">tag your photo</a>"
-        )
-    ); 
+sub approx_rating {
+    my $self = shift;
+    $self->rated or return 0;
+    int($self->rating/$self->rated*10)/10;
+}
+
+sub add_rating :Exported {
+    my ($self, $r) = @_;
+    my $photo = $r->{objects}[0];
+    my $delta = $r->{params}{rating};
+    if ($delta <= 0 or $delta > 5) { return; } # Scammer
+    # XXX Race
+    $photo->rating($photo->rating() + $delta);
+    $photo->rated($photo->rated() + 1);
+    $r->output(""); # Only used by ajax
 }
 
 sub view :Exported {
     my ($self, $r) = @_;
+    my $photo = $r->{objects}[0];
+    $photo->hit_count($photo->hit_count()+1);
     if ($r->{session}{last_search}) {
-        my $photo = $r->{objects}[0];
         # This is slightly inefficient
         my @search = split/,/, $r->{session}{last_search};
         my $found = -1;
@@ -85,39 +206,52 @@ sub view :Exported {
     }
 }
 sub upload :Exported {}
+sub exif :Exported {}
+sub comment :Exported {}
+sub tagedit :Exported {}
+sub similar :Exported {}
+sub sized :Exported {}
+sub delete :Exported {
+    my ($self, $r, $photo) = @_;
+    if ($r) { 
+    if ($photo and $photo->uploader == $r->user) {
+        $photo->delete;
+        $r->message("Photo deleted!");
+    }
+    $r->template("frontpage");
+    } else { $self->SUPER::delete() } 
+}
 
 use Class::DBI::Plugin::Pager;
 use Class::DBI::Plugin::AbstractCount;
 
-sub recent :Exported {
-    my ($self, $r) = @_;
+sub view_paged_ordered {
+    my ($self, $r, $how) = @_;
     my $page = $r->params->{page} || 1;
     my $pager = $self->pager(
         per_page => Memories->config->{photos_per_page}, 
         page => $page,
         syntax => PAGER_SYNTAX,
-        order_by => "uploaded desc"
+        order_by => $how
     );
     $r->objects([$pager->retrieve_all ]);
     $r->{template_args}{pager} = $pager;
     $r->last_search;
+    $r->template("paged"); # Set the what using the action name
 }
 
 sub add_comment :Exported {
     my ($self, $r, $photo) = @_;
     $r->template("view");
+    if ($r->params->{content} =~ /\S/) {
     $r->objects->[0]->add_to_comments({
         name => $r->params->{name},
         content => $r->params->{content}
     });
+    }
 }
 
-sub format { 
-    "jpg" # For now
-} 
-
 use Cache::MemoryCache;
-use Image::Info qw(dim image_info);
 use Image::ExifTool;
 my $cache = new Cache::MemoryCache( { 'namespace' => 'MemoriesInfo' });
 
@@ -174,16 +308,20 @@ sub exif_info {
     $info;
 }
 
+my %banned_tags = map { $_ => 1 }
+    qw( CodedCharacterSet ApplicationRecordVersion );
+
 sub _exif_info {
     my $exifTool = new Image::ExifTool;
-    $exifTool->Options(Group0 => ['EXIF', 'MakerNotes', 'Composite']);
-    my $info = $exifTool->ImageInfo(shift->path);
+    $exifTool->Options(Group0 => ['IPTC', 'EXIF', 'XMP', 'MakerNotes', 'Composite']);
+    my $info = $exifTool->ImageInfo(shift->path(0,0,1));
     my $hash = {};
     foreach my $tag ($exifTool->GetFoundTags('Group0')) {
+        next if $banned_tags{$tag};
          my $group = $exifTool->GetGroup($tag);
          my $val = $info->{$tag};
          next if ref $val eq 'SCALAR';
-         next if $val =~ /^[0\s]*$/;
+         next if $val =~ /^[0\s]*$/ or $val =~ /^nil$/;
          $hash->{$group}->{$exifTool->GetDescription($tag)} = $val;
     }
     return $hash;
@@ -213,14 +351,18 @@ sub sized_url { # Use this rather than ->path from TT
 }
 
 sub path { 
-    my ($self, $is_url, $scale) = @_;
+    my ($self, $is_url, $scale, $raw) = @_;
     my $path =
         Memories->config->{$is_url eq "url" ? "data_store_external" : "data_store" };
     if ($scale) { $path .= "$scale/" }
     # Make dir if it doesn't exist, save trouble later
     use File::Path;
-    if ($is_url ne "url") {mkpath($path);}
-    $path .= $self->id.".".$self->format;
+    if ($is_url ne "url" and ! -d $path) {mkpath($path) or die "Couldn't make path $path: $!";}
+    if ($scale or ($is_url ne "url" and !$raw)) { 
+        $path .= $self->id.".jpg";
+    } else {
+        $path .= $self->id.".".($self->format||"jpg");
+    }
     return $path;
 }
 
@@ -253,38 +395,78 @@ sub scale {
     }
 }
 
-use Text::Balanced qw(extract_multiple extract_quotelike);
 sub edit_tags :Exported {
     my ($self, $r) = @_;
     my $photo = $r->objects->[0];
     my %params = %{$r->params};
+    my $exifTool = new Image::ExifTool;
     for (keys %params) { 
         next unless /delete_(\d+)/;
         my $tagging = Memories::Tagging->retrieve($1) or next;
         next unless $tagging->photo->id == $photo->id;
+        $exifTool->SetNewValue(Keywords => $1, DelValue => 1);
         $tagging->delete;
     }
+    $exifTool->WriteInfo($photo->path);
     $photo->add_tags($params{newtags});
     $r->template("view");
 }
 
 sub add_tags {
     my ($photo, $tagstring) = @_;
+    my $exifTool = new Image::ExifTool;
 
-    for my $tag (map { s/^"|"$//g; $_} extract_multiple(lc $tagstring, [ \&extract_quotelike, qr/([^\s]+)/ ], undef,1)) {
-        $photo->add_to_tags({tag => Memories::Tag->find_or_create({name =>$tag}) })
+    for my $tag (Tagtools->separate_tags($tagstring)) {
+        $photo->add_to_tags({tag => Memories::Tag->find_or_create({name =>$tag}) });
+        $exifTool->SetNewValue(Keywords => $tag, AddValue => 1);
     }
+    $exifTool->WriteInfo($photo->path);
+}
+
+# Work out some common properties from a set of potential photo metadata
+# tags
+sub _grovel_metadata {
+    my ($self, @tags) = @_;
+    my %md = map {%$_} values %{$self->exif_info};
+    for (@tags) {
+        if ($md{$_} and $md{$_} =~/[^ 0:]/) { return $md{$_} }
+    }
+    return;
 }
 
 sub shot {
-  my $self = shift;
-  my $exif = $self->exif_info->{EXIF};
-  my ($dt) =
-    grep {$_ and /[^ 0:]/} 
-        ($exif->{ 'Shooting Date/Time' },
-         $exif->{ 'Date/Time Of Digitization' },
-         $exif->{ 'Date/Time Of Last Modification' });
-  if (!$dt) { return $self->uploaded }
-  return Time::Piece->strptime($dt, "%Y:%m:%d %T") || $self->uploaded;
+    my $self = shift;
+    my $dt = $self->_grovel_metadata(
+        'Shooting Date/Time',
+        'Date/Time Of Digitization',
+        'Date/Time Of Last Modification'
+    );
+    if (!$dt) { return $self->uploaded }
+    return Time::Piece->strptime($dt, "%Y:%m:%d %T") || $self->uploaded;
+}
+
+sub description {
+    shift->_grovel_metadata(
+        'Description', 'Image Description', 'Caption-Abstract'
+    );
+}
+
+sub title_exif { shift->_grovel_metadata( 'Headline', 'Title'); }
+sub license { shift->_grovel_metadata( 'Rights Usage Terms', 'Usage Terms' ) }
+sub copyright { shift->_grovel_metadata( 'Rights', 'Copyright', 'Copyright Notice') }
+
+# This one's slightly different since we want everything we can get...
+sub tags_exif {
+    my $self = shift;
+    my %md = map {%$_} values %{$self->exif_info};
+    my %tags = 
+        map { s/\s+/-/g; lc $_ => 1  }
+        map { split /\s*,\s*/, $md{$_}}
+        grep {$md{$_} and $md{$_} =~/[^ 0:]/}
+        (qw(Keywords Subject City State Location Country Province-State), 
+        'Intellectual Genre', 
+        'Country-Primary Location Name'
+        );
+    return keys %tags;
 }
 1;
index cdb8e14169bb9d360f57977a3f92ccb86a994f50..0383db2f8bb60f325e7d29ca43caa45d47cd696a 100644 (file)
@@ -6,10 +6,9 @@ __PACKAGE__->table("system_tag");
 
 Memories::Photo->set_sql(sorted_by_system_tag => q/
 SELECT photo.id as id, title, uploader, uploaded, x, y
-FROM photo, system_tag, system_tagging
+FROM photo, system_tagging
 WHERE system_tagging.photo = photo.id
-    AND system_tagging.tag = system_tag.id
-    AND system_tag.id = ?
+    AND system_tagging.tag = ?
 ORDER BY photo.uploaded DESC
 /
 );
@@ -36,28 +35,4 @@ sub view :Exported {
 package Memories::SystemTagging;
 use base qw(Memories::DBI);
 use Class::DBI::Pager;
-__PACKAGE__->table("system_tagging");
-__PACKAGE__->columns(TEMP => qw/count/);
-__PACKAGE__->columns(Essential => qw/id tag photo/);
-__PACKAGE__->set_sql(summary => qq/
-SELECT  id, system_tag, count(*) AS count
-FROM system_tagging
-GROUP BY system_tag
-ORDER BY count DESC
-LIMIT 50
-    /);
-__PACKAGE__->set_sql(all => qq/
-SELECT  id, tag, count(*) AS count
-FROM system_tagging
-GROUP BY system_tag
-ORDER BY count DESC
-    /);
-Memories::SystemTagging->has_a("photo" => "Memories::Photo");
-Memories::SystemTagging->has_a("tag" => "Memories::SystemTag");
-
-Memories::Photo->has_many(system_tags => ["Memories::SystemTagging" => "system_tag"]);
-Memories::Photo->has_many(system_taggings => "Memories::SystemTagging");
-Memories::SystemTag->has_many(photos => ["Memories::SystemTagging" => "photo"] );
-Memories::SystemTag->has_many(system_taggings => "Memories::SystemTagging");
-
 1;
index 1140185a307e45e07b328e919a463c9d4120c802..3354489cc661d3a15c9738dddb0687a7fecd669a 100644 (file)
@@ -5,10 +5,9 @@ __PACKAGE__->columns(Essential => qw/id name/);
 
 Memories::Photo->set_sql(sorted_by_tag => q/
 SELECT photo.id as id, title, uploader, uploaded, x, y
-FROM photo, tag, tagging
+FROM photo, tagging
 WHERE tagging.photo = photo.id
-    AND tagging.tag = tag.id
-    AND tag.id = ?
+    AND tagging.tag = ?
 ORDER BY photo.uploaded DESC
 /
 );
@@ -28,7 +27,7 @@ sub view :Exported {
         $sth->finish;
         $r->{template_args}{tags} = \@tags;
     } else {
-        if (!$r->objects) {
+        if (!@{$r->objects||[]}) {
             $tag = $tags[0];
         } else {
             $tag = $r->objects->[0];
@@ -75,22 +74,8 @@ sub list_js :Exported {
 
 package Memories::Tagging;
 use base qw(Memories::DBI);
-use Class::DBI::Pager;
-__PACKAGE__->columns(TEMP => qw/count/);
-__PACKAGE__->columns(Essential => qw/id tag photo/);
-__PACKAGE__->set_sql(summary => qq/
-SELECT  id, tag, count(*) AS count
-FROM tagging
-GROUP BY tag
-ORDER BY count DESC
-LIMIT 75
-    /);
-__PACKAGE__->set_sql(all => qq/
-SELECT  id, tag, count(*) AS count
-FROM tagging
-GROUP BY tag
-ORDER BY count DESC
-    /);
+
+
 __PACKAGE__->set_sql(user_summary => qq/
 SELECT  tagging.id id, tag, count(*) AS count
 FROM tagging, photo
@@ -99,12 +84,4 @@ GROUP BY tag
 ORDER BY count DESC
 /);
 
-Memories::Tagging->has_a("photo" => "Memories::Photo");
-Memories::Tagging->has_a("tag" => "Memories::Tag");
-
-Memories::Photo->has_many(tags => ["Memories::Tagging" => "tag"]);
-Memories::Photo->has_many(taggings => "Memories::Tagging");
-Memories::Tag->has_many(photos => ["Memories::Tagging" => "photo"] );
-Memories::Tag->has_many(taggings => "Memories::Tagging");
-
 1;
diff --git a/README b/README
index 5b1fc004dbe95a6c70c508a494b96324b7187832..d2acb4188e817df5f6d56165071811b5592694d4 100644 (file)
--- a/README
+++ b/README
@@ -9,7 +9,7 @@ First, Perl modules. This is the hard part. You will need:
     DBI
     DBD::mysql
     Class::DBI::mysql
-    Image::Info
+    Image::Size
     Image::Imlib2 (requires libimlib2 and libimlib2-dev packages)
     Time::Piece
     Cache::Cache
@@ -17,10 +17,20 @@ First, Perl modules. This is the hard part. You will need:
     Maypole::Plugin::Authentication::UserSessionCookie
     HTML::TagCloud
     XML::RSS
+    Calendar::Simple
+    Text::Balanced
+    Archive::Any
+    Class::DBI::Plugin::Pager
+    Image::Seek
+    Image::ExifTool
+    Apache::Session::File
 
 Apache mod_perl is recommended. Memories can also be run as a CGI
 application, but you're on your own.
 
+If you want to support RAW files, then you'll need to install
+imagemagick and dcraw.
+
 You will also need a MySQL database. Again, in theory other databases
 can be used, but in practice, you're on your own again.
 
index 0fc2529b370505206b4bc99f3aed29aae1c49b09..44c5405ef50a770785eed8b50495fa8fd7eb384e 100644 (file)
@@ -1,9 +1,14 @@
 package Tagtools;
+use warnings;
+use Lingua::EN::Inflect::Number qw(to_PL);
+use URI::Escape;
 use HTML::TagCloud;
 use Carp;
 use Cache::FileCache;
 use Storable qw(freeze); use MIME::Base64;
 use Calendar::Simple;
+use Text::Balanced qw(extract_multiple extract_quotelike);
+
 sub import {
     my $whence = caller;
     my ($class) = @_;
@@ -16,9 +21,9 @@ sub import {
     *{$whence."::do_cached"} = sub {
         my ($self, $codeblock,$arg) = @_;
         my $key = 0+$codeblock; if ($arg) { $key .=":".encode_base64(freeze(\$arg));  }
-        my $c = $cache->get(0+$codeblock); return @$c if $c;
+        my $c = $cache->get($key); return @$c if $c;
         my @stuff = $codeblock->($arg);
-        $cache->set(0+$codeblock, [ @stuff ]);
+        $cache->set($key, [ @stuff ]);
         return @stuff;
     };
     *{$whence."::_tagcloud"} = sub {
@@ -31,8 +36,8 @@ sub import {
         $cloud
     };
     *{$whence."::_calendar"} = sub {
-        my $self = shift;
         my $arg = shift;
+        require Time::Piece;
         my ($y, $m) = split /-/, ($arg || Time::Piece->new->ymd);
         my @m = Calendar::Simple::calendar($m, $y);
         my @month;
@@ -52,11 +57,65 @@ sub import {
         return \@month;
     };
     for my $thing (qw(tagcloud calendar)) {
-        *{$whence."::$thing"} = sub { shift->do_cached($thing, @_) }
+        *{$whence."::$thing"} = sub { shift->do_cached(\&{$whence."::_".$thing}, @_) }
     }
+    *{$whence."::setup_tagging"} = \&Tagtools::_setup_tagging;
+}
+
+sub _setup_tagging {
+    my ($maypole_class, $target_table, $tag_table_name) = @_;
+    my $class_for = sub {
+        $maypole_class->config->model->class_of($maypole_class, shift)
+    };
+    $tag_table_name ||= "tag";
+    my $target = $class_for->($target_table) 
+        || die "Couldn't find a class representing $target_table";
+    my $via_table = $tag_table_name . "ging";
 
+    # Does the tag table exist?
+    # If not create it or at least moan
+    # If so configure it as a new class
+
+    # At this point, the $via_table should now be able to be named as...
+    my $tag_class = $class_for->($tag_table_name);
+    my $via = $tag_class."ging";
+
+    # Set up the class
+    @{$via."::ISA"} = @{$tag_class."::ISA"};
+    $via->table($via_table);
+    $via->columns(TEMP => qw/count/);
+    $via->columns(Essential => "id", $tag_table_name, $target_table);
+    # Set up the auxilliary methods
+    $via->set_sql(summary => qq/
+    SELECT  id, $tag_table_name, count(*) AS count
+    FROM $via_table
+    GROUP BY $tag_table_name
+    ORDER BY count DESC
+    LIMIT 50
+    /);
+    $via->set_sql(all => qq/
+    SELECT  id, $tag_table_name, count(*) AS count
+    FROM $via_table
+    GROUP BY $tag_table_name
+    ORDER BY count DESC
+        /);
+    # Set up the has_many relations
+    $via->has_a($target_table => $target);
+    $via->has_a($tag_table_name => $tag_class);
+    $target->has_many(to_PL($tag_table_name) => [ $via => $tag_table_name ]);
+    $target->has_many(to_PL($via) => $via);
+    $tag_class->has_many(to_PL($target_table) => [ $via => $target_table ]);
+    $tag_class->has_many(to_PL($via_table) => $via);
 }
 
+sub separate_tags {
+    map { s/^"|"$//g; $_} 
+    extract_multiple(
+        lc $_[1], [ 
+            \&extract_quotelike, 
+            qr/([^\s,]+)/ 
+        ], undef,1)
+}
 
 # THIS IS A HACK
 
index 9ebe256a095fc76502cfd50704a4de1cf8169bc6..898f288f28283920e442a19b7ea22f638b8e3edf 100644 (file)
@@ -4,7 +4,11 @@ CREATE TABLE photo (
     uploader integer,
     uploaded datetime,
     x integer,
-    y integer
+    y integer,
+    hit_count integer,
+    rating integer,
+    rated integer,
+    format varchar(10)
 );
 
 CREATE TABLE comment (
index 02292898d97b4243362d38a6048c4b715750a3de..9c2895514fc826d64f653e2eb4fa15b58871ae57 100644 (file)
@@ -8,7 +8,7 @@
     [% FOR album = minilist %]
         <td>
         <a href="[%base%]/album/view/[%album.id%]">[% album %]<br/>
-        <img src="[% album.photos.last.thumb_url |uri%]"></a><br/>
+        <img src="[% album.photos.last.thumb_url %]"></a><br/>
         <span class="info">
             [%album.photos.size %] 
         photo[%-"s" IF album.photos.size != 1 %]
index c71bf824e02e6b12f67eae30cd8a46d37ca55b34..68403a87329446d8fac9dcaf4d7257066d9484d0 100644 (file)
@@ -1,4 +1,10 @@
+<div id="calendar">
 <div align="center" style="border: 1px solid black; background: #eee">
+<script>
+  function redo_self(arg) {
+    new Ajax.Updater("calendar","[%base%]/calendar?view_cal="+arg, { method: "get" })
+  }
+</script>
 [% IF request.action == "view" %]
     [% SET shot = photo.shot %]
 [% END %]
 [% SET calendar = request.calendar(date.ymd) %]
 <table class="calendar">
     <tr>
-        <th> <a href="[%base%]/[%request.path%]?view_cal=[%prev.ymd%]">&laquo; </a></th>
+        <th> <a href="javascript:redo_self('[%prev.ymd%]')">&laquo; </a></th>
         <th colspan="5"> 
             [% date.strftime("%Y-%m") %] 
         </th>
         <th> [% IF next %]
-            <a href="[%base%]/[%request.path%]?view_cal=[%next.ymd%]">&raquo; </a>
+            <a href="javascript:redo_self('[%next.ymd%]')">&raquo; </a>
             [% END %]
         </th>
     </tr>
@@ -41,3 +47,4 @@
 [% END %]
 </table>
 </div>
+</div>
index 3dd50b3f88a28a32199c572ef9c9317e64773d19..2c66aa87c9e462d0ff23715489ec4ec9ba03ca1d 100644 (file)
@@ -7,8 +7,8 @@
     [% SET minilist = objects.splice(0,3) %]
     [% FOR object = minilist %]
         <td>
-        <a href="[%base%]/object/view/[%object.id%]">[% object %]<br/>
-        <img src="[% object.photos.last.thumb_url |uri%]"></a><br/>
+        <a href="[%base%]/[%object.moniker%]/view/[%object.id%]">[% object %]<br/>
+        <img src="[% object.photos.last.thumb_url %]"></a><br/>
         <span class="info">
             [%object.photos.size %] 
         photo[%-"s" IF object.photos.size != 1 %]</span>
index 9bd95766eba306d8e062f70253708713830a4f29..b833e22241ec7d393c4e4ead979fa645aae92f84 100644 (file)
@@ -1,6 +1,17 @@
 [% INCLUDE header %]
-<h1> Welcome to Memories </h1>
-
+[% PROCESS macros %]
+<table class="frontpagetable" width="100%">
+[% FOR how = ["popular", "interesting", "random"] %]
+<tr><th colspan="4">[%how | ucfirst%] photos</th></tr>
+<tr>
+[% FOR pic = request.$how %]
+    <td align="center"> [% minithumb(pic) %] <br> [%pic.title%] </td>
+[% END %]
+</tr>
+[% END %]
+</table>
+   
+   <hr>
 <p>
  Memories is a site where you can upload and share your photos.
 </p>
index 3241c67e4cf62c5aa0368cc721f170a218c78f2e..20c66396bb7b7c4aa45d2e1be2d803b2f256e382 100644 (file)
@@ -3,19 +3,17 @@
     <title> Memories - Photo Sharing [% IF photo %] - [% photo.title; END%]</title>
     <meta name="robots" content="nofollow">
     <link title="Maypole" href="[%base%]/static/memories.css" type="text/css" rel="stylesheet"/>
+    <link href="[%base%]/static/tagcloud.css" type="text/css" rel="stylesheet"/>
+    <link href="[%base%]/static/star_rating.css" type="text/css" rel="stylesheet"/>
     [% IF photos %]
 <link rel="alternate" type="application/rdf+xml" title="RSS"
 href="[%base%]/[%path%]?format=rss" />
 [% END %]
-[% IF request.params.active == "tagedit" %]
-    <script type="text/javascript" src="[%base%]/tag/list_js"></script>
-    <script type="text/javascript" src="[%base%]/static/upload.js"></script>
-</head>
-<body onload="init()">
-[% ELSE %]
+    <script type="text/javascript" src="[%base%]/static/prototype.js"></script>
+<script type="text/javascript" src="[%base%]/tag/list_js"></script>
+<script type="text/javascript" src="[%base%]/static/upload.js"></script>
 </head>
 <body>
-[% END %]
 [% INCLUDE nav %]
 <table width="100%">
     <tr>
index 05824ef88ce77ba83437d3613c0422b23bd796f5..5b6dc3a2b2fc0fb54649f399959c59caa9657254 100644 (file)
@@ -1,6 +1,12 @@
+[% MACRO ministar(rating, photo) BLOCK;
+IF photo.rated > 0; %]
+<span class="inline-rating"><ul class="star-rating small-star">
+<li class="current-rating" style="width:[%rating * 20 %]%">Currently [%rating%]/5 Stars.</li>
+</ul></span>
+[% END; END %]
 [% MACRO minithumb(photo) BLOCK %]
         <a href="[%base%]/photo/view/[%photo.id%]">
-        <img src="[% photo.thumb_url |uri%]" alt="[%photo.title|html%]"/>
+        <img src="[% photo.thumb_url %]" alt="[%photo.title|html%]"/>
         </a>
 [% END; MACRO thumb(photo, album) BLOCK %]
 <table class="thumb">
         <small>[% FOR tag = photo.tags %] <a
         href="[%base%]/tag/view/[%tag%]">[%tag %]</a> [% END %]</small>
         </td></tr>
-    <tr><td> Uploaded by 
+    <tr><td style="background:#fff"> Uploaded by 
         <a href="[%base%]/user/view/[%photo.uploader.id%]">
             [% photo.uploader.name |html%]
-        </a><br/> at [% photo.uploaded %] </td></tr>
+        </a><br/> at [% photo.uploaded %] 
+        [% SET r = photo.approx_rating %]
+        <br/>
+[% IF r > 0 OR photo.hit_count > 0 %]
+    [% photo.hit_count %] view[%IF photo.hit_count>1%]s[% END %]
+    [% IF r > 0 %] <br> [% ministar(r, photo) %] in [%photo.rated %]
+    rating[%IF photo.rated > 1%]s[%END%] [% END %]
+[% END %]
+    </td></tr>
         [% IF request.user %]
     [% IF album == 1 %]
         <tr><td> Add to album: <input type="checkbox" name="add[%photo.id%]" value="[%photo.id%]"> </td></tr>
index f42bb061cf824d99fabec8e4cb6cf283308f4886..ec06bbc761bf30bd3afc06d4794e04aff860d203 100644 (file)
@@ -2,9 +2,9 @@
 <table width="100%"><tr><td align="left">
 <a href="[%base%]"><img src="[%base%]/static/memories.png"/></a>
 </td><td align="right">
-[% FOR photo = request.recent_uploads %]
+[% FOR photo = request.recent %]
         <a href="[%base%]/photo/view/[%photo.id%]">
-        <img src="[% photo.thumb_url |uri%]" alt="[%photo.title|html%]"/>
+        <img src="[% photo.thumb_url%]" alt="[%photo.title|html%]"/>
         </a>
 [% END %]
 </td></tr></table>
index 498e5b6adaf73c6126eb2e9c177216f824c42db5..6b867220d708c5e86bda2e4d41d99ad0f3b5c889 100644 (file)
@@ -1,3 +1,44 @@
+<p style="background:#fff">Rate this photo!
+<span class="inline-rating">
+<ul class="star-rating">
+   [% IF photo.approx_rating %]
+   <li class='current-rating' style="width:[% photo.approx_rating * 25 %]px">Currently [%photo.approx_rating%]/5 Stars.</li>
+   [% END %]
+   <li><a href='javascript:add_rating(1)' title='1 star out of 5' class='one-star'>1</a></li>
+   <li><a href='javascript:add_rating(2)' title='2 stars out of 5' class='two-stars'>2</a></li>
+   <li><a href='javascript:add_rating(3)' title='3 stars out of 5' class='three-stars'>3</a></li>
+   <li><a href='javascript:add_rating(4)' title='4 stars out of 5' class='four-stars'>4</a></li>
+   <li><a href='javascript:add_rating(5)' title='5 stars out of 5' class='five-stars'>5</a></li>
+   </ul>
+</span>
+ [% IF photo.rated > 0%]
+ ([%photo.approx_rating%] out of [%photo.rated%] votes) 
+ [% END %]
+</p>
+<script>
+add_rating = function (rank) {
+    new Ajax.Request("[%base%]/photo/add_rating/[%photo.id%]",
+      { method: "get", parameters: {rating: rank },
+        onSuccess: function () {
+          new Ajax.Updater("content", "[%base%]/photo/comment/[%photo.id%]")
+        }
+      });
+    add_rating = function (rank) { alert("You've already rated this photo")  }
+}
+
+
+function post_comment () {
+    new Ajax.Request("[%base%]/photo/add_comment/[%photo.id%]",
+      { method: "post",
+        parameters: { name: $("commentname").value,
+                      content: $("newcomment").value
+                    },
+        onSuccess: function () {
+          new Ajax.Updater("content", "[%base%]/photo/comment/[%photo.id%]")
+        }
+      });
+}
+</script>
 <div class="comments">
 [% FOR comment = photo.comments %]
     <div class="comment">
     </div>
 [% END %]
     <div class="comment">
-        <form action="[%base%]/photo/add_comment/[%photo.id%]" method="post">
-        Name: <input name="name" /> <hr>
-        <textarea name="content" cols="60" rows="5"/>
-        </textarea>
+        <form action="javascript:post_comment()" method="post">
+        [% IF request.user %]
+        <input id="commentname" name="name" type="hidden" value="[% request.user |html %]"/> 
+        [% ELSE %]
+        Name: <input id="commentname" name="name" /> 
+        <hr>
+        [% END %]
+        <textarea id="newcomment" name="content" cols="60" rows="5"/></textarea>
         <p>
         <input type="submit" name="Comment on this picture" value="Comment on this picture">
         </p>
diff --git a/templates/photo/paged b/templates/photo/paged
new file mode 100644 (file)
index 0000000..549682d
--- /dev/null
@@ -0,0 +1,18 @@
+[% INCLUDE header %]
+[% PROCESS macros %]
+<h1> [% IF title; title | html; ELSE %] Most [% request.action %] photos [% END %]</h1>
+<table class="userlist">
+[% WHILE photos.size > 0 %]
+[% SET triple = photos.splice(0,3) %]
+    <tr>
+        [% FOR photo = triple %]
+        <td> 
+        [% thumb(photo, 1) %]
+        </td>
+        [% END %]
+    </tr>
+[% END %]
+</table>
+
+[% INCLUDE pager %]
+[% INCLUDE footer %]
diff --git a/templates/photo/quarantine b/templates/photo/quarantine
new file mode 100644 (file)
index 0000000..559c911
--- /dev/null
@@ -0,0 +1,35 @@
+[% INCLUDE header %]
+[% PROCESS macros %]
+<h1> Quarantined photos </h1>
+
+<p> The following photos need to be tagged. Sorry to be anal about this,
+but we really need to have photos tagged if we're going to be able to
+find them in the future. You won't be able to do anything, anything at
+all, until we've resolved this. Please, please put some tags in the
+boxes below. (You can make it easier for yourself in the future by
+adding IPTC tags to your photos in your photo management software.)
+</p>
+
+<div id="suggestionlist"> </div>
+<form action="[%base%]/photo/quarantine/" method="post">
+<table class="userlist">
+[% WHILE objects.size > 0 %]
+[% SET triple = objects.splice(0,3) %]
+    <tr>
+        [% FOR photo = triple %]
+        <td> 
+        [% minithumb(photo, 1) %]
+        <br>
+        <input name="tags[%photo.id%]" id="tags[%photo.id%]">
+        <script type="text/javascript">
+        addHandler(document.getElementById("tags[%photo.id%]")) </script>
+        </td>
+        [% END %]
+    </tr>
+[% END %]
+</table>
+<input type="submit" value="Tag it!"> 
+</form>
+
+[% INCLUDE pager %]
+[% INCLUDE footer %]
diff --git a/templates/photo/recent b/templates/photo/recent
deleted file mode 100644 (file)
index 37d1ae3..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-[% INCLUDE header %]
-[% PROCESS macros %]
-<h1> Recent photos </h1>
-<table class="userlist">
-[% WHILE photos.size > 0 %]
-[% SET triple = photos.splice(0,3) %]
-    <tr>
-        [% FOR photo = triple %]
-        <td> 
-        [% thumb(photo, 1) %]
-        </td>
-        [% END %]
-    </tr>
-[% END %]
-</table>
-
-[% INCLUDE pager %]
-[% INCLUDE footer %]
index 2743353618b2c3b84182bcd0937cb958d8472375..1c8b76d6dd57710e691c58c364a0156a33759cfc 100644 (file)
@@ -4,6 +4,6 @@
 
 <p>
 Suggested tags: [% FOR tag = photo.recommended_tags; %]
-<a href="[%base%]/tag/view/[%tag.name|uri%]">[%tag.name%] </a>
+<a href="[%base%]/tag/view/[%tag.name%]">[%tag.name%] </a>
 [% END %]
 </p>
diff --git a/templates/photo/sized b/templates/photo/sized
new file mode 100644 (file)
index 0000000..c0a0f10
--- /dev/null
@@ -0,0 +1,30 @@
+<div class="photoview" id="photoview">
+    [% SET sizes = request.config.sizes %]
+    [% IF request.params.exists("scale") %]
+        [% SET size =  request.params.scale  %]
+    [% ELSIF request.session.scale %]
+        [% SET size =  request.session.scale  %]
+    [% ELSE; SET size =  1; END; %]
+    <img src="[% photo.sized_url(size) %]" id="thephoto">
+    <p> (Original size [%photo.dimensions %]) </p>
+    <p>
+    <script> function change_size(size) { 
+        new Ajax.Updater("photoview", "[%base%]/photo/sized/[%photo.id%]?scale="+size);
+    } </script>
+    Size: 
+    [% SET i = 0; WHILE i < sizes.size %]
+        [% IF photo.is_bigger(sizes.$i);  %]
+            [% IF i == size %]
+                [% sizes.$i %]
+            [% ELSIF sizes.$i == "full" %]
+                <a href="[%photo.path("url")%]">full</a>
+            [% ELSE %]
+                <a href="javascript:change_size([%i%])">[% sizes.$i %]</a>
+            [% END %]
+        [% END; %] 
+    [% SET i = i + 1 %]
+    [% END %]
+    </p>
+    [%# Now put it back in the session %]
+    [% SET request.session.scale = size %]
+</div>
index a34d8bd644ace8ff25f095ac5497cc2d48677ea3..24f60ad25b6c836e988314dbf631e9a2bc909143 100644 (file)
@@ -1,3 +1,4 @@
+
 <div class="messages"><small>
 <b>Tagging advice</b>: Tags should be words, (<i>portrait</i>, <i>henry</i>) or
 phrases surrounded by double quotes. (<i>"tall buildings"</i>) You can
@@ -11,11 +12,10 @@ put any number of tags in the "add tags" box, like this:
 </div>
 
 Delete tags: <form action="[%base%]/photo/edit_tags/[%photo.id%]" method="post">
-<ul>
 [% FOR tagging = photo.taggings %]
-    <li> [% tagging.tag %] <input type="checkbox" name="delete_[%tagging.id%]">
+    <span class="tagedittagdelete">[% tagging.tag %]<input type="checkbox" name="delete_[%tagging.id%]"></span>
 [% END %]
-</ul>
 <p> Add tags: <input type="textbox" name="newtags" id="tags"> <input
 type="submit" value="Tag it!"> </p>
+<script type="text/javascript"> addHandler($("tags")) </script>
 </form>
index ad88ff84974eb0c8c6c483aaadfc8ef39a086bb1..9921f49d465074adf9ed5c8ed661ff8f027d3e82 100644 (file)
@@ -1,26 +1,9 @@
-<html>
-<head>
-    <title> Memories - Photo Sharing </title>
-    <link title="Maypole" href="[%base%]/static/memories.css" type="text/css" rel="stylesheet"/>
-    <script type="text/javascript" src="[%base%]/tag/list_js"></script>
-    <script type="text/javascript" src="[%base%]/static/upload.js"></script>
-</head>
-<body onload="init()">
-[% INCLUDE nav %]
-<table width="100%">
-    <tr>
-    <td valign="top">
-    [% IF messages %]
-    <div class="messages">
-    <ul> [% FOR m = messages %] <li> [%m%] </li> [% END %]
-    </ul></div>
-    [% END %]
-   <div id="main">
+[% INCLUDE header %]
 <h1> Upload a photo </h1>
 <p>
-This is where you can upload your photographs. At the moment, you must
-upload them one at a time; in the near future, you will be able to
-upload a Zip file containing several photos.
+This is where you can upload your photographs. You can upload
+photographs individually or a Zip file (or Unix tar/tar.gz) full of
+pictures.
 </p>
 <p>
 Please note that it may take a while to transfer your photograph to the
@@ -38,6 +21,7 @@ server, so don't press stop or reload after pressing the Upload button.
     <td><input name="tags" id="tags"></td>
     <tr><td> &nbsp; </td> <td>
 <div class="messages"><small>
+<script type="text/javascript"> addHandler($("tags")) </script>
 <b>Tagging advice</b>: Tags should be words, (<i>portrait</i>, <i>henry</i>) or
 phrases surrounded by double quotes. (<i>"tall buildings"</i>) You can
 put any number of tags in the "add tags" box, like this:
index b8a06bf6e72a19c8ed271a5eff59abcff40022fd..0216bdaeacb01c6fa3da2392829796043034c962 100644 (file)
     </form>
 [% END %]
 </td>
-<td align="right" class="tagbrowse">
-[% FOR tag = photo.tags;
-    "<a href=\""; base;"/tag/view/";tag.name | html | uri;"\">";
-    tag;
-    "</a>&nbsp;(";
-    tag.taggings.size; ")";
-    ", " UNLESS tag == photo.tags.last;
-END %]
-</td>
-</tr>
-<tr>
-<td>
+<td align="right">
 [% IF photo.albums %]
 In albums:
 [% FOR album = photo.albums; %]
 <a href="[%base%]/album/view/[%album.id%]">[%album.name%]</a> ([%album.photos.size%])
 [% ";" UNLESS album == photo.albums.last %]
 [% END %]
+<br>
 [% END %]
-
-</td>
-<td align="right" class="tagbrowse"><small>
+<small>
 <i>Photo shot on [% photo.shot.ymd %] </i> <br>
 Uploaded by <a href="[%base%]/user/view/[%photo.uploader.id%]"> [%
 photo.uploader %] </a>
-</small></td>
+</small> <br>
+[% photo.hit_count %] view[%IF photo.hit_count > 1%]s[%END%]
+</td>
 </tr>
 </table>
-<div class="photoview">
-    [% SET sizes = request.config.sizes %]
-    [% IF request.params.exists("scale") %]
-        [% SET size =  request.params.scale  %]
-    [% ELSIF request.session.scale %]
-        [% SET size =  request.session.scale  %]
-    [% ELSE; SET size =  1; END; %]
-    <img src="[% photo.sized_url(size) %]">
-    <p> (Original size [%photo.dimensions %]) </p>
-    <p>
-    Size: 
-    [% SET i = 0; WHILE i < sizes.size %]
-        [% IF photo.is_bigger(sizes.$i);  %]
-            [% IF i == size %]
-                [% sizes.$i %]
-            [% ELSIF sizes.$i == "full" %]
-                <a href="[%photo.path("url")%]">full</a>
-            [% ELSE %]
-                <a href="[%url%]?scale=[% i %]&active=[%tab%]">[% sizes.$i %]</a>
-            [% END %]
-        [% END; %] 
-    [% SET i = i + 1 %]
-    [% END %]
-    </p>
-    [%# Now put it back in the session %]
-    [% SET request.session.scale = size %]
+
+<div class="tagbrowse smaller">
+[% SET tagset = {}; FOR tag = photo.tags;
+    SET name = tag.name; tagset.$name = tag.taggings.size;
+  END;
+  SET sorted = tagset.nsort.reverse;
+%]
+[% SET counter = 0;
+  FOR tag = sorted;
+    counter = counter + 1;
+    IF tagset.$tag > 1; 
+        "<a href=\""; base;"/tag/view/";tag | html | uri;"\">";
+        tag;
+        "</a>&nbsp;(";
+        tagset.$tag; ")";
+    ELSE; 
+        tag; 
+        IF counter > 20; "..."; LAST; END;
+    END;
+    ", " UNLESS tag == sorted.last;
+END %]
+</div>
+
+[% PROCESS sized %]
+
+[% IF photo.description OR photo.copyright OR photo.license %]
+<div class="photodesc"> 
+    [% photo.description | html %] 
+    [% IF photo.copyright %] <div class="photorights"> [% photo.copyright | html %] </div> [% END %]
+    [% IF photo.license %] <div class="photolicense"> [% photo.license | html %] </div> [% END %]
 </div>
+[% END %]
 
+<script>
+function select_tab(name) {
+    document.getElementsByClassName("active").each(function (x) { x.removeClassName("active")});
+    new Ajax.Updater("content","[%base%]/photo/"+name+"/[%photo.id%]",
+      { method: "get", evalScripts: true }
+    )
+    $(name).addClassName("active");
+}
+</script>
 [% MACRO do_tab(tabname, label) BLOCK; %]
 <li>
-    [% IF tab == tabname %]<a class="active">
-    [% ELSE %]<a href="[%url%]?scale=[%size%]&active=[%tabname%]"> 
-    [% END %]
+    <a [% IF tab == tabname %] class="active" [% END %] id="[%tabname%]" href="javascript:select_tab('[%tabname%]')">
     [%label%]</a>
 </li>
 [% END %]
@@ -92,12 +94,7 @@ photo.uploader %] </a>
     [%do_tab("similar", "Similar photos") %]
 </ul>
 <div id="content">
-[%
-IF request.params.active == "tagedit"; INCLUDE tagedit; 
-ELSIF request.params.active == "exif"; INCLUDE exif;
-ELSIF request.params.active == "similar"; INCLUDE similar;
-ELSE;                                  INCLUDE comment; END;
-%]
+[% INCLUDE comment %]
 </div>
 [%
 INCLUDE footer;
index 51be5822a059653579812f2dcdb1ad5331981f19..7e146fe9b250389b7ea243155427a194d5d6e510 100644 (file)
@@ -4,7 +4,7 @@
         <title>Memories photostream</title>
         <link>[%base%]</link>
         <description>Recent photos from [%base %]</description>
-[% FOR photo = request.recent_uploads %]
+[% FOR photo = request.recent %]
     <item>
         <title>[%photo.title|html%]</title>
         <pubDate>[%photo.shot.strftime("%a, %d %b %Y %H:%M:%S %z") %]</pubDate>
@@ -12,7 +12,7 @@
         <description>
 [% FILTER html %]
         <a href="[%base%]/photo/view/[%photo.id%]">
-        <img src="[% photo.thumb_url |uri%]" alt="[%photo.title|html%]"/>
+        <img src="[% photo.thumb_url %]" alt="[%photo.title|html%]"/>
         </a>
 [% END %]
         </description>
index 2816a7343ab4a0254f0efcb337963890e3cd0a99..bf70a13fcdcb283c9be52656f6abf71da38fed1a 100644 (file)
 <td><a href="[%base%]/photo/recent">Recently uploaded</a></td>
 </tr>
 <tr>
+<td><a href="[%base%]/photo/popular">Most popular</a></td>
+</tr>
+<tr>
+<td><a href="[%base%]/photo/loved">Highest rated</a></td>
+</tr>
+<tr>
+<td><a href="[%base%]/photo/interesting">Most interesting</a></td>
+</tr>
+<tr>
 <td><a href="[%base%]/album/list">List albums</a></td>
 </tr>
 
diff --git a/templates/static/alt_star.gif b/templates/static/alt_star.gif
new file mode 100644 (file)
index 0000000..068fa7f
Binary files /dev/null and b/templates/static/alt_star.gif differ
index 45752c9a39a541ec93b80572e5ac5f3c9055c3b7..cca46496889db5216813e754f5358d4b631c9660 100644 (file)
@@ -1,17 +1,17 @@
 BODY { background: #fff; border: 0; margin: 0; padding: 0 }
 a { text-decoration: none; color: #000; }
 p a { font-weight: bold }
-.tagbrowse a { font-weight: bold }
 a:hover { text-decoration: underline }
 a img { border: 0 }
 #nav { background: #994; padding: 2px;  }
 #rhs { background: #fff; height: 100%; width:150px;
 padding: 0 5px 0 5px; border-left: 1px dotted black }
+
+.tagbrowse a { font-weight: bold }
 #suggestionlist { background:#ffd; padding: 10px; margin:10px;
 font-weight: bold; font-family: sans-serif;}
 
-#login { margin-top: 10px;
-font-weight: bold; font-family: sans-serif; background: #eee}
+#login { margin-top: 10px; font-weight: bold; font-family: sans-serif; background: #eee}
 .loginfield { font-weight: bold; font-size: 80%; }
 
 .messages { background: #fff; border: 1px dotted black; margin: 0px 10px
@@ -29,8 +29,7 @@ p { font-family: sans-serif; }
 .thumb {
     width: 100%;
     text-align:center; font-size: 80%; font-family:sans-serif; 
-    border-bottom: 1px solid black; }
-
+}
 .photoview {
     text-align:center;
     }
@@ -86,56 +85,13 @@ p { font-family: sans-serif; }
 .exiftag { font-size: 8pt; background: #ffa; }
 .exifvalue { font-size: 9pt; background: #fff }
 
-.albums { background: #ffc; padding-left: 15px; border: 1px dotted black; }
 .htmltagcloud { text-align:  center; font-family: sans-serif; }
-span.tagcloud0 { font-size: 9px;}
-span.tagcloud1 { font-size: 10px;}
-span.tagcloud2 { font-size: 10px;}
-span.tagcloud3 { font-size: 11px;}
-span.tagcloud4 { font-size: 11px;}
-span.tagcloud5 { font-size: 12px;}
-span.tagcloud6 { font-size: 12px;}
-span.tagcloud7 { font-size: 13px;}
-span.tagcloud8 { font-size: 13px;}
-span.tagcloud9 { font-size: 14px;}
-span.tagcloud10 { font-size: 14px;}
-span.tagcloud11 { font-size: 15px;}
-span.tagcloud12 { font-size: 15px;}
-span.tagcloud13 { font-size: 16px;}
-span.tagcloud14 { font-size: 16px;}
-span.tagcloud15 { font-size: 17px;}
-span.tagcloud16 { font-size: 17px;}
-span.tagcloud17 { font-size: 18px;}
-span.tagcloud18 { font-size: 18px;}
-span.tagcloud19 { font-size: 19px;}
-span.tagcloud20 { font-size: 19px;}
-span.tagcloud21 { font-size: 20px;}
-span.tagcloud22 { font-size: 20px;}
-span.tagcloud23 { font-size: 21px;}
-span.tagcloud24 { font-size: 21px;}
-
-span.tagcloud0 a {text-decoration: none; color: #000;}
-span.tagcloud1 a {text-decoration: none; color: #000;}
-span.tagcloud2 a {text-decoration: none; color: #000;}
-span.tagcloud3 a {text-decoration: none; color: #000;}
-span.tagcloud10 a {text-decoration: none; color: #000;}
-span.tagcloud20 a {text-decoration: none; color: #000;}
-span.tagcloud11 a {text-decoration: none; color: #000;}
-span.tagcloud21 a {text-decoration: none; color: #000;}
-span.tagcloud12 a {text-decoration: none; color: #000;}
-span.tagcloud22 a {text-decoration: none; color: #000;}
-span.tagcloud13 a {text-decoration: none; color: #000;}
-span.tagcloud23 a {text-decoration: none; color: #000;}
-span.tagcloud14 a {text-decoration: none; color: #000;}
-span.tagcloud24 a {text-decoration: none; color: #000;}
-span.tagcloud4 a {text-decoration: none; color: #000;}
-span.tagcloud15 a {text-decoration: none; color: #000;}
-span.tagcloud5 a {text-decoration: none; color: #000;}
-span.tagcloud16 a {text-decoration: none; color: #000;}
-span.tagcloud6 a {text-decoration: none; color: #000;}
-span.tagcloud17 a {text-decoration: none; color: #000;}
-span.tagcloud7 a {text-decoration: none; color: #000;}
-span.tagcloud18 a {text-decoration: none; color: #000;}
-span.tagcloud8 a {text-decoration: none; color: #000;}
-span.tagcloud19 a {text-decoration: none; color: #000;}
-span.tagcloud9 a {text-decoration: none; color: #000;}
+.photodesc { background: #fff; padding: 10px; margin: 5px 15px 5px 15px; text-align: center; }
+.photorights { font-size: 90%; padding: 10px; text-align: right; font-weight: bold; }
+.photolicense { font-size: 80%; padding: 10px; }
+.smaller { font-size: 80%  }
+
+.frontpagetable { padding: 10px;  }
+.frontpagetable th { border: 1px solid black; }
+
+.tagedittagdelete { margin-right: 2em; }
diff --git a/templates/static/prototype.js b/templates/static/prototype.js
new file mode 100644 (file)
index 0000000..5058221
--- /dev/null
@@ -0,0 +1,2515 @@
+/*  Prototype JavaScript framework, version 1.5.0
+ *  (c) 2005-2007 Sam Stephenson
+ *
+ *  Prototype is freely distributable under the terms of an MIT-style license.
+ *  For details, see the Prototype web site: http://prototype.conio.net/
+ *
+/*--------------------------------------------------------------------------*/
+
+var Prototype = {
+  Version: '1.5.0',
+  BrowserFeatures: {
+    XPath: !!document.evaluate
+  },
+
+  ScriptFragment: '(?:<script.*?>)((\n|\r|.)*?)(?:<\/script>)',
+  emptyFunction: function() {},
+  K: function(x) { return x }
+}
+
+var Class = {
+  create: function() {
+    return function() {
+      this.initialize.apply(this, arguments);
+    }
+  }
+}
+
+var Abstract = new Object();
+
+Object.extend = function(destination, source) {
+  for (var property in source) {
+    destination[property] = source[property];
+  }
+  return destination;
+}
+
+Object.extend(Object, {
+  inspect: function(object) {
+    try {
+      if (object === undefined) return 'undefined';
+      if (object === null) return 'null';
+      return object.inspect ? object.inspect() : object.toString();
+    } catch (e) {
+      if (e instanceof RangeError) return '...';
+      throw e;
+    }
+  },
+
+  keys: function(object) {
+    var keys = [];
+    for (var property in object)
+      keys.push(property);
+    return keys;
+  },
+
+  values: function(object) {
+    var values = [];
+    for (var property in object)
+      values.push(object[property]);
+    return values;
+  },
+
+  clone: function(object) {
+    return Object.extend({}, object);
+  }
+});
+
+Function.prototype.bind = function() {
+  var __method = this, args = $A(arguments), object = args.shift();
+  return function() {
+    return __method.apply(object, args.concat($A(arguments)));
+  }
+}
+
+Function.prototype.bindAsEventListener = function(object) {
+  var __method = this, args = $A(arguments), object = args.shift();
+  return function(event) {
+    return __method.apply(object, [( event || window.event)].concat(args).concat($A(arguments)));
+  }
+}
+
+Object.extend(Number.prototype, {
+  toColorPart: function() {
+    var digits = this.toString(16);
+    if (this < 16) return '0' + digits;
+    return digits;
+  },
+
+  succ: function() {
+    return this + 1;
+  },
+
+  times: function(iterator) {
+    $R(0, this, true).each(iterator);
+    return this;
+  }
+});
+
+var Try = {
+  these: function() {
+    var returnValue;
+
+    for (var i = 0, length = arguments.length; i < length; i++) {
+      var lambda = arguments[i];
+      try {
+        returnValue = lambda();
+        break;
+      } catch (e) {}
+    }
+
+    return returnValue;
+  }
+}
+
+/*--------------------------------------------------------------------------*/
+
+var PeriodicalExecuter = Class.create();
+PeriodicalExecuter.prototype = {
+  initialize: function(callback, frequency) {
+    this.callback = callback;
+    this.frequency = frequency;
+    this.currentlyExecuting = false;
+
+    this.registerCallback();
+  },
+
+  registerCallback: function() {
+    this.timer = setInterval(this.onTimerEvent.bind(this), this.frequency * 1000);
+  },
+
+  stop: function() {
+    if (!this.timer) return;
+    clearInterval(this.timer);
+    this.timer = null;
+  },
+
+  onTimerEvent: function() {
+    if (!this.currentlyExecuting) {
+      try {
+        this.currentlyExecuting = true;
+        this.callback(this);
+      } finally {
+        this.currentlyExecuting = false;
+      }
+    }
+  }
+}
+String.interpret = function(value){
+  return value == null ? '' : String(value);
+}
+
+Object.extend(String.prototype, {
+  gsub: function(pattern, replacement) {
+    var result = '', source = this, match;
+    replacement = arguments.callee.prepareReplacement(replacement);
+
+    while (source.length > 0) {
+      if (match = source.match(pattern)) {
+        result += source.slice(0, match.index);
+        result += String.interpret(replacement(match));
+        source  = source.slice(match.index + match[0].length);
+      } else {
+        result += source, source = '';
+      }
+    }
+    return result;
+  },
+
+  sub: function(pattern, replacement, count) {
+    replacement = this.gsub.prepareReplacement(replacement);
+    count = count === undefined ? 1 : count;
+
+    return this.gsub(pattern, function(match) {
+      if (--count < 0) return match[0];
+      return replacement(match);
+    });
+  },
+
+  scan: function(pattern, iterator) {
+    this.gsub(pattern, iterator);
+    return this;
+  },
+
+  truncate: function(length, truncation) {
+    length = length || 30;
+    truncation = truncation === undefined ? '...' : truncation;
+    return this.length > length ?
+      this.slice(0, length - truncation.length) + truncation : this;
+  },
+
+  strip: function() {
+    return this.replace(/^\s+/, '').replace(/\s+$/, '');
+  },
+
+  stripTags: function() {
+    return this.replace(/<\/?[^>]+>/gi, '');
+  },
+
+  stripScripts: function() {
+    return this.replace(new RegExp(Prototype.ScriptFragment, 'img'), '');
+  },
+
+  extractScripts: function() {
+    var matchAll = new RegExp(Prototype.ScriptFragment, 'img');
+    var matchOne = new RegExp(Prototype.ScriptFragment, 'im');
+    return (this.match(matchAll) || []).map(function(scriptTag) {
+      return (scriptTag.match(matchOne) || ['', ''])[1];
+    });
+  },
+
+  evalScripts: function() {
+    return this.extractScripts().map(function(script) { return eval(script) });
+  },
+
+  escapeHTML: function() {
+    var div = document.createElement('div');
+    var text = document.createTextNode(this);
+    div.appendChild(text);
+    return div.innerHTML;
+  },
+
+  unescapeHTML: function() {
+    var div = document.createElement('div');
+    div.innerHTML = this.stripTags();
+    return div.childNodes[0] ? (div.childNodes.length > 1 ?
+      $A(div.childNodes).inject('',function(memo,node){ return memo+node.nodeValue }) :
+      div.childNodes[0].nodeValue) : '';
+  },
+
+  toQueryParams: function(separator) {
+    var match = this.strip().match(/([^?#]*)(#.*)?$/);
+    if (!match) return {};
+
+    return match[1].split(separator || '&').inject({}, function(hash, pair) {
+      if ((pair = pair.split('='))[0]) {
+        var name = decodeURIComponent(pair[0]);
+        var value = pair[1] ? decodeURIComponent(pair[1]) : undefined;
+
+        if (hash[name] !== undefined) {
+          if (hash[name].constructor != Array)
+            hash[name] = [hash[name]];
+          if (value) hash[name].push(value);
+        }
+        else hash[name] = value;
+      }
+      return hash;
+    });
+  },
+
+  toArray: function() {
+    return this.split('');
+  },
+
+  succ: function() {
+    return this.slice(0, this.length - 1) +
+      String.fromCharCode(this.charCodeAt(this.length - 1) + 1);
+  },
+
+  camelize: function() {
+    var parts = this.split('-'), len = parts.length;
+    if (len == 1) return parts[0];
+
+    var camelized = this.charAt(0) == '-'
+      ? parts[0].charAt(0).toUpperCase() + parts[0].substring(1)
+      : parts[0];
+
+    for (var i = 1; i < len; i++)
+      camelized += parts[i].charAt(0).toUpperCase() + parts[i].substring(1);
+
+    return camelized;
+  },
+
+  capitalize: function(){
+    return this.charAt(0).toUpperCase() + this.substring(1).toLowerCase();
+  },
+
+  underscore: function() {
+    return this.gsub(/::/, '/').gsub(/([A-Z]+)([A-Z][a-z])/,'#{1}_#{2}').gsub(/([a-z\d])([A-Z])/,'#{1}_#{2}').gsub(/-/,'_').toLowerCase();
+  },
+
+  dasherize: function() {
+    return this.gsub(/_/,'-');
+  },
+
+  inspect: function(useDoubleQuotes) {
+    var escapedString = this.replace(/\\/g, '\\\\');
+    if (useDoubleQuotes)
+      return '"' + escapedString.replace(/"/g, '\\"') + '"';
+    else
+      return "'" + escapedString.replace(/'/g, '\\\'') + "'";
+  }
+});
+
+String.prototype.gsub.prepareReplacement = function(replacement) {
+  if (typeof replacement == 'function') return replacement;
+  var template = new Template(replacement);
+  return function(match) { return template.evaluate(match) };
+}
+
+String.prototype.parseQuery = String.prototype.toQueryParams;
+
+var Template = Class.create();
+Template.Pattern = /(^|.|\r|\n)(#\{(.*?)\})/;
+Template.prototype = {
+  initialize: function(template, pattern) {
+    this.template = template.toString();
+    this.pattern  = pattern || Template.Pattern;
+  },
+
+  evaluate: function(object) {
+    return this.template.gsub(this.pattern, function(match) {
+      var before = match[1];
+      if (before == '\\') return match[2];
+      return before + String.interpret(object[match[3]]);
+    });
+  }
+}
+
+var $break    = new Object();
+var $continue = new Object();
+
+var Enumerable = {
+  each: function(iterator) {
+    var index = 0;
+    try {
+      this._each(function(value) {
+        try {
+          iterator(value, index++);
+        } catch (e) {
+          if (e != $continue) throw e;
+        }
+      });
+    } catch (e) {
+      if (e != $break) throw e;
+    }
+    return this;
+  },
+
+  eachSlice: function(number, iterator) {
+    var index = -number, slices = [], array = this.toArray();
+    while ((index += number) < array.length)
+      slices.push(array.slice(index, index+number));
+    return slices.map(iterator);
+  },
+
+  all: function(iterator) {
+    var result = true;
+    this.each(function(value, index) {
+      result = result && !!(iterator || Prototype.K)(value, index);
+      if (!result) throw $break;
+    });
+    return result;
+  },
+
+  any: function(iterator) {
+    var result = false;
+    this.each(function(value, index) {
+      if (result = !!(iterator || Prototype.K)(value, index))
+        throw $break;
+    });
+    return result;
+  },
+
+  collect: function(iterator) {
+    var results = [];
+    this.each(function(value, index) {
+      results.push((iterator || Prototype.K)(value, index));
+    });
+    return results;
+  },
+
+  detect: function(iterator) {
+    var result;
+    this.each(function(value, index) {
+      if (iterator(value, index)) {
+        result = value;
+        throw $break;
+      }
+    });
+    return result;
+  },
+
+  findAll: function(iterator) {
+    var results = [];
+    this.each(function(value, index) {
+      if (iterator(value, index))
+        results.push(value);
+    });
+    return results;
+  },
+
+  grep: function(pattern, iterator) {
+    var results = [];
+    this.each(function(value, index) {
+      var stringValue = value.toString();
+      if (stringValue.match(pattern))
+        results.push((iterator || Prototype.K)(value, index));
+    })
+    return results;
+  },
+
+  include: function(object) {
+    var found = false;
+    this.each(function(value) {
+      if (value == object) {
+        found = true;
+        throw $break;
+      }
+    });
+    return found;
+  },
+
+  inGroupsOf: function(number, fillWith) {
+    fillWith = fillWith === undefined ? null : fillWith;
+    return this.eachSlice(number, function(slice) {
+      while(slice.length < number) slice.push(fillWith);
+      return slice;
+    });
+  },
+
+  inject: function(memo, iterator) {
+    this.each(function(value, index) {
+      memo = iterator(memo, value, index);
+    });
+    return memo;
+  },
+
+  invoke: function(method) {
+    var args = $A(arguments).slice(1);
+    return this.map(function(value) {
+      return value[method].apply(value, args);
+    });
+  },
+
+  max: function(iterator) {
+    var result;
+    this.each(function(value, index) {
+      value = (iterator || Prototype.K)(value, index);
+      if (result == undefined || value >= result)
+        result = value;
+    });
+    return result;
+  },
+
+  min: function(iterator) {
+    var result;
+    this.each(function(value, index) {
+      value = (iterator || Prototype.K)(value, index);
+      if (result == undefined || value < result)
+        result = value;
+    });
+    return result;
+  },
+
+  partition: function(iterator) {
+    var trues = [], falses = [];
+    this.each(function(value, index) {
+      ((iterator || Prototype.K)(value, index) ?
+        trues : falses).push(value);
+    });
+    return [trues, falses];
+  },
+
+  pluck: function(property) {
+    var results = [];
+    this.each(function(value, index) {
+      results.push(value[property]);
+    });
+    return results;
+  },
+
+  reject: function(iterator) {
+    var results = [];
+    this.each(function(value, index) {
+      if (!iterator(value, index))
+        results.push(value);
+    });
+    return results;
+  },
+
+  sortBy: function(iterator) {
+    return this.map(function(value, index) {
+      return {value: value, criteria: iterator(value, index)};
+    }).sort(function(left, right) {
+      var a = left.criteria, b = right.criteria;
+      return a < b ? -1 : a > b ? 1 : 0;
+    }).pluck('value');
+  },
+
+  toArray: function() {
+    return this.map();
+  },
+
+  zip: function() {
+    var iterator = Prototype.K, args = $A(arguments);
+    if (typeof args.last() == 'function')
+      iterator = args.pop();
+
+    var collections = [this].concat(args).map($A);
+    return this.map(function(value, index) {
+      return iterator(collections.pluck(index));
+    });
+  },
+
+  size: function() {
+    return this.toArray().length;
+  },
+
+  inspect: function() {
+    return '#<Enumerable:' + this.toArray().inspect() + '>';
+  }
+}
+
+Object.extend(Enumerable, {
+  map:     Enumerable.collect,
+  find:    Enumerable.detect,
+  select:  Enumerable.findAll,
+  member:  Enumerable.include,
+  entries: Enumerable.toArray
+});
+var $A = Array.from = function(iterable) {
+  if (!iterable) return [];
+  if (iterable.toArray) {
+    return iterable.toArray();
+  } else {
+    var results = [];
+    for (var i = 0, length = iterable.length; i < length; i++)
+      results.push(iterable[i]);
+    return results;
+  }
+}
+
+Object.extend(Array.prototype, Enumerable);
+
+if (!Array.prototype._reverse)
+  Array.prototype._reverse = Array.prototype.reverse;
+
+Object.extend(Array.prototype, {
+  _each: function(iterator) {
+    for (var i = 0, length = this.length; i < length; i++)
+      iterator(this[i]);
+  },
+
+  clear: function() {
+    this.length = 0;
+    return this;
+  },
+
+  first: function() {
+    return this[0];
+  },
+
+  last: function() {
+    return this[this.length - 1];
+  },
+
+  compact: function() {
+    return this.select(function(value) {
+      return value != null;
+    });
+  },
+
+  flatten: function() {
+    return this.inject([], function(array, value) {
+      return array.concat(value && value.constructor == Array ?
+        value.flatten() : [value]);
+    });
+  },
+
+  without: function() {
+    var values = $A(arguments);
+    return this.select(function(value) {
+      return !values.include(value);
+    });
+  },
+
+  indexOf: function(object) {
+    for (var i = 0, length = this.length; i < length; i++)
+      if (this[i] == object) return i;
+    return -1;
+  },
+
+  reverse: function(inline) {
+    return (inline !== false ? this : this.toArray())._reverse();
+  },
+
+  reduce: function() {
+    return this.length > 1 ? this : this[0];
+  },
+
+  uniq: function() {
+    return this.inject([], function(array, value) {
+      return array.include(value) ? array : array.concat([value]);
+    });
+  },
+
+  clone: function() {
+    return [].concat(this);
+  },
+
+  size: function() {
+    return this.length;
+  },
+
+  inspect: function() {
+    return '[' + this.map(Object.inspect).join(', ') + ']';
+  }
+});
+
+Array.prototype.toArray = Array.prototype.clone;
+
+function $w(string){
+  string = string.strip();
+  return string ? string.split(/\s+/) : [];
+}
+
+if(window.opera){
+  Array.prototype.concat = function(){
+    var array = [];
+    for(var i = 0, length = this.length; i < length; i++) array.push(this[i]);
+    for(var i = 0, length = arguments.length; i < length; i++) {
+      if(arguments[i].constructor == Array) {
+        for(var j = 0, arrayLength = arguments[i].length; j < arrayLength; j++)
+          array.push(arguments[i][j]);
+      } else {
+        array.push(arguments[i]);
+      }
+    }
+    return array;
+  }
+}
+var Hash = function(obj) {
+  Object.extend(this, obj || {});
+};
+
+Object.extend(Hash, {
+  toQueryString: function(obj) {
+    var parts = [];
+
+         this.prototype._each.call(obj, function(pair) {
+      if (!pair.key) return;
+
+      if (pair.value && pair.value.constructor == Array) {
+        var values = pair.value.compact();
+        if (values.length < 2) pair.value = values.reduce();
+        else {
+               key = encodeURIComponent(pair.key);
+          values.each(function(value) {
+            value = value != undefined ? encodeURIComponent(value) : '';
+            parts.push(key + '=' + encodeURIComponent(value));
+          });
+          return;
+        }
+      }
+      if (pair.value == undefined) pair[1] = '';
+      parts.push(pair.map(encodeURIComponent).join('='));
+         });
+
+    return parts.join('&');
+  }
+});
+
+Object.extend(Hash.prototype, Enumerable);
+Object.extend(Hash.prototype, {
+  _each: function(iterator) {
+    for (var key in this) {
+      var value = this[key];
+      if (value && value == Hash.prototype[key]) continue;
+
+      var pair = [key, value];
+      pair.key = key;
+      pair.value = value;
+      iterator(pair);
+    }
+  },
+
+  keys: function() {
+    return this.pluck('key');
+  },
+
+  values: function() {
+    return this.pluck('value');
+  },
+
+  merge: function(hash) {
+    return $H(hash).inject(this, function(mergedHash, pair) {
+      mergedHash[pair.key] = pair.value;
+      return mergedHash;
+    });
+  },
+
+  remove: function() {
+    var result;
+    for(var i = 0, length = arguments.length; i < length; i++) {
+      var value = this[arguments[i]];
+      if (value !== undefined){
+        if (result === undefined) result = value;
+        else {
+          if (result.constructor != Array) result = [result];
+          result.push(value)
+        }
+      }
+      delete this[arguments[i]];
+    }
+    return result;
+  },
+
+  toQueryString: function() {
+    return Hash.toQueryString(this);
+  },
+
+  inspect: function() {
+    return '#<Hash:{' + this.map(function(pair) {
+      return pair.map(Object.inspect).join(': ');
+    }).join(', ') + '}>';
+  }
+});
+
+function $H(object) {
+  if (object && object.constructor == Hash) return object;
+  return new Hash(object);
+};
+ObjectRange = Class.create();
+Object.extend(ObjectRange.prototype, Enumerable);
+Object.extend(ObjectRange.prototype, {
+  initialize: function(start, end, exclusive) {
+    this.start = start;
+    this.end = end;
+    this.exclusive = exclusive;
+  },
+
+  _each: function(iterator) {
+    var value = this.start;
+    while (this.include(value)) {
+      iterator(value);
+      value = value.succ();
+    }
+  },
+
+  include: function(value) {
+    if (value < this.start)
+      return false;
+    if (this.exclusive)
+      return value < this.end;
+    return value <= this.end;
+  }
+});
+
+var $R = function(start, end, exclusive) {
+  return new ObjectRange(start, end, exclusive);
+}
+
+var Ajax = {
+  getTransport: function() {
+    return Try.these(
+      function() {return new XMLHttpRequest()},
+      function() {return new ActiveXObject('Msxml2.XMLHTTP')},
+      function() {return new ActiveXObject('Microsoft.XMLHTTP')}
+    ) || false;
+  },
+
+  activeRequestCount: 0
+}
+
+Ajax.Responders = {
+  responders: [],
+
+  _each: function(iterator) {
+    this.responders._each(iterator);
+  },
+
+  register: function(responder) {
+    if (!this.include(responder))
+      this.responders.push(responder);
+  },
+
+  unregister: function(responder) {
+    this.responders = this.responders.without(responder);
+  },
+
+  dispatch: function(callback, request, transport, json) {
+    this.each(function(responder) {
+      if (typeof responder[callback] == 'function') {
+        try {
+          responder[callback].apply(responder, [request, transport, json]);
+        } catch (e) {}
+      }
+    });
+  }
+};
+
+Object.extend(Ajax.Responders, Enumerable);
+
+Ajax.Responders.register({
+  onCreate: function() {
+    Ajax.activeRequestCount++;
+  },
+  onComplete: function() {
+    Ajax.activeRequestCount--;
+  }
+});
+
+Ajax.Base = function() {};
+Ajax.Base.prototype = {
+  setOptions: function(options) {
+    this.options = {
+      method:       'post',
+      asynchronous: true,
+      contentType:  'application/x-www-form-urlencoded',
+      encoding:     'UTF-8',
+      parameters:   ''
+    }
+    Object.extend(this.options, options || {});
+
+    this.options.method = this.options.method.toLowerCase();
+    if (typeof this.options.parameters == 'string')
+      this.options.parameters = this.options.parameters.toQueryParams();
+  }
+}
+
+Ajax.Request = Class.create();
+Ajax.Request.Events =
+  ['Uninitialized', 'Loading', 'Loaded', 'Interactive', 'Complete'];
+
+Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
+  _complete: false,
+
+  initialize: function(url, options) {
+    this.transport = Ajax.getTransport();
+    this.setOptions(options);
+    this.request(url);
+  },
+
+  request: function(url) {
+    this.url = url;
+    this.method = this.options.method;
+    var params = this.options.parameters;
+
+    if (!['get', 'post'].include(this.method)) {
+      // simulate other verbs over post
+      params['_method'] = this.method;
+      this.method = 'post';
+    }
+
+    params = Hash.toQueryString(params);
+    if (params && /Konqueror|Safari|KHTML/.test(navigator.userAgent)) params += '&_='
+
+    // when GET, append parameters to URL
+    if (this.method == 'get' && params)
+      this.url += (this.url.indexOf('?') > -1 ? '&' : '?') + params;
+
+    try {
+      Ajax.Responders.dispatch('onCreate', this, this.transport);
+
+      this.transport.open(this.method.toUpperCase(), this.url,
+        this.options.asynchronous);
+
+      if (this.options.asynchronous)
+        setTimeout(function() { this.respondToReadyState(1) }.bind(this), 10);
+
+      this.transport.onreadystatechange = this.onStateChange.bind(this);
+      this.setRequestHeaders();
+
+      var body = this.method == 'post' ? (this.options.postBody || params) : null;
+
+      this.transport.send(body);
+
+      /* Force Firefox to handle ready state 4 for synchronous requests */
+      if (!this.options.asynchronous && this.transport.overrideMimeType)
+        this.onStateChange();
+
+    }
+    catch (e) {
+      this.dispatchException(e);
+    }
+  },
+
+  onStateChange: function() {
+    var readyState = this.transport.readyState;
+    if (readyState > 1 && !((readyState == 4) && this._complete))
+      this.respondToReadyState(this.transport.readyState);
+  },
+
+  setRequestHeaders: function() {
+    var headers = {
+      'X-Requested-With': 'XMLHttpRequest',
+      'X-Prototype-Version': Prototype.Version,
+      'Accept': 'text/javascript, text/html, application/xml, text/xml, */*'
+    };
+
+    if (this.method == 'post') {
+      headers['Content-type'] = this.options.contentType +
+        (this.options.encoding ? '; charset=' + this.options.encoding : '');
+
+      /* Force "Connection: close" for older Mozilla browsers to work
+       * around a bug where XMLHttpRequest sends an incorrect
+       * Content-length header. See Mozilla Bugzilla #246651.
+       */
+      if (this.transport.overrideMimeType &&
+          (navigator.userAgent.match(/Gecko\/(\d{4})/) || [0,2005])[1] < 2005)
+            headers['Connection'] = 'close';
+    }
+
+    // user-defined headers
+    if (typeof this.options.requestHeaders == 'object') {
+      var extras = this.options.requestHeaders;
+
+      if (typeof extras.push == 'function')
+        for (var i = 0, length = extras.length; i < length; i += 2)
+          headers[extras[i]] = extras[i+1];
+      else
+        $H(extras).each(function(pair) { headers[pair.key] = pair.value });
+    }
+
+    for (var name in headers)
+      this.transport.setRequestHeader(name, headers[name]);
+  },
+
+  success: function() {
+    return !this.transport.status
+        || (this.transport.status >= 200 && this.transport.status < 300);
+  },
+
+  respondToReadyState: function(readyState) {
+    var state = Ajax.Request.Events[readyState];
+    var transport = this.transport, json = this.evalJSON();
+
+    if (state == 'Complete') {
+      try {
+        this._complete = true;
+        (this.options['on' + this.transport.status]
+         || this.options['on' + (this.success() ? 'Success' : 'Failure')]
+         || Prototype.emptyFunction)(transport, json);
+      } catch (e) {
+        this.dispatchException(e);
+      }
+
+      if ((this.getHeader('Content-type') || 'text/javascript').strip().
+        match(/^(text|application)\/(x-)?(java|ecma)script(;.*)?$/i))
+          this.evalResponse();
+    }
+
+    try {
+      (this.options['on' + state] || Prototype.emptyFunction)(transport, json);
+      Ajax.Responders.dispatch('on' + state, this, transport, json);
+    } catch (e) {
+      this.dispatchException(e);
+    }
+
+    if (state == 'Complete') {
+      // avoid memory leak in MSIE: clean up
+      this.transport.onreadystatechange = Prototype.emptyFunction;
+    }
+  },
+
+  getHeader: function(name) {
+    try {
+      return this.transport.getResponseHeader(name);
+    } catch (e) { return null }
+  },
+
+  evalJSON: function() {
+    try {
+      var json = this.getHeader('X-JSON');
+      return json ? eval('(' + json + ')') : null;
+    } catch (e) { return null }
+  },
+
+  evalResponse: function() {
+    try {
+      return eval(this.transport.responseText);
+    } catch (e) {
+      this.dispatchException(e);
+    }
+  },
+
+  dispatchException: function(exception) {
+    (this.options.onException || Prototype.emptyFunction)(this, exception);
+    Ajax.Responders.dispatch('onException', this, exception);
+  }
+});
+
+Ajax.Updater = Class.create();
+
+Object.extend(Object.extend(Ajax.Updater.prototype, Ajax.Request.prototype), {
+  initialize: function(container, url, options) {
+    this.container = {
+      success: (container.success || container),
+      failure: (container.failure || (container.success ? null : container))
+    }
+
+    this.transport = Ajax.getTransport();
+    this.setOptions(options);
+
+    var onComplete = this.options.onComplete || Prototype.emptyFunction;
+    this.options.onComplete = (function(transport, param) {
+      this.updateContent();
+      onComplete(transport, param);
+    }).bind(this);
+
+    this.request(url);
+  },
+
+  updateContent: function() {
+    var receiver = this.container[this.success() ? 'success' : 'failure'];
+    var response = this.transport.responseText;
+
+    if (!this.options.evalScripts) response = response.stripScripts();
+
+    if (receiver = $(receiver)) {
+      if (this.options.insertion)
+        new this.options.insertion(receiver, response);
+      else
+        receiver.update(response);
+    }
+
+    if (this.success()) {
+      if (this.onComplete)
+        setTimeout(this.onComplete.bind(this), 10);
+    }
+  }
+});
+
+Ajax.PeriodicalUpdater = Class.create();
+Ajax.PeriodicalUpdater.prototype = Object.extend(new Ajax.Base(), {
+  initialize: function(container, url, options) {
+    this.setOptions(options);
+    this.onComplete = this.options.onComplete;
+
+    this.frequency = (this.options.frequency || 2);
+    this.decay = (this.options.decay || 1);
+
+    this.updater = {};
+    this.container = container;
+    this.url = url;
+
+    this.start();
+  },
+
+  start: function() {
+    this.options.onComplete = this.updateComplete.bind(this);
+    this.onTimerEvent();
+  },
+
+  stop: function() {
+    this.updater.options.onComplete = undefined;
+    clearTimeout(this.timer);
+    (this.onComplete || Prototype.emptyFunction).apply(this, arguments);
+  },
+
+  updateComplete: function(request) {
+    if (this.options.decay) {
+      this.decay = (request.responseText == this.lastText ?
+        this.decay * this.options.decay : 1);
+
+      this.lastText = request.responseText;
+    }
+    this.timer = setTimeout(this.onTimerEvent.bind(this),
+      this.decay * this.frequency * 1000);
+  },
+
+  onTimerEvent: function() {
+    this.updater = new Ajax.Updater(this.container, this.url, this.options);
+  }
+});
+function $(element) {
+  if (arguments.length > 1) {
+    for (var i = 0, elements = [], length = arguments.length; i < length; i++)
+      elements.push($(arguments[i]));
+    return elements;
+  }
+  if (typeof element == 'string')
+    element = document.getElementById(element);
+  return Element.extend(element);
+}
+
+if (Prototype.BrowserFeatures.XPath) {
+  document._getElementsByXPath = function(expression, parentElement) {
+    var results = [];
+    var query = document.evaluate(expression, $(parentElement) || document,
+      null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+    for (var i = 0, length = query.snapshotLength; i < length; i++)
+      results.push(query.snapshotItem(i));
+    return results;
+  };
+}
+
+document.getElementsByClassName = function(className, parentElement) {
+  if (Prototype.BrowserFeatures.XPath) {
+    var q = ".//*[contains(concat(' ', @class, ' '), ' " + className + " ')]";
+    return document._getElementsByXPath(q, parentElement);
+  } else {
+    var children = ($(parentElement) || document.body).getElementsByTagName('*');
+    var elements = [], child;
+    for (var i = 0, length = children.length; i < length; i++) {
+      child = children[i];
+      if (Element.hasClassName(child, className))
+        elements.push(Element.extend(child));
+    }
+    return elements;
+  }
+};
+
+/*--------------------------------------------------------------------------*/
+
+if (!window.Element)
+  var Element = new Object();
+
+Element.extend = function(element) {
+  if (!element || _nativeExtensions || element.nodeType == 3) return element;
+
+  if (!element._extended && element.tagName && element != window) {
+    var methods = Object.clone(Element.Methods), cache = Element.extend.cache;
+
+    if (element.tagName == 'FORM')
+      Object.extend(methods, Form.Methods);
+    if (['INPUT', 'TEXTAREA', 'SELECT'].include(element.tagName))
+      Object.extend(methods, Form.Element.Methods);
+
+    Object.extend(methods, Element.Methods.Simulated);
+
+    for (var property in methods) {
+      var value = methods[property];
+      if (typeof value == 'function' && !(property in element))
+        element[property] = cache.findOrStore(value);
+    }
+  }
+
+  element._extended = true;
+  return element;
+};
+
+Element.extend.cache = {
+  findOrStore: function(value) {
+    return this[value] = this[value] || function() {
+      return value.apply(null, [this].concat($A(arguments)));
+    }
+  }
+};
+
+Element.Methods = {
+  visible: function(element) {
+    return $(element).style.display != 'none';
+  },
+
+  toggle: function(element) {
+    element = $(element);
+    Element[Element.visible(element) ? 'hide' : 'show'](element);
+    return element;
+  },
+
+  hide: function(element) {
+    $(element).style.display = 'none';
+    return element;
+  },
+
+  show: function(element) {
+    $(element).style.display = '';
+    return element;
+  },
+
+  remove: function(element) {
+    element = $(element);
+    element.parentNode.removeChild(element);
+    return element;
+  },
+
+  update: function(element, html) {
+    html = typeof html == 'undefined' ? '' : html.toString();
+    $(element).innerHTML = html.stripScripts();
+    setTimeout(function() {html.evalScripts()}, 10);
+    return element;
+  },
+
+  replace: function(element, html) {
+    element = $(element);
+    html = typeof html == 'undefined' ? '' : html.toString();
+    if (element.outerHTML) {
+      element.outerHTML = html.stripScripts();
+    } else {
+      var range = element.ownerDocument.createRange();
+      range.selectNodeContents(element);
+      element.parentNode.replaceChild(
+        range.createContextualFragment(html.stripScripts()), element);
+    }
+    setTimeout(function() {html.evalScripts()}, 10);
+    return element;
+  },
+
+  inspect: function(element) {
+    element = $(element);
+    var result = '<' + element.tagName.toLowerCase();
+    $H({'id': 'id', 'className': 'class'}).each(function(pair) {
+      var property = pair.first(), attribute = pair.last();
+      var value = (element[property] || '').toString();
+      if (value) result += ' ' + attribute + '=' + value.inspect(true);
+    });
+    return result + '>';
+  },
+
+  recursivelyCollect: function(element, property) {
+    element = $(element);
+    var elements = [];
+    while (element = element[property])
+      if (element.nodeType == 1)
+        elements.push(Element.extend(element));
+    return elements;
+  },
+
+  ancestors: function(element) {
+    return $(element).recursivelyCollect('parentNode');
+  },
+
+  descendants: function(element) {
+    return $A($(element).getElementsByTagName('*'));
+  },
+
+  immediateDescendants: function(element) {
+    if (!(element = $(element).firstChild)) return [];
+    while (element && element.nodeType != 1) element = element.nextSibling;
+    if (element) return [element].concat($(element).nextSiblings());
+    return [];
+  },
+
+  previousSiblings: function(element) {
+    return $(element).recursivelyCollect('previousSibling');
+  },
+
+  nextSiblings: function(element) {
+    return $(element).recursivelyCollect('nextSibling');
+  },
+
+  siblings: function(element) {
+    element = $(element);
+    return element.previousSiblings().reverse().concat(element.nextSiblings());
+  },
+
+  match: function(element, selector) {
+    if (typeof selector == 'string')
+      selector = new Selector(selector);
+    return selector.match($(element));
+  },
+
+  up: function(element, expression, index) {
+    return Selector.findElement($(element).ancestors(), expression, index);
+  },
+
+  down: function(element, expression, index) {
+    return Selector.findElement($(element).descendants(), expression, index);
+  },
+
+  previous: function(element, expression, index) {
+    return Selector.findElement($(element).previousSiblings(), expression, index);
+  },
+
+  next: function(element, expression, index) {
+    return Selector.findElement($(element).nextSiblings(), expression, index);
+  },
+
+  getElementsBySelector: function() {
+    var args = $A(arguments), element = $(args.shift());
+    return Selector.findChildElements(element, args);
+  },
+
+  getElementsByClassName: function(element, className) {
+    return document.getElementsByClassName(className, element);
+  },
+
+  readAttribute: function(element, name) {
+    element = $(element);
+    if (document.all && !window.opera) {
+      var t = Element._attributeTranslations;
+      if (t.values[name]) return t.values[name](element, name);
+      if (t.names[name])  name = t.names[name];
+      var attribute = element.attributes[name];
+      if(attribute) return attribute.nodeValue;
+    }
+    return element.getAttribute(name);
+  },
+
+  getHeight: function(element) {
+    return $(element).getDimensions().height;
+  },
+
+  getWidth: function(element) {
+    return $(element).getDimensions().width;
+  },
+
+  classNames: function(element) {
+    return new Element.ClassNames(element);
+  },
+
+  hasClassName: function(element, className) {
+    if (!(element = $(element))) return;
+    var elementClassName = element.className;
+    if (elementClassName.length == 0) return false;
+    if (elementClassName == className ||
+        elementClassName.match(new RegExp("(^|\\s)" + className + "(\\s|$)")))
+      return true;
+    return false;
+  },
+
+  addClassName: function(element, className) {
+    if (!(element = $(element))) return;
+    Element.classNames(element).add(className);
+    return element;
+  },
+
+  removeClassName: function(element, className) {
+    if (!(element = $(element))) return;
+    Element.classNames(element).remove(className);
+    return element;
+  },
+
+  toggleClassName: function(element, className) {
+    if (!(element = $(element))) return;
+    Element.classNames(element)[element.hasClassName(className) ? 'remove' : 'add'](className);
+    return element;
+  },
+
+  observe: function() {
+    Event.observe.apply(Event, arguments);
+    return $A(arguments).first();
+  },
+
+  stopObserving: function() {
+    Event.stopObserving.apply(Event, arguments);
+    return $A(arguments).first();
+  },
+
+  // removes whitespace-only text node children
+  cleanWhitespace: function(element) {
+    element = $(element);
+    var node = element.firstChild;
+    while (node) {
+      var nextNode = node.nextSibling;
+      if (node.nodeType == 3 && !/\S/.test(node.nodeValue))
+        element.removeChild(node);
+      node = nextNode;
+    }
+    return element;
+  },
+
+  empty: function(element) {
+    return $(element).innerHTML.match(/^\s*$/);
+  },
+
+  descendantOf: function(element, ancestor) {
+    element = $(element), ancestor = $(ancestor);
+    while (element = element.parentNode)
+      if (element == ancestor) return true;
+    return false;
+  },
+
+  scrollTo: function(element) {
+    element = $(element);
+    var pos = Position.cumulativeOffset(element);
+    window.scrollTo(pos[0], pos[1]);
+    return element;
+  },
+
+  getStyle: function(element, style) {
+    element = $(element);
+    if (['float','cssFloat'].include(style))
+      style = (typeof element.style.styleFloat != 'undefined' ? 'styleFloat' : 'cssFloat');
+    style = style.camelize();
+    var value = element.style[style];
+    if (!value) {
+      if (document.defaultView && document.defaultView.getComputedStyle) {
+        var css = document.defaultView.getComputedStyle(element, null);
+        value = css ? css[style] : null;
+      } else if (element.currentStyle) {
+        value = element.currentStyle[style];
+      }
+    }
+
+    if((value == 'auto') && ['width','height'].include(style) && (element.getStyle('display') != 'none'))
+      value = element['offset'+style.capitalize()] + 'px';
+
+    if (window.opera && ['left', 'top', 'right', 'bottom'].include(style))
+      if (Element.getStyle(element, 'position') == 'static') value = 'auto';
+    if(style == 'opacity') {
+      if(value) return parseFloat(value);
+      if(value = (element.getStyle('filter') || '').match(/alpha\(opacity=(.*)\)/))
+        if(value[1]) return parseFloat(value[1]) / 100;
+      return 1.0;
+    }
+    return value == 'auto' ? null : value;
+  },
+
+  setStyle: function(element, style) {
+    element = $(element);
+    for (var name in style) {
+      var value = style[name];
+      if(name == 'opacity') {
+        if (value == 1) {
+          value = (/Gecko/.test(navigator.userAgent) &&
+            !/Konqueror|Safari|KHTML/.test(navigator.userAgent)) ? 0.999999 : 1.0;
+          if(/MSIE/.test(navigator.userAgent) && !window.opera)
+            element.style.filter = element.getStyle('filter').replace(/alpha\([^\)]*\)/gi,'');
+        } else if(value == '') {
+          if(/MSIE/.test(navigator.userAgent) && !window.opera)
+            element.style.filter = element.getStyle('filter').replace(/alpha\([^\)]*\)/gi,'');
+        } else {
+          if(value < 0.00001) value = 0;
+          if(/MSIE/.test(navigator.userAgent) && !window.opera)
+            element.style.filter = element.getStyle('filter').replace(/alpha\([^\)]*\)/gi,'') +
+              'alpha(opacity='+value*100+')';
+        }
+      } else if(['float','cssFloat'].include(name)) name = (typeof element.style.styleFloat != 'undefined') ? 'styleFloat' : 'cssFloat';
+      element.style[name.camelize()] = value;
+    }
+    return element;
+  },
+
+  getDimensions: function(element) {
+    element = $(element);
+    var display = $(element).getStyle('display');
+    if (display != 'none' && display != null) // Safari bug
+      return {width: element.offsetWidth, height: element.offsetHeight};
+
+    // All *Width and *Height properties give 0 on elements with display none,
+    // so enable the element temporarily
+    var els = element.style;
+    var originalVisibility = els.visibility;
+    var originalPosition = els.position;
+    var originalDisplay = els.display;
+    els.visibility = 'hidden';
+    els.position = 'absolute';
+    els.display = 'block';
+    var originalWidth = element.clientWidth;
+    var originalHeight = element.clientHeight;
+    els.display = originalDisplay;
+    els.position = originalPosition;
+    els.visibility = originalVisibility;
+    return {width: originalWidth, height: originalHeight};
+  },
+
+  makePositioned: function(element) {
+    element = $(element);
+    var pos = Element.getStyle(element, 'position');
+    if (pos == 'static' || !pos) {
+      element._madePositioned = true;
+      element.style.position = 'relative';
+      // Opera returns the offset relative to the positioning context, when an
+      // element is position relative but top and left have not been defined
+      if (window.opera) {
+        element.style.top = 0;
+        element.style.left = 0;
+      }
+    }
+    return element;
+  },
+
+  undoPositioned: function(element) {
+    element = $(element);
+    if (element._madePositioned) {
+      element._madePositioned = undefined;
+      element.style.position =
+        element.style.top =
+        element.style.left =
+        element.style.bottom =
+        element.style.right = '';
+    }
+    return element;
+  },
+
+  makeClipping: function(element) {
+    element = $(element);
+    if (element._overflow) return element;
+    element._overflow = element.style.overflow || 'auto';
+    if ((Element.getStyle(element, 'overflow') || 'visible') != 'hidden')
+      element.style.overflow = 'hidden';
+    return element;
+  },
+
+  undoClipping: function(element) {
+    element = $(element);
+    if (!element._overflow) return element;
+    element.style.overflow = element._overflow == 'auto' ? '' : element._overflow;
+    element._overflow = null;
+    return element;
+  }
+};
+
+Object.extend(Element.Methods, {childOf: Element.Methods.descendantOf});
+
+Element._attributeTranslations = {};
+
+Element._attributeTranslations.names = {
+  colspan:   "colSpan",
+  rowspan:   "rowSpan",
+  valign:    "vAlign",
+  datetime:  "dateTime",
+  accesskey: "accessKey",
+  tabindex:  "tabIndex",
+  enctype:   "encType",
+  maxlength: "maxLength",
+  readonly:  "readOnly",
+  longdesc:  "longDesc"
+};
+
+Element._attributeTranslations.values = {
+  _getAttr: function(element, attribute) {
+    return element.getAttribute(attribute, 2);
+  },
+
+  _flag: function(element, attribute) {
+    return $(element).hasAttribute(attribute) ? attribute : null;
+  },
+
+  style: function(element) {
+    return element.style.cssText.toLowerCase();
+  },
+
+  title: function(element) {
+    var node = element.getAttributeNode('title');
+    return node.specified ? node.nodeValue : null;
+  }
+};
+
+Object.extend(Element._attributeTranslations.values, {
+  href: Element._attributeTranslations.values._getAttr,
+  src:  Element._attributeTranslations.values._getAttr,
+  disabled: Element._attributeTranslations.values._flag,
+  checked:  Element._attributeTranslations.values._flag,
+  readonly: Element._attributeTranslations.values._flag,
+  multiple: Element._attributeTranslations.values._flag
+});
+
+Element.Methods.Simulated = {
+  hasAttribute: function(element, attribute) {
+    var t = Element._attributeTranslations;
+    attribute = t.names[attribute] || attribute;
+    return $(element).getAttributeNode(attribute).specified;
+  }
+};
+
+// IE is missing .innerHTML support for TABLE-related elements
+if (document.all && !window.opera){
+  Element.Methods.update = function(element, html) {
+    element = $(element);
+    html = typeof html == 'undefined' ? '' : html.toString();
+    var tagName = element.tagName.toUpperCase();
+    if (['THEAD','TBODY','TR','TD'].include(tagName)) {
+      var div = document.createElement('div');
+      switch (tagName) {
+        case 'THEAD':
+        case 'TBODY':
+          div.innerHTML = '<table><tbody>' +  html.stripScripts() + '</tbody></table>';
+          depth = 2;
+          break;
+        case 'TR':
+          div.innerHTML = '<table><tbody><tr>' +  html.stripScripts() + '</tr></tbody></table>';
+          depth = 3;
+          break;
+        case 'TD':
+          div.innerHTML = '<table><tbody><tr><td>' +  html.stripScripts() + '</td></tr></tbody></table>';
+          depth = 4;
+      }
+      $A(element.childNodes).each(function(node){
+        element.removeChild(node)
+      });
+      depth.times(function(){ div = div.firstChild });
+
+      $A(div.childNodes).each(
+        function(node){ element.appendChild(node) });
+    } else {
+      element.innerHTML = html.stripScripts();
+    }
+    setTimeout(function() {html.evalScripts()}, 10);
+    return element;
+  }
+};
+
+Object.extend(Element, Element.Methods);
+
+var _nativeExtensions = false;
+
+if(/Konqueror|Safari|KHTML/.test(navigator.userAgent))
+  ['', 'Form', 'Input', 'TextArea', 'Select'].each(function(tag) {
+    var className = 'HTML' + tag + 'Element';
+    if(window[className]) return;
+    var klass = window[className] = {};
+    klass.prototype = document.createElement(tag ? tag.toLowerCase() : 'div').__proto__;
+  });
+
+Element.addMethods = function(methods) {
+  Object.extend(Element.Methods, methods || {});
+
+  function copy(methods, destination, onlyIfAbsent) {
+    onlyIfAbsent = onlyIfAbsent || false;
+    var cache = Element.extend.cache;
+    for (var property in methods) {
+      var value = methods[property];
+      if (!onlyIfAbsent || !(property in destination))
+        destination[property] = cache.findOrStore(value);
+    }
+  }
+
+  if (typeof HTMLElement != 'undefined') {
+    copy(Element.Methods, HTMLElement.prototype);
+    copy(Element.Methods.Simulated, HTMLElement.prototype, true);
+    copy(Form.Methods, HTMLFormElement.prototype);
+    [HTMLInputElement, HTMLTextAreaElement, HTMLSelectElement].each(function(klass) {
+      copy(Form.Element.Methods, klass.prototype);
+    });
+    _nativeExtensions = true;
+  }
+}
+
+var Toggle = new Object();
+Toggle.display = Element.toggle;
+
+/*--------------------------------------------------------------------------*/
+
+Abstract.Insertion = function(adjacency) {
+  this.adjacency = adjacency;
+}
+
+Abstract.Insertion.prototype = {
+  initialize: function(element, content) {
+    this.element = $(element);
+    this.content = content.stripScripts();
+
+    if (this.adjacency && this.element.insertAdjacentHTML) {
+      try {
+        this.element.insertAdjacentHTML(this.adjacency, this.content);
+      } catch (e) {
+        var tagName = this.element.tagName.toUpperCase();
+        if (['TBODY', 'TR'].include(tagName)) {
+          this.insertContent(this.contentFromAnonymousTable());
+        } else {
+          throw e;
+        }
+      }
+    } else {
+      this.range = this.element.ownerDocument.createRange();
+      if (this.initializeRange) this.initializeRange();
+      this.insertContent([this.range.createContextualFragment(this.content)]);
+    }
+
+    setTimeout(function() {content.evalScripts()}, 10);
+  },
+
+  contentFromAnonymousTable: function() {
+    var div = document.createElement('div');
+    div.innerHTML = '<table><tbody>' + this.content + '</tbody></table>';
+    return $A(div.childNodes[0].childNodes[0].childNodes);
+  }
+}
+
+var Insertion = new Object();
+
+Insertion.Before = Class.create();
+Insertion.Before.prototype = Object.extend(new Abstract.Insertion('beforeBegin'), {
+  initializeRange: function() {
+    this.range.setStartBefore(this.element);
+  },
+
+  insertContent: function(fragments) {
+    fragments.each((function(fragment) {
+      this.element.parentNode.insertBefore(fragment, this.element);
+    }).bind(this));
+  }
+});
+
+Insertion.Top = Class.create();
+Insertion.Top.prototype = Object.extend(new Abstract.Insertion('afterBegin'), {
+  initializeRange: function() {
+    this.range.selectNodeContents(this.element);
+    this.range.collapse(true);
+  },
+
+  insertContent: function(fragments) {
+    fragments.reverse(false).each((function(fragment) {
+      this.element.insertBefore(fragment, this.element.firstChild);
+    }).bind(this));
+  }
+});
+
+Insertion.Bottom = Class.create();
+Insertion.Bottom.prototype = Object.extend(new Abstract.Insertion('beforeEnd'), {
+  initializeRange: function() {
+    this.range.selectNodeContents(this.element);
+    this.range.collapse(this.element);
+  },
+
+  insertContent: function(fragments) {
+    fragments.each((function(fragment) {
+      this.element.appendChild(fragment);
+    }).bind(this));
+  }
+});
+
+Insertion.After = Class.create();
+Insertion.After.prototype = Object.extend(new Abstract.Insertion('afterEnd'), {
+  initializeRange: function() {
+    this.range.setStartAfter(this.element);
+  },
+
+  insertContent: function(fragments) {
+    fragments.each((function(fragment) {
+      this.element.parentNode.insertBefore(fragment,
+        this.element.nextSibling);
+    }).bind(this));
+  }
+});
+
+/*--------------------------------------------------------------------------*/
+
+Element.ClassNames = Class.create();
+Element.ClassNames.prototype = {
+  initialize: function(element) {
+    this.element = $(element);
+  },
+
+  _each: function(iterator) {
+    this.element.className.split(/\s+/).select(function(name) {
+      return name.length > 0;
+    })._each(iterator);
+  },
+
+  set: function(className) {
+    this.element.className = className;
+  },
+
+  add: function(classNameToAdd) {
+    if (this.include(classNameToAdd)) return;
+    this.set($A(this).concat(classNameToAdd).join(' '));
+  },
+
+  remove: function(classNameToRemove) {
+    if (!this.include(classNameToRemove)) return;
+    this.set($A(this).without(classNameToRemove).join(' '));
+  },
+
+  toString: function() {
+    return $A(this).join(' ');
+  }
+};
+
+Object.extend(Element.ClassNames.prototype, Enumerable);
+var Selector = Class.create();
+Selector.prototype = {
+  initialize: function(expression) {
+    this.params = {classNames: []};
+    this.expression = expression.toString().strip();
+    this.parseExpression();
+    this.compileMatcher();
+  },
+
+  parseExpression: function() {
+    function abort(message) { throw 'Parse error in selector: ' + message; }
+
+    if (this.expression == '')  abort('empty expression');
+
+    var params = this.params, expr = this.expression, match, modifier, clause, rest;
+    while (match = expr.match(/^(.*)\[([a-z0-9_:-]+?)(?:([~\|!]?=)(?:"([^"]*)"|([^\]\s]*)))?\]$/i)) {
+      params.attributes = params.attributes || [];
+      params.attributes.push({name: match[2], operator: match[3], value: match[4] || match[5] || ''});
+      expr = match[1];
+    }
+
+    if (expr == '*') return this.params.wildcard = true;
+
+    while (match = expr.match(/^([^a-z0-9_-])?([a-z0-9_-]+)(.*)/i)) {
+      modifier = match[1], clause = match[2], rest = match[3];
+      switch (modifier) {
+        case '#':       params.id = clause; break;
+        case '.':       params.classNames.push(clause); break;
+        case '':
+        case undefined: params.tagName = clause.toUpperCase(); break;
+        default:        abort(expr.inspect());
+      }
+      expr = rest;
+    }
+
+    if (expr.length > 0) abort(expr.inspect());
+  },
+
+  buildMatchExpression: function() {
+    var params = this.params, conditions = [], clause;
+
+    if (params.wildcard)
+      conditions.push('true');
+    if (clause = params.id)
+      conditions.push('element.readAttribute("id") == ' + clause.inspect());
+    if (clause = params.tagName)
+      conditions.push('element.tagName.toUpperCase() == ' + clause.inspect());
+    if ((clause = params.classNames).length > 0)
+      for (var i = 0, length = clause.length; i < length; i++)
+        conditions.push('element.hasClassName(' + clause[i].inspect() + ')');
+    if (clause = params.attributes) {
+      clause.each(function(attribute) {
+        var value = 'element.readAttribute(' + attribute.name.inspect() + ')';
+        var splitValueBy = function(delimiter) {
+          return value + ' && ' + value + '.split(' + delimiter.inspect() + ')';
+        }
+
+        switch (attribute.operator) {
+          case '=':       conditions.push(value + ' == ' + attribute.value.inspect()); break;
+          case '~=':      conditions.push(splitValueBy(' ') + '.include(' + attribute.value.inspect() + ')'); break;
+          case '|=':      conditions.push(
+                            splitValueBy('-') + '.first().toUpperCase() == ' + attribute.value.toUpperCase().inspect()
+                          ); break;
+          case '!=':      conditions.push(value + ' != ' + attribute.value.inspect()); break;
+          case '':
+          case undefined: conditions.push('element.hasAttribute(' + attribute.name.inspect() + ')'); break;
+          default:        throw 'Unknown operator ' + attribute.operator + ' in selector';
+        }
+      });
+    }
+
+    return conditions.join(' && ');
+  },
+
+  compileMatcher: function() {
+    this.match = new Function('element', 'if (!element.tagName) return false; \
+      element = $(element); \
+      return ' + this.buildMatchExpression());
+  },
+
+  findElements: function(scope) {
+    var element;
+
+    if (element = $(this.params.id))
+      if (this.match(element))
+        if (!scope || Element.childOf(element, scope))
+          return [element];
+
+    scope = (scope || document).getElementsByTagName(this.params.tagName || '*');
+
+    var results = [];
+    for (var i = 0, length = scope.length; i < length; i++)
+      if (this.match(element = scope[i]))
+        results.push(Element.extend(element));
+
+    return results;
+  },
+
+  toString: function() {
+    return this.expression;
+  }
+}
+
+Object.extend(Selector, {
+  matchElements: function(elements, expression) {
+    var selector = new Selector(expression);
+    return elements.select(selector.match.bind(selector)).map(Element.extend);
+  },
+
+  findElement: function(elements, expression, index) {
+    if (typeof expression == 'number') index = expression, expression = false;
+    return Selector.matchElements(elements, expression || '*')[index || 0];
+  },
+
+  findChildElements: function(element, expressions) {
+    return expressions.map(function(expression) {
+      return expression.match(/[^\s"]+(?:"[^"]*"[^\s"]+)*/g).inject([null], function(results, expr) {
+        var selector = new Selector(expr);
+        return results.inject([], function(elements, result) {
+          return elements.concat(selector.findElements(result || element));
+        });
+      });
+    }).flatten();
+  }
+});
+
+function $$() {
+  return Selector.findChildElements(document, $A(arguments));
+}
+var Form = {
+  reset: function(form) {
+    $(form).reset();
+    return form;
+  },
+
+  serializeElements: function(elements, getHash) {
+    var data = elements.inject({}, function(result, element) {
+      if (!element.disabled && element.name) {
+        var key = element.name, value = $(element).getValue();
+        if (value != undefined) {
+          if (result[key]) {
+            if (result[key].constructor != Array) result[key] = [result[key]];
+            result[key].push(value);
+          }
+          else result[key] = value;
+        }
+      }
+      return result;
+    });
+
+    return getHash ? data : Hash.toQueryString(data);
+  }
+};
+
+Form.Methods = {
+  serialize: function(form, getHash) {
+    return Form.serializeElements(Form.getElements(form), getHash);
+  },
+
+  getElements: function(form) {
+    return $A($(form).getElementsByTagName('*')).inject([],
+      function(elements, child) {
+        if (Form.Element.Serializers[child.tagName.toLowerCase()])
+          elements.push(Element.extend(child));
+        return elements;
+      }
+    );
+  },
+
+  getInputs: function(form, typeName, name) {
+    form = $(form);
+    var inputs = form.getElementsByTagName('input');
+
+    if (!typeName && !name) return $A(inputs).map(Element.extend);
+
+    for (var i = 0, matchingInputs = [], length = inputs.length; i < length; i++) {
+      var input = inputs[i];
+      if ((typeName && input.type != typeName) || (name && input.name != name))
+        continue;
+      matchingInputs.push(Element.extend(input));
+    }
+
+    return matchingInputs;
+  },
+
+  disable: function(form) {
+    form = $(form);
+    form.getElements().each(function(element) {
+      element.blur();
+      element.disabled = 'true';
+    });
+    return form;
+  },
+
+  enable: function(form) {
+    form = $(form);
+    form.getElements().each(function(element) {
+      element.disabled = '';
+    });
+    return form;
+  },
+
+  findFirstElement: function(form) {
+    return $(form).getElements().find(function(element) {
+      return element.type != 'hidden' && !element.disabled &&
+        ['input', 'select', 'textarea'].include(element.tagName.toLowerCase());
+    });
+  },
+
+  focusFirstElement: function(form) {
+    form = $(form);
+    form.findFirstElement().activate();
+    return form;
+  }
+}
+
+Object.extend(Form, Form.Methods);
+
+/*--------------------------------------------------------------------------*/
+
+Form.Element = {
+  focus: function(element) {
+    $(element).focus();
+    return element;
+  },
+
+  select: function(element) {
+    $(element).select();
+    return element;
+  }
+}
+
+Form.Element.Methods = {
+  serialize: function(element) {
+    element = $(element);
+    if (!element.disabled && element.name) {
+      var value = element.getValue();
+      if (value != undefined) {
+        var pair = {};
+        pair[element.name] = value;
+        return Hash.toQueryString(pair);
+      }
+    }
+    return '';
+  },
+
+  getValue: function(element) {
+    element = $(element);
+    var method = element.tagName.toLowerCase();
+    return Form.Element.Serializers[method](element);
+  },
+
+  clear: function(element) {
+    $(element).value = '';
+    return element;
+  },
+
+  present: function(element) {
+    return $(element).value != '';
+  },
+
+  activate: function(element) {
+    element = $(element);
+    element.focus();
+    if (element.select && ( element.tagName.toLowerCase() != 'input' ||
+      !['button', 'reset', 'submit'].include(element.type) ) )
+      element.select();
+    return element;
+  },
+
+  disable: function(element) {
+    element = $(element);
+    element.disabled = true;
+    return element;
+  },
+
+  enable: function(element) {
+    element = $(element);
+    element.blur();
+    element.disabled = false;
+    return element;
+  }
+}
+
+Object.extend(Form.Element, Form.Element.Methods);
+var Field = Form.Element;
+var $F = Form.Element.getValue;
+
+/*--------------------------------------------------------------------------*/
+
+Form.Element.Serializers = {
+  input: function(element) {
+    switch (element.type.toLowerCase()) {
+      case 'checkbox':
+      case 'radio':
+        return Form.Element.Serializers.inputSelector(element);
+      default:
+        return Form.Element.Serializers.textarea(element);
+    }
+  },
+
+  inputSelector: function(element) {
+    return element.checked ? element.value : null;
+  },
+
+  textarea: function(element) {
+    return element.value;
+  },
+
+  select: function(element) {
+    return this[element.type == 'select-one' ?
+      'selectOne' : 'selectMany'](element);
+  },
+
+  selectOne: function(element) {
+    var index = element.selectedIndex;
+    return index >= 0 ? this.optionValue(element.options[index]) : null;
+  },
+
+  selectMany: function(element) {
+    var values, length = element.length;
+    if (!length) return null;
+
+    for (var i = 0, values = []; i < length; i++) {
+      var opt = element.options[i];
+      if (opt.selected) values.push(this.optionValue(opt));
+    }
+    return values;
+  },
+
+  optionValue: function(opt) {
+    // extend element because hasAttribute may not be native
+    return Element.extend(opt).hasAttribute('value') ? opt.value : opt.text;
+  }
+}
+
+/*--------------------------------------------------------------------------*/
+
+Abstract.TimedObserver = function() {}
+Abstract.TimedObserver.prototype = {
+  initialize: function(element, frequency, callback) {
+    this.frequency = frequency;
+    this.element   = $(element);
+    this.callback  = callback;
+
+    this.lastValue = this.getValue();
+    this.registerCallback();
+  },
+
+  registerCallback: function() {
+    setInterval(this.onTimerEvent.bind(this), this.frequency * 1000);
+  },
+
+  onTimerEvent: function() {
+    var value = this.getValue();
+    var changed = ('string' == typeof this.lastValue && 'string' == typeof value
+      ? this.lastValue != value : String(this.lastValue) != String(value));
+    if (changed) {
+      this.callback(this.element, value);
+      this.lastValue = value;
+    }
+  }
+}
+
+Form.Element.Observer = Class.create();
+Form.Element.Observer.prototype = Object.extend(new Abstract.TimedObserver(), {
+  getValue: function() {
+    return Form.Element.getValue(this.element);
+  }
+});
+
+Form.Observer = Class.create();
+Form.Observer.prototype = Object.extend(new Abstract.TimedObserver(), {
+  getValue: function() {
+    return Form.serialize(this.element);
+  }
+});
+
+/*--------------------------------------------------------------------------*/
+
+Abstract.EventObserver = function() {}
+Abstract.EventObserver.prototype = {
+  initialize: function(element, callback) {
+    this.element  = $(element);
+    this.callback = callback;
+
+    this.lastValue = this.getValue();
+    if (this.element.tagName.toLowerCase() == 'form')
+      this.registerFormCallbacks();
+    else
+      this.registerCallback(this.element);
+  },
+
+  onElementEvent: function() {
+    var value = this.getValue();
+    if (this.lastValue != value) {
+      this.callback(this.element, value);
+      this.lastValue = value;
+    }
+  },
+
+  registerFormCallbacks: function() {
+    Form.getElements(this.element).each(this.registerCallback.bind(this));
+  },
+
+  registerCallback: function(element) {
+    if (element.type) {
+      switch (element.type.toLowerCase()) {
+        case 'checkbox':
+        case 'radio':
+          Event.observe(element, 'click', this.onElementEvent.bind(this));
+          break;
+        default:
+          Event.observe(element, 'change', this.onElementEvent.bind(this));
+          break;
+      }
+    }
+  }
+}
+
+Form.Element.EventObserver = Class.create();
+Form.Element.EventObserver.prototype = Object.extend(new Abstract.EventObserver(), {
+  getValue: function() {
+    return Form.Element.getValue(this.element);
+  }
+});
+
+Form.EventObserver = Class.create();
+Form.EventObserver.prototype = Object.extend(new Abstract.EventObserver(), {
+  getValue: function() {
+    return Form.serialize(this.element);
+  }
+});
+if (!window.Event) {
+  var Event = new Object();
+}
+
+Object.extend(Event, {
+  KEY_BACKSPACE: 8,
+  KEY_TAB:       9,
+  KEY_RETURN:   13,
+  KEY_ESC:      27,
+  KEY_LEFT:     37,
+  KEY_UP:       38,
+  KEY_RIGHT:    39,
+  KEY_DOWN:     40,
+  KEY_DELETE:   46,
+  KEY_HOME:     36,
+  KEY_END:      35,
+  KEY_PAGEUP:   33,
+  KEY_PAGEDOWN: 34,
+
+  element: function(event) {
+    return event.target || event.srcElement;
+  },
+
+  isLeftClick: function(event) {
+    return (((event.which) && (event.which == 1)) ||
+            ((event.button) && (event.button == 1)));
+  },
+
+  pointerX: function(event) {
+    return event.pageX || (event.clientX +
+      (document.documentElement.scrollLeft || document.body.scrollLeft));
+  },
+
+  pointerY: function(event) {
+    return event.pageY || (event.clientY +
+      (document.documentElement.scrollTop || document.body.scrollTop));
+  },
+
+  stop: function(event) {
+    if (event.preventDefault) {
+      event.preventDefault();
+      event.stopPropagation();
+    } else {
+      event.returnValue = false;
+      event.cancelBubble = true;
+    }
+  },
+
+  // find the first node with the given tagName, starting from the
+  // node the event was triggered on; traverses the DOM upwards
+  findElement: function(event, tagName) {
+    var element = Event.element(event);
+    while (element.parentNode && (!element.tagName ||
+        (element.tagName.toUpperCase() != tagName.toUpperCase())))
+      element = element.parentNode;
+    return element;
+  },
+
+  observers: false,
+
+  _observeAndCache: function(element, name, observer, useCapture) {
+    if (!this.observers) this.observers = [];
+    if (element.addEventListener) {
+      this.observers.push([element, name, observer, useCapture]);
+      element.addEventListener(name, observer, useCapture);
+    } else if (element.attachEvent) {
+      this.observers.push([element, name, observer, useCapture]);
+      element.attachEvent('on' + name, observer);
+    }
+  },
+
+  unloadCache: function() {
+    if (!Event.observers) return;
+    for (var i = 0, length = Event.observers.length; i < length; i++) {
+      Event.stopObserving.apply(this, Event.observers[i]);
+      Event.observers[i][0] = null;
+    }
+    Event.observers = false;
+  },
+
+  observe: function(element, name, observer, useCapture) {
+    element = $(element);
+    useCapture = useCapture || false;
+
+    if (name == 'keypress' &&
+        (navigator.appVersion.match(/Konqueror|Safari|KHTML/)
+        || element.attachEvent))
+      name = 'keydown';
+
+    Event._observeAndCache(element, name, observer, useCapture);
+  },
+
+  stopObserving: function(element, name, observer, useCapture) {
+    element = $(element);
+    useCapture = useCapture || false;
+
+    if (name == 'keypress' &&
+        (navigator.appVersion.match(/Konqueror|Safari|KHTML/)
+        || element.detachEvent))
+      name = 'keydown';
+
+    if (element.removeEventListener) {
+      element.removeEventListener(name, observer, useCapture);
+    } else if (element.detachEvent) {
+      try {
+        element.detachEvent('on' + name, observer);
+      } catch (e) {}
+    }
+  }
+});
+
+/* prevent memory leaks in IE */
+if (navigator.appVersion.match(/\bMSIE\b/))
+  Event.observe(window, 'unload', Event.unloadCache, false);
+var Position = {
+  // set to true if needed, warning: firefox performance problems
+  // NOT neeeded for page scrolling, only if draggable contained in
+  // scrollable elements
+  includeScrollOffsets: false,
+
+  // must be called before calling withinIncludingScrolloffset, every time the
+  // page is scrolled
+  prepare: function() {
+    this.deltaX =  window.pageXOffset
+                || document.documentElement.scrollLeft
+                || document.body.scrollLeft
+                || 0;
+    this.deltaY =  window.pageYOffset
+                || document.documentElement.scrollTop
+                || document.body.scrollTop
+                || 0;
+  },
+
+  realOffset: function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.scrollTop  || 0;
+      valueL += element.scrollLeft || 0;
+      element = element.parentNode;
+    } while (element);
+    return [valueL, valueT];
+  },
+
+  cumulativeOffset: function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+      element = element.offsetParent;
+    } while (element);
+    return [valueL, valueT];
+  },
+
+  positionedOffset: function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+      element = element.offsetParent;
+      if (element) {
+        if(element.tagName=='BODY') break;
+        var p = Element.getStyle(element, 'position');
+        if (p == 'relative' || p == 'absolute') break;
+      }
+    } while (element);
+    return [valueL, valueT];
+  },
+
+  offsetParent: function(element) {
+    if (element.offsetParent) return element.offsetParent;
+    if (element == document.body) return element;
+
+    while ((element = element.parentNode) && element != document.body)
+      if (Element.getStyle(element, 'position') != 'static')
+        return element;
+
+    return document.body;
+  },
+
+  // caches x/y coordinate pair to use with overlap
+  within: function(element, x, y) {
+    if (this.includeScrollOffsets)
+      return this.withinIncludingScrolloffsets(element, x, y);
+    this.xcomp = x;
+    this.ycomp = y;
+    this.offset = this.cumulativeOffset(element);
+
+    return (y >= this.offset[1] &&
+            y <  this.offset[1] + element.offsetHeight &&
+            x >= this.offset[0] &&
+            x <  this.offset[0] + element.offsetWidth);
+  },
+
+  withinIncludingScrolloffsets: function(element, x, y) {
+    var offsetcache = this.realOffset(element);
+
+    this.xcomp = x + offsetcache[0] - this.deltaX;
+    this.ycomp = y + offsetcache[1] - this.deltaY;
+    this.offset = this.cumulativeOffset(element);
+
+    return (this.ycomp >= this.offset[1] &&
+            this.ycomp <  this.offset[1] + element.offsetHeight &&
+            this.xcomp >= this.offset[0] &&
+            this.xcomp <  this.offset[0] + element.offsetWidth);
+  },
+
+  // within must be called directly before
+  overlap: function(mode, element) {
+    if (!mode) return 0;
+    if (mode == 'vertical')
+      return ((this.offset[1] + element.offsetHeight) - this.ycomp) /
+        element.offsetHeight;
+    if (mode == 'horizontal')
+      return ((this.offset[0] + element.offsetWidth) - this.xcomp) /
+        element.offsetWidth;
+  },
+
+  page: function(forElement) {
+    var valueT = 0, valueL = 0;
+
+    var element = forElement;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+
+      // Safari fix
+      if (element.offsetParent==document.body)
+        if (Element.getStyle(element,'position')=='absolute') break;
+
+    } while (element = element.offsetParent);
+
+    element = forElement;
+    do {
+      if (!window.opera || element.tagName=='BODY') {
+        valueT -= element.scrollTop  || 0;
+        valueL -= element.scrollLeft || 0;
+      }
+    } while (element = element.parentNode);
+
+    return [valueL, valueT];
+  },
+
+  clone: function(source, target) {
+    var options = Object.extend({
+      setLeft:    true,
+      setTop:     true,
+      setWidth:   true,
+      setHeight:  true,
+      offsetTop:  0,
+      offsetLeft: 0
+    }, arguments[2] || {})
+
+    // find page position of source
+    source = $(source);
+    var p = Position.page(source);
+
+    // find coordinate system to use
+    target = $(target);
+    var delta = [0, 0];
+    var parent = null;
+    // delta [0,0] will do fine with position: fixed elements,
+    // position:absolute needs offsetParent deltas
+    if (Element.getStyle(target,'position') == 'absolute') {
+      parent = Position.offsetParent(target);
+      delta = Position.page(parent);
+    }
+
+    // correct by body offsets (fixes Safari)
+    if (parent == document.body) {
+      delta[0] -= document.body.offsetLeft;
+      delta[1] -= document.body.offsetTop;
+    }
+
+    // set position
+    if(options.setLeft)   target.style.left  = (p[0] - delta[0] + options.offsetLeft) + 'px';
+    if(options.setTop)    target.style.top   = (p[1] - delta[1] + options.offsetTop) + 'px';
+    if(options.setWidth)  target.style.width = source.offsetWidth + 'px';
+    if(options.setHeight) target.style.height = source.offsetHeight + 'px';
+  },
+
+  absolutize: function(element) {
+    element = $(element);
+    if (element.style.position == 'absolute') return;
+    Position.prepare();
+
+    var offsets = Position.positionedOffset(element);
+    var top     = offsets[1];
+    var left    = offsets[0];
+    var width   = element.clientWidth;
+    var height  = element.clientHeight;
+
+    element._originalLeft   = left - parseFloat(element.style.left  || 0);
+    element._originalTop    = top  - parseFloat(element.style.top || 0);
+    element._originalWidth  = element.style.width;
+    element._originalHeight = element.style.height;
+
+    element.style.position = 'absolute';
+    element.style.top    = top + 'px';
+    element.style.left   = left + 'px';
+    element.style.width  = width + 'px';
+    element.style.height = height + 'px';
+  },
+
+  relativize: function(element) {
+    element = $(element);
+    if (element.style.position == 'relative') return;
+    Position.prepare();
+
+    element.style.position = 'relative';
+    var top  = parseFloat(element.style.top  || 0) - (element._originalTop || 0);
+    var left = parseFloat(element.style.left || 0) - (element._originalLeft || 0);
+
+    element.style.top    = top + 'px';
+    element.style.left   = left + 'px';
+    element.style.height = element._originalHeight;
+    element.style.width  = element._originalWidth;
+  }
+}
+
+// Safari returns margins on body which is incorrect if the child is absolutely
+// positioned.  For performance reasons, redefine Position.cumulativeOffset for
+// KHTML/WebKit only.
+if (/Konqueror|Safari|KHTML/.test(navigator.userAgent)) {
+  Position.cumulativeOffset = function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+      if (element.offsetParent == document.body)
+        if (Element.getStyle(element, 'position') == 'absolute') break;
+
+      element = element.offsetParent;
+    } while (element);
+
+    return [valueL, valueT];
+  }
+}
+
+Element.addMethods();
\ No newline at end of file
diff --git a/templates/static/star.gif b/templates/static/star.gif
new file mode 100644 (file)
index 0000000..32633e3
Binary files /dev/null and b/templates/static/star.gif differ
diff --git a/templates/static/star_rating.css b/templates/static/star_rating.css
new file mode 100644 (file)
index 0000000..8ba0141
--- /dev/null
@@ -0,0 +1,84 @@
+.star-rating,
+.star-rating a:hover,\r
+.star-rating a:active,\r
+.star-rating a:focus,
+.star-rating .current-rating{
+       background: url(star.gif) left -1000px repeat-x;
+}
+.star-rating{\r
+       position:relative;\r
+       width:125px;\r
+       height:25px;\r
+       overflow:hidden;\r
+       list-style:none;\r
+       margin:0;\r
+       padding:0;\r
+       background-position: left top;\r
+}\r
+.star-rating li{
+       display: inline;\r
+}\r
+.star-rating a, 
+.star-rating .current-rating{\r
+       position:absolute;\r
+       top:0;\r
+       left:0;\r
+       text-indent:-1000em;\r
+       height:25px;\r
+       line-height:25px;\r
+       outline:none;\r
+       overflow:hidden;
+       border: none;
+}
+.star-rating a:hover,\r
+.star-rating a:active,\r
+.star-rating a:focus{
+       background-position: left bottom;
+}\r
+.star-rating a.one-star{\r
+       width:20%;\r
+       z-index:6;\r
+}\r
+.star-rating a.two-stars{\r
+       width:40%;\r
+       z-index:5;\r
+}\r
+.star-rating a.three-stars{\r
+       width:60%;\r
+       z-index:4;\r
+}\r
+.star-rating a.four-stars{\r
+       width:80%;\r
+       z-index:3;\r
+}\r
+.star-rating a.five-stars{\r
+       width:100%;\r
+       z-index:2;\r
+}\r
+.star-rating .current-rating{\r
+       z-index:1;\r
+       background-position: left center;
+}      \r
+\r
+/* for an inline rater */\r
+.inline-rating{\r
+       display:-moz-inline-block;\r
+       display:-moz-inline-box;\r
+       display:inline-block;\r
+       vertical-align: middle;\r
+}\r
+\r
+/* smaller star */\r
+.small-star{\r
+       width:50px;\r
+       height:10px;\r
+}
+.small-star,
+.small-star a:hover,\r
+.small-star a:active,\r
+.small-star a:focus,
+.small-star .current-rating{
+       background-image: url(star_small.gif);
+       line-height: 10px;
+       height: 10px;
+}\r\r\r\r
diff --git a/templates/static/star_small.gif b/templates/static/star_small.gif
new file mode 100644 (file)
index 0000000..f180d89
Binary files /dev/null and b/templates/static/star_small.gif differ
diff --git a/templates/static/tagcloud.css b/templates/static/tagcloud.css
new file mode 100644 (file)
index 0000000..77ca080
--- /dev/null
@@ -0,0 +1,51 @@
+span.tagcloud0 { font-size: 9px;}
+span.tagcloud1 { font-size: 10px;}
+span.tagcloud2 { font-size: 10px;}
+span.tagcloud3 { font-size: 11px;}
+span.tagcloud4 { font-size: 11px;}
+span.tagcloud5 { font-size: 12px;}
+span.tagcloud6 { font-size: 12px;}
+span.tagcloud7 { font-size: 13px;}
+span.tagcloud8 { font-size: 13px;}
+span.tagcloud9 { font-size: 14px;}
+span.tagcloud10 { font-size: 14px;}
+span.tagcloud11 { font-size: 15px;}
+span.tagcloud12 { font-size: 15px;}
+span.tagcloud13 { font-size: 16px;}
+span.tagcloud14 { font-size: 16px;}
+span.tagcloud15 { font-size: 17px;}
+span.tagcloud16 { font-size: 17px;}
+span.tagcloud17 { font-size: 18px;}
+span.tagcloud18 { font-size: 18px;}
+span.tagcloud19 { font-size: 19px;}
+span.tagcloud20 { font-size: 19px;}
+span.tagcloud21 { font-size: 20px;}
+span.tagcloud22 { font-size: 20px;}
+span.tagcloud23 { font-size: 21px;}
+span.tagcloud24 { font-size: 21px;}
+
+span.tagcloud0 a {text-decoration: none; color: #000;}
+span.tagcloud1 a {text-decoration: none; color: #000;}
+span.tagcloud2 a {text-decoration: none; color: #000;}
+span.tagcloud3 a {text-decoration: none; color: #000;}
+span.tagcloud10 a {text-decoration: none; color: #000;}
+span.tagcloud20 a {text-decoration: none; color: #000;}
+span.tagcloud11 a {text-decoration: none; color: #000;}
+span.tagcloud21 a {text-decoration: none; color: #000;}
+span.tagcloud12 a {text-decoration: none; color: #000;}
+span.tagcloud22 a {text-decoration: none; color: #000;}
+span.tagcloud13 a {text-decoration: none; color: #000;}
+span.tagcloud23 a {text-decoration: none; color: #000;}
+span.tagcloud14 a {text-decoration: none; color: #000;}
+span.tagcloud24 a {text-decoration: none; color: #000;}
+span.tagcloud4 a {text-decoration: none; color: #000;}
+span.tagcloud15 a {text-decoration: none; color: #000;}
+span.tagcloud5 a {text-decoration: none; color: #000;}
+span.tagcloud16 a {text-decoration: none; color: #000;}
+span.tagcloud6 a {text-decoration: none; color: #000;}
+span.tagcloud17 a {text-decoration: none; color: #000;}
+span.tagcloud7 a {text-decoration: none; color: #000;}
+span.tagcloud18 a {text-decoration: none; color: #000;}
+span.tagcloud8 a {text-decoration: none; color: #000;}
+span.tagcloud19 a {text-decoration: none; color: #000;}
+span.tagcloud9 a {text-decoration: none; color: #000;}
index 0b033c6951c8d7575a397efd49ff4f8bf20070d5..c62663d10e844bcc3d40a6de746df9f3fb28a8d2 100644 (file)
@@ -1,82 +1,59 @@
 String.prototype.trim = function(){ return this.replace(/^\s+|\s+$/g,'') }
 String.prototype.escRegExp = function(){ return this.replace(/[\\$*+?()=!|,{}\[\]\.^]/g,'\\$&') }
 String.prototype.unescHtml = function(){ var i,t=this; for(i in e) t=t.replace(new RegExp(i,'g'),e[i]); return t }
-function Suggestions() { this.length=0; this.picked=0 }
-var suggestions = new Suggestions();
-var tagSearch='', lastEdit='';
-var h={}, sections=[{},{},{},{},{},{}], selected={}, currentTag={},
-e={'&lt;':'<','&gt;':'>','&amp;':'&','&quot;':'"'};
-
-function init () {
-    document.onkeydown = document.onkeypress = document.onkeyup = handler
-    h.suggest = document.getElementById("suggestionlist");
-    h.tags = document.getElementById("tags");
-}
-
-function handler(event) { var e=(event||window.event) //w3||ie
-    if (e.type == 'keyup') {
+var availhash = {};
+var tags = tagList.split(/\s+/); 
+for (t in tags) { availhash[tags[t]] = 1 }
+
+function addHandler( elt ) {
+    var params = {
+        elt: elt,
+        oldvalue: elt.value
+    };
+    document.onkeyup = function (event) {
+       var e=(event||window.event) //w3||ie
         switch(e.keyCode) {
-            //case 8:  //backspace
-            //case 46: //delete
             case 35: //end
             case 36: //home
             case 39: // right
             case 37: // left
             case 32: // space
-                hideSuggestions(); break
-            case 38: case 40: break;
-            case 9: break;
-            case 13: break;
+                params.elt.style.background = "#fff";
+                $("suggestionlist").style.visibility='hidden';
+                break
+            case 38: case 40: case 9: case 13: break;
             default: 
-            updateSuggestions()
-}}
-    else if (e.type == "keypress") { lastEdit = h.tags.value }
-}
-
-function makeTag(parent, tag, js) {
-    parent.appendChild(document.createTextNode(" "+ tag))
-}
-
-function updateSuggestions() {
-    while (h.suggest.hasChildNodes()) h.suggest.removeChild(h.suggest.firstChild)
-    if(!getCurrentTag() || !currentTag.text) { 
-        hideSuggestions(); return false 
-    }
-    var tagArray = h.tags.value.toLowerCase().split(' '),
-    txt=currentTag.text.trim().escRegExp(), tagHash={}, t
-    for(t in tagArray) tagHash[tagArray[t]] = true;
-
-    var search = tagList.match(new RegExp(("(?:^| )("+txt+"[^ ]+)"), "gi"))
-    if(search){
-        var i;
-        for (i=0; i<search.length; i++) {
-            var tl = search[i].trim()
-            if(tagHash[tl])  continue // do not suggest already typed tag
-            suggestions[suggestions.length] = makeTag(h.suggest, tl, 'complete')
-            suggestions.length++
-    }} 
-    if (suggestions.length > 0) { showSuggestions() } 
-    else { hideSuggestions(); }
-}
-
-function getCurrentTag() {
-    if(h.tags.value == lastEdit) return true // no edit
-    if(h.tags == '') return false
-    currentTag = {}
-    var tagArray=h.tags.value.toLowerCase().split(' '), oldArray=lastEdit.toLowerCase().split(' '), currentTags = [], matched=false, t,o
-    for (t in tagArray) {
-        for (o in oldArray) {
-            if(typeof oldArray[o] == 'undefined') { oldArray.splice(o,1); break }
-            if(tagArray[t] == oldArray[o]) { matched = true; oldArray.splice(o,1); break; }
+            var tagArray = params.elt.value.toLowerCase().split(' '),
+            txt=tagArray[tagArray.length-1].trim().escRegExp(), tagHash={}, t
+            for(t in tagArray) tagHash[tagArray[t]] = true;
+            var sl = $("suggestionlist");
+            if (!txt) { sl.style.visibility = 'hidden'; return; }
+
+            var search = tagList.match(new RegExp(("(?:^| )("+txt+"[^ ]+)"), "gi"))
+            if(search){ 
+                params.elt.style.background = availhash[txt] ? "#dfd" : "#fff";
+                while(sl.childNodes.length > 0) { sl.removeChild(sl.firstChild) }
+                sl.style.visibility='visible';
+                var i;
+                for (i in search) {
+                    if (!tagHash[search[i]]) {
+                        var tn = document.createTextNode(search[i]);
+                        var a = document.createElement("a");
+                        var elt = params.elt;
+                        var closureTxt = txt;
+                        a.onclick = function () {
+                            elt.value = elt.value.replace(new RegExp(closureTxt+"$"),this.firstChild.nodeValue+" ");
+                            elt.focus(); 
+                            sl.style.visibility='hidden';
+                        };
+                        a.appendChild(tn);
+                        sl.appendChild(a);
+                    }
+                }
+            } else { 
+                params.elt.style.background = availhash[txt] ? "#dfd" : "#fdd";
+                sl.style.visibility='hidden';
+           }
         }
-        if(!matched) currentTags[currentTags.length] = t
-        matched=false
     }
-    // more than one word changed... abort
-    currentTag = { text:tagArray[currentTags[0]], index:currentTags[0] }
-    return true
 }
-
-
-function hideSuggestions() { h.suggest.style.visibility='hidden' }
-function showSuggestions() { h.suggest.style.visibility='visible' }
index 9a7361a61d709343e7672e8ad8266765b43bfb1c..cc9d344b0184f12cf16c6379cefdd842f52a8740 100644 (file)
@@ -8,13 +8,13 @@
     [% FOR tag = minilist %]
         <td>
         <table><tr> <td width="100%" class="taglistheader">
-        <a href="[%base%]/tag/view/[%tag.name |html|uri%]">[% tag %]</a>
+        <a href="[%base%]/tag/view/[%tag.name |html%]">[% tag %]</a>
         </td></tr>
         [% SET photos = tag.photos %]
         [% IF photos.last %]
         <tr><td>
-        <a href="[%base%]/tag/view/[%tag.name |html|uri%]">
-            <img src="[% photos.last.thumb_url |uri%]">
+        <a href="[%base%]/tag/view/[%tag.name |html%]">
+            <img src="[% photos.last.thumb_url %]">
         </a>
         </td></tr>
         <tr><td class="info">
index 9a7361a61d709343e7672e8ad8266765b43bfb1c..fea4e0d1d4a90b63d228571eb19c55ac8d70f990 100644 (file)
@@ -8,13 +8,13 @@
     [% FOR tag = minilist %]
         <td>
         <table><tr> <td width="100%" class="taglistheader">
-        <a href="[%base%]/tag/view/[%tag.name |html|uri%]">[% tag %]</a>
+        <a href="[%base%]/tag/view/[%tag.name |html%]">[% tag %]</a>
         </td></tr>
-        [% SET photos = tag.photos %]
+        [% IF tag.photos.isa("Memories::Photo"); SET photos = [ tag.photos ]; ELSE; SET photos = tag.photos; END %]
         [% IF photos.last %]
         <tr><td>
-        <a href="[%base%]/tag/view/[%tag.name |html|uri%]">
-            <img src="[% photos.last.thumb_url |uri%]">
+        <a href="[%base%]/tag/view/[%tag.name |html%]">
+            <img src="[% photos.last.thumb_url%]">
         </a>
         </td></tr>
         <tr><td class="info">
index 59bcad08f5bb55366f8cc2ddcc170ef8a17e7a37..c5da0f680994b812289783386d6a6e0f4927e32a 100644 (file)
@@ -1,4 +1,4 @@
-<div class="albums">
+<div class="messages">
 [% IF request.user == user %]
     <h2> Albums </h2>
     <form method="post" action="[%base%]/user/edit_albums/[%user.id%]">
index 66ba34ed4b03eeccd1551037b9f97b5f67edd461..078c83fe609834017f8f67cc8103e086d0f49d3a 100644 (file)
@@ -10,7 +10,7 @@
         [% SET photos = user.photos %]
         <a href="[%base%]/user/view/[%user.id%]">[% user %]<br/>
         [% IF photos.last %]
-        <img src="[% photos.last.thumb_url |uri%]"></a><br/>
+        <img src="[% photos.last.thumb_url %]"></a><br/>
         <span class="info">
             [%photos.size %] 
         photo[%-"s" IF photos.size != 1 %]</span>