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 language: php
php: php:
- 7.0 - 7.0
@ -5,15 +7,21 @@ php:
cache: cache:
directories: directories:
- vendor - vendor
- node_modules
- $HOME/.composer/cache
addons: addons:
mariadb: '10.0' apt:
packages:
- mysql-server-5.6
- mysql-client-core-5.6
- mysql-client-5.6
before_install: before_install:
- npm install -g npm@latest - npm install -g npm@latest
before_script: 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 - composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
- phpenv config-rm xdebug.ini - phpenv config-rm xdebug.ini
- composer self-update - 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 * @return bool
*/ */
public function isSimilarTo($activityB) { 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 = $request->all();
$input['priority'] = $this->bookRepo->getNewPriority($book); $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); Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl()); return redirect($chapter->getUrl());
} }
@ -154,6 +154,63 @@ class ChapterController extends Controller
return redirect($book->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. * Show the Restrictions view.
* @param $bookSlug * @param $bookSlug

View File

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

View File

@ -92,7 +92,7 @@ class PageController extends Controller
$draftPage = $this->pageRepo->getById($pageId, true); $draftPage = $this->pageRepo->getById($pageId, true);
$chapterId = $draftPage->chapter_id; $chapterId = intval($draftPage->chapter_id);
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book; $parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent); $this->checkOwnablePermission('page-create', $parent);
@ -221,8 +221,8 @@ class PageController extends Controller
$updateTime = $draft->updated_at->timestamp; $updateTime = $draft->updated_at->timestamp;
$utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset; $utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => 'Draft saved at ', 'message' => 'Draft saved at ',
'timestamp' => $utcUpdateTimestamp '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. * Set the permissions for this page.
* @param $bookSlug * @param $bookSlug

View File

@ -2,10 +2,10 @@
namespace BookStack\Http\Controllers; namespace BookStack\Http\Controllers;
use BookStack\Services\ViewService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use BookStack\Http\Requests; use BookStack\Http\Requests;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\BookRepo; use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo; use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo; use BookStack\Repos\PageRepo;
@ -15,18 +15,21 @@ class SearchController extends Controller
protected $pageRepo; protected $pageRepo;
protected $bookRepo; protected $bookRepo;
protected $chapterRepo; protected $chapterRepo;
protected $viewService;
/** /**
* SearchController constructor. * SearchController constructor.
* @param $pageRepo * @param PageRepo $pageRepo
* @param $bookRepo * @param BookRepo $bookRepo
* @param $chapterRepo * @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->pageRepo = $pageRepo;
$this->bookRepo = $bookRepo; $this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo; $this->chapterRepo = $chapterRepo;
$this->viewService = $viewService;
parent::__construct(); parent::__construct();
} }
@ -48,9 +51,9 @@ class SearchController extends Controller
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends); $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends);
$this->setPageTitle('Search For ' . $searchTerm); $this->setPageTitle('Search For ' . $searchTerm);
return view('search/all', [ return view('search/all', [
'pages' => $pages, 'pages' => $pages,
'books' => $books, 'books' => $books,
'chapters' => $chapters, 'chapters' => $chapters,
'searchTerm' => $searchTerm 'searchTerm' => $searchTerm
]); ]);
} }
@ -69,8 +72,8 @@ class SearchController extends Controller
$pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends); $pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Page Search For ' . $searchTerm); $this->setPageTitle('Page Search For ' . $searchTerm);
return view('search/entity-search-list', [ return view('search/entity-search-list', [
'entities' => $pages, 'entities' => $pages,
'title' => 'Page Search Results', 'title' => 'Page Search Results',
'searchTerm' => $searchTerm 'searchTerm' => $searchTerm
]); ]);
} }
@ -89,8 +92,8 @@ class SearchController extends Controller
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends); $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Chapter Search For ' . $searchTerm); $this->setPageTitle('Chapter Search For ' . $searchTerm);
return view('search/entity-search-list', [ return view('search/entity-search-list', [
'entities' => $chapters, 'entities' => $chapters,
'title' => 'Chapter Search Results', 'title' => 'Chapter Search Results',
'searchTerm' => $searchTerm 'searchTerm' => $searchTerm
]); ]);
} }
@ -109,8 +112,8 @@ class SearchController extends Controller
$books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends); $books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends);
$this->setPageTitle('Book Search For ' . $searchTerm); $this->setPageTitle('Book Search For ' . $searchTerm);
return view('search/entity-search-list', [ return view('search/entity-search-list', [
'entities' => $books, 'entities' => $books,
'title' => 'Book Search Results', 'title' => 'Book Search Results',
'searchTerm' => $searchTerm 'searchTerm' => $searchTerm
]); ]);
} }
@ -134,4 +137,35 @@ class SearchController extends Controller
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, '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]);
}
} }

View File

@ -55,7 +55,7 @@ class TagController extends Controller
*/ */
public function getNameSuggestions(Request $request) public function getNameSuggestions(Request $request)
{ {
$searchTerm = $request->get('search'); $searchTerm = $request->has('search') ? $request->get('search') : false;
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm); $suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions); return response()->json($suggestions);
} }
@ -66,8 +66,9 @@ class TagController extends Controller
*/ */
public function getValueSuggestions(Request $request) public function getValueSuggestions(Request $request)
{ {
$searchTerm = $request->get('search'); $searchTerm = $request->has('search') ? $request->get('search') : false;
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm); $tagName = $request->has('name') ? $request->get('name') : false;
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions); 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/html', 'PageController@exportHtml');
Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText'); Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit'); 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}/page/{pageSlug}/delete', 'PageController@showDelete');
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft'); Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict'); 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::post('/{bookSlug}/chapter/create', 'ChapterController@store');
Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show'); Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update'); 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}/edit', 'ChapterController@edit');
Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict'); Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict');
Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict'); 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::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
}); });
Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
// Links // Links
Route::get('/link/{id}', 'PageController@redirectFromLink'); Route::get('/link/{id}', 'PageController@redirectFromLink');

View File

@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot() 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'); $chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapters = $chapterQuery->get(); $chapters = $chapterQuery->get();
$children = $pages->merge($chapters); $children = $pages->values();
foreach ($chapters as $chapter) {
$children->push($chapter);
}
$bookSlug = $book->slug; $bookSlug = $book->slug;
$children->each(function ($child) use ($bookSlug) { $children->each(function ($child) use ($bookSlug) {

View File

@ -9,6 +9,18 @@ use BookStack\Chapter;
class ChapterRepo extends EntityRepo 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. * Base query for getting chapters, Takes permissions into account.
* @return mixed * @return mixed
@ -189,12 +201,21 @@ class ChapterRepo extends EntityRepo
public function changeBook($bookId, Chapter $chapter) public function changeBook($bookId, Chapter $chapter)
{ {
$chapter->book_id = $bookId; $chapter->book_id = $bookId;
// Update related activity
foreach ($chapter->activity as $activity) { foreach ($chapter->activity as $activity) {
$activity->book_id = $bookId; $activity->book_id = $bookId;
$activity->save(); $activity->save();
} }
$chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id); $chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
$chapter->save(); $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; return $chapter;
} }

View File

@ -3,6 +3,7 @@
use Activity; use Activity;
use BookStack\Book; use BookStack\Book;
use BookStack\Chapter; use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon; use Carbon\Carbon;
use DOMDocument; use DOMDocument;
@ -572,6 +573,22 @@ class PageRepo extends EntityRepo
return $page; 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 * Gets a suitable slug for the resource
* @param $name * @param $name

View File

@ -58,29 +58,48 @@ class TagRepo
/** /**
* Get tag name suggestions from scanning existing tag names. * 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 * @param $searchTerm
* @return array * @return array
*/ */
public function getNameSuggestions($searchTerm) public function getNameSuggestions($searchTerm = false)
{ {
if ($searchTerm === '') return []; $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
$query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc');
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'); $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name'); return $query->get(['name'])->pluck('name');
} }
/** /**
* Get tag value suggestions from scanning existing tag values. * 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 $searchTerm
* @param $tagName
* @return array * @return array
*/ */
public function getValueSuggestions($searchTerm) public function getValueSuggestions($searchTerm = false, $tagName = false)
{ {
if ($searchTerm === '') return []; $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
$query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
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'); $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value'); return $query->get(['value'])->pluck('value');
} }
/** /**
* Save an array of tags to an entity * Save an array of tags to an entity
* @param Entity $entity * @param Entity $entity

View File

@ -90,7 +90,7 @@ class ActivityService
{ {
$activityList = $this->permissionService $activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type') ->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); return $this->filterSimilar($activityList);
} }

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@
*/ */
return [ 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() 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->increments('id');
$table->string('name'); $table->string('name');
$table->string('slug')->indexed(); $table->string('slug')->indexed();

View File

@ -12,7 +12,13 @@ class CreatePagesTable extends Migration
*/ */
public function up() 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->increments('id');
$table->integer('book_id'); $table->integer('book_id');
$table->integer('chapter_id'); $table->integer('chapter_id');

View File

@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration
*/ */
public function up() 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->increments('id');
$table->integer('book_id'); $table->integer('book_id');
$table->string('slug')->indexed(); $table->string('slug')->indexed();

View File

@ -1,5 +1,7 @@
# BookStack # 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) [![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/. 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(); 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 * Discard the current draft and grab the current page
* content from the system via an AJAX request. * 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 () { ngApp.directive('dropdown', [function () {
return { return {
restrict: 'A', 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 { return {
restrict: 'A', restrict: 'A',
scope: { scope: {
@ -185,6 +192,10 @@ module.exports = function (ngApp, events) {
scope.mceChange(content); scope.mceChange(content);
}); });
editor.on('keydown', (event) => {
scope.$emit('editor-keydown', event);
});
editor.on('init', (e) => { editor.on('init', (e) => {
scope.mceModel = editor.getContent(); scope.mceModel = editor.getContent();
}); });
@ -200,8 +211,8 @@ module.exports = function (ngApp, events) {
scope.tinymce.extraSetups.push(tinyMceSetup); scope.tinymce.extraSetups.push(tinyMceSetup);
// Custom tinyMCE plugins // Custom tinyMCE plugins
tinymce.PluginManager.add('customhr', function(editor) { tinymce.PluginManager.add('customhr', function (editor) {
editor.addCommand('InsertHorizontalRule', function() { editor.addCommand('InsertHorizontalRule', function () {
var hrElem = document.createElement('hr'); var hrElem = document.createElement('hr');
var cNode = editor.selection.getNode(); var cNode = editor.selection.getNode();
var parentNode = cNode.parentNode; 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 { return {
restrict: 'A', restrict: 'A',
scope: { scope: {
@ -251,7 +266,7 @@ module.exports = function (ngApp, events) {
scope.$on('markdown-update', (event, value) => { scope.$on('markdown-update', (event, value) => {
element.val(value); element.val(value);
scope.mdModel= value; scope.mdModel = value;
scope.mdChange(markdown(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 { return {
restrict: 'A', restrict: 'A',
link: function (scope, element, attrs) { link: function (scope, element, attrs) {
// Elements // Elements
var input = element.find('textarea[markdown-input]'); const input = element.find('textarea[markdown-input]');
var insertImage = element.find('button[data-action="insertImage"]'); 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; currentCaretPos = input[0].selectionStart;
}); });
// Insert image shortcut // Scroll sync
input.keydown((event) => { 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) { if (event.which === 73 && event.ctrlKey && event.shiftKey) {
event.preventDefault(); event.preventDefault();
var caretPos = input[0].selectionStart; var caretPos = input[0].selectionStart;
@ -285,12 +336,15 @@ module.exports = function (ngApp, events) {
input.focus(); input.focus();
input[0].selectionStart = caretPos + ("![](".length); input[0].selectionStart = caretPos + ("![](".length);
input[0].selectionEnd = caretPos + ('![](http://'.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 // Insert image from image manager
insertImage.click((event) => { insertImage.click(event => {
window.ImageManager.showExternal((image) => { window.ImageManager.showExternal(image => {
var caretPos = currentCaretPos; var caretPos = currentCaretPos;
var currentContent = input.val(); var currentContent = input.val();
var mdImageText = "![" + image.name + "](" + image.url + ")"; 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 { return {
restrict: 'A', restrict: 'A',
link: function(scope, elem, attrs) { link: function (scope, elem, attrs) {
// Get common elements // Get common elements
const $buttons = elem.find('[tab-button]'); const $buttons = elem.find('[tab-button]');
@ -317,7 +376,7 @@ module.exports = function (ngApp, events) {
$toggle.click((e) => { $toggle.click((e) => {
elem.toggleClass('open'); elem.toggleClass('open');
}); });
// Set an active tab/content by name // Set an active tab/content by name
function setActive(tabName, openToolbox) { function setActive(tabName, openToolbox) {
$buttons.removeClass('active'); $buttons.removeClass('active');
@ -331,7 +390,7 @@ module.exports = function (ngApp, events) {
setActive($content.first().attr('tab-content'), false); setActive($content.first().attr('tab-content'), false);
// Handle tab button click // Handle tab button click
$buttons.click(function(e) { $buttons.click(function (e) {
let name = $(this).attr('tab-button'); let name = $(this).attr('tab-button');
setActive(name, true); 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 { return {
restrict: 'A', restrict: 'A',
link: function(scope, elem, attrs) { link: function (scope, elem, attrs) {
// Local storage for quick caching. // Local storage for quick caching.
const localCache = {}; const localCache = {};
@ -360,38 +424,49 @@ module.exports = function (ngApp, events) {
let active = 0; let active = 0;
// Listen to input events on autosuggest fields // Listen to input events on autosuggest fields
elem.on('input', '[autosuggest]', function(event) { elem.on('input focus', '[autosuggest]', function (event) {
let $input = $(this); let $input = $(this);
let val = $input.val(); let val = $input.val();
let url = $input.attr('autosuggest'); let url = $input.attr('autosuggest');
// No suggestions until at least 3 chars let type = $input.attr('autosuggest-type');
if (val.length < 3) {
if (isShowing) { // Add name param to request if for a value
$suggestionBox.hide(); if (type.toLowerCase() === 'value') {
isShowing = false; 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); let suggestionPromise = getSuggestions(val.slice(0, 3), url);
suggestionPromise.then((suggestions) => { suggestionPromise.then(suggestions => {
if (val.length > 2) { if (val.length === 0) {
suggestions = suggestions.filter((item) => { displaySuggestions($input, suggestions.slice(0, 6));
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1; } else {
}).slice(0, 4); suggestions = suggestions.filter(item => {
displaySuggestions($input, suggestions); return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
} }).slice(0, 4);
displaySuggestions($input, suggestions);
}
}); });
}); });
// Hide autosuggestions when input loses focus. // Hide autosuggestions when input loses focus.
// Slight delay to allow clicks. // 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(() => { setTimeout(() => {
$suggestionBox.hide(); if (lastFocusTime < startTime) {
isShowing = false; $suggestionBox.hide();
isShowing = false;
}
}, 200) }, 200)
}); });
elem.on('focus', '[autosuggest]', function (event) {
lastFocusTime = Date.now();
});
elem.on('keydown', '[autosuggest]', function (event) { elem.on('keydown', '[autosuggest]', function (event) {
if (!isShowing) return; if (!isShowing) return;
@ -401,23 +476,25 @@ module.exports = function (ngApp, events) {
// Down arrow // Down arrow
if (event.keyCode === 40) { if (event.keyCode === 40) {
let newActive = (active === suggestCount-1) ? 0 : active + 1; let newActive = (active === suggestCount - 1) ? 0 : active + 1;
changeActiveTo(newActive, suggestionElems); changeActiveTo(newActive, suggestionElems);
} }
// Up arrow // Up arrow
else if (event.keyCode === 38) { else if (event.keyCode === 38) {
let newActive = (active === 0) ? suggestCount-1 : active - 1; let newActive = (active === 0) ? suggestCount - 1 : active - 1;
changeActiveTo(newActive, suggestionElems); changeActiveTo(newActive, suggestionElems);
} }
// Enter key // Enter or tab key
else if (event.keyCode === 13) { else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
let text = suggestionElems[active].textContent; let text = suggestionElems[active].textContent;
currentInput[0].value = text; currentInput[0].value = text;
currentInput.focus(); currentInput.focus();
$suggestionBox.hide(); $suggestionBox.hide();
isShowing = false; isShowing = false;
event.preventDefault(); if (event.keyCode === 13) {
return false; event.preventDefault();
return false;
}
} }
}); });
@ -430,6 +507,7 @@ module.exports = function (ngApp, events) {
// Display suggestions on a field // Display suggestions on a field
let prevSuggestions = []; let prevSuggestions = [];
function displaySuggestions($input, suggestions) { function displaySuggestions($input, suggestions) {
// Hide if no suggestions // Hide if no suggestions
@ -466,7 +544,8 @@ module.exports = function (ngApp, events) {
if (i === 0) { if (i === 0) {
suggestion.className = 'active' suggestion.className = 'active'
active = 0; active = 0;
}; }
;
$suggestionBox[0].appendChild(suggestion); $suggestionBox[0].appendChild(suggestion);
} }
@ -484,17 +563,18 @@ module.exports = function (ngApp, events) {
// Get suggestions & cache // Get suggestions & cache
function getSuggestions(input, url) { 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 // Get from local cache if exists
if (localCache[searchUrl]) { if (typeof localCache[searchUrl] !== 'undefined') {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
resolve(localCache[input]); resolve(localCache[searchUrl]);
}); });
} }
return $http.get(searchUrl).then((response) => { return $http.get(searchUrl).then(response => {
localCache[input] = response.data; localCache[searchUrl] = response.data;
return 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 // Common jQuery actions
$('[data-action="expand-entity-list-details"]').click(function() { $('[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 // Page specific items
require('./pages/page-show'); require('./pages/page-show');

View File

@ -1,7 +1,8 @@
var mceOptions = module.exports = { var mceOptions = module.exports = {
selector: '#html-editor', selector: '#html-editor',
content_css: [ content_css: [
'/css/styles.css' '/css/styles.css',
'/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
], ],
body_class: 'page-content', body_class: 'page-content',
relative_urls: false, relative_urls: false,
@ -19,11 +20,18 @@ var mceOptions = module.exports = {
{title: "Header 1", format: "h1"}, {title: "Header 1", format: "h1"},
{title: "Header 2", format: "h2"}, {title: "Header 2", format: "h2"},
{title: "Header 3", format: "h3"}, {title: "Header 3", format: "h3"},
{title: "Paragraph", format: "p"}, {title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"}, {title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"}, {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: { formats: {
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'}, 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'}, 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 // Make the book-tree sidebar stick in view on scroll
var $window = $(window); var $window = $(window);
var $bookTree = $(".book-tree"); var $bookTree = $(".book-tree");
var $bookTreeParent = $bookTree.parent();
// Check the page is scrollable and the content is taller than the tree // Check the page is scrollable and the content is taller than the tree
var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height()); var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
// Get current tree's width and header height // Get current tree's width and header height
var headerHeight = $("#header").height() + $(".toolbar").height(); var headerHeight = $("#header").height() + $(".toolbar").height();
var isFixed = $window.scrollTop() > headerHeight; var isFixed = $window.scrollTop() > headerHeight;
var bookTreeWidth = $bookTree.width();
// Function to fix the tree as a sidebar // Function to fix the tree as a sidebar
function stickTree() { function stickTree() {
$bookTree.width(bookTreeWidth + 48 + 15); $bookTree.width($bookTreeParent.width() + 15);
$bookTree.addClass("fixed"); $bookTree.addClass("fixed");
isFixed = true; isFixed = true;
} }
@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) {
unstickTree(); 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 // If the page is scrollable and the window is wide enough listen to scroll events
// and evaluate tree stickiness. // and evaluate tree stickiness.
if (pageScrollable && $window.width() > 1000) { if (pageScrollable && $window.width() > 1000) {
$window.scroll(function() { $window.on('scroll', windowScrollEvent);
checkTreeStickiness(false);
});
checkTreeStickiness(true); 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; 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] { &.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==); background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
} }
&:focus {
outline: 0;
}
} }
#html-editor { #html-editor {

View File

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

View File

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

View File

@ -3,7 +3,7 @@
*/ */
h1 { h1 {
font-size: 3.625em; font-size: 3.425em;
line-height: 1.22222222em; line-height: 1.22222222em;
margin-top: 0.48888889em; margin-top: 0.48888889em;
margin-bottom: 0.48888889em; margin-bottom: 0.48888889em;
@ -33,10 +33,10 @@ h1, h2, h3, h4 {
display: block; display: block;
color: #555; color: #555;
.subheader { .subheader {
display: block; //display: block;
font-size: 0.5em; font-size: 0.5em;
line-height: 1em; 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; 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 { span.highlight {
//background-color: rgba($primary, 0.2); //background-color: rgba($primary, 0.2);

View File

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

View File

@ -207,3 +207,59 @@ $btt-size: 40px;
color: #EEE; 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. * Activity text strings.
* Is used for all the text within activity logs. * Is used for all the text within activity logs & notifications.
*/ */
// Pages // Pages
@ -16,6 +16,7 @@ return [
'page_delete_notification' => 'Page Successfully Deleted', 'page_delete_notification' => 'Page Successfully Deleted',
'page_restore' => 'restored page', 'page_restore' => 'restored page',
'page_restore_notification' => 'Page Successfully Restored', 'page_restore_notification' => 'Page Successfully Restored',
'page_move' => 'moved page',
// Chapters // Chapters
'chapter_create' => 'created chapter', 'chapter_create' => 'created chapter',
@ -24,6 +25,7 @@ return [
'chapter_update_notification' => 'Chapter Successfully Updated', 'chapter_update_notification' => 'Chapter Successfully Updated',
'chapter_delete' => 'deleted chapter', 'chapter_delete' => 'deleted chapter',
'chapter_delete_notification' => 'Chapter Successfully Deleted', 'chapter_delete_notification' => 'Chapter Successfully Deleted',
'chapter_move' => 'moved chapter',
// Books // Books
'book_create' => 'created book', '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> <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)) @if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p> <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> <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"> <a href="{{ $chapter->getUrl() }}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }} <i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
</a> </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') @section('content')
<div class="faded-small toolbar" ng-non-bindable> <div class="faded-small toolbar">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-md-4 faded"> <div class="col-sm-8 faded" ng-non-bindable>
<div class="breadcrumbs"> <div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a> <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
</div> </div>
</div> </div>
<div class="col-md-8 faded"> <div class="col-sm-4 faded">
<div class="action-buttons"> <div class="action-buttons">
@if(userCan('page-create', $chapter)) @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> <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)) @if(userCan('chapter-update', $chapter))
<a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a> <a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
@endif @endif
@if(userCan('restrictions-manage', $chapter)) @if(userCan('chapter-update', $chapter) || userCan('restrictions-manage', $chapter) || userCan('chapter-delete', $chapter))
<a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a> <div dropdown class="dropdown-container">
@endif <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
@if(userCan('chapter-delete', $chapter)) <ul>
<a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a> @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 @endif
</div> </div>
</div> </div>

View File

@ -34,18 +34,30 @@
@else @else
<h3>Recent Books</h3> <h3>Recent Books</h3>
@endif @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>
<div class="col-sm-4"> <div class="col-sm-4">
<h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3> <h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
<div id="recently-created-pages"> <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> </div>
<h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3> <h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
<div id="recently-updated-pages"> <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>
</div> </div>

View File

@ -10,12 +10,12 @@
<h4>Page Tags</h4> <h4>Page Tags</h4>
<div class="padded tags"> <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> <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" > <tbody ui-sortable="sortOptions" ng-model="tags" >
<tr ng-repeat="tag in tags track by $index"> <tr ng-repeat="tag in tags track by $index">
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td> <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/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" 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/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> <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> </tr>
</tbody> </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> <button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
</div> </div>
</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> @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>

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> <h3>
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a> <a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
</h3> </h3>
@ -11,11 +11,11 @@
@if(isset($style) && $style === 'detailed') @if(isset($style) && $style === 'detailed')
<div class="row meta text-muted text-small"> <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> 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 Last updated {{ $page->updated_at->diffForHumans() }} @if($page->updatedBy)by {{$page->updatedBy->name}} @endif
</div> </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> <a class="text-book" href="{{ $page->book->getUrl() }}"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName(30) }}</a>
<br> <br>
@if($page->chapter) @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> </ul>
</span> </span>
@if(userCan('page-update', $page)) @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> <a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
@endif @endif
@if(userCan('restrictions-manage', $page)) @if(userCan('page-update', $page) || userCan('restrictions-manage', $page) || userCan('page-delete', $page))
<a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a> <div dropdown class="dropdown-container">
@endif <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
@if(userCan('page-delete', $page)) <ul>
<a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a> @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 @endif
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -16,8 +16,8 @@
@endforeach @endforeach
@else @else
<p class="text-muted"> <p class="text-muted empty-text">
No items available {{ $emptyText or 'No items available' }}
</p> </p>
@endif @endif
</div> </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> <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> <i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
</div> </div>
<div class="notification anim warning stopped" @if(!Session::has('warning')) style="display:none;" @endif> <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> <i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
</div> </div>
<div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif> <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> <i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
</div> </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) $this->asAdmin()->visit('/search/books?term=' . $book->name)
->see('Book Search Results')->see('.entity-list', $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); ->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() public function asAdmin()
{ {
return $this->actingAs($this->getAdmin());
}
/**
* Get the current admin user.
* @return mixed
*/
public function getAdmin() {
if($this->admin === null) { if($this->admin === null) {
$adminRole = \BookStack\Role::getRole('admin'); $adminRole = \BookStack\Role::getRole('admin');
$this->admin = $adminRole->users->first(); $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