feat: home with lists poc

This commit is contained in:
2026-04-05 21:50:17 +03:00
parent 3839e058dc
commit 6c4d7e64a3
58 changed files with 7971 additions and 1504 deletions

View File

@@ -6,9 +6,16 @@
-->
<id>pantry</id>
<name>Pantry</name>
<summary>Enter your app summary here.</summary>
<summary>Manage your household: shared shopping lists, photos and notes.</summary>
<description><![CDATA[
Enter your app description here.
Pantry helps households stay organized in Nextcloud.
- **Houses** group members and their shared data. A user can belong to multiple houses and switch between them.
- **Shopping lists** support recurring items (e.g. milk every week) that automatically reappear when due.
- **Photo boards** (coming soon) let you keep shared reference photos — the right brand of dog food, a favorite recipe card, and so on.
- **Notes wall** (coming soon) gives the household a lightweight shared space for reminders.
All data is scoped to a house; members only see the houses they belong to.
]]></description>
<version>1.0.0</version>
<licence>agpl</licence>
@@ -26,7 +33,7 @@ Enter your app description here.
<repository>https://github.com/chenasraf/nextcloud-pantry</repository>
<screenshot>https://raw.githubusercontent.com/chenasraf/nextcloud-pantry/refs/heads/master/promo.png</screenshot>
<dependencies>
<nextcloud min-version="29" max-version="32"/>
<nextcloud min-version="29" max-version="34"/>
</dependencies>
<settings>
<admin>OCA\Pantry\Settings\Admin</admin>

View File

@@ -24,7 +24,7 @@
},
"require": {
"php": "^8.1",
"chriskonnertz/bbcode": "^1.1"
"sabre/vobject": "^4.5"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8",

230
composer.lock generated
View File

@@ -4,56 +4,242 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d40c8f0b129f3a8c487902989c988880",
"content-hash": "502959d88450c4cc20089ae1b1673125",
"packages": [
{
"name": "chriskonnertz/bbcode",
"version": "v1.1.2",
"name": "sabre/uri",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/chriskonnertz/bbcode.git",
"reference": "d3acd447ee11265d4ef38b9058cef32adcafa245"
"url": "https://github.com/sabre-io/uri.git",
"reference": "4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/chriskonnertz/bbcode/zipball/d3acd447ee11265d4ef38b9058cef32adcafa245",
"reference": "d3acd447ee11265d4ef38b9058cef32adcafa245",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc",
"reference": "4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc",
"shasum": ""
},
"require": {
"php": ">=5.3.7"
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "~4"
"friendsofphp/php-cs-fixer": "^3.94",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"rector/rector": "^2.3"
},
"type": "library",
"autoload": {
"psr-0": {
"ChrisKonnertz\\BBCode": "src/"
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
"BSD-3-Clause"
],
"authors": [
{
"name": "Kai Mallea",
"email": "kmallea@gmail.com"
},
{
"name": "Chris Konnertz"
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "A naive attempt at a BBCode 'parser' written in PHP. It uses regex and thus fails at complex, nested tags.",
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"bbcode"
"rfc3986",
"uri",
"url"
],
"support": {
"issues": "https://github.com/chriskonnertz/bbcode/issues",
"source": "https://github.com/chriskonnertz/bbcode/tree/master"
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2018-06-17T13:58:51+00:00"
"time": "2026-04-01T08:19:11+00:00"
},
{
"name": "sabre/vobject",
"version": "4.5.8",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2026-01-12T10:45:19+00:00"
},
{
"name": "sabre/xml",
"version": "4.0.7",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "53db7bad0953949fb61037fbf9b13b421492395c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/53db7bad0953949fb61037fbf9b13b421492395c",
"reference": "53db7bad0953949fb61037fbf9b13b421492395c",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.4 || ^8.0",
"sabre/uri": ">=2.0,<4.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.94",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"rector/rector": "^2.3"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2026-04-02T11:40:41+00:00"
}
],
"packages-dev": [

View File

@@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Controller;
use OCA\Pantry\AppInfo;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IRequest;
final class ApiController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IAppConfig $config,
private IL10N $l10n,
) {
parent::__construct($appName, $request);
$this->config = $config;
$this->l10n = $l10n;
}
/**
* GET /api/hello
*
* Returns a simple hello message and the last time the server said hello.
*
* @return DataResponse<Http::STATUS_OK, array{message: string, at: string|null}, array{}>
*
* 200: Data returned successfully.
*/
#[ApiRoute(verb: 'GET', url: '/api/hello')]
#[NoAdminRequired]
public function getHello(): DataResponse {
$lastAt = $this->config->getValueString(AppInfo\Application::APP_ID, 'last_hello_at', '');
$at = $lastAt !== '' ? $lastAt : null;
$message = (string)$this->l10n->t('👋 Hello from server!');
return new DataResponse([
'message' => $message,
'at' => $at,
]);
}
/**
* POST /api/hello
*
* Accepts example payload and returns a message + timestamp.
*
* @param array{
* name?: string,
* theme?: string,
* items?: list<string>,
* counter?: int
* } $data Request payload for creating a hello message.
*
* @return DataResponse<Http::STATUS_OK, array{message: string, at: string}, array{}>
*
* 200: Data returned successfully.
*/
#[ApiRoute(verb: 'POST', url: '/api/hello')]
#[NoAdminRequired]
public function postHello(mixed $data = []): DataResponse {
// Normalize incoming payload (be permissive for the example)
$name = isset($data['name']) && is_string($data['name']) ? trim($data['name']) : '';
$theme = isset($data['theme']) && is_string($data['theme']) ? $data['theme'] : null;
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
$counter = isset($data['counter']) && is_int($data['counter']) ? $data['counter'] : 0;
// Build a friendly message (localized)
$who = $name !== '' ? $name : (string)$this->l10n->t('there');
$message = (string)$this->l10n->t('Hello, %s!', [$who]);
// Optionally include a tiny summary (kept simple for the example)
if ($theme !== null) {
$message .= ' ' . (string)$this->l10n->t('Theme: %s.', [$theme]);
}
if (!empty($items)) {
$message .= ' ' . (string)$this->l10n->t('Items: %d.', [count($items)]);
}
if ($counter !== 0) {
$message .= ' ' . (string)$this->l10n->t('Counter: %d.', [$counter]);
}
// Stamp "now" and persist as the last hello time
$now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DATE_ATOM);
$this->config->setValueString(AppInfo\Application::APP_ID, 'last_hello_at', $now);
return new DataResponse([
'message' => $message,
'at' => $now,
]);
}
}

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Controller;
use OCA\Pantry\Db\House;
use OCA\Pantry\Db\HouseMember;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\ResponseDefinitions;
use OCA\Pantry\Service\HouseAuthService;
use OCA\Pantry\Service\HouseService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
/**
* @psalm-import-type PantryHouse from ResponseDefinitions
* @psalm-import-type PantryMember from ResponseDefinitions
* @psalm-import-type PantrySuccess from ResponseDefinitions
*/
final class HouseController extends OCSController {
use TranslatesDomainExceptions;
public function __construct(
string $appName,
IRequest $request,
private HouseService $houseService,
private HouseAuthService $auth,
private IUserSession $userSession,
private \OCP\IUserManager $userManager,
) {
parent::__construct($appName, $request);
}
/**
* List houses the current user belongs to
*
* @param int<1, 500> $limit Maximum number of houses to return.
* @param int<0, max> $offset Number of houses to skip.
*
* @return DataResponse<Http::STATUS_OK, list<PantryHouse>, array{}>
*
* 200: Houses returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses')]
#[NoAdminRequired]
public function index(int $limit = 100, int $offset = 0): DataResponse {
return $this->runAction(function () use ($limit, $offset): DataResponse {
$uid = $this->requireUid();
$houses = $this->houseService->listForUser($uid);
$sliced = array_slice($houses, max(0, $offset), max(0, $limit));
$out = [];
foreach ($sliced as $house) {
$member = $this->auth->requireMember((int)$house->getId(), $uid);
$out[] = $this->serializeHouseWithRole($house, $member->getRole());
}
return new DataResponse($out);
});
}
/**
* Create a new house
*
* The caller becomes the owner.
*
* @param string $name House name.
* @param string|null $description Optional description.
*
* @return DataResponse<Http::STATUS_OK, PantryHouse, array{}>
*
* 200: House created
*/
#[ApiRoute(verb: 'POST', url: '/api/houses')]
#[NoAdminRequired]
public function create(string $name, ?string $description = null): DataResponse {
return $this->runAction(function () use ($name, $description): DataResponse {
$uid = $this->requireUid();
$house = $this->houseService->create($uid, $name, $description);
return new DataResponse($this->serializeHouseWithRole($house, HouseMember::ROLE_OWNER));
});
}
/**
* Fetch a single house
*
* The caller must be a member.
*
* @param int $houseId House id.
*
* @return DataResponse<Http::STATUS_OK, PantryHouse, array{}>
*
* 200: House returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}')]
#[NoAdminRequired]
public function show(int $houseId): DataResponse {
return $this->runAction(function () use ($houseId): DataResponse {
$uid = $this->requireUid();
$member = $this->auth->requireMember($houseId, $uid);
$house = $this->houseService->get($houseId);
return new DataResponse($this->serializeHouseWithRole($house, $member->getRole()));
});
}
/**
* Update a house
*
* Requires admin or owner role.
*
* @param int $houseId House id.
* @param string|null $name New name.
* @param string|null $description New description.
*
* @return DataResponse<Http::STATUS_OK, PantryHouse, array{}>
*
* 200: House updated
*/
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}')]
#[NoAdminRequired]
public function update(int $houseId, ?string $name = null, ?string $description = null): DataResponse {
return $this->runAction(function () use ($houseId, $name, $description): DataResponse {
$uid = $this->requireUid();
$member = $this->auth->requireAdmin($houseId, $uid);
$patch = [];
if ($name !== null) {
$patch['name'] = $name;
}
if ($description !== null) {
$patch['description'] = $description;
}
$house = $this->houseService->update($houseId, $patch);
return new DataResponse($this->serializeHouseWithRole($house, $member->getRole()));
});
}
/**
* Delete a house and all of its data
*
* Owner only. Removes all lists, items, photos, notes and member records.
*
* @param int $houseId House id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: House deleted
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}')]
#[NoAdminRequired]
public function destroy(int $houseId): DataResponse {
return $this->runAction(function () use ($houseId): DataResponse {
$uid = $this->requireUid();
$this->auth->requireOwner($houseId, $uid);
$this->houseService->delete($houseId);
return new DataResponse(['success' => true]);
});
}
/**
* List members of a house
*
* @param int $houseId House id.
* @param int<1, 500> $limit Maximum number of members to return.
* @param int<0, max> $offset Number of members to skip.
*
* @return DataResponse<Http::STATUS_OK, list<PantryMember>, array{}>
*
* 200: Members returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/members')]
#[NoAdminRequired]
public function listMembers(int $houseId, int $limit = 100, int $offset = 0): DataResponse {
return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse {
$uid = $this->requireUid();
$this->auth->requireMember($houseId, $uid);
$members = array_slice(
$this->houseService->listMembers($houseId),
max(0, $offset),
max(0, $limit),
);
$out = array_map(fn (HouseMember $m) => $this->serializeMember($m), $members);
return new DataResponse($out);
});
}
/**
* Add a member to a house
*
* Requires admin or owner role.
*
* @param int $houseId House id.
* @param string $userId Nextcloud user id to add.
* @param string $role Role: "admin" or "member".
*
* @return DataResponse<Http::STATUS_OK, PantryMember, array{}>
*
* 200: Member added
*/
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/members')]
#[NoAdminRequired]
public function addMember(int $houseId, string $userId, string $role = HouseMember::ROLE_MEMBER): DataResponse {
return $this->runAction(function () use ($houseId, $userId, $role): DataResponse {
$uid = $this->requireUid();
$this->auth->requireAdmin($houseId, $uid);
$member = $this->houseService->addMember($houseId, $userId, $role);
return new DataResponse($this->serializeMember($member));
});
}
/**
* Change a member's role
*
* Requires admin or owner role. The owner's role cannot be changed.
*
* @param int $houseId House id.
* @param int $memberId Member id.
* @param string $role New role.
*
* @return DataResponse<Http::STATUS_OK, PantryMember, array{}>
*
* 200: Role updated
*/
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/members/{memberId}')]
#[NoAdminRequired]
public function updateMember(int $houseId, int $memberId, string $role): DataResponse {
return $this->runAction(function () use ($houseId, $memberId, $role): DataResponse {
$uid = $this->requireUid();
$this->auth->requireAdmin($houseId, $uid);
$member = $this->houseService->updateMemberRole($houseId, $memberId, $role);
return new DataResponse($this->serializeMember($member));
});
}
/**
* Remove a member from a house
*
* Requires admin or owner role. The owner cannot be removed.
*
* @param int $houseId House id.
* @param int $memberId Member id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: Member removed
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/members/{memberId}')]
#[NoAdminRequired]
public function removeMember(int $houseId, int $memberId): DataResponse {
return $this->runAction(function () use ($houseId, $memberId): DataResponse {
$uid = $this->requireUid();
$this->auth->requireAdmin($houseId, $uid);
$this->houseService->removeMember($houseId, $memberId);
return new DataResponse(['success' => true]);
});
}
/**
* Leave a house
*
* Any non-owner member may call this. The owner must transfer ownership first.
*
* @param int $houseId House id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: Left house
*/
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/leave')]
#[NoAdminRequired]
public function leave(int $houseId): DataResponse {
return $this->runAction(function () use ($houseId): DataResponse {
$uid = $this->requireUid();
$this->houseService->leaveHouse($houseId, $uid);
return new DataResponse(['success' => true]);
});
}
private function requireUid(): string {
$user = $this->userSession->getUser();
if ($user === null) {
throw new ForbiddenException('Not authenticated');
}
return $user->getUID();
}
/**
* @return PantryHouse
*/
private function serializeHouseWithRole(House $house, string $role): array {
return [
'id' => (int)$house->getId(),
'name' => $house->getName(),
'description' => $house->getDescription(),
'ownerUid' => $house->getOwnerUid(),
'createdAt' => $house->getCreatedAt(),
'updatedAt' => $house->getUpdatedAt(),
'role' => $role,
];
}
/**
* @return PantryMember
*/
private function serializeMember(HouseMember $member): array {
$user = $this->userManager->get($member->getUserId());
return [
'id' => (int)$member->getId(),
'houseId' => $member->getHouseId(),
'userId' => $member->getUserId(),
'displayName' => $user !== null ? $user->getDisplayName() : $member->getUserId(),
'role' => $member->getRole(),
'joinedAt' => $member->getJoinedAt(),
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Controller;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\ResponseDefinitions;
use OCA\Pantry\Service\HouseAuthService;
use OCA\Pantry\Service\PrefsService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
/**
* @psalm-import-type PantryLastHouse from ResponseDefinitions
*/
final class PrefsController extends OCSController {
use TranslatesDomainExceptions;
public function __construct(
string $appName,
IRequest $request,
private PrefsService $prefs,
private HouseAuthService $auth,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
}
/**
* Get the current user's last-used house id
*
* @return DataResponse<Http::STATUS_OK, PantryLastHouse, array{}>
*
* 200: Last house returned
*/
#[ApiRoute(verb: 'GET', url: '/api/prefs/last-house')]
#[NoAdminRequired]
public function getLastHouse(): DataResponse {
return $this->runAction(function (): DataResponse {
$uid = $this->requireUid();
$houseId = $this->prefs->getLastHouseId($uid);
// If the saved house is no longer accessible, forget it.
if ($houseId !== null) {
try {
$this->auth->requireMember($houseId, $uid);
} catch (ForbiddenException) {
$this->prefs->setLastHouseId($uid, null);
$houseId = null;
}
}
return new DataResponse(['houseId' => $houseId]);
});
}
/**
* Set the current user's last-used house id
*
* @param int|null $houseId House id, or null to clear.
*
* @return DataResponse<Http::STATUS_OK, PantryLastHouse, array{}>
*
* 200: Last house updated
*/
#[ApiRoute(verb: 'PUT', url: '/api/prefs/last-house')]
#[NoAdminRequired]
public function setLastHouse(?int $houseId = null): DataResponse {
return $this->runAction(function () use ($houseId): DataResponse {
$uid = $this->requireUid();
if ($houseId !== null) {
$this->auth->requireMember($houseId, $uid);
}
$this->prefs->setLastHouseId($uid, $houseId);
return new DataResponse(['houseId' => $houseId]);
});
}
private function requireUid(): string {
$user = $this->userSession->getUser();
if ($user === null) {
throw new ForbiddenException('Not authenticated');
}
return $user->getUID();
}
}

View File

@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Controller;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\Exception\NotFoundException;
use OCA\Pantry\ResponseDefinitions;
use OCA\Pantry\Service\HouseAuthService;
use OCA\Pantry\Service\ShoppingListService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
/**
* @psalm-import-type PantryList from ResponseDefinitions
* @psalm-import-type PantryListItem from ResponseDefinitions
* @psalm-import-type PantrySuccess from ResponseDefinitions
*/
final class ShoppingListController extends OCSController {
use TranslatesDomainExceptions;
public function __construct(
string $appName,
IRequest $request,
private ShoppingListService $lists,
private HouseAuthService $auth,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
}
/**
* List all shopping lists in a house
*
* @param int $houseId House id.
* @param int<1, 500> $limit Maximum number of lists to return.
* @param int<0, max> $offset Number of lists to skip.
*
* @return DataResponse<Http::STATUS_OK, list<PantryList>, array{}>
*
* 200: Lists returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists')]
#[NoAdminRequired]
public function indexLists(int $houseId, int $limit = 100, int $offset = 0): DataResponse {
return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$all = $this->lists->listForHouse($houseId);
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
$out = array_map(fn ($l) => $l->jsonSerialize(), $sliced);
return new DataResponse($out);
});
}
/**
* Create a shopping list in a house
*
* @param int $houseId House id.
* @param string $name List name.
* @param string|null $description Optional description.
*
* @return DataResponse<Http::STATUS_OK, PantryList, array{}>
*
* 200: List created
*/
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists')]
#[NoAdminRequired]
public function createList(int $houseId, string $name, ?string $description = null): DataResponse {
return $this->runAction(function () use ($houseId, $name, $description): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->createList($houseId, $name, $description);
return new DataResponse($list->jsonSerialize());
});
}
/**
* Get a shopping list
*
* @param int $houseId House id.
* @param int $listId List id.
*
* @return DataResponse<Http::STATUS_OK, PantryList, array{}>
*
* 200: List returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists/{listId}')]
#[NoAdminRequired]
public function showList(int $houseId, int $listId): DataResponse {
return $this->runAction(function () use ($houseId, $listId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->getList($listId);
$this->assertListInHouse($list->getHouseId(), $houseId);
return new DataResponse($list->jsonSerialize());
});
}
/**
* Update a shopping list
*
* @param int $houseId House id.
* @param int $listId List id.
* @param string|null $name New name.
* @param string|null $description New description.
* @param int|null $sortOrder New sort order.
*
* @return DataResponse<Http::STATUS_OK, PantryList, array{}>
*
* 200: List updated
*/
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/lists/{listId}')]
#[NoAdminRequired]
public function updateList(int $houseId, int $listId, ?string $name = null, ?string $description = null, ?int $sortOrder = null): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $name, $description, $sortOrder): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$existing = $this->lists->getList($listId);
$this->assertListInHouse($existing->getHouseId(), $houseId);
$patch = [];
if ($name !== null) {
$patch['name'] = $name;
}
if ($description !== null) {
$patch['description'] = $description;
}
if ($sortOrder !== null) {
$patch['sortOrder'] = $sortOrder;
}
$list = $this->lists->updateList($listId, $patch);
return new DataResponse($list->jsonSerialize());
});
}
/**
* Delete a shopping list
*
* @param int $houseId House id.
* @param int $listId List id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: List deleted
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}')]
#[NoAdminRequired]
public function deleteList(int $houseId, int $listId): DataResponse {
return $this->runAction(function () use ($houseId, $listId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$existing = $this->lists->getList($listId);
$this->assertListInHouse($existing->getHouseId(), $houseId);
$this->lists->deleteList($listId);
return new DataResponse(['success' => true]);
});
}
/**
* List items in a shopping list
*
* Auto-reopens recurring items whose next occurrence has arrived.
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int<1, 1000> $limit Maximum number of items to return.
* @param int<0, max> $offset Number of items to skip.
*
* @return DataResponse<Http::STATUS_OK, list<PantryListItem>, array{}>
*
* 200: Items returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists/{listId}/items')]
#[NoAdminRequired]
public function indexItems(int $houseId, int $listId, int $limit = 200, int $offset = 0): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $limit, $offset): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->getList($listId);
$this->assertListInHouse($list->getHouseId(), $houseId);
$all = $this->lists->listItems($listId);
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
$items = array_map(fn ($i) => $i->jsonSerialize(), $sliced);
return new DataResponse($items);
});
}
/**
* Add an item to a list
*
* @param int $houseId House id.
* @param int $listId List id.
* @param string $name Item name.
* @param string|null $category Optional category label.
* @param string|null $quantity Optional quantity string.
* @param string|null $rrule Optional RFC 5545 RRULE for recurrence.
* @param int|null $sortOrder Optional sort order.
*
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
*
* 200: Item added
*/
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items')]
#[NoAdminRequired]
public function addItem(
int $houseId,
int $listId,
string $name,
?string $category = null,
?string $quantity = null,
?string $rrule = null,
?int $sortOrder = null,
): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $name, $category, $quantity, $rrule, $sortOrder): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->getList($listId);
$this->assertListInHouse($list->getHouseId(), $houseId);
$item = $this->lists->addItem($listId, [
'name' => $name,
'category' => $category,
'quantity' => $quantity,
'rrule' => $rrule,
'sortOrder' => $sortOrder ?? 0,
]);
return new DataResponse($item->jsonSerialize());
});
}
/**
* Update an item
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int $itemId Item id.
* @param string|null $name New name.
* @param string|null $category New category (empty string clears).
* @param string|null $quantity New quantity (empty string clears).
* @param string|null $rrule New RRULE (empty string clears).
* @param int|null $sortOrder New sort order.
*
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
*
* 200: Item updated
*/
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}')]
#[NoAdminRequired]
public function updateItem(
int $houseId,
int $listId,
int $itemId,
?string $name = null,
?string $category = null,
?string $quantity = null,
?string $rrule = null,
?int $sortOrder = null,
): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $category, $quantity, $rrule, $sortOrder): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$item = $this->lists->getItem($itemId);
$list = $this->lists->getList($item->getListId());
$this->assertListInHouse($list->getHouseId(), $houseId);
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$patch = [];
if ($name !== null) {
$patch['name'] = $name;
}
if ($category !== null) {
$patch['category'] = $category;
}
if ($quantity !== null) {
$patch['quantity'] = $quantity;
}
if ($rrule !== null) {
$patch['rrule'] = $rrule;
}
if ($sortOrder !== null) {
$patch['sortOrder'] = $sortOrder;
}
$updated = $this->lists->updateItem($itemId, $patch);
return new DataResponse($updated->jsonSerialize());
});
}
/**
* Toggle an item's bought status
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int $itemId Item id.
*
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
*
* 200: Item toggled
*/
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/toggle')]
#[NoAdminRequired]
public function toggleItem(int $houseId, int $listId, int $itemId): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
$uid = $this->requireUid();
$this->auth->requireMember($houseId, $uid);
$item = $this->lists->getItem($itemId);
$list = $this->lists->getList($item->getListId());
$this->assertListInHouse($list->getHouseId(), $houseId);
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$toggled = $this->lists->toggleItem($itemId, $uid);
return new DataResponse($toggled->jsonSerialize());
});
}
/**
* Delete an item
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int $itemId Item id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: Item deleted
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}')]
#[NoAdminRequired]
public function deleteItem(int $houseId, int $listId, int $itemId): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$item = $this->lists->getItem($itemId);
$list = $this->lists->getList($item->getListId());
$this->assertListInHouse($list->getHouseId(), $houseId);
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$this->lists->deleteItem($itemId);
return new DataResponse(['success' => true]);
});
}
private function requireUid(): string {
$user = $this->userSession->getUser();
if ($user === null) {
throw new ForbiddenException('Not authenticated');
}
return $user->getUID();
}
private function assertListInHouse(int $listHouseId, int $routeHouseId): void {
if ($listHouseId !== $routeHouseId) {
throw new NotFoundException('List does not belong to this house');
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Controller;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\Exception\NotFoundException;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCS\OCSNotFoundException;
/**
* Wraps controller actions so domain exceptions become proper OCS errors.
*/
trait TranslatesDomainExceptions {
/**
* @template T
* @param callable():T $fn
* @return T
*/
private function runAction(callable $fn) {
try {
return $fn();
} catch (ForbiddenException $e) {
throw new OCSForbiddenException($e->getMessage(), $e);
} catch (NotFoundException $e) {
throw new OCSNotFoundException($e->getMessage(), $e);
} catch (\InvalidArgumentException $e) {
throw new OCSBadRequestException($e->getMessage(), $e);
}
}
}

46
lib/Db/House.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method string getName()
* @method void setName(string $name)
* @method string|null getDescription()
* @method void setDescription(?string $description)
* @method string getOwnerUid()
* @method void setOwnerUid(string $ownerUid)
* @method int getCreatedAt()
* @method void setCreatedAt(int $createdAt)
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $updatedAt)
*/
class House extends Entity implements \JsonSerializable {
protected string $name = '';
protected ?string $description = null;
protected string $ownerUid = '';
protected int $createdAt = 0;
protected int $updatedAt = 0;
public function __construct() {
$this->addType('createdAt', 'integer');
$this->addType('updatedAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'ownerUid' => $this->ownerUid,
'createdAt' => $this->createdAt,
'updatedAt' => $this->updatedAt,
];
}
}

54
lib/Db/HouseMapper.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCA\Pantry\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<House>
*/
class HouseMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, Application::tableName('houses'), House::class);
}
/**
* @return House[]
*/
public function findAllForUser(string $uid): array {
$qb = $this->db->getQueryBuilder();
$qb->select('h.*')
->from($this->getTableName(), 'h')
->innerJoin(
'h',
Application::tableName('house_members'),
'm',
$qb->expr()->eq('m.house_id', 'h.id'),
)
->where($qb->expr()->eq('m.user_id', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)))
->orderBy('h.name', 'ASC');
return $this->findEntities($qb);
}
/**
* @throws DoesNotExistException
*/
public function findById(int $id): House {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
}

54
lib/Db/HouseMember.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method int getHouseId()
* @method void setHouseId(int $houseId)
* @method string getUserId()
* @method void setUserId(string $userId)
* @method string getRole()
* @method void setRole(string $role)
* @method int getJoinedAt()
* @method void setJoinedAt(int $joinedAt)
*/
class HouseMember extends Entity implements \JsonSerializable {
public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin';
public const ROLE_MEMBER = 'member';
protected int $houseId = 0;
protected string $userId = '';
protected string $role = self::ROLE_MEMBER;
protected int $joinedAt = 0;
public function __construct() {
$this->addType('houseId', 'integer');
$this->addType('joinedAt', 'integer');
}
public function isAtLeastAdmin(): bool {
return $this->role === self::ROLE_OWNER || $this->role === self::ROLE_ADMIN;
}
public function isOwner(): bool {
return $this->role === self::ROLE_OWNER;
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'houseId' => $this->houseId,
'userId' => $this->userId,
'role' => $this->role,
'joinedAt' => $this->joinedAt,
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCA\Pantry\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<HouseMember>
*/
class HouseMemberMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, Application::tableName('house_members'), HouseMember::class);
}
/**
* @return HouseMember[]
*/
public function findByHouse(int $houseId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
->orderBy('joined_at', 'ASC');
return $this->findEntities($qb);
}
public function findForUserAndHouse(string $uid, int $houseId): ?HouseMember {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)));
try {
return $this->findEntity($qb);
} catch (DoesNotExistException) {
return null;
}
}
/**
* @throws DoesNotExistException
*/
public function findById(int $id): HouseMember {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function deleteByHouse(int $houseId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
}

52
lib/Db/ShoppingList.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method int getHouseId()
* @method void setHouseId(int $houseId)
* @method string getName()
* @method void setName(string $name)
* @method string|null getDescription()
* @method void setDescription(?string $description)
* @method int getSortOrder()
* @method void setSortOrder(int $sortOrder)
* @method int getCreatedAt()
* @method void setCreatedAt(int $createdAt)
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $updatedAt)
*/
class ShoppingList extends Entity implements \JsonSerializable {
protected int $houseId = 0;
protected string $name = '';
protected ?string $description = null;
protected int $sortOrder = 0;
protected int $createdAt = 0;
protected int $updatedAt = 0;
public function __construct() {
$this->addType('houseId', 'integer');
$this->addType('sortOrder', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('updatedAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'houseId' => $this->houseId,
'name' => $this->name,
'description' => $this->description,
'sortOrder' => $this->sortOrder,
'createdAt' => $this->createdAt,
'updatedAt' => $this->updatedAt,
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method int getListId()
* @method void setListId(int $listId)
* @method string getName()
* @method void setName(string $name)
* @method string|null getCategory()
* @method void setCategory(?string $category)
* @method string|null getQuantity()
* @method void setQuantity(?string $quantity)
* @method bool getBought()
* @method void setBought(bool $bought)
* @method int|null getBoughtAt()
* @method void setBoughtAt(?int $boughtAt)
* @method string|null getBoughtBy()
* @method void setBoughtBy(?string $boughtBy)
* @method string|null getRrule()
* @method void setRrule(?string $rrule)
* @method int|null getNextDueAt()
* @method void setNextDueAt(?int $nextDueAt)
* @method int getSortOrder()
* @method void setSortOrder(int $sortOrder)
* @method int getCreatedAt()
* @method void setCreatedAt(int $createdAt)
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $updatedAt)
*/
class ShoppingListItem extends Entity implements \JsonSerializable {
protected int $listId = 0;
protected string $name = '';
protected ?string $category = null;
protected ?string $quantity = null;
protected bool $bought = false;
protected ?int $boughtAt = null;
protected ?string $boughtBy = null;
protected ?string $rrule = null;
protected ?int $nextDueAt = null;
protected int $sortOrder = 0;
protected int $createdAt = 0;
protected int $updatedAt = 0;
public function __construct() {
$this->addType('listId', 'integer');
$this->addType('bought', 'boolean');
$this->addType('boughtAt', 'integer');
$this->addType('nextDueAt', 'integer');
$this->addType('sortOrder', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('updatedAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'listId' => $this->listId,
'name' => $this->name,
'category' => $this->category,
'quantity' => $this->quantity,
'bought' => $this->bought,
'boughtAt' => $this->boughtAt,
'boughtBy' => $this->boughtBy,
'rrule' => $this->rrule,
'nextDueAt' => $this->nextDueAt,
'sortOrder' => $this->sortOrder,
'createdAt' => $this->createdAt,
'updatedAt' => $this->updatedAt,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCA\Pantry\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<ShoppingListItem>
*/
class ShoppingListItemMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, Application::tableName('list_items'), ShoppingListItem::class);
}
/**
* @return ShoppingListItem[]
*/
public function findByList(int $listId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)))
->orderBy('sort_order', 'ASC')
->addOrderBy('created_at', 'ASC');
return $this->findEntities($qb);
}
/**
* @throws DoesNotExistException
*/
public function findById(int $id): ShoppingListItem {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function deleteByList(int $listId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Db;
use OCA\Pantry\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<ShoppingList>
*/
class ShoppingListMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, Application::tableName('lists'), ShoppingList::class);
}
/**
* @return ShoppingList[]
*/
public function findByHouse(int $houseId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
->orderBy('sort_order', 'ASC')
->addOrderBy('name', 'ASC');
return $this->findEntities($qb);
}
/**
* @throws DoesNotExistException
*/
public function findById(int $id): ShoppingList {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function deleteByHouse(int $houseId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Exception;
class ForbiddenException extends \RuntimeException {
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Exception;
class NotFoundException extends \RuntimeException {
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Migration;
use Closure;
use OCA\Pantry\AppInfo\Application;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Initial schema for Pantry: houses, members, shopping lists, list items.
*/
class Version1Date20260405000000 extends SimpleMigrationStep {
/**
* @param Closure():ISchemaWrapper $schemaClosure
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// ---- pantry_houses ----
$housesTable = Application::tableName('houses');
if (!$schema->hasTable($housesTable)) {
$table = $schema->createTable($housesTable);
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
$table->addColumn('name', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$table->addColumn('description', Types::TEXT, [
'notnull' => false,
]);
$table->addColumn('owner_uid', Types::STRING, [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('created_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('updated_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['owner_uid'], 'pantry_houses_owner_idx');
}
// ---- pantry_house_members ----
$membersTable = Application::tableName('house_members');
if (!$schema->hasTable($membersTable)) {
$table = $schema->createTable($membersTable);
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
$table->addColumn('house_id', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('user_id', Types::STRING, [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('role', Types::STRING, [
'notnull' => true,
'length' => 16,
]);
$table->addColumn('joined_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['house_id', 'user_id'], 'pantry_members_house_user_uq');
$table->addIndex(['user_id'], 'pantry_members_user_idx');
}
// ---- pantry_lists ----
$listsTable = Application::tableName('lists');
if (!$schema->hasTable($listsTable)) {
$table = $schema->createTable($listsTable);
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
$table->addColumn('house_id', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('name', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$table->addColumn('description', Types::TEXT, [
'notnull' => false,
]);
$table->addColumn('sort_order', Types::INTEGER, [
'notnull' => true,
'default' => 0,
]);
$table->addColumn('created_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('updated_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['house_id'], 'pantry_lists_house_idx');
}
// ---- pantry_list_items ----
$itemsTable = Application::tableName('list_items');
if (!$schema->hasTable($itemsTable)) {
$table = $schema->createTable($itemsTable);
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
$table->addColumn('list_id', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('name', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$table->addColumn('category', Types::STRING, [
'notnull' => false,
'length' => 128,
]);
$table->addColumn('quantity', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
$table->addColumn('bought', Types::BOOLEAN, [
'notnull' => true,
'default' => false,
]);
$table->addColumn('bought_at', Types::BIGINT, [
'notnull' => false,
'length' => 20,
]);
$table->addColumn('bought_by', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
$table->addColumn('rrule', Types::STRING, [
'notnull' => false,
'length' => 512,
]);
$table->addColumn('next_due_at', Types::BIGINT, [
'notnull' => false,
'length' => 20,
]);
$table->addColumn('sort_order', Types::INTEGER, [
'notnull' => true,
'default' => 0,
]);
$table->addColumn('created_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('updated_at', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['list_id'], 'pantry_items_list_idx');
}
return $schema;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry;
/**
* @psalm-type PantryHouse = array{
* id: int,
* name: string,
* description: string|null,
* ownerUid: string,
* createdAt: int,
* updatedAt: int,
* role: string,
* }
*
* @psalm-type PantryMember = array{
* id: int,
* houseId: int,
* userId: string,
* displayName: string,
* role: string,
* joinedAt: int,
* }
*
* @psalm-type PantryList = array{
* id: int,
* houseId: int,
* name: string,
* description: string|null,
* sortOrder: int,
* createdAt: int,
* updatedAt: int,
* }
*
* @psalm-type PantryListItem = array{
* id: int,
* listId: int,
* name: string,
* category: string|null,
* quantity: string|null,
* bought: bool,
* boughtAt: int|null,
* boughtBy: string|null,
* rrule: string|null,
* nextDueAt: int|null,
* sortOrder: int,
* createdAt: int,
* updatedAt: int,
* }
*
* @psalm-type PantrySuccess = array{success: true}
*
* @psalm-type PantryLastHouse = array{houseId: int|null}
*/
class ResponseDefinitions {
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Service;
use OCA\Pantry\Db\HouseMember;
use OCA\Pantry\Db\HouseMemberMapper;
use OCA\Pantry\Exception\ForbiddenException;
class HouseAuthService {
public function __construct(
private HouseMemberMapper $memberMapper,
) {
}
public function requireMember(int $houseId, string $uid): HouseMember {
$member = $this->memberMapper->findForUserAndHouse($uid, $houseId);
if ($member === null) {
throw new ForbiddenException('Not a member of this house');
}
return $member;
}
public function requireAdmin(int $houseId, string $uid): HouseMember {
$member = $this->requireMember($houseId, $uid);
if (!$member->isAtLeastAdmin()) {
throw new ForbiddenException('Admin privileges required');
}
return $member;
}
public function requireOwner(int $houseId, string $uid): HouseMember {
$member = $this->requireMember($houseId, $uid);
if (!$member->isOwner()) {
throw new ForbiddenException('Owner privileges required');
}
return $member;
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Service;
use OCA\Pantry\Db\House;
use OCA\Pantry\Db\HouseMapper;
use OCA\Pantry\Db\HouseMember;
use OCA\Pantry\Db\HouseMemberMapper;
use OCA\Pantry\Db\ShoppingListItemMapper;
use OCA\Pantry\Db\ShoppingListMapper;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\Exception\NotFoundException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use OCP\IUserManager;
class HouseService {
public function __construct(
private HouseMapper $houseMapper,
private HouseMemberMapper $memberMapper,
private ShoppingListMapper $listMapper,
private ShoppingListItemMapper $itemMapper,
private IDBConnection $db,
private IUserManager $userManager,
) {
}
/**
* @return House[]
*/
public function listForUser(string $uid): array {
return $this->houseMapper->findAllForUser($uid);
}
public function get(int $houseId): House {
try {
return $this->houseMapper->findById($houseId);
} catch (DoesNotExistException) {
throw new NotFoundException('House not found');
}
}
public function create(string $uid, string $name, ?string $description): House {
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('House name cannot be empty');
}
$this->db->beginTransaction();
try {
$now = time();
$house = new House();
$house->setName($name);
$house->setDescription($description !== null && $description !== '' ? $description : null);
$house->setOwnerUid($uid);
$house->setCreatedAt($now);
$house->setUpdatedAt($now);
/** @var House $house */
$house = $this->houseMapper->insert($house);
$member = new HouseMember();
$member->setHouseId((int)$house->getId());
$member->setUserId($uid);
$member->setRole(HouseMember::ROLE_OWNER);
$member->setJoinedAt($now);
$this->memberMapper->insert($member);
$this->db->commit();
return $house;
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
public function update(int $houseId, array $patch): House {
$house = $this->get($houseId);
if (isset($patch['name'])) {
$name = trim((string)$patch['name']);
if ($name === '') {
throw new \InvalidArgumentException('House name cannot be empty');
}
$house->setName($name);
}
if (array_key_exists('description', $patch)) {
$desc = $patch['description'];
$house->setDescription(is_string($desc) && $desc !== '' ? $desc : null);
}
$house->setUpdatedAt(time());
$this->houseMapper->update($house);
return $house;
}
public function delete(int $houseId): void {
$house = $this->get($houseId);
$this->db->beginTransaction();
try {
// Delete all items under all lists of this house
foreach ($this->listMapper->findByHouse($houseId) as $list) {
$this->itemMapper->deleteByList((int)$list->getId());
}
$this->listMapper->deleteByHouse($houseId);
$this->memberMapper->deleteByHouse($houseId);
$this->houseMapper->delete($house);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
/**
* @return HouseMember[]
*/
public function listMembers(int $houseId): array {
return $this->memberMapper->findByHouse($houseId);
}
public function addMember(int $houseId, string $userId, string $role): HouseMember {
$role = $this->normalizeAssignableRole($role);
if ($this->userManager->get($userId) === null) {
throw new NotFoundException('User not found: ' . $userId);
}
if ($this->memberMapper->findForUserAndHouse($userId, $houseId) !== null) {
throw new \InvalidArgumentException('User is already a member of this house');
}
$member = new HouseMember();
$member->setHouseId($houseId);
$member->setUserId($userId);
$member->setRole($role);
$member->setJoinedAt(time());
/** @var HouseMember $saved */
$saved = $this->memberMapper->insert($member);
return $saved;
}
public function updateMemberRole(int $houseId, int $memberId, string $role): HouseMember {
$role = $this->normalizeAssignableRole($role);
$member = $this->getMember($houseId, $memberId);
if ($member->isOwner()) {
throw new ForbiddenException('Cannot change the role of the house owner');
}
$member->setRole($role);
$this->memberMapper->update($member);
return $member;
}
public function removeMember(int $houseId, int $memberId): void {
$member = $this->getMember($houseId, $memberId);
if ($member->isOwner()) {
throw new ForbiddenException('Cannot remove the house owner');
}
$this->memberMapper->delete($member);
}
public function leaveHouse(int $houseId, string $uid): void {
$member = $this->memberMapper->findForUserAndHouse($uid, $houseId);
if ($member === null) {
throw new NotFoundException('Not a member of this house');
}
if ($member->isOwner()) {
throw new ForbiddenException('Owner cannot leave the house. Transfer ownership or delete the house.');
}
$this->memberMapper->delete($member);
}
private function getMember(int $houseId, int $memberId): HouseMember {
try {
$member = $this->memberMapper->findById($memberId);
} catch (DoesNotExistException) {
throw new NotFoundException('Member not found');
}
if ($member->getHouseId() !== $houseId) {
throw new NotFoundException('Member does not belong to this house');
}
return $member;
}
private function normalizeAssignableRole(string $role): string {
if ($role === HouseMember::ROLE_ADMIN) {
return HouseMember::ROLE_ADMIN;
}
if ($role === HouseMember::ROLE_MEMBER) {
return HouseMember::ROLE_MEMBER;
}
throw new \InvalidArgumentException('Role must be "admin" or "member"');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Service;
use OCA\Pantry\AppInfo\Application;
use OCP\IConfig;
class PrefsService {
private const KEY_LAST_HOUSE = 'last_house_id';
public function __construct(
private IConfig $config,
) {
}
public function getLastHouseId(string $uid): ?int {
$value = $this->config->getUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE, '');
if ($value === '') {
return null;
}
return (int)$value;
}
public function setLastHouseId(string $uid, ?int $houseId): void {
if ($houseId === null) {
$this->config->deleteUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE);
return;
}
$this->config->setUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE, (string)$houseId);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Service;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Recur\RRuleIterator;
/**
* Thin wrapper around sabre/vobject's RRuleIterator.
*/
class RecurrenceService {
/**
* Validate an RRULE string (RFC 5545). Accepts either a bare rule ("FREQ=WEEKLY;INTERVAL=1")
* or a full "RRULE:..." line.
*
* @throws \InvalidArgumentException if the rule is malformed.
*/
public function validate(string $rrule): void {
try {
new RRuleIterator($this->normalize($rrule), new \DateTimeImmutable('2000-01-01T00:00:00Z'));
} catch (InvalidDataException $e) {
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
}
}
/**
* Compute the next occurrence strictly after $from.
*
* The iterator's semantics are: the first item equals DTSTART; subsequent items are
* successive occurrences per the rule. We seed with DTSTART = $from and advance once.
*/
public function computeNextOccurrence(string $rrule, \DateTimeImmutable $from): ?\DateTimeImmutable {
try {
$iter = new RRuleIterator($this->normalize($rrule), $from);
} catch (InvalidDataException $e) {
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
}
// First call yields DTSTART itself. Advance to the next one.
$iter->next();
if (!$iter->valid()) {
return null;
}
$current = $iter->current();
if (!$current instanceof \DateTimeInterface) {
return null;
}
return \DateTimeImmutable::createFromInterface($current);
}
private function normalize(string $rrule): string {
$trim = trim($rrule);
if (stripos($trim, 'RRULE:') === 0) {
return substr($trim, 6);
}
return $trim;
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Service;
use OCA\Pantry\Db\ShoppingList;
use OCA\Pantry\Db\ShoppingListItem;
use OCA\Pantry\Db\ShoppingListItemMapper;
use OCA\Pantry\Db\ShoppingListMapper;
use OCA\Pantry\Exception\NotFoundException;
use OCP\AppFramework\Db\DoesNotExistException;
class ShoppingListService {
public function __construct(
private ShoppingListMapper $listMapper,
private ShoppingListItemMapper $itemMapper,
private RecurrenceService $recurrence,
) {
}
// ----- Lists -----
/**
* @return ShoppingList[]
*/
public function listForHouse(int $houseId): array {
return $this->listMapper->findByHouse($houseId);
}
public function getList(int $listId): ShoppingList {
try {
return $this->listMapper->findById($listId);
} catch (DoesNotExistException) {
throw new NotFoundException('List not found');
}
}
public function createList(int $houseId, string $name, ?string $description): ShoppingList {
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('List name cannot be empty');
}
$now = time();
$list = new ShoppingList();
$list->setHouseId($houseId);
$list->setName($name);
$list->setDescription($description !== null && $description !== '' ? $description : null);
$list->setSortOrder(0);
$list->setCreatedAt($now);
$list->setUpdatedAt($now);
/** @var ShoppingList $saved */
$saved = $this->listMapper->insert($list);
return $saved;
}
public function updateList(int $listId, array $patch): ShoppingList {
$list = $this->getList($listId);
if (isset($patch['name'])) {
$name = trim((string)$patch['name']);
if ($name === '') {
throw new \InvalidArgumentException('List name cannot be empty');
}
$list->setName($name);
}
if (array_key_exists('description', $patch)) {
$desc = $patch['description'];
$list->setDescription(is_string($desc) && $desc !== '' ? $desc : null);
}
if (isset($patch['sortOrder'])) {
$list->setSortOrder((int)$patch['sortOrder']);
}
$list->setUpdatedAt(time());
$this->listMapper->update($list);
return $list;
}
public function deleteList(int $listId): void {
$list = $this->getList($listId);
$this->itemMapper->deleteByList((int)$list->getId());
$this->listMapper->delete($list);
}
// ----- Items -----
/**
* List items for a list, auto-unchecking any recurring items whose next_due_at has passed.
*
* @return ShoppingListItem[]
*/
public function listItems(int $listId, ?int $now = null): array {
$now ??= time();
$items = $this->itemMapper->findByList($listId);
$refreshed = [];
foreach ($items as $item) {
if ($item->getBought() && $item->getNextDueAt() !== null && $item->getNextDueAt() <= $now) {
$item->setBought(false);
$item->setBoughtAt(null);
$item->setBoughtBy(null);
$item->setNextDueAt(null);
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
}
$refreshed[] = $item;
}
return $refreshed;
}
public function getItem(int $itemId): ShoppingListItem {
try {
return $this->itemMapper->findById($itemId);
} catch (DoesNotExistException) {
throw new NotFoundException('Item not found');
}
}
public function addItem(int $listId, array $data): ShoppingListItem {
// Ensure the list exists.
$this->getList($listId);
$name = trim((string)($data['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Item name cannot be empty');
}
$rrule = isset($data['rrule']) && is_string($data['rrule']) && trim($data['rrule']) !== ''
? trim($data['rrule'])
: null;
if ($rrule !== null) {
$this->recurrence->validate($rrule);
}
$now = time();
$item = new ShoppingListItem();
$item->setListId($listId);
$item->setName($name);
$item->setCategory($this->strOrNull($data['category'] ?? null));
$item->setQuantity($this->strOrNull($data['quantity'] ?? null));
$item->setBought(false);
$item->setBoughtAt(null);
$item->setBoughtBy(null);
$item->setRrule($rrule);
$item->setNextDueAt(null);
$item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0);
$item->setCreatedAt($now);
$item->setUpdatedAt($now);
/** @var ShoppingListItem $saved */
$saved = $this->itemMapper->insert($item);
return $saved;
}
public function updateItem(int $itemId, array $patch): ShoppingListItem {
$item = $this->getItem($itemId);
if (isset($patch['name'])) {
$name = trim((string)$patch['name']);
if ($name === '') {
throw new \InvalidArgumentException('Item name cannot be empty');
}
$item->setName($name);
}
if (array_key_exists('category', $patch)) {
$item->setCategory($this->strOrNull($patch['category']));
}
if (array_key_exists('quantity', $patch)) {
$item->setQuantity($this->strOrNull($patch['quantity']));
}
if (array_key_exists('rrule', $patch)) {
$rrule = $patch['rrule'];
if ($rrule === null || (is_string($rrule) && trim($rrule) === '')) {
$item->setRrule(null);
// Clearing recurrence also clears any scheduled re-open.
if ($item->getBought()) {
$item->setNextDueAt(null);
}
} else {
$rrule = trim((string)$rrule);
$this->recurrence->validate($rrule);
$item->setRrule($rrule);
// If already bought, recompute next due from now.
if ($item->getBought()) {
$next = $this->recurrence->computeNextOccurrence($rrule, new \DateTimeImmutable('@' . time()));
$item->setNextDueAt($next?->getTimestamp());
}
}
}
if (isset($patch['sortOrder'])) {
$item->setSortOrder((int)$patch['sortOrder']);
}
$item->setUpdatedAt(time());
$this->itemMapper->update($item);
return $item;
}
public function toggleItem(int $itemId, string $uid, ?int $now = null): ShoppingListItem {
$item = $this->getItem($itemId);
$now ??= time();
if (!$item->getBought()) {
$item->setBought(true);
$item->setBoughtAt($now);
$item->setBoughtBy($uid);
if ($item->getRrule() !== null) {
$next = $this->recurrence->computeNextOccurrence(
$item->getRrule(),
(new \DateTimeImmutable())->setTimestamp($now),
);
$item->setNextDueAt($next?->getTimestamp());
}
} else {
$item->setBought(false);
$item->setBoughtAt(null);
$item->setBoughtBy(null);
$item->setNextDueAt(null);
}
$item->setUpdatedAt($now);
$this->itemMapper->update($item);
return $item;
}
public function deleteItem(int $itemId): void {
$item = $this->getItem($itemId);
$this->itemMapper->delete($item);
}
private function strOrNull(mixed $v): ?string {
if (!is_string($v)) {
return null;
}
$t = trim($v);
return $t === '' ? null : $t;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
"node": "^22.19.0",
"pnpm": "^10.17.0"
},
"packageManager": "pnpm@10.28.0",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
@@ -28,6 +29,7 @@
"@nextcloud/vue": "^9.6.0",
"date-fns": "^4.1.0",
"linkifyjs": "^4.3.2",
"rrule": "^2.8.1",
"vue": "^3.5.32",
"vue-material-design-icons": "^5.3.1"
},
@@ -38,7 +40,7 @@
"@nextcloud/stylelint-config": "^3.2.1",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.0",
"happy-dom": "^20.8.9",
"husky": "^9.1.7",
@@ -47,9 +49,9 @@
"rollup-plugin-visualizer": "^7.0.1",
"sass": "^1.99.0",
"sass-embedded": "^1.99.0",
"typescript": "^5.9.3",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^7.3.1",
"vite": "^8.0.3",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.2",
"vue-router": "^5.0.4",

797
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,8 @@
<template>
<NcContent app-name="pantry">
<!-- Left sidebar -->
<NcAppNavigation>
<template #search>
<NcAppNavigationSearch
v-model="searchValue"
:label="strings.searchLabel"
:placeholder="strings.searchPlaceholder"
/>
</template>
<template #list>
<NcAppNavigationItem
:name="strings.navHome"
:to="{ path: '/' }"
:active="$route.path === '/' || $route.path === ''"
>
<template #icon>
<HomeIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.navExamples"
:to="{ path: basePath + '/examples' }"
:active="isPrefixRoute(basePath + '/examples')"
>
<template #icon>
<PuzzleIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.navAbout"
:to="{ path: basePath + '/about' }"
:active="isPrefixRoute(basePath + '/about')"
>
<template #icon>
<InfoIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
<template #footer>
<!-- Optional footer controls -->
</template>
</NcAppNavigation>
<!-- Main content -->
<NcAppContent id="hello-main">
<header class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted" v-html="strings.subtitle"></p>
</header>
<div id="hello-router">
<router-view name="navigation" />
<NcAppContent id="pantry-main">
<div id="pantry-router">
<div v-if="isRouterLoading" class="router-loading">
<NcLoadingIcon :size="48" />
</div>
@@ -65,61 +13,28 @@
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcContent from '@nextcloud/vue/components/NcContent'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import HomeIcon from '@icons/Home.vue'
import PuzzleIcon from '@icons/Puzzle.vue'
import InfoIcon from '@icons/Information.vue'
export default {
name: 'AppUserWrapper',
name: 'PantryApp',
components: {
NcContent,
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationSearch,
NcLoadingIcon,
HomeIcon,
PuzzleIcon,
InfoIcon,
},
// Tell NcContent we *do* have a sidebar so it arranges layout properly
provide() {
return { 'NcContent:setHasAppNavigation': () => true }
},
data() {
return {
searchValue: '',
isRouterLoading: false,
// Mount path for this app section; adjust to your mount.
basePath: '/apps/pantry',
strings: {
title: t('pantry', 'Hello World — App'),
subtitle: t(
'pantry',
'Use the sidebar to navigate between views. Backend calls use {cStart}axios{cEnd} and OCS responses.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
searchLabel: t('pantry', 'Search'),
searchPlaceholder: t('pantry', 'Type to filter…'),
navHome: t('pantry', 'Home'),
navExamples: t('pantry', 'Examples'),
navAbout: t('pantry', 'About'),
},
_removeBeforeEach: null,
_removeAfterEach: null,
}
},
created() {
// Show a loading overlay while routes are changing
this._removeBeforeEach = this.$router.beforeEach((to, from, next) => {
this.isRouterLoading = true
next()
@@ -129,42 +44,21 @@ export default {
})
},
beforeUnmount() {
// Clean up router guards
if (typeof this._removeBeforeEach === 'function') this._removeBeforeEach()
if (typeof this._removeAfterEach === 'function') this._removeAfterEach()
},
methods: {
isPrefixRoute(prefix) {
return this.$route.path.startsWith(prefix)
},
},
}
</script>
<style scoped lang="scss">
#hello-main {
#pantry-main {
display: flex;
flex-direction: column;
height: 100vh;
/* fills viewport next to sidebar */
overflow: hidden;
}
.page-header {
padding: 1rem;
padding-bottom: 0.5rem;
h2 {
margin: 0 0 6px 0;
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
}
#hello-router {
#pantry-router {
flex: 1;
overflow-y: auto;
padding: 1rem;

View File

@@ -1,360 +1,33 @@
<template>
<div id="pantry-content" class="section">
<h2>{{ strings.title }}</h2>
<!-- Information / quick start -->
<NcAppSettingsSection :name="strings.infoTitle">
<p v-html="strings.infoIntro"></p>
<ol class="ol">
<li v-for="li in strings.gettingStartedList" :key="li" v-html="li"></li>
</ol>
<NcNoteCard type="info">
<p v-html="strings.tipsNote"></p>
</NcNoteCard>
</NcAppSettingsSection>
<!-- Live examples -->
<NcAppSettingsSection :name="strings.examplesHeader">
<section class="example-grid">
<!-- v-model example -->
<div class="card">
<h3 class="card-title">{{ strings.nameInputHeader }}</h3>
<NcTextField
v-model="name"
:label="strings.nameInputLabel"
:placeholder="strings.nameInputPlaceholder"
/>
<p class="mt-8">
{{ strings.livePreview }} <b>{{ greeting }}</b>
</p>
</div>
<!-- Select + computed example -->
<div class="card">
<h3 class="card-title">{{ strings.themeHeader }}</h3>
<NcSelect
v-model="themeLabel"
:options="themeOptionsLabels"
:input-label="strings.themeLabel"
/>
<p class="mt-8">
{{ strings.themePreview }}
<code>{{ activeTheme.value }}</code>
</p>
</div>
<!-- Counter + events example -->
<div class="card">
<h3 class="card-title">{{ strings.counterHeader }}</h3>
<div class="row gap-8">
<NcButton @click="decrement">{{ strings.minus }}</NcButton>
<span class="counter">{{ counter }}</span>
<NcButton @click="increment">{{ strings.plus }}</NcButton>
</div>
</div>
</section>
</NcAppSettingsSection>
<!-- Table + add/remove items example -->
<NcAppSettingsSection :name="strings.itemsHeader">
<div class="row align-start gap-16">
<div style="max-width: 320px">
<NcTextField
v-model="newItem"
:label="strings.newItemLabel"
:placeholder="strings.newItemPlaceholder"
trailing-button-icon="plus"
:show-trailing-button="newItem.trim() !== ''"
@trailing-button-click="addItem"
/>
</div>
<NcButton @click="addItem" :disabled="newItem.trim() === ''">{{ strings.add }}</NcButton>
<NcButton type="secondary" @click="clearItems" :disabled="items.length === 0">
{{ strings.clear }}
</NcButton>
</div>
<table class="mt-16">
<thead>
<tr>
<th style="width: 60%">{{ strings.tableItem }}</th>
<th style="width: 40%">{{ strings.tableActions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in items" :key="item.id">
<td>
<input class="inline-input" :aria-label="strings.editItemAria" v-model="item.label" />
</td>
<td>
<div class="row gap-8">
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
</div>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="2" class="muted">{{ strings.noItems }}</td>
</tr>
</tbody>
</table>
</NcAppSettingsSection>
<!-- Backend calls example -->
<NcAppSettingsSection :name="strings.backendHeader">
<form @submit.prevent @submit="save">
<div class="row gap-16 align-center">
<NcButton @click="fetchHello" :disabled="loading">{{ strings.fetchHello }}</NcButton>
<NcButton :disabled="loading" @click="submit">{{ strings.save }}</NcButton>
<span>
<span v-if="loading">{{ strings.loading }}</span>
<span v-else-if="lastHelloAt">
{{ strings.lastHelloAt }}
<NcDateTime :timestamp="lastHelloAt.valueOf()" />
</span>
<span v-else class="muted">{{ strings.never }}</span>
</span>
</div>
</form>
<NcNoteCard v-if="serverMessage" type="success" class="mt-12">
<p>
{{ strings.serverSaid }} <code>{{ serverMessage }}</code>
</p>
</NcNoteCard>
<NcAppSettingsSection :name="strings.aboutHeader">
<p>{{ strings.aboutBody }}</p>
</NcAppSettingsSection>
</div>
</template>
<script>
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
export default {
name: 'HelloWorld',
name: 'PantrySettings',
components: {
NcAppSettingsSection,
NcButton,
NcDateTime,
NcNoteCard,
NcSelect,
NcTextField,
},
data() {
return {
// UI state
loading: false,
// Example: simple input
name: '',
// Example: select with label <-> value mapping (like your intervals)
themeLabel: null,
themeOptions: [
{ label: t('pantry', 'Light'), value: 'light' },
{ label: t('pantry', 'Dark'), value: 'dark' },
{
label: n('pantry', 'System (1 option)', 'System (%n options)', 2),
value: 'system',
},
],
// Example: small counter
counter: 0,
// Example: simple items table
items: [],
newItem: '',
// Example: tracking server interactions
lastHelloAt: null,
serverMessage: '',
// All user-visible strings go here
strings: {
// Titles / headers
title: t('pantry', 'Hello World — App Template'),
infoTitle: t('pantry', 'Information'),
examplesHeader: t('pantry', 'Quick Examples'),
itemsHeader: t('pantry', 'Editable List'),
backendHeader: t('pantry', 'Backend Calls'),
// Info
infoIntro: t(
title: t('pantry', 'Pantry'),
aboutHeader: t('pantry', 'About:'),
aboutBody: t(
'pantry',
'This view shows {bStart}small, focused examples{bEnd} for inputs, lists, selections, and backend calls.',
{ bStart: '<b>', bEnd: '</b>' },
undefined,
{ escape: false },
'Pantry is a household organizer. Open the Pantry app from the top navigation to manage your houses, shopping lists, photos and notes.',
),
gettingStartedList: [
t(
'pantry',
'Import UI parts from {cStart}@nextcloud/vue{cEnd} and wire them with {cStart}v-model{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
t(
'pantry',
'Use {cStart}axios{cEnd} for API calls; return OCS data as needed.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
t(
'pantry',
'Keep user-facing text in a central {cStart}strings{cEnd} object with {cStart}t/n{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
],
tipsNote: t(
'pantry',
'Pro tip: keep labels in {cStart}label{cEnd} and values in {cStart}value{cEnd} to simplify mapping.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
// Name example
nameInputHeader: t('pantry', 'Chen Asraf'),
nameInputLabel: t('pantry', 'Name'),
nameInputPlaceholder: t('pantry', 'e.g. Ada Lovelace'),
livePreview: t('pantry', 'Live preview:'),
// Theme example
themeHeader: t('pantry', 'Theme'),
themeLabel: t('pantry', 'Choose a theme'),
themePreview: t('pantry', 'Active value:'),
// Counter example
counterHeader: t('pantry', 'Counter'),
plus: t('pantry', '+1'),
minus: t('pantry', '-1'),
// Items table
newItemLabel: t('pantry', 'New item'),
newItemPlaceholder: t('pantry', 'e.g. Hello item'),
add: t('pantry', 'Add'),
clear: t('pantry', 'Clear'),
tableItem: t('pantry', 'Item'),
tableActions: t('pantry', 'Actions'),
editItemAria: t('pantry', 'Edit item'),
duplicate: t('pantry', 'Duplicate'),
remove: t('pantry', 'Remove'),
noItems: t('pantry', 'No items yet'),
// Backend
fetchHello: t('pantry', 'Fetch Hello'),
save: t('pantry', 'Save'),
loading: t('pantry', 'Loading…'),
lastHelloAt: t('pantry', 'Last hello at:'),
never: t('pantry', 'Never'),
serverSaid: t('pantry', 'Server said:'),
},
}
},
created() {
// Load initial data if you want
this.fetchHello()
},
computed: {
// Map selected theme label -> full option
activeTheme() {
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
},
// Convenience list for NcSelect (labels only)
themeOptionsLabels() {
return this.themeOptions.map((x) => x.label)
},
// Live greeting preview (reacts to "name")
greeting() {
return this.name.trim() ? `Hello, ${this.name.trim()}!` : 'Hello!'
},
},
methods: {
// Counter handlers
increment() {
this.counter++
},
decrement() {
this.counter--
},
// Items handlers
addItem() {
const label = this.newItem.trim()
if (!label) return
this.items.push({ id: cryptoRandom(), label })
this.newItem = ''
},
duplicate(index) {
const src = this.items[index]
if (!src) return
this.items.splice(index + 1, 0, { id: cryptoRandom(), label: src.label })
},
remove(index) {
this.items.splice(index, 1)
},
clearItems() {
this.items = []
},
// Backend examples (adjust endpoints to your apps routes)
async fetchHello() {
try {
this.loading = true
// Example GET -> /hello (expects: { ocs: { data: { message: string, at: string }}})
const resp = await ocs.get('/hello')
this.serverMessage = resp.data.message ?? '👋'
// If backend returns ISO date strings, store a Date instance
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
} catch (e) {
console.error('Failed to fetch hello', e)
} finally {
this.loading = false
}
},
async save() {
try {
this.loading = true
// Example POST -> /hello (send minimal payload)
const payload = {
name: this.name.trim() || null,
theme: this.activeTheme.value,
items: this.items.map((x) => x.label),
counter: this.counter,
}
const resp = await ocs.post('/hello', { data: payload })
// Update preview/message
if (resp.data.message) this.serverMessage = resp.data.message
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
} catch (e) {
console.error('Failed to save hello', e)
} finally {
this.loading = false
}
},
},
}
/** Small helper for local IDs (no crypto dep) */
function cryptoRandom() {
return Math.random().toString(36).slice(2, 10)
}
</script>
@@ -363,104 +36,5 @@ function cryptoRandom() {
h2:first-child {
margin-top: 0;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.row {
display: flex;
&.align-start {
align-items: flex-start;
}
&.align-center {
align-items: center;
}
&.gap-8 {
gap: 8px;
}
&.gap-16 {
gap: 16px;
}
}
.example-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
}
.card-title {
margin: 0 0 8px 0;
font-size: 1rem;
font-weight: 600;
}
.counter {
min-width: 3ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
.inline-input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-main-background);
color: var(--color-main-text);
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.ol {
padding-left: 2.5em;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border);
margin-top: 8px;
tr:not(:last-child),
thead tr {
border-bottom: 1px solid var(--color-border);
}
thead,
tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
td,
th {
padding: 6px 8px;
vertical-align: middle;
}
}
}
</style>

60
src/api/houses.ts Normal file
View File

@@ -0,0 +1,60 @@
import { ocs } from '@/axios'
import type { House, HouseMember, HouseRole } from './types'
export async function listHouses(): Promise<House[]> {
const resp = await ocs.get<House[]>('/houses')
return resp.data ?? []
}
export async function getHouse(houseId: number): Promise<House> {
const resp = await ocs.get<House>(`/houses/${houseId}`)
return resp.data
}
export async function createHouse(name: string, description?: string | null): Promise<House> {
const resp = await ocs.post<House>('/houses', { name, description: description ?? null })
return resp.data
}
export async function updateHouse(
houseId: number,
patch: { name?: string; description?: string | null },
): Promise<House> {
const resp = await ocs.patch<House>(`/houses/${houseId}`, patch)
return resp.data
}
export async function deleteHouse(houseId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}`)
}
export async function listMembers(houseId: number): Promise<HouseMember[]> {
const resp = await ocs.get<HouseMember[]>(`/houses/${houseId}/members`)
return resp.data ?? []
}
export async function addMember(
houseId: number,
userId: string,
role: HouseRole = 'member',
): Promise<HouseMember> {
const resp = await ocs.post<HouseMember>(`/houses/${houseId}/members`, { userId, role })
return resp.data
}
export async function updateMemberRole(
houseId: number,
memberId: number,
role: HouseRole,
): Promise<HouseMember> {
const resp = await ocs.patch<HouseMember>(`/houses/${houseId}/members/${memberId}`, { role })
return resp.data
}
export async function removeMember(houseId: number, memberId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}/members/${memberId}`)
}
export async function leaveHouse(houseId: number): Promise<void> {
await ocs.post(`/houses/${houseId}/leave`)
}

87
src/api/lists.ts Normal file
View File

@@ -0,0 +1,87 @@
import { ocs } from '@/axios'
import type { ShoppingList, ShoppingListItem } from './types'
export async function listLists(houseId: number): Promise<ShoppingList[]> {
const resp = await ocs.get<ShoppingList[]>(`/houses/${houseId}/lists`)
return resp.data ?? []
}
export async function createList(
houseId: number,
name: string,
description?: string | null,
): Promise<ShoppingList> {
const resp = await ocs.post<ShoppingList>(`/houses/${houseId}/lists`, {
name,
description: description ?? null,
})
return resp.data
}
export async function getList(houseId: number, listId: number): Promise<ShoppingList> {
const resp = await ocs.get<ShoppingList>(`/houses/${houseId}/lists/${listId}`)
return resp.data
}
export async function updateList(
houseId: number,
listId: number,
patch: { name?: string; description?: string | null; sortOrder?: number },
): Promise<ShoppingList> {
const resp = await ocs.patch<ShoppingList>(`/houses/${houseId}/lists/${listId}`, patch)
return resp.data
}
export async function deleteList(houseId: number, listId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}/lists/${listId}`)
}
export async function listItems(houseId: number, listId: number): Promise<ShoppingListItem[]> {
const resp = await ocs.get<ShoppingListItem[]>(`/houses/${houseId}/lists/${listId}/items`)
return resp.data ?? []
}
export interface ItemInput {
name: string
category?: string | null
quantity?: string | null
rrule?: string | null
sortOrder?: number
}
export async function addItem(
houseId: number,
listId: number,
input: ItemInput,
): Promise<ShoppingListItem> {
const resp = await ocs.post<ShoppingListItem>(`/houses/${houseId}/lists/${listId}/items`, input)
return resp.data
}
export async function updateItem(
houseId: number,
listId: number,
itemId: number,
patch: Partial<ItemInput>,
): Promise<ShoppingListItem> {
const resp = await ocs.patch<ShoppingListItem>(
`/houses/${houseId}/lists/${listId}/items/${itemId}`,
patch,
)
return resp.data
}
export async function toggleItem(
houseId: number,
listId: number,
itemId: number,
): Promise<ShoppingListItem> {
const resp = await ocs.post<ShoppingListItem>(
`/houses/${houseId}/lists/${listId}/items/${itemId}/toggle`,
)
return resp.data
}
export async function deleteItem(houseId: number, listId: number, itemId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`)
}

10
src/api/prefs.ts Normal file
View File

@@ -0,0 +1,10 @@
import { ocs } from '@/axios'
export async function getLastHouse(): Promise<number | null> {
const resp = await ocs.get<{ houseId: number | null }>('/prefs/last-house')
return resp.data?.houseId ?? null
}
export async function setLastHouse(houseId: number | null): Promise<void> {
await ocs.put('/prefs/last-house', { houseId })
}

46
src/api/types.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface House {
id: number
name: string
description: string | null
ownerUid: string
createdAt: number
updatedAt: number
role: HouseRole
}
export type HouseRole = 'owner' | 'admin' | 'member'
export interface HouseMember {
id: number
houseId: number
userId: string
displayName: string
role: HouseRole
joinedAt: number
}
export interface ShoppingList {
id: number
houseId: number
name: string
description: string | null
sortOrder: number
createdAt: number
updatedAt: number
}
export interface ShoppingListItem {
id: number
listId: number
name: string
category: string | null
quantity: string | null
bought: boolean
boughtAt: number | null
boughtBy: string | null
rrule: string | null
nextDueAt: number | null
sortOrder: number
createdAt: number
updatedAt: number
}

View File

@@ -0,0 +1,161 @@
<template>
<NcDialog :name="strings.title" :open="open" @update:open="$emit('update:open', $event)">
<div class="pantry-recurrence">
<NcSelect
v-model="selectedPreset"
:options="presetOptions"
:input-label="strings.repeatLabel"
/>
<div v-if="selectedPreset?.value === 'custom'" class="pantry-recurrence__custom">
<NcTextField
v-model="customRrule"
:label="strings.customLabel"
:placeholder="strings.customPlaceholder"
/>
<p class="pantry-recurrence__hint">{{ strings.customHint }}</p>
</div>
<p v-if="error" class="pantry-recurrence__error">{{ error }}</p>
</div>
<template #actions>
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
<NcButton v-if="hasExisting" variant="tertiary" @click="clear">
{{ strings.clearButton }}
</NcButton>
<NcButton variant="primary" @click="submit">{{ strings.save }}</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { RRule } from 'rrule'
type PresetValue = 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom'
interface PresetOption {
label: string
value: PresetValue
rrule: string | null
}
const props = defineProps<{
open: boolean
modelValue: string | null
}>()
const emit = defineEmits<{
(e: 'update:open', open: boolean): void
(e: 'update:modelValue', value: string | null): void
}>()
const presetOptions = computed<PresetOption[]>(() => [
{ label: t('pantry', 'No repeat'), value: 'none', rrule: null },
{ label: t('pantry', 'Daily'), value: 'daily', rrule: 'FREQ=DAILY' },
{ label: t('pantry', 'Weekly'), value: 'weekly', rrule: 'FREQ=WEEKLY' },
{ label: t('pantry', 'Every two weeks'), value: 'biweekly', rrule: 'FREQ=WEEKLY;INTERVAL=2' },
{ label: t('pantry', 'Monthly'), value: 'monthly', rrule: 'FREQ=MONTHLY' },
{ label: t('pantry', 'Custom …'), value: 'custom', rrule: null },
])
const selectedPreset = ref<PresetOption | null>(presetOptions.value[0] ?? null)
const customRrule = ref('')
const error = ref<string | null>(null)
const hasExisting = computed(() => !!props.modelValue)
function matchPreset(rrule: string | null): PresetOption {
const all = presetOptions.value
if (!rrule) return all[0]!
const normalized = rrule.trim().replace(/^RRULE:/i, '')
const found = all.find((p) => p.rrule === normalized)
return found ?? all[all.length - 1]! // custom
}
watch(
() => [props.open, props.modelValue] as const,
([isOpen, value]) => {
if (isOpen) {
error.value = null
selectedPreset.value = matchPreset(value)
customRrule.value = selectedPreset.value.value === 'custom' ? (value ?? '') : ''
}
},
{ immediate: true },
)
function submit() {
try {
const preset = selectedPreset.value
if (!preset || preset.value === 'none') {
emit('update:modelValue', null)
emit('update:open', false)
return
}
if (preset.value === 'custom') {
const raw = customRrule.value.trim().replace(/^RRULE:/i, '')
if (!raw) {
error.value = t('pantry', 'Please enter a rule.')
return
}
// Validate on the client via rrule library.
RRule.fromString('RRULE:' + raw)
emit('update:modelValue', raw)
} else if (preset.rrule) {
emit('update:modelValue', preset.rrule)
}
emit('update:open', false)
} catch (e) {
error.value = (e as Error).message || t('pantry', 'Invalid recurrence rule.')
}
}
function clear() {
emit('update:modelValue', null)
emit('update:open', false)
}
const strings = {
title: t('pantry', 'Recurrence'),
repeatLabel: t('pantry', 'Repeat:'),
customLabel: t('pantry', 'Custom rule (RFC 5545):'),
customPlaceholder: t('pantry', 'e.g. FREQ=WEEKLY;BYDAY=MO,FR'),
customHint: t(
'pantry',
'Specify a standard iCalendar RRULE value. Leave off the "RRULE:" prefix.',
),
cancel: t('pantry', 'Cancel'),
save: t('pantry', 'Save'),
clearButton: t('pantry', 'Remove recurrence'),
}
</script>
<style scoped>
.pantry-recurrence {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.pantry-recurrence__custom {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pantry-recurrence__hint {
margin: 0;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
}
.pantry-recurrence__error {
margin: 0;
color: var(--color-error);
}
</style>

View File

@@ -254,7 +254,7 @@ describe('StatusBadge', () => {
const emittedEvents = wrapper.emitted('click')
expect(emittedEvents).toBeTruthy()
expect(emittedEvents![0][0]).toBeInstanceOf(MouseEvent)
expect(emittedEvents![0]![0]).toBeInstanceOf(MouseEvent)
})
})

View File

@@ -0,0 +1,63 @@
import { computed, watch, ref, type ComputedRef, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import { useHouses } from './useHouses'
import * as api from '@/api/houses'
import type { House } from '@/api/types'
export function useCurrentHouse(): {
house: Ref<House | null>
houseId: ComputedRef<number | null>
loading: Ref<boolean>
canEdit: ComputedRef<boolean>
canAdmin: ComputedRef<boolean>
isOwner: ComputedRef<boolean>
refresh: () => Promise<void>
} {
const route = useRoute()
const { findById, load } = useHouses()
const house = ref<House | null>(null)
const loading = ref(false)
const houseId = computed<number | null>(() => {
const raw = route.params.houseId
if (!raw) return null
const id = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(id) ? id : null
})
async function refresh(): Promise<void> {
const id = houseId.value
if (id === null) {
house.value = null
return
}
loading.value = true
try {
// Fast path: use cached list if present.
await load()
const cached = findById(id)
if (cached) {
house.value = cached
} else {
house.value = await api.getHouse(id)
}
} finally {
loading.value = false
}
}
watch(houseId, refresh, { immediate: true })
return {
house,
houseId,
loading,
canEdit: computed(() => house.value !== null),
canAdmin: computed(() => {
const role = house.value?.role
return role === 'owner' || role === 'admin'
}),
isOwner: computed(() => house.value?.role === 'owner'),
refresh,
}
}

View File

@@ -0,0 +1,63 @@
import { ref, computed } from 'vue'
import * as api from '@/api/houses'
import type { House } from '@/api/types'
const houses = ref<House[]>([])
const loaded = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function load(force = false): Promise<House[]> {
if (loaded.value && !force) return houses.value
loading.value = true
error.value = null
try {
houses.value = await api.listHouses()
loaded.value = true
} catch (e) {
error.value = (e as Error).message
throw e
} finally {
loading.value = false
}
return houses.value
}
async function create(name: string, description?: string | null): Promise<House> {
const house = await api.createHouse(name, description)
houses.value = [...houses.value, house]
return house
}
async function update(
id: number,
patch: { name?: string; description?: string | null },
): Promise<House> {
const updated = await api.updateHouse(id, patch)
houses.value = houses.value.map((h) => (h.id === id ? updated : h))
return updated
}
async function remove(id: number): Promise<void> {
await api.deleteHouse(id)
houses.value = houses.value.filter((h) => h.id !== id)
}
function findById(id: number): House | undefined {
return houses.value.find((h) => h.id === id)
}
export function useHouses() {
return {
houses,
loaded,
loading,
error,
isEmpty: computed(() => loaded.value && houses.value.length === 0),
load,
create,
update,
remove,
findById,
}
}

View File

@@ -0,0 +1,8 @@
import * as api from '@/api/prefs'
export function useLastHouse() {
return {
get: api.getLastHouse,
set: api.setLastHouse,
}
}

View File

@@ -0,0 +1,93 @@
import { ref } from 'vue'
import * as api from '@/api/lists'
import type { ShoppingList, ShoppingListItem } from '@/api/types'
export function useShoppingLists(houseId: number) {
const lists = ref<ShoppingList[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
lists.value = await api.listLists(houseId)
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
async function create(name: string, description?: string | null): Promise<ShoppingList> {
const created = await api.createList(houseId, name, description)
lists.value = [...lists.value, created]
return created
}
async function rename(listId: number, name: string): Promise<void> {
const updated = await api.updateList(houseId, listId, { name })
lists.value = lists.value.map((l) => (l.id === listId ? updated : l))
}
async function remove(listId: number): Promise<void> {
await api.deleteList(houseId, listId)
lists.value = lists.value.filter((l) => l.id !== listId)
}
return { lists, loading, error, load, create, rename, remove }
}
export function useShoppingListItems(houseId: number, listId: number) {
const items = ref<ShoppingListItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
items.value = await api.listItems(houseId, listId)
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
async function add(input: api.ItemInput): Promise<ShoppingListItem> {
const created = await api.addItem(houseId, listId, input)
items.value = [...items.value, created]
return created
}
async function update(itemId: number, patch: Partial<api.ItemInput>): Promise<void> {
const updated = await api.updateItem(houseId, listId, itemId, patch)
items.value = items.value.map((i) => (i.id === itemId ? updated : i))
}
async function toggle(itemId: number): Promise<void> {
// Optimistic flip.
const prev = items.value.find((i) => i.id === itemId)
if (prev) {
items.value = items.value.map((i) => (i.id === itemId ? { ...i, bought: !i.bought } : i))
}
try {
const updated = await api.toggleItem(houseId, listId, itemId)
items.value = items.value.map((i) => (i.id === itemId ? updated : i))
} catch (e) {
// Roll back on failure.
if (prev) {
items.value = items.value.map((i) => (i.id === itemId ? prev : i))
}
throw e
}
}
async function remove(itemId: number): Promise<void> {
await api.deleteItem(houseId, listId, itemId)
items.value = items.value.filter((i) => i.id !== itemId)
}
return { items, loading, error, load, add, update, toggle, remove }
}

View File

@@ -1,7 +1,73 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { generateUrl } from '@nextcloud/router'
const routes: RouteRecordRaw[] = [{ path: '/', component: () => import('@/views/AppView.vue') }]
const HouseLayout = () => import('@/views/HouseLayout.vue')
const HousesNavigation = () => import('@/views/HousesNavigation.vue')
const HouseNavigation = () => import('@/views/HouseNavigation.vue')
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
components: {
default: () => import('@/views/HomeRedirect.vue'),
navigation: HousesNavigation,
},
},
{
path: '/houses',
name: 'houses',
components: {
default: () => import('@/views/HousesList.vue'),
navigation: HousesNavigation,
},
},
{
path: '/houses/:houseId',
components: {
default: HouseLayout,
navigation: HouseNavigation,
},
props: { default: true, navigation: true },
children: [
{ path: '', redirect: (to) => ({ name: 'lists', params: to.params }) },
{
path: 'lists',
name: 'lists',
component: () => import('@/views/ShoppingListsView.vue'),
props: true,
},
{
path: 'lists/:listId',
name: 'list-detail',
component: () => import('@/views/ShoppingListDetail.vue'),
props: true,
},
{
path: 'photos',
name: 'photos',
component: () => import('@/views/PhotoBoardStub.vue'),
},
{
path: 'notes',
name: 'notes',
component: () => import('@/views/NotesWallStub.vue'),
},
{
path: 'members',
name: 'members',
component: () => import('@/views/MembersView.vue'),
props: true,
},
{
path: 'settings',
name: 'house-settings',
component: () => import('@/views/HouseSettingsView.vue'),
props: true,
},
],
},
]
const router = createRouter({
history: createWebHistory(generateUrl('/apps/pantry')),

View File

@@ -1,458 +0,0 @@
<template>
<div class="user-inner">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<div style="max-width: 320px">
<NcTextField
v-model="search"
:label="strings.searchLabel"
:placeholder="strings.searchPlaceholder"
trailing-button-icon="close"
:show-trailing-button="search !== ''"
@trailing-button-click="clearSearch"
/>
</div>
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
</div>
<div class="toolbar-right">
<NcButton type="secondary" @click="toggleForm">
{{ formOpen ? strings.hideForm : strings.showForm }}
</NcButton>
</div>
</div>
<!-- Quick info / doc -->
<NcNoteCard class="mt-12" type="info">
<p v-html="strings.quickHelp"></p>
</NcNoteCard>
<!-- Add item form -->
<section v-if="formOpen" class="card mt-16">
<h3 class="card-title">{{ strings.formHeader }}</h3>
<div class="row gap-16 align-start">
<div style="max-width: 260px">
<NcTextField
v-model="name"
:label="strings.nameInputLabel"
:placeholder="strings.nameInputPlaceholder"
/>
</div>
<div style="max-width: 220px">
<NcSelect
v-model="themeLabel"
:options="themeOptionsLabels"
:input-label="strings.themeLabel"
/>
</div>
<div class="row gap-8 align-center">
<NcButton @click="addFromForm" :disabled="name.trim() === '' || loading">
{{ strings.add }}
</NcButton>
<NcButton type="tertiary" @click="clearForm" :disabled="loading">
{{ strings.clear }}
</NcButton>
</div>
</div>
<p class="mt-12">
{{ strings.livePreview }} <b>{{ previewGreeting }}</b>
</p>
</section>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="filteredHellos.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="seedOne">{{ strings.addExample }}</NcButton>
</template>
</NcEmptyContent>
<!-- List -->
<section v-else class="mt-16">
<table>
<thead>
<tr>
<th style="width: 40%">{{ strings.colMessage }}</th>
<th style="width: 15%">{{ strings.colStatus }}</th>
<th style="width: 25%">{{ strings.colAt }}</th>
<th style="width: 20%">{{ strings.colActions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(hello, idx) in filteredHellos" :key="hello.id">
<td class="ellipsis">
<span class="mono">{{ hello.message }}</span>
</td>
<td>
<StatusBadge
:status="hello.synced ? 'success' : 'pending'"
:label="hello.synced ? strings.statusSynced : strings.statusLocal"
/>
</td>
<td class="nowrap">
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
<span v-else class="muted">{{ strings.never }}</span>
</td>
<td>
<div class="row gap-8">
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Footer actions -->
<div class="row gap-12 mt-12">
<NcButton type="secondary" @click="refresh" :disabled="loading">{{
strings.refresh
}}</NcButton>
<NcButton type="secondary" @click="clearAll" :disabled="loading || hellos.length === 0">
{{ strings.clearAll }}
</NcButton>
</div>
</section>
</div>
</template>
<script>
/**
* Inner view rendered inside AppUserWrapper via <router-view>.
* Uses the Hello controller (GET/POST /hello).
*/
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import StatusBadge from '@/components/StatusBadge.vue'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
export default {
name: 'AppUserHome',
components: {
NcButton,
NcNoteCard,
NcTextField,
NcSelect,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
StatusBadge,
},
data() {
return {
loading: false,
formOpen: true,
// Toolbar
search: '',
// Form data
name: '',
themeLabel: null,
themeOptions: [
{ label: t('pantry', 'Light'), value: 'light' },
{ label: t('pantry', 'Dark'), value: 'dark' },
{
label: n('pantry', 'System (1 option)', 'System (%n options)', 2),
value: 'system',
},
],
// List of "hellos"
hellos: [],
strings: {
// Toolbar
searchLabel: t('pantry', 'Search'),
searchPlaceholder: t('pantry', 'Filter messages…'),
refresh: t('pantry', 'Refresh'),
showForm: t('pantry', 'Show form'),
hideForm: t('pantry', 'Hide form'),
// Info
quickHelp: t(
'pantry',
'Use the form to post a hello. The list shows recent hellos fetched from the server. All user-visible text is centralized in {cStart}strings{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
// Form
formHeader: t('pantry', 'Say hello'),
nameInputLabel: t('pantry', 'Name'),
nameInputPlaceholder: t('pantry', 'e.g. Ada'),
themeLabel: t('pantry', 'Theme'),
add: t('pantry', 'Add'),
clear: t('pantry', 'Clear'),
livePreview: t('pantry', 'Preview:'),
// List
loading: t('pantry', 'Loading…'),
emptyTitle: t('pantry', 'No hellos yet'),
emptyDesc: t('pantry', 'Try adding one using the form above.'),
addExample: t('pantry', 'Add example'),
colMessage: t('pantry', 'Message'),
colStatus: t('pantry', 'Status'),
colAt: t('pantry', 'Time'),
colActions: t('pantry', 'Actions'),
statusSynced: t('pantry', 'Synced'),
statusLocal: t('pantry', 'Local'),
duplicate: t('pantry', 'Duplicate'),
remove: t('pantry', 'Remove'),
clearAll: t('pantry', 'Clear all'),
never: t('pantry', 'Never'),
},
}
},
created() {
this.refresh()
},
computed: {
themeOptionsLabels() {
return this.themeOptions.map((x) => x.label)
},
activeTheme() {
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
},
previewGreeting() {
const n = this.name.trim()
return n ? `Hello, ${n}!` : 'Hello!'
},
filteredHellos() {
const q = this.search.trim().toLowerCase()
if (!q) return this.hellos
return this.hellos.filter((h) => h.message.toLowerCase().includes(q))
},
},
methods: {
toggleForm() {
this.formOpen = !this.formOpen
},
clearForm() {
this.name = ''
this.themeLabel = null
},
clearSearch() {
this.search = ''
},
async refresh() {
try {
this.loading = true
// GET /hello -> { ocs: { data: { message, at } } }
const resp = await ocs.get('/hello')
const data = resp.data
if (data?.message) {
this.hellos.unshift({
id: genId(),
message: data.message,
at: data.at ?? null,
synced: true,
})
}
} catch (e) {
console.error('Failed to refresh', e)
} finally {
this.loading = false
}
},
async addFromForm() {
const name = this.name.trim()
if (!name) return
try {
this.loading = true
const payload = {
name,
theme: this.activeTheme.value,
items: [],
counter: 0,
}
// POST /hello -> { ocs: { data: { message, at } } }
const resp = await ocs.post('/hello', { data: payload })
const data = resp.data
const message = data?.message ?? `Hello, ${name}!`
const at = data?.at ?? new Date().toISOString()
this.hellos.unshift({ id: genId(), message, at, synced: true })
this.clearForm()
this.formOpen = false
} catch (e) {
console.error('Failed to add hello', e)
} finally {
this.loading = false
}
},
duplicate(index) {
const src = this.hellos[index]
if (!src) return
// Duplicated items are local-only until synced
this.hellos.splice(index + 1, 0, { ...src, id: genId(), synced: false })
},
remove(index) {
this.hellos.splice(index, 1)
},
clearAll() {
this.hellos = []
},
seedOne() {
// Seeded examples are local-only
this.hellos.push({
id: genId(),
message: '👋 Hello example',
at: new Date().toISOString(),
synced: false,
})
},
},
}
function genId() {
return Math.random().toString(36).slice(2, 10)
}
</script>
<style scoped lang="scss">
.user-inner {
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.mono {
font-family: var(--font-monospace);
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.ml-8 {
margin-left: 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.toolbar {
margin-top: 8px;
display: flex;
justify-content: space-between;
gap: 16px;
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
}
.row {
display: flex;
&.align-start {
align-items: flex-start;
}
&.align-center {
align-items: center;
}
&.gap-8 {
gap: 8px;
}
&.gap-12 {
gap: 12px;
}
&.gap-16 {
gap: 16px;
}
}
.card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
background: var(--color-main-background);
}
.card-title {
margin: 0 0 8px 0;
font-size: 1rem;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border);
thead tr,
tr:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
thead,
tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
th,
td {
padding: 8px;
vertical-align: middle;
}
.nowrap {
white-space: nowrap;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="pantry-loading">
<NcLoadingIcon :size="48" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { useHouses } from '@/composables/useHouses'
import { useLastHouse } from '@/composables/useLastHouse'
const router = useRouter()
const { load, houses } = useHouses()
const lastHouse = useLastHouse()
onMounted(async () => {
await load()
if (houses.value.length === 0) {
await router.replace({ name: 'houses' })
return
}
const lastId = await lastHouse.get()
const first = houses.value[0]
if (!first) {
await router.replace({ name: 'houses' })
return
}
const target = lastId !== null && houses.value.some((h) => h.id === lastId) ? lastId : first.id
await router.replace({ name: 'lists', params: { houseId: String(target) } })
})
</script>
<style scoped>
.pantry-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
}
</style>

56
src/views/HouseLayout.vue Normal file
View File

@@ -0,0 +1,56 @@
<template>
<div v-if="loading" class="pantry-loading">
<NcLoadingIcon :size="48" />
</div>
<NcEmptyContent
v-else-if="!house"
:name="strings.notFoundTitle"
:description="strings.notFoundBody"
>
<template #icon>
<HomeIcon />
</template>
</NcEmptyContent>
<router-view v-else />
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import HomeIcon from '@icons/Home.vue'
import { useCurrentHouse } from '@/composables/useCurrentHouse'
import { useLastHouse } from '@/composables/useLastHouse'
const { house, houseId, loading } = useCurrentHouse()
const lastHouse = useLastHouse()
async function persistLastHouse() {
if (houseId.value !== null) {
try {
await lastHouse.set(houseId.value)
} catch {
// Non-critical; swallow.
}
}
}
onMounted(persistLastHouse)
watch(houseId, persistLastHouse)
const strings = {
notFoundTitle: t('pantry', 'House not found'),
notFoundBody: t('pantry', 'This house does not exist or you no longer have access.'),
}
</script>
<style scoped>
.pantry-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<NcAppNavigation>
<template #list>
<NcAppNavigationItem :name="strings.allHouses" :to="{ name: 'houses' }">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
</NcAppNavigationItem>
<li v-if="house" class="pantry-nav-house-name" :title="house.name">
{{ house.name }}
</li>
<NcAppNavigationItem
:name="strings.lists"
:to="{ name: 'lists', params: { houseId: String(houseId) } }"
>
<template #icon>
<CartIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.photos"
:to="{ name: 'photos', params: { houseId: String(houseId) } }"
>
<template #icon>
<ImageIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.notes"
:to="{ name: 'notes', params: { houseId: String(houseId) } }"
>
<template #icon>
<NoteIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.members"
:to="{ name: 'members', params: { houseId: String(houseId) } }"
>
<template #icon>
<AccountGroupIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canAdmin"
:name="strings.houseSettings"
:to="{ name: 'house-settings', params: { houseId: String(houseId) } }"
>
<template #icon>
<CogIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigation>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import CartIcon from '@icons/Cart.vue'
import ImageIcon from '@icons/Image.vue'
import NoteIcon from '@icons/Note.vue'
import AccountGroupIcon from '@icons/AccountGroup.vue'
import CogIcon from '@icons/Cog.vue'
import { useCurrentHouse } from '@/composables/useCurrentHouse'
const { house, houseId, canAdmin } = useCurrentHouse()
const strings = {
allHouses: t('pantry', 'All houses'),
lists: t('pantry', 'Shopping lists'),
photos: t('pantry', 'Photo board'),
notes: t('pantry', 'Notes wall'),
members: t('pantry', 'Members'),
houseSettings: t('pantry', 'House settings'),
}
</script>
<style scoped>
.pantry-nav-house-name {
padding: 8px 16px 4px;
font-weight: 600;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="pantry-house-settings">
<h2>{{ strings.title }}</h2>
<form v-if="house" class="pantry-form" @submit.prevent="save">
<NcTextField
v-model="name"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
/>
<NcTextField
v-model="description"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
/>
<div class="pantry-form__actions">
<NcButton type="submit" variant="primary" :disabled="saving || !name.trim()">
{{ saving ? strings.saving : strings.save }}
</NcButton>
</div>
</form>
<hr v-if="isOwner" class="pantry-divider" />
<section v-if="isOwner" class="pantry-danger">
<h3>{{ strings.dangerTitle }}</h3>
<p>{{ strings.dangerBody }}</p>
<NcButton variant="error" @click="confirmingDelete = true">
{{ strings.deleteButton }}
</NcButton>
</section>
<NcDialog
v-if="confirmingDelete"
:name="strings.deleteDialogTitle"
:open="confirmingDelete"
@update:open="confirmingDelete = $event"
>
<p>{{ strings.deleteConfirmBody }}</p>
<template #actions>
<NcButton @click="confirmingDelete = false">{{ strings.cancel }}</NcButton>
<NcButton variant="error" @click="deleteHouse">{{ strings.deleteButton }}</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import { useCurrentHouse } from '@/composables/useCurrentHouse'
import { useHouses } from '@/composables/useHouses'
const router = useRouter()
const { house, isOwner, refresh } = useCurrentHouse()
const { update, remove } = useHouses()
const name = ref('')
const description = ref('')
const saving = ref(false)
const confirmingDelete = ref(false)
watch(
house,
(h) => {
if (h) {
name.value = h.name
description.value = h.description ?? ''
}
},
{ immediate: true },
)
async function save() {
if (!house.value) return
saving.value = true
try {
await update(house.value.id, {
name: name.value.trim(),
description: description.value.trim() || null,
})
await refresh()
} finally {
saving.value = false
}
}
async function deleteHouse() {
if (!house.value) return
await remove(house.value.id)
confirmingDelete.value = false
await router.push({ name: 'houses' })
}
const strings = {
title: t('pantry', 'House settings'),
nameLabel: t('pantry', 'Name:'),
namePlaceholder: t('pantry', 'House name'),
descriptionLabel: t('pantry', 'Description:'),
descriptionPlaceholder: t('pantry', 'A short description'),
save: t('pantry', 'Save changes'),
saving: t('pantry', 'Saving …'),
dangerTitle: t('pantry', 'Danger zone'),
dangerBody: t(
'pantry',
'Deleting a house permanently removes all of its lists, items, and membership records. This cannot be undone.',
),
deleteButton: t('pantry', 'Delete house'),
deleteDialogTitle: t('pantry', 'Delete this house?'),
deleteConfirmBody: t(
'pantry',
'All lists, items and member records for this house will be permanently deleted.',
),
cancel: t('pantry', 'Cancel'),
}
</script>
<style scoped lang="scss">
.pantry-house-settings {
max-width: 640px;
margin: 0 auto;
h2 {
margin-top: 0;
}
}
.pantry-form {
display: flex;
flex-direction: column;
gap: 1rem;
&__actions {
display: flex;
justify-content: flex-end;
}
}
.pantry-divider {
margin: 2rem 0;
border: none;
border-top: 1px solid var(--color-border);
}
.pantry-danger {
h3 {
margin-top: 0;
color: var(--color-error);
}
p {
color: var(--color-text-maxcontrast);
}
}
</style>

253
src/views/HousesList.vue Normal file
View File

@@ -0,0 +1,253 @@
<template>
<div class="pantry-houses">
<header class="pantry-houses__header">
<h2>{{ strings.title }}</h2>
<NcButton variant="primary" @click="showCreate = true">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createButton }}
</NcButton>
</header>
<div v-if="loading && !loaded" class="pantry-houses__loading">
<NcLoadingIcon :size="48" />
</div>
<NcEmptyContent
v-else-if="loaded && houses.length === 0"
:name="strings.emptyTitle"
:description="strings.emptyBody"
>
<template #icon>
<HomeIcon />
</template>
<template #action>
<NcButton variant="primary" @click="showCreate = true">
{{ strings.createButton }}
</NcButton>
</template>
</NcEmptyContent>
<ul v-else class="pantry-houses__grid">
<li v-for="house in houses" :key="house.id">
<router-link
class="pantry-house-card"
:to="{ name: 'lists', params: { houseId: String(house.id) } }"
>
<div class="pantry-house-card__icon">
<HomeIcon :size="32" />
</div>
<div class="pantry-house-card__body">
<h3 class="pantry-house-card__name">{{ house.name }}</h3>
<p v-if="house.description" class="pantry-house-card__desc">
{{ house.description }}
</p>
<span class="pantry-house-card__role">{{ roleLabel(house.role) }}</span>
</div>
</router-link>
</li>
</ul>
<NcDialog
v-if="showCreate"
:name="strings.createDialogTitle"
:open="showCreate"
@update:open="showCreate = $event"
>
<form class="pantry-create-form" @submit.prevent="submitCreate">
<NcTextField
v-model="newName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
/>
<NcTextField
v-model="newDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
/>
<p v-if="createError" class="pantry-form-error">{{ createError }}</p>
</form>
<template #actions>
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="creating || !newName.trim()" @click="submitCreate">
{{ creating ? strings.creatingLabel : strings.createButton }}
</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import PlusIcon from '@icons/Plus.vue'
import HomeIcon from '@icons/Home.vue'
import { useHouses } from '@/composables/useHouses'
import type { HouseRole } from '@/api/types'
const router = useRouter()
const { houses, loaded, loading, load, create } = useHouses()
const showCreate = ref(false)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const createError = ref<string | null>(null)
onMounted(() => {
void load()
})
async function submitCreate() {
const name = newName.value.trim()
if (!name) return
creating.value = true
createError.value = null
try {
const house = await create(name, newDescription.value.trim() || null)
showCreate.value = false
newName.value = ''
newDescription.value = ''
await router.push({ name: 'lists', params: { houseId: String(house.id) } })
} catch (e) {
createError.value = (e as Error).message || t('pantry', 'Could not create house.')
} finally {
creating.value = false
}
}
function roleLabel(role: HouseRole): string {
switch (role) {
case 'owner':
return t('pantry', 'Owner')
case 'admin':
return t('pantry', 'Administrator')
default:
return t('pantry', 'Member')
}
}
const strings = {
title: t('pantry', 'Your houses'),
createButton: t('pantry', 'New house'),
creatingLabel: t('pantry', 'Creating …'),
createDialogTitle: t('pantry', 'Create a house'),
nameLabel: t('pantry', 'Name:'),
namePlaceholder: t('pantry', 'e.g. Home, Beach house'),
descriptionLabel: t('pantry', 'Description (optional):'),
descriptionPlaceholder: t('pantry', 'A short description'),
cancel: t('pantry', 'Cancel'),
emptyTitle: t('pantry', 'No houses yet'),
emptyBody: t(
'pantry',
'Create a house to start organizing your shopping lists, photos and notes.',
),
}
</script>
<style scoped lang="scss">
.pantry-houses {
max-width: 1100px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: 0;
}
}
&__loading {
display: flex;
justify-content: center;
padding: 2rem;
}
&__grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
}
.pantry-house-card {
display: flex;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large, 12px);
background: var(--color-main-background);
color: inherit;
text-decoration: none;
transition: background-color 0.15s ease;
&:hover,
&:focus-visible {
background: var(--color-background-hover);
}
&__icon {
color: var(--color-primary-element);
display: flex;
align-items: flex-start;
}
&__body {
flex: 1;
min-width: 0;
}
&__name {
margin: 0 0 4px 0;
font-size: 1.1rem;
}
&__desc {
margin: 0 0 6px 0;
color: var(--color-text-maxcontrast);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__role {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-primary-element-light);
color: var(--color-primary-element-light-text);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.pantry-create-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.pantry-form-error {
color: var(--color-error);
margin: 0;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<NcAppNavigation>
<template #list>
<NcAppNavigationItem
:name="strings.allHouses"
:to="{ name: 'houses' }"
:active="$route.name === 'houses' || $route.name === 'home'"
>
<template #icon>
<HomeIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigation>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import HomeIcon from '@icons/Home.vue'
const strings = {
allHouses: t('pantry', 'All houses'),
}
</script>

256
src/views/MembersView.vue Normal file
View File

@@ -0,0 +1,256 @@
<template>
<div class="pantry-members">
<header class="pantry-members__header">
<h2>{{ strings.title }}</h2>
<NcButton v-if="canAdmin" variant="primary" @click="showAdd = true">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.addMember }}
</NcButton>
</header>
<div v-if="loading" class="pantry-center">
<NcLoadingIcon :size="36" />
</div>
<table v-else class="pantry-members__table">
<thead>
<tr>
<th>{{ strings.colUser }}</th>
<th>{{ strings.colRole }}</th>
<th>{{ strings.colJoined }}</th>
<th class="pantry-members__actions-col"></th>
</tr>
</thead>
<tbody>
<tr v-for="member in members" :key="member.id">
<td>{{ member.displayName }}</td>
<td>
<select
v-if="canAdmin && member.role !== 'owner'"
:value="member.role"
@change="changeRole(member.id, ($event.target as HTMLSelectElement).value)"
>
<option value="admin">{{ roleLabel('admin') }}</option>
<option value="member">{{ roleLabel('member') }}</option>
</select>
<span v-else>{{ roleLabel(member.role) }}</span>
</td>
<td>
<NcDateTime :timestamp="member.joinedAt * 1000" />
</td>
<td class="pantry-members__actions">
<NcButton
v-if="canAdmin && member.role !== 'owner'"
variant="tertiary"
:aria-label="strings.removeMember"
@click="removeExisting(member.id)"
>
<template #icon>
<DeleteIcon :size="18" />
</template>
</NcButton>
</td>
</tr>
</tbody>
</table>
<div v-if="!isOwner" class="pantry-members__leave">
<NcButton variant="secondary" @click="leave">
{{ strings.leaveButton }}
</NcButton>
</div>
<NcDialog
v-if="showAdd"
:name="strings.addDialogTitle"
:open="showAdd"
@update:open="showAdd = $event"
>
<form class="pantry-form" @submit.prevent="submitAdd">
<NcTextField
v-model="newUserId"
:label="strings.userIdLabel"
:placeholder="strings.userIdPlaceholder"
/>
<NcSelect v-model="newRoleLabel" :options="roleOptions" :input-label="strings.roleLabel" />
<p v-if="addError" class="pantry-form-error">{{ addError }}</p>
</form>
<template #actions>
<NcButton @click="showAdd = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!newUserId.trim()" @click="submitAdd">
{{ strings.addMember }}
</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import PlusIcon from '@icons/Plus.vue'
import DeleteIcon from '@icons/Delete.vue'
import * as api from '@/api/houses'
import type { HouseMember, HouseRole } from '@/api/types'
import { useCurrentHouse } from '@/composables/useCurrentHouse'
const props = defineProps<{ houseId: string }>()
const router = useRouter()
const houseIdNum = computed(() => Number(props.houseId))
const { canAdmin, isOwner } = useCurrentHouse()
const members = ref<HouseMember[]>([])
const loading = ref(false)
const showAdd = ref(false)
const newUserId = ref('')
interface RoleOption {
label: string
value: HouseRole
}
const roleOptions = computed<RoleOption[]>(() => [
{ label: t('pantry', 'Member'), value: 'member' },
{ label: t('pantry', 'Administrator'), value: 'admin' },
])
const newRoleLabel = ref<RoleOption>(roleOptions.value[0]!)
const addError = ref<string | null>(null)
async function load() {
loading.value = true
try {
members.value = await api.listMembers(houseIdNum.value)
} finally {
loading.value = false
}
}
onMounted(load)
async function submitAdd() {
const uid = newUserId.value.trim()
if (!uid) return
addError.value = null
try {
const role: HouseRole = newRoleLabel.value?.value ?? 'member'
const member = await api.addMember(houseIdNum.value, uid, role)
members.value = [...members.value, member]
showAdd.value = false
newUserId.value = ''
} catch (e) {
addError.value = (e as Error).message || t('pantry', 'Could not add member.')
}
}
async function changeRole(memberId: number, role: string) {
if (role !== 'admin' && role !== 'member') return
const updated = await api.updateMemberRole(houseIdNum.value, memberId, role)
members.value = members.value.map((m) => (m.id === memberId ? updated : m))
}
async function removeExisting(memberId: number) {
await api.removeMember(houseIdNum.value, memberId)
members.value = members.value.filter((m) => m.id !== memberId)
}
async function leave() {
await api.leaveHouse(houseIdNum.value)
await router.push({ name: 'houses' })
}
function roleLabel(role: HouseRole): string {
switch (role) {
case 'owner':
return t('pantry', 'Owner')
case 'admin':
return t('pantry', 'Administrator')
default:
return t('pantry', 'Member')
}
}
const strings = {
title: t('pantry', 'Members'),
addMember: t('pantry', 'Add member'),
removeMember: t('pantry', 'Remove member'),
leaveButton: t('pantry', 'Leave this house'),
colUser: t('pantry', 'Account'),
colRole: t('pantry', 'Role'),
colJoined: t('pantry', 'Joined'),
addDialogTitle: t('pantry', 'Add a member'),
userIdLabel: t('pantry', 'Account ID:'),
userIdPlaceholder: t('pantry', 'The Nextcloud username'),
roleLabel: t('pantry', 'Role:'),
cancel: t('pantry', 'Cancel'),
}
</script>
<style scoped lang="scss">
.pantry-members {
max-width: 900px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: 0;
}
}
&__table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
}
&__actions-col {
width: 44px;
}
&__actions {
text-align: right;
}
&__leave {
margin-top: 1.5rem;
display: flex;
justify-content: flex-end;
}
}
.pantry-center {
display: flex;
justify-content: center;
padding: 2rem;
}
.pantry-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.pantry-form-error {
color: var(--color-error);
margin: 0;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<NcEmptyContent :name="strings.title" :description="strings.body">
<template #icon>
<NoteIcon />
</template>
</NcEmptyContent>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NoteIcon from '@icons/Note.vue'
const strings = {
title: t('pantry', 'Notes wall'),
body: t('pantry', 'A shared space for household notes and reminders is coming soon.'),
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<NcEmptyContent :name="strings.title" :description="strings.body">
<template #icon>
<ImageIcon />
</template>
</NcEmptyContent>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import ImageIcon from '@icons/Image.vue'
const strings = {
title: t('pantry', 'Photo board'),
body: t(
'pantry',
'Shared reference photos are coming soon. You will be able to upload and categorize pictures for the whole household.',
),
}
</script>

View File

@@ -0,0 +1,299 @@
<template>
<div class="pantry-detail">
<header class="pantry-detail__header">
<NcButton
variant="tertiary"
:aria-label="strings.back"
@click="$router.push({ name: 'lists', params: { houseId } })"
>
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
</NcButton>
<h2 v-if="list">{{ list.name }}</h2>
<h2 v-else>&nbsp;</h2>
</header>
<form class="pantry-detail__add" @submit.prevent="submitAdd">
<NcTextField
v-model="newName"
:label="strings.newItemLabel"
:placeholder="strings.newItemPlaceholder"
/>
<NcTextField
v-model="newQuantity"
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
/>
<NcTextField
v-model="newCategory"
:label="strings.categoryLabel"
:placeholder="strings.categoryPlaceholder"
/>
<NcButton variant="tertiary" @click="showRecurrenceEditor = true">
<template #icon>
<RepeatIcon :size="20" />
</template>
{{ newRrule ? strings.recurrenceSet : strings.recurrenceButton }}
</NcButton>
<NcButton type="submit" variant="primary" :disabled="!newName.trim() || adding">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.add }}
</NcButton>
</form>
<div v-if="loading" class="pantry-center">
<NcLoadingIcon :size="36" />
</div>
<NcEmptyContent
v-else-if="items.length === 0"
:name="strings.emptyTitle"
:description="strings.emptyBody"
>
<template #icon>
<CartIcon />
</template>
</NcEmptyContent>
<ul v-else class="pantry-items">
<li
v-for="item in sortedItems"
:key="item.id"
class="pantry-item"
:class="{ 'pantry-item--bought': item.bought }"
>
<NcCheckboxRadioSwitch
:model-value="item.bought"
@update:model-value="handleToggle(item.id)"
>
<span class="pantry-item__name">{{ item.name }}</span>
</NcCheckboxRadioSwitch>
<div class="pantry-item__meta">
<span v-if="item.quantity" class="pantry-item__quantity">{{ item.quantity }}</span>
<span v-if="item.category" class="pantry-item__category">{{ item.category }}</span>
<span v-if="item.rrule" class="pantry-item__recurrence" :title="item.rrule">
<RepeatIcon :size="14" />
{{ formatRrule(item.rrule) }}
</span>
</div>
<div class="pantry-item__actions">
<NcButton
variant="tertiary"
:aria-label="strings.removeItem"
@click="handleRemove(item.id)"
>
<template #icon>
<DeleteIcon :size="18" />
</template>
</NcButton>
</div>
</li>
</ul>
<RecurrenceEditor v-model:open="showRecurrenceEditor" v-model="newRrule" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import PlusIcon from '@icons/Plus.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import DeleteIcon from '@icons/Delete.vue'
import RepeatIcon from '@icons/Repeat.vue'
import CartIcon from '@icons/Cart.vue'
import RecurrenceEditor from '@/components/RecurrenceEditor.vue'
import { useShoppingListItems } from '@/composables/useShoppingList'
import { getList } from '@/api/lists'
import type { ShoppingList } from '@/api/types'
import { RRule } from 'rrule'
const props = defineProps<{ houseId: string; listId: string }>()
const houseIdNum = computed(() => Number(props.houseId))
const listIdNum = computed(() => Number(props.listId))
const list = ref<ShoppingList | null>(null)
const { items, loading, load, add, toggle, remove } = useShoppingListItems(
houseIdNum.value,
listIdNum.value,
)
const newName = ref('')
const newQuantity = ref('')
const newCategory = ref('')
const newRrule = ref<string | null>(null)
const adding = ref(false)
const showRecurrenceEditor = ref(false)
async function loadList() {
list.value = await getList(houseIdNum.value, listIdNum.value)
}
onMounted(async () => {
await Promise.all([loadList(), load()])
})
watch(
() => [props.houseId, props.listId],
async () => {
await Promise.all([loadList(), load()])
},
)
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => {
if (a.bought !== b.bought) return a.bought ? 1 : -1
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
return a.name.localeCompare(b.name)
})
})
async function submitAdd() {
const name = newName.value.trim()
if (!name) return
adding.value = true
try {
await add({
name,
quantity: newQuantity.value.trim() || null,
category: newCategory.value.trim() || null,
rrule: newRrule.value,
})
newName.value = ''
newQuantity.value = ''
newCategory.value = ''
newRrule.value = null
} finally {
adding.value = false
}
}
async function handleToggle(itemId: number) {
await toggle(itemId)
}
async function handleRemove(itemId: number) {
await remove(itemId)
}
function formatRrule(rrule: string): string {
try {
const rule = RRule.fromString('RRULE:' + rrule.replace(/^RRULE:/i, ''))
return rule.toText()
} catch {
return rrule
}
}
const strings = {
back: t('pantry', 'Back to lists'),
add: t('pantry', 'Add'),
newItemLabel: t('pantry', 'Item:'),
newItemPlaceholder: t('pantry', 'e.g. Milk'),
quantityLabel: t('pantry', 'Quantity:'),
quantityPlaceholder: t('pantry', 'e.g. 2 L'),
categoryLabel: t('pantry', 'Category:'),
categoryPlaceholder: t('pantry', 'e.g. Dairy'),
recurrenceButton: t('pantry', 'Repeat …'),
recurrenceSet: t('pantry', 'Repeat: set'),
removeItem: t('pantry', 'Remove item'),
emptyTitle: t('pantry', 'No items yet'),
emptyBody: t('pantry', 'Add items using the form above.'),
}
</script>
<style scoped lang="scss">
.pantry-detail {
max-width: 900px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
h2 {
margin: 0;
}
}
&__add {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1.5rem;
@media (max-width: 900px) {
grid-template-columns: 1fr 1fr;
}
}
}
.pantry-center {
display: flex;
justify-content: center;
padding: 2rem;
}
.pantry-items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pantry-item {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
&--bought {
opacity: 0.6;
.pantry-item__name {
text-decoration: line-through;
}
}
&__name {
font-weight: 500;
}
&__meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
}
&__quantity,
&__category,
&__recurrence {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-background-hover);
}
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="pantry-lists">
<header class="pantry-lists__header">
<h2>{{ strings.title }}</h2>
<NcButton variant="primary" @click="showCreate = true">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newList }}
</NcButton>
</header>
<div v-if="loading" class="pantry-center">
<NcLoadingIcon :size="36" />
</div>
<NcEmptyContent
v-else-if="lists.length === 0"
:name="strings.emptyTitle"
:description="strings.emptyBody"
>
<template #icon>
<CartIcon />
</template>
<template #action>
<NcButton variant="primary" @click="showCreate = true">
{{ strings.newList }}
</NcButton>
</template>
</NcEmptyContent>
<ul v-else class="pantry-lists__grid">
<li v-for="list in lists" :key="list.id">
<router-link
:to="{
name: 'list-detail',
params: { houseId: String(houseIdNum), listId: String(list.id) },
}"
class="pantry-list-card"
>
<CartIcon :size="28" class="pantry-list-card__icon" />
<div class="pantry-list-card__body">
<h3>{{ list.name }}</h3>
<p v-if="list.description">{{ list.description }}</p>
</div>
</router-link>
</li>
</ul>
<NcDialog
v-if="showCreate"
:name="strings.createDialogTitle"
:open="showCreate"
@update:open="showCreate = $event"
>
<form class="pantry-form" @submit.prevent="submitCreate">
<NcTextField
v-model="newName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
/>
<NcTextField
v-model="newDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
/>
</form>
<template #actions>
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!newName.trim()" @click="submitCreate">
{{ strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import PlusIcon from '@icons/Plus.vue'
import CartIcon from '@icons/Cart.vue'
import { useShoppingLists } from '@/composables/useShoppingList'
const props = defineProps<{ houseId: string }>()
const router = useRouter()
const houseIdNum = computed(() => Number(props.houseId))
const { lists, loading, load, create } = useShoppingLists(houseIdNum.value)
onMounted(load)
watch(
() => props.houseId,
() => load(),
)
const showCreate = ref(false)
const newName = ref('')
const newDescription = ref('')
async function submitCreate() {
const name = newName.value.trim()
if (!name) return
const list = await create(name, newDescription.value.trim() || null)
showCreate.value = false
newName.value = ''
newDescription.value = ''
await router.push({
name: 'list-detail',
params: { houseId: String(houseIdNum.value), listId: String(list.id) },
})
}
const strings = {
title: t('pantry', 'Shopping lists'),
newList: t('pantry', 'New list'),
create: t('pantry', 'Create'),
cancel: t('pantry', 'Cancel'),
createDialogTitle: t('pantry', 'Create a shopping list'),
nameLabel: t('pantry', 'Name:'),
namePlaceholder: t('pantry', 'e.g. Weekly groceries'),
descriptionLabel: t('pantry', 'Description (optional):'),
descriptionPlaceholder: t('pantry', 'A short description'),
emptyTitle: t('pantry', 'No lists yet'),
emptyBody: t('pantry', 'Create your first shopping list to start adding items.'),
}
</script>
<style scoped lang="scss">
.pantry-lists {
max-width: 1100px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: 0;
}
}
&__grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
}
.pantry-list-card {
display: flex;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large, 12px);
background: var(--color-main-background);
color: inherit;
text-decoration: none;
transition: background-color 0.15s ease;
&:hover,
&:focus-visible {
background: var(--color-background-hover);
}
&__icon {
color: var(--color-primary-element);
}
&__body {
flex: 1;
min-width: 0;
h3 {
margin: 0 0 4px 0;
font-size: 1.05rem;
}
p {
margin: 0;
color: var(--color-text-maxcontrast);
font-size: 0.9rem;
}
}
}
.pantry-center {
display: flex;
justify-content: center;
padding: 2rem;
}
.pantry-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
</style>

View File

@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace Controller;
use OCA\Pantry\AppInfo\Application;
use OCA\Pantry\Controller\ApiController;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IRequest;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiTest extends TestCase {
private ApiController $controller;
/** @var IRequest&MockObject */
private IRequest $request;
/** @var IAppConfig&MockObject */
private IAppConfig $config;
/** @var IL10N&MockObject */
private IL10N $l10n;
protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->config = $this->createMock(IAppConfig::class);
$this->l10n = $this->createMock(IL10N::class);
// Mock translation to return a simple string by default
$this->l10n->method('t')
->willReturnCallback(function ($text, $params = []) {
if (empty($params)) {
return $text;
}
return vsprintf(str_replace('%s', '%s', $text), $params);
});
$this->controller = new ApiController(
Application::APP_ID,
$this->request,
$this->config,
$this->l10n
);
}
public function testGetHello(): void {
// Mock config to return empty string (no previous hello)
$this->config->method('getValueString')
->willReturn('');
$resp = $this->controller->getHello()->getData();
$this->assertIsArray($resp);
$this->assertArrayHasKey('message', $resp);
$this->assertArrayHasKey('at', $resp);
$this->assertEquals('👋 Hello from server!', $resp['message']);
$this->assertNull($resp['at']);
}
public function testPostHello(): void {
// Expect setValueString to be called to save the timestamp
$this->config->expects($this->once())
->method('setValueString');
$resp = $this->controller->postHello([
'name' => 'World',
'theme' => 'dark',
'items' => ['item1', 'item2'],
'counter' => 5
])->getData();
$this->assertIsArray($resp);
$this->assertArrayHasKey('message', $resp);
$this->assertArrayHasKey('at', $resp);
$this->assertStringContainsString('World', $resp['message']);
$this->assertNotEmpty($resp['at']);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Tests\Unit\Service;
use OCA\Pantry\Db\HouseMember;
use OCA\Pantry\Db\HouseMemberMapper;
use OCA\Pantry\Exception\ForbiddenException;
use OCA\Pantry\Service\HouseAuthService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class HouseAuthServiceTest extends TestCase {
/** @var HouseMemberMapper&MockObject */
private HouseMemberMapper $mapper;
private HouseAuthService $svc;
protected function setUp(): void {
$this->mapper = $this->createMock(HouseMemberMapper::class);
$this->svc = new HouseAuthService($this->mapper);
}
private function makeMember(string $role): HouseMember {
$m = new HouseMember();
$m->setHouseId(1);
$m->setUserId('alice');
$m->setRole($role);
$m->setJoinedAt(0);
return $m;
}
public function testRequireMemberAllowsAnyRole(): void {
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_MEMBER));
$this->svc->requireMember(1, 'alice');
$this->assertTrue(true);
}
public function testRequireMemberForbidsNonMember(): void {
$this->mapper->method('findForUserAndHouse')->willReturn(null);
$this->expectException(ForbiddenException::class);
$this->svc->requireMember(1, 'bob');
}
public function testRequireAdminAllowsAdmin(): void {
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_ADMIN));
$this->svc->requireAdmin(1, 'alice');
$this->assertTrue(true);
}
public function testRequireAdminAllowsOwner(): void {
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_OWNER));
$this->svc->requireAdmin(1, 'alice');
$this->assertTrue(true);
}
public function testRequireAdminForbidsPlainMember(): void {
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_MEMBER));
$this->expectException(ForbiddenException::class);
$this->svc->requireAdmin(1, 'alice');
}
public function testRequireOwnerForbidsAdmin(): void {
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_ADMIN));
$this->expectException(ForbiddenException::class);
$this->svc->requireOwner(1, 'alice');
}
public function testRequireOwnerAllowsOwner(): void {
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_OWNER));
$this->svc->requireOwner(1, 'alice');
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Tests\Unit\Service;
use OCA\Pantry\Service\RecurrenceService;
use PHPUnit\Framework\TestCase;
class RecurrenceServiceTest extends TestCase {
private RecurrenceService $svc;
protected function setUp(): void {
$this->svc = new RecurrenceService();
}
public function testValidateAcceptsBareRule(): void {
$this->svc->validate('FREQ=WEEKLY;INTERVAL=1');
$this->assertTrue(true);
}
public function testValidateAcceptsPrefixedRule(): void {
$this->svc->validate('RRULE:FREQ=DAILY');
$this->assertTrue(true);
}
public function testValidateRejectsGarbage(): void {
$this->expectException(\InvalidArgumentException::class);
$this->svc->validate('not-an-rrule');
}
public function testWeeklyNextOccurrence(): void {
$from = new \DateTimeImmutable('2026-04-05T12:00:00Z');
$next = $this->svc->computeNextOccurrence('FREQ=WEEKLY;INTERVAL=1', $from);
$this->assertNotNull($next);
$this->assertSame('2026-04-12', $next->format('Y-m-d'));
}
public function testDailyNextOccurrence(): void {
$from = new \DateTimeImmutable('2026-04-05T08:00:00Z');
$next = $this->svc->computeNextOccurrence('FREQ=DAILY', $from);
$this->assertNotNull($next);
$this->assertSame('2026-04-06', $next->format('Y-m-d'));
}
public function testMonthlyNextOccurrence(): void {
$from = new \DateTimeImmutable('2026-04-05T00:00:00Z');
$next = $this->svc->computeNextOccurrence('FREQ=MONTHLY;INTERVAL=1', $from);
$this->assertNotNull($next);
$this->assertSame('2026-05-05', $next->format('Y-m-d'));
}
public function testBiweeklyNextOccurrence(): void {
$from = new \DateTimeImmutable('2026-04-05T00:00:00Z');
$next = $this->svc->computeNextOccurrence('FREQ=WEEKLY;INTERVAL=2', $from);
$this->assertNotNull($next);
$this->assertSame('2026-04-19', $next->format('Y-m-d'));
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Tests\Unit\Service;
use OCA\Pantry\Db\ShoppingList;
use OCA\Pantry\Db\ShoppingListItem;
use OCA\Pantry\Db\ShoppingListItemMapper;
use OCA\Pantry\Db\ShoppingListMapper;
use OCA\Pantry\Service\RecurrenceService;
use OCA\Pantry\Service\ShoppingListService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ShoppingListServiceTest extends TestCase {
/** @var ShoppingListMapper&MockObject */
private ShoppingListMapper $listMapper;
/** @var ShoppingListItemMapper&MockObject */
private ShoppingListItemMapper $itemMapper;
private ShoppingListService $svc;
protected function setUp(): void {
$this->listMapper = $this->createMock(ShoppingListMapper::class);
$this->itemMapper = $this->createMock(ShoppingListItemMapper::class);
$this->svc = new ShoppingListService(
$this->listMapper,
$this->itemMapper,
new RecurrenceService(),
);
}
private function makeItem(array $overrides = []): ShoppingListItem {
$item = new ShoppingListItem();
$item->setListId($overrides['listId'] ?? 1);
$item->setName($overrides['name'] ?? 'Milk');
$item->setCategory($overrides['category'] ?? null);
$item->setQuantity($overrides['quantity'] ?? null);
$item->setBought($overrides['bought'] ?? false);
$item->setBoughtAt($overrides['boughtAt'] ?? null);
$item->setBoughtBy($overrides['boughtBy'] ?? null);
$item->setRrule($overrides['rrule'] ?? null);
$item->setNextDueAt($overrides['nextDueAt'] ?? null);
$item->setSortOrder($overrides['sortOrder'] ?? 0);
$item->setCreatedAt($overrides['createdAt'] ?? 0);
$item->setUpdatedAt($overrides['updatedAt'] ?? 0);
return $item;
}
public function testListItemsAutoUnchecksDueRecurring(): void {
$now = 2_000_000_000;
$dueItem = $this->makeItem([
'bought' => true,
'boughtAt' => $now - 86400 * 8,
'boughtBy' => 'alice',
'rrule' => 'FREQ=WEEKLY',
'nextDueAt' => $now - 10,
]);
$freshItem = $this->makeItem([
'bought' => true,
'boughtAt' => $now - 3600,
'boughtBy' => 'alice',
'rrule' => 'FREQ=WEEKLY',
'nextDueAt' => $now + 86400 * 3,
]);
$this->itemMapper->method('findByList')->willReturn([$dueItem, $freshItem]);
$this->itemMapper->expects($this->once())
->method('update')
->with($this->callback(function (ShoppingListItem $i) {
return $i->getBought() === false
&& $i->getBoughtAt() === null
&& $i->getBoughtBy() === null
&& $i->getNextDueAt() === null;
}));
$result = $this->svc->listItems(1, $now);
$this->assertCount(2, $result);
$this->assertFalse($result[0]->getBought(), 'Due item should be reopened');
$this->assertTrue($result[1]->getBought(), 'Fresh item should stay bought');
}
public function testToggleItemOnNonRecurringDoesNotSetNextDue(): void {
$item = $this->makeItem();
$this->itemMapper->method('findById')->willReturn($item);
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
$toggled = $this->svc->toggleItem(42, 'alice', 1_000_000_000);
$this->assertTrue($toggled->getBought());
$this->assertSame('alice', $toggled->getBoughtBy());
$this->assertSame(1_000_000_000, $toggled->getBoughtAt());
$this->assertNull($toggled->getNextDueAt());
}
public function testToggleItemOnRecurringComputesNextDue(): void {
$now = 1_700_000_000; // 2023-11-14 22:13:20 UTC
$item = $this->makeItem(['rrule' => 'FREQ=WEEKLY']);
$this->itemMapper->method('findById')->willReturn($item);
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
$toggled = $this->svc->toggleItem(42, 'alice', $now);
$this->assertTrue($toggled->getBought());
$this->assertNotNull($toggled->getNextDueAt());
$this->assertSame($now + 7 * 86400, $toggled->getNextDueAt());
}
public function testToggleItemCheckingOffClearsEverything(): void {
$item = $this->makeItem([
'bought' => true,
'boughtAt' => 123,
'boughtBy' => 'alice',
'rrule' => 'FREQ=WEEKLY',
'nextDueAt' => 456,
]);
$this->itemMapper->method('findById')->willReturn($item);
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
$toggled = $this->svc->toggleItem(42, 'alice', 999);
$this->assertFalse($toggled->getBought());
$this->assertNull($toggled->getBoughtAt());
$this->assertNull($toggled->getBoughtBy());
$this->assertNull($toggled->getNextDueAt());
}
public function testAddItemRejectsEmptyName(): void {
$this->listMapper->method('findById')->willReturn(new ShoppingList());
$this->expectException(\InvalidArgumentException::class);
$this->svc->addItem(1, ['name' => ' ']);
}
public function testAddItemRejectsBadRrule(): void {
$this->listMapper->method('findById')->willReturn(new ShoppingList());
$this->expectException(\InvalidArgumentException::class);
$this->svc->addItem(1, ['name' => 'Eggs', 'rrule' => 'not valid']);
}
}

View File

@@ -1,24 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"src/env.d.ts",
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
],
"include": ["src/env.d.ts", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"allowJs": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@icons/*": [
"node_modules/vue-material-design-icons/*"
],
"@/*": [
"src/*"
]
"@icons/*": ["node_modules/vue-material-design-icons/*"],
"@/*": ["src/*"]
}
}
}