Created backend code for corredctly prefixed API endpoints

Signed-off-by: Christian Wolf <github@christianwolf.email>
This commit is contained in:
Christian Wolf 2021-10-20 09:42:02 +02:00
Родитель 119a47d0c5
Коммит 27178d341e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9FC3120E932F73F1
8 изменённых файлов: 330 добавлений и 265 удалений

Просмотреть файл

@ -5,10 +5,16 @@ if [ -e 'vendor/bin/php-cs-fixer' ]; then
git stash push --keep-index
lines_after=`git stash list | wc -l`
composer cs:check || { echo "The PHP code is not validly formatted."; exit 1; }
composer cs:check
RET=$?
if [ $lines_before -lt $lines_after ]; then
git stash pop
fi
if [ $RET -ne 0 ]; then
echo "The PHP code is not validly formatted."
exit $RET
fi
fi

Просмотреть файл

@ -16,26 +16,47 @@ return [
*/
['name' => 'main#getApiVersion', 'url' => '/api/version', 'verb' => 'GET'],
['name' => 'main#index', 'url' => '/', 'verb' => 'GET'],
['name' => 'main#keywords', 'url' => '/keywords', 'verb' => 'GET'],
['name' => 'main#categories', 'url' => '/categories', 'verb' => 'GET'],
['name' => 'main#import', 'url' => '/import', 'verb' => 'POST'],
['name' => 'recipe#image', 'url' => '/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+']],
['name' => 'config#reindex', 'url' => '/reindex', 'verb' => 'POST'],
['name' => 'config#list', 'url' => '/config', 'verb' => 'GET'],
['name' => 'config#config', 'url' => '/config', 'verb' => 'POST'],
/*
* legacy routes -- deprecated
*/
['name' => 'main_v1#keywords', 'url' => '/keywords', 'verb' => 'GET', 'postfix' => '-legacy'],
['name' => 'main_v1#categories', 'url' => '/categories', 'verb' => 'GET', 'postfix' => '-legacy'],
['name' => 'main_v1#import', 'url' => '/import', 'verb' => 'POST', 'postfix' => '-legacy'],
['name' => 'recipe_v1#image', 'url' => '/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+'], 'postfix' => '-legacy'],
['name' => 'config_v1#reindex', 'url' => '/reindex', 'verb' => 'POST', 'postfix' => '-legacy'],
['name' => 'config_v1#list', 'url' => '/config', 'verb' => 'GET', 'postfix' => '-legacy'],
['name' => 'config_v1#config', 'url' => '/config', 'verb' => 'POST', 'postfix' => '-legacy'],
/* API routes */
['name' => 'main#category', 'url' => '/api/category/{category}', 'verb' => 'GET'],
['name' => 'main#categoryUpdate', 'url' => '/api/category/{category}', 'verb' => 'PUT'],
['name' => 'main#tags', 'url' => '/api/tags/{keywords}', 'verb' => 'GET'],
['name' => 'main#search', 'url' => '/api/search/{query}', 'verb' => 'GET'],
['name' => 'main_v1#category', 'url' => '/api/category/{category}', 'verb' => 'GET', 'postfix' => '-legacy'],
['name' => 'main_v1#categoryUpdate', 'url' => '/api/category/{category}', 'verb' => 'PUT', 'postfix' => '-legacy'],
['name' => 'main_v1#tags', 'url' => '/api/tags/{keywords}', 'verb' => 'GET', 'postfix' => '-legacy'],
['name' => 'main_v1#search', 'url' => '/api/search/{query}', 'verb' => 'GET', 'postfix' => '-legacy'],
/* Unknown usage */
/* Deprecated routes */
['name' => 'main#new', 'url' => '/recipes/create', 'verb' => 'POST'],
['name' => 'main#update', 'url' => '/recipes/{id}/edit', 'verb' => 'PUT', 'requirements' => ['id' => '\d+']],
['name' => 'main_v1#new', 'url' => '/recipes/create', 'verb' => 'POST', 'postfix' => '-legacy'],
['name' => 'main_v1#update', 'url' => '/recipes/{id}/edit', 'verb' => 'PUT', 'requirements' => ['id' => '\d+'], 'postfix' => '-legacy'],
/*
* API v1
*/
['name' => 'main_v1#keywords', 'url' => '/api/v1/keywords', 'verb' => 'GET'],
['name' => 'main_v1#categories', 'url' => '/api/v1/categories', 'verb' => 'GET'],
['name' => 'main_v1#import', 'url' => '/api/v1/import', 'verb' => 'POST'],
['name' => 'recipe_v1#image', 'url' => '/api/v1/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+']],
['name' => 'config_v1#reindex', 'url' => '/api/v1/reindex', 'verb' => 'POST'],
['name' => 'config_v1#list', 'url' => '/api/v1/config', 'verb' => 'GET'],
['name' => 'config_v1#config', 'url' => '/api/v1/config', 'verb' => 'POST'],
/* API routes */
['name' => 'main_v1#category', 'url' => '/api/v1/category/{category}', 'verb' => 'GET'],
['name' => 'main_v1#categoryUpdate', 'url' => '/api/v1/category/{category}', 'verb' => 'PUT'],
['name' => 'main_v1#tags', 'url' => '/api/v1/tags/{keywords}', 'verb' => 'GET'],
['name' => 'main_v1#search', 'url' => '/api/v1/search/{query}', 'verb' => 'GET'],
],
/* API resources */
'resources' => [
'recipe' => ['url' => '/api/recipes']
'recipe_legacy' => ['url' => '/api/recipes'],
'recipe_v1' => ['url' => '/api/v1/recipes'],
]
];

Просмотреть файл

@ -2,6 +2,9 @@
namespace OCA\Cookbook\AppInfo;
use OCA\Cookbook\Controller\v1\ConfigController;
use OCA\Cookbook\Controller\v1\MainController;
use OCA\Cookbook\Controller\v1\RecipeController;
use OCA\Cookbook\Search\Provider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
@ -21,6 +24,11 @@ if (Util::getVersion()[0] >= 20) {
public function register(IRegistrationContext $context): void {
$context->registerSearchProvider(Provider::class);
$context->registerServiceAlias('ConfigV1Controller', ConfigController::class);
$context->registerServiceAlias('MainV1Controller', MainController::class);
$context->registerServiceAlias('RecipeV1Controller', RecipeController::class);
$context->registerServiceAlias('RecipeLegacyController', RecipeController::class);
}
public function boot(IBootContext $context): void {

Просмотреть файл

@ -3,17 +3,12 @@
namespace OCA\Cookbook\Controller;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Controller;
use OCA\Cookbook\Service\RecipeService;
use OCA\Cookbook\Service\DbCacheService;
use OCA\Cookbook\Helper\RestParameterParser;
use OCA\Cookbook\Exception\UserFolderNotWritableException;
use OCA\Cookbook\Exception\RecipeExistsException;
use OCP\AppFramework\Http\JSONResponse;
class MainController extends Controller {
protected $appName;
@ -26,24 +21,13 @@ class MainController extends Controller {
* @var DbCacheService
*/
private $dbCacheService;
/**
* @var IURLGenerator
*/
private $urlGenerator;
/**
* @var RestParameterParser
*/
private $restParser;
public function __construct(string $AppName, IRequest $request, RecipeService $recipeService, DbCacheService $dbCacheService, IURLGenerator $urlGenerator, RestParameterParser $restParser) {
public function __construct(string $AppName, IRequest $request, RecipeService $recipeService, DbCacheService $dbCacheService) {
parent::__construct($AppName, $request);
$this->service = $recipeService;
$this->urlGenerator = $urlGenerator;
$this->appName = $AppName;
$this->dbCacheService = $dbCacheService;
$this->restParser = $restParser;
}
/**
@ -76,234 +60,9 @@ class MainController extends Controller {
'api_version' => [
'epoch' => 0,
'major' => 0,
'minor' => 2
'minor' => 3
]
];
return new DataResponse($response, 200, ['Content-Type' => 'application/json']);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function categories() {
$this->dbCacheService->triggerCheck();
$categories = $this->service->getAllCategoriesInSearchIndex();
return new DataResponse($categories, 200, ['Content-Type' => 'application/json']);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function keywords() {
$this->dbCacheService->triggerCheck();
$keywords = $this->service->getAllKeywordsInSearchIndex();
return new DataResponse($keywords, 200, ['Content-Type' => 'application/json']);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function search($query) {
$this->dbCacheService->triggerCheck();
$query = urldecode($query);
try {
$recipes = $this->service->findRecipesInSearchIndex($query);
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb',
't' => $this->service->getRecipeMTime($recipe['recipe_id'])
]
);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb16'
]
);
}
return new DataResponse($recipes, 200, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function category($category) {
$this->dbCacheService->triggerCheck();
$category = urldecode($category);
try {
$recipes = $this->service->getRecipesByCategory($category);
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb',
't' => $this->service->getRecipeMTime($recipe['recipe_id'])
]
);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb16'
]
);
}
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function categoryUpdate($category) {
$this->dbCacheService->triggerCheck();
$json = $this->restParser->getParameters();
if (!$json || !isset($json['name']) || !$json['name']) {
return new DataResponse('New category name not found in data', 400);
}
$category = urldecode($category);
try {
$recipes = $this->service->getRecipesByCategory($category);
foreach ($recipes as $recipe) {
$r = $this->service->getRecipeById($recipe['recipe_id']);
$r['recipeCategory'] = $json['name'];
$this->service->addRecipe($r);
}
// Update cache
$this->dbCacheService->updateCache();
return new DataResponse($json['name'], Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function tags($keywords) {
$this->dbCacheService->triggerCheck();
$keywords = urldecode($keywords);
try {
$recipes = $this->service->getRecipesByKeywords($keywords);
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb',
't' => $this->service->getRecipeMTime($recipe['recipe_id'])
]
);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb16'
]
);
}
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
// error_log($e->getMessage());
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function import() {
$this->dbCacheService->triggerCheck();
$data = $this->restParser->getParameters();
if (!isset($data['url'])) {
return new DataResponse('Field "url" is required', 400);
}
try {
$recipe_file = $this->service->downloadRecipe($data['url']);
$recipe_json = $this->service->parseRecipeFile($recipe_file);
$this->dbCacheService->addRecipe($recipe_file);
return new DataResponse($recipe_json, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (RecipeExistsException $ex) {
$json = [
'msg' => $ex->getMessage(),
'line' => $ex->getLine(),
'file' => $ex->getFile(),
];
return new JSONResponse($json, Http::STATUS_CONFLICT);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 400);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function new() {
$this->dbCacheService->triggerCheck();
try {
$recipe_data = $this->restParser->getParameters();
$file = $this->service->addRecipe($recipe_data);
$this->dbCacheService->addRecipe($file);
return new DataResponse($file->getParent()->getId());
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function update($id) {
$this->dbCacheService->triggerCheck();
try {
$recipe_data = $this->restParser->getParameters();
$recipe_data['id'] = $id;
$file = $this->service->addRecipe($recipe_data);
$this->dbCacheService->addRecipe($file);
return new DataResponse($id);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
}

Просмотреть файл

@ -1,6 +1,6 @@
<?php
namespace OCA\Cookbook\Controller;
namespace OCA\Cookbook\Controller\v1;
use OCP\IRequest;
use OCP\AppFramework\Http;

Просмотреть файл

@ -0,0 +1,271 @@
<?php
namespace OCA\Cookbook\Controller\v1;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Controller;
use OCA\Cookbook\Service\RecipeService;
use OCA\Cookbook\Service\DbCacheService;
use OCA\Cookbook\Helper\RestParameterParser;
use OCA\Cookbook\Exception\RecipeExistsException;
use OCP\AppFramework\Http\JSONResponse;
class MainController extends Controller {
protected $appName;
/**
* @var RecipeService
*/
private $service;
/**
* @var DbCacheService
*/
private $dbCacheService;
/**
* @var IURLGenerator
*/
private $urlGenerator;
/**
* @var RestParameterParser
*/
private $restParser;
public function __construct(string $AppName, IRequest $request, RecipeService $recipeService, DbCacheService $dbCacheService, IURLGenerator $urlGenerator, RestParameterParser $restParser) {
parent::__construct($AppName, $request);
$this->service = $recipeService;
$this->urlGenerator = $urlGenerator;
$this->appName = $AppName;
$this->dbCacheService = $dbCacheService;
$this->restParser = $restParser;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function categories() {
$this->dbCacheService->triggerCheck();
$categories = $this->service->getAllCategoriesInSearchIndex();
return new DataResponse($categories, 200, ['Content-Type' => 'application/json']);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function keywords() {
$this->dbCacheService->triggerCheck();
$keywords = $this->service->getAllKeywordsInSearchIndex();
return new DataResponse($keywords, 200, ['Content-Type' => 'application/json']);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function search($query) {
$this->dbCacheService->triggerCheck();
$query = urldecode($query);
try {
$recipes = $this->service->findRecipesInSearchIndex($query);
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe_v1.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb',
't' => $this->service->getRecipeMTime($recipe['recipe_id'])
]
);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe_v1.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb16'
]
);
}
return new DataResponse($recipes, 200, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function category($category) {
$this->dbCacheService->triggerCheck();
$category = urldecode($category);
try {
$recipes = $this->service->getRecipesByCategory($category);
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe_v1.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb',
't' => $this->service->getRecipeMTime($recipe['recipe_id'])
]
);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe_v1.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb16'
]
);
}
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function categoryUpdate($category) {
$this->dbCacheService->triggerCheck();
$json = $this->restParser->getParameters();
if (!$json || !isset($json['name']) || !$json['name']) {
return new DataResponse('New category name not found in data', 400);
}
$category = urldecode($category);
try {
$recipes = $this->service->getRecipesByCategory($category);
foreach ($recipes as $recipe) {
$r = $this->service->getRecipeById($recipe['recipe_id']);
$r['recipeCategory'] = $json['name'];
$this->service->addRecipe($r);
}
// Update cache
$this->dbCacheService->updateCache();
return new DataResponse($json['name'], Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function tags($keywords) {
$this->dbCacheService->triggerCheck();
$keywords = urldecode($keywords);
try {
$recipes = $this->service->getRecipesByKeywords($keywords);
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe_v1.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb',
't' => $this->service->getRecipeMTime($recipe['recipe_id'])
]
);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe_v1.image',
[
'id' => $recipe['recipe_id'],
'size' => 'thumb16'
]
);
}
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
// error_log($e->getMessage());
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function import() {
$this->dbCacheService->triggerCheck();
$data = $this->restParser->getParameters();
if (!isset($data['url'])) {
return new DataResponse('Field "url" is required', 400);
}
try {
$recipe_file = $this->service->downloadRecipe($data['url']);
$recipe_json = $this->service->parseRecipeFile($recipe_file);
$this->dbCacheService->addRecipe($recipe_file);
return new DataResponse($recipe_json, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (RecipeExistsException $ex) {
$json = [
'msg' => $ex->getMessage(),
'line' => $ex->getLine(),
'file' => $ex->getFile(),
];
return new JSONResponse($json, Http::STATUS_CONFLICT);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 400);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function new() {
$this->dbCacheService->triggerCheck();
try {
$recipe_data = $this->restParser->getParameters();
$file = $this->service->addRecipe($recipe_data);
$this->dbCacheService->addRecipe($file);
return new DataResponse($file->getParent()->getId());
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function update($id) {
$this->dbCacheService->triggerCheck();
try {
$recipe_data = $this->restParser->getParameters();
$recipe_data['id'] = $id;
$file = $this->service->addRecipe($recipe_data);
$this->dbCacheService->addRecipe($file);
return new DataResponse($id);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
}
}

Просмотреть файл

@ -1,6 +1,6 @@
<?php
namespace OCA\Cookbook\Controller;
namespace OCA\Cookbook\Controller\v1;
use OCP\IRequest;
use OCP\AppFramework\Http;
@ -58,8 +58,8 @@ class RecipeController extends Controller {
$recipes = $this->service->findRecipesInSearchIndex(isset($_GET['keywords']) ? $_GET['keywords'] : '');
}
foreach ($recipes as $i => $recipe) {
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb']);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb16']);
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe_v1.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb']);
$recipes[$i]['imagePlaceholderUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe_v1.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb16']);
}
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
}
@ -80,7 +80,7 @@ class RecipeController extends Controller {
}
$json['printImage'] = $this->service->getPrintImage();
$json['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $json['id'], 'size' => 'full']);
$json['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe_v1.image', ['id' => $json['id'], 'size' => 'full']);
return new DataResponse($json, Http::STATUS_OK, ['Content-Type' => 'application/json']);
}
@ -168,7 +168,7 @@ class RecipeController extends Controller {
return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=604800']);
} catch (\Exception $e) {
$file = file_get_contents(dirname(__FILE__) . '/../../img/recipe.svg');
$file = file_get_contents(dirname(__FILE__) . '/../../../img/recipe.svg');
return new DataDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/svg+xml']);
}

Просмотреть файл

@ -71,7 +71,7 @@ if (Util::getVersion()[0] >= 20) {
return new SearchResultEntry(
// Thumb image
$this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $id, 'size' => 'thumb']),
$this->urlGenerator->linkToRoute('cookbook.recipe_v1.image', ['id' => $id, 'size' => 'thumb']),
// Name as title
$recipe['name'],
// Category as subline