Skip to content

Commit 4f760b5

Browse files
committed
feat: edit lists/items, upload item images + prefs
1 parent fdf4b00 commit 4f760b5

22 files changed

Lines changed: 1355 additions & 87 deletions

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Pantry helps households stay organized in Nextcloud.
1717
1818
All data is scoped to a house; members only see the houses they belong to.
1919
]]></description>
20-
<version>1.0.0</version>
20+
<version>0.0.1</version>
2121
<licence>agpl</licence>
2222
<author mail="contact@casraf.dev" homepage="https://github.com/chenasraf/nextcloud-pantry">Chen Asraf</author>
2323
<namespace>Pantry</namespace>

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
},
3232
"require": {
3333
"php": "^8.1",
34-
"sabre/vobject": "^4.5"
34+
"sabre/vobject": "^4.5",
35+
"sabre/xml": "^2.1"
3536
},
3637
"require-dev": {
3738
"bamarni/composer-bin-plugin": "^1.8",

composer.lock

Lines changed: 22 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Controller/PrefsController.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
/**
2323
* @psalm-import-type PantryLastHouse from ResponseDefinitions
24+
* @psalm-import-type PantryImageFolder from ResponseDefinitions
2425
*/
2526
final class PrefsController extends OCSController {
2627
use TranslatesDomainExceptions;
@@ -83,6 +84,41 @@ public function setLastHouse(?int $houseId = null): DataResponse {
8384
});
8485
}
8586

87+
/**
88+
* Get the user's preferred image upload folder
89+
*
90+
* @return DataResponse<Http::STATUS_OK, PantryImageFolder, array{}>
91+
*
92+
* 200: Folder returned
93+
*/
94+
#[ApiRoute(verb: 'GET', url: '/api/prefs/image-folder')]
95+
#[NoAdminRequired]
96+
public function getImageFolder(): DataResponse {
97+
return $this->runAction(function (): DataResponse {
98+
$uid = $this->requireUid();
99+
return new DataResponse(['folder' => $this->prefs->getImageFolder($uid)]);
100+
});
101+
}
102+
103+
/**
104+
* Set the user's preferred image upload folder
105+
*
106+
* @param string $folder Absolute path within the user's files.
107+
*
108+
* @return DataResponse<Http::STATUS_OK, PantryImageFolder, array{}>
109+
*
110+
* 200: Folder updated
111+
*/
112+
#[ApiRoute(verb: 'PUT', url: '/api/prefs/image-folder')]
113+
#[NoAdminRequired]
114+
public function setImageFolder(string $folder): DataResponse {
115+
return $this->runAction(function () use ($folder): DataResponse {
116+
$uid = $this->requireUid();
117+
$stored = $this->prefs->setImageFolder($uid, $folder);
118+
return new DataResponse(['folder' => $stored]);
119+
});
120+
}
121+
86122
private function requireUid(): string {
87123
$user = $this->userSession->getUser();
88124
if ($user === null) {

lib/Controller/ShoppingListController.php

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use OCA\Pantry\ResponseDefinitions;
1313
use OCA\Pantry\Service\CategoryService;
1414
use OCA\Pantry\Service\HouseAuthService;
15+
use OCA\Pantry\Service\ImageService;
1516
use OCA\Pantry\Service\ShoppingListService;
1617
use OCP\AppFramework\Http;
1718
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -35,6 +36,7 @@ public function __construct(
3536
private ShoppingListService $lists,
3637
private CategoryService $categories,
3738
private HouseAuthService $auth,
39+
private ImageService $images,
3840
private IUserSession $userSession,
3941
) {
4042
parent::__construct($appName, $request);
@@ -248,6 +250,7 @@ public function addItem(
248250
* @param string|null $quantity New quantity (empty string clears).
249251
* @param string|null $rrule New RRULE (empty string clears).
250252
* @param bool|null $repeatFromCompletion New recurrence anchor mode.
253+
* @param int|null $imageFileId File id of attached image (0 or negative clears).
251254
* @param int|null $sortOrder New sort order.
252255
*
253256
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
@@ -265,9 +268,10 @@ public function updateItem(
265268
?string $quantity = null,
266269
?string $rrule = null,
267270
?bool $repeatFromCompletion = null,
271+
?int $imageFileId = null,
268272
?int $sortOrder = null,
269273
): DataResponse {
270-
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $categoryId, $quantity, $rrule, $repeatFromCompletion, $sortOrder): DataResponse {
274+
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $categoryId, $quantity, $rrule, $repeatFromCompletion, $imageFileId, $sortOrder): DataResponse {
271275
$this->auth->requireMember($houseId, $this->requireUid());
272276
$item = $this->lists->getItem($itemId);
273277
$list = $this->lists->getList($item->getListId());
@@ -296,6 +300,9 @@ public function updateItem(
296300
if ($repeatFromCompletion !== null) {
297301
$patch['repeatFromCompletion'] = $repeatFromCompletion;
298302
}
303+
if ($imageFileId !== null) {
304+
$patch['imageFileId'] = $imageFileId > 0 ? $imageFileId : null;
305+
}
299306
if ($sortOrder !== null) {
300307
$patch['sortOrder'] = $sortOrder;
301308
}
@@ -359,6 +366,81 @@ public function deleteItem(int $houseId, int $listId, int $itemId): DataResponse
359366
});
360367
}
361368

369+
/**
370+
* Upload an image for an item
371+
*
372+
* Uploads the request body as an image into the user's configured pantry
373+
* image folder and attaches it to the item.
374+
*
375+
* @param int $houseId House id.
376+
* @param int $listId List id.
377+
* @param int $itemId Item id.
378+
*
379+
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
380+
*
381+
* 200: Image uploaded and attached
382+
*/
383+
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/image')]
384+
#[NoAdminRequired]
385+
public function uploadItemImage(int $houseId, int $listId, int $itemId): DataResponse {
386+
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
387+
$uid = $this->requireUid();
388+
$this->auth->requireMember($houseId, $uid);
389+
$item = $this->lists->getItem($itemId);
390+
$list = $this->lists->getList($item->getListId());
391+
$this->assertListInHouse($list->getHouseId(), $houseId);
392+
if ($item->getListId() !== $listId) {
393+
throw new NotFoundException('Item does not belong to this list');
394+
}
395+
396+
$data = $this->request->getUploadedFile('image');
397+
if ($data === null || !is_array($data) || ($data['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
398+
throw new \InvalidArgumentException('No image uploaded');
399+
}
400+
$tmp = (string)($data['tmp_name'] ?? '');
401+
if ($tmp === '' || !is_uploaded_file($tmp)) {
402+
throw new \InvalidArgumentException('Invalid upload');
403+
}
404+
$bytes = file_get_contents($tmp);
405+
if ($bytes === false) {
406+
throw new \RuntimeException('Could not read uploaded file');
407+
}
408+
$original = (string)($data['name'] ?? 'image.jpg');
409+
$fileId = $this->images->uploadForUser($uid, $original, $bytes);
410+
411+
$updated = $this->lists->updateItem($itemId, ['imageFileId' => $fileId]);
412+
return new DataResponse($updated->jsonSerialize());
413+
});
414+
}
415+
416+
/**
417+
* Clear the image attached to an item
418+
*
419+
* @param int $houseId House id.
420+
* @param int $listId List id.
421+
* @param int $itemId Item id.
422+
*
423+
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
424+
*
425+
* 200: Image cleared
426+
*/
427+
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/image')]
428+
#[NoAdminRequired]
429+
public function clearItemImage(int $houseId, int $listId, int $itemId): DataResponse {
430+
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
431+
$uid = $this->requireUid();
432+
$this->auth->requireMember($houseId, $uid);
433+
$item = $this->lists->getItem($itemId);
434+
$list = $this->lists->getList($item->getListId());
435+
$this->assertListInHouse($list->getHouseId(), $houseId);
436+
if ($item->getListId() !== $listId) {
437+
throw new NotFoundException('Item does not belong to this list');
438+
}
439+
$updated = $this->lists->updateItem($itemId, ['imageFileId' => null]);
440+
return new DataResponse($updated->jsonSerialize());
441+
});
442+
}
443+
362444
private function requireUid(): string {
363445
$user = $this->userSession->getUser();
364446
if ($user === null) {

lib/Db/ShoppingListItem.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
* @method void setRepeatFromCompletion(bool $repeatFromCompletion)
3131
* @method int|null getNextDueAt()
3232
* @method void setNextDueAt(?int $nextDueAt)
33+
* @method int|null getImageFileId()
34+
* @method void setImageFileId(?int $imageFileId)
3335
* @method int getSortOrder()
3436
* @method void setSortOrder(int $sortOrder)
3537
* @method int getCreatedAt()
@@ -48,6 +50,7 @@ class ShoppingListItem extends Entity implements \JsonSerializable {
4850
protected ?string $rrule = null;
4951
protected bool $repeatFromCompletion = false;
5052
protected ?int $nextDueAt = null;
53+
protected ?int $imageFileId = null;
5154
protected int $sortOrder = 0;
5255
protected int $createdAt = 0;
5356
protected int $updatedAt = 0;
@@ -59,6 +62,7 @@ public function __construct() {
5962
$this->addType('boughtAt', 'integer');
6063
$this->addType('repeatFromCompletion', 'boolean');
6164
$this->addType('nextDueAt', 'integer');
65+
$this->addType('imageFileId', 'integer');
6266
$this->addType('sortOrder', 'integer');
6367
$this->addType('createdAt', 'integer');
6468
$this->addType('updatedAt', 'integer');
@@ -83,6 +87,7 @@ public function jsonSerialize(): array {
8387
'rrule' => $this->rrule,
8488
'repeatFromCompletion' => $this->repeatFromCompletion,
8589
'nextDueAt' => $this->nextDueAt,
90+
'imageFileId' => $this->imageFileId,
8691
'sortOrder' => $this->sortOrder,
8792
'createdAt' => $this->createdAt,
8893
'updatedAt' => $this->updatedAt,

lib/Migration/Version1Date20260405000000.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
211211
'notnull' => false,
212212
'length' => 20,
213213
]);
214+
$table->addColumn('image_file_id', Types::BIGINT, [
215+
'notnull' => false,
216+
'length' => 20,
217+
]);
214218
$table->addColumn('sort_order', Types::INTEGER, [
215219
'notnull' => true,
216220
'default' => 0,
@@ -226,6 +230,16 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
226230
$table->setPrimaryKey(['id']);
227231
$table->addIndex(['list_id'], 'pantry_items_list_idx');
228232
$table->addIndex(['category_id'], 'pantry_items_cat_idx');
233+
} else {
234+
// Idempotent adds for columns introduced after the initial create
235+
// (covers early-dev deployments where the table already existed).
236+
$table = $schema->getTable($itemsTable);
237+
if (!$table->hasColumn('image_file_id')) {
238+
$table->addColumn('image_file_id', Types::BIGINT, [
239+
'notnull' => false,
240+
'length' => 20,
241+
]);
242+
}
229243
}
230244

231245
return $schema;

lib/ResponseDefinitions.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
* rrule: string|null,
5050
* repeatFromCompletion: bool,
5151
* nextDueAt: int|null,
52+
* imageFileId: int|null,
5253
* sortOrder: int,
5354
* createdAt: int,
5455
* updatedAt: int,
@@ -68,6 +69,8 @@
6869
* @psalm-type PantrySuccess = array{success: true}
6970
*
7071
* @psalm-type PantryLastHouse = array{houseId: int|null}
72+
*
73+
* @psalm-type PantryImageFolder = array{folder: string}
7174
*/
7275
class ResponseDefinitions {
7376
}

0 commit comments

Comments
 (0)