diff --git a/TODO b/TODO index 16f637e1..7dc6105e 100644 --- a/TODO +++ b/TODO @@ -7,8 +7,6 @@ everything related to posts: - score - editing - ability to loop video posts - - post edit history (think - http://konachan.com/history?search=post%3A188614) - previous and next post (difficult) - extract Pager from PagedCollectionPresenter - rename PagedCollectionPresenter to PagerPresenter diff --git a/data/config.ini b/data/config.ini index d83ef15f..480c22ef 100644 --- a/data/config.ini +++ b/data/config.ini @@ -53,6 +53,8 @@ changePostRelations = regularUser, powerUser, moderator, administrator listTags = anonymous, regularUser, powerUser, moderator, administrator +viewHistory = anonymous, regularUser, powerUser, moderator, administrator + [users] minUserNameLength = 1 maxUserNameLength = 32 diff --git a/public_html/css/history.css b/public_html/css/history.css new file mode 100644 index 00000000..7ccf6a57 --- /dev/null +++ b/public_html/css/history.css @@ -0,0 +1,50 @@ +table.history { + font-size: 80%; +} +table.history .addition { + color: forestgreen; +} +table.history .addition:before { + content: '+'; +} + +table.history .removal { + color: red; +} +table.history .removal:before { + content: '-'; +} + +table.history .time, +table.history .user, +table.history .subject { + white-space: nowrap; +} + +table.history .user img { + vertical-align: middle; +} + +table.history td { + padding: 0.1em 0.25em; +} + +table.history ul { + margin: 0; + padding: 0; + list-style-type: none; + display: inline; +} +table.history ul:before { + content: ' ('; +} +table.history ul:after { + content: ')'; +} + +table.history li { + display: inline; +} +table.history li:not(:last-of-type):after { + content: ', '; +} diff --git a/public_html/css/post.css b/public_html/css/post.css index a0ffe758..e26fca55 100644 --- a/public_html/css/post.css +++ b/public_html/css/post.css @@ -124,3 +124,8 @@ #post-view-wrapper .post-edit-wrapper .file-handler { margin: 0.5em 0; } + +#post-view-wrapper .post-history-wrapper { + padding: 1em; + display: none; +} diff --git a/public_html/index.html b/public_html/index.html index 86a5b4cd..08da4674 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -32,6 +32,7 @@ + diff --git a/public_html/js/Auth.js b/public_html/js/Auth.js index 330f60b1..0b8a8100 100644 --- a/public_html/js/Auth.js +++ b/public_html/js/Auth.js @@ -33,6 +33,8 @@ App.Auth = function(_, jQuery, util, api, appState, promise) { changePostRelations: 'changePostRelations', listTags: 'listTags', + + viewHistory: 'viewHistory', }; function loginFromCredentials(userNameOrEmail, password, remember) { diff --git a/public_html/js/Presenters/PostPresenter.js b/public_html/js/Presenters/PostPresenter.js index 287452a1..321c75ad 100644 --- a/public_html/js/Presenters/PostPresenter.js +++ b/public_html/js/Presenters/PostPresenter.js @@ -15,13 +15,19 @@ App.Presenters.PostPresenter = function( var $el = jQuery('#content'); var $messages = $el; + var postTemplate; var postEditTemplate; var postContentTemplate; + var historyTemplate; + var post; + var postHistory; var postNameOrId; + var privileges = {}; var editPrivileges = {}; + var tagInput; var postContentFileDropper; var postThumbnailFileDropper; @@ -33,6 +39,7 @@ App.Presenters.PostPresenter = function( privileges.canDeletePosts = auth.hasPrivilege(auth.privileges.deletePosts); privileges.canFeaturePosts = auth.hasPrivilege(auth.privileges.featurePosts); + privileges.canViewHistory = auth.hasPrivilege(auth.privileges.viewHistory); editPrivileges.canChangeSafety = auth.hasPrivilege(auth.privileges.changePostSafety); editPrivileges.canChangeSource = auth.hasPrivilege(auth.privileges.changePostSource); editPrivileges.canChangeTags = auth.hasPrivilege(auth.privileges.changePostTags); @@ -43,15 +50,17 @@ App.Presenters.PostPresenter = function( promise.waitAll( util.promiseTemplate('post'), util.promiseTemplate('post-edit'), - util.promiseTemplate('post-content')) + util.promiseTemplate('post-content'), + util.promiseTemplate('history')) .then(function( postTemplateHtml, postEditTemplateHtml, postContentTemplateHtml, - response) { + historyTemplateHtml) { postTemplate = _.template(postTemplateHtml); postEditTemplate = _.template(postEditTemplateHtml); postContentTemplate = _.template(postContentTemplateHtml); + historyTemplate = _.template(historyTemplateHtml); reinit(args, loaded); }).fail(showGenericError); @@ -60,9 +69,14 @@ App.Presenters.PostPresenter = function( function reinit(args, loaded) { postNameOrId = args.postNameOrId; - promise.wait(api.get('/posts/' + postNameOrId)) - .then(function(response) { - post = response.json; + promise.waitAll( + api.get('/posts/' + postNameOrId), + privileges.canViewHistory ? + api.get('/posts/' + postNameOrId + '/history') : + null) + .then(function(postResponse, postHistoryResponse) { + post = postResponse.json; + postHistory = postHistoryResponse && postHistoryResponse.json && postHistoryResponse.json.data; topNavigationPresenter.changeTitle('@' + post.id); render(); loaded(); @@ -92,9 +106,10 @@ App.Presenters.PostPresenter = function( } $el.find('.post-edit-wrapper form').submit(editFormSubmitted); - $el.find('.delete').click(deleteButtonClicked); - $el.find('.feature').click(featureButtonClicked); - $el.find('.edit').click(editButtonClicked); + $el.find('#sidebar .delete').click(deleteButtonClicked); + $el.find('#sidebar .feature').click(featureButtonClicked); + $el.find('#sidebar .edit').click(editButtonClicked); + $el.find('#sidebar .history').click(historyButtonClicked); } function renderSidebar() { @@ -104,10 +119,12 @@ App.Presenters.PostPresenter = function( function renderPostTemplate() { return postTemplate({ post: post, + postHistory: postHistory, formatRelativeTime: util.formatRelativeTime, formatFileSize: util.formatFileSize, postContentTemplate: postContentTemplate, postEditTemplate: postEditTemplate, + historyTemplate: historyTemplate, privileges: privileges, editPrivileges: editPrivileges, }); @@ -228,6 +245,23 @@ App.Presenters.PostPresenter = function( }); } + function historyButtonClicked(e) { + e.preventDefault(); + if ($el.find('.post-history-wrapper').is(':visible')) { + hideHistory(); + } else { + showHistory(); + } + } + + function hideHistory() { + $el.find('.post-history-wrapper').slideUp('slow'); + } + + function showHistory() { + $el.find('.post-history-wrapper').slideDown('slow'); + } + function showEditError(response) { window.alert(response.json && response.json.error || response); } diff --git a/public_html/templates/history.tpl b/public_html/templates/history.tpl new file mode 100644 index 00000000..d8ead058 --- /dev/null +++ b/public_html/templates/history.tpl @@ -0,0 +1,63 @@ + + + <% _.each(history, function( historyEntry) { %> + + + + + + + + + + <% }) %> + +
+ <%= formatRelativeTime(historyEntry.time) %> + + <% var userName = historyEntry.user && historyEntry.user.name || '' %> + + <% if (userName) { %> + + <% } %> + + <%= userName || 'Anonymous user' %> + + <%= userName || 'Anonymous user' %> + + <% if (userName) { %> + + <% } %> + + <% if (historyEntry.type == 0) { %> + + @<%= historyEntry.primaryKey %> + + <% } else { %> + ? + <% } %> + + <% if (historyEntry.operation == 1) { %> + deleted + <% } else { %> + changed + + <% if (historyEntry.dataDifference) { %> +
    <% _.each(historyEntry.dataDifference['+'], function (difference) { %>
  • <%= difference[0] + ':' + difference[1] %>
  • <% }) %><% _.each(historyEntry.dataDifference['-'], function (difference) { %>
  • <%= difference[0] + ':' + difference[1] %>
  • <% }) %>
+ <% } %> + <% } %> +
diff --git a/public_html/templates/home.tpl b/public_html/templates/home.tpl index 541597cb..f44c4f55 100644 --- a/public_html/templates/home.tpl +++ b/public_html/templates/home.tpl @@ -20,11 +20,20 @@ featured by + + <% if (post.user.name) { %> + + <% } %> + <%= post.user.name || 'Anonymous user' %> <%= post.user.name || 'Anonymous user' %> + + <% if (post.user.name) { %> + + <% } %> diff --git a/public_html/templates/post.tpl b/public_html/templates/post.tpl index 71767368..1352d379 100644 --- a/public_html/templates/post.tpl +++ b/public_html/templates/post.tpl @@ -134,6 +134,14 @@ <% } %> + + <% if (privileges.canViewHistory) { %> +
  • + + History + +
  • + <% } %> <% } %> @@ -147,5 +155,14 @@ <%= postContentTemplate({post: post}) %> + + <% if (privileges.canViewHistory) { %> +
    + <%= historyTemplate({ + history: postHistory, + formatRelativeTime: formatRelativeTime + }) %> +
    + <% } %> diff --git a/src/Controllers/HistoryController.php b/src/Controllers/HistoryController.php new file mode 100644 index 00000000..726b4163 --- /dev/null +++ b/src/Controllers/HistoryController.php @@ -0,0 +1,44 @@ +historyService = $historyService; + $this->privilegeService = $privilegeService; + $this->snapshotSearchParser = $snapshotSearchParser; + $this->inputReader = $inputReader; + $this->snapshotViewProxy = $snapshotViewProxy; + } + + public function registerRoutes(\Szurubooru\Router $router) + { + $router->get('/api/history', [$this, 'getFiltered']); + } + + public function getFiltered() + { + $this->privilegeService->assertPrivilege(\Szurubooru\Privilege::VIEW_HISTORY); + + $filter = $this->snapshotSearchParser->createFilterFromInputReader($this->inputReader); + $filter->setPageSize(50); + $result = $this->historyService->getFiltered($filter); + $entities = $this->snapshotViewProxy->fromArray($result->getEntities()); + return [ + 'data' => $entities, + 'pageSize' => $result->getPageSize(), + 'totalRecords' => $result->getTotalRecords()]; + } +} diff --git a/src/Controllers/PostController.php b/src/Controllers/PostController.php index 7e35caf4..45c191f2 100644 --- a/src/Controllers/PostController.php +++ b/src/Controllers/PostController.php @@ -9,6 +9,7 @@ final class PostController extends AbstractController private $postSearchParser; private $inputReader; private $postViewProxy; + private $snapshotViewProxy; public function __construct( \Szurubooru\Config $config, @@ -16,7 +17,8 @@ final class PostController extends AbstractController \Szurubooru\Services\PostService $postService, \Szurubooru\SearchServices\Parsers\PostSearchParser $postSearchParser, \Szurubooru\Helpers\InputReader $inputReader, - \Szurubooru\Controllers\ViewProxies\PostViewProxy $postViewProxy) + \Szurubooru\Controllers\ViewProxies\PostViewProxy $postViewProxy, + \Szurubooru\Controllers\ViewProxies\SnapshotViewProxy $snapshotViewProxy) { $this->config = $config; $this->privilegeService = $privilegeService; @@ -24,30 +26,31 @@ final class PostController extends AbstractController $this->postSearchParser = $postSearchParser; $this->inputReader = $inputReader; $this->postViewProxy = $postViewProxy; + $this->snapshotViewProxy = $snapshotViewProxy; } public function registerRoutes(\Szurubooru\Router $router) { $router->post('/api/posts', [$this, 'createPost']); $router->get('/api/posts', [$this, 'getFiltered']); - $router->get('/api/posts/featured', [$this, 'getFeatured']); $router->get('/api/posts/:postNameOrId', [$this, 'getByNameOrId']); + $router->get('/api/posts/:postNameOrId/history', [$this, 'getHistory']); $router->put('/api/posts/:postNameOrId', [$this, 'updatePost']); $router->delete('/api/posts/:postNameOrId', [$this, 'deletePost']); $router->post('/api/posts/:postNameOrId/feature', [$this, 'featurePost']); $router->put('/api/posts/:postNameOrId/feature', [$this, 'featurePost']); } - public function getFeatured() + public function getByNameOrId($postNameOrId) { - $post = $this->postService->getFeatured(); + $post = $this->getByNameOrIdWithoutProxy($postNameOrId); return $this->postViewProxy->fromEntity($post, $this->getFullFetchConfig()); } - public function getByNameOrId($postNameOrId) + public function getHistory($postNameOrId) { - $post = $this->postService->getByNameOrId($postNameOrId); - return $this->postViewProxy->fromEntity($post, $this->getFullFetchConfig()); + $post = $this->getByNameOrIdWithoutProxy($postNameOrId); + return ['data' => $this->snapshotViewProxy->fromArray($this->postService->getHistory($post))]; } public function getFiltered() @@ -113,6 +116,14 @@ final class PostController extends AbstractController $this->postService->featurePost($post); } + private function getByNameOrIdWithoutProxy($postNameOrId) + { + if ($postNameOrId === 'featured') + return $this->postService->getFeatured(); + else + return $this->postService->getByNameOrId($postNameOrId); + } + private function getFullFetchConfig() { return diff --git a/src/Controllers/ViewProxies/SnapshotViewProxy.php b/src/Controllers/ViewProxies/SnapshotViewProxy.php new file mode 100644 index 00000000..4a957f75 --- /dev/null +++ b/src/Controllers/ViewProxies/SnapshotViewProxy.php @@ -0,0 +1,29 @@ +userViewProxy = $userViewProxy; + } + + public function fromEntity($snapshot, $config = []) + { + $result = new \StdClass; + if ($snapshot) + { + $result->time = $snapshot->getTime(); + $result->type = $snapshot->getType(); + $result->primaryKey = $snapshot->getPrimaryKey(); + $result->operation = $snapshot->getOperation(); + $result->user = $this->userViewProxy->fromEntity($snapshot->getUser()); + $result->data = $snapshot->getData(); + $result->dataDifference = $snapshot->getDataDifference(); + } + return $result; + } +} + diff --git a/src/Dao/EntityConverters/SnapshotEntityConverter.php b/src/Dao/EntityConverters/SnapshotEntityConverter.php new file mode 100644 index 00000000..4b1343be --- /dev/null +++ b/src/Dao/EntityConverters/SnapshotEntityConverter.php @@ -0,0 +1,34 @@ + $entity->getId(), + 'time' => $entity->getTime(), + 'type' => $entity->getType(), + 'primaryKey' => $entity->getPrimaryKey(), + 'userId' => $entity->getUserId(), + 'operation' => $entity->getOperation(), + 'data' => json_encode($entity->getData()), + 'dataDifference' => json_encode($entity->getDataDifference()), + ]; + } + + public function toBasicEntity(array $array) + { + $entity = new \Szurubooru\Entities\Snapshot(intval($array['id'])); + $entity->setTime($array['time']); + $entity->setType(intval($array['type'])); + $entity->setPrimaryKey($array['primaryKey']); + $entity->setUserId($array['userId']); + $entity->setOperation($array['operation']); + $entity->setData(json_decode($array['data'], true)); + $entity->setDataDifference(json_decode($array['dataDifference'], true)); + return $entity; + } +} + diff --git a/src/Dao/SnapshotDao.php b/src/Dao/SnapshotDao.php new file mode 100644 index 00000000..e8d04498 --- /dev/null +++ b/src/Dao/SnapshotDao.php @@ -0,0 +1,45 @@ +userDao = $userDao; + } + + public function findByTypeAndKey($type, $primaryKey) + { + $query = $this->fpdo + ->from($this->tableName) + ->where('type', $type) + ->where('primaryKey', $primaryKey) + ->orderBy('time DESC'); + return $this->arrayToEntities(iterator_to_array($query)); + } + + public function afterLoad(\Szurubooru\Entities\Entity $snapshot) + { + $snapshot->setLazyLoader( + \Szurubooru\Entities\Snapshot::LAZY_LOADER_USER, + function (\Szurubooru\Entities\Snapshot $snapshot) + { + return $this->getUser($snapshot); + }); + } + + private function getUser(\Szurubooru\Entities\Snapshot $snapshot) + { + $userId = $snapshot->getUserId(); + return $this->userDao->findById($userId); + } +} diff --git a/src/Entities/Post.php b/src/Entities/Post.php index 7e396040..a636b3df 100644 --- a/src/Entities/Post.php +++ b/src/Entities/Post.php @@ -220,14 +220,7 @@ final class Post extends Entity public function setUser(\Szurubooru\Entities\User $user = null) { $this->lazySave(self::LAZY_LOADER_USER, $user); - if ($user) - { - $this->userId = $user->getId(); - } - else - { - $this->userId = null; - } + $this->userId = $user ? $user->getId() : null; } public function getContent() diff --git a/src/Entities/Snapshot.php b/src/Entities/Snapshot.php new file mode 100644 index 00000000..5eaed75a --- /dev/null +++ b/src/Entities/Snapshot.php @@ -0,0 +1,101 @@ +time; + } + + public function setTime($time) + { + $this->time = $time; + } + + public function getType() + { + return $this->type; + } + + public function setType($type) + { + $this->type = $type; + } + + public function getPrimaryKey() + { + return $this->primaryKey; + } + + public function setPrimaryKey($primaryKey) + { + $this->primaryKey = $primaryKey; + } + + public function getOperation() + { + return $this->operation; + } + + public function setOperation($operation) + { + $this->operation = $operation; + } + + public function getUserId() + { + return $this->userId; + } + + public function setUserId($userId) + { + $this->userId = $userId; + } + + public function getData() + { + return $this->data; + } + + public function setData($data) + { + $this->data = $data; + } + + public function getDataDifference() + { + return $this->dataDifference; + } + + public function setDataDifference($dataDifference) + { + $this->dataDifference = $dataDifference; + } + + public function getUser() + { + return $this->lazyLoad(self::LAZY_LOADER_USER, null); + } + + public function setUser(\Szurubooru\Entities\User $user = null) + { + $this->lazySave(self::LAZY_LOADER_USER, $user); + $this->userId = $user ? $user->getId() : null; + } +} diff --git a/src/Helpers/EnumHelper.php b/src/Helpers/EnumHelper.php index 1e21c471..368d8ba5 100644 --- a/src/Helpers/EnumHelper.php +++ b/src/Helpers/EnumHelper.php @@ -35,6 +35,11 @@ class EnumHelper 'youtube' => \Szurubooru\Entities\Post::POST_TYPE_YOUTUBE, ]; + private static $snapshotTypeMap = + [ + 'post' => \Szurubooru\Entities\Snapshot::TYPE_POST, + ]; + public static function accessRankToString($accessRank) { return self::enumToString(self::$accessRankMap, $accessRank); @@ -70,6 +75,11 @@ class EnumHelper return self::enumToString(self::$postTypeMap, $postType); } + public static function snapshotTypeFromString($snapshotTypeString) + { + return self::stringToEnum(self::$snapshotTypeMap, $snapshotTypeString); + } + private static function enumToString($enumMap, $enumValue) { $reverseMap = array_flip($enumMap); diff --git a/src/Privilege.php b/src/Privilege.php index 804e7e34..88599efb 100644 --- a/src/Privilege.php +++ b/src/Privilege.php @@ -33,4 +33,6 @@ class Privilege const CHANGE_POST_RELATIONS = 'changePostRelations'; const LIST_TAGS = 'listTags'; + + const VIEW_HISTORY = 'viewHistory'; } diff --git a/src/SearchServices/Filters/SnapshotFilter.php b/src/SearchServices/Filters/SnapshotFilter.php new file mode 100644 index 00000000..3908119c --- /dev/null +++ b/src/SearchServices/Filters/SnapshotFilter.php @@ -0,0 +1,8 @@ +getValue(), ',') !== 1) + throw new \BadMethodCallException('Not supported'); + + if ($token->isNegated()) + throw new \BadMethodCallException('Not supported'); + + list ($type, $primaryKey) = explode(',', $token->getValue()); + + $requirement = new \Szurubooru\SearchServices\Requirements\Requirement(); + $requirement->setType(\Szurubooru\SearchServices\Filters\SnapshotFilter::REQUIREMENT_PRIMARY_KEY); + $requirement->setValue($primaryKey); + $filter->addRequirement($requirement); + + $requirement = new \Szurubooru\SearchServices\Requirements\Requirement(); + $requirement->setType(\Szurubooru\SearchServices\Filters\SnapshotFilter::REQUIREMENT_TYPE); + $requirement->setValue(\Szurubooru\Helpers\EnumHelper::snapshotTypeFromString($type)); + $filter->addRequirement($requirement); + } + + protected function decorateFilterFromNamedToken($filter, $namedToken) + { + throw new \BadMethodCallException('Not supported'); + } + + protected function getOrderColumn($token) + { + throw new \BadMethodCallException('Not supported'); + } +} diff --git a/src/Services/HistoryService.php b/src/Services/HistoryService.php new file mode 100644 index 00000000..4466db3e --- /dev/null +++ b/src/Services/HistoryService.php @@ -0,0 +1,145 @@ +validator = $validator; + $this->snapshotDao = $snapshotDao; + $this->globalParamDao = $globalParamDao; + $this->timeService = $timeService; + $this->authService = $authService; + $this->transactionManager = $transactionManager; + } + + public function getFiltered(\Szurubooru\SearchServices\Filters\SnapshotFilter $filter) + { + $transactionFunc = function() use ($filter) + { + return $this->snapshotDao->findFiltered($filter); + }; + return $this->transactionManager->rollback($transactionFunc); + } + + public function saveSnapshot(\Szurubooru\Entities\Snapshot $snapshot) + { + $transactionFunc = function() use ($snapshot) + { + $otherSnapshots = $this->snapshotDao->findByTypeAndKey($snapshot->getType(), $snapshot->getPrimaryKey()); + if ($otherSnapshots) + { + $lastSnapshot = array_shift($otherSnapshots); + if ($lastSnapshot->getData() === $snapshot->getData()) + return $lastSnapshot; + + $dataDifference = $this->getSnapshotDataDifference($snapshot->getData(), $lastSnapshot->getData()); + $snapshot->setDataDifference($dataDifference); + } + else + { + $dataDifference = $this->getSnapshotDataDifference($snapshot->getData(), []); + $snapshot->setDataDifference($dataDifference); + } + + $snapshot->setTime($this->timeService->getCurrentTime()); + $snapshot->setUser($this->authService->getLoggedInUser()); + return $this->snapshotDao->save($snapshot); + }; + return $this->transactionManager->commit($transactionFunc); + } + + public function getPostDeleteSnapshot(\Szurubooru\Entities\Post $post) + { + $snapshot = $this->getPostSnapshot($post); + $snapshot->setOperation(\Szurubooru\Entities\Snapshot::OPERATION_DELETE); + return $snapshot; + } + + public function getPostChangeSnapshot(\Szurubooru\Entities\Post $post) + { + $featuredPostParam = $this->globalParamDao->findByKey(\Szurubooru\Entities\GlobalParam::KEY_FEATURED_POST); + $isFeatured = ($featuredPostParam and intval($featuredPostParam->getValue()) === $post->getId()); + + $data = + [ + 'source' => $post->getSource(), + 'safety' => \Szurubooru\Helpers\EnumHelper::postSafetyToString($post->getSafety()), + 'contentChecksum' => $post->getContentChecksum(), + 'featured' => $isFeatured, + + 'tags' => + array_map( + function ($tag) + { + return $tag->getName(); + }, + $post->getTags()), + + 'relations' => + array_map( + function ($post) + { + return $post->getId(); + }, + $post->getRelatedPosts()), + + ]; + + $snapshot = $this->getPostSnapshot($post); + $snapshot->setOperation(\Szurubooru\Entities\Snapshot::OPERATION_CHANGE); + $snapshot->setData($data); + return $snapshot; + } + + public function getSnapshotDataDifference($newData, $oldData) + { + $diffFunction = function($base, $other) + { + $result = []; + foreach ($base as $key => $value) + { + if (is_array($base[$key])) + { + foreach ($base[$key] as $subValue) + { + if (!isset($other[$key]) or !in_array($subValue, $other[$key])) + $result[] = [$key, $subValue]; + } + } + elseif (!isset($other[$key]) or $base[$key] !== $other[$key]) + { + $result[] = [$key, $value]; + } + } + return $result; + + }; + + return [ + '+' => $diffFunction($newData, $oldData), + '-' => $diffFunction($oldData, $newData), + ]; + } + + private function getPostSnapshot(\Szurubooru\Entities\Post $post) + { + $snapshot = new \Szurubooru\Entities\Snapshot(); + $snapshot->setType(\Szurubooru\Entities\Snapshot::TYPE_POST); + $snapshot->setPrimaryKey($post->getId()); + return $snapshot; + } +} diff --git a/src/Services/PostService.php b/src/Services/PostService.php index ab9b5538..c399009d 100644 --- a/src/Services/PostService.php +++ b/src/Services/PostService.php @@ -11,6 +11,7 @@ class PostService private $timeService; private $authService; private $fileService; + private $historyService; private $imageManipulator; public function __construct( @@ -22,6 +23,7 @@ class PostService \Szurubooru\Services\AuthService $authService, \Szurubooru\Services\TimeService $timeService, \Szurubooru\Services\FileService $fileService, + \Szurubooru\Services\HistoryService $historyService, \Szurubooru\Services\ImageManipulation\ImageManipulator $imageManipulator) { $this->config = $config; @@ -32,6 +34,7 @@ class PostService $this->timeService = $timeService; $this->authService = $authService; $this->fileService = $fileService; + $this->historyService = $historyService; $this->imageManipulator = $imageManipulator; } @@ -82,6 +85,27 @@ class PostService return $this->transactionManager->rollback($transactionFunc); } + public function getHistory(\Szurubooru\Entities\Post $post) + { + $transactionFunc = function() use ($post) + { + $filter = new \Szurubooru\SearchServices\Filters\SnapshotFilter(); + + $requirement = new \Szurubooru\SearchServices\Requirement(); + $requirement->setType(\Szurubooru\SearchServices\Filters\SnapshotFilter::REQUIREMENT_PRIMARY_KEY); + $requirement->setValue($post->getId()); + $filter->addRequirement($requirement); + + $requirement = new \Szurubooru\SearchServices\Requirement(); + $requirement->setType(\Szurubooru\SearchServices\Filters\SnapshotFilter::REQUIREMENT_TYPE); + $requirement->setValue(\Szurubooru\Entities\Snapshot::TYPE_POST); + $filter->addRequirement($requirement); + + return $this->historyService->getFiltered($filter)->getEntities(); + }; + return $this->transactionManager->rollback($transactionFunc); + } + public function createPost(\Szurubooru\FormData\UploadFormData $formData) { $transactionFunc = function() use ($formData) @@ -100,7 +124,10 @@ class PostService $this->updatePostTags($post, $formData->tags); $this->updatePostContentFromStringOrUrl($post, $formData->content, $formData->url); - return $this->postDao->save($post); + $savedPost = $this->postDao->save($post); + + $this->historyService->saveSnapshot($this->historyService->getPostChangeSnapshot($savedPost)); + return $savedPost; }; return $this->transactionManager->commit($transactionFunc); } @@ -134,6 +161,7 @@ class PostService if ($formData->relations !== null) $this->updatePostRelations($post, $formData->relations); + $this->historyService->saveSnapshot($this->historyService->getPostChangeSnapshot($post)); return $this->postDao->save($post); }; return $this->transactionManager->commit($transactionFunc); @@ -246,8 +274,10 @@ class PostService { $relatedPosts = $this->postDao->findByIds($newRelatedPostIds); foreach ($newRelatedPostIds as $postId) + { if (!isset($relatedPosts[$postId])) throw new \DomainException('Post with id "' . $postId . '" was not found.'); + } $post->setRelatedPosts($relatedPosts); } @@ -256,6 +286,7 @@ class PostService { $transactionFunc = function() use ($post) { + $this->historyService->saveSnapshot($this->historyService->getPostDeleteSnapshot($post)); $this->postDao->deleteById($post->getId()); }; $this->transactionManager->commit($transactionFunc); @@ -265,6 +296,8 @@ class PostService { $transactionFunc = function() use ($post) { + $previousFeaturedPost = $this->getFeatured(); + $post->setLastFeatureTime($this->timeService->getCurrentTime()); $post->setFeatureCount($post->getFeatureCount() + 1); $this->postDao->save($post); @@ -272,6 +305,10 @@ class PostService $globalParam->setKey(\Szurubooru\Entities\GlobalParam::KEY_FEATURED_POST); $globalParam->setValue($post->getId()); $this->globalParamDao->save($globalParam); + + if ($previousFeaturedPost) + $this->historyService->saveSnapshot($this->historyService->getPostChangeSnapshot($previousFeaturedPost)); + $this->historyService->saveSnapshot($this->historyService->getPostChangeSnapshot($post)); }; $this->transactionManager->commit($transactionFunc); } diff --git a/src/Upgrades/Upgrade08.php b/src/Upgrades/Upgrade08.php index a26c2bf2..db0d8dac 100644 --- a/src/Upgrades/Upgrade08.php +++ b/src/Upgrades/Upgrade08.php @@ -16,4 +16,3 @@ class Upgrade08 implements IUpgrade )'); } } - diff --git a/src/Upgrades/Upgrade09.php b/src/Upgrades/Upgrade09.php new file mode 100644 index 00000000..11a66b12 --- /dev/null +++ b/src/Upgrades/Upgrade09.php @@ -0,0 +1,40 @@ +postDao = $postDao; + $this->historyService = $historyService; + } + + public function run(\Szurubooru\DatabaseConnection $databaseConnection) + { + $pdo = $databaseConnection->getPDO(); + + $pdo->exec('DROP TABLE IF EXISTS snapshots'); + + $pdo->exec('CREATE TABLE snapshots + ( + id INTEGER PRIMARY KEY NOT NULL, + time TIMESTAMP NOT NULL, + type INTEGER NOT NULL, + primaryKey TEXT NOT NULL, + operation INTEGER NOT NULL, + userId INTEGER, + data BLOB, + dataDifference BLOB + )'); + + foreach ($this->postDao->findAll() as $post) + { + $this->historyService->saveSnapshot($this->historyService->getPostChangeSnapshot($post)); + } + } +} diff --git a/src/di.php b/src/di.php index 4d7a369e..f3695c17 100644 --- a/src/di.php +++ b/src/di.php @@ -24,6 +24,7 @@ return [ $container->get(\Szurubooru\Upgrades\Upgrade06::class), $container->get(\Szurubooru\Upgrades\Upgrade07::class), $container->get(\Szurubooru\Upgrades\Upgrade08::class), + $container->get(\Szurubooru\Upgrades\Upgrade09::class), ]; }), @@ -35,6 +36,7 @@ return [ $container->get(\Szurubooru\Controllers\PostController::class), $container->get(\Szurubooru\Controllers\PostContentController::class), $container->get(\Szurubooru\Controllers\GlobalParamController::class), + $container->get(\Szurubooru\Controllers\HistoryController::class), ]; }), ]; diff --git a/tests/Dao/SnapshotDaoTest.php b/tests/Dao/SnapshotDaoTest.php new file mode 100644 index 00000000..e9475dca --- /dev/null +++ b/tests/Dao/SnapshotDaoTest.php @@ -0,0 +1,55 @@ +userDaoMock = $this->mock(\Szurubooru\Dao\UserDao::class); + } + + public function testSaving() + { + $snapshot = $this->getTestSnapshot(); + $snapshotDao = $this->getSnapshotDao(); + $snapshotDao->save($snapshot); + $this->assertNotNull($snapshot->getId()); + $this->assertEntitiesEqual($snapshot, $snapshotDao->findById($snapshot->getId())); + } + + public function testUserLazyLoader() + { + $snapshot = $this->getTestSnapshot(); + $snapshot->setUser(new \Szurubooru\Entities\User(5)); + $this->assertEquals(5, $snapshot->getUserId()); + $snapshotDao = $this->getSnapshotDao(); + $snapshotDao->save($snapshot); + $savedSnapshot = $snapshotDao->findById($snapshot->getId()); + $this->assertEquals(5, $savedSnapshot->getUserId()); + + $this->userDaoMock + ->expects($this->once()) + ->method('findById'); + $savedSnapshot->getUser(); + } + + private function getTestSnapshot() + { + $snapshot = new \Szurubooru\Entities\Snapshot(); + $snapshot->setType(\Szurubooru\Entities\Snapshot::TYPE_POST); + $snapshot->setData(['wake up', 'neo', ['follow' => 'white rabbit']]); + $snapshot->setPrimaryKey(1); + $snapshot->setTime('whateveer'); + $snapshot->setUserId(null); + $snapshot->setOperation(\Szurubooru\Entities\Snapshot::OPERATION_CHANGE); + return $snapshot; + } + + private function getSnapshotDao() + { + return new \Szurubooru\Dao\SnapshotDao( + $this->databaseConnection, + $this->userDaoMock); + } +} diff --git a/tests/Services/HistoryServiceTest.php b/tests/Services/HistoryServiceTest.php new file mode 100644 index 00000000..7fe15341 --- /dev/null +++ b/tests/Services/HistoryServiceTest.php @@ -0,0 +1,183 @@ + [], '-' => []] + ]; + + yield + [ + ['key' => 'unchangedValue'], + ['key' => 'unchangedValue'], + ['+' => [], '-' => []] + ]; + + yield + [ + ['key' => 'newValue'], + [], + [ + '+' => [['key', 'newValue']], + '-' => [] + ] + ]; + + yield + [ + [], + ['key' => 'deletedValue'], + [ + '+' => [], + '-' => [['key', 'deletedValue']] + ] + ]; + + yield + [ + ['key' => 'changedValue'], + ['key' => 'oldValue'], + [ + '+' => [['key', 'changedValue']], + '-' => [['key', 'oldValue']] + ] + ]; + + yield + [ + ['key' => []], + ['key' => []], + [ + '+' => [], + '-' => [] + ] + ]; + + yield + [ + ['key' => ['newArrayElement']], + ['key' => []], + [ + '+' => [['key', 'newArrayElement']], + '-' => [] + ] + ]; + + yield + [ + ['key' => []], + ['key' => ['removedArrayElement']], + [ + '+' => [], + '-' => [['key', 'removedArrayElement']] + ] + ]; + + yield + [ + ['key' => ['unchangedValue', 'newValue']], + ['key' => ['unchangedValue', 'oldValue']], + [ + '+' => [['key', 'newValue']], + '-' => [['key', 'oldValue']] + ] + ]; + } + + public function setUp() + { + parent::setUp(); + $this->validatorMock = $this->mock(\Szurubooru\Validator::class); + $this->snapshotDaoMock = $this->mock(\Szurubooru\Dao\SnapshotDao::class); + $this->globalParamDaoMock = $this->mock(\Szurubooru\Dao\GlobalParamDao::class); + $this->transactionManagerMock = $this->mock(\Szurubooru\Dao\TransactionManager::class); + $this->timeServiceMock = $this->mock(\Szurubooru\Services\TimeService::class); + $this->authServiceMock = $this->mock(\Szurubooru\Services\AuthService::class); + } + + public function testPostChangeSnapshot() + { + $tag1 = new \Szurubooru\Entities\Tag(); + $tag2 = new \Szurubooru\Entities\Tag(); + $tag1->setName('tag1'); + $tag2->setName('tag2'); + $post1 = new \Szurubooru\Entities\Post(1); + $post2 = new \Szurubooru\Entities\Post(2); + + $post = new \Szurubooru\Entities\Post(5); + $post->setTags([$tag1, $tag2]); + $post->setRelatedPosts([$post1, $post2]); + $post->setContentChecksum('checksum'); + $post->setSafety(\Szurubooru\Entities\Post::POST_SAFETY_SKETCHY); + $post->setSource('amazing source'); + + $historyService = $this->getHistoryService(); + $snapshot = $historyService->getPostChangeSnapshot($post); + + $this->assertEquals([ + 'source' => 'amazing source', + 'safety' => 'sketchy', + 'contentChecksum' => 'checksum', + 'featured' => false, + 'tags' => ['tag1', 'tag2'], + 'relations' => [1, 2] + ], $snapshot->getData()); + + $this->assertEquals(\Szurubooru\Entities\Snapshot::TYPE_POST, $snapshot->getType()); + $this->assertEquals(5, $snapshot->getPrimaryKey()); + + return $post; + } + + /** + * @depends testPostChangeSnapshot + */ + public function testPostChangeSnapshotFeature($post) + { + $param = new \Szurubooru\Entities\GlobalParam; + $param->setValue($post->getId()); + $this->globalParamDaoMock + ->expects($this->once()) + ->method('findByKey') + ->with(\Szurubooru\Entities\GlobalParam::KEY_FEATURED_POST) + ->willReturn($param); + + $historyService = $this->getHistoryService(); + $snapshot = $historyService->getPostChangeSnapshot($post); + + $this->assertTrue($snapshot->getData()['featured']); + } + + /** + * @dataProvider snapshotDataDifferenceProvider + */ + public function testSnapshotDataDifference($newData, $oldData, $expectedResult) + { + $historyService = $this->getHistoryService(); + $this->assertEquals($expectedResult, $historyService->getSnapshotDataDifference($newData, $oldData)); + } + + private function getHistoryService() + { + return new \Szurubooru\Services\HistoryService( + $this->validatorMock, + $this->snapshotDaoMock, + $this->globalParamDaoMock, + $this->transactionManagerMock, + $this->timeServiceMock, + $this->authServiceMock); + } +} diff --git a/tests/Services/PostServiceTest.php b/tests/Services/PostServiceTest.php index 2251b1c7..f1047061 100644 --- a/tests/Services/PostServiceTest.php +++ b/tests/Services/PostServiceTest.php @@ -11,6 +11,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase private $authServiceMock; private $timeServiceMock; private $fileServiceMock; + private $historyServiceMock; private $imageManipulatorMock; public function setUp() @@ -23,6 +24,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $this->authServiceMock = $this->mock(\Szurubooru\Services\AuthService::class); $this->timeServiceMock = $this->mock(\Szurubooru\Services\TimeService::class); $this->fileServiceMock = $this->mock(\Szurubooru\Services\FileService::class); + $this->historyServiceMock = $this->mock(\Szurubooru\Services\HistoryService::class); $this->configMock->set('database/maxPostSize', 1000000); $this->imageManipulatorMock = $this->mock(\Szurubooru\Services\ImageManipulation\ImageManipulator::class); } @@ -37,6 +39,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $this->postDaoMock->expects($this->once())->method('save')->will($this->returnArgument(0)); $this->authServiceMock->expects($this->once())->method('getLoggedInUser')->willReturn(new \Szurubooru\Entities\User(5)); + $this->historyServiceMock->expects($this->once())->method('getPostChangeSnapshot')->willReturn(new \Szurubooru\Entities\Snapshot()); $this->postService = $this->getPostService(); $savedPost = $this->postService->createPost($formData); @@ -65,6 +68,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $this->postDaoMock->expects($this->once())->method('save')->will($this->returnArgument(0)); $this->imageManipulatorMock->expects($this->once())->method('getImageWidth')->willReturn(640); $this->imageManipulatorMock->expects($this->once())->method('getImageHeight')->willReturn(480); + $this->historyServiceMock->expects($this->once())->method('getPostChangeSnapshot')->willReturn(new \Szurubooru\Entities\Snapshot()); $this->postService = $this->getPostService(); $savedPost = $this->postService->createPost($formData); @@ -85,6 +89,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $formData->contentFileName = 'blah'; $this->postDaoMock->expects($this->once())->method('save')->will($this->returnArgument(0)); + $this->historyServiceMock->expects($this->once())->method('getPostChangeSnapshot')->willReturn(new \Szurubooru\Entities\Snapshot()); $this->postService = $this->getPostService(); $savedPost = $this->postService->createPost($formData); @@ -103,6 +108,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $formData->contentFileName = 'blah'; $this->postDaoMock->expects($this->once())->method('save')->will($this->returnArgument(0)); + $this->historyServiceMock->expects($this->once())->method('getPostChangeSnapshot')->willReturn(new \Szurubooru\Entities\Snapshot()); $this->postService = $this->getPostService(); $savedPost = $this->postService->createPost($formData); @@ -165,6 +171,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $this->postDaoMock->expects($this->once())->method('save')->will($this->returnArgument(0)); $this->authServiceMock->expects($this->never())->method('getLoggedInUser'); + $this->historyServiceMock->expects($this->once())->method('getPostChangeSnapshot')->willReturn(new \Szurubooru\Entities\Snapshot()); $this->postService = $this->getPostService(); $savedPost = $this->postService->createPost($formData); @@ -182,6 +189,7 @@ class PostServiceTest extends \Szurubooru\Tests\AbstractTestCase $this->authServiceMock, $this->timeServiceMock, $this->fileServiceMock, + $this->historyServiceMock, $this->imageManipulatorMock); } }