Merge branch 'master' into release

This commit is contained in:
Dan Brown 2016-07-03 10:52:04 +01:00
commit 7113807f12
57 changed files with 1049 additions and 184 deletions

View File

@ -1,3 +1,5 @@
dist: trusty
sudo: required
language: php
php:
- 7.0
@ -5,15 +7,21 @@ php:
cache:
directories:
- vendor
- node_modules
- $HOME/.composer/cache
addons:
mariadb: '10.0'
apt:
packages:
- mysql-server-5.6
- mysql-client-core-5.6
- mysql-client-5.6
before_install:
- npm install -g npm@latest
before_script:
- mysql -e 'create database `bookstack-test`;'
- mysql -u root -e 'create database `bookstack-test`;'
- composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
- phpenv config-rm xdebug.ini
- composer self-update

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Dan Brown
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -44,7 +44,7 @@ class Activity extends Model
* @return bool
*/
public function isSimilarTo($activityB) {
return [$this->key, $this->entitiy_type, $this->entitiy_id] === [$activityB->key, $activityB->entitiy_type, $activityB->entitiy_id];
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
}
}

View File

@ -59,7 +59,7 @@ class ChapterController extends Controller
$input = $request->all();
$input['priority'] = $this->bookRepo->getNewPriority($book);
$chapter = $this->chapterRepo->createFromInput($request->all(), $book);
$chapter = $this->chapterRepo->createFromInput($input, $book);
Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl());
}
@ -154,6 +154,63 @@ class ChapterController extends Controller
return redirect($book->getUrl());
}
/**
* Show the page for moving a chapter.
* @param $bookSlug
* @param $chapterSlug
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*/
public function showMove($bookSlug, $chapterSlug) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
return view('chapters/move', [
'chapter' => $chapter,
'book' => $book
]);
}
/**
* Perform the move action for a chapter.
* @param $bookSlug
* @param $chapterSlug
* @param Request $request
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*/
public function move($bookSlug, $chapterSlug, Request $request) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($chapter->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book was not found');
return redirect()->back();
}
$this->chapterRepo->changeBook($parent->id, $chapter);
Activity::add($chapter, 'chapter_move', $chapter->book->id);
session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));
return redirect($chapter->getUrl());
}
/**
* Show the Restrictions view.
* @param $bookSlug

View File

@ -51,9 +51,9 @@ class ImageController extends Controller
$this->validate($request, [
'term' => 'required|string'
]);
$searchTerm = $request->get('term');
$imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm);
$imgData = $this->imageRepo->searchPaginatedByType($type, $page, 24, $searchTerm);
return response()->json($imgData);
}
@ -99,7 +99,7 @@ class ImageController extends Controller
{
$this->checkPermission('image-create-all');
$this->validate($request, [
'file' => 'image|mimes:jpeg,gif,png'
'file' => 'is_image'
]);
$imageUpload = $request->file('file');

View File

@ -92,7 +92,7 @@ class PageController extends Controller
$draftPage = $this->pageRepo->getById($pageId, true);
$chapterId = $draftPage->chapter_id;
$chapterId = intval($draftPage->chapter_id);
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
@ -221,8 +221,8 @@ class PageController extends Controller
$updateTime = $draft->updated_at->timestamp;
$utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
return response()->json([
'status' => 'success',
'message' => 'Draft saved at ',
'status' => 'success',
'message' => 'Draft saved at ',
'timestamp' => $utcUpdateTimestamp
]);
}
@ -450,6 +450,67 @@ class PageController extends Controller
]);
}
/**
* Show the view to choose a new parent to move a page into.
* @param $bookSlug
* @param $pageSlug
* @return mixed
* @throws NotFoundException
*/
public function showMove($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-update', $page);
return view('pages/move', [
'book' => $book,
'page' => $page
]);
}
/**
* Does the action of moving the location of a page
* @param $bookSlug
* @param $pageSlug
* @param Request $request
* @return mixed
* @throws NotFoundException
*/
public function move($bookSlug, $pageSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-update', $page);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($page->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'chapter') {
$parent = $this->chapterRepo->getById($entityId);
} else if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book or Chapter was not found');
return redirect()->back();
}
$this->pageRepo->changePageParent($page, $parent);
Activity::add($page, 'page_move', $page->book->id);
session()->flash('success', sprintf('Page moved to "%s"', $parent->name));
return redirect($page->getUrl());
}
/**
* Set the permissions for this page.
* @param $bookSlug

View File

@ -2,10 +2,10 @@
namespace BookStack\Http\Controllers;
use BookStack\Services\ViewService;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
@ -15,18 +15,21 @@ class SearchController extends Controller
protected $pageRepo;
protected $bookRepo;
protected $chapterRepo;
protected $viewService;
/**
* SearchController constructor.
* @param $pageRepo
* @param $bookRepo
* @param $chapterRepo
* @param PageRepo $pageRepo
* @param BookRepo $bookRepo
* @param ChapterRepo $chapterRepo
* @param ViewService $viewService
*/
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService)
{
$this->pageRepo = $pageRepo;
$this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo;
$this->viewService = $viewService;
parent::__construct();
}
@ -48,9 +51,9 @@ class SearchController extends Controller
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends);
$this->setPageTitle('Search For ' . $searchTerm);
return view('search/all', [
'pages' => $pages,
'books' => $books,
'chapters' => $chapters,
'pages' => $pages,
'books' => $books,
'chapters' => $chapters,
'searchTerm' => $searchTerm
]);
}
@ -69,8 +72,8 @@ class SearchController extends Controller
$pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Page Search For ' . $searchTerm);
return view('search/entity-search-list', [
'entities' => $pages,
'title' => 'Page Search Results',
'entities' => $pages,
'title' => 'Page Search Results',
'searchTerm' => $searchTerm
]);
}
@ -89,8 +92,8 @@ class SearchController extends Controller
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Chapter Search For ' . $searchTerm);
return view('search/entity-search-list', [
'entities' => $chapters,
'title' => 'Chapter Search Results',
'entities' => $chapters,
'title' => 'Chapter Search Results',
'searchTerm' => $searchTerm
]);
}
@ -109,8 +112,8 @@ class SearchController extends Controller
$books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends);
$this->setPageTitle('Book Search For ' . $searchTerm);
return view('search/entity-search-list', [
'entities' => $books,
'title' => 'Book Search Results',
'entities' => $books,
'title' => 'Book Search Results',
'searchTerm' => $searchTerm
]);
}
@ -134,4 +137,35 @@ class SearchController extends Controller
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
}
/**
* Search for a list of entities and return a partial HTML response of matching entities.
* Returns the most popular entities if no search is provided.
* @param Request $request
* @return mixed
*/
public function searchEntitiesAjax(Request $request)
{
$entities = collect();
$entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']);
$searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false;
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items());
$entities = $entities->sortByDesc('title_relevance');
} else {
$entityNames = $entityTypes->map(function ($type) {
return 'BookStack\\' . ucfirst($type);
})->toArray();
$entities = $this->viewService->getPopular(20, 0, $entityNames);
}
return view('search/entity-ajax-list', ['entities' => $entities]);
}
}

View File

@ -55,7 +55,7 @@ class TagController extends Controller
*/
public function getNameSuggestions(Request $request)
{
$searchTerm = $request->get('search');
$searchTerm = $request->has('search') ? $request->get('search') : false;
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions);
}
@ -66,8 +66,9 @@ class TagController extends Controller
*/
public function getValueSuggestions(Request $request)
{
$searchTerm = $request->get('search');
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm);
$searchTerm = $request->has('search') ? $request->get('search') : false;
$tagName = $request->has('name') ? $request->get('name') : false;
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);
}

View File

@ -34,6 +34,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move');
Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict');
@ -53,6 +55,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update');
Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove');
Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move');
Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit');
Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict');
Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
@ -93,6 +97,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
});
Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
// Links
Route::get('/link/{id}', 'PageController@redirectFromLink');

View File

@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
//
// Custom validation methods
\Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
$imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp'];
return in_array($value->getMimeType(), $imageMimes);
});
}
/**

View File

@ -251,7 +251,10 @@ class BookRepo extends EntityRepo
}]);
$chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapters = $chapterQuery->get();
$children = $pages->merge($chapters);
$children = $pages->values();
foreach ($chapters as $chapter) {
$children->push($chapter);
}
$bookSlug = $book->slug;
$children->each(function ($child) use ($bookSlug) {

View File

@ -9,6 +9,18 @@ use BookStack\Chapter;
class ChapterRepo extends EntityRepo
{
protected $pageRepo;
/**
* ChapterRepo constructor.
* @param $pageRepo
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Base query for getting chapters, Takes permissions into account.
* @return mixed
@ -189,12 +201,21 @@ class ChapterRepo extends EntityRepo
public function changeBook($bookId, Chapter $chapter)
{
$chapter->book_id = $bookId;
// Update related activity
foreach ($chapter->activity as $activity) {
$activity->book_id = $bookId;
$activity->save();
}
$chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
$chapter->save();
// Update all child pages
foreach ($chapter->pages as $page) {
$this->pageRepo->changeBook($bookId, $page);
}
// Update permissions
$chapter->load('book');
$this->permissionService->buildJointPermissionsForEntity($chapter->book);
return $chapter;
}

View File

@ -3,6 +3,7 @@
use Activity;
use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon;
use DOMDocument;
@ -572,6 +573,22 @@ class PageRepo extends EntityRepo
return $page;
}
/**
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
*/
public function changePageParent(Page $page, Entity $parent)
{
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
$page = $this->changeBook($book->id, $page);
$page->load('book');
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Gets a suitable slug for the resource
* @param $name

View File

@ -58,29 +58,48 @@ class TagRepo
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
* @param $searchTerm
* @return array
*/
public function getNameSuggestions($searchTerm)
public function getNameSuggestions($searchTerm = false)
{
if ($searchTerm === '') return [];
$query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc');
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
}
/**
* Get tag value suggestions from scanning existing tag values.
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
* @param $searchTerm
* @param $tagName
* @return array
*/
public function getValueSuggestions($searchTerm)
public function getValueSuggestions($searchTerm = false, $tagName = false)
{
if ($searchTerm === '') return [];
$query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
if ($tagName !== false) $query = $query->where('name', '=', $tagName);
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
}
/**
* Save an array of tags to an entity
* @param Entity $entity

View File

@ -90,7 +90,7 @@ class ActivityService
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activityList);
}

View File

@ -4,6 +4,7 @@ use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\JointPermission;
use BookStack\Ownable;
use BookStack\Page;
use BookStack\Role;
use BookStack\User;
@ -307,16 +308,16 @@ class PermissionService
/**
* Checks if an entity has a restriction set upon it.
* @param Entity $entity
* @param Ownable $ownable
* @param $permission
* @return bool
*/
public function checkEntityUserAccess(Entity $entity, $permission)
public function checkOwnableUserAccess(Ownable $ownable, $permission)
{
if ($this->isAdmin) return true;
$explodedPermission = explode('-', $permission);
$baseQuery = $entity->where('id', '=', $entity->id);
$baseQuery = $ownable->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$this->currentAction = $action;
@ -327,7 +328,7 @@ class PermissionService
$allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
$ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
$this->currentAction = 'view';
$isOwner = $this->currentUser && $this->currentUser->id === $entity->created_by;
$isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
}

View File

@ -50,7 +50,7 @@ class ViewService
* Get the entities with the most views.
* @param int $count
* @param int $page
* @param bool|false $filterModel
* @param bool|false|array $filterModel
*/
public function getPopular($count = 10, $page = 0, $filterModel = false)
{
@ -60,7 +60,11 @@ class ViewService
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel));
if ($filterModel && is_array($filterModel)) {
$query->whereIn('viewable_type', $filterModel);
} else if ($filterModel) {
$query->where('viewable_type', '=', get_class($filterModel));
};
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
}

View File

@ -1,5 +1,7 @@
<?php
use BookStack\Ownable;
if (!function_exists('versioned_asset')) {
/**
* Get the path to a versioned file.
@ -34,18 +36,18 @@ if (!function_exists('versioned_asset')) {
* If an ownable element is passed in the jointPermissions are checked against
* that particular item.
* @param $permission
* @param \BookStack\Ownable $ownable
* @param Ownable $ownable
* @return mixed
*/
function userCan($permission, \BookStack\Ownable $ownable = null)
function userCan($permission, Ownable $ownable = null)
{
if ($ownable === null) {
return auth()->user() && auth()->user()->can($permission);
}
// Check permission on ownable item
$permissionService = app('BookStack\Services\PermissionService');
return $permissionService->checkEntityUserAccess($ownable, $permission);
$permissionService = app(\BookStack\Services\PermissionService::class);
return $permissionService->checkOwnableUserAccess($ownable, $permission);
}
/**

View File

@ -5,6 +5,8 @@
*/
return [
'app-editor' => 'wysiwyg'
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)'
];

View File

@ -12,7 +12,13 @@ class CreateBooksTable extends Migration
*/
public function up()
{
Schema::create('books', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('books', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->string('name');
$table->string('slug')->indexed();

View File

@ -12,7 +12,13 @@ class CreatePagesTable extends Migration
*/
public function up()
{
Schema::create('pages', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('pages', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->integer('book_id');
$table->integer('chapter_id');

View File

@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration
*/
public function up()
{
Schema::create('chapters', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('chapters', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->integer('book_id');
$table->string('slug')->indexed();

View File

@ -1,5 +1,7 @@
# BookStack
[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.

View File

@ -379,6 +379,15 @@ module.exports = function (ngApp, events) {
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();
}
});
/**
* Discard the current draft and grab the current page
* content from the system via an AJAX request.

View File

@ -149,7 +149,10 @@ module.exports = function (ngApp, events) {
};
}]);
/**
* Dropdown
* Provides some simple logic to create small dropdown menus
*/
ngApp.directive('dropdown', [function () {
return {
restrict: 'A',
@ -166,7 +169,11 @@ module.exports = function (ngApp, events) {
};
}]);
ngApp.directive('tinymce', ['$timeout', function($timeout) {
/**
* TinyMCE
* An angular wrapper around the tinyMCE editor.
*/
ngApp.directive('tinymce', ['$timeout', function ($timeout) {
return {
restrict: 'A',
scope: {
@ -185,6 +192,10 @@ module.exports = function (ngApp, events) {
scope.mceChange(content);
});
editor.on('keydown', (event) => {
scope.$emit('editor-keydown', event);
});
editor.on('init', (e) => {
scope.mceModel = editor.getContent();
});
@ -200,8 +211,8 @@ module.exports = function (ngApp, events) {
scope.tinymce.extraSetups.push(tinyMceSetup);
// Custom tinyMCE plugins
tinymce.PluginManager.add('customhr', function(editor) {
editor.addCommand('InsertHorizontalRule', function() {
tinymce.PluginManager.add('customhr', function (editor) {
editor.addCommand('InsertHorizontalRule', function () {
var hrElem = document.createElement('hr');
var cNode = editor.selection.getNode();
var parentNode = cNode.parentNode;
@ -227,7 +238,11 @@ module.exports = function (ngApp, events) {
}
}]);
ngApp.directive('markdownInput', ['$timeout', function($timeout) {
/**
* Markdown input
* Handles the logic for just the editor input field.
*/
ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
return {
restrict: 'A',
scope: {
@ -251,7 +266,7 @@ module.exports = function (ngApp, events) {
scope.$on('markdown-update', (event, value) => {
element.val(value);
scope.mdModel= value;
scope.mdModel = value;
scope.mdChange(markdown(value));
});
@ -259,23 +274,59 @@ module.exports = function (ngApp, events) {
}
}]);
ngApp.directive('markdownEditor', ['$timeout', function($timeout) {
/**
* Markdown Editor
* Handles all functionality of the markdown editor.
*/
ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
return {
restrict: 'A',
link: function (scope, element, attrs) {
// Elements
var input = element.find('textarea[markdown-input]');
var insertImage = element.find('button[data-action="insertImage"]');
const input = element.find('textarea[markdown-input]');
const display = element.find('.markdown-display').first();
const insertImage = element.find('button[data-action="insertImage"]');
var currentCaretPos = 0;
let currentCaretPos = 0;
input.blur((event) => {
input.blur(event => {
currentCaretPos = input[0].selectionStart;
});
// Insert image shortcut
input.keydown((event) => {
// Scroll sync
let inputScrollHeight,
inputHeight,
displayScrollHeight,
displayHeight;
function setScrollHeights() {
inputScrollHeight = input[0].scrollHeight;
inputHeight = input.height();
displayScrollHeight = display[0].scrollHeight;
displayHeight = display.height();
}
setTimeout(() => {
setScrollHeights();
}, 200);
window.addEventListener('resize', setScrollHeights);
let scrollDebounceTime = 800;
let lastScroll = 0;
input.on('scroll', event => {
let now = Date.now();
if (now - lastScroll > scrollDebounceTime) {
setScrollHeights()
}
let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
display.scrollTop(displayScrollY);
lastScroll = now;
});
// Editor key-presses
input.keydown(event => {
// Insert image shortcut
if (event.which === 73 && event.ctrlKey && event.shiftKey) {
event.preventDefault();
var caretPos = input[0].selectionStart;
@ -285,12 +336,15 @@ module.exports = function (ngApp, events) {
input.focus();
input[0].selectionStart = caretPos + ("![](".length);
input[0].selectionEnd = caretPos + ('![](http://'.length);
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) => {
insertImage.click(event => {
window.ImageManager.showExternal(image => {
var caretPos = currentCaretPos;
var currentContent = input.val();
var mdImageText = "![" + image.name + "](" + image.url + ")";
@ -302,11 +356,16 @@ module.exports = function (ngApp, events) {
}
}
}]);
ngApp.directive('toolbox', [function() {
/**
* Page Editor Toolbox
* Controls all functionality for the sliding toolbox
* on the page edit view.
*/
ngApp.directive('toolbox', [function () {
return {
restrict: 'A',
link: function(scope, elem, attrs) {
link: function (scope, elem, attrs) {
// Get common elements
const $buttons = elem.find('[tab-button]');
@ -317,7 +376,7 @@ module.exports = function (ngApp, events) {
$toggle.click((e) => {
elem.toggleClass('open');
});
// Set an active tab/content by name
function setActive(tabName, openToolbox) {
$buttons.removeClass('active');
@ -331,7 +390,7 @@ module.exports = function (ngApp, events) {
setActive($content.first().attr('tab-content'), false);
// Handle tab button click
$buttons.click(function(e) {
$buttons.click(function (e) {
let name = $(this).attr('tab-button');
setActive(name, true);
});
@ -339,11 +398,16 @@ module.exports = function (ngApp, events) {
}
}]);
ngApp.directive('autosuggestions', ['$http', function($http) {
/**
* Tag Autosuggestions
* Listens to child inputs and provides autosuggestions depending on field type
* and input. Suggestions provided by server.
*/
ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
return {
restrict: 'A',
link: function(scope, elem, attrs) {
link: function (scope, elem, attrs) {
// Local storage for quick caching.
const localCache = {};
@ -360,38 +424,49 @@ module.exports = function (ngApp, events) {
let active = 0;
// Listen to input events on autosuggest fields
elem.on('input', '[autosuggest]', function(event) {
elem.on('input focus', '[autosuggest]', function (event) {
let $input = $(this);
let val = $input.val();
let url = $input.attr('autosuggest');
// No suggestions until at least 3 chars
if (val.length < 3) {
if (isShowing) {
$suggestionBox.hide();
isShowing = false;
let type = $input.attr('autosuggest-type');
// Add name param to request if for a value
if (type.toLowerCase() === 'value') {
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
let nameVal = $nameInput.val();
if (nameVal !== '') {
url += '?name=' + encodeURIComponent(nameVal);
}
return;
};
}
let suggestionPromise = getSuggestions(val.slice(0, 3), url);
suggestionPromise.then((suggestions) => {
if (val.length > 2) {
suggestions = suggestions.filter((item) => {
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
}).slice(0, 4);
displaySuggestions($input, suggestions);
}
suggestionPromise.then(suggestions => {
if (val.length === 0) {
displaySuggestions($input, suggestions.slice(0, 6));
} else {
suggestions = suggestions.filter(item => {
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
}).slice(0, 4);
displaySuggestions($input, suggestions);
}
});
});
// Hide autosuggestions when input loses focus.
// Slight delay to allow clicks.
elem.on('blur', '[autosuggest]', function(event) {
let lastFocusTime = 0;
elem.on('blur', '[autosuggest]', function (event) {
let startTime = Date.now();
setTimeout(() => {
$suggestionBox.hide();
isShowing = false;
if (lastFocusTime < startTime) {
$suggestionBox.hide();
isShowing = false;
}
}, 200)
});
elem.on('focus', '[autosuggest]', function (event) {
lastFocusTime = Date.now();
});
elem.on('keydown', '[autosuggest]', function (event) {
if (!isShowing) return;
@ -401,23 +476,25 @@ module.exports = function (ngApp, events) {
// Down arrow
if (event.keyCode === 40) {
let newActive = (active === suggestCount-1) ? 0 : active + 1;
let newActive = (active === suggestCount - 1) ? 0 : active + 1;
changeActiveTo(newActive, suggestionElems);
}
// Up arrow
else if (event.keyCode === 38) {
let newActive = (active === 0) ? suggestCount-1 : active - 1;
let newActive = (active === 0) ? suggestCount - 1 : active - 1;
changeActiveTo(newActive, suggestionElems);
}
// Enter key
else if (event.keyCode === 13) {
// Enter or tab key
else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
let text = suggestionElems[active].textContent;
currentInput[0].value = text;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
event.preventDefault();
return false;
if (event.keyCode === 13) {
event.preventDefault();
return false;
}
}
});
@ -430,6 +507,7 @@ module.exports = function (ngApp, events) {
// Display suggestions on a field
let prevSuggestions = [];
function displaySuggestions($input, suggestions) {
// Hide if no suggestions
@ -466,7 +544,8 @@ module.exports = function (ngApp, events) {
if (i === 0) {
suggestion.className = 'active'
active = 0;
};
}
;
$suggestionBox[0].appendChild(suggestion);
}
@ -484,17 +563,18 @@ module.exports = function (ngApp, events) {
// Get suggestions & cache
function getSuggestions(input, url) {
let searchUrl = url + '?search=' + encodeURIComponent(input);
let hasQuery = url.indexOf('?') !== -1;
let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
// Get from local cache if exists
if (localCache[searchUrl]) {
if (typeof localCache[searchUrl] !== 'undefined') {
return new Promise((resolve, reject) => {
resolve(localCache[input]);
resolve(localCache[searchUrl]);
});
}
return $http.get(searchUrl).then((response) => {
localCache[input] = response.data;
return $http.get(searchUrl).then(response => {
localCache[searchUrl] = response.data;
return response.data;
});
}
@ -502,6 +582,67 @@ module.exports = function (ngApp, events) {
}
}
}]);
ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
return {
restrict: 'A',
scope: true,
link: function (scope, element, attrs) {
scope.loading = true;
scope.entityResults = false;
scope.search = '';
// Add input for forms
const input = element.find('[entity-selector-input]').first();
// Listen to entity item clicks
element.on('click', '.entity-list a', function(event) {
event.preventDefault();
event.stopPropagation();
let item = $(this).closest('[data-entity-type]');
itemSelect(item);
});
element.on('click', '[data-entity-type]', function(event) {
itemSelect($(this));
});
// Select entity action
function itemSelect(item) {
let entityType = item.attr('data-entity-type');
let entityId = item.attr('data-entity-id');
let isSelected = !item.hasClass('selected');
element.find('.selected').removeClass('selected').removeClass('primary-background');
if (isSelected) item.addClass('selected').addClass('primary-background');
let newVal = isSelected ? `${entityType}:${entityId}` : '';
input.val(newVal);
}
// 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}`;
}
// Get initial contents
$http.get(getSearchUrl()).then(resp => {
scope.entityResults = $sce.trustAsHtml(resp.data);
scope.loading = false;
});
// Search when typing
scope.searchEntities = function() {
scope.loading = true;
input.val('');
let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
$http.get(url).then(resp => {
scope.entityResults = $sce.trustAsHtml(resp.data);
scope.loading = false;
});
};
}
};
}]);
};

View File

@ -112,16 +112,11 @@ $(function () {
// Common jQuery actions
$('[data-action="expand-entity-list-details"]').click(function() {
$('.entity-list.compact').find('p').slideToggle(240);
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
});
});
function elemExists(selector) {
return document.querySelector(selector) !== null;
}
// Page specific items
require('./pages/page-show');

View File

@ -1,7 +1,8 @@
var mceOptions = module.exports = {
selector: '#html-editor',
content_css: [
'/css/styles.css'
'/css/styles.css',
'/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
],
body_class: 'page-content',
relative_urls: false,
@ -19,11 +20,18 @@ var mceOptions = module.exports = {
{title: "Header 1", format: "h1"},
{title: "Header 2", format: "h2"},
{title: "Header 3", format: "h3"},
{title: "Paragraph", format: "p"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"},
{title: "Inline Code", icon: "code", inline: "code"}
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
]}
],
style_formats_merge: false,
formats: {
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},

View File

@ -74,15 +74,15 @@ window.setupPageShow = module.exports = function (pageId) {
// Make the book-tree sidebar stick in view on scroll
var $window = $(window);
var $bookTree = $(".book-tree");
var $bookTreeParent = $bookTree.parent();
// Check the page is scrollable and the content is taller than the tree
var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
// Get current tree's width and header height
var headerHeight = $("#header").height() + $(".toolbar").height();
var isFixed = $window.scrollTop() > headerHeight;
var bookTreeWidth = $bookTree.width();
// Function to fix the tree as a sidebar
function stickTree() {
$bookTree.width(bookTreeWidth + 48 + 15);
$bookTree.width($bookTreeParent.width() + 15);
$bookTree.addClass("fixed");
isFixed = true;
}
@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) {
unstickTree();
}
}
// The event ran when the window scrolls
function windowScrollEvent() {
checkTreeStickiness(false);
}
// If the page is scrollable and the window is wide enough listen to scroll events
// and evaluate tree stickiness.
if (pageScrollable && $window.width() > 1000) {
$window.scroll(function() {
checkTreeStickiness(false);
});
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
}
// Handle window resizing and switch between desktop/mobile views
$window.on('resize', event => {
if (pageScrollable && $window.width() > 1000) {
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
} else {
$window.off('scroll', windowScrollEvent);
unstickTree();
}
});
};

View File

@ -125,3 +125,51 @@
margin-right: $-xl;
}
}
/**
* Callouts
*/
.callout {
border-left: 3px solid #BBB;
background-color: #EEE;
padding: $-s;
&:before {
font-family: 'Material-Design-Iconic-Font';
padding-right: $-s;
display: inline-block;
}
&.success {
border-left-color: $positive;
background-color: lighten($positive, 45%);
color: darken($positive, 16%);
}
&.success:before {
content: '\f269';
}
&.danger {
border-left-color: $negative;
background-color: lighten($negative, 34%);
color: darken($negative, 20%);
}
&.danger:before {
content: '\f1f2';
}
&.info {
border-left-color: $info;
background-color: lighten($info, 50%);
color: darken($info, 16%);
}
&.info:before {
content: '\f1f8';
}
&.warning {
border-left-color: $warning;
background-color: lighten($warning, 36%);
color: darken($warning, 16%);
}
&.warning:before {
content: '\f1f1';
}
}

View File

@ -20,6 +20,9 @@
&.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
}
&:focus {
outline: 0;
}
}
#html-editor {

View File

@ -1,6 +1,7 @@
.page-list {
h3 {
margin: $-l 0 $-m 0;
margin: $-l 0 $-xs 0;
font-size: 1.666em;
}
a.chapter {
color: $color-chapter;
@ -8,7 +9,6 @@
.inset-list {
display: none;
overflow: hidden;
// padding-left: $-m;
margin-bottom: $-l;
}
h4 {
@ -338,6 +338,10 @@ ul.pagination {
padding-top: $-xs;
margin: 0;
}
> p.empty-text {
display: block;
font-size: $fs-m;
}
hr {
margin: 0;
}

View File

@ -48,7 +48,7 @@
max-width: 100%;
height:auto;
}
h1, h2, h3, h4, h5, h6 {
h1, h2, h3, h4, h5, h6, pre {
clear: left;
}
hr {

View File

@ -3,7 +3,7 @@
*/
h1 {
font-size: 3.625em;
font-size: 3.425em;
line-height: 1.22222222em;
margin-top: 0.48888889em;
margin-bottom: 0.48888889em;
@ -33,10 +33,10 @@ h1, h2, h3, h4 {
display: block;
color: #555;
.subheader {
display: block;
//display: block;
font-size: 0.5em;
line-height: 1em;
color: lighten($text-dark, 16%);
color: lighten($text-dark, 32%);
}
}
@ -225,6 +225,15 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
color: $color-chapter;
}
}
.faded .text-book:hover {
color: $color-book !important;
}
.faded .text-chapter:hover {
color: $color-chapter !important;
}
.faded .text-page:hover {
color: $color-page !important;
}
span.highlight {
//background-color: rgba($primary, 0.2);

View File

@ -38,6 +38,7 @@ $primary-dark: #0288D1;
$secondary: #e27b41;
$positive: #52A256;
$negative: #E84F4F;
$info: $primary;
$warning: $secondary;
$primary-faded: rgba(21, 101, 192, 0.15);

View File

@ -207,3 +207,59 @@ $btt-size: 40px;
color: #EEE;
}
}
.entity-selector {
border: 1px solid #DDD;
border-radius: 3px;
overflow: hidden;
font-size: 0.8em;
input[type="text"] {
width: 100%;
display: block;
border-radius: 0;
border: 0;
border-bottom: 1px solid #DDD;
font-size: 16px;
padding: $-s $-m;
}
.entity-list {
overflow-y: scroll;
height: 400px;
background-color: #EEEEEE;
}
.loading {
height: 400px;
padding-top: $-l;
}
.entity-list > p {
text-align: center;
padding-top: $-l;
font-size: 1.333em;
}
.entity-list > div {
padding-left: $-m;
padding-right: $-m;
background-color: #FFF;
transition: all ease-in-out 120ms;
cursor: pointer;
}
}
.entity-list-item.selected {
h3, i, p ,a, span {
color: #EEE;
}
}

View File

@ -4,7 +4,7 @@ return [
/**
* Activity text strings.
* Is used for all the text within activity logs.
* Is used for all the text within activity logs & notifications.
*/
// Pages
@ -16,6 +16,7 @@ return [
'page_delete_notification' => 'Page Successfully Deleted',
'page_restore' => 'restored page',
'page_restore_notification' => 'Page Successfully Restored',
'page_move' => 'moved page',
// Chapters
'chapter_create' => 'created chapter',
@ -24,6 +25,7 @@ return [
'chapter_update_notification' => 'Chapter Successfully Updated',
'chapter_delete' => 'deleted chapter',
'chapter_delete_notification' => 'Chapter Successfully Deleted',
'chapter_move' => 'moved chapter',
// Books
'book_create' => 'created book',

View File

@ -1,4 +1,4 @@
<div class="book">
<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
<h3 class="text-book"><a class="text-book" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i>{{$book->name}}</a></h3>
@if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p>

View File

@ -1,5 +1,11 @@
<div class="chapter">
<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
<h3>
@if (isset($showPath) && $showPath)
<a href="{{ $chapter->book->getUrl() }}" class="text-book">
<i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
</a>
<span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
@endif
<a href="{{ $chapter->getUrl() }}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
</a>

View File

@ -0,0 +1,33 @@
@extends('base')
@section('content')
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-sm-12 faded">
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
<span class="sep">&raquo;</span>
<a href="{{$chapter->getUrl()}}" class="text-chapter text-button"><i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->getShortName() }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<h1>Move Chapter <small class="subheader">{{$chapter->name}}</small></h1>
<form action="{{ $chapter->getUrl() }}/move" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="PUT">
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book'])
<a href="{{ $chapter->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Move Chapter</button>
</form>
</div>
@stop

View File

@ -2,15 +2,15 @@
@section('content')
<div class="faded-small toolbar" ng-non-bindable>
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-md-4 faded">
<div class="col-sm-8 faded" ng-non-bindable>
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
</div>
</div>
<div class="col-md-8 faded">
<div class="col-sm-4 faded">
<div class="action-buttons">
@if(userCan('page-create', $chapter))
<a href="{{$chapter->getUrl() . '/create-page'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>New Page</a>
@ -18,11 +18,21 @@
@if(userCan('chapter-update', $chapter))
<a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('restrictions-manage', $chapter))
<a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
@endif
@if(userCan('chapter-delete', $chapter))
<a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
@if(userCan('chapter-update', $chapter) || userCan('restrictions-manage', $chapter) || userCan('chapter-delete', $chapter))
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
<ul>
@if(userCan('chapter-update', $chapter))
<li><a href="{{$chapter->getUrl() . '/move'}}" class="text-primary"><i class="zmdi zmdi-folder"></i>Move</a></li>
@endif
@if(userCan('restrictions-manage', $chapter))
<li><a href="{{$chapter->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
@endif
@if(userCan('chapter-delete', $chapter))
<li><a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
@endif
</ul>
</div>
@endif
</div>
</div>

View File

@ -34,18 +34,30 @@
@else
<h3>Recent Books</h3>
@endif
@include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => $signedIn ? 'You have not viewed any pages' : 'No books have been created'
])
</div>
<div class="col-sm-4">
<h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
<div id="recently-created-pages">
@include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recentlyCreatedPages,
'style' => 'compact',
'emptyText' => 'No pages have been recently created'
])
</div>
<h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
<div id="recently-updated-pages">
@include('partials/entity-list', ['entities' => $recentlyUpdatedPages, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => 'No pages have been recently updated'
])
</div>
</div>

View File

@ -10,12 +10,12 @@
<h4>Page Tags</h4>
<div class="padded tags">
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
<table class="no-style" autosuggestions style="width: 100%;">
<table class="no-style" tag-autosuggestions style="width: 100%;">
<tbody ui-sortable="sortOptions" ng-model="tags" >
<tr ng-repeat="tag in tags track by $index">
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
<td><input autosuggest="/ajax/tags/suggest/names" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
<td><input autosuggest="/ajax/tags/suggest/values" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
<td><input autosuggest="/ajax/tags/suggest/names" autosuggest-type="name" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
<td><input autosuggest="/ajax/tags/suggest/values" autosuggest-type="value" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
<td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
</tr>
</tbody>

View File

@ -61,7 +61,7 @@
<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" md-model="editContent" name="markdown" rows="5"
<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>

View File

@ -1,4 +1,4 @@
<div class="page {{$page->draft ? 'draft' : ''}}">
<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
<h3>
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
</h3>
@ -11,11 +11,11 @@
@if(isset($style) && $style === 'detailed')
<div class="row meta text-muted text-small">
<div class="col-md-4">
<div class="col-md-6">
Created {{$page->created_at->diffForHumans()}} @if($page->createdBy)by {{$page->createdBy->name}}@endif <br>
Last updated {{ $page->updated_at->diffForHumans() }} @if($page->updatedBy)by {{$page->updatedBy->name}} @endif
</div>
<div class="col-md-8">
<div class="col-md-6">
<a class="text-book" href="{{ $page->book->getUrl() }}"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName(30) }}</a>
<br>
@if($page->chapter)

View File

@ -0,0 +1,40 @@
@extends('base')
@section('content')
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-sm-12 faded">
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $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()}}
</a>
@endif
<span class="sep">&raquo;</span>
<a href="{{$page->getUrl()}}" class="text-page text-button"><i class="zmdi zmdi-file-text"></i>{{ $page->getShortName() }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<h1>Move Page <small class="subheader">{{$page->name}}</small></h1>
<form action="{{ $page->getUrl() }}/move" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="PUT">
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter'])
<a href="{{ $page->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Move Page</button>
</form>
</div>
@stop

View File

@ -28,15 +28,26 @@
</ul>
</span>
@if(userCan('page-update', $page))
<a href="{{$page->getUrl()}}/revisions" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
<a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('restrictions-manage', $page))
<a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
@endif
@if(userCan('page-delete', $page))
<a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
@if(userCan('page-update', $page) || userCan('restrictions-manage', $page) || userCan('page-delete', $page))
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
<ul>
@if(userCan('page-update', $page))
<li><a href="{{$page->getUrl()}}/move" class="text-primary" ><i class="zmdi zmdi-folder"></i>Move</a></li>
<li><a href="{{$page->getUrl()}}/revisions" class="text-primary"><i class="zmdi zmdi-replay"></i>Revisions</a></li>
@endif
@if(userCan('restrictions-manage', $page))
<li><a href="{{$page->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
@endif
@if(userCan('page-delete', $page))
<li><a href="{{$page->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
@endif
</ul>
</div>
@endif
</div>
</div>
</div>

View File

@ -1,6 +1,4 @@
{{--Requires an entity to be passed with the name $entity--}}
@if(count($activity) > 0)
<div class="activity-list">
@foreach($activity as $activityItem)
@ -10,5 +8,5 @@
@endforeach
</div>
@else
<p class="text-muted">New activity will show up here.</p>
<p class="text-muted">No activity to show</p>
@endif

View File

@ -1,22 +1,20 @@
@if(Setting::get('app-color'))
<style>
header, #back-to-top, .primary-background {
background-color: {{ Setting::get('app-color') }};
}
.faded-small, .primary-background-light {
background-color: {{ Setting::get('app-color-light') }};
}
.button-base, .button, input[type="button"], input[type="submit"] {
background-color: {{ Setting::get('app-color') }};
}
.button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
background-color: {{ Setting::get('app-color') }};
}
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
border-bottom-color: {{ Setting::get('app-color') }};
}
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
color: {{ Setting::get('app-color') }};
}
</style>
@endif
<style>
header, #back-to-top, .primary-background {
background-color: {{ Setting::get('app-color') }} !important;
}
.faded-small, .primary-background-light {
background-color: {{ Setting::get('app-color-light') }};
}
.button-base, .button, input[type="button"], input[type="submit"] {
background-color: {{ Setting::get('app-color') }};
}
.button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
background-color: {{ Setting::get('app-color') }};
}
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
border-bottom-color: {{ Setting::get('app-color') }};
}
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
color: {{ Setting::get('app-color') }};
}
</style>

View File

@ -16,8 +16,8 @@
@endforeach
@else
<p class="text-muted">
No items available
<p class="text-muted empty-text">
{{ $emptyText or 'No items available' }}
</p>
@endif
</div>

View File

@ -0,0 +1,8 @@
<div class="form-group">
<div entity-selector class="entity-selector {{$selectorSize or ''}}" entity-types="{{ $entityTypes or 'book,chapter,page' }}">
<input type="hidden" entity-selector-input name="{{$name}}" value="">
<input type="text" placeholder="Search" ng-model="search" ng-model-options="{debounce: 200}" ng-change="searchEntities()">
<div class="text-center loading" ng-show="loading">@include('partials/loading-icon')</div>
<div ng-show="!loading" ng-bind-html="entityResults"></div>
</div>
</div>

View File

@ -1,12 +1,12 @@
<div class="notification anim pos" @if(!Session::has('success')) style="display:none;" @endif>
<i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(Session::get('success'))) !!}</span>
<div class="notification anim pos" @if(!session()->has('success')) style="display:none;" @endif>
<i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
</div>
<div class="notification anim warning stopped" @if(!Session::has('warning')) style="display:none;" @endif>
<i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(Session::get('warning'))) !!}</span>
<div class="notification anim warning stopped" @if(!session()->has('warning')) style="display:none;" @endif>
<i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
</div>
<div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif>
<i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(Session::get('error'))) !!}</span>
<div class="notification anim neg stopped" @if(!session()->has('error')) style="display:none;" @endif>
<i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
</div>

View File

@ -0,0 +1,22 @@
<div class="entity-list @if(isset($style)){{ $style }}@endif" ng-non-bindable>
@if(count($entities) > 0)
@foreach($entities as $index => $entity)
@if($entity->isA('page'))
@include('pages/list-item', ['page' => $entity])
@elseif($entity->isA('book'))
@include('books/list-item', ['book' => $entity])
@elseif($entity->isA('chapter'))
@include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true, 'showPath' => true])
@endif
@if($index !== count($entities) - 1)
<hr>
@endif
@endforeach
@else
<p class="text-muted">
No items available
</p>
@endif
</div>

View File

@ -82,4 +82,14 @@ class EntitySearchTest extends TestCase
$this->asAdmin()->visit('/search/books?term=' . $book->name)
->see('Book Search Results')->see('.entity-list', $book->name);
}
public function test_ajax_entity_search()
{
$page = \BookStack\Page::all()->last();
$notVisitedPage = \BookStack\Page::first();
$this->visit($page->getUrl());
$this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name);
$this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name);
$this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name);
}
}

View File

@ -22,4 +22,47 @@ class SortTest extends TestCase
->dontSee($draft->name);
}
public function test_page_move()
{
$page = \BookStack\Page::first();
$currentBook = $page->book;
$newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
$this->asAdmin()->visit($page->getUrl() . '/move')
->see('Move Page')->see($page->name)
->type('book:' . $newBook->id, 'entity_selection')->press('Move Page');
$page = \BookStack\Page::find($page->id);
$this->seePageIs($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
$this->visit($newBook->getUrl())
->seeInNthElement('.activity-list-item', 0, 'moved page')
->seeInNthElement('.activity-list-item', 0, $page->name);
}
public function test_chapter_move()
{
$chapter = \BookStack\Chapter::first();
$currentBook = $chapter->book;
$pageToCheck = $chapter->pages->first();
$newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
$this->asAdmin()->visit($chapter->getUrl() . '/move')
->see('Move Chapter')->see($chapter->name)
->type('book:' . $newBook->id, 'entity_selection')->press('Move Chapter');
$chapter = \BookStack\Chapter::find($chapter->id);
$this->seePageIs($chapter->getUrl());
$this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
$this->visit($newBook->getUrl())
->seeInNthElement('.activity-list-item', 0, 'moved chapter')
->seeInNthElement('.activity-list-item', 0, $chapter->name);
$pageToCheck = \BookStack\Page::find($pageToCheck->id);
$this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
$this->visit($pageToCheck->getUrl())
->see($newBook->name);
}
}

95
tests/ImageTest.php Normal file
View File

@ -0,0 +1,95 @@
<?php
class ImageTest extends TestCase
{
/**
* Get a test image that can be uploaded
* @param $fileName
* @return \Illuminate\Http\UploadedFile
*/
protected function getTestImage($fileName)
{
return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
}
/**
* Get the path for a test image.
* @param $type
* @param $fileName
* @return string
*/
protected function getTestImagePath($type, $fileName)
{
return '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/' . $fileName;
}
/**
* Uploads an image with the given name.
* @param $name
* @param int $uploadedTo
* @return string
*/
protected function uploadImage($name, $uploadedTo = 0)
{
$file = $this->getTestImage($name);
$this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
return $this->getTestImagePath('gallery', $name);
}
/**
* Delete an uploaded image.
* @param $relPath
*/
protected function deleteImage($relPath)
{
unlink(public_path($relPath));
}
public function test_image_upload()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$admin = $this->getAdmin();
$imageName = 'first-image.jpg';
$relPath = $this->uploadImage($imageName, $page->id);
$this->assertResponseOk();
$this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image exists');
$this->seeInDatabase('images', [
'url' => $relPath,
'type' => 'gallery',
'uploaded_to' => $page->id,
'path' => $relPath,
'created_by' => $admin->id,
'updated_by' => $admin->id,
'name' => $imageName
]);
$this->deleteImage($relPath);
}
public function test_image_delete()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$imageName = 'first-image.jpg';
$relPath = $this->uploadImage($imageName, $page->id);
$image = \BookStack\Image::first();
$this->call('DELETE', '/images/' . $image->id);
$this->assertResponseOk();
$this->dontSeeInDatabase('images', [
'url' => $relPath,
'type' => 'gallery'
]);
$this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has been deleted');
}
}

View File

@ -39,11 +39,19 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
*/
public function asAdmin()
{
return $this->actingAs($this->getAdmin());
}
/**
* Get the current admin user.
* @return mixed
*/
public function getAdmin() {
if($this->admin === null) {
$adminRole = \BookStack\Role::getRole('admin');
$this->admin = $adminRole->users->first();
}
return $this->actingAs($this->admin);
return $this->admin;
}
/**

BIN
tests/test-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB