--- /dev/null
+### For Feature Requests
+Desired Feature:
+
+### For Bug Reports
+PHP Version:
+
+MySQL Version:
+
+Expected Behavior:
+
+Actual Behavior:
- 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
class PageRevision extends Model
{
- protected $fillable = ['name', 'html', 'text', 'markdown'];
+ protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
/**
* Get the user that created the page revision
$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) {
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;
}
-}
\ No newline at end of file
+}
--- /dev/null
+<?php
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddSummaryToPageRevisions extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('page_revisions', function ($table) {
+ $table->string('summary')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('page_revisions', function ($table) {
+ $table->dropColumn('summary');
+ });
+ }
+}
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');
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);
// 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;
};
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(() => {
+"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: [
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);
}
};
\ No newline at end of file
&.fluid {
max-width: 100%;
}
+ &.medium {
+ max-width: 992px;
+ }
&.small {
max-width: 840px;
}
text-decoration: none;
}
}
+
}
.faded span.faded-text {
.text-muted {
color: #999;
}
+ li.padded {
+ padding: $-xs $-m;
+ }
a {
display: block;
padding: $-xs $-m;
background-color: #EEE;
}
i {
- margin-right: $-m;
+ margin-right: $-s;
padding-right: 0;
- display: inline;
- width: 22px;
+ display: inline-block;
+ width: 16px;
}
}
li.border-bottom {
<li ng-if="isNewPageDraft">
<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>
</a>
@endif
<span class="sep">»</span>
- <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>
<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->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">»</span>
+ <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
+ <i class="zmdi zmdi-collection-bookmark"></i>
+ {{ $page->chapter->getShortName() }}
+ </a>
+ @endif
+ <span class="sep">»</span>
+ <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>
- <a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
- <span class="text-muted"> | </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>
+ <span class="text-muted"> | </span>
+ <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>
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);