Zip class (part II)

Small, short code snippets that other people may find useful. Do you have a good regex that you would like to share? Share it! Even better, the code can be commented on, and improved.

Moderator: General Moderators

Post Reply
redmonkey
Forum Regular
Posts: 836
Joined: Thu Dec 18, 2003 3:58 pm

Zip class (part II)

Post by redmonkey »

Following up on the Zip class for creating zip files on the fly. Here is the complimentary extraction class. Again hopefully the provided examples will give enough info to get it going.

You will find the required includes/functions.lib.php file within the Zip class (second code snippet within first post)

Updated 05/04/06 - updated to address the compatability issue with Windows XP's native compression utility as described/noted in this post. Revisions to zipextract.cls.php only.

Code: Select all

<?php
/******************************************************************************
* filename     zipextract.cls.php
*
* description  Extract zip files on the fly
*
* project
*
* author       redmonkey
*
* version      0.3
*
* status       beta
*
* license      GPL, the GNU General Public License can be found at
*              http://www.gnu.org/copyleft/gpl.html
*
* copyright    2005 redmonkey, all rights reserved
*
* dependancy   function dos2unixtime() (found in supporting function library
*              (includes/functions.lib.php))
*
* dependancy   function make_dirs() (found in supporting function library
*              (includes/functions.lib.php))
*
* notes        zip file format can be found at
*              http://www.pkware.com/company/standards/appnote/
*
* notes        the documented zip file format omits to detailthe required header
*              signature for the data descriptor (extended local file header)
*              section which is (0x08074b50). while many decompression utilities
*              will ignore this error, this signature is vital for compatability
*              with Stuffit Expander for Mac if you have included the data
*              descriptor
*
* notes        while using bzip2 compression offers a reduced file size it does
*              come at the expense of higher system resources usage. the
*              decompression utility will also have to be compatabile with at
*              least v4.6 zip file format specification
*
* history
* 01/01/2005   v0.1 - initial version
* 05/04/2006   v0.2 - corrected file type/format check on internal file
*                     attributes
* 06/06/2006   v0.3 - corrected 'version made by' compatibility check
*
* notice       this is written to be compatible with the original PKWARE .zip
*              file format. the author of this program is NOT the original
*              publisher of this format.
*
* notice       this program is free software, you can redistribute it and/or
*              modify it under the terms of the GNU General Public License as
*              published by the Free Software Foundation; either version 2 of
*              the License, or (at your option) any later version
*
* notice       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 the
*              GNU General Public License for more details
******************************************************************************/
class ZipExtract
{
  var $zip_header;  // array containg end of central directory headers
  var $zipfile;     // the name including path of zip file
  var $filedata;    // array containg individual file headers
  var $file_ids;    // array containg internal file ids
  var $content;     // zip file contents
  var $methods;     // array of known compression methods
  var $known_fa;    // array of recognisable operating system's file attributes

  /**
  * @return
  * @param  _file  filename including path to zip file
  * @desc          constructor, initialises class variables
  */
  function ZipExtract($_file)
  {
    $this->zipfile = $_file;    // set the name including path of zip file

    $this->_load_zip($_file);   // load in zip file
    $this->_parse_end_record(); // parse end of central directory headers
    $this->_parse_cntrldir();   // parse central directory records

    // set known compression methods array
    $this->methods = array('Stored',
                           'Shrunk',
                           'Reduced with compression factor 1',
                           'Reduced with compression factor 2',
                           'Reduced with compression factor 3',
                           'Reduced with compression factor 4',
                           'Imploded',
                           'Tokenizing compression algorithm',
                           'Deflated',
                           'Deflated64(tm)',
                           'PKWARE Data Compression Library Imploding',
                           'Reserved by PKWARE',
                           'BZIP2');

    // set operating systems with recognised file attributes
    //  0 = DOS compatible (FAT etc..)
    // 10 = Windows NTFS (According to spec)
    // 11 = Zip files created with XP (reports as NTFS)
    $this->known_fa = array(0x00, 0x0a, 0x0b);
  }

  /**
  * @return array  numerically indexed array of files within the archive
  * @desc          lists all files within the archive as an array. the index
  *                of the array element is the internal file id and the value
  *                is the filename including path
  */
  function list_files()
  {
    $array = array();

    foreach ($this->file_ids as $k => $v)
    {
      if (($this->filedata[$v]['ex_fa'] & 0x10) != 0x10)
      {
        $array[$v] = $k;
      }
    }
    return $array;
  }

  /**
  * @return string           filename excluding path
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns the filename excluding path
  */
  function get_filename($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get filename for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->filedata[$fid]['realname'];
  }

  /**
  * @return string            path of file as stored in the archive
  * @param  mixed   _fileref  either internal file ID integer or filename including
  *                           full path
  * @desc                     returns the path to the file
  */
  function get_filepath($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get file path for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->filedata[$fid]['path'];
  }

  /**
  * @return int              uncompressed size of file
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns the uncompressed size of file in bytes
  */
  function get_filesize($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get file size for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->filedata[$fid]['uc_len'];
  }

  /**
  * @return int              compressed size of file
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns the compressed size of file in bytes
  */
  function get_filecsize($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get compressed size for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->filedata[$fid]['c_len'];
  }

  /**
  * @return int              Unix timestamp of the files last modified time and date
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns the compressed size of file in bytes
  */
  function get_modtime($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get last modified time for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->filedata[$fid]['mod_time'];
  }

  /**
  * @return int              compression ratio as a percentage
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns the ratio of compressed versus uncompressed size
  */
  function get_ratio($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get compression ratio for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    if ($this->filedata[$fid]['uc_len'] == $this->filedata[$fid]['c_len'])
    {
      return 0;
    }

    return round(100 - (100 /
                       ($this->filedata[$fid]['uc_len'] /
                       ($this->filedata[$fid]['c_len']))), 2);
  }

  /**
  * @return string           method used to compress the file within the archive
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns method used to compress the file within the archive
  */
  function get_compress_method($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get compression method for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->methods[$this->filedata[$fid]['method']];
  }

  /**
  * @return string           crc32 checksum for file
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns crc32 checksum for the file
  */
  function get_checksum($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to get checksum for file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return $this->filedata[$fid]['crc32'];
  }

  /**
  * @return string           minimum version required to extract the file
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    returns the minimum version of the zip file specification
  *                          required to extract the file
  */
  function get_min_ver($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg  = 'Failed to get minmum version required to extract file ';
      $notice_msg .= '(' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    return number_format($this->filedata[$fid]['min_ver'] / 10, 1);
  }

  /**
  * @return string           main comment within zip file
  * @desc                    returns the main comment from the zip archive
  */
  function get_comment()
  {
    return $this->zip_header['comment'];
  }

  /**
  * @return mixed            bool true if the file can be extracted otherwise a
  *                          string containing a reason for failure
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    checks file attribute compatability and general purpose
  *                          bit 0 flag for compatability with this extractor
  */
  function can_extract($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      return 'no such file in archive';
    }

    $compatible = in_array($this->filedata[$fid]['fa_comp'] & 0xff, $this->known_fa) ? true : false;

    $encrypted  = $this->filedata[$fid]['gp_bit']   & 0x01 ? true : false;

    if ($compatible && !$encrypted)
    {
      switch($this->filedata[$fid]['method'])
      {
        case (0x08):  // Deflated
          if (!function_exists('gzinflate'))
          {
            $error_msg  = 'file is Deflated but your PHP installation does not ';
            $error_msg .= 'support decompressing this format';
            return $error_msg;
          }
          return true;

        case (0x00):  // Stored
          return true;

        case (0x0c):  // bzip2 algorithm
          if (!function_exists('bzdecompress'))
          {
            $error_msg  = 'file is compressed with bzip2 compression but your PHP ';
            $error_msg .= 'installation does not support decompressing this format';
            return $error_msg;
          }
          return true;

        default    :  // anything else
          return 'unsupported compression format (' . $this->get_compress_method($_fileref) . ')';
      }
    }

    if (!$compatible)
    {
      return 'incompatible file attributes';
    }

    if ($encrypted)
    {
      return 'encrypted files are not supported';
    }

    return 'unknown error';
  }

  /**
  * @return string           decompressed file contents
  * @param  mixed  _fileref  either internal file ID integer or filename including
  *                          full path
  * @desc                    extracts individual file from the zip archive
  */
  function extract_file($_fileref)
  {
    if (false === ($fid = $this->_get_fid($_fileref)))
    {
      $notice_msg = 'Failed to extract file (' . $_fileref . ') no such file in archive';
      trigger_error($notice_msg, E_USER_NOTICE);
      return false;
    }

    if (true !== ($reason = $this->can_extract($fid)))
    {
      trigger_error('Failed to extract file (' . $_fileref . ') ' . $reason, E_USER_NOTICE);
      return false;
    }

    $data = $this->_extract_data($this->filedata[$fid]['datastart'],
                                 $this->filedata[$fid]['c_len']);

    switch($this->filedata[$fid]['method'])
    {
      case (0x08):  // Deflated
        return gzinflate($data);

      case (0x00):  // Stored
        return $data;

      case (0x0c):  // bzip2 algorithm
        return bzdecompress($data);

      default    :
        return false;
    }
  }

  /**
  * @return
  * @param  string  _path  path to directory to unzip files to
  * @desc                  extracts all files from zip archive to specified
  *                        directory of $_path
  */
  function extract_all($_path = '.')
  { // remove leading and trailing spaces from path
    // and correct and erros with directory seperators
    $_path = trim(str_replace('\\', '/', $_path));

    // remove trailing slash if present
    $_path = preg_replace('/\/+$/', '', $_path);

    // if path is root then set $path to nothing as this
    // is catered for later.
    $_path = $_path == '/' ? '' : $_path;

    // get a list of entries defined as directories
    $dirs  = $this->_list_dirs();

    if (count($dirs) > 0)
    { // loop through each entry an attempt to make the directory structure
      foreach ($dirs as $id => $dir)
      {
        if (!make_dirs($_path . '/' . $dir, $this->get_modtime($id)))
        {
          return false;
        }
      }
    }

    // get a list of all files within the archive
    $files = $this->list_files();

    // loop through each file in the archive and extract
    foreach ($files as $fid => $filename)
    {
      $path = $_path . '/' . $this->get_filepath($fid);

      // attempt to make directory for file
      if (!make_dirs($path))
      {
        $notice_msg  = 'Failed to extract ' . $this->get_filename($fid);
        $notice_msg .= ' could not create required directory structure';
        trigger_error($notice_msg, E_USER_WARNING);
        continue;
      }

      // make sure we can write to directory where the file is to be extracted
      if (!is_writable($path))
      {
        $error_msg  = 'Failed to extract ' . $this->get_filename($fid);
        $error_msg .= ' to ' . $path . ' check directory permissions';
        trigger_error($error_msg, E_USER_WARNING);
        continue;
      }

      // get the uncompressed file data
      $file_contents = $this->extract_file($fid);

      $path = $path == './' ? '.' : $path;
      $file = $path  . '/'  . $this->get_filename($fid);

      // write data to extracted file
      if ($fp = @fopen($file, 'wb'))
      {
        fwrite($fp, $file_contents);
        fclose($fp);
        // set last modified filetime
        @touch($file, $this->get_modtime($fid));
      }
    }
  }

  /**
  * @return
  * @param  _file  filename including path to zip file
  * @desc          loads the contents of the zip file into the content string
  */
  function _load_zip($_file)
  {
    if (!file_exists($_file) && !is_file($_file))
    {
      trigger_error('Failed to load zip file (' . $_file . ') does not exist', E_USER_ERROR);
    }

    if (!$fp = fopen($_file, 'rb'))
    {
      trigger_error('Failed to open zip file (' . $_file . ') permission denied', E_USER_ERROR);
    }
    $this->content = fread($fp, filesize($_file));
    fclose($fp);
  }

  /**
  * @return
  * @desc     parse end of central directory headers
  */
  function _parse_end_record()
  {
    if (!$pos_eof_cntrldir = _strrpos($this->content, "\x50\x4b\x05\x06"))
    {
      trigger_error('Failed to read central directory header information', E_USER_ERROR);
    }

    $data   = substr($this->content, ($pos_eof_cntrldir + 0x04));

    $unpack = 'vthis_disk/'       // number of this disk              (2 bytes)
            . 'vstart_disk/'      // number of the disk with start of
                                  // central directory record         (2 bytes)
            . 'vdisk_entries/'    // total # of entries on this disk  (2 bytes)
            . 'vtotal_entries/'   // total # of entries overall       (2 bytes)
            . 'Vcntrldir_size/'   // size of central dir              (4 bytes)
            . 'Vcntrldir_offset/' // offset to start of central dir   (4 bytes)
            . 'vcom_len';         // .zip file comment length         (2 bytes)

    $this->zip_header            = unpack($unpack, $data);

    $this->zip_header['comment'] = substr($data, 0x12, $this->zip_header['com_len']);

    // check that this is a single disk archive
    if ($this->zip_header['start_disk']   != $this->zip_header['this_disk'] ||
        $this->zip_header['disk_entries'] != $this->zip_header['total_entries'])
    {
      trigger_error('Multiple disk archives are not supported', E_USER_ERROR);
    }
  }

  /**
  * @return
  * @desc     parse central directory record of zip file and populate files
  *           array with the results
  */
  function _parse_cntrldir()
  {
    $fid      = 0;
    $cntrldir = $this->_extract_data($this->zip_header['cntrldir_offset'],
                                     $this->zip_header['cntrldir_size']);

    $unpack = 'vversion/'         // version made by                 (2 bytes)
            . 'vmin_ver/'         // version needed to extract       (2 bytes)
            . 'vgp_bit/'          // general purpose bit             (2 bytes)
            . 'vmethod/'          // compression method              (2 bytes)
            . 'Vmod_time/'        // last modified time and date     (4 bytes)
            . 'Vcrc32/'           // crc32                           (4 bytes)
            . 'Vc_len/'           // compressed length               (4 bytes)
            . 'Vuc_len/'          // uncompressed length             (4 bytes)
            . 'vfn_len/'          // length of filename              (2 bytes)
            . 'vef_len/'          // extra field length              (2 bytes)
            . 'vfcom_len/'        // file comment length             (2 bytes)
            . 'vdisk_start/'      // disk number start               (2 bytes)
            . 'vin_fa/'           // internal file attributes        (2 bytes)
            . 'Vex_fa/'           // external file sttributes        (4 bytes)
            . 'Voffset';          // relative offset of local header (4 bytes)

    $file_headers = explode("\x50\x4b\x01\x02", $cntrldir);
    array_shift($file_headers); // remove first empty array element

    // loop through each local file header and populate files array
    foreach ($file_headers as $header)
    {
      $info = unpack($unpack, $header);

      // ZIP file specification version
      $info['pk_spec']   = $info['version'] &  0xff;

      // file attribute compatibility
      $info['fa_comp']   = $info['version'] >> 0x08;

      // filename and path information
      $info['filename']  = substr($header, 0x2a, $info['fn_len']);
      $pathinfo          = pathinfo($info['filename']);
      $info['datastart'] = $info['offset'] + 0x1e + $info['fn_len'];
      $info['realname']  = $pathinfo['basename'];
      $info['path']      = $pathinfo['dirname'] == '.' ? '' : $pathinfo['dirname'];

      // file last modified date and time
      $info['mod_time']  = dos2unixtime($info['mod_time']);

      // save file information
      $this->filedata[$fid]              = $info;
      $this->file_ids[$info['filename']] = $fid;

      // increment internal fileID counter
      $fid++;

      // cleanup
      unset($info);
    }
  }

  /**
  * @return int               internal file ID
  * @param  mixed   _fileref  either internal file ID integer or filename including
  *                           path as string
  * @desc                     check the existance of the file within the archive
  *                           returns internal file ID on success, false on failure
  */
  function _get_fid($_fileref)
  {
    if (is_string($_fileref))
    {// file reference is a string we need to look up it's internal file ID
      if (!isset($this->file_ids[$_fileref]))
      {// file doesn't exist in the archive
        return false;
      }
      // return internal file ID
      return $this->file_ids[$_fileref];
    }
    elseif (!isset($this->filedata[$_fileref]))
    {
      return false;
    }
    return $_fileref;
  }

  /**
  * @return string            compressed filedata content
  * @param  int      _offset  offset from start of file to begining of data
  * @param  int      _length  length of data from offset
  * @desc                     returns compressed data from within the archive
  *                           starting at offset up to length
  */
  function _extract_data($_offset, $_length)
  {
    return substr($this->content, $_offset, $_length);
  }

  /**
  * @return array  numerically indexed array of directories within the archive
  * @desc          lists all directory definitions within the archive as an array.
  */
  function _list_dirs()
  {
    $array = array();

    foreach ($this->file_ids as $k => $v)
    {
      if (($this->filedata[$v]['ex_fa'] & 0x10) == 0x10)
      {
        $array[$v] = $k;
      }
    }
    return $array;
  }
}
?>
Examples....

Code: Select all

<?php
/***********************************************************
* filename     zipextract.ex1.php
* description  Example of extracting the contents of an
*              archive to a directory
*
* author       redmonkey
************************************************************/
include('./includes/functions.lib.php');
include('./includes/zipextract.cls.php');

$zipfile = 'example.zip'; // name of file to extract inc path
$xtr_dir = 'extract';     // directory to extract to inc path

$ZIP = new ZipExtract($zipfile);

if (!is_dir($xtr_dir))
{
  mkdir($xtr_dir, 0755);
}

$ZIP->extract_all($xtr_dir);

?>

Code: Select all

<?php
/***********************************************************
* filename     zipextract.ex2.php
* description  Example of extracting a single file from the
*              archive and writing it to the current
*              directory
*
* author       redmonkey
************************************************************/
include('./includes/functions.lib.php');
include('./includes/zipextract.cls.php');

$zipfile = 'example.zip';

$ZIP = new ZipExtract($zipfile);

$filelist = $ZIP->list_files();

foreach ($filelist as $fid => $file)
{
  if ($file == 'images/diao.jpg')
  {
    if ($fp = @fopen($ZIP->get_filename($fid), 'wb'))
    {
      fwrite($fp, $ZIP->extract_file($fid));
      fclose($fp);
    }
  }
}
?>

Code: Select all

<?php
/***********************************************************
* filename     zipextract.ex3.php
* description  Example of sumarizing the contents of a zip
*              archive
*
* author       redmonkey
************************************************************/
include('./includes/functions.lib.php');
include('./includes/zipextract.cls.php');

header('Content-Type: text/plain');

$zipfile = 'example.zip';

$ZIP = new ZipExtract($zipfile);

$filelist = $ZIP->list_files();

echo 'Zipfile Comment - ' . $ZIP->get_comment() . "\x0a\x0a";

foreach ($filelist as $fid => $file)
{
  echo "\x0aFilename - " . $ZIP->get_filename($fid);
  echo "\x0a========================================";
  echo "\x0a\tFilepath            - " . $ZIP->get_filepath($fid);
  echo "\x0a\tCompression Method  - " . $ZIP->get_compress_method($fid);
  echo "\x0a\tCompressed Length   - " . $ZIP->get_filecsize($fid);
  echo "\x0a\tUncompressed Length - " . $ZIP->get_filesize($fid);
  echo "\x0a\tCompression Ratio   - " . $ZIP->get_ratio($fid) . '%';
  echo "\x0a\tCRC32 Checksum      - " . $ZIP->get_checksum($fid);
  echo "\x0a\tMinimum Version Req - " . $ZIP->get_min_ver($fid);
  echo "\x0a\tLast Modified       - " . date('d/m/Y H:i', $ZIP->get_modtime($fid));
  echo "\x0a========================================";
  echo "\x0a\x0a";
}
?>
Last edited by redmonkey on Tue Jun 06, 2006 7:18 am, edited 3 times in total.
kirilisa
Forum Commoner
Posts: 28
Joined: Fri Dec 05, 2003 4:41 pm

Post by kirilisa »

I'm having trouble using this code because it seems some of the characters are not displayed correctly... a lot of & stuff. I've replaced a bunch of it by guessing what operator is actually supposed to be there but I think I've screwed up a few instances.
User avatar
feyd
Neighborhood Spidermoddy
Posts: 31559
Joined: Mon Mar 29, 2004 3:24 pm
Location: Bothell, Washington, USA

Post by feyd »

it was an error between versions of the forums, I've attempted to fix as much as possible, however no guarantee.
kirilisa
Forum Commoner
Posts: 28
Joined: Fri Dec 05, 2003 4:41 pm

Post by kirilisa »

Thanks, that helps. Any chance you can do the same fix to Redmonkey's Zip Class post (linked at the top of this post of his) viewtopic.php?t=29027
User avatar
feyd
Neighborhood Spidermoddy
Posts: 31559
Joined: Mon Mar 29, 2004 3:24 pm
Location: Bothell, Washington, USA

Post by feyd »

I'll see what I can do...
redmonkey
Forum Regular
Posts: 836
Joined: Thu Dec 18, 2003 3:58 pm

Post by redmonkey »

If you are having any problems copying/pasting this or the zip creation class, feel free to drop me a PM with your email address and I'll mail you over the scripts.

Or indeed, if you are having any problems implementing these code snippets, feel free to shout :)
User avatar
marientje
Forum Newbie
Posts: 1
Joined: Sun May 14, 2006 6:42 am

Post by marientje »

Hi, nice script!

Sometimes I can extract a file, sometimes I get:
Fatal error: Failed to read central directory header information

I don't know whats wrong. Could you help me out?
Catzwolf
Forum Newbie
Posts: 5
Joined: Tue Aug 31, 2004 12:30 am

Post by Catzwolf »

Does anyone have a working version of this script?

I am having issues due the forum encoding.

Please can you pm me regarding this script thanx?
Post Reply