X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/3286f29a61833327b5701b28db626d0a480b07f9..refs/pull/2511/head:/app/Entities/Repos/PageRepo.php diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 148ae8459..ca5748c86 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -1,111 +1,233 @@ baseRepo = $baseRepo; + } + + /** + * Get a page by ID. + * @throws NotFoundException + */ + public function getById(int $id, array $relations = ['book']): Page + { + $page = Page::visible()->with($relations)->find($id); + + if (!$page) { + throw new NotFoundException(trans('errors.page_not_found')); + } + + return $page; + } + /** - * Get page by slug. - * @param string $pageSlug - * @param string $bookSlug - * @return Page - * @throws \BookStack\Exceptions\NotFoundException + * Get a page its book and own slug. + * @throws NotFoundException */ - public function getPageBySlug(string $pageSlug, string $bookSlug) + public function getBySlug(string $bookSlug, string $pageSlug): Page { - return $this->getBySlug('page', $pageSlug, $bookSlug); + $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first(); + + if (!$page) { + 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 string $pageSlug - * @param string $bookSlug - * @return null|Page + * Get a page by its old slug but checking the revisions table + * for the last revision that matched the given page and book slug. */ - public function getPageByOldSlug(string $pageSlug, string $bookSlug) + public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page { - $revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug) - ->whereHas('page', function ($query) { - $this->permissionService->enforceEntityRestrictions('page', $query); + $revision = PageRevision::query() + ->whereHas('page', function (Builder $query) { + $query->visible(); }) + ->where('slug', '=', $pageSlug) ->where('type', '=', 'version') ->where('book_slug', '=', $bookSlug) ->orderBy('created_at', 'desc') - ->with('page')->first(); - return $revision !== null ? $revision->page : null; + ->with('page') + ->first(); + return $revision ? $revision->page : null; } /** - * Updates a page with any fillable data and saves it into the database. - * @param Page $page - * @param int $book_id - * @param array $input - * @return Page - * @throws \Exception + * Get pages that have been marked as a template. */ - public function updatePage(Page $page, int $book_id, array $input) + public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator { - // Hold the old details to compare later - $oldHtml = $page->html; - $oldName = $page->name; + $query = Page::visible() + ->where('template', '=', true) + ->orderBy('name', 'asc') + ->skip(($page - 1) * $count) + ->take($count); + + if ($search) { + $query->where('name', 'like', '%' . $search . '%'); + } + + $paginator = $query->paginate($count, ['*'], 'page', $page); + $paginator->withPath('/templates'); + + return $paginator; + } - // Prevent slug being updated if no name change - if ($page->name !== $input['name']) { - $page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id); + /** + * Get a parent item via slugs. + */ + public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity + { + if ($chapterSlug !== null) { + return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); } - // Save page tags if present - if (isset($input['tags'])) { - $this->tagRepo->saveTagsToEntity($page, $input['tags']); + return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); + } + + /** + * Get the draft copy of the given page for the current user. + */ + public function getUserDraft(Page $page): ?PageRevision + { + $revision = $this->getUserDraftQuery($page)->first(); + return $revision; + } + + /** + * Get a new draft page belonging to the given parent entity. + */ + public function getNewDraftPage(Entity $parent) + { + $page = (new Page())->forceFill([ + 'name' => trans('entities.pages_initial_name'), + 'created_by' => user()->id, + 'owned_by' => user()->id, + 'updated_by' => user()->id, + 'draft' => true, + ]); + + if ($parent instanceof Chapter) { + $page->chapter_id = $parent->id; + $page->book_id = $parent->book_id; + } else { + $page->book_id = $parent->id; } + $page->save(); + $page->refresh()->rebuildPermissions(); + return $page; + } + + /** + * Publish a draft page to make it a live, non-draft page. + */ + public function publishDraft(Page $draft, array $input): Page + { + $this->baseRepo->update($draft, $input); + $this->updateTemplateStatusAndContentFromInput($draft, $input); + + $draft->draft = false; + $draft->revision_count = 1; + $draft->priority = $this->getNewPriority($draft); + $draft->refreshSlug(); + $draft->save(); + + $this->savePageRevision($draft, trans('entities.pages_initial_revision')); + $draft->indexForSearch(); + $draft->refresh(); + + Activity::addForEntity($draft, ActivityType::PAGE_CREATE); + return $draft; + } + + /** + * Update a page in the system. + */ + public function update(Page $page, array $input): Page + { + // Hold the old details to compare later + $oldHtml = $page->html; + $oldName = $page->name; + + $this->updateTemplateStatusAndContentFromInput($page, $input); + $this->baseRepo->update($page, $input); + // Update with new details - $userId = user()->id; - $page->fill($input); - $page->html = $this->formatHtml($input['html']); - $page->text = $this->pageToPlainText($page); + $page->revision_count++; + if (setting('app-editor') !== 'markdown') { $page->markdown = ''; } - $page->updated_by = $userId; - $page->revision_count++; + $page->save(); // Remove all update drafts for this user & page. - $this->userUpdatePageDraftsQuery($page, $userId)->delete(); + $this->getUserDraftQuery($page)->delete(); // Save a revision after updating - if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) { - $this->savePageRevision($page, $input['summary']); + $summary = $input['summary'] ?? null; + if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) { + $this->savePageRevision($page, $summary); } - $this->searchService->indexEntity($page); - + Activity::addForEntity($page, ActivityType::PAGE_UPDATE); return $page; } + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input) + { + if (isset($input['template']) && userCan('templates-manage')) { + $page->template = ($input['template'] === 'true'); + } + + $pageContent = new PageContent($page); + if (!empty($input['markdown'] ?? '')) { + $pageContent->setNewMarkdown($input['markdown']); + } else { + $pageContent->setNewHTML($input['html']); + } + } + /** * Saves a page revision into the system. - * @param Page $page - * @param null|string $summary - * @return PageRevision - * @throws \Exception */ - public function savePageRevision(Page $page, string $summary = null) + protected function savePageRevision(Page $page, string $summary = null): PageRevision { - $revision = $this->entityProvider->pageRevision->newInstance($page->toArray()); + $revision = new PageRevision($page->getAttributes()); + if (setting('app-editor') !== 'markdown') { $revision->markdown = ''; } + $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; @@ -116,393 +238,239 @@ class PageRepo extends EntityRepo $revision->revision_number = $page->revision_count; $revision->save(); - $revisionLimit = config('app.revision_limit'); - if ($revisionLimit !== false) { - $revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']); - if ($revisionsToDelete->count() > 0) { - $this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); - } - } - + $this->deleteOldRevisions($page); return $revision; } /** - * Formats a page's html to be tagged correctly - * within the system. - * @param string $htmlText - * @return string + * Save a page update draft. */ - protected function formatHtml(string $htmlText) + public function updatePageDraft(Page $page, array $input) { - 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++; + // If the page itself is a draft simply update that + if ($page->draft) { + if (isset($input['html'])) { + (new PageContent($page))->setNewHTML($input['html']); } - - $childNode->setAttribute('id', $newId); - $idArray[] = $newId; + $page->fill($input); + $page->save(); + return $page; } - // Generate inner html as a string - $html = ''; - foreach ($childNodes as $childNode) { - $html .= $doc->saveHTML($childNode); + // Otherwise save the data to a revision + $draft = $this->getPageRevisionToUpdate($page); + $draft->fill($input); + if (setting('app-editor') !== 'markdown') { + $draft->markdown = ''; } - return $html; + $draft->save(); + return $draft; } /** - * Get the plain text version of a page's content. - * @param \BookStack\Entities\Page $page - * @return string + * Destroy a page from the system. + * @throws Exception */ - protected function pageToPlainText(Page $page) : string + public function destroy(Page $page) { - $html = $this->renderPage($page, true); - return strip_tags($html); + $trashCan = new TrashCan(); + $trashCan->softDestroyPage($page); + Activity::addForEntity($page, ActivityType::PAGE_DELETE); + $trashCan->autoClearOld(); } /** - * Get a new draft page instance. - * @param Book $book - * @param Chapter|null $chapter - * @return \BookStack\Entities\Page - * @throws \Throwable + * Restores a revision's content back into a page. */ - public function getDraftPage(Book $book, Chapter $chapter = null) + public function restoreRevision(Page $page, int $revisionId): Page { - $page = $this->entityProvider->page->newInstance(); - $page->name = trans('entities.pages_initial_name'); - $page->created_by = user()->id; + $page->revision_count++; + $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + + $page->fill($revision->toArray()); + $content = new PageContent($page); + $content->setNewHTML($revision->html); $page->updated_by = user()->id; - $page->draft = true; + $page->refreshSlug(); + $page->save(); + $page->indexForSearch(); - if ($chapter) { - $page->chapter_id = $chapter->id; - } + $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); + $this->savePageRevision($page, $summary); - $book->pages()->save($page); - $page = $this->entityProvider->page->find($page->id); - $this->permissionService->buildJointPermissionsForEntity($page); + Activity::addForEntity($page, ActivityType::PAGE_RESTORE); return $page; } /** - * Save a page update draft. - * @param Page $page - * @param array $data - * @return PageRevision|Page + * Move the given page into a new parent book or chapter. + * The $parentIdentifier must be a string of the following format: + * 'book:' (book:5) + * @throws MoveOperationException + * @throws PermissionsException */ - public function updatePageDraft(Page $page, array $data = []) + public function move(Page $page, string $parentIdentifier): Entity { - // If the page itself is a draft simply update that - if ($page->draft) { - $page->fill($data); - if (isset($data['html'])) { - $page->text = $this->pageToPlainText($page); - } - $page->save(); - return $page; + $parent = $this->findParentByIdentifier($parentIdentifier); + if ($parent === null) { + throw new MoveOperationException('Book or chapter to move page into not found'); } - // Otherwise save the data to a revision - $userId = user()->id; - $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get(); - - if ($drafts->count() > 0) { - $draft = $drafts->first(); - } else { - $draft = $this->entityProvider->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'; + if (!userCan('page-create', $parent)) { + throw new PermissionsException('User does not have permission to create a page within the new parent'); } - $draft->fill($data); - if (setting('app-editor') !== 'markdown') { - $draft->markdown = ''; - } + $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; + $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id); + $page->rebuildPermissions(); - $draft->save(); - return $draft; + Activity::addForEntity($page, ActivityType::PAGE_MOVE); + return $parent; } /** - * 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 - * @throws \Exception + * Copy an existing page in the system. + * Optionally providing a new parent via string identifier and a new name. + * @throws MoveOperationException + * @throws PermissionsException */ - public function publishPageDraft(Page $draftPage, array $input) + public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page { - $draftPage->fill($input); + $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent(); + if ($parent === null) { + throw new MoveOperationException('Book or chapter to move page into not found'); + } - // Save page tags if present - if (isset($input['tags'])) { - $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']); + if (!userCan('page-create', $parent)) { + throw new PermissionsException('User does not have permission to create a page within the new parent'); } - $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id); - $draftPage->html = $this->formatHtml($input['html']); - $draftPage->text = $this->pageToPlainText($draftPage); - $draftPage->draft = false; - $draftPage->revision_count = 1; + $copyPage = $this->getNewDraftPage($parent); + $pageData = $page->getAttributes(); - $draftPage->save(); - $this->savePageRevision($draftPage, trans('entities.pages_initial_revision')); - $this->searchService->indexEntity($draftPage); - return $draftPage; - } + // Update name + if (!empty($newName)) { + $pageData['name'] = $newName; + } - /** - * The base query for getting user update drafts. - * @param Page $page - * @param $userId - * @return mixed - */ - protected function userUpdatePageDraftsQuery(Page $page, int $userId) - { - return $this->entityProvider->pageRevision->where('created_by', '=', $userId) - ->where('type', 'update_draft') - ->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc'); - } + // Copy tags from previous page if set + if ($page->tags) { + $pageData['tags'] = []; + foreach ($page->tags as $tag) { + $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; + } + } - /** - * Get the latest updated draft revision for a particular page and user. - * @param Page $page - * @param $userId - * @return PageRevision|null - */ - public function getUserPageDraft(Page $page, int $userId) - { - return $this->userUpdatePageDraftsQuery($page, $userId)->first(); + return $this->publishDraft($copyPage, $pageData); } /** - * Get the notification message that informs the user that they are editing a draft page. - * @param PageRevision $draft - * @return string + * Find a page parent entity via a identifier string in the format: + * {type}:{id} + * Example: (book:5) + * @throws MoveOperationException */ - public function getUserPageDraftMessage(PageRevision $draft) + protected function findParentByIdentifier(string $identifier): ?Entity { - $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); - if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) { - return $message; + $stringExploded = explode(':', $identifier); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + if ($entityType !== 'book' && $entityType !== 'chapter') { + throw new MoveOperationException('Pages can only be in books or chapters'); } - return $message . "\n" . trans('entities.pages_draft_edited_notification'); + + $parentClass = $entityType === 'book' ? Book::class : Chapter::class; + return $parentClass::visible()->where('id', '=', $entityId)->first(); } /** - * A query to check for active update drafts on a particular page. - * @param Page $page - * @param int $minRange - * @return mixed + * Change the page's parent to the given entity. */ - protected function activePageEditingQuery(Page $page, int $minRange = null) + protected function changeParent(Page $page, Entity $parent) { - $query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft') - ->where('page_id', '=', $page->id) - ->where('updated_at', '>', $page->updated_at) - ->where('created_by', '!=', user()->id) - ->with('createdBy'); + $book = ($parent instanceof Book) ? $parent : $parent->book; + $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0; + $page->save(); - if ($minRange !== null) { - $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange)); + if ($page->book->id !== $book->id) { + $page->changeBook($book->id); } - return $query; - } - - /** - * 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 int $minRange - * @return bool - */ - public function isPageEditingActive(Page $page, int $minRange = null) - { - $draftSearch = $this->activePageEditingQuery($page, $minRange); - return $draftSearch->count() > 0; + $page->load('book'); + $book->rebuildPermissions(); } /** - * Get a notification message concerning the editing activity on a particular page. - * @param Page $page - * @param int $minRange - * @return string + * Get a page revision to update for the given page. + * Checks for an existing revisions before providing a fresh one. */ - public function getPageEditingActiveMessage(Page $page, int $minRange = null) + protected function getPageRevisionToUpdate(Page $page): PageRevision { - $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get(); + $drafts = $this->getUserDraftQuery($page)->get(); + if ($drafts->count() > 0) { + return $drafts->first(); + } - $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]); + $draft = new PageRevision(); + $draft->page_id = $page->id; + $draft->slug = $page->slug; + $draft->book_slug = $page->book->slug; + $draft->created_by = user()->id; + $draft->type = 'update_draft'; + return $draft; } /** - * Parse the headers on the page to get a navigation menu - * @param string $pageContent - * @return array + * Delete old revisions, for the given page, from the system. */ - public function getPageNav(string $pageContent) + protected function deleteOldRevisions(Page $page) { - if ($pageContent == '') { - return []; - } - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8')); - $xPath = new DOMXPath($doc); - $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6"); - - if (is_null($headers)) { - return []; - } - - $tree = collect([]); - foreach ($headers as $header) { - $text = $header->nodeValue; - $tree->push([ - 'nodeName' => strtolower($header->nodeName), - 'level' => intval(str_replace('h', '', $header->nodeName)), - 'link' => '#' . $header->getAttribute('id'), - 'text' => strlen($text) > 30 ? substr($text, 0, 27) . '...' : $text - ]); + $revisionLimit = config('app.revision_limit'); + if ($revisionLimit === false) { + return; } - // Normalise headers if only smaller headers have been used - if (count($tree) > 0) { - $minLevel = $tree->pluck('level')->min(); - $tree = $tree->map(function ($header) use ($minLevel) { - $header['level'] -= ($minLevel - 2); - return $header; - }); + $revisionsToDelete = PageRevision::query() + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc') + ->skip(intval($revisionLimit)) + ->take(10) + ->get(['id']); + if ($revisionsToDelete->count() > 0) { + PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); } - return $tree->toArray(); } /** - * Restores a revision's content back into a page. - * @param Page $page - * @param Book $book - * @param int $revisionId - * @return Page - * @throws \Exception + * Get a new priority for a page */ - public function restorePageRevision(Page $page, Book $book, int $revisionId) + protected function getNewPriority(Page $page): int { - $page->revision_count++; - $this->savePageRevision($page); - $revision = $page->revisions()->where('id', '=', $revisionId)->first(); - $page->fill($revision->toArray()); - $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id); - $page->text = $this->pageToPlainText($page); - $page->updated_by = user()->id; - $page->save(); - $this->searchService->indexEntity($page); - return $page; + $parent = $page->getParent(); + if ($parent instanceof Chapter) { + $lastPage = $parent->pages('desc')->first(); + return $lastPage ? $lastPage->priority + 1 : 0; + } + + return (new BookContents($page->book))->getLastPriority() + 1; } /** - * Change the page's parent to the given entity. - * @param Page $page - * @param Entity $parent - * @throws \Throwable + * Get the query to find the user's draft copies of the given page. */ - public function changePageParent(Page $page, Entity $parent) + protected function getUserDraftQuery(Page $page) { - $book = $parent->isA('book') ? $parent : $parent->book; - $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0; - $page->save(); - if ($page->book->id !== $book->id) { - $page = $this->changeBook('page', $book->id, $page); - } - $page->load('book'); - $this->permissionService->buildJointPermissionsForEntity($book); + return PageRevision::query()->where('created_by', '=', user()->id) + ->where('type', 'update_draft') + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc'); } - /** - * Create a copy of a page in a new location with a new name. - * @param \BookStack\Entities\Page $page - * @param \BookStack\Entities\Entity $newParent - * @param string $newName - * @return \BookStack\Entities\Page - * @throws \Throwable + * Get page details by chapter ID. */ - public function copyPage(Page $page, Entity $newParent, string $newName = '') - { - $newBook = $newParent->isA('book') ? $newParent : $newParent->book; - $newChapter = $newParent->isA('chapter') ? $newParent : null; - $copyPage = $this->getDraftPage($newBook, $newChapter); - $pageData = $page->getAttributes(); - - // Update name - if (!empty($newName)) { - $pageData['name'] = $newName; - } - - // Copy tags from previous page if set - if ($page->tags) { - $pageData['tags'] = []; - foreach ($page->tags as $tag) { - $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; - } - } - - // Set priority - if ($newParent->isA('chapter')) { - $pageData['priority'] = $this->getNewChapterPriority($newParent); - } else { - $pageData['priority'] = $this->getNewBookPriority($newParent); - } - - return $this->publishPageDraft($copyPage, $pageData); + public function getPageByChapterID(int $id){ + return Page::visible()->where('chapter_id', '=', $id)->get(['id','slug']); } }