X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/ef77c10a7038165e78d18f9a1431c1e8d966efc3..refs/pull/234/head:/app/Repos/PageRepo.php diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 0e2f136be..14463c12d 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -1,137 +1,664 @@ -page = $page; + $this->pageRevision = $pageRevision; + $this->tagRepo = $tagRepo; + parent::__construct(); } - public function getById($id) + /** + * Base query for getting pages, Takes restrictions into account. + * @param bool $allowDrafts + * @return mixed + */ + private function pageQuery($allowDrafts = false) { - return $this->page->findOrFail($id); + $query = $this->permissionService->enforcePageRestrictions($this->page, 'view'); + if (!$allowDrafts) { + $query = $query->where('draft', '=', false); + } + return $query; } - public function getAll() + /** + * Get a page via a specific ID. + * @param $id + * @param bool $allowDrafts + * @return Page + */ + public function getById($id, $allowDrafts = false) { - return $this->page->all(); + return $this->pageQuery($allowDrafts)->findOrFail($id); } + /** + * Get a page identified by the given slug. + * @param $slug + * @param $bookId + * @return Page + * @throws NotFoundException + */ public function getBySlug($slug, $bookId) { - return $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first(); + $page = $this->pageQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first(); + if ($page === null) throw new NotFoundException(trans('errors.page_not_found')); + return $page; } + /** + * Search through page revisions and retrieve + * the last page in the current book that + * has a slug equal to the one given. + * @param $pageSlug + * @param $bookSlug + * @return null | Page + */ + public function findPageUsingOldSlug($pageSlug, $bookSlug) + { + $revision = $this->pageRevision->where('slug', '=', $pageSlug) + ->whereHas('page', function ($query) { + $this->permissionService->enforcePageRestrictions($query); + }) + ->where('type', '=', 'version') + ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc') + ->with('page')->first(); + return $revision !== null ? $revision->page : null; + } + + /** + * Get a new Page instance from the given input. + * @param $input + * @return Page + */ public function newFromInput($input) { $page = $this->page->fill($input); return $page; } + /** + * Count the pages with a particular slug within a book. + * @param $slug + * @param $bookId + * @return mixed + */ public function countBySlug($slug, $bookId) { return $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId)->count(); } - public function destroyById($id) + /** + * Publish a draft page to make it a normal page. + * Sets the slug and updates the content. + * @param Page $draftPage + * @param array $input + * @return Page + */ + public function publishDraft(Page $draftPage, array $input) { - $page = $this->getById($id); - $page->delete(); + $draftPage->fill($input); + + // Save page tags if present + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']); + } + + $draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id); + $draftPage->html = $this->formatHtml($input['html']); + $draftPage->text = strip_tags($draftPage->html); + $draftPage->draft = false; + + $draftPage->save(); + $this->saveRevision($draftPage, trans('entities.pages_initial_revision')); + + return $draftPage; } - public function getBySearch($term) + /** + * Get a new draft page instance. + * @param Book $book + * @param Chapter|bool $chapter + * @return Page + */ + public function getDraftPage(Book $book, $chapter = false) { - $terms = explode(' ', trim($term)); - $query = $this->page; - foreach($terms as $term) { - $query = $query->where('text', 'like', '%'.$term.'%'); - } - return $query->get(); + $page = $this->page->newInstance(); + $page->name = trans('entities.pages_initial_name'); + $page->created_by = user()->id; + $page->updated_by = user()->id; + $page->draft = true; + + if ($chapter) $page->chapter_id = $chapter->id; + + $book->pages()->save($page); + $this->permissionService->buildJointPermissionsForEntity($page); + return $page; } - public function getBreadCrumbs($page) + /** + * Parse te headers on the page to get a navigation menu + * @param Page $page + * @return array + */ + public function getPageNav(Page $page) { + if ($page->html == '') return null; + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($page->html, 'HTML-ENTITIES', 'UTF-8')); + $xPath = new DOMXPath($doc); + $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6"); + + if (is_null($headers)) return null; + $tree = []; - $cPage = $page; - while($cPage->parent && $cPage->parent->id !== 0) { - $cPage = $cPage->parent; - $tree[] = $cPage; + foreach ($headers as $header) { + $text = $header->nodeValue; + $tree[] = [ + 'nodeName' => strtolower($header->nodeName), + 'level' => intval(str_replace('h', '', $header->nodeName)), + 'link' => '#' . $header->getAttribute('id'), + 'text' => strlen($text) > 30 ? substr($text, 0, 27) . '...' : $text + ]; } - return count($tree) > 0 ? array_reverse($tree) : false; + return $tree; } /** - * Creates a tree of child pages, Nested by their - * set parent pages. - * @param $bookId - * @param bool $currentPageId - * @return array + * Formats a page's html to be tagged correctly + * within the system. + * @param string $htmlText + * @return string */ - public function getTreeByBookId($bookId, $currentPageId = false) + protected function formatHtml($htmlText) { - $topLevelPages = $this->getTopLevelPages($bookId); - $pageTree = []; + if ($htmlText == '') return $htmlText; + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); + + $container = $doc->documentElement; + $body = $container->childNodes->item(0); + $childNodes = $body->childNodes; + + // Ensure no duplicate ids are used + $idArray = []; + + foreach ($childNodes as $index => $childNode) { + /** @var \DOMElement $childNode */ + if (get_class($childNode) !== 'DOMElement') continue; + + // Overwrite id if not a BookStack custom id + if ($childNode->hasAttribute('id')) { + $id = $childNode->getAttribute('id'); + if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) { + $idArray[] = $id; + continue; + }; + } + + // Create an unique id for the element + // Uses the content as a basis to ensure output is the same every time + // the same content is passed through. + $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($childNode->nodeValue))), 0, 20); + $newId = urlencode($contentId); + $loopIndex = 0; + while (in_array($newId, $idArray)) { + $newId = urlencode($contentId . '-' . $loopIndex); + $loopIndex++; + } + + $childNode->setAttribute('id', $newId); + $idArray[] = $newId; + } - foreach($topLevelPages as $key => $topPage) { - $pageTree[$key] = $this->toArrayTree($topPage, $currentPageId); + // Generate inner html as a string + $html = ''; + foreach ($childNodes as $childNode) { + $html .= $doc->saveHTML($childNode); } - return $pageTree; + return $html; } + /** - * Creates a page tree array with the supplied page - * as the parent of the tree. - * @param $page - * @param bool $currentPageId + * Gets pages by a search term. + * Highlights page content for showing in results. + * @param string $term + * @param array $whereTerms + * @param int $count + * @param array $paginationAppends * @return mixed */ - private function toArrayTree($page, $currentPageId = false) + public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) { - $cPage = $page->toSimpleArray(); - $cPage['current'] = ($currentPageId !== false && $cPage['id'] === $currentPageId); - $cPage['pages'] = []; - foreach($page->children as $key => $childPage) { - $cPage['pages'][$key] = $this->toArrayTree($childPage, $currentPageId); + $terms = $this->prepareSearchTerms($term); + $pageQuery = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms)); + $pageQuery = $this->addAdvancedSearchQueries($pageQuery, $term); + $pages = $pageQuery->paginate($count)->appends($paginationAppends); + + // Add highlights to page text. + $words = join('|', explode(' ', preg_quote(trim($term), '/'))); + //lookahead/behind assertions ensures cut between words + $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words + + foreach ($pages as $page) { + preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER); + //delimiter between occurrences + $results = []; + foreach ($matches as $line) { + $results[] = htmlspecialchars($line[0], 0, 'UTF-8'); + } + $matchLimit = 6; + if (count($results) > $matchLimit) { + $results = array_slice($results, 0, $matchLimit); + } + $result = join('... ', $results); + + //highlight + $result = preg_replace('#' . $words . '#iu', "\$0", $result); + if (strlen($result) < 5) { + $result = $page->getExcerpt(80); + } + $page->searchSnippet = $result; } - $cPage['hasChildren'] = count($cPage['pages']) > 0; - return $cPage; + return $pages; } /** - * Gets the pages at the top of the page hierarchy. - * @param $bookId + * Search for image usage. + * @param $imageString + * @return mixed */ - private function getTopLevelPages($bookId) + public function searchForImage($imageString) { - return $this->page->where('book_id', '=', $bookId)->where('page_id', '=', 0)->orderBy('priority')->get(); + $pages = $this->pageQuery()->where('html', 'like', '%' . $imageString . '%')->get(); + foreach ($pages as $page) { + $page->url = $page->getUrl(); + $page->html = ''; + $page->text = ''; + } + return count($pages) > 0 ? $pages : false; } /** - * Applies a sort map to all applicable pages. - * @param $sortMap - * @param $bookId + * Updates a page with any fillable data and saves it into the database. + * @param Page $page + * @param int $book_id + * @param string $input + * @return Page */ - public function applySortMap($sortMap, $bookId) + public function updatePage(Page $page, $book_id, $input) { - foreach($sortMap as $index => $map) { - $page = $this->getById($map->id); - if($page->book_id === $bookId) { - $page->page_id = $map->parent; - $page->priority = $index; - $page->save(); - } + // Hold the old details to compare later + $oldHtml = $page->html; + $oldName = $page->name; + + // Prevent slug being updated if no name change + if ($page->name !== $input['name']) { + $page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id); + } + + // Save page tags if present + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($page, $input['tags']); + } + + // Update with new details + $userId = user()->id; + $page->fill($input); + $page->html = $this->formatHtml($input['html']); + $page->text = strip_tags($page->html); + if (setting('app-editor') !== 'markdown') $page->markdown = ''; + $page->updated_by = $userId; + $page->save(); + + // Remove all update drafts for this user & page. + $this->userUpdateDraftsQuery($page, $userId)->delete(); + + // Save a revision after updating + if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) { + $this->saveRevision($page, $input['summary']); + } + + return $page; + } + + /** + * Restores a revision's content back into a page. + * @param Page $page + * @param Book $book + * @param int $revisionId + * @return Page + */ + public function restoreRevision(Page $page, Book $book, $revisionId) + { + $this->saveRevision($page); + $revision = $this->getRevisionById($revisionId); + $page->fill($revision->toArray()); + $page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id); + $page->text = strip_tags($page->html); + $page->updated_by = user()->id; + $page->save(); + return $page; + } + + /** + * Saves a page revision into the system. + * @param Page $page + * @param null|string $summary + * @return $this + */ + public function saveRevision(Page $page, $summary = null) + { + $revision = $this->pageRevision->newInstance($page->toArray()); + if (setting('app-editor') !== 'markdown') $revision->markdown = ''; + $revision->page_id = $page->id; + $revision->slug = $page->slug; + $revision->book_slug = $page->book->slug; + $revision->created_by = user()->id; + $revision->created_at = $page->updated_at; + $revision->type = 'version'; + $revision->summary = $summary; + $revision->save(); + + // Clear old revisions + if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { + $this->pageRevision->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete(); + } + + return $revision; + } + + /** + * Save a page update draft. + * @param Page $page + * @param array $data + * @return PageRevision + */ + public function saveUpdateDraft(Page $page, $data = []) + { + $userId = user()->id; + $drafts = $this->userUpdateDraftsQuery($page, $userId)->get(); + + if ($drafts->count() > 0) { + $draft = $drafts->first(); + } else { + $draft = $this->pageRevision->newInstance(); + $draft->page_id = $page->id; + $draft->slug = $page->slug; + $draft->book_slug = $page->book->slug; + $draft->created_by = $userId; + $draft->type = 'update_draft'; + } + + $draft->fill($data); + if (setting('app-editor') !== 'markdown') $draft->markdown = ''; + + $draft->save(); + return $draft; + } + + /** + * Update a draft page. + * @param Page $page + * @param array $data + * @return Page + */ + public function updateDraftPage(Page $page, $data = []) + { + $page->fill($data); + + if (isset($data['html'])) { + $page->text = strip_tags($data['html']); + } + + $page->save(); + return $page; + } + + /** + * The base query for getting user update drafts. + * @param Page $page + * @param $userId + * @return mixed + */ + private function userUpdateDraftsQuery(Page $page, $userId) + { + return $this->pageRevision->where('created_by', '=', $userId) + ->where('type', 'update_draft') + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc'); + } + + /** + * Checks whether a user has a draft version of a particular page or not. + * @param Page $page + * @param $userId + * @return bool + */ + public function hasUserGotPageDraft(Page $page, $userId) + { + return $this->userUpdateDraftsQuery($page, $userId)->count() > 0; + } + + /** + * Get the latest updated draft revision for a particular page and user. + * @param Page $page + * @param $userId + * @return mixed + */ + public function getUserPageDraft(Page $page, $userId) + { + return $this->userUpdateDraftsQuery($page, $userId)->first(); + } + + /** + * Get the notification message that informs the user that they are editing a draft page. + * @param PageRevision $draft + * @return string + */ + public function getUserPageDraftMessage(PageRevision $draft) + { + $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); + if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) return $message; + return $message . "\n" . trans('entities.pages_draft_edited_notification'); + } + + /** + * Check if a page is being actively editing. + * Checks for edits since last page updated. + * Passing in a minuted range will check for edits + * within the last x minutes. + * @param Page $page + * @param null $minRange + * @return bool + */ + public function isPageEditingActive(Page $page, $minRange = null) + { + $draftSearch = $this->activePageEditingQuery($page, $minRange); + return $draftSearch->count() > 0; + } + + /** + * Get a notification message concerning the editing activity on + * a particular page. + * @param Page $page + * @param null $minRange + * @return string + */ + public function getPageEditingActiveMessage(Page $page, $minRange = null) + { + $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get(); + + $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]); + $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]); + return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]); + } + + /** + * A query to check for active update drafts on a particular page. + * @param Page $page + * @param null $minRange + * @return mixed + */ + private function activePageEditingQuery(Page $page, $minRange = null) + { + $query = $this->pageRevision->where('type', '=', 'update_draft') + ->where('page_id', '=', $page->id) + ->where('updated_at', '>', $page->updated_at) + ->where('created_by', '!=', user()->id) + ->with('createdBy'); + + if ($minRange !== null) { + $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange)); + } + + return $query; + } + + /** + * Gets a single revision via it's id. + * @param $id + * @return PageRevision + */ + public function getRevisionById($id) + { + return $this->pageRevision->findOrFail($id); + } + + /** + * Checks if a slug exists within a book already. + * @param $slug + * @param $bookId + * @param bool|false $currentId + * @return bool + */ + public function doesSlugExist($slug, $bookId, $currentId = false) + { + $query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId); + if ($currentId) $query = $query->where('id', '!=', $currentId); + return $query->count() > 0; + } + + /** + * Changes the related book for the specified page. + * Changes the book id of any relations to the page that store the book id. + * @param int $bookId + * @param Page $page + * @return Page + */ + public function changeBook($bookId, Page $page) + { + $page->book_id = $bookId; + foreach ($page->activity as $activity) { + $activity->book_id = $bookId; + $activity->save(); + } + $page->slug = $this->findSuitableSlug($page->name, $bookId, $page->id); + $page->save(); + return $page; + } + + + /** + * Change the page's parent to the given entity. + * @param Page $page + * @param Entity $parent + */ + public function changePageParent(Page $page, Entity $parent) + { + $book = $parent->isA('book') ? $parent : $parent->book; + $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0; + $page->save(); + $page = $this->changeBook($book->id, $page); + $page->load('book'); + $this->permissionService->buildJointPermissionsForEntity($book); + } + + /** + * Gets a suitable slug for the resource + * @param string $name + * @param int $bookId + * @param bool|false $currentId + * @return string + */ + public function findSuitableSlug($name, $bookId, $currentId = false) + { + $slug = $this->nameToSlug($name); + while ($this->doesSlugExist($slug, $bookId, $currentId)) { + $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); } + return $slug; + } + + /** + * Destroy a given page along with its dependencies. + * @param $page + */ + public function destroy(Page $page) + { + Activity::removeEntity($page); + $page->views()->delete(); + $page->tags()->delete(); + $page->revisions()->delete(); + $page->permissions()->delete(); + $this->permissionService->deleteJointPermissionsForEntity($page); + + // Delete AttachedFiles + $attachmentService = app(AttachmentService::class); + foreach ($page->attachments as $attachment) { + $attachmentService->deleteFile($attachment); + } + + $page->delete(); + } + + /** + * Get the latest pages added to the system. + * @param $count + * @return mixed + */ + public function getRecentlyCreatedPaginated($count = 20) + { + return $this->pageQuery()->orderBy('created_at', 'desc')->paginate($count); + } + + /** + * Get the latest pages added to the system. + * @param $count + * @return mixed + */ + public function getRecentlyUpdatedPaginated($count = 20) + { + return $this->pageQuery()->orderBy('updated_at', 'desc')->paginate($count); } -} \ No newline at end of file +}