diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 19e4744ea..04065996e 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -107,6 +107,17 @@ class PageController extends Controller return view('pages/show', ['page' => $page, 'book' => $book, 'current' => $page, 'sidebarTree' => $sidebarTree]); } + /** + * Get page from an ajax request. + * @param $pageId + * @return \Illuminate\Http\JsonResponse + */ + public function getPageAjax($pageId) + { + $page = $this->pageRepo->getById($pageId); + return response()->json($page); + } + /** * Show the form for editing the specified page. * @param $bookSlug @@ -119,6 +130,24 @@ class PageController extends Controller $page = $this->pageRepo->getBySlug($pageSlug, $book->id); $this->checkOwnablePermission('page-update', $page); $this->setPageTitle('Editing Page ' . $page->getShortName()); + $page->isDraft = false; + + // Check for active editing and drafts + $warnings = []; + if ($this->pageRepo->isPageEditingActive($page, 60)) { + $warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60); + } + + if ($this->pageRepo->hasUserGotPageDraft($page, $this->currentUser->id)) { + $draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id); + $page->name = $draft->name; + $page->html = $draft->html; + $page->isDraft = true; + $warnings [] = $this->pageRepo->getUserPageDraftMessage($draft); + } + + if (count($warnings) > 0) session()->flash('warning', implode("\n", $warnings)); + return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]); } @@ -142,6 +171,24 @@ class PageController extends Controller return redirect($page->getUrl()); } + /** + * Save a draft update as a revision. + * @param Request $request + * @param $pageId + * @return \Illuminate\Http\JsonResponse + */ + public function saveUpdateDraft(Request $request, $pageId) + { + $this->validate($request, [ + 'name' => 'required|string|max:255' + ]); + $page = $this->pageRepo->getById($pageId); + $this->checkOwnablePermission('page-update', $page); + $draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); + $updateTime = $draft->updated_at->format('H:i'); + return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]); + } + /** * Redirect from a special link url which * uses the page id rather than the name. diff --git a/app/Http/routes.php b/app/Http/routes.php index 81bbb16bc..48765be88 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -75,6 +75,10 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/{imageId}', 'ImageController@destroy'); }); + // Ajax routes + Route::put('/ajax/page/{id}/save-draft', 'PageController@saveUpdateDraft'); + Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); + // Links Route::get('/link/{id}', 'PageController@redirectFromLink'); diff --git a/app/Page.php b/app/Page.php index 53724ec20..34dee2f2f 100644 --- a/app/Page.php +++ b/app/Page.php @@ -34,7 +34,7 @@ class Page extends Entity public function revisions() { - return $this->hasMany('BookStack\PageRevision')->orderBy('created_at', 'desc'); + return $this->hasMany('BookStack\PageRevision')->where('type', '=', 'version')->orderBy('created_at', 'desc'); } public function getUrl() diff --git a/app/PageRevision.php b/app/PageRevision.php index 52c37e390..f1b4bc587 100644 --- a/app/PageRevision.php +++ b/app/PageRevision.php @@ -1,6 +1,4 @@ -belongsTo('BookStack\User', 'created_by'); } + /** + * Get the page this revision originates from. + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function page() { return $this->belongsTo('BookStack\Page'); } + /** + * Get the url for this revision. + * @return string + */ public function getUrl() { return $this->page->getUrl() . '/revisions/' . $this->id; diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 4784ad407..776d1eadf 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -4,6 +4,8 @@ use Activity; use BookStack\Book; use BookStack\Exceptions\NotFoundException; +use Carbon\Carbon; +use DOMDocument; use Illuminate\Support\Str; use BookStack\Page; use BookStack\PageRevision; @@ -66,9 +68,10 @@ class PageRepo extends EntityRepo public function findPageUsingOldSlug($pageSlug, $bookSlug) { $revision = $this->pageRevision->where('slug', '=', $pageSlug) - ->whereHas('page', function($query) { + ->whereHas('page', function ($query) { $this->restrictionService->enforcePageRestrictions($query); }) + ->where('type', '=', 'version') ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc') ->with('page')->first(); return $revision !== null ? $revision->page : null; @@ -100,8 +103,8 @@ class PageRepo extends EntityRepo * Save a new page into the system. * Input validation must be done beforehand. * @param array $input - * @param Book $book - * @param int $chapterId + * @param Book $book + * @param int $chapterId * @return Page */ public function saveNew(array $input, Book $book, $chapterId = null) @@ -128,9 +131,9 @@ class PageRepo extends EntityRepo */ protected function formatHtml($htmlText) { - if($htmlText == '') return $htmlText; + if ($htmlText == '') return $htmlText; libxml_use_internal_errors(true); - $doc = new \DOMDocument(); + $doc = new DOMDocument(); $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); $container = $doc->documentElement; @@ -239,8 +242,8 @@ class PageRepo extends EntityRepo /** * Updates a page with any fillable data and saves it into the database. - * @param Page $page - * @param int $book_id + * @param Page $page + * @param int $book_id * @param string $input * @return Page */ @@ -257,11 +260,16 @@ class PageRepo extends EntityRepo } // Update with new details + $userId = auth()->user()->id; $page->fill($input); $page->html = $this->formatHtml($input['html']); $page->text = strip_tags($page->html); - $page->updated_by = auth()->user()->id; + $page->updated_by = $userId; $page->save(); + + // Remove all update drafts for this user & page. + $this->userUpdateDraftsQuery($page, $userId)->delete(); + return $page; } @@ -297,6 +305,7 @@ class PageRepo extends EntityRepo $revision->book_slug = $page->book->slug; $revision->created_by = auth()->user()->id; $revision->created_at = $page->updated_at; + $revision->type = 'version'; $revision->save(); // Clear old revisions if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { @@ -306,6 +315,134 @@ class PageRepo extends EntityRepo return $revision; } + /** + * Save a page update draft. + * @param Page $page + * @param array $data + * @return PageRevision + */ + public function saveUpdateDraft(Page $page, $data = []) + { + $userId = auth()->user()->id; + $drafts = $this->userUpdateDraftsQuery($page, $userId)->get(); + + if ($drafts->count() > 0) { + $draft = $drafts->first(); + } else { + $draft = $this->pageRevision->newInstance(); + $draft->page_id = $page->id; + $draft->slug = $page->slug; + $draft->book_slug = $page->book->slug; + $draft->created_by = $userId; + $draft->type = 'update_draft'; + } + + $draft->fill($data); + $draft->save(); + return $draft; + } + + /** + * The base query for getting user update drafts. + * @param Page $page + * @param $userId + * @return mixed + */ + private function userUpdateDraftsQuery(Page $page, $userId) + { + return $this->pageRevision->where('created_by', '=', $userId) + ->where('type', 'update_draft') + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc'); + } + + /** + * Checks whether a user has a draft version of a particular page or not. + * @param Page $page + * @param $userId + * @return bool + */ + public function hasUserGotPageDraft(Page $page, $userId) + { + return $this->userUpdateDraftsQuery($page, $userId)->count() > 0; + } + + /** + * Get the latest updated draft revision for a particular page and user. + * @param Page $page + * @param $userId + * @return mixed + */ + public function getUserPageDraft(Page $page, $userId) + { + return $this->userUpdateDraftsQuery($page, $userId)->first(); + } + + /** + * Get the notification message that informs the user that they are editing a draft page. + * @param PageRevision $draft + * @return string + */ + public function getUserPageDraftMessage(PageRevision $draft) + { + $message = 'You are currently editing a draft that was last saved ' . $draft->updated_at->diffForHumans() . '.'; + if ($draft->page->updated_at->timestamp > $draft->updated_at->timestamp) { + $message .= "\n This page has been updated by since that time. It is recommended that you discard this draft."; + } + return $message; + } + + /** + * Check if a page is being actively editing. + * Checks for edits since last page updated. + * Passing in a minuted range will check for edits + * within the last x minutes. + * @param Page $page + * @param null $minRange + * @return bool + */ + public function isPageEditingActive(Page $page, $minRange = null) + { + $draftSearch = $this->activePageEditingQuery($page, $minRange); + return $draftSearch->count() > 0; + } + + /** + * Get a notification message concerning the editing activity on + * a particular page. + * @param Page $page + * @param null $minRange + * @return string + */ + public function getPageEditingActiveMessage(Page $page, $minRange = null) + { + $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get(); + $userMessage = $pageDraftEdits->count() > 1 ? $pageDraftEdits->count() . ' users have' : $pageDraftEdits->first()->createdBy->name . ' has'; + $timeMessage = $minRange === null ? 'since the page was last updated' : 'in the last ' . $minRange . ' minutes'; + $message = '%s started editing this page %s. Take care not to overwrite each other\'s updates!'; + return sprintf($message, $userMessage, $timeMessage); + } + + /** + * A query to check for active update drafts on a particular page. + * @param Page $page + * @param null $minRange + * @return mixed + */ + private function activePageEditingQuery(Page $page, $minRange = null) + { + $query = $this->pageRevision->where('type', '=', 'update_draft') + ->where('updated_at', '>', $page->updated_at) + ->where('created_by', '!=', auth()->user()->id) + ->with('createdBy'); + + if ($minRange !== null) { + $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange)); + } + + return $query; + } + /** * Gets a single revision via it's id. * @param $id @@ -333,7 +470,7 @@ class PageRepo extends EntityRepo /** * Changes the related book for the specified page. * Changes the book id of any relations to the page that store the book id. - * @param int $bookId + * @param int $bookId * @param Page $page * @return Page */ diff --git a/database/migrations/2016_03_09_203143_add_page_revision_types.php b/database/migrations/2016_03_09_203143_add_page_revision_types.php new file mode 100644 index 000000000..e39c77d18 --- /dev/null +++ b/database/migrations/2016_03_09_203143_add_page_revision_types.php @@ -0,0 +1,32 @@ +string('type')->default('version'); + $table->index('type'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('page_revisions', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +} diff --git a/public/uploads/.gitignore b/public/uploads/.gitignore old mode 100644 new mode 100755 diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 1f7388859..76b8cc67d 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -213,4 +213,85 @@ module.exports = function (ngApp, events) { }]); + ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', function ($scope, $http, $attrs, $interval, $timeout) { + + $scope.editorOptions = require('./pages/page-form'); + $scope.editorHtml = ''; + $scope.draftText = ''; + var pageId = Number($attrs.pageId); + var isEdit = pageId !== 0; + var autosaveFrequency = 30; // AutoSave interval in seconds. + $scope.isDraft = Number($attrs.pageDraft) === 1; + if ($scope.isDraft) $scope.draftText = 'Editing Draft'; + + var autoSave = false; + + var currentContent = { + title: false, + html: false + }; + + if (isEdit) { + setTimeout(() => { + startAutoSave(); + }, 1000); + } + + $scope.editorChange = function () {} + + /** + * Start the AutoSave loop, Checks for content change + * before performing the costly AJAX request. + */ + function startAutoSave() { + currentContent.title = $('#name').val(); + currentContent.html = $scope.editorHtml; + + autoSave = $interval(() => { + var newTitle = $('#name').val(); + var newHtml = $scope.editorHtml; + + if (newTitle !== currentContent.title || newHtml !== currentContent.html) { + currentContent.html = newHtml; + currentContent.title = newTitle; + saveDraftUpdate(newTitle, newHtml); + } + }, 1000 * autosaveFrequency); + } + + /** + * Save a draft update into the system via an AJAX request. + * @param title + * @param html + */ + function saveDraftUpdate(title, html) { + $http.put('/ajax/page/' + pageId + '/save-draft', { + name: title, + html: html + }).then((responseData) => { + $scope.draftText = responseData.data.message; + $scope.isDraft = true; + }); + } + + /** + * Discard the current draft and grab the current page + * content from the system via an AJAX request. + */ + $scope.discardDraft = function () { + $http.get('/ajax/page/' + pageId).then((responseData) => { + if (autoSave) $interval.cancel(autoSave); + $scope.draftText = ''; + $scope.isDraft = false; + $scope.$broadcast('html-update', responseData.data.html); + $('#name').val(currentContent.title); + $timeout(() => { + startAutoSave(); + }, 1000); + events.emit('success', 'Draft discarded, The editor has been updated with the current page content'); + }); + }; + + }]); + }; \ No newline at end of file diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 60abde6e9..72d35d455 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -162,5 +162,42 @@ module.exports = function (ngApp, events) { }; }]); + ngApp.directive('tinymce', ['$timeout', function($timeout) { + return { + restrict: 'A', + scope: { + tinymce: '=', + mceModel: '=', + mceChange: '=' + }, + link: function (scope, element, attrs) { + + function tinyMceSetup(editor) { + editor.on('ExecCommand change NodeChange ObjectResized', (e) => { + var content = editor.getContent(); + $timeout(() => { + scope.mceModel = content; + }); + scope.mceChange(content); + }); + + editor.on('init', (e) => { + scope.mceModel = editor.getContent(); + }); + + scope.$on('html-update', (event, value) => { + editor.setContent(value); + editor.selection.select(editor.getBody(), true); + editor.selection.collapse(false); + scope.mceModel = editor.getContent(); + }); + } + + scope.tinymce.extraSetups.push(tinyMceSetup); + tinymce.init(scope.tinymce); + } + } + }]) + }; \ No newline at end of file diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 5400a8af0..9e2b3b8ea 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -54,10 +54,10 @@ $.expr[":"].contains = $.expr.createPseudo(function (arg) { // Global jQuery Elements $(function () { - var notifications = $('.notification'); var successNotification = notifications.filter('.pos'); var errorNotification = notifications.filter('.neg'); + var warningNotification = notifications.filter('.warning'); // Notification Events window.Events.listen('success', function (text) { successNotification.hide(); @@ -66,6 +66,10 @@ $(function () { successNotification.show(); }, 1); }); + window.Events.listen('warning', function (text) { + warningNotification.find('span').text(text); + warningNotification.show(); + }); window.Events.listen('error', function (text) { errorNotification.find('span').text(text); errorNotification.show(); @@ -119,11 +123,5 @@ function elemExists(selector) { return document.querySelector(selector) !== null; } -// TinyMCE editor -if (elemExists('#html-editor')) { - var tinyMceOptions = require('./pages/page-form'); - tinymce.init(tinyMceOptions); -} - // Page specific items -require('./pages/page-show'); \ No newline at end of file +require('./pages/page-show'); diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index 290b7c653..c6787ba87 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -1,4 +1,4 @@ -module.exports = { +var mceOptions = module.exports = { selector: '#html-editor', content_css: [ '/css/styles.css' @@ -51,8 +51,13 @@ module.exports = { args.content = ''; } }, + extraSetups: [], setup: function (editor) { + for (var i = 0; i < mceOptions.extraSetups.length; i++) { + mceOptions.extraSetups[i](editor); + } + (function () { var wrap; diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index 87aa20046..8fed6aef7 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -161,6 +161,12 @@ form.search-box { } } +.faded span.faded-text { + display: inline-block; + padding: $-s; + opacity: 0.5; +} + .faded-small { color: #000; font-size: 0.9em; @@ -183,6 +189,9 @@ form.search-box { padding-left: 0; } } + &.text-center { + text-align: center; + } } .setting-nav { diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss index 874515bfd..cb6cec9c1 100644 --- a/resources/assets/sass/_variables.scss +++ b/resources/assets/sass/_variables.scss @@ -38,6 +38,7 @@ $primary-dark: #0288D1; $secondary: #e27b41; $positive: #52A256; $negative: #E84F4F; +$warning: $secondary; $primary-faded: rgba(21, 101, 192, 0.15); // Item Colors diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 9c4a4dafc..7c7821242 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -88,6 +88,10 @@ body.dragging, body.dragging * { background-color: $negative; color: #EEE; } + &.warning { + background-color: $secondary; + color: #EEE; + } } // Loading icon diff --git a/resources/views/pages/create.blade.php b/resources/views/pages/create.blade.php index 69c5f7c94..441379eae 100644 --- a/resources/views/pages/create.blade.php +++ b/resources/views/pages/create.blade.php @@ -8,7 +8,7 @@ @section('content') -
+
@include('pages/form') @if($chapter) diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 6dde47c63..0832f63b4 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -8,8 +8,8 @@ @section('content') -
- +
+ @include('pages/form', ['model' => $page]) diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index f1f54d97f..a5beabacf 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,7 +1,7 @@ -
+
{{ csrf_field() }}
@@ -9,12 +9,16 @@
-
-
- Cancel +
+ +
+
+
+
@@ -22,13 +26,13 @@
-
+
@include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
- @if($errors->has('html'))
{{ $errors->first('html') }}
diff --git a/resources/views/pages/revisions.blade.php b/resources/views/pages/revisions.blade.php index e3782ef6e..a73f16a4f 100644 --- a/resources/views/pages/revisions.blade.php +++ b/resources/views/pages/revisions.blade.php @@ -24,10 +24,10 @@ - - - - + + + + @foreach($page->revisions as $revision) @@ -38,7 +38,7 @@ @endif - +
NameCreated ByRevision DateActionsNameCreated ByRevision DateActions
@if($revision->createdBy) {{$revision->createdBy->name}} @else Deleted User @endif{{$revision->created_at->format('jS F, Y H:i:s')}} ({{$revision->created_at->diffForHumans()}}){{$revision->created_at->format('jS F, Y H:i:s')}}
({{$revision->created_at->diffForHumans()}})
Preview  |  diff --git a/resources/views/partials/notifications.blade.php b/resources/views/partials/notifications.blade.php index 8cc0774c9..183934c66 100644 --- a/resources/views/partials/notifications.blade.php +++ b/resources/views/partials/notifications.blade.php @@ -1,8 +1,12 @@ + + diff --git a/tests/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php similarity index 100% rename from tests/EntitySearchTest.php rename to tests/Entity/EntitySearchTest.php diff --git a/tests/EntityTest.php b/tests/Entity/EntityTest.php similarity index 100% rename from tests/EntityTest.php rename to tests/Entity/EntityTest.php diff --git a/tests/Entity/PageUpdateDraftTest.php b/tests/Entity/PageUpdateDraftTest.php new file mode 100644 index 000000000..d321974db --- /dev/null +++ b/tests/Entity/PageUpdateDraftTest.php @@ -0,0 +1,62 @@ +page = \BookStack\Page::first(); + $this->pageRepo = app('\BookStack\Repos\PageRepo'); + } + + public function test_draft_content_shows_if_available() + { + $addedContent = '

test message content

'; + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->dontSeeInField('html', $addedContent); + + $newContent = $this->page->html . $addedContent; + $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->seeInField('html', $newContent); + } + + public function test_draft_not_visible_by_others() + { + $addedContent = '

test message content

'; + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->dontSeeInField('html', $addedContent); + + $newContent = $this->page->html . $addedContent; + $newUser = $this->getNewUser(); + $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); + $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') + ->dontSeeInField('html', $newContent); + } + + public function test_alert_message_shows_if_editing_draft() + { + $this->asAdmin(); + $this->pageRepo->saveUpdateDraft($this->page, ['html' => 'test content']); + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->see('You are currently editing a draft'); + } + + public function test_alert_message_shows_if_someone_else_editing() + { + $addedContent = '

test message content

'; + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->dontSeeInField('html', $addedContent); + + $newContent = $this->page->html . $addedContent; + $newUser = $this->getNewUser(); + $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); + $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') + ->see('Admin has started editing this page'); + } + +} diff --git a/tests/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php similarity index 100% rename from tests/RestrictionsTest.php rename to tests/Permissions/RestrictionsTest.php diff --git a/tests/RolesTest.php b/tests/Permissions/RolesTest.php similarity index 100% rename from tests/RolesTest.php rename to tests/Permissions/RolesTest.php