diff --git a/wire/modules/Pages/PagesVersions/PageVersionInfo.php b/wire/modules/Pages/PagesVersions/PageVersionInfo.php new file mode 100644 index 00000000..2abfb0e0 --- /dev/null +++ b/wire/modules/Pages/PagesVersions/PageVersionInfo.php @@ -0,0 +1,186 @@ + 0, + 'description' => '', + 'created' => 0, + 'modified' => 0, + 'created_users_id' => 0, + 'modified_users_id' => 0, + 'pages_id' => 0, + 'action' => '', + ]; + parent::setArray(array_merge($defaults, $data)); + } + + /** + * Set property + * + * @param string $key + * @param string|int|Page $value + * @return self + * + */ + public function set($key, $value) { + if($key === 'version' || $key === 'pages_id') { + $value = (int) $value; + } else if($key === 'created' || $key === 'modified') { + if($value) { + $value = ctype_digit("$value") ? (int) $value : strtotime($value); + } else { + $value = 0; + } + } else if($key === 'created_users_id' || $key === 'modified_users_id') { + $value = (int) $value; + } else if($key === 'page') { + $this->setPage($value); + } else if($key === 'description') { + $value = (string) $value; + } + return parent::set($key, $value); + } + + /** + * Get property + * + * @param string $key + * @return mixed|NullPage|Page|User|null + * + */ + public function get($key) { + switch($key) { + case 'page': return $this->getPage(); + case 'createdUser': return $this->getCreatedUser(); + 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) : ''; + } + return parent::get($key); + } + + /** + * Get page that this version is for + * + * @return NullPage|Page + * + */ + public function getPage() { + if($this->page) return $this->page; + if($this->pages_id) { + $this->page = $this->wire()->pages->get($this->pages_id); + return $this->page; + } + return new NullPage(); + } + + /** + * Set page that this version is for + * + * @param Page $page + * + */ + public function setPage(Page $page) { + $this->page = $page; + if($page->id) parent::set('pages_id', $page->id); + $page->wire($this); + } + + /** + * Get user that created this version + * + * @return NullPage|User + * + */ + public function getCreatedUser() { + return $this->wire()->users->get($this->created_users_id); + } + + /** + * Get user that last modified this version + * + * @return NullPage|User + * + */ + public function getModifiedUser() { + $id = $this->modified_users_id; + return $id ? $this->wire()->users->get($id) : $this->getCreatedUser(); + } + + /** + * Set action for PagesVersions + * + * #pw-internal + * + * @param string $action + * + */ + public function setAction($action) { + parent::set('action', $action); + } + + /** + * Get action for PagesVersions + * + * #pw-internal + * + * @return string + * + */ + public function getAction() { + return parent::get('action'); + } + + /** + * String value is version number as a string + * + * @return string + * + */ + public function __toString() { + return (string) $this->version; + } +} diff --git a/wire/modules/Pages/PagesVersions/PagesVersions.module.php b/wire/modules/Pages/PagesVersions/PagesVersions.module.php new file mode 100644 index 00000000..1517875e --- /dev/null +++ b/wire/modules/Pages/PagesVersions/PagesVersions.module.php @@ -0,0 +1,1427 @@ +get(1234); + * $page->title = 'New title'; + * $version = $pagesVersions->addPageVersion($page); + * echo $version; // i.e. "2" + * + * // Get version 2 of a page + * $pageV2 = $pagesVersions->getPageVersion($page, 2); + * + * // Update a version of a page + * $pageV2->title = "Updated title"; + * $pagesVersions->savePageVersion($pageV2); + * + * // Restore version to live page + * $pagesVersions->restorePageVersion($pageV2); + * + * // Delete page version + * $pagesVersions->deletePageVersion($pageV2); + * ~~~~~ + * + * HOOKABLE METHODS + * ---------------- + * @method bool allowPageVersions(Page $page) + * @method bool useTempVersionToRestore(Page $page) + * + * @todo test change of template in version + * + */ +class PagesVersions extends Wire implements Module { + + /** + * Module information + * + * @return array + * + */ + public static function getModuleInfo() { + return [ + 'title' => 'Pages Versions', + 'summary' => 'Version control API for pages', + 'version' => 1, + 'icon' => self::iconName, + 'autoload' => true, + 'author' => 'Ryan Cramer', + ]; + } + + const versionsTable = 'version_pages'; + const valuesTable = 'version_pages_fields'; + const pageProperty = '_version'; // property for PageVersionInfo instance on page + const iconName = 'code-fork'; + + /******************************************************************************** + * PUBLIC API + * + */ + + /** + * Get requested page version in a copy of given page + * + * ~~~~~ + * $page = $pages->get(1234); + * $pageV2 = $pagesVersions->getPageVersion($page, 2); + * ~~~~~ + * + * @param Page $page Page that version is for + * @param int $version Version number to get + * @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. + * + */ + public function getPageVersion(Page $page, $version, array $options = []) { + if(!$this->allowPageVersions($page)) return new NullPage(); + if($this->pageVersionNumber($page)) { + $page = $this->wire()->pages->getFresh($page->id); + } else { + $page = clone $page; + } + $page->setTrackChanges(true); + return ($this->loadPageVersion($page, $version, $options) ? $page : new NullPage()); + } + + /** + * Load and populate version data to given page + * + * This is similar to the `getPageVersion()` method except that it populates + * the given `$page` rather than populating and returning a cloned copy of it. + * + * @param Page $page + * @param int|string|PageVersionInfo $version + * @param array $options + * - `names` (array): Optionally load only these field/property names from version. + * @return bool True if version data was available and populated, false if not + * + */ + public function loadPageVersion(Page $page, $version, array $options = []) { + + $defaults = [ + 'names' => [], // Optionally load only these field/property names from version. + ]; + + $database = $this->wire()->database; + $table = self::versionsTable; + $options = array_merge($defaults, $options); + $filter = count($options['names']) > 0; + $version = $this->pageVersionNumber($page, $version); + $of = $page->of(); + + $sql = + "SELECT description, data, created, modified, " . + "created_users_id, modified_users_id " . + "FROM $table " . + "WHERE version=:version AND pages_id=:pages_id"; + + $query = $database->prepare($sql); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->execute(); + + $hasRow = $query->rowCount() > 0; + $row = $hasRow ? $query->fetch(\PDO::FETCH_ASSOC) : null; + + $query->closeCursor(); + + if($row === null) return false; + + $data = json_decode($row['data'], true); + + $info = new PageVersionInfo([ + 'version' => (int) $version, + 'created' => $row['created'], + 'modified' => $row['modified'], + 'created_users_id' => (int) $row['created_users_id'], + 'modified_users_id' => (int) $row['modified_users_id'], + 'description' => (string) $row['description'], + ]); + + if($of) $page->of(false); + + $info->setPage($page); + $page->setQuietly(self::pageProperty, $info); + + if(is_array($data)) { + foreach($data as $name => $value) { + if($filter && !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; + $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? + } else { + $page->set($field->name, $value); + } + } + + if($of) $page->of(true); + + return true; + } + + + /** + * Get all versions for given page + * + * ~~~~~ + * $page = $pages->get(1234); + * $versions = $pagesVersions->getPageVersions($page); + * foreach($versions as $p) { + * echo $p->get('_version')->version; // i.e. 2, 3, 4, etc. + * } + * ~~~~~ + * + * @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') + * @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, + * which is a `PageVersionInfo` object. + * @throws WireException + * + */ + public function getPageVersions(Page $page, array $options = []) { + + $defaults = [ + 'getInfo' => false, + 'sort' => '-created', + ]; + + $sorts = [ + 'created' => 'created ASC', + '-created' => 'created DESC', + 'modified' => 'modified ASC', + '-modified' => 'modified DESC', + 'version' => 'version ASC', + '-version' => 'version DESC', + ]; + + $options = array_merge($defaults, $options); + $database = $this->wire()->database; + $table = self::versionsTable; + $rows = []; + + if(!$this->allowPageVersions($page)) return []; + + if(!isset($sorts[$options['sort']])) $options['sort'] = $defaults['sort']; + + $sql = + "SELECT version, description, created, modified, " . + "created_users_id, modified_users_id " . + "FROM $table " . + "WHERE pages_id=:pages_id " . + "ORDER BY " . $sorts[$options['sort']]; + + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->execute(); + + while($row = $query->fetch(\PDO::FETCH_ASSOC)) { + $info = new PageVersionInfo($row); + $info->set('pages_id', $page->id); + $rows[] = $this->wire($info); + } + + $query->closeCursor(); + + if($options['getInfo']) return $rows; + + if($page->get(self::pageProperty)) { + $page = $this->wire()->pages->getFresh($page->id); + } + + $pageVersions = []; + + foreach($rows as $info) { + /** @var PageVersionInfo $info */ + $pageCopy = clone $page; + $info->setPage($pageCopy); + $pageCopy->setQuietly(self::pageProperty, $info); + if($this->loadPageVersion($pageCopy, $info->version)) { + $pageVersions[$info->version] = $pageCopy; + } + } + + return $pageVersions; + } + + /** + * Get just PageVersionInfo objects for all versions of given page + * + * This is the same as using the getPageVersions() method with the `getInfo` option. + * + * ~~~~~ + * $page = $pages->get(1234); + * $infos = $pagesVersions->getPageVersionInfos($page); + * foreach($infos as $info) { + * echo $info->version; // i.e. 2, 3, 4, etc. + * } + * ~~~~~ + * + * @param Page $page + * @param array $options + * - `sort`: Sort by property, one of: 'created', '-created', 'version', '-version' (default='-created') + * @return PageVersionInfo[] + * + */ + public function getPageVersionInfos(Page $page, array $options = []) { + $options['getInfo'] = true; + return $this->getPageVersions($page, $options); + } + + /** + * Get all pages that have 1 or more versions available + * + * @return PageArray + * @throws WireException + * + */ + public function getAllPagesWithVersions() { + $table = self::versionsTable; + $sql = "SELECT DISTINCT(pages_id) FROM $table"; + $query = $this->wire()->database->prepare($sql); + $query->execute(); + $ids = []; + while($row = $query->fetch(\PDO::FETCH_NUM)) { + $ids[] = (int) $row[0]; + } + $query->closeCursor(); + return $this->wire()->pages->getByIDs($ids); + } + + /** + * Does page have the given version? + * + * @param Page $page + * @param int|string|PageVersionInfo $version Version number or omit to return quantity of versions + * @return bool|int + * - Returns boolean true or false if a non-empty $version argument was specified. + * - Returns integer with quantity of versions of no $version was specified. + * + */ + public function hasPageVersion(Page $page, $version = 0) { + $database = $this->wire()->database(); + $table = self::versionsTable; + if(empty($version)) { + return $this->hasPageVersions($page); + } else if($version instanceof PageVersionInfo) { + $version = $version->version; + } + if(ctype_digit("$version")) { + $sql = "SELECT version FROM $table WHERE pages_id=:pages_id AND version=:version"; + $query = $database->prepare($sql); + $query->bindValue(':version', (int) $version, \PDO::PARAM_INT); + } else { + // find by name + $sql = "SELECT version FROM $table WHERE pages_id=:pages_id AND name=:name"; + $query = $database->prepare($sql); + $query->bindValue(':name', $version); + } + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->execute(); + $n = $query->rowCount() ? (int) $query->fetchColumn() : 0; + $query->closeCursor(); + return $n > 0; + } + + /** + * Return quantity of versions available for given page + * + * This is the same as calling the `hasPageVersion()` method + * with $version argument omitted. + * + * @param Page $page + * @return int + * + */ + public function hasPageVersions(Page $page) { + $database = $this->wire()->database(); + $table = self::versionsTable; + $sql = "SELECT COUNT(*) FROM $table WHERE pages_id=:pages_id"; + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->execute(); + $qty = (int) $query->fetchColumn(); + $query->closeCursor(); + return $qty; + } + + /** + * Add a new page version and return the added version number + * + * ~~~~~ + * $page = $pages->get(1234); + * $version = $pagesVersions->addPageVersion($page); + * echo "Added version $version for page $page"; + * ~~~~~ + * + * @param Page $page + * @param array $options + * - `description` (string) Optional text description for version + * @return int Version number or 0 if no version created + * @throws WireException|\PDOException + * + */ + public function addPageVersion(Page $page, array $options = []) { + + $defaults = [ + 'description' => '', + 'retry' => 10, // max times to retry + ]; + + if(!$this->allowPageVersions($page)) return 0; + + $version = $this->getNextPageVersionNumber($page); + $options = array_merge($defaults, $options); + $retry = (int) $options['retry']; + $e = null; + + $options['update'] = false; + $options['returnVersion'] = true; + + do { + try { + $fail = false; + $this->savePageVersion($page, $version, $options); + } catch(\Exception $e) { + $code = $e->getCode(); + if($code != 23000) throw $e; // 23000=duplicate key + $fail = true; + $version++; + } + } while($fail && --$retry > 0); + + if($fail && $e) throw $e; + + return $version; + } + + /** + * Save a page version + * + * @param Page $page + * @param int|PageVersionInfo $version Version number or PageVersionInfo + * - If given version number is greater than 0 and version doesn't exist, it will be added. + * - If 0 or omitted and given page is already a version, its version will be updated. + * - If 0 or omitted and given page is not a version, this method behaves the same as + * 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. + * @throws WireException|\PDOException + * + */ + public function savePageVersion(Page $page, $version = 0, array $options = []) { + + $defaults = [ + 'description' => null, + 'names' => [], // save only these field/property names, internal use only + 'copyFiles' => true, // make a copy of the page’s files? internal use only + 'returnVersion' => false, + 'returnNames' => false, + 'update' => true, + ]; + + $options = array_merge($defaults, $options); + $database = $this->wire()->database; + $filter = !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($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); + if($of) $page->of(true); + return $result; + } + + $sql = + "INSERT INTO $table (version, pages_id, description, " . + "data, created, modified, created_users_id, modified_users_id) " . + "VALUES(:version, :pages_id, :description, " . + ":data, :created, :modified, :created_users_id, :modified_users_id) "; + + if($options['update']) $sql .= + "ON DUPLICATE KEY UPDATE " . + "data=VALUES(data), modified=VALUES(modified), " . + "modified_users_id=VALUES(modified_users_id)"; + + // update description only if it is being changed + if($options['update'] && $options['description'] !== null) $sql .= ', ' . + 'description=VALUES(description)'; + + $data = $this->getNativePagesTableData($page, $options); + $names = array_keys($data); + + $query = $database->prepare($sql); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->bindValue(':created', $date); + $query->bindValue(':modified', $date); + $query->bindValue(':created_users_id', $user->id, \PDO::PARAM_INT); + $query->bindValue(':modified_users_id', $user->id, \PDO::PARAM_INT); + $query->bindValue(':description', (string) $options['description']); + $query->bindValue(':data', json_encode($data)); + $query->execute(); + + if($options['copyFiles']) { + $this->pagesVersionsFiles->copyPageVersionFiles($page, $version); + } + + foreach($page->fieldgroup as $field) { + /** @var Field $field */ + if($filter && !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); + } 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; + } + + /** + * Delete specific page version + * + * ~~~~~~ + * // delete version 2 of the page + * $page = $pages->get(1234); + * $pagesVersions->deletePageVersion($page, 2); + * + * // this does the same thing as above + * $pageV2 = $pagesVersions->getPageVersion($page, 2); + * $pagesVersions->deletePageVersion($pageV2); + * ~~~~~~ + * + * @param Page $page Page to delete version from, or page having the version you want to delete. + * @param int $version Version number to delete or omit if given $page is the version you want to delete. + * @return int Number of DB rows deleted as part of the deletion process + * + */ + public function deletePageVersion(Page $page, $version = 0) { + + if(!is_int($version)) $version = $this->pageVersionNumber($page, $version); + + $database = $this->wire()->database; + $qty = 0; + + if($version < 1) $version = $this->pageVersionNumber($page); + if($version < 1) return 0; + + foreach($page->fieldgroup as $field) { + $allow = $this->allowFieldVersions($field); + if($allow instanceof FieldtypeDoesVersions) { + if($allow->deletePageFieldVersion($page, $field, $version)) $qty++; + } else if($allow) { + if($this->deletePageFieldVersion($page, $field, $version)) $qty++; + } + } + + foreach([self::valuesTable, self::versionsTable] as $table) { + $sql = "DELETE FROM $table WHERE pages_id=:pages_id AND version=:version"; + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->execute(); + $qty += $query->rowCount(); + } + + $this->pagesVersionsFiles->deletePageVersionFiles($page, $version); + + return $qty; + } + + /** + * Delete all versions for given page + * + * @param Page $page + * @return int Number of versions deleted + * + */ + public function deleteAllPageVersions(Page $page) { + $qty = 0; + $versions = $this->getPageVersionInfos($page); + foreach($versions as $v) { + if($this->deletePageVersion($page, $v['version'])) $qty++; + } + return $qty; + } + + /** + * Delete all versions across all pages + * + * @param bool $areYouSure Specify true to indicate you are sure you want to do this + * @return int Quantity of versions deleted + * + */ + public function deleteAllVersions($areYouSure = false) { + if($areYouSure !== true) throw new WireException( + "You must specify deleteAllVersions(true) to " . + "complete deletion of all versions across all pages" + ); + $qty = 0; + $pagesWithVersions = $this->getAllPagesWithVersions(); + foreach($pagesWithVersions as $page) { + $qty += $this->deleteAllPageVersions($page); + } + return $qty; + } + + /** + * Restore a page version to be the live version + * + * Note that the restored page is saved to the database, replacing the current live version. + * So consider whether you should backup the live version (by using addPageVersion) before + * restoring a version to it. + * + * ~~~~~ + * // restore version 2 to live page + * $page = $pages->get(1234); + * $pagesVersions->restore($page, 2); // restore version 2 + * + * // this does the same as the above + * $pageV2 = $pagesVersions->getPageVersion($page, 2); + * $pagesVersions->restore($pageV2); + * ~~~~~ + * + * @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 + * - `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. + * @return Page|bool Returns restored version page on success or false on fail + * @throws WireException + * + */ + public function restorePageVersion(Page $page, $version = 0, array $options = []) { + + $defaults = [ + 'useTempVersion' => null, + ]; + + $options = array_merge($defaults, $options); + $version = (int) "$version"; + $pageVersion = $this->pageVersionNumber($page); + $useTempVersion = $options['useTempVersion']; + $of = $page->of(); + + if($version < 1) $version = $pageVersion; + + if($version < 1) { + return $this->pageError($page, $this->_('Cannot restore unknown version')); + } + + if(!$this->allowPageVersions($page)) { + return $this->pageError($page, $this->_('Restore failed, page does not allow versions')); + } + + if($pageVersion) { + // given page is the one to restore + $versionPage = $page; + } else { + // we need to get the versioned page + $versionPage = $this->getPageVersion($page, $version); + } + + if(!$versionPage->id) return false; + if($versionPage->of()) $versionPage->of(false); + if($of) $page->of(false); + + if($useTempVersion === null) { + $useTempVersion = $this->useTempVersionToRestore($page); + } + + if($useTempVersion) { + // create a temporary version page to restore from + $useTempVersion = $this->addPageVersion($versionPage); + if($useTempVersion) { + $versionPage = $this->getPageVersion($page, $useTempVersion); + $version = $useTempVersion; + } + } + + $this->pageVersionInfo($versionPage, 'action', PageVersionInfo::actionRestore); + $this->pagesVersionsFiles->restorePageVersionFiles($page, $version); + + foreach($page->fieldgroup as $field) { + $allow = $this->allowFieldVersions($field); + if($allow instanceof FieldtypeDoesVersions) { + $allow->restorePageFieldVersion($page, $field, $version); + } + } + + // save to make it the live page + $versionPage->save(); + + // live page needs no version property + $versionPage->__unset(self::pageProperty); + + if($useTempVersion && is_int($useTempVersion)) { + // delete the temporary version page if used + $this->deletePageVersion($page, $useTempVersion); + } + + if($of) { + $page->of(true); + $versionPage->of(true); + } + + return $versionPage; + } + + /*************************************************************************** + * INTERNAL API + * + */ + + /** + * Get the field value for a page field version + * + * #pw-internal + * + * @param Page $page + * @param Field $field + * @param int $version + * @return mixed|null Returns null if version data for field not available, field value otherwise + * + */ + public function getPageFieldVersion(Page $page, Field $field, $version) { + + $database = $this->wire()->database; + $table = self::valuesTable; + $version = (int) "$version"; + + $sql = + "SELECT data FROM $table " . + "WHERE pages_id=:pages_id AND field_id=:field_id AND version=:version"; + + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->bindValue(':field_id', $field->id, \PDO::PARAM_INT); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->execute(); + + if($query->rowCount()) { + $value = $query->fetchColumn(); + $value = json_decode($value, true); + if(is_array($value)) { + if(count($value) === 1 && isset($value['data'])) { + $value = $value['data']; + } + } + $value = $field->type->wakeupValue($page, $field, $value); + } else { + $value = null; + } + + $query->closeCursor(); + + return $value; + } + + /** + * Get native pages table data from page for storage in version + * + * #pw-internal + * + * @param Page $page + * @param array $options + * - `names` (array): Names of native page properties to include or omit for all + * @return array + * + */ + public function getNativePagesTableData(Page $page, array $options = []) { + + $database = $this->wire()->database; + $names = isset($options['names']) ? $options['names'] : []; + $filter = !empty($names); + + $sql = "SELECT * FROM pages WHERE id=:id"; + $query = $database->prepare($sql); + $query->bindValue(':id', $page->id, \PDO::PARAM_INT); + $query->execute(); + $data = $query->fetch(\PDO::FETCH_ASSOC); + $query->closeCursor(); + unset($data['id']); + + if($filter) { + foreach($data as $name => $value) { + if(in_array($name, $names)) continue; + unset($data[$name]); + } + } + + // get live values for any changed properties in memory + $changes = $page->getChanges(); + if(count($changes)) { + foreach($data as $name => $value) { + if(!in_array($name, $changes)) continue; + $data[$name] = $page->get($name); + } + } + + foreach([ 'modified', 'created', 'published' ] as $key) { + if(!isset($data[$key])) continue; + if(ctype_digit((string) $data[$key])) continue; + $data[$key] = strtotime($data[$key]); + } + + return $data; + } + + /** + * Save page field version + * + * #pw-internal + * + * @param Page $page + * @param Field $field + * @param int $version + * @param array $options + * - `fromDatabase` (bool): Save from values in database rather than in $page? (not recommended) + * @return bool + * + */ + public function savePageFieldVersion(Page $page, Field $field, $version, array $options = []) { + + $defaults = [ + 'fromDatabase' => false, + ]; + + $database = $this->wire()->database; + $table = self::valuesTable; + $options = array_merge($defaults, $options); + $version = (int) "$version"; + + if($options['fromDatabase']) { + $value = $this->getSleepValueFromDatabase($page, $field); + } else { + $value = $this->getSleepValueFromPage($page, $field); + } + + if($value === false) return false; + + $value = json_encode($value); + $error = ''; + + if($value === false) { + // json encode failed + $error = $this->_('Skipped because unable to encode value'); + } else if(strlen($value) > 16777215) { + // encoded value exceeds max length of MEDIUMTEXT col + $error = $this->_('Skipped because value is too large to save in a version'); + } + + if($error) { + return $this->pageFieldError($page, $field, $error); + } + + $sql = + "INSERT INTO $table (pages_id, field_id, version, data) " . + "VALUES(:pages_id, :field_id, :version, :data) " . + "ON DUPLICATE KEY UPDATE data=VALUES(data)"; + + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->bindValue(':field_id', $field->id, \PDO::PARAM_INT); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->bindValue(':data', $value); + + return $query->execute(); + } + + /** + * Get sleep value from given live page + * + * @param Page $page + * @param Field $field + * @return array|int|string|false + * + */ + protected function getSleepValueFromPage(Page $page, Field $field) { + $value = $page->get($field->name); + if($value instanceof PaginatedArray && $value->getTotal() > $value->count()) { + // we only have a partial value + // return $this->getSleepValueFromDatabase(); // @todo determine if we can do this + return $this->pageFieldError( + $page, $field, + $this->_('Paginated value cannot be versioned') + ); + } + return $field->type->sleepValue($page, $field, $value); + } + + /** + * Get sleep value from the page field’s field_name table data in database + * + * @todo method currently not used but may be later + * + * @param Page $page + * @param Field $field + * @return array|int|mixed|string + * + */ + protected function getSleepValueFromDatabase(Page $page, Field $field) { + + $database = $this->wire()->database; + $table = $field->getTable(); + $multi = $field->type instanceof FieldtypeMulti; + + $sql = + "SELECT * FROM `$table` " . + "WHERE pages_id=:pages_id " . + ($multi ? 'ORDER BY sort' : ''); + + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->execute(); + + $numRows = $query->rowCount(); + + if($multi && $numRows) { + $value = []; + while($row = $query->fetch(\PDO::FETCH_ASSOC)) { + unset($value['pages_id']); + $value[] = $row; + } + } else if($numRows) { + $value = $query->fetch(\PDO::FETCH_ASSOC); + unset($value['pages_id']); + } else { + $fieldtype = $field->type; + $value = $fieldtype->getBlankValue($page, $field); + $value = $fieldtype->sleepValue($page, $field, $value); + } + + $query->closeCursor(); + + return $value; + } + + /** + * Delete a page field version + * + * @param Page $page + * @param Field $field + * @param int $version + * @return bool + * + */ + protected function deletePageFieldVersion(Page $page, Field $field, $version) { + + $database = $this->wire()->database; + $table = self::valuesTable; + $version = (int) "$version"; + + $sql = + "DELETE FROM $table " . + "WHERE pages_id=:pages_id AND field_id=:field_id AND version=:version"; + + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->bindValue(':field_id', $field->id, \PDO::PARAM_INT); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->execute(); + + return $query->rowCount() > 0; + } + + /** + * Update a property from the version info in the database + * + * #pw-internal + * + * @param Page $page + * @param int $version + * @param string $property + * @param string|int $value + * @return bool + * @throws WireException|\PDOException + * + */ + public function updateVersionInfoProperty(Page $page, $version, $property, $value) { + $database = $this->wire()->database; + $table = self::versionsTable; + if($property === 'data') { + if(!is_array($value)) throw new WireException("Value must be array when setting data"); + $value = json_encode($value); + } else { + $testInfo = new PageVersionInfo([]); + $testValue = $testInfo->get($property); + if($testValue === null) throw new WireException("Unknown property: $property"); + } + $col = $database->escapeCol($property); + $sql = "UPDATE $table SET $col=:value WHERE pages_id=:pages_id AND version=:version"; + $query = $database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->bindValue(':version', $version, \PDO::PARAM_INT); + $query->bindValue(':value', $value); + return $query->execute(); + } + + /******************************************************************************** + * UTILITY METHODS + * + */ + + /** + * Cache for getUnsupportedFields method + * + * @var array + * + */ + protected $unsupportedFields = [ + // 0 => [ 'field_name' => Field ] // where 0 is index for all fields + // 3 => [ 'field_name' => Field ] // where 3 is template ID + ]; + + /** + * Get the version number of given page or 0 if not versioned + * + * #pw-internal + * + * @param Page $page + * @param int|string|PageVersionInfo Optional version argument to use, if omitted it pulls from $page + * - If this argument resolves to a number, that number is returned. + * - If this argument is omitted, the version number is pulled from the $page argument. + * @return int + * + */ + public function pageVersionNumber(Page $page, $version = 0) { + + if(empty($version)) { + // default + + } else if($version instanceof PageVersionInfo) { + // PageVersionInfo object + return $version->version; + + } else if(ctype_digit("$version")) { + // integer version number + return (int) $version; + + } else if(is_string($version)) { + // string with v prefix i.e. v2 + $version = ltrim($version, 'v'); + if(ctype_digit($version)) return (int) $version; + } + + /** @var PageVersionInfo $info */ + $info = $page->get(self::pageProperty); + if(empty($info) || !$info->version) return 0; + + return $info->version; + } + + /** + * Get or set page version info as stored in $page->_version + * + * #pw-internal + * + * @param Page $page + * @param string|array $key Optional property to get or set, or array of values to set + * @param string|int|null $value Specify value to set or omit when getting + * @return PageVersionInfo|string|int|null + * + */ + public function pageVersionInfo(Page $page, $key = '', $value = null) { + /** @var PageVersionInfo $info */ + $info = $page->get(self::pageProperty); + if(!$info) { + $info = new PageVersionInfo(); + $info->setPage($page); + } + if(is_array($key)) { + $info->setArray($key); + } else if($key && $value !== null) { + $info->set($key, $value); + } else if($key) { + return $info->get($key); + } + return $info; + } + + /** + * Get fields where versions are not supported + * + * @param Page|null $page Page to limit check to or omit for all fields + * @return Field[] Returned array of Field objects is indexed by Field name + * + */ + public function getUnsupportedFields(Page $page = null) { + if($page && !$page->id) return []; + $templateId = $page ? $page->templates_id : 0; + if(isset($this->unsupportedFields[$templateId])) { + return $this->unsupportedFields[$templateId]; + } + $fields = $page ? $page->template->fieldgroup : $this->wire()->fields; + $unsupported = []; + foreach($fields as $field) { + if($this->allowFieldVersions($field)) continue; + $unsupported[$field->name] = $field; + } + $this->unsupportedFields[$templateId] = $unsupported; + return $unsupported; + } + + /** + * Does field allow versions? + * + * #pw-internal + * + * @param Field $field + * @return bool|int|FieldtypeDoesVersions + * - Return boolean true if field allows versions + * - Return boolean false if field does not support versions + * - Return int 0 if field does not allow versions because it stores no data + * - Return Fieldtype (FieldtypeDoesVersions) instance if field allows versions and handles internally + * + */ + public function allowFieldVersions(Field $field) { + $fieldtype = $field->type; + $allow = true; + if($fieldtype instanceof FieldtypeFieldsetOpen) { + $allow = 0; + } else if(wireInstanceOf($fieldtype, 'FieldtypeComments')) { + $allow = false; + } else if($fieldtype instanceof FieldtypeDoesVersions) { + $allow = $fieldtype; + } else { + $schema = $fieldtype->getDatabaseSchema($field); + if(isset($schema['xtra']['all'])) { + $allow = $schema['xtra']['all'] !== false; + } + } + return $allow; + } + + /** + * Is given page allowed to have versions? + * + * #pw-hooker + * + * @param Page $page + * @return bool + * + */ + public function ___allowPageVersions(Page $page) { + $disallows = [ 'User', 'Role', 'Permission', 'Language' ]; + if(wireInstanceOf($page, $disallows)) return false; + return true; + } + + /** + * Get next available version number for given page + * + * #pw-internal + * + * @param Page $page + * @return int + * + */ + public function getNextPageVersionNumber(Page $page) { + $table = self::versionsTable; + $sql = "SELECT MAX(version) from $table WHERE pages_id=:pages_id"; + $query = $this->wire()->database->prepare($sql); + $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT); + $query->execute(); + $version = (int) $query->fetchColumn() + 1; + if($version === 1) $version++; // version 1 is reserved for draft version + $query->closeCursor(); + return $version; + } + + /** + * Should a temporary version be used during restore? + * + * This is necessary in some cases, where restoring may involve moving or destroying + * fields from the version. An example is nested repeater fields. + * + * #pw-internal + * #pw-hooker + * + * @param Page $page + * @return bool + * + */ + public function ___useTempVersionToRestore(Page $page) { + $fieldtype = $this->wire()->fieldtypes->FieldtypeRepeater; + if(!$fieldtype) return false; + return $fieldtype->versions()->hasNestedRepeaterFields($page); + } + + + /******************************************************************************** + * HOOKS + * + */ + + /** + * Hook before Pages::save or Pages::saveField + * + * This hook prevents save on a version page unless $action === 'restore' + * + * #pw-internal + * + * @param HookEvent $event + * + */ + public function hookBeforePagesSave(HookEvent $event) { + + $page = $event->arguments(0); /** @var Page $page */ + $field = $event->arguments(1); + $info = $this->pageVersionInfo($page); + + if(!$info->version || $info->getAction() === PageVersionInfo::actionRestore) { + // not a version, or version is being restored + return; + } + + if($field instanceof Field) { + // this is the Pages::saveField hook + $options = [ 'names' => [ $field->name ] ]; + } else { + // this is the Pages::save hook + $options = []; + } + + $event->replace = true; + $event->return = $this->savePageVersion($page, $info->version, $options); + $this->pagesVersionsFiles->hookBeforePagesSave($page); + } + + /** + * Hook after page deleted to delete its versions + * + * #pw-internal + * + * @param HookEvent $event + * + */ + public function hookPageDeleted(HookEvent $event) { + $page = $event->arguments(0); /** @var Page $page */ + $this->deleteAllPageVersions($page); + } + + /******************************************************************************** + * MODULE SUPPORT + * + */ + + /** + * @var PagesVersionsFiles + * + */ + protected $pagesVersionsFiles; + + /** + * Module init + * + * #pw-internal + * + */ + public function init() { + $this->addHookBefore('Pages::save, Pages::saveField', $this, 'hookBeforePagesSave'); + $this->addHook('Pages::deleted', $this, 'hookPageDeleted'); + require_once(__DIR__ . '/PagesVersionsFiles.php'); + $this->pagesVersionsFiles = new PagesVersionsFiles($this); + $this->wire('pagesVersions', $this); // set API var + } + + /** + * ProcessWire API ready + * + * #pw-internal + * + */ + public function ready() { + + $config = $this->wire()->config; + + if(!$config->admin) { + // front-end + $page = $this->wire()->page; + $version = (int) $this->wire()->input->get('version'); + if($version > 0 && $page->editable()) { + // page-view on front-end + $this->loadPageVersion($page, $version); + } + } + } + + /** + * Message notification + * + * #pw-internal + * + * @param string $text + * @param int|string $flags + * @return self + * + */ + public function message($text, $flags = 0) { + if(empty($flags)) $flags = 'nogroup icon-' . self::iconName; + return parent::message($text, $flags); + } + + /** + * Report an error for specific page and field + * + * #pw-internal + * + * @param Page $page + * @param Field $field + * @param string $error + * @return false For code convenience only + * + */ + public function pageFieldError(Page $page, Field $field, $error) { + $this->error(sprintf( + $this->_('Page %d field %s'), + $page->id, + $field->name + ) . " - $error"); + return false; + } + + /** + * Report an error for specific page + * + * #pw-internal + * + * @param Page $page + * @param string $error + * @return false For code convenience only + * + */ + public function pageError(Page $page, $error) { + $this->error(sprintf( + $this->_('Page %d'), + $page->id + ) . " - $error"); + return false; + } + + /** + * Install version tables + * + * #pw-internal + * + */ + public function install() { + $database = $this->wire()->database; + $config = $this->wire()->config; + + $tables = [ + self::valuesTable => " + pages_id INT UNSIGNED NOT NULL, + field_id INT UNSIGNED NOT NULL, + version INT UNSIGNED NOT NULL, + data MEDIUMTEXT NOT NULL, + PRIMARY KEY (pages_id, field_id, version), + INDEX(field_id), + INDEX(version) + ", + self::versionsTable => " + version INT UNSIGNED NOT NULL, + pages_id INT UNSIGNED NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_users_id INT UNSIGNED NOT NULL, + modified_users_id INT UNSIGNED NOT NULL, + description TEXT, + data MEDIUMTEXT NOT NULL, + PRIMARY KEY(version, pages_id), + INDEX(created), + INDEX(modified), + INDEX(created_users_id) + ", + /* + self::namesTable => " + name VARCHAR(191) NOT NULL, + pages_id INT UNSIGNED NOT NULL, + version INT UNSIGNED NOT NULL, + PRIMARY KEY(pages_id, version), + UNIQUE(name, pages_id) + ", + */ + ]; + + foreach($tables as $table => $sql) { + try { + $database->exec( + "CREATE TABLE $table ($sql) " . + "ENGINE=$config->dbEngine DEFAULT CHARSET=$config->dbCharset" + ); + } catch(\Exception $e) { + $this->error($e->getMessage()); + } + } + } + + /** + * Uninstall version tables + * + * #pw-internal + * + */ + public function uninstall() { + $qty = $this->deleteAllVersions(true); + if($qty) $this->message("Deleted $qty page versions"); + $database = $this->wire()->database; + $database->exec('DROP TABLE ' . self::valuesTable); + $database->exec('DROP TABLE ' . self::versionsTable); + } +} + +require_once(__DIR__ . '/PageVersionInfo.php'); diff --git a/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php b/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php new file mode 100644 index 00000000..8d6dad77 --- /dev/null +++ b/wire/modules/Pages/PagesVersions/PagesVersionsFiles.php @@ -0,0 +1,227 @@ +pagesVersions = $pagesVersions; + parent::__construct(); + $pagesVersions->wire($this); + $this->addHookAfter('PagefilesManager::path, PagefilesManager::url', $this, 'hookPagefilesManagerPath'); + $this->addHookAfter('Pages::savePageOrFieldReady', $this, 'hookPagesSaveReady'); + } + + /******************************************************************************** + * API SUPPORT METHODS + * + */ + + /** + * Copy files for given $page into version directory + * + * @param Page $page + * @param $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; + } else { + $qty = $files->copy($sourcePath, $targetPath, [ 'recursive' => false ]); + } + + return $qty; + } + + /** + * Delete files for given version + * + * @param Page $page + * @param int $version + * @return bool + * + */ + public function deletePageVersionFiles(Page $page, $version) { + if(!$page->hasFilesPath()) return true; + $path = $this->versionFilesPath($page->filesManager()->___path(), $version); + if(!is_dir($path)) return true; + return $this->wire()->files->rmdir($path, true); + } + + /** + * Restore files from version into live $page + * + * @param Page $page + * @param int $version + * @return int + * + */ + 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; + return $qty; + } + + /******************************************************************************** + * UTILITIES + * + */ + + /** + * Update given files path for version + * + * #pw-internal + * + * @param string $path + * @param int $version + * @return string + * + */ + public function versionFilesPath($path, $version) { + if($path instanceof Page) $path = $path->filesManager()->___path(); + $version = (int) $version; + return $path . self::dirPrefix . $version . '/'; + } + + + /** + * Get the total size of all files in given version + * + * #pw-internal + * + * @param Page $page + * @param int $version + * @return int + * + */ + public function getTotalVersionSize(Page $page, $version = 0) { + if(!$page->hasFilesPath()) return 0; + if(!$version) $version = $this->pagesVersions->pageVersionNumber($page); + if($version) { + $path = $this->versionFilesPath($page, $version); + } else { + $path = $page->filesPath(); + } + $size = 0; + foreach(new \DirectoryIterator($path) as $file) { + if($file->isDir() || $file->isDot()) continue; + $size += $file->getSize(); + } + return $size; + } + + /******************************************************************************** + * HOOKS + * + */ + + /** + * Hook to PagefilesManager::path to update for version directories + * + * #pw-internal + * + * @param HookEvent $event + * + */ + public function hookPagefilesManagerPath(HookEvent $event) { + if($this->disableFileHooks) return; + $manager = $event->object; /** @var PagefilesManager $manager */ + $page = $manager->page; + if(!$page->get(PagesVersions::pageProperty)) return; + $version = $this->pagesVersions->pageVersionNumber($page); + if(!$version) return; + $versionDir = self::dirPrefix . "$version/"; + $event->return .= $versionDir; + if($event->method == 'path') { + $path = $event->return; + if($this->lastMkdir != $path && !is_dir($path)) { + $this->wire()->files->mkdir($path, true); + } + } + } + + /** + * Hook Pages::saveReady to restore version files when $action === 'restore' + * + * #pw-internal + * + * @param HookEvent $event + * + */ + public function hookPagesSaveReady(HookEvent $event) { + $page = $event->arguments(0); + $info = $this->pagesVersions->pageVersionInfo($page); + if($info->version && $info->getAction() === PageVersionInfo::actionRestore) { + $this->restorePageVersionFiles($page, $info->version); + } + } + + /** + * Hook before Pages::save or Pages::saveField to prevent save on a version page unless $action === 'restore' + * + * #pw-internal + * + * @param Page $page + * + */ + public function hookBeforePagesSave(Page $page) { + if(PagefilesManager::hasPath($page)) { + // ensures files flagged for deletion get deleted + // this hook doesn't get called by $pages since we replaced the call + $page->filesManager->save(); + } + } + +}