root/fDirectory.php

Revision 760, 17.4 kB (checked in by wbond, 3 days ago)

BackwardsCompatibilityBreak - fixed ticket #376 - fDirectory::scan() and fDirectory::scanRecursive() to strip the current directory's path before matching the $filter. Also added support for glob style matching.

LineHide Line Numbers
1 <?php
2 /**
3  * Represents a directory on the filesystem, also provides static directory-related methods
4  *
5  * @copyright  Copyright (c) 2007-2010 Will Bond, others
6  * @author     Will Bond [wb] <will@flourishlib.com>
7  * @author     Will Bond, iMarc LLC [wb-imarc] <will@imarc.net>
8  * @license    http://flourishlib.com/license
9  *
10  * @package    Flourish
11  * @link       http://flourishlib.com/fDirectory
12  *
13  * @version    1.0.0b10
14  * @changes    1.0.0b10  BackwardsCompatibilityBreak - Fixed ::scan() and ::scanRecursive() to strip the current directory's path before matching, added support for glob style matching [wb, 2010-03-05]
15  * @changes    1.0.0b9   Changed the way directories deleted in a filesystem transaction are handled, including improvements to the exception that is thrown [wb+wb-imarc, 2010-03-05]
16  * @changes    1.0.0b8   Backwards Compatibility Break - renamed ::getFilesize() to ::getSize(), added ::move() [wb, 2009-12-16]
17  * @changes    1.0.0b7   Fixed ::__construct() to throw an fValidationException when the directory does not exist [wb, 2009-08-21]
18  * @changes    1.0.0b6   Fixed a bug where deleting a directory would prevent any future operations in the same script execution on a file or directory with the same path [wb, 2009-08-20]
19  * @changes    1.0.0b5   Added the ability to skip checks in ::__construct() for better performance in conjunction with fFilesystem::createObject() [wb, 2009-08-06]
20  * @changes    1.0.0b4   Refactored ::scan() to use the new fFilesystem::createObject() method [wb, 2009-01-21]
21  * @changes    1.0.0b3   Added the $regex_filter parameter to ::scan() and ::scanRecursive(), fixed bug in ::scanRecursive() [wb, 2009-01-05]
22  * @changes    1.0.0b2   Removed some unnecessary error suppresion operators [wb, 2008-12-11]
23  * @changes    1.0.0b    The initial implementation [wb, 2007-12-21]
24  */
25 class fDirectory
26 {
27     // The following constants allow for nice looking callbacks to static methods
28     const create        = 'fDirectory::create';
29     const makeCanonical = 'fDirectory::makeCanonical';
30    
31    
32     /**
33     * Creates a directory on the filesystem and returns an object representing it
34     *
35     * The directory creation is done recursively, so if any of the parent
36     * directories do not exist, they will be created.
37     *
38     * This operation will be reverted by a filesystem transaction being rolled back.
39     *
40     * @throws fValidationException  When no directory was specified, or the directory already exists
41     *
42     * @param  string  $directory  The path to the new directory
43     * @param  numeric $mode       The mode (permissions) to use when creating the directory. This should be an octal number (requires a leading zero). This has no effect on the Windows platform.
44     * @return fDirectory
45     */
46     static public function create($directory, $mode=0777)
47     {
48         if (empty($directory)) {
49             throw new fValidationException('No directory name was specified');
50         }
51        
52         if (file_exists($directory)) {
53             throw new fValidationException(
54                 'The directory specified, %s, already exists',
55                 $directory
56             );
57         }
58        
59         $parent_directory = fFilesystem::getPathInfo($directory, 'dirname');
60         if (!file_exists($parent_directory)) {
61             fDirectory::create($parent_directory, $mode);
62         }
63        
64         if (!is_writable($parent_directory)) {
65             throw new fEnvironmentException(
66                 'The directory specified, %s, is inside of a directory that is not writable',
67                 $directory
68             );
69         }
70        
71         mkdir($directory, $mode);
72        
73         $directory = new fDirectory($directory);
74        
75         fFilesystem::recordCreate($directory);
76        
77         return $directory;
78     }
79    
80    
81     /**
82     * Makes sure a directory has a `/` or `\` at the end
83     *
84     * @param  string $directory  The directory to check
85     * @return string  The directory name in canonical form
86     */
87     static public function makeCanonical($directory)
88     {
89         if (substr($directory, -1) != '/' && substr($directory, -1) != '\\') {
90             $directory .= DIRECTORY_SEPARATOR;
91         }
92         return $directory;
93     }
94    
95    
96     /**
97     * A backtrace from when the file was deleted
98     *
99     * @var array
100     */
101     protected $deleted = NULL;
102    
103     /**
104     * The full path to the directory
105     *
106     * @var string
107     */
108     protected $directory;
109    
110    
111     /**
112     * Creates an object to represent a directory on the filesystem
113     *
114     * If multiple fDirectory objects are created for a single directory,
115     * they will reflect changes in each other including rename and delete
116     * actions.
117     *
118     * @throws fValidationException  When no directory was specified, when the directory does not exist or when the path specified is not a directory
119     *
120     * @param  string  $directory    The path to the directory
121     * @param  boolean $skip_checks  If file checks should be skipped, which improves performance, but may cause undefined behavior - only skip these if they are duplicated elsewhere
122     * @return fDirectory
123     */
124     public function __construct($directory, $skip_checks=FALSE)
125     {
126         if (!$skip_checks) {
127             if (empty($directory)) {
128                 throw new fValidationException('No directory was specified');
129             }
130            
131             if (!is_readable($directory)) {
132                 throw new fValidationException(
133                     'The directory specified, %s, does not exist or is not readable',
134                     $directory
135                 );
136             }
137             if (!is_dir($directory)) {
138                 throw new fValidationException(
139                     'The directory specified, %s, is not a directory',
140                     $directory
141                 );
142             }
143         }
144        
145         $directory = self::makeCanonical(realpath($directory));
146        
147         $this->directory =& fFilesystem::hookFilenameMap($directory);
148         $this->deleted   =& fFilesystem::hookDeletedMap($directory);
149        
150         // If the directory is listed as deleted and we are not inside a transaction,
151         // but we've gotten to here, then the directory exists, so we can wipe the backtrace
152         if ($this->deleted !== NULL && !fFilesystem::isInsideTransaction()) {
153             fFilesystem::updateDeletedMap($directory, NULL);
154         }
155     }
156    
157    
158     /**
159     * All requests that hit this method should be requests for callbacks
160     *
161     * @internal
162      *
163     * @param  string $method  The method to create a callback for
164     * @return callback  The callback for the method requested
165     */
166     public function __get($method)
167     {
168         return array($this, $method);       
169     }
170    
171    
172     /**
173     * Returns the full filesystem path for the directory
174     *
175     * @return string  The full filesystem path
176     */
177     public function __toString()
178     {
179         return $this->getPath();
180     }
181    
182    
183     /**
184     * Will delete a directory and all files and directories inside of it
185     *
186     * This operation will not be performed until the filesystem transaction
187     * has been committed, if a transaction is in progress. Any non-Flourish
188     * code (PHP or system) will still see this directory and all contents as
189     * existing until that point.
190     *
191     * @return void
192     */
193     public function delete()
194     {
195         if ($this->deleted) {
196             return;   
197         }
198        
199         $files = $this->scan();
200        
201         foreach ($files as $file) {
202             $file->delete();
203         }
204        
205         // Allow filesystem transactions
206         if (fFilesystem::isInsideTransaction()) {
207             return fFilesystem::delete($this);
208         }
209        
210         rmdir($this->directory);
211        
212         fFilesystem::updateDeletedMap($this->directory, debug_backtrace());
213         fFilesystem::updateFilenameMapForDirectory($this->directory, '*DELETED at ' . time() . ' with token ' . uniqid('', TRUE) . '* ' . $this->directory);
214     }
215    
216    
217     /**
218     * Gets the name of the directory
219     *
220     * @return string  The name of the directory
221     */
222     public function getName()
223     {
224         return fFilesystem::getPathInfo($this->directory, 'basename');
225     }
226    
227    
228     /**
229     * Gets the parent directory
230     *
231     * @return fDirectory  The object representing the parent directory
232     */
233     public function getParent()
234     {
235         $this->tossIfDeleted();
236        
237         $dirname = fFilesystem::getPathInfo($this->directory, 'dirname');
238        
239         if ($dirname == $this->directory) {
240             throw new fEnvironmentException(
241                 'The current directory does not have a parent directory'
242             );
243         }
244        
245         return new fDirectory($dirname);
246     }
247    
248    
249     /**
250     * Gets the directory's current path
251     *
252     * If the web path is requested, uses translations set with
253     * fFilesystem::addWebPathTranslation()
254     *
255     * @param  boolean $translate_to_web_path  If the path should be the web path
256     * @return string  The path for the directory
257     */
258     public function getPath($translate_to_web_path=FALSE)
259     {
260         $this->tossIfDeleted();
261        
262         if ($translate_to_web_path) {
263             return fFilesystem::translateToWebPath($this->directory);
264         }
265         return $this->directory;
266     }
267    
268    
269     /**
270     * Gets the disk usage of the directory and all files and folders contained within
271     *
272     * This method may return incorrect results if files over 2GB exist and the
273     * server uses a 32 bit operating system
274     *
275     * @param  boolean $format          If the filesize should be formatted for human readability
276     * @param  integer $decimal_places  The number of decimal places to format to (if enabled)
277     * @return integer|string  If formatted, a string with filesize in b/kb/mb/gb/tb, otherwise an integer
278     */
279     public function getSize($format=FALSE, $decimal_places=1)
280     {
281         $this->tossIfDeleted();
282        
283         $size = 0;
284        
285         $children = $this->scan();
286         foreach ($children as $child) {
287             $size += $child->getSize();
288         }
289        
290         if (!$format) {
291             return $size;
292         }
293        
294         return fFilesystem::formatFilesize($size, $decimal_places);
295     }
296    
297    
298     /**
299     * Check to see if the current directory is writable
300     *
301     * @return boolean  If the directory is writable
302     */
303     public function isWritable()
304     {
305         $this->tossIfDeleted();
306        
307         return is_writable($this->directory);
308     }
309    
310    
311     /**
312     * Moves the current directory into a different directory
313     *
314     * Please note that ::rename() will rename a directory in its current
315     * parent directory or rename it into a different parent directory.
316     *
317     * If the current directory's name already exists in the new parent
318     * directory and the overwrite flag is set to false, the name will be
319     * changed to a unique name.
320     *
321     * This operation will be reverted if a filesystem transaction is in
322     * progress and is later rolled back.
323     *
324     * @throws fValidationException  When the new parent directory passed is not a directory, is not readable or is a sub-directory of this directory
325     *
326     * @param  fDirectory|string $new_parent_directory  The directory to move this directory into
327     * @param  boolean           $overwrite             If the current filename already exists in the new directory, `TRUE` will cause the file to be overwritten, `FALSE` will cause the new filename to change
328     * @return fDirectory  The directory object, to allow for method chaining
329     */
330     public function move($new_parent_directory, $overwrite)
331     {
332         if (!$new_parent_directory instanceof fDirectory) {
333             $new_parent_directory = new fDirectory($new_parent_directory);
334         }
335        
336         if (strpos($new_parent_directory->getPath(), $this->getPath()) === 0) {
337             throw new fValidationException('It is not possible to move a directory into one of its sub-directories');   
338         }
339        
340         return $this->rename($new_parent_directory->getPath() . $this->getName(), $overwrite);
341     }
342    
343    
344     /**
345     * Renames the current directory
346     *
347     * This operation will NOT be performed until the filesystem transaction
348     * has been committed, if a transaction is in progress. Any non-Flourish
349     * code (PHP or system) will still see this directory (and all contained
350     * files/dirs) as existing with the old paths until that point.
351     *
352     * @param  string  $new_dirname  The new full path to the directory or a new name in the current parent directory
353     * @param  boolean $overwrite    If the new dirname already exists, TRUE will cause the file to be overwritten, FALSE will cause the new filename to change
354     * @return void
355     */
356     public function rename($new_dirname, $overwrite)
357     {
358         $this->tossIfDeleted();
359        
360         if (!$this->getParent()->isWritable()) {
361             throw new fEnvironmentException(
362                 'The directory, %s, can not be renamed because the directory containing it is not writable',
363                 $this->directory
364             );
365         }
366        
367         // If the dirname does not contain any folder traversal, rename the dir in the current parent directory
368         if (preg_match('#^[^/\\\\]+$#D', $new_dirname)) {
369             $new_dirname = $this->getParent()->getPath() . $new_dirname;   
370         }
371        
372         $info = fFilesystem::getPathInfo($new_dirname);
373        
374         if (!file_exists($info['dirname'])) {
375             throw new fProgrammerException(
376                 'The new directory name specified, %s, is inside of a directory that does not exist',
377                 $new_dirname
378             );
379         }
380        
381         if (file_exists($new_dirname)) {
382             if (!is_writable($new_dirname)) {
383                 throw new fEnvironmentException(
384                     'The new directory name specified, %s, already exists, but is not writable',
385                     $new_dirname
386                 );
387             }
388             if (!$overwrite) {
389                 $new_dirname = fFilesystem::makeUniqueName($new_dirname);
390             }
391         } else {
392             $parent_dir = new fDirectory($info['dirname']);
393             if (!$parent_dir->isWritable()) {
394                 throw new fEnvironmentException(
395                     'The new directory name specified, %s, is inside of a directory that is not writable',
396                     $new_dirname
397                 );
398             }
399         }
400        
401         rename($this->directory, $new_dirname);
402        
403         // Make the dirname absolute
404         $new_dirname = fDirectory::makeCanonical(realpath($new_dirname));
405        
406         // Allow filesystem transactions
407         if (fFilesystem::isInsideTransaction()) {
408             fFilesystem::rename($this->directory, $new_dirname);
409         }
410        
411         fFilesystem::updateFilenameMapForDirectory($this->directory, $new_dirname);
412     }
413    
414    
415     /**
416     * Performs a [http://php.net/scandir scandir()] on a directory, removing the `.` and `..` entries
417     *
418     * If the `$filter` looks like a valid PCRE pattern - matching delimeters
419     * (a delimeter can be any non-alphanumeric, non-backslash, non-whitespace
420     * character) followed by zero or more of the flags `i`, `m`, `s`, `x`,
421     * `e`, `A`, `D`,  `S`, `U`, `X`, `J`, `u` - then
422     * [http://php.net/preg_match `preg_match()`] will be used.
423     *
424     * Otherwise the `$filter` will do a case-sensitive match with `*` matching
425     * zero or more characters and `?` matching a single character.
426     *
427     * On all OSes (even Windows), directories will be separated by `/`s when
428     * comparing with the `$filter`.
429     *
430     * @param  string $filter  A PCRE or glob pattern to filter files/directories by path - directories can be detected by checking for a trailing / (even on Windows)
431     * @return array  The fFile (or fImage) and fDirectory objects for the files/directories in this directory
432     */
433     public function scan($filter=NULL)
434     {
435         $this->tossIfDeleted();
436        
437         $files   = array_diff(scandir($this->directory), array('.', '..'));
438         $objects = array();
439        
440         if ($filter && !preg_match('#^([^a-zA-Z0-9\\\\\s]).*\1[imsxeADSUXJu]*$#D', $filter)) {
441             $filter = '#^' . strtr(
442                 preg_quote($filter, '#'),
443                 array(
444                     '\\*' => '.*',
445                     '\\?' => '.'
446                 )
447             ) . '$#D';
448         }
449        
450         natcasesort($files);
451        
452         foreach ($files as $file) {
453             if ($filter) {
454                 $test_path = (is_dir($file)) ? $file . '/' : $file;
455                 if (!preg_match($filter, $test_path)) {
456                     continue;
457                 }
458             }
459            
460             $objects[] = fFilesystem::createObject($this->directory . $file);
461         }
462        
463         return $objects;
464     }
465    
466    
467     /**
468     * Performs a **recursive** [http://php.net/scandir scandir()] on a directory, removing the `.` and `..` entries
469     *
470     * @param  string $filter  A PCRE or glob pattern to filter files/directories by path - see ::scan() for details
471     * @return array  The fFile (or fImage) and fDirectory objects for the files/directories (listed recursively) in this directory
472     */
473     public function scanRecursive($filter=NULL)
474     {
475         $this->tossIfDeleted();
476        
477         $objects = $this->scan();
478        
479         $total_files = sizeof($objects);
480         for ($i=0; $i < $total_files; $i++) {
481             if ($objects[$i] instanceof fDirectory) {
482                 array_splice($objects, $i+1, 0, $objects[$i]->scanRecursive());
483             }
484         }
485        
486         if ($filter) {
487             if (!preg_match('#^([^a-zA-Z0-9\\\\\s*?^$]).*\1[imsxeADSUXJu]*$#D', $filter)) {
488                 $filter = '#^' . strtr(
489                     preg_quote($filter, '#'),
490                     array(
491                         '\\*' => '.*',
492                         '\\?' => '.'
493                     )
494                 ) . '$#D';
495             }
496            
497             $new_objects  = array();
498             $strip_length = strlen($this->getPath());
499             foreach ($objects as $object) {
500                 $test_path = substr($object->getPath(), $strip_length);
501                 $test_path = str_replace(DIRECTORY_SEPARATOR, '/', $test_path);
502                 if (!preg_match($filter, $test_path)) {
503                     continue;   
504                 }   
505                 $new_objects[] = $object;
506             }
507             $objects = $new_objects;
508         }
509        
510         return $objects;
511     }
512    
513    
514     /**
515     * Throws an exception if the directory has been deleted
516     *
517     * @return void
518     */
519     protected function tossIfDeleted()
520     {
521         if ($this->deleted) {
522             throw new fProgrammerException(
523                 "The action requested can not be performed because the directory has been deleted\n\nBacktrace for fDirectory::delete() call:\n%s",
524                 fCore::backtrace(0, $this->deleted)
525             );
526         }
527     }
528 }
529  
530  
531  
532 /**
533  * Copyright (c) 2007-2010 Will Bond <will@flourishlib.com>, others
534  *
535  * Permission is hereby granted, free of charge, to any person obtaining a copy
536  * of this software and associated documentation files (the "Software"), to deal
537  * in the Software without restriction, including without limitation the rights
538  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
539  * copies of the Software, and to permit persons to whom the Software is
540  * furnished to do so, subject to the following conditions:
541  *
542  * The above copyright notice and this permission notice shall be included in
543  * all copies or substantial portions of the Software.
544  *
545  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
546  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
547  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
548  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
549  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
550  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
551  * THE SOFTWARE.
552  */