diff --git a/CHANGELOG.md b/CHANGELOG.md index e41ea0196..dce48a4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ [#383](https://github.com/nextcloud/cookbook/pull/383/) @christianlupus - Unit tests for JSON object service [#387](https://github.com/nextcloud/cookbook/pull/387) @TobiasMie +- Rating recipes for nextcloud users + [#342](https://github.com/nextcloud/cookbook/pull/342/) @sam-19 ### Changed - Switch of project ownership to neextcloud organization in GitHub diff --git a/appinfo/routes.php b/appinfo/routes.php index 42b9236c5..e26662901 100755 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -28,6 +28,9 @@ ['name' => 'main#category', 'url' => '/api/category/{category}', 'verb' => 'GET'], ['name' => 'main#tags', 'url' => '/api/tags/{keywords}', 'verb' => 'GET'], ['name' => 'main#search', 'url' => '/api/search/{query}', 'verb' => 'GET'], + /* Rating routes */ + ['name' => 'rating#save', 'url' => '/api/v1/recipes/{id}/rating', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], + ['name' => 'rating#remove', 'url' => '/api/v1/recipes/{id}/rating', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], ], /* API resources */ diff --git a/lib/Controller/RatingController.php b/lib/Controller/RatingController.php new file mode 100644 index 000000000..99a2798a4 --- /dev/null +++ b/lib/Controller/RatingController.php @@ -0,0 +1,71 @@ + + * + */ +class RatingController extends Controller +{ + + /** + * @var DbCacheService + */ + private $dbCacheService; + + /** + * @var RatingService + */ + private $ratingService; + + /** + * @var string + */ + private $userId; + + public function __construct( + ?string $UserId, DbCacheService $dbCacheService, RatingService $ratingService) + { + $this->dbCacheService = $dbCacheService; + $this->ratingService = $ratingService; + $this->userId = $UserId; + } + + /** + * Add or update a rating for the current user to a recipe + * @param int $id The id of the recipe to be rated + */ + public function save($id) + { + $this->dbCacheService->triggerCheck(); + + $data = []; + parse_str(file_get_contents('php://input'), $data); + + $this->ratingService->addRating((int) $id, $this->userId, $data); + + return new DataResponse([], Http::STATUS_OK, ['Content-Type' => 'application/json']); + } + + /** + * Remove a rating from a recipe for the currently logged in user + * @param int $id The id of the recipe to be altered + */ + public function remove($id) + { + $this->dbCacheService->triggerCheck(); + $this->ratingService->removeRating($id, $this->userId); + + return new DataResponse([], Http::STATUS_OK, ['Content-Type' => 'application/json']); + } + +} diff --git a/lib/Migration/Version000000Date20201114165448.php b/lib/Migration/Version000000Date20201114165448.php new file mode 100644 index 000000000..ee3d5e90e --- /dev/null +++ b/lib/Migration/Version000000Date20201114165448.php @@ -0,0 +1,53 @@ +createTable(self::RATINGS); + + // Set up columns + $tableRatings->addColumn('recipe_id', 'integer', [ + 'notnull' => true + ]); + $tableRatings->addColumn('user_id', 'string', [ + 'notnull' => true, + 'length' => 64 + ]); + $tableRatings->addColumn('rating', 'float', [ + 'notnull' => true + ]); + + // Set up indices + $tableRatings->addIndex(['recipe_id', 'user_id']); + // XXX ForeignKeyConstraints + + return $schema; + } + +} diff --git a/lib/Service/RatingService.php b/lib/Service/RatingService.php new file mode 100644 index 000000000..6b0a3e2d8 --- /dev/null +++ b/lib/Service/RatingService.php @@ -0,0 +1,367 @@ +recipeService = $recipeService; + $this->jsonService = $jsonService; + $this->l = $l10n; + $this->db = $db; + } + + /** + * Remove a rating from a recipe + * @param int $recipeId The recipe to remove the rating from + * @param string $userId The user of the rating that should be removed + * @throws \Exception If either the JSON is invalid or the recipe was not found. + */ + public function removeRating(int $recipeId, string $userId) + { + $json = $this->recipeService->getRecipeById($recipeId); + + if($json === null) + { + throw new \Exception($this->l->t('No matching recipe was found.')); + } + + $json = $this->canonicalizeRatings($json); + + $idx = $this->searchForRating($json, $userId); + + if($idx >= 0) + { + // We found the rating + unset($json[self::CONTENT_RATING][$idx]); + + $json = $this->updateAggregateRating($json); + $this->updateDatabase($userId, $recipeId, $json['aggregateRating']); + + // Undo our canonicalization + $json = $this->uncanonicalizeRatings($json); + + // Save the file on disk + $recipeFile = $this->recipeService->getRecipeFileByFolderId($recipeId); + $recipeFile->putContent(json_encode($json)); + + $recipeFile->getParent()->touch(); + } + } + + /** + * Add a rating to a recipe + * @param int $recipeId The recipe to add the rating to + * @param string $userId The user who gave the rating + * @param int $rating The rating value + * @throws \Exception If either the JSON is invalid or the recipe was not found. + */ + public function addRating(int $recipeId, string $userId, int $rating) : void + { + $json = $this->recipeService->getRecipeById($recipeId); + + if($json === null) + { + throw new \Exception($this->l->t('No matching recipe was found.')); + } + + $json = $this->canonicalizeRatings($json); + + $idx = $this->searchForRating($json, $userId); + + if($idx == -1) + { + // Add a new rating + $json[self::CONTENT_RATING][] = $this->createRating($rating, $userId); + } + else + { + // Replace the corresponding rating + $json[self::CONTENT_RATING][$idx] = $this->createRating($rating, $userId); + } + + $this->updateAggregateRating($json); + $this->updateDatabase($userId, $recipeId, $json['aggregateRating']); + + // Undo our canonicalization + $json = $this->uncanonicalizeRatings($json); + + // Save the file on disk + $recipeFile = $this->recipeService->getRecipeFileByFolderId($recipeId); + $recipeFile->putContent(json_encode($json)); + + $recipeFile->getParent()->touch(); + } + + /** + * Create a schema.org [Rating object](https://schema.org/Rating) + * + * @param int $rating The numerical rating value + * @param string $userId The user id of the rating + * @return array The Rating object generated as an array + */ + private function createRating(int $rating, string $userId) : array + { + $ret = []; + + $ret['@type'] = 'Rating'; + $ret['ratingValue'] = $rating; + + $ret['author'] = array( + '@type' => 'Person', + 'identifier' => $userId + ); + + return $ret; + } + + /** + * Update the aggregate rating of the structure. + * + * The ratings **must** be canonicalized. + * + * @param array $json The object to parse + * @return array The updated structure + */ + private function updateAggregateRating(array $json) : array + { + $count = count($json[self::CONTENT_RATING]); + + $json['aggregateRating'] = array( + '@type' => 'AggregateRating', + 'ratingCount' => $count + ); + + if($count > 0) + { + // We have some ratings + $min = $max = -1; + $sum = 0; + + foreach($json[self::CONTENT_RATING] as $rating) + { + if($this->jsonService->isSchemaObject($rating, 'Rating') && + $this->jsonService->hasProperty($rating, 'ratingValue')) + { + // Shortcut for the value of the rating + $rv = $rating['ratingValue']; + + $min = ($min == -1) ? $rv : min([ $min, $rv ]); + $max = ($max == -1) ? $rv : max([ $max, $rv ]); + $sum += $rv; + } + } + + $json['aggregateRating']['bestRating'] = $max; + $json['aggregateRating']['worstRating'] = $min; + $json['aggregateRating']['ratingValue'] = (float) $sum / $count; + } + + return $json; + } + + /** + * Update the ratings in the database + * @param string $userId The owner of the recipe + * @param int $recipeId The id or the recipe + * @param array $rating The aggregate rating array as a AggregareRating schema.org object + * @deprecated This should be moved to a DB helper class + */ + private function updateDatabase(string $userId, int $recipeId, array $rating) + { + + $qb = $this->db->getQueryBuilder(); + + $qb->delete('cookbook_ratings') + ->where(['recipe_id = :rid', 'user_id = :uid']); + + $qb->setParameter('uid', $userId, IQueryBuilder::PARAM_STR); + $qb->setParameter('rid', $recipeId, IQueryBuilder::PARAM_INT); + + $qb->execute(); + + if($rating['ratingCount'] > 0) + { + $qb = $this->db->getQueryBuilder(); + + $qb ->insert('cookbook_ratings') + ->values([ + 'recipe_id' => ':rid', + 'user_id' => ':uid', + 'rating' => ':rating' + ]); + + $qb->setParameters([ + 'rid' => $recipeId, + 'uid' => $userId, + 'rating' => $rating['ratingValue'] + ], [ + IQueryBuilder::PARAM_INT, + IQueryBuilder::PARAM_STR, + IQueryBuilder::PARAM_STR + ]); + + $qb->execute(); + } + } + + /** + * Ensure that the contentRating property exists and is an array. + * + * By the schema.org standard, the rating might be either + * - non-existent + * - an empty array + * - a single string + * - a single Rating object + * - an array consisting of strings and/or Rating objects. + * + * For simpler processing this method eensures that the property + * exists and is always an array. + * The individual ratings are entries in this recipe. + * So, the number of entries in the property represent the count of ratings. + * + * @param string $json The JSON string to be canonicalized + * @return string The canonical JSON as string + */ + private function canonicalizeRatings(string $json) : string + { + if(! isset($json[self::CONTENT_RATING])) + { + // Ensure there is at leasst an empty array + $json[self::CONTENT_RATING] = []; + } + + if(is_string($json[self::CONTENT_RATING])) + { + // If there is only a single rating in form of text, put it into an array + $json[self::CONTENT_RATING] = array($json[self::CONTENT_RATING]); + } + + // We have for sure an array now. + + if($this->jsonService->isSchemaObject($json[self::CONTENT_RATING])) + { + // We do have an object as rating. Put it into a nested array for iterating + $json[self::CONTENT_RATING] = array( + $json[self::CONTENT_RATING] + ); + } + + return $json; + } + + /** + * Undo the changes by canonicalizeRatings + * + * This makes the JSON again compatible with the schema.org standard. + * + * @param string $json The JSON in canconical form + * @return string The standard conforming JSON as string + */ + private function uncanonicalizeRatings(string $json) : string + { + if(count($json[self::CONTENT_RATING]) == 0) + { + unset($json[self::CONTENT_RATING]); + } + else if(count($json[self::CONTENT_RATING]) == 1) + { + // Move the only entry to the top level. + $json[self::CONTENT_RATING] = $json[self::CONTENT_RATING][0]; + } + + return $json; + } + + /** + * Search all ratings for a rating with the given user id. + * + * Please note that the JSON data to be searched **must** be canonicalized! + * + * @param string $json The data of the recipe to look for ratings + * @param string $userId The user to look for + * @throws \Exception If an invalid type of rating was found in the JSON + * @return int The index of the rating in the array or -1 if no rating for the user was found. + */ + private function searchForRating(string $json, string $userId) : int + { + foreach($json[self::CONTENT_RATING] as $key => $val) + { + if(is_string($val)) + { + // Simple text rating found. We have no knowledge who wrote it. Skipping here + // XXX Do something useful? + continue; + } + + if(! $this->jsonService->isSchemaObject($val, 'Rating')) + { + // Some illegal object was found in the array. + throw new \Exception($this->l->t('Invalid type for rating found.')); + } + + if(! $this->jsonService->hasProperty($val, 'author') || + ! $this->jsonService->isSchemaObject($val['author'], 'Person')) + { + // We have no clue about the author. Skipping here. + // XXX Do something useful? + continue; + } + + if(! $this->jsonService->hasProperty($val['author'], 'identifier')) + { + // Without id of the author we cannot match it + continue; + } + + if($val['author']['identifier'] === $userId) + { + return $key; + } + } + + // Nothing was found + return -1; + } + +} diff --git a/lib/Service/RecipeService.php b/lib/Service/RecipeService.php index 4fa47f83d..134ec250c 100755 --- a/lib/Service/RecipeService.php +++ b/lib/Service/RecipeService.php @@ -664,6 +664,8 @@ public function addRecipe($json) // Create/move recipe folder $user_folder = $this->getFolderForUser(); $recipe_folder = null; + + $this->dropRatingChange($json, $user_folder); // Recipe already has an id, update it if (isset($json['id']) && $json['id']) { @@ -1171,4 +1173,48 @@ private function cleanUpString($str, $preserve_newlines = false, $remove_slashes return $str; } + + /** + * Drop any changes of the ratings of the JSON data in favor of the currently stored ones. + * This avoid unintended or malicious changes to the rating data. + * + * @param string $json The JSON data to be checked + * @param Folder $userFolder The folder of the cookbook to look for the recipe + */ + private function dropRatingChange(string &$json, Folder $userFolder) : void + { + if(isset($json['id'])) + { + // We have an existing recipe. Copy the ratings from there to make sure, + // no change has been made. + $recipeFolder = $userFolder->getById((int) $json['id'])[0]; + $recipeFile = $this->getRecipeFileByFolderId($recipeFolder->getId()); + $oldJson = json_decode($recipeFile->getContent()); + + if(isset($oldJson['contentRating'])) + { + $json['contentRating'] = $oldJson['contentRating']; + } + + if(isset($oldJson['aggregateRating'])) + { + $json['aggregateRating'] = $oldJson['aggregateRating']; + } + } + + // Ensure the rating fields do exist in all cases. + if(!isset($json['contentRating'])) + { + $json['contentRating'] = []; + } + + if(!isset($json['aggreegateRating'])) + { + $json['aggregateRating'] = array( + '@type' => 'AggregateRating', + 'ratingCount' => 0 + ); + } + } + } diff --git a/src/components/RecipeEdit.vue b/src/components/RecipeEdit.vue index 1991de642..2d363814b 100644 --- a/src/components/RecipeEdit.vue +++ b/src/components/RecipeEdit.vue @@ -38,6 +38,7 @@ export default { id: 0, name: null, description: '', + contentRating: 0, url: '', image: '', prepTime: '',