]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'master' of git://github.com/Abijeet/BookStack into Abijeet-master
authorDan Brown <redacted>
Tue, 1 Aug 2017 18:24:33 +0000 (19:24 +0100)
committerDan Brown <redacted>
Tue, 1 Aug 2017 18:24:33 +0000 (19:24 +0100)
1  2 
app/Services/PermissionService.php
gulpfile.js
resources/assets/js/controllers.js
resources/assets/js/directives.js
resources/lang/fr/entities.php
resources/lang/fr/errors.php
resources/views/pages/show.blade.php
tests/Permissions/RolesTest.php

index c6c9813370369fa6f270cb7be81444fc37979685,89f80f9360a878f6224a36853a399c691221a6a1..93787a3e589ba9e6a1d82d2be5030af6ded82c13
@@@ -259,7 -259,7 +259,7 @@@ class PermissionServic
          $roleIds = array_map(function($role) {
              return $role->id;
          }, $roles);
 -        $this->jointPermission->newQuery()->whereIn('id', $roleIds)->delete();
 +        $this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete();
      }
  
      /**
          $action = end($explodedPermission);
          $this->currentAction = $action;
  
-         $nonJointPermissions = ['restrictions', 'image', 'attachment'];
+         $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
  
          // Handle non entity specific jointPermissions
          if (in_array($explodedPermission[0], $nonJointPermissions)) {
diff --combined gulpfile.js
index 08c8886bdd1e70b045ca531ea2354bcdee82cac9,580db00cc0d6cbb5d9ac923fc280050175132520..f851dd7d626753cf1c6623de50be48000e91fcff
@@@ -1,3 -1,5 +1,5 @@@
+ 'use strict';
  const argv = require('yargs').argv;
  const gulp = require('gulp'),
      plumber = require('gulp-plumber');
@@@ -12,10 -14,8 +14,10 @@@ const babelify = require("babelify")
  const watchify = require("watchify");
  const envify = require("envify");
  const gutil = require("gulp-util");
 +const liveReload = require('gulp-livereload');
  
  if (argv.production) process.env.NODE_ENV = 'production';
 +let isProduction = argv.production || process.env.NODE_ENV === 'production';
  
  gulp.task('styles', () => {
      let chain = gulp.src(['resources/assets/sass/**/*.scss'])
              }}))
          .pipe(sass())
          .pipe(autoprefixer('last 2 versions'));
 -    if (argv.production) chain = chain.pipe(minifycss());
 -    return chain.pipe(gulp.dest('public/css/'));
 +    if (isProduction) chain = chain.pipe(minifycss());
 +    return chain.pipe(gulp.dest('public/css/')).pipe(liveReload());
  });
  
  
 -function scriptTask(watch=false) {
 +function scriptTask(watch = false) {
  
      let props = {
          basedir: 'resources/assets/js',
          debug: true,
 -        entries: ['global.js']
 +        entries: ['global.js'],
 +        fast: !isProduction,
 +        cache: {},
 +        packageCache: {},
      };
  
      let bundler = watch ? watchify(browserify(props), { poll: true }) : browserify(props);
 -    bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']});
 +
 +    if (isProduction) {
 +        bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']});
 +    }
 +
      function rebundle() {
          let stream = bundler.bundle();
          stream = stream.pipe(source('common.js'));
 -        if (argv.production) stream = stream.pipe(buffer()).pipe(uglify());
 -        return stream.pipe(gulp.dest('public/js/'));
 +        if (isProduction) stream = stream.pipe(buffer()).pipe(uglify());
 +        return stream.pipe(gulp.dest('public/js/')).pipe(liveReload());
      }
 +
      bundler.on('update', function() {
          rebundle();
 -        gutil.log('Rebundle...');
 +        gutil.log('Rebundling assets...');
      });
 +
      bundler.on('log', gutil.log);
      return rebundle();
  }
@@@ -68,7 -59,6 +70,7 @@@ gulp.task('scripts', () => {scriptTask(
  gulp.task('scripts-watch', () => {scriptTask(true)});
  
  gulp.task('default', ['styles', 'scripts-watch'], () => {
 +    liveReload.listen();
      gulp.watch("resources/assets/sass/**/*.scss", ['styles']);
  });
  
index 9337ea8899fd092b9f297c90ad9d1315875a4b9a,aebde8da4136fcd7bc2f293e7198742661fa90bd..e1d838bb6375a610c435b05e2c9d097493ece887
@@@ -370,8 -370,14 +370,8 @@@ module.exports = function (ngApp, event
              saveDraft();
          };
  
 -        // Listen to shortcuts coming via events
 -        $scope.$on('editor-keydown', (event, data) => {
 -            // Save shortcut (ctrl+s)
 -            if (data.keyCode == 83 && (navigator.platform.match("Mac") ? data.metaKey : data.ctrlKey)) {
 -                data.preventDefault();
 -                saveDraft();
 -            }
 -        });
 +        // Listen to save draft events from editor
 +        $scope.$on('save-draft', saveDraft);
  
          /**
           * Discard the current draft and grab the current page
           */
          $scope.discardDraft = function () {
              let url = window.baseUrl('/ajax/page/' + pageId);
 -            $http.get(url).then((responseData) => {
 +            $http.get(url).then(responseData => {
                  if (autoSave) $interval.cancel(autoSave);
                  $scope.draftText = trans('entities.pages_editing_page');
                  $scope.isUpdateDraft = false;
  
          }]);
  
+     // Controller used to reply to and add new comments
+     ngApp.controller('CommentReplyController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
+         const MarkdownIt = require("markdown-it");
+         const md = new MarkdownIt({html: true});
+         let vm = this;
+         vm.saveComment = function () {
+             let pageId = $scope.comment.pageId || $scope.pageId;
+             let comment = $scope.comment.text;
+             if (!comment) {
+                 return events.emit('warning', trans('errors.empty_comment'));
+             }
+             let commentHTML = md.render($scope.comment.text);
+             let serviceUrl = `/ajax/page/${pageId}/comment/`;
+             let httpMethod = 'post';
+             let reqObj = {
+                 text: comment,
+                 html: commentHTML
+             };
+             if ($scope.isEdit === true) {
+                 // this will be set when editing the comment.
+                 serviceUrl = `/ajax/page/${pageId}/comment/${$scope.comment.id}`;
+                 httpMethod = 'put';
+             } else if ($scope.isReply === true) {
+                 // if its reply, get the parent comment id
+                 reqObj.parent_id = $scope.parentId;
+             }
+             $http[httpMethod](window.baseUrl(serviceUrl), reqObj).then(resp => {
+                 if (!isCommentOpSuccess(resp)) {
+                      return;
+                 }
+                 // hide the comments first, and then retrigger the refresh
+                 if ($scope.isEdit) {
+                     updateComment($scope.comment, resp.data);
+                     $scope.$emit('evt.comment-success', $scope.comment.id);
+                 } else {
+                     $scope.comment.text = '';
+                     if ($scope.isReply === true && $scope.parent.sub_comments) {
+                         $scope.parent.sub_comments.push(resp.data.comment);
+                     } else {
+                         $scope.$emit('evt.new-comment', resp.data.comment);
+                     }
+                     $scope.$emit('evt.comment-success', null, true);
+                 }
+                 $scope.comment.is_hidden = true;
+                 $timeout(function() {
+                     $scope.comment.is_hidden = false;
+                 });
+                 events.emit('success', trans(resp.data.message));
+             }, checkError);
+         };
+         function checkError(response) {
+             let msg = null;
+             if (isCommentOpSuccess(response)) {
+                 // all good
+                 return;
+             } else if (response.data) {
+                 msg = response.data.message;
+             } else {
+                 msg = trans('errors.comment_add');
+             }
+             if (msg) {
+                 events.emit('success', msg);
+             }
+         }
+     }]);
+     // Controller used to delete comments
+     ngApp.controller('CommentDeleteController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
+         let vm = this;
+         vm.delete = function(comment) {
+             $http.delete(window.baseUrl(`/ajax/comment/${comment.id}`)).then(resp => {
+                 if (!isCommentOpSuccess(resp)) {
+                     return;
+                 }
+                 updateComment(comment, resp.data, $timeout, true);
+             }, function (resp) {
+                 if (isCommentOpSuccess(resp)) {
+                     events.emit('success', trans('entities.comment_deleted'));
+                 } else {
+                     events.emit('error', trans('error.comment_delete'));
+                 }
+             });
+         };
+     }]);
+     // Controller used to fetch all comments for a page
+     ngApp.controller('CommentListController', ['$scope', '$http', '$timeout', '$location', function ($scope, $http, $timeout, $location) {
+         let vm = this;
+         $scope.errors = {};
+         // keep track of comment levels
+         $scope.level = 1;
+         vm.totalCommentsStr = trans('entities.comments_loading');
+         vm.permissions = {};
+         vm.trans = window.trans;
+         $scope.$on('evt.new-comment', function (event, comment) {
+             // add the comment to the comment list.
+             vm.comments.push(comment);
+             ++vm.totalComments;
+             setTotalCommentMsg();
+             event.stopPropagation();
+             event.preventDefault();
+         });
+         vm.canEditDelete = function (comment, prop) {
+             if (!comment.active) {
+                 return false;
+             }
+             let propAll = prop + '_all';
+             let propOwn = prop + '_own';
+             if (vm.permissions[propAll]) {
+                 return true;
+             }
+             if (vm.permissions[propOwn] && comment.created_by.id === vm.current_user_id) {
+                 return true;
+             }
+             return false;
+         };
+         vm.canComment = function () {
+             return vm.permissions.comment_create;
+         };
+         // check if there are is any direct linking
+         let linkedCommentId = $location.search().cm;
+         $timeout(function() {
+             $http.get(window.baseUrl(`/ajax/page/${$scope.pageId}/comments/`)).then(resp => {
+                 if (!isCommentOpSuccess(resp)) {
+                     // just show that no comments are available.
+                     vm.totalComments = 0;
+                     setTotalCommentMsg();
+                     return;
+                 }
+                 vm.comments = resp.data.comments;
+                 vm.totalComments = +resp.data.total;
+                 vm.permissions = resp.data.permissions;
+                 vm.current_user_id = resp.data.user_id;
+                 setTotalCommentMsg();
+                 if (!linkedCommentId) {
+                     return;
+                 }
+                 $timeout(function() {
+                     // wait for the UI to render.
+                     focusLinkedComment(linkedCommentId);
+                 });
+             }, checkError);
+         });
+         function setTotalCommentMsg () {
+             if (vm.totalComments === 0) {
+                 vm.totalCommentsStr = trans('entities.no_comments');
+             } else if (vm.totalComments === 1) {
+                 vm.totalCommentsStr = trans('entities.one_comment');
+             } else {
+                 vm.totalCommentsStr = trans('entities.x_comments', {
+                     numComments: vm.totalComments
+                 });
+             }
+         }
+         function focusLinkedComment(linkedCommentId) {
+             let comment = angular.element('#' + linkedCommentId);
+             if (comment.length === 0) {
+                 return;
+             }
+             window.setupPageShow.goToText(linkedCommentId);
+         }
+         function checkError(response) {
+             let msg = null;
+             if (isCommentOpSuccess(response)) {
+                 // all good
+                 return;
+             } else if (response.data) {
+                 msg = response.data.message;
+             } else {
+                 msg = trans('errors.comment_list');
+             }
+             if (msg) {
+                 events.emit('success', msg);
+             }
+         }
+     }]);
+     function updateComment(comment, resp, $timeout, isDelete) {
+         comment.text = resp.comment.text;
+         comment.updated = resp.comment.updated;
+         comment.updated_by = resp.comment.updated_by;
+         comment.active = resp.comment.active;
+         if (isDelete && !resp.comment.active) {
+             comment.html = trans('entities.comment_deleted');
+         } else {
+             comment.html = resp.comment.html;
+         }
+         if (!$timeout) {
+             return;
+         }
+         comment.is_hidden = true;
+         $timeout(function() {
+             comment.is_hidden = false;
+         });
+     }
+     function isCommentOpSuccess(resp) {
+         if (resp && resp.data && resp.data.status === 'success') {
+             return true;
+         }
+         return false;
+     }
  };
index 8d7d89cee2522be31848a697f65753c9275f1724,16d1ad2a4fe1be758f57cc24ac0a2b286275b509..d8745462d03f4c5964f699d5b6b18276e2dc1be4
@@@ -123,31 -123,25 +123,31 @@@ module.exports = function (ngApp, event
              restrict: 'A',
              link: function (scope, element, attrs) {
                  const menu = element.find('ul');
 -                element.find('[dropdown-toggle]').on('click', function () {
 +
 +                function hide() {
 +                    menu.hide();
 +                    menu.removeClass('anim menuIn');
 +                }
 +
 +                function show() {
                      menu.show().addClass('anim menuIn');
 +                    element.mouseleave(hide);
 +
 +                    // Focus on input if exist in dropdown and hide on enter press
                      let inputs = menu.find('input');
 -                    let hasInput = inputs.length > 0;
 -                    if (hasInput) {
 -                        inputs.first().focus();
 -                        element.on('keypress', 'input', event => {
 -                            if (event.keyCode === 13) {
 -                                event.preventDefault();
 -                                menu.hide();
 -                                menu.removeClass('anim menuIn');
 -                                return false;
 -                            }
 -                        });
 -                    }
 -                    element.mouseleave(function () {
 -                        menu.hide();
 -                        menu.removeClass('anim menuIn');
 -                    });
 +                    if (inputs.length > 0) inputs.first().focus();
 +                }
 +
 +                // Hide menu on option click
 +                element.on('click', '> ul a', hide);
 +                // Show dropdown on toggle click.
 +                element.find('[dropdown-toggle]').on('click', show);
 +                // Hide menu on enter press in inputs
 +                element.on('keypress', 'input', event => {
 +                    if (event.keyCode !== 13) return true;
 +                    event.preventDefault();
 +                    hide();
 +                    return false;
                  });
              }
          };
                  }
  
                  scope.tinymce.extraSetups.push(tinyMceSetup);
 -
 -                // Custom tinyMCE plugins
 -                tinymce.PluginManager.add('customhr', function (editor) {
 -                    editor.addCommand('InsertHorizontalRule', function () {
 -                        let hrElem = document.createElement('hr');
 -                        let cNode = editor.selection.getNode();
 -                        let parentNode = cNode.parentNode;
 -                        parentNode.insertBefore(hrElem, cNode);
 -                    });
 -
 -                    editor.addButton('hr', {
 -                        icon: 'hr',
 -                        tooltip: 'Horizontal line',
 -                        cmd: 'InsertHorizontalRule'
 -                    });
 -
 -                    editor.addMenuItem('hr', {
 -                        icon: 'hr',
 -                        text: 'Horizontal line',
 -                        cmd: 'InsertHorizontalRule',
 -                        context: 'insert'
 -                    });
 -                });
 -
                  tinymce.init(scope.tinymce);
              }
          }
              },
              link: function (scope, element, attrs) {
  
 -                // Set initial model content
 -                element = element.find('textarea').first();
 -
                  // Codemirror Setup
 +                element = element.find('textarea').first();
                  let cm = code.markdownEditor(element[0]);
 +
 +                // Custom key commands
 +                let metaKey = code.getMetaKey();
 +                const extraKeys = {};
 +                // Insert Image shortcut
 +                extraKeys[`${metaKey}-Alt-I`] = function(cm) {
 +                    let selectedText = cm.getSelection();
 +                    let newText = `![${selectedText}](http://)`;
 +                    let cursorPos = cm.getCursor('from');
 +                    cm.replaceSelection(newText);
 +                    cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
 +                };
 +                // Save draft
 +                extraKeys[`${metaKey}-S`] = function(cm) {scope.$emit('save-draft');};
 +                // Show link selector
 +                extraKeys[`Shift-${metaKey}-K`] = function(cm) {showLinkSelector()};
 +                // Insert Link
 +                extraKeys[`${metaKey}-K`] = function(cm) {insertLink()};
 +                // FormatShortcuts
 +                extraKeys[`${metaKey}-1`] = function(cm) {replaceLineStart('##');};
 +                extraKeys[`${metaKey}-2`] = function(cm) {replaceLineStart('###');};
 +                extraKeys[`${metaKey}-3`] = function(cm) {replaceLineStart('####');};
 +                extraKeys[`${metaKey}-4`] = function(cm) {replaceLineStart('#####');};
 +                extraKeys[`${metaKey}-5`] = function(cm) {replaceLineStart('');};
 +                extraKeys[`${metaKey}-d`] = function(cm) {replaceLineStart('');};
 +                extraKeys[`${metaKey}-6`] = function(cm) {replaceLineStart('>');};
 +                extraKeys[`${metaKey}-q`] = function(cm) {replaceLineStart('>');};
 +                extraKeys[`${metaKey}-7`] = function(cm) {wrapSelection('\n```\n', '\n```');};
 +                extraKeys[`${metaKey}-8`] = function(cm) {wrapSelection('`', '`');};
 +                extraKeys[`Shift-${metaKey}-E`] = function(cm) {wrapSelection('`', '`');};
 +                extraKeys[`${metaKey}-9`] = function(cm) {wrapSelection('<p class="callout info">', '</div>');};
 +                cm.setOption('extraKeys', extraKeys);
 +
 +                // Update data on content change
                  cm.on('change', (instance, changeObj) => {
                      update(instance);
                  });
  
 +                // Handle scroll to sync display view
                  cm.on('scroll', instance => {
                      // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
                      let scroll = instance.getScrollInfo();
                      scope.$emit('markdown-scroll', totalLines.length);
                  });
  
 +                // Handle image paste
 +                cm.on('paste', (cm, event) => {
 +                    if (!event.clipboardData || !event.clipboardData.items) return;
 +                    for (let i = 0; i < event.clipboardData.items.length; i++) {
 +                        uploadImage(event.clipboardData.items[i].getAsFile());
 +                    }
 +                });
 +
 +                // Handle images on drag-drop
 +                cm.on('drop', (cm, event) => {
 +                    event.stopPropagation();
 +                    event.preventDefault();
 +                    let cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
 +                    cm.setCursor(cursorPos);
 +                    if (!event.dataTransfer || !event.dataTransfer.files) return;
 +                    for (let i = 0; i < event.dataTransfer.files.length; i++) {
 +                        uploadImage(event.dataTransfer.files[i]);
 +                    }
 +                });
 +
 +                // Helper to replace editor content
 +                function replaceContent(search, replace) {
 +                    let text = cm.getValue();
 +                    let cursor = cm.listSelections();
 +                    cm.setValue(text.replace(search, replace));
 +                    cm.setSelections(cursor);
 +                }
 +
 +                // Helper to replace the start of the line
 +                function replaceLineStart(newStart) {
 +                    let cursor = cm.getCursor();
 +                    let lineContent = cm.getLine(cursor.line);
 +                    let lineLen = lineContent.length;
 +                    let lineStart = lineContent.split(' ')[0];
 +
 +                    // Remove symbol if already set
 +                    if (lineStart === newStart) {
 +                        lineContent = lineContent.replace(`${newStart} `, '');
 +                        cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
 +                        cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
 +                        return;
 +                    }
 +
 +                    let alreadySymbol = /^[#>`]/.test(lineStart);
 +                    let posDif = 0;
 +                    if (alreadySymbol) {
 +                        posDif = newStart.length - lineStart.length;
 +                        lineContent = lineContent.replace(lineStart, newStart).trim();
 +                    } else if (newStart !== '') {
 +                        posDif = newStart.length + 1;
 +                        lineContent = newStart + ' ' + lineContent;
 +                    }
 +                    cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
 +                    cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
 +                }
 +
 +                function wrapLine(start, end) {
 +                    let cursor = cm.getCursor();
 +                    let lineContent = cm.getLine(cursor.line);
 +                    let lineLen = lineContent.length;
 +                    let newLineContent = lineContent;
 +
 +                    if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
 +                        newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
 +                    } else {
 +                        newLineContent = `${start}${lineContent}${end}`;
 +                    }
 +
 +                    cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
 +                    cm.setCursor({line: cursor.line, ch: cursor.ch + (newLineContent.length - lineLen)});
 +                }
 +
 +                function wrapSelection(start, end) {
 +                    let selection = cm.getSelection();
 +                    if (selection === '') return wrapLine(start, end);
 +                    let newSelection = selection;
 +                    let frontDiff = 0;
 +                    let endDiff = 0;
 +
 +                    if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
 +                        newSelection = selection.slice(start.length, selection.length - end.length);
 +                        endDiff = -(end.length + start.length);
 +                    } else {
 +                        newSelection = `${start}${selection}${end}`;
 +                        endDiff = start.length + end.length;
 +                    }
 +
 +                    let selections = cm.listSelections()[0];
 +                    cm.replaceSelection(newSelection);
 +                    let headFirst = selections.head.ch <= selections.anchor.ch;
 +                    selections.head.ch += headFirst ? frontDiff : endDiff;
 +                    selections.anchor.ch += headFirst ? endDiff : frontDiff;
 +                    cm.setSelections([selections]);
 +                }
 +
 +                // Handle image upload and add image into markdown content
 +                function uploadImage(file) {
 +                    if (file === null || file.type.indexOf('image') !== 0) return;
 +                    let ext = 'png';
 +
 +                    if (file.name) {
 +                        let fileNameMatches = file.name.match(/\.(.+)$/);
 +                        if (fileNameMatches.length > 1) ext = fileNameMatches[1];
 +                    }
 +
 +                    // Insert image into markdown
 +                    let id = "image-" + Math.random().toString(16).slice(2);
 +                    let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
 +                    let selectedText = cm.getSelection();
 +                    let placeHolderText = `![${selectedText}](${placeholderImage})`;
 +                    cm.replaceSelection(placeHolderText);
 +
 +                    let remoteFilename = "image-" + Date.now() + "." + ext;
 +                    let formData = new FormData();
 +                    formData.append('file', file, remoteFilename);
 +
 +                    window.$http.post('/images/gallery/upload', formData).then(resp => {
 +                        replaceContent(placeholderImage, resp.data.thumbs.display);
 +                    }).catch(err => {
 +                        events.emit('error', trans('errors.image_upload_error'));
 +                        replaceContent(placeHolderText, selectedText);
 +                        console.log(err);
 +                    });
 +                }
 +
 +                // Show the popup link selector and insert a link when finished
 +                function showLinkSelector() {
 +                    let cursorPos = cm.getCursor('from');
 +                    window.showEntityLinkSelector(entity => {
 +                        let selectedText = cm.getSelection() || entity.name;
 +                        let newText = `[${selectedText}](${entity.link})`;
 +                        cm.focus();
 +                        cm.replaceSelection(newText);
 +                        cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
 +                    });
 +                }
 +
 +                function insertLink() {
 +                    let cursorPos = cm.getCursor('from');
 +                    let selectedText = cm.getSelection() || '';
 +                    let newText = `[${selectedText}]()`;
 +                    cm.focus();
 +                    cm.replaceSelection(newText);
 +                    let cursorPosDiff = (selectedText === '') ? -3 : -1;
 +                    cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
 +                }
 +
 +                // Show the image manager and handle image insertion
 +                function showImageManager() {
 +                    let cursorPos = cm.getCursor('from');
 +                    window.ImageManager.showExternal(image => {
 +                        let selectedText = cm.getSelection();
 +                        let newText = "![" + (selectedText || image.name) + "](" + image.thumbs.display + ")";
 +                        cm.focus();
 +                        cm.replaceSelection(newText);
 +                        cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
 +                    });
 +                }
 +
 +                // Update the data models and rendered output
                  function update(instance) {
                      let content = instance.getValue();
                      element.val(content);
                  }
                  update(cm);
  
 +                // Listen to commands from parent scope
 +                scope.$on('md-insert-link', showLinkSelector);
 +                scope.$on('md-insert-image', showImageManager);
                  scope.$on('markdown-update', (event, value) => {
                      cm.setValue(value);
                      element.val(value);
              restrict: 'A',
              link: function (scope, element, attrs) {
  
 -                // Elements
 -                const $input = element.find('[markdown-input] textarea').first();
 +                // Editor Elements
                  const $display = element.find('.markdown-display').first();
                  const $insertImage = element.find('button[data-action="insertImage"]');
                  const $insertEntityLink = element.find('button[data-action="insertEntityLink"]');
                      window.open(this.getAttribute('href'));
                  });
  
 -                let currentCaretPos = 0;
 -
 -                $input.blur(event => {
 -                    currentCaretPos = $input[0].selectionStart;
 -                });
 +                // Editor UI Actions
 +                $insertEntityLink.click(e => {scope.$broadcast('md-insert-link');});
 +                $insertImage.click(e => {scope.$broadcast('md-insert-image');});
  
                  // Handle scroll sync event from editor scroll
                  $rootScope.$on('markdown-scroll', (event, lineCount) => {
                          }, {queue: false, duration: 200, easing: 'linear'});
                      }
                  });
 -
 -                // Editor key-presses
 -                $input.keydown(event => {
 -                    // Insert image shortcut
 -                    if (event.which === 73 && event.ctrlKey && event.shiftKey) {
 -                        event.preventDefault();
 -                        let caretPos = $input[0].selectionStart;
 -                        let currentContent = $input.val();
 -                        const mdImageText = "![](http://)";
 -                        $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
 -                        $input.focus();
 -                        $input[0].selectionStart = caretPos + ("![](".length);
 -                        $input[0].selectionEnd = caretPos + ('![](http://'.length);
 -                        return;
 -                    }
 -
 -                    // Insert entity link shortcut
 -                    if (event.which === 75 && event.ctrlKey && event.shiftKey) {
 -                        showLinkSelector();
 -                        return;
 -                    }
 -
 -                    // Pass key presses to controller via event
 -                    scope.$emit('editor-keydown', event);
 -                });
 -
 -                // Insert image from image manager
 -                $insertImage.click(event => {
 -                    window.ImageManager.showExternal(image => {
 -                        let caretPos = currentCaretPos;
 -                        let currentContent = $input.val();
 -                        let mdImageText = "![" + image.name + "](" + image.thumbs.display + ")";
 -                        $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
 -                        $input.change();
 -                    });
 -                });
 -
 -                function showLinkSelector() {
 -                    window.showEntityLinkSelector((entity) => {
 -                        let selectionStart = currentCaretPos;
 -                        let selectionEnd = $input[0].selectionEnd;
 -                        let textSelected = (selectionEnd !== selectionStart);
 -                        let currentContent = $input.val();
 -
 -                        if (textSelected) {
 -                            let selectedText = currentContent.substring(selectionStart, selectionEnd);
 -                            let linkText = `[${selectedText}](${entity.link})`;
 -                            $input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
 -                        } else {
 -                            let linkText = ` [${entity.name}](${entity.link}) `;
 -                            $input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
 -                        }
 -                        $input.change();
 -                    });
 -                }
 -                $insertEntityLink.click(showLinkSelector);
 -
 -                // Upload and insert image on paste
 -                function editorPaste(e) {
 -                    e = e.originalEvent;
 -                    if (!e.clipboardData) return
 -                    let items = e.clipboardData.items;
 -                    if (!items) return;
 -                    for (let i = 0; i < items.length; i++) {
 -                        uploadImage(items[i].getAsFile());
 -                    }
 -                }
 -
 -                $input.on('paste', editorPaste);
 -
 -                // Handle image drop, Uploads images to BookStack.
 -                function handleImageDrop(event) {
 -                    event.stopPropagation();
 -                    event.preventDefault();
 -                    let files = event.originalEvent.dataTransfer.files;
 -                    for (let i = 0; i < files.length; i++) {
 -                        uploadImage(files[i]);
 -                    }
 -                }
 -
 -                $input.on('drop', handleImageDrop);
 -
 -                // Handle image upload and add image into markdown content
 -                function uploadImage(file) {
 -                    if (file.type.indexOf('image') !== 0) return;
 -                    let formData = new FormData();
 -                    let ext = 'png';
 -                    let xhr = new XMLHttpRequest();
 -
 -                    if (file.name) {
 -                        let fileNameMatches = file.name.match(/\.(.+)$/);
 -                        if (fileNameMatches) {
 -                            ext = fileNameMatches[1];
 -                        }
 -                    }
 -
 -                    // Insert image into markdown
 -                    let id = "image-" + Math.random().toString(16).slice(2);
 -                    let selectStart = $input[0].selectionStart;
 -                    let selectEnd = $input[0].selectionEnd;
 -                    let content = $input[0].value;
 -                    let selectText = content.substring(selectStart, selectEnd);
 -                    let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
 -                    let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
 -                    $input[0].value = content.substring(0, selectStart) +  innerContent + content.substring(selectEnd);
 -
 -                    $input.focus();
 -                    $input[0].selectionStart = selectStart;
 -                    $input[0].selectionEnd = selectStart;
 -
 -                    let remoteFilename = "image-" + Date.now() + "." + ext;
 -                    formData.append('file', file, remoteFilename);
 -                    formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
 -
 -                    xhr.open('POST', window.baseUrl('/images/gallery/upload'));
 -                    xhr.onload = function () {
 -                        let selectStart = $input[0].selectionStart;
 -                        if (xhr.status === 200 || xhr.status === 201) {
 -                            let result = JSON.parse(xhr.responseText);
 -                            $input[0].value = $input[0].value.replace(placeholderImage, result.thumbs.display);
 -                            $input.change();
 -                        } else {
 -                            console.log(trans('errors.image_upload_error'));
 -                            console.log(xhr.responseText);
 -                            $input[0].value = $input[0].value.replace(innerContent, '');
 -                            $input.change();
 -                        }
 -                        $input.focus();
 -                        $input[0].selectionStart = selectStart;
 -                        $input[0].selectionEnd = selectStart;
 -                    };
 -                    xhr.send(formData);
 -                }
 -
              }
          }
      }]);
              }
          };
      }]);
+     ngApp.directive('commentReply', [function () {
+         return {
+             restrict: 'E',
+             templateUrl: 'comment-reply.html',
+             scope: {
+               pageId: '=',
+               parentId: '=',
+               parent: '='
+             },
+             link: function (scope, element) {
+                 scope.isReply = true;
+                 element.find('textarea').focus();
+                 scope.$on('evt.comment-success', function (event) {
+                     // no need for the event to do anything more.
+                     event.stopPropagation();
+                     event.preventDefault();
+                     scope.closeBox();
+                 });
+                 scope.closeBox = function () {
+                     element.remove();
+                     scope.$destroy();
+                 };
+             }
+         };
+     }]);
+     ngApp.directive('commentEdit', [function () {
+          return {
+             restrict: 'E',
+             templateUrl: 'comment-reply.html',
+             scope: {
+               comment: '='
+             },
+             link: function (scope, element) {
+                 scope.isEdit = true;
+                 element.find('textarea').focus();
+                 scope.$on('evt.comment-success', function (event, commentId) {
+                    // no need for the event to do anything more.
+                    event.stopPropagation();
+                    event.preventDefault();
+                    if (commentId === scope.comment.id && !scope.isNew) {
+                        scope.closeBox();
+                    }
+                 });
+                 scope.closeBox = function () {
+                     element.remove();
+                     scope.$destroy();
+                 };
+             }
+         };
+     }]);
+     ngApp.directive('commentReplyLink', ['$document', '$compile', function ($document, $compile) {
+         return {
+             scope: {
+                 comment: '='
+             },
+             link: function (scope, element, attr) {
+                 element.on('$destroy', function () {
+                     element.off('click');
+                     scope.$destroy();
+                 });
+                 element.on('click', function (e) {
+                     e.preventDefault();
+                     var $container = element.parents('.comment-actions').first();
+                     if (!$container.length) {
+                         console.error('commentReplyLink directive should be placed inside a container with class comment-box!');
+                         return;
+                     }
+                     if (attr.noCommentReplyDupe) {
+                         removeDupe();
+                     }
+                     compileHtml($container, scope, attr.isReply === 'true');
+                 });
+             }
+         };
+         function compileHtml($container, scope, isReply) {
+             let lnkFunc = null;
+             if (isReply) {
+                 lnkFunc = $compile('<comment-reply page-id="comment.pageId" parent-id="comment.id" parent="comment"></comment-reply>');
+             } else {
+                 lnkFunc = $compile('<comment-edit comment="comment"></comment-add>');
+             }
+             var compiledHTML = lnkFunc(scope);
+             $container.append(compiledHTML);
+         }
+         function removeDupe() {
+             let $existingElement = $document.find('.comments-list comment-reply, .comments-list comment-edit');
+             if (!$existingElement.length) {
+                 return;
+             }
+             $existingElement.remove();
+         }
+     }]);
+     ngApp.directive('commentDeleteLink', ['$window', function ($window) {
+         return {
+             controller: 'CommentDeleteController',
+             scope: {
+                 comment: '='
+             },
+             link: function (scope, element, attr, ctrl) {
+                 element.on('click', function(e) {
+                     e.preventDefault();
+                     var resp = $window.confirm(trans('entities.comment_delete_confirm'));
+                     if (!resp) {
+                         return;
+                     }
+                     ctrl.delete(scope.comment);
+                 });
+             }
+         };
+     }]);
  };
index 17b4ea91372b2a47f8025a1ef9baf2301b0eb711,c618bab0808e0a8934251f3d8ea0700ab719a719..0d89993e9d8043ba4c6e3f9b9f6f5c24079ad5f6
@@@ -12,7 -12,7 +12,7 @@@ return 
      'recently_update' => 'Mis à jour récemment',
      'recently_viewed' => 'Vus récemment',
      'recent_activity' => 'Activité récente',
 -    'create_now' => 'En créer un récemment',
 +    'create_now' => 'En créer un maintenant',
      'revisions' => 'Révisions',
      'meta_created' => 'Créé :timeLength',
      'meta_created_name' => 'Créé :timeLength par :user',
@@@ -59,8 -59,8 +59,8 @@@
      'books_create' => 'Créer un nouveau livre',
      'books_delete' => 'Supprimer un livre',
      'books_delete_named' => 'Supprimer le livre :bookName',
 -    'books_delete_explain' => 'Ceci va supprimer le livre nommé \':bookName\', Tous les chapitres et pages seront supprimés.',
 -    'books_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer ce livre?',
 +    'books_delete_explain' => 'Ceci va supprimer le livre nommé \':bookName\', tous les chapitres et pages seront supprimés.',
 +    'books_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer ce livre ?',
      'books_edit' => 'Modifier le livre',
      'books_edit_named' => 'Modifier le livre :bookName',
      'books_form_book_name' => 'Nom du livre',
      'chapters_create' => 'Créer un nouveau chapitre',
      'chapters_delete' => 'Supprimer le chapitre',
      'chapters_delete_named' => 'Supprimer le chapitre :chapterName',
 -    'chapters_delete_explain' => 'Ceci va supprimer le chapitre \':chapterName\', Toutes les pages seront déplacée dans le livre parent.',
 -    'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre?',
 +    'chapters_delete_explain' => 'Ceci va supprimer le chapitre \':chapterName\', toutes les pages seront déplacées dans le livre parent.',
 +    'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre ?',
      'chapters_edit' => 'Modifier le chapitre',
      'chapters_edit_named' => 'Modifier le chapitre :chapterName',
      'chapters_save' => 'Enregistrer le chapitre',
 -    'chapters_move' => 'Déplace le chapitre',
 +    'chapters_move' => 'Déplacer le chapitre',
      'chapters_move_named' => 'Déplacer le chapitre :chapterName',
      'chapter_move_success' => 'Chapitre déplacé dans :bookName',
      'chapters_permissions' => 'Permissions du chapitre',
 -    'chapters_empty' => 'Il n\'y a pas de pages dans ce chapitre actuellement.',
 +    'chapters_empty' => 'Il n\'y a pas de page dans ce chapitre actuellement.',
      'chapters_permissions_active' => 'Permissions du chapitre activées',
 -    'chapters_permissions_success' => 'Permissions du chapitres mises à jour',
 +    'chapters_permissions_success' => 'Permissions du chapitre mises à jour',
  
      /**
       * Pages
      'pages_delete_draft' => 'Supprimer le brouillon',
      'pages_delete_success' => 'Page supprimée',
      'pages_delete_draft_success' => 'Brouillon supprimé',
 -    'pages_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cette page?',
 -    'pages_delete_draft_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce brouillon?',
 +    'pages_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cette page ?',
 +    'pages_delete_draft_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce brouillon ?',
      'pages_editing_named' => 'Modification de la page :pageName',
      'pages_edit_toggle_header' => 'Afficher/cacher l\'en-tête',
      'pages_edit_save_draft' => 'Enregistrer le brouillon',
      'pages_edit_discard_draft' => 'Ecarter le brouillon',
      'pages_edit_set_changelog' => 'Remplir le journal des changements',
      'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués',
 -    'pages_edit_enter_changelog' => 'Entrez dans le journal des changements',
 +    'pages_edit_enter_changelog' => 'Entrer dans le journal des changements',
      'pages_save' => 'Enregistrez la page',
      'pages_title' => 'Titre de la page',
      'pages_name' => 'Nom de la page',
      'pages_md_preview' => 'Prévisualisation',
      'pages_md_insert_image' => 'Insérer une image',
      'pages_md_insert_link' => 'Insérer un lien',
 -    'pages_not_in_chapter' => 'La page n\'est pas dans un chanpitre',
 +    'pages_not_in_chapter' => 'La page n\'est pas dans un chapitre',
      'pages_move' => 'Déplacer la page',
      'pages_move_success' => 'Page déplacée à ":parentName"',
      'pages_permissions' => 'Permissions de la page',
      'pages_initial_revision' => 'Publication initiale',
      'pages_initial_name' => 'Nouvelle page',
      'pages_editing_draft_notification' => 'Vous éditez actuellement un brouillon qui a été sauvé :timeDiff.',
 -    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visit. Vous devriez écarter ce brouillon.',
 +    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visite. Vous devriez écarter ce brouillon.',
      'pages_draft_edit_active' => [
 -        'start_a' => ':count utilisateurs ont commencé a éditer cette page',
 +        'start_a' => ':count utilisateurs ont commencé à éditer cette page',
          'start_b' => ':userName a commencé à éditer cette page',
          'time_a' => 'depuis la dernière sauvegarde',
          'time_b' => 'dans les :minCount dernières minutes',
 -        'message' => ':start :time. Attention a ne pas écraser les mises à jour de quelqu\'un d\'autre!',
 +        'message' => ':start :time. Attention à ne pas écraser les mises à jour de quelqu\'un d\'autre !',
      ],
 -    'pages_draft_discarded' => 'Brouuillon écarté, la page est dans sa version actuelle.',
 +    'pages_draft_discarded' => 'Brouillon écarté, la page est dans sa version actuelle.',
  
      /**
       * Editor sidebar
       */
      'profile_user_for_x' => 'Utilisateur depuis :time',
      'profile_created_content' => 'Contenu créé',
 -    'profile_not_created_pages' => ':userName n\'a pas créé de pages',
 -    'profile_not_created_chapters' => ':userName n\'a pas créé de chapitres',
 -    'profile_not_created_books' => ':userName n\'a pas créé de livres',
 +    'profile_not_created_pages' => ':userName n\'a pas créé de page',
 +    'profile_not_created_chapters' => ':userName n\'a pas créé de chapitre',
 +    'profile_not_created_books' => ':userName n\'a pas créé de livre',
+     /**
+      * Comments
+      */
+     'comment' => 'Commentaire',
+     'comments' => 'Commentaires',
+     'comment_placeholder' => 'Entrez vos commentaires ici, merci supporté ...',
+     'no_comments' => 'No Comments',
+     'x_comments' => ':numComments Commentaires',
+     'one_comment' => '1 Commentaire',
+     'comments_loading' => 'Loading ...',
+     'comment_save' => 'Enregistrer le commentaire',
+     'comment_reply' => 'Répondre',
+     'comment_edit' => 'Modifier',
+     'comment_delete' => 'Supprimer',
+     'comment_cancel' => 'Annuler',
+     'comment_created' => 'Commentaire ajouté',
+     'comment_updated' => 'Commentaire mis à jour',
+     'comment_deleted' => 'Commentaire supprimé',
+     'comment_updated_text' => 'Mis à jour il y a :updateDiff par',
+     'comment_delete_confirm' => 'Cela supprime le contenu du commentaire. Êtes-vous sûr de vouloir supprimer ce commentaire?',
+     'comment_create' => 'Créé'
  ];
index 4197b1708e4b91c66a115607b0ff21e49992e3a7,402eeb405c8d8f42aeeeedaadc30fa566343dadf..9e20147b60a474e48b844267a37c9c3283375f56
@@@ -18,21 -18,21 +18,21 @@@ return 
      'ldap_fail_anonymous' => 'L\'accès LDAP anonyme n\'a pas abouti',
      'ldap_fail_authed' => 'L\'accès LDAP n\'a pas abouti avec cet utilisateur et ce mot de passe',
      'ldap_extension_not_installed' => 'L\'extention LDAP PHP n\'est pas installée',
 -    'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed',
 -    'social_no_action_defined' => 'No action defined',
 -    'social_account_in_use' => 'Cet compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
 -    'social_account_email_in_use' => 'L\'email :email Est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
 +    'ldap_cannot_connect' => 'Impossible de se connecter au serveur LDAP, la connexion initiale a échoué',
 +    'social_no_action_defined' => 'Pas d\'action définie',
 +    'social_account_in_use' => 'Ce compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
 +    'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
      'social_account_existing' => 'Ce compte :socialAccount est déjà rattaché à votre profil.',
      'social_account_already_used_existing' => 'Ce compte :socialAccount est déjà utilisé par un autre utilisateur.',
      'social_account_not_used' => 'Ce compte :socialAccount n\'est lié à aucun utilisateur. ',
      'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez le lier avec l\'option :socialAccount.',
 -    'social_driver_not_found' => 'Social driver not found',
 -    'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.',
 +    'social_driver_not_found' => 'Pilote de compte social absent',
 +    'social_driver_not_configured' => 'Vos préférences pour le compte :socialAccount sont incorrectes.',
  
      // System
 -    'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',
 +    'path_not_writable' => 'Impossible d\'écrire dans :filePath. Assurez-vous d\'avoir les droits d\'écriture sur le serveur',
      'cannot_get_image_from_url' => 'Impossible de récupérer l\'image depuis :url',
 -    'cannot_create_thumbs' => 'Le serveur ne peux pas créer de miniatures, vérifier que l\extensions GD PHP est installée.',
 +    'cannot_create_thumbs' => 'Le serveur ne peut pas créer de miniature, vérifier que l\'extension PHP GD est installée.',
      'server_upload_limit' => 'La taille du fichier est trop grande.',
      'image_upload_error' => 'Une erreur est survenue pendant l\'envoi de l\'image',
  
@@@ -57,7 -57,7 +57,7 @@@
  
      // Roles
      'role_cannot_be_edited' => 'Ce rôle ne peut pas être modifié',
 -    'role_system_cannot_be_deleted' => 'Ceci est un rôle du système et on ne peut pas le supprimer',
 +    'role_system_cannot_be_deleted' => 'Ceci est un rôle du système et ne peut pas être supprimé',
      'role_registration_default_cannot_delete' => 'Ce rôle ne peut pas être supprimé tant qu\'il est le rôle par défaut',
  
      // Error pages
      'error_occurred' => 'Une erreur est survenue',
      'app_down' => ':appName n\'est pas en service pour le moment',
      'back_soon' => 'Nous serons bientôt de retour.',
+     // comments
+     'comment_list' => 'Une erreur s\'est produite lors de la récupération des commentaires.',
+     'cannot_add_comment_to_draft' => 'Vous ne pouvez pas ajouter de commentaires à un projet.',
+     'comment_add' => 'Une erreur s\'est produite lors de l\'ajout du commentaire.',
+     'comment_delete' => 'Une erreur s\'est produite lors de la suppression du commentaire.',
+     'empty_comment' => 'Impossible d\'ajouter un commentaire vide.',
  ];
index 221ed44769393182553f5cebe59cca6dbc4a7e49,c6344434464f1ea99d21688a4da7ffa1ec9c2572..0d75a534ab0b56dc497ff1807e4dcc6454d20810
@@@ -5,10 -5,10 +5,10 @@@
      <div class="faded-small toolbar">
          <div class="container">
              <div class="row">
 -                <div class="col-sm-6 faded">
 +                <div class="col-sm-8 col-xs-5 faded">
                      @include('pages._breadcrumbs', ['page' => $page])
                  </div>
 -                <div class="col-sm-6 faded">
 +                <div class="col-sm-4 col-xs-7 faded">
                      <div class="action-buttons">
                          <span dropdown class="dropdown-container">
                              <div dropdown-toggle class="text-button text-primary"><i class="zmdi zmdi-open-in-new"></i>{{ trans('entities.export') }}</div>
      </div>
  
  
-     <div class="container" id="page-show" ng-non-bindable>
+     <div class="container" id="page-show">
          <div class="row">
              <div class="col-md-9 print-full-width">
-                 <div class="page-content">
+                 <div class="page-content" ng-non-bindable>
  
                      <div class="pointer-container" id="pointer">
-                         <div class="pointer anim">
+                         <div class="pointer anim" >
                              <span class="icon text-primary"><i class="zmdi zmdi-link"></i></span>
                              <input readonly="readonly" type="text" id="pointer-url" placeholder="url">
                              <button class="button icon" data-clipboard-target="#pointer-url" type="button" title="{{ trans('entities.pages_copy_link') }}"><i class="zmdi zmdi-copy"></i></button>
@@@ -66,6 -66,7 +66,7 @@@
                      @include('partials.entity-meta', ['entity' => $page])
  
                  </div>
+                 @include('comments/comments', ['pageId' => $page->id])
              </div>
  
              <div class="col-md-3 print-hidden">
  
          </div>
      </div>
  @stop
  
  @section('scripts')
index eda5d092ab0c6a180454d047a3b7b92851852488,0e9f691e0d86aa6d737ab211da977a5abf4cb2f1..f131ed8857927263173cdddcf0b42546b427d06b
@@@ -1,10 -1,7 +1,10 @@@
  <?php namespace Tests;
  
 +use BookStack\Page;
  use BookStack\Repos\PermissionsRepo;
  use BookStack\Role;
 +use Laravel\BrowserKitTesting\HttpException;
 +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  
  class RolesTest extends BrowserKitTest
  {
              ->see('Cannot be deleted');
      }
  
 -
 -
      public function test_image_delete_own_permission()
      {
          $this->giveUserPermissions($this->user, ['image-update-all']);
              ->dontSeeInDatabase('images', ['id' => $image->id]);
      }
  
 +    public function test_role_permission_removal()
 +    {
 +        // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
 +        $page = Page::first();
 +        $viewerRole = \BookStack\Role::getRole('viewer');
 +        $viewer = $this->getViewer();
 +        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseOk();
 +
 +        $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [
 +            'display_name' => $viewerRole->display_name,
 +            'description' => $viewerRole->description,
 +            'permission' => []
 +        ])->assertResponseStatus(302);
 +
 +        $this->expectException(HttpException::class);
 +        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(404);
 +    }
 +
 +    public function test_empty_state_actions_not_visible_without_permission()
 +    {
 +        $admin = $this->getAdmin();
 +        // Book links
 +        $book = factory(\BookStack\Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
 +        $this->updateEntityPermissions($book);
 +        $this->actingAs($this->getViewer())->visit($book->getUrl())
 +            ->dontSee('Create a new page')
 +            ->dontSee('Add a chapter');
 +
 +        // Chapter links
 +        $chapter = factory(\BookStack\Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
 +        $this->updateEntityPermissions($chapter);
 +        $this->actingAs($this->getViewer())->visit($chapter->getUrl())
 +            ->dontSee('Create a new page')
 +            ->dontSee('Sort the current book');
 +    }
 +
+     public function test_comment_create_permission () {
+         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+         $this->actingAs($this->user)->addComment($ownPage);
+         $this->assertResponseStatus(403);
+         $this->giveUserPermissions($this->user, ['comment-create-all']);
+         $this->actingAs($this->user)->addComment($ownPage);
+         $this->assertResponseOk(200)->seeJsonContains(['status' => 'success']);
+     }
+     public function test_comment_update_own_permission () {
+         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+         $this->giveUserPermissions($this->user, ['comment-create-all']);
+         $comment = $this->actingAs($this->user)->addComment($ownPage);
+         // no comment-update-own
+         $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+         $this->assertResponseStatus(403);
+         $this->giveUserPermissions($this->user, ['comment-update-own']);
+         // now has comment-update-own
+         $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+         $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+     }
+     public function test_comment_update_all_permission () {
+         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+         $comment = $this->asAdmin()->addComment($ownPage);
+         // no comment-update-all
+         $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+         $this->assertResponseStatus(403);
+         $this->giveUserPermissions($this->user, ['comment-update-all']);
+         // now has comment-update-all
+         $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+         $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+     }
+     public function test_comment_delete_own_permission () {
+         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+         $this->giveUserPermissions($this->user, ['comment-create-all']);
+         $comment = $this->actingAs($this->user)->addComment($ownPage);
+         // no comment-delete-own
+         $this->actingAs($this->user)->deleteComment($comment['id']);
+         $this->assertResponseStatus(403);
+         $this->giveUserPermissions($this->user, ['comment-delete-own']);
+         // now has comment-update-own
+         $this->actingAs($this->user)->deleteComment($comment['id']);
+         $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+     }
+     public function test_comment_delete_all_permission () {
+         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+         $comment = $this->asAdmin()->addComment($ownPage);
+         // no comment-delete-all
+         $this->actingAs($this->user)->deleteComment($comment['id']);
+         $this->assertResponseStatus(403);
+         $this->giveUserPermissions($this->user, ['comment-delete-all']);
+         // now has comment-delete-all
+         $this->actingAs($this->user)->deleteComment($comment['id']);
+         $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+     }
+     private function addComment($page) {
+         $comment = factory(\BookStack\Comment::class)->make();
+         $url = "/ajax/page/$page->id/comment/";
+         $request = [
+             'text' => $comment->text,
+             'html' => $comment->html
+         ];
+         $this->json('POST', $url, $request);
+         $resp = $this->decodeResponseJson();
+         if (isset($resp['comment'])) {
+             return $resp['comment'];
+         }
+         return null;
+     }
+     private function updateComment($page, $commentId) {
+         $comment = factory(\BookStack\Comment::class)->make();
+         $url = "/ajax/page/$page->id/comment/$commentId";
+         $request = [
+             'text' => $comment->text,
+             'html' => $comment->html
+         ];
+         return $this->json('PUT', $url, $request);
+     }
+     private function deleteComment($commentId) {
+          $url = '/ajax/comment/' . $commentId;
+          return $this->json('DELETE', $url);
+     }
  }