Dan Brown

Merge pull request #110 from ssddanbrown/page_attributes

Attribute System. Closes #48.
1 language: php 1 language: php
2 -
3 php: 2 php:
4 - 7.0 3 - 7.0
5 4
6 cache: 5 cache:
7 directories: 6 directories:
8 - - node_modules
9 - vendor 7 - vendor
10 8
11 addons: 9 addons:
......
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 /**
......
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);
......
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);
......
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",
......
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>&#160;</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
......
...@@ -26,6 +26,13 @@ table { ...@@ -26,6 +26,13 @@ table {
26 } 26 }
27 } 27 }
28 28
29 +table.no-style {
30 + td {
31 + border: 0;
32 + padding: 0;
33 + }
34 +}
35 +
29 table.list-table { 36 table.list-table {
30 margin: 0 -$-xs; 37 margin: 0 -$-xs;
31 td { 38 td {
......
...@@ -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
......
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')
......
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);
......