################################################################################ # WeBWorK Online Homework Delivery System # Copyright © 2000-2003 The WeBWorK Project, http://openwebwork.sf.net/ # $CVSHeader: webwork2/lib/WeBWorK/Utils.pm,v 1.48 2004/09/10 02:32:09 sh002i Exp $ # # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the # Artistic License for more details. ################################################################################ package WeBWorK::Utils; use base qw(Exporter); =head1 NAME WeBWorK::Utils - useful utilities used by other WeBWorK modules. =cut use strict; use warnings; #use Apache::DB; use DateTime; use Errno; use File::Path qw(rmtree); use Carp; use constant MKDIR_ATTEMPTS => 10; # "standard" WeBWorK date/time format (for set definition files): # %m/%d/%y at %I:%M%P # where: # %m = month number, starting with 01 # %d = numeric day of the month, with leading zeros (eg 01..31) # %y = year (2 digits) # %I = hour, 12 hour clock, leading 0's) # %M = minute, leading 0's # %P = am or pm (Yes %p and %P are backwards :) use constant DATE_FORMAT => "%m/%d/%y at %I:%M%P"; our @EXPORT = (); our @EXPORT_OK = qw( runtime_use readFile readDirectory listFilesRecursive surePathToFile makeTempDirectory removeTempDirectory formatDateTime parseDateTime textDateTime intDateTime writeLog writeCourseLog writeTimingLogEntry list2hash ref2string decodeAnswers encodeAnswers max pretty_print_rh cryptPassword dequote undefstr sortByName ); ################################################################################ # Lowlevel thingies ################################################################################ sub runtime_use($) { croak "runtime_use: no module specified" unless $_[0]; eval "package Main; require $_[0]; import $_[0]"; die $@ if $@; } #sub backtrace($) { # my ($style) = @_; # $style = "warn" unless $style; # my @bt = DB->backtrace; # shift @bt; # Remove "backtrace" from the backtrace; # if ($style eq "die") { # die join "\n", @bt; # } elsif ($style eq "warn") { # warn join "\n", @bt; # } elsif ($style eq "print") { # print join "\n", @bt; # } elsif ($style eq "return") { # return @bt; # } #} ################################################################################ # Filesystem interaction ################################################################################ # Convert Windows and Mac (classic) line endings to UNIX line endings in a string. # Windows uses CRLF, Mac uses CR, UNIX uses LF. (CR is ASCII 15, LF if ASCII 12) sub force_eoln($) { my ($string) = @_; $string =~ s/\015\012?/\012/g; return $string; } sub readFile($) { my $fileName = shift; local $/ = undef; # slurp the whole thing into one string open my $dh, "<", $fileName or die "failed to read file $fileName: $!"; my $result = <$dh>; close $dh; return force_eoln($result); } sub readDirectory($) { my $dirName = shift; opendir my $dh, $dirName or die "Failed to read directory $dirName: $!"; my @result = readdir $dh; close $dh; return @result; } =item @matches = listFilesRecusive($dir, $match_qr, $prune_qr, $match_full, $prune_full) Traverses the directory tree rooted at $dir, returning a list of files, named pipes, and sockets matching the regular expression $match_qr. Directories matching the regular expression $prune_qr are not visited. $match_full and $prune_full are boolean values that indicate whether $match_qr and $prune_qr, respectively, should be applied to the bare directory entry (false) or to the path to the directory entry relative to $dir. @matches is a list of paths relative to $dir. =cut sub listFilesRecursiveHelper($$$$$$); sub listFilesRecursive($;$$$$) { my ($dir, $match_qr, $prune_qr, $match_full, $prune_full) = @_; return listFilesRecursiveHelper($dir, "", $match_qr, $prune_qr, $match_full, $prune_full); } sub listFilesRecursiveHelper($$$$$$) { my ($base_dir, $curr_dir, $match_qr, $prune_qr, $match_full, $prune_full) = @_; my $full_dir = "$base_dir/$curr_dir"; my @dir_contents = readDirectory($full_dir); my @matches; foreach my $dir_entry (@dir_contents) { my $full_path = "$full_dir/$dir_entry"; if (-d $full_path or -l $full_path) { # standard things to skip next if $dir_entry eq "."; next if $dir_entry eq ".."; # skip unreadable directories (and broken symlinks, incidentally) unless (-r $full_path) { warn "Directory/symlink $full_path not readable"; next; } # check $prune_qr my $subdir = ($curr_dir eq "") ? $dir_entry : "$curr_dir/$dir_entry"; if (defined $prune_qr) { my $prune_string = $prune_full ? $subdir : $dir_entry; next if $prune_string =~ m/$prune_qr/; } # everything looks good, time to recurse! push @matches, listFilesRecursiveHelper($base_dir, $subdir, $match_qr, $prune_qr, $match_full, $prune_full); } elsif (-f $full_path or -p $full_path or -S $full_path) { my $file = ($curr_dir eq "") ? $dir_entry : "$curr_dir/$dir_entry"; my $match_string = $match_full ? $file : $dir_entry; if (not defined $match_string or $dir_entry =~ m/$match_qr/) { push @matches, $file; } } } return @matches; } # A very useful macro for making sure that all of the directories to a file have # been constructed. sub surePathToFile($$) { # constructs intermediate # the input path must be the path relative to this starting directory my $start_directory = shift; my $path = shift; my $delim = "/"; #&getDirDelim(); unless ($start_directory and $path ) { warn "missing directory
surePathToFile start_directory path "; return ''; } # use the permissions/group on the start directory itself as a template my ($perms, $groupID) = (stat $start_directory)[2,5]; #warn "&urePathToTmpFile: perms=$perms groupID=$groupID\n"; # if the path starts with $start_directory (which is permitted but optional) remove this initial segment $path =~ s|^$start_directory|| if $path =~ m|^$start_directory|; #$path = convertPath($path); # find the nodes on the given path my @nodes = split("$delim",$path); # create new path $path = $start_directory; #convertPath("$tmpDirectory"); while (@nodes>1) { $path = $path . shift (@nodes) . "/"; #convertPath($path . shift (@nodes) . "/"); #FIXME this make directory command may not be fool proof. unless (-e $path) { mkdir($path, $perms) or warn "Failed to create directory $path"; } } $path = $path . shift(@nodes); #convertPath($path . shift(@nodes)); return $path; } sub makeTempDirectory($$) { my ($parent, $basename) = @_; # Loop until we're able to create a directory, or it fails for some # reason other than there already being something there. my $triesRemaining = MKDIR_ATTEMPTS; my ($fullPath, $success); do { my $suffix = join "", map { ('A'..'Z','a'..'z','0'..'9')[int rand 62] } 1 .. 8; $fullPath = "$parent/$basename.$suffix"; $success = mkdir $fullPath; } until ($success or not $!{EEXIST}); die "Failed to create directory $fullPath: $!" unless $success; return $fullPath; } sub removeTempDirectory($) { my ($dir) = @_; rmtree($dir, 0, 0); } ################################################################################ # Date/time processing ################################################################################ =head2 Date/time processing =over =item $dateTime = parseDateTime($string, $display_tz) Parses $string as a datetime. If $display_tz is given, $string is assumed to be in that timezone. Otherwise, the server's timezone is used. The result, $dateTime, is an integer UNIX datetime (epoch) in the server's timezone. =cut sub parseDateTime($;$) { my ($string, $display_tz) = @_; $display_tz ||= "local"; warn "parseDateTime('$string', '$display_tz')\n"; # Method #1: using Date::Parse use Date::Parse; $string =~ s/\s*\bat\b\s*/ /; # Date::Parse can't handle the "at" in WeBWorK datetimes. my $epoch = str2time($string); #warn "\tMethod #1: str2time($string) = $epoch\n"; my $dt = DateTime->from_epoch(epoch => $epoch, time_zone => "local"); #warn "\tMethod #1: \$dt = ", $dt->strftime(DATE_FORMAT." %Z"), "\n"; # Method #2: using Date::Manip #use Date::Manip; #my $dm = ParseDateString($string); #warn "\tMethod #2: ParseDateString($string) = $dm\n"; #use DateTime::Format::DateManip; #my $dt = DateTime::Format::DateManip->parse_datetime($dm); #warn "\tdMethod #2: \$dt = ", $dt->strftime(DATE_FORMAT." %Z"), "\n"; my $dt2 = $dt->clone->set_time_zone("floating")->set_time_zone($display_tz); #warn "\t\$dt2 = ", $dt2->strftime(DATE_FORMAT." %Z"), "\n"; my $epoch2 = $dt2->epoch; #warn "\t\$epoch2 (return value) = $epoch2\n"; return $epoch2; } =item $string = formatDateTime($dateTime, $display_tz) Formats the UNIX datetime $dateTime in the standard WeBWorK datetime format. $dateTime is assumed to be in the server's time zone. If $display_tz is given, the datetime is converted from the server's timezone to the timezone specified. =cut sub formatDateTime($;$) { my ($dateTime, $display_tz) = @_; $display_tz ||= "local"; #warn "formatDateTime('$dateTime', '$display_tz')\n"; my $dt = DateTime->from_epoch(epoch => $dateTime, time_zone => $display_tz); #warn "\t\$dt = ", $dt->strftime(DATE_FORMAT." %Z"), "\n"; return $dt->strftime(DATE_FORMAT); } =item $string = textDateTime($string_or_dateTime) Accepts a UNIX datetime or a formatted string, returns a formatted string. =cut sub textDateTime($) { return ($_[0] =~ m/^\d*$/) ? formatDateTime($_[0]) : $_[0]; } =item $dateTIme = intDateTime($string_or_dateTime) Accepts a UNIX datetime or a formatted string, returns a UNIX datetime. =cut sub intDateTime($) { return ($_[0] =~ m/^\d*$/) ? $_[0] : parseDateTime($_[0]); } =back =cut ################################################################################ # Logging ################################################################################ sub writeLog($$@) { my ($ce, $facility, @message) = @_; unless ($ce->{webworkFiles}->{logs}->{$facility}) { warn "There is no log file for the $facility facility defined.\n"; return; } my $logFile = $ce->{webworkFiles}->{logs}->{$facility}; local *LOG; if (open LOG, ">>", $logFile) { print LOG "[", time2str("%a %b %d %H:%M:%S %Y", time), "] @message\n"; close LOG; } else { warn "failed to open $logFile for writing: $!"; } } sub writeCourseLog($$@) { my ($ce, $facility, @message) = @_; unless ($ce->{courseFiles}->{logs}->{$facility}) { warn "There is no course log file for the $facility facility defined.\n"; return; } my $logFile = $ce->{courseFiles}->{logs}->{$facility}; local *LOG; if (open LOG, ">>", $logFile) { print LOG "[", time2str("%a %b %d %H:%M:%S %Y", time), "] @message\n"; close LOG; } else { warn "failed to open $logFile for writing: $!"; } } # $ce - a WeBWork::CourseEnvironment object # $function - fully qualified function name # $details - any information, do not use the characters '[' or ']' # $beginEnd - the string "begin", "intermediate", or "end" # use the intermediate step begun or completed for INTERMEDIATE # use an empty string for $details when calling for END sub writeTimingLogEntry($$$$) { my ($ce, $function, $details, $beginEnd) = @_; return unless defined $ce->{webworkFiles}->{logs}->{timing}; $beginEnd = ($beginEnd eq "begin") ? ">" : ($beginEnd eq "end") ? "<" : "-"; writeLog($ce, "timing", "$$ ".time." $beginEnd $function [$details]"); } ################################################################################ # Data munging ################################################################################ sub list2hash(@) { map {$_ => "0"} @_; } sub refBaseType($) { my $ref = shift; $ref =~ m/(\w+)\(/; # this might not be robust... return $1; } sub ref2string($;$); sub ref2string($;$) { my $ref = shift; my $dontExpand = shift || {}; my $refType = ref $ref; my $result; if ($refType and not $dontExpand->{$refType}) { my $baseType = refBaseType($ref); $result .= '' . $refType; $result .= " ($baseType)" if $baseType and $refType ne $baseType; $result .= ":
"; $result .= ''; if ($baseType eq "HASH") { my %hash = %$ref; foreach (sort keys %hash) { $result .= ''; $result .= ""; $result .= ""; $result .= ""; } } elsif ($baseType eq "ARRAY") { my @array = @$ref; # special case for Problem, Set, and User objects, which are defined # using lists and contain a @FIELDS package variable: no strict 'refs'; my @FIELDS = eval { @{$refType."::FIELDS"} }; use strict 'refs'; undef @FIELDS unless scalar @FIELDS == scalar @array and not $@; foreach (0 .. $#array) { $result .= ''; $result .= ""; $result .= "" if @FIELDS; $result .= ""; $result .= ""; } } elsif ($baseType eq "SCALAR") { my $scalar = $$ref; $result .= ''; $result .= ""; $result .= ""; } else { # perhaps a coderef? in any case, i don't feel like dealing with it! $result .= ''; $result .= ""; $result .= ""; } $result .= "
$_" . ref2string($hash{$_}, $dontExpand) . "
$_".$FIELDS[$_]."" . ref2string($array[$_], $dontExpand) . "
$scalar
$ref
" } else { $result .= defined $ref ? $ref : 'undef'; } } sub decodeAnswers($) { my $string = shift; return unless defined $string and $string; my @array = split m/##/, $string; $array[$_] =~ s/\\#\\/#/g foreach 0 .. $#array; push @array, "" if @array%2; return @array; # it's actually a hash ;) } sub encodeAnswers(\%\@) { my %hash = %{ shift() }; my @order = @{ shift() }; my $string = ""; foreach my $name (@order) { my $value = defined $hash{$name} ? $hash{$name} : ""; $name =~ s/#/\\#\\/g; # this is a WEIRD way to escape things $value =~ s/#/\\#\\/g; # and it's not my fault! if ($value =~ m/\\$/) { # if the value ends with a backslash, string2hash will # interpret that as a normal escape sequence (not part # of the weird pound escape sequence) if the next # character is &. So we have to protect against this. # will adding a spcae at the end of the last answer # hurt anything? i don't think so... $value .= " "; } $string .= "$name##$value##"; # this is also not my fault } $string =~ s/##$//; # remove last pair of hashs return $string; } sub max(@) { my $soFar; foreach my $item (@_) { $soFar = $item unless defined $soFar; if ($item > $soFar) { $soFar = $item; } } return defined $soFar ? $soFar : 0; } sub pretty_print_rh($) { my $rh = shift; foreach my $key (sort keys %{$rh}) { warn " $key => ",$rh->{$key},"\n"; } } sub cryptPassword($) { my ($clearPassword) = @_; my $salt = join("", ('.','/','0'..'9','A'..'Z','a'..'z')[rand 64, rand 64]); my $cryptPassword = crypt($clearPassword, $salt); return $cryptPassword; } # from the Perl Cookbook, first edition, page 25: sub dequote($) { local $_ = shift; my ($white, $leader); # common whitespace and common leading string if (/^\s*(?:([^\w\s]+)(\s*).*\n)(?:\s*\1\2?.*\n)+$/) { ($white, $leader) = ($2, quotemeta($1)); } else { ($white, $leader) = (/^(\s+)/, ''); } s/^\s*?$leader(?:$white)?//gm; return $_; } sub undefstr($@) { map { defined $_ ? $_ : $_[0] } @_[1..$#_]; } ################################################################################ # Sorting ################################################################################ # p. 101, Camel, 3rd ed. # The <=> and cmp operators return -1 if the left operand is less than the # right operand, 0 if they are equal, and +1 if the left operand is greater # than the right operand. sub sortByName($@) { my ($field, @items) = @_; return sort { my @aParts = split m/(?<=\D)(?=\d)|(?<=\d)(?=\D)/, defined $field ? $a->$field : $a; my @bParts = split m/(?<=\D)(?=\d)|(?<=\d)(?=\D)/, defined $field ? $b->$field : $b; while (@aParts and @bParts) { my $aPart = shift @aParts; my $bPart = shift @bParts; my $aNumeric = $aPart =~ m/^\d*$/; my $bNumeric = $bPart =~ m/^\d*$/; # numbers should come before words return -1 if $aNumeric and not $bNumeric; return +1 if not $aNumeric and $bNumeric; # both have the same type if ($aNumeric and $bNumeric) { next if $aPart == $bPart; # check next pair return $aPart <=> $bPart; # compare numerically } else { next if $aPart eq $bPart; # check next pair return $aPart cmp $bPart; # compare lexicographically } } return +1 if @aParts; # a has more sections, should go second return -1 if @bParts; # a had fewer sections, should go first } @items; } 1;