File Coverage

blib/lib/FSpot/DbTool.pm
Criterion Covered Total %
statement 1 3 33.3
branch n/a
condition n/a
subroutine 1 1 100.0
pod n/a
total 2 4 50.0


line stmt bran cond sub pod time code
1             package FSpot::DbTool;
2 1     1   24582 use Moose;
  0            
  0            
3             use MooseX::StrictConstructor;
4             use Moose::Util::TypeConstraints;
5             use MooseX::Params::Validate;
6              
7             use File::Util;
8             use File::HomeDir;
9             use File::Copy;
10             use DBI;
11             use Data::Dumper;
12             use YAML::Any qw/Dump/;
13             use Log::Log4perl;
14              
15             use 5.010000;
16             our $VERSION = '0.02';
17              
18             =pod
19              
20             =head1 NAME
21              
22             FSpot::DbTool - An interface to the F-Spot database
23              
24             =head1 SYNOPSIS
25              
26             use FSpot::DbTool;
27             my $fsdb = FSpot::DbTool->new( 'db_file' => '~/.config/f-spot/photos.db' );
28              
29             Parameters:
30              
31             db_file Override the default location for the database file
32             ignore_db_version Normally this module is designed to work with a specific
33             version of the FSpot database. If your version is different
34             but you are sure you want to continue anyway, set this parameter.
35              
36             =head1 DESCRIPTION
37             Interface to FSpot database =head1 PROPERTIES
38              
39             =cut
40              
41              
42             # The version of the database for which this module is designed to work
43             has 'designed_for_db_version' => ( is => 'ro',
44             isa => 'Int',
45             required => 1,
46             lazy => 1,
47             default => 18 );
48              
49              
50             # Give the ability to ignore conflicting database version
51             has 'ignore_db_version' => ( is => 'ro',
52             isa => 'Int',
53             required => 1,
54             default => 0 );
55              
56             # Location of the FSpot database file
57             has 'db_file' => ( is => 'ro',
58             isa => 'Str',
59             required => 1,
60             default => sub{ File::HomeDir->my_home . '/.config/f-spot/photos.db' },
61             );
62              
63              
64             # Database handler - It's lazy, so it should only connect to the database when necessary
65             has 'dbh' => ( is => 'ro',
66             isa => 'DBI::db',
67             required => 1,
68             lazy => 1,
69             default => sub{ my $self = shift;
70             if( ! -f $self->db_file ){
71             die( sprintf "Database file [%s] does not exist\n", $self->db_file() );
72             }
73             my $dbh = DBI->connect('dbi:SQLite:dbname=' . $self->db_file(),'','');
74             if( ! $dbh ){
75             die( "Could not connect to the database: $!" );
76             }
77             if( ! $self->ignore_db_version ){
78             my $sth = $dbh->prepare( 'SELECT data FROM meta WHERE name=?' );
79             $sth->execute( 'F-Spot Database Version' );
80             my $row = $sth->fetchrow_hashref();
81             if( ! $row ){
82             die( "Could not identify the F-Spot Database version from the meta table\n" );
83             }
84             if( $row->{data} ne $self->designed_for_db_version ){
85             die( "This interface is designed to work with version " . $self->designed_for_db_version .
86             " of the FSpot database but you have $row->{data}.\n" .
87             "If you want to continue anyway, set the ignore_db_version parameter\n" );
88             }
89             $sth->finish();
90             }
91             return $dbh;
92             } );
93              
94             # Hard-coded reference for the tables and columns which exist in the f-spot database
95             # Would be better to get these dynamically from the database!
96             has 'db_columns' => ( is => 'ro',
97             isa => 'HashRef',
98             required => 1,
99             lazy => 1,
100             default => sub{ return { exports => [ qw/id image_id image_version_id export_type export_token/ ],
101             jobs => [ qw/id job_type job_options run_at job_priority/ ],
102             meta => [ qw/id name data/ ],
103             photo_tags => [ qw/photo_id tag_id/ ],
104             photo_versions => [ qw/photo_id version_id name base_uri filename import_md5 protected/ ],
105             photos => [ qw/id time base_uri filename description roll_id default_version_id rating/ ],
106             rolls => [ qw/id time/ ],
107             sqlite_sequence => [ qw/name seq/ ],
108             tags => [ qw/id name category_id is_category set_priority icon/ ],
109             } } );
110              
111             # A list of the tools loaded
112             has 'tools' => ( is => 'ro',
113             isa => 'ArrayRef',
114             required => 1,
115             default => sub{ [] }, );
116              
117              
118             has 'logger' => ( is => 'rw',
119             isa => 'Log::Log4perl::Logger',
120             default => sub{ Log::Log4perl->get_logger('fspot') },
121             required => 1 );
122              
123              
124             # A search entry is always an arrayref with a column, comparator, and value
125             subtype 'SearchEntry'
126             => as 'ArrayRef'
127             => where { $_->[1] =~ m/^(like|>|>=|<|<=|<>|=)$/i and scalar( @{ $_ } ) == 3 }
128             => message { "Not an valid search entry" };
129              
130             # A Path must exist
131             subtype 'Path'
132             => as 'Str'
133             => where { -f $_ or -d $_ }
134             => message { "Not a path" };
135              
136             # A Path must exist
137             subtype 'NonEmptyHashRef'
138             => as 'HashRef'
139             => where { scalar( keys( %{ $_ } ) ) > 0 }
140             => message { "Not a non-empty HashRef" };
141              
142             =head1 METHODS
143              
144             =head2 new()
145              
146             Object constructor.
147              
148             =head2 load_tool( $tool_name )
149              
150             Loads a tool (Moose::Role) which brings special database manipulation methods with it
151              
152             =cut
153             sub load_tool{
154             my ( $self, %params ) = validated_hash(
155             \@_,
156             tool => { isa => 'Str' },
157             );
158             if( $params{tool} !~ m/^\w*$/ ){
159             die( "Not a valid tool name: $params{tool}\n" );
160             }
161              
162             # See if the tool is already loaded
163             foreach( @{ $self->tools() } ){
164             if( $_ eq $params{tool} ){
165             $self->logger->debug( "I've already loaded this tool - not loading again!: $params{tool}" );
166             return 1;
167             }
168             }
169             $self->logger->debug( "Loading tool: $params{tool}" );
170              
171             eval{
172             with 'FSpot::DbTools::' . $params{tool};
173             };
174             if( $@ ){
175             warn( "Couldn not load tool $params{tool}\n$@\n" );
176             return 0;
177             }
178             push( @{ $self->{tools} }, $params{tool} );
179             }
180              
181             =head2 compact_db()
182              
183             Compacts the database with the VACUUM command
184              
185             Usage:
186              
187             $fs->compact_db();
188              
189             =cut
190             sub compact_db{
191             my ( $self ) = @_;
192             my $sth = $self->dbh->prepare( 'VACUUM' );
193             $self->logger->debug( "Compacting database" );
194             $sth->execute;
195             $self->logger->debug( "Finished compacting database" );
196             $sth->finish;
197             }
198              
199             =head2 backup_db( %params )
200              
201             Backs up the database. If target is defined, it will write to there, otherwise like this:
202              
203             Original:
204              
205             ~/.config/f-spot/photos.db
206              
207             Backup:
208              
209             ~/.config/f-spot/photos.db.bak.0
210              
211             Usage:
212              
213             $fs->backup_db();
214              
215             =cut
216             sub backup_db{
217             my ( $self, %params ) = validated_hash(
218             \@_,
219             target => { isa => 'Str', optional => 1 },
220             );
221             # First see if the db_file is defined and exists
222             if( ! -f $self->db_file ){
223             die( "Cannot backup db, when db_file does not exist...\n" );
224             }
225              
226             my $target = $params{target};
227             # If target wasn't defined, try and find
228             if( ! $target ){
229             my $counter = 0;
230             do{
231             if( $counter == 0 ){
232             $target = $self->db_file() . '.bak';
233             }else{
234             $target = $self->db_file() . '.bak.' . $counter;
235             }
236             $counter++;
237             if( -f $target ){
238             $target = undef;
239             }
240             }while( ! $target and $counter < 1000 );
241             if( ! $target ){
242             die( "Could not find a target to backup db to\n" );
243             }
244             }
245             $self->logger->debug( "Backing up database from " . $self->db_file() . " to $target" );
246              
247             if( ! copy( $self->db_file(), $target ) ){
248             die( "Could not backup db_file: $!\n" );
249             }
250             }
251              
252             =head2 search( %params )
253              
254             Returns (an array of) rows (all columns) of matching entries
255              
256             Usage:
257              
258             $fs->search( table => $table,
259             search => [ [ 'filename', 'LIKE', '%123%' ], [ .... ] ] );
260              
261             =cut
262             sub search{
263             my ( $self, %params ) = validated_hash(
264             \@_,
265             table => { isa => 'Str' },
266             search => { isa => 'ArrayRef[SearchEntry]', optional => 1 }
267             );
268              
269             my( @where, @vals, %ids );
270             foreach my $entry( @{ $params{search} } ){
271             $self->column_must_exist( table => $params{table},
272             column => $entry->[0] );
273              
274             push( @where, "$entry->[0] $entry->[1] ?" );
275             push( @vals, $entry->[2] );
276             }
277              
278             # Get the entries from the photos table
279             my $sql = "SELECT * FROM $params{table}";
280             if( $#where >= 0 ){
281             $sql .= " WHERE " . join( ' AND ', @where );
282             }
283              
284             my $sth = $self->dbh->prepare( $sql );
285             $sth->execute( @vals );
286              
287             my( $row, @results );
288             while( $row = $sth->fetchrow_hashref ){
289             push( @results, $row );
290             }
291             $sth->finish();
292             return @results;
293             }
294              
295             =head2 update_photo( %params )
296              
297             Update a photo in the database
298              
299             Usage:
300             $details = { 'filename' => $newname,
301             'base_uri' => $new_base_uri };
302             $fs->update_photo_version( photo_id => $id,
303             details => $details );
304              
305             =cut
306             sub update_photo{
307             my ( $self, %params ) = validated_hash(
308             \@_,
309             photo_id => { isa => 'Int' },
310             details => { isa => 'NonEmptyHashRef' },
311             );
312             my( @cols, @vals );
313             foreach my $column( keys( %{ $params{details} } ) ){
314             $self->column_must_exist( table => 'photos',
315             column => $column );
316             push( @cols, "$column=?" );
317             push( @vals, $params{details}->{$column} );
318             }
319             my $sql = 'UPDATE photos SET ' . join( ', ', @cols ) . " WHERE id=?;";
320             push( @vals, $params{photo_id} );
321              
322             $self->logger->debug( "Updating photo $params{photo_id} with details:\n " . Dump( $params{details} ) );
323              
324             my $sth = $self->dbh->prepare( $sql );
325             $sth->execute( @vals );
326             $sth->finish();
327             }
328              
329             =head2 update_photo_version( %params )
330              
331             Update a version of a photo in the database
332              
333             Usage:
334             $details = { 'filename' => $newname,
335             'base_uri' => $new_base_uri };
336             $fs->update_photo_version( photo_id => $id,
337             version_id => $version_id,
338             details => $details );
339              
340             =cut
341             sub update_photo_version{
342             my ( $self, %params ) = validated_hash(
343             \@_,
344             photo_id => { isa => 'Int' },
345             version_id => { isa => 'Int' },
346             details => { isa => 'NonEmptyHashRef' },
347             );
348              
349             my( @cols, @vals );
350             foreach my $column( keys( %{ $params{details} } ) ){
351             $self->column_must_exist( table => 'photo_versions',
352             column => $column );
353             push( @cols, "$column=?" );
354             push( @vals, $params{details}->{$column} );
355             }
356              
357             my $sql = 'UPDATE photo_versions SET ' . join( ', ', @cols ) .
358             " WHERE photo_id=? AND version_id=?;";
359             push( @vals, $params{photo_id}, $params{version_id} );
360             my $sth = $self->dbh->prepare( $sql );
361             $self->logger->debug( "Updating photo_version $params{photo_id} with details:\n " . Dump( $params{details} ) );
362              
363             $sth->execute( @vals );
364             $sth->finish();
365             }
366              
367             =head2 add_tag( %params )
368              
369             Add a tag.
370             Parent name is optional. If not defined, the tag will be attached to the root.
371              
372             Usage:
373              
374             $fs->add_tag( name => $name,
375             parent_name => $parent_name );
376              
377             =cut
378             sub add_tag{
379             my ( $self, %params ) = validated_hash(
380             \@_,
381             name => { isa => 'Str' },
382             parent_name => { isa => 'Str', optional => 1 },
383             );
384              
385             # If the parent was defined, try and find it
386             my $parent;
387             if( $params{parent_name} ){
388             my @result_parent = $self->search( table => 'tags',
389             search => [ [ 'name', '=', $params{parent_name} ] ] );
390              
391             if( scalar( @result_parent ) == 0 ){
392             die( "Parent tag ($params{parent_name}) does not exist\n" );
393             }
394             $parent = $result_parent[0];
395             }
396              
397             # If we found a parent, find the ID, otherwise just create it as a "root" tag
398             my( $sql, @vals );
399             if( $parent ){
400             if( $self->search( table => 'tags',
401             search => [ [ 'name', '=', $params{name} ], [ 'category_id', '=', $parent->{id} ] ] ) ){
402             die( "Tag ($params{name}) already exists as a child of $params{parent_name}\n" );
403             }else{
404             $sql = 'INSERT INTO tags ( name, category_id, is_category, sort_priority ) VALUES( ?, ?, 1, 0 )';
405             @vals = ( $params{name}, $parent->{id} );
406             }
407             }else{
408             $sql = 'INSERT INTO tags ( name, category_id, is_category, sort_priority ) VALUES( ?, 0, 1, 0 )';
409             @vals = ( $params{name} );
410             }
411              
412             my $sth = $self->dbh->prepare( $sql );
413             $sth->execute( @vals );
414             $sth->finish();
415             }
416              
417             =head2 tag_photo( %params )
418              
419             Tag a photo
420              
421             Usage:
422              
423             $fs->tag_photo( photo_id => $photo_id,
424             tag_id => $tag_id );
425              
426             =cut
427             sub tag_photo{
428             my ( $self, %params ) = validated_hash(
429             \@_,
430             photo_id => { isa => 'Int' },
431             tag_id => { isa => 'Int' },
432             );
433             # First confirm it isn't already tagged
434             my $sql = 'SELECT * FROM photo_tags WHERE photo_id=? AND tag_id=?';
435             my $sth = $self->dbh->prepare( $sql );
436             $sth->execute( $params{photo_id}, $params{tag_id} );
437              
438             # Not tagged, so add tag
439             if( ! $sth->fetchrow_hashref ){
440             $sql = 'INSERT INTO photo_tags ( photo_id, tag_id ) VALUES( ?, ? )';
441             $sth = $self->dbh->prepare( $sql );
442             $sth->execute( $params{photo_id}, $params{tag_id} );
443             }
444             $sth->finish();
445             }
446              
447             =head2 untag_all( %params )
448              
449             Remove all of these tag links
450              
451             Usage:
452              
453             $fs->untag_all( tag_id => $tag_id );
454              
455             =cut
456             sub untag_all{
457             my ( $self, %params ) = validated_hash(
458             \@_,
459             tag_id => { isa => 'Int' },
460             );
461             my $sql = 'DELETE FROM photo_tags WHERE tag_id=?';
462             my $sth = $self->dbh->prepare( $sql );
463             $sth->execute( $params{tag_id} );
464             $sth->finish();
465             }
466              
467             =head2 column_exists( %params )
468              
469             Test if the column exists for this table
470             Returns 1 if it does, undef if not
471              
472             Usage:
473              
474             $fs->column_exists( table => $table,
475             column => $column );
476              
477             =cut
478             sub column_exists{
479             my ( $self, %params ) = validated_hash(
480             \@_,
481             table => { isa => 'Str' },
482             column => { isa => 'Str' },
483             );
484             if( $self->db_columns->{$params{table}} ){
485             foreach( @{ $self->db_columns->{$params{table}} } ){
486             if( $_ eq $params{column} ){
487             return 1;
488             }
489             }
490             }
491             return undef;
492             }
493              
494             =head2 column_must_exist( %params )
495              
496             Returns 1 if the table/column exists, dies if it doesn't
497              
498             Usage:
499              
500             $fs->column_must_exist( table => $table,
501             column => $column );
502              
503             =cut
504             sub column_must_exist{
505             my( $self, %params ) = @_;
506             if( ! $self->column_exists( %params ) ){
507             die( "Column $params{table}/$params{column} does not exist!\n" );
508             }
509             return 1;
510             }
511              
512              
513             1;
514             __END__
515              
516             =head1 AUTHOR
517              
518             Robin Clarke C<perl@robinclarke.net>
519              
520             =head1 SUPPORT
521              
522             You can find documentation for this module with the perldoc command.
523              
524             perldoc FSpot::DbTool
525              
526              
527             You can also look for information at:
528              
529             =over 4
530              
531             =item * Repository on Github
532              
533             L<https://github.com/robin13/FSpot--DbTool>
534              
535             =item * RT: CPAN's request tracker
536              
537             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Git-Demo>
538              
539             =item * AnnoCPAN: Annotated CPAN documentation
540              
541             L<http://annocpan.org/dist/Git-Demo>
542              
543             =item * CPAN Ratings
544              
545             L<http://cpanratings.perl.org/d/Git-Demo>
546              
547             =item * Search CPAN
548              
549             L<http://search.cpan.org/dist/Git-Demo/>
550              
551             =back
552              
553             =head1 ACKNOWLEDGEMENTS
554              
555             L<http://f-spot.org/>
556              
557             =head1 LICENSE AND COPYRIGHT
558              
559             Copyright 2010 Robin Clarke.
560              
561             This program is free software; you can redistribute it and/or modify it
562             under the terms of either: the GNU General Public License as published
563             by the Free Software Foundation; or the Artistic License.
564              
565             See http://dev.perl.org/licenses/ for more information.
566              
567             =cut