Name) $this->Name = _t('AssetAdmin.NEWFOLDER',"NewFolder"); } /** * Find the given folder or create it both as {@link Folder} database records * and on the filesystem. If necessary, creates parent folders as well. If it's * unable to find or make the folder, it will return null (as /assets is unable * to be represented by a Folder DataObject) * * @param $folderPath string Absolute or relative path to the file. * If path is relative, its interpreted relative to the "assets/" directory. * @return Folder|null */ public static function find_or_make($folderPath) { // Create assets directory, if it is missing if(!file_exists(ASSETS_PATH)) Filesystem::makeFolder(ASSETS_PATH); $folderPath = trim(Director::makeRelative($folderPath)); // replace leading and trailing slashes $folderPath = preg_replace('/^\/?(.*)\/?$/', '$1', $folderPath); $parts = explode("/",$folderPath); $parentID = 0; $item = null; $filter = FileNameFilter::create(); foreach($parts as $part) { if(!$part) continue; // happens for paths with a trailing slash // Ensure search includes folders with illegal characters removed, but // err in favour of matching existing folders if $folderPath // includes illegal characters itself. $partSafe = $filter->filter($part); $item = Folder::get()->filter(array( 'ParentID' => $parentID, 'Name' => array($partSafe, $part) ))->first(); if(!$item) { $item = new Folder(); $item->ParentID = $parentID; $item->Name = $partSafe; $item->Title = $part; $item->write(); } if(!file_exists($item->getFullPath())) { Filesystem::makeFolder($item->getFullPath()); } $parentID = $item->ID; } return $item; } /** * Synchronize the file database with the actual content of the assets * folder. */ public function syncChildren() { $parentID = (int)$this->ID; // parentID = 0 on the singleton, used as the 'root node'; $added = 0; $deleted = 0; $skipped = 0; // First, merge any children that are duplicates $duplicateChildrenNames = DB::prepared_query( 'SELECT "Name" FROM "File" WHERE "ParentID" = ? GROUP BY "Name" HAVING count(*) > 1', array($parentID) )->column(); if($duplicateChildrenNames) foreach($duplicateChildrenNames as $childName) { // Note, we do this in the database rather than object-model; otherwise we get all sorts of problems // about deleting files $children = DB::prepared_query( 'SELECT "ID" FROM "File" WHERE "Name" = ? AND "ParentID" = ?', array($childName, $parentID) )->column(); if($children) { $keptChild = array_shift($children); foreach($children as $removedChild) { DB::prepared_query('UPDATE "File" SET "ParentID" = ? WHERE "ParentID" = ?', array($keptChild, $removedChild)); DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($removedChild)); } } else { user_error("Inconsistent database issue: SELECT ID FROM \"File\" WHERE Name = '$childName'" . " AND ParentID = $parentID should have returned data", E_USER_WARNING); } } // Get index of database content // We don't use DataObject so that things like subsites doesn't muck with this. $dbChildren = DB::prepared_query('SELECT * FROM "File" WHERE "ParentID" = ?', array($parentID)); $hasDbChild = array(); if($dbChildren) { foreach($dbChildren as $dbChild) { $className = $dbChild['ClassName']; if(!$className) $className = "File"; $hasDbChild[$dbChild['Name']] = new $className($dbChild); } } $unwantedDbChildren = $hasDbChild; // if we're syncing a folder with no ID, we assume we're syncing the root assets folder // however the Filename field is populated with "NewFolder", so we need to set this to empty // to satisfy the baseDir variable below, which is the root folder to scan for new files in if(!$parentID) $this->Filename = ''; // Iterate through the actual children, correcting the database as necessary $baseDir = $this->FullPath; // @todo this shouldn't call die() but log instead if($parentID && !$this->Filename) die($this->ID . " - " . $this->FullPath); if(file_exists($baseDir)) { $actualChildren = scandir($baseDir); $ignoreRules = Filesystem::config()->sync_blacklisted_patterns; $allowedExtensions = File::config()->allowed_extensions; $checkExtensions = $this->config()->apply_restrictions_to_admin || !Permission::check('ADMIN'); foreach($actualChildren as $actualChild) { $skip = false; // Check ignore patterns if($ignoreRules) foreach($ignoreRules as $rule) { if(preg_match($rule, $actualChild)) { $skip = true; break; } } // Check allowed extensions, unless admin users are allowed to bypass these exclusions if($checkExtensions && ($extension = self::get_file_extension($actualChild)) && !in_array(strtolower($extension), $allowedExtensions) ) { $skip = true; } if($skip) { $skipped++; continue; } // A record with a bad class type doesn't deserve to exist. It must be purged! if(isset($hasDbChild[$actualChild])) { $child = $hasDbChild[$actualChild]; if(( !( $child instanceof Folder ) && is_dir($baseDir . $actualChild) ) || (( $child instanceof Folder ) && !is_dir($baseDir . $actualChild)) ) { DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($child->ID)); unset($hasDbChild[$actualChild]); } } if(isset($hasDbChild[$actualChild])) { $child = $hasDbChild[$actualChild]; unset($unwantedDbChildren[$actualChild]); } else { $added++; $childID = $this->constructChild($actualChild); $child = DataObject::get_by_id("File", $childID); } if( $child && is_dir($baseDir . $actualChild)) { $childResult = $child->syncChildren(); $added += $childResult['added']; $deleted += $childResult['deleted']; $skipped += $childResult['skipped']; } // Clean up the child record from memory after use. Important! $child->destroy(); $child = null; } // Iterate through the unwanted children, removing them all if(isset($unwantedDbChildren)) foreach($unwantedDbChildren as $unwantedDbChild) { DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($unwantedDbChild->ID)); $deleted++; } } else { DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($this->ID)); } return array( 'added' => $added, 'deleted' => $deleted, 'skipped' => $skipped ); } /** * Construct a child of this Folder with the given name. * It does this without actually using the object model, as this starts messing * with all the data. Rather, it does a direct database insert. * * @param string $name Name of the file or folder * @return integer the ID of the newly saved File record */ public function constructChild($name) { // Determine the class name - File, Folder or Image $baseDir = $this->FullPath; if(is_dir($baseDir . $name)) { $className = "Folder"; } else { $className = File::get_class_for_file_extension(pathinfo($name, PATHINFO_EXTENSION)); } $ownerID = Member::currentUserID(); $filename = $this->Filename . $name; if($className == 'Folder' ) $filename .= '/'; $nowExpression = DB::get_conn()->now(); DB::prepared_query("INSERT INTO \"File\" (\"ClassName\", \"ParentID\", \"OwnerID\", \"Name\", \"Filename\", \"Created\", \"LastEdited\", \"Title\") VALUES (?, ?, ?, ?, ?, $nowExpression, $nowExpression, ?)", array($className, $this->ID, $ownerID, $name, $filename, $name) ); return DB::get_generated_id("File"); } /** * Take a file uploaded via a POST form, and save it inside this folder. * File names are filtered through {@link FileNameFilter}, see class documentation * on how to influence this behaviour. */ public function addUploadToFolder($tmpFile) { if(!is_array($tmpFile)) { user_error("Folder::addUploadToFolder() Not passed an array." . " Most likely, the form hasn't got the right enctype", E_USER_ERROR); } if(!isset($tmpFile['size'])) { return; } $base = BASE_PATH; // $parentFolder = Folder::findOrMake("Uploads"); // Generate default filename $nameFilter = FileNameFilter::create(); $file = $nameFilter->filter($tmpFile['name']); while($file[0] == '_' || $file[0] == '.') { $file = substr($file, 1); } $file = $this->RelativePath . $file; Filesystem::makeFolder(dirname("$base/$file")); $doubleBarrelledExts = array('.gz', '.bz', '.bz2'); $ext = ""; if(preg_match('/^(.*)(\.[^.]+)$/', $file, $matches)) { $file = $matches[1]; $ext = $matches[2]; // Special case for double-barrelled if(in_array($ext, $doubleBarrelledExts) && preg_match('/^(.*)(\.[^.]+)$/', $file, $matches)) { $file = $matches[1]; $ext = $matches[2] . $ext; } } $origFile = $file; $i = 1; while(file_exists("$base/$file$ext")) { $i++; $oldFile = $file; if(strpos($file, '.') !== false) { $file = preg_replace('/[0-9]*(\.[^.]+$)/', $i . '\\1', $file); } elseif(strpos($file, '_') !== false) { $file = preg_replace('/_([^_]+$)/', '_' . $i, $file); } else { $file .= '_'.$i; } if($oldFile == $file && $i > 2) user_error("Couldn't fix $file$ext with $i", E_USER_ERROR); } if (move_uploaded_file($tmpFile['tmp_name'], "$base/$file$ext")) { // Update with the new image return $this->constructChild(basename($file . $ext)); } else { if(!file_exists($tmpFile['tmp_name'])) { user_error("Folder::addUploadToFolder: '$tmpFile[tmp_name]' doesn't exist", E_USER_ERROR); } else { user_error("Folder::addUploadToFolder: Couldn't copy '$tmpFile[tmp_name]' to '$base/$file$ext'", E_USER_ERROR); } return false; } } protected function validate() { return new ValidationResult(true); } //------------------------------------------------------------------------------------------------- // Data Model Definition public function getRelativePath() { return parent::getRelativePath() . "/"; } public function onBeforeDelete() { if($this->ID && ($children = $this->AllChildren())) { foreach($children as $child) { if(!$this->Filename || !$this->Name || !file_exists($this->getFullPath())) { $child->setField('Name',null); $child->Filename = null; } $child->delete(); } } // Do this after so a folder's contents are removed before we delete the folder. if($this->Filename && $this->Name && file_exists($this->getFullPath())) { $files = glob( $this->getFullPath() . '/*' ); if( !$files || ( count( $files ) == 1 && preg_match( '/\/_resampled$/', $files[0] ) ) ) Filesystem::removeFolder( $this->getFullPath() ); } parent::onBeforeDelete(); } /** Override setting the Title of Folders to that Name, Filename and Title are always in sync. * Note that this is not appropriate for files, because someone might want to create a human-readable name * of a file that is different from its name on disk. But folders should always match their name on disk. */ public function setTitle($title) { $this->setName($title); } public function getTitle() { return $this->Name; } public function setName($name) { parent::setName($name); $this->setField('Title', $this->Name); } public function setFilename($filename) { $this->setField('Title',pathinfo($filename, PATHINFO_BASENAME)); parent::setFilename($filename); } /** * A folder doesn't have a (meaningful) file size. * * @return Null */ public function getSize() { return null; } /** * Delete the database record (recursively for folders) without touching the filesystem */ public function deleteDatabaseOnly() { if($children = $this->myChildren()) { foreach($children as $child) $child->deleteDatabaseOnly(); } parent::deleteDatabaseOnly(); } /** * Returns all children of this folder * * @return DataList */ public function myChildren() { return File::get()->filter("ParentID", $this->ID); } /** * Returns true if this folder has children * * @return bool */ public function hasChildren() { return $this->myChildren()->exists(); } /** * Returns true if this folder has children * * @return bool */ public function hasChildFolders() { return $this->ChildFolders()->exists(); } /** * Overloaded to call recursively on all contained {@link File} records. */ public function updateFilesystem() { parent::updateFilesystem(); // Note: Folders will have been renamed on the filesystem already at this point, // File->updateFilesystem() needs to take this into account. if($this->ID && ($children = $this->AllChildren())) { foreach($children as $child) { $child->updateFilesystem(); $child->write(); } } } /** * Return the FieldList used to edit this folder in the CMS. * You can modify this FieldList by subclassing folder, or by creating a {@link DataExtension} * and implemeting updateCMSFields(FieldList $fields) on that extension. */ public function getCMSFields() { // Hide field on root level, which can't be renamed if(!$this->ID || $this->ID === "root") { $titleField = new HiddenField("Name"); } else { $titleField = new TextField("Name", $this->fieldLabel('Name')); } $fields = new FieldList( $titleField, new HiddenField('ParentID') ); $this->extend('updateCMSFields', $fields); return $fields; } /** * Get the children of this folder that are also folders. * * @return DataList */ public function ChildFolders() { return Folder::get()->filter('ParentID', $this->ID); } /** * Get the number of children of this folder that are also folders. */ public function numChildFolders() { return $this->ChildFolders()->count(); } /** * @return String */ public function CMSTreeClasses() { $classes = sprintf('class-%s', $this->class); if(!$this->canDelete()) $classes .= " nodelete"; if(!$this->canEdit()) $classes .= " disabled"; $classes .= $this->markingClasses('numChildFolders'); return $classes; } /** * @return string */ public function getTreeTitle() { return $treeTitle = sprintf( "%s", Convert::raw2xml(str_replace(array("\n","\r"),"",$this->Title)) ); } }