The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
# -*- perl -*-

#
# $Id: Export.pm,v 1.10 2005/02/16 23:59:10 eserte Exp $
# Author: Slaven Rezic
#
# Copyright (C) 2001 Online Office Berlin. All rights reserved.
# Copyright (C) 2002 Slaven Rezic.
# This is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License, see the file COPYING.

#
# Mail: slaven@rezic.de
# WWW:  http://we-framework.sourceforge.net
#

package WE::Export;

use base qw/Class::Accessor/;

__PACKAGE__->mk_accessors(qw/Root Tmpdir Archive Verbose Force
			     _DirMode _FileMode/);

use strict;
use vars qw($VERSION);
$VERSION = sprintf("%d.%02d", q$Revision: 1.10 $ =~ /(\d+)\.(\d+)/);

use WE::Util::Functions qw(_save_pwd is_in_path file_name_is_absolute);

=head1 NAME

WE::Export - export a WE::DB database

=head1 SYNOPSIS

    use WE::Export;
    my $r = new WE::DB ...;
    my $ex = new WE::Export $r;
    $ex->export_all;

=head1 DESCRIPTION

This module provides export and import methods for the WE::DB database.

=cut

use File::Path;
use File::Basename;
use File::Find;
use File::Copy;
use Data::Dumper 2.101; # older versions are buggy
use DB_File;
use Fcntl;
use Safe;
use Cwd;

use vars qw(%db_filename);
%db_filename = (
    ObjDB        => 'objdb',
    UserDB       => 'userdb',
    OnlineUserDB => 'onlinedb',
    NameDB       => 'name',
);

# Should be something which is recognized by the "*" glob:
use constant MTREE_FILE => "mtree";

=head2 CONSTRUCTOR new

Called as C<new WE::Export $rootdb>. Create a new WE::Export object
for the given database C<$rootdb>. Additional arguments (as dashed
key-value pairs) will be passed to the object.

=cut

sub new {
    my($pkg, $rootdb, %args) = @_;
    my $self = { };
    bless $self, $pkg;
    $self->_DirMode(undef);
    $self->_FileMode(undef);
    while(my($k,$v) = each %args) {
	die "Option does not start with a dash: $k" if $k !~ /^-/;
	$self->{ucfirst(substr($k,1))} = $v;
    }
    $self->Root($rootdb);
    $self;
}

=head2 METHODS

=over 4

=item export_db

Create data dumper files of the metadata databases and store them into
the directory specified by the C<Tmpdir> member. Data::Dumper files
are created because most DBM file formats are incompatible between
various systems.

=cut

sub export_db {
    my $self = shift;

    my $rootdb = $self->Root;
    my $objdb = $rootdb->ObjDB;
    my $objdbfile = $objdb->DBFile;
    my $objdump;
    my $dd;

    # Note: do not use connect(), because we want the plain data, not the
    # MLDBM cooked data
    tie my %db, 'DB_File', $objdbfile, O_RDONLY, 0644
	or die "Can't tie to $objdbfile: $!";
    $dd = Data::Dumper->new([\%db],['ObjDB']);
    $dd->Indent(0);
    #$dd->Purity(1); # XXX
    $objdump = $dd->Dump;

    if (!defined $objdump) {
	die "Dump of object database is empty!";
    }

    # XXX this will not work if UserDB will switch to MLDBM!
    my $userdb = $rootdb->UserDB;
    my $userdump;
    $dd = Data::Dumper->new([$userdb->{DB}], ['UserDB']);
    $dd->Indent(0);
    #$dd->Purity(1); # XXX
    $userdump = $dd->Dump;
    if (!defined $userdump) {
	die "Dump of user database is empty!";
    }

    my $onlineuserdb = $rootdb->OnlineUserDB;
    my $onlineuserdump;
    $dd = Data::Dumper->new([$onlineuserdb->{DB}], ['OnlineUserDB']);
    $dd->Indent(0);
    #$dd->Purity(1); # XXX
    $onlineuserdump = $dd->Dump;
    if (!defined $onlineuserdump) {
	die "Dump of online user database is empty!";
    }

    my $namedb = $rootdb->NameDB;
    my $namedump;
    $dd = Data::Dumper->new([$namedb->{DB}], ['NameDB']);
    $dd->Indent(0);
    #$dd->Purity(1); # XXX
    $namedump = $dd->Dump;
    if (!defined $namedump) {
	die "Dump of name database is empty!";
    }

    my $objdboutfile  = $self->Tmpdir . "/$db_filename{ObjDB}.db.dd";
    my $userdboutfile = $self->Tmpdir . "/$db_filename{UserDB}.db.dd";
    my $onlineuserdboutfile = $self->Tmpdir . "/$db_filename{OnlineUserDB}.db.dd";
    my $namedboutfile = $self->Tmpdir . "/$db_filename{NameDB}.db.dd";

    open(DB, "> $objdboutfile") or die "Can't create $objdboutfile: $!";
    print DB $objdump;
    close DB;

    open(DB, "> $userdboutfile") or die "Can't create $userdboutfile: $!";
    print DB $userdump;
    close DB;

    open(DB, "> $onlineuserdboutfile") or die "Can't create $onlineuserdboutfile: $!";
    print DB $onlineuserdump;
    close DB;

    open(DB, "> $namedboutfile") or die "Can't create $namedboutfile: $!";
    print DB $namedump;
    close DB;

    1;
}

=item export_content

Copy the content files to the C<content> subdirectory of the directory
specified by the C<Tmpdir> member.

=cut

sub export_content {
    my $self = shift;

    my $rootdb = $self->Root;
    my $contentdb = $rootdb->ContentDB;
    my $directory = $contentdb->Directory;

    my $contentdir = $self->Tmpdir . "/content";
    mkdir $contentdir, 0755;
    if (!-d $contentdir) {
	die "Can't create $contentdir: $!";
    }

    my @directories;
    my @files;

    my $wanted = sub {
	# unwanted directories
	if ($_ =~ /^(RCS|CVS|\.svn|\.AppleDouble)$/) {
	    $File::Find::prune = 1;
	    return;
	}
	# unwanted files
	if ($_ =~ /~$/) {
	    return;
	}
	if (-f $_) {
	    push @files, $File::Find::name;
	} elsif (-d $_) {
	    push @directories, $File::Find::name;
	}
    };

    _save_pwd {
	chdir $directory or die "Can't chdir to $directory: $!";
	find($wanted, ".");
	foreach my $d (@directories) {
	    mkpath(["$contentdir/$d"], $self->Verbose, 0770);
	}
	foreach my $f (@files) {
	    copy($f, "$contentdir/$f")
		or die "Can't copy file $f to $contentdir: $!";
	    copy_stat($f, "$contentdir/$f");
	}
    };

    1;
}

=item export_all

Create an archive file (.tar.gz format) of both database and content.
Two member variables control paths for the export: C<Tmpdir> specifies
the temporary directory, where database and content files will be
stored, and C<Archive> specifies the path for the generated archive
file. If not specified, then reasonable defaults are chosen (using the
systems default temp directory). After the creation of the archive
file, the temporary directory will be deleted completely.

=cut

sub export_all {
    my($self, %args) = @_;

    my @l = localtime;
    my $timestamp = sprintf "%04d%02d%02d-%02d%02d%02d",
	                    $l[5]+1900,$l[4],@l[3,2,1,0];

    if (defined $args{-destdir}) {
	$self->Tmpdir($args{-destdir});
	mkpath $args{-destdir};
    }

    if (!defined $self->Tmpdir) {
	my $tmpdir = _tmpdir() . "/we_export.$timestamp";

	if (-d $tmpdir) {
	    rmtree([$tmpdir], $self->Verbose, 1);
	}
	mkdir $tmpdir, 0775 or die "Can't create $tmpdir: $!";

	$self->Tmpdir($tmpdir);
    }

    if (!-d $self->Tmpdir || !-w $self->Tmpdir) {
	die "The directory " . $self->Tmpdir . " does not exist or is not readable";
    }

    $self->export_db       unless $args{-onlycontent};
    $self->export_content  unless $args{-onlydb};
    $self->create_mtree    unless $args{-onlycontent} || $args{-onlydb};

    return 1 if defined $args{-destdir};

    if (defined $args{-destfile}) {
	$self->Archive($args{-destfile});
    }

    if (!defined $self->Archive) {
	$self->Archive(_tmpdir() . "/we_export.$timestamp.tar.gz");
    }

    _save_pwd {
	chdir $self->Tmpdir or die "Can't chdir to ".$self->Tmpdir.": $!";
	if ($^O eq 'MSWin32' && eval q{ require Archive::Tar; 1 }) {
	    my @files;
	    find(sub {
		     push @files, $File::Find::name if -f $_ && -r $_;
		 }, ".");
	    if (!@files) {
		warn "No files to archive";
	    } else {
		my $tar = Archive::Tar->new
		    or die "Can't create Archive::Tar object";
		$tar->add_files(@files);
		$tar->write($self->Archive, 9);
		if ($self->Verbose) {
		    warn "Archived to @{[ $self->Archive ]}: @files\n";
		}
	    }
	} else {
	    my $v = $self->Verbose ? "v" : "";
	    #system("tar cf${v}z ".$self->Archive." *");
	    system("tar cf${v} - * | gzip > " . $self->Archive);
	    if ($?/256 != 0) {
		warn "Error while creating ".$self->Archive.", please check.\n";
	    }
	}
    };

 CLEANUP:
    rmtree([$self->Tmpdir], $self->Verbose, 1);

    1;
}

=item import_archive($tarfile, $destdir, %args)

For the specified tar archive C<$tarfile> (previously created by
C<export_all>), the content will be extracted to the directory
C<$destdir>. The destination directory must not exist and will be
created by the method.

Further arguments %args:

=over

=item -verbose => $boolean

Be verbose.

=item -force => $boolean

Extract even if destination directory exists.

=item -only => [DB1, ...]

Extract only specified databases. Note that content is B<always> extracted.

=item -chmod => $boolean

Make chmod manipulations (set everything to 0777 resp. 0666) if set to
true.

=back

=cut

# Do not rename this to "import" :-)
sub import_archive {
    my $self = shift;
    my($tarfile, $destdir, %args) = @_;

    if (!ref $self) {
	$self = WE::Export->new(undef);
    }

    if ($args{-force}) {
	$self->Force($args{-force});
    }
    if ($args{-verbose}) {
	$self->Verbose($args{-verbose});
    }
    if ($args{"-chmod"}) {
	$self->_DirMode(0777);
	$self->_FileMode(0666);
    } else {
	$self->_DirMode(undef);
	$self->_FileMode(undef);
    }

    my $all = 1;
    my %only;
    if ($args{-only}) {
	%only = map { ($_ => 1) } @{ $args{-only} };
	$all = 0;
    }

    if (-e $destdir && !$self->Force) {
	die "Destination directory $destdir must not exist";
    }
    mkpath($destdir, $self->Verbose, $self->_DirMode);

    if (!file_name_is_absolute($tarfile)) {
	$tarfile = cwd()."/$tarfile";
    }

    _save_pwd {
	chdir $destdir or die "Can't change to $destdir: $!";
	my @filelist;
	my @dirlist;
	if ($^O eq 'MSWin32' && eval q{ require Archive::Tar; 1 }) {
	    my $tar = Archive::Tar->new($tarfile, 1)
		or die "Can't create tar object";
	    if ($self->Verbose) {
		warn "About to extract the following files from $tarfile:\n" . join(" ", $tar->list_files) . "\n";
	    }
	    $tar->extract($tar->list_files); # extract() does not work!
	} else {
	    my $v = $self->Verbose ? "v" : "";
	    #system("tar", "xf${v}pz", $tarfile);
	    system("gzip -dc < $tarfile | tar xf${v}p -");
	    if ($?/256 != 0) {
		warn "Error while extracting from $tarfile, but continuing...\n";
	    }
	    @filelist = `gzip -dc < $tarfile | tar tf -`;
	    chomp @filelist;
	    for(my $f_i = $#filelist; $f_i >= 0; $f_i--) {
		my $f = $filelist[$f_i];
		if (-d $f) {
		    push @dirlist, $f;
		    splice @filelist, $f_i, 1;
		}
	    }
	}

	my @db_files;
	for my $dbkey (qw(ObjDB UserDB OnlineUserDB NameDB)) {
	    if ($all || $only{$dbkey}) {
		$self->exportdb_to_nativedb("$db_filename{$dbkey}.db.dd", ".", $dbkey);
		push @db_files, "$db_filename{$dbkey}.db";
	    }
	}
	if (-e MTREE_FILE && is_in_path("mtree")) {
	    my $xfile = $self->_create_mtree_x_file;
	    system("mtree -X $xfile < " . MTREE_FILE);
	    unlink $xfile;
	} elsif (@filelist) {
	    if (defined $self->_FileMode) {
		warn "Changing mode of all files to " .
		    sprintf("0%o", $self->_FileMode) . "...\n";
		chmod $self->_FileMode, @filelist, @db_files;
	    }
	    if (defined $self->_DirMode) {
		warn "Changing mode of all directories to " .
		    sprintf("0%o", $self->_DirMode) . "...\n";
		chmod $self->_DirMode, @dirlist;
	    }
	} else {
	    if (defined $self->_FileMode && defined $self->_DirMode) {
		warn "No mtree and no filelist/dirlist, no chmod manipulation possible";
	    }
	}
    };

    1;
}

# $type is either ObjDB or UserDB
# This method will convert the Data::Dumper files to a DB_File.
sub exportdb_to_nativedb {
    my($self, $dbfile, $destdir, $type) = @_;

    if (!-d $destdir) {
	die "Destination directory $destdir does not exist";
    }
    if (!defined $db_filename{$type} || $db_filename{$type} eq '') {
	die "Unsupported type $type";
    }
    my $destfile = $destdir . "/" . $db_filename{$type} . ".db";

    if ($self->Force && -e $destfile) {
	unlink $destfile;
    }
    tie my %db, 'DB_File', $destfile, O_RDWR|O_CREAT, 0644
	or die "Can't tie to $destfile: $!";
    if (scalar keys %db) {
	die "Database $destfile is not empty, please remove first";
    }

    my $s = Safe->new('WE::Export::Safe');
    my $cwd = cwd;
    $s->rdo($dbfile) or die "Can't load $dbfile (in $cwd) with Safe::rdo: $!";
    my $indb;
    {
	no strict 'refs';
	$indb = $ {"WE::Export::Safe::" . $type};
    }
    if (!defined $indb || !UNIVERSAL::isa($indb, 'HASH')) {
	die "$type not defined in $dbfile";
    }
    while(my($k,$v) = each %$indb) {
	$db{$k} = $v;
    }

    untie %db;
}

# XXX This assumes that all databases and content is in the same directory
# as objdbd.db. Which may be wrong.
sub create_mtree {
    my $self = shift;
    if (!is_in_path("mtree")) {
	warn "No mtree in PATH, skipping creation of .mtree file";
	return;
    }

    my $rootdb = $self->Root;
    my $objdb = $rootdb->ObjDB;
    my $objdbfile = $objdb->DBFile;
    my $dbdir = dirname($objdbfile);

    my $xfile = $self->_create_mtree_x_file;
    my $mtree_output = `mtree -k flags,gid,mode,nlink,link,time,uid -X $xfile -c -p $dbdir`;

    my $mtree_file = $self->Tmpdir . "/" . MTREE_FILE;
    open(MTREE_FH, ">$mtree_file") or die "Can't write $mtree_file: $!";
    print MTREE_FH $mtree_output;
    close MTREE_FH;

    unlink $xfile;
}

sub _create_mtree_x_file {
    my $self = shift;
    my $xfile = _tmpdir() . "/.mtree.exclude.$$";
    open(XFILE, ">$xfile") or die "Can't write to $xfile: $!";
    print XFILE <<EOF;
.svn
CVS
RCS
*~
EOF
    close XFILE;
    $xfile;
}

# REPO BEGIN
# REPO NAME tmpdir /home/e/eserte/src/repository 
# REPO MD5 c41d886135d054ba05e1b9eb0c157644
sub _tmpdir {
    foreach my $d ($ENV{TMPDIR}, $ENV{TEMP},
		   "/tmp", "/var/tmp", "/usr/tmp", "/temp") {
	next if !defined $d;
	next if !-d $d || !-w $d;
	return $d;
    }
    undef;
}
# REPO END

# REPO BEGIN
# REPO NAME copy_stat /home/e/eserte/src/repository 
# REPO MD5 f567def1f7ce8f3361e474b026594660

sub copy_stat {
    my($src, $dest) = @_;
    my @stat = ref $src eq 'ARRAY' ? @$src : stat($src);
    die "Can't stat $src: $!" if !@stat;

    chmod $stat[2], $dest
	or warn "Can't chmod $dest to " . sprintf("0%o", $stat[2]) . ": $!";
    chown $stat[4], $stat[5], $dest;
#  	or do {
#  	    my $save_err = $!; # otherwise it's lost in the get... calls
#  	    warn "Can't chown $dest to " .
#  		 (getpwuid($stat[4]))[0] . "/" .
#                   (getgrgid($stat[5]))[0] . ": $save_err";
#  	};
    utime $stat[8], $stat[9], $dest
	or warn "Can't utime $dest to " .
	        scalar(localtime $stat[8]) . "/" .
		scalar(localtime $stat[9]) .
		": $!";
}
# REPO END

1;

__END__

=back

=head1 BUGS

This module will only work on Windows with installed Archive::Tar and
Compress::Zlib (this is usually true with ActivePerl). On Unix, you
need the programs C<tar> and C<gzip>.

=head1 AUTHOR

Slaven Rezic - slaven@rezic.de

=head1 SEE ALSO

=cut