$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)) {
+ 'use strict';
+
const argv = require('yargs').argv;
const gulp = require('gulp'),
plumber = require('gulp-plumber');
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();
}
gulp.task('scripts-watch', () => {scriptTask(true)});
gulp.task('default', ['styles', 'scripts-watch'], () => {
+ liveReload.listen();
gulp.watch("resources/assets/sass/**/*.scss", ['styles']);
});
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;
+ }
};
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 = ``;
+ 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 = ``;
+ 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 = "";
+ 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 = "";
- $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
- $input.focus();
- $input[0].selectionStart = caretPos + (";
- $input[0].selectionEnd = caretPos + (';
- 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 = "";
- $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);
+ });
+ }
+ };
+ }]);
};
'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',
'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éé'
];
'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',
// 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.',
];
<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>
@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')
<?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);
+ }
+
}