]> git.decadent.org.uk Git - memories.git/blob - Memories/Photo.pm
Oops, we need this...
[memories.git] / Memories / Photo.pm
1 package Memories::Photo;
2 use strict;
3 use Carp qw(cluck confess);
4 use base qw(Memories::DBI Maypole::Model::CDBI::Plain);
5 use Time::Piece;
6 use Image::Seek;
7 use constant PAGER_SYNTAX => "LimitXY";
8 __PACKAGE__->columns(Essential => qw(id title uploader uploaded x y rating rated hit_count));
9 __PACKAGE__->untaint_columns(printable => [qw/title/]);
10 __PACKAGE__->columns(TEMP => qw/exif_object/);
11 __PACKAGE__->set_sql(recent => q{
12 SELECT __ESSENTIAL__
13 FROM __TABLE__
14 ORDER BY uploaded DESC
15 LIMIT 4
16 });
17
18 __PACKAGE__->has_many(comments => "Memories::Comment");
19 __PACKAGE__->has_a(uploader => "Memories::User");
20 __PACKAGE__->has_a(uploaded => "Time::Piece",
21     inflate => sub { Time::Piece->strptime(shift, "%Y-%m-%d %H:%M:%S") },
22     deflate => 'datetime',
23 );
24
25 sub do_upload :Exported {
26     my ($self, $r) = @_;
27     my %upload = $r->upload("photo");
28
29     # XXX Stop anonymous uploads!
30     my $photo = $self->create({
31         uploader => $r->user,
32         uploaded => Time::Piece->new(),
33         title => $r->params->{title},
34         hit_count => 0,
35         rating => 0,
36         rated => 0, # Oh, the potential for divide by zero errors...
37     });
38
39     # Dump content
40     if (!open OUT, ">". $photo->path("file")) {
41         die "Can't write ".$photo->path("file")." because $!";
42     }
43     # XXX Check it's a JPEG, etc.
44     # XXX Unzip ZIP file
45     print OUT $upload{content};
46     close OUT;
47     my ($x, $y) = dim(image_info($photo->path));
48     $photo->x($x); $photo->y($y);
49
50     # Rotate?
51     $photo->unrotate(); 
52     if (!$photo->title){ 
53         $photo->title($photo->title_exif || $upload{filename});
54     }
55
56     $photo->make_thumb;
57     $r->{params}{tags} ||= join " ", map { qq{"$_"} } $photo->tags_exif;
58     $photo->add_tags($r->{params}{tags});
59     $photo->add_to_imageseek_library;
60     Memories->zap_cache();
61
62     # Add system tags here
63     my $tag = "date:".$photo->shot->ymd;
64     $photo->add_to_system_tags({tag => Memories::SystemTag->find_or_create({name =>$tag}) });
65
66     # Set it up to go again
67     $r->objects([$photo]);
68     $r->template("view");
69     $r->message("Thanks for the upload! ".
70         ($r->{params}{tags} ? "" 
71         : "Don't forget to <a href=\"?".$r->config->uri_base."photo/view/".$photo->id."?active=tagedit\">tag your photo</a>"
72         )
73     ); 
74 }
75
76 sub approx_rating {
77     my $self = shift;
78     $self->rated or return 0;
79     int($self->rating/$self->rated*10)/10;
80 }
81
82 sub add_rating :Exported {
83     my ($self, $r) = @_;
84     my $photo = $r->{objects}[0];
85     my $delta = $r->{params}{rating};
86     if ($delta < 0 or $delta > 5) { return; } # Scammer
87     # XXX Race
88     $photo->rating($photo->rating() + $delta);
89     $photo->rated($photo->rated() + 1);
90     $r->output(""); # Only used by ajax
91 }
92
93 sub view :Exported {
94     my ($self, $r) = @_;
95     my $photo = $r->{objects}[0];
96     $photo->hit_count($photo->hit_count()+1);
97     if ($r->{session}{last_search}) {
98         # This is slightly inefficient
99         my @search = split/,/, $r->{session}{last_search};
100         my $found = -1;
101         for my $i (0..$#search) {
102             next unless $photo->id == $search[$i];
103             $found = $i;
104         }
105         return unless $found > -1;
106         $r->{template_args}{next} = $self->retrieve($search[$found+1]) 
107             if $found+1 <= $#search;
108         $r->{template_args}{prev} = $self->retrieve($search[$found-1])
109             if $found-1 >= 0;
110     }
111 }
112 sub upload :Exported {}
113 sub exif :Exported {}
114 sub comment :Exported {}
115 sub tagedit :Exported {}
116 sub similar :Exported {}
117
118 use Class::DBI::Plugin::Pager;
119 use Class::DBI::Plugin::AbstractCount;
120
121 sub recent :Exported {
122     my ($self, $r) = @_;
123     my $page = $r->params->{page} || 1;
124     my $pager = $self->pager(
125         per_page => Memories->config->{photos_per_page}, 
126         page => $page,
127         syntax => PAGER_SYNTAX,
128         order_by => "uploaded desc"
129     );
130     $r->objects([$pager->retrieve_all ]);
131     $r->{template_args}{pager} = $pager;
132     $r->last_search;
133 }
134
135 sub add_comment :Exported {
136     my ($self, $r, $photo) = @_;
137     $r->template("view");
138     $r->objects->[0]->add_to_comments({
139         name => $r->params->{name},
140         content => $r->params->{content}
141     });
142 }
143
144 sub format { 
145     "jpg" # For now
146
147
148 use Cache::MemoryCache;
149 use Image::Info qw(dim image_info);
150 use Image::ExifTool;
151 my $cache = new Cache::MemoryCache( { 'namespace' => 'MemoriesInfo' });
152
153 sub add_to_imageseek_library {
154     my $self = shift;
155     Image::Seek::cleardb();
156     my $img = Image::Imlib2->load($self->path("file"));
157
158     Image::Seek::add_image($img, $self->id);
159     # Merge this new one into the main database; there is a bit of a
160     # race condition here. XXX
161     Image::Seek::loaddb(Memories->config->{image_seek});
162     Image::Seek::savedb(Memories->config->{image_seek});
163 }
164
165 sub recommended_tags {
166     my $self = shift;
167     my %tags = map { $_->name => $_ }
168                map { $_->tags } 
169                $self->find_similar(3);
170     values %tags;
171 }
172
173 sub find_similar {
174     my ($self, $count) = @_;
175     Image::Seek::cleardb();
176     Image::Seek::loaddb(Memories->config->{image_seek});
177     my @res = map {$_->[0] } Image::Seek::query_id($self->id, $count);
178     shift @res; # $self
179     map { $self->retrieve($_) } @res;
180 }
181
182 sub unrotate {
183     my $self = shift;
184     my $orient = $self->exif_info->{EXIF}->{Orientation};
185     return if $orient !~/Rotate (\d+)/i;
186     my $steps = $1/90;
187     my $img = Image::Imlib2->load($self->path("file"));
188     $img->image_orientate($steps);
189     $img->save($self->path("file"));
190 }
191
192 sub exif_info {
193     my $self = shift;
194     my $info = $self->exif_object;
195     return $info if $info;
196     # Get it from the Cache
197     if (!($info = $cache->get($self->id))) {
198         # Create it
199         $info = $self->_exif_info;
200         $cache->set($self->id, $info);
201     }
202     $self->exif_object($info);
203     $info;
204 }
205
206 my %banned_tags = map { $_ => 1 }
207     qw( CodedCharacterSet ApplicationRecordVersion );
208
209 sub _exif_info {
210     my $exifTool = new Image::ExifTool;
211     $exifTool->Options(Group0 => ['IPTC', 'EXIF', 'XMP', 'MakerNotes', 'Composite']);
212     my $info = $exifTool->ImageInfo(shift->path);
213     my $hash = {};
214     foreach my $tag ($exifTool->GetFoundTags('Group0')) {
215         next if $banned_tags{$tag};
216          my $group = $exifTool->GetGroup($tag);
217          my $val = $info->{$tag};
218          next if ref $val eq 'SCALAR';
219          next if $val =~ /^[0\s]*$/ or $val =~ /^nil$/;
220          $hash->{$group}->{$exifTool->GetDescription($tag)} = $val;
221     }
222     return $hash;
223 }
224
225 # XXX Cache this
226 sub dimensions { join "x", $_[0]->x, $_[0]->y }
227
228 sub is_bigger {
229     my ($self, $size) = @_;
230     return 1 if $size eq "full";
231     my ($w, $h) = ($self->x, $self->y);
232     my ($w2, $h2) = split /x/, $size;
233     return 1 if $w > $w2 or $h > $h2;
234     return 0;
235 }
236
237 sub sized_url { # Use this rather than ->path from TT
238     my ($self, $size) = @_;
239     my $url = Memories->config->{data_store_external};
240     my $resized = Memories->config->{sizes}->[$size];
241     if (!$resized) { cluck "Asked for crazy size $size"; return; }
242     if ($resized eq "full") { return $self->path("url") }
243     $self->scale($resized) 
244         unless -e $self->path( file => $resized );
245     return $self->path(url => $resized);
246 }
247
248 sub path { 
249     my ($self, $is_url, $scale) = @_;
250     my $path =
251         Memories->config->{$is_url eq "url" ? "data_store_external" : "data_store" };
252     if ($scale) { $path .= "$scale/" }
253     # Make dir if it doesn't exist, save trouble later
254     use File::Path;
255     if ($is_url ne "url") {mkpath($path);}
256     $path .= $self->id.".".$self->format;
257     return $path;
258 }
259
260 sub thumb_url { shift->path(url => Memories->config->{thumb_size}); }
261 sub make_thumb { shift->scale(Memories->config->{thumb_size}, 1); }
262
263 use Image::Imlib2;
264 sub scale {
265     my ($self, $scale, $swap) = @_;
266     my ($x, $y) = split /x/, $scale;
267     # Find out smaller axis
268     my ($cur_x, $cur_y) = ($self->x, $self->y);
269     if (!$swap) {
270         if ($cur_x < $cur_y) { $y = 0 } else { $x = 0 }
271     } else {
272         if ($cur_x > $cur_y) { $y = 0 } else { $x = 0 }
273     }
274     my $img = Image::Imlib2->load($self->path("file"));
275     unless ($img) {
276         cluck "Couldn't open image file ".$self->path("file");
277         return;
278     }
279     $img = $img->create_scaled_image($x, $y);
280     $img->image_set_format("jpeg");
281     my $file = $self->path( file => $scale );
282     $img->save($file);
283     if ($!) {
284         cluck "Couldn't write image file $file ($!)"; 
285         return;
286     }
287 }
288
289 use Text::Balanced qw(extract_multiple extract_quotelike);
290 sub edit_tags :Exported {
291     my ($self, $r) = @_;
292     my $photo = $r->objects->[0];
293     my %params = %{$r->params};
294     for (keys %params) { 
295         next unless /delete_(\d+)/;
296         my $tagging = Memories::Tagging->retrieve($1) or next;
297         next unless $tagging->photo->id == $photo->id;
298         $tagging->delete;
299     }
300     $photo->add_tags($params{newtags});
301     $r->template("view");
302 }
303
304 sub add_tags {
305     my ($photo, $tagstring) = @_;
306
307     for my $tag (map { s/^"|"$//g; $_} extract_multiple(lc $tagstring, [ \&extract_quotelike, qr/([^\s]+)/ ], undef,1)) {
308         $photo->add_to_tags({tag => Memories::Tag->find_or_create({name =>$tag}) })
309     }
310 }
311
312 # Work out some common properties from a set of potential photo metadata
313 # tags
314 sub _grovel_metadata {
315     my ($self, @tags) = @_;
316     my %md = map {%$_} values %{$self->exif_info};
317     for (@tags) {
318         if ($md{$_} and $md{$_} =~/[^ 0:]/) { return $md{$_} }
319     }
320     return;
321 }
322
323 sub shot {
324     my $self = shift;
325     my $dt = $self->_grovel_metadata(
326         'Shooting Date/Time',
327         'Date/Time Of Digitization',
328         'Date/Time Of Last Modification'
329     );
330     if (!$dt) { return $self->uploaded }
331     return Time::Piece->strptime($dt, "%Y:%m:%d %T") || $self->uploaded;
332 }
333
334 sub description {
335     shift->_grovel_metadata(
336         'Description', 'Image Description', 'Caption-Abstract'
337     );
338 }
339
340 sub title_exif { shift->_grovel_metadata( 'Headline', 'Title'); }
341 sub license { shift->_grovel_metadata( 'Rights Usage Terms', 'Usage Terms' ) }
342 sub copyright { shift->_grovel_metadata( 'Rights', 'Copyright', 'Copyright Notice') }
343
344 # This one's slightly different since we want everything we can get...
345 sub tags_exif {
346     my $self = shift;
347     my %md = map {%$_} values %{$self->exif_info};
348     my %tags = 
349         map { s/\s+/-/g; lc $_ => 1  }
350         map { split /\s*,\s*/, $md{$_}}
351         grep {$md{$_} and $md{$_} =~/[^ 0:]/}
352         (qw(Keywords Subject City State Location Country Province-State), 
353         'Transmission Reference', 'Intellectual Genre', 
354         'Country-Primary Location Name'
355         );
356     return keys %tags;
357 }
358 1;