]> BookStack Code Mirror - bookstack/blob - app/Repos/EntityRepo.php
Actually fixed the BaseURL this time 🤦
[bookstack] / app / Repos / EntityRepo.php
1 <?php namespace BookStack\Repos;
2
3 use BookStack\Book;
4 use BookStack\Chapter;
5 use BookStack\Entity;
6 use BookStack\Exceptions\NotFoundException;
7 use BookStack\Exceptions\NotifyException;
8 use BookStack\Page;
9 use BookStack\PageRevision;
10 use BookStack\Services\AttachmentService;
11 use BookStack\Services\PermissionService;
12 use BookStack\Services\SearchService;
13 use BookStack\Services\ViewService;
14 use Carbon\Carbon;
15 use DOMDocument;
16 use DOMXPath;
17 use Illuminate\Support\Collection;
18
19 class EntityRepo
20 {
21
22     /**
23      * @var Book $book
24      */
25     public $book;
26
27     /**
28      * @var Chapter
29      */
30     public $chapter;
31
32     /**
33      * @var Page
34      */
35     public $page;
36
37     /**
38      * @var PageRevision
39      */
40     protected $pageRevision;
41
42     /**
43      * Base entity instances keyed by type
44      * @var []Entity
45      */
46     protected $entities;
47
48     /**
49      * @var PermissionService
50      */
51     protected $permissionService;
52
53     /**
54      * @var ViewService
55      */
56     protected $viewService;
57
58     /**
59      * @var TagRepo
60      */
61     protected $tagRepo;
62
63     /**
64      * @var SearchService
65      */
66     protected $searchService;
67
68     /**
69      * EntityRepo constructor.
70      * @param Book $book
71      * @param Chapter $chapter
72      * @param Page $page
73      * @param PageRevision $pageRevision
74      * @param ViewService $viewService
75      * @param PermissionService $permissionService
76      * @param TagRepo $tagRepo
77      * @param SearchService $searchService
78      */
79     public function __construct(
80         Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision,
81         ViewService $viewService, PermissionService $permissionService,
82         TagRepo $tagRepo, SearchService $searchService
83     )
84     {
85         $this->book = $book;
86         $this->chapter = $chapter;
87         $this->page = $page;
88         $this->pageRevision = $pageRevision;
89         $this->entities = [
90             'page' => $this->page,
91             'chapter' => $this->chapter,
92             'book' => $this->book
93         ];
94         $this->viewService = $viewService;
95         $this->permissionService = $permissionService;
96         $this->tagRepo = $tagRepo;
97         $this->searchService = $searchService;
98     }
99
100     /**
101      * Get an entity instance via type.
102      * @param $type
103      * @return Entity
104      */
105     protected function getEntity($type)
106     {
107         return $this->entities[strtolower($type)];
108     }
109
110     /**
111      * Base query for searching entities via permission system
112      * @param string $type
113      * @param bool $allowDrafts
114      * @return \Illuminate\Database\Query\Builder
115      */
116     protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
117     {
118         $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type), $permission);
119         if (strtolower($type) === 'page' && !$allowDrafts) {
120             $q = $q->where('draft', '=', false);
121         }
122         return $q;
123     }
124
125     /**
126      * Check if an entity with the given id exists.
127      * @param $type
128      * @param $id
129      * @return bool
130      */
131     public function exists($type, $id)
132     {
133         return $this->entityQuery($type)->where('id', '=', $id)->exists();
134     }
135
136     /**
137      * Get an entity by ID
138      * @param string $type
139      * @param integer $id
140      * @param bool $allowDrafts
141      * @param bool $ignorePermissions
142      * @return Entity
143      */
144     public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
145     {
146         if ($ignorePermissions) {
147             $entity = $this->getEntity($type);
148             return $entity->newQuery()->find($id);
149         }
150         return $this->entityQuery($type, $allowDrafts)->find($id);
151     }
152
153     /**
154      * Get an entity by its url slug.
155      * @param string $type
156      * @param string $slug
157      * @param string|bool $bookSlug
158      * @return Entity
159      * @throws NotFoundException
160      */
161     public function getBySlug($type, $slug, $bookSlug = false)
162     {
163         $q = $this->entityQuery($type)->where('slug', '=', $slug);
164
165         if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
166             $q = $q->where('book_id', '=', function($query) use ($bookSlug) {
167                 $query->select('id')
168                     ->from($this->book->getTable())
169                     ->where('slug', '=', $bookSlug)->limit(1);
170             });
171         }
172         $entity = $q->first();
173         if ($entity === null) throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
174         return $entity;
175     }
176
177
178     /**
179      * Search through page revisions and retrieve the last page in the
180      * current book that has a slug equal to the one given.
181      * @param string $pageSlug
182      * @param string $bookSlug
183      * @return null|Page
184      */
185     public function getPageByOldSlug($pageSlug, $bookSlug)
186     {
187         $revision = $this->pageRevision->where('slug', '=', $pageSlug)
188             ->whereHas('page', function ($query) {
189                 $this->permissionService->enforceEntityRestrictions('page', $query);
190             })
191             ->where('type', '=', 'version')
192             ->where('book_slug', '=', $bookSlug)
193             ->orderBy('created_at', 'desc')
194             ->with('page')->first();
195         return $revision !== null ? $revision->page : null;
196     }
197
198     /**
199      * Get all entities of a type with the given permission, limited by count unless count is false.
200      * @param string $type
201      * @param integer|bool $count
202      * @param string $permission
203      * @return Collection
204      */
205     public function getAll($type, $count = 20, $permission = 'view')
206     {
207         $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
208         if ($count !== false) $q = $q->take($count);
209         return $q->get();
210     }
211
212     /**
213      * Get all entities in a paginated format
214      * @param $type
215      * @param int $count
216      * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
217      */
218     public function getAllPaginated($type, $count = 10)
219     {
220         return $this->entityQuery($type)->orderBy('name', 'asc')->paginate($count);
221     }
222
223     /**
224      * Get the most recently created entities of the given type.
225      * @param string $type
226      * @param int $count
227      * @param int $page
228      * @param bool|callable $additionalQuery
229      * @return Collection
230      */
231     public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
232     {
233         $query = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type))
234             ->orderBy('created_at', 'desc');
235         if (strtolower($type) === 'page') $query = $query->where('draft', '=', false);
236         if ($additionalQuery !== false && is_callable($additionalQuery)) {
237             $additionalQuery($query);
238         }
239         return $query->skip($page * $count)->take($count)->get();
240     }
241
242     /**
243      * Get the most recently updated entities of the given type.
244      * @param string $type
245      * @param int $count
246      * @param int $page
247      * @param bool|callable $additionalQuery
248      * @return Collection
249      */
250     public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
251     {
252         $query = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type))
253             ->orderBy('updated_at', 'desc');
254         if (strtolower($type) === 'page') $query = $query->where('draft', '=', false);
255         if ($additionalQuery !== false && is_callable($additionalQuery)) {
256             $additionalQuery($query);
257         }
258         return $query->skip($page * $count)->take($count)->get();
259     }
260
261     /**
262      * Get the most recently viewed entities.
263      * @param string|bool $type
264      * @param int $count
265      * @param int $page
266      * @return mixed
267      */
268     public function getRecentlyViewed($type, $count = 10, $page = 0)
269     {
270         $filter = is_bool($type) ? false : $this->getEntity($type);
271         return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
272     }
273
274     /**
275      * Get the latest pages added to the system with pagination.
276      * @param string $type
277      * @param int $count
278      * @return mixed
279      */
280     public function getRecentlyCreatedPaginated($type, $count = 20)
281     {
282         return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
283     }
284
285     /**
286      * Get the latest pages added to the system with pagination.
287      * @param string $type
288      * @param int $count
289      * @return mixed
290      */
291     public function getRecentlyUpdatedPaginated($type, $count = 20)
292     {
293         return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
294     }
295
296     /**
297      * Get the most popular entities base on all views.
298      * @param string|bool $type
299      * @param int $count
300      * @param int $page
301      * @return mixed
302      */
303     public function getPopular($type, $count = 10, $page = 0)
304     {
305         $filter = is_bool($type) ? false : $this->getEntity($type);
306         return $this->viewService->getPopular($count, $page, $filter);
307     }
308
309     /**
310      * Get draft pages owned by the current user.
311      * @param int $count
312      * @param int $page
313      */
314     public function getUserDraftPages($count = 20, $page = 0)
315     {
316         return $this->page->where('draft', '=', true)
317             ->where('created_by', '=', user()->id)
318             ->orderBy('updated_at', 'desc')
319             ->skip($count * $page)->take($count)->get();
320     }
321
322     /**
323      * Get all child objects of a book.
324      * Returns a sorted collection of Pages and Chapters.
325      * Loads the book slug onto child elements to prevent access database access for getting the slug.
326      * @param Book $book
327      * @param bool $filterDrafts
328      * @param bool $renderPages
329      * @return mixed
330      */
331     public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
332     {
333         $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
334         $entities = [];
335         $parents = [];
336         $tree = [];
337
338         foreach ($q as $index => $rawEntity) {
339             if ($rawEntity->entity_type === 'BookStack\\Page') {
340                 $entities[$index] = $this->page->newFromBuilder($rawEntity);
341                 if ($renderPages) {
342                     $entities[$index]->html = $rawEntity->html;
343                     $entities[$index]->html = $this->renderPage($entities[$index]);
344                 };
345             } else if ($rawEntity->entity_type === 'BookStack\\Chapter') {
346                 $entities[$index] = $this->chapter->newFromBuilder($rawEntity);
347                 $key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
348                 $parents[$key] = $entities[$index];
349                 $parents[$key]->setAttribute('pages', collect());
350             }
351             if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') $tree[] = $entities[$index];
352             $entities[$index]->book = $book;
353         }
354
355         foreach ($entities as $entity) {
356             if ($entity->chapter_id === 0 || $entity->chapter_id === '0') continue;
357             $parentKey = 'BookStack\\Chapter:' . $entity->chapter_id;
358             if (!isset($parents[$parentKey])) {
359                 $tree[] = $entity;
360                 continue;
361             }
362             $chapter = $parents[$parentKey];
363             $chapter->pages->push($entity);
364         }
365
366         return collect($tree);
367     }
368
369     /**
370      * Get the child items for a chapter sorted by priority but
371      * with draft items floated to the top.
372      * @param Chapter $chapter
373      * @return \Illuminate\Database\Eloquent\Collection|static[]
374      */
375     public function getChapterChildren(Chapter $chapter)
376     {
377         return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
378             ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
379     }
380
381
382     /**
383      * Get the next sequential priority for a new child element in the given book.
384      * @param Book $book
385      * @return int
386      */
387     public function getNewBookPriority(Book $book)
388     {
389         $lastElem = $this->getBookChildren($book)->pop();
390         return $lastElem ? $lastElem->priority + 1 : 0;
391     }
392
393     /**
394      * Get a new priority for a new page to be added to the given chapter.
395      * @param Chapter $chapter
396      * @return int
397      */
398     public function getNewChapterPriority(Chapter $chapter)
399     {
400         $lastPage = $chapter->pages('DESC')->first();
401         return $lastPage !== null ? $lastPage->priority + 1 : 0;
402     }
403
404     /**
405      * Find a suitable slug for an entity.
406      * @param string $type
407      * @param string $name
408      * @param bool|integer $currentId
409      * @param bool|integer $bookId Only pass if type is not a book
410      * @return string
411      */
412     public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
413     {
414         $slug = $this->nameToSlug($name);
415         while ($this->slugExists($type, $slug, $currentId, $bookId)) {
416             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
417         }
418         return $slug;
419     }
420
421     /**
422      * Check if a slug already exists in the database.
423      * @param string $type
424      * @param string $slug
425      * @param bool|integer $currentId
426      * @param bool|integer $bookId
427      * @return bool
428      */
429     protected function slugExists($type, $slug, $currentId = false, $bookId = false)
430     {
431         $query = $this->getEntity($type)->where('slug', '=', $slug);
432         if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
433             $query = $query->where('book_id', '=', $bookId);
434         }
435         if ($currentId) $query = $query->where('id', '!=', $currentId);
436         return $query->count() > 0;
437     }
438
439     /**
440      * Updates entity restrictions from a request
441      * @param $request
442      * @param Entity $entity
443      */
444     public function updateEntityPermissionsFromRequest($request, Entity $entity)
445     {
446         $entity->restricted = $request->get('restricted', '') === 'true';
447         $entity->permissions()->delete();
448
449         if ($request->filled('restrictions')) {
450             foreach ($request->get('restrictions') as $roleId => $restrictions) {
451                 foreach ($restrictions as $action => $value) {
452                     $entity->permissions()->create([
453                         'role_id' => $roleId,
454                         'action'  => strtolower($action)
455                     ]);
456                 }
457             }
458         }
459
460         $entity->save();
461         $this->permissionService->buildJointPermissionsForEntity($entity);
462     }
463
464
465
466     /**
467      * Create a new entity from request input.
468      * Used for books and chapters.
469      * @param string $type
470      * @param array $input
471      * @param bool|Book $book
472      * @return Entity
473      */
474     public function createFromInput($type, $input = [], $book = false)
475     {
476         $isChapter = strtolower($type) === 'chapter';
477         $entity = $this->getEntity($type)->newInstance($input);
478         $entity->slug = $this->findSuitableSlug($type, $entity->name, false, $isChapter ? $book->id : false);
479         $entity->created_by = user()->id;
480         $entity->updated_by = user()->id;
481         $isChapter ? $book->chapters()->save($entity) : $entity->save();
482         $this->permissionService->buildJointPermissionsForEntity($entity);
483         $this->searchService->indexEntity($entity);
484         return $entity;
485     }
486
487     /**
488      * Update entity details from request input.
489      * Used for books and chapters
490      * @param string $type
491      * @param Entity $entityModel
492      * @param array $input
493      * @return Entity
494      */
495     public function updateFromInput($type, Entity $entityModel, $input = [])
496     {
497         if ($entityModel->name !== $input['name']) {
498             $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
499         }
500         $entityModel->fill($input);
501         $entityModel->updated_by = user()->id;
502         $entityModel->save();
503         $this->permissionService->buildJointPermissionsForEntity($entityModel);
504         $this->searchService->indexEntity($entityModel);
505         return $entityModel;
506     }
507
508     /**
509      * Change the book that an entity belongs to.
510      * @param string $type
511      * @param integer $newBookId
512      * @param Entity $entity
513      * @param bool $rebuildPermissions
514      * @return Entity
515      */
516     public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
517     {
518         $entity->book_id = $newBookId;
519         // Update related activity
520         foreach ($entity->activity as $activity) {
521             $activity->book_id = $newBookId;
522             $activity->save();
523         }
524         $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
525         $entity->save();
526
527         // Update all child pages if a chapter
528         if (strtolower($type) === 'chapter') {
529             foreach ($entity->pages as $page) {
530                 $this->changeBook('page', $newBookId, $page, false);
531             }
532         }
533
534         // Update permissions if applicable
535         if ($rebuildPermissions) {
536             $entity->load('book');
537             $this->permissionService->buildJointPermissionsForEntity($entity->book);
538         }
539
540         return $entity;
541     }
542
543     /**
544      * Alias method to update the book jointPermissions in the PermissionService.
545      * @param Book $book
546      */
547     public function buildJointPermissionsForBook(Book $book)
548     {
549         $this->permissionService->buildJointPermissionsForEntity($book);
550     }
551
552     /**
553      * Format a name as a url slug.
554      * @param $name
555      * @return string
556      */
557     protected function nameToSlug($name)
558     {
559         $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
560         $slug = preg_replace('/\s{2,}/', ' ', $slug);
561         $slug = str_replace(' ', '-', $slug);
562         if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
563         return $slug;
564     }
565
566     /**
567      * Publish a draft page to make it a normal page.
568      * Sets the slug and updates the content.
569      * @param Page $draftPage
570      * @param array $input
571      * @return Page
572      */
573     public function publishPageDraft(Page $draftPage, array $input)
574     {
575         $draftPage->fill($input);
576
577         // Save page tags if present
578         if (isset($input['tags'])) {
579             $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
580         }
581
582         $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
583         $draftPage->html = $this->formatHtml($input['html']);
584         $draftPage->text = $this->pageToPlainText($draftPage);
585         $draftPage->draft = false;
586         $draftPage->revision_count = 1;
587
588         $draftPage->save();
589         $this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
590         $this->searchService->indexEntity($draftPage);
591         return $draftPage;
592     }
593
594     /**
595      * Saves a page revision into the system.
596      * @param Page $page
597      * @param null|string $summary
598      * @return PageRevision
599      */
600     public function savePageRevision(Page $page, $summary = null)
601     {
602         $revision = $this->pageRevision->newInstance($page->toArray());
603         if (setting('app-editor') !== 'markdown') $revision->markdown = '';
604         $revision->page_id = $page->id;
605         $revision->slug = $page->slug;
606         $revision->book_slug = $page->book->slug;
607         $revision->created_by = user()->id;
608         $revision->created_at = $page->updated_at;
609         $revision->type = 'version';
610         $revision->summary = $summary;
611         $revision->revision_number = $page->revision_count;
612         $revision->save();
613
614         // Clear old revisions
615         if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
616             $this->pageRevision->where('page_id', '=', $page->id)
617                 ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
618         }
619
620         return $revision;
621     }
622
623     /**
624      * Formats a page's html to be tagged correctly
625      * within the system.
626      * @param string $htmlText
627      * @return string
628      */
629     protected function formatHtml($htmlText)
630     {
631         if ($htmlText == '') return $htmlText;
632         libxml_use_internal_errors(true);
633         $doc = new DOMDocument();
634         $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
635
636         $container = $doc->documentElement;
637         $body = $container->childNodes->item(0);
638         $childNodes = $body->childNodes;
639
640         // Ensure no duplicate ids are used
641         $idArray = [];
642
643         foreach ($childNodes as $index => $childNode) {
644             /** @var \DOMElement $childNode */
645             if (get_class($childNode) !== 'DOMElement') continue;
646
647             // Overwrite id if not a BookStack custom id
648             if ($childNode->hasAttribute('id')) {
649                 $id = $childNode->getAttribute('id');
650                 if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) {
651                     $idArray[] = $id;
652                     continue;
653                 };
654             }
655
656             // Create an unique id for the element
657             // Uses the content as a basis to ensure output is the same every time
658             // the same content is passed through.
659             $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($childNode->nodeValue))), 0, 20);
660             $newId = urlencode($contentId);
661             $loopIndex = 0;
662             while (in_array($newId, $idArray)) {
663                 $newId = urlencode($contentId . '-' . $loopIndex);
664                 $loopIndex++;
665             }
666
667             $childNode->setAttribute('id', $newId);
668             $idArray[] = $newId;
669         }
670
671         // Generate inner html as a string
672         $html = '';
673         foreach ($childNodes as $childNode) {
674             $html .= $doc->saveHTML($childNode);
675         }
676
677         return $html;
678     }
679
680
681     /**
682      * Render the page for viewing, Parsing and performing features such as page transclusion.
683      * @param Page $page
684      * @param bool $ignorePermissions
685      * @return mixed|string
686      */
687     public function renderPage(Page $page, $ignorePermissions = false)
688     {
689         $content = $page->html;
690         $matches = [];
691         preg_match_all("/{{@\s?([0-9].*?)}}/", $content, $matches);
692         if (count($matches[0]) === 0) return $content;
693
694         $topLevelTags = ['table', 'ul', 'ol'];
695         foreach ($matches[1] as $index => $includeId) {
696             $splitInclude = explode('#', $includeId, 2);
697             $pageId = intval($splitInclude[0]);
698             if (is_nan($pageId)) continue;
699
700             $matchedPage = $this->getById('page', $pageId, false, $ignorePermissions);
701             if ($matchedPage === null) {
702                 $content = str_replace($matches[0][$index], '', $content);
703                 continue;
704             }
705
706             if (count($splitInclude) === 1) {
707                 $content = str_replace($matches[0][$index], $matchedPage->html, $content);
708                 continue;
709             }
710
711             $doc = new DOMDocument();
712             $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
713             $matchingElem = $doc->getElementById($splitInclude[1]);
714             if ($matchingElem === null) {
715                 $content = str_replace($matches[0][$index], '', $content);
716                 continue;
717             }
718             $innerContent = '';
719             $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
720             if ($isTopLevel) {
721                 $innerContent .= $doc->saveHTML($matchingElem);
722             } else {
723                 foreach ($matchingElem->childNodes as $childNode) {
724                     $innerContent .= $doc->saveHTML($childNode);
725                 }
726             }
727             $content = str_replace($matches[0][$index], trim($innerContent), $content);
728         }
729
730         return $content;
731     }
732
733     /**
734      * Get the plain text version of a page's content.
735      * @param Page $page
736      * @return string
737      */
738     public function pageToPlainText(Page $page)
739     {
740         $html = $this->renderPage($page);
741         return strip_tags($html);
742     }
743
744     /**
745      * Get a new draft page instance.
746      * @param Book $book
747      * @param Chapter|bool $chapter
748      * @return Page
749      */
750     public function getDraftPage(Book $book, $chapter = false)
751     {
752         $page = $this->page->newInstance();
753         $page->name = trans('entities.pages_initial_name');
754         $page->created_by = user()->id;
755         $page->updated_by = user()->id;
756         $page->draft = true;
757
758         if ($chapter) $page->chapter_id = $chapter->id;
759
760         $book->pages()->save($page);
761         $page = $this->page->find($page->id);
762         $this->permissionService->buildJointPermissionsForEntity($page);
763         return $page;
764     }
765
766     /**
767      * Search for image usage within page content.
768      * @param $imageString
769      * @return mixed
770      */
771     public function searchForImage($imageString)
772     {
773         $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get();
774         foreach ($pages as $page) {
775             $page->url = $page->getUrl();
776             $page->html = '';
777             $page->text = '';
778         }
779         return count($pages) > 0 ? $pages : false;
780     }
781
782     /**
783      * Parse the headers on the page to get a navigation menu
784      * @param String $pageContent
785      * @return array
786      */
787     public function getPageNav($pageContent)
788     {
789         if ($pageContent == '') return [];
790         libxml_use_internal_errors(true);
791         $doc = new DOMDocument();
792         $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
793         $xPath = new DOMXPath($doc);
794         $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
795
796         if (is_null($headers)) return [];
797
798         $tree = collect([]);
799         foreach ($headers as $header) {
800             $text = $header->nodeValue;
801             $tree->push([
802                 'nodeName' => strtolower($header->nodeName),
803                 'level' => intval(str_replace('h', '', $header->nodeName)),
804                 'link' => '#' . $header->getAttribute('id'),
805                 'text' => strlen($text) > 30 ? substr($text, 0, 27) . '...' : $text
806             ]);
807         }
808
809         // Normalise headers if only smaller headers have been used
810         if (count($tree) > 0) {
811             $minLevel = $tree->pluck('level')->min();
812             $tree = $tree->map(function($header) use ($minLevel) {
813                 $header['level'] -= ($minLevel - 2);
814                 return $header;
815             });
816         }
817         return $tree->toArray();
818     }
819
820     /**
821      * Updates a page with any fillable data and saves it into the database.
822      * @param Page $page
823      * @param int $book_id
824      * @param array $input
825      * @return Page
826      */
827     public function updatePage(Page $page, $book_id, $input)
828     {
829         // Hold the old details to compare later
830         $oldHtml = $page->html;
831         $oldName = $page->name;
832
833         // Prevent slug being updated if no name change
834         if ($page->name !== $input['name']) {
835             $page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
836         }
837
838         // Save page tags if present
839         if (isset($input['tags'])) {
840             $this->tagRepo->saveTagsToEntity($page, $input['tags']);
841         }
842
843         // Update with new details
844         $userId = user()->id;
845         $page->fill($input);
846         $page->html = $this->formatHtml($input['html']);
847         $page->text = $this->pageToPlainText($page);
848         if (setting('app-editor') !== 'markdown') $page->markdown = '';
849         $page->updated_by = $userId;
850         $page->revision_count++;
851         $page->save();
852
853         // Remove all update drafts for this user & page.
854         $this->userUpdatePageDraftsQuery($page, $userId)->delete();
855
856         // Save a revision after updating
857         if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
858             $this->savePageRevision($page, $input['summary']);
859         }
860
861         $this->searchService->indexEntity($page);
862
863         return $page;
864     }
865
866     /**
867      * The base query for getting user update drafts.
868      * @param Page $page
869      * @param $userId
870      * @return mixed
871      */
872     protected function userUpdatePageDraftsQuery(Page $page, $userId)
873     {
874         return $this->pageRevision->where('created_by', '=', $userId)
875             ->where('type', 'update_draft')
876             ->where('page_id', '=', $page->id)
877             ->orderBy('created_at', 'desc');
878     }
879
880     /**
881      * Checks whether a user has a draft version of a particular page or not.
882      * @param Page $page
883      * @param $userId
884      * @return bool
885      */
886     public function hasUserGotPageDraft(Page $page, $userId)
887     {
888         return $this->userUpdatePageDraftsQuery($page, $userId)->count() > 0;
889     }
890
891     /**
892      * Get the latest updated draft revision for a particular page and user.
893      * @param Page $page
894      * @param $userId
895      * @return mixed
896      */
897     public function getUserPageDraft(Page $page, $userId)
898     {
899         return $this->userUpdatePageDraftsQuery($page, $userId)->first();
900     }
901
902     /**
903      * Get the notification message that informs the user that they are editing a draft page.
904      * @param PageRevision $draft
905      * @return string
906      */
907     public function getUserPageDraftMessage(PageRevision $draft)
908     {
909         $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
910         if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) return $message;
911         return $message . "\n" . trans('entities.pages_draft_edited_notification');
912     }
913
914     /**
915      * Check if a page is being actively editing.
916      * Checks for edits since last page updated.
917      * Passing in a minuted range will check for edits
918      * within the last x minutes.
919      * @param Page $page
920      * @param null $minRange
921      * @return bool
922      */
923     public function isPageEditingActive(Page $page, $minRange = null)
924     {
925         $draftSearch = $this->activePageEditingQuery($page, $minRange);
926         return $draftSearch->count() > 0;
927     }
928
929     /**
930      * A query to check for active update drafts on a particular page.
931      * @param Page $page
932      * @param null $minRange
933      * @return mixed
934      */
935     protected function activePageEditingQuery(Page $page, $minRange = null)
936     {
937         $query = $this->pageRevision->where('type', '=', 'update_draft')
938             ->where('page_id', '=', $page->id)
939             ->where('updated_at', '>', $page->updated_at)
940             ->where('created_by', '!=', user()->id)
941             ->with('createdBy');
942
943         if ($minRange !== null) {
944             $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
945         }
946
947         return $query;
948     }
949
950     /**
951      * Restores a revision's content back into a page.
952      * @param Page $page
953      * @param Book $book
954      * @param  int $revisionId
955      * @return Page
956      */
957     public function restorePageRevision(Page $page, Book $book, $revisionId)
958     {
959         $page->revision_count++;
960         $this->savePageRevision($page);
961         $revision = $page->revisions()->where('id', '=', $revisionId)->first();
962         $page->fill($revision->toArray());
963         $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
964         $page->text = $this->pageToPlainText($page);
965         $page->updated_by = user()->id;
966         $page->save();
967         $this->searchService->indexEntity($page);
968         return $page;
969     }
970
971
972     /**
973      * Save a page update draft.
974      * @param Page $page
975      * @param array $data
976      * @return PageRevision|Page
977      */
978     public function updatePageDraft(Page $page, $data = [])
979     {
980         // If the page itself is a draft simply update that
981         if ($page->draft) {
982             $page->fill($data);
983             if (isset($data['html'])) {
984                 $page->text = $this->pageToPlainText($page);
985             }
986             $page->save();
987             return $page;
988         }
989
990         // Otherwise save the data to a revision
991         $userId = user()->id;
992         $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
993
994         if ($drafts->count() > 0) {
995             $draft = $drafts->first();
996         } else {
997             $draft = $this->pageRevision->newInstance();
998             $draft->page_id = $page->id;
999             $draft->slug = $page->slug;
1000             $draft->book_slug = $page->book->slug;
1001             $draft->created_by = $userId;
1002             $draft->type = 'update_draft';
1003         }
1004
1005         $draft->fill($data);
1006         if (setting('app-editor') !== 'markdown') $draft->markdown = '';
1007
1008         $draft->save();
1009         return $draft;
1010     }
1011
1012     /**
1013      * Get a notification message concerning the editing activity on a particular page.
1014      * @param Page $page
1015      * @param null $minRange
1016      * @return string
1017      */
1018     public function getPageEditingActiveMessage(Page $page, $minRange = null)
1019     {
1020         $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
1021
1022         $userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
1023         $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
1024         return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
1025     }
1026
1027     /**
1028      * Change the page's parent to the given entity.
1029      * @param Page $page
1030      * @param Entity $parent
1031      */
1032     public function changePageParent(Page $page, Entity $parent)
1033     {
1034         $book = $parent->isA('book') ? $parent : $parent->book;
1035         $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
1036         $page->save();
1037         if ($page->book->id !== $book->id) {
1038             $page = $this->changeBook('page', $book->id, $page);
1039         }
1040         $page->load('book');
1041         $this->permissionService->buildJointPermissionsForEntity($book);
1042     }
1043
1044     /**
1045      * Destroy the provided book and all its child entities.
1046      * @param Book $book
1047      */
1048     public function destroyBook(Book $book)
1049     {
1050         foreach ($book->pages as $page) {
1051             $this->destroyPage($page);
1052         }
1053         foreach ($book->chapters as $chapter) {
1054             $this->destroyChapter($chapter);
1055         }
1056         \Activity::removeEntity($book);
1057         $book->views()->delete();
1058         $book->permissions()->delete();
1059         $this->permissionService->deleteJointPermissionsForEntity($book);
1060         $this->searchService->deleteEntityTerms($book);
1061         $book->delete();
1062     }
1063
1064     /**
1065      * Destroy a chapter and its relations.
1066      * @param Chapter $chapter
1067      */
1068     public function destroyChapter(Chapter $chapter)
1069     {
1070         if (count($chapter->pages) > 0) {
1071             foreach ($chapter->pages as $page) {
1072                 $page->chapter_id = 0;
1073                 $page->save();
1074             }
1075         }
1076         \Activity::removeEntity($chapter);
1077         $chapter->views()->delete();
1078         $chapter->permissions()->delete();
1079         $this->permissionService->deleteJointPermissionsForEntity($chapter);
1080         $this->searchService->deleteEntityTerms($chapter);
1081         $chapter->delete();
1082     }
1083
1084     /**
1085      * Destroy a given page along with its dependencies.
1086      * @param Page $page
1087      * @throws NotifyException
1088      */
1089     public function destroyPage(Page $page)
1090     {
1091         \Activity::removeEntity($page);
1092         $page->views()->delete();
1093         $page->tags()->delete();
1094         $page->revisions()->delete();
1095         $page->permissions()->delete();
1096         $this->permissionService->deleteJointPermissionsForEntity($page);
1097         $this->searchService->deleteEntityTerms($page);
1098
1099         // Check if set as custom homepage
1100         $customHome = setting('app-homepage', '0:');
1101         if (intval($page->id) === intval(explode(':', $customHome)[0])) {
1102             throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
1103         }
1104
1105         // Delete Attached Files
1106         $attachmentService = app(AttachmentService::class);
1107         foreach ($page->attachments as $attachment) {
1108             $attachmentService->deleteFile($attachment);
1109         }
1110
1111         $page->delete();
1112     }
1113
1114 }
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126