Merge branch 'master' into release

This commit is contained in:
Dan Brown 2016-09-05 19:35:21 +01:00
commit 7caed3b0db
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
44 changed files with 958 additions and 337 deletions

11
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,11 @@
### For Feature Requests
Desired Feature:
### For Bug Reports
PHP Version:
MySQL Version:
Expected Behavior:
Actual Behavior:

View File

@ -6,8 +6,6 @@ php:
cache: cache:
directories: directories:
- vendor
- node_modules
- $HOME/.composer/cache - $HOME/.composer/cache
addons: addons:
@ -17,19 +15,17 @@ addons:
- mysql-client-core-5.6 - mysql-client-core-5.6
- mysql-client-5.6 - mysql-client-5.6
before_install:
- npm install -g npm@latest
before_script: before_script:
- mysql -u root -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
- composer dump-autoload --no-interaction
- composer install --prefer-dist --no-interaction - composer install --prefer-dist --no-interaction
- npm install - php artisan clear-compiled -n
- ./node_modules/.bin/gulp - php artisan optimize -n
- php artisan migrate --force -n --database=mysql_testing - php artisan migrate --force -n --database=mysql_testing
- php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing - php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
script: script:
- vendor/bin/phpunit - phpunit

View File

@ -167,7 +167,8 @@ class Entity extends Ownable
foreach ($terms as $key => $term) { foreach ($terms as $key => $term) {
$term = htmlentities($term, ENT_QUOTES); $term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/\s/', $term)) { if (preg_match('/&quot;.*?&quot;/', $term)) {
$term = str_replace('&quot;', '', $term);
$exactTerms[] = '%' . $term . '%'; $exactTerms[] = '%' . $term . '%';
$term = '"' . $term . '"'; $term = '"' . $term . '"';
} else { } else {

View File

@ -47,19 +47,44 @@ class Handler extends ExceptionHandler
{ {
// Handle notify exceptions which will redirect to the // Handle notify exceptions which will redirect to the
// specified location then show a notification message. // specified location then show a notification message.
if ($e instanceof NotifyException) { if ($this->isExceptionType($e, NotifyException::class)) {
session()->flash('error', $e->message); session()->flash('error', $this->getOriginalMessage($e));
return redirect($e->redirectLocation); return redirect($e->redirectLocation);
} }
// Handle pretty exceptions which will show a friendly application-fitting page // Handle pretty exceptions which will show a friendly application-fitting page
// Which will include the basic message to point the user roughly to the cause. // Which will include the basic message to point the user roughly to the cause.
if (($e instanceof PrettyException || $e->getPrevious() instanceof PrettyException) && !config('app.debug')) { if ($this->isExceptionType($e, PrettyException::class) && !config('app.debug')) {
$message = ($e instanceof PrettyException) ? $e->getMessage() : $e->getPrevious()->getMessage(); $message = $this->getOriginalMessage($e);
$code = ($e->getCode() === 0) ? 500 : $e->getCode(); $code = ($e->getCode() === 0) ? 500 : $e->getCode();
return response()->view('errors/' . $code, ['message' => $message], $code); return response()->view('errors/' . $code, ['message' => $message], $code);
} }
return parent::render($request, $e); return parent::render($request, $e);
} }
/**
* Check the exception chain to compare against the original exception type.
* @param Exception $e
* @param $type
* @return bool
*/
protected function isExceptionType(Exception $e, $type) {
do {
if (is_a($e, $type)) return true;
} while ($e = $e->getPrevious());
return false;
}
/**
* Get original exception message.
* @param Exception $e
* @return string
*/
protected function getOriginalMessage(Exception $e) {
do {
$message = $e->getMessage();
} while ($e = $e->getPrevious());
return $message;
}
} }

View File

@ -1,5 +1,3 @@
<?php namespace BookStack\Exceptions; <?php namespace BookStack\Exceptions;
use Exception; class PrettyException extends \Exception {}
class PrettyException extends Exception {}

View File

@ -3,7 +3,6 @@
use Activity; use Activity;
use BookStack\Repos\UserRepo; use BookStack\Repos\UserRepo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use BookStack\Http\Requests; use BookStack\Http\Requests;
use BookStack\Repos\BookRepo; use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo; use BookStack\Repos\ChapterRepo;
@ -180,21 +179,31 @@ class BookController extends Controller
return redirect($book->getUrl()); return redirect($book->getUrl());
} }
$sortedBooks = [];
// Sort pages and chapters // Sort pages and chapters
$sortedBooks = [];
$updatedModels = collect();
$sortMap = json_decode($request->get('sort-tree')); $sortMap = json_decode($request->get('sort-tree'));
$defaultBookId = $book->id; $defaultBookId = $book->id;
foreach ($sortMap as $index => $bookChild) {
$id = $bookChild->id; // Loop through contents of provided map and update entities accordingly
foreach ($sortMap as $bookChild) {
$priority = $bookChild->sort;
$id = intval($bookChild->id);
$isPage = $bookChild->type == 'page'; $isPage = $bookChild->type == 'page';
$bookId = $this->bookRepo->exists($bookChild->book) ? $bookChild->book : $defaultBookId; $bookId = $this->bookRepo->exists($bookChild->book) ? intval($bookChild->book) : $defaultBookId;
$chapterId = ($isPage && $bookChild->parentChapter === false) ? 0 : intval($bookChild->parentChapter);
$model = $isPage ? $this->pageRepo->getById($id) : $this->chapterRepo->getById($id); $model = $isPage ? $this->pageRepo->getById($id) : $this->chapterRepo->getById($id);
// Update models only if there's a change in parent chain or ordering.
if ($model->priority !== $priority || $model->book_id !== $bookId || ($isPage && $model->chapter_id !== $chapterId)) {
$isPage ? $this->pageRepo->changeBook($bookId, $model) : $this->chapterRepo->changeBook($bookId, $model); $isPage ? $this->pageRepo->changeBook($bookId, $model) : $this->chapterRepo->changeBook($bookId, $model);
$model->priority = $index; $model->priority = $priority;
if ($isPage) { if ($isPage) $model->chapter_id = $chapterId;
$model->chapter_id = ($bookChild->parentChapter === false) ? 0 : $bookChild->parentChapter;
}
$model->save(); $model->save();
$updatedModels->push($model);
}
// Store involved books to be sorted later
if (!in_array($bookId, $sortedBooks)) { if (!in_array($bookId, $sortedBooks)) {
$sortedBooks[] = $bookId; $sortedBooks[] = $bookId;
} }
@ -203,10 +212,12 @@ class BookController extends Controller
// Add activity for books // Add activity for books
foreach ($sortedBooks as $bookId) { foreach ($sortedBooks as $bookId) {
$updatedBook = $this->bookRepo->getById($bookId); $updatedBook = $this->bookRepo->getById($bookId);
$this->bookRepo->updateBookPermissions($updatedBook);
Activity::add($updatedBook, 'book_sort', $updatedBook->id); Activity::add($updatedBook, 'book_sort', $updatedBook->id);
} }
// Update permissions on changed models
$this->bookRepo->buildJointPermissions($updatedModels);
return redirect($book->getUrl()); return redirect($book->getUrl());
} }

View File

@ -204,7 +204,7 @@ class ChapterController extends Controller
return redirect()->back(); return redirect()->back();
} }
$this->chapterRepo->changeBook($parent->id, $chapter); $this->chapterRepo->changeBook($parent->id, $chapter, true);
Activity::add($chapter, 'chapter_move', $chapter->book->id); Activity::add($chapter, 'chapter_move', $chapter->book->id);
session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name)); session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));

View File

@ -3,7 +3,7 @@
class PageRevision extends Model class PageRevision extends Model
{ {
protected $fillable = ['name', 'html', 'text', 'markdown']; protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
/** /**
* Get the user that created the page revision * Get the user that created the page revision

View File

@ -1,10 +1,6 @@
<?php <?php namespace BookStack\Providers;
namespace BookStack\Providers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use BookStack\User;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {

View File

@ -2,6 +2,7 @@
use Alpha\B; use Alpha\B;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use BookStack\Book; use BookStack\Book;
use Views; use Views;
@ -173,15 +174,6 @@ class BookRepo extends EntityRepo
$book->delete(); $book->delete();
} }
/**
* Alias method to update the book jointPermissions in the PermissionService.
* @param Book $book
*/
public function updateBookPermissions(Book $book)
{
$this->permissionService->buildJointPermissionsForEntity($book);
}
/** /**
* Get the next child element priority. * Get the next child element priority.
* @param Book $book * @param Book $book

View File

@ -197,9 +197,10 @@ class ChapterRepo extends EntityRepo
* Changes the book relation of this chapter. * Changes the book relation of this chapter.
* @param $bookId * @param $bookId
* @param Chapter $chapter * @param Chapter $chapter
* @param bool $rebuildPermissions
* @return Chapter * @return Chapter
*/ */
public function changeBook($bookId, Chapter $chapter) public function changeBook($bookId, Chapter $chapter, $rebuildPermissions = false)
{ {
$chapter->book_id = $bookId; $chapter->book_id = $bookId;
// Update related activity // Update related activity
@ -213,9 +214,12 @@ class ChapterRepo extends EntityRepo
foreach ($chapter->pages as $page) { foreach ($chapter->pages as $page) {
$this->pageRepo->changeBook($bookId, $page); $this->pageRepo->changeBook($bookId, $page);
} }
// Update permissions
// Update permissions if applicable
if ($rebuildPermissions) {
$chapter->load('book'); $chapter->load('book');
$this->permissionService->buildJointPermissionsForEntity($chapter->book); $this->permissionService->buildJointPermissionsForEntity($chapter->book);
}
return $chapter; return $chapter;
} }

View File

@ -6,6 +6,7 @@ use BookStack\Entity;
use BookStack\Page; use BookStack\Page;
use BookStack\Services\PermissionService; use BookStack\Services\PermissionService;
use BookStack\User; use BookStack\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class EntityRepo class EntityRepo
@ -168,15 +169,16 @@ class EntityRepo
* @param $termString * @param $termString
* @return array * @return array
*/ */
protected function prepareSearchTerms($termString) public function prepareSearchTerms($termString)
{ {
$termString = $this->cleanSearchTermString($termString); $termString = $this->cleanSearchTermString($termString);
preg_match_all('/"(.*?)"/', $termString, $matches); preg_match_all('/(".*?")/', $termString, $matches);
if (count($matches[1]) > 0) {
$terms = $matches[1];
$termString = trim(preg_replace('/"(.*?)"/', '', $termString));
} else {
$terms = []; $terms = [];
if (count($matches[1]) > 0) {
foreach ($matches[1] as $match) {
$terms[] = $match;
}
$termString = trim(preg_replace('/"(.*?)"/', '', $termString));
} }
if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString)); if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString));
return $terms; return $terms;
@ -259,6 +261,15 @@ class EntityRepo
return $query; return $query;
} }
/**
* Alias method to update the book jointPermissions in the PermissionService.
* @param Collection $collection collection on entities
*/
public function buildJointPermissions(Collection $collection)
{
$this->permissionService->buildJointPermissionsForEntities($collection);
}
} }

View File

@ -157,6 +157,8 @@ class PageRepo extends EntityRepo
$draftPage->draft = false; $draftPage->draft = false;
$draftPage->save(); $draftPage->save();
$this->saveRevision($draftPage, 'Initial Publish');
return $draftPage; return $draftPage;
} }
@ -308,10 +310,9 @@ class PageRepo extends EntityRepo
*/ */
public function updatePage(Page $page, $book_id, $input) public function updatePage(Page $page, $book_id, $input)
{ {
// Save a revision before updating // Hold the old details to compare later
if ($page->html !== $input['html'] || $page->name !== $input['name']) { $oldHtml = $page->html;
$this->saveRevision($page); $oldName = $page->name;
}
// Prevent slug being updated if no name change // Prevent slug being updated if no name change
if ($page->name !== $input['name']) { if ($page->name !== $input['name']) {
@ -335,6 +336,11 @@ class PageRepo extends EntityRepo
// Remove all update drafts for this user & page. // Remove all update drafts for this user & page.
$this->userUpdateDraftsQuery($page, $userId)->delete(); $this->userUpdateDraftsQuery($page, $userId)->delete();
// Save a revision after updating
if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
$this->saveRevision($page, $input['summary']);
}
return $page; return $page;
} }
@ -360,9 +366,10 @@ class PageRepo extends EntityRepo
/** /**
* Saves a page revision into the system. * Saves a page revision into the system.
* @param Page $page * @param Page $page
* @param null|string $summary
* @return $this * @return $this
*/ */
public function saveRevision(Page $page) public function saveRevision(Page $page, $summary = null)
{ {
$revision = $this->pageRevision->fill($page->toArray()); $revision = $this->pageRevision->fill($page->toArray());
if (setting('app-editor') !== 'markdown') $revision->markdown = ''; if (setting('app-editor') !== 'markdown') $revision->markdown = '';
@ -372,6 +379,7 @@ class PageRepo extends EntityRepo
$revision->created_by = auth()->user()->id; $revision->created_by = auth()->user()->id;
$revision->created_at = $page->updated_at; $revision->created_at = $page->updated_at;
$revision->type = 'version'; $revision->type = 'version';
$revision->summary = $summary;
$revision->save(); $revision->save();
// Clear old revisions // Clear old revisions
if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {

View File

@ -48,11 +48,13 @@ class ExportService
foreach ($imageTagsOutput[0] as $index => $imgMatch) { foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgString = $imgMatch; $oldImgString = $imgMatch;
$srcString = $imageTagsOutput[2][$index]; $srcString = $imageTagsOutput[2][$index];
if (strpos(trim($srcString), 'http') !== 0) { $isLocal = strpos(trim($srcString), 'http') !== 0;
$pathString = public_path($srcString); if ($isLocal) {
$pathString = public_path(trim($srcString, '/'));
} else { } else {
$pathString = $srcString; $pathString = $srcString;
} }
if ($isLocal && !file_exists($pathString)) continue;
$imageContent = file_get_contents($pathString); $imageContent = file_get_contents($pathString);
$imageEncoded = 'data:image/' . pathinfo($pathString, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageContent); $imageEncoded = 'data:image/' . pathinfo($pathString, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageContent);
$newImageString = str_replace($srcString, $imageEncoded, $oldImgString); $newImageString = str_replace($srcString, $imageEncoded, $oldImgString);

View File

@ -95,6 +95,7 @@ class ImageService
try { try {
$storage->put($fullPath, $imageData); $storage->put($fullPath, $imageData);
$storage->setVisibility($fullPath, 'public');
} catch (Exception $e) { } catch (Exception $e) {
throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
} }
@ -167,6 +168,7 @@ class ImageService
$thumbData = (string)$thumb->encode(); $thumbData = (string)$thumb->encode();
$storage->put($thumbFilePath, $thumbData); $storage->put($thumbFilePath, $thumbData);
$storage->setVisibility($thumbFilePath, 'public');
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 72); $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 72);
return $this->getPublicUrl($thumbFilePath); return $this->getPublicUrl($thumbFilePath);
@ -257,10 +259,16 @@ class ImageService
$storageUrl = config('filesystems.url'); $storageUrl = config('filesystems.url');
// Get the standard public s3 url if s3 is set as storage type // Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if ($storageUrl == false && config('filesystems.default') === 's3') { if ($storageUrl == false && config('filesystems.default') === 's3') {
$storageDetails = config('filesystems.disks.s3'); $storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket']; $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
} }
}
$this->storageUrl = $storageUrl; $this->storageUrl = $storageUrl;
} }

View File

@ -8,7 +8,7 @@ use BookStack\Ownable;
use BookStack\Page; use BookStack\Page;
use BookStack\Role; use BookStack\Role;
use BookStack\User; use BookStack\User;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
class PermissionService class PermissionService
{ {
@ -25,6 +25,8 @@ class PermissionService
protected $jointPermission; protected $jointPermission;
protected $role; protected $role;
protected $entityCache;
/** /**
* PermissionService constructor. * PermissionService constructor.
* @param JointPermission $jointPermission * @param JointPermission $jointPermission
@ -48,6 +50,57 @@ class PermissionService
$this->page = $page; $this->page = $page;
} }
/**
* Prepare the local entity cache and ensure it's empty
*/
protected function readyEntityCache()
{
$this->entityCache = [
'books' => collect(),
'chapters' => collect()
];
}
/**
* Get a book via ID, Checks local cache
* @param $bookId
* @return Book
*/
protected function getBook($bookId)
{
if (isset($this->entityCache['books']) && $this->entityCache['books']->has($bookId)) {
return $this->entityCache['books']->get($bookId);
}
$book = $this->book->find($bookId);
if ($book === null) $book = false;
if (isset($this->entityCache['books'])) {
$this->entityCache['books']->put($bookId, $book);
}
return $book;
}
/**
* Get a chapter via ID, Checks local cache
* @param $chapterId
* @return Book
*/
protected function getChapter($chapterId)
{
if (isset($this->entityCache['chapters']) && $this->entityCache['chapters']->has($chapterId)) {
return $this->entityCache['chapters']->get($chapterId);
}
$chapter = $this->chapter->find($chapterId);
if ($chapter === null) $chapter = false;
if (isset($this->entityCache['chapters'])) {
$this->entityCache['chapters']->put($chapterId, $chapter);
}
return $chapter;
}
/** /**
* Get the roles for the current user; * Get the roles for the current user;
* @return array|bool * @return array|bool
@ -76,6 +129,7 @@ class PermissionService
public function buildJointPermissions() public function buildJointPermissions()
{ {
$this->jointPermission->truncate(); $this->jointPermission->truncate();
$this->readyEntityCache();
// Get all roles (Should be the most limited dimension) // Get all roles (Should be the most limited dimension)
$roles = $this->role->with('permissions')->get(); $roles = $this->role->with('permissions')->get();
@ -97,7 +151,7 @@ class PermissionService
} }
/** /**
* Create the entity jointPermissions for a particular entity. * Rebuild the entity jointPermissions for a particular entity.
* @param Entity $entity * @param Entity $entity
*/ */
public function buildJointPermissionsForEntity(Entity $entity) public function buildJointPermissionsForEntity(Entity $entity)
@ -116,6 +170,17 @@ class PermissionService
$this->createManyJointPermissions($entities, $roles); $this->createManyJointPermissions($entities, $roles);
} }
/**
* Rebuild the entity jointPermissions for a collection of entities.
* @param Collection $entities
*/
public function buildJointPermissionsForEntities(Collection $entities)
{
$roles = $this->role->with('jointPermissions')->get();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/** /**
* Build the entity jointPermissions for a particular role. * Build the entity jointPermissions for a particular role.
* @param Role $role * @param Role $role
@ -177,9 +242,14 @@ class PermissionService
*/ */
protected function deleteManyJointPermissionsForEntities($entities) protected function deleteManyJointPermissionsForEntities($entities)
{ {
$query = $this->jointPermission->newQuery();
foreach ($entities as $entity) { foreach ($entities as $entity) {
$entity->jointPermissions()->delete(); $query->orWhere(function($query) use ($entity) {
$query->where('entity_id', '=', $entity->id)
->where('entity_type', '=', $entity->getMorphClass());
});
} }
$query->delete();
} }
/** /**
@ -189,6 +259,7 @@ class PermissionService
*/ */
protected function createManyJointPermissions($entities, $roles) protected function createManyJointPermissions($entities, $roles)
{ {
$this->readyEntityCache();
$jointPermissions = []; $jointPermissions = [];
foreach ($entities as $entity) { foreach ($entities as $entity) {
foreach ($roles as $role) { foreach ($roles as $role) {
@ -248,8 +319,9 @@ class PermissionService
} elseif ($entity->isA('chapter')) { } elseif ($entity->isA('chapter')) {
if (!$entity->restricted) { if (!$entity->restricted) {
$hasExplicitAccessToBook = $entity->book->hasActiveRestriction($role->id, $restrictionAction); $book = $this->getBook($entity->book_id);
$hasPermissiveAccessToBook = !$entity->book->restricted; $hasExplicitAccessToBook = $book->hasActiveRestriction($role->id, $restrictionAction);
$hasPermissiveAccessToBook = !$book->restricted;
return $this->createJointPermissionDataArray($entity, $role, $action, return $this->createJointPermissionDataArray($entity, $role, $action,
($hasExplicitAccessToBook || ($roleHasPermission && $hasPermissiveAccessToBook)), ($hasExplicitAccessToBook || ($roleHasPermission && $hasPermissiveAccessToBook)),
($hasExplicitAccessToBook || ($roleHasPermissionOwn && $hasPermissiveAccessToBook))); ($hasExplicitAccessToBook || ($roleHasPermissionOwn && $hasPermissiveAccessToBook)));
@ -261,11 +333,14 @@ class PermissionService
} elseif ($entity->isA('page')) { } elseif ($entity->isA('page')) {
if (!$entity->restricted) { if (!$entity->restricted) {
$hasExplicitAccessToBook = $entity->book->hasActiveRestriction($role->id, $restrictionAction); $book = $this->getBook($entity->book_id);
$hasPermissiveAccessToBook = !$entity->book->restricted; $hasExplicitAccessToBook = $book->hasActiveRestriction($role->id, $restrictionAction);
$hasExplicitAccessToChapter = $entity->chapter && $entity->chapter->hasActiveRestriction($role->id, $restrictionAction); $hasPermissiveAccessToBook = !$book->restricted;
$hasPermissiveAccessToChapter = $entity->chapter && !$entity->chapter->restricted;
$acknowledgeChapter = ($entity->chapter && $entity->chapter->restricted); $chapter = $this->getChapter($entity->chapter_id);
$hasExplicitAccessToChapter = $chapter && $chapter->hasActiveRestriction($role->id, $restrictionAction);
$hasPermissiveAccessToChapter = $chapter && !$chapter->restricted;
$acknowledgeChapter = ($chapter && $chapter->restricted);
$hasExplicitAccessToParents = $acknowledgeChapter ? $hasExplicitAccessToChapter : $hasExplicitAccessToBook; $hasExplicitAccessToParents = $acknowledgeChapter ? $hasExplicitAccessToChapter : $hasExplicitAccessToBook;
$hasPermissiveAccessToParents = $acknowledgeChapter ? $hasPermissiveAccessToChapter : $hasPermissiveAccessToBook; $hasPermissiveAccessToParents = $acknowledgeChapter ? $hasPermissiveAccessToChapter : $hasPermissiveAccessToBook;

View File

@ -158,7 +158,7 @@ class SocialAuthService
$driver = trim(strtolower($socialDriver)); $driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) abort(404, 'Social Driver Not Found'); if (!in_array($driver, $this->validSocialDrivers)) abort(404, 'Social Driver Not Found');
if (!$this->checkDriverConfigured($driver)) throw new SocialDriverNotConfigured; if (!$this->checkDriverConfigured($driver)) throw new SocialDriverNotConfigured("Your {$driver} social settings are not configured correctly.");
return $driver; return $driver;
} }

View File

@ -2,34 +2,39 @@
use BookStack\Ownable; use BookStack\Ownable;
if (!function_exists('versioned_asset')) {
/** /**
* Get the path to a versioned file. * Get the path to a versioned file.
* *
* @param string $file * @param string $file
* @return string * @return string
* * @throws Exception
* @throws \InvalidArgumentException
*/ */
function versioned_asset($file) function versioned_asset($file = '')
{ {
static $manifest = null; // Don't require css and JS assets for testing
if (config('app.env') === 'testing') return '';
if (is_null($manifest)) { static $manifest = null;
$manifest = json_decode(file_get_contents(public_path('build/manifest.json')), true); $manifestPath = 'build/manifest.json';
if (is_null($manifest) && file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents(public_path($manifestPath)), true);
} else if (!file_exists($manifestPath)) {
if (config('app.env') !== 'production') {
$path = public_path($manifestPath);
$error = "No {$path} file found, Ensure you have built the css/js assets using gulp.";
} else {
$error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack";
}
throw new \Exception($error);
} }
if (isset($manifest[$file])) { if (isset($manifest[$file])) {
return baseUrl($manifest[$file]); return baseUrl($manifest[$file]);
} }
if (file_exists(public_path($file))) {
return baseUrl($file);
}
throw new InvalidArgumentException("File {$file} not defined in asset manifest."); throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
} }
}
/** /**
* Check if the current user has a permission. * Check if the current user has a permission.

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSummaryToPageRevisions extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('page_revisions', function ($table) {
$table->string('summary')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('page_revisions', function ($table) {
$table->dropColumn('summary');
});
}
}

View File

@ -28,7 +28,7 @@
<env name="DB_CONNECTION" value="mysql_testing"/> <env name="DB_CONNECTION" value="mysql_testing"/>
<env name="MAIL_DRIVER" value="log"/> <env name="MAIL_DRIVER" value="log"/>
<env name="AUTH_METHOD" value="standard"/> <env name="AUTH_METHOD" value="standard"/>
<env name="DISABLE_EXTERNAL_SERVICES" value="false"/> <env name="DISABLE_EXTERNAL_SERVICES" value="true"/>
<env name="LDAP_VERSION" value="3"/> <env name="LDAP_VERSION" value="3"/>
<env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/> <env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
<env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/> <env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>

View File

@ -69,7 +69,7 @@ module.exports = function (ngApp, events) {
*/ */
function callbackAndHide(returnData) { function callbackAndHide(returnData) {
if (callback) callback(returnData); if (callback) callback(returnData);
$scope.showing = false; $scope.hide();
} }
/** /**
@ -109,6 +109,7 @@ module.exports = function (ngApp, events) {
function show(doneCallback) { function show(doneCallback) {
callback = doneCallback; callback = doneCallback;
$scope.showing = true; $scope.showing = true;
$('#image-manager').find('.overlay').css('display', 'flex').hide().fadeIn(240);
// Get initial images if they have not yet been loaded in. // Get initial images if they have not yet been loaded in.
if (!dataLoaded) { if (!dataLoaded) {
fetchData(); fetchData();
@ -131,6 +132,7 @@ module.exports = function (ngApp, events) {
*/ */
$scope.hide = function () { $scope.hide = function () {
$scope.showing = false; $scope.showing = false;
$('#image-manager').find('.overlay').fadeOut(240);
}; };
var baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/'); var baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/');
@ -357,8 +359,6 @@ module.exports = function (ngApp, events) {
/** /**
* Save a draft update into the system via an AJAX request. * Save a draft update into the system via an AJAX request.
* @param title
* @param html
*/ */
function saveDraft() { function saveDraft() {
var data = { var data = {
@ -373,9 +373,17 @@ module.exports = function (ngApp, events) {
var updateTime = moment.utc(moment.unix(responseData.data.timestamp)).toDate(); var updateTime = moment.utc(moment.unix(responseData.data.timestamp)).toDate();
$scope.draftText = responseData.data.message + moment(updateTime).format('HH:mm'); $scope.draftText = responseData.data.message + moment(updateTime).format('HH:mm');
if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true; if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true;
showDraftSaveNotification();
}); });
} }
function showDraftSaveNotification() {
$scope.draftUpdated = true;
$timeout(() => {
$scope.draftUpdated = false;
}, 2000)
}
$scope.forceDraftSave = function() { $scope.forceDraftSave = function() {
saveDraft(); saveDraft();
}; };

View File

@ -158,9 +158,22 @@ module.exports = function (ngApp, events) {
return { return {
restrict: 'A', restrict: 'A',
link: function (scope, element, attrs) { link: function (scope, element, attrs) {
var menu = element.find('ul'); const menu = element.find('ul');
element.find('[dropdown-toggle]').on('click', function () { element.find('[dropdown-toggle]').on('click', function () {
menu.show().addClass('anim menuIn'); menu.show().addClass('anim menuIn');
let inputs = menu.find('input');
let hasInput = inputs.length > 0;
if (hasInput) {
inputs.first().focus();
element.on('keypress', 'input', event => {
if (event.keyCode === 13) {
event.preventDefault();
menu.hide();
menu.removeClass('anim menuIn');
return false;
}
});
}
element.mouseleave(function () { element.mouseleave(function () {
menu.hide(); menu.hide();
menu.removeClass('anim menuIn'); menu.removeClass('anim menuIn');
@ -258,8 +271,6 @@ module.exports = function (ngApp, events) {
scope.mdModel = content; scope.mdModel = content;
scope.mdChange(markdown(content)); scope.mdChange(markdown(content));
console.log('test');
element.on('change input', (event) => { element.on('change input', (event) => {
content = element.val(); content = element.val();
$timeout(() => { $timeout(() => {
@ -291,6 +302,7 @@ module.exports = function (ngApp, events) {
const input = element.find('[markdown-input] textarea').first(); const input = element.find('[markdown-input] textarea').first();
const display = element.find('.markdown-display').first(); const display = element.find('.markdown-display').first();
const insertImage = element.find('button[data-action="insertImage"]'); const insertImage = element.find('button[data-action="insertImage"]');
const insertEntityLink = element.find('button[data-action="insertEntityLink"]')
let currentCaretPos = 0; let currentCaretPos = 0;
@ -342,6 +354,13 @@ module.exports = function (ngApp, events) {
input[0].selectionEnd = caretPos + ('![](http://'.length); input[0].selectionEnd = caretPos + ('![](http://'.length);
return; return;
} }
// Insert entity link shortcut
if (event.which === 75 && event.ctrlKey && event.shiftKey) {
showLinkSelector();
return;
}
// Pass key presses to controller via event // Pass key presses to controller via event
scope.$emit('editor-keydown', event); scope.$emit('editor-keydown', event);
}); });
@ -351,12 +370,109 @@ module.exports = function (ngApp, events) {
window.ImageManager.showExternal(image => { window.ImageManager.showExternal(image => {
let caretPos = currentCaretPos; let caretPos = currentCaretPos;
let currentContent = input.val(); let currentContent = input.val();
let mdImageText = "![" + image.name + "](" + image.url + ")"; let mdImageText = "![" + image.name + "](" + image.thumbs.display + ")";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos)); input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.change(); input.change();
}); });
}); });
function showLinkSelector() {
window.showEntityLinkSelector((entity) => {
let selectionStart = currentCaretPos;
let selectionEnd = input[0].selectionEnd;
let textSelected = (selectionEnd !== selectionStart);
let currentContent = input.val();
if (textSelected) {
let selectedText = currentContent.substring(selectionStart, selectionEnd);
let linkText = `[${selectedText}](${entity.link})`;
input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
} else {
let linkText = ` [${entity.name}](${entity.link}) `;
input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
}
input.change();
});
}
insertEntityLink.click(showLinkSelector);
// Upload and insert image on paste
function editorPaste(e) {
e = e.originalEvent;
if (!e.clipboardData) return
var items = e.clipboardData.items;
if (!items) return;
for (var i = 0; i < items.length; i++) {
uploadImage(items[i].getAsFile());
}
}
input.on('paste', editorPaste);
// Handle image drop, Uploads images to BookStack.
function handleImageDrop(event) {
event.stopPropagation();
event.preventDefault();
let files = event.originalEvent.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
uploadImage(files[i]);
}
}
input.on('drop', handleImageDrop);
// Handle image upload and add image into markdown content
function uploadImage(file) {
if (file.type.indexOf('image') !== 0) return;
var formData = new FormData();
var ext = 'png';
var xhr = new XMLHttpRequest();
if (file.name) {
var fileNameMatches = file.name.match(/\.(.+)$/);
if (fileNameMatches) {
ext = fileNameMatches[1];
}
}
// Insert image into markdown
let id = "image-" + Math.random().toString(16).slice(2);
let selectStart = input[0].selectionStart;
let selectEnd = input[0].selectionEnd;
let content = input[0].value;
let selectText = content.substring(selectStart, selectEnd);
let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
input[0].value = content.substring(0, selectStart) + innerContent + content.substring(selectEnd);
input.focus();
input[0].selectionStart = selectStart;
input[0].selectionEnd = selectStart;
let remoteFilename = "image-" + Date.now() + "." + ext;
formData.append('file', file, remoteFilename);
formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
xhr.open('POST', window.baseUrl('/images/gallery/upload'));
xhr.onload = function () {
let selectStart = input[0].selectionStart;
if (xhr.status === 200 || xhr.status === 201) {
var result = JSON.parse(xhr.responseText);
input[0].value = input[0].value.replace(placeholderImage, result.thumbs.display);
input.change();
} else {
console.log('An error occurred uploading the image');
console.log(xhr.responseText);
input[0].value = input[0].value.replace(innerContent, '');
input.change();
}
input.focus();
input[0].selectionStart = selectStart;
input[0].selectionEnd = selectStart;
};
xhr.send(formData);
}
} }
} }
}]); }]);
@ -587,6 +703,58 @@ module.exports = function (ngApp, events) {
} }
}]); }]);
ngApp.directive('entityLinkSelector', [function($http) {
return {
restict: 'A',
link: function(scope, element, attrs) {
const selectButton = element.find('.entity-link-selector-confirm');
let callback = false;
let entitySelection = null;
// Handle entity selection change, Stores the selected entity locally
function entitySelectionChange(entity) {
entitySelection = entity;
if (entity === null) {
selectButton.attr('disabled', 'true');
} else {
selectButton.removeAttr('disabled');
}
}
events.listen('entity-select-change', entitySelectionChange);
// Handle selection confirm button click
selectButton.click(event => {
hide();
if (entitySelection !== null) callback(entitySelection);
});
// Show selector interface
function show() {
element.fadeIn(240);
}
// Hide selector interface
function hide() {
element.fadeOut(240);
}
// Listen to confirmation of entity selections (doubleclick)
events.listen('entity-select-confirm', entity => {
hide();
callback(entity);
});
// Show entity selector, Accessible globally, and store the callback
window.showEntityLinkSelector = function(passedCallback) {
show();
callback = passedCallback;
};
}
};
}]);
ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) { ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
return { return {
@ -600,26 +768,60 @@ module.exports = function (ngApp, events) {
// Add input for forms // Add input for forms
const input = element.find('[entity-selector-input]').first(); const input = element.find('[entity-selector-input]').first();
// Detect double click events
var lastClick = 0;
function isDoubleClick() {
let now = Date.now();
let answer = now - lastClick < 300;
lastClick = now;
return answer;
}
// Listen to entity item clicks // Listen to entity item clicks
element.on('click', '.entity-list a', function(event) { element.on('click', '.entity-list a', function(event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
let item = $(this).closest('[data-entity-type]'); let item = $(this).closest('[data-entity-type]');
itemSelect(item); itemSelect(item, isDoubleClick());
}); });
element.on('click', '[data-entity-type]', function(event) { element.on('click', '[data-entity-type]', function(event) {
itemSelect($(this)); itemSelect($(this), isDoubleClick());
}); });
// Select entity action // Select entity action
function itemSelect(item) { function itemSelect(item, doubleClick) {
let entityType = item.attr('data-entity-type'); let entityType = item.attr('data-entity-type');
let entityId = item.attr('data-entity-id'); let entityId = item.attr('data-entity-id');
let isSelected = !item.hasClass('selected'); let isSelected = !item.hasClass('selected') || doubleClick;
element.find('.selected').removeClass('selected').removeClass('primary-background'); element.find('.selected').removeClass('selected').removeClass('primary-background');
if (isSelected) item.addClass('selected').addClass('primary-background'); if (isSelected) item.addClass('selected').addClass('primary-background');
let newVal = isSelected ? `${entityType}:${entityId}` : ''; let newVal = isSelected ? `${entityType}:${entityId}` : '';
input.val(newVal); input.val(newVal);
if (!isSelected) {
events.emit('entity-select-change', null);
}
if (!doubleClick && !isSelected) return;
let link = item.find('.entity-list-item-link').attr('href');
let name = item.find('.entity-list-item-name').text();
if (doubleClick) {
events.emit('entity-select-confirm', {
id: Number(entityId),
name: name,
link: link
});
}
if (isSelected) {
events.emit('entity-select-change', {
id: Number(entityId),
name: name,
link: link
});
}
} }
// Get search url with correct types // Get search url with correct types

View File

@ -18,9 +18,12 @@ window.baseUrl = function(path) {
var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']); var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
// Global Event System // Global Event System
var Events = { class EventManager {
listeners: {}, constructor() {
emit: function (eventName, eventData) { this.listeners = {};
}
emit(eventName, eventData) {
if (typeof this.listeners[eventName] === 'undefined') return this; if (typeof this.listeners[eventName] === 'undefined') return this;
var eventsToStart = this.listeners[eventName]; var eventsToStart = this.listeners[eventName];
for (let i = 0; i < eventsToStart.length; i++) { for (let i = 0; i < eventsToStart.length; i++) {
@ -28,33 +31,35 @@ var Events = {
event(eventData); event(eventData);
} }
return this; return this;
}, }
listen: function (eventName, callback) {
listen(eventName, callback) {
if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = []; if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
this.listeners[eventName].push(callback); this.listeners[eventName].push(callback);
return this; return this;
} }
}; };
window.Events = Events; window.Events = new EventManager();
var services = require('./services')(ngApp, Events); var services = require('./services')(ngApp, window.Events);
var directives = require('./directives')(ngApp, Events); var directives = require('./directives')(ngApp, window.Events);
var controllers = require('./controllers')(ngApp, Events); var controllers = require('./controllers')(ngApp, window.Events);
//Global jQuery Config & Extensions //Global jQuery Config & Extensions
// Smooth scrolling // Smooth scrolling
jQuery.fn.smoothScrollTo = function () { jQuery.fn.smoothScrollTo = function () {
if (this.length === 0) return; if (this.length === 0) return;
$('body').animate({ let scrollElem = document.documentElement.scrollTop === 0 ? document.body : document.documentElement;
$(scrollElem).animate({
scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin
}, 800); // Adjust to change animations speed (ms) }, 800); // Adjust to change animations speed (ms)
return this; return this;
}; };
// Making contains text expression not worry about casing // Making contains text expression not worry about casing
$.expr[":"].contains = $.expr.createPseudo(function (arg) { jQuery.expr[":"].contains = $.expr.createPseudo(function (arg) {
return function (elem) { return function (elem) {
return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0; return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
}; };
@ -104,13 +109,14 @@ $(function () {
var scrollTop = document.getElementById('back-to-top'); var scrollTop = document.getElementById('back-to-top');
var scrollTopBreakpoint = 1200; var scrollTopBreakpoint = 1200;
window.addEventListener('scroll', function() { window.addEventListener('scroll', function() {
if (!scrollTopShowing && document.body.scrollTop > scrollTopBreakpoint) { let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
if (!scrollTopShowing && scrollTopPos > scrollTopBreakpoint) {
scrollTop.style.display = 'block'; scrollTop.style.display = 'block';
scrollTopShowing = true; scrollTopShowing = true;
setTimeout(() => { setTimeout(() => {
scrollTop.style.opacity = 0.4; scrollTop.style.opacity = 0.4;
}, 1); }, 1);
} else if (scrollTopShowing && document.body.scrollTop < scrollTopBreakpoint) { } else if (scrollTopShowing && scrollTopPos < scrollTopBreakpoint) {
scrollTop.style.opacity = 0; scrollTop.style.opacity = 0;
scrollTopShowing = false; scrollTopShowing = false;
setTimeout(() => { setTimeout(() => {
@ -124,6 +130,27 @@ $(function () {
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240); $('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
}); });
// Popup close
$('.popup-close').click(function() {
$(this).closest('.overlay').fadeOut(240);
});
$('.overlay').click(function(event) {
if (!$(event.target).hasClass('overlay')) return;
$(this).fadeOut(240);
});
// Prevent markdown display link click redirect
$('.markdown-display').on('click', 'a', function(event) {
event.preventDefault();
window.open($(this).attr('href'));
});
// Detect IE for css
if(navigator.userAgent.indexOf('MSIE')!==-1
|| navigator.appVersion.indexOf('Trident/') > 0
|| navigator.userAgent.indexOf('Safari') !== -1){
$('body').addClass('flexbox-support');
}
}); });

View File

@ -1,3 +1,65 @@
"use strict";
/**
* Handle pasting images from clipboard.
* @param e - event
* @param editor - editor instance
*/
function editorPaste(e, editor) {
if (!e.clipboardData) return
let items = e.clipboardData.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") === -1) return
let file = items[i].getAsFile();
let formData = new FormData();
let ext = 'png';
let xhr = new XMLHttpRequest();
if (file.name) {
let fileNameMatches = file.name.match(/\.(.+)$/);
if (fileNameMatches) {
ext = fileNameMatches[1];
}
}
let id = "image-" + Math.random().toString(16).slice(2);
let loadingImage = window.baseUrl('/loading.gif');
editor.execCommand('mceInsertContent', false, `<img src="${loadingImage}" id="${id}">`);
let remoteFilename = "image-" + Date.now() + "." + ext;
formData.append('file', file, remoteFilename);
formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
xhr.open('POST', window.baseUrl('/images/gallery/upload'));
xhr.onload = function () {
if (xhr.status === 200 || xhr.status === 201) {
let result = JSON.parse(xhr.responseText);
editor.dom.setAttrib(id, 'src', result.thumbs.display);
} else {
console.log('An error occurred uploading the image', xhr.responseText);
editor.dom.remove(id);
}
};
xhr.send(formData);
}
}
function registerEditorShortcuts(editor) {
// Headers
for (let i = 1; i < 5; i++) {
editor.addShortcut('ctrl+' + i, '', ['FormatBlock', false, 'h' + i]);
}
// Other block shortcuts
editor.addShortcut('ctrl+q', '', ['FormatBlock', false, 'blockquote']);
editor.addShortcut('ctrl+d', '', ['FormatBlock', false, 'p']);
editor.addShortcut('ctrl+e', '', ['FormatBlock', false, 'pre']);
editor.addShortcut('ctrl+s', '', ['FormatBlock', false, 'code']);
}
var mceOptions = module.exports = { var mceOptions = module.exports = {
selector: '#html-editor', selector: '#html-editor',
content_css: [ content_css: [
@ -6,6 +68,8 @@ var mceOptions = module.exports = {
], ],
body_class: 'page-content', body_class: 'page-content',
relative_urls: false, relative_urls: false,
remove_script_host: false,
document_base_url: window.baseUrl('/'),
statusbar: false, statusbar: false,
menubar: false, menubar: false,
paste_data_images: false, paste_data_images: false,
@ -38,23 +102,41 @@ var mceOptions = module.exports = {
alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'}, alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
}, },
file_browser_callback: function (field_name, url, type, win) { file_browser_callback: function (field_name, url, type, win) {
if (type === 'file') {
window.showEntityLinkSelector(function(entity) {
let originalField = win.document.getElementById(field_name);
originalField.value = entity.link;
$(originalField).closest('.mce-form').find('input').eq(2).val(entity.name);
});
}
if (type === 'image') {
// Show image manager
window.ImageManager.showExternal(function (image) { window.ImageManager.showExternal(function (image) {
// Set popover link input to image url then fire change event
// to ensure the new value sticks
win.document.getElementById(field_name).value = image.url; win.document.getElementById(field_name).value = image.url;
if ("createEvent" in document) { if ("createEvent" in document) {
var evt = document.createEvent("HTMLEvents"); let evt = document.createEvent("HTMLEvents");
evt.initEvent("change", false, true); evt.initEvent("change", false, true);
win.document.getElementById(field_name).dispatchEvent(evt); win.document.getElementById(field_name).dispatchEvent(evt);
} else { } else {
win.document.getElementById(field_name).fireEvent("onchange"); win.document.getElementById(field_name).fireEvent("onchange");
} }
var html = '<a href="' + image.url + '" target="_blank">';
html += '<img src="' + image.thumbs.display + '" alt="' + image.name + '">'; // Replace the actively selected content with the linked image
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>'; html += '</a>';
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html); win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
}); });
}
}, },
paste_preprocess: function (plugin, args) { paste_preprocess: function (plugin, args) {
var content = args.content; let content = args.content;
if (content.indexOf('<img src="file://') !== -1) { if (content.indexOf('<img src="file://') !== -1) {
args.content = ''; args.content = '';
} }
@ -62,10 +144,14 @@ var mceOptions = module.exports = {
extraSetups: [], extraSetups: [],
setup: function (editor) { setup: function (editor) {
for (var i = 0; i < mceOptions.extraSetups.length; i++) { // Run additional setup actions
// Used by the angular side of things
for (let i = 0; i < mceOptions.extraSetups.length; i++) {
mceOptions.extraSetups[i](editor); mceOptions.extraSetups[i](editor);
} }
registerEditorShortcuts(editor);
(function () { (function () {
var wrap; var wrap;
@ -76,13 +162,12 @@ var mceOptions = module.exports = {
editor.on('dragstart', function () { editor.on('dragstart', function () {
var node = editor.selection.getNode(); var node = editor.selection.getNode();
if (node.nodeName === 'IMG') { if (node.nodeName !== 'IMG') return;
wrap = editor.dom.getParent(node, '.mceTemp'); wrap = editor.dom.getParent(node, '.mceTemp');
if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) { if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) {
wrap = node.parentNode; wrap = node.parentNode;
} }
}
}); });
editor.on('drop', function (event) { editor.on('drop', function (event) {
@ -106,15 +191,15 @@ var mceOptions = module.exports = {
}); });
})(); })();
// Image picker button // Custom Image picker button
editor.addButton('image-insert', { editor.addButton('image-insert', {
title: 'My title', title: 'My title',
icon: 'image', icon: 'image',
tooltip: 'Insert an image', tooltip: 'Insert an image',
onclick: function () { onclick: function () {
window.ImageManager.showExternal(function (image) { window.ImageManager.showExternal(function (image) {
var html = '<a href="' + image.url + '" target="_blank">'; let html = `<a href="${image.url}" target="_blank">`;
html += '<img src="' + image.thumbs.display + '" alt="' + image.name + '">'; html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>'; html += '</a>';
editor.execCommand('mceInsertContent', false, html); editor.execCommand('mceInsertContent', false, html);
}); });
@ -122,49 +207,8 @@ var mceOptions = module.exports = {
}); });
// Paste image-uploads // Paste image-uploads
editor.on('paste', function (e) { editor.on('paste', function(event) {
if (e.clipboardData) { editorPaste(event, editor);
var items = e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
var file = items[i].getAsFile();
var formData = new FormData();
var ext = 'png';
var xhr = new XMLHttpRequest();
if (file.name) {
var fileNameMatches = file.name.match(/\.(.+)$/);
if (fileNameMatches) {
ext = fileNameMatches[1];
}
}
var id = "image-" + Math.random().toString(16).slice(2);
editor.execCommand('mceInsertContent', false, '<img src="/loading.gif" id="' + id + '">');
var remoteFilename = "image-" + Date.now() + "." + ext;
formData.append('file', file, remoteFilename);
formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
xhr.open('POST', window.baseUrl('/images/gallery/upload'));
xhr.onload = function () {
if (xhr.status === 200 || xhr.status === 201) {
var result = JSON.parse(xhr.responseText);
editor.dom.setAttrib(id, 'src', result.url);
} else {
console.log('An error occured uploading the image');
console.log(xhr.responseText);
editor.dom.remove(id);
}
};
xhr.send(formData);
}
}
}
}
}); });
} }
}; };

View File

@ -100,3 +100,13 @@ $button-border-radius: 2px;
} }
} }
.button[disabled] {
background-color: #BBB;
cursor: default;
&:hover {
background-color: #BBB;
cursor: default;
box-shadow: none;
}
}

View File

@ -1,5 +1,5 @@
.overlay { .overlay {
background-color: rgba(0, 0, 0, 0.2); background-color: rgba(0, 0, 0, 0.333);
position: fixed; position: fixed;
z-index: 95536; z-index: 95536;
width: 100%; width: 100%;
@ -10,26 +10,76 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
display: none;
} }
.image-manager-body { .popup-body-wrap {
display: flex;
}
.popup-body {
background-color: #FFF; background-color: #FFF;
max-height: 90%; max-height: 90%;
width: 90%; width: 1200px;
height: 90%; height: auto;
margin: 2% 5%; margin: 2% 5%;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3);
overflow: hidden; overflow: hidden;
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 999; z-index: 999;
display: flex; display: flex;
h1, h2, h3 { flex-direction: column;
font-weight: 300; &.small {
margin: 2% auto;
width: 800px;
max-width: 90%;
} }
&:before {
display: flex;
align-self: flex-start;
}
}
//body.ie .popup-body {
// min-height: 100%;
//}
.corner-button {
position: absolute;
top: 0;
right: 0;
margin: 0;
height: 40px;
border-radius: 0;
box-shadow: none;
}
.popup-header, .popup-footer {
display: block !important;
position: relative;
height: 40px;
flex: none !important;
.popup-title {
color: #FFF;
padding: 8px $-m;
}
}
body.flexbox-support #entity-selector-wrap .popup-body .form-group {
height: 444px;
min-height: 444px;
}
#entity-selector-wrap .popup-body .form-group {
margin: 0;
}
//body.ie #entity-selector-wrap .popup-body .form-group {
// min-height: 60vh;
//}
.image-manager-body {
min-height: 70vh;
} }
#image-manager .dropzone-container { #image-manager .dropzone-container {
@ -37,12 +87,6 @@
border: 3px dashed #DDD; border: 3px dashed #DDD;
} }
.image-manager-bottom {
position: absolute;
bottom: 0;
right: 0;
}
.image-manager-list .image { .image-manager-list .image {
display: block; display: block;
position: relative; position: relative;
@ -103,18 +147,13 @@
.image-manager-sidebar { .image-manager-sidebar {
width: 300px; width: 300px;
height: 100%;
margin-left: 1px; margin-left: 1px;
padding: 0 $-l; padding: $-m $-l;
overflow-y: auto;
border-left: 1px solid #DDD; border-left: 1px solid #DDD;
.dropzone-container {
margin-top: $-m;
} }
.image-manager-close {
position: absolute;
top: 0;
right: 0;
margin: 0;
border-radius: 0;
} }
.image-manager-list { .image-manager-list {
@ -125,7 +164,6 @@
.image-manager-content { .image-manager-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
flex: 1; flex: 1;
.container { .container {
width: 100%; width: 100%;
@ -141,12 +179,13 @@
* Copyright (c) 2012 Matias Meno <m@tias.me> * Copyright (c) 2012 Matias Meno <m@tias.me>
*/ */
.dz-message { .dz-message {
font-size: 1.4em; font-size: 1.2em;
line-height: 1.1;
font-style: italic; font-style: italic;
color: #aaa; color: #aaa;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
padding: $-xl $-m; padding: $-l $-m;
transition: all ease-in-out 120ms; transition: all ease-in-out 120ms;
} }

View File

@ -25,6 +25,14 @@ body.flexbox {
} }
} }
.flex-child > div {
flex: 1;
}
//body.ie .flex-child > div {
// flex: 1 0 0px;
//}
/** Rules for all columns */ /** Rules for all columns */
div[class^="col-"] img { div[class^="col-"] img {
max-width: 100%; max-width: 100%;
@ -39,6 +47,9 @@ div[class^="col-"] img {
&.fluid { &.fluid {
max-width: 100%; max-width: 100%;
} }
&.medium {
max-width: 992px;
}
&.small { &.small {
max-width: 840px; max-width: 840px;
} }

View File

@ -155,6 +155,7 @@ form.search-box {
text-decoration: none; text-decoration: none;
} }
} }
} }
.faded span.faded-text { .faded span.faded-text {

View File

@ -375,6 +375,9 @@ ul.pagination {
.text-muted { .text-muted {
color: #999; color: #999;
} }
li.padded {
padding: $-xs $-m;
}
a { a {
display: block; display: block;
padding: $-xs $-m; padding: $-xs $-m;
@ -384,10 +387,10 @@ ul.pagination {
background-color: #EEE; background-color: #EEE;
} }
i { i {
margin-right: $-m; margin-right: $-s;
padding-right: 0; padding-right: 0;
display: inline; display: inline-block;
width: 22px; width: 16px;
} }
} }
li.border-bottom { li.border-bottom {

View File

@ -20,6 +20,16 @@
} }
} }
.draft-notification {
pointer-events: none;
transform: scale(0);
transition: transform ease-in-out 120ms;
transform-origin: 50% 50%;
&.visible {
transform: scale(1);
}
}
.page-style.editor { .page-style.editor {
padding: 0 !important; padding: 0 !important;
} }
@ -238,7 +248,7 @@
} }
.tag-display { .tag-display {
margin: $-xl $-xs; margin: $-xl $-m;
border: 1px solid #DDD; border: 1px solid #DDD;
min-width: 180px; min-width: 180px;
max-width: 320px; max-width: 320px;

View File

@ -12,7 +12,7 @@
@import "animations"; @import "animations";
@import "tinymce"; @import "tinymce";
@import "highlightjs"; @import "highlightjs";
@import "image-manager"; @import "components";
@import "header"; @import "header";
@import "lists"; @import "lists";
@import "pages"; @import "pages";
@ -72,7 +72,7 @@ body.dragging, body.dragging * {
border-radius: 3px; border-radius: 3px;
box-shadow: $bs-med; box-shadow: $bs-med;
z-index: 999999; z-index: 999999;
display: table; display: block;
cursor: pointer; cursor: pointer;
max-width: 480px; max-width: 480px;
i, span { i, span {

View File

@ -1,5 +1,5 @@
<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}"> <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 entity-list-item-link" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i><span class="entity-list-item-name">{{$book->name}}</span></a></h3>
@if(isset($book->searchSnippet)) @if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p> <p class="text-muted">{!! $book->searchSnippet !!}</p>
@else @else

View File

@ -50,7 +50,7 @@
var sortableOptions = { var sortableOptions = {
group: 'serialization', group: 'serialization',
onDrop: function($item, container, _super) { onDrop: function($item, container, _super) {
var pageMap = buildPageMap(); var pageMap = buildEntityMap();
$('#sort-tree-input').val(JSON.stringify(pageMap)); $('#sort-tree-input').val(JSON.stringify(pageMap));
_super($item, container); _super($item, container);
}, },
@ -74,29 +74,42 @@
$link.remove(); $link.remove();
}); });
function buildPageMap() { /**
var pageMap = []; * Build up a mapping of entities with their ordering and nesting.
* @returns {Array}
*/
function buildEntityMap() {
var entityMap = [];
var $lists = $('.sort-list'); var $lists = $('.sort-list');
$lists.each(function(listIndex) { $lists.each(function(listIndex) {
var list = $(this); var list = $(this);
var bookId = list.closest('[data-type="book"]').attr('data-id'); var bookId = list.closest('[data-type="book"]').attr('data-id');
var $childElements = list.find('[data-type="page"], [data-type="chapter"]'); var $directChildren = list.find('> [data-type="page"], > [data-type="chapter"]');
$childElements.each(function(childIndex) { $directChildren.each(function(directChildIndex) {
var $childElem = $(this); var $childElem = $(this);
var type = $childElem.attr('data-type'); var type = $childElem.attr('data-type');
var parentChapter = false; var parentChapter = false;
if(type === 'page' && $childElem.closest('[data-type="chapter"]').length === 1) { var childId = $childElem.attr('data-id');
parentChapter = $childElem.closest('[data-type="chapter"]').attr('data-id'); entityMap.push({
} id: childId,
pageMap.push({ sort: directChildIndex,
id: $childElem.attr('data-id'),
parentChapter: parentChapter, parentChapter: parentChapter,
type: type, type: type,
book: bookId book: bookId
}); });
$chapterChildren = $childElem.find('[data-type="page"]').each(function(pageIndex) {
var $chapterChild = $(this);
entityMap.push({
id: $chapterChild.attr('data-id'),
sort: pageIndex,
parentChapter: childId,
type: 'page',
book: bookId
}); });
}); });
return pageMap; });
});
return entityMap;
} }
}); });

View File

@ -6,8 +6,8 @@
</a> </a>
<span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span> <span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
@endif @endif
<a href="{{ $chapter->getUrl() }}" class="text-chapter"> <a href="{{ $chapter->getUrl() }}" class="text-chapter entity-list-item-link">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }} <i class="zmdi zmdi-collection-bookmark"></i><span class="entity-list-item-name">{{ $chapter->name }}</span>
</a> </a>
</h3> </h3>
@if(isset($chapter->searchSnippet)) @if(isset($chapter->searchSnippet))

View File

@ -19,6 +19,14 @@
</div> </div>
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
@include('partials/entity-selector-popup')
<script>
(function() {
})();
</script>
@stop @stop

View File

@ -13,8 +13,9 @@
</div> </div>
<div class="col-sm-4 faded text-center"> <div class="col-sm-4 faded text-center">
<div dropdown class="dropdown-container"> <div dropdown class="dropdown-container draft-display">
<a dropdown-toggle class="text-primary text-button"><span class="faded-text" ng-bind="draftText"></span>&nbsp; <i class="zmdi zmdi-more-vert"></i></a> <a dropdown-toggle class="text-primary text-button"><span class="faded-text" ng-bind="draftText"></span>&nbsp; <i class="zmdi zmdi-more-vert"></i></a>
<i class="zmdi zmdi-check-circle text-pos draft-notification" ng-class="{visible: draftUpdated}"></i>
<ul> <ul>
<li> <li>
<a ng-click="forceDraftSave()" class="text-pos"><i class="zmdi zmdi-save"></i>Save Draft</a> <a ng-click="forceDraftSave()" class="text-pos"><i class="zmdi zmdi-save"></i>Save Draft</a>
@ -22,13 +23,24 @@
<li ng-if="isNewPageDraft"> <li ng-if="isNewPageDraft">
<a href="{{ $model->getUrl('/delete') }}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete Draft</a> <a href="{{ $model->getUrl('/delete') }}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete Draft</a>
</li> </li>
<li>
<a type="button" ng-if="isUpdateDraft" ng-click="discardDraft()" class="text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="col-sm-4 faded"> <div class="col-sm-4 faded">
<div class="action-buttons" ng-cloak> <div class="action-buttons" ng-cloak>
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-edit"></i> @{{(changeSummary | limitTo:16) + (changeSummary.length>16?'...':'') || 'Set Changelog'}}</a>
<ul class="wide">
<li class="padded">
<p class="text-muted">Enter a brief description of the changes you've made</p>
<input name="summary" id="summary-input" type="text" placeholder="Enter Changelog" ng-model="changeSummary" />
</li>
</ul>
</div>
<button type="button" ng-if="isUpdateDraft" ng-click="discardDraft()" class="text-button text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</button>
<button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button> <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button>
</div> </div>
</div> </div>
@ -62,6 +74,8 @@
<span class="float left">Editor</span> <span class="float left">Editor</span>
<div class="float right buttons"> <div class="float right buttons">
<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>
&nbsp;|&nbsp;
<button class="text-button" type="button" data-action="insertEntityLink"><i class="zmdi zmdi-link"></i>Insert Entity Link</button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}"> <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 entity-list-item-link"><i class="zmdi zmdi-file-text"></i><span class="entity-list-item-name">{{ $page->name }}</span></a>
</h3> </h3>
@if(isset($page->searchSnippet)) @if(isset($page->searchSnippet))

View File

@ -16,7 +16,7 @@
</a> </a>
@endif @endif
<span class="sep">&raquo;</span> <span class="sep">&raquo;</span>
<a href="{{ $page->getUrl() }}" class="text-book text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a> <a href="{{ $page->getUrl() }}" class="text-page text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,31 +5,40 @@
<div class="faded-small toolbar"> <div class="faded-small toolbar">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-md-6 faded"> <div class="col-sm-12 faded">
<div class="breadcrumbs"> <div class="breadcrumbs">
<a href="{{ $page->getUrl() }}" class="text-primary text-button"><i class="zmdi zmdi-arrow-left"></i>Back to page</a> <a href="{{ $page->book->getUrl() }}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}</a>
@if($page->hasChapter())
<span class="sep">&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"></i>{{ $page->getShortName() }}</a>
</div> </div>
</div> </div>
<div class="col-md-6 faded">
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="container small" ng-non-bindable>
<div class="container" ng-non-bindable>
<h1>Page Revisions <span class="subheader">For "{{ $page->name }}"</span></h1> <h1>Page Revisions <span class="subheader">For "{{ $page->name }}"</span></h1>
@if(count($page->revisions) > 0) @if(count($page->revisions) > 0)
<table class="table"> <table class="table">
<tr> <tr>
<th width="40%">Name</th> <th width="25%">Name</th>
<th colspan="2" width="20%">Created By</th> <th colspan="2" width="10%">Created By</th>
<th width="20%">Revision Date</th> <th width="15%">Revision Date</th>
<th width="20%">Actions</th> <th width="25%">Changelog</th>
<th width="15%">Actions</th>
</tr> </tr>
@foreach($page->revisions as $revision) @foreach($page->revisions as $index => $revision)
<tr> <tr>
<td>{{ $revision->name }}</td> <td>{{ $revision->name }}</td>
<td style="line-height: 0;"> <td style="line-height: 0;">
@ -39,11 +48,16 @@
</td> </td>
<td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif</td> <td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif</td>
<td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td> <td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
<td>{{ $revision->summary }}</td>
@if ($index !== 0)
<td> <td>
<a href="{{ $revision->getUrl() }}" target="_blank">Preview</a> <a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
<span class="text-muted">&nbsp;|&nbsp;</span> <span class="text-muted">&nbsp;|&nbsp;</span>
<a href="{{ $revision->getUrl('/restore') }}">Restore</a> <a href="{{ $revision->getUrl() }}/restore">Restore</a>
</td> </td>
@else
<td><a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a></td>
@endif
</tr> </tr>
@endforeach @endforeach
</table> </table>

View File

@ -58,7 +58,7 @@
<div class="container" id="page-show" ng-non-bindable> <div class="container" id="page-show" ng-non-bindable>
<div class="row"> <div class="row">
<div class="col-md-9 print-full-width"> <div class="col-md-9 print-full-width">
<div class="page-content anim fadeIn"> <div class="page-content">
<div class="pointer-container" id="pointer"> <div class="pointer-container" id="pointer">
<div class="pointer anim"> <div class="pointer anim">

View File

@ -0,0 +1,14 @@
<div id="entity-selector-wrap">
<div class="overlay" entity-link-selector>
<div class="popup-body small flex-child">
<div class="popup-header primary-background">
<div class="popup-title">Entity Select</div>
<button type="button" class="corner-button neg button popup-close">x</button>
</div>
@include('partials/entity-selector', ['name' => 'entity-selector'])
<div class="popup-footer">
<button type="button" disabled="true" class="button entity-link-selector-confirm pos corner-button">Select</button>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,13 @@
<div id="image-manager" image-type="{{ $imageType }}" ng-controller="ImageManagerController" uploaded-to="{{ $uploaded_to or 0 }}"> <div id="image-manager" image-type="{{ $imageType }}" ng-controller="ImageManagerController" uploaded-to="{{ $uploaded_to or 0 }}">
<div class="overlay anim-slide" ng-show="showing" ng-cloak ng-click="hide()"> <div class="overlay" ng-cloak ng-click="hide()">
<div class="image-manager-body" ng-click="$event.stopPropagation()"> <div class="popup-body" ng-click="$event.stopPropagation()">
<div class="popup-header primary-background">
<div class="popup-title">Image Select</div>
<button class="popup-close neg corner-button button">x</button>
</div>
<div class="flex-fill image-manager-body">
<div class="image-manager-content"> <div class="image-manager-content">
<div ng-if="imageType === 'gallery'" class="container"> <div ng-if="imageType === 'gallery'" class="container">
@ -24,7 +31,7 @@
<img ng-src="@{{image.thumbs.gallery}}" ng-attr-alt="@{{image.title}}" ng-attr-title="@{{image.name}}"> <img ng-src="@{{image.thumbs.gallery}}" ng-attr-alt="@{{image.title}}" ng-attr-title="@{{image.name}}">
<div class="image-meta"> <div class="image-meta">
<span class="name" ng-bind="image.name"></span> <span class="name" ng-bind="image.name"></span>
<span class="date">Uploaded @{{ getDate(image.created_at) | date:'mediumDate' }}</span> <span class="date">Uploaded @{{ getDate(image.created_at) }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -32,14 +39,10 @@
</div> </div>
</div> </div>
<button class="neg button image-manager-close" ng-click="hide()">x</button>
<div class="image-manager-sidebar"> <div class="image-manager-sidebar">
<h2>Images</h2> <div class="inner">
<drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
<div class="image-manager-details anim fadeIn" ng-show="selectedImage">
<hr class="even"> <div class="image-manager-details anim fadeIn" ng-show="selectedImage">
<form ng-submit="saveImageDetails($event)"> <form ng-submit="saveImageDetails($event)">
<div> <div>
@ -53,8 +56,6 @@
</div> </div>
</form> </form>
<hr class="even">
<div ng-show="dependantPages"> <div ng-show="dependantPages">
<p class="text-neg text-small"> <p class="text-neg text-small">
This image is used in the pages below, Click delete again to confirm you want to delete This image is used in the pages below, Click delete again to confirm you want to delete
@ -67,18 +68,27 @@
</ul> </ul>
</div> </div>
<form ng-submit="deleteImage($event)"> <div class="clearfix">
<button class="button neg"><i class="zmdi zmdi-delete"></i>Delete Image</button> <form class="float left" ng-submit="deleteImage($event)">
<button class="button icon neg"><i class="zmdi zmdi-delete"></i></button>
</form> </form>
</div> <button class="button pos anim fadeIn float right" ng-show="selectedImage" ng-click="selectButtonClick()">
<div class="image-manager-bottom">
<button class="button pos anim fadeIn" ng-show="selectedImage" ng-click="selectButtonClick()">
<i class="zmdi zmdi-square-right"></i>Select Image <i class="zmdi zmdi-square-right"></i>Select Image
</button> </button>
</div> </div>
</div> </div>
<drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -76,6 +76,14 @@ class EntitySearchTest extends TestCase
->see('Chapter Search Results')->seeInElement('.entity-list', $chapter->name); ->see('Chapter Search Results')->seeInElement('.entity-list', $chapter->name);
} }
public function test_search_quote_term_preparation()
{
$termString = '"192" cat "dog hat"';
$repo = $this->app[\BookStack\Repos\EntityRepo::class];
$preparedTerms = $repo->prepareSearchTerms($termString);
$this->assertTrue($preparedTerms === ['"192"','"dog hat"', 'cat']);
}
public function test_books_search_listing() public function test_books_search_listing()
{ {
$book = \BookStack\Book::all()->last(); $book = \BookStack\Book::all()->last();

View File

@ -218,13 +218,24 @@ class EntityTest extends TestCase
public function test_old_page_slugs_redirect_to_new_pages() public function test_old_page_slugs_redirect_to_new_pages()
{ {
$page = \BookStack\Page::all()->first(); $page = \BookStack\Page::first();
$pageUrl = $page->getUrl(); $pageUrl = $page->getUrl();
$newPageUrl = '/books/' . $page->book->slug . '/page/super-test-page'; $newPageUrl = '/books/' . $page->book->slug . '/page/super-test-page';
// Need to save twice since revisions are not generated in seeder.
$this->asAdmin()->visit($pageUrl) $this->asAdmin()->visit($pageUrl)
->clickInElement('#content', 'Edit')
->type('super test', '#name')
->press('Save Page');
$page = \BookStack\Page::first();
$pageUrl = $page->getUrl();
// Second Save
$this->visit($pageUrl)
->clickInElement('#content', 'Edit') ->clickInElement('#content', 'Edit')
->type('super test page', '#name') ->type('super test page', '#name')
->press('Save Page') ->press('Save Page')
// Check redirect
->seePageIs($newPageUrl) ->seePageIs($newPageUrl)
->visit($pageUrl) ->visit($pageUrl)
->seePageIs($newPageUrl); ->seePageIs($newPageUrl);