diff options
author | korelstar <korelstar@users.noreply.github.com> | 2020-04-12 20:26:57 +0300 |
---|---|---|
committer | korelstar <korelstar@users.noreply.github.com> | 2020-04-13 09:49:40 +0300 |
commit | ffe6a02947d40d9b5bdb7ed93b9b54df5d690cc1 (patch) | |
tree | de4c425076f188c650f492eeb5c3c4a42cd8c78c | |
parent | 02fdcee3774a5265e3dbc5d22f11eddc5b7b1747 (diff) |
maintenance and new API v1
27 files changed, 1057 insertions, 1013 deletions
@@ -47,7 +47,7 @@ appstore: clean lint build-js-production ### from vueexample -all: dev-setup lint build-js-production test +all: dev-setup build-js-production # Dev env management dev-setup: clean clean-dev init diff --git a/appinfo/routes.php b/appinfo/routes.php index 59af979a..1c43732b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1,16 +1,6 @@ <?php -/** - * Nextcloud - Notes - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. - * - * @author Bernhard Posselt <dev@bernhard-posselt.com> - * @copyright Bernhard Posselt 2012, 2014 - */ - return ['routes' => [ - // page + ////////// P A G E ////////// [ 'name' => 'page#index', 'url' => '/', @@ -30,7 +20,8 @@ return ['routes' => [ 'requirements' => ['id' => '\d+'], ], - // notes + + ////////// N O T E S ////////// [ 'name' => 'notes#index', 'url' => '/notes', @@ -59,22 +50,13 @@ return ['routes' => [ 'requirements' => ['id' => '\d+'], ], [ - 'name' => 'notes#category', - 'url' => '/notes/{id}/category', - 'verb' => 'PUT', - 'requirements' => ['id' => '\d+'], - ], - [ - 'name' => 'notes#title', - 'url' => '/notes/{id}/title', - 'verb' => 'PUT', - 'requirements' => ['id' => '\d+'], - ], - [ - 'name' => 'notes#favorite', - 'url' => '/notes/{id}/favorite', + 'name' => 'notes#updateProperty', + 'url' => '/notes/{id}/{property}', 'verb' => 'PUT', - 'requirements' => ['id' => '\d+'], + 'requirements' => [ + 'id' => '\d+', + 'property' => '(modified|title|category|favorite)', + ], ], [ 'name' => 'notes#destroy', @@ -83,43 +65,88 @@ return ['routes' => [ 'requirements' => ['id' => '\d+'], ], - // api + + ////////// S E T T I N G S ////////// + ['name' => 'settings#set', 'url' => '/settings', 'verb' => 'PUT'], + ['name' => 'settings#get', 'url' => '/settings', 'verb' => 'GET'], + + + ////////// A P I ////////// [ 'name' => 'notes_api#index', - 'url' => '/api/v0.2/notes', + 'url' => '/api/{apiVersion}/notes', 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => '(v0.2|v1)', + ], ], [ 'name' => 'notes_api#get', - 'url' => '/api/v0.2/notes/{id}', + 'url' => '/api/{apiVersion}/notes/{id}', 'verb' => 'GET', - 'requirements' => ['id' => '\d+'], + 'requirements' => [ + 'apiVersion' => '(v0.2|v1)', + 'id' => '\d+', + ], + ], + [ + 'name' => 'notes_api#createAutoTitle', + 'url' => '/api/{apiVersion}/notes', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => '(v0.2)', + ], ], [ 'name' => 'notes_api#create', - 'url' => '/api/v0.2/notes', + 'url' => '/api/{apiVersion}/notes', 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => '(v1)', + ], + ], + [ + 'name' => 'notes_api#updateAutoTitle', + 'url' => '/api/{apiVersion}/notes/{id}', + 'verb' => 'PUT', + 'requirements' => [ + 'apiVersion' => '(v0.2)', + 'id' => '\d+', + ], ], [ 'name' => 'notes_api#update', - 'url' => '/api/v0.2/notes/{id}', + 'url' => '/api/{apiVersion}/notes/{id}', 'verb' => 'PUT', - 'requirements' => ['id' => '\d+'], + 'requirements' => [ + 'apiVersion' => '(v1)', + 'id' => '\d+', + ], ], [ 'name' => 'notes_api#destroy', - 'url' => '/api/v0.2/notes/{id}', + 'url' => '/api/{apiVersion}/notes/{id}', 'verb' => 'DELETE', - 'requirements' => ['id' => '\d+'], + 'requirements' => [ + 'apiVersion' => '(v0.2|v1)', + 'id' => '\d+', + ], + ], + [ + 'name' => 'notes_api#fail', + 'url' => '/api/{catchAll}', + 'verb' => 'GET', + 'requirements' => [ + 'catchAll' => '.*', + ], ], [ 'name' => 'notes_api#preflighted_cors', - 'url' => '/api/v0.2/{path}', + 'url' => '/api/{apiVersion}/{path}', 'verb' => 'OPTIONS', - 'requirements' => ['path' => '.+'], + 'requirements' => [ + 'apiVersion' => '(v0.2|v1)', + 'path' => '.+', + ], ], - - // settings - ['name' => 'settings#set', 'url' => '/settings', 'verb' => 'PUT'], - ['name' => 'settings#get', 'url' => '/settings', 'verb' => 'GET'], ]]; diff --git a/lib/Application.php b/lib/Application.php index 1f9010a6..a92bf5f7 100644 --- a/lib/Application.php +++ b/lib/Application.php @@ -6,6 +6,8 @@ use OCP\AppFramework\App; class Application extends App { + public static $API_VERSIONS = [ '0.2', '1.0' ]; + public function __construct(array $urlParams = []) { parent::__construct('notes', $urlParams); } diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 0f05aa78..8f4a0010 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -9,7 +9,7 @@ class Capabilities implements ICapability { public function getCapabilities() { return [ 'notes' => [ - 'api_version' => [ '0.2' ], + 'api_version' => Application::$API_VERSIONS, ], ]; } diff --git a/lib/Controller/Errors.php b/lib/Controller/Errors.php deleted file mode 100644 index 138f05d8..00000000 --- a/lib/Controller/Errors.php +++ /dev/null @@ -1,27 +0,0 @@ -<?php - -namespace OCA\Notes\Controller; - -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\DataResponse; - -use OCA\Notes\Service\NoteDoesNotExistException; - -/** - * Class Errors - * - * @package OCA\Notes\Controller - */ -trait Errors { - /** - * @param $callback - * @return DataResponse - */ - protected function respond($callback) { - try { - return new DataResponse($callback()); - } catch (NoteDoesNotExistException $ex) { - return new DataResponse([], Http::STATUS_NOT_FOUND); - } - } -} diff --git a/lib/Controller/Helper.php b/lib/Controller/Helper.php new file mode 100644 index 00000000..7eaf1dbe --- /dev/null +++ b/lib/Controller/Helper.php @@ -0,0 +1,43 @@ +<?php declare(strict_types=1); + +namespace OCA\Notes\Controller; + +use OCA\Notes\Application; +use OCA\Notes\Service\InsufficientStorageException; +use OCA\Notes\Service\NoteDoesNotExistException; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\ILogger; + +class Helper { + + private $logger; + private $appName; + + public function __construct( + ILogger $logger, + string $appName + ) { + $this->logger = $logger; + $this->appName = $appName; + } + + public function handleErrorResponse(callable $respond) : DataResponse { + try { + $data = $respond(); + $response = $data instanceof DataResponse ? $data : new DataResponse($data); + } catch (NoteDoesNotExistException $e) { + $this->logger->logException($e, [ 'app' => $this->appName ]); + $response = new DataResponse([], Http::STATUS_NOT_FOUND); + } catch (InsufficientStorageException $e) { + $this->logger->logException($e, [ 'app' => $this->appName ]); + $response = new DataResponse([], Http::STATUS_INSUFFICIENT_STORAGE); + } catch (\Throwable $e) { + $this->logger->logException($e, [ 'app' => $this->appName ]); + $response = new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $response->addHeader('X-Notes-API-Versions', implode(', ', Application::$API_VERSIONS)); + return $response; + } +} diff --git a/lib/Controller/NotesApiController.php b/lib/Controller/NotesApiController.php index 52b553e2..f8e62ef3 100644 --- a/lib/Controller/NotesApiController.php +++ b/lib/Controller/NotesApiController.php @@ -1,106 +1,74 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Controller; +use OCA\Notes\Service\NotesService; +use OCA\Notes\Service\MetaService; + use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; use OCP\IUserSession; -use OCA\Notes\Service\NotesService; -use OCA\Notes\Service\MetaService; -use OCA\Notes\Service\InsufficientStorageException; -use OCA\Notes\Service\NoteDoesNotExistException; -use OCA\Notes\Db\Note; - -/** - * Class NotesApiController - * - * @package OCA\Notes\Controller - */ class NotesApiController extends ApiController { - use Errors; - /** @var NotesService */ private $service; /** @var MetaService */ private $metaService; + /** @var Helper */ + private $helper; /** @var IUserSession */ private $userSession; - /** - * @param string $AppName - * @param IRequest $request - * @param NotesService $service - * @param IUserSession $userSession - */ public function __construct( - $AppName, + string $AppName, IRequest $request, NotesService $service, MetaService $metaService, + Helper $helper, IUserSession $userSession ) { parent::__construct($AppName, $request); $this->service = $service; $this->metaService = $metaService; + $this->helper = $helper; $this->userSession = $userSession; } - private function getUID() { + private function getUID() : string { return $this->userSession->getUser()->getUID(); } - /** - * @param Note $note - * @param string[] $exclude the fields that should be removed from the - * notes - * @return Note - */ - private function excludeFields(Note &$note, array $exclude) { - if (count($exclude) > 0) { - foreach ($exclude as $field) { - if (property_exists($note, $field)) { - unset($note->$field); - } - } - } - return $note; - } - /** * @NoAdminRequired * @CORS * @NoCSRFRequired - * - * @param string $exclude - * @return DataResponse */ - public function index($exclude = '', $pruneBefore = 0) { - $exclude = explode(',', $exclude); - $now = new \DateTime(); // this must be before loading notes if there are concurrent changes possible - $notes = $this->service->getAll($this->getUID()); - $metas = $this->metaService->updateAll($this->getUID(), $notes); - foreach ($notes as $note) { - $lastUpdate = $metas[$note->getId()]->getLastUpdate(); - if ($pruneBefore && $lastUpdate<$pruneBefore) { - $vars = get_object_vars($note); - unset($vars['id']); - $this->excludeFields($note, array_keys($vars)); - } else { - $this->excludeFields($note, $exclude); + public function index(string $exclude = '', int $pruneBefore = 0) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($exclude, $pruneBefore) { + $exclude = explode(',', $exclude); + $now = new \DateTime(); // this must be before loading notes if there are concurrent changes possible + $notes = $this->service->getAll($this->getUID()); + $metas = $this->metaService->updateAll($this->getUID(), $notes); + $notesData = array_map(function ($note) use ($metas, $pruneBefore, $exclude) { + $lastUpdate = $metas[$note->getId()]->getLastUpdate(); + if ($pruneBefore && $lastUpdate<$pruneBefore) { + return [ 'id' => $note->getId() ]; + } else { + return $note->getData($exclude); + } + }, $notes); + $etag = md5(json_encode($notesData)); + if ($this->request->getHeader('If-None-Match') === '"'.$etag.'"') { + return new DataResponse([], Http::STATUS_NOT_MODIFIED); } - } - $etag = md5(json_encode($notes)); - if ($this->request->getHeader('If-None-Match') === '"'.$etag.'"') { - return new DataResponse([], Http::STATUS_NOT_MODIFIED); - } - return (new DataResponse($notes)) - ->setLastModified($now) - ->setETag($etag); + return (new DataResponse($notesData)) + ->setLastModified($now) + ->setETag($etag); + }); } @@ -108,20 +76,13 @@ class NotesApiController extends ApiController { * @NoAdminRequired * @CORS * @NoCSRFRequired - * - * @param int $id - * @param string $exclude - * @return DataResponse */ - public function get($id, $exclude = '') { - try { + public function get(int $id, string $exclude = '') : DataResponse { + return $this->helper->handleErrorResponse(function () use ($id, $exclude) { $exclude = explode(',', $exclude); - $note = $this->service->get($id, $this->getUID()); - $note = $this->excludeFields($note, $exclude); - return new DataResponse($note); - } catch (NoteDoesNotExistException $e) { - return new DataResponse([], Http::STATUS_NOT_FOUND); - } + $note = $this->service->get($this->getUID(), $id); + return $note->getData($exclude); + }); } @@ -129,86 +90,134 @@ class NotesApiController extends ApiController { * @NoAdminRequired * @CORS * @NoCSRFRequired - * - * @param string $content - * @param string $category - * @param int $modified - * @param boolean $favorite - * @return DataResponse */ - public function create($content, $category = null, $modified = 0, $favorite = null) { - try { - $note = $this->service->create($this->getUID()); + public function create( + string $category = '', + string $title = '', + string $content = '', + int $modified = 0, + bool $favorite = false + ) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($category, $title, $content, $modified, $favorite) { + $note = $this->service->create($this->getUID(), $title, $category); try { - $note = $this->updateData($note->getId(), $content, $category, $modified, $favorite); + $note->setContent($content); + if ($modified) { + $note->setModified($modified); + } + if ($favorite) { + $note->setFavorite($favorite); + } } catch (\Throwable $e) { // roll-back note creation - $this->service->delete($note->getId(), $this->getUID()); + $this->service->delete($this->getUID(), $note->getId()); throw $e; } - return new DataResponse($note); - } catch (InsufficientStorageException $e) { - return new DataResponse([], Http::STATUS_INSUFFICIENT_STORAGE); - } + return $note->getData(); + }); } + /** + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + * @deprecated this was used in API v0.2 only, use #create() instead + */ + public function createAutoTitle( + string $category = '', + string $content = '', + int $modified = 0, + bool $favorite = false + ) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($category, $content, $modified, $favorite) { + $title = $this->service->getTitleFromContent($content); + return $this->create($category, $title, $content, $modified, $favorite); + }); + } /** * @NoAdminRequired * @CORS * @NoCSRFRequired - * - * @param int $id - * @param string $content - * @param string $category - * @param int $modified - * @param boolean $favorite - * @return DataResponse */ - public function update($id, $content = null, $category = null, $modified = 0, $favorite = null) { - try { - $note = $this->updateData($id, $content, $category, $modified, $favorite); - return new DataResponse($note); - } catch (NoteDoesNotExistException $e) { - return new DataResponse([], Http::STATUS_NOT_FOUND); - } catch (InsufficientStorageException $e) { - return new DataResponse([], Http::STATUS_INSUFFICIENT_STORAGE); - } + public function update( + int $id, + ?string $content = null, + ?int $modified = null, + ?string $title = null, + ?string $category = null, + ?bool $favorite = null + ) : DataResponse { + return $this->helper->handleErrorResponse(function () use ( + $id, + $content, + $modified, + $title, + $category, + $favorite + ) { + $note = $this->service->get($this->getUID(), $id); + if ($content !== null) { + $note->setContent($content); + } + if ($modified !== null) { + $note->setModified($modified); + } + if ($title !== null) { + $note->setTitleCategory($title, $category); + } elseif ($category !== null) { + $note->setCategory($category); + } + if ($favorite !== null) { + $note->setFavorite($favorite); + } + return $note->getData(); + }); } /** - * Updates a note, used by create and update - * @param int $id - * @param string|null $content - * @param int $modified - * @param boolean|null $favorite - * @return Note + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + * @deprecated this was used in API v0.2 only, use #update() instead */ - private function updateData($id, $content, $category, $modified, $favorite) { - if ($favorite!==null) { - $this->service->favorite($id, $favorite, $this->getUID()); - } - if ($content===null) { - return $this->service->get($id, $this->getUID()); - } else { - return $this->service->update($id, $content, $this->getUID(), $category, $modified); - } + public function updateAutoTitle( + int $id, + ?string $content = null, + ?int $modified = null, + ?string $category = null, + ?bool $favorite = null + ) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($id, $content, $modified, $category, $favorite) { + if ($content === null) { + $note = $this->service->get($this->getUID(), $id); + $title = $this->service->getTitleFromContent($note->getContent()); + } else { + $title = $this->service->getTitleFromContent($content); + } + return $this->update($id, $content, $modified, $title, $category, $favorite); + }); } /** * @NoAdminRequired * @CORS * @NoCSRFRequired - * - * @param int $id - * @return DataResponse */ - public function destroy($id) { - try { - $this->service->delete($id, $this->getUID()); - return new DataResponse([]); - } catch (NoteDoesNotExistException $e) { - return new DataResponse([], Http::STATUS_NOT_FOUND); - } + public function destroy(int $id) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($id) { + $this->service->delete($this->getUID(), $id); + return []; + }); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function fail() : DataResponse { + return $this->helper->handleErrorResponse(function () { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + }); } } diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php index 13d22697..c2be6e73 100644 --- a/lib/Controller/NotesController.php +++ b/lib/Controller/NotesController.php @@ -1,7 +1,10 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Controller; +use OCA\Notes\Service\NotesService; +use OCA\Notes\Service\SettingsService; + use OCP\AppFramework\Controller; use OCP\IRequest; use OCP\IConfig; @@ -9,23 +12,14 @@ use OCP\IL10N; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; -use OCA\Notes\Service\NotesService; -use OCA\Notes\Service\SettingsService; -use OCA\Notes\Service\InsufficientStorageException; - -/** - * Class NotesController - * - * @package OCA\Notes\Controller - */ class NotesController extends Controller { - use Errors; - /** @var NotesService */ private $notesService; /** @var SettingsService */ private $settingsService; + /** @var Helper */ + private $helper; /** @var IConfig */ private $settings; /** @var string */ @@ -33,27 +27,20 @@ class NotesController extends Controller { /** @var IL10N */ private $l10n; - /** - * @param string $AppName - * @param IRequest $request - * @param NotesService $notesService - * @param SettingsService $settingsService - * @param IConfig $settings - * @param IL10N $l10n - * @param string $UserId - */ public function __construct( - $AppName, + string $AppName, IRequest $request, NotesService $notesService, SettingsService $settingsService, + Helper $helper, IConfig $settings, IL10N $l10n, - $UserId + string $UserId ) { parent::__construct($AppName, $request); $this->notesService = $notesService; $this->settingsService = $settingsService; + $this->helper = $helper; $this->settings = $settings; $this->userId = $UserId; $this->l10n = $l10n; @@ -63,176 +50,199 @@ class NotesController extends Controller { /** * @NoAdminRequired */ - public function index() { - $settings = $this->settingsService->getAll($this->userId); - - $errorMessage = null; - $lastViewedNote = (int) $this->settings->getUserValue( - $this->userId, - $this->appName, - 'notesLastViewedNote' - ); - // check if notes folder is accessible - $notes = null; - try { - $notes = $this->notesService->getAll($this->userId, true); - if ($lastViewedNote) { - // check if note exists - try { - $this->notesService->get($lastViewedNote, $this->userId); - } catch (\Exception $ex) { - $this->settings->deleteUserValue($this->userId, $this->appName, 'notesLastViewedNote'); - $lastViewedNote = 0; - $errorMessage = $this->l10n->t('The last viewed note cannot be accessed. ').$ex->getMessage(); + public function index() : DataResponse { + return $this->helper->handleErrorResponse(function () { + $settings = $this->settingsService->getAll($this->userId); + + $errorMessage = null; + $lastViewedNote = (int) $this->settings->getUserValue( + $this->userId, + $this->appName, + 'notesLastViewedNote' + ); + // check if notes folder is accessible + $notes = null; + try { + $notes = $this->notesService->getAll($this->userId); + $notesData = array_map(function ($note) { + return $note->getData([ 'content' ]); + }, $notes); + if ($lastViewedNote) { + // check if note exists + try { + $this->notesService->get($this->userId, $lastViewedNote); + } catch (\Exception $ex) { + $this->settings->deleteUserValue($this->userId, $this->appName, 'notesLastViewedNote'); + $lastViewedNote = 0; + $errorMessage = $this->l10n->t('The last viewed note cannot be accessed. ').$ex->getMessage(); + } } + } catch (\Exception $e) { + $errorMessage = $this->l10n->t('The notes folder is not accessible: %s', $e->getMessage()); } - } catch (\Exception $e) { - $errorMessage = $this->l10n->t('The notes folder is not accessible: %s', $e->getMessage()); - } - - return new DataResponse([ - 'notes' => $notes, - 'settings' => $settings, - 'lastViewedNote' => $lastViewedNote, - 'errorMessage' => $errorMessage, - ]); - } - - /** - * @NoAdminRequired - * - * @param int $id - * @return DataResponse - */ - public function get($id) { - // save the last viewed note - $this->settings->setUserValue( - $this->userId, - $this->appName, - 'notesLastViewedNote', - strval($id) - ); - - $note = $this->notesService->get($id, $this->userId); - return new DataResponse($note); + return [ + 'notes' => $notesData, + 'settings' => $settings, + 'lastViewedNote' => $lastViewedNote, + 'errorMessage' => $errorMessage, + ]; + }); } /** * @NoAdminRequired - * - * @param string $content */ - public function create($content = '', $category = null) { - try { - $note = $this->notesService->create($this->userId); - $note = $this->notesService->update( - $note->getId(), - $content, - $this->userId, - $category - ); - return new DataResponse($note); - } catch (InsufficientStorageException $e) { - return new DataResponse([], Http::STATUS_INSUFFICIENT_STORAGE); - } - } - + public function get(int $id) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($id) { + $note = $this->notesService->get($this->userId, $id); - /** - * @NoAdminRequired - * - * @param string $content - */ - public function undo($id, $content, $category, $modified, $favorite) { - try { - // check if note still exists - $note = $this->notesService->get($id, $this->userId); - if ($note->getError()) { - throw new \Exception(); - } - } catch (\Throwable $e) { - // re-create if note doesn't exit anymore - $note = $this->notesService->create($this->userId); - $note = $this->notesService->update( - $note->getId(), - $content, + // save the last viewed note + $this->settings->setUserValue( $this->userId, - $category, - $modified + $this->appName, + 'notesLastViewedNote', + strval($id) ); - $note->favorite = $this->notesService->favorite($note->getId(), $favorite, $this->userId); - } - return new DataResponse($note); + + return $note->getData(); + }); } /** * @NoAdminRequired */ - public function update(int $id, string $content, bool $autotitle) : DataResponse { - try { - if ($autotitle) { - $note = $this->notesService->update($id, $content, $this->userId); - } else { - $note = $this->notesService->setContent($this->userId, $id, $content); - } - return new DataResponse($note); - } catch (InsufficientStorageException $e) { - return new DataResponse([], Http::STATUS_INSUFFICIENT_STORAGE); - } + public function create(string $category) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($category) { + $note = $this->notesService->create($this->userId, '', $category); + $note->setContent(''); + return $note->getData(); + }); } - /** * @NoAdminRequired - * - * @param int $id - * @param string $category - * @return DataResponse */ - public function category($id, $category) { - $note = $this->notesService->setTitleCategory($this->userId, $id, null, $category); - return new DataResponse($note->getCategory()); // @phan-suppress-current-line PhanTypeMismatchArgument + public function undo( + int $id, + string $title, + string $content, + string $category, + int $modified, + bool $favorite + ) : DataResponse { + return $this->helper->handleErrorResponse(function () use ( + $id, + $title, + $content, + $category, + $modified, + $favorite + ) { + try { + // check if note still exists + $note = $this->notesService->get($this->userId, $id); + $noteData = $note->getData(); + if ($noteData['error']) { + throw new \Exception(); + } + return $noteData; + } catch (\Throwable $e) { + // re-create if note doesn't exit anymore + $note = $this->notesService->create($this->userId, $title, $category); + $note->setContent($content); + $note->setModified($modified); + $note->setFavorite($favorite); + return $note->getData(); + } + }); } /** * @NoAdminRequired - * - * @param int $id - * @param string $title - * @return DataResponse */ - public function title($id, $title) { - $note = $this->notesService->setTitleCategory($this->userId, $id, $title, null); - return new DataResponse($note->getTitle()); // @phan-suppress-current-line PhanTypeMismatchArgument + public function update(int $id, string $content, bool $autotitle) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($id, $content, $autotitle) { + $note = $this->notesService->get($this->userId, $id); + $note->setContent($content); + if ($autotitle) { + $title = $this->notesService->getTitleFromContent($content); + $note->setTitle($title); + } + return $note->getData(); + }); } /** * @NoAdminRequired - * - * @param int $id - * @param boolean $favorite - * @return DataResponse */ - public function favorite($id, $favorite) { - $result = $this->notesService->favorite($id, $favorite, $this->userId); - return new DataResponse($result); // @phan-suppress-current-line PhanTypeMismatchArgument + public function updateProperty( + int $id, + string $property, + ?int $modified = null, + ?string $title = null, + ?string $category = null, + ?bool $favorite = null + ) : DataResponse { + return $this->helper->handleErrorResponse(function () use ( + $id, + $property, + $modified, + $title, + $category, + $favorite + ) { + $note = $this->notesService->get($this->userId, $id); + $result = null; + switch ($property) { + case 'modified': + if ($modified !== null) { + $note->setModified($modified); + } + $result = $note->getModified(); + break; + + case 'title': + if ($title !== null) { + $note->setTitle($title); + } + $result = $note->getTitle(); + break; + + case 'category': + if ($category !== null) { + $note->setCategory($category); + } + $result = $note->getCategory(); + break; + + case 'favorite': + if ($favorite !== null) { + $note->setFavorite($favorite); + } + $result = $note->getFavorite(); + break; + + default: + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + return $result; + }); } /** * @NoAdminRequired - * - * @param int $id - * @return DataResponse */ - public function destroy($id) { - $this->notesService->delete($id, $this->userId); - return new DataResponse([]); + public function destroy(int $id) : DataResponse { + return $this->helper->handleErrorResponse(function () use ($id) { + $this->notesService->delete($this->userId, $id); + return []; + }); } } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index e595116e..03fe7896 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -1,4 +1,4 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Controller; @@ -7,18 +7,9 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\IRequest; -/** - * Class PageController - * - * @package OCA\Notes\Controller - */ class PageController extends Controller { - /** - * @param string $AppName - * @param IRequest $request - */ - public function __construct($AppName, IRequest $request) { + public function __construct(string $AppName, IRequest $request) { parent::__construct($AppName, $request); } @@ -29,7 +20,7 @@ class PageController extends Controller { * * @return TemplateResponse */ - public function index() { + public function index() : TemplateResponse { $devMode = !is_file(dirname(__FILE__).'/../../js/notes.js'); $response = new TemplateResponse( $this->appName, diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 19b00b79..681e7763 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -1,12 +1,13 @@ -<?php +<?php declare(strict_types=1); + namespace OCA\Notes\Controller; -use OCP\AppFramework\Controller; +use OCA\Notes\Service\SettingsService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; use OCP\IUserSession; -use OCP\AppFramework\Http\JSONResponse; -use OCA\Notes\Service\SettingsService; class SettingsController extends Controller { @@ -14,7 +15,7 @@ class SettingsController extends Controller { private $userSession; public function __construct( - $appName, + string $appName, IRequest $request, SettingsService $service, IUserSession $userSession diff --git a/lib/Db/Meta.php b/lib/Db/Meta.php index e970ed00..9144bea8 100644 --- a/lib/Db/Meta.php +++ b/lib/Db/Meta.php @@ -2,6 +2,8 @@ namespace OCA\Notes\Db; +use OCA\Notes\Service\Note; + use OCP\AppFramework\Db\Entity; /** diff --git a/lib/Db/Note.php b/lib/Db/Note.php deleted file mode 100644 index 69411de1..00000000 --- a/lib/Db/Note.php +++ /dev/null @@ -1,123 +0,0 @@ -<?php - -namespace OCA\Notes\Db; - -use OCP\Files\File; -use OCP\Files\Folder; -use OCP\AppFramework\Db\Entity; - -/** - * Class Note - * @method integer getId() - * @method void setId(integer $value) - * @method string getEtag() - * @method void setEtag(string $value) - * @method integer getModified() - * @method void setModified(integer $value) - * @method string getTitle() - * @method void setTitle(string $value) - * @method string getCategory() - * @method void setCategory(string $value) - * @method string getContent() - * @method void setContent(string $value) - * @method boolean getFavorite() - * @method void setFavorite(boolean $value) - * @method boolean getError() - * @method void setError(boolean $value) - * @method string getErrorMessage() - * @method void setErrorMessage(string $value) - * @package OCA\Notes\Db - */ -class Note extends Entity { - public $etag; - public $modified; - public $title; - public $category; - public $content = null; - public $favorite = false; - public $error = false; - public $errorMessage=''; - - public function __construct() { - $this->addType('modified', 'integer'); - $this->addType('favorite', 'boolean'); - } - - /** - * @param File $file - * @return static - */ - public static function fromFile(File $file, Folder $notesFolder, $tags = [], $onlyMeta = false) { - $note = new static(); - $note->initCommonBaseFields($file, $notesFolder, $tags); - if (!$onlyMeta) { - $fileContent=$file->getContent(); - $note->setContent(self::convertEncoding($fileContent)); - } - if (!$onlyMeta) { - $note->updateETag(); - } - $note->resetUpdatedFields(); - return $note; - } - - /** - * @param File $file - * @return static - */ - public static function fromException($message, File $file, Folder $notesFolder, $tags = []) { - $note = new static(); - $note->initCommonBaseFields($file, $notesFolder, $tags); - $note->setErrorMessage($message); - $note->setError(true); - $note->setContent($message); - $note->resetUpdatedFields(); - return $note; - } - - private static function convertEncoding($str) { - if (!mb_check_encoding($str, 'UTF-8')) { - $str = mb_convert_encoding($str, 'UTF-8'); - } - return $str; - } - - // TODO NC19: replace this by OCP\ITags::TAG_FAVORITE - // OCP\ITags::TAG_FAVORITE was introduced in NC19 - // https://github.com/nextcloud/server/pull/19412 - /** - * @suppress PhanUndeclaredClassConstant - * @suppress PhanUndeclaredConstant - * @suppress PhanUndeclaredConstantOfClass - */ - private static function getTagFavorite() { - if (defined('OCP\ITags::TAG_FAVORITE')) { - return \OCP\ITags::TAG_FAVORITE; - } else { - return \OC\Tags::TAG_FAVORITE; - } - } - - private function initCommonBaseFields(File $file, Folder $notesFolder, $tags) { - $this->setId($file->getId()); - $this->setTitle(pathinfo($file->getName(), PATHINFO_FILENAME)); // remove extension - $this->setModified($file->getMTime()); - $subdir = substr(dirname($file->getPath()), strlen($notesFolder->getPath())+1); - $this->setCategory($subdir ? $subdir : ''); - if (is_array($tags) && in_array(self::getTagFavorite(), $tags)) { - $this->setFavorite(true); - } - } - - private function updateETag() { - // collect all relevant attributes - $data = ''; - foreach (get_object_vars($this) as $key => $val) { - if ($key!=='etag') { - $data .= $val; - } - } - $etag = md5($data); - $this->setEtag($etag); - } -} diff --git a/lib/Service/InsufficientStorageException.php b/lib/Service/InsufficientStorageException.php index d62cd6a3..78821d3b 100644 --- a/lib/Service/InsufficientStorageException.php +++ b/lib/Service/InsufficientStorageException.php @@ -1,4 +1,4 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; diff --git a/lib/Service/MetaService.php b/lib/Service/MetaService.php index 337ea03d..e4da1333 100644 --- a/lib/Service/MetaService.php +++ b/lib/Service/MetaService.php @@ -1,15 +1,10 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; use OCA\Notes\Db\Meta; use OCA\Notes\Db\MetaMapper; -/** - * Class MetaService - * - * @package OCA\Notes\Service - */ class MetaService { private $metaMapper; @@ -18,7 +13,7 @@ class MetaService { $this->metaMapper = $metaMapper; } - public function updateAll($userId, Array $notes) { + public function updateAll(string $userId, array $notes) : array { $metas = $this->metaMapper->getAll($userId); $metas = $this->getIndexedArray($metas, 'fileId'); $notes = $this->getIndexedArray($notes, 'id'); @@ -42,7 +37,7 @@ class MetaService { return $metas; } - private function getIndexedArray(array $data, $property) { + private function getIndexedArray(array $data, string $property) : array { $property = ucfirst($property); $getter = 'get'.$property; $result = array(); @@ -52,13 +47,13 @@ class MetaService { return $result; } - private function create($userId, $note) { + private function create(string $userId, Note $note) : Meta { $meta = Meta::fromNote($note, $userId); $this->metaMapper->insert($meta); return $meta; } - private function updateIfNeeded(&$meta, $note) { + private function updateIfNeeded(Meta &$meta, Note $note) : void { if ($note->getEtag()!==$meta->getEtag()) { $meta->setEtag($note->getEtag()); $meta->setLastUpdate(time()); diff --git a/lib/Service/Note.php b/lib/Service/Note.php new file mode 100644 index 00000000..1fa3192c --- /dev/null +++ b/lib/Service/Note.php @@ -0,0 +1,141 @@ +<?php declare(strict_types=1); + +namespace OCA\Notes\Service; + +use OCP\Files\File; +use OCP\Files\Folder; + +class Note { + + private $file; + private $notesFolder; + private $noteUtil; + + public function __construct(File $file, Folder $notesFolder, NoteUtil $noteUtil) { + $this->file = $file; + $this->notesFolder = $notesFolder; + $this->noteUtil = $noteUtil; + } + + + public function getId() : int { + return $this->file->getId(); + } + + public function getTitle() : string { + return pathinfo($this->file->getName(), PATHINFO_FILENAME); + } + + public function getCategory() : string { + $subdir = substr( + dirname($this->file->getPath()), + strlen($this->notesFolder->getPath()) + 1 + ); + return $subdir === false ? '' : $subdir; + } + + public function getContent() : string { + $content = $this->file->getContent(); + if (!mb_check_encoding($content, 'UTF-8')) { + $content = mb_convert_encoding($content, 'UTF-8'); + } + return $content; + } + + public function getModified() : int { + return $this->file->getMTime(); + } + + public function getFavorite() : bool { + return $this->noteUtil->getTagService()->isFavorite($this->getId()); + } + + + public function getData(array $exclude = []) : array { + $data = []; + if (!in_array('id', $exclude)) { + $data['id'] = $this->getId(); + } + if (!in_array('title', $exclude)) { + $data['title'] = $this->getTitle(); + } + if (!in_array('modified', $exclude)) { + $data['modified'] = $this->getModified(); + } + if (!in_array('category', $exclude)) { + $data['category'] = $this->getCategory(); + } + if (!in_array('favorite', $exclude)) { + $data['favorite'] = $this->getFavorite(); + } + $data['error'] = false; + $data['errorMessage'] = ''; + if (!in_array('content', $exclude)) { + try { + $data['content'] = $this->getContent(); + } catch (\Throwable $e) { + $message = $this->noteUtil->getL10N()->t('Error').': ('.$this->file->getName().') '.$e->getMessage(); + $data['content'] = $message; + $data['error'] = true; + $data['errorMessage'] = $message; + } + } + return $data; + } + + public function getEtag() : string { + $data = $this->getData(); + // collect all relevant attributes + $str = ''; + foreach ($data as $key => $val) { + $str .= $val; + } + return md5($str); + } + + + public function setTitle(string $title) : void { + $this->setTitleCategory($title); + } + + public function setCategory(string $category) : void { + $this->setTitleCategory($this->getTitle(), $category); + } + + /** + * @throws \OCP\Files\NotPermittedException + */ + public function setTitleCategory(string $title, ?string $category = null) : void { + if ($category===null) { + $category = $this->getCategory(); + } + $oldParent = $this->file->getParent(); + $currentFilePath = $this->noteUtil->getRoot()->getFullPath($this->file->getPath()); + $fileSuffix = '.' . pathinfo($this->file->getName(), PATHINFO_EXTENSION); + + $folder = $this->noteUtil->getCategoryFolder($this->notesFolder, $category); + $filename = $this->noteUtil->generateFileName($folder, $title, $fileSuffix, $this->getId()); + $newFilePath = $folder->getPath() . '/' . $filename; + + // if the current path is not the new path, the file has to be renamed + if ($currentFilePath !== $newFilePath) { + $this->file->move($newFilePath); + } + $this->noteUtil->deleteEmptyFolder($oldParent, $this->notesFolder); + } + + public function setContent(string $content) : void { + $this->noteUtil->ensureSufficientStorage($this->file->getParent(), strlen($content)); + $this->file->putContent($content); + } + + public function setModified(int $modified) : void { + $this->file->touch($modified); + } + + public function setFavorite(bool $favorite) : void { + if ($favorite !== $this->getFavorite()) { + $this->noteUtil->getTagService()->setFavorite($this->getId(), $favorite); + } + } +} diff --git a/lib/Service/NoteDoesNotExistException.php b/lib/Service/NoteDoesNotExistException.php index fc904e5a..25362ac0 100644 --- a/lib/Service/NoteDoesNotExistException.php +++ b/lib/Service/NoteDoesNotExistException.php @@ -1,13 +1,8 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; use Exception; -/** - * Class NoteDoesNotExistException - * - * @package OCA\Notes\Service - */ class NoteDoesNotExistException extends Exception { } diff --git a/lib/Service/NoteUtil.php b/lib/Service/NoteUtil.php index 6f6bf99f..51d34a0a 100644 --- a/lib/Service/NoteUtil.php +++ b/lib/Service/NoteUtil.php @@ -1,109 +1,65 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\IDBConnection; use OCP\IL10N; use OCP\ILogger; -use OCP\Files\IRootFolder; -use OCP\Files\FileInfo; -use OCP\Files\File; -use OCP\Files\Folder; class NoteUtil { private $db; private $l10n; private $root; + private $tagService; + private $cachedTags; private $logger; private $appName; - /** - * @param IDBConnection $db - * @param IRootFolder $root - * @param IL10N $l10n - * @param ILogger $logger - * @param String $appName - */ public function __construct( - IDBConnection $db, IRootFolder $root, + IDBConnection $db, + TagService $tagService, IL10N $l10n, ILogger $logger, - $appName + string $appName ) { - $this->db = $db; $this->root = $root; + $this->db = $db; + $this->tagService = $tagService; $this->l10n = $l10n; $this->logger = $logger; $this->appName = $appName; } - /** - * gather note files in given directory and all subdirectories - */ - public function gatherNoteFiles(Folder $folder) : array { - $notes = []; - $nodes = $folder->getDirectoryListing(); - foreach ($nodes as $node) { - if ($node->getType() === FileInfo::TYPE_FOLDER && $node instanceof Folder) { - $notes = array_merge($notes, $this->gatherNoteFiles($node)); - continue; - } - if ($this->isNote($node)) { - $notes[] = $node; - } - } - return $notes; + public function getRoot() : IRootFolder { + return $this->root; } - - /** - * test if file is a note - */ - public function isNote(FileInfo $file) : bool { - $allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note']; - $ext = strtolower(pathinfo($file->getName(), PATHINFO_EXTENSION)); - return $file->getType() === 'file' && in_array($ext, $allowedExtensions); + public function getTagService() : TagService { + return $this->tagService; } - public function moveNote(Folder $notesFolder, File $file, string $title, ?string $category = null) : void { - $id = $file->getId(); - $title = $this->getSafeTitle($title); - $currentFilePath = $this->root->getFullPath($file->getPath()); - $currentBasePath = pathinfo($currentFilePath, PATHINFO_DIRNAME); - $fileSuffix = '.' . pathinfo($file->getName(), PATHINFO_EXTENSION); - - // detect (new) folder path based on category name - if ($category===null) { - $basePath = $currentBasePath; - } else { - $basePath = $notesFolder->getPath(); - if (!empty($category)) { - // sanitise path - $cats = explode('/', $category); - $cats = array_map([$this, 'sanitisePath'], $cats); - $cats = array_filter($cats, function ($str) { - return !empty($str); - }); - $basePath .= '/'.implode('/', $cats); - } - } - $folder = $this->getOrCreateFolder($basePath); + public function getL10N() : IL10N { + return $this->l10n; + } - // assemble new file path - $newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileSuffix, $id); + public function getLogger() : ILogger { + return $this->logger; + } - // if the current path is not the new path, the file has to be renamed - if ($currentFilePath !== $newFilePath) { - $file->move($newFilePath); - } - if ($currentBasePath !== $basePath) { - $fileBasePath = $this->root->get($currentBasePath); - if ($fileBasePath instanceof Folder) { - $this->deleteEmptyFolder($notesFolder, $fileBasePath); - } - } + public function getCategoryFolder(Folder $notesFolder, string $category) { + $path = $notesFolder->getPath(); + // sanitise path + $cats = explode('/', $category); + $cats = array_map([$this, 'sanitisePath'], $cats); + $cats = array_filter($cats, function ($str) { + return $str !== ''; + }); + $path .= '/'.implode('/', $cats); + return $this->getOrCreateFolder($path); } /** @@ -120,12 +76,13 @@ class NoteUtil { * files with the same title */ public function generateFileName(Folder $folder, string $title, string $suffix, int $id) : string { - $path = $title . $suffix; + $title = $this->getSafeTitle($title); + $filename = $title . $suffix; // if file does not exist, that name has not been taken. Similar we don't // need to handle file collisions if it is the filename did not change - if (!$folder->nodeExists($path) || $folder->get($path)->getId() === $id) { - return $path; + if (!$folder->nodeExists($filename) || $folder->get($filename)->getId() === $id) { + return $filename; } else { // increments name (2) to name (3) $match = preg_match('/\((?P<id>\d+)\)$/u', $title, $matches); @@ -143,15 +100,6 @@ class NoteUtil { } } - public function getSafeTitleFromContent(string $content) : string { - // prepare content: remove markdown characters and empty spaces - $content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item - $content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline - $content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline - $content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis - return $this->getSafeTitle($content); - } - public function getSafeTitle(string $content) : string { // sanitize: prevent directory traversal, illegal characters and unintended file names $content = $this->sanitisePath($content); @@ -172,7 +120,7 @@ class NoteUtil { } /** removes characters that are illegal in a file or folder name on some operating systems */ - public function sanitisePath(string $str) : string { + private function sanitisePath(string $str) : string { // remove characters which are illegal on Windows (includes illegal characters on Unix/Linux) // prevents also directory traversal by eliminiating slashes // see also \OC\Files\Storage\Common::verifyPosixPath(...) @@ -212,10 +160,10 @@ class NoteUtil { /* * Delete a folder and it's parent(s) if it's/they're empty - * @param Folder $notesFolder root folder for notes * @param Folder $folder folder to delete + * @param Folder $notesFolder root notes folder */ - public function deleteEmptyFolder(Folder $notesFolder, Folder $folder) : void { + public function deleteEmptyFolder(Folder $folder, Folder $notesFolder) : void { $content = $folder->getDirectoryListing(); $isEmpty = !count($content); $isNotesFolder = $folder->getPath()===$notesFolder->getPath(); @@ -223,7 +171,7 @@ class NoteUtil { $this->logger->info('Deleting empty category folder '.$folder->getPath(), ['app' => $this->appName]); $parent = $folder->getParent(); $folder->delete(); - $this->deleteEmptyFolder($notesFolder, $parent); + $this->deleteEmptyFolder($parent, $notesFolder); } } diff --git a/lib/Service/NotesFolderException.php b/lib/Service/NotesFolderException.php index 8b98f3de..f6e9b6f8 100644 --- a/lib/Service/NotesFolderException.php +++ b/lib/Service/NotesFolderException.php @@ -1,13 +1,8 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; use Exception; -/** - * Class NotesFolderException - * - * @package OCA\Notes\Service - */ class NotesFolderException extends Exception { } diff --git a/lib/Service/NotesService.php b/lib/Service/NotesService.php index 8d8050ea..3bfbf794 100644 --- a/lib/Service/NotesService.php +++ b/lib/Service/NotesService.php @@ -1,137 +1,61 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; -use OCP\Encryption\Exceptions\GenericEncryptionException; +use OCA\Notes\Service\SettingsService; + use OCP\Files\File; +use OCP\Files\FileInfo; use OCP\Files\Folder; -use OCP\Files\IRootFolder; -use OCP\IConfig; -use OCP\IL10N; -use OCP\ILogger; -use OCP\ITagManager; -use OCA\Notes\Db\Note; -use OCA\Notes\Service\SettingsService; - -/** - * Class NotesService - * - * @package OCA\Notes\Service - */ class NotesService { - private $l10n; - private $root; - private $logger; - private $config; - private $tags; private $settings; private $noteUtil; - private $appName; - /** - * @param IRootFolder $root - * @param IL10N $l10n - * @param ILogger $logger - * @param IConfig $config - * @param ITagManager $tagManager - * @param SettingsService $settings - * @param NoteUtil $noteUtil - * @param String $appName - */ public function __construct( - IRootFolder $root, - IL10N $l10n, - ILogger $logger, - IConfig $config, - ITagManager $tagManager, SettingsService $settings, - NoteUtil $noteUtil, - $appName + NoteUtil $noteUtil ) { - $this->root = $root; - $this->l10n = $l10n; - $this->logger = $logger; - $this->config = $config; - $this->tags = $tagManager->load('files'); $this->settings = $settings; $this->noteUtil = $noteUtil; - $this->appName = $appName; } - - /** - * @param string $userId - * @return array with all notes in the current directory - */ - public function getAll(string $userId, bool $onlyMeta = false) { - $notesFolder = $this->getFolderForUser($userId); - $notes = $this->noteUtil->gatherNoteFiles($notesFolder); - $filesById = []; - foreach ($notes as $note) { - $filesById[$note->getId()] = $note; - } - $tags = $this->tags->getTagsForObjects(array_keys($filesById)); - - $notes = []; - foreach ($filesById as $id => $file) { - $noteTags = is_array($tags) && array_key_exists($id, $tags) ? $tags[$id] : []; - $notes[] = $this->getNote($file, $notesFolder, $noteTags, $onlyMeta); - } - + public function getAll(string $userId) { + $notesFolder = $this->getNotesFolder($userId); + $files = $this->gatherNoteFiles($notesFolder); + $fileIds = array_map(function (File $file) : int { + return $file->getId(); + }, $files); + // pre-load tags for all notes (performance improvement) + $this->noteUtil->getTagService()->loadTags($fileIds); + $notes = array_map(function (File $file) use ($notesFolder) : Note { + return new Note($file, $notesFolder, $this->noteUtil); + }, $files); return $notes; } - - /** - * Used to get a single note by id - * @param int $id the id of the note to get - * @param string $userId - * @throws NoteDoesNotExistException if note does not exist - * @return Note - */ - public function get(int $id, string $userId, bool $onlyMeta = false) : Note { - $folder = $this->getFolderForUser($userId); - return $this->getNote($this->getFileById($folder, $id), $folder, $this->getTags($id), $onlyMeta); - } - - private function getTags(int $id) { - $tags = $this->tags->getTagsForObjects([$id]); - return is_array($tags) && array_key_exists($id, $tags) ? $tags[$id] : []; - } - - private function getNote(File $file, Folder $notesFolder, array $tags = [], bool $onlyMeta = false) : Note { - $id = $file->getId(); - try { - $note = Note::fromFile($file, $notesFolder, $tags, $onlyMeta); - } catch (GenericEncryptionException $e) { - $message = $this->l10n->t('Encryption Error').': ('.$file->getName().') '.$e->getMessage(); - $note = Note::fromException($message, $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []); - } catch (\Exception $e) { - $message = $this->l10n->t('Error').': ('.$file->getName().') '.$e->getMessage(); - $note = Note::fromException($message, $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []); - } - return $note; + public function get(string $userId, int $id) : Note { + $notesFolder = $this->getNotesFolder($userId); + return new Note($this->getFileById($notesFolder, $id), $notesFolder, $this->noteUtil); } /** - * Creates a note and returns the empty note - * @param string $userId - * @see update for setting note content - * @return Note the newly created note + * @throws \OCP\Files\NotPermittedException */ - public function create(string $userId) : Note { - $title = $this->l10n->t('New note'); - $folder = $this->getFolderForUser($userId); + public function create(string $userId, string $title, string $category) : Note { + // get folder based on category + $notesFolder = $this->getNotesFolder($userId); + $folder = $this->noteUtil->getCategoryFolder($notesFolder, $category); $this->noteUtil->ensureSufficientStorage($folder, 1); - // check new note exists already and we need to number it - // pass -1 because no file has id -1 and that will ensure - // to only return filenames that dont yet exist - $path = $this->noteUtil->generateFileName($folder, $title, $this->settings->get($userId, 'fileSuffix'), -1); - $file = $folder->newFile($path); + // get file name + $fileSuffix = $this->settings->get($userId, 'fileSuffix'); + $filename = $this->noteUtil->generateFileName($folder, $title, $fileSuffix, -1); + + // create file + $file = $folder->newFile($filename); // try to write some content try { @@ -141,142 +65,90 @@ class NotesService { $file->putContent(' '); } catch (\Throwable $e) { // if writing the content fails, we have to roll back the note creation - $this->delete($file->getId(), $userId); + $this->delete($userId, $file->getId()); throw $e; } - return $this->getNote($file, $folder); + return new Note($file, $notesFolder, $this->noteUtil); } /** - * Updates a note. Be sure to check the returned note since the title is - * dynamically generated and filename conflicts are resolved - * @param int $id the id of the note used to update - * @param string|null $content the content which will be written into the note - * the title is generated from the first line of the content - * @param string|null $category the category in which the note should be saved - * @param int $mtime time of the note modification (optional) * @throws NoteDoesNotExistException if note does not exist - * @return \OCA\Notes\Db\Note the updated note */ - public function update(int $id, ?string $content, string $userId, ?string $category = null, int $mtime = 0) : Note { - $notesFolder = $this->getFolderForUser($userId); + public function delete(string $userId, int $id) { + $notesFolder = $this->getNotesFolder($userId); $file = $this->getFileById($notesFolder, $id); - $title = $this->noteUtil->getSafeTitleFromContent($content===null ? $file->getContent() : $content); + $parent = $file->getParent(); + $file->delete(); + $this->noteUtil->deleteEmptyFolder($parent, $notesFolder); + } - // rename/move file with respect to title/category - // this can fail if access rights are not sufficient or category name is illegal - try { - $this->noteUtil->moveNote($notesFolder, $file, $title, $category); - } catch (\OCP\Files\NotPermittedException $e) { - $err = 'Moving note '.$file->getId().' ('.$title.') to the desired target is not allowed.' - .' Please check the note\'s target category ('.$category.').'; - $this->logger->error($err, ['app' => $this->appName]); - } catch (\Exception $e) { - $err = 'Moving note '.$id.' ('.$title.') to the desired target has failed ' - .'with a '.get_class($e).': '.$e->getMessage(); - $this->logger->error($err, ['app' => $this->appName]); - } + public function getTitleFromContent(string $content) : string { + // prepare content: remove markdown characters and empty spaces + $content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item + $content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline + $content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline + $content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis + return $this->noteUtil->getSafeTitle($content); + } - if ($content !== null) { - $this->setContentForFile($file, $content); - } - if ($mtime) { - $file->touch($mtime); - } - return $this->getNote($file, $notesFolder, $this->getTags($id)); - } - private function setContentForFile(File $file, $content) : void { - $this->noteUtil->ensureSufficientStorage($file->getParent(), strlen($content)); - $file->putContent($content); - } - public function setContent(string $userId, int $id, string $content) : Note { - $notesFolder = $this->getFolderForUser($userId); - $file = $this->getFileById($notesFolder, $id); - $this->setContentForFile($file, $content); - return $this->getNote($file, $notesFolder, $this->getTags($id)); - } - public function setTitleCategory(string $userId, int $id, ?string $title, ?string $category = null) : Note { - $notesFolder = $this->getFolderForUser($userId); - $file = $this->getFileById($notesFolder, $id); - if ($title === null) { - $note = $this->getNote($file, $notesFolder, [], true); - $title = $note->getTitle(); + /** + * @param string $userId the user id + * @return Folder + */ + private function getNotesFolder(string $userId) : Folder { + $userPath = $this->noteUtil->getRoot()->getUserFolder($userId)->getPath(); + $path = $userPath . '/' . $this->settings->get($userId, 'notesPath'); + try { + $folder = $this->noteUtil->getOrCreateFolder($path); + } catch (\Exception $e) { + throw new NotesFolderException($path); } - $this->noteUtil->moveNote($notesFolder, $file, $title, $category); - - return $this->getNote($file, $notesFolder, $this->getTags($id)); + return $folder; } /** - * Set or unset a note as favorite. - * @param int $id the id of the note used to update - * @param boolean $favorite whether the note should be a favorite or not - * @throws NoteDoesNotExistException if note does not exist - * @return boolean the new favorite state of the note + * gather note files in given directory and all subdirectories */ - public function favorite(int $id, bool $favorite, string $userId) { - $note = $this->get($id, $userId, true); - if ($favorite !== $note->getFavorite()) { - if ($favorite) { - $this->tags->addToFavorites($id); - } else { - $this->tags->removeFromFavorites($id); + private static function gatherNoteFiles(Folder $folder) : array { + $files = []; + $nodes = $folder->getDirectoryListing(); + foreach ($nodes as $node) { + if ($node->getType() === FileInfo::TYPE_FOLDER && $node instanceof Folder) { + $files = array_merge($files, self::gatherNoteFiles($node)); + continue; + } + if (self::isNote($node)) { + $files[] = $node; } - $note = $this->get($id, $userId, true); } - return $note->getFavorite(); + return $files; } - /** - * Deletes a note - * @param int $id the id of the note which should be deleted - * @param string $userId - * @throws NoteDoesNotExistException if note does not - * exist + * test if file is a note */ - public function delete(int $id, string $userId) { - $notesFolder = $this->getFolderForUser($userId); - $file = $this->getFileById($notesFolder, $id); - $parent = $file->getParent(); - $file->delete(); - $this->noteUtil->deleteEmptyFolder($notesFolder, $parent); + private static function isNote(FileInfo $file) : bool { + static $allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note']; + $ext = strtolower(pathinfo($file->getName(), PATHINFO_EXTENSION)); + return $file->getType() === 'file' && in_array($ext, $allowedExtensions); } /** - * @param Folder $folder - * @param int $id * @throws NoteDoesNotExistException - * @return \OCP\Files\File */ - private function getFileById(Folder $folder, int $id) : File { + private static function getFileById(Folder $folder, int $id) : File { $file = $folder->getById($id); - if (count($file) <= 0 || !($file[0] instanceof File) || !$this->noteUtil->isNote($file[0])) { + if (count($file) <= 0 || !($file[0] instanceof File) || !self::isNote($file[0])) { throw new NoteDoesNotExistException(); } return $file[0]; } - - /** - * @param string $userId the user id - * @return Folder - */ - private function getFolderForUser(string $userId) : Folder { - // TODO use IRootFolder->getUserFolder() ? - $path = '/' . $userId . '/files/' . $this->settings->get($userId, 'notesPath'); - try { - $folder = $this->noteUtil->getOrCreateFolder($path); - } catch (\Exception $e) { - throw new NotesFolderException($path); - } - return $folder; - } } diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 1ce7add7..5c2308c3 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -1,4 +1,4 @@ -<?php +<?php declare(strict_types=1); namespace OCA\Notes\Service; @@ -24,7 +24,7 @@ class SettingsService { /** * @throws \OCP\PreConditionNotMetException */ - public function set($uid, $settings) { + public function set(string $uid, array $settings) : void { // remove illegal, empty and default settings foreach ($settings as $name => $value) { if (!array_key_exists($name, $this->defaults) @@ -37,7 +37,7 @@ class SettingsService { $this->config->setUserValue($uid, 'notes', 'settings', json_encode($settings)); } - public function getAll($uid) { + public function getAll(string $uid) : \stdClass { $settings = json_decode($this->config->getUserValue($uid, 'notes', 'settings')); if (is_object($settings)) { // use default for empty settings @@ -55,7 +55,7 @@ class SettingsService { /** * @throws \OCP\PreConditionNotMetException */ - public function get($uid, $name) { + public function get(string $uid, string $name) : string { $settings = $this->getAll($uid); if (property_exists($settings, $name)) { return $settings->{$name}; diff --git a/lib/Service/TagService.php b/lib/Service/TagService.php new file mode 100644 index 00000000..5f3d5f2f --- /dev/null +++ b/lib/Service/TagService.php @@ -0,0 +1,50 @@ +<?php declare(strict_types=1); + +namespace OCA\Notes\Service; + +use OCP\ITagManager; + +class TagService { + private $tagger; + private $cachedTags; + + public function __construct(ITagManager $tagManager) { + $this->tagger = $tagManager->load('files'); + } + + public function loadTags(array $fileIds) : void { + $this->cachedTags = $this->tagger->getTagsForObjects($fileIds); + } + + // TODO NC19: replace this by OCP\ITags::TAG_FAVORITE + // OCP\ITags::TAG_FAVORITE was introduced in NC19 + // https://github.com/nextcloud/server/pull/19412 + /** + * @suppress PhanUndeclaredClassConstant + * @suppress PhanUndeclaredConstant + * @suppress PhanUndeclaredConstantOfClass + */ + private static function getTagFavorite() { + if (defined('OCP\ITags::TAG_FAVORITE')) { + return \OCP\ITags::TAG_FAVORITE; + } else { + return \OC\Tags::TAG_FAVORITE; + } + } + + public function isFavorite($fileId) : bool { + $alltags = $this->cachedTags; + if (!is_array($alltags)) { + $alltags = $this->tagger->getTagsForObjects([$fileId]); + } + return array_key_exists($fileId, $alltags) && in_array(self::getTagFavorite(), $alltags[$fileId]); + } + + public function setFavorite($fileId, $favorite) : void { + if ($favorite) { + $this->tagger->addToFavorites($fileId); + } else { + $this->tagger->removeFromFavorites($fileId); + } + } +} diff --git a/src/App.vue b/src/App.vue index 0d9a5c61..fe1bbabf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -198,7 +198,7 @@ export default { return } this.loading.create = true - createNote(this.filter.category) + createNote(this.filter.category || '') .then(note => { this.routeToNote(note.id) }) diff --git a/tests/api/APIv02Test.php b/tests/api/APIv02Test.php index 93250eb8..cbca70de 100644 --- a/tests/api/APIv02Test.php +++ b/tests/api/APIv02Test.php @@ -2,199 +2,9 @@ namespace OCA\Notes\Tests\API; -class APIv02Test extends AbstractAPITest { +class APIv02Test extends CommonAPITest { public function __construct() { - parent::__construct('v0.2'); + parent::__construct('0.2', true); } - - public function testCheckForReferenceNotes() : array { - $response = $this->http->request('GET', 'notes'); - $this->checkResponse($response, 'Get existing notes', 200); - $notes = json_decode($response->getBody()->getContents()); - $this->assertNotEmpty($notes, 'List of notes'); - return $notes; - } - - /** @depends testCheckForReferenceNotes */ - public function testGetNotesWithExclude(array $refNotes) : void { - $this->checkGetReferenceNotes( - $refNotes, - 'exclude content', - '?exclude=content', - false, - ['content'] - ); - $this->checkGetReferenceNotes( - $refNotes, - 'exclude content and category', - '?exclude=content,category', - false, - ['content','category'] - ); - } - - /** @depends testCheckForReferenceNotes */ - public function testGetNotesWithEtag(array $refNotes) : void { - $response1 = $this->http->request('GET', 'notes'); - $this->checkResponse($response1, 'Initial response', 200); - $this->assertTrue($response1->hasHeader('ETag'), 'Initial response has ETag header'); - $etag = $response1->getHeaderLine('ETag'); - $this->assertRegExp('/^"[[:alnum:]]{32}"$/', $etag, 'ETag format'); - - // Test If-None-Match with ETag - $response2 = $this->http->request('GET', 'notes', [ 'headers' => [ 'If-None-Match' => $etag ] ]); - $this->checkResponse($response2, 'ETag response', 304); - $this->assertEquals('', $response2->getBody(), 'ETag response body'); - } - - /** @depends testCheckForReferenceNotes */ - public function testGetNotesWithPruneBefore(array $refNotes) : void { - sleep(1); // wait for 'Last-Modified' to be >= Last-change + 1 - $response1 = $this->http->request('GET', 'notes'); - $this->checkResponse($response1, 'Initial response', 200); - $this->assertTrue($response1->hasHeader('Last-Modified'), 'Initial response has Last-Modified header'); - $lastModified = $response1->getHeaderLine('Last-Modified'); - $dt = \DateTime::createFromFormat(\DateTime::RFC2822, $lastModified); - $this->assertInstanceOf(\DateTime::class, $dt); - - $this->checkGetReferenceNotes( - $refNotes, - 'pruneBefore with Last-Modified', - '?pruneBefore='.$dt->getTimestamp(), - true - ); - $this->checkGetReferenceNotes( - $refNotes, - 'pruneBefore with 1', - '?pruneBefore=1', - false - ); - $this->checkGetReferenceNotes( - $refNotes, - 'pruneBefore with PHP_INT_MAX (32bit)', - '?pruneBefore=2147483647', // 2038-01-19 03:14:07 - true - ); - $this->checkGetReferenceNotes( - $refNotes, - 'pruneBefore with PHP_INT_MAX (64bit)', - '?pruneBefore=9223372036854775807', - true - ); - } - - /** @depends testCheckForReferenceNotes */ - public function testCreateNotes(array $refNotes) : array { - $this->checkGetReferenceNotes($refNotes, 'Pre-condition'); - $testNotes = []; - $testNotes[] = $this->createNote((object)[ - 'title' => 'This is not used', - 'content' => '# *First* test/ note'.PHP_EOL.'This is some body content with some data.', - 'favorite' => true, - 'category' => 'Test/../New Category', - 'modified' => mktime(8, 14, 30, 10, 2, 2020), - ], (object)[ - 'title' => 'First test note', - 'category' => 'Test/New Category', - ]); - $testNotes[] = $this->createNote((object)[ - 'content' => 'Note with Defaults'.PHP_EOL.'This is some body content with some data.', - ], (object)[ - 'title' => 'Note with Defaults', - 'favorite' => false, - 'category' => '', - 'modified' => time(), - ]); - $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'After creating notes'); - return $testNotes; - } - - /** - * @depends testCheckForReferenceNotes - * @depends testCreateNotes - */ - public function testGetSingleNote(array $refNotes, array $testNotes) : void { - foreach ($testNotes as $testNote) { - $response = $this->http->request('GET', 'notes/'.$testNote->id); - $this->checkResponse($response, 'Get note '.$testNote->title, 200); - $note = json_decode($response->getBody()->getContents()); - $this->checkReferenceNote($testNote, $note, 'Get single note'); - } - // test non-existing note - $response = $this->http->request('GET', 'notes/1'); - $this->assertEquals(404, $response->getStatusCode()); - } - - - /** - * @depends testCheckForReferenceNotes - * @depends testCreateNotes - */ - public function testUpdateNotes(array $refNotes, array $testNotes) : array { - $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'Pre-condition'); - $note = $testNotes[0]; - // test update note with all attributes - $this->updateNote($note, (object)[ - 'title' => 'This is not used', - 'content' => '# *First* edited/ note'.PHP_EOL.'This is some body content with some data.', - 'favorite' => false, - 'category' => 'Test/Another Category', - 'modified' => mktime(11, 46, 23, 4, 3, 2020), - ], (object)[ - 'title' => 'First edited note', - ]); - // test update note with single attributes - /* TODO this doesn't work (content is null) - $this->updateNote($note, (object)[ - 'category' => 'Test/Third Category', - ], (object)[]); - */ - $this->updateNote($note, (object)[ - 'favorite' => true, - ], (object)[]); - $this->updateNote($note, (object)[ - 'content' => '# First multi edited note'.PHP_EOL.'This is some body content with some data.', - ], (object)[ - 'title' => 'First multi edited note', - 'modified' => time(), - ]); - $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'After updating notes'); - return $testNotes; - } - - /** - * @depends testCheckForReferenceNotes - * @depends testUpdateNotes - */ - public function testDeleteNotes(array $refNotes, array $testNotes) : void { - $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'Pre-condition'); - foreach ($testNotes as $note) { - $response = $this->http->request('DELETE', 'notes/'.$note->id); - $this->checkResponse($response, 'Delete note '.$note->title, 200); - } - // test non-existing note - $response = $this->http->request('DELETE', 'notes/1'); - $this->checkResponse($response, 'Delete non-existing note', 404); - $this->checkGetReferenceNotes($refNotes, 'After deletion'); - } - - public function testInsuficientStorage() { - $auth = ['quotatest', 'test']; - // get notes must still work - $response = $this->http->request('GET', 'notes', [ 'auth' => $auth ]); - $this->checkResponse($response, 'Get existing notes', 200); - $notes = json_decode($response->getBody()->getContents()); - $this->assertNotEmpty($notes, 'List of notes'); - $note = $notes[0]; // @phan-suppress-current-line PhanTypeArraySuspiciousNullable - $request = (object)[ 'content' => 'New test content' ]; - // update will fail - $response1 = $this->http->request('PUT', 'notes/'.$note->id, [ 'auth' => $auth, 'json' => $request]); - $this->assertEquals(507, $response1->getStatusCode()); - // craete will fail - $response2 = $this->http->request('POST', 'notes', [ 'auth' => $auth, 'json' => $request]); - $this->assertEquals(507, $response2->getStatusCode()); - } - - // TODO Test settings (switch to another notes folder) } diff --git a/tests/api/APIv1Test.php b/tests/api/APIv1Test.php new file mode 100644 index 00000000..acf8a03e --- /dev/null +++ b/tests/api/APIv1Test.php @@ -0,0 +1,10 @@ +<?php declare(strict_types=1); + +namespace OCA\Notes\Tests\API; + +class APIv1Test extends CommonAPITest { + + public function __construct() { + parent::__construct('1.0', false); + } +} diff --git a/tests/api/AbstractAPITest.php b/tests/api/AbstractAPITest.php index 728b77f6..473c3af8 100644 --- a/tests/api/AbstractAPITest.php +++ b/tests/api/AbstractAPITest.php @@ -14,8 +14,13 @@ abstract class AbstractAPITest extends TestCase { } protected function setUp() : void { + if ($this->apiVersion === '0.2') { + $v = $this->apiVersion; + } else { + $v = intval($this->apiVersion); + } $this->http = new \GuzzleHttp\Client([ - 'base_uri' => 'http://localhost:8080/index.php/apps/notes/api/'.$this->apiVersion.'/', + 'base_uri' => 'http://localhost:8080/index.php/apps/notes/api/v'.$v.'/', 'auth' => ['test', 'test'], 'http_errors' => false, ]); @@ -28,12 +33,24 @@ abstract class AbstractAPITest extends TestCase { string $contentTypeExp = 'application/json; charset=utf-8' ) { $this->assertEquals($statusExp, $response->getStatusCode(), $message.': Response status code'); - $this->assertTrue($response->hasHeader('Content-Type'), $message.': Response has content-type header'); + $this->assertTrue( + $response->hasHeader('Content-Type'), + $message.': Response has content-type header' + ); $this->assertEquals( $contentTypeExp, $response->getHeaderLine('Content-Type'), $message.': Response content type' ); + $this->assertTrue( + $response->hasHeader('X-Notes-API-Versions'), + $message.': Response has Notes-API-Versions header' + ); + $this->assertContains( + $this->apiVersion, + explode(', ', $response->getHeaderLine('X-Notes-API-Versions')), + $message.': Response Notes-API-Versions header' + ); } protected function checkGetReferenceNotes( @@ -47,7 +64,7 @@ abstract class AbstractAPITest extends TestCase { $response = $this->http->request('GET', 'notes' . $param); $this->checkResponse($response, $messagePrefix, 200); $notes = json_decode($response->getBody()->getContents()); - $notesMap = self::getNotesIdMap($notes); + $notesMap = $this->getNotesIdMap($notes, $messagePrefix); $this->assertEquals(count($refNotes), count($notes), $messagePrefix.': Number of notes'); foreach ($refNotes as $refNote) { $this->assertArrayHasKey( @@ -140,9 +157,10 @@ abstract class AbstractAPITest extends TestCase { ); } - protected static function getNotesIdMap(array $notes) : array { + protected function getNotesIdMap(array $notes, string $messagePrefix) : array { $map = []; foreach ($notes as $note) { + $this->assertObjectHasAttribute('id', $note, $messagePrefix.': Note has property id'); $map[$note->id] = $note; } return $map; diff --git a/tests/api/CapabilitiesTest.php b/tests/api/CapabilitiesTest.php new file mode 100644 index 00000000..2153a2e5 --- /dev/null +++ b/tests/api/CapabilitiesTest.php @@ -0,0 +1,58 @@ +<?php declare(strict_types=1); + +namespace OCA\Notes\Tests\API; + +use PHPUnit\Framework\TestCase; + +class CapabilitiesTest extends TestCase { + protected $http; + + protected function setUp() : void { + $this->http = new \GuzzleHttp\Client([ + 'base_uri' => 'http://localhost:8080/', + 'auth' => ['test', 'test'], + 'http_errors' => false, + ]); + } + + public function testCapabilities() { + $response = $this->http->request('GET', 'ocs/v2.php/cloud/capabilities', [ + 'headers' => [ + 'OCS-APIRequest' => 'true', + 'Accept' => 'application/json', + ] + ]); + $this->assertEquals(200, $response->getStatusCode(), 'Response status code'); + $this->assertTrue( + $response->hasHeader('Content-Type'), + 'Response has content-type header' + ); + $this->assertEquals( + 'application/json; charset=utf-8', + $response->getHeaderLine('Content-Type'), + 'Response content type' + ); + $ocs = json_decode($response->getBody()->getContents()); + $capabilities = $ocs->ocs->data->capabilities; + $this->assertObjectHasAttribute('notes', $capabilities, 'Nextcloud provides capabilities'); + $notesCapability = $capabilities->notes; + $this->assertObjectHasAttribute('api_version', $notesCapability, 'Notes API-Version capability exists'); + $apiVersions = $notesCapability->api_version; + $this->assertIsArray($apiVersions, 'Notes API-Version capability is array'); + $this->assertNotEmpty($apiVersions, 'Notes API-Version capability array'); + foreach ($apiVersions as $apiVersion) { + $this->assertStringMatchesFormat('%d.%d', $apiVersion, 'API Version format'); + $v = $apiVersion === '0.2' ? '02' : intval($apiVersion); + $path = dirname(__FILE__).'/APIv'.$v.'Test.php'; + $this->assertFileExists($path, 'Test for API v'.$apiVersion.' exists'); + } + } + + public function testInvalidVersion() { + $v = 7; + $response1 = $this->http->request('GET', 'index.php/apps/notes/api/v'.$v.'/notes'); + $this->assertEquals(400, $response1->getStatusCode(), 'First response status code'); + $response2 = $this->http->request('GET', 'index.php/apps/notes/api/v'.$v.'/notes/1'); + $this->assertEquals(400, $response2->getStatusCode(), 'Second response status code'); + } +} diff --git a/tests/api/CommonAPITest.php b/tests/api/CommonAPITest.php new file mode 100644 index 00000000..5f69e02a --- /dev/null +++ b/tests/api/CommonAPITest.php @@ -0,0 +1,217 @@ +<?php declare(strict_types=1); + +namespace OCA\Notes\Tests\API; + +abstract class CommonAPITest extends AbstractAPITest { + + private $requiredAttributes = [ + 'id' => 'integer', + 'content' => 'string', + 'title' => 'string', + 'category' => 'string', + 'modified' => 'integer', + 'favorite' => 'boolean', + ]; + + private $autotitle; + + public function __construct(string $apiVersion, bool $autotitle) { + parent::__construct($apiVersion); + $this->autotitle = $autotitle; + } + + public function testCheckForReferenceNotes() : array { + $response = $this->http->request('GET', 'notes'); + $this->checkResponse($response, 'Get existing notes', 200); + $notes = json_decode($response->getBody()->getContents()); + $this->assertNotEmpty($notes, 'List of notes'); + foreach ($notes as $note) { + foreach ($this->requiredAttributes as $key => $type) { + $this->assertObjectHasAttribute($key, $note, 'Note has property '.$key); + $this->assertEquals($type, gettype($note->$key), 'Property type of '.$key); + } + } + return $notes; + } + + /** @depends testCheckForReferenceNotes */ + public function testGetNotesWithExclude(array $refNotes) : void { + $this->checkGetReferenceNotes( + $refNotes, + 'exclude content', + '?exclude=content', + false, + ['content'] + ); + $this->checkGetReferenceNotes( + $refNotes, + 'exclude content and category', + '?exclude=content,category', + false, + ['content','category'] + ); + } + + /** @depends testCheckForReferenceNotes */ + public function testGetNotesWithEtag(array $refNotes) : void { + $response1 = $this->http->request('GET', 'notes'); + $this->checkResponse($response1, 'Initial response', 200); + $this->assertTrue($response1->hasHeader('ETag'), 'Initial response has ETag header'); + $etag = $response1->getHeaderLine('ETag'); + $this->assertRegExp('/^"[[:alnum:]]{32}"$/', $etag, 'ETag format'); + + // Test If-None-Match with ETag + $response2 = $this->http->request('GET', 'notes', [ 'headers' => [ 'If-None-Match' => $etag ] ]); + $this->checkResponse($response2, 'ETag response', 304); + $this->assertEquals('', $response2->getBody(), 'ETag response body'); + } + + /** @depends testCheckForReferenceNotes */ + public function testGetNotesWithPruneBefore(array $refNotes) : void { + sleep(1); // wait for 'Last-Modified' to be >= Last-change + 1 + $response1 = $this->http->request('GET', 'notes'); + $this->checkResponse($response1, 'Initial response', 200); + $this->assertTrue($response1->hasHeader('Last-Modified'), 'Initial response has Last-Modified header'); + $lastModified = $response1->getHeaderLine('Last-Modified'); + $dt = \DateTime::createFromFormat(\DateTime::RFC2822, $lastModified); + $this->assertInstanceOf(\DateTime::class, $dt); + + $this->checkGetReferenceNotes( + $refNotes, + 'pruneBefore with Last-Modified', + '?pruneBefore='.$dt->getTimestamp(), + true + ); + $this->checkGetReferenceNotes( + $refNotes, + 'pruneBefore with 1', + '?pruneBefore=1', + false + ); + $this->checkGetReferenceNotes( + $refNotes, + 'pruneBefore with PHP_INT_MAX (32bit)', + '?pruneBefore=2147483647', // 2038-01-19 03:14:07 + true + ); + $this->checkGetReferenceNotes( + $refNotes, + 'pruneBefore with PHP_INT_MAX (64bit)', + '?pruneBefore=9223372036854775807', + true + ); + } + + /** @depends testCheckForReferenceNotes */ + public function testCreateNotes(array $refNotes) : array { + $this->checkGetReferenceNotes($refNotes, 'Pre-condition'); + $testNotes = []; + $testNotes[] = $this->createNote((object)[ + 'title' => 'First *manual* title', + 'content' => '# *First* test/ note'.PHP_EOL.'This is some body content with some data.', + 'favorite' => true, + 'category' => 'Test/../New Category', + 'modified' => mktime(8, 14, 30, 10, 2, 2020), + ], (object)[ + 'title' => $this->autotitle ? 'First test note' : 'First manual title', + 'category' => 'Test/New Category', + ]); + $testNotes[] = $this->createNote((object)[ + 'content' => 'Note with Defaults'.PHP_EOL.'This is some body content with some data.', + ], (object)[ + 'title' => $this->autotitle ? 'Note with Defaults' : 'New note', // waring: requires lang=C + 'favorite' => false, + 'category' => '', + 'modified' => time(), + ]); + $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'After creating notes'); + return $testNotes; + } + + /** + * @depends testCheckForReferenceNotes + * @depends testCreateNotes + */ + public function testGetSingleNote(array $refNotes, array $testNotes) : void { + foreach ($testNotes as $testNote) { + $response = $this->http->request('GET', 'notes/'.$testNote->id); + $this->checkResponse($response, 'Get note '.$testNote->title, 200); + $note = json_decode($response->getBody()->getContents()); + $this->checkReferenceNote($testNote, $note, 'Get single note'); + } + // test non-existing note + $response = $this->http->request('GET', 'notes/1'); + $this->assertEquals(404, $response->getStatusCode()); + } + + + /** + * @depends testCheckForReferenceNotes + * @depends testCreateNotes + */ + public function testUpdateNotes(array $refNotes, array $testNotes) : array { + $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'Pre-condition'); + $note = $testNotes[0]; + // test update note with all attributes + $this->updateNote($note, (object)[ + 'title' => 'First *manual* edited title', + 'content' => '# *First* edited/ note'.PHP_EOL.'This is some body content with some data.', + 'favorite' => false, + 'category' => 'Test/Another Category', + 'modified' => mktime(11, 46, 23, 4, 3, 2020), + ], (object)[ + 'title' => $this->autotitle ? 'First edited note' : 'First manual edited title', + ]); + // test update note with single attributes + $this->updateNote($note, (object)[ + 'category' => 'Test/Third Category', + ], (object)[]); + // TODO test update category with read-only folder (target category) + $this->updateNote($note, (object)[ + 'favorite' => true, + ], (object)[]); + $this->updateNote($note, (object)[ + 'content' => '# First multi edited note'.PHP_EOL.'This is some body content with some data.', + ], (object)[ + 'title' => $this->autotitle ? 'First multi edited note' : 'First manual edited title', + 'modified' => time(), + ]); + $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'After updating notes'); + return $testNotes; + } + + /** + * @depends testCheckForReferenceNotes + * @depends testUpdateNotes + */ + public function testDeleteNotes(array $refNotes, array $testNotes) : void { + $this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'Pre-condition'); + foreach ($testNotes as $note) { + $response = $this->http->request('DELETE', 'notes/'.$note->id); + $this->checkResponse($response, 'Delete note '.$note->title, 200); + } + // test non-existing note + $response = $this->http->request('DELETE', 'notes/1'); + $this->checkResponse($response, 'Delete non-existing note', 404); + $this->checkGetReferenceNotes($refNotes, 'After deletion'); + } + + public function testInsuficientStorage() { + $auth = ['quotatest', 'test']; + // get notes must still work + $response = $this->http->request('GET', 'notes', [ 'auth' => $auth ]); + $this->checkResponse($response, 'Get existing notes', 200); + $notes = json_decode($response->getBody()->getContents()); + $this->assertNotEmpty($notes, 'List of notes'); + $note = $notes[0]; // @phan-suppress-current-line PhanTypeArraySuspiciousNullable + $request = (object)[ 'content' => 'New test content' ]; + // update will fail + $response1 = $this->http->request('PUT', 'notes/'.$note->id, [ 'auth' => $auth, 'json' => $request]); + $this->assertEquals(507, $response1->getStatusCode()); + // craete will fail + $response2 = $this->http->request('POST', 'notes', [ 'auth' => $auth, 'json' => $request]); + $this->assertEquals(507, $response2->getStatusCode()); + } + + // TODO Test settings (switch to another notes folder) +} |