mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-08-25 03:35:35 +00:00
Merge pull request #110 from ssddanbrown/page_attributes
Attribute System. Closes #48.
This commit is contained in:
commit
0b364fd72f
@ -1,11 +1,9 @@
|
||||
language: php
|
||||
|
||||
php:
|
||||
- 7.0
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
- vendor
|
||||
|
||||
addons:
|
||||
|
@ -1,7 +1,7 @@
|
||||
<?php namespace BookStack;
|
||||
|
||||
|
||||
abstract class Entity extends Ownable
|
||||
class Entity extends Ownable
|
||||
{
|
||||
|
||||
/**
|
||||
@ -54,6 +54,15 @@ abstract class Entity extends Ownable
|
||||
return $this->morphMany(View::class, 'viewable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Tag models that have been user assigned to this entity.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
|
||||
*/
|
||||
public function tags()
|
||||
{
|
||||
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this entities restrictions.
|
||||
*/
|
||||
@ -114,6 +123,22 @@ abstract class Entity extends Ownable
|
||||
return strtolower(static::getClassName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance of an entity of the given type.
|
||||
* @param $type
|
||||
* @return Entity
|
||||
*/
|
||||
public static function getEntityInstance($type)
|
||||
{
|
||||
$types = ['Page', 'Book', 'Chapter'];
|
||||
$className = str_replace([' ', '-', '_'], '', ucwords($type));
|
||||
if (!in_array($className, $types)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app('BookStack\\' . $className);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a limited-length version of the entities name.
|
||||
* @param int $length
|
||||
@ -132,54 +157,54 @@ abstract class Entity extends Ownable
|
||||
* @param string[] array $wheres
|
||||
* @return mixed
|
||||
*/
|
||||
public static function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
|
||||
public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
|
||||
{
|
||||
$exactTerms = [];
|
||||
foreach ($terms as $key => $term) {
|
||||
$term = htmlentities($term, ENT_QUOTES);
|
||||
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
|
||||
if (preg_match('/\s/', $term)) {
|
||||
$exactTerms[] = '%' . $term . '%';
|
||||
$term = '"' . $term . '"';
|
||||
} else {
|
||||
$term = '' . $term . '*';
|
||||
}
|
||||
if ($term !== '*') $terms[$key] = $term;
|
||||
}
|
||||
$termString = implode(' ', $terms);
|
||||
$fields = implode(',', $fieldsToSearch);
|
||||
$search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
|
||||
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
|
||||
|
||||
// Ensure at least one exact term matches if in search
|
||||
if (count($exactTerms) > 0) {
|
||||
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
|
||||
foreach ($exactTerms as $exactTerm) {
|
||||
foreach ($fieldsToSearch as $field) {
|
||||
$query->orWhere($field, 'like', $exactTerm);
|
||||
}
|
||||
if (count($terms) === 0) {
|
||||
$search = $this;
|
||||
$orderBy = 'updated_at';
|
||||
} else {
|
||||
foreach ($terms as $key => $term) {
|
||||
$term = htmlentities($term, ENT_QUOTES);
|
||||
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
|
||||
if (preg_match('/\s/', $term)) {
|
||||
$exactTerms[] = '%' . $term . '%';
|
||||
$term = '"' . $term . '"';
|
||||
} else {
|
||||
$term = '' . $term . '*';
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($term !== '*') $terms[$key] = $term;
|
||||
}
|
||||
$termString = implode(' ', $terms);
|
||||
$fields = implode(',', $fieldsToSearch);
|
||||
$search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
|
||||
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
|
||||
|
||||
// Ensure at least one exact term matches if in search
|
||||
if (count($exactTerms) > 0) {
|
||||
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
|
||||
foreach ($exactTerms as $exactTerm) {
|
||||
foreach ($fieldsToSearch as $field) {
|
||||
$query->orWhere($field, 'like', $exactTerm);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
$orderBy = 'title_relevance';
|
||||
};
|
||||
|
||||
// Add additional where terms
|
||||
foreach ($wheres as $whereTerm) {
|
||||
$search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
|
||||
}
|
||||
// Load in relations
|
||||
if (static::isA('page')) {
|
||||
if ($this->isA('page')) {
|
||||
$search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
|
||||
} else if (static::isA('chapter')) {
|
||||
} else if ($this->isA('chapter')) {
|
||||
$search = $search->with('book');
|
||||
}
|
||||
|
||||
return $search->orderBy('title_relevance', 'desc');
|
||||
return $search->orderBy($orderBy, 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url for this item.
|
||||
* @return string
|
||||
*/
|
||||
abstract public function getUrl();
|
||||
|
||||
|
||||
}
|
||||
|
@ -110,4 +110,15 @@ abstract class Controller extends BaseController
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send back a json error message.
|
||||
* @param string $messageText
|
||||
* @param int $statusCode
|
||||
* @return mixed
|
||||
*/
|
||||
protected function jsonError($messageText = "", $statusCode = 500)
|
||||
{
|
||||
return response()->json(['message' => $messageText], $statusCode);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ class PageController extends Controller
|
||||
$this->checkOwnablePermission('page-create', $book);
|
||||
$this->setPageTitle('Edit Page Draft');
|
||||
|
||||
return view('pages/create', ['draft' => $draft, 'book' => $book]);
|
||||
return view('pages/edit', ['page' => $draft, 'book' => $book, 'isDraft' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
74
app/Http/Controllers/TagController.php
Normal file
74
app/Http/Controllers/TagController.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Repos\TagRepo;
|
||||
use Illuminate\Http\Request;
|
||||
use BookStack\Http\Requests;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
|
||||
protected $tagRepo;
|
||||
|
||||
/**
|
||||
* TagController constructor.
|
||||
* @param $tagRepo
|
||||
*/
|
||||
public function __construct(TagRepo $tagRepo)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the Tags for a particular entity
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$tags = $this->tagRepo->getForEntity($entityType, $entityId);
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tags for a particular entity.
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function updateForEntity($entityType, $entityId, Request $request)
|
||||
{
|
||||
$entity = $this->tagRepo->getEntity($entityType, $entityId, 'update');
|
||||
if ($entity === null) return $this->jsonError("Entity not found", 404);
|
||||
|
||||
$inputTags = $request->input('tags');
|
||||
$tags = $this->tagRepo->saveTagsToEntity($entity, $inputTags);
|
||||
return response()->json([
|
||||
'tags' => $tags,
|
||||
'message' => 'Tags successfully updated'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from a given search term.
|
||||
* @param Request $request
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search');
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from a given search term.
|
||||
* @param Request $request
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
}
|
@ -28,7 +28,7 @@ Route::group(['middleware' => 'auth'], function () {
|
||||
// Pages
|
||||
Route::get('/{bookSlug}/page/create', 'PageController@create');
|
||||
Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft');
|
||||
Route::post('/{bookSlug}/page/{pageId}', 'PageController@store');
|
||||
Route::post('/{bookSlug}/draft/{pageId}', 'PageController@store');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageController@exportPdf');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
|
||||
@ -80,11 +80,19 @@ Route::group(['middleware' => 'auth'], function () {
|
||||
Route::delete('/{imageId}', 'ImageController@destroy');
|
||||
});
|
||||
|
||||
// Ajax routes
|
||||
// AJAX routes
|
||||
Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
|
||||
Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
|
||||
Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
|
||||
|
||||
// Tag routes (AJAX)
|
||||
Route::group(['prefix' => 'ajax/tags'], function() {
|
||||
Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity');
|
||||
Route::get('/suggest/names', 'TagController@getNameSuggestions');
|
||||
Route::get('/suggest/values', 'TagController@getValueSuggestions');
|
||||
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
|
||||
});
|
||||
|
||||
// Links
|
||||
Route::get('/link/{id}', 'PageController@redirectFromLink');
|
||||
|
||||
|
@ -286,8 +286,9 @@ class BookRepo extends EntityRepo
|
||||
public function getBySearch($term, $count = 20, $paginationAppends = [])
|
||||
{
|
||||
$terms = $this->prepareSearchTerms($term);
|
||||
$books = $this->permissionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms))
|
||||
->paginate($count)->appends($paginationAppends);
|
||||
$bookQuery = $this->permissionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms));
|
||||
$bookQuery = $this->addAdvancedSearchQueries($bookQuery, $term);
|
||||
$books = $bookQuery->paginate($count)->appends($paginationAppends);
|
||||
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
|
||||
foreach ($books as $book) {
|
||||
//highlight
|
||||
|
@ -168,8 +168,9 @@ class ChapterRepo extends EntityRepo
|
||||
public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
|
||||
{
|
||||
$terms = $this->prepareSearchTerms($term);
|
||||
$chapters = $this->permissionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms))
|
||||
->paginate($count)->appends($paginationAppends);
|
||||
$chapterQuery = $this->permissionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms));
|
||||
$chapterQuery = $this->addAdvancedSearchQueries($chapterQuery, $term);
|
||||
$chapters = $chapterQuery->paginate($count)->appends($paginationAppends);
|
||||
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
|
||||
foreach ($chapters as $chapter) {
|
||||
//highlight
|
||||
|
@ -6,6 +6,7 @@ use BookStack\Entity;
|
||||
use BookStack\Page;
|
||||
use BookStack\Services\PermissionService;
|
||||
use BookStack\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EntityRepo
|
||||
{
|
||||
@ -30,6 +31,12 @@ class EntityRepo
|
||||
*/
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* Acceptable operators to be used in a query
|
||||
* @var array
|
||||
*/
|
||||
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
|
||||
|
||||
/**
|
||||
* EntityService constructor.
|
||||
*/
|
||||
@ -163,6 +170,7 @@ class EntityRepo
|
||||
*/
|
||||
protected function prepareSearchTerms($termString)
|
||||
{
|
||||
$termString = $this->cleanSearchTermString($termString);
|
||||
preg_match_all('/"(.*?)"/', $termString, $matches);
|
||||
if (count($matches[1]) > 0) {
|
||||
$terms = $matches[1];
|
||||
@ -174,5 +182,93 @@ class EntityRepo
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any special search notation that should not
|
||||
* be used in a full-text search.
|
||||
* @param $termString
|
||||
* @return mixed
|
||||
*/
|
||||
protected function cleanSearchTermString($termString)
|
||||
{
|
||||
// Strip tag searches
|
||||
$termString = preg_replace('/\[.*?\]/', '', $termString);
|
||||
// Reduced multiple spacing into single spacing
|
||||
$termString = preg_replace("/\s{2,}/", " ", $termString);
|
||||
return $termString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available query operators as a regex escaped list.
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getRegexEscapedOperators()
|
||||
{
|
||||
$escapedOperators = [];
|
||||
foreach ($this->queryOperators as $operator) {
|
||||
$escapedOperators[] = preg_quote($operator);
|
||||
}
|
||||
return join('|', $escapedOperators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses advanced search notations and adds them to the db query.
|
||||
* @param $query
|
||||
* @param $termString
|
||||
* @return mixed
|
||||
*/
|
||||
protected function addAdvancedSearchQueries($query, $termString)
|
||||
{
|
||||
$escapedOperators = $this->getRegexEscapedOperators();
|
||||
// Look for tag searches
|
||||
preg_match_all("/\[(.*?)((${escapedOperators})(.*?))?\]/", $termString, $tags);
|
||||
if (count($tags[0]) > 0) {
|
||||
$this->applyTagSearches($query, $tags);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply extracted tag search terms onto a entity query.
|
||||
* @param $query
|
||||
* @param $tags
|
||||
* @return mixed
|
||||
*/
|
||||
protected function applyTagSearches($query, $tags) {
|
||||
$query->where(function($query) use ($tags) {
|
||||
foreach ($tags[1] as $index => $tagName) {
|
||||
$query->whereHas('tags', function($query) use ($tags, $index, $tagName) {
|
||||
$tagOperator = $tags[3][$index];
|
||||
$tagValue = $tags[4][$index];
|
||||
if (!empty($tagOperator) && !empty($tagValue) && in_array($tagOperator, $this->queryOperators)) {
|
||||
if (is_numeric($tagValue) && $tagOperator !== 'like') {
|
||||
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
|
||||
// search the value as a string which prevents being able to do number-based operations
|
||||
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
|
||||
$tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
|
||||
$query->where('name', '=', $tagName)->whereRaw("value ${tagOperator} ${tagValue}");
|
||||
} else {
|
||||
$query->where('name', '=', $tagName)->where('value', $tagOperator, $tagValue);
|
||||
}
|
||||
} else {
|
||||
$query->where('name', '=', $tagName);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return $query;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
@ -14,14 +14,17 @@ class PageRepo extends EntityRepo
|
||||
{
|
||||
|
||||
protected $pageRevision;
|
||||
protected $tagRepo;
|
||||
|
||||
/**
|
||||
* PageRepo constructor.
|
||||
* @param PageRevision $pageRevision
|
||||
* @param TagRepo $tagRepo
|
||||
*/
|
||||
public function __construct(PageRevision $pageRevision)
|
||||
public function __construct(PageRevision $pageRevision, TagRepo $tagRepo)
|
||||
{
|
||||
$this->pageRevision = $pageRevision;
|
||||
$this->tagRepo = $tagRepo;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@ -142,6 +145,11 @@ class PageRepo extends EntityRepo
|
||||
{
|
||||
$draftPage->fill($input);
|
||||
|
||||
// Save page tags if present
|
||||
if(isset($input['tags'])) {
|
||||
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
|
||||
}
|
||||
|
||||
$draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id);
|
||||
$draftPage->html = $this->formatHtml($input['html']);
|
||||
$draftPage->text = strip_tags($draftPage->html);
|
||||
@ -242,8 +250,9 @@ class PageRepo extends EntityRepo
|
||||
public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
|
||||
{
|
||||
$terms = $this->prepareSearchTerms($term);
|
||||
$pages = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms))
|
||||
->paginate($count)->appends($paginationAppends);
|
||||
$pageQuery = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms));
|
||||
$pageQuery = $this->addAdvancedSearchQueries($pageQuery, $term);
|
||||
$pages = $pageQuery->paginate($count)->appends($paginationAppends);
|
||||
|
||||
// Add highlights to page text.
|
||||
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
|
||||
@ -308,6 +317,11 @@ class PageRepo extends EntityRepo
|
||||
$page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id);
|
||||
}
|
||||
|
||||
// Save page tags if present
|
||||
if(isset($input['tags'])) {
|
||||
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
|
||||
}
|
||||
|
||||
// Update with new details
|
||||
$userId = auth()->user()->id;
|
||||
$page->fill($input);
|
||||
@ -582,6 +596,7 @@ class PageRepo extends EntityRepo
|
||||
{
|
||||
Activity::removeEntity($page);
|
||||
$page->views()->delete();
|
||||
$page->tags()->delete();
|
||||
$page->revisions()->delete();
|
||||
$page->permissions()->delete();
|
||||
$this->permissionService->deleteJointPermissionsForEntity($page);
|
||||
|
116
app/Repos/TagRepo.php
Normal file
116
app/Repos/TagRepo.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php namespace BookStack\Repos;
|
||||
|
||||
use BookStack\Tag;
|
||||
use BookStack\Entity;
|
||||
use BookStack\Services\PermissionService;
|
||||
|
||||
/**
|
||||
* Class TagRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class TagRepo
|
||||
{
|
||||
|
||||
protected $tag;
|
||||
protected $entity;
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* TagRepo constructor.
|
||||
* @param Tag $attr
|
||||
* @param Entity $ent
|
||||
* @param PermissionService $ps
|
||||
*/
|
||||
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
|
||||
{
|
||||
$this->tag = $attr;
|
||||
$this->entity = $ent;
|
||||
$this->permissionService = $ps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an entity instance of its particular type.
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @param string $action
|
||||
*/
|
||||
public function getEntity($entityType, $entityId, $action = 'view')
|
||||
{
|
||||
$entityInstance = $this->entity->getEntityInstance($entityType);
|
||||
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
|
||||
$searchQuery = $this->permissionService->enforceEntityRestrictions($searchQuery, $action);
|
||||
return $searchQuery->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a particular entity.
|
||||
* @param string $entityType
|
||||
* @param int $entityId
|
||||
* @return mixed
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$entity = $this->getEntity($entityType, $entityId);
|
||||
if ($entity === null) return collect();
|
||||
|
||||
return $entity->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* @param $searchTerm
|
||||
* @return array
|
||||
*/
|
||||
public function getNameSuggestions($searchTerm)
|
||||
{
|
||||
if ($searchTerm === '') return [];
|
||||
$query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc');
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['name'])->pluck('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from scanning existing tag values.
|
||||
* @param $searchTerm
|
||||
* @return array
|
||||
*/
|
||||
public function getValueSuggestions($searchTerm)
|
||||
{
|
||||
if ($searchTerm === '') return [];
|
||||
$query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['value'])->pluck('value');
|
||||
}
|
||||
/**
|
||||
* Save an array of tags to an entity
|
||||
* @param Entity $entity
|
||||
* @param array $tags
|
||||
* @return array|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function saveTagsToEntity(Entity $entity, $tags = [])
|
||||
{
|
||||
$entity->tags()->delete();
|
||||
$newTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (trim($tag['name']) === '') continue;
|
||||
$newTags[] = $this->newInstanceFromInput($tag);
|
||||
}
|
||||
|
||||
return $entity->tags()->saveMany($newTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Tag instance from user input.
|
||||
* @param $input
|
||||
* @return static
|
||||
*/
|
||||
protected function newInstanceFromInput($input)
|
||||
{
|
||||
$name = trim($input['name']);
|
||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||
// Any other modification or cleanup required can go here
|
||||
$values = ['name' => $name, 'value' => $value];
|
||||
return $this->tag->newInstance($values);
|
||||
}
|
||||
|
||||
}
|
@ -400,9 +400,7 @@ class PermissionService
|
||||
}
|
||||
});
|
||||
|
||||
if ($this->isAdmin) return $query;
|
||||
$this->currentAction = $action;
|
||||
return $this->entityRestrictionQuery($query);
|
||||
return $this->enforceEntityRestrictions($query, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -413,9 +411,7 @@ class PermissionService
|
||||
*/
|
||||
public function enforceChapterRestrictions($query, $action = 'view')
|
||||
{
|
||||
if ($this->isAdmin) return $query;
|
||||
$this->currentAction = $action;
|
||||
return $this->entityRestrictionQuery($query);
|
||||
return $this->enforceEntityRestrictions($query, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -425,6 +421,17 @@ class PermissionService
|
||||
* @return mixed
|
||||
*/
|
||||
public function enforceBookRestrictions($query, $action = 'view')
|
||||
{
|
||||
return $this->enforceEntityRestrictions($query, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add restrictions for a generic entity
|
||||
* @param $query
|
||||
* @param string $action
|
||||
* @return mixed
|
||||
*/
|
||||
public function enforceEntityRestrictions($query, $action = 'view')
|
||||
{
|
||||
if ($this->isAdmin) return $query;
|
||||
$this->currentAction = $action;
|
||||
|
19
app/Tag.php
Normal file
19
app/Tag.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php namespace BookStack;
|
||||
|
||||
/**
|
||||
* Class Attribute
|
||||
* @package BookStack
|
||||
*/
|
||||
class Tag extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'value', 'order'];
|
||||
|
||||
/**
|
||||
* Get the entity that this tag belongs to
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
}
|
@ -52,4 +52,11 @@ $factory->define(BookStack\Role::class, function ($faker) {
|
||||
'display_name' => $faker->sentence(3),
|
||||
'description' => $faker->sentence(10)
|
||||
];
|
||||
});
|
||||
|
||||
$factory->define(BookStack\Tag::class, function ($faker) {
|
||||
return [
|
||||
'name' => $faker->city,
|
||||
'value' => $faker->sentence(3)
|
||||
];
|
||||
});
|
40
database/migrations/2016_05_06_185215_create_tags_table.php
Normal file
40
database/migrations/2016_05_06_185215_create_tags_table.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateTagsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('tags', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('entity_id');
|
||||
$table->string('entity_type', 100);
|
||||
$table->string('name');
|
||||
$table->string('value');
|
||||
$table->integer('order');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('name');
|
||||
$table->index('value');
|
||||
$table->index('order');
|
||||
$table->index(['entity_id', 'entity_type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::drop('tags');
|
||||
}
|
||||
}
|
@ -4,10 +4,11 @@
|
||||
"gulp": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"angular": "^1.5.0-rc.0",
|
||||
"angular-animate": "^1.5.0-rc.0",
|
||||
"angular-resource": "^1.5.0-rc.0",
|
||||
"angular-sanitize": "^1.5.0-rc.0",
|
||||
"angular": "^1.5.5",
|
||||
"angular-animate": "^1.5.5",
|
||||
"angular-resource": "^1.5.5",
|
||||
"angular-sanitize": "^1.5.5",
|
||||
"angular-ui-sortable": "^0.14.0",
|
||||
"babel-runtime": "^5.8.29",
|
||||
"bootstrap-sass": "^3.0.0",
|
||||
"dropzone": "^4.0.1",
|
||||
|
7
public/libs/jquery/jquery-ui.min.js
vendored
Normal file
7
public/libs/jquery/jquery-ui.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -400,4 +400,116 @@ module.exports = function (ngApp, events) {
|
||||
|
||||
}]);
|
||||
|
||||
};
|
||||
ngApp.controller('PageTagController', ['$scope', '$http', '$attrs',
|
||||
function ($scope, $http, $attrs) {
|
||||
|
||||
const pageId = Number($attrs.pageId);
|
||||
$scope.tags = [];
|
||||
|
||||
$scope.sortOptions = {
|
||||
handle: '.handle',
|
||||
items: '> tr',
|
||||
containment: "parent",
|
||||
axis: "y"
|
||||
};
|
||||
|
||||
/**
|
||||
* Push an empty tag to the end of the scope tags.
|
||||
*/
|
||||
function addEmptyTag() {
|
||||
$scope.tags.push({
|
||||
name: '',
|
||||
value: ''
|
||||
});
|
||||
}
|
||||
$scope.addEmptyTag = addEmptyTag;
|
||||
|
||||
/**
|
||||
* Get all tags for the current book and add into scope.
|
||||
*/
|
||||
function getTags() {
|
||||
$http.get('/ajax/tags/get/page/' + pageId).then((responseData) => {
|
||||
$scope.tags = responseData.data;
|
||||
addEmptyTag();
|
||||
});
|
||||
}
|
||||
getTags();
|
||||
|
||||
/**
|
||||
* Set the order property on all tags.
|
||||
*/
|
||||
function setTagOrder() {
|
||||
for (let i = 0; i < $scope.tags.length; i++) {
|
||||
$scope.tags[i].order = i;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When an tag changes check if another empty editable
|
||||
* field needs to be added onto the end.
|
||||
* @param tag
|
||||
*/
|
||||
$scope.tagChange = function(tag) {
|
||||
let cPos = $scope.tags.indexOf(tag);
|
||||
if (cPos !== $scope.tags.length-1) return;
|
||||
|
||||
if (tag.name !== '' || tag.value !== '') {
|
||||
addEmptyTag();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When an tag field loses focus check the tag to see if its
|
||||
* empty and therefore could be removed from the list.
|
||||
* @param tag
|
||||
*/
|
||||
$scope.tagBlur = function(tag) {
|
||||
let isLast = $scope.tags.length - 1 === $scope.tags.indexOf(tag);
|
||||
if (tag.name === '' && tag.value === '' && !isLast) {
|
||||
let cPos = $scope.tags.indexOf(tag);
|
||||
$scope.tags.splice(cPos, 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the tags to the current page.
|
||||
*/
|
||||
$scope.saveTags = function() {
|
||||
setTagOrder();
|
||||
let postData = {tags: $scope.tags};
|
||||
$http.post('/ajax/tags/update/page/' + pageId, postData).then((responseData) => {
|
||||
$scope.tags = responseData.data.tags;
|
||||
addEmptyTag();
|
||||
events.emit('success', responseData.data.message);
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a tag from the current list.
|
||||
* @param tag
|
||||
*/
|
||||
$scope.removeTag = function(tag) {
|
||||
let cIndex = $scope.tags.indexOf(tag);
|
||||
$scope.tags.splice(cIndex, 1);
|
||||
};
|
||||
|
||||
}]);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -301,6 +301,219 @@ module.exports = function (ngApp, events) {
|
||||
|
||||
}
|
||||
}
|
||||
}])
|
||||
}]);
|
||||
|
||||
ngApp.directive('toolbox', [function() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem, attrs) {
|
||||
|
||||
// Get common elements
|
||||
const $buttons = elem.find('[tab-button]');
|
||||
const $content = elem.find('[tab-content]');
|
||||
const $toggle = elem.find('[toolbox-toggle]');
|
||||
|
||||
// Handle toolbox toggle click
|
||||
$toggle.click((e) => {
|
||||
elem.toggleClass('open');
|
||||
});
|
||||
|
||||
// Set an active tab/content by name
|
||||
function setActive(tabName, openToolbox) {
|
||||
$buttons.removeClass('active');
|
||||
$content.hide();
|
||||
$buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
|
||||
$content.filter(`[tab-content="${tabName}"]`).show();
|
||||
if (openToolbox) elem.addClass('open');
|
||||
}
|
||||
|
||||
// Set the first tab content active on load
|
||||
setActive($content.first().attr('tab-content'), false);
|
||||
|
||||
// Handle tab button click
|
||||
$buttons.click(function(e) {
|
||||
let name = $(this).attr('tab-button');
|
||||
setActive(name, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
ngApp.directive('autosuggestions', ['$http', function($http) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem, attrs) {
|
||||
|
||||
// Local storage for quick caching.
|
||||
const localCache = {};
|
||||
|
||||
// Create suggestion element
|
||||
const suggestionBox = document.createElement('ul');
|
||||
suggestionBox.className = 'suggestion-box';
|
||||
suggestionBox.style.position = 'absolute';
|
||||
suggestionBox.style.display = 'none';
|
||||
const $suggestionBox = $(suggestionBox);
|
||||
|
||||
// General state tracking
|
||||
let isShowing = false;
|
||||
let currentInput = false;
|
||||
let active = 0;
|
||||
|
||||
// Listen to input events on autosuggest fields
|
||||
elem.on('input', '[autosuggest]', function(event) {
|
||||
let $input = $(this);
|
||||
let val = $input.val();
|
||||
let url = $input.attr('autosuggest');
|
||||
// No suggestions until at least 3 chars
|
||||
if (val.length < 3) {
|
||||
if (isShowing) {
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let suggestionPromise = getSuggestions(val.slice(0, 3), url);
|
||||
suggestionPromise.then((suggestions) => {
|
||||
if (val.length > 2) {
|
||||
suggestions = suggestions.filter((item) => {
|
||||
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
|
||||
}).slice(0, 4);
|
||||
displaySuggestions($input, suggestions);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hide autosuggestions when input loses focus.
|
||||
// Slight delay to allow clicks.
|
||||
elem.on('blur', '[autosuggest]', function(event) {
|
||||
setTimeout(() => {
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
}, 200)
|
||||
});
|
||||
|
||||
elem.on('keydown', '[autosuggest]', function (event) {
|
||||
if (!isShowing) return;
|
||||
|
||||
let suggestionElems = suggestionBox.childNodes;
|
||||
let suggestCount = suggestionElems.length;
|
||||
|
||||
// Down arrow
|
||||
if (event.keyCode === 40) {
|
||||
let newActive = (active === suggestCount-1) ? 0 : active + 1;
|
||||
changeActiveTo(newActive, suggestionElems);
|
||||
}
|
||||
// Up arrow
|
||||
else if (event.keyCode === 38) {
|
||||
let newActive = (active === 0) ? suggestCount-1 : active - 1;
|
||||
changeActiveTo(newActive, suggestionElems);
|
||||
}
|
||||
// Enter key
|
||||
else if (event.keyCode === 13) {
|
||||
let text = suggestionElems[active].textContent;
|
||||
currentInput[0].value = text;
|
||||
currentInput.focus();
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Change the active suggestion to the given index
|
||||
function changeActiveTo(index, suggestionElems) {
|
||||
suggestionElems[active].className = '';
|
||||
active = index;
|
||||
suggestionElems[active].className = 'active';
|
||||
}
|
||||
|
||||
// Display suggestions on a field
|
||||
let prevSuggestions = [];
|
||||
function displaySuggestions($input, suggestions) {
|
||||
|
||||
// Hide if no suggestions
|
||||
if (suggestions.length === 0) {
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
prevSuggestions = suggestions;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise show and attach to input
|
||||
if (!isShowing) {
|
||||
$suggestionBox.show();
|
||||
isShowing = true;
|
||||
}
|
||||
if ($input !== currentInput) {
|
||||
$suggestionBox.detach();
|
||||
$input.after($suggestionBox);
|
||||
currentInput = $input;
|
||||
}
|
||||
|
||||
// Return if no change
|
||||
if (prevSuggestions.join() === suggestions.join()) {
|
||||
prevSuggestions = suggestions;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build suggestions
|
||||
$suggestionBox[0].innerHTML = '';
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
var suggestion = document.createElement('li');
|
||||
suggestion.textContent = suggestions[i];
|
||||
suggestion.onclick = suggestionClick;
|
||||
if (i === 0) {
|
||||
suggestion.className = 'active'
|
||||
active = 0;
|
||||
};
|
||||
$suggestionBox[0].appendChild(suggestion);
|
||||
}
|
||||
|
||||
prevSuggestions = suggestions;
|
||||
}
|
||||
|
||||
// Suggestion click event
|
||||
function suggestionClick(event) {
|
||||
let text = this.textContent;
|
||||
currentInput[0].value = text;
|
||||
currentInput.focus();
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
};
|
||||
|
||||
// Get suggestions & cache
|
||||
function getSuggestions(input, url) {
|
||||
let searchUrl = url + '?search=' + encodeURIComponent(input);
|
||||
|
||||
// Get from local cache if exists
|
||||
if (localCache[searchUrl]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(localCache[input]);
|
||||
});
|
||||
}
|
||||
|
||||
return $http.get(searchUrl).then((response) => {
|
||||
localCache[input] = response.data;
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
@ -5,9 +5,9 @@ var angular = require('angular');
|
||||
var ngResource = require('angular-resource');
|
||||
var ngAnimate = require('angular-animate');
|
||||
var ngSanitize = require('angular-sanitize');
|
||||
require('angular-ui-sortable');
|
||||
|
||||
var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize']);
|
||||
|
||||
var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
|
||||
|
||||
// Global Event System
|
||||
var Events = {
|
||||
|
@ -65,6 +65,9 @@ $button-border-radius: 2px;
|
||||
&:focus, &:active {
|
||||
outline: 0;
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
&.neg {
|
||||
color: $negative;
|
||||
}
|
||||
|
@ -239,6 +239,17 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
|
||||
}
|
||||
}
|
||||
|
||||
input.outline {
|
||||
border: 0;
|
||||
border-bottom: 2px solid #DDD;
|
||||
border-radius: 0;
|
||||
&:focus, &:active {
|
||||
border: 0;
|
||||
border-bottom: 2px solid #AAA;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#login-form label[for="remember"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
@ -122,9 +122,176 @@
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
&:hover a.link-hook {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
// Attribute form
|
||||
.floating-toolbox {
|
||||
background-color: #FFF;
|
||||
border: 1px solid #DDD;
|
||||
right: $-xl*2;
|
||||
z-index: 99;
|
||||
width: 48px;
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
transition: width ease-in-out 180ms;
|
||||
margin-top: -1px;
|
||||
&.open {
|
||||
width: 480px;
|
||||
}
|
||||
[toolbox-toggle] i {
|
||||
transition: transform ease-in-out 180ms;
|
||||
}
|
||||
[toolbox-toggle] {
|
||||
transition: background-color ease-in-out 180ms;
|
||||
}
|
||||
&.open [toolbox-toggle] {
|
||||
background-color: rgba(255, 0, 0, 0.29);
|
||||
}
|
||||
&.open [toolbox-toggle] i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
> div {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.tabs {
|
||||
display: block;
|
||||
border-right: 1px solid #DDD;
|
||||
width: 54px;
|
||||
flex: 0;
|
||||
}
|
||||
.tabs i {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.tabs > span {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
padding: $-s $-m;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
&.open .tabs > span.active {
|
||||
color: #444;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
div[tab-content] {
|
||||
padding-bottom: 45px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
div[tab-content] .padded {
|
||||
flex: 1;
|
||||
padding-top: 0;
|
||||
}
|
||||
h4 {
|
||||
font-size: 24px;
|
||||
margin: $-m 0 0 0;
|
||||
padding: 0 $-l $-s $-l;
|
||||
}
|
||||
.tags input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-width: 50px;
|
||||
}
|
||||
.tags td {
|
||||
padding-right: $-s;
|
||||
padding-top: $-s;
|
||||
position: relative;
|
||||
}
|
||||
button.pos {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $-s;
|
||||
height: 45px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
&:hover{
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.handle {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
color: #999;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
[tab-content] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag-display {
|
||||
margin: $-xl $-xs;
|
||||
border: 1px solid #DDD;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
opacity: 0.7;
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
span {
|
||||
color: #666;
|
||||
margin-left: $-s;
|
||||
}
|
||||
.heading {
|
||||
padding: $-xs $-s;
|
||||
color: #444;
|
||||
}
|
||||
td {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #DDD;
|
||||
padding: $-xs $-s;
|
||||
color: #444;
|
||||
}
|
||||
.tag-value {
|
||||
color: #888;
|
||||
}
|
||||
td i {
|
||||
color: #888;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tag {
|
||||
padding: $-s;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-box {
|
||||
position: absolute;
|
||||
background-color: #FFF;
|
||||
border: 1px solid #BBB;
|
||||
box-shadow: $bs-light;
|
||||
list-style: none;
|
||||
z-index: 100;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 3px;
|
||||
li {
|
||||
display: block;
|
||||
padding: $-xs $-s;
|
||||
border-bottom: 1px solid #DDD;
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
&.active {
|
||||
background-color: #EEE;
|
||||
}
|
||||
}
|
||||
}
|
@ -26,6 +26,13 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
table.no-style {
|
||||
td {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
table.list-table {
|
||||
margin: 0 -$-xs;
|
||||
td {
|
||||
|
@ -21,6 +21,11 @@
|
||||
|
||||
[ng\:cloak], [ng-cloak], .ng-cloak {
|
||||
display: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[ng-click] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Jquery Sortable Styles
|
||||
@ -201,4 +206,4 @@ $btt-size: 40px;
|
||||
background-color: $negative;
|
||||
color: #EEE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/libs/jquery/jquery.min.js?version=2.1.4"></script>
|
||||
<script src="/libs/jquery/jquery-ui.min.js?version=1.11.4"></script>
|
||||
|
||||
@yield('head')
|
||||
|
||||
|
@ -1,17 +0,0 @@
|
||||
@extends('base')
|
||||
|
||||
@section('head')
|
||||
<script src="/libs/tinymce/tinymce.min.js?ver=4.3.7"></script>
|
||||
@stop
|
||||
|
||||
@section('body-class', 'flexbox')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="flex-fill flex">
|
||||
<form action="{{$book->getUrl() . '/page/' . $draft->id}}" method="POST" class="flex flex-fill">
|
||||
@include('pages/form', ['model' => $draft])
|
||||
</form>
|
||||
</div>
|
||||
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $draft->id])
|
||||
@stop
|
@ -9,10 +9,15 @@
|
||||
@section('content')
|
||||
|
||||
<div class="flex-fill flex">
|
||||
<form action="{{$page->getUrl()}}" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
<form action="{{$page->getUrl()}}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
|
||||
@if(!isset($isDraft))
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
@endif
|
||||
@include('pages/form', ['model' => $page])
|
||||
@include('pages/form-toolbox')
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
|
||||
|
||||
|
37
resources/views/pages/form-toolbox.blade.php
Normal file
37
resources/views/pages/form-toolbox.blade.php
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
<div toolbox class="floating-toolbox">
|
||||
|
||||
<div class="tabs primary-background-light">
|
||||
<span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
|
||||
<span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
|
||||
</div>
|
||||
|
||||
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
|
||||
<h4>Page Tags</h4>
|
||||
<div class="padded tags">
|
||||
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
|
||||
<table class="no-style" autosuggestions style="width: 100%;">
|
||||
<tbody ui-sortable="sortOptions" ng-model="tags" >
|
||||
<tr ng-repeat="tag in tags track by $index">
|
||||
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
|
||||
<td><input autosuggest="/ajax/tags/suggest/names" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
|
||||
<td><input autosuggest="/ajax/tags/suggest/values" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
|
||||
<td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="no-style" style="width: 100%;">
|
||||
<tbody>
|
||||
<tr class="unsortable">
|
||||
<td width="34"></td>
|
||||
<td ng-click="addEmptyTag()">
|
||||
<button type="button" class="text-button">Add another tag</button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
@ -41,6 +41,7 @@
|
||||
@include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-area flex-fill flex">
|
||||
@if(setting('app-editor') === 'wysiwyg')
|
||||
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" name="html" rows="5"
|
||||
|
@ -1,5 +1,22 @@
|
||||
<div ng-non-bindable>
|
||||
<h1 id="bkmrk-page-title">{{$page->name}}</h1>
|
||||
|
||||
<h1 id="bkmrk-page-title" class="float left">{{$page->name}}</h1>
|
||||
|
||||
@if(count($page->tags) > 0)
|
||||
<div class="tag-display float right">
|
||||
<div class="heading primary-background-light">Page Tags</div>
|
||||
<table>
|
||||
@foreach($page->tags as $tag)
|
||||
<tr class="tag">
|
||||
<td @if(!$tag->value) colspan="2" @endif><a href="/search/all?term=%5B{{ urlencode($tag->name) }}%5D">{{ $tag->name }}</a></td>
|
||||
@if($tag->value) <td class="tag-value"><a href="/search/all?term=%5B{{ urlencode($tag->name) }}%3D{{ urlencode($tag->value) }}%5D">{{$tag->value}}</a></td> @endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div style="clear:left;"></div>
|
||||
|
||||
{!! $page->html !!}
|
||||
</div>
|
@ -1,9 +1,9 @@
|
||||
@if(Setting::get('app-color'))
|
||||
<style>
|
||||
header, #back-to-top {
|
||||
header, #back-to-top, .primary-background {
|
||||
background-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
.faded-small {
|
||||
.faded-small, .primary-background-light {
|
||||
background-color: {{ Setting::get('app-color-light') }};
|
||||
}
|
||||
.button-base, .button, input[type="button"], input[type="submit"] {
|
||||
@ -15,7 +15,7 @@
|
||||
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
|
||||
border-bottom-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus {
|
||||
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
|
||||
color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
</style>
|
||||
|
@ -181,7 +181,7 @@ class AuthTest extends TestCase
|
||||
public function test_user_deletion()
|
||||
{
|
||||
$userDetails = factory(\BookStack\User::class)->make();
|
||||
$user = $this->getNewUser($userDetails->toArray());
|
||||
$user = $this->getEditor($userDetails->toArray());
|
||||
|
||||
$this->asAdmin()
|
||||
->visit('/settings/users/' . $user->id)
|
||||
|
@ -161,8 +161,8 @@ class EntityTest extends TestCase
|
||||
public function test_entities_viewable_after_creator_deletion()
|
||||
{
|
||||
// Create required assets and revisions
|
||||
$creator = $this->getNewUser();
|
||||
$updater = $this->getNewUser();
|
||||
$creator = $this->getEditor();
|
||||
$updater = $this->getEditor();
|
||||
$entities = $this->createEntityChainBelongingToUser($creator, $updater);
|
||||
$this->actingAs($creator);
|
||||
app('BookStack\Repos\UserRepo')->destroy($creator);
|
||||
@ -174,8 +174,8 @@ class EntityTest extends TestCase
|
||||
public function test_entities_viewable_after_updater_deletion()
|
||||
{
|
||||
// Create required assets and revisions
|
||||
$creator = $this->getNewUser();
|
||||
$updater = $this->getNewUser();
|
||||
$creator = $this->getEditor();
|
||||
$updater = $this->getEditor();
|
||||
$entities = $this->createEntityChainBelongingToUser($creator, $updater);
|
||||
$this->actingAs($updater);
|
||||
app('BookStack\Repos\UserRepo')->destroy($updater);
|
||||
@ -198,7 +198,7 @@ class EntityTest extends TestCase
|
||||
|
||||
public function test_recently_created_pages_view()
|
||||
{
|
||||
$user = $this->getNewUser();
|
||||
$user = $this->getEditor();
|
||||
$content = $this->createEntityChainBelongingToUser($user);
|
||||
|
||||
$this->asAdmin()->visit('/pages/recently-created')
|
||||
@ -207,7 +207,7 @@ class EntityTest extends TestCase
|
||||
|
||||
public function test_recently_updated_pages_view()
|
||||
{
|
||||
$user = $this->getNewUser();
|
||||
$user = $this->getEditor();
|
||||
$content = $this->createEntityChainBelongingToUser($user);
|
||||
|
||||
$this->asAdmin()->visit('/pages/recently-updated')
|
||||
@ -241,7 +241,7 @@ class EntityTest extends TestCase
|
||||
|
||||
public function test_recently_created_pages_on_home()
|
||||
{
|
||||
$entityChain = $this->createEntityChainBelongingToUser($this->getNewUser());
|
||||
$entityChain = $this->createEntityChainBelongingToUser($this->getEditor());
|
||||
$this->asAdmin()->visit('/')
|
||||
->seeInElement('#recently-created-pages', $entityChain['page']->name);
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ class PageDraftTest extends TestCase
|
||||
->dontSeeInField('html', $addedContent);
|
||||
|
||||
$newContent = $this->page->html . $addedContent;
|
||||
$newUser = $this->getNewUser();
|
||||
$newUser = $this->getEditor();
|
||||
$this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]);
|
||||
$this->actingAs($newUser)->visit($this->page->getUrl() . '/edit')
|
||||
->dontSeeInField('html', $newContent);
|
||||
@ -54,7 +54,7 @@ class PageDraftTest extends TestCase
|
||||
->dontSeeInField('html', $addedContent);
|
||||
|
||||
$newContent = $this->page->html . $addedContent;
|
||||
$newUser = $this->getNewUser();
|
||||
$newUser = $this->getEditor();
|
||||
$this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]);
|
||||
|
||||
$this->actingAs($newUser)
|
||||
@ -79,7 +79,7 @@ class PageDraftTest extends TestCase
|
||||
{
|
||||
$book = \BookStack\Book::first();
|
||||
$chapter = $book->chapters->first();
|
||||
$newUser = $this->getNewUser();
|
||||
$newUser = $this->getEditor();
|
||||
|
||||
$this->actingAs($newUser)->visit('/')
|
||||
->visit($book->getUrl() . '/page/create')
|
||||
|
146
tests/Entity/TagTests.php
Normal file
146
tests/Entity/TagTests.php
Normal file
@ -0,0 +1,146 @@
|
||||
<?php namespace Entity;
|
||||
|
||||
use BookStack\Tag;
|
||||
use BookStack\Page;
|
||||
use BookStack\Services\PermissionService;
|
||||
|
||||
class TagTests extends \TestCase
|
||||
{
|
||||
|
||||
protected $defaultTagCount = 20;
|
||||
|
||||
/**
|
||||
* Get an instance of a page that has many tags.
|
||||
* @param Tag[]|bool $tags
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getPageWithTags($tags = false)
|
||||
{
|
||||
$page = Page::first();
|
||||
|
||||
if (!$tags) {
|
||||
$tags = factory(Tag::class, $this->defaultTagCount)->make();
|
||||
}
|
||||
|
||||
$page->tags()->saveMany($tags);
|
||||
return $page;
|
||||
}
|
||||
|
||||
public function test_get_page_tags()
|
||||
{
|
||||
$page = $this->getPageWithTags();
|
||||
|
||||
// Add some other tags to check they don't interfere
|
||||
factory(Tag::class, $this->defaultTagCount)->create();
|
||||
|
||||
$this->asAdmin()->get("/ajax/tags/get/page/" . $page->id)
|
||||
->shouldReturnJson();
|
||||
|
||||
$json = json_decode($this->response->getContent());
|
||||
$this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected");
|
||||
}
|
||||
|
||||
public function test_tag_name_suggestions()
|
||||
{
|
||||
// Create some tags with similar names to test with
|
||||
$attrs = collect();
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans']));
|
||||
$page = $this->getPageWithTags($attrs);
|
||||
|
||||
$this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]);
|
||||
$this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']);
|
||||
$this->get('/ajax/tags/suggest/names?search=cou')->seeJsonEquals(['country', 'county']);
|
||||
$this->get('/ajax/tags/suggest/names?search=pla')->seeJsonEquals(['planet', 'plans']);
|
||||
}
|
||||
|
||||
public function test_tag_value_suggestions()
|
||||
{
|
||||
// Create some tags with similar values to test with
|
||||
$attrs = collect();
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country', 'value' => 'cats']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color', 'value' => 'cattery']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city', 'value' => 'castle']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy']));
|
||||
$page = $this->getPageWithTags($attrs);
|
||||
|
||||
$this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]);
|
||||
$this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']);
|
||||
$this->get('/ajax/tags/suggest/values?search=do')->seeJsonEquals(['dog', 'dodgy']);
|
||||
$this->get('/ajax/tags/suggest/values?search=cas')->seeJsonEquals(['castle']);
|
||||
}
|
||||
|
||||
public function test_entity_permissions_effect_tag_suggestions()
|
||||
{
|
||||
$permissionService = $this->app->make(PermissionService::class);
|
||||
|
||||
// Create some tags with similar names to test with and save to a page
|
||||
$attrs = collect();
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country']));
|
||||
$attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color']));
|
||||
$page = $this->getPageWithTags($attrs);
|
||||
|
||||
$this->asAdmin()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']);
|
||||
$this->asEditor()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']);
|
||||
|
||||
// Set restricted permission the page
|
||||
$page->restricted = true;
|
||||
$page->save();
|
||||
$permissionService->buildJointPermissionsForEntity($page);
|
||||
|
||||
$this->asAdmin()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']);
|
||||
$this->asEditor()->get('/ajax/tags/suggest?search=co')->seeJsonEquals([]);
|
||||
}
|
||||
|
||||
public function test_entity_tag_updating()
|
||||
{
|
||||
$page = $this->getPageWithTags();
|
||||
|
||||
$testJsonData = [
|
||||
['name' => 'color', 'value' => 'red'],
|
||||
['name' => 'color', 'value' => ' blue '],
|
||||
['name' => 'city', 'value' => 'London '],
|
||||
['name' => 'country', 'value' => ' England'],
|
||||
];
|
||||
$testResponseJsonData = [
|
||||
['name' => 'color', 'value' => 'red'],
|
||||
['name' => 'color', 'value' => 'blue'],
|
||||
['name' => 'city', 'value' => 'London'],
|
||||
['name' => 'country', 'value' => 'England'],
|
||||
];
|
||||
|
||||
// Do update request
|
||||
$this->asAdmin()->json("POST", "/ajax/tags/update/page/" . $page->id, ['tags' => $testJsonData]);
|
||||
$updateData = json_decode($this->response->getContent());
|
||||
// Check data is correct
|
||||
$testDataCorrect = true;
|
||||
foreach ($updateData->tags as $data) {
|
||||
$testItem = ['name' => $data->name, 'value' => $data->value];
|
||||
if (!in_array($testItem, $testResponseJsonData)) $testDataCorrect = false;
|
||||
}
|
||||
$testMessage = "Expected data was not found in the response.\nExpected Data: %s\nRecieved Data: %s";
|
||||
$this->assertTrue($testDataCorrect, sprintf($testMessage, json_encode($testResponseJsonData), json_encode($updateData)));
|
||||
$this->assertTrue(isset($updateData->message), "No message returned in tag update response");
|
||||
|
||||
// Do get request
|
||||
$this->asAdmin()->get("/ajax/tags/get/page/" . $page->id);
|
||||
$getResponseData = json_decode($this->response->getContent());
|
||||
// Check counts
|
||||
$this->assertTrue(count($getResponseData) === count($testJsonData), "The received tag count is incorrect");
|
||||
// Check data is correct
|
||||
$testDataCorrect = true;
|
||||
foreach ($getResponseData as $data) {
|
||||
$testItem = ['name' => $data->name, 'value' => $data->value];
|
||||
if (!in_array($testItem, $testResponseJsonData)) $testDataCorrect = false;
|
||||
}
|
||||
$testMessage = "Expected data was not found in the response.\nExpected Data: %s\nRecieved Data: %s";
|
||||
$this->assertTrue($testDataCorrect, sprintf($testMessage, json_encode($testResponseJsonData), json_encode($getResponseData)));
|
||||
}
|
||||
|
||||
}
|
@ -9,7 +9,7 @@ class RestrictionsTest extends TestCase
|
||||
public function setUp()
|
||||
{
|
||||
parent::setUp();
|
||||
$this->user = $this->getNewUser();
|
||||
$this->user = $this->getEditor();
|
||||
$this->viewer = $this->getViewer();
|
||||
$this->restrictionService = $this->app[\BookStack\Services\PermissionService::class];
|
||||
}
|
||||
|
@ -14,7 +14,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
|
||||
* @var string
|
||||
*/
|
||||
protected $baseUrl = 'http://localhost';
|
||||
|
||||
// Local user instances
|
||||
private $admin;
|
||||
private $editor;
|
||||
|
||||
/**
|
||||
* Creates the application.
|
||||
@ -30,6 +33,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
|
||||
return $app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current user context to be an admin.
|
||||
* @return $this
|
||||
*/
|
||||
public function asAdmin()
|
||||
{
|
||||
if($this->admin === null) {
|
||||
@ -39,6 +46,18 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
|
||||
return $this->actingAs($this->admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current editor context to be an editor.
|
||||
* @return $this
|
||||
*/
|
||||
public function asEditor()
|
||||
{
|
||||
if($this->editor === null) {
|
||||
$this->editor = $this->getEditor();
|
||||
}
|
||||
return $this->actingAs($this->editor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly sets an array of settings.
|
||||
* @param $settingsArray
|
||||
@ -79,7 +98,7 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
|
||||
* @param array $attributes
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getNewUser($attributes = [])
|
||||
protected function getEditor($attributes = [])
|
||||
{
|
||||
$user = factory(\BookStack\User::class)->create($attributes);
|
||||
$role = \BookStack\Role::getRole('editor');
|
||||
|
@ -33,7 +33,7 @@ class UserProfileTest extends TestCase
|
||||
|
||||
public function test_profile_page_shows_created_content_counts()
|
||||
{
|
||||
$newUser = $this->getNewUser();
|
||||
$newUser = $this->getEditor();
|
||||
|
||||
$this->asAdmin()->visit('/user/' . $newUser->id)
|
||||
->see($newUser->name)
|
||||
@ -52,7 +52,7 @@ class UserProfileTest extends TestCase
|
||||
|
||||
public function test_profile_page_shows_recent_activity()
|
||||
{
|
||||
$newUser = $this->getNewUser();
|
||||
$newUser = $this->getEditor();
|
||||
$this->actingAs($newUser);
|
||||
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
|
||||
Activity::add($entities['book'], 'book_update', $entities['book']->id);
|
||||
@ -66,7 +66,7 @@ class UserProfileTest extends TestCase
|
||||
|
||||
public function test_clicking_user_name_in_activity_leads_to_profile_page()
|
||||
{
|
||||
$newUser = $this->getNewUser();
|
||||
$newUser = $this->getEditor();
|
||||
$this->actingAs($newUser);
|
||||
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
|
||||
Activity::add($entities['book'], 'book_update', $entities['book']->id);
|
||||
|
Loading…
Reference in New Issue
Block a user