diff --git a/wire/modules/Pages/PagesVersions/PageVersionInfo.php b/wire/modules/Pages/PagesVersions/PageVersionInfo.php index 2abfb0e0..24092e77 100644 --- a/wire/modules/Pages/PagesVersions/PageVersionInfo.php +++ b/wire/modules/Pages/PagesVersions/PageVersionInfo.php @@ -3,25 +3,30 @@ /** * Page Version Info * - * For pages that are a version, this class represents - * the `_version` property of the page. + * For pages that are a version, this class represents the `_version` + * property of the page. It is also used as the return value for some + * methods in the PagesVersions class to return version information. + * * ProcessWire 3.x, Copyright 2023 by Ryan Cramer * https://processwire.com * - * @property int $version - * @property string $description - * @property int $created - * @property int $modified - * @property int $pages_id - * @property Page $page - * @property int $created_users_id - * @property int $modified_users_id - * @property-read User|NullPage $createdUser - * @property-read User|NullPage $modifiedUser - * @property-read string $createdStr - * @property-read string $modifiedStr - * @property string $action + * @property int $version Version number + * @property string $description Version description (not entity encoded) + * @property-read string $descriptionHtml Version description entity encoded for output in HTML + * @property int $created Date/time created (unix timestamp) + * @property-read string $createdStr Date/time created (YYYY-MM-DD HH:MM:SS) + * @property int $modified Date/time last modified (unix timestamp) + * @property-read string $modifiedStr Date/time last modified (YYYY-MM-DD HH:MM:SS) + * @property int $pages_id ID of page this version is for + * @property Page $page Page this version is for + * @property int $created_users_id ID of user that created this version + * @property-read User|NullPage $createdUser User that created this version + * @property int $modified_users_id ID of user that last modified this version + * @property-read User|NullPage $modifiedUser User that last modified this version + * @property array $properties Native page properties array in format [ property => value ] + * @property-read array $fieldNames Names of fields in this version + * @property string $action Populated with action name if info is being used for an action * */ class PageVersionInfo extends WireData { @@ -52,6 +57,7 @@ class PageVersionInfo extends WireData { 'created_users_id' => 0, 'modified_users_id' => 0, 'pages_id' => 0, + 'properties' => [], 'action' => '', ]; parent::setArray(array_merge($defaults, $data)); @@ -98,6 +104,8 @@ class PageVersionInfo extends WireData { case 'modifiedUser': return $this->getModifiedUser(); case 'createdStr': return $this->created > 0 ? date('Y-m-d H:i:s', $this->created) : ''; case 'modifiedStr': return $this->modified > 0 ? date('Y-m-d H:i:s', $this->modified) : ''; + case 'fieldNames': return $this->getFieldNames(); + case 'descriptionHtml': return $this->wire()->sanitizer->entities(parent::get('description')); } return parent::get($key); } @@ -150,6 +158,47 @@ class PageVersionInfo extends WireData { return $id ? $this->wire()->users->get($id) : $this->getCreatedUser(); } + /** + * Get native property names in this version + * + * #pw-internal + * + * @return string[] + * + */ + public function getPropertyNames() { + return array_keys($this->properties); + } + + /** + * Get field names in this version + * + * #pw-internal + * + * @return string[] + * + */ + public function getFieldNames() { + $a = parent::get('fieldNames'); + if(!empty($a)) return $a; + $a = $this->wire()->pagesVersions->getPageVersionFields($this->pages_id, $this->version); + $a = array_keys($a); + parent::set('fieldNames', $a); + return $a; + } + + /** + * Get field and property names in this version + * + * #pw-internal + * + * @return string[] + * + */ + public function getNames() { + return array_merge($this->getPropertyNames(), $this->getFieldNames()); + } + /** * Set action for PagesVersions * diff --git a/wire/modules/Pages/PagesVersions/PagesVersions.module.php b/wire/modules/Pages/PagesVersions/PagesVersions.module.php index ef3fc6c2..c4b3a913 100644 --- a/wire/modules/Pages/PagesVersions/PagesVersions.module.php +++ b/wire/modules/Pages/PagesVersions/PagesVersions.module.php @@ -57,7 +57,7 @@ class PagesVersions extends Wire implements Module { return [ 'title' => 'Pages Versions', 'summary' => 'Provides a version control API for pages in ProcessWire.', - 'version' => 1, + 'version' => 2, 'icon' => self::iconName, 'autoload' => true, 'author' => 'Ryan Cramer', @@ -86,6 +86,8 @@ class PagesVersions extends Wire implements Module { * * @param Page $page Page that version is for * @param int $version Version number to get + * @param array $options + * - `names` (array): Optionally load only these field/property names from version. * @return Page|NullPage * - Returned page is a clone/copy of the given page updated for version data. * - Returns a `NullPage` if requested version is not found or not allowed. @@ -120,13 +122,13 @@ class PagesVersions extends Wire implements Module { public function loadPageVersion(Page $page, $version, array $options = []) { $defaults = [ - 'names' => [], // Optionally load only these field/property names from version. + 'names' => [], ]; $database = $this->wire()->database; $table = self::versionsTable; $options = array_merge($defaults, $options); - $filter = count($options['names']) > 0; + $partial = count($options['names']) > 0; $version = $this->pageVersionNumber($page, $version); $of = $page->of(); @@ -166,23 +168,26 @@ class PagesVersions extends Wire implements Module { if(is_array($data)) { foreach($data as $name => $value) { - if($filter && !in_array($name, $options['names'])) continue; + if($partial && !in_array($name, $options['names'])) continue; $page->set($name, $value); } } foreach($page->template->fieldgroup as $field) { /** @var Field $field */ - if($filter && !in_array($field->name, $options['names'])) continue; + if($partial && !in_array($field->name, $options['names'])) continue; + $allow = $this->allowFieldVersions($field); if(!$allow) continue; + if($allow instanceof FieldtypeDoesVersions) { $value = $allow->getPageFieldVersion($page, $field, $version); } else { $value = $this->getPageFieldVersion($page, $field, $version); } + if($value === null) { - // @todo set to blankValue or leave as-is? + // value is not present in version } else { $page->set($field->name, $value); } @@ -209,8 +214,9 @@ class PagesVersions extends Wire implements Module { * * @param Page $page * @param array $options - * - `getInfo`: Specify true to instead get PageVersionInfo objects (default=false) - * - `sort`: Sort by property, one of: 'created', '-created', 'version', '-version' (default='-created') + * - `getInfo` (bool): Specify true to instead get PageVersionInfo objects (default=false) + * - `sort` (string): Sort by property, one of: 'created', '-created', 'version', '-version' (default='-created') + * - `version` (array): Limit to this version number, for internal use (default=0) * @return PageVersionInfo[]|Page[] * - Returns Array of `Page` objects or array of `PageVersionInfo` objects if `getInfo` requested. * - When returning pages, version info is in `$page->_version` value of each page, @@ -223,6 +229,7 @@ class PagesVersions extends Wire implements Module { $defaults = [ 'getInfo' => false, 'sort' => '-created', + 'version' => 0, ]; $sorts = [ @@ -245,18 +252,22 @@ class PagesVersions extends Wire implements Module { $sql = "SELECT version, description, created, modified, " . - "created_users_id, modified_users_id " . + "created_users_id, modified_users_id, data " . "FROM $table " . - "WHERE pages_id=:pages_id " . - "ORDER BY " . $sorts[$options['sort']]; + "WHERE pages_id=:pages_id " . ($options['version'] ? "AND version=:version " : "") . + ($options['version'] ? "LIMIT 1" : "ORDER BY " . $sorts[$options['sort']]); $query = $database->prepare($sql); $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + if($options['version']) $query->bindValue(':version', (int) $options['version'], \PDO::PARAM_INT); $query->execute(); while($row = $query->fetch(\PDO::FETCH_ASSOC)) { + $properties = json_decode($row['data'], true); + unset($row['data']); $info = new PageVersionInfo($row); $info->set('pages_id', $page->id); + $info->set('properties', $properties); $rows[] = $this->wire($info); } @@ -283,16 +294,49 @@ class PagesVersions extends Wire implements Module { return $pageVersions; } + /** + * Get info for given page and version + * + * ~~~~~ + * // get info for version 2 + * $info = $pagesVersions->getPageVersionInfo($page, 2); + * if($info) { + * echo "Version: $info->version
"; + * echo "Created: $info->createdStr by {$info->createdUser->name}
"; + * echo "Description: $info->descriptionHtml"; + * } else { + * echo "Version does not exist"; + * } + * ~~~~~ + * + * #pw-group-info + * + * @param Page $page + * @param int $version + * @return PageVersionInfo|null + * + */ + public function getPageVersionInfo(Page $page, $version) { + $options = [ + 'getInfo' => true, + 'version' => $version, + ]; + $a = $this->getPageVersions($page, $options); + return count($a) ? reset($a) : null; + } + /** * Get just PageVersionInfo objects for all versions of given page * - * This is the same as using the getPageVersions() method with the `getInfo` option. + * This is the same as using the getPageVersions() method with the `getInfo` option. + * + * #pw-group-info * * ~~~~~ * $page = $pages->get(1234); * $infos = $pagesVersions->getPageVersionInfos($page); * foreach($infos as $info) { - * echo $info->version; // i.e. 2, 3, 4, etc. + * echo "
  • $info->version: $descriptionHtml
  • "; // i.e. "2: Hello world" * } * ~~~~~ * @@ -308,14 +352,13 @@ class PagesVersions extends Wire implements Module { $options['getInfo'] = true; return $this->getPageVersions($page, $options); } - + /** * Get all pages that have 1 or more versions available * * #pw-group-getting * * @return PageArray - * @throws WireException * */ public function getAllPagesWithVersions() { @@ -334,7 +377,7 @@ class PagesVersions extends Wire implements Module { /** * Does page have the given version? * - * #pw-group-getting + * #pw-group-info * * @param Page $page * @param int|string|PageVersionInfo $version Version number or omit to return quantity of versions @@ -374,7 +417,7 @@ class PagesVersions extends Wire implements Module { * This is the same as calling the `hasPageVersion()` method * with $version argument omitted. * - * #pw-group-getting + * #pw-group-info * * @param Page $page * @return int @@ -405,7 +448,8 @@ class PagesVersions extends Wire implements Module { * * @param Page $page * @param array $options - * - `description` (string) Optional text description for version + * - `description` (string): Optional text description for version. + * - `names` (array): Names of fields/properties to include in the version or omit for all. * @return int Version number or 0 if no version created * @throws WireException|\PDOException * @@ -414,6 +458,7 @@ class PagesVersions extends Wire implements Module { $defaults = [ 'description' => '', + 'names' => [], 'retry' => 10, // max times to retry ]; @@ -457,14 +502,8 @@ class PagesVersions extends Wire implements Module { * the `addPageVersion()` method and returns the added version number. * @param array $options * - `description` (string): Optional text description for version (default='') - * - `returnVersion` (bool): Return the version number on success? (default=false) - * - `returnNames` (bool): Return names of properties/fields that were saved (default=false) * - `update` (bool): Update version if it already exists (default=true) - * @return bool|int|array Boolean true, version number, or array of property/field names - * - Returns boolean true when version is saved or false when no version is saved. - * - Returns integer version number when no version specified in arguments and new version added, - * - Returns integer version number when the `returnVersion` option is true. - * - Returns array of field names in the version when the `returnNames` option is true. + * @return int|array Returns version number saved or added or 0 on fail * @throws WireException|\PDOException * */ @@ -472,30 +511,30 @@ class PagesVersions extends Wire implements Module { $defaults = [ 'description' => null, - 'names' => [], // save only these field/property names, internal use only + 'names' => [], 'copyFiles' => true, // make a copy of the page’s files? internal use only - 'returnVersion' => false, - 'returnNames' => false, + 'returnNames' => false, // undocumented option, internal use only 'update' => true, ]; $options = array_merge($defaults, $options); $database = $this->wire()->database; - $filter = !empty($options['names']); + $copyFilesByField = false; + $partial = !empty($options['names']); $table = self::versionsTable; $date = date('Y-m-d H:i:s'); $user = $this->wire()->user; $of = $page->of(); - if(!$this->allowPageVersions($page)) return false; + if(!$this->allowPageVersions($page)) return 0; if($of) $page->of(false); if(!is_int($version)) $version = $this->pageVersionNumber($page, $version); if($version < 1) $version = $this->pageVersionNumber($page); if(!$version) { - $result = $this->addPageVersion($page); + $version = $this->addPageVersion($page, $options); if($of) $page->of(true); - return $result; + return $version; } $sql = @@ -526,32 +565,57 @@ class PagesVersions extends Wire implements Module { $query->bindValue(':description', (string) $options['description']); $query->bindValue(':data', json_encode($data)); $query->execute(); - - if($options['copyFiles']) { + + if(!$options['copyFiles']) { + // files will be excluded from the data + + } else if($partial) { + // if only saving some fields in the version then copy by field + if($this->pagesVersionsFiles->useFilesByField($page, $options['names'])) { + $copyFilesByField = true; + } else { + // page does not support partial version + $this->pageError($page, $this->_('Partial version not supported (file fields), saved full version')); + $partial = false; + } + + } else if($this->pagesVersionsFiles->useFilesByField($page)) { + // page and all its fields support copying files by field + $copyFilesByField = true; + } + + if(!$copyFilesByField) { + // copy all files in directory (fallback) $this->pagesVersionsFiles->copyPageVersionFiles($page, $version); } foreach($page->fieldgroup as $field) { + /** @var Field $field */ - if($filter && !in_array($field->name, $options['names'])) continue; + if($partial && !in_array($field->name, $options['names'])) continue; + $allow = $this->allowFieldVersions($field); + if($allow instanceof FieldtypeDoesVersions) { $added = $allow->savePageFieldVersion($page, $field, $version); + } else if($allow === true) { $added = $this->savePageFieldVersion($page, $field, $version); + if($added && $copyFilesByField && $this->pagesVersionsFiles->fieldSupportsFiles($field)) { + $this->pagesVersionsFiles->copyPageFieldVersionFiles($page, $field, $version); + } + } else { // field excluded from version $added = false; } + if($added) $names[] = $field->name; } if($of) $page->of(true); - if($options['returnVersion']) return $version; - if($options['returnNames']) return $names; - - return true; + return ($options['returnNames'] ? $names : $version); } /** @@ -669,6 +733,7 @@ class PagesVersions extends Wire implements Module { * @param Page $page Page to restore version to or a page that was loaded as a version. * @param int $version Version number to restore. Can be omitted if given $page is already a version. * @param array $options + * - `names` (array): Names of fields/properties to restore or omit for all (default=[]) * - `useTempVersion` (bool): Create a temporary version and restore from that? (default=auto-detect). * This is necessary for some Fieldtypes like nested repeaters. Use of it is auto-detected so * it is not necessary to specify this when using the public API. @@ -679,6 +744,7 @@ class PagesVersions extends Wire implements Module { public function restorePageVersion(Page $page, $version = 0, array $options = []) { $defaults = [ + 'names' => [], 'useTempVersion' => null, ]; @@ -686,18 +752,29 @@ class PagesVersions extends Wire implements Module { $version = (int) "$version"; $pageVersion = $this->pageVersionNumber($page); $useTempVersion = $options['useTempVersion']; + $partialRestore = count($options['names']) > 0; $of = $page->of(); - + if($version < 1) $version = $pageVersion; if($version < 1) { - return $this->pageError($page, $this->_('Cannot restore unknown version')); + return $this->pageError($page, + sprintf($this->_('Cannot restore unknown version %s'), "$version") + ); } if(!$this->allowPageVersions($page)) { - return $this->pageError($page, $this->_('Restore failed, page does not allow versions')); + return $this->pageError($page, + $this->_('Restore failed, page does not allow versions') + ); } + if($partialRestore && !$this->pageSupportsPartialVersion($page, $options['names'])) { + return $this->pageError($page, + $this->_('One or more fields requested does not support partial restore.') + ); + } + if($pageVersion) { // given page is the one to restore $versionPage = $page; @@ -722,14 +799,32 @@ class PagesVersions extends Wire implements Module { $version = $useTempVersion; } } - + + // this action is looked for in the Pages::saveReady hook $this->pageVersionInfo($versionPage, 'action', PageVersionInfo::actionRestore); - $this->pagesVersionsFiles->restorePageVersionFiles($page, $version); + + if(!$partialRestore) { + // restore all files + $this->pagesVersionsFiles->restorePageVersionFiles($page, $version); + } foreach($page->fieldgroup as $field) { + /** @var Field $field */ $allow = $this->allowFieldVersions($field); - if($allow instanceof FieldtypeDoesVersions) { + + if(!$allow) { + // field not allowed in versions + + } else if($partialRestore && !in_array($field->name, $options['names'])) { + // partial restore does not include this field + + } else if($allow instanceof FieldtypeDoesVersions) { + // fieldtype handles its own version restore $allow->restorePageFieldVersion($page, $field, $version); + + } else if($partialRestore && $this->pagesVersionsFiles->fieldSupportsFiles($field)) { + // restore just files for a particular file field + $this->pagesVersionsFiles->restorePageFieldVersionFiles($versionPage, $field, $version); } } @@ -765,11 +860,18 @@ class PagesVersions extends Wire implements Module { * @param Page $page * @param Field $field * @param int $version + * @param array $options + * - `getRaw` (bool): Get raw data rather than page-ready value? (default=false) * @return mixed|null Returns null if version data for field not available, field value otherwise * */ - public function getPageFieldVersion(Page $page, Field $field, $version) { + public function getPageFieldVersion(Page $page, Field $field, $version, array $options = []) { + + $defaults = [ + 'getRaw' => false, + ]; + $options = array_merge($defaults, $options); $database = $this->wire()->database; $table = self::valuesTable; $version = (int) "$version"; @@ -792,7 +894,9 @@ class PagesVersions extends Wire implements Module { $value = $value['data']; } } - $value = $field->type->wakeupValue($page, $field, $value); + if(!$options['getRaw']) { + $value = $field->type->wakeupValue($page, $field, $value); + } } else { $value = null; } @@ -817,7 +921,7 @@ class PagesVersions extends Wire implements Module { $database = $this->wire()->database; $names = isset($options['names']) ? $options['names'] : []; - $filter = !empty($names); + $partial = !empty($names); $sql = "SELECT * FROM pages WHERE id=:id"; $query = $database->prepare($sql); @@ -827,10 +931,9 @@ class PagesVersions extends Wire implements Module { $query->closeCursor(); unset($data['id']); - if($filter) { + if($partial) { foreach($data as $name => $value) { - if(in_array($name, $names)) continue; - unset($data[$name]); + if(!in_array($name, $names)) unset($data[$name]); } } @@ -984,6 +1087,9 @@ class PagesVersions extends Wire implements Module { /** * Delete a page field version * + * This should not be called independently of deletePageVersion() as this + * method does not delete any files connected to the version. + * * @param Page $page * @param Field $field * @param int $version @@ -1195,6 +1301,37 @@ class PagesVersions extends Wire implements Module { if(wireInstanceOf($page, $disallows)) return false; return true; } + + /** + * Get the fields included with given page version + * + * #pw-internal + * + * @param Page|int $page + * @param int $version + * @return Field[] Array of field objects indexed by field name + * + */ + public function getPageVersionFields($page, $version) { + $pageId = (int) "$page"; + $fields = $this->wire()->fields; + $versionFields = []; + $table = self::valuesTable; + $sql = "SELECT field_id FROM $table WHERE pages_id=:pages_id AND version=:version"; + $query = $this->wire()->database->prepare($sql); + $query->bindValue(':pages_id', $pageId, \PDO::PARAM_INT); + $query->bindValue(':version', (int) $version, \PDO::PARAM_INT); + $query->execute(); + while($row = $query->fetch(\PDO::FETCH_NUM)) { + $fieldId = (int) $row[0]; + $field = $fields->get($fieldId); + if(!$field) continue; + $versionFields[$field->name] = $field; + } + $query->closeCursor(); + return $versionFields; + } + /** * Get next available version number for given page @@ -1236,6 +1373,26 @@ class PagesVersions extends Wire implements Module { return $fieldtype->versions()->hasNestedRepeaterFields($page); } + /** + * Does given page support partial version save and restore? + * + * #pw-internal + * + * @param Page $page + * @param array $names Optionally limit check to these field names + * @return bool + * + */ + public function pageSupportsPartialVersion(Page $page, array $names = []) { + $fileFields = $this->pagesVersionsFiles->getFileFields($page, [ 'names' => $names ]); + if(!count($fileFields)) { + return true; + } else if($this->pagesVersionsFiles->useFilesByField($page, $names)) { + return true; + } + return false; + } + /******************************************************************************** * HOOKS * @@ -1271,7 +1428,7 @@ class PagesVersions extends Wire implements Module { } $event->replace = true; - $event->return = $this->savePageVersion($page, $info->version, $options); + $event->return = (bool) $this->savePageVersion($page, $info->version, $options); $this->pagesVersionsFiles->hookBeforePagesSave($page); } diff --git a/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php b/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php index 8d6dad77..26d44fbf 100644 --- a/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php +++ b/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php @@ -46,7 +46,7 @@ class PagesVersionsFiles extends Wire { } /******************************************************************************** - * API SUPPORT METHODS + * API SUPPORT METHODS FOR FILES BY ENTIRE DIRECTORY * */ @@ -54,25 +54,34 @@ class PagesVersionsFiles extends Wire { * Copy files for given $page into version directory * * @param Page $page - * @param $version + * @param int $version * @return bool|int * */ public function copyPageVersionFiles(Page $page, $version) { - - if(!$page->hasFilesPath()) return 0; - - $files = $this->wire()->files; - $filesManager = $page->filesManager(); - - $sourcePath = $filesManager->path(); - $targetPath = $this->versionFilesPath($filesManager->___path(), $version); - - if($sourcePath === $targetPath) { - // skipping copy - $qty = 0; + + $qty = 0; + + if(!$page->hasFilesPath()) { + // files not applicable + + } else if($this->useFilesByField($page)) { + foreach($this->getFileFields($page) as $field) { + $qty += $this->copyPageFieldVersionFiles($page, $field, $version); + } + } else { - $qty = $files->copy($sourcePath, $targetPath, [ 'recursive' => false ]); + $files = $this->wire()->files; + $filesManager = $page->filesManager(); + + $sourcePath = $filesManager->path(); + $targetPath = $this->versionFilesPath($filesManager->___path(), $version); + + if($sourcePath === $targetPath) { + // skipping copy + } else { + $qty = $files->copy($sourcePath, $targetPath, ['recursive' => false]); + } } return $qty; @@ -102,23 +111,288 @@ class PagesVersionsFiles extends Wire { * */ public function restorePageVersionFiles(Page $page, $version) { - if(!$page->hasFilesPath()) return 0; - $filesManager = $page->filesManager(); - $livePath = $filesManager->___path(); - $versionPath = $this->versionFilesPath($livePath, $version); - if(!is_dir($versionPath)) return 0; - $this->disableFileHooks = true; - $filesManager->emptyPath(false, false); - $qty = $filesManager->importFiles($versionPath); - $this->disableFileHooks = false; + + $qty = 0; + + if(!$page->hasFilesPath()) { + // files not applicable + } else if($this->useFilesByField($page)) { + foreach($this->getFileFields($page) as $field) { + $qty += $this->restorePageFieldVersionFiles($page, $field, $version); + } + } else { + $filesManager = $page->filesManager(); + $livePath = $filesManager->___path(); + $versionPath = $this->versionFilesPath($livePath, $version); + if(!is_dir($versionPath)) return 0; + $this->disableFileHooks = true; + $filesManager->emptyPath(false, false); + $qty = $filesManager->importFiles($versionPath); + $this->disableFileHooks = false; + } + return $qty; } + /******************************************************************************** + * API SUPPORT METHODS FOR FILES BY FIELD + * + */ + + /** + * Copy files for given $page and field into version directory + * + * @param Page $page + * @param Field $field + * @param int $version + * @return int + * + */ + public function copyPageFieldVersionFiles(Page $page, Field $field, $version) { + + $fieldtype = $field->type; + + if(!$fieldtype instanceof FieldtypeHasFiles) return 0; + if(!$page->hasFilesPath() || !$version) return 0; + + $files = $this->wire()->files; + $filesManager = $page->filesManager(); + $pageVersion = $this->pagesVersions->pageVersionNumber($page); + $livePath = $filesManager->___path(); + $sourcePath = $pageVersion ? $this->versionFilesPath($livePath, $pageVersion) : $livePath; + $targetPath = $this->versionFilesPath($livePath, $version); + $qty = 0; + + foreach($fieldtype->getFiles($page, $field) as $sourceFile) { + $sourceFile = $sourcePath . basename($sourceFile); + $targetFile = $targetPath . basename($sourceFile); + if($sourceFile === $targetFile) continue; + if($files->copy($sourceFile, $targetFile)) $qty++; + } + + return $qty; + } + + /** + * Delete files for given page and field version + * + * @todo is this method even needed? + * + * @param Page $page + * @param Field $field + * @param int $version + * @return int + * + */ + protected function deletePageFieldVersionFiles(Page $page, Field $field, $version) { + $fieldtype = $field->type; + if(!$page->hasFilesPath() || !$version) return 0; + if(!$fieldtype instanceof FieldtypeHasFiles) return 0; + $page = $this->pageVersion($page, $version); + $path = $this->versionFilesPath($page->filesManager()->___path(), $version); + $files = $this->wire()->files; + if(!is_dir($path)) return 0; + $qty = 0; + foreach($fieldtype->getFiles($page, $field) as $filename) { + $filename = $path . basename($filename); + if(!is_file($filename)) continue; + if($files->unlink($filename)) $qty++; + } + return $qty; + } + + /** + * Restore files for given field from version into live $page + * + * @param Page $page + * @param Field $field + * @param int $version + * @return int + * + */ + public function restorePageFieldVersionFiles(Page $page, Field $field, $version) { + + $fieldtype = $field->type; + + if(!$fieldtype instanceof FieldtypeHasFiles) return 0; + if(!$page->hasFilesPath() || !$version) return 0; + + $v = $this->pagesVersions->pageVersionNumber($page); + + if($v == $version) { + // page already has the version number we want to restore + $versionPage = $page; + $livePage = $this->pageVersion($page, 0); + } else if($v) { + // page is for some other version we do not want + $livePage = $this->pageVersion($page, 0); + $versionPage = $this->pageVersion($livePage, $version); + } else { + // page is live page and we also need version page + $versionPage = $this->pageVersion($page, $version); + $livePage = $page; + } + + $files = $this->wire()->files; + $filesManager = $livePage->filesManager(); + $livePath = $filesManager->___path(); + $versionPath = $this->versionFilesPath($livePath, $version); + $qty = 0; + + if(!is_dir($versionPath)) return 0; + + // clear out live files for this field + foreach($fieldtype->getFiles($livePage, $field) as $filename) { + $files->unlink($filename, $livePath); + } + + // copy version files to live path for this field + foreach($fieldtype->getFiles($versionPage, $field) as $filename) { + $basename = basename($filename); + $sourceFile = $versionPath . $basename; + $targetFile = $livePath . $basename; + if($files->copy($sourceFile, $targetFile)) $qty++; + } + + return $qty; + } + + /******************************************************************************** * UTILITIES * */ + protected $fileFieldsCache = []; + + /** + * Get all fields that can support files + * + * @param Page $page + * @param array $options + * - `populated` (bool): Only return populated file fields with 1+ files in them? (default=false) + * - `names` (array): Limit check to these field names or omit for all. (default=[]) + * @return Field[] Returned fields array is indexed by field name + * + */ + public function getFileFields(Page $page, array $options = []) { + + $defaults = [ + 'populated' => false, + 'names' => [], + ]; + + $options = array_merge($defaults, $options); + $fileFields = []; + $cacheKey = ($options['populated'] ? "p$page" : "t$page->templates_id"); + + if(isset($this->fileFieldsCache[$cacheKey]) && empty($options['names'])) { + return $this->fileFieldsCache[$cacheKey]; + } + + foreach($page->template->fieldgroup as $field) { + if(!$this->fieldSupportsFiles($field)) continue; + $fieldtype = $field->type; + if($options['populated'] && $fieldtype instanceof FieldtypeHasFiles) { + if($fieldtype->hasFiles($page, $field)) $fileFields[$field->name] = $field; + } else { + $fileFields[$field->name] = $field; + } + } + + if(empty($options['names'])) $this->fileFieldsCache[$cacheKey] = $fileFields; + + return $fileFields; + } + + /** + * Does given field support files? + * + * @param Field|string|int $field + * @return bool + * + */ + public function fieldSupportsFiles($field) { + + if(!$field instanceof Field) { + $field = $this->wire()->fields->get($field); + if(!$field) return false; + } + + $fieldtype = $field->type; + if($fieldtype instanceof FieldtypeHasFiles) return true; + + $typeName = $fieldtype->className(); + + if($typeName === 'FieldtypeTable' || $typeName === 'FieldtypeCombo') { + // Table or Combo version prior to one that implemented FieldtypeHasFiles + $version = $this->wire()->modules->getModuleInfoProperty($typeName, 'versionStr'); + if($typeName === 'FieldtypeCombo') return version_compare($version, '0.0.9', '>='); + return version_compare($version, '0.2.3', '>='); + } + + return false; + } + + /** + * Copy/restore files individually by field for given page? + * + * - Return true if files should be copied/restored individually by field. + * - Returns false if entire page directory should be copied/restored at once. + * + * @param Page $page + * @param Field[]|string[] $names Optionally limit check to these fields + * @return bool + * + */ + public function useFilesByField(Page $page, array $names = []) { + + $fileFields = $this->getFileFields($page); + if(!count($fileFields)) return false; + + $useFilesByField = true; + + if(count($names)) { + $a = []; + foreach($names as $name) $a["$name"] = $name; + $names = $a; + } else { + $names = null; + } + + foreach($fileFields as $field) { + if($names && !isset($names[$field->name])) continue; + $fieldtype = $field->type; + if($fieldtype instanceof FieldtypeHasFiles) { + // supports individual files + } else { + // version of table or combo that doesn't implement FieldtypeHasFiles + $useFilesByField = false; + break; + } + } + + return $useFilesByField; + } + + /** + * Ensure that given page is given version, and return version page if it isn't already + * + * @param Page $page + * @param int $version Page version or 0 to get live page + * @return NullPage|Page + * + */ + protected function pageVersion(Page $page, $version) { + $v = $this->pagesVersions->pageVersionNumber($page); + if($v == $version) return $page; + if($version === 0) return $this->wire()->pages->getFresh($page->id); + $pageVersion = $this->pagesVersions->getPageVersion($page, $version); + if(!$pageVersion->id) throw new WireException("Cannot find page $page version $version"); + return $pageVersion; + } + + /** * Update given files path for version * @@ -135,7 +409,6 @@ class PagesVersionsFiles extends Wire { return $path . self::dirPrefix . $version . '/'; } - /** * Get the total size of all files in given version *