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' %>
+
+ <% 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' %>
+
+ <% 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);
}
}