1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-01-31 12:11:37 +01:00

Added new page drafts and started image entity attaching

Closes #80.
This commit is contained in:
Dan Brown 2016-03-13 12:04:08 +00:00
parent ced8c8e497
commit 5283919d24
26 changed files with 403 additions and 84 deletions

View File

@ -29,14 +29,17 @@ class HomeController extends Controller
public function index() public function index()
{ {
$activity = Activity::latest(10); $activity = Activity::latest(10);
$recents = $this->signedIn ? Views::getUserRecentlyViewed(12, 0) : $this->entityRepo->getRecentlyCreatedBooks(10); $draftPages = $this->signedIn ? $this->entityRepo->getUserDraftPages(6) : [];
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreatedBooks(10*$recentFactor);
$recentlyCreatedPages = $this->entityRepo->getRecentlyCreatedPages(5); $recentlyCreatedPages = $this->entityRepo->getRecentlyCreatedPages(5);
$recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdatedPages(5); $recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdatedPages(5);
return view('home', [ return view('home', [
'activity' => $activity, 'activity' => $activity,
'recents' => $recents, 'recents' => $recents,
'recentlyCreatedPages' => $recentlyCreatedPages, 'recentlyCreatedPages' => $recentlyCreatedPages,
'recentlyUpdatedPages' => $recentlyUpdatedPages 'recentlyUpdatedPages' => $recentlyUpdatedPages,
'draftPages' => $draftPages
]); ]);
} }

View File

@ -32,7 +32,6 @@ class ImageController extends Controller
parent::__construct(); parent::__construct();
} }
/** /**
* Get all images for a specific type, Paginated * Get all images for a specific type, Paginated
* @param int $page * @param int $page
@ -55,7 +54,6 @@ class ImageController extends Controller
return response()->json($imgData); return response()->json($imgData);
} }
/** /**
* Handles image uploads for use on pages. * Handles image uploads for use on pages.
* @param string $type * @param string $type
@ -113,7 +111,6 @@ class ImageController extends Controller
return response()->json($image); return response()->json($image);
} }
/** /**
* Deletes an image and all thumbnail/image files * Deletes an image and all thumbnail/image files
* @param PageRepo $pageRepo * @param PageRepo $pageRepo

View File

@ -49,33 +49,54 @@ class PageController extends Controller
public function create($bookSlug, $chapterSlug = false) public function create($bookSlug, $chapterSlug = false)
{ {
$book = $this->bookRepo->getBySlug($bookSlug); $book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : false; $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null;
$parent = $chapter ? $chapter : $book; $parent = $chapter ? $chapter : $book;
$this->checkOwnablePermission('page-create', $parent); $this->checkOwnablePermission('page-create', $parent);
$this->setPageTitle('Create New Page'); $this->setPageTitle('Create New Page');
return view('pages/create', ['book' => $book, 'chapter' => $chapter]);
$draft = $this->pageRepo->getDraftPage($book, $chapter);
return redirect($draft->getUrl());
} }
/** /**
* Store a newly created page in storage. * Show form to continue editing a draft page.
* @param $bookSlug
* @param $pageId
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function editDraft($bookSlug, $pageId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$draft = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-create', $draft);
$this->setPageTitle('Edit Page Draft');
return view('pages/create', ['draft' => $draft, 'book' => $book]);
}
/**
* Store a new page by changing a draft into a page.
* @param Request $request * @param Request $request
* @param $bookSlug * @param string $bookSlug
* @return Response * @return Response
*/ */
public function store(Request $request, $bookSlug) public function store(Request $request, $bookSlug, $pageId)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255' 'name' => 'required|string|max:255'
]); ]);
$input = $request->all(); $input = $request->all();
$book = $this->bookRepo->getBySlug($bookSlug); $book = $this->bookRepo->getBySlug($bookSlug);
$chapterId = ($request->has('chapter') && $this->chapterRepo->idExists($request->get('chapter'))) ? $request->get('chapter') : null;
$parent = $chapterId !== null ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
$input['priority'] = $this->bookRepo->getNewPriority($book); $input['priority'] = $this->bookRepo->getNewPriority($book);
$page = $this->pageRepo->saveNew($input, $book, $chapterId); $draftPage = $this->pageRepo->getById($pageId, true);
$chapterId = $draftPage->chapter_id;
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
$page = $this->pageRepo->publishDraft($draftPage, $input);
Activity::add($page, 'page_create', $book->id); Activity::add($page, 'page_create', $book->id);
return redirect($page->getUrl()); return redirect($page->getUrl());
@ -132,12 +153,13 @@ class PageController extends Controller
$this->setPageTitle('Editing Page ' . $page->getShortName()); $this->setPageTitle('Editing Page ' . $page->getShortName());
$page->isDraft = false; $page->isDraft = false;
// Check for active editing and drafts // Check for active editing
$warnings = []; $warnings = [];
if ($this->pageRepo->isPageEditingActive($page, 60)) { if ($this->pageRepo->isPageEditingActive($page, 60)) {
$warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60); $warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60);
} }
// Check for a current draft version for this user
if ($this->pageRepo->hasUserGotPageDraft($page, $this->currentUser->id)) { if ($this->pageRepo->hasUserGotPageDraft($page, $this->currentUser->id)) {
$draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id); $draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
$page->name = $draft->name; $page->name = $draft->name;
@ -161,7 +183,7 @@ class PageController extends Controller
public function update(Request $request, $bookSlug, $pageSlug) public function update(Request $request, $bookSlug, $pageSlug)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255' 'name' => 'required|string|max:255'
]); ]);
$book = $this->bookRepo->getBySlug($bookSlug); $book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id); $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
@ -177,14 +199,15 @@ class PageController extends Controller
* @param $pageId * @param $pageId
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
public function saveUpdateDraft(Request $request, $pageId) public function saveDraft(Request $request, $pageId)
{ {
$this->validate($request, [ $page = $this->pageRepo->getById($pageId, true);
'name' => 'required|string|max:255'
]);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page); $this->checkOwnablePermission('page-update', $page);
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); if ($page->draft) {
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html']));
} else {
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html']));
}
$updateTime = $draft->updated_at->format('H:i'); $updateTime = $draft->updated_at->format('H:i');
return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]); return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]);
} }
@ -216,9 +239,25 @@ class PageController extends Controller
return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]); return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]);
} }
/**
* Show the deletion page for the specified page.
* @param $bookSlug
* @param $pageId
* @return \Illuminate\View\View
* @throws NotFoundException
*/
public function showDeleteDraft($bookSlug, $pageId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle('Delete Draft Page ' . $page->getShortName());
return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]);
}
/** /**
* Remove the specified page from storage. * Remove the specified page from storage.
*
* @param $bookSlug * @param $bookSlug
* @param $pageSlug * @param $pageSlug
* @return Response * @return Response
@ -230,6 +269,24 @@ class PageController extends Controller
$page = $this->pageRepo->getBySlug($pageSlug, $book->id); $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-delete', $page); $this->checkOwnablePermission('page-delete', $page);
Activity::addMessage('page_delete', $book->id, $page->name); Activity::addMessage('page_delete', $book->id, $page->name);
session()->flash('success', 'Page deleted');
$this->pageRepo->destroy($page);
return redirect($book->getUrl());
}
/**
* Remove the specified draft page from storage.
* @param $bookSlug
* @param $pageId
* @return Response
* @throws NotFoundException
*/
public function destroyDraft($bookSlug, $pageId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-update', $page);
session()->flash('success', 'Draft deleted');
$this->pageRepo->destroy($page); $this->pageRepo->destroy($page);
return redirect($book->getUrl()); return redirect($book->getUrl());
} }
@ -295,8 +352,8 @@ class PageController extends Controller
$page = $this->pageRepo->getBySlug($pageSlug, $book->id); $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$pdfContent = $this->exportService->pageToPdf($page); $pdfContent = $this->exportService->pageToPdf($page);
return response()->make($pdfContent, 200, [ return response()->make($pdfContent, 200, [
'Content-Type' => 'application/octet-stream', 'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.$pageSlug.'.pdf' 'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.pdf'
]); ]);
} }
@ -312,8 +369,8 @@ class PageController extends Controller
$page = $this->pageRepo->getBySlug($pageSlug, $book->id); $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$containedHtml = $this->exportService->pageToContainedHtml($page); $containedHtml = $this->exportService->pageToContainedHtml($page);
return response()->make($containedHtml, 200, [ return response()->make($containedHtml, 200, [
'Content-Type' => 'application/octet-stream', 'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.$pageSlug.'.html' 'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.html'
]); ]);
} }
@ -329,8 +386,8 @@ class PageController extends Controller
$page = $this->pageRepo->getBySlug($pageSlug, $book->id); $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$containedHtml = $this->exportService->pageToPlainText($page); $containedHtml = $this->exportService->pageToPlainText($page);
return response()->make($containedHtml, 200, [ return response()->make($containedHtml, 200, [
'Content-Type' => 'application/octet-stream', 'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.$pageSlug.'.txt' 'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.txt'
]); ]);
} }
@ -373,7 +430,7 @@ class PageController extends Controller
$this->checkOwnablePermission('restrictions-manage', $page); $this->checkOwnablePermission('restrictions-manage', $page);
$roles = $this->userRepo->getRestrictableRoles(); $roles = $this->userRepo->getRestrictableRoles();
return view('pages/restrictions', [ return view('pages/restrictions', [
'page' => $page, 'page' => $page,
'roles' => $roles 'roles' => $roles
]); ]);
} }

View File

@ -27,17 +27,20 @@ Route::group(['middleware' => 'auth'], function () {
// Pages // Pages
Route::get('/{bookSlug}/page/create', 'PageController@create'); Route::get('/{bookSlug}/page/create', 'PageController@create');
Route::post('/{bookSlug}/page', 'PageController@store'); Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft');
Route::post('/{bookSlug}/page/{pageId}', 'PageController@store');
Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show'); Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageController@exportPdf'); Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageController@exportPdf');
Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml'); Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText'); Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit'); Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete'); Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
Route::get('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@showRestrict'); Route::get('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@showRestrict');
Route::put('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@restrict'); Route::put('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@restrict');
Route::put('/{bookSlug}/page/{pageSlug}', 'PageController@update'); Route::put('/{bookSlug}/page/{pageSlug}', 'PageController@update');
Route::delete('/{bookSlug}/page/{pageSlug}', 'PageController@destroy'); Route::delete('/{bookSlug}/page/{pageSlug}', 'PageController@destroy');
Route::delete('/{bookSlug}/draft/{pageId}', 'PageController@destroyDraft');
// Revisions // Revisions
Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions'); Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions');
@ -76,8 +79,9 @@ Route::group(['middleware' => 'auth'], function () {
}); });
// Ajax routes // Ajax routes
Route::put('/ajax/page/{id}/save-draft', 'PageController@saveUpdateDraft'); Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
// Links // Links
Route::get('/link/{id}', 'PageController@redirectFromLink'); Route::get('/link/{id}', 'PageController@redirectFromLink');

View File

@ -40,7 +40,9 @@ class Page extends Entity
public function getUrl() public function getUrl()
{ {
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
return '/books/' . $bookSlug . '/page/' . $this->slug; $midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : $this->slug;
return '/books/' . $bookSlug . $midText . $idComponent;
} }
public function getExcerpt($length = 100) public function getExcerpt($length = 100)

View File

@ -213,15 +213,27 @@ class BookRepo extends EntityRepo
$chapters = $chapterQuery->get(); $chapters = $chapterQuery->get();
$children = $pages->merge($chapters); $children = $pages->merge($chapters);
$bookSlug = $book->slug; $bookSlug = $book->slug;
$children->each(function ($child) use ($bookSlug) { $children->each(function ($child) use ($bookSlug) {
$child->setAttribute('bookSlug', $bookSlug); $child->setAttribute('bookSlug', $bookSlug);
if ($child->isA('chapter')) { if ($child->isA('chapter')) {
$child->pages->each(function ($page) use ($bookSlug) { $child->pages->each(function ($page) use ($bookSlug) {
$page->setAttribute('bookSlug', $bookSlug); $page->setAttribute('bookSlug', $bookSlug);
}); });
$child->pages = $child->pages->sortBy(function($child, $key) {
$score = $child->priority;
if ($child->draft) $score -= 100;
return $score;
});
} }
}); });
return $children->sortBy('priority');
// Sort items with drafts first then by priority.
return $children->sortBy(function($child, $key) {
$score = $child->priority;
if ($child->isA('page') && $child->draft) $score -= 100;
return $score;
});
} }
/** /**

View File

@ -66,7 +66,13 @@ class ChapterRepo extends EntityRepo
*/ */
public function getChildren(Chapter $chapter) public function getChildren(Chapter $chapter)
{ {
return $this->restrictionService->enforcePageRestrictions($chapter->pages())->get(); $pages = $this->restrictionService->enforcePageRestrictions($chapter->pages())->get();
// Sort items with drafts first then by priority.
return $pages->sortBy(function($child, $key) {
$score = $child->priority;
if ($child->draft) $score -= 100;
return $score;
});
} }
/** /**

View File

@ -5,6 +5,7 @@ use BookStack\Chapter;
use BookStack\Entity; use BookStack\Entity;
use BookStack\Page; use BookStack\Page;
use BookStack\Services\RestrictionService; use BookStack\Services\RestrictionService;
use BookStack\User;
class EntityRepo class EntityRepo
{ {
@ -79,7 +80,7 @@ class EntityRepo
public function getRecentlyCreatedPages($count = 20, $page = 0, $additionalQuery = false) public function getRecentlyCreatedPages($count = 20, $page = 0, $additionalQuery = false)
{ {
$query = $this->restrictionService->enforcePageRestrictions($this->page) $query = $this->restrictionService->enforcePageRestrictions($this->page)
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc')->where('draft', '=', false);
if ($additionalQuery !== false && is_callable($additionalQuery)) { if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query); $additionalQuery($query);
} }
@ -112,9 +113,24 @@ class EntityRepo
public function getRecentlyUpdatedPages($count = 20, $page = 0) public function getRecentlyUpdatedPages($count = 20, $page = 0)
{ {
return $this->restrictionService->enforcePageRestrictions($this->page) return $this->restrictionService->enforcePageRestrictions($this->page)
->where('draft', '=', false)
->orderBy('updated_at', 'desc')->skip($page * $count)->take($count)->get(); ->orderBy('updated_at', 'desc')->skip($page * $count)->take($count)->get();
} }
/**
* Get draft pages owned by the current user.
* @param int $count
* @param int $page
*/
public function getUserDraftPages($count = 20, $page = 0)
{
$user = auth()->user();
return $this->page->where('draft', '=', true)
->where('created_by', '=', $user->id)
->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get();
}
/** /**
* Updates entity restrictions from a request * Updates entity restrictions from a request
* @param $request * @param $request

View File

@ -1,8 +1,8 @@
<?php namespace BookStack\Repos; <?php namespace BookStack\Repos;
use Activity; use Activity;
use BookStack\Book; use BookStack\Book;
use BookStack\Chapter;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon; use Carbon\Carbon;
use DOMDocument; use DOMDocument;
@ -12,6 +12,7 @@ use BookStack\PageRevision;
class PageRepo extends EntityRepo class PageRepo extends EntityRepo
{ {
protected $pageRevision; protected $pageRevision;
/** /**
@ -26,21 +27,27 @@ class PageRepo extends EntityRepo
/** /**
* Base query for getting pages, Takes restrictions into account. * Base query for getting pages, Takes restrictions into account.
* @param bool $allowDrafts
* @return mixed * @return mixed
*/ */
private function pageQuery() private function pageQuery($allowDrafts = false)
{ {
return $this->restrictionService->enforcePageRestrictions($this->page, 'view'); $query = $this->restrictionService->enforcePageRestrictions($this->page, 'view');
if (!$allowDrafts) {
$query = $query->where('draft', '=', false);
}
return $query;
} }
/** /**
* Get a page via a specific ID. * Get a page via a specific ID.
* @param $id * @param $id
* @param bool $allowDrafts
* @return mixed * @return mixed
*/ */
public function getById($id) public function getById($id, $allowDrafts = false)
{ {
return $this->pageQuery()->findOrFail($id); return $this->pageQuery($allowDrafts)->findOrFail($id);
} }
/** /**
@ -123,6 +130,47 @@ class PageRepo extends EntityRepo
return $page; return $page;
} }
/**
* 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)
{
$draftPage->fill($input);
$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();
return $draftPage;
}
/**
* Get a new draft page instance.
* @param Book $book
* @param Chapter|null $chapter
* @return static
*/
public function getDraftPage(Book $book, $chapter)
{
$page = $this->page->newInstance();
$page->name = 'New Page';
$page->created_by = auth()->user()->id;
$page->updated_by = auth()->user()->id;
$page->draft = true;
if ($chapter) $page->chapter_id = $chapter->id;
$book->pages()->save($page);
return $page;
}
/** /**
* Formats a page's html to be tagged correctly * Formats a page's html to be tagged correctly
* within the system. * within the system.
@ -342,6 +390,24 @@ class PageRepo extends EntityRepo
return $draft; 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. * The base query for getting user update drafts.
* @param Page $page * @param Page $page

View File

@ -8,15 +8,16 @@ class RestrictionService
protected $userRoles; protected $userRoles;
protected $isAdmin; protected $isAdmin;
protected $currentAction; protected $currentAction;
protected $currentUser;
/** /**
* RestrictionService constructor. * RestrictionService constructor.
*/ */
public function __construct() public function __construct()
{ {
$user = auth()->user(); $this->currentUser = auth()->user();
$this->userRoles = $user ? auth()->user()->roles->pluck('id') : []; $this->userRoles = $this->currentUser ? $this->currentUser->roles->pluck('id') : [];
$this->isAdmin = $user ? auth()->user()->hasRole('admin') : false; $this->isAdmin = $this->currentUser ? $this->currentUser->hasRole('admin') : false;
} }
/** /**
@ -48,6 +49,16 @@ class RestrictionService
*/ */
public function enforcePageRestrictions($query, $action = 'view') public function enforcePageRestrictions($query, $action = 'view')
{ {
// Prevent drafts being visible to others.
$query = $query->where(function($query) {
$query->where('draft', '=', false);
if ($this->currentUser) {
$query->orWhere(function($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser->id);
});
}
});
if ($this->isAdmin) return $query; if ($this->isAdmin) return $query;
$this->currentAction = $action; $this->currentAction = $action;
return $this->pageRestrictionQuery($query); return $this->pageRestrictionQuery($query);

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class ImageEntitiesAndPageDrafts extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('images', function (Blueprint $table) {
$table->string('entity_type', 100);
$table->integer('entity_id');
$table->index(['entity_type', 'entity_id']);
});
Schema::table('pages', function(Blueprint $table) {
$table->boolean('draft')->default(false);
$table->index('draft');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('images', function (Blueprint $table) {
$table->dropIndex(['entity_type', 'entity_id']);
$table->dropColumn('entity_type');
$table->dropColumn('entity_id');
});
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('draft');
});
}
}

View File

@ -4,6 +4,7 @@ module.exports = function (ngApp, events) {
ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService', ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
function ($scope, $attrs, $http, $timeout, imageManagerService) { function ($scope, $attrs, $http, $timeout, imageManagerService) {
$scope.images = []; $scope.images = [];
$scope.imageType = $attrs.imageType; $scope.imageType = $attrs.imageType;
$scope.selectedImage = false; $scope.selectedImage = false;
@ -12,6 +13,7 @@ module.exports = function (ngApp, events) {
$scope.hasMore = false; $scope.hasMore = false;
$scope.imageUpdateSuccess = false; $scope.imageUpdateSuccess = false;
$scope.imageDeleteSuccess = false; $scope.imageDeleteSuccess = false;
var page = 0; var page = 0;
var previousClickTime = 0; var previousClickTime = 0;
var dataLoaded = false; var dataLoaded = false;
@ -221,8 +223,13 @@ module.exports = function (ngApp, events) {
var pageId = Number($attrs.pageId); var pageId = Number($attrs.pageId);
var isEdit = pageId !== 0; var isEdit = pageId !== 0;
var autosaveFrequency = 30; // AutoSave interval in seconds. var autosaveFrequency = 30; // AutoSave interval in seconds.
$scope.isDraft = Number($attrs.pageDraft) === 1; $scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
if ($scope.isDraft) $scope.draftText = 'Editing Draft'; $scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
$scope.draftText = 'Editing Draft'
} else {
$scope.draftText = 'Editing Page'
};
var autoSave = false; var autoSave = false;
@ -254,7 +261,7 @@ module.exports = function (ngApp, events) {
if (newTitle !== currentContent.title || newHtml !== currentContent.html) { if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
currentContent.html = newHtml; currentContent.html = newHtml;
currentContent.title = newTitle; currentContent.title = newTitle;
saveDraftUpdate(newTitle, newHtml); saveDraft(newTitle, newHtml);
} }
}, 1000 * autosaveFrequency); }, 1000 * autosaveFrequency);
} }
@ -264,16 +271,22 @@ module.exports = function (ngApp, events) {
* @param title * @param title
* @param html * @param html
*/ */
function saveDraftUpdate(title, html) { function saveDraft(title, html) {
$http.put('/ajax/page/' + pageId + '/save-draft', { $http.put('/ajax/page/' + pageId + '/save-draft', {
name: title, name: title,
html: html html: html
}).then((responseData) => { }).then((responseData) => {
$scope.draftText = responseData.data.message; $scope.draftText = responseData.data.message;
$scope.isDraft = true; if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true;
}); });
} }
$scope.forceDraftSave = function() {
var newTitle = $('#name').val();
var newHtml = $scope.editorHtml;
saveDraft(newTitle, newHtml);
};
/** /**
* Discard the current draft and grab the current page * Discard the current draft and grab the current page
* content from the system via an AJAX request. * content from the system via an AJAX request.
@ -281,10 +294,10 @@ module.exports = function (ngApp, events) {
$scope.discardDraft = function () { $scope.discardDraft = function () {
$http.get('/ajax/page/' + pageId).then((responseData) => { $http.get('/ajax/page/' + pageId).then((responseData) => {
if (autoSave) $interval.cancel(autoSave); if (autoSave) $interval.cancel(autoSave);
$scope.draftText = ''; $scope.draftText = 'Editing Page';
$scope.isDraft = false; $scope.isUpdateDraft = false;
$scope.$broadcast('html-update', responseData.data.html); $scope.$broadcast('html-update', responseData.data.html);
$('#name').val(currentContent.title); $('#name').val(responseData.data.name);
$timeout(() => { $timeout(() => {
startAutoSave(); startAutoSave();
}, 1000); }, 1000);

View File

@ -164,7 +164,6 @@ form.search-box {
.faded span.faded-text { .faded span.faded-text {
display: inline-block; display: inline-block;
padding: $-s; padding: $-s;
opacity: 0.5;
} }
.faded-small { .faded-small {

View File

@ -26,6 +26,12 @@
.page { .page {
border-left: 5px solid $color-page; border-left: 5px solid $color-page;
} }
.page.draft {
border-left: 5px solid $color-page-draft;
.text-page {
color: $color-page-draft;
}
}
.chapter { .chapter {
border-left: 5px solid $color-chapter; border-left: 5px solid $color-chapter;
} }
@ -182,6 +188,12 @@
background-color: rgba($color-page, 0.1); background-color: rgba($color-page, 0.1);
} }
} }
.list-item-page.draft {
border-left: 5px solid $color-page-draft;
}
.page.draft .page, .list-item-page.draft a.page {
color: $color-page-draft !important;
}
.sub-menu { .sub-menu {
display: none; display: none;
padding-left: 0; padding-left: 0;
@ -234,7 +246,6 @@
position: absolute; position: absolute;
} }
.activity-list-item { .activity-list-item {
padding: $-s 0; padding: $-s 0;
color: #888; color: #888;
@ -304,6 +315,9 @@ ul.pagination {
font-size: 0.75em; font-size: 0.75em;
margin-top: $-xs; margin-top: $-xs;
} }
.page.draft .text-page {
color: $color-page-draft;
}
} }
.entity-list.compact { .entity-list.compact {
font-size: 0.6em; font-size: 0.6em;

View File

@ -45,6 +45,7 @@ $primary-faded: rgba(21, 101, 192, 0.15);
$color-book: #009688; $color-book: #009688;
$color-chapter: #ef7c3c; $color-chapter: #ef7c3c;
$color-page: $primary; $color-page: $primary;
$color-page-draft: #9A60DA;
// Text colours // Text colours
$text-dark: #444; $text-dark: #444;

View File

@ -7,7 +7,7 @@
<div class="row"> <div class="row">
<div class="col-md-4 faded"> <div class="col-md-4 faded">
<div class="breadcrumbs"> <div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->name }}</a> <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
</div> </div>
</div> </div>
<div class="col-md-8 faded"> <div class="col-md-8 faded">

View File

@ -23,6 +23,12 @@
<div class="row"> <div class="row">
<div class="col-sm-4"> <div class="col-sm-4">
<div id="recent-drafts">
@if(count($draftPages) > 0)
<h3>My Recent Drafts</h3>
@include('partials/entity-list', ['entities' => $draftPages, 'style' => 'compact'])
@endif
</div>
@if($signedIn) @if($signedIn)
<h3>My Recently Viewed</h3> <h3>My Recently Viewed</h3>
@else @else

View File

@ -1,7 +1,7 @@
@extends('base') @extends('base')
@section('head') @section('head')
<script src="/libs/tinymce/tinymce.min.js?ver=4.3.2"></script> <script src="/libs/tinymce/tinymce.min.js?ver=4.3.7"></script>
@stop @stop
@section('body-class', 'flexbox') @section('body-class', 'flexbox')
@ -9,11 +9,8 @@
@section('content') @section('content')
<div class="flex-fill flex"> <div class="flex-fill flex">
<form action="{{$book->getUrl() . '/page'}}" method="POST" class="flex flex-fill"> <form action="{{$book->getUrl() . '/page/' . $draft->id}}" method="POST" class="flex flex-fill">
@include('pages/form') @include('pages/form', ['model' => $draft])
@if($chapter)
<input type="hidden" name="chapter" value="{{$chapter->id}}">
@endif
</form> </form>
</div> </div>
@include('partials/image-manager', ['imageType' => 'gallery']) @include('partials/image-manager', ['imageType' => 'gallery'])

View File

@ -3,8 +3,8 @@
@section('content') @section('content')
<div class="container small" ng-non-bindable> <div class="container small" ng-non-bindable>
<h1>Delete Page</h1> <h1>Delete {{ $page->draft ? 'Draft' : '' }} Page</h1>
<p class="text-neg">Are you sure you want to delete this page?</p> <p class="text-neg">Are you sure you want to delete this {{ $page->draft ? 'draft' : '' }} page?</p>
<form action="{{$page->getUrl()}}" method="POST"> <form action="{{$page->getUrl()}}" method="POST">
{!! csrf_field() !!} {!! csrf_field() !!}

View File

@ -1,7 +1,7 @@
@extends('base') @extends('base')
@section('head') @section('head')
<script src="/libs/tinymce/tinymce.min.js?ver=4.3.2"></script> <script src="/libs/tinymce/tinymce.min.js?ver=4.3.7"></script>
@stop @stop
@section('body-class', 'flexbox') @section('body-class', 'flexbox')

View File

@ -1,7 +1,5 @@
<div class="page-editor flex-fill flex" ng-controller="PageEditController" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
<div class="page-editor flex-fill flex" ng-controller="PageEditController" page-id="{{ $model->id or 0 }}" page-draft="{{ $page->isDraft or 0 }}">
{{ csrf_field() }} {{ csrf_field() }}
<div class="faded-small toolbar"> <div class="faded-small toolbar">
@ -14,11 +12,23 @@
</div> </div>
</div> </div>
<div class="col-sm-4 faded text-center"> <div class="col-sm-4 faded text-center">
<span class="faded-text" ng-bind="draftText"></span>
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><span class="faded-text" ng-bind="draftText"></span>&nbsp; <i class="zmdi zmdi-more-vert"></i></a>
<ul>
<li>
<a ng-click="forceDraftSave()" class="text-pos"><i class="zmdi zmdi-save"></i>Save Draft</a>
</li>
<li ng-if="isNewPageDraft">
<a href="{{$model->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete Draft</a>
</li>
</ul>
</div>
</div> </div>
<div class="col-sm-4 faded"> <div class="col-sm-4 faded">
<div class="action-buttons" ng-cloak> <div class="action-buttons" ng-cloak>
<button type="button" ng-if="isDraft" ng-click="discardDraft()" class="text-button text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</button>
<button type="button" ng-if="isUpdateDraft" ng-click="discardDraft()" class="text-button text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</button>
<button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button> <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="page"> <div class="page {{$page->draft ? 'draft' : ''}}">
<h3> <h3>
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a> <a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
</h3> </h3>

View File

@ -6,7 +6,7 @@
@foreach($sidebarTree as $bookChild) @foreach($sidebarTree as $bookChild)
<li class="list-item-{{ $bookChild->getClassName() }} {{ $bookChild->getClassName() }}"> <li class="list-item-{{ $bookChild->getClassName() }} {{ $bookChild->getClassName() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
<a href="{{$bookChild->getUrl()}}" class="{{ $bookChild->getClassName() }} {{ $current->matches($bookChild)? 'selected' : '' }}"> <a href="{{$bookChild->getUrl()}}" class="{{ $bookChild->getClassName() }} {{ $current->matches($bookChild)? 'selected' : '' }}">
@if($bookChild->isA('chapter'))<i class="zmdi zmdi-collection-bookmark"></i>@else <i class="zmdi zmdi-file-text"></i>@endif{{ $bookChild->name }} @if($bookChild->isA('chapter'))<i class="zmdi zmdi-collection-bookmark"></i>@else <i class="zmdi zmdi-file-text"></i>@endif{{ $bookChild->name }}
</a> </a>
@ -17,7 +17,7 @@
</p> </p>
<ul class="menu sub-menu inset-list @if($bookChild->matchesOrContains($current)) open @endif"> <ul class="menu sub-menu inset-list @if($bookChild->matchesOrContains($current)) open @endif">
@foreach($bookChild->pages as $childPage) @foreach($bookChild->pages as $childPage)
<li class="list-item-page"> <li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}">
<a href="{{$childPage->getUrl()}}" class="page {{ $current->matches($childPage)? 'selected' : '' }}"> <a href="{{$childPage->getUrl()}}" class="page {{ $current->matches($childPage)? 'selected' : '' }}">
<i class="zmdi zmdi-file-text"></i> {{ $childPage->name }} <i class="zmdi zmdi-file-text"></i> {{ $childPage->name }}
</a> </a>

View File

@ -88,8 +88,11 @@ class EntityTest extends TestCase
$this->asAdmin() $this->asAdmin()
// Navigate to page create form // Navigate to page create form
->visit($chapter->getUrl()) ->visit($chapter->getUrl())
->click('New Page') ->click('New Page');
->seePageIs($chapter->getUrl() . '/create-page')
$draftPage = \BookStack\Page::where('draft', '=', true)->orderBy('created_at', 'desc')->first();
$this->seePageIs($draftPage->getUrl())
// Fill out form // Fill out form
->type($page->name, '#name') ->type($page->name, '#name')
->type($page->html, '#html') ->type($page->html, '#html')

View File

@ -1,7 +1,7 @@
<?php <?php
class PageUpdateDraftTest extends TestCase class PageDraftTest extends TestCase
{ {
protected $page; protected $page;
protected $pageRepo; protected $pageRepo;
@ -59,4 +59,33 @@ class PageUpdateDraftTest extends TestCase
->see('Admin has started editing this page'); ->see('Admin has started editing this page');
} }
public function test_draft_pages_show_on_homepage()
{
$book = \BookStack\Book::first();
$this->asAdmin()->visit('/')
->dontSeeInElement('#recent-drafts', 'New Page')
->visit($book->getUrl() . '/page/create')
->visit('/')
->seeInElement('#recent-drafts', 'New Page');
}
public function test_draft_pages_not_visible_by_others()
{
$book = \BookStack\Book::first();
$chapter = $book->chapters->first();
$newUser = $this->getNewUser();
$this->actingAs($newUser)->visit('/')
->visit($book->getUrl() . '/page/create')
->visit($chapter->getUrl() . '/create-page')
->visit($book->getUrl())
->seeInElement('.page-list', 'New Page');
$this->asAdmin()
->visit($book->getUrl())
->dontSeeInElement('.page-list', 'New Page')
->visit($chapter->getUrl())
->dontSeeInElement('.page-list', 'New Page');
}
} }

View File

@ -392,14 +392,28 @@ class RolesTest extends TestCase
$baseUrl = $ownBook->getUrl() . '/page'; $baseUrl = $ownBook->getUrl() . '/page';
$this->checkAccessPermission('page-create-own', [ $createUrl = $baseUrl . '/create';
$baseUrl . '/create', $createUrlChapter = $ownChapter->getUrl() . '/create-page';
$ownChapter->getUrl() . '/create-page' $accessUrls = [$createUrl, $createUrlChapter];
], [
foreach ($accessUrls as $url) {
$this->actingAs($this->user)->visit('/')->visit($url)
->seePageIs('/');
}
$this->checkAccessPermission('page-create-own', [], [
$ownBook->getUrl() => 'New Page', $ownBook->getUrl() => 'New Page',
$ownChapter->getUrl() => 'New Page' $ownChapter->getUrl() => 'New Page'
]); ]);
$this->giveUserPermissions($this->user, ['page-create-own']);
foreach ($accessUrls as $index => $url) {
$this->actingAs($this->user)->visit('/')->visit($url);
$expectedUrl = \BookStack\Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
$this->seePageIs($expectedUrl);
}
$this->visit($baseUrl . '/create') $this->visit($baseUrl . '/create')
->type('test page', 'name') ->type('test page', 'name')
->type('page desc', 'html') ->type('page desc', 'html')
@ -421,14 +435,29 @@ class RolesTest extends TestCase
$book = \BookStack\Book::take(1)->get()->first(); $book = \BookStack\Book::take(1)->get()->first();
$chapter = \BookStack\Chapter::take(1)->get()->first(); $chapter = \BookStack\Chapter::take(1)->get()->first();
$baseUrl = $book->getUrl() . '/page'; $baseUrl = $book->getUrl() . '/page';
$this->checkAccessPermission('page-create-all', [ $createUrl = $baseUrl . '/create';
$baseUrl . '/create',
$chapter->getUrl() . '/create-page' $createUrlChapter = $chapter->getUrl() . '/create-page';
], [ $accessUrls = [$createUrl, $createUrlChapter];
foreach ($accessUrls as $url) {
$this->actingAs($this->user)->visit('/')->visit($url)
->seePageIs('/');
}
$this->checkAccessPermission('page-create-all', [], [
$book->getUrl() => 'New Page', $book->getUrl() => 'New Page',
$chapter->getUrl() => 'New Page' $chapter->getUrl() => 'New Page'
]); ]);
$this->giveUserPermissions($this->user, ['page-create-all']);
foreach ($accessUrls as $index => $url) {
$this->actingAs($this->user)->visit('/')->visit($url);
$expectedUrl = \BookStack\Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
$this->seePageIs($expectedUrl);
}
$this->visit($baseUrl . '/create') $this->visit($baseUrl . '/create')
->type('test page', 'name') ->type('test page', 'name')
->type('page desc', 'html') ->type('page desc', 'html')