+dist: trusty
+sudo: required
language: php
php:
- 7.0
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
--- /dev/null
+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.
* @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];
}
}
$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());
}
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
$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);
}
{
$this->checkPermission('image-create-all');
$this->validate($request, [
- 'file' => 'image|mimes:jpeg,gif,png'
+ 'file' => 'is_image'
]);
$imageUpload = $request->file('file');
$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);
$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
]);
}
]);
}
+ /**
+ * 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
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;
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();
}
$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
]);
}
$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
]);
}
$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
]);
}
$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
]);
}
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]);
+ }
+
}
+
+
*/
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);
}
*/
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);
}
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');
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');
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
});
+ Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
+
// Links
Route::get('/link/{id}', 'PageController@redirectFromLink');
*/
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);
+ });
+
}
/**
}]);
$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) {
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
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;
}
use Activity;
use BookStack\Book;
use BookStack\Chapter;
+use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon;
use DOMDocument;
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
/**
* 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
{
$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);
}
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\JointPermission;
+use BookStack\Ownable;
use BookStack\Page;
use BookStack\Role;
use BookStack\User;
/**
* 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;
$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));
}
* 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)
{
->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');
}
<?php
+use BookStack\Ownable;
+
if (!function_exists('versioned_asset')) {
/**
* Get the path to a versioned file.
* 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);
}
/**
*/
return [
- 'app-editor' => 'wysiwyg'
+ 'app-editor' => 'wysiwyg',
+ 'app-color' => '#0288D1',
+ 'app-color-light' => 'rgba(21, 101, 192, 0.15)'
];
\ No newline at end of file
*/
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();
*/
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');
*/
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();
# BookStack
+[](https://github.com/ssddanbrown/BookStack/releases/latest)
+[](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[](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/.
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.
};
}]);
-
+ /**
+ * Dropdown
+ * Provides some simple logic to create small dropdown menus
+ */
ngApp.directive('dropdown', [function () {
return {
restrict: 'A',
};
}]);
- ngApp.directive('tinymce', ['$timeout', function($timeout) {
+ /**
+ * TinyMCE
+ * An angular wrapper around the tinyMCE editor.
+ */
+ ngApp.directive('tinymce', ['$timeout', function ($timeout) {
return {
restrict: 'A',
scope: {
scope.mceChange(content);
});
+ editor.on('keydown', (event) => {
+ scope.$emit('editor-keydown', event);
+ });
+
editor.on('init', (e) => {
scope.mceModel = editor.getContent();
});
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;
}
}]);
- 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: {
scope.$on('markdown-update', (event, value) => {
element.val(value);
- scope.mdModel= value;
+ scope.mdModel = value;
scope.mdChange(markdown(value));
});
}
}]);
- 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;
input.focus();
input[0].selectionStart = caretPos + (";
input[0].selectionEnd = caretPos + (';
+ 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 = "";
}
}
}]);
-
- 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]');
$toggle.click((e) => {
elem.toggleClass('open');
});
-
+
// Set an active tab/content by name
function setActive(tabName, openToolbox) {
$buttons.removeClass('active');
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);
});
}
}]);
- 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 = {};
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;
// 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;
+ }
}
});
// Display suggestions on a field
let prevSuggestions = [];
+
function displaySuggestions($input, suggestions) {
// Hide if no suggestions
if (i === 0) {
suggestion.className = 'active'
active = 0;
- };
+ }
+ ;
$suggestionBox[0].appendChild(suggestion);
}
// 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;
});
}
}
}
}]);
+
+
+ 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;
+ });
+ };
+ }
+ };
+ }]);
};
// 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');
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,
{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'},
// 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;
}
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();
+ }
+ });
+
};
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';
+ }
+}
\ No newline at end of file
&.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
}
+ &:focus {
+ outline: 0;
+ }
}
#html-editor {
.page-list {
h3 {
- margin: $-l 0 $-m 0;
+ margin: $-l 0 $-xs 0;
+ font-size: 1.666em;
}
a.chapter {
color: $color-chapter;
.inset-list {
display: none;
overflow: hidden;
- // padding-left: $-m;
margin-bottom: $-l;
}
h4 {
padding-top: $-xs;
margin: 0;
}
+ > p.empty-text {
+ display: block;
+ font-size: $fs-m;
+ }
hr {
margin: 0;
}
max-width: 100%;
height:auto;
}
- h1, h2, h3, h4, h5, h6 {
+ h1, h2, h3, h4, h5, h6, pre {
clear: left;
}
hr {
*/
h1 {
- font-size: 3.625em;
+ font-size: 3.425em;
line-height: 1.22222222em;
margin-top: 0.48888889em;
margin-bottom: 0.48888889em;
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%);
}
}
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);
$secondary: #e27b41;
$positive: #52A256;
$negative: #E84F4F;
+$info: $primary;
$warning: $secondary;
$primary-faded: rgba(21, 101, 192, 0.15);
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;
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
/**
* Activity text strings.
- * Is used for all the text within activity logs.
+ * Is used for all the text within activity logs & notifications.
*/
// Pages
'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',
'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',
-<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>
-<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"> » </span>
+ @endif
<a href="{{ $chapter->getUrl() }}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
</a>
--- /dev/null
+@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">»</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
@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>
@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>
@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>
<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>
<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>
-<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>
@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)
--- /dev/null
+@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">»</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-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
</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>
-{{--Requires an entity to be passed with the name $entity--}}
-
@if(count($activity) > 0)
<div class="activity-list">
@foreach($activity as $activityItem)
@endforeach
</div>
@else
- <p class="text-muted">New activity will show up here.</p>
+ <p class="text-muted">No activity to show</p>
@endif
\ No newline at end of file
-@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
\ No newline at end of file
+<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>
\ No newline at end of file
@endforeach
@else
- <p class="text-muted">
- No items available
+ <p class="text-muted empty-text">
+ {{ $emptyText or 'No items available' }}
</p>
@endif
</div>
\ No newline at end of file
--- /dev/null
+<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>
\ No newline at end of file
-<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>
--- /dev/null
+<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>
\ No newline at end of file
$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);
+ }
}
->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);
+ }
+
}
\ No newline at end of file
--- /dev/null
+<?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');
+ }
+
+}
\ No newline at end of file
*/
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;
}
/**