Merge pull request #110 from ssddanbrown/page_attributes
Attribute System. Closes #48.
Showing
39 changed files
with
1261 additions
and
108 deletions
| 1 | <?php namespace BookStack; | 1 | <?php namespace BookStack; |
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | -abstract class Entity extends Ownable | 4 | +class Entity extends Ownable |
| 5 | { | 5 | { |
| 6 | 6 | ||
| 7 | /** | 7 | /** |
| ... | @@ -55,6 +55,15 @@ abstract class Entity extends Ownable | ... | @@ -55,6 +55,15 @@ abstract class Entity extends Ownable |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | /** | 57 | /** |
| 58 | + * Get the Tag models that have been user assigned to this entity. | ||
| 59 | + * @return \Illuminate\Database\Eloquent\Relations\MorphMany | ||
| 60 | + */ | ||
| 61 | + public function tags() | ||
| 62 | + { | ||
| 63 | + return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + /** | ||
| 58 | * Get this entities restrictions. | 67 | * Get this entities restrictions. |
| 59 | */ | 68 | */ |
| 60 | public function permissions() | 69 | public function permissions() |
| ... | @@ -115,6 +124,22 @@ abstract class Entity extends Ownable | ... | @@ -115,6 +124,22 @@ abstract class Entity extends Ownable |
| 115 | } | 124 | } |
| 116 | 125 | ||
| 117 | /** | 126 | /** |
| 127 | + * Get an instance of an entity of the given type. | ||
| 128 | + * @param $type | ||
| 129 | + * @return Entity | ||
| 130 | + */ | ||
| 131 | + public static function getEntityInstance($type) | ||
| 132 | + { | ||
| 133 | + $types = ['Page', 'Book', 'Chapter']; | ||
| 134 | + $className = str_replace([' ', '-', '_'], '', ucwords($type)); | ||
| 135 | + if (!in_array($className, $types)) { | ||
| 136 | + return null; | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + return app('BookStack\\' . $className); | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + /** | ||
| 118 | * Gets a limited-length version of the entities name. | 143 | * Gets a limited-length version of the entities name. |
| 119 | * @param int $length | 144 | * @param int $length |
| 120 | * @return string | 145 | * @return string |
| ... | @@ -132,54 +157,54 @@ abstract class Entity extends Ownable | ... | @@ -132,54 +157,54 @@ abstract class Entity extends Ownable |
| 132 | * @param string[] array $wheres | 157 | * @param string[] array $wheres |
| 133 | * @return mixed | 158 | * @return mixed |
| 134 | */ | 159 | */ |
| 135 | - public static function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = []) | 160 | + public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = []) |
| 136 | { | 161 | { |
| 137 | $exactTerms = []; | 162 | $exactTerms = []; |
| 138 | - foreach ($terms as $key => $term) { | 163 | + if (count($terms) === 0) { |
| 139 | - $term = htmlentities($term, ENT_QUOTES); | 164 | + $search = $this; |
| 140 | - $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); | 165 | + $orderBy = 'updated_at'; |
| 141 | - if (preg_match('/\s/', $term)) { | 166 | + } else { |
| 142 | - $exactTerms[] = '%' . $term . '%'; | 167 | + foreach ($terms as $key => $term) { |
| 143 | - $term = '"' . $term . '"'; | 168 | + $term = htmlentities($term, ENT_QUOTES); |
| 144 | - } else { | 169 | + $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); |
| 145 | - $term = '' . $term . '*'; | 170 | + if (preg_match('/\s/', $term)) { |
| 171 | + $exactTerms[] = '%' . $term . '%'; | ||
| 172 | + $term = '"' . $term . '"'; | ||
| 173 | + } else { | ||
| 174 | + $term = '' . $term . '*'; | ||
| 175 | + } | ||
| 176 | + if ($term !== '*') $terms[$key] = $term; | ||
| 146 | } | 177 | } |
| 147 | - if ($term !== '*') $terms[$key] = $term; | 178 | + $termString = implode(' ', $terms); |
| 148 | - } | 179 | + $fields = implode(',', $fieldsToSearch); |
| 149 | - $termString = implode(' ', $terms); | 180 | + $search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); |
| 150 | - $fields = implode(',', $fieldsToSearch); | 181 | + $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); |
| 151 | - $search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); | 182 | + |
| 152 | - $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); | 183 | + // Ensure at least one exact term matches if in search |
| 153 | - | 184 | + if (count($exactTerms) > 0) { |
| 154 | - // Ensure at least one exact term matches if in search | 185 | + $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) { |
| 155 | - if (count($exactTerms) > 0) { | 186 | + foreach ($exactTerms as $exactTerm) { |
| 156 | - $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) { | 187 | + foreach ($fieldsToSearch as $field) { |
| 157 | - foreach ($exactTerms as $exactTerm) { | 188 | + $query->orWhere($field, 'like', $exactTerm); |
| 158 | - foreach ($fieldsToSearch as $field) { | 189 | + } |
| 159 | - $query->orWhere($field, 'like', $exactTerm); | ||
| 160 | } | 190 | } |
| 161 | - } | 191 | + }); |
| 162 | - }); | 192 | + } |
| 163 | - } | 193 | + $orderBy = 'title_relevance'; |
| 194 | + }; | ||
| 164 | 195 | ||
| 165 | // Add additional where terms | 196 | // Add additional where terms |
| 166 | foreach ($wheres as $whereTerm) { | 197 | foreach ($wheres as $whereTerm) { |
| 167 | $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); | 198 | $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); |
| 168 | } | 199 | } |
| 169 | // Load in relations | 200 | // Load in relations |
| 170 | - if (static::isA('page')) { | 201 | + if ($this->isA('page')) { |
| 171 | $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy'); | 202 | $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy'); |
| 172 | - } else if (static::isA('chapter')) { | 203 | + } else if ($this->isA('chapter')) { |
| 173 | $search = $search->with('book'); | 204 | $search = $search->with('book'); |
| 174 | } | 205 | } |
| 175 | 206 | ||
| 176 | - return $search->orderBy('title_relevance', 'desc'); | 207 | + return $search->orderBy($orderBy, 'desc'); |
| 177 | } | 208 | } |
| 178 | - | 209 | + |
| 179 | - /** | ||
| 180 | - * Get the url for this item. | ||
| 181 | - * @return string | ||
| 182 | - */ | ||
| 183 | - abstract public function getUrl(); | ||
| 184 | - | ||
| 185 | } | 210 | } | ... | ... |
| ... | @@ -110,4 +110,15 @@ abstract class Controller extends BaseController | ... | @@ -110,4 +110,15 @@ abstract class Controller extends BaseController |
| 110 | return true; | 110 | return true; |
| 111 | } | 111 | } |
| 112 | 112 | ||
| 113 | + /** | ||
| 114 | + * Send back a json error message. | ||
| 115 | + * @param string $messageText | ||
| 116 | + * @param int $statusCode | ||
| 117 | + * @return mixed | ||
| 118 | + */ | ||
| 119 | + protected function jsonError($messageText = "", $statusCode = 500) | ||
| 120 | + { | ||
| 121 | + return response()->json(['message' => $messageText], $statusCode); | ||
| 122 | + } | ||
| 123 | + | ||
| 113 | } | 124 | } | ... | ... |
| ... | @@ -72,7 +72,7 @@ class PageController extends Controller | ... | @@ -72,7 +72,7 @@ class PageController extends Controller |
| 72 | $this->checkOwnablePermission('page-create', $book); | 72 | $this->checkOwnablePermission('page-create', $book); |
| 73 | $this->setPageTitle('Edit Page Draft'); | 73 | $this->setPageTitle('Edit Page Draft'); |
| 74 | 74 | ||
| 75 | - return view('pages/create', ['draft' => $draft, 'book' => $book]); | 75 | + return view('pages/edit', ['page' => $draft, 'book' => $book, 'isDraft' => true]); |
| 76 | } | 76 | } |
| 77 | 77 | ||
| 78 | /** | 78 | /** | ... | ... |
app/Http/Controllers/TagController.php
0 → 100644
| 1 | +<?php namespace BookStack\Http\Controllers; | ||
| 2 | + | ||
| 3 | +use BookStack\Repos\TagRepo; | ||
| 4 | +use Illuminate\Http\Request; | ||
| 5 | +use BookStack\Http\Requests; | ||
| 6 | + | ||
| 7 | +class TagController extends Controller | ||
| 8 | +{ | ||
| 9 | + | ||
| 10 | + protected $tagRepo; | ||
| 11 | + | ||
| 12 | + /** | ||
| 13 | + * TagController constructor. | ||
| 14 | + * @param $tagRepo | ||
| 15 | + */ | ||
| 16 | + public function __construct(TagRepo $tagRepo) | ||
| 17 | + { | ||
| 18 | + $this->tagRepo = $tagRepo; | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + /** | ||
| 22 | + * Get all the Tags for a particular entity | ||
| 23 | + * @param $entityType | ||
| 24 | + * @param $entityId | ||
| 25 | + */ | ||
| 26 | + public function getForEntity($entityType, $entityId) | ||
| 27 | + { | ||
| 28 | + $tags = $this->tagRepo->getForEntity($entityType, $entityId); | ||
| 29 | + return response()->json($tags); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + /** | ||
| 33 | + * Update the tags for a particular entity. | ||
| 34 | + * @param $entityType | ||
| 35 | + * @param $entityId | ||
| 36 | + * @param Request $request | ||
| 37 | + * @return mixed | ||
| 38 | + */ | ||
| 39 | + public function updateForEntity($entityType, $entityId, Request $request) | ||
| 40 | + { | ||
| 41 | + $entity = $this->tagRepo->getEntity($entityType, $entityId, 'update'); | ||
| 42 | + if ($entity === null) return $this->jsonError("Entity not found", 404); | ||
| 43 | + | ||
| 44 | + $inputTags = $request->input('tags'); | ||
| 45 | + $tags = $this->tagRepo->saveTagsToEntity($entity, $inputTags); | ||
| 46 | + return response()->json([ | ||
| 47 | + 'tags' => $tags, | ||
| 48 | + 'message' => 'Tags successfully updated' | ||
| 49 | + ]); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + /** | ||
| 53 | + * Get tag name suggestions from a given search term. | ||
| 54 | + * @param Request $request | ||
| 55 | + */ | ||
| 56 | + public function getNameSuggestions(Request $request) | ||
| 57 | + { | ||
| 58 | + $searchTerm = $request->get('search'); | ||
| 59 | + $suggestions = $this->tagRepo->getNameSuggestions($searchTerm); | ||
| 60 | + return response()->json($suggestions); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + /** | ||
| 64 | + * Get tag value suggestions from a given search term. | ||
| 65 | + * @param Request $request | ||
| 66 | + */ | ||
| 67 | + public function getValueSuggestions(Request $request) | ||
| 68 | + { | ||
| 69 | + $searchTerm = $request->get('search'); | ||
| 70 | + $suggestions = $this->tagRepo->getValueSuggestions($searchTerm); | ||
| 71 | + return response()->json($suggestions); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | +} |
| ... | @@ -28,7 +28,7 @@ Route::group(['middleware' => 'auth'], function () { | ... | @@ -28,7 +28,7 @@ Route::group(['middleware' => 'auth'], function () { |
| 28 | // Pages | 28 | // Pages |
| 29 | Route::get('/{bookSlug}/page/create', 'PageController@create'); | 29 | Route::get('/{bookSlug}/page/create', 'PageController@create'); |
| 30 | Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft'); | 30 | Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft'); |
| 31 | - Route::post('/{bookSlug}/page/{pageId}', 'PageController@store'); | 31 | + Route::post('/{bookSlug}/draft/{pageId}', 'PageController@store'); |
| 32 | Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show'); | 32 | Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show'); |
| 33 | Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageController@exportPdf'); | 33 | Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageController@exportPdf'); |
| 34 | Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml'); | 34 | Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml'); |
| ... | @@ -80,11 +80,19 @@ Route::group(['middleware' => 'auth'], function () { | ... | @@ -80,11 +80,19 @@ Route::group(['middleware' => 'auth'], function () { |
| 80 | Route::delete('/{imageId}', 'ImageController@destroy'); | 80 | Route::delete('/{imageId}', 'ImageController@destroy'); |
| 81 | }); | 81 | }); |
| 82 | 82 | ||
| 83 | - // Ajax routes | 83 | + // AJAX routes |
| 84 | Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft'); | 84 | Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft'); |
| 85 | Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); | 85 | Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); |
| 86 | Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy'); | 86 | Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy'); |
| 87 | 87 | ||
| 88 | + // Tag routes (AJAX) | ||
| 89 | + Route::group(['prefix' => 'ajax/tags'], function() { | ||
| 90 | + Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity'); | ||
| 91 | + Route::get('/suggest/names', 'TagController@getNameSuggestions'); | ||
| 92 | + Route::get('/suggest/values', 'TagController@getValueSuggestions'); | ||
| 93 | + Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity'); | ||
| 94 | + }); | ||
| 95 | + | ||
| 88 | // Links | 96 | // Links |
| 89 | Route::get('/link/{id}', 'PageController@redirectFromLink'); | 97 | Route::get('/link/{id}', 'PageController@redirectFromLink'); |
| 90 | 98 | ... | ... |
| ... | @@ -286,8 +286,9 @@ class BookRepo extends EntityRepo | ... | @@ -286,8 +286,9 @@ class BookRepo extends EntityRepo |
| 286 | public function getBySearch($term, $count = 20, $paginationAppends = []) | 286 | public function getBySearch($term, $count = 20, $paginationAppends = []) |
| 287 | { | 287 | { |
| 288 | $terms = $this->prepareSearchTerms($term); | 288 | $terms = $this->prepareSearchTerms($term); |
| 289 | - $books = $this->permissionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms)) | 289 | + $bookQuery = $this->permissionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms)); |
| 290 | - ->paginate($count)->appends($paginationAppends); | 290 | + $bookQuery = $this->addAdvancedSearchQueries($bookQuery, $term); |
| 291 | + $books = $bookQuery->paginate($count)->appends($paginationAppends); | ||
| 291 | $words = join('|', explode(' ', preg_quote(trim($term), '/'))); | 292 | $words = join('|', explode(' ', preg_quote(trim($term), '/'))); |
| 292 | foreach ($books as $book) { | 293 | foreach ($books as $book) { |
| 293 | //highlight | 294 | //highlight | ... | ... |
| ... | @@ -168,8 +168,9 @@ class ChapterRepo extends EntityRepo | ... | @@ -168,8 +168,9 @@ class ChapterRepo extends EntityRepo |
| 168 | public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) | 168 | public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) |
| 169 | { | 169 | { |
| 170 | $terms = $this->prepareSearchTerms($term); | 170 | $terms = $this->prepareSearchTerms($term); |
| 171 | - $chapters = $this->permissionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms)) | 171 | + $chapterQuery = $this->permissionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms)); |
| 172 | - ->paginate($count)->appends($paginationAppends); | 172 | + $chapterQuery = $this->addAdvancedSearchQueries($chapterQuery, $term); |
| 173 | + $chapters = $chapterQuery->paginate($count)->appends($paginationAppends); | ||
| 173 | $words = join('|', explode(' ', preg_quote(trim($term), '/'))); | 174 | $words = join('|', explode(' ', preg_quote(trim($term), '/'))); |
| 174 | foreach ($chapters as $chapter) { | 175 | foreach ($chapters as $chapter) { |
| 175 | //highlight | 176 | //highlight | ... | ... |
| ... | @@ -6,6 +6,7 @@ use BookStack\Entity; | ... | @@ -6,6 +6,7 @@ use BookStack\Entity; |
| 6 | use BookStack\Page; | 6 | use BookStack\Page; |
| 7 | use BookStack\Services\PermissionService; | 7 | use BookStack\Services\PermissionService; |
| 8 | use BookStack\User; | 8 | use BookStack\User; |
| 9 | +use Illuminate\Support\Facades\Log; | ||
| 9 | 10 | ||
| 10 | class EntityRepo | 11 | class EntityRepo |
| 11 | { | 12 | { |
| ... | @@ -31,6 +32,12 @@ class EntityRepo | ... | @@ -31,6 +32,12 @@ class EntityRepo |
| 31 | protected $permissionService; | 32 | protected $permissionService; |
| 32 | 33 | ||
| 33 | /** | 34 | /** |
| 35 | + * Acceptable operators to be used in a query | ||
| 36 | + * @var array | ||
| 37 | + */ | ||
| 38 | + protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; | ||
| 39 | + | ||
| 40 | + /** | ||
| 34 | * EntityService constructor. | 41 | * EntityService constructor. |
| 35 | */ | 42 | */ |
| 36 | public function __construct() | 43 | public function __construct() |
| ... | @@ -163,6 +170,7 @@ class EntityRepo | ... | @@ -163,6 +170,7 @@ class EntityRepo |
| 163 | */ | 170 | */ |
| 164 | protected function prepareSearchTerms($termString) | 171 | protected function prepareSearchTerms($termString) |
| 165 | { | 172 | { |
| 173 | + $termString = $this->cleanSearchTermString($termString); | ||
| 166 | preg_match_all('/"(.*?)"/', $termString, $matches); | 174 | preg_match_all('/"(.*?)"/', $termString, $matches); |
| 167 | if (count($matches[1]) > 0) { | 175 | if (count($matches[1]) > 0) { |
| 168 | $terms = $matches[1]; | 176 | $terms = $matches[1]; |
| ... | @@ -174,5 +182,93 @@ class EntityRepo | ... | @@ -174,5 +182,93 @@ class EntityRepo |
| 174 | return $terms; | 182 | return $terms; |
| 175 | } | 183 | } |
| 176 | 184 | ||
| 185 | + /** | ||
| 186 | + * Removes any special search notation that should not | ||
| 187 | + * be used in a full-text search. | ||
| 188 | + * @param $termString | ||
| 189 | + * @return mixed | ||
| 190 | + */ | ||
| 191 | + protected function cleanSearchTermString($termString) | ||
| 192 | + { | ||
| 193 | + // Strip tag searches | ||
| 194 | + $termString = preg_replace('/\[.*?\]/', '', $termString); | ||
| 195 | + // Reduced multiple spacing into single spacing | ||
| 196 | + $termString = preg_replace("/\s{2,}/", " ", $termString); | ||
| 197 | + return $termString; | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + /** | ||
| 201 | + * Get the available query operators as a regex escaped list. | ||
| 202 | + * @return mixed | ||
| 203 | + */ | ||
| 204 | + protected function getRegexEscapedOperators() | ||
| 205 | + { | ||
| 206 | + $escapedOperators = []; | ||
| 207 | + foreach ($this->queryOperators as $operator) { | ||
| 208 | + $escapedOperators[] = preg_quote($operator); | ||
| 209 | + } | ||
| 210 | + return join('|', $escapedOperators); | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + /** | ||
| 214 | + * Parses advanced search notations and adds them to the db query. | ||
| 215 | + * @param $query | ||
| 216 | + * @param $termString | ||
| 217 | + * @return mixed | ||
| 218 | + */ | ||
| 219 | + protected function addAdvancedSearchQueries($query, $termString) | ||
| 220 | + { | ||
| 221 | + $escapedOperators = $this->getRegexEscapedOperators(); | ||
| 222 | + // Look for tag searches | ||
| 223 | + preg_match_all("/\[(.*?)((${escapedOperators})(.*?))?\]/", $termString, $tags); | ||
| 224 | + if (count($tags[0]) > 0) { | ||
| 225 | + $this->applyTagSearches($query, $tags); | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + return $query; | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + /** | ||
| 232 | + * Apply extracted tag search terms onto a entity query. | ||
| 233 | + * @param $query | ||
| 234 | + * @param $tags | ||
| 235 | + * @return mixed | ||
| 236 | + */ | ||
| 237 | + protected function applyTagSearches($query, $tags) { | ||
| 238 | + $query->where(function($query) use ($tags) { | ||
| 239 | + foreach ($tags[1] as $index => $tagName) { | ||
| 240 | + $query->whereHas('tags', function($query) use ($tags, $index, $tagName) { | ||
| 241 | + $tagOperator = $tags[3][$index]; | ||
| 242 | + $tagValue = $tags[4][$index]; | ||
| 243 | + if (!empty($tagOperator) && !empty($tagValue) && in_array($tagOperator, $this->queryOperators)) { | ||
| 244 | + if (is_numeric($tagValue) && $tagOperator !== 'like') { | ||
| 245 | + // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will | ||
| 246 | + // search the value as a string which prevents being able to do number-based operations | ||
| 247 | + // on the tag values. We ensure it has a numeric value and then cast it just to be sure. | ||
| 248 | + $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'"); | ||
| 249 | + $query->where('name', '=', $tagName)->whereRaw("value ${tagOperator} ${tagValue}"); | ||
| 250 | + } else { | ||
| 251 | + $query->where('name', '=', $tagName)->where('value', $tagOperator, $tagValue); | ||
| 252 | + } | ||
| 253 | + } else { | ||
| 254 | + $query->where('name', '=', $tagName); | ||
| 255 | + } | ||
| 256 | + }); | ||
| 257 | + } | ||
| 258 | + }); | ||
| 259 | + return $query; | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | + | ||
| 265 | + | ||
| 266 | + | ||
| 267 | + | ||
| 268 | + | ||
| 269 | + | ||
| 270 | + | ||
| 271 | + | ||
| 272 | + | ||
| 273 | + | ||
| 177 | 274 | ||
| 178 | -} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -14,14 +14,17 @@ class PageRepo extends EntityRepo | ... | @@ -14,14 +14,17 @@ class PageRepo extends EntityRepo |
| 14 | { | 14 | { |
| 15 | 15 | ||
| 16 | protected $pageRevision; | 16 | protected $pageRevision; |
| 17 | + protected $tagRepo; | ||
| 17 | 18 | ||
| 18 | /** | 19 | /** |
| 19 | * PageRepo constructor. | 20 | * PageRepo constructor. |
| 20 | * @param PageRevision $pageRevision | 21 | * @param PageRevision $pageRevision |
| 22 | + * @param TagRepo $tagRepo | ||
| 21 | */ | 23 | */ |
| 22 | - public function __construct(PageRevision $pageRevision) | 24 | + public function __construct(PageRevision $pageRevision, TagRepo $tagRepo) |
| 23 | { | 25 | { |
| 24 | $this->pageRevision = $pageRevision; | 26 | $this->pageRevision = $pageRevision; |
| 27 | + $this->tagRepo = $tagRepo; | ||
| 25 | parent::__construct(); | 28 | parent::__construct(); |
| 26 | } | 29 | } |
| 27 | 30 | ||
| ... | @@ -142,6 +145,11 @@ class PageRepo extends EntityRepo | ... | @@ -142,6 +145,11 @@ class PageRepo extends EntityRepo |
| 142 | { | 145 | { |
| 143 | $draftPage->fill($input); | 146 | $draftPage->fill($input); |
| 144 | 147 | ||
| 148 | + // Save page tags if present | ||
| 149 | + if(isset($input['tags'])) { | ||
| 150 | + $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']); | ||
| 151 | + } | ||
| 152 | + | ||
| 145 | $draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id); | 153 | $draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id); |
| 146 | $draftPage->html = $this->formatHtml($input['html']); | 154 | $draftPage->html = $this->formatHtml($input['html']); |
| 147 | $draftPage->text = strip_tags($draftPage->html); | 155 | $draftPage->text = strip_tags($draftPage->html); |
| ... | @@ -242,8 +250,9 @@ class PageRepo extends EntityRepo | ... | @@ -242,8 +250,9 @@ class PageRepo extends EntityRepo |
| 242 | public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) | 250 | public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) |
| 243 | { | 251 | { |
| 244 | $terms = $this->prepareSearchTerms($term); | 252 | $terms = $this->prepareSearchTerms($term); |
| 245 | - $pages = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms)) | 253 | + $pageQuery = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms)); |
| 246 | - ->paginate($count)->appends($paginationAppends); | 254 | + $pageQuery = $this->addAdvancedSearchQueries($pageQuery, $term); |
| 255 | + $pages = $pageQuery->paginate($count)->appends($paginationAppends); | ||
| 247 | 256 | ||
| 248 | // Add highlights to page text. | 257 | // Add highlights to page text. |
| 249 | $words = join('|', explode(' ', preg_quote(trim($term), '/'))); | 258 | $words = join('|', explode(' ', preg_quote(trim($term), '/'))); |
| ... | @@ -308,6 +317,11 @@ class PageRepo extends EntityRepo | ... | @@ -308,6 +317,11 @@ class PageRepo extends EntityRepo |
| 308 | $page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id); | 317 | $page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id); |
| 309 | } | 318 | } |
| 310 | 319 | ||
| 320 | + // Save page tags if present | ||
| 321 | + if(isset($input['tags'])) { | ||
| 322 | + $this->tagRepo->saveTagsToEntity($page, $input['tags']); | ||
| 323 | + } | ||
| 324 | + | ||
| 311 | // Update with new details | 325 | // Update with new details |
| 312 | $userId = auth()->user()->id; | 326 | $userId = auth()->user()->id; |
| 313 | $page->fill($input); | 327 | $page->fill($input); |
| ... | @@ -582,6 +596,7 @@ class PageRepo extends EntityRepo | ... | @@ -582,6 +596,7 @@ class PageRepo extends EntityRepo |
| 582 | { | 596 | { |
| 583 | Activity::removeEntity($page); | 597 | Activity::removeEntity($page); |
| 584 | $page->views()->delete(); | 598 | $page->views()->delete(); |
| 599 | + $page->tags()->delete(); | ||
| 585 | $page->revisions()->delete(); | 600 | $page->revisions()->delete(); |
| 586 | $page->permissions()->delete(); | 601 | $page->permissions()->delete(); |
| 587 | $this->permissionService->deleteJointPermissionsForEntity($page); | 602 | $this->permissionService->deleteJointPermissionsForEntity($page); | ... | ... |
app/Repos/TagRepo.php
0 → 100644
| 1 | +<?php namespace BookStack\Repos; | ||
| 2 | + | ||
| 3 | +use BookStack\Tag; | ||
| 4 | +use BookStack\Entity; | ||
| 5 | +use BookStack\Services\PermissionService; | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * Class TagRepo | ||
| 9 | + * @package BookStack\Repos | ||
| 10 | + */ | ||
| 11 | +class TagRepo | ||
| 12 | +{ | ||
| 13 | + | ||
| 14 | + protected $tag; | ||
| 15 | + protected $entity; | ||
| 16 | + protected $permissionService; | ||
| 17 | + | ||
| 18 | + /** | ||
| 19 | + * TagRepo constructor. | ||
| 20 | + * @param Tag $attr | ||
| 21 | + * @param Entity $ent | ||
| 22 | + * @param PermissionService $ps | ||
| 23 | + */ | ||
| 24 | + public function __construct(Tag $attr, Entity $ent, PermissionService $ps) | ||
| 25 | + { | ||
| 26 | + $this->tag = $attr; | ||
| 27 | + $this->entity = $ent; | ||
| 28 | + $this->permissionService = $ps; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + /** | ||
| 32 | + * Get an entity instance of its particular type. | ||
| 33 | + * @param $entityType | ||
| 34 | + * @param $entityId | ||
| 35 | + * @param string $action | ||
| 36 | + */ | ||
| 37 | + public function getEntity($entityType, $entityId, $action = 'view') | ||
| 38 | + { | ||
| 39 | + $entityInstance = $this->entity->getEntityInstance($entityType); | ||
| 40 | + $searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags'); | ||
| 41 | + $searchQuery = $this->permissionService->enforceEntityRestrictions($searchQuery, $action); | ||
| 42 | + return $searchQuery->first(); | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + /** | ||
| 46 | + * Get all tags for a particular entity. | ||
| 47 | + * @param string $entityType | ||
| 48 | + * @param int $entityId | ||
| 49 | + * @return mixed | ||
| 50 | + */ | ||
| 51 | + public function getForEntity($entityType, $entityId) | ||
| 52 | + { | ||
| 53 | + $entity = $this->getEntity($entityType, $entityId); | ||
| 54 | + if ($entity === null) return collect(); | ||
| 55 | + | ||
| 56 | + return $entity->tags; | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + /** | ||
| 60 | + * Get tag name suggestions from scanning existing tag names. | ||
| 61 | + * @param $searchTerm | ||
| 62 | + * @return array | ||
| 63 | + */ | ||
| 64 | + public function getNameSuggestions($searchTerm) | ||
| 65 | + { | ||
| 66 | + if ($searchTerm === '') return []; | ||
| 67 | + $query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc'); | ||
| 68 | + $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); | ||
| 69 | + return $query->get(['name'])->pluck('name'); | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + /** | ||
| 73 | + * Get tag value suggestions from scanning existing tag values. | ||
| 74 | + * @param $searchTerm | ||
| 75 | + * @return array | ||
| 76 | + */ | ||
| 77 | + public function getValueSuggestions($searchTerm) | ||
| 78 | + { | ||
| 79 | + if ($searchTerm === '') return []; | ||
| 80 | + $query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc'); | ||
| 81 | + $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); | ||
| 82 | + return $query->get(['value'])->pluck('value'); | ||
| 83 | + } | ||
| 84 | + /** | ||
| 85 | + * Save an array of tags to an entity | ||
| 86 | + * @param Entity $entity | ||
| 87 | + * @param array $tags | ||
| 88 | + * @return array|\Illuminate\Database\Eloquent\Collection | ||
| 89 | + */ | ||
| 90 | + public function saveTagsToEntity(Entity $entity, $tags = []) | ||
| 91 | + { | ||
| 92 | + $entity->tags()->delete(); | ||
| 93 | + $newTags = []; | ||
| 94 | + foreach ($tags as $tag) { | ||
| 95 | + if (trim($tag['name']) === '') continue; | ||
| 96 | + $newTags[] = $this->newInstanceFromInput($tag); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + return $entity->tags()->saveMany($newTags); | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + /** | ||
| 103 | + * Create a new Tag instance from user input. | ||
| 104 | + * @param $input | ||
| 105 | + * @return static | ||
| 106 | + */ | ||
| 107 | + protected function newInstanceFromInput($input) | ||
| 108 | + { | ||
| 109 | + $name = trim($input['name']); | ||
| 110 | + $value = isset($input['value']) ? trim($input['value']) : ''; | ||
| 111 | + // Any other modification or cleanup required can go here | ||
| 112 | + $values = ['name' => $name, 'value' => $value]; | ||
| 113 | + return $this->tag->newInstance($values); | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -400,9 +400,7 @@ class PermissionService | ... | @@ -400,9 +400,7 @@ class PermissionService |
| 400 | } | 400 | } |
| 401 | }); | 401 | }); |
| 402 | 402 | ||
| 403 | - if ($this->isAdmin) return $query; | 403 | + return $this->enforceEntityRestrictions($query, $action); |
| 404 | - $this->currentAction = $action; | ||
| 405 | - return $this->entityRestrictionQuery($query); | ||
| 406 | } | 404 | } |
| 407 | 405 | ||
| 408 | /** | 406 | /** |
| ... | @@ -413,9 +411,7 @@ class PermissionService | ... | @@ -413,9 +411,7 @@ class PermissionService |
| 413 | */ | 411 | */ |
| 414 | public function enforceChapterRestrictions($query, $action = 'view') | 412 | public function enforceChapterRestrictions($query, $action = 'view') |
| 415 | { | 413 | { |
| 416 | - if ($this->isAdmin) return $query; | 414 | + return $this->enforceEntityRestrictions($query, $action); |
| 417 | - $this->currentAction = $action; | ||
| 418 | - return $this->entityRestrictionQuery($query); | ||
| 419 | } | 415 | } |
| 420 | 416 | ||
| 421 | /** | 417 | /** |
| ... | @@ -426,6 +422,17 @@ class PermissionService | ... | @@ -426,6 +422,17 @@ class PermissionService |
| 426 | */ | 422 | */ |
| 427 | public function enforceBookRestrictions($query, $action = 'view') | 423 | public function enforceBookRestrictions($query, $action = 'view') |
| 428 | { | 424 | { |
| 425 | + return $this->enforceEntityRestrictions($query, $action); | ||
| 426 | + } | ||
| 427 | + | ||
| 428 | + /** | ||
| 429 | + * Add restrictions for a generic entity | ||
| 430 | + * @param $query | ||
| 431 | + * @param string $action | ||
| 432 | + * @return mixed | ||
| 433 | + */ | ||
| 434 | + public function enforceEntityRestrictions($query, $action = 'view') | ||
| 435 | + { | ||
| 429 | if ($this->isAdmin) return $query; | 436 | if ($this->isAdmin) return $query; |
| 430 | $this->currentAction = $action; | 437 | $this->currentAction = $action; |
| 431 | return $this->entityRestrictionQuery($query); | 438 | return $this->entityRestrictionQuery($query); | ... | ... |
app/Tag.php
0 → 100644
| 1 | +<?php namespace BookStack; | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * Class Attribute | ||
| 5 | + * @package BookStack | ||
| 6 | + */ | ||
| 7 | +class Tag extends Model | ||
| 8 | +{ | ||
| 9 | + protected $fillable = ['name', 'value', 'order']; | ||
| 10 | + | ||
| 11 | + /** | ||
| 12 | + * Get the entity that this tag belongs to | ||
| 13 | + * @return \Illuminate\Database\Eloquent\Relations\MorphTo | ||
| 14 | + */ | ||
| 15 | + public function entity() | ||
| 16 | + { | ||
| 17 | + return $this->morphTo('entity'); | ||
| 18 | + } | ||
| 19 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -52,4 +52,11 @@ $factory->define(BookStack\Role::class, function ($faker) { | ... | @@ -52,4 +52,11 @@ $factory->define(BookStack\Role::class, function ($faker) { |
| 52 | 'display_name' => $faker->sentence(3), | 52 | 'display_name' => $faker->sentence(3), |
| 53 | 'description' => $faker->sentence(10) | 53 | 'description' => $faker->sentence(10) |
| 54 | ]; | 54 | ]; |
| 55 | +}); | ||
| 56 | + | ||
| 57 | +$factory->define(BookStack\Tag::class, function ($faker) { | ||
| 58 | + return [ | ||
| 59 | + 'name' => $faker->city, | ||
| 60 | + 'value' => $faker->sentence(3) | ||
| 61 | + ]; | ||
| 55 | }); | 62 | }); |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +use Illuminate\Database\Schema\Blueprint; | ||
| 4 | +use Illuminate\Database\Migrations\Migration; | ||
| 5 | + | ||
| 6 | +class CreateTagsTable extends Migration | ||
| 7 | +{ | ||
| 8 | + /** | ||
| 9 | + * Run the migrations. | ||
| 10 | + * | ||
| 11 | + * @return void | ||
| 12 | + */ | ||
| 13 | + public function up() | ||
| 14 | + { | ||
| 15 | + Schema::create('tags', function (Blueprint $table) { | ||
| 16 | + $table->increments('id'); | ||
| 17 | + $table->integer('entity_id'); | ||
| 18 | + $table->string('entity_type', 100); | ||
| 19 | + $table->string('name'); | ||
| 20 | + $table->string('value'); | ||
| 21 | + $table->integer('order'); | ||
| 22 | + $table->timestamps(); | ||
| 23 | + | ||
| 24 | + $table->index('name'); | ||
| 25 | + $table->index('value'); | ||
| 26 | + $table->index('order'); | ||
| 27 | + $table->index(['entity_id', 'entity_type']); | ||
| 28 | + }); | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + /** | ||
| 32 | + * Reverse the migrations. | ||
| 33 | + * | ||
| 34 | + * @return void | ||
| 35 | + */ | ||
| 36 | + public function down() | ||
| 37 | + { | ||
| 38 | + Schema::drop('tags'); | ||
| 39 | + } | ||
| 40 | +} |
| ... | @@ -4,10 +4,11 @@ | ... | @@ -4,10 +4,11 @@ |
| 4 | "gulp": "^3.9.0" | 4 | "gulp": "^3.9.0" |
| 5 | }, | 5 | }, |
| 6 | "dependencies": { | 6 | "dependencies": { |
| 7 | - "angular": "^1.5.0-rc.0", | 7 | + "angular": "^1.5.5", |
| 8 | - "angular-animate": "^1.5.0-rc.0", | 8 | + "angular-animate": "^1.5.5", |
| 9 | - "angular-resource": "^1.5.0-rc.0", | 9 | + "angular-resource": "^1.5.5", |
| 10 | - "angular-sanitize": "^1.5.0-rc.0", | 10 | + "angular-sanitize": "^1.5.5", |
| 11 | + "angular-ui-sortable": "^0.14.0", | ||
| 11 | "babel-runtime": "^5.8.29", | 12 | "babel-runtime": "^5.8.29", |
| 12 | "bootstrap-sass": "^3.0.0", | 13 | "bootstrap-sass": "^3.0.0", |
| 13 | "dropzone": "^4.0.1", | 14 | "dropzone": "^4.0.1", | ... | ... |
public/libs/jquery/jquery-ui.min.js
0 → 100644
| 1 | +/*! jQuery UI - v1.11.4 - 2016-05-14 | ||
| 2 | +* http://jqueryui.com | ||
| 3 | +* Includes: core.js, widget.js, mouse.js, sortable.js | ||
| 4 | +* Copyright jQuery Foundation and other contributors; Licensed MIT */ | ||
| 5 | + | ||
| 6 | +(function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)})(function(t){function e(e,s){var n,a,o,r=e.nodeName.toLowerCase();return"area"===r?(n=e.parentNode,a=n.name,e.href&&a&&"map"===n.nodeName.toLowerCase()?(o=t("img[usemap='#"+a+"']")[0],!!o&&i(o)):!1):(/^(input|select|textarea|button|object)$/.test(r)?!e.disabled:"a"===r?e.href||s:s)&&i(e)}function i(e){return t.expr.filters.visible(e)&&!t(e).parents().addBack().filter(function(){return"hidden"===t.css(this,"visibility")}).length}t.ui=t.ui||{},t.extend(t.ui,{version:"1.11.4",keyCode:{BACKSPACE:8,COMMA:188,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,LEFT:37,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SPACE:32,TAB:9,UP:38}}),t.fn.extend({scrollParent:function(e){var i=this.css("position"),s="absolute"===i,n=e?/(auto|scroll|hidden)/:/(auto|scroll)/,a=this.parents().filter(function(){var e=t(this);return s&&"static"===e.css("position")?!1:n.test(e.css("overflow")+e.css("overflow-y")+e.css("overflow-x"))}).eq(0);return"fixed"!==i&&a.length?a:t(this[0].ownerDocument||document)},uniqueId:function(){var t=0;return function(){return this.each(function(){this.id||(this.id="ui-id-"+ ++t)})}}(),removeUniqueId:function(){return this.each(function(){/^ui-id-\d+$/.test(this.id)&&t(this).removeAttr("id")})}}),t.extend(t.expr[":"],{data:t.expr.createPseudo?t.expr.createPseudo(function(e){return function(i){return!!t.data(i,e)}}):function(e,i,s){return!!t.data(e,s[3])},focusable:function(i){return e(i,!isNaN(t.attr(i,"tabindex")))},tabbable:function(i){var s=t.attr(i,"tabindex"),n=isNaN(s);return(n||s>=0)&&e(i,!n)}}),t("<a>").outerWidth(1).jquery||t.each(["Width","Height"],function(e,i){function s(e,i,s,a){return t.each(n,function(){i-=parseFloat(t.css(e,"padding"+this))||0,s&&(i-=parseFloat(t.css(e,"border"+this+"Width"))||0),a&&(i-=parseFloat(t.css(e,"margin"+this))||0)}),i}var n="Width"===i?["Left","Right"]:["Top","Bottom"],a=i.toLowerCase(),o={innerWidth:t.fn.innerWidth,innerHeight:t.fn.innerHeight,outerWidth:t.fn.outerWidth,outerHeight:t.fn.outerHeight};t.fn["inner"+i]=function(e){return void 0===e?o["inner"+i].call(this):this.each(function(){t(this).css(a,s(this,e)+"px")})},t.fn["outer"+i]=function(e,n){return"number"!=typeof e?o["outer"+i].call(this,e):this.each(function(){t(this).css(a,s(this,e,!0,n)+"px")})}}),t.fn.addBack||(t.fn.addBack=function(t){return this.add(null==t?this.prevObject:this.prevObject.filter(t))}),t("<a>").data("a-b","a").removeData("a-b").data("a-b")&&(t.fn.removeData=function(e){return function(i){return arguments.length?e.call(this,t.camelCase(i)):e.call(this)}}(t.fn.removeData)),t.ui.ie=!!/msie [\w.]+/.exec(navigator.userAgent.toLowerCase()),t.fn.extend({focus:function(e){return function(i,s){return"number"==typeof i?this.each(function(){var e=this;setTimeout(function(){t(e).focus(),s&&s.call(e)},i)}):e.apply(this,arguments)}}(t.fn.focus),disableSelection:function(){var t="onselectstart"in document.createElement("div")?"selectstart":"mousedown";return function(){return this.bind(t+".ui-disableSelection",function(t){t.preventDefault()})}}(),enableSelection:function(){return this.unbind(".ui-disableSelection")},zIndex:function(e){if(void 0!==e)return this.css("zIndex",e);if(this.length)for(var i,s,n=t(this[0]);n.length&&n[0]!==document;){if(i=n.css("position"),("absolute"===i||"relative"===i||"fixed"===i)&&(s=parseInt(n.css("zIndex"),10),!isNaN(s)&&0!==s))return s;n=n.parent()}return 0}}),t.ui.plugin={add:function(e,i,s){var n,a=t.ui[e].prototype;for(n in s)a.plugins[n]=a.plugins[n]||[],a.plugins[n].push([i,s[n]])},call:function(t,e,i,s){var n,a=t.plugins[e];if(a&&(s||t.element[0].parentNode&&11!==t.element[0].parentNode.nodeType))for(n=0;a.length>n;n++)t.options[a[n][0]]&&a[n][1].apply(t.element,i)}};var s=0,n=Array.prototype.slice;t.cleanData=function(e){return function(i){var s,n,a;for(a=0;null!=(n=i[a]);a++)try{s=t._data(n,"events"),s&&s.remove&&t(n).triggerHandler("remove")}catch(o){}e(i)}}(t.cleanData),t.widget=function(e,i,s){var n,a,o,r,h={},l=e.split(".")[0];return e=e.split(".")[1],n=l+"-"+e,s||(s=i,i=t.Widget),t.expr[":"][n.toLowerCase()]=function(e){return!!t.data(e,n)},t[l]=t[l]||{},a=t[l][e],o=t[l][e]=function(t,e){return this._createWidget?(arguments.length&&this._createWidget(t,e),void 0):new o(t,e)},t.extend(o,a,{version:s.version,_proto:t.extend({},s),_childConstructors:[]}),r=new i,r.options=t.widget.extend({},r.options),t.each(s,function(e,s){return t.isFunction(s)?(h[e]=function(){var t=function(){return i.prototype[e].apply(this,arguments)},n=function(t){return i.prototype[e].apply(this,t)};return function(){var e,i=this._super,a=this._superApply;return this._super=t,this._superApply=n,e=s.apply(this,arguments),this._super=i,this._superApply=a,e}}(),void 0):(h[e]=s,void 0)}),o.prototype=t.widget.extend(r,{widgetEventPrefix:a?r.widgetEventPrefix||e:e},h,{constructor:o,namespace:l,widgetName:e,widgetFullName:n}),a?(t.each(a._childConstructors,function(e,i){var s=i.prototype;t.widget(s.namespace+"."+s.widgetName,o,i._proto)}),delete a._childConstructors):i._childConstructors.push(o),t.widget.bridge(e,o),o},t.widget.extend=function(e){for(var i,s,a=n.call(arguments,1),o=0,r=a.length;r>o;o++)for(i in a[o])s=a[o][i],a[o].hasOwnProperty(i)&&void 0!==s&&(e[i]=t.isPlainObject(s)?t.isPlainObject(e[i])?t.widget.extend({},e[i],s):t.widget.extend({},s):s);return e},t.widget.bridge=function(e,i){var s=i.prototype.widgetFullName||e;t.fn[e]=function(a){var o="string"==typeof a,r=n.call(arguments,1),h=this;return o?this.each(function(){var i,n=t.data(this,s);return"instance"===a?(h=n,!1):n?t.isFunction(n[a])&&"_"!==a.charAt(0)?(i=n[a].apply(n,r),i!==n&&void 0!==i?(h=i&&i.jquery?h.pushStack(i.get()):i,!1):void 0):t.error("no such method '"+a+"' for "+e+" widget instance"):t.error("cannot call methods on "+e+" prior to initialization; "+"attempted to call method '"+a+"'")}):(r.length&&(a=t.widget.extend.apply(null,[a].concat(r))),this.each(function(){var e=t.data(this,s);e?(e.option(a||{}),e._init&&e._init()):t.data(this,s,new i(a,this))})),h}},t.Widget=function(){},t.Widget._childConstructors=[],t.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",defaultElement:"<div>",options:{disabled:!1,create:null},_createWidget:function(e,i){i=t(i||this.defaultElement||this)[0],this.element=t(i),this.uuid=s++,this.eventNamespace="."+this.widgetName+this.uuid,this.bindings=t(),this.hoverable=t(),this.focusable=t(),i!==this&&(t.data(i,this.widgetFullName,this),this._on(!0,this.element,{remove:function(t){t.target===i&&this.destroy()}}),this.document=t(i.style?i.ownerDocument:i.document||i),this.window=t(this.document[0].defaultView||this.document[0].parentWindow)),this.options=t.widget.extend({},this.options,this._getCreateOptions(),e),this._create(),this._trigger("create",null,this._getCreateEventData()),this._init()},_getCreateOptions:t.noop,_getCreateEventData:t.noop,_create:t.noop,_init:t.noop,destroy:function(){this._destroy(),this.element.unbind(this.eventNamespace).removeData(this.widgetFullName).removeData(t.camelCase(this.widgetFullName)),this.widget().unbind(this.eventNamespace).removeAttr("aria-disabled").removeClass(this.widgetFullName+"-disabled "+"ui-state-disabled"),this.bindings.unbind(this.eventNamespace),this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus")},_destroy:t.noop,widget:function(){return this.element},option:function(e,i){var s,n,a,o=e;if(0===arguments.length)return t.widget.extend({},this.options);if("string"==typeof e)if(o={},s=e.split("."),e=s.shift(),s.length){for(n=o[e]=t.widget.extend({},this.options[e]),a=0;s.length-1>a;a++)n[s[a]]=n[s[a]]||{},n=n[s[a]];if(e=s.pop(),1===arguments.length)return void 0===n[e]?null:n[e];n[e]=i}else{if(1===arguments.length)return void 0===this.options[e]?null:this.options[e];o[e]=i}return this._setOptions(o),this},_setOptions:function(t){var e;for(e in t)this._setOption(e,t[e]);return this},_setOption:function(t,e){return this.options[t]=e,"disabled"===t&&(this.widget().toggleClass(this.widgetFullName+"-disabled",!!e),e&&(this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus"))),this},enable:function(){return this._setOptions({disabled:!1})},disable:function(){return this._setOptions({disabled:!0})},_on:function(e,i,s){var n,a=this;"boolean"!=typeof e&&(s=i,i=e,e=!1),s?(i=n=t(i),this.bindings=this.bindings.add(i)):(s=i,i=this.element,n=this.widget()),t.each(s,function(s,o){function r(){return e||a.options.disabled!==!0&&!t(this).hasClass("ui-state-disabled")?("string"==typeof o?a[o]:o).apply(a,arguments):void 0}"string"!=typeof o&&(r.guid=o.guid=o.guid||r.guid||t.guid++);var h=s.match(/^([\w:-]*)\s*(.*)$/),l=h[1]+a.eventNamespace,u=h[2];u?n.delegate(u,l,r):i.bind(l,r)})},_off:function(e,i){i=(i||"").split(" ").join(this.eventNamespace+" ")+this.eventNamespace,e.unbind(i).undelegate(i),this.bindings=t(this.bindings.not(e).get()),this.focusable=t(this.focusable.not(e).get()),this.hoverable=t(this.hoverable.not(e).get())},_delay:function(t,e){function i(){return("string"==typeof t?s[t]:t).apply(s,arguments)}var s=this;return setTimeout(i,e||0)},_hoverable:function(e){this.hoverable=this.hoverable.add(e),this._on(e,{mouseenter:function(e){t(e.currentTarget).addClass("ui-state-hover")},mouseleave:function(e){t(e.currentTarget).removeClass("ui-state-hover")}})},_focusable:function(e){this.focusable=this.focusable.add(e),this._on(e,{focusin:function(e){t(e.currentTarget).addClass("ui-state-focus")},focusout:function(e){t(e.currentTarget).removeClass("ui-state-focus")}})},_trigger:function(e,i,s){var n,a,o=this.options[e];if(s=s||{},i=t.Event(i),i.type=(e===this.widgetEventPrefix?e:this.widgetEventPrefix+e).toLowerCase(),i.target=this.element[0],a=i.originalEvent)for(n in a)n in i||(i[n]=a[n]);return this.element.trigger(i,s),!(t.isFunction(o)&&o.apply(this.element[0],[i].concat(s))===!1||i.isDefaultPrevented())}},t.each({show:"fadeIn",hide:"fadeOut"},function(e,i){t.Widget.prototype["_"+e]=function(s,n,a){"string"==typeof n&&(n={effect:n});var o,r=n?n===!0||"number"==typeof n?i:n.effect||i:e;n=n||{},"number"==typeof n&&(n={duration:n}),o=!t.isEmptyObject(n),n.complete=a,n.delay&&s.delay(n.delay),o&&t.effects&&t.effects.effect[r]?s[e](n):r!==e&&s[r]?s[r](n.duration,n.easing,a):s.queue(function(i){t(this)[e](),a&&a.call(s[0]),i()})}}),t.widget;var a=!1;t(document).mouseup(function(){a=!1}),t.widget("ui.mouse",{version:"1.11.4",options:{cancel:"input,textarea,button,select,option",distance:1,delay:0},_mouseInit:function(){var e=this;this.element.bind("mousedown."+this.widgetName,function(t){return e._mouseDown(t)}).bind("click."+this.widgetName,function(i){return!0===t.data(i.target,e.widgetName+".preventClickEvent")?(t.removeData(i.target,e.widgetName+".preventClickEvent"),i.stopImmediatePropagation(),!1):void 0}),this.started=!1},_mouseDestroy:function(){this.element.unbind("."+this.widgetName),this._mouseMoveDelegate&&this.document.unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate)},_mouseDown:function(e){if(!a){this._mouseMoved=!1,this._mouseStarted&&this._mouseUp(e),this._mouseDownEvent=e;var i=this,s=1===e.which,n="string"==typeof this.options.cancel&&e.target.nodeName?t(e.target).closest(this.options.cancel).length:!1;return s&&!n&&this._mouseCapture(e)?(this.mouseDelayMet=!this.options.delay,this.mouseDelayMet||(this._mouseDelayTimer=setTimeout(function(){i.mouseDelayMet=!0},this.options.delay)),this._mouseDistanceMet(e)&&this._mouseDelayMet(e)&&(this._mouseStarted=this._mouseStart(e)!==!1,!this._mouseStarted)?(e.preventDefault(),!0):(!0===t.data(e.target,this.widgetName+".preventClickEvent")&&t.removeData(e.target,this.widgetName+".preventClickEvent"),this._mouseMoveDelegate=function(t){return i._mouseMove(t)},this._mouseUpDelegate=function(t){return i._mouseUp(t)},this.document.bind("mousemove."+this.widgetName,this._mouseMoveDelegate).bind("mouseup."+this.widgetName,this._mouseUpDelegate),e.preventDefault(),a=!0,!0)):!0}},_mouseMove:function(e){if(this._mouseMoved){if(t.ui.ie&&(!document.documentMode||9>document.documentMode)&&!e.button)return this._mouseUp(e);if(!e.which)return this._mouseUp(e)}return(e.which||e.button)&&(this._mouseMoved=!0),this._mouseStarted?(this._mouseDrag(e),e.preventDefault()):(this._mouseDistanceMet(e)&&this._mouseDelayMet(e)&&(this._mouseStarted=this._mouseStart(this._mouseDownEvent,e)!==!1,this._mouseStarted?this._mouseDrag(e):this._mouseUp(e)),!this._mouseStarted)},_mouseUp:function(e){return this.document.unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate),this._mouseStarted&&(this._mouseStarted=!1,e.target===this._mouseDownEvent.target&&t.data(e.target,this.widgetName+".preventClickEvent",!0),this._mouseStop(e)),a=!1,!1},_mouseDistanceMet:function(t){return Math.max(Math.abs(this._mouseDownEvent.pageX-t.pageX),Math.abs(this._mouseDownEvent.pageY-t.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return!0}}),t.widget("ui.sortable",t.ui.mouse,{version:"1.11.4",widgetEventPrefix:"sort",ready:!1,options:{appendTo:"parent",axis:!1,connectWith:!1,containment:!1,cursor:"auto",cursorAt:!1,dropOnEmpty:!0,forcePlaceholderSize:!1,forceHelperSize:!1,grid:!1,handle:!1,helper:"original",items:"> *",opacity:!1,placeholder:!1,revert:!1,scroll:!0,scrollSensitivity:20,scrollSpeed:20,scope:"default",tolerance:"intersect",zIndex:1e3,activate:null,beforeStop:null,change:null,deactivate:null,out:null,over:null,receive:null,remove:null,sort:null,start:null,stop:null,update:null},_isOverAxis:function(t,e,i){return t>=e&&e+i>t},_isFloating:function(t){return/left|right/.test(t.css("float"))||/inline|table-cell/.test(t.css("display"))},_create:function(){this.containerCache={},this.element.addClass("ui-sortable"),this.refresh(),this.offset=this.element.offset(),this._mouseInit(),this._setHandleClassName(),this.ready=!0},_setOption:function(t,e){this._super(t,e),"handle"===t&&this._setHandleClassName()},_setHandleClassName:function(){this.element.find(".ui-sortable-handle").removeClass("ui-sortable-handle"),t.each(this.items,function(){(this.instance.options.handle?this.item.find(this.instance.options.handle):this.item).addClass("ui-sortable-handle")})},_destroy:function(){this.element.removeClass("ui-sortable ui-sortable-disabled").find(".ui-sortable-handle").removeClass("ui-sortable-handle"),this._mouseDestroy();for(var t=this.items.length-1;t>=0;t--)this.items[t].item.removeData(this.widgetName+"-item");return this},_mouseCapture:function(e,i){var s=null,n=!1,a=this;return this.reverting?!1:this.options.disabled||"static"===this.options.type?!1:(this._refreshItems(e),t(e.target).parents().each(function(){return t.data(this,a.widgetName+"-item")===a?(s=t(this),!1):void 0}),t.data(e.target,a.widgetName+"-item")===a&&(s=t(e.target)),s?!this.options.handle||i||(t(this.options.handle,s).find("*").addBack().each(function(){this===e.target&&(n=!0)}),n)?(this.currentItem=s,this._removeCurrentsFromItems(),!0):!1:!1)},_mouseStart:function(e,i,s){var n,a,o=this.options;if(this.currentContainer=this,this.refreshPositions(),this.helper=this._createHelper(e),this._cacheHelperProportions(),this._cacheMargins(),this.scrollParent=this.helper.scrollParent(),this.offset=this.currentItem.offset(),this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left},t.extend(this.offset,{click:{left:e.pageX-this.offset.left,top:e.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()}),this.helper.css("position","absolute"),this.cssPosition=this.helper.css("position"),this.originalPosition=this._generatePosition(e),this.originalPageX=e.pageX,this.originalPageY=e.pageY,o.cursorAt&&this._adjustOffsetFromHelper(o.cursorAt),this.domPosition={prev:this.currentItem.prev()[0],parent:this.currentItem.parent()[0]},this.helper[0]!==this.currentItem[0]&&this.currentItem.hide(),this._createPlaceholder(),o.containment&&this._setContainment(),o.cursor&&"auto"!==o.cursor&&(a=this.document.find("body"),this.storedCursor=a.css("cursor"),a.css("cursor",o.cursor),this.storedStylesheet=t("<style>*{ cursor: "+o.cursor+" !important; }</style>").appendTo(a)),o.opacity&&(this.helper.css("opacity")&&(this._storedOpacity=this.helper.css("opacity")),this.helper.css("opacity",o.opacity)),o.zIndex&&(this.helper.css("zIndex")&&(this._storedZIndex=this.helper.css("zIndex")),this.helper.css("zIndex",o.zIndex)),this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName&&(this.overflowOffset=this.scrollParent.offset()),this._trigger("start",e,this._uiHash()),this._preserveHelperProportions||this._cacheHelperProportions(),!s)for(n=this.containers.length-1;n>=0;n--)this.containers[n]._trigger("activate",e,this._uiHash(this));return t.ui.ddmanager&&(t.ui.ddmanager.current=this),t.ui.ddmanager&&!o.dropBehaviour&&t.ui.ddmanager.prepareOffsets(this,e),this.dragging=!0,this.helper.addClass("ui-sortable-helper"),this._mouseDrag(e),!0},_mouseDrag:function(e){var i,s,n,a,o=this.options,r=!1;for(this.position=this._generatePosition(e),this.positionAbs=this._convertPositionTo("absolute"),this.lastPositionAbs||(this.lastPositionAbs=this.positionAbs),this.options.scroll&&(this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName?(this.overflowOffset.top+this.scrollParent[0].offsetHeight-e.pageY<o.scrollSensitivity?this.scrollParent[0].scrollTop=r=this.scrollParent[0].scrollTop+o.scrollSpeed:e.pageY-this.overflowOffset.top<o.scrollSensitivity&&(this.scrollParent[0].scrollTop=r=this.scrollParent[0].scrollTop-o.scrollSpeed),this.overflowOffset.left+this.scrollParent[0].offsetWidth-e.pageX<o.scrollSensitivity?this.scrollParent[0].scrollLeft=r=this.scrollParent[0].scrollLeft+o.scrollSpeed:e.pageX-this.overflowOffset.left<o.scrollSensitivity&&(this.scrollParent[0].scrollLeft=r=this.scrollParent[0].scrollLeft-o.scrollSpeed)):(e.pageY-this.document.scrollTop()<o.scrollSensitivity?r=this.document.scrollTop(this.document.scrollTop()-o.scrollSpeed):this.window.height()-(e.pageY-this.document.scrollTop())<o.scrollSensitivity&&(r=this.document.scrollTop(this.document.scrollTop()+o.scrollSpeed)),e.pageX-this.document.scrollLeft()<o.scrollSensitivity?r=this.document.scrollLeft(this.document.scrollLeft()-o.scrollSpeed):this.window.width()-(e.pageX-this.document.scrollLeft())<o.scrollSensitivity&&(r=this.document.scrollLeft(this.document.scrollLeft()+o.scrollSpeed))),r!==!1&&t.ui.ddmanager&&!o.dropBehaviour&&t.ui.ddmanager.prepareOffsets(this,e)),this.positionAbs=this._convertPositionTo("absolute"),this.options.axis&&"y"===this.options.axis||(this.helper[0].style.left=this.position.left+"px"),this.options.axis&&"x"===this.options.axis||(this.helper[0].style.top=this.position.top+"px"),i=this.items.length-1;i>=0;i--)if(s=this.items[i],n=s.item[0],a=this._intersectsWithPointer(s),a&&s.instance===this.currentContainer&&n!==this.currentItem[0]&&this.placeholder[1===a?"next":"prev"]()[0]!==n&&!t.contains(this.placeholder[0],n)&&("semi-dynamic"===this.options.type?!t.contains(this.element[0],n):!0)){if(this.direction=1===a?"down":"up","pointer"!==this.options.tolerance&&!this._intersectsWithSides(s))break;this._rearrange(e,s),this._trigger("change",e,this._uiHash());break}return this._contactContainers(e),t.ui.ddmanager&&t.ui.ddmanager.drag(this,e),this._trigger("sort",e,this._uiHash()),this.lastPositionAbs=this.positionAbs,!1},_mouseStop:function(e,i){if(e){if(t.ui.ddmanager&&!this.options.dropBehaviour&&t.ui.ddmanager.drop(this,e),this.options.revert){var s=this,n=this.placeholder.offset(),a=this.options.axis,o={};a&&"x"!==a||(o.left=n.left-this.offset.parent.left-this.margins.left+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollLeft)),a&&"y"!==a||(o.top=n.top-this.offset.parent.top-this.margins.top+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollTop)),this.reverting=!0,t(this.helper).animate(o,parseInt(this.options.revert,10)||500,function(){s._clear(e)})}else this._clear(e,i);return!1}},cancel:function(){if(this.dragging){this._mouseUp({target:null}),"original"===this.options.helper?this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"):this.currentItem.show();for(var e=this.containers.length-1;e>=0;e--)this.containers[e]._trigger("deactivate",null,this._uiHash(this)),this.containers[e].containerCache.over&&(this.containers[e]._trigger("out",null,this._uiHash(this)),this.containers[e].containerCache.over=0)}return this.placeholder&&(this.placeholder[0].parentNode&&this.placeholder[0].parentNode.removeChild(this.placeholder[0]),"original"!==this.options.helper&&this.helper&&this.helper[0].parentNode&&this.helper.remove(),t.extend(this,{helper:null,dragging:!1,reverting:!1,_noFinalSort:null}),this.domPosition.prev?t(this.domPosition.prev).after(this.currentItem):t(this.domPosition.parent).prepend(this.currentItem)),this},serialize:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},t(i).each(function(){var i=(t(e.item||this).attr(e.attribute||"id")||"").match(e.expression||/(.+)[\-=_](.+)/);i&&s.push((e.key||i[1]+"[]")+"="+(e.key&&e.expression?i[1]:i[2]))}),!s.length&&e.key&&s.push(e.key+"="),s.join("&")},toArray:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},i.each(function(){s.push(t(e.item||this).attr(e.attribute||"id")||"")}),s},_intersectsWith:function(t){var e=this.positionAbs.left,i=e+this.helperProportions.width,s=this.positionAbs.top,n=s+this.helperProportions.height,a=t.left,o=a+t.width,r=t.top,h=r+t.height,l=this.offset.click.top,u=this.offset.click.left,c="x"===this.options.axis||s+l>r&&h>s+l,d="y"===this.options.axis||e+u>a&&o>e+u,p=c&&d;return"pointer"===this.options.tolerance||this.options.forcePointerForContainers||"pointer"!==this.options.tolerance&&this.helperProportions[this.floating?"width":"height"]>t[this.floating?"width":"height"]?p:e+this.helperProportions.width/2>a&&o>i-this.helperProportions.width/2&&s+this.helperProportions.height/2>r&&h>n-this.helperProportions.height/2},_intersectsWithPointer:function(t){var e="x"===this.options.axis||this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top,t.height),i="y"===this.options.axis||this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left,t.width),s=e&&i,n=this._getDragVerticalDirection(),a=this._getDragHorizontalDirection();return s?this.floating?a&&"right"===a||"down"===n?2:1:n&&("down"===n?2:1):!1},_intersectsWithSides:function(t){var e=this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top+t.height/2,t.height),i=this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left+t.width/2,t.width),s=this._getDragVerticalDirection(),n=this._getDragHorizontalDirection();return this.floating&&n?"right"===n&&i||"left"===n&&!i:s&&("down"===s&&e||"up"===s&&!e)},_getDragVerticalDirection:function(){var t=this.positionAbs.top-this.lastPositionAbs.top;return 0!==t&&(t>0?"down":"up")},_getDragHorizontalDirection:function(){var t=this.positionAbs.left-this.lastPositionAbs.left;return 0!==t&&(t>0?"right":"left")},refresh:function(t){return this._refreshItems(t),this._setHandleClassName(),this.refreshPositions(),this},_connectWith:function(){var t=this.options;return t.connectWith.constructor===String?[t.connectWith]:t.connectWith},_getItemsAsjQuery:function(e){function i(){r.push(this)}var s,n,a,o,r=[],h=[],l=this._connectWith();if(l&&e)for(s=l.length-1;s>=0;s--)for(a=t(l[s],this.document[0]),n=a.length-1;n>=0;n--)o=t.data(a[n],this.widgetFullName),o&&o!==this&&!o.options.disabled&&h.push([t.isFunction(o.options.items)?o.options.items.call(o.element):t(o.options.items,o.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),o]);for(h.push([t.isFunction(this.options.items)?this.options.items.call(this.element,null,{options:this.options,item:this.currentItem}):t(this.options.items,this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),this]),s=h.length-1;s>=0;s--)h[s][0].each(i);return t(r)},_removeCurrentsFromItems:function(){var e=this.currentItem.find(":data("+this.widgetName+"-item)");this.items=t.grep(this.items,function(t){for(var i=0;e.length>i;i++)if(e[i]===t.item[0])return!1;return!0})},_refreshItems:function(e){this.items=[],this.containers=[this];var i,s,n,a,o,r,h,l,u=this.items,c=[[t.isFunction(this.options.items)?this.options.items.call(this.element[0],e,{item:this.currentItem}):t(this.options.items,this.element),this]],d=this._connectWith();if(d&&this.ready)for(i=d.length-1;i>=0;i--)for(n=t(d[i],this.document[0]),s=n.length-1;s>=0;s--)a=t.data(n[s],this.widgetFullName),a&&a!==this&&!a.options.disabled&&(c.push([t.isFunction(a.options.items)?a.options.items.call(a.element[0],e,{item:this.currentItem}):t(a.options.items,a.element),a]),this.containers.push(a));for(i=c.length-1;i>=0;i--)for(o=c[i][1],r=c[i][0],s=0,l=r.length;l>s;s++)h=t(r[s]),h.data(this.widgetName+"-item",o),u.push({item:h,instance:o,width:0,height:0,left:0,top:0})},refreshPositions:function(e){this.floating=this.items.length?"x"===this.options.axis||this._isFloating(this.items[0].item):!1,this.offsetParent&&this.helper&&(this.offset.parent=this._getParentOffset());var i,s,n,a;for(i=this.items.length-1;i>=0;i--)s=this.items[i],s.instance!==this.currentContainer&&this.currentContainer&&s.item[0]!==this.currentItem[0]||(n=this.options.toleranceElement?t(this.options.toleranceElement,s.item):s.item,e||(s.width=n.outerWidth(),s.height=n.outerHeight()),a=n.offset(),s.left=a.left,s.top=a.top);if(this.options.custom&&this.options.custom.refreshContainers)this.options.custom.refreshContainers.call(this);else for(i=this.containers.length-1;i>=0;i--)a=this.containers[i].element.offset(),this.containers[i].containerCache.left=a.left,this.containers[i].containerCache.top=a.top,this.containers[i].containerCache.width=this.containers[i].element.outerWidth(),this.containers[i].containerCache.height=this.containers[i].element.outerHeight();return this},_createPlaceholder:function(e){e=e||this;var i,s=e.options;s.placeholder&&s.placeholder.constructor!==String||(i=s.placeholder,s.placeholder={element:function(){var s=e.currentItem[0].nodeName.toLowerCase(),n=t("<"+s+">",e.document[0]).addClass(i||e.currentItem[0].className+" ui-sortable-placeholder").removeClass("ui-sortable-helper");return"tbody"===s?e._createTrPlaceholder(e.currentItem.find("tr").eq(0),t("<tr>",e.document[0]).appendTo(n)):"tr"===s?e._createTrPlaceholder(e.currentItem,n):"img"===s&&n.attr("src",e.currentItem.attr("src")),i||n.css("visibility","hidden"),n},update:function(t,n){(!i||s.forcePlaceholderSize)&&(n.height()||n.height(e.currentItem.innerHeight()-parseInt(e.currentItem.css("paddingTop")||0,10)-parseInt(e.currentItem.css("paddingBottom")||0,10)),n.width()||n.width(e.currentItem.innerWidth()-parseInt(e.currentItem.css("paddingLeft")||0,10)-parseInt(e.currentItem.css("paddingRight")||0,10)))}}),e.placeholder=t(s.placeholder.element.call(e.element,e.currentItem)),e.currentItem.after(e.placeholder),s.placeholder.update(e,e.placeholder)},_createTrPlaceholder:function(e,i){var s=this;e.children().each(function(){t("<td> </td>",s.document[0]).attr("colspan",t(this).attr("colspan")||1).appendTo(i)})},_contactContainers:function(e){var i,s,n,a,o,r,h,l,u,c,d=null,p=null;for(i=this.containers.length-1;i>=0;i--)if(!t.contains(this.currentItem[0],this.containers[i].element[0]))if(this._intersectsWith(this.containers[i].containerCache)){if(d&&t.contains(this.containers[i].element[0],d.element[0]))continue;d=this.containers[i],p=i}else this.containers[i].containerCache.over&&(this.containers[i]._trigger("out",e,this._uiHash(this)),this.containers[i].containerCache.over=0);if(d)if(1===this.containers.length)this.containers[p].containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1);else{for(n=1e4,a=null,u=d.floating||this._isFloating(this.currentItem),o=u?"left":"top",r=u?"width":"height",c=u?"clientX":"clientY",s=this.items.length-1;s>=0;s--)t.contains(this.containers[p].element[0],this.items[s].item[0])&&this.items[s].item[0]!==this.currentItem[0]&&(h=this.items[s].item.offset()[o],l=!1,e[c]-h>this.items[s][r]/2&&(l=!0),n>Math.abs(e[c]-h)&&(n=Math.abs(e[c]-h),a=this.items[s],this.direction=l?"up":"down"));if(!a&&!this.options.dropOnEmpty)return;if(this.currentContainer===this.containers[p])return this.currentContainer.containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash()),this.currentContainer.containerCache.over=1),void 0;a?this._rearrange(e,a,null,!0):this._rearrange(e,null,this.containers[p].element,!0),this._trigger("change",e,this._uiHash()),this.containers[p]._trigger("change",e,this._uiHash(this)),this.currentContainer=this.containers[p],this.options.placeholder.update(this.currentContainer,this.placeholder),this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1}},_createHelper:function(e){var i=this.options,s=t.isFunction(i.helper)?t(i.helper.apply(this.element[0],[e,this.currentItem])):"clone"===i.helper?this.currentItem.clone():this.currentItem;return s.parents("body").length||t("parent"!==i.appendTo?i.appendTo:this.currentItem[0].parentNode)[0].appendChild(s[0]),s[0]===this.currentItem[0]&&(this._storedCSS={width:this.currentItem[0].style.width,height:this.currentItem[0].style.height,position:this.currentItem.css("position"),top:this.currentItem.css("top"),left:this.currentItem.css("left")}),(!s[0].style.width||i.forceHelperSize)&&s.width(this.currentItem.width()),(!s[0].style.height||i.forceHelperSize)&&s.height(this.currentItem.height()),s},_adjustOffsetFromHelper:function(e){"string"==typeof e&&(e=e.split(" ")),t.isArray(e)&&(e={left:+e[0],top:+e[1]||0}),"left"in e&&(this.offset.click.left=e.left+this.margins.left),"right"in e&&(this.offset.click.left=this.helperProportions.width-e.right+this.margins.left),"top"in e&&(this.offset.click.top=e.top+this.margins.top),"bottom"in e&&(this.offset.click.top=this.helperProportions.height-e.bottom+this.margins.top)},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var e=this.offsetParent.offset();return"absolute"===this.cssPosition&&this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])&&(e.left+=this.scrollParent.scrollLeft(),e.top+=this.scrollParent.scrollTop()),(this.offsetParent[0]===this.document[0].body||this.offsetParent[0].tagName&&"html"===this.offsetParent[0].tagName.toLowerCase()&&t.ui.ie)&&(e={top:0,left:0}),{top:e.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:e.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if("relative"===this.cssPosition){var t=this.currentItem.position();return{top:t.top-(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:t.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.currentItem.css("marginLeft"),10)||0,top:parseInt(this.currentItem.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var e,i,s,n=this.options;"parent"===n.containment&&(n.containment=this.helper[0].parentNode),("document"===n.containment||"window"===n.containment)&&(this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,"document"===n.containment?this.document.width():this.window.width()-this.helperProportions.width-this.margins.left,("document"===n.containment?this.document.width():this.window.height()||this.document[0].body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top]),/^(document|window|parent)$/.test(n.containment)||(e=t(n.containment)[0],i=t(n.containment).offset(),s="hidden"!==t(e).css("overflow"),this.containment=[i.left+(parseInt(t(e).css("borderLeftWidth"),10)||0)+(parseInt(t(e).css("paddingLeft"),10)||0)-this.margins.left,i.top+(parseInt(t(e).css("borderTopWidth"),10)||0)+(parseInt(t(e).css("paddingTop"),10)||0)-this.margins.top,i.left+(s?Math.max(e.scrollWidth,e.offsetWidth):e.offsetWidth)-(parseInt(t(e).css("borderLeftWidth"),10)||0)-(parseInt(t(e).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left,i.top+(s?Math.max(e.scrollHeight,e.offsetHeight):e.offsetHeight)-(parseInt(t(e).css("borderTopWidth"),10)||0)-(parseInt(t(e).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top]) | ||
| 7 | +},_convertPositionTo:function(e,i){i||(i=this.position);var s="absolute"===e?1:-1,n="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,a=/(html|body)/i.test(n[0].tagName);return{top:i.top+this.offset.relative.top*s+this.offset.parent.top*s-("fixed"===this.cssPosition?-this.scrollParent.scrollTop():a?0:n.scrollTop())*s,left:i.left+this.offset.relative.left*s+this.offset.parent.left*s-("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():a?0:n.scrollLeft())*s}},_generatePosition:function(e){var i,s,n=this.options,a=e.pageX,o=e.pageY,r="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,h=/(html|body)/i.test(r[0].tagName);return"relative"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&this.scrollParent[0]!==this.offsetParent[0]||(this.offset.relative=this._getRelativeOffset()),this.originalPosition&&(this.containment&&(e.pageX-this.offset.click.left<this.containment[0]&&(a=this.containment[0]+this.offset.click.left),e.pageY-this.offset.click.top<this.containment[1]&&(o=this.containment[1]+this.offset.click.top),e.pageX-this.offset.click.left>this.containment[2]&&(a=this.containment[2]+this.offset.click.left),e.pageY-this.offset.click.top>this.containment[3]&&(o=this.containment[3]+this.offset.click.top)),n.grid&&(i=this.originalPageY+Math.round((o-this.originalPageY)/n.grid[1])*n.grid[1],o=this.containment?i-this.offset.click.top>=this.containment[1]&&i-this.offset.click.top<=this.containment[3]?i:i-this.offset.click.top>=this.containment[1]?i-n.grid[1]:i+n.grid[1]:i,s=this.originalPageX+Math.round((a-this.originalPageX)/n.grid[0])*n.grid[0],a=this.containment?s-this.offset.click.left>=this.containment[0]&&s-this.offset.click.left<=this.containment[2]?s:s-this.offset.click.left>=this.containment[0]?s-n.grid[0]:s+n.grid[0]:s)),{top:o-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+("fixed"===this.cssPosition?-this.scrollParent.scrollTop():h?0:r.scrollTop()),left:a-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():h?0:r.scrollLeft())}},_rearrange:function(t,e,i,s){i?i[0].appendChild(this.placeholder[0]):e.item[0].parentNode.insertBefore(this.placeholder[0],"down"===this.direction?e.item[0]:e.item[0].nextSibling),this.counter=this.counter?++this.counter:1;var n=this.counter;this._delay(function(){n===this.counter&&this.refreshPositions(!s)})},_clear:function(t,e){function i(t,e,i){return function(s){i._trigger(t,s,e._uiHash(e))}}this.reverting=!1;var s,n=[];if(!this._noFinalSort&&this.currentItem.parent().length&&this.placeholder.before(this.currentItem),this._noFinalSort=null,this.helper[0]===this.currentItem[0]){for(s in this._storedCSS)("auto"===this._storedCSS[s]||"static"===this._storedCSS[s])&&(this._storedCSS[s]="");this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper")}else this.currentItem.show();for(this.fromOutside&&!e&&n.push(function(t){this._trigger("receive",t,this._uiHash(this.fromOutside))}),!this.fromOutside&&this.domPosition.prev===this.currentItem.prev().not(".ui-sortable-helper")[0]&&this.domPosition.parent===this.currentItem.parent()[0]||e||n.push(function(t){this._trigger("update",t,this._uiHash())}),this!==this.currentContainer&&(e||(n.push(function(t){this._trigger("remove",t,this._uiHash())}),n.push(function(t){return function(e){t._trigger("receive",e,this._uiHash(this))}}.call(this,this.currentContainer)),n.push(function(t){return function(e){t._trigger("update",e,this._uiHash(this))}}.call(this,this.currentContainer)))),s=this.containers.length-1;s>=0;s--)e||n.push(i("deactivate",this,this.containers[s])),this.containers[s].containerCache.over&&(n.push(i("out",this,this.containers[s])),this.containers[s].containerCache.over=0);if(this.storedCursor&&(this.document.find("body").css("cursor",this.storedCursor),this.storedStylesheet.remove()),this._storedOpacity&&this.helper.css("opacity",this._storedOpacity),this._storedZIndex&&this.helper.css("zIndex","auto"===this._storedZIndex?"":this._storedZIndex),this.dragging=!1,e||this._trigger("beforeStop",t,this._uiHash()),this.placeholder[0].parentNode.removeChild(this.placeholder[0]),this.cancelHelperRemoval||(this.helper[0]!==this.currentItem[0]&&this.helper.remove(),this.helper=null),!e){for(s=0;n.length>s;s++)n[s].call(this,t);this._trigger("stop",t,this._uiHash())}return this.fromOutside=!1,!this.cancelHelperRemoval},_trigger:function(){t.Widget.prototype._trigger.apply(this,arguments)===!1&&this.cancel()},_uiHash:function(e){var i=e||this;return{helper:i.helper,placeholder:i.placeholder||t([]),position:i.position,originalPosition:i.originalPosition,offset:i.positionAbs,item:i.currentItem,sender:e?e.element:null}}})}); | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -400,4 +400,116 @@ module.exports = function (ngApp, events) { | ... | @@ -400,4 +400,116 @@ module.exports = function (ngApp, events) { |
| 400 | 400 | ||
| 401 | }]); | 401 | }]); |
| 402 | 402 | ||
| 403 | -}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 403 | + ngApp.controller('PageTagController', ['$scope', '$http', '$attrs', | ||
| 404 | + function ($scope, $http, $attrs) { | ||
| 405 | + | ||
| 406 | + const pageId = Number($attrs.pageId); | ||
| 407 | + $scope.tags = []; | ||
| 408 | + | ||
| 409 | + $scope.sortOptions = { | ||
| 410 | + handle: '.handle', | ||
| 411 | + items: '> tr', | ||
| 412 | + containment: "parent", | ||
| 413 | + axis: "y" | ||
| 414 | + }; | ||
| 415 | + | ||
| 416 | + /** | ||
| 417 | + * Push an empty tag to the end of the scope tags. | ||
| 418 | + */ | ||
| 419 | + function addEmptyTag() { | ||
| 420 | + $scope.tags.push({ | ||
| 421 | + name: '', | ||
| 422 | + value: '' | ||
| 423 | + }); | ||
| 424 | + } | ||
| 425 | + $scope.addEmptyTag = addEmptyTag; | ||
| 426 | + | ||
| 427 | + /** | ||
| 428 | + * Get all tags for the current book and add into scope. | ||
| 429 | + */ | ||
| 430 | + function getTags() { | ||
| 431 | + $http.get('/ajax/tags/get/page/' + pageId).then((responseData) => { | ||
| 432 | + $scope.tags = responseData.data; | ||
| 433 | + addEmptyTag(); | ||
| 434 | + }); | ||
| 435 | + } | ||
| 436 | + getTags(); | ||
| 437 | + | ||
| 438 | + /** | ||
| 439 | + * Set the order property on all tags. | ||
| 440 | + */ | ||
| 441 | + function setTagOrder() { | ||
| 442 | + for (let i = 0; i < $scope.tags.length; i++) { | ||
| 443 | + $scope.tags[i].order = i; | ||
| 444 | + } | ||
| 445 | + } | ||
| 446 | + | ||
| 447 | + /** | ||
| 448 | + * When an tag changes check if another empty editable | ||
| 449 | + * field needs to be added onto the end. | ||
| 450 | + * @param tag | ||
| 451 | + */ | ||
| 452 | + $scope.tagChange = function(tag) { | ||
| 453 | + let cPos = $scope.tags.indexOf(tag); | ||
| 454 | + if (cPos !== $scope.tags.length-1) return; | ||
| 455 | + | ||
| 456 | + if (tag.name !== '' || tag.value !== '') { | ||
| 457 | + addEmptyTag(); | ||
| 458 | + } | ||
| 459 | + }; | ||
| 460 | + | ||
| 461 | + /** | ||
| 462 | + * When an tag field loses focus check the tag to see if its | ||
| 463 | + * empty and therefore could be removed from the list. | ||
| 464 | + * @param tag | ||
| 465 | + */ | ||
| 466 | + $scope.tagBlur = function(tag) { | ||
| 467 | + let isLast = $scope.tags.length - 1 === $scope.tags.indexOf(tag); | ||
| 468 | + if (tag.name === '' && tag.value === '' && !isLast) { | ||
| 469 | + let cPos = $scope.tags.indexOf(tag); | ||
| 470 | + $scope.tags.splice(cPos, 1); | ||
| 471 | + } | ||
| 472 | + }; | ||
| 473 | + | ||
| 474 | + /** | ||
| 475 | + * Save the tags to the current page. | ||
| 476 | + */ | ||
| 477 | + $scope.saveTags = function() { | ||
| 478 | + setTagOrder(); | ||
| 479 | + let postData = {tags: $scope.tags}; | ||
| 480 | + $http.post('/ajax/tags/update/page/' + pageId, postData).then((responseData) => { | ||
| 481 | + $scope.tags = responseData.data.tags; | ||
| 482 | + addEmptyTag(); | ||
| 483 | + events.emit('success', responseData.data.message); | ||
| 484 | + }) | ||
| 485 | + }; | ||
| 486 | + | ||
| 487 | + /** | ||
| 488 | + * Remove a tag from the current list. | ||
| 489 | + * @param tag | ||
| 490 | + */ | ||
| 491 | + $scope.removeTag = function(tag) { | ||
| 492 | + let cIndex = $scope.tags.indexOf(tag); | ||
| 493 | + $scope.tags.splice(cIndex, 1); | ||
| 494 | + }; | ||
| 495 | + | ||
| 496 | + }]); | ||
| 497 | + | ||
| 498 | +}; | ||
| 499 | + | ||
| 500 | + | ||
| 501 | + | ||
| 502 | + | ||
| 503 | + | ||
| 504 | + | ||
| 505 | + | ||
| 506 | + | ||
| 507 | + | ||
| 508 | + | ||
| 509 | + | ||
| 510 | + | ||
| 511 | + | ||
| 512 | + | ||
| 513 | + | ||
| 514 | + | ||
| 515 | + | ... | ... |
| ... | @@ -301,6 +301,219 @@ module.exports = function (ngApp, events) { | ... | @@ -301,6 +301,219 @@ module.exports = function (ngApp, events) { |
| 301 | 301 | ||
| 302 | } | 302 | } |
| 303 | } | 303 | } |
| 304 | - }]) | 304 | + }]); |
| 305 | + | ||
| 306 | + ngApp.directive('toolbox', [function() { | ||
| 307 | + return { | ||
| 308 | + restrict: 'A', | ||
| 309 | + link: function(scope, elem, attrs) { | ||
| 310 | + | ||
| 311 | + // Get common elements | ||
| 312 | + const $buttons = elem.find('[tab-button]'); | ||
| 313 | + const $content = elem.find('[tab-content]'); | ||
| 314 | + const $toggle = elem.find('[toolbox-toggle]'); | ||
| 315 | + | ||
| 316 | + // Handle toolbox toggle click | ||
| 317 | + $toggle.click((e) => { | ||
| 318 | + elem.toggleClass('open'); | ||
| 319 | + }); | ||
| 320 | + | ||
| 321 | + // Set an active tab/content by name | ||
| 322 | + function setActive(tabName, openToolbox) { | ||
| 323 | + $buttons.removeClass('active'); | ||
| 324 | + $content.hide(); | ||
| 325 | + $buttons.filter(`[tab-button="${tabName}"]`).addClass('active'); | ||
| 326 | + $content.filter(`[tab-content="${tabName}"]`).show(); | ||
| 327 | + if (openToolbox) elem.addClass('open'); | ||
| 328 | + } | ||
| 329 | + | ||
| 330 | + // Set the first tab content active on load | ||
| 331 | + setActive($content.first().attr('tab-content'), false); | ||
| 332 | + | ||
| 333 | + // Handle tab button click | ||
| 334 | + $buttons.click(function(e) { | ||
| 335 | + let name = $(this).attr('tab-button'); | ||
| 336 | + setActive(name, true); | ||
| 337 | + }); | ||
| 338 | + } | ||
| 339 | + } | ||
| 340 | + }]); | ||
| 341 | + | ||
| 342 | + ngApp.directive('autosuggestions', ['$http', function($http) { | ||
| 343 | + return { | ||
| 344 | + restrict: 'A', | ||
| 345 | + link: function(scope, elem, attrs) { | ||
| 346 | + | ||
| 347 | + // Local storage for quick caching. | ||
| 348 | + const localCache = {}; | ||
| 349 | + | ||
| 350 | + // Create suggestion element | ||
| 351 | + const suggestionBox = document.createElement('ul'); | ||
| 352 | + suggestionBox.className = 'suggestion-box'; | ||
| 353 | + suggestionBox.style.position = 'absolute'; | ||
| 354 | + suggestionBox.style.display = 'none'; | ||
| 355 | + const $suggestionBox = $(suggestionBox); | ||
| 356 | + | ||
| 357 | + // General state tracking | ||
| 358 | + let isShowing = false; | ||
| 359 | + let currentInput = false; | ||
| 360 | + let active = 0; | ||
| 361 | + | ||
| 362 | + // Listen to input events on autosuggest fields | ||
| 363 | + elem.on('input', '[autosuggest]', function(event) { | ||
| 364 | + let $input = $(this); | ||
| 365 | + let val = $input.val(); | ||
| 366 | + let url = $input.attr('autosuggest'); | ||
| 367 | + // No suggestions until at least 3 chars | ||
| 368 | + if (val.length < 3) { | ||
| 369 | + if (isShowing) { | ||
| 370 | + $suggestionBox.hide(); | ||
| 371 | + isShowing = false; | ||
| 372 | + } | ||
| 373 | + return; | ||
| 374 | + }; | ||
| 375 | + | ||
| 376 | + let suggestionPromise = getSuggestions(val.slice(0, 3), url); | ||
| 377 | + suggestionPromise.then((suggestions) => { | ||
| 378 | + if (val.length > 2) { | ||
| 379 | + suggestions = suggestions.filter((item) => { | ||
| 380 | + return item.toLowerCase().indexOf(val.toLowerCase()) !== -1; | ||
| 381 | + }).slice(0, 4); | ||
| 382 | + displaySuggestions($input, suggestions); | ||
| 383 | + } | ||
| 384 | + }); | ||
| 385 | + }); | ||
| 386 | + | ||
| 387 | + // Hide autosuggestions when input loses focus. | ||
| 388 | + // Slight delay to allow clicks. | ||
| 389 | + elem.on('blur', '[autosuggest]', function(event) { | ||
| 390 | + setTimeout(() => { | ||
| 391 | + $suggestionBox.hide(); | ||
| 392 | + isShowing = false; | ||
| 393 | + }, 200) | ||
| 394 | + }); | ||
| 395 | + | ||
| 396 | + elem.on('keydown', '[autosuggest]', function (event) { | ||
| 397 | + if (!isShowing) return; | ||
| 398 | + | ||
| 399 | + let suggestionElems = suggestionBox.childNodes; | ||
| 400 | + let suggestCount = suggestionElems.length; | ||
| 401 | + | ||
| 402 | + // Down arrow | ||
| 403 | + if (event.keyCode === 40) { | ||
| 404 | + let newActive = (active === suggestCount-1) ? 0 : active + 1; | ||
| 405 | + changeActiveTo(newActive, suggestionElems); | ||
| 406 | + } | ||
| 407 | + // Up arrow | ||
| 408 | + else if (event.keyCode === 38) { | ||
| 409 | + let newActive = (active === 0) ? suggestCount-1 : active - 1; | ||
| 410 | + changeActiveTo(newActive, suggestionElems); | ||
| 411 | + } | ||
| 412 | + // Enter key | ||
| 413 | + else if (event.keyCode === 13) { | ||
| 414 | + let text = suggestionElems[active].textContent; | ||
| 415 | + currentInput[0].value = text; | ||
| 416 | + currentInput.focus(); | ||
| 417 | + $suggestionBox.hide(); | ||
| 418 | + isShowing = false; | ||
| 419 | + event.preventDefault(); | ||
| 420 | + return false; | ||
| 421 | + } | ||
| 422 | + }); | ||
| 423 | + | ||
| 424 | + // Change the active suggestion to the given index | ||
| 425 | + function changeActiveTo(index, suggestionElems) { | ||
| 426 | + suggestionElems[active].className = ''; | ||
| 427 | + active = index; | ||
| 428 | + suggestionElems[active].className = 'active'; | ||
| 429 | + } | ||
| 430 | + | ||
| 431 | + // Display suggestions on a field | ||
| 432 | + let prevSuggestions = []; | ||
| 433 | + function displaySuggestions($input, suggestions) { | ||
| 434 | + | ||
| 435 | + // Hide if no suggestions | ||
| 436 | + if (suggestions.length === 0) { | ||
| 437 | + $suggestionBox.hide(); | ||
| 438 | + isShowing = false; | ||
| 439 | + prevSuggestions = suggestions; | ||
| 440 | + return; | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + // Otherwise show and attach to input | ||
| 444 | + if (!isShowing) { | ||
| 445 | + $suggestionBox.show(); | ||
| 446 | + isShowing = true; | ||
| 447 | + } | ||
| 448 | + if ($input !== currentInput) { | ||
| 449 | + $suggestionBox.detach(); | ||
| 450 | + $input.after($suggestionBox); | ||
| 451 | + currentInput = $input; | ||
| 452 | + } | ||
| 453 | + | ||
| 454 | + // Return if no change | ||
| 455 | + if (prevSuggestions.join() === suggestions.join()) { | ||
| 456 | + prevSuggestions = suggestions; | ||
| 457 | + return; | ||
| 458 | + } | ||
| 459 | + | ||
| 460 | + // Build suggestions | ||
| 461 | + $suggestionBox[0].innerHTML = ''; | ||
| 462 | + for (let i = 0; i < suggestions.length; i++) { | ||
| 463 | + var suggestion = document.createElement('li'); | ||
| 464 | + suggestion.textContent = suggestions[i]; | ||
| 465 | + suggestion.onclick = suggestionClick; | ||
| 466 | + if (i === 0) { | ||
| 467 | + suggestion.className = 'active' | ||
| 468 | + active = 0; | ||
| 469 | + }; | ||
| 470 | + $suggestionBox[0].appendChild(suggestion); | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + prevSuggestions = suggestions; | ||
| 474 | + } | ||
| 475 | + | ||
| 476 | + // Suggestion click event | ||
| 477 | + function suggestionClick(event) { | ||
| 478 | + let text = this.textContent; | ||
| 479 | + currentInput[0].value = text; | ||
| 480 | + currentInput.focus(); | ||
| 481 | + $suggestionBox.hide(); | ||
| 482 | + isShowing = false; | ||
| 483 | + }; | ||
| 484 | + | ||
| 485 | + // Get suggestions & cache | ||
| 486 | + function getSuggestions(input, url) { | ||
| 487 | + let searchUrl = url + '?search=' + encodeURIComponent(input); | ||
| 488 | + | ||
| 489 | + // Get from local cache if exists | ||
| 490 | + if (localCache[searchUrl]) { | ||
| 491 | + return new Promise((resolve, reject) => { | ||
| 492 | + resolve(localCache[input]); | ||
| 493 | + }); | ||
| 494 | + } | ||
| 495 | + | ||
| 496 | + return $http.get(searchUrl).then((response) => { | ||
| 497 | + localCache[input] = response.data; | ||
| 498 | + return response.data; | ||
| 499 | + }); | ||
| 500 | + } | ||
| 501 | + | ||
| 502 | + } | ||
| 503 | + } | ||
| 504 | + }]); | ||
| 505 | +}; | ||
| 506 | + | ||
| 507 | + | ||
| 508 | + | ||
| 509 | + | ||
| 510 | + | ||
| 511 | + | ||
| 512 | + | ||
| 513 | + | ||
| 514 | + | ||
| 515 | + | ||
| 516 | + | ||
| 517 | + | ||
| 518 | + | ||
| 305 | 519 | ||
| 306 | -}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -5,9 +5,9 @@ var angular = require('angular'); | ... | @@ -5,9 +5,9 @@ var angular = require('angular'); |
| 5 | var ngResource = require('angular-resource'); | 5 | var ngResource = require('angular-resource'); |
| 6 | var ngAnimate = require('angular-animate'); | 6 | var ngAnimate = require('angular-animate'); |
| 7 | var ngSanitize = require('angular-sanitize'); | 7 | var ngSanitize = require('angular-sanitize'); |
| 8 | +require('angular-ui-sortable'); | ||
| 8 | 9 | ||
| 9 | -var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize']); | 10 | +var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']); |
| 10 | - | ||
| 11 | 11 | ||
| 12 | // Global Event System | 12 | // Global Event System |
| 13 | var Events = { | 13 | var Events = { | ... | ... |
| ... | @@ -65,6 +65,9 @@ $button-border-radius: 2px; | ... | @@ -65,6 +65,9 @@ $button-border-radius: 2px; |
| 65 | &:focus, &:active { | 65 | &:focus, &:active { |
| 66 | outline: 0; | 66 | outline: 0; |
| 67 | } | 67 | } |
| 68 | + &:hover { | ||
| 69 | + text-decoration: none; | ||
| 70 | + } | ||
| 68 | &.neg { | 71 | &.neg { |
| 69 | color: $negative; | 72 | color: $negative; |
| 70 | } | 73 | } | ... | ... |
| ... | @@ -239,6 +239,17 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] { | ... | @@ -239,6 +239,17 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] { |
| 239 | } | 239 | } |
| 240 | } | 240 | } |
| 241 | 241 | ||
| 242 | +input.outline { | ||
| 243 | + border: 0; | ||
| 244 | + border-bottom: 2px solid #DDD; | ||
| 245 | + border-radius: 0; | ||
| 246 | + &:focus, &:active { | ||
| 247 | + border: 0; | ||
| 248 | + border-bottom: 2px solid #AAA; | ||
| 249 | + outline: 0; | ||
| 250 | + } | ||
| 251 | +} | ||
| 252 | + | ||
| 242 | #login-form label[for="remember"] { | 253 | #login-form label[for="remember"] { |
| 243 | margin: 0; | 254 | margin: 0; |
| 244 | } | 255 | } | ... | ... |
| ... | @@ -122,9 +122,176 @@ | ... | @@ -122,9 +122,176 @@ |
| 122 | } | 122 | } |
| 123 | } | 123 | } |
| 124 | 124 | ||
| 125 | -h1, h2, h3, h4, h5, h6 { | 125 | +// Attribute form |
| 126 | - &:hover a.link-hook { | 126 | +.floating-toolbox { |
| 127 | - opacity: 1; | 127 | + background-color: #FFF; |
| 128 | - transform: translate3d(0, 0, 0); | 128 | + border: 1px solid #DDD; |
| 129 | + right: $-xl*2; | ||
| 130 | + z-index: 99; | ||
| 131 | + width: 48px; | ||
| 132 | + overflow: hidden; | ||
| 133 | + align-items: stretch; | ||
| 134 | + flex-direction: row; | ||
| 135 | + display: flex; | ||
| 136 | + transition: width ease-in-out 180ms; | ||
| 137 | + margin-top: -1px; | ||
| 138 | + &.open { | ||
| 139 | + width: 480px; | ||
| 140 | + } | ||
| 141 | + [toolbox-toggle] i { | ||
| 142 | + transition: transform ease-in-out 180ms; | ||
| 143 | + } | ||
| 144 | + [toolbox-toggle] { | ||
| 145 | + transition: background-color ease-in-out 180ms; | ||
| 146 | + } | ||
| 147 | + &.open [toolbox-toggle] { | ||
| 148 | + background-color: rgba(255, 0, 0, 0.29); | ||
| 149 | + } | ||
| 150 | + &.open [toolbox-toggle] i { | ||
| 151 | + transform: rotate(180deg); | ||
| 152 | + } | ||
| 153 | + > div { | ||
| 154 | + flex: 1; | ||
| 155 | + position: relative; | ||
| 156 | + } | ||
| 157 | + .tabs { | ||
| 158 | + display: block; | ||
| 159 | + border-right: 1px solid #DDD; | ||
| 160 | + width: 54px; | ||
| 161 | + flex: 0; | ||
| 162 | + } | ||
| 163 | + .tabs i { | ||
| 164 | + color: rgba(0, 0, 0, 0.5); | ||
| 165 | + padding: 0; | ||
| 166 | + margin: 0; | ||
| 167 | + } | ||
| 168 | + .tabs > span { | ||
| 169 | + display: block; | ||
| 170 | + cursor: pointer; | ||
| 171 | + padding: $-s $-m; | ||
| 172 | + font-size: 13.5px; | ||
| 173 | + line-height: 1.6; | ||
| 174 | + border-bottom: 1px solid rgba(255, 255, 255, 0.3); | ||
| 129 | } | 175 | } |
| 176 | + &.open .tabs > span.active { | ||
| 177 | + color: #444; | ||
| 178 | + background-color: rgba(0, 0, 0, 0.1); | ||
| 179 | + } | ||
| 180 | + div[tab-content] { | ||
| 181 | + padding-bottom: 45px; | ||
| 182 | + display: flex; | ||
| 183 | + flex: 1; | ||
| 184 | + flex-direction: column; | ||
| 185 | + } | ||
| 186 | + div[tab-content] .padded { | ||
| 187 | + flex: 1; | ||
| 188 | + padding-top: 0; | ||
| 189 | + } | ||
| 190 | + h4 { | ||
| 191 | + font-size: 24px; | ||
| 192 | + margin: $-m 0 0 0; | ||
| 193 | + padding: 0 $-l $-s $-l; | ||
| 194 | + } | ||
| 195 | + .tags input { | ||
| 196 | + max-width: 100%; | ||
| 197 | + width: 100%; | ||
| 198 | + min-width: 50px; | ||
| 199 | + } | ||
| 200 | + .tags td { | ||
| 201 | + padding-right: $-s; | ||
| 202 | + padding-top: $-s; | ||
| 203 | + position: relative; | ||
| 204 | + } | ||
| 205 | + button.pos { | ||
| 206 | + position: absolute; | ||
| 207 | + bottom: 0; | ||
| 208 | + display: block; | ||
| 209 | + width: 100%; | ||
| 210 | + padding: $-s; | ||
| 211 | + height: 45px; | ||
| 212 | + border: 0; | ||
| 213 | + margin: 0; | ||
| 214 | + box-shadow: none; | ||
| 215 | + border-radius: 0; | ||
| 216 | + &:hover{ | ||
| 217 | + box-shadow: none; | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | + .handle { | ||
| 221 | + user-select: none; | ||
| 222 | + cursor: move; | ||
| 223 | + color: #999; | ||
| 224 | + } | ||
| 225 | + form { | ||
| 226 | + display: flex; | ||
| 227 | + flex: 1; | ||
| 228 | + flex-direction: column; | ||
| 229 | + overflow-y: scroll; | ||
| 230 | + } | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +[tab-content] { | ||
| 234 | + display: none; | ||
| 130 | } | 235 | } |
| 236 | + | ||
| 237 | +.tag-display { | ||
| 238 | + margin: $-xl $-xs; | ||
| 239 | + border: 1px solid #DDD; | ||
| 240 | + min-width: 180px; | ||
| 241 | + max-width: 320px; | ||
| 242 | + opacity: 0.7; | ||
| 243 | + table { | ||
| 244 | + width: 100%; | ||
| 245 | + margin: 0; | ||
| 246 | + padding: 0; | ||
| 247 | + } | ||
| 248 | + span { | ||
| 249 | + color: #666; | ||
| 250 | + margin-left: $-s; | ||
| 251 | + } | ||
| 252 | + .heading { | ||
| 253 | + padding: $-xs $-s; | ||
| 254 | + color: #444; | ||
| 255 | + } | ||
| 256 | + td { | ||
| 257 | + border: 0; | ||
| 258 | + border-bottom: 1px solid #DDD; | ||
| 259 | + padding: $-xs $-s; | ||
| 260 | + color: #444; | ||
| 261 | + } | ||
| 262 | + .tag-value { | ||
| 263 | + color: #888; | ||
| 264 | + } | ||
| 265 | + td i { | ||
| 266 | + color: #888; | ||
| 267 | + } | ||
| 268 | + tr:last-child td { | ||
| 269 | + border-bottom: none; | ||
| 270 | + } | ||
| 271 | + .tag { | ||
| 272 | + padding: $-s; | ||
| 273 | + } | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +.suggestion-box { | ||
| 277 | + position: absolute; | ||
| 278 | + background-color: #FFF; | ||
| 279 | + border: 1px solid #BBB; | ||
| 280 | + box-shadow: $bs-light; | ||
| 281 | + list-style: none; | ||
| 282 | + z-index: 100; | ||
| 283 | + padding: 0; | ||
| 284 | + margin: 0; | ||
| 285 | + border-radius: 3px; | ||
| 286 | + li { | ||
| 287 | + display: block; | ||
| 288 | + padding: $-xs $-s; | ||
| 289 | + border-bottom: 1px solid #DDD; | ||
| 290 | + &:last-child { | ||
| 291 | + border-bottom: 0; | ||
| 292 | + } | ||
| 293 | + &.active { | ||
| 294 | + background-color: #EEE; | ||
| 295 | + } | ||
| 296 | + } | ||
| 297 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -21,6 +21,11 @@ | ... | @@ -21,6 +21,11 @@ |
| 21 | 21 | ||
| 22 | [ng\:cloak], [ng-cloak], .ng-cloak { | 22 | [ng\:cloak], [ng-cloak], .ng-cloak { |
| 23 | display: none !important; | 23 | display: none !important; |
| 24 | + user-select: none; | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +[ng-click] { | ||
| 28 | + cursor: pointer; | ||
| 24 | } | 29 | } |
| 25 | 30 | ||
| 26 | // Jquery Sortable Styles | 31 | // Jquery Sortable Styles |
| ... | @@ -201,4 +206,4 @@ $btt-size: 40px; | ... | @@ -201,4 +206,4 @@ $btt-size: 40px; |
| 201 | background-color: $negative; | 206 | background-color: $negative; |
| 202 | color: #EEE; | 207 | color: #EEE; |
| 203 | } | 208 | } |
| 204 | -} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 209 | +} | ... | ... |
| ... | @@ -15,6 +15,7 @@ | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 | ||
| 16 | <!-- Scripts --> | 16 | <!-- Scripts --> |
| 17 | <script src="/libs/jquery/jquery.min.js?version=2.1.4"></script> | 17 | <script src="/libs/jquery/jquery.min.js?version=2.1.4"></script> |
| 18 | + <script src="/libs/jquery/jquery-ui.min.js?version=1.11.4"></script> | ||
| 18 | 19 | ||
| 19 | @yield('head') | 20 | @yield('head') |
| 20 | 21 | ... | ... |
| 1 | -@extends('base') | ||
| 2 | - | ||
| 3 | -@section('head') | ||
| 4 | - <script src="/libs/tinymce/tinymce.min.js?ver=4.3.7"></script> | ||
| 5 | -@stop | ||
| 6 | - | ||
| 7 | -@section('body-class', 'flexbox') | ||
| 8 | - | ||
| 9 | -@section('content') | ||
| 10 | - | ||
| 11 | - <div class="flex-fill flex"> | ||
| 12 | - <form action="{{$book->getUrl() . '/page/' . $draft->id}}" method="POST" class="flex flex-fill"> | ||
| 13 | - @include('pages/form', ['model' => $draft]) | ||
| 14 | - </form> | ||
| 15 | - </div> | ||
| 16 | - @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $draft->id]) | ||
| 17 | -@stop | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -9,10 +9,15 @@ | ... | @@ -9,10 +9,15 @@ |
| 9 | @section('content') | 9 | @section('content') |
| 10 | 10 | ||
| 11 | <div class="flex-fill flex"> | 11 | <div class="flex-fill flex"> |
| 12 | - <form action="{{$page->getUrl()}}" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill"> | 12 | + <form action="{{$page->getUrl()}}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill"> |
| 13 | - <input type="hidden" name="_method" value="PUT"> | 13 | + @if(!isset($isDraft)) |
| 14 | + <input type="hidden" name="_method" value="PUT"> | ||
| 15 | + @endif | ||
| 14 | @include('pages/form', ['model' => $page]) | 16 | @include('pages/form', ['model' => $page]) |
| 17 | + @include('pages/form-toolbox') | ||
| 15 | </form> | 18 | </form> |
| 19 | + | ||
| 20 | + | ||
| 16 | </div> | 21 | </div> |
| 17 | @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) | 22 | @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) |
| 18 | 23 | ... | ... |
resources/views/pages/form-toolbox.blade.php
0 → 100644
| 1 | + | ||
| 2 | +<div toolbox class="floating-toolbox"> | ||
| 3 | + | ||
| 4 | + <div class="tabs primary-background-light"> | ||
| 5 | + <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span> | ||
| 6 | + <span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span> | ||
| 7 | + </div> | ||
| 8 | + | ||
| 9 | + <div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}"> | ||
| 10 | + <h4>Page Tags</h4> | ||
| 11 | + <div class="padded tags"> | ||
| 12 | + <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> | ||
| 13 | + <table class="no-style" autosuggestions style="width: 100%;"> | ||
| 14 | + <tbody ui-sortable="sortOptions" ng-model="tags" > | ||
| 15 | + <tr ng-repeat="tag in tags track by $index"> | ||
| 16 | + <td width="20" ><i class="handle zmdi zmdi-menu"></i></td> | ||
| 17 | + <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> | ||
| 18 | + <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> | ||
| 19 | + <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> | ||
| 20 | + </tr> | ||
| 21 | + </tbody> | ||
| 22 | + </table> | ||
| 23 | + <table class="no-style" style="width: 100%;"> | ||
| 24 | + <tbody> | ||
| 25 | + <tr class="unsortable"> | ||
| 26 | + <td width="34"></td> | ||
| 27 | + <td ng-click="addEmptyTag()"> | ||
| 28 | + <button type="button" class="text-button">Add another tag</button> | ||
| 29 | + </td> | ||
| 30 | + <td></td> | ||
| 31 | + </tr> | ||
| 32 | + </tbody> | ||
| 33 | + </table> | ||
| 34 | + </div> | ||
| 35 | + </div> | ||
| 36 | + | ||
| 37 | +</div> | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -41,6 +41,7 @@ | ... | @@ -41,6 +41,7 @@ |
| 41 | @include('form/text', ['name' => 'name', 'placeholder' => 'Page Title']) | 41 | @include('form/text', ['name' => 'name', 'placeholder' => 'Page Title']) |
| 42 | </div> | 42 | </div> |
| 43 | </div> | 43 | </div> |
| 44 | + | ||
| 44 | <div class="edit-area flex-fill flex"> | 45 | <div class="edit-area flex-fill flex"> |
| 45 | @if(setting('app-editor') === 'wysiwyg') | 46 | @if(setting('app-editor') === 'wysiwyg') |
| 46 | <textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" name="html" rows="5" | 47 | <textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" name="html" rows="5" | ... | ... |
| 1 | <div ng-non-bindable> | 1 | <div ng-non-bindable> |
| 2 | - <h1 id="bkmrk-page-title">{{$page->name}}</h1> | 2 | + |
| 3 | + <h1 id="bkmrk-page-title" class="float left">{{$page->name}}</h1> | ||
| 4 | + | ||
| 5 | + @if(count($page->tags) > 0) | ||
| 6 | + <div class="tag-display float right"> | ||
| 7 | + <div class="heading primary-background-light">Page Tags</div> | ||
| 8 | + <table> | ||
| 9 | + @foreach($page->tags as $tag) | ||
| 10 | + <tr class="tag"> | ||
| 11 | + <td @if(!$tag->value) colspan="2" @endif><a href="/search/all?term=%5B{{ urlencode($tag->name) }}%5D">{{ $tag->name }}</a></td> | ||
| 12 | + @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 | ||
| 13 | + </tr> | ||
| 14 | + @endforeach | ||
| 15 | + </table> | ||
| 16 | + </div> | ||
| 17 | + @endif | ||
| 18 | + | ||
| 19 | + <div style="clear:left;"></div> | ||
| 3 | 20 | ||
| 4 | {!! $page->html !!} | 21 | {!! $page->html !!} |
| 5 | </div> | 22 | </div> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| 1 | @if(Setting::get('app-color')) | 1 | @if(Setting::get('app-color')) |
| 2 | <style> | 2 | <style> |
| 3 | - header, #back-to-top { | 3 | + header, #back-to-top, .primary-background { |
| 4 | background-color: {{ Setting::get('app-color') }}; | 4 | background-color: {{ Setting::get('app-color') }}; |
| 5 | } | 5 | } |
| 6 | - .faded-small { | 6 | + .faded-small, .primary-background-light { |
| 7 | background-color: {{ Setting::get('app-color-light') }}; | 7 | background-color: {{ Setting::get('app-color-light') }}; |
| 8 | } | 8 | } |
| 9 | .button-base, .button, input[type="button"], input[type="submit"] { | 9 | .button-base, .button, input[type="button"], input[type="submit"] { |
| ... | @@ -15,7 +15,7 @@ | ... | @@ -15,7 +15,7 @@ |
| 15 | .nav-tabs a.selected, .nav-tabs .tab-item.selected { | 15 | .nav-tabs a.selected, .nav-tabs .tab-item.selected { |
| 16 | border-bottom-color: {{ Setting::get('app-color') }}; | 16 | border-bottom-color: {{ Setting::get('app-color') }}; |
| 17 | } | 17 | } |
| 18 | - p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus { | 18 | + 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 { |
| 19 | color: {{ Setting::get('app-color') }}; | 19 | color: {{ Setting::get('app-color') }}; |
| 20 | } | 20 | } |
| 21 | </style> | 21 | </style> | ... | ... |
| ... | @@ -181,7 +181,7 @@ class AuthTest extends TestCase | ... | @@ -181,7 +181,7 @@ class AuthTest extends TestCase |
| 181 | public function test_user_deletion() | 181 | public function test_user_deletion() |
| 182 | { | 182 | { |
| 183 | $userDetails = factory(\BookStack\User::class)->make(); | 183 | $userDetails = factory(\BookStack\User::class)->make(); |
| 184 | - $user = $this->getNewUser($userDetails->toArray()); | 184 | + $user = $this->getEditor($userDetails->toArray()); |
| 185 | 185 | ||
| 186 | $this->asAdmin() | 186 | $this->asAdmin() |
| 187 | ->visit('/settings/users/' . $user->id) | 187 | ->visit('/settings/users/' . $user->id) | ... | ... |
| ... | @@ -161,8 +161,8 @@ class EntityTest extends TestCase | ... | @@ -161,8 +161,8 @@ class EntityTest extends TestCase |
| 161 | public function test_entities_viewable_after_creator_deletion() | 161 | public function test_entities_viewable_after_creator_deletion() |
| 162 | { | 162 | { |
| 163 | // Create required assets and revisions | 163 | // Create required assets and revisions |
| 164 | - $creator = $this->getNewUser(); | 164 | + $creator = $this->getEditor(); |
| 165 | - $updater = $this->getNewUser(); | 165 | + $updater = $this->getEditor(); |
| 166 | $entities = $this->createEntityChainBelongingToUser($creator, $updater); | 166 | $entities = $this->createEntityChainBelongingToUser($creator, $updater); |
| 167 | $this->actingAs($creator); | 167 | $this->actingAs($creator); |
| 168 | app('BookStack\Repos\UserRepo')->destroy($creator); | 168 | app('BookStack\Repos\UserRepo')->destroy($creator); |
| ... | @@ -174,8 +174,8 @@ class EntityTest extends TestCase | ... | @@ -174,8 +174,8 @@ class EntityTest extends TestCase |
| 174 | public function test_entities_viewable_after_updater_deletion() | 174 | public function test_entities_viewable_after_updater_deletion() |
| 175 | { | 175 | { |
| 176 | // Create required assets and revisions | 176 | // Create required assets and revisions |
| 177 | - $creator = $this->getNewUser(); | 177 | + $creator = $this->getEditor(); |
| 178 | - $updater = $this->getNewUser(); | 178 | + $updater = $this->getEditor(); |
| 179 | $entities = $this->createEntityChainBelongingToUser($creator, $updater); | 179 | $entities = $this->createEntityChainBelongingToUser($creator, $updater); |
| 180 | $this->actingAs($updater); | 180 | $this->actingAs($updater); |
| 181 | app('BookStack\Repos\UserRepo')->destroy($updater); | 181 | app('BookStack\Repos\UserRepo')->destroy($updater); |
| ... | @@ -198,7 +198,7 @@ class EntityTest extends TestCase | ... | @@ -198,7 +198,7 @@ class EntityTest extends TestCase |
| 198 | 198 | ||
| 199 | public function test_recently_created_pages_view() | 199 | public function test_recently_created_pages_view() |
| 200 | { | 200 | { |
| 201 | - $user = $this->getNewUser(); | 201 | + $user = $this->getEditor(); |
| 202 | $content = $this->createEntityChainBelongingToUser($user); | 202 | $content = $this->createEntityChainBelongingToUser($user); |
| 203 | 203 | ||
| 204 | $this->asAdmin()->visit('/pages/recently-created') | 204 | $this->asAdmin()->visit('/pages/recently-created') |
| ... | @@ -207,7 +207,7 @@ class EntityTest extends TestCase | ... | @@ -207,7 +207,7 @@ class EntityTest extends TestCase |
| 207 | 207 | ||
| 208 | public function test_recently_updated_pages_view() | 208 | public function test_recently_updated_pages_view() |
| 209 | { | 209 | { |
| 210 | - $user = $this->getNewUser(); | 210 | + $user = $this->getEditor(); |
| 211 | $content = $this->createEntityChainBelongingToUser($user); | 211 | $content = $this->createEntityChainBelongingToUser($user); |
| 212 | 212 | ||
| 213 | $this->asAdmin()->visit('/pages/recently-updated') | 213 | $this->asAdmin()->visit('/pages/recently-updated') |
| ... | @@ -241,7 +241,7 @@ class EntityTest extends TestCase | ... | @@ -241,7 +241,7 @@ class EntityTest extends TestCase |
| 241 | 241 | ||
| 242 | public function test_recently_created_pages_on_home() | 242 | public function test_recently_created_pages_on_home() |
| 243 | { | 243 | { |
| 244 | - $entityChain = $this->createEntityChainBelongingToUser($this->getNewUser()); | 244 | + $entityChain = $this->createEntityChainBelongingToUser($this->getEditor()); |
| 245 | $this->asAdmin()->visit('/') | 245 | $this->asAdmin()->visit('/') |
| 246 | ->seeInElement('#recently-created-pages', $entityChain['page']->name); | 246 | ->seeInElement('#recently-created-pages', $entityChain['page']->name); |
| 247 | } | 247 | } | ... | ... |
| ... | @@ -32,7 +32,7 @@ class PageDraftTest extends TestCase | ... | @@ -32,7 +32,7 @@ class PageDraftTest extends TestCase |
| 32 | ->dontSeeInField('html', $addedContent); | 32 | ->dontSeeInField('html', $addedContent); |
| 33 | 33 | ||
| 34 | $newContent = $this->page->html . $addedContent; | 34 | $newContent = $this->page->html . $addedContent; |
| 35 | - $newUser = $this->getNewUser(); | 35 | + $newUser = $this->getEditor(); |
| 36 | $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); | 36 | $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); |
| 37 | $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') | 37 | $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') |
| 38 | ->dontSeeInField('html', $newContent); | 38 | ->dontSeeInField('html', $newContent); |
| ... | @@ -54,7 +54,7 @@ class PageDraftTest extends TestCase | ... | @@ -54,7 +54,7 @@ class PageDraftTest extends TestCase |
| 54 | ->dontSeeInField('html', $addedContent); | 54 | ->dontSeeInField('html', $addedContent); |
| 55 | 55 | ||
| 56 | $newContent = $this->page->html . $addedContent; | 56 | $newContent = $this->page->html . $addedContent; |
| 57 | - $newUser = $this->getNewUser(); | 57 | + $newUser = $this->getEditor(); |
| 58 | $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); | 58 | $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); |
| 59 | 59 | ||
| 60 | $this->actingAs($newUser) | 60 | $this->actingAs($newUser) |
| ... | @@ -79,7 +79,7 @@ class PageDraftTest extends TestCase | ... | @@ -79,7 +79,7 @@ class PageDraftTest extends TestCase |
| 79 | { | 79 | { |
| 80 | $book = \BookStack\Book::first(); | 80 | $book = \BookStack\Book::first(); |
| 81 | $chapter = $book->chapters->first(); | 81 | $chapter = $book->chapters->first(); |
| 82 | - $newUser = $this->getNewUser(); | 82 | + $newUser = $this->getEditor(); |
| 83 | 83 | ||
| 84 | $this->actingAs($newUser)->visit('/') | 84 | $this->actingAs($newUser)->visit('/') |
| 85 | ->visit($book->getUrl() . '/page/create') | 85 | ->visit($book->getUrl() . '/page/create') | ... | ... |
tests/Entity/TagTests.php
0 → 100644
| 1 | +<?php namespace Entity; | ||
| 2 | + | ||
| 3 | +use BookStack\Tag; | ||
| 4 | +use BookStack\Page; | ||
| 5 | +use BookStack\Services\PermissionService; | ||
| 6 | + | ||
| 7 | +class TagTests extends \TestCase | ||
| 8 | +{ | ||
| 9 | + | ||
| 10 | + protected $defaultTagCount = 20; | ||
| 11 | + | ||
| 12 | + /** | ||
| 13 | + * Get an instance of a page that has many tags. | ||
| 14 | + * @param Tag[]|bool $tags | ||
| 15 | + * @return mixed | ||
| 16 | + */ | ||
| 17 | + protected function getPageWithTags($tags = false) | ||
| 18 | + { | ||
| 19 | + $page = Page::first(); | ||
| 20 | + | ||
| 21 | + if (!$tags) { | ||
| 22 | + $tags = factory(Tag::class, $this->defaultTagCount)->make(); | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + $page->tags()->saveMany($tags); | ||
| 26 | + return $page; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + public function test_get_page_tags() | ||
| 30 | + { | ||
| 31 | + $page = $this->getPageWithTags(); | ||
| 32 | + | ||
| 33 | + // Add some other tags to check they don't interfere | ||
| 34 | + factory(Tag::class, $this->defaultTagCount)->create(); | ||
| 35 | + | ||
| 36 | + $this->asAdmin()->get("/ajax/tags/get/page/" . $page->id) | ||
| 37 | + ->shouldReturnJson(); | ||
| 38 | + | ||
| 39 | + $json = json_decode($this->response->getContent()); | ||
| 40 | + $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected"); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + public function test_tag_name_suggestions() | ||
| 44 | + { | ||
| 45 | + // Create some tags with similar names to test with | ||
| 46 | + $attrs = collect(); | ||
| 47 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country'])); | ||
| 48 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color'])); | ||
| 49 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city'])); | ||
| 50 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county'])); | ||
| 51 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet'])); | ||
| 52 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans'])); | ||
| 53 | + $page = $this->getPageWithTags($attrs); | ||
| 54 | + | ||
| 55 | + $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]); | ||
| 56 | + $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']); | ||
| 57 | + $this->get('/ajax/tags/suggest/names?search=cou')->seeJsonEquals(['country', 'county']); | ||
| 58 | + $this->get('/ajax/tags/suggest/names?search=pla')->seeJsonEquals(['planet', 'plans']); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + public function test_tag_value_suggestions() | ||
| 62 | + { | ||
| 63 | + // Create some tags with similar values to test with | ||
| 64 | + $attrs = collect(); | ||
| 65 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country', 'value' => 'cats'])); | ||
| 66 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color', 'value' => 'cattery'])); | ||
| 67 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city', 'value' => 'castle'])); | ||
| 68 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog'])); | ||
| 69 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult'])); | ||
| 70 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy'])); | ||
| 71 | + $page = $this->getPageWithTags($attrs); | ||
| 72 | + | ||
| 73 | + $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]); | ||
| 74 | + $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']); | ||
| 75 | + $this->get('/ajax/tags/suggest/values?search=do')->seeJsonEquals(['dog', 'dodgy']); | ||
| 76 | + $this->get('/ajax/tags/suggest/values?search=cas')->seeJsonEquals(['castle']); | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + public function test_entity_permissions_effect_tag_suggestions() | ||
| 80 | + { | ||
| 81 | + $permissionService = $this->app->make(PermissionService::class); | ||
| 82 | + | ||
| 83 | + // Create some tags with similar names to test with and save to a page | ||
| 84 | + $attrs = collect(); | ||
| 85 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country'])); | ||
| 86 | + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color'])); | ||
| 87 | + $page = $this->getPageWithTags($attrs); | ||
| 88 | + | ||
| 89 | + $this->asAdmin()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']); | ||
| 90 | + $this->asEditor()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']); | ||
| 91 | + | ||
| 92 | + // Set restricted permission the page | ||
| 93 | + $page->restricted = true; | ||
| 94 | + $page->save(); | ||
| 95 | + $permissionService->buildJointPermissionsForEntity($page); | ||
| 96 | + | ||
| 97 | + $this->asAdmin()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']); | ||
| 98 | + $this->asEditor()->get('/ajax/tags/suggest?search=co')->seeJsonEquals([]); | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + public function test_entity_tag_updating() | ||
| 102 | + { | ||
| 103 | + $page = $this->getPageWithTags(); | ||
| 104 | + | ||
| 105 | + $testJsonData = [ | ||
| 106 | + ['name' => 'color', 'value' => 'red'], | ||
| 107 | + ['name' => 'color', 'value' => ' blue '], | ||
| 108 | + ['name' => 'city', 'value' => 'London '], | ||
| 109 | + ['name' => 'country', 'value' => ' England'], | ||
| 110 | + ]; | ||
| 111 | + $testResponseJsonData = [ | ||
| 112 | + ['name' => 'color', 'value' => 'red'], | ||
| 113 | + ['name' => 'color', 'value' => 'blue'], | ||
| 114 | + ['name' => 'city', 'value' => 'London'], | ||
| 115 | + ['name' => 'country', 'value' => 'England'], | ||
| 116 | + ]; | ||
| 117 | + | ||
| 118 | + // Do update request | ||
| 119 | + $this->asAdmin()->json("POST", "/ajax/tags/update/page/" . $page->id, ['tags' => $testJsonData]); | ||
| 120 | + $updateData = json_decode($this->response->getContent()); | ||
| 121 | + // Check data is correct | ||
| 122 | + $testDataCorrect = true; | ||
| 123 | + foreach ($updateData->tags as $data) { | ||
| 124 | + $testItem = ['name' => $data->name, 'value' => $data->value]; | ||
| 125 | + if (!in_array($testItem, $testResponseJsonData)) $testDataCorrect = false; | ||
| 126 | + } | ||
| 127 | + $testMessage = "Expected data was not found in the response.\nExpected Data: %s\nRecieved Data: %s"; | ||
| 128 | + $this->assertTrue($testDataCorrect, sprintf($testMessage, json_encode($testResponseJsonData), json_encode($updateData))); | ||
| 129 | + $this->assertTrue(isset($updateData->message), "No message returned in tag update response"); | ||
| 130 | + | ||
| 131 | + // Do get request | ||
| 132 | + $this->asAdmin()->get("/ajax/tags/get/page/" . $page->id); | ||
| 133 | + $getResponseData = json_decode($this->response->getContent()); | ||
| 134 | + // Check counts | ||
| 135 | + $this->assertTrue(count($getResponseData) === count($testJsonData), "The received tag count is incorrect"); | ||
| 136 | + // Check data is correct | ||
| 137 | + $testDataCorrect = true; | ||
| 138 | + foreach ($getResponseData as $data) { | ||
| 139 | + $testItem = ['name' => $data->name, 'value' => $data->value]; | ||
| 140 | + if (!in_array($testItem, $testResponseJsonData)) $testDataCorrect = false; | ||
| 141 | + } | ||
| 142 | + $testMessage = "Expected data was not found in the response.\nExpected Data: %s\nRecieved Data: %s"; | ||
| 143 | + $this->assertTrue($testDataCorrect, sprintf($testMessage, json_encode($testResponseJsonData), json_encode($getResponseData))); | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | +} |
| ... | @@ -9,7 +9,7 @@ class RestrictionsTest extends TestCase | ... | @@ -9,7 +9,7 @@ class RestrictionsTest extends TestCase |
| 9 | public function setUp() | 9 | public function setUp() |
| 10 | { | 10 | { |
| 11 | parent::setUp(); | 11 | parent::setUp(); |
| 12 | - $this->user = $this->getNewUser(); | 12 | + $this->user = $this->getEditor(); |
| 13 | $this->viewer = $this->getViewer(); | 13 | $this->viewer = $this->getViewer(); |
| 14 | $this->restrictionService = $this->app[\BookStack\Services\PermissionService::class]; | 14 | $this->restrictionService = $this->app[\BookStack\Services\PermissionService::class]; |
| 15 | } | 15 | } | ... | ... |
| ... | @@ -14,7 +14,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase | ... | @@ -14,7 +14,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase |
| 14 | * @var string | 14 | * @var string |
| 15 | */ | 15 | */ |
| 16 | protected $baseUrl = 'http://localhost'; | 16 | protected $baseUrl = 'http://localhost'; |
| 17 | + | ||
| 18 | + // Local user instances | ||
| 17 | private $admin; | 19 | private $admin; |
| 20 | + private $editor; | ||
| 18 | 21 | ||
| 19 | /** | 22 | /** |
| 20 | * Creates the application. | 23 | * Creates the application. |
| ... | @@ -30,6 +33,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase | ... | @@ -30,6 +33,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase |
| 30 | return $app; | 33 | return $app; |
| 31 | } | 34 | } |
| 32 | 35 | ||
| 36 | + /** | ||
| 37 | + * Set the current user context to be an admin. | ||
| 38 | + * @return $this | ||
| 39 | + */ | ||
| 33 | public function asAdmin() | 40 | public function asAdmin() |
| 34 | { | 41 | { |
| 35 | if($this->admin === null) { | 42 | if($this->admin === null) { |
| ... | @@ -40,6 +47,18 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase | ... | @@ -40,6 +47,18 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase |
| 40 | } | 47 | } |
| 41 | 48 | ||
| 42 | /** | 49 | /** |
| 50 | + * Set the current editor context to be an editor. | ||
| 51 | + * @return $this | ||
| 52 | + */ | ||
| 53 | + public function asEditor() | ||
| 54 | + { | ||
| 55 | + if($this->editor === null) { | ||
| 56 | + $this->editor = $this->getEditor(); | ||
| 57 | + } | ||
| 58 | + return $this->actingAs($this->editor); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /** | ||
| 43 | * Quickly sets an array of settings. | 62 | * Quickly sets an array of settings. |
| 44 | * @param $settingsArray | 63 | * @param $settingsArray |
| 45 | */ | 64 | */ |
| ... | @@ -79,7 +98,7 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase | ... | @@ -79,7 +98,7 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase |
| 79 | * @param array $attributes | 98 | * @param array $attributes |
| 80 | * @return mixed | 99 | * @return mixed |
| 81 | */ | 100 | */ |
| 82 | - protected function getNewUser($attributes = []) | 101 | + protected function getEditor($attributes = []) |
| 83 | { | 102 | { |
| 84 | $user = factory(\BookStack\User::class)->create($attributes); | 103 | $user = factory(\BookStack\User::class)->create($attributes); |
| 85 | $role = \BookStack\Role::getRole('editor'); | 104 | $role = \BookStack\Role::getRole('editor'); | ... | ... |
| ... | @@ -33,7 +33,7 @@ class UserProfileTest extends TestCase | ... | @@ -33,7 +33,7 @@ class UserProfileTest extends TestCase |
| 33 | 33 | ||
| 34 | public function test_profile_page_shows_created_content_counts() | 34 | public function test_profile_page_shows_created_content_counts() |
| 35 | { | 35 | { |
| 36 | - $newUser = $this->getNewUser(); | 36 | + $newUser = $this->getEditor(); |
| 37 | 37 | ||
| 38 | $this->asAdmin()->visit('/user/' . $newUser->id) | 38 | $this->asAdmin()->visit('/user/' . $newUser->id) |
| 39 | ->see($newUser->name) | 39 | ->see($newUser->name) |
| ... | @@ -52,7 +52,7 @@ class UserProfileTest extends TestCase | ... | @@ -52,7 +52,7 @@ class UserProfileTest extends TestCase |
| 52 | 52 | ||
| 53 | public function test_profile_page_shows_recent_activity() | 53 | public function test_profile_page_shows_recent_activity() |
| 54 | { | 54 | { |
| 55 | - $newUser = $this->getNewUser(); | 55 | + $newUser = $this->getEditor(); |
| 56 | $this->actingAs($newUser); | 56 | $this->actingAs($newUser); |
| 57 | $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); | 57 | $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); |
| 58 | Activity::add($entities['book'], 'book_update', $entities['book']->id); | 58 | Activity::add($entities['book'], 'book_update', $entities['book']->id); |
| ... | @@ -66,7 +66,7 @@ class UserProfileTest extends TestCase | ... | @@ -66,7 +66,7 @@ class UserProfileTest extends TestCase |
| 66 | 66 | ||
| 67 | public function test_clicking_user_name_in_activity_leads_to_profile_page() | 67 | public function test_clicking_user_name_in_activity_leads_to_profile_page() |
| 68 | { | 68 | { |
| 69 | - $newUser = $this->getNewUser(); | 69 | + $newUser = $this->getEditor(); |
| 70 | $this->actingAs($newUser); | 70 | $this->actingAs($newUser); |
| 71 | $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); | 71 | $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); |
| 72 | Activity::add($entities['book'], 'book_update', $entities['book']->id); | 72 | Activity::add($entities['book'], 'book_update', $entities['book']->id); | ... | ... |
-
Please register or sign in to post a comment