Implemented abstract version tracking and comparison (wip)
authorAlexander Ebert <ebert@woltlab.com>
Mon, 27 Mar 2017 16:33:17 +0000 (18:33 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Mon, 27 Mar 2017 16:33:26 +0000 (18:33 +0200)
See #2240

13 files changed:
wcfsetup/install/files/acp/templates/articleAdd.tpl
wcfsetup/install/files/acp/templates/versionTrackerList.tpl [new file with mode: 0644]
wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php
wcfsetup/install/files/lib/acp/page/VersionTrackerListPage.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/IVersionTrackerObject.class.php
wcfsetup/install/files/lib/data/article/ArticleVersionTracker.class.php
wcfsetup/install/files/lib/system/version/AbstractVersionTrackerProvider.class.php
wcfsetup/install/files/lib/system/version/ArticleVersionTrackerProvider.class.php
wcfsetup/install/files/lib/system/version/IVersionTrackerProvider.class.php
wcfsetup/install/files/lib/system/version/VersionTracker.class.php
wcfsetup/install/files/lib/system/version/VersionTrackerEntry.class.php [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml

index 6f4cfd14e8bab16b63f915e731c90d3b4f0db40f..f45f04b63873ff6ba1e8d772a5bd0e6142503165 100644 (file)
        <p class="success">{lang}wcf.global.success.{$action}{/lang}</p>
 {/if}
 
-{if $action == 'edit'}<p class="info jsArticleNoticeTrash"{if !$article->isDeleted} style="display: none;"{/if}>{lang}wcf.acp.article.trash.notice{/lang}</p>{/if}
+{if $action == 'edit'}
+       <p class="info jsArticleNoticeTrash"{if !$article->isDeleted} style="display: none;"{/if}>{lang}wcf.acp.article.trash.notice{/lang}</p>
+       
+       {if $lastVersion}<p class="info">{lang}wcf.acp.article.lastVersion{/lang}</p>{/if}
+{/if}
 
 <form method="post" action="{if $action == 'add'}{link controller='ArticleAdd'}{/link}{else}{link controller='ArticleEdit' id=$articleID}{/link}{/if}">
        <div class="section">
diff --git a/wcfsetup/install/files/acp/templates/versionTrackerList.tpl b/wcfsetup/install/files/acp/templates/versionTrackerList.tpl
new file mode 100644 (file)
index 0000000..2248274
--- /dev/null
@@ -0,0 +1,149 @@
+{capture assign='pageTitle'}{$object->getTitle()} - {lang}wcf.edit.versions{/lang}{/capture}
+
+{include file='header'}
+
+<header class="contentHeader">
+       <div class="contentHeaderTitle">
+               <h1 class="contentTitle">{lang}wcf.edit.versions{/lang}: {$object->getTitle()}</h1>
+       </div>
+       
+       <nav class="contentHeaderNavigation">
+               <ul>
+                       <li><a href="{$object->getLink()}" class="button"><span class="icon icon16 fa-arrow-right"></span> <span>{lang}wcf.edit.button.goToContent{/lang}</span></a></li>
+                       
+                       {event name='contentHeaderNavigation'}
+               </ul>
+       </nav>
+</header>
+
+{if !$diffs|empty}
+{if !$diffs[0]|isset}
+<div class="section tabMenuContainer">
+       <nav class="tabMenu">
+               <ul>
+                       {foreach from=$languages item=language}
+                               <li data-name="language{@$language->languageID}"><a href="#">{$language}</a></li>
+                       {/foreach}
+               </ul>
+       </nav>
+{/if}
+{foreach from=$diffs key=languageID item=properties}
+{if $languageID}<div class="tabMenuContent" data-name="language{@$languageID}">{/if}
+{foreach from=$properties key=property item=diff}
+<section class="section editHistoryDiff">
+       <h2 class="sectionTitle">{lang}wcf.edit.headline.comparison{/lang}: {$objectTypeProcessor->getPropertyLabel($property)}</h2>
+       
+       <div class="sideBySide">
+               <div class="containerHeadline">
+                       <h3>{lang}wcf.edit.headline.old{/lang}</h3>
+               </div>
+               <div class="containerHeadline">
+                       <h3>{lang}wcf.edit.headline.newOrCurrent{/lang}</h3>
+               </div>
+       </div>
+
+<div><div>
+{assign var='prevType' value=''}
+{foreach from=$diff item='line'}
+{if $line[0] !== $prevType}
+       </div>
+       
+       {* unmodified, after deletion needs a "fake" insertion *}
+       {if $line[0] === ' ' && $prevType === '-'}<div></div>{/if}
+       
+       {* unmodified and deleted start a new container *}
+       {if $line[0] === ' ' || $line[0] === '-'}</div>{/if}
+       
+       {* adding, without deleting needs a "fake" deletion *}
+       {if $line[0] === '+' && $prevType !== '-'}
+               </div>
+               <div class="sideBySide">
+                       <div></div>
+       {/if}
+       
+       {if $line[0] === ' '}
+               <div>
+       {/if}
+       {if $line[0] === '-'}
+               <div class="sideBySide">
+       {/if}
+       <div{if $line[0] === '+'} style="color: green;"{elseif $line[0] === '-'} style="color: red;"{/if}>
+{/if}
+{if $line[0] === ' '}{@$line[1]}<br>{/if}
+{if $line[0] === '-'}{@$line[1]}<br>{/if}
+{if $line[0] === '+'}{@$line[1]}<br>{/if}
+{assign var='prevType' value=$line[0]}
+{/foreach}
+</div></div>
+</section>
+{/foreach}
+{if $languageID}</div>{/if}
+{/foreach}
+{if !$diffs[0]|isset}</div>{/if}
+{/if}
+
+<form action="{link controller='VersionTrackerList'}{/link}" method="post">
+       <section class="section tabularBox editHistoryVersionList">
+               {assign var='versionCount' value=$versions|count}
+               <h2 class="sectionTitle">
+                       {lang}wcf.edit.versions{/lang} <span class="badge">{#$versionCount+1}</span>
+               </h2>
+               
+               <table class="table">
+                       <thead>
+                               <tr>
+                                       <th class="columnID columnEditID" colspan="2">{lang}wcf.edit.version{/lang}</th>
+                                       <th class="columnText columnUser">{lang}wcf.user.username{/lang}</th>
+                                       <th class="columnDate columnTime">{lang}wcf.edit.time{/lang}</th>
+                                       
+                                       {event name='columnHeads'}
+                               </tr>
+                       </thead>
+                       
+                       <tbody>
+                               <tr>
+                                       <td class="columnIcon">
+                                               <span class="icon icon16 fa-undo disabled"></span>
+                                               <input type="radio" name="oldID" value="current"{if $oldID === 'current'} checked{/if}> <input type="radio" name="newID" value="current"{if $newID === 'current'} checked{/if}>
+                                               {event name='rowButtons'}
+                                       </td>
+                                       <td class="columnID"><strong>{lang}wcf.edit.currentVersion{/lang}</strong></td>
+                                       <td class="columnText columnUser"><a href="{link controller='UserEdit' id=$object->getUserID()}{/link}">{$object->getUsername()}</a></td>
+                                       <td class="columnDate columnTime">{@$object->getTime()|time}</td>
+                                       
+                                       {event name='columns'}
+                               </tr>
+                               {foreach from=$versions item=edit name=edit}
+                                       <tr class="jsEditRow">
+                                               <td class="columnIcon">
+                                                       <span class="icon icon16 fa-undo pointer jsRevertButton jsTooltip" title="{lang}wcf.edit.revert{/lang}" data-object-id="{@$edit->versionID}" data-confirm-message="{lang __encode=true}wcf.edit.revert.sure{/lang}"></span>
+                                                       <input type="radio" name="oldID" value="{@$edit->versionID}"{if $oldID == $edit->versionID} checked{/if}> <input type="radio" name="newID" value="{@$edit->versionID}"{if $newID == $edit->versionID} checked{/if}>
+                                                       {event name='rowButtons'}
+                                               </td>
+                                               <td class="columnID">{#($tpl[foreach][edit][total] - $tpl[foreach][edit][iteration] + 1)}</td>
+                                               <td class="columnText columnUser"><a href="{link controller='User' id=$edit->userID title=$edit->username}{/link}">{$edit->username}</a></td>
+                                               <td class="columnDate columnTime">{@$edit->time|time}</td>
+                                               
+                                               {event name='columns'}
+                                       </tr>
+                               {/foreach}
+                       </tbody>
+                       <script data-relocate="true">
+                               $(function () {
+                                       /*
+                                               TODO: this needs to be adjusted
+                                       */
+                                       new WCF.Message.EditHistory($('input[name=oldID]'), $('input[name=newID]'), '.jsEditRow');
+                               });
+                       </script>
+               </table>
+       </section>
+       
+       <div class="formSubmit">
+               <input type="hidden" name="objectID" value="{$objectID}">
+               <input type="hidden" name="objectType" value="{$objectType}">
+               <button class="button buttonPrimary" data-type="submit">{lang}wcf.edit.button.compare{/lang}</button>
+       </div>
+</form>
+
+{include file='footer'}
index 4b8a2975c5c76daa423f822473433f55982f2141..1289d1cc3728748b783204b7fdf658d4dadf17cc 100644 (file)
@@ -7,6 +7,7 @@ use wcf\system\exception\IllegalLinkException;
 use wcf\system\exception\PermissionDeniedException;
 use wcf\system\language\LanguageFactory;
 use wcf\system\tagging\TagEngine;
+use wcf\system\version\VersionTracker;
 use wcf\system\WCF;
 use wcf\util\DateUtil;
 
@@ -175,7 +176,8 @@ class ArticleEditForm extends ArticleAddForm {
                WCF::getTPL()->assign([
                        'action' => 'edit',
                        'articleID' => $this->articleID,
-                       'article' => $this->article
+                       'article' => $this->article,
+                       'lastVersion' => VersionTracker::getInstance()->getLastVersion('com.woltlab.wcf.article', $this->articleID)
                ]);
        }
 }
diff --git a/wcfsetup/install/files/lib/acp/page/VersionTrackerListPage.class.php b/wcfsetup/install/files/lib/acp/page/VersionTrackerListPage.class.php
new file mode 100644 (file)
index 0000000..11444dd
--- /dev/null
@@ -0,0 +1,270 @@
+<?php
+namespace wcf\acp\page;
+use wcf\data\IVersionTrackerObject;
+use wcf\data\language\Language;
+use wcf\page\AbstractPage;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\language\LanguageFactory;
+use wcf\system\request\LinkHandler;
+use wcf\system\version\IVersionTrackerProvider;
+use wcf\system\version\VersionTracker;
+use wcf\system\version\VersionTrackerEntry;
+use wcf\system\WCF;
+use wcf\util\Diff;
+use wcf\util\HeaderUtil;
+use wcf\util\StringUtil;
+
+/**
+ * Shows a list of tracked versions for provided object type and id.
+ * 
+ * @author     Alexander Ebert
+ * @copyright  2001-2017 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\Acp\Page
+ */
+class VersionTrackerListPage extends AbstractPage {
+       /**
+        * object id
+        * @var integer
+        */
+       public $objectID = 0;
+       
+       /**
+        * object type name
+        * @var string
+        */
+       public $objectType = '';
+       
+       /**
+        * @var IVersionTrackerProvider
+        */
+       public $objectTypeProcessor;
+       
+       /**
+        * @var VersionTrackerEntry[]
+        */
+       public $versions = [];
+       
+       /**
+        * left / old version id
+        * @var integer
+        */
+       public $oldID = 0;
+       
+       /**
+        * left / old version
+        * @var VersionTrackerEntry
+        */
+       public $old;
+       
+       /**
+        * right / new version id
+        * @var integer
+        */
+       public $newID = 0;
+       
+       /**
+        * right / new version
+        * @var VersionTrackerEntry
+        */
+       public $new;
+       
+       /**
+        * differences between both versions
+        * @var Diff[]
+        */
+       public $diffs = [];
+       
+       /**
+        * requested object
+        * @var IVersionTrackerObject
+        */
+       public $object;
+       
+       /**
+        * list of available languages for comparison
+        * @var Language[]
+        */
+       public $languages = [];
+       
+       /**
+        * property used for comparison
+        * @var string
+        */
+       public $property = '';
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (isset($_REQUEST['objectID'])) $this->objectID = intval($_REQUEST['objectID']);
+               if (isset($_REQUEST['objectType'])) $this->objectType = $_REQUEST['objectType'];
+               
+               try {
+                       $objectType = VersionTracker::getInstance()->getObjectType($this->objectType);
+               }
+               catch (\InvalidArgumentException $e) {
+                       throw new IllegalLinkException();
+               }
+               
+               $this->objectTypeProcessor = $objectType->getProcessor();
+               if (!$this->objectTypeProcessor->canAccess()) {
+                       throw new PermissionDeniedException();
+               }
+               
+               $this->activeMenuItem = $this->objectTypeProcessor->getActiveMenuItem();
+               
+               $this->object = $this->objectTypeProcessor->getObjectByID($this->objectID);
+               if (!$this->object->getObjectID()) {
+                       throw new IllegalLinkException();
+               }
+               
+               $this->versions = VersionTracker::getInstance()->getVersions($this->objectType, $this->objectID);
+               
+               if (isset($_REQUEST['oldID'])) {
+                       $this->oldID = intval($_REQUEST['oldID']);
+                       $this->old = VersionTracker::getInstance()->getVersion($this->objectType, $this->oldID);
+                       if (!$this->old->versionID) throw new IllegalLinkException();
+                       
+                       if (isset($_REQUEST['newID']) && $_REQUEST['newID'] !== 'current') {
+                               $this->newID = intval($_REQUEST['newID']);
+                               $this->new = VersionTracker::getInstance()->getVersion($this->objectType, $this->newID);
+                               if (!$this->new->versionID) throw new IllegalLinkException();
+                       }
+               }
+               
+               if (isset($_REQUEST['newID']) && !$this->new) {
+                       $this->new = $this->objectTypeProcessor->getCurrentVersion($this->object);
+                       $this->newID = 'current';
+               }
+               
+               if (!empty($_POST)) {
+                       HeaderUtil::redirect(LinkHandler::getInstance()->getLink('VersionTrackerList', [
+                               'objectID' => $this->objectID,
+                               'objectType' => $this->objectType,
+                               'newID' => $this->newID,
+                               'oldID' => $this->oldID
+                       ]));
+                       exit;
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function readData() {
+               parent::readData();
+               
+               // valid IDs were given, calculate diff
+               if ($this->old && $this->new) {
+                       $languageIDs = $this->new->getLanguageIDs();
+                       
+                       if (count($languageIDs) > 1 || $languageIDs[0] != 0) {
+                               foreach ($languageIDs as $i => $languageID) {
+                                       $language = LanguageFactory::getInstance()->getLanguage($languageID);
+                                       if ($language === null) {
+                                               unset($languageIDs[$i]);
+                                       }
+                                       else {
+                                               $this->languages[$languageID] = $language;
+                                       }
+                               }
+                               
+                               $languageIDs = array_unique($languageIDs);
+                       }
+                       
+                       $properties = $this->objectTypeProcessor->getTrackedProperties();
+                       foreach ($languageIDs as $languageID) {
+                               $this->diffs[$languageID] = [];
+                               
+                               foreach ($properties as $property) {
+                                       $a = explode("\n", StringUtil::unifyNewlines($this->old->getPayload($property, $languageID)));
+                                       $b = explode("\n", StringUtil::unifyNewlines($this->new->getPayload($property, $languageID)));
+                                       
+                                       $diff = new Diff($a, $b);
+                                       $rawDiff = $diff->getRawDiff();
+                                       
+                                       // create word diff for small changes (only one consecutive paragraph modified)
+                                       for ($i = 0, $max = count($rawDiff); $i < $max;) {
+                                               $previousIsNotRemoved = !isset($rawDiff[$i - 1][0]) || $rawDiff[$i - 1][0] !== Diff::REMOVED;
+                                               $currentIsRemoved = $rawDiff[$i][0] === Diff::REMOVED;
+                                               $nextIsAdded = isset($rawDiff[$i + 1][0]) && $rawDiff[$i + 1][0] === Diff::ADDED;
+                                               $afterNextIsNotAdded = !isset($rawDiff[$i + 2][0]) || $rawDiff[$i + 2][0] !== Diff::ADDED;
+                                               
+                                               if ($previousIsNotRemoved && $currentIsRemoved && $nextIsAdded && $afterNextIsNotAdded) {
+                                                       $a = preg_split('/(\\W)/u', $rawDiff[$i][1], -1, PREG_SPLIT_DELIM_CAPTURE);
+                                                       $b = preg_split('/(\\W)/u', $rawDiff[$i + 1][1], -1, PREG_SPLIT_DELIM_CAPTURE);
+                                                       
+                                                       $diff = new Diff($a, $b);
+                                                       $rawDiff[$i][1] = '';
+                                                       $rawDiff[$i + 1][1] = '';
+                                                       foreach ($diff->getRawDiff() as $entry) {
+                                                               $entry[1] = StringUtil::encodeHTML($entry[1]);
+                                                               
+                                                               if ($entry[0] === Diff::SAME) {
+                                                                       $rawDiff[$i][1] .= $entry[1];
+                                                                       $rawDiff[$i + 1][1] .= $entry[1];
+                                                               }
+                                                               else {
+                                                                       if ($entry[0] === Diff::REMOVED) {
+                                                                               $rawDiff[$i][1] .= '<strong>' . $entry[1] . '</strong>';
+                                                                       }
+                                                                       else {
+                                                                               if ($entry[0] === Diff::ADDED) {
+                                                                                       $rawDiff[$i + 1][1] .= '<strong>' . $entry[1] . '</strong>';
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }
+                                                       $i += 2;
+                                               }
+                                               else {
+                                                       $rawDiff[$i][1] = StringUtil::encodeHTML($rawDiff[$i][1]);
+                                                       $i++;
+                                               }
+                                       }
+                                       
+                                       $this->diffs[$languageID][$property] = $rawDiff;
+                               }
+                       }
+                       
+                       // simply template logic by treating diffs with only one language as "no i18n"
+                       if (count($this->diffs) == 1 && !isset($this->diffs[0])) {
+                               $this->diffs = [reset($this->diffs)];
+                       }
+               }
+               
+               // set default values
+               if (!isset($_REQUEST['oldID']) && !isset($_REQUEST['newID'])) {
+                       foreach ($this->versions as $version) {
+                               $this->oldID = $version->versionID;
+                               break;
+                       }
+                       $this->newID = 'current';
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function assignVariables() {
+               parent::assignVariables();
+               
+               WCF::getTPL()->assign([
+                       'oldID' => $this->oldID,
+                       'old' => $this->old,
+                       'newID' => $this->newID,
+                       'new' => $this->new,
+                       'diffs' => $this->diffs,
+                       'objectID' => $this->objectID,
+                       'objectType' => $this->objectType,
+                       'objectTypeProcessor' => $this->objectTypeProcessor,
+                       'object' => $this->object,
+                       'languages' => $this->languages,
+                       'versions' => $this->versions
+               ]);
+       }
+}
index d9d760684402d574adb30840784234fe2b392783..afd84b4297f38476c259ca9a216e51b1f4c85138 100644 (file)
@@ -9,7 +9,7 @@ namespace wcf\data;
  * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  * @package    WoltLabSuite\Core\Data
  */
-interface IVersionTrackerObject {
+interface IVersionTrackerObject extends IUserContent {
        /**
         * Returns the object's unique id.
         * 
index 1b5bce2577805f15ee294d9f18d9107c00c0e32e..90a084df8ef0ee9abadd2d11f4da5328a994e173 100644 (file)
@@ -61,4 +61,39 @@ class ArticleVersionTracker extends DatabaseObjectDecorator implements IVersionT
        public function getContent() {
                return $this->content;
        }
-}
\ No newline at end of file
+       
+       /**
+        * @inheritDoc
+        */
+       public function getLink() {
+               return $this->getDecoratedObject()->getLink();
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getUsername() {
+               return $this->getDecoratedObject()->username;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getUserID() {
+               return $this->getDecoratedObject()->userID;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getTime() {
+               return $this->getDecoratedObject()->time;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getTitle() {
+               return $this->getDecoratedObject()->getTitle();
+       }
+}
index c046899cdb0058bc803577677883954466556db4..931cff3e5bc1194149d89319dd8f580025d18e4d 100644 (file)
@@ -1,6 +1,8 @@
 <?php
 namespace wcf\system\version;
+use wcf\data\IVersionTrackerObject;
 use wcf\data\object\type\AbstractObjectTypeProvider;
+use wcf\system\WCF;
 
 /**
  * Abstract implementation of an version tracker object type provider.
@@ -11,9 +13,85 @@ use wcf\data\object\type\AbstractObjectTypeProvider;
  * @package    WoltLabSuite\Core\System\Version
  */
 abstract class AbstractVersionTrackerProvider extends AbstractObjectTypeProvider implements IVersionTrackerProvider {
+       /**
+        * the default property that should be used when initiating a diff
+        * @var string
+        */
+       public static $defaultProperty = '';
+       
+       /**
+        * list of property names to their phrase
+        * @var string[]
+        */
+       public static $propertyLabels = [];
+       
        /**
         * list of properties that should be tracked
         * @var string[]
         */
        public static $trackedProperties = [];
+       
+       /**
+        * internal identifier of the menu item that should be marked as active
+        * @var string
+        */
+       public $activeMenuItem = '';
+       
+       /**
+        * true if content supports i18n
+        * @var boolean
+        */
+       public $isI18n = false;
+       
+       /**
+        * permission name to access stored versions
+        * @var string
+        */
+       public $permissionCanAccess = '';
+       
+       /**
+        * @inheritDoc
+        */
+       public function canAccess() {
+               return WCF::getSession()->getPermission($this->permissionCanAccess);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getActiveMenuItem() {
+               return $this->activeMenuItem;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getDefaultProperty() {
+               return static::$defaultProperty;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getPropertyLabel($property) {
+               if (isset(static::$propertyLabels[$property])) {
+                       return WCF::getLanguage()->get(static::$propertyLabels[$property]);
+               }
+               
+               return '(void)';
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getTrackedProperties() {
+               return static::$trackedProperties;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function isI18n(IVersionTrackerObject $object) {
+               return $this->isI18n;
+       }
 }
index 809571348346795ddfbd2c04018ae091cc9ba2a3..5fecaa6f172c0c0e8f615502a0d02f8c5e2d65ad 100644 (file)
@@ -14,11 +14,21 @@ use wcf\data\IVersionTrackerObject;
  * @package    WoltLabSuite\Core\System\Version
  */
 class ArticleVersionTrackerProvider extends AbstractVersionTrackerProvider {
+       /**
+        * @inheritDoc
+        */
+       public $activeMenuItem = 'wcf.acp.menu.link.article.list';
+       
        /**
         * @inheritDoc
         */
        public $className = Article::class;
        
+       /**
+        * @inheritDoc
+        */
+       public $decoratorClassName = ArticleVersionTracker::class;
+       
        /**
         * @inheritDoc
         */
@@ -27,7 +37,49 @@ class ArticleVersionTrackerProvider extends AbstractVersionTrackerProvider {
        /**
         * @inheritDoc
         */
-       public static $trackedProperties = ['content', 'teaser', 'title'];
+       public $permissionCanAccess = 'admin.content.article.canManageArticle';
+       
+       /**
+        * @inheritDoc
+        */
+       public static $defaultProperty = 'content';
+       
+       /**
+        * @inheritDoc
+        */
+       public static $propertyLabels = [
+               'content' => 'wcf.acp.article.content',
+               'teaser' => 'wcf.acp.article.teaser',
+               'title' => 'wcf.global.title'
+       ];
+       
+       /**
+        * @inheritDoc
+        */
+       public static $trackedProperties = ['title', 'teaser', 'content'];
+       
+       /**
+        * @inheritDoc
+        */
+       public function getCurrentVersion(IVersionTrackerObject $object) {
+               $properties = $this->getTrackedProperties();
+               
+               /** @var Article $object */
+               $payload = [];
+               foreach ($object->getArticleContents() as $languageID => $articleContent) {
+                       $payload[$languageID] = [];
+                       foreach ($properties as $property) {
+                               $payload[$languageID][$property] = $articleContent->{$property};
+                       }
+               }
+               
+               return new VersionTrackerEntry(null, [
+                       'versionID' => 'current',
+                       'userID' => $object->userID,
+                       'username' => $object->username,
+                       'data' => $payload
+               ]);
+       }
        
        /**
         * @inheritDoc
@@ -47,4 +99,12 @@ class ArticleVersionTrackerProvider extends AbstractVersionTrackerProvider {
                
                return $data;
        }
-}
\ No newline at end of file
+       
+       /**
+        * @inheritDoc
+        */
+       public function isI18n(IVersionTrackerObject $object) {
+               /** @var Article $object */
+               return $object->isMultilingual == 1;
+       }
+}
index 6fed52ca46b280b84e271159cd330101c74fb73c..563f6b3b80faa5b0a31c941fab9be9ae021ce431 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 namespace wcf\system\version;
-use wcf\data\IVersionTrackerObject;
 use wcf\data\object\type\IObjectTypeProvider;
+use wcf\data\IVersionTrackerObject;
 
 /**
  * Represents objects that support some of their properties to be saved.
@@ -12,6 +12,44 @@ use wcf\data\object\type\IObjectTypeProvider;
  * @package    WoltLabSuite\Core\System\Version
  */
 interface IVersionTrackerProvider extends IObjectTypeProvider {
+       /**
+        * Returns true if current user can view the stored versions of this type.
+        * 
+        * @return      boolean
+        */
+       public function canAccess();
+       
+       /**
+        * Returns the internal identifier for the ACP menu item that should be
+        * marked as active when navigating through the relevant versions.
+        * 
+        * @return      string          active menu item identifier
+        */
+       public function getActiveMenuItem();
+       
+       /**
+        * Returns an arbitrary version entry that represents the current version
+        * 
+        * @param       IVersionTrackerObject   $object         target object
+        * @return      VersionTrackerEntry
+        */
+       public function getCurrentVersion(IVersionTrackerObject $object);
+       
+       /**
+        * Returns the name of the default property used when initiating a diff.
+        * 
+        * @return      string          default property name
+        */
+       public function getDefaultProperty();
+       
+       /**
+        * Returns the label for provided property.
+        * 
+        * @param       string          $property       property name
+        * @return      string          property label
+        */
+       public function getPropertyLabel($property);
+       
        /**
         * Returns an array containing the values that should be stored in the database.
         * 
@@ -19,4 +57,20 @@ interface IVersionTrackerProvider extends IObjectTypeProvider {
         * @return      mixed[]                 property to value mapping
         */
        public function getTrackedData(IVersionTrackerObject $object);
+       
+       /**
+        * Returns the list of tracked properties.
+        * 
+        * @return      string[]        list of tracked properties
+        */
+       public function getTrackedProperties();
+       
+       /**
+        * Indicates that the payload is provided for each language and that the
+        * payload's array indices represent language ids rather than property values.
+        * 
+        * @param       IVersionTrackerObject   $object         target object
+        * @return      boolean
+        */
+       public function isI18n(IVersionTrackerObject $object);
 }
index 85a97dbcfb5b54c2e0a60c4b88a31d3fe1af97c3..ca13ed5b4e876c8094b4e530b4658af7c593c3a8 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 namespace wcf\system\version;
+use wcf\data\DatabaseObject;
 use wcf\data\object\type\ObjectType;
 use wcf\data\object\type\ObjectTypeCache;
 use wcf\data\object\type\ObjectTypeList;
@@ -36,7 +37,7 @@ class VersionTracker extends SingletonFactory {
        /**
         * Adds a new entry to the version history.
         * 
-        * @param       string                  $objectTypeName         object typename
+        * @param       string                  $objectTypeName         object type name
         * @param       IVersionTrackerObject   $object                 target object
         */
        public function add($objectTypeName, IVersionTrackerObject $object) {
@@ -47,15 +48,100 @@ class VersionTracker extends SingletonFactory {
                $data = $processor->getTrackedData($object);
                
                $sql = "INSERT INTO     ".$this->getTableName($objectType)."_version
-                                       (objectID, data)
-                       VALUES          (?, ?)";
+                                       (objectID, userID, username, time, data)
+                       VALUES          (?, ?, ?, ?, ?)";
                $statement = WCF::getDB()->prepareStatement($sql);
                $statement->execute([
                        $object->getObjectID(),
+                       WCF::getUser()->userID,
+                       WCF::getUser()->username,
+                       TIME_NOW,
                        serialize($data)
                ]);
        }
        
+       /**
+        * Returns the number of stored versions for provided object type and object id.
+        * 
+        * @param       string          $objectTypeName         object type name
+        * @param       integer         $objectID               target object id
+        * @return      integer         number of stored versions
+        */
+       public function countVersions($objectTypeName, $objectID) {
+               $objectType = $this->getObjectType($objectTypeName);
+               
+               $sql = "SELECT  COUNT(*) as count
+                       FROM    ".$this->getTableName($objectType)."_version
+                       WHERE   objectID = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([$objectID]);
+               
+               return $statement->fetchColumn();
+       }
+       
+       /**
+        * Returns the last stored version.
+        * 
+        * @param       string          $objectTypeName         object type name
+        * @param       integer         $objectID               target object id
+        * @return      VersionTrackerEntry|null|DatabaseObject
+        */
+       public function getLastVersion($objectTypeName, $objectID) {
+               $objectType = $this->getObjectType($objectTypeName);
+               
+               $sql = "SELECT          *, '' as data
+                       FROM            ".$this->getTableName($objectType)."_version
+                       WHERE           objectID = ?
+                       ORDER BY        versionID DESC";
+               $statement = WCF::getDB()->prepareStatement($sql, 1);
+               $statement->execute([$objectID]);
+               
+               return $statement->fetchObject(VersionTrackerEntry::class);
+       }
+       
+       /**
+        * Returns the list of stored versions.
+        * 
+        * @param       string          $objectTypeName         object type name
+        * @param       integer         $objectID               target object id
+        * @return      VersionTrackerEntry[]
+        */
+       public function getVersions($objectTypeName, $objectID) {
+               $objectType = $this->getObjectType($objectTypeName);
+               
+               $sql = "SELECT          *, '' as data
+                       FROM            ".$this->getTableName($objectType)."_version
+                       WHERE           objectID = ?
+                       ORDER BY        versionID DESC";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([$objectID]);
+               $versions = [];
+               while ($version = $statement->fetchObject(VersionTrackerEntry::class)) {
+                       $versions[] = $version;
+               }
+               
+               return $versions;
+       }
+       
+       /**
+        * Returns a version including the contents of the data column.
+        * 
+        * @param       string          $objectTypeName         object type name
+        * @param       integer         $versionID              version id
+        * @return      VersionTrackerEntry|null|DatabaseObject
+        */
+       public function getVersion($objectTypeName, $versionID) {
+               $objectType = $this->getObjectType($objectTypeName);
+               
+               $sql = "SELECT          *
+                       FROM            ".$this->getTableName($objectType)."_version
+                       WHERE           versionID = ?";
+               $statement = WCF::getDB()->prepareStatement($sql, 1);
+               $statement->execute([$versionID]);
+               
+               return $statement->fetchObject(VersionTrackerEntry::class);
+       }
+       
        /**
         * Creates the database tables to store each version.
         */
@@ -77,6 +163,23 @@ class VersionTracker extends SingletonFactory {
                }
        }
        
+       /**
+        * Retrieves the object type object by its name.
+        *
+        * @param       string          $name   object type name
+        * @return      ObjectType      target object
+        * @throws      SystemException
+        */
+       public function getObjectType($name) {
+               foreach ($this->availableObjectTypes as $objectType) {
+                       if ($objectType->objectType === $name) {
+                               return $objectType;
+                       }
+               }
+               
+               throw new \InvalidArgumentException("Unknown object type '".$name."' for definition 'com.woltlab.wcf.versionTracker.objectType'.");
+       }
+       
        /**
         * Creates a database table for an object type unless it exists already.
         * 
@@ -100,7 +203,11 @@ class VersionTracker extends SingletonFactory {
                }
                
                $columns = [
+                       ['name' => 'versionID', 'data' => ['length' => 10, 'notNull' => true, 'type' => 'int', 'key' => 'PRIMARY', 'autoIncrement' => true]],
                        ['name' => 'objectID', 'data' => ['length' => 10, 'notNull' => true, 'type' => 'int']],
+                       ['name' => 'userID', 'data' => ['length' => 10, 'type' => 'int']],
+                       ['name' => 'username', 'data' => ['length' => 100, 'notNull' => true, 'type' => 'varchar']],
+                       ['name' => 'time', 'data' => ['length' => 10, 'notNull' => true, 'type' => 'int']],
                        ['name' => 'data', 'data' => ['type' => 'longblob']]
                ];
                
@@ -115,6 +222,16 @@ class VersionTracker extends SingletonFactory {
                                'ON DELETE' => 'CASCADE'
                        ]
                );
+               WCF::getDB()->getEditor()->addForeignKey(
+                       $tableName,
+                       md5($tableName . '_userID') . '_fk',
+                       [
+                               'columns' => 'userID',
+                               'referencedTable' => 'wcf'.WCF_N.'_user',
+                               'referencedColumns' => 'userID',
+                               'ON DELETE' => 'SET NULL'
+                       ]
+               );
                
                // add comment
                $sql = "ALTER TABLE     ".$tableName."
@@ -162,21 +279,4 @@ class VersionTracker extends SingletonFactory {
                
                return $tableName;
        }
-       
-       /**
-        * Retrieves the object type object by its name.
-        * 
-        * @param       string          $name   object type name
-        * @return      ObjectType      target object
-        * @throws      SystemException
-        */
-       protected function getObjectType($name) {
-               foreach ($this->availableObjectTypes as $objectType) {
-                       if ($objectType->objectType === $name) {
-                               return $objectType;
-                       }
-               }
-               
-               throw new SystemException("Unknown object type '".$name."' for definition 'com.woltlab.wcf.versionTracker.objectType'.");
-       }
 }
diff --git a/wcfsetup/install/files/lib/system/version/VersionTrackerEntry.class.php b/wcfsetup/install/files/lib/system/version/VersionTrackerEntry.class.php
new file mode 100644 (file)
index 0000000..f9591fd
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+namespace wcf\system\version;
+
+/**
+ * Generic data holder for version tracker entries.
+ * 
+ * @author     Alexander Ebert
+ * @copyright  2001-2017 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\System\Version
+ * 
+ * @property-read      integer         $versionID              unique id of the tracked version entry
+ * @property-read      integer         $objectID               id of the edited object
+ * @property-read      integer|null    $userID                 id of the user who has created the previous version of the object or `null` if the user does not exist anymore or if the previous version has been created by a guest
+ * @property-read      string          $username               name of the user who has created the previous version of the object
+ * @property-read      integer         $time                   timestamp at which the original version has been created
+ */
+class VersionTrackerEntry {
+       /**
+        * object data
+        * @var array
+        */
+       protected $data = [];
+       
+       /**
+        * list of stored properties and their values
+        * @var array
+        */
+       protected $payload = [];
+       
+       /**
+        * VersionTrackerEntry constructor.
+        *
+        * @param       integer         $id             id
+        * @param       array           $data           version data
+        */
+       public function __construct($id, array $data) {
+               if ($id !== null) {
+                       throw new \InvalidArgumentException("Accessing tracked versions by id is not supported.");
+               }
+               
+               if (isset($data['data'])) {
+                       $payload = (is_array($data['data'])) ? $data['data'] : @unserialize($data['data']);
+                       if ($payload !== false && is_array($payload)) {
+                               $this->payload = $payload;
+                       }
+                       
+                       unset($data['data']);
+               }
+               
+               $this->data = $data;
+       }
+       
+       /**
+        * Returns the value of a object data variable with the given name or `null` if no
+        * such data variable exists.
+        *
+        * @param       string          $name
+        * @return      mixed
+        */
+       public function __get($name) {
+               if (isset($this->data[$name])) {
+                       return $this->data[$name];
+               }
+               else {
+                       return null;
+               }
+       }
+       
+       /**
+        * Determines if the object data variable with the given name is set and
+        * is not NULL.
+        *
+        * @param       string          $name
+        * @return      boolean
+        */
+       public function __isset($name) {
+               return isset($this->data[$name]);
+       }
+       
+       /**
+        * Returns the stored value of a property or null if unknown.
+        * 
+        * @param       string          $property       property name
+        * @param       integer         $languageID     language id
+        * @return      string
+        */
+       public function getPayload($property, $languageID) {
+               if (isset($this->payload[$languageID])) {
+                       return (isset($this->payload[$languageID][$property])) ? $this->payload[$languageID][$property] : '';
+               }
+               
+               return '';
+       }
+       
+       /**
+        * Returns the list of language ids.
+        * 
+        * @return      integer[]
+        */
+       public function getLanguageIDs() {
+               return array_keys($this->payload);
+       }
+}
\ No newline at end of file
index 020819d47d8befbf2fd90d92ee80d01d984693aa..9344d127aa3eb4d1c6a86710160344eb8905b185 100644 (file)
@@ -96,6 +96,7 @@
                <item name="wcf.acp.article.trash.confirmMessage"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} {if $isArticleEdit|empty}den Artikel <span class="confirmationObject">{$article->getTitle()}</span>{else}diesen Artikel{/if} wirklich in den Papierkorb verschieben?]]></item>
                <item name="wcf.acp.article.trash.notice"><![CDATA[Dieser Artikel befindet sich im Papierkorb und wird gegenwärtig nicht angezeigt.]]></item>
                <item name="wcf.acp.article.views"><![CDATA[Zugriffe]]></item>
+               <item name="wcf.acp.article.lastVersion"><![CDATA[Es gibt <a href="{link controller='VersionTrackerList' objectType='com.woltlab.wcf.article' objectID=$article->articleID}{/link}">vorherige Versionen</a> dieses Artikels, die <a href="{link controller='VersionTrackerCompare' id=$lastVersion->versionID objectType='com.woltlab.wcf.article'}{/link}">letzte Änderung</a> erfolgte durch <a href="{link controller='UserEdit' id=$lastVersion->userID}{/link}">{$lastVersion->username}</a> ({@$lastVersion->time|time}).]]></item>
        </category>
        
        <category name="wcf.acp.attachment">
@@ -2400,8 +2401,10 @@ Fehler sind beispielsweise:
                <item name="wcf.edit.reverted"><![CDATA[Wiederhergestellt auf Version von {$edit->username} vom {$edit->time|plainTime}]]></item>
                <item name="wcf.edit.button.compare"><![CDATA[Vergleichen]]></item>
                <item name="wcf.edit.button.goToContent"><![CDATA[Zum Inhalt gehen]]></item>
+               <item name="wcf.edit.headline.comparison"><![CDATA[Vergleich]]></item>
                <item name="wcf.edit.headline.old"><![CDATA[{if $oldID == 'current'}Aktuelle {/if}Version vom {@$old->time|plainTime} ({$old->username})]]></item>
                <item name="wcf.edit.headline.new"><![CDATA[{if $newID == 'current'}Aktuelle {/if}Version vom {@$new->time|plainTime} ({$new->username})]]></item>
+               <item name="wcf.edit.headline.newOrCurrent"><![CDATA[{if $newID == 'current'}Aktuelle Version{else}Version vom {@$new->time|plainTime}{/if} ({$new->username})]]></item>
        </category>
        
        <category name="wcf.editor">
index 1cdcedcd5ea02df89b21b430442c3d06ed768246..d56dbd16b8c0d784afcba7bc49c91cf98178bfe8 100644 (file)
@@ -2352,8 +2352,10 @@ Errors are:
                <item name="wcf.edit.reverted"><![CDATA[Reverted to the version at {$edit->time|plainTime}, created by {$edit->username}]]></item>
                <item name="wcf.edit.button.compare"><![CDATA[Compare]]></item>
                <item name="wcf.edit.button.goToContent"><![CDATA[Go to Related Content]]></item>
+               <item name="wcf.edit.headline.comparison"><![CDATA[Comparison]]></item>
                <item name="wcf.edit.headline.old"><![CDATA[{if $oldID == 'current'}Current version{else}Version{/if} as of {@$old->time|plainTime} ({$old->username})]]></item>
                <item name="wcf.edit.headline.new"><![CDATA[{if $newID == 'current'}Current version{else}Version{/if} as of {@$new->time|plainTime} ({$new->username})]]></item>
+               <item name="wcf.edit.headline.newOrCurrent"><![CDATA[{if $newID == 'current'}Current version{else}Version as of {@$new->time|plainTime}{/if} ({$new->username})]]></item>
        </category>
        
        <category name="wcf.editor">