]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'v0.11'
authorDan Brown <redacted>
Sun, 14 Aug 2016 12:09:44 +0000 (13:09 +0100)
committerDan Brown <redacted>
Sun, 14 Aug 2016 12:09:44 +0000 (13:09 +0100)
1  2 
.travis.yml
app/Repos/PageRepo.php
app/Services/ImageService.php
resources/assets/js/directives.js
resources/assets/js/global.js
resources/assets/js/pages/page-form.js
resources/views/pages/form.blade.php
resources/views/pages/restrictions.blade.php
resources/views/pages/revisions.blade.php
tests/Entity/EntityTest.php

diff --combined .travis.yml
index 83e9e10f5edff887590f9f3d7cbe8217252a4fc8,83e9e10f5edff887590f9f3d7cbe8217252a4fc8..bea8d179597f920b1fa59ebf9249d5faf1e15305
@@@ -25,6 -25,6 +25,7 @@@ before_script
    - composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
    - phpenv config-rm xdebug.ini
    - composer self-update
++  - composer dump-autoload --no-interaction
    - composer install --prefer-dist --no-interaction
    - npm install
    - ./node_modules/.bin/gulp
diff --combined app/Repos/PageRepo.php
index d3b71cebd1b895e941adc8e07acb61f7c72c8baa,3698e5efb925994a547b0252d3850369b639ca18..235246f823ef9696e1e97e934da79605dffe31b2
@@@ -157,8 -157,6 +157,8 @@@ class PageRepo extends EntityRep
          $draftPage->draft = false;
  
          $draftPage->save();
 +        $this->saveRevision($draftPage, 'Initial Publish');
 +        
          return $draftPage;
      }
  
       */
      public function updatePage(Page $page, $book_id, $input)
      {
 -        // Save a revision before updating
 -        if ($page->html !== $input['html'] || $page->name !== $input['name']) {
 -            $this->saveRevision($page);
 -        }
 +        // Hold the old details to compare later
 +        $oldHtml = $page->html;
 +        $oldName = $page->name;
  
          // Prevent slug being updated if no name change
          if ($page->name !== $input['name']) {
          // Remove all update drafts for this user & page.
          $this->userUpdateDraftsQuery($page, $userId)->delete();
  
 +        // Save a revision after updating
 +        if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
 +            $this->saveRevision($page, $input['summary']);
 +        }
 +
          return $page;
      }
  
      /**
       * Saves a page revision into the system.
       * @param Page $page
 +     * @param null|string $summary
       * @return $this
       */
 -    public function saveRevision(Page $page)
 +    public function saveRevision(Page $page, $summary = null)
      {
          $revision = $this->pageRevision->fill($page->toArray());
          if (setting('app-editor') !== 'markdown') $revision->markdown = '';
          $revision->created_by = auth()->user()->id;
          $revision->created_at = $page->updated_at;
          $revision->type = 'version';
 +        $revision->summary = $summary;
          $revision->save();
          // Clear old revisions
          if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
  
      /**
       * Gets a suitable slug for the resource
-      * @param            $name
-      * @param            $bookId
+      * @param string $name
+      * @param int $bookId
       * @param bool|false $currentId
       * @return string
       */
      public function findSuitableSlug($name, $bookId, $currentId = false)
      {
          $slug = Str::slug($name);
+         if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
          while ($this->doesSlugExist($slug, $bookId, $currentId)) {
              $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
          }
index c10e989f96a8284c319ee58c6a9eb7e655351559,d9bd61e9fb4ef79cf0174f610a1122d751e6adf1..4401cb230a8b4105ba5458be1e4e88f596803ccb
@@@ -95,7 -95,6 +95,7 @@@ class ImageServic
  
          try {
              $storage->put($fullPath, $imageData);
 +            $storage->setVisibility($fullPath, 'public');
          } catch (Exception $e) {
              throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
          }
  
          $thumbData = (string)$thumb->encode();
          $storage->put($thumbFilePath, $thumbData);
 +        $storage->setVisibility($thumbFilePath, 'public');
          $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 72);
  
          return $this->getPublicUrl($thumbFilePath);
              $storageUrl = config('filesystems.url');
  
              // Get the standard public s3 url if s3 is set as storage type
 +            // Uses the nice, short URL if bucket name has no periods in otherwise the longer
 +            // region-based url will be used to prevent http issues.
              if ($storageUrl == false && config('filesystems.default') === 's3') {
                  $storageDetails = config('filesystems.disks.s3');
 -                $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
 +                if (strpos($storageDetails['bucket'], '.') === false) {
 +                    $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
 +                } else {
 +                    $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
 +                }
              }
  
              $this->storageUrl = $storageUrl;
          }
  
-         return ($this->storageUrl == false ? '' : rtrim($this->storageUrl, '/')) . $filePath;
+         return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
      }
  
  
 -}
 +}
index e50f5c6dd2226abe44cc44bed195269e7d310489,897707af56df79481ff61df42e873fed75324598..fc7b88259eccb92b17e4a2305abe75d96bd66027
@@@ -1,10 -1,10 +1,10 @@@
  "use strict";
var DropZone = require('dropzone');
var markdown = require('marked');
const DropZone = require('dropzone');
const markdown = require('marked');
  
var toggleSwitchTemplate = require('./components/toggle-switch.html');
var imagePickerTemplate = require('./components/image-picker.html');
var dropZoneTemplate = require('./components/drop-zone.html');
const toggleSwitchTemplate = require('./components/toggle-switch.html');
const imagePickerTemplate = require('./components/image-picker.html');
const dropZoneTemplate = require('./components/drop-zone.html');
  
  module.exports = function (ngApp, events) {
  
@@@ -54,7 -54,7 +54,7 @@@
                  imageClass: '@'
              },
              link: function (scope, element, attrs) {
-                 var usingIds = typeof scope.currentId !== 'undefined' || scope.currentId === 'false';
+                 let usingIds = typeof scope.currentId !== 'undefined' || scope.currentId === 'false';
                  scope.image = scope.currentImage;
                  scope.value = scope.currentImage || '';
                  if (usingIds) scope.value = scope.currentId;
@@@ -80,7 -80,7 +80,7 @@@
                  };
  
                  scope.updateImageFromModel = function (model) {
-                     var isResized = scope.resizeWidth && scope.resizeHeight;
+                     let isResized = scope.resizeWidth && scope.resizeHeight;
  
                      if (!isResized) {
                          scope.$apply(() => {
@@@ -89,8 -89,9 +89,9 @@@
                          return;
                      }
  
-                     var cropped = scope.resizeCrop ? 'true' : 'false';
-                     var requestString = '/images/thumb/' + model.id + '/' + scope.resizeWidth + '/' + scope.resizeHeight + '/' + cropped;
+                     let cropped = scope.resizeCrop ? 'true' : 'false';
+                     let requestString = '/images/thumb/' + model.id + '/' + scope.resizeWidth + '/' + scope.resizeHeight + '/' + cropped;
+                     requestString = window.baseUrl(requestString);
                      $http.get(requestString).then((response) => {
                          setImage(model, response.data.url);
                      });
          return {
              restrict: 'A',
              link: function (scope, element, attrs) {
 -                var menu = element.find('ul');
 +                const menu = element.find('ul');
                  element.find('[dropdown-toggle]').on('click', function () {
                      menu.show().addClass('anim menuIn');
 +                    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');
              link: function (scope, element, attrs) {
  
                  // Set initial model content
-                 var content = element.val();
+                 element = element.find('textarea').first();
+                 let content = element.val();
                  scope.mdModel = content;
                  scope.mdChange(markdown(content));
  
-                 element.on('change input', (e) => {
+                 console.log('test');
+                 element.on('change input', (event) => {
                      content = element.val();
                      $timeout(() => {
                          scope.mdModel = content;
              link: function (scope, element, attrs) {
  
                  // Elements
-                 const input = element.find('textarea[markdown-input]');
+                 const input = element.find('[markdown-input] textarea').first();
                  const display = element.find('.markdown-display').first();
                  const insertImage = element.find('button[data-action="insertImage"]');
  
                      // Insert image shortcut
                      if (event.which === 73 && event.ctrlKey && event.shiftKey) {
                          event.preventDefault();
-                         var caretPos = input[0].selectionStart;
-                         var currentContent = input.val();
-                         var mdImageText = "![](http://)";
+                         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);
                  // Insert image from image manager
                  insertImage.click(event => {
                      window.ImageManager.showExternal(image => {
-                         var caretPos = currentCaretPos;
-                         var currentContent = input.val();
-                         var mdImageText = "![" + image.name + "](" + image.url + ")";
+                         let caretPos = currentCaretPos;
+                         let currentContent = input.val();
+                         let mdImageText = "![" + image.name + "](" + image.url + ")";
                          input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
                          input.change();
                      });
                  // Get search url with correct types
                  function getSearchUrl() {
                      let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
-                     return `/ajax/search/entities?types=${types}`;
+                     return window.baseUrl(`/ajax/search/entities?types=${types}`);
                  }
  
                  // Get initial contents
index eeb1e4ea735c75d6e25e2f4024ee0b2b4186224d,1c300ad26b209a671146bc657e2176308823c2bb..3a107afa8d29584fdf397fadee2fefac1d95135c
@@@ -7,15 -7,20 +7,23 @@@ var ngAnimate = require('angular-animat
  var ngSanitize = require('angular-sanitize');
  require('angular-ui-sortable');
  
+ // Url retrieval function
+ window.baseUrl = function(path) {
+     let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content');
+     if (basePath[basePath.length-1] === '/') basePath = basePath.slice(0, basePath.length-1);
+     if (path[0] === '/') path = path.slice(1);
+     return basePath + '/' + path;
+ };
  var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
  
  // Global Event System
 -var Events = {
 -    listeners: {},
 -    emit: function (eventName, eventData) {
 +class Events {
 +    constructor() {
 +        this.listeners = {};
 +    }
 +
 +    emit(eventName, eventData) {
          if (typeof this.listeners[eventName] === 'undefined') return this;
          var eventsToStart = this.listeners[eventName];
          for (let i = 0; i < eventsToStart.length; i++) {
              event(eventData);
          }
          return this;
 -    },
 -    listen: function (eventName, callback) {
 +    }
 +
 +    listen(eventName, callback) {
          if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
          this.listeners[eventName].push(callback);
          return this;
      }
  };
 -window.Events = Events;
 +window.Events = new Events();
  
  var services = require('./services')(ngApp, Events);
  var directives = require('./directives')(ngApp, Events);
  var controllers = require('./controllers')(ngApp, Events);
  // Smooth scrolling
  jQuery.fn.smoothScrollTo = function () {
      if (this.length === 0) return;
 -    $('body').animate({
 +    let scrollElem = document.documentElement.scrollTop === 0 ?  document.body : document.documentElement;
 +    $(scrollElem).animate({
          scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin
      }, 800); // Adjust to change animations speed (ms)
      return this;
  };
  
  // Making contains text expression not worry about casing
 -$.expr[":"].contains = $.expr.createPseudo(function (arg) {
 +jQuery.expr[":"].contains = $.expr.createPseudo(function (arg) {
      return function (elem) {
          return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
      };
@@@ -100,14 -104,13 +109,14 @@@ $(function () 
      var scrollTop = document.getElementById('back-to-top');
      var scrollTopBreakpoint = 1200;
      window.addEventListener('scroll', function() {
 -        if (!scrollTopShowing && document.body.scrollTop > scrollTopBreakpoint) {
 +        let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
 +        if (!scrollTopShowing && scrollTopPos > scrollTopBreakpoint) {
              scrollTop.style.display = 'block';
              scrollTopShowing = true;
              setTimeout(() => {
                  scrollTop.style.opacity = 0.4;
              }, 1);
 -        } else if (scrollTopShowing && document.body.scrollTop < scrollTopBreakpoint) {
 +        } else if (scrollTopShowing && scrollTopPos < scrollTopBreakpoint) {
              scrollTop.style.opacity = 0;
              scrollTopShowing = false;
              setTimeout(() => {
index b2d5f0c5c0bd25c6d5a0a21c3cd366d0d14bd499,f8b314e9cb914e49ddbc214054af04988d086237..86678a1bada517ce3156569b1bf57f5a5ba223f2
@@@ -1,65 -1,8 +1,65 @@@
-             xhr.open('POST', '/images/gallery/upload');
 +"use strict";
 +
 +function editorPaste(e) {
 +    if (!e.clipboardData) return
 +    var items = e.clipboardData.items;
 +    if (!items) return;
 +    for (var i = 0; i < items.length; i++) {
 +        if (items[i].type.indexOf("image") !== -1) {
 +
 +            var file = items[i].getAsFile();
 +            var formData = new FormData();
 +            var ext = 'png';
 +            var xhr = new XMLHttpRequest();
 +
 +            if (file.name) {
 +                var fileNameMatches = file.name.match(/\.(.+)$/);
 +                if (fileNameMatches) {
 +                    ext = fileNameMatches[1];
 +                }
 +            }
 +
 +            var id = "image-" + Math.random().toString(16).slice(2);
 +            editor.execCommand('mceInsertContent', false, '<img src="/loading.gif" id="' + id + '">');
 +
 +            var 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 () {
 +                if (xhr.status === 200 || xhr.status === 201) {
 +                    var result = JSON.parse(xhr.responseText);
 +                    editor.dom.setAttrib(id, 'src', result.url);
 +                } else {
 +                    console.log('An error occured uploading the image');
 +                    console.log(xhr.responseText);
 +                    editor.dom.remove(id);
 +                }
 +            };
 +            xhr.send(formData);
 +        }
 +    }
 +}
 +
 +function registerEditorShortcuts(editor) {
 +    // Headers
 +    for (let i = 1; i < 5; i++) {
 +        editor.addShortcut('ctrl+' + i, '', ['FormatBlock', false, 'h' + i]);
 +    }
 +
 +    // Other block shortcuts
 +    editor.addShortcut('ctrl+q', '', ['FormatBlock', false, 'blockquote']);
 +    editor.addShortcut('ctrl+d', '', ['FormatBlock', false, 'p']);
 +    editor.addShortcut('ctrl+e', '', ['FormatBlock', false, 'pre']);
 +    editor.addShortcut('ctrl+s', '', ['FormatBlock', false, 'code']);
 +}
 +
  var mceOptions = module.exports = {
      selector: '#html-editor',
      content_css: [
-         '/css/styles.css',
-         '/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
+         window.baseUrl('/css/styles.css'),
+         window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css')
      ],
      body_class: 'page-content',
      relative_urls: false,
              mceOptions.extraSetups[i](editor);
          }
  
 +        registerEditorShortcuts(editor);
 +
          (function () {
              var wrap;
  
          });
  
          // Paste image-uploads
 -        editor.on('paste', function (e) {
 -            if (e.clipboardData) {
 -                var items = e.clipboardData.items;
 -                if (items) {
 -                    for (var i = 0; i < items.length; i++) {
 -                        if (items[i].type.indexOf("image") !== -1) {
 -
 -                            var file = items[i].getAsFile();
 -                            var formData = new FormData();
 -                            var ext = 'png';
 -                            var xhr = new XMLHttpRequest();
 -
 -                            if (file.name) {
 -                                var fileNameMatches = file.name.match(/\.(.+)$/);
 -                                if (fileNameMatches) {
 -                                    ext = fileNameMatches[1];
 -                                }
 -                            }
 -
 -                            var id = "image-" + Math.random().toString(16).slice(2);
 -                            editor.execCommand('mceInsertContent', false, '<img src="/loading.gif" id="' + id + '">');
 -
 -                            var 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 () {
 -                                if (xhr.status === 200 || xhr.status === 201) {
 -                                    var result = JSON.parse(xhr.responseText);
 -                                    editor.dom.setAttrib(id, 'src', result.url);
 -                                } else {
 -                                    console.log('An error occured uploading the image');
 -                                    console.log(xhr.responseText);
 -                                    editor.dom.remove(id);
 -                                }
 -                            };
 -                            xhr.send(formData);
 -                        }
 -                    }
 -                }
 -
 -            }
 -        });
 +        editor.on('paste', editorPaste);
      }
  };
index a5558f8e435f6178cb3f2de25ab92b954bc9328f,18a9868c7aecd5734acedcb89df35aa8b063cde5..366316b339cf2d472922907dc3a6d93eb553b3c5
                                  <a ng-click="forceDraftSave()" class="text-pos"><i class="zmdi zmdi-save"></i>Save Draft</a>
                              </li>
                              <li ng-if="isNewPageDraft">
-                                 <a href="{{$model->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete Draft</a>
+                                 <a href="{{ $model->getUrl('/delete') }}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete Draft</a>
                              </li>
 +                            <li>
 +                                <a type="button" ng-if="isUpdateDraft" ng-click="discardDraft()" class="text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</a>
 +                            </li>
                          </ul>
                      </div>
                  </div>
                  <div class="col-sm-4 faded">
                      <div class="action-buttons" ng-cloak>
 +                        <div dropdown class="dropdown-container">
 +                            <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-edit"></i> @{{(changeSummary | limitTo:16) + (changeSummary.length>16?'...':'') || 'Set Changelog'}}</a>
 +                            <ul class="wide">
 +                                <li class="padded">
 +                                    <p class="text-muted">Enter a brief description of the changes you've made</p>
 +                                    <input name="summary" id="summary-input" type="text" placeholder="Enter Changelog" ng-model="changeSummary" />
 +                                </li>
 +                            </ul>
 +                        </div>
  
 -                        <button type="button" ng-if="isUpdateDraft" ng-click="discardDraft()" class="text-button text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</button>
 -                        <button type="submit" id="save-button" class="text-button  text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button>
 +                        <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button>
                      </div>
                  </div>
              </div>
  
      <div class="edit-area flex-fill flex">
          @if(setting('app-editor') === 'wysiwyg')
-             <textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent"  name="html" rows="5"
-                       @if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
+             <div tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" class="flex-fill flex">
+                 <textarea id="html-editor"   name="html" rows="5" ng-non-bindable
+                           @if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
+             </div>
              @if($errors->has('html'))
                  <div class="text-neg text-small">{{ $errors->first('html') }}</div>
              @endif
                              <button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
                          </div>
                      </div>
-                     <textarea markdown-input md-change="editorChange" id="markdown-editor-input" md-model="editContent"  name="markdown" rows="5"
-                               @if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
+                     <div markdown-input md-change="editorChange" md-model="editContent" class="flex flex-fill">
+                         <textarea ng-non-bindable id="markdown-editor-input"  name="markdown" rows="5"
+                                   @if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
+                     </div>
                  </div>
  
                  <div class="markdown-editor-wrap">
index 8dccc021eb556d8008c16a294bb004707029d692,8eca486c3d13ca8f5bdc50cc70addc7c9f62cbbf..bd88919dfa83c23571e723dca0bcdd1d74ef99c9
@@@ -7,16 -7,16 +7,16 @@@
              <div class="row">
                  <div class="col-sm-12 faded">
                      <div class="breadcrumbs">
-                         <a href="{{$page->book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}</a>
+                         <a href="{{ $page->book->getUrl() }}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}</a>
                          @if($page->hasChapter())
                              <span class="sep">&raquo;</span>
                              <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
                                  <i class="zmdi zmdi-collection-bookmark"></i>
-                                 {{$page->chapter->getShortName()}}
+                                 {{ $page->chapter->getShortName() }}
                              </a>
                          @endif
                          <span class="sep">&raquo;</span>
-                         <a href="{{$page->getUrl()}}" class="text-page text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
 -                        <a href="{{ $page->getUrl() }}" class="text-book text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
++                        <a href="{{ $page->getUrl() }}" class="text-page text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
                      </div>
                  </div>
              </div>
index d92b97d629234cf64b324d6178f0b708ba4538d5,03fb23673c09f0fd7c935fe48e1d8ce4813ce4f1..926affffc3824d969518ae57383ed19a53726b00
@@@ -5,59 -5,45 +5,59 @@@
      <div class="faded-small toolbar">
          <div class="container">
              <div class="row">
 -                <div class="col-md-6 faded">
 +                <div class="col-sm-12 faded">
                      <div class="breadcrumbs">
-                         <a href="{{$page->book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}</a>
 -                        <a href="{{ $page->getUrl() }}" class="text-primary text-button"><i class="zmdi zmdi-arrow-left"></i>Back to page</a>
++                        <a href="{{ $page->book->getUrl() }}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}</a>
 +                        @if($page->hasChapter())
 +                            <span class="sep">&raquo;</span>
 +                            <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
 +                                <i class="zmdi zmdi-collection-bookmark"></i>
-                                 {{$page->chapter->getShortName()}}
++                                {{ $page->chapter->getShortName() }}
 +                            </a>
 +                        @endif
 +                        <span class="sep">&raquo;</span>
-                         <a href="{{$page->getUrl()}}" class="text-page text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
++                        <a href="{{ $page->getUrl() }}" class="text-page text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
                      </div>
                  </div>
 -                <div class="col-md-6 faded">
 -                </div>
              </div>
          </div>
      </div>
  
  
 -    <div class="container small" ng-non-bindable>
 +
 +    <div class="container" ng-non-bindable>
          <h1>Page Revisions <span class="subheader">For "{{ $page->name }}"</span></h1>
  
          @if(count($page->revisions) > 0)
  
              <table class="table">
                  <tr>
 -                    <th width="40%">Name</th>
 -                    <th colspan="2" width="20%">Created By</th>
 -                    <th width="20%">Revision Date</th>
 -                    <th width="20%">Actions</th>
 +                    <th width="25%">Name</th>
 +                    <th colspan="2" width="10%">Created By</th>
 +                    <th width="15%">Revision Date</th>
 +                    <th width="25%">Changelog</th>
 +                    <th width="15%">Actions</th>
                  </tr>
 -                @foreach($page->revisions as $revision)
 +                @foreach($page->revisions as $index => $revision)
                      <tr>
--                        <td>{{$revision->name}}</td>
++                        <td>{{ $revision->name }}</td>
                          <td style="line-height: 0;">
                              @if($revision->createdBy)
--                                <img class="avatar" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{$revision->createdBy->name}}">
++                                <img class="avatar" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{ $revision->createdBy->name }}">
                              @endif
                          </td>
--                        <td> @if($revision->createdBy) {{$revision->createdBy->name}} @else Deleted User @endif</td>
--                        <td><small>{{$revision->created_at->format('jS F, Y H:i:s')}} <br> ({{$revision->created_at->diffForHumans()}})</small></td>
-                         <td>{{$revision->summary}}</td>
 -                        <td>
 -                            <a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
 -                            <span class="text-muted">&nbsp;|&nbsp;</span>
 -                            <a href="{{ $revision->getUrl('/restore') }}">Restore</a>
 -                        </td>
++                        <td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif</td>
++                        <td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
++                        <td>{{ $revision->summary }}</td>
 +                        @if ($index !== 0)
 +                            <td>
-                                 <a href="{{$revision->getUrl()}}" target="_blank">Preview</a>
++                                <a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
 +                                <span class="text-muted">&nbsp;|&nbsp;</span>
-                                 <a href="{{$revision->getUrl()}}/restore">Restore</a>
++                                <a href="{{ $revision->getUrl() }}/restore">Restore</a>
 +                            </td>
 +                        @else
 +                            <td><a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a></td>
 +                        @endif
                      </tr>
                  @endforeach
              </table>
index 8c0c286a6c271939567eb73445df77e9c94ff775,71d83dd47237efaedd2830af575f8c0f445fe78a..296aa72edc1491ae703e3fab90a92b15db85cf7e
@@@ -151,8 -151,10 +151,10 @@@ class EntityTest extends TestCas
              ->visit('/books/create')
              ->type($book->name, '#name')
              ->type($book->description, '#description')
-             ->press('Save Book')
-             ->seePageIs('/books/my-first-book-2');
+             ->press('Save Book');
+         
+         $expectedPattern = '/\/books\/my-first-book-[0-9a-zA-Z]{3}/';
+         $this->assertRegExp($expectedPattern, $this->currentUri, "Did not land on expected page [$expectedPattern].\n");
  
          $book = \BookStack\Book::where('slug', '=', 'my-first-book')->first();
          return $book;
  
      public function test_old_page_slugs_redirect_to_new_pages()
      {
 -        $page = \BookStack\Page::all()->first();
 +        $page = \BookStack\Page::first();
          $pageUrl = $page->getUrl();
          $newPageUrl = '/books/' . $page->book->slug . '/page/super-test-page';
 +        // Need to save twice since revisions are not generated in seeder.
          $this->asAdmin()->visit($pageUrl)
 +            ->clickInElement('#content', 'Edit')
 +            ->type('super test', '#name')
 +            ->press('Save Page');
 +
 +        $page = \BookStack\Page::first();
 +        $pageUrl = $page->getUrl();
 +
 +        // Second Save
 +        $this->visit($pageUrl)
              ->clickInElement('#content', 'Edit')
              ->type('super test page', '#name')
              ->press('Save Page')
 +            // Check redirect
              ->seePageIs($newPageUrl)
              ->visit($pageUrl)
              ->seePageIs($newPageUrl);