Skip to content

Commit 1985958

Browse files
authored
#10404 Allow nested categories (#11243)
* Implement endpoints to create, edit, and delete categories * Added migration to move category images into application's public directory * Load category thumbnail & full size image from application's public directory * Update logic for generating breadcrumbs, to ensure nested categories are handled * Delete sub-categories when a parent category is deleted * Remove ability to order categories
1 parent 4d8891f commit 1985958

File tree

25 files changed

+1113
-1131
lines changed

25 files changed

+1113
-1131
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
<?php
2+
3+
/**
4+
* @file api/v1/categories/CategoryCategoryController.php
5+
*
6+
* Copyright (c) 2025 Simon Fraser University
7+
* Copyright (c) 2025 John Willinsky
8+
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
9+
*
10+
* @class CategoryCategoryController
11+
*
12+
* @ingroup api_v1_category
13+
*
14+
* @brief Handle API requests for category operations.
15+
*/
16+
17+
namespace PKP\API\v1\categories;
18+
19+
use APP\facades\Repo;
20+
use APP\file\PublicFileManager;
21+
use Illuminate\Http\JsonResponse;
22+
use Illuminate\Http\Request;
23+
use Illuminate\Http\Response;
24+
use Illuminate\Support\Facades\Route;
25+
use PKP\category\Category;
26+
use PKP\context\Context;
27+
use PKP\core\Core;
28+
use PKP\core\PKPBaseController;
29+
use PKP\core\PKPRequest;
30+
use PKP\file\TemporaryFile;
31+
use PKP\file\TemporaryFileManager;
32+
use PKP\security\authorization\CanAccessSettingsPolicy;
33+
use PKP\security\authorization\ContextAccessPolicy;
34+
use PKP\security\Role;
35+
36+
class CategoryCategoryController extends PKPBaseController
37+
{
38+
/**
39+
* @inheritDoc
40+
*/
41+
public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool
42+
{
43+
$this->addPolicy(new ContextAccessPolicy($request, $roleAssignments));
44+
$this->addPolicy(new CanAccessSettingsPolicy());
45+
46+
return parent::authorize($request, $args, $roleAssignments);
47+
}
48+
49+
/**
50+
* @inheritDoc
51+
*/
52+
public function getHandlerPath(): string
53+
{
54+
return 'categories';
55+
}
56+
57+
/**
58+
* @inheritDoc
59+
*/
60+
public function getRouteGroupMiddleware(): array
61+
{
62+
return [
63+
'has.user',
64+
'has.context',
65+
];
66+
}
67+
68+
/**
69+
* @inheritDoc
70+
*/
71+
public function getGroupRoutes(): void
72+
{
73+
Route::middleware([
74+
self::roleAuthorizer([
75+
Role::ROLE_ID_MANAGER,
76+
Role::ROLE_ID_SITE_ADMIN,
77+
]),
78+
])->group(function () {
79+
Route::get('', $this->getMany(...));
80+
Route::get('categoryFormComponent', $this->getCategoryFormComponent(...));
81+
Route::post('', $this->add(...));
82+
Route::put('{categoryId}', $this->edit(...))
83+
->whereNumber('categoryId');
84+
Route::delete('{categoryId}', $this->delete(...))
85+
->whereNumber('categoryId');
86+
});
87+
}
88+
89+
/**
90+
* Create a new category.
91+
* Include `parentCategoryId` in query params to create new Category as a sub-category of an existing one.
92+
*/
93+
public function add(Request $illuminateRequest): JsonResponse
94+
{
95+
return $this->saveCategory($illuminateRequest);
96+
}
97+
98+
/**
99+
* Edit an existing category by ID.
100+
*/
101+
public function edit(Request $illuminateRequest): JsonResponse
102+
{
103+
return $this->saveCategory($illuminateRequest);
104+
}
105+
106+
/**
107+
* Create or update a category.
108+
*
109+
* Used internally to handle both new category creation and editing existing ones.
110+
*/
111+
private function saveCategory(Request $illuminateRequest): JsonResponse
112+
{
113+
$context = $this->getRequest()->getContext();
114+
$user = $this->getRequest()->getUser();
115+
$parentId = (int)$illuminateRequest->input('parentCategoryId') ?: null;
116+
$categoryId = (int)$illuminateRequest->route('categoryId') ?: null;
117+
118+
if ($parentId) {
119+
$parent = Repo::category()->get($parentId, $context->getId());
120+
if (!$parent) {
121+
return response()->json(__('api.404.resourceNotFound'), Response::HTTP_NOT_FOUND);
122+
}
123+
}
124+
125+
// Get a category object to edit or create
126+
if ($categoryId == null) {
127+
$category = Repo::category()->dao->newDataObject();
128+
$category->setContextId($context->getId());
129+
} else {
130+
$category = Repo::category()->get($categoryId, $context->getId());
131+
132+
if (!$category) {
133+
return response()->json(__('api.404.resourceNotFound'), Response::HTTP_NOT_FOUND);
134+
}
135+
136+
if (!$parentId) {
137+
$parentId = $category->getParentId();
138+
}
139+
}
140+
141+
$params = $this->convertStringsToSchema(\PKP\services\PKPSchemaService::SCHEMA_CATEGORY, $illuminateRequest->input());
142+
$params['contextId'] = $category->getContextId();
143+
$errors = Repo::category()->validate($category, $params, $context);
144+
145+
if (!empty($errors)) {
146+
return response()->json($errors, Response::HTTP_BAD_REQUEST);
147+
}
148+
149+
// Set the editable properties of the category object
150+
$category->setTitle($illuminateRequest->input('title'), null);
151+
$category->setDescription($illuminateRequest->input('description'), null);
152+
$category->setParentId($parentId);
153+
$category->setPath($illuminateRequest->input('path'));
154+
$category->setSortOption($illuminateRequest->input('sortOption'));
155+
156+
// Prevent category from being updated to have a circular parent reference
157+
if ($parentId && $categoryId && Repo::category()->hasCircularReference($categoryId, $parentId, $context->getId())) {
158+
return response()->json(__('api.categories.400.circularParentReference'), Response::HTTP_BAD_REQUEST);
159+
}
160+
161+
// Update or insert the category object
162+
if ($categoryId == null) {
163+
$categoryId = Repo::category()->add($category);
164+
} else {
165+
Repo::category()->edit($category, []);
166+
}
167+
168+
// Update category editors
169+
// Expects subEditor to be an array of sub-editor IDs, grouped by Group IDs.
170+
$subEditors = $illuminateRequest->input('subEditors');
171+
172+
// Subeditors are assigned to user group that has access to WORKFLOW_STAGE_ID_SUBMISSION stage, but OPS does not have that stage.
173+
// So $subEditors will be null in OPS.
174+
if ($subEditors !== null) {
175+
Repo::category()->updateEditors($categoryId, $subEditors, Category::ASSIGNABLE_ROLES, $context->getId());
176+
}
177+
178+
$submittedImageData = $illuminateRequest->input('image') ?: [];
179+
$temporaryFileId = $submittedImageData['temporaryFileId'] ?? null;
180+
181+
// Delete the old image if a new one was submitted or if the existing one was removed
182+
if ($temporaryFileId || !$submittedImageData && $oldImageData = $category->getImage()) {
183+
$publicFileManager = new PublicFileManager();
184+
$publicFileManager->removeContextFile($category->getContextId(), $oldImageData['uploadName']);
185+
$publicFileManager->removeContextFile($category->getContextId(), $oldImageData['thumbnailName']);
186+
}
187+
188+
$imageData = null;
189+
// Fetch the temporary file storing the uploaded image
190+
$temporaryFileManager = new TemporaryFileManager();
191+
$temporaryFile = $temporaryFileManager->getFile((int)$temporaryFileId, $user->getId());
192+
if ($temporaryFile) {
193+
$thumbnail = $this->generateThumbnail($temporaryFile, $context, $categoryId);
194+
$filenameBase = $categoryId . '-category';
195+
$originalFileName = $temporaryFile->getOriginalFileName();
196+
// Moves the temporary file to the public directory
197+
$fileName = app()->get('context')->moveTemporaryFile($context, $temporaryFile, $filenameBase, $user->getId());
198+
$imageData = [
199+
'name' => $originalFileName,
200+
'thumbnailName' => $thumbnail['thumbnailName'],
201+
'thumbnailWidth' => $thumbnail['thumbnailWidth'],
202+
'thumbnailHeight' => $thumbnail['thumbnailHeight'],
203+
'uploadName' => $fileName,
204+
'altText' => $submittedImageData['altText'] ?? '',
205+
'dateUploaded' => Core::getCurrentDate(),
206+
];
207+
} elseif ($submittedImageData && array_key_exists('altText', $submittedImageData)) {
208+
// Update existing image info with altText
209+
$existingImageData = $category->getImage();
210+
$imageData = [
211+
'name' => $existingImageData['name'],
212+
'thumbnailName' => $existingImageData['thumbnailName'],
213+
'thumbnailWidth' => $existingImageData['thumbnailWidth'],
214+
'thumbnailHeight' => $existingImageData['thumbnailHeight'],
215+
'uploadName' => $existingImageData['uploadName'],
216+
'altText' => $illuminateRequest->input('image')['altText'],
217+
'dateUploaded' => Core::getCurrentDate(),
218+
];
219+
}
220+
221+
$category->setImage($imageData);
222+
Repo::category()->edit($category, []);
223+
$category = Repo::category()->get($categoryId, $context->getId());
224+
return response()->json(Repo::category()->getSchemaMap()->summarize($category), Response::HTTP_OK);
225+
}
226+
227+
/**
228+
* Get a list of categories. Returns top-level categories with their nested sub-categories.
229+
*/
230+
public function getMany(Request $illuminateRequest): JsonResponse
231+
{
232+
$context = $this->getRequest()->getContext();
233+
$categories = Repo::category()
234+
->getCollector()
235+
->filterByContextIds([$context->getId()])
236+
->filterByParentIds([null])
237+
->getMany();
238+
239+
$data = Repo::category()->getSchemaMap()->mapMany($categories)->values();
240+
241+
return response()->json($data, Response::HTTP_OK);
242+
}
243+
244+
/**
245+
* Delete a category by ID.
246+
*/
247+
public function delete(Request $illuminateRequest): JsonResponse
248+
{
249+
$categoryId = $illuminateRequest->route('categoryId');
250+
$context = $this->getRequest()->getContext();
251+
252+
$category = Repo::category()->get($categoryId, $context->getId());
253+
if (!$category) {
254+
return response()->json(__('api.404.resourceNotFound'), Response::HTTP_NOT_FOUND);
255+
}
256+
257+
Repo::category()->delete($category);
258+
return response()->json([], Response::HTTP_OK);
259+
}
260+
261+
262+
/**
263+
* Generate the thumbnail image when creating a category.
264+
*
265+
* @return array{'thumbnailName': string, 'thumbnailWidth':int, 'thumbnailHeight':int} - assoc array with thumbnail details.
266+
*/
267+
private function generateThumbnail(TemporaryFile $temporaryFile, Context $context, $categoryId): array
268+
{
269+
$image = null;
270+
$thumbnail = null;
271+
272+
try {
273+
$temporaryFileManager = new TemporaryFileManager();
274+
$imageExtension = $temporaryFileManager->getImageExtension($temporaryFile->getFileType());
275+
$sizeArray = getimagesize($temporaryFile->getFilePath());
276+
$temporaryFilePath = $temporaryFile->getFilePath();
277+
278+
// Generate the surrogate images. Used later to create thumbnail
279+
$image = match ($imageExtension) {
280+
'.jpg' => imagecreatefromjpeg($temporaryFilePath),
281+
'.png' => imagecreatefrompng($temporaryFilePath),
282+
'.gif' => imagecreatefromgif($temporaryFilePath),
283+
};
284+
285+
$coverThumbnailsMaxWidth = $context->getData('coverThumbnailsMaxWidth');
286+
$coverThumbnailsMaxHeight = $context->getData('coverThumbnailsMaxHeight');
287+
$thumbnailFilename = $categoryId . '-category-thumbnail' . $imageExtension;
288+
$xRatio = min(1, ($coverThumbnailsMaxWidth ? $coverThumbnailsMaxWidth : 100) / $sizeArray[0]);
289+
$yRatio = min(1, ($coverThumbnailsMaxHeight ? $coverThumbnailsMaxHeight : 100) / $sizeArray[1]);
290+
$ratio = min($xRatio, $yRatio);
291+
$thumbnailWidth = round($ratio * $sizeArray[0]);
292+
$thumbnailHeight = round($ratio * $sizeArray[1]);
293+
$thumbnail = imagecreatetruecolor($thumbnailWidth, $thumbnailHeight);
294+
imagecopyresampled($thumbnail, $image, 0, 0, 0, 0, $thumbnailWidth, $thumbnailHeight, $sizeArray[0], $sizeArray[1]);
295+
296+
$publicFileManager = new PublicFileManager();
297+
298+
// store the thumbnail
299+
$fullPath = $publicFileManager->getContextFilesPath($context->getId()) . '/' . $thumbnailFilename;
300+
match ($imageExtension) {
301+
'.jpg' => imagejpeg($thumbnail, $fullPath),
302+
'.png' => imagepng($thumbnail, $fullPath),
303+
'.gif' => imagegif($thumbnail, $fullPath),
304+
};
305+
306+
return [
307+
'thumbnailName' => $thumbnailFilename,
308+
'thumbnailWidth' => $thumbnailWidth,
309+
'thumbnailHeight' => $thumbnailHeight,
310+
];
311+
} finally {
312+
// Cleanup created image resources
313+
if ($thumbnail) {
314+
imagedestroy($thumbnail);
315+
}
316+
if ($image) {
317+
imagedestroy($image);
318+
}
319+
}
320+
}
321+
}

classes/category/Category.php

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414

1515
namespace PKP\category;
1616

17+
use PKP\security\Role;
18+
1719
class Category extends \PKP\core\DataObject
1820
{
21+
public const ASSIGNABLE_ROLES = [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT];
22+
public const SUPPORTED_IMAGE_TYPES = ['.jpg', '.png', '.gif'];
1923
/**
2024
* Get ID of context.
2125
*/
@@ -48,22 +52,6 @@ public function setParentId(?int $parentId)
4852
return $this->setData('parentId', $parentId);
4953
}
5054

51-
/**
52-
* Get sequence of category.
53-
*/
54-
public function getSequence(): float
55-
{
56-
return (float) $this->getData('sequence');
57-
}
58-
59-
/**
60-
* Set sequence of category.
61-
*/
62-
public function setSequence(float $sequence)
63-
{
64-
return $this->setData('sequence', $sequence);
65-
}
66-
6755
/**
6856
* Get category path.
6957
*/

0 commit comments

Comments
 (0)