diff --git a/app/Comment.php b/app/Comment.php index de01b6212..2800ab21a 100644 --- a/app/Comment.php +++ b/app/Comment.php @@ -1,12 +1,10 @@ -belongsTo(Page::class); + return $this->updated_at->timestamp > $this->created_at->timestamp; } /** - * Get the owner of this comment. - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * Get created date as a relative diff. + * @return mixed */ - public function user() + public function getCreatedAttribute() { - return $this->belongsTo(User::class); + return $this->created_at->diffForHumans(); } - /* - * Not being used, but left here because might be used in the future for performance reasons. + /** + * Get updated date as a relative diff. + * @return mixed */ - public function getPageComments($pageId) { - $query = static::newQuery(); - $query->join('users AS u', 'comments.created_by', '=', 'u.id'); - $query->leftJoin('users AS u1', 'comments.updated_by', '=', 'u1.id'); - $query->leftJoin('images AS i', 'i.id', '=', 'u.image_id'); - $query->selectRaw('comments.id, text, html, comments.created_by, comments.updated_by, ' - . 'comments.created_at, comments.updated_at, comments.parent_id, ' - . 'u.name AS created_by_name, u1.name AS updated_by_name, ' - . 'i.url AS avatar '); - $query->whereRaw('page_id = ?', [$pageId]); - $query->orderBy('created_at'); - return $query->get(); - } - - public function getAllPageComments($pageId) { - return self::where('page_id', '=', $pageId)->with(['createdBy' => function($query) { - $query->select('id', 'name', 'image_id'); - }, 'updatedBy' => function($query) { - $query->select('id', 'name'); - }, 'createdBy.avatar' => function ($query) { - $query->select('id', 'path', 'url'); - }])->get(); - } - - public function getCommentById($commentId) { - return self::where('id', '=', $commentId)->with(['createdBy' => function($query) { - $query->select('id', 'name', 'image_id'); - }, 'updatedBy' => function($query) { - $query->select('id', 'name'); - }, 'createdBy.avatar' => function ($query) { - $query->select('id', 'path', 'url'); - }])->first(); - } - - public function getCreatedAttribute() { - $created = [ - 'day_time_str' => $this->created_at->toDayDateTimeString(), - 'diff' => $this->created_at->diffForHumans() - ]; - return $created; - } - - public function getUpdatedAttribute() { - if (empty($this->updated_at)) { - return null; - } - $updated = [ - 'day_time_str' => $this->updated_at->toDayDateTimeString(), - 'diff' => $this->updated_at->diffForHumans() - ]; - return $updated; - } - - public function getSubCommentsAttribute() { - return $this->sub_comments; + public function getUpdatedAttribute() + { + return $this->updated_at->diffForHumans(); } } diff --git a/app/Entity.php b/app/Entity.php index e5dd04bf2..efbbf0eba 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -1,6 +1,8 @@ morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); } + /** + * Get the comments for an entity + * @return \Illuminate\Database\Eloquent\Relations\MorphMany + */ + public function comments() + { + return $this->morphMany(Comment::class, 'entity')->orderBy('created_at', 'asc'); + } + + /** * Get the related search terms. * @return \Illuminate\Database\Eloquent\Relations\MorphMany diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index e8d5eab30..384731f84 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -2,22 +2,34 @@ use BookStack\Repos\CommentRepo; use BookStack\Repos\EntityRepo; -use BookStack\Comment; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; class CommentController extends Controller { protected $entityRepo; + protected $commentRepo; - public function __construct(EntityRepo $entityRepo, CommentRepo $commentRepo, Comment $comment) + /** + * CommentController constructor. + * @param EntityRepo $entityRepo + * @param CommentRepo $commentRepo + */ + public function __construct(EntityRepo $entityRepo, CommentRepo $commentRepo) { $this->entityRepo = $entityRepo; $this->commentRepo = $commentRepo; - $this->comment = $comment; parent::__construct(); } - public function save(Request $request, $pageId, $commentId = null) + /** + * Save a new comment for a Page + * @param Request $request + * @param integer $pageId + * @param null|integer $commentId + * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response + */ + public function savePageComment(Request $request, $pageId, $commentId = null) { $this->validate($request, [ 'text' => 'required|string', @@ -30,70 +42,50 @@ class CommentController extends Controller return response('Not found', 404); } - if($page->draft) { - // cannot add comments to drafts. - return response()->json([ - 'status' => 'error', - 'message' => trans('errors.cannot_add_comment_to_draft'), - ], 400); - } - $this->checkOwnablePermission('page-view', $page); - if (empty($commentId)) { - // create a new comment. - $this->checkPermission('comment-create-all'); - $comment = $this->commentRepo->create($page, $request->only(['text', 'html', 'parent_id'])); - $respMsg = trans('entities.comment_created'); - } else { - // update existing comment - // get comment by ID and check if this user has permission to update. - $comment = $this->comment->findOrFail($commentId); - $this->checkOwnablePermission('comment-update', $comment); - $this->commentRepo->update($comment, $request->all()); - $respMsg = trans('entities.comment_updated'); + + // Prevent adding comments to draft pages + if ($page->draft) { + return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400); } - $comment = $this->commentRepo->getCommentById($comment->id); - - return response()->json([ - 'status' => 'success', - 'message' => $respMsg, - 'comment' => $comment - ]); - + // Create a new comment. + $this->checkPermission('comment-create-all'); + $comment = $this->commentRepo->create($page, $request->all()); + return view('comments/comment', ['comment' => $comment]); } - public function destroy($id) { - $comment = $this->comment->findOrFail($id); + /** + * Update an existing comment. + * @param Request $request + * @param integer $commentId + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function update(Request $request, $commentId) + { + $this->validate($request, [ + 'text' => 'required|string', + 'html' => 'required|string', + ]); + + $comment = $this->commentRepo->getById($commentId); + $this->checkOwnablePermission('page-view', $comment->entity); + $this->checkOwnablePermission('comment-update', $comment); + + $comment = $this->commentRepo->update($comment, $request->all()); + return view('comments/comment', ['comment' => $comment]); + } + + /** + * Delete a comment from the system. + * @param integer $id + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + $comment = $this->commentRepo->getById($id); $this->checkOwnablePermission('comment-delete', $comment); $this->commentRepo->delete($comment); - $updatedComment = $this->commentRepo->getCommentById($comment->id); - - return response()->json([ - 'status' => 'success', - 'message' => trans('entities.comment_deleted'), - 'comment' => $updatedComment - ]); - } - - - public function getPageComments($pageId) { - try { - $page = $this->entityRepo->getById('page', $pageId, true); - } catch (ModelNotFoundException $e) { - return response('Not found', 404); - } - - $this->checkOwnablePermission('page-view', $page); - - $comments = $this->commentRepo->getPageComments($pageId); - return response()->json(['status' => 'success', 'comments'=> $comments['comments'], - 'total' => $comments['total'], 'permissions' => [ - 'comment_create' => $this->currentUser->can('comment-create-all'), - 'comment_update_own' => $this->currentUser->can('comment-update-own'), - 'comment_update_all' => $this->currentUser->can('comment-update-all'), - 'comment_delete_all' => $this->currentUser->can('comment-delete-all'), - 'comment_delete_own' => $this->currentUser->can('comment-delete-own'), - ], 'user_id' => $this->currentUser->id]); + return response()->json(['message' => trans('entities.comment_deleted')]); } } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 21572db29..c3090af83 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -161,6 +161,7 @@ class PageController extends Controller $pageContent = $this->entityRepo->renderPage($page); $sidebarTree = $this->entityRepo->getBookChildren($page->book); $pageNav = $this->entityRepo->getPageNav($pageContent); + $page->load(['comments.createdBy']); Views::add($page); $this->setPageTitle($page->getShortName()); diff --git a/app/Page.php b/app/Page.php index d722e4e54..c9823e7e4 100644 --- a/app/Page.php +++ b/app/Page.php @@ -66,10 +66,6 @@ class Page extends Entity return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); } - public function comments() { - return $this->hasMany(Comment::class, 'page_id')->orderBy('created_on', 'asc'); - } - /** * Get the url for this page. * @param string|bool $path diff --git a/app/Repos/CommentRepo.php b/app/Repos/CommentRepo.php index ce71b9234..c3d7468cf 100644 --- a/app/Repos/CommentRepo.php +++ b/app/Repos/CommentRepo.php @@ -1,105 +1,87 @@ comment = $comment; } - public function create (Page $page, $data = []) { + /** + * Get a comment by ID. + * @param $id + * @return Comment|\Illuminate\Database\Eloquent\Model + */ + public function getById($id) + { + return $this->comment->newQuery()->findOrFail($id); + } + + /** + * Create a new comment on an entity. + * @param Entity $entity + * @param array $data + * @return Comment + */ + public function create (Entity $entity, $data = []) + { $userId = user()->id; - $comment = $this->comment->newInstance(); - $comment->fill($data); - // new comment - $comment->page_id = $page->id; + $comment = $this->comment->newInstance($data); $comment->created_by = $userId; - $comment->updated_at = null; - $comment->save(); - return $comment; - } - - public function update($comment, $input, $activeOnly = true) { - $userId = user()->id; $comment->updated_by = $userId; - $comment->fill($input); - - // only update active comments by default. - $whereClause = ['active' => 1]; - if (!$activeOnly) { - $whereClause = []; - } - $comment->update($whereClause); + $comment->local_id = $this->getNextLocalId($entity); + $entity->comments()->save($comment); return $comment; } - public function delete($comment) { - $comment->text = trans('entities.comment_deleted'); - $comment->html = trans('entities.comment_deleted'); - $comment->active = false; - $userId = user()->id; - $comment->updated_by = $userId; - $comment->save(); + /** + * Update an existing comment. + * @param Comment $comment + * @param array $input + * @return mixed + */ + public function update($comment, $input) + { + $comment->updated_by = user()->id; + $comment->update($input); return $comment; } - public function getPageComments($pageId) { - $comments = $this->comment->getAllPageComments($pageId); - $index = []; - $totalComments = count($comments); - $finalCommentList = []; - - // normalizing the response. - for ($i = 0; $i < count($comments); ++$i) { - $comment = $this->normalizeComment($comments[$i]); - $parentId = $comment->parent_id; - if (empty($parentId)) { - $finalCommentList[] = $comment; - $index[$comment->id] = $comment; - continue; - } - - if (empty($index[$parentId])) { - // weird condition should not happen. - continue; - } - if (empty($index[$parentId]->sub_comments)) { - $index[$parentId]->sub_comments = []; - } - array_push($index[$parentId]->sub_comments, $comment); - $index[$comment->id] = $comment; - } - return [ - 'comments' => $finalCommentList, - 'total' => $totalComments - ]; + /** + * Delete a comment from the system. + * @param Comment $comment + * @return mixed + */ + public function delete($comment) + { + return $comment->delete(); } - public function getCommentById($commentId) { - return $this->normalizeComment($this->comment->getCommentById($commentId)); - } - - private function normalizeComment($comment) { - if (empty($comment)) { - return; - } - $comment->createdBy->avatar_url = $comment->createdBy->getAvatar(50); - $comment->createdBy->profile_url = $comment->createdBy->getProfileUrl(); - if (!empty($comment->updatedBy)) { - $comment->updatedBy->profile_url = $comment->updatedBy->getProfileUrl(); - } - return $comment; + /** + * Get the next local ID relative to the linked entity. + * @param Entity $entity + * @return int + */ + protected function getNextLocalId(Entity $entity) + { + $comments = $entity->comments()->orderBy('local_id', 'desc')->first(); + if ($comments === null) return 1; + return $comments->local_id + 1; } } \ No newline at end of file diff --git a/app/Repos/TagRepo.php b/app/Repos/TagRepo.php index c6350db1a..5edd6df3c 100644 --- a/app/Repos/TagRepo.php +++ b/app/Repos/TagRepo.php @@ -33,6 +33,7 @@ class TagRepo * @param $entityType * @param $entityId * @param string $action + * @return \Illuminate\Database\Eloquent\Model|null|static */ public function getEntity($entityType, $entityId, $action = 'view') { diff --git a/database/migrations/2017_08_01_130541_create_comments_table.php b/database/migrations/2017_08_01_130541_create_comments_table.php index bfb7eecbf..1d69d1fa7 100644 --- a/database/migrations/2017_08_01_130541_create_comments_table.php +++ b/database/migrations/2017_08_01_130541_create_comments_table.php @@ -15,17 +15,19 @@ class CreateCommentsTable extends Migration { Schema::create('comments', function (Blueprint $table) { $table->increments('id')->unsigned(); - $table->integer('page_id')->unsigned(); + $table->integer('entity_id')->unsigned(); + $table->string('entity_type'); $table->longText('text')->nullable(); $table->longText('html')->nullable(); $table->integer('parent_id')->unsigned()->nullable(); + $table->integer('local_id')->unsigned()->nullable(); $table->integer('created_by')->unsigned(); $table->integer('updated_by')->unsigned()->nullable(); - $table->boolean('active')->default(true); - - $table->index(['page_id']); $table->timestamps(); + $table->index(['entity_id', 'entity_type']); + $table->index(['local_id']); + // Assign new comment permissions to admin role $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; // Create & attach new entity permissions diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js index 988409fbc..d970a581f 100644 --- a/resources/assets/js/components/index.js +++ b/resources/assets/js/components/index.js @@ -10,6 +10,7 @@ let componentMapping = { 'entity-selector': require('./entity-selector'), 'sidebar': require('./sidebar'), 'page-picker': require('./page-picker'), + 'page-comments': require('./page-comments'), }; window.components = {}; diff --git a/resources/assets/js/components/page-comments.js b/resources/assets/js/components/page-comments.js new file mode 100644 index 000000000..87cd88905 --- /dev/null +++ b/resources/assets/js/components/page-comments.js @@ -0,0 +1,137 @@ +const MarkdownIt = require("markdown-it"); +const md = new MarkdownIt({ html: true }); + +class PageComments { + + constructor(elem) { + this.elem = elem; + this.pageId = Number(elem.getAttribute('page-id')); + + this.formContainer = elem.querySelector('[comment-form-container]'); + this.form = this.formContainer.querySelector('form'); + this.formInput = this.form.querySelector('textarea'); + this.container = elem.querySelector('[comment-container]'); + + // TODO - Handle elem usage when no permissions + this.form.addEventListener('submit', this.saveComment.bind(this)); + this.elem.addEventListener('click', this.handleAction.bind(this)); + this.elem.addEventListener('submit', this.updateComment.bind(this)); + + this.editingComment = null; + } + + handleAction(event) { + let actionElem = event.target.closest('[action]'); + if (actionElem === null) return; + + let action = actionElem.getAttribute('action'); + if (action === 'edit') this.editComment(actionElem.closest('[comment]')); + if (action === 'closeUpdateForm') this.closeUpdateForm(); + if (action === 'delete') this.deleteComment(actionElem.closest('[comment]')); + if (action === 'addComment') this.showForm(); + if (action === 'hideForm') this.hideForm(); + if (action === 'reply') this.setReply(); + } + + closeUpdateForm() { + if (!this.editingComment) return; + this.editingComment.querySelector('[comment-content]').style.display = 'block'; + this.editingComment.querySelector('[comment-edit-container]').style.display = 'none'; + } + + editComment(commentElem) { + this.hideForm(); + if (this.editingComment) this.closeUpdateForm(); + commentElem.querySelector('[comment-content]').style.display = 'none'; + commentElem.querySelector('[comment-edit-container]').style.display = 'block'; + this.editingComment = commentElem; + } + + updateComment(event) { + let form = event.target; + event.preventDefault(); + let text = form.querySelector('textarea').value; + let reqData = { + text: text, + html: md.render(text), + // parent_id: this.parent_id TODO - Handle replies + }; + // TODO - Loading indicator + let commentId = this.editingComment.getAttribute('comment'); + window.$http.put(window.baseUrl(`/ajax/comment/${commentId}`), reqData).then(resp => { + let newComment = document.createElement('div'); + newComment.innerHTML = resp.data; + this.editingComment.innerHTML = newComment.children[0].innerHTML; + window.$events.emit('success', window.trans('entities.comment_updated_success')); + this.closeUpdateForm(); + this.editingComment = null; + }); + } + + deleteComment(commentElem) { + let id = commentElem.getAttribute('comment'); + // TODO - Loading indicator + // TODO - Confirm dropdown + window.$http.delete(window.baseUrl(`/ajax/comment/${id}`)).then(resp => { + commentElem.parentNode.removeChild(commentElem); + window.$events.emit('success', window.trans('entities.comment_deleted_success')); + this.updateCount(); + }); + } + + saveComment(event) { + event.preventDefault(); + event.stopPropagation(); + let text = this.formInput.value; + let reqData = { + text: text, + html: md.render(text), + // parent_id: this.parent_id TODO - Handle replies + }; + // TODO - Loading indicator + window.$http.post(window.baseUrl(`/ajax/page/${this.pageId}/comment`), reqData).then(resp => { + let newComment = document.createElement('div'); + newComment.innerHTML = resp.data; + this.container.appendChild(newComment.children[0]); + + window.$events.emit('success', window.trans('entities.comment_created_success')); + this.resetForm(); + this.updateCount(); + }); + } + + updateCount() { + let count = this.container.children.length; + this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count}); + } + + resetForm() { + this.formInput.value = ''; + this.formContainer.appendChild(this.form); + this.hideForm(); + } + + showForm() { + this.formContainer.style.display = 'block'; + this.formContainer.parentNode.style.display = 'block'; + this.elem.querySelector('[comment-add-button]').style.display = 'none'; + this.formInput.focus(); // TODO - Scroll to input on focus + } + + hideForm() { + this.formContainer.style.display = 'none'; + this.formContainer.parentNode.style.display = 'none'; + this.elem.querySelector('[comment-add-button]').style.display = 'block'; + } + + setReply() { + + this.showForm(); + } + +} + +// TODO - Go to comment if url param set + + +module.exports = PageComments; \ No newline at end of file diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 85f9f77a6..37b1b8b7c 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -73,6 +73,7 @@ let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize' const Translations = require("./translations"); let translator = new Translations(window.translations); window.trans = translator.get.bind(translator); +window.trans_choice = translator.getPlural.bind(translator); require("./vues/vues"); diff --git a/resources/assets/js/translations.js b/resources/assets/js/translations.js index ca6a7bd29..99c6b4321 100644 --- a/resources/assets/js/translations.js +++ b/resources/assets/js/translations.js @@ -20,9 +20,64 @@ class Translator { * @returns {*} */ get(key, replacements) { + let text = this.getTransText(key); + return this.performReplacements(text, replacements); + } + + /** + * Get pluralised text, Dependant on the given count. + * Same format at laravel's 'trans_choice' helper. + * @param key + * @param count + * @param replacements + * @returns {*} + */ + getPlural(key, count, replacements) { + let text = this.getTransText(key); + let splitText = text.split('|'); + let result = null; + let exactCountRegex = /^{([0-9]+)}/; + let rangeRegex = /^\[([0-9]+),([0-9*]+)]/; + + for (let i = 0, len = splitText.length; i < len; i++) { + let t = splitText[i]; + + // Parse exact matches + let exactMatches = t.match(exactCountRegex); + console.log(exactMatches); + if (exactMatches !== null && Number(exactMatches[1]) === count) { + result = t.replace(exactCountRegex, '').trim(); + break; + } + + // Parse range matches + let rangeMatches = t.match(rangeRegex); + if (rangeMatches !== null) { + let rangeStart = Number(rangeMatches[1]); + if (rangeStart <= count && (rangeMatches[2] === '*' || Number(rangeMatches[2]) >= count)) { + result = t.replace(rangeRegex, '').trim(); + break; + } + } + } + + if (result === null && splitText.length > 1) { + result = (count === 1) ? splitText[0] : splitText[1]; + } + + if (result === null) result = splitText[0]; + return this.performReplacements(result, replacements); + } + + /** + * Fetched translation text from the store for the given key. + * @param key + * @returns {String|Object} + */ + getTransText(key) { let splitKey = key.split('.'); let value = splitKey.reduce((a, b) => { - return a != undefined ? a[b] : a; + return a !== undefined ? a[b] : a; }, this.store); if (value === undefined) { @@ -30,16 +85,25 @@ class Translator { value = key; } - if (replacements === undefined) return value; + return value; + } - let replaceMatches = value.match(/:([\S]+)/g); - if (replaceMatches === null) return value; + /** + * Perform replacements on a string. + * @param {String} string + * @param {Object} replacements + * @returns {*} + */ + performReplacements(string, replacements) { + if (!replacements) return string; + let replaceMatches = string.match(/:([\S]+)/g); + if (replaceMatches === null) return string; replaceMatches.forEach(match => { let key = match.substring(1); if (typeof replacements[key] === 'undefined') return; - value = value.replace(match, replacements[key]); + string = string.replace(match, replacements[key]); }); - return value; + return string; } } diff --git a/resources/assets/js/vues/components/comments/comment-reply.js b/resources/assets/js/vues/components/comments/comment-reply.js deleted file mode 100644 index 0f65fc237..000000000 --- a/resources/assets/js/vues/components/comments/comment-reply.js +++ /dev/null @@ -1,113 +0,0 @@ -const MarkdownIt = require("markdown-it"); -const md = new MarkdownIt({ html: true }); - -var template = ` -