From 6c4d7e64a380374a75619fed6e78d11125095c95 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 5 Apr 2026 21:50:17 +0300 Subject: [PATCH] feat: home with lists poc --- appinfo/info.xml | 13 +- composer.json | 2 +- composer.lock | 230 +- lib/Controller/ApiController.php | 104 - lib/Controller/HouseController.php | 322 ++ lib/Controller/PrefsController.php | 93 + lib/Controller/ShoppingListController.php | 357 +++ lib/Controller/TranslatesDomainExceptions.php | 36 + lib/Db/House.php | 46 + lib/Db/HouseMapper.php | 54 + lib/Db/HouseMember.php | 54 + lib/Db/HouseMemberMapper.php | 69 + lib/Db/ShoppingList.php | 52 + lib/Db/ShoppingListItem.php | 79 + lib/Db/ShoppingListItemMapper.php | 56 + lib/Db/ShoppingListMapper.php | 56 + lib/Exception/ForbiddenException.php | 11 + lib/Exception/NotFoundException.php | 11 + lib/Migration/Version1Date20260405000000.php | 189 ++ lib/ResponseDefinitions.php | 61 + lib/Service/HouseAuthService.php | 43 + lib/Service/HouseService.php | 198 ++ lib/Service/PrefsService.php | 36 + lib/Service/RecurrenceService.php | 63 + lib/Service/ShoppingListService.php | 237 ++ openapi.json | 2707 ++++++++++++++++- package.json | 8 +- pnpm-lock.yaml | 797 +++-- src/App.vue | 118 +- src/Settings.vue | 442 +-- src/api/houses.ts | 60 + src/api/lists.ts | 87 + src/api/prefs.ts | 10 + src/api/types.ts | 46 + src/components/RecurrenceEditor.vue | 161 + src/components/StatusBadge.test.ts | 2 +- src/composables/useCurrentHouse.ts | 63 + src/composables/useHouses.ts | 63 + src/composables/useLastHouse.ts | 8 + src/composables/useShoppingList.ts | 93 + src/router.ts | 68 +- src/views/AppView.vue | 458 --- src/views/HomeRedirect.vue | 43 + src/views/HouseLayout.vue | 56 + src/views/HouseNavigation.vue | 99 + src/views/HouseSettingsView.vue | 159 + src/views/HousesList.vue | 253 ++ src/views/HousesNavigation.vue | 26 + src/views/MembersView.vue | 256 ++ src/views/NotesWallStub.vue | 18 + src/views/PhotoBoardStub.vue | 21 + src/views/ShoppingListDetail.vue | 299 ++ src/views/ShoppingListsView.vue | 210 ++ tests/unit/Controller/ApiTest.php | 78 - tests/unit/Service/HouseAuthServiceTest.php | 77 + tests/unit/Service/RecurrenceServiceTest.php | 62 + .../unit/Service/ShoppingListServiceTest.php | 139 + tsconfig.app.json | 16 +- 58 files changed, 7971 insertions(+), 1504 deletions(-) delete mode 100644 lib/Controller/ApiController.php create mode 100644 lib/Controller/HouseController.php create mode 100644 lib/Controller/PrefsController.php create mode 100644 lib/Controller/ShoppingListController.php create mode 100644 lib/Controller/TranslatesDomainExceptions.php create mode 100644 lib/Db/House.php create mode 100644 lib/Db/HouseMapper.php create mode 100644 lib/Db/HouseMember.php create mode 100644 lib/Db/HouseMemberMapper.php create mode 100644 lib/Db/ShoppingList.php create mode 100644 lib/Db/ShoppingListItem.php create mode 100644 lib/Db/ShoppingListItemMapper.php create mode 100644 lib/Db/ShoppingListMapper.php create mode 100644 lib/Exception/ForbiddenException.php create mode 100644 lib/Exception/NotFoundException.php create mode 100644 lib/Migration/Version1Date20260405000000.php create mode 100644 lib/ResponseDefinitions.php create mode 100644 lib/Service/HouseAuthService.php create mode 100644 lib/Service/HouseService.php create mode 100644 lib/Service/PrefsService.php create mode 100644 lib/Service/RecurrenceService.php create mode 100644 lib/Service/ShoppingListService.php create mode 100644 src/api/houses.ts create mode 100644 src/api/lists.ts create mode 100644 src/api/prefs.ts create mode 100644 src/api/types.ts create mode 100644 src/components/RecurrenceEditor.vue create mode 100644 src/composables/useCurrentHouse.ts create mode 100644 src/composables/useHouses.ts create mode 100644 src/composables/useLastHouse.ts create mode 100644 src/composables/useShoppingList.ts delete mode 100644 src/views/AppView.vue create mode 100644 src/views/HomeRedirect.vue create mode 100644 src/views/HouseLayout.vue create mode 100644 src/views/HouseNavigation.vue create mode 100644 src/views/HouseSettingsView.vue create mode 100644 src/views/HousesList.vue create mode 100644 src/views/HousesNavigation.vue create mode 100644 src/views/MembersView.vue create mode 100644 src/views/NotesWallStub.vue create mode 100644 src/views/PhotoBoardStub.vue create mode 100644 src/views/ShoppingListDetail.vue create mode 100644 src/views/ShoppingListsView.vue delete mode 100644 tests/unit/Controller/ApiTest.php create mode 100644 tests/unit/Service/HouseAuthServiceTest.php create mode 100644 tests/unit/Service/RecurrenceServiceTest.php create mode 100644 tests/unit/Service/ShoppingListServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index dab6de4..3969547 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -6,9 +6,16 @@ --> pantry Pantry - Enter your app summary here. + Manage your household: shared shopping lists, photos and notes. 1.0.0 agpl @@ -26,7 +33,7 @@ Enter your app description here. https://github.com/chenasraf/nextcloud-pantry https://raw.githubusercontent.com/chenasraf/nextcloud-pantry/refs/heads/master/promo.png - + OCA\Pantry\Settings\Admin diff --git a/composer.json b/composer.json index 5881096..062edc0 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ }, "require": { "php": "^8.1", - "chriskonnertz/bbcode": "^1.1" + "sabre/vobject": "^4.5" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8", diff --git a/composer.lock b/composer.lock index d73aad4..51c598c 100644 --- a/composer.lock +++ b/composer.lock @@ -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": [ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php deleted file mode 100644 index 753836c..0000000 --- a/lib/Controller/ApiController.php +++ /dev/null @@ -1,104 +0,0 @@ - -// 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 - * - * 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, - * counter?: int - * } $data Request payload for creating a hello message. - * - * @return DataResponse - * - * 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, - ]); - } -} diff --git a/lib/Controller/HouseController.php b/lib/Controller/HouseController.php new file mode 100644 index 0000000..dd2aa63 --- /dev/null +++ b/lib/Controller/HouseController.php @@ -0,0 +1,322 @@ + +// 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, 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 + * + * 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 + * + * 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 + * + * 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 + * + * 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, 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 + * + * 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 + * + * 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 + * + * 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 + * + * 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(), + ]; + } +} diff --git a/lib/Controller/PrefsController.php b/lib/Controller/PrefsController.php new file mode 100644 index 0000000..67dbad5 --- /dev/null +++ b/lib/Controller/PrefsController.php @@ -0,0 +1,93 @@ + +// 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 + * + * 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 + * + * 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(); + } +} diff --git a/lib/Controller/ShoppingListController.php b/lib/Controller/ShoppingListController.php new file mode 100644 index 0000000..60c10ac --- /dev/null +++ b/lib/Controller/ShoppingListController.php @@ -0,0 +1,357 @@ + +// 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, 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 + * + * 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 + * + * 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 + * + * 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 + * + * 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, 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 + * + * 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 + * + * 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 + * + * 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 + * + * 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'); + } + } +} diff --git a/lib/Controller/TranslatesDomainExceptions.php b/lib/Controller/TranslatesDomainExceptions.php new file mode 100644 index 0000000..238d368 --- /dev/null +++ b/lib/Controller/TranslatesDomainExceptions.php @@ -0,0 +1,36 @@ + +// 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); + } + } +} diff --git a/lib/Db/House.php b/lib/Db/House.php new file mode 100644 index 0000000..78454db --- /dev/null +++ b/lib/Db/House.php @@ -0,0 +1,46 @@ + +// 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, + ]; + } +} diff --git a/lib/Db/HouseMapper.php b/lib/Db/HouseMapper.php new file mode 100644 index 0000000..836fe8f --- /dev/null +++ b/lib/Db/HouseMapper.php @@ -0,0 +1,54 @@ + +// 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 + */ +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); + } +} diff --git a/lib/Db/HouseMember.php b/lib/Db/HouseMember.php new file mode 100644 index 0000000..b8f4688 --- /dev/null +++ b/lib/Db/HouseMember.php @@ -0,0 +1,54 @@ + +// 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, + ]; + } +} diff --git a/lib/Db/HouseMemberMapper.php b/lib/Db/HouseMemberMapper.php new file mode 100644 index 0000000..3207f8c --- /dev/null +++ b/lib/Db/HouseMemberMapper.php @@ -0,0 +1,69 @@ + +// 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 + */ +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(); + } +} diff --git a/lib/Db/ShoppingList.php b/lib/Db/ShoppingList.php new file mode 100644 index 0000000..cc1b787 --- /dev/null +++ b/lib/Db/ShoppingList.php @@ -0,0 +1,52 @@ + +// 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, + ]; + } +} diff --git a/lib/Db/ShoppingListItem.php b/lib/Db/ShoppingListItem.php new file mode 100644 index 0000000..b3563fc --- /dev/null +++ b/lib/Db/ShoppingListItem.php @@ -0,0 +1,79 @@ + +// 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, + ]; + } +} diff --git a/lib/Db/ShoppingListItemMapper.php b/lib/Db/ShoppingListItemMapper.php new file mode 100644 index 0000000..c0613b5 --- /dev/null +++ b/lib/Db/ShoppingListItemMapper.php @@ -0,0 +1,56 @@ + +// 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 + */ +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(); + } +} diff --git a/lib/Db/ShoppingListMapper.php b/lib/Db/ShoppingListMapper.php new file mode 100644 index 0000000..5d76d5f --- /dev/null +++ b/lib/Db/ShoppingListMapper.php @@ -0,0 +1,56 @@ + +// 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 + */ +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(); + } +} diff --git a/lib/Exception/ForbiddenException.php b/lib/Exception/ForbiddenException.php new file mode 100644 index 0000000..269c704 --- /dev/null +++ b/lib/Exception/ForbiddenException.php @@ -0,0 +1,11 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Exception; + +class ForbiddenException extends \RuntimeException { +} diff --git a/lib/Exception/NotFoundException.php b/lib/Exception/NotFoundException.php new file mode 100644 index 0000000..e7b54c3 --- /dev/null +++ b/lib/Exception/NotFoundException.php @@ -0,0 +1,11 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Exception; + +class NotFoundException extends \RuntimeException { +} diff --git a/lib/Migration/Version1Date20260405000000.php b/lib/Migration/Version1Date20260405000000.php new file mode 100644 index 0000000..4d89e41 --- /dev/null +++ b/lib/Migration/Version1Date20260405000000.php @@ -0,0 +1,189 @@ + +// 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; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php new file mode 100644 index 0000000..bb2340f --- /dev/null +++ b/lib/ResponseDefinitions.php @@ -0,0 +1,61 @@ + +// 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 { +} diff --git a/lib/Service/HouseAuthService.php b/lib/Service/HouseAuthService.php new file mode 100644 index 0000000..de1fbe7 --- /dev/null +++ b/lib/Service/HouseAuthService.php @@ -0,0 +1,43 @@ + +// 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; + } +} diff --git a/lib/Service/HouseService.php b/lib/Service/HouseService.php new file mode 100644 index 0000000..ff4a20a --- /dev/null +++ b/lib/Service/HouseService.php @@ -0,0 +1,198 @@ + +// 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"'); + } +} diff --git a/lib/Service/PrefsService.php b/lib/Service/PrefsService.php new file mode 100644 index 0000000..c6cfe75 --- /dev/null +++ b/lib/Service/PrefsService.php @@ -0,0 +1,36 @@ + +// 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); + } +} diff --git a/lib/Service/RecurrenceService.php b/lib/Service/RecurrenceService.php new file mode 100644 index 0000000..9bc50ff --- /dev/null +++ b/lib/Service/RecurrenceService.php @@ -0,0 +1,63 @@ + +// 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; + } +} diff --git a/lib/Service/ShoppingListService.php b/lib/Service/ShoppingListService.php new file mode 100644 index 0000000..84081fd --- /dev/null +++ b/lib/Service/ShoppingListService.php @@ -0,0 +1,237 @@ + +// 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; + } +} diff --git a/openapi.json b/openapi.json index 29ed2bb..7d7061b 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "pantry", "version": "0.0.1", - "description": "Enter your app summary here.", + "description": "Manage your household: shared shopping lists, photos and notes.", "license": { "name": "agpl" } @@ -20,6 +20,205 @@ } }, "schemas": { + "House": { + "type": "object", + "required": [ + "id", + "name", + "description", + "ownerUid", + "createdAt", + "updatedAt", + "role" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "ownerUid": { + "type": "string" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "integer", + "format": "int64" + }, + "role": { + "type": "string" + } + } + }, + "LastHouse": { + "type": "object", + "required": [ + "houseId" + ], + "properties": { + "houseId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "List": { + "type": "object", + "required": [ + "id", + "houseId", + "name", + "description", + "sortOrder", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "houseId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int64" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "integer", + "format": "int64" + } + } + }, + "ListItem": { + "type": "object", + "required": [ + "id", + "listId", + "name", + "category", + "quantity", + "bought", + "boughtAt", + "boughtBy", + "rrule", + "nextDueAt", + "sortOrder", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "listId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "category": { + "type": "string", + "nullable": true + }, + "quantity": { + "type": "string", + "nullable": true + }, + "bought": { + "type": "boolean" + }, + "boughtAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "boughtBy": { + "type": "string", + "nullable": true + }, + "rrule": { + "type": "string", + "nullable": true + }, + "nextDueAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int64" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "integer", + "format": "int64" + } + } + }, + "Member": { + "type": "object", + "required": [ + "id", + "houseId", + "userId", + "displayName", + "role", + "joinedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "houseId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "role": { + "type": "string" + }, + "joinedAt": { + "type": "integer", + "format": "int64" + } + } + }, "OCSMeta": { "type": "object", "required": [ @@ -43,17 +242,30 @@ "type": "string" } } + }, + "Success": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + } + } } } }, "paths": { - "/ocs/v2.php/apps/pantry/api/hello": { + "/ocs/v2.php/apps/pantry/api/houses": { "get": { - "operationId": "api-get-hello", - "summary": "GET /api/hello", - "description": "Returns a simple hello message and the last time the server said hello.", + "operationId": "house-index", + "summary": "List houses the current user belongs to", "tags": [ - "api" + "house" ], "security": [ { @@ -64,6 +276,29 @@ } ], "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of houses to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 100, + "minimum": 1, + "maximum": 500 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of houses to skip.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -77,7 +312,7 @@ ], "responses": { "200": { - "description": "Data returned successfully.", + "description": "Houses returned", "content": { "application/json": { "schema": { @@ -97,19 +332,9 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "message", - "at" - ], - "properties": { - "message": { - "type": "string" - }, - "at": { - "type": "string", - "nullable": true - } + "type": "array", + "items": { + "$ref": "#/components/schemas/House" } } } @@ -150,11 +375,11 @@ } }, "post": { - "operationId": "api-post-hello", - "summary": "POST /api/hello", - "description": "Accepts example payload and returns a message + timestamp.", + "operationId": "house-create", + "summary": "Create a new house", + "description": "The caller becomes the owner.", "tags": [ - "api" + "house" ], "security": [ { @@ -165,34 +390,24 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "name" + ], "properties": { - "data": { - "type": "object", - "default": {}, - "description": "Request payload for creating a hello message.", - "properties": { - "name": { - "type": "string" - }, - "theme": { - "type": "string" - }, - "items": { - "type": "array", - "items": { - "type": "string" - } - }, - "counter": { - "type": "integer", - "format": "int64" - } - } + "name": { + "type": "string", + "description": "House name." + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional description." } } } @@ -213,7 +428,7 @@ ], "responses": { "200": { - "description": "Data returned successfully.", + "description": "House created", "content": { "application/json": { "schema": { @@ -233,18 +448,451 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "message", - "at" - ], - "properties": { - "message": { - "type": "string" - }, - "at": { - "type": "string" - } + "$ref": "#/components/schemas/House" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}": { + "get": { + "operationId": "house-show", + "summary": "Fetch a single house", + "description": "The caller must be a member.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "House returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/House" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "house-update", + "summary": "Update a house", + "description": "Requires admin or owner role.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "default": null, + "description": "New name." + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "New description." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "House updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/House" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "house-destroy", + "summary": "Delete a house and all of its data", + "description": "Owner only. Removes all lists, items, photos, notes and member records.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "House deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/members": { + "get": { + "operationId": "house-list-members", + "summary": "List members of a house", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of members to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 100, + "minimum": 1, + "maximum": 500 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of members to skip.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Members returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Member" } } } @@ -283,6 +931,1945 @@ } } } + }, + "post": { + "operationId": "house-add-member", + "summary": "Add a member to a house", + "description": "Requires admin or owner role.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "description": "Nextcloud user id to add." + }, + "role": { + "type": "string", + "description": "Role: \"admin\" or \"member\"." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Member added", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Member" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/members/{memberId}": { + "patch": { + "operationId": "house-update-member", + "summary": "Change a member's role", + "description": "Requires admin or owner role. The owner's role cannot be changed.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string", + "description": "New role." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "memberId", + "in": "path", + "description": "Member id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Role updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Member" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "house-remove-member", + "summary": "Remove a member from a house", + "description": "Requires admin or owner role. The owner cannot be removed.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "memberId", + "in": "path", + "description": "Member id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Member removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/leave": { + "post": { + "operationId": "house-leave", + "summary": "Leave a house", + "description": "Any non-owner member may call this. The owner must transfer ownership first.", + "tags": [ + "house" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Left house", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/prefs/last-house": { + "get": { + "operationId": "prefs-get-last-house", + "summary": "Get the current user's last-used house id", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Last house returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/LastHouse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "prefs-set-last-house", + "summary": "Set the current user's last-used house id", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "houseId": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "House id, or null to clear." + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Last house updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/LastHouse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists": { + "get": { + "operationId": "shopping_list-index-lists", + "summary": "List all shopping lists in a house", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of lists to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 100, + "minimum": 1, + "maximum": 500 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of lists to skip.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Lists returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/List" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "shopping_list-create-list", + "summary": "Create a shopping list in a house", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "List name." + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional description." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "List created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/List" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}": { + "get": { + "operationId": "shopping_list-show-list", + "summary": "Get a shopping list", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "List returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/List" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "shopping_list-update-list", + "summary": "Update a shopping list", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "default": null, + "description": "New name." + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "New description." + }, + "sortOrder": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "New sort order." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "List updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/List" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "shopping_list-delete-list", + "summary": "Delete a shopping list", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "List deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items": { + "get": { + "operationId": "shopping_list-index-items", + "summary": "List items in a shopping list", + "description": "Auto-reopens recurring items whose next occurrence has arrived.", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 200, + "minimum": 1, + "maximum": 1000 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of items to skip.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Items returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "shopping_list-add-item", + "summary": "Add an item to a list", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Item name." + }, + "category": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional category label." + }, + "quantity": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional quantity string." + }, + "rrule": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional RFC 5545 RRULE for recurrence." + }, + "sortOrder": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "Optional sort order." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item added", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}": { + "patch": { + "operationId": "shopping_list-update-item", + "summary": "Update an item", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "default": null, + "description": "New name." + }, + "category": { + "type": "string", + "nullable": true, + "default": null, + "description": "New category (empty string clears)." + }, + "quantity": { + "type": "string", + "nullable": true, + "default": null, + "description": "New quantity (empty string clears)." + }, + "rrule": { + "type": "string", + "nullable": true, + "default": null, + "description": "New RRULE (empty string clears)." + }, + "sortOrder": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "New sort order." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "shopping_list-delete-item", + "summary": "Delete an item", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}/toggle": { + "post": { + "operationId": "shopping_list-toggle-item", + "summary": "Toggle an item's bought status", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item toggled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } } } }, diff --git a/package.json b/package.json index 5c3b5f5..e3eaf4f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbac81d..8e20354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,19 +19,22 @@ importers: version: 3.1.0 '@nextcloud/vite-config': specifier: ^2.5.2 - version: 2.5.2(@types/node@25.5.2)(browserslist@4.28.2)(picomatch@4.0.4)(rollup@4.60.1)(sass@1.99.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 2.5.2(@types/node@25.5.2)(browserslist@4.28.2)(picomatch@4.0.4)(rollup@4.60.1)(sass@1.99.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2)) '@nextcloud/vue': specifier: ^9.6.0 - version: 9.6.0(@vue/compiler-sfc@3.5.32)(typescript@5.9.3) + version: 9.6.0(@vue/compiler-sfc@3.5.32)(typescript@6.0.2) date-fns: specifier: ^4.1.0 version: 4.1.0 linkifyjs: specifier: ^4.3.2 version: 4.3.2 + rrule: + specifier: ^2.8.1 + version: 2.8.1 vue: specifier: ^3.5.32 - version: 3.5.32(typescript@5.9.3) + version: 3.5.32(typescript@6.0.2) vue-material-design-icons: specifier: ^5.3.1 version: 5.3.1 @@ -44,19 +47,19 @@ importers: version: 3.1.2(browserslist@4.28.2) '@nextcloud/eslint-config': specifier: ^8.4.2 - version: 8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@10.2.0))(@nextcloud/eslint-plugin@2.2.1(eslint@10.2.0))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@5.9.3))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@10.2.0))(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@5.9.3) + version: 8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@10.2.0))(@nextcloud/eslint-plugin@2.2.1(eslint@10.2.0))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@10.2.0))(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2) '@nextcloud/stylelint-config': specifier: ^3.2.1 - version: 3.2.1(stylelint-config-recommended-scss@13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@5.9.3)))(stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@5.9.3)))(stylelint@16.11.0(typescript@5.9.3)) + version: 3.2.1(stylelint-config-recommended-scss@13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@6.0.2)))(stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@6.0.2)))(stylelint@16.11.0(typescript@6.0.2)) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 6.0.5(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 '@vue/tsconfig': - specifier: ^0.8.1 - version: 0.8.1(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + specifier: ^0.9.1 + version: 0.9.1(typescript@6.0.2)(vue@3.5.32(typescript@6.0.2)) eslint: specifier: ^10.2.0 version: 10.2.0 @@ -74,7 +77,7 @@ importers: version: 3.8.1 rollup-plugin-visualizer: specifier: ^7.0.1 - version: 7.0.1(rollup@4.60.1) + version: 7.0.1(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1) sass: specifier: ^1.99.0 version: 1.99.0 @@ -82,26 +85,26 @@ importers: specifier: ^1.99.0 version: 1.99.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.2 + version: 6.0.2 typescript-eslint: specifier: ^8.58.0 - version: 8.58.0(eslint@10.2.0)(typescript@5.9.3) + version: 8.58.0(eslint@10.2.0)(typescript@6.0.2) vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + specifier: ^8.0.3 + version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) vite-plugin-checker: specifier: ^0.12.0 - version: 0.12.0(eslint@10.2.0)(meow@13.2.0)(optionator@0.9.4)(stylelint@16.11.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3)) + version: 0.12.0(eslint@10.2.0)(meow@13.2.0)(optionator@0.9.4)(stylelint@16.11.0(typescript@6.0.2))(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@6.0.2)) vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@25.5.2)(happy-dom@20.8.9)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) + version: 4.1.2(@types/node@25.5.2)(happy-dom@20.8.9)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) vue-router: specifier: ^5.0.4 - version: 5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@5.9.3)) + version: 5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)) vue-tsc: specifier: ^3.2.6 - version: 3.2.6(typescript@5.9.3) + version: 3.2.6(typescript@6.0.2) packages: @@ -229,6 +232,15 @@ packages: '@dual-bundle/import-meta-resolve@4.2.1': resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@es-joy/jsdoccomment@0.41.0': resolution: {integrity: sha512-aKUhyn1QI5Ksbqcr3fFJj16p99QdjUxXAEuFst1Z47DRyoiMwivIH9MV/ARcJOCXVjPfjITciej8ZD2O/6qUmw==} engines: {node: '>=16'} @@ -648,6 +660,12 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@nextcloud/auth@2.5.3': resolution: {integrity: sha512-KIhWLk0BKcP4hvypE4o11YqKOPeFMfEFjRrhUUF+h7Fry+dhTBIEIxuQPVCKXMIpjTDd8791y8V6UdRZ2feKAQ==} engines: {node: ^20.0.0 || ^22.0.0 || ^24.0.0} @@ -781,6 +799,9 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -867,6 +888,98 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} @@ -1061,6 +1174,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -1370,10 +1486,10 @@ packages: '@vue/test-utils@2.4.6': resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} - '@vue/tsconfig@0.8.1': - resolution: {integrity: sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==} + '@vue/tsconfig@0.9.1': + resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} peerDependencies: - typescript: 5.x + typescript: '>= 5.8' vue: ^3.4.0 peerDependenciesMeta: typescript: @@ -2887,6 +3003,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3543,6 +3729,11 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-corejs@1.0.2: resolution: {integrity: sha512-1IDoQa+EW2NraBc7xANejbQwx62jNikLnDBNrzguRhfVnatyjCcmiIJJ4ScG6PwMP6OIwS8osHMl43CcVJqvaQ==} engines: {node: '>= 20.0.0'} @@ -3585,6 +3776,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrule@2.8.1: + resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -4146,6 +4340,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -4287,15 +4486,16 @@ packages: peerDependencies: vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 jiti: '>=1.21.0' less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -4306,12 +4506,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -4662,11 +4864,11 @@ snapshots: node-fetch: 3.3.2 optional: true - '@ckpack/vue-color@1.6.0(vue@3.5.32(typescript@5.9.3))': + '@ckpack/vue-color@1.6.0(vue@3.5.32(typescript@6.0.2))': dependencies: '@ctrl/tinycolor': 3.6.1 material-colors: 1.2.6 - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: @@ -4687,6 +4889,22 @@ snapshots: '@dual-bundle/import-meta-resolve@4.2.1': {} + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@es-joy/jsdoccomment@0.41.0': dependencies: comment-parser: 1.4.1 @@ -4978,6 +5196,13 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nextcloud/auth@2.5.3': dependencies: '@nextcloud/browser-storage': 0.5.0 @@ -5001,22 +5226,22 @@ snapshots: dependencies: '@nextcloud/initial-state': 3.0.0 - '@nextcloud/eslint-config@8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@10.2.0))(@nextcloud/eslint-plugin@2.2.1(eslint@10.2.0))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@5.9.3))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@10.2.0))(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@5.9.3)': + '@nextcloud/eslint-config@8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@10.2.0))(@nextcloud/eslint-plugin@2.2.1(eslint@10.2.0))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@10.2.0))(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@babel/core': 7.26.0 '@babel/eslint-parser': 7.25.9(@babel/core@7.26.0)(eslint@10.2.0) '@nextcloud/eslint-plugin': 2.2.1(eslint@10.2.0) - '@vue/eslint-config-typescript': 13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@5.9.3) + '@vue/eslint-config-typescript': 13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2) eslint: 10.2.0 eslint-config-standard: 17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0) eslint-import-resolver-exports: 1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0) - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-plugin-import@2.31.0)(eslint@10.2.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-plugin-import@2.31.0)(eslint@10.2.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) eslint-plugin-jsdoc: 46.10.1(eslint@10.2.0) eslint-plugin-n: 16.6.2(eslint@10.2.0) eslint-plugin-promise: 6.6.0(eslint@10.2.0) eslint-plugin-vue: 9.32.0(eslint@10.2.0) - typescript: 5.9.3 + typescript: 6.0.2 '@nextcloud/eslint-plugin@2.2.1(eslint@10.2.0)': dependencies: @@ -5095,21 +5320,21 @@ snapshots: optionalDependencies: '@nextcloud/files': 4.0.0 - '@nextcloud/stylelint-config@3.2.1(stylelint-config-recommended-scss@13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@5.9.3)))(stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@5.9.3)))(stylelint@16.11.0(typescript@5.9.3))': + '@nextcloud/stylelint-config@3.2.1(stylelint-config-recommended-scss@13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@6.0.2)))(stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@6.0.2)))(stylelint@16.11.0(typescript@6.0.2))': dependencies: - stylelint: 16.11.0(typescript@5.9.3) - stylelint-config-recommended-scss: 13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@5.9.3)) - stylelint-config-recommended-vue: 1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@5.9.3)) - stylelint-use-logical: 2.1.3(stylelint@16.11.0(typescript@5.9.3)) + stylelint: 16.11.0(typescript@6.0.2) + stylelint-config-recommended-scss: 13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@6.0.2)) + stylelint-config-recommended-vue: 1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@6.0.2)) + stylelint-use-logical: 2.1.3(stylelint@16.11.0(typescript@6.0.2)) '@nextcloud/typings@1.10.0': dependencies: '@types/jquery': 3.5.16 - '@nextcloud/vite-config@2.5.2(@types/node@25.5.2)(browserslist@4.28.2)(picomatch@4.0.4)(rollup@4.60.1)(sass@1.99.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + '@nextcloud/vite-config@2.5.2(@types/node@25.5.2)(browserslist@4.28.2)(picomatch@4.0.4)(rollup@4.60.1)(sass@1.99.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2))': dependencies: '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) - '@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.5(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2)) browserslist: 4.28.2 browserslist-to-esbuild: 2.1.1(browserslist@4.28.2) magic-string: 0.30.21 @@ -5119,10 +5344,10 @@ snapshots: rollup-plugin-node-externals: 8.1.2(rollup@4.60.1) sass: 1.99.0 spdx-expression-parse: 4.0.0 - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) - vite-plugin-css-injected-by-js: 3.5.2(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) - vite-plugin-dts: 4.5.4(@types/node@25.5.2)(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) - vite-plugin-node-polyfills: 0.24.0(rollup@4.60.1)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite-plugin-css-injected-by-js: 3.5.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) + vite-plugin-dts: 4.5.4(@types/node@25.5.2)(rollup@4.60.1)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) + vite-plugin-node-polyfills: 0.24.0(rollup@4.60.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) transitivePeerDependencies: - '@types/node' - picomatch @@ -5131,9 +5356,9 @@ snapshots: - typescript - vue - '@nextcloud/vue@9.6.0(@vue/compiler-sfc@3.5.32)(typescript@5.9.3)': + '@nextcloud/vue@9.6.0(@vue/compiler-sfc@3.5.32)(typescript@6.0.2)': dependencies: - '@ckpack/vue-color': 1.6.0(vue@3.5.32(typescript@5.9.3)) + '@ckpack/vue-color': 1.6.0(vue@3.5.32(typescript@6.0.2)) '@floating-ui/dom': 1.7.6 '@nextcloud/auth': 2.5.3 '@nextcloud/axios': 2.5.2 @@ -5145,16 +5370,16 @@ snapshots: '@nextcloud/logger': 3.0.3 '@nextcloud/router': 3.1.0 '@nextcloud/sharing': 0.4.0 - '@vuepic/vue-datepicker': 11.0.3(vue@3.5.32(typescript@5.9.3)) - '@vueuse/components': 14.2.1(vue@3.5.32(typescript@5.9.3)) - '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) + '@vuepic/vue-datepicker': 11.0.3(vue@3.5.32(typescript@6.0.2)) + '@vueuse/components': 14.2.1(vue@3.5.32(typescript@6.0.2)) + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2)) blurhash: 2.0.5 clone: 2.1.2 debounce: 3.0.0 dompurify: 3.3.3 - emoji-mart-vue-fast: 15.0.5(vue@3.5.32(typescript@5.9.3)) + emoji-mart-vue-fast: 15.0.5(vue@3.5.32(typescript@6.0.2)) escape-html: 1.0.3 - floating-vue: 5.2.2(vue@3.5.32(typescript@5.9.3)) + floating-vue: 5.2.2(vue@3.5.32(typescript@6.0.2)) focus-trap: 8.0.1 linkifyjs: 4.3.2 p-queue: 9.1.1 @@ -5166,7 +5391,7 @@ snapshots: remark-rehype: 11.1.2 remark-stringify: 11.0.0 remark-unlink-protocols: 1.0.0 - splitpanes: 4.0.4(vue@3.5.32(typescript@5.9.3)) + splitpanes: 4.0.4(vue@3.5.32(typescript@6.0.2)) striptags: 3.2.0 tabbable: 6.4.0 tributejs: 5.1.3 @@ -5174,9 +5399,9 @@ snapshots: unified: 11.0.5 unist-builder: 4.0.0 unist-util-visit: 5.1.0 - vue: 3.5.32(typescript@5.9.3) - vue-router: 5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@5.9.3)) - vue-select: 4.0.0-beta.6(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@6.0.2) + vue-router: 5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)) + vue-select: 4.0.0-beta.6(vue@3.5.32(typescript@6.0.2)) transitivePeerDependencies: - '@nuxt/kit' - '@pinia/colada' @@ -5206,6 +5431,8 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@oxc-project/types@0.122.0': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -5270,6 +5497,58 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/plugin-inject@5.0.5(rollup@4.60.1)': @@ -5415,6 +5694,11 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + '@types/argparse@1.0.38': {} '@types/chai@5.2.3': @@ -5479,71 +5763,71 @@ snapshots: dependencies: '@types/node': 25.5.2 - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 7.18.0(eslint@10.2.0)(typescript@6.0.2) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@10.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 7.18.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/utils': 7.18.0(eslint@10.2.0)(typescript@6.0.2) '@typescript-eslint/visitor-keys': 7.18.0 eslint: 10.2.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@5.9.3) + ts-api-utils: 1.4.3(typescript@6.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@6.0.2) '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.58.0(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.58.0 eslint: 10.2.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/parser@7.18.0(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.2) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 eslint: 10.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 eslint: 10.2.0 - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.0(typescript@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) '@typescript-eslint/types': 8.58.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -5557,31 +5841,31 @@ snapshots: '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.2)': dependencies: - typescript: 5.9.3 + typescript: 6.0.2 - '@typescript-eslint/type-utils@7.18.0(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@10.2.0)(typescript@6.0.2)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) - '@typescript-eslint/utils': 7.18.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.2) + '@typescript-eslint/utils': 7.18.0(eslint@10.2.0)(typescript@6.0.2) debug: 4.4.3 eslint: 10.2.0 - ts-api-utils: 1.4.3(typescript@5.9.3) + ts-api-utils: 1.4.3(typescript@6.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.0(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@6.0.2) debug: 4.4.3 eslint: 10.2.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -5589,7 +5873,7 @@ snapshots: '@typescript-eslint/types@8.58.0': {} - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 @@ -5598,46 +5882,46 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.9 semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.9.3) + ts-api-utils: 1.4.3(typescript@6.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.58.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.18.0(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/utils@7.18.0(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.2) eslint: 10.2.0 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.0(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) eslint: 10.2.0 - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -5653,11 +5937,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.5(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) - vue: 3.5.32(typescript@5.9.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vue: 3.5.32(typescript@6.0.2) '@vitest/expect@4.1.2': dependencies: @@ -5668,13 +5952,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.2': dependencies: @@ -5712,7 +5996,7 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue-macros/common@3.1.2(vue@3.5.32(typescript@5.9.3))': + '@vue-macros/common@3.1.2(vue@3.5.32(typescript@6.0.2))': dependencies: '@vue/compiler-sfc': 3.5.32 ast-kit: 2.2.0 @@ -5720,7 +6004,7 @@ snapshots: magic-string-ast: 1.0.3 unplugin-utils: 0.3.1 optionalDependencies: - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) '@vue/compiler-core@3.5.32': dependencies: @@ -5770,19 +6054,19 @@ snapshots: '@vue/devtools-shared@8.1.1': {} - '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@5.9.3)': + '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2)': dependencies: - '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3) - '@typescript-eslint/parser': 7.18.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/parser': 7.18.0(eslint@10.2.0)(typescript@6.0.2) eslint: 10.2.0 eslint-plugin-vue: 9.32.0(eslint@10.2.0) vue-eslint-parser: 9.4.3(eslint@10.2.0) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@vue/language-core@2.2.0(typescript@5.9.3)': + '@vue/language-core@2.2.0(typescript@6.0.2)': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.32 @@ -5793,7 +6077,7 @@ snapshots: muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 '@vue/language-core@3.2.6': dependencies: @@ -5821,11 +6105,11 @@ snapshots: '@vue/shared': 3.5.32 csstype: 3.2.3 - '@vue/server-renderer@3.5.32(vue@3.5.32(typescript@5.9.3))': + '@vue/server-renderer@3.5.32(vue@3.5.32(typescript@6.0.2))': dependencies: '@vue/compiler-ssr': 3.5.32 '@vue/shared': 3.5.32 - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) '@vue/shared@3.5.32': {} @@ -5834,34 +6118,34 @@ snapshots: js-beautify: 1.15.4 vue-component-type-helpers: 2.2.12 - '@vue/tsconfig@0.8.1(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))': + '@vue/tsconfig@0.9.1(typescript@6.0.2)(vue@3.5.32(typescript@6.0.2))': optionalDependencies: - typescript: 5.9.3 - vue: 3.5.32(typescript@5.9.3) + typescript: 6.0.2 + vue: 3.5.32(typescript@6.0.2) - '@vuepic/vue-datepicker@11.0.3(vue@3.5.32(typescript@5.9.3))': + '@vuepic/vue-datepicker@11.0.3(vue@3.5.32(typescript@6.0.2))': dependencies: date-fns: 4.1.0 - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) - '@vueuse/components@14.2.1(vue@3.5.32(typescript@5.9.3))': + '@vueuse/components@14.2.1(vue@3.5.32(typescript@6.0.2))': dependencies: - '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) - '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) - vue: 3.5.32(typescript@5.9.3) + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2)) + '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2)) + vue: 3.5.32(typescript@6.0.2) - '@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3))': + '@vueuse/core@14.2.1(vue@3.5.32(typescript@6.0.2))': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 14.2.1 - '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) - vue: 3.5.32(typescript@5.9.3) + '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2)) + vue: 3.5.32(typescript@6.0.2) '@vueuse/metadata@14.2.1': {} - '@vueuse/shared@14.2.1(vue@3.5.32(typescript@5.9.3))': + '@vueuse/shared@14.2.1(vue@3.5.32(typescript@6.0.2))': dependencies: - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) abbrev@2.0.0: {} @@ -6267,14 +6551,14 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig@9.0.1(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@6.0.2): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 create-ecdh@4.0.4: dependencies: @@ -6406,8 +6690,7 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} devlop@1.1.0: dependencies: @@ -6480,11 +6763,11 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - emoji-mart-vue-fast@15.0.5(vue@3.5.32(typescript@5.9.3)): + emoji-mart-vue-fast@15.0.5(vue@3.5.32(typescript@6.0.2)): dependencies: '@babel/runtime': 7.29.2 core-js: 3.49.0 - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) emoji-regex@10.6.0: {} @@ -6653,6 +6936,7 @@ snapshots: '@esbuild/win32-arm64': 0.27.7 '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + optional: true escalade@3.2.0: {} @@ -6670,14 +6954,14 @@ snapshots: eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0): dependencies: eslint: 10.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) eslint-plugin-n: 16.6.2(eslint@10.2.0) eslint-plugin-promise: 6.6.0(eslint@10.2.0) eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0): dependencies: eslint: 10.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) resolve.exports: 2.0.3 eslint-import-resolver-node@0.3.10: @@ -6688,33 +6972,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-plugin-import@2.31.0)(eslint@10.2.0): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-plugin-import@2.31.0)(eslint@10.2.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 enhanced-resolve: 5.20.1 eslint: 10.2.0 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) fast-glob: 3.3.3 get-tsconfig: 4.13.7 is-bun-module: 1.3.0 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@6.0.2) eslint: 10.2.0 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-plugin-import@2.31.0)(eslint@10.2.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-plugin-import@2.31.0)(eslint@10.2.0) transitivePeerDependencies: - supports-color @@ -6725,7 +7009,7 @@ snapshots: eslint: 10.2.0 eslint-compat-utils: 0.5.1(eslint@10.2.0) - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6736,7 +7020,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.2.0 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.6.3)(eslint@10.2.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6748,7 +7032,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@6.0.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6991,11 +7275,11 @@ snapshots: flatted@3.4.2: {} - floating-vue@5.2.2(vue@3.5.32(typescript@5.9.3)): + floating-vue@5.2.2(vue@3.5.32(typescript@6.0.2)): dependencies: '@floating-ui/dom': 1.1.1 - vue: 3.5.32(typescript@5.9.3) - vue-resize: 2.0.0-alpha.1(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@6.0.2) + vue-resize: 2.0.0-alpha.1(vue@3.5.32(typescript@6.0.2)) focus-trap@8.0.1: dependencies: @@ -7531,6 +7815,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} linkifyjs@4.3.2: {} @@ -8417,6 +8750,30 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 + rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + rollup-plugin-corejs@1.0.2(rollup@4.60.1): dependencies: acorn: 8.16.0 @@ -8449,13 +8806,14 @@ snapshots: dependencies: rollup: 4.60.1 - rollup-plugin-visualizer@7.0.1(rollup@4.60.1): + rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1): dependencies: open: 11.0.0 picomatch: 4.0.4 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) rollup: 4.60.1 rollup@4.60.1: @@ -8489,6 +8847,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + rrule@2.8.1: + dependencies: + tslib: 2.8.1 + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -8755,9 +9117,9 @@ snapshots: spdx-expression-parse: 3.0.1 spdx-ranges: 2.1.1 - splitpanes@4.0.4(vue@3.5.32(typescript@5.9.3)): + splitpanes@4.0.4(vue@3.5.32(typescript@6.0.2)): dependencies: - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) sprintf-js@1.0.3: {} @@ -8874,50 +9236,50 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylelint-config-html@1.1.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@5.9.3)): + stylelint-config-html@1.1.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@6.0.2)): dependencies: postcss-html: 1.7.0 - stylelint: 16.11.0(typescript@5.9.3) + stylelint: 16.11.0(typescript@6.0.2) - stylelint-config-recommended-scss@13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@5.9.3)): + stylelint-config-recommended-scss@13.1.0(postcss@8.5.8)(stylelint@16.11.0(typescript@6.0.2)): dependencies: postcss-scss: 4.0.9(postcss@8.5.8) - stylelint: 16.11.0(typescript@5.9.3) - stylelint-config-recommended: 13.0.0(stylelint@16.11.0(typescript@5.9.3)) - stylelint-scss: 5.3.2(stylelint@16.11.0(typescript@5.9.3)) + stylelint: 16.11.0(typescript@6.0.2) + stylelint-config-recommended: 13.0.0(stylelint@16.11.0(typescript@6.0.2)) + stylelint-scss: 5.3.2(stylelint@16.11.0(typescript@6.0.2)) optionalDependencies: postcss: 8.5.8 - stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@5.9.3)): + stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@6.0.2)): dependencies: postcss-html: 1.7.0 semver: 7.7.4 - stylelint: 16.11.0(typescript@5.9.3) - stylelint-config-html: 1.1.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@5.9.3)) - stylelint-config-recommended: 18.0.0(stylelint@16.11.0(typescript@5.9.3)) + stylelint: 16.11.0(typescript@6.0.2) + stylelint-config-html: 1.1.0(postcss-html@1.7.0)(stylelint@16.11.0(typescript@6.0.2)) + stylelint-config-recommended: 18.0.0(stylelint@16.11.0(typescript@6.0.2)) - stylelint-config-recommended@13.0.0(stylelint@16.11.0(typescript@5.9.3)): + stylelint-config-recommended@13.0.0(stylelint@16.11.0(typescript@6.0.2)): dependencies: - stylelint: 16.11.0(typescript@5.9.3) + stylelint: 16.11.0(typescript@6.0.2) - stylelint-config-recommended@18.0.0(stylelint@16.11.0(typescript@5.9.3)): + stylelint-config-recommended@18.0.0(stylelint@16.11.0(typescript@6.0.2)): dependencies: - stylelint: 16.11.0(typescript@5.9.3) + stylelint: 16.11.0(typescript@6.0.2) - stylelint-scss@5.3.2(stylelint@16.11.0(typescript@5.9.3)): + stylelint-scss@5.3.2(stylelint@16.11.0(typescript@6.0.2)): dependencies: known-css-properties: 0.29.0 postcss-media-query-parser: 0.2.3 postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - stylelint: 16.11.0(typescript@5.9.3) + stylelint: 16.11.0(typescript@6.0.2) - stylelint-use-logical@2.1.3(stylelint@16.11.0(typescript@5.9.3)): + stylelint-use-logical@2.1.3(stylelint@16.11.0(typescript@6.0.2)): dependencies: - stylelint: 16.11.0(typescript@5.9.3) + stylelint: 16.11.0(typescript@6.0.2) - stylelint@16.11.0(typescript@5.9.3): + stylelint@16.11.0(typescript@6.0.2): dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 @@ -8926,7 +9288,7 @@ snapshots: '@dual-bundle/import-meta-resolve': 4.2.1 balanced-match: 2.0.0 colord: 2.9.3 - cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.2) css-functions-list: 3.3.3 css-tree: 3.2.1 debug: 4.4.3 @@ -9029,13 +9391,13 @@ snapshots: trough@2.2.0: {} - ts-api-utils@1.4.3(typescript@5.9.3): + ts-api-utils@1.4.3(typescript@6.0.2): dependencies: - typescript: 5.9.3 + typescript: 6.0.2 - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.2): dependencies: - typescript: 5.9.3 + typescript: 6.0.2 ts-md5@2.0.1: {} @@ -9089,14 +9451,14 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.58.0(eslint@10.2.0)(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@10.2.0)(typescript@6.0.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@6.0.2) eslint: 10.2.0 - typescript: 5.9.3 + typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -9105,6 +9467,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.2: {} + ufo@1.6.3: {} unbox-primitive@1.1.0: @@ -9219,7 +9583,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-checker@0.12.0(eslint@10.2.0)(meow@13.2.0)(optionator@0.9.4)(stylelint@16.11.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3)): + vite-plugin-checker@0.12.0(eslint@10.2.0)(meow@13.2.0)(optionator@0.9.4)(stylelint@16.11.0(typescript@6.0.2))(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@6.0.2)): dependencies: '@babel/code-frame': 7.29.0 chokidar: 4.0.3 @@ -9228,66 +9592,69 @@ snapshots: picomatch: 4.0.4 tiny-invariant: 1.3.3 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) vscode-uri: 3.1.0 optionalDependencies: eslint: 10.2.0 meow: 13.2.0 optionator: 0.9.4 - stylelint: 16.11.0(typescript@5.9.3) - typescript: 5.9.3 - vue-tsc: 3.2.6(typescript@5.9.3) + stylelint: 16.11.0(typescript@6.0.2) + typescript: 6.0.2 + vue-tsc: 3.2.6(typescript@6.0.2) - vite-plugin-css-injected-by-js@3.5.2(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): + vite-plugin-css-injected-by-js@3.5.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): dependencies: - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) - vite-plugin-dts@4.5.4(@types/node@25.5.2)(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): + vite-plugin-dts@4.5.4(@types/node@25.5.2)(rollup@4.60.1)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): dependencies: '@microsoft/api-extractor': 7.58.1(@types/node@25.5.2) '@rollup/pluginutils': 5.3.0(rollup@4.60.1) '@volar/typescript': 2.4.28 - '@vue/language-core': 2.2.0(typescript@5.9.3) + '@vue/language-core': 2.2.0(typescript@6.0.2) compare-versions: 6.1.1 debug: 4.4.3 kolorist: 1.8.0 local-pkg: 1.1.2 magic-string: 0.30.21 - typescript: 5.9.3 + typescript: 6.0.2 optionalDependencies: - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite-plugin-node-polyfills@0.24.0(rollup@4.60.1)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): + vite-plugin-node-polyfills@0.24.0(rollup@4.60.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): dependencies: '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) node-stdlib-browser: 1.3.1 - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) transitivePeerDependencies: - rollup - vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3): + vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) + lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.8 - rollup: 4.60.1 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.5.2 + esbuild: 0.27.7 fsevents: 2.3.3 sass: 1.99.0 sass-embedded: 1.99.0 yaml: 2.8.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - vitest@4.1.2(@types/node@25.5.2)(happy-dom@20.8.9)(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): + vitest@4.1.2(@types/node@25.5.2)(happy-dom@20.8.9)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 @@ -9304,7 +9671,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.5.2)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.2 @@ -9333,14 +9700,14 @@ snapshots: vue-material-design-icons@5.3.1: {} - vue-resize@2.0.0-alpha.1(vue@3.5.32(typescript@5.9.3)): + vue-resize@2.0.0-alpha.1(vue@3.5.32(typescript@6.0.2)): dependencies: - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) - vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@5.9.3)): + vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)): dependencies: '@babel/generator': 7.29.1 - '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@6.0.2)) '@vue/devtools-api': 8.1.1 ast-walker-scope: 0.8.3 chokidar: 5.0.0 @@ -9355,30 +9722,30 @@ snapshots: tinyglobby: 0.2.15 unplugin: 3.0.0 unplugin-utils: 0.3.1 - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) yaml: 2.8.3 optionalDependencies: '@vue/compiler-sfc': 3.5.32 - vue-select@4.0.0-beta.6(vue@3.5.32(typescript@5.9.3)): + vue-select@4.0.0-beta.6(vue@3.5.32(typescript@6.0.2)): dependencies: - vue: 3.5.32(typescript@5.9.3) + vue: 3.5.32(typescript@6.0.2) - vue-tsc@3.2.6(typescript@5.9.3): + vue-tsc@3.2.6(typescript@6.0.2): dependencies: '@volar/typescript': 2.4.28 '@vue/language-core': 3.2.6 - typescript: 5.9.3 + typescript: 6.0.2 - vue@3.5.32(typescript@5.9.3): + vue@3.5.32(typescript@6.0.2): dependencies: '@vue/compiler-dom': 3.5.32 '@vue/compiler-sfc': 3.5.32 '@vue/runtime-dom': 3.5.32 - '@vue/server-renderer': 3.5.32(vue@3.5.32(typescript@5.9.3)) + '@vue/server-renderer': 3.5.32(vue@3.5.32(typescript@6.0.2)) '@vue/shared': 3.5.32 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 web-streams-polyfill@3.3.3: optional: true diff --git a/src/App.vue b/src/App.vue index d60e0ba..3d77c02 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,60 +1,8 @@ diff --git a/src/api/houses.ts b/src/api/houses.ts new file mode 100644 index 0000000..6299811 --- /dev/null +++ b/src/api/houses.ts @@ -0,0 +1,60 @@ +import { ocs } from '@/axios' +import type { House, HouseMember, HouseRole } from './types' + +export async function listHouses(): Promise { + const resp = await ocs.get('/houses') + return resp.data ?? [] +} + +export async function getHouse(houseId: number): Promise { + const resp = await ocs.get(`/houses/${houseId}`) + return resp.data +} + +export async function createHouse(name: string, description?: string | null): Promise { + const resp = await ocs.post('/houses', { name, description: description ?? null }) + return resp.data +} + +export async function updateHouse( + houseId: number, + patch: { name?: string; description?: string | null }, +): Promise { + const resp = await ocs.patch(`/houses/${houseId}`, patch) + return resp.data +} + +export async function deleteHouse(houseId: number): Promise { + await ocs.delete(`/houses/${houseId}`) +} + +export async function listMembers(houseId: number): Promise { + const resp = await ocs.get(`/houses/${houseId}/members`) + return resp.data ?? [] +} + +export async function addMember( + houseId: number, + userId: string, + role: HouseRole = 'member', +): Promise { + const resp = await ocs.post(`/houses/${houseId}/members`, { userId, role }) + return resp.data +} + +export async function updateMemberRole( + houseId: number, + memberId: number, + role: HouseRole, +): Promise { + const resp = await ocs.patch(`/houses/${houseId}/members/${memberId}`, { role }) + return resp.data +} + +export async function removeMember(houseId: number, memberId: number): Promise { + await ocs.delete(`/houses/${houseId}/members/${memberId}`) +} + +export async function leaveHouse(houseId: number): Promise { + await ocs.post(`/houses/${houseId}/leave`) +} diff --git a/src/api/lists.ts b/src/api/lists.ts new file mode 100644 index 0000000..5df09b4 --- /dev/null +++ b/src/api/lists.ts @@ -0,0 +1,87 @@ +import { ocs } from '@/axios' +import type { ShoppingList, ShoppingListItem } from './types' + +export async function listLists(houseId: number): Promise { + const resp = await ocs.get(`/houses/${houseId}/lists`) + return resp.data ?? [] +} + +export async function createList( + houseId: number, + name: string, + description?: string | null, +): Promise { + const resp = await ocs.post(`/houses/${houseId}/lists`, { + name, + description: description ?? null, + }) + return resp.data +} + +export async function getList(houseId: number, listId: number): Promise { + const resp = await ocs.get(`/houses/${houseId}/lists/${listId}`) + return resp.data +} + +export async function updateList( + houseId: number, + listId: number, + patch: { name?: string; description?: string | null; sortOrder?: number }, +): Promise { + const resp = await ocs.patch(`/houses/${houseId}/lists/${listId}`, patch) + return resp.data +} + +export async function deleteList(houseId: number, listId: number): Promise { + await ocs.delete(`/houses/${houseId}/lists/${listId}`) +} + +export async function listItems(houseId: number, listId: number): Promise { + const resp = await ocs.get(`/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 { + const resp = await ocs.post(`/houses/${houseId}/lists/${listId}/items`, input) + return resp.data +} + +export async function updateItem( + houseId: number, + listId: number, + itemId: number, + patch: Partial, +): Promise { + const resp = await ocs.patch( + `/houses/${houseId}/lists/${listId}/items/${itemId}`, + patch, + ) + return resp.data +} + +export async function toggleItem( + houseId: number, + listId: number, + itemId: number, +): Promise { + const resp = await ocs.post( + `/houses/${houseId}/lists/${listId}/items/${itemId}/toggle`, + ) + return resp.data +} + +export async function deleteItem(houseId: number, listId: number, itemId: number): Promise { + await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`) +} diff --git a/src/api/prefs.ts b/src/api/prefs.ts new file mode 100644 index 0000000..7d06786 --- /dev/null +++ b/src/api/prefs.ts @@ -0,0 +1,10 @@ +import { ocs } from '@/axios' + +export async function getLastHouse(): Promise { + const resp = await ocs.get<{ houseId: number | null }>('/prefs/last-house') + return resp.data?.houseId ?? null +} + +export async function setLastHouse(houseId: number | null): Promise { + await ocs.put('/prefs/last-house', { houseId }) +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..2abf648 --- /dev/null +++ b/src/api/types.ts @@ -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 +} diff --git a/src/components/RecurrenceEditor.vue b/src/components/RecurrenceEditor.vue new file mode 100644 index 0000000..be2046f --- /dev/null +++ b/src/components/RecurrenceEditor.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/components/StatusBadge.test.ts b/src/components/StatusBadge.test.ts index 56e29a6..0678967 100644 --- a/src/components/StatusBadge.test.ts +++ b/src/components/StatusBadge.test.ts @@ -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) }) }) diff --git a/src/composables/useCurrentHouse.ts b/src/composables/useCurrentHouse.ts new file mode 100644 index 0000000..5b2c546 --- /dev/null +++ b/src/composables/useCurrentHouse.ts @@ -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 + houseId: ComputedRef + loading: Ref + canEdit: ComputedRef + canAdmin: ComputedRef + isOwner: ComputedRef + refresh: () => Promise +} { + const route = useRoute() + const { findById, load } = useHouses() + const house = ref(null) + const loading = ref(false) + + const houseId = computed(() => { + 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 { + 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, + } +} diff --git a/src/composables/useHouses.ts b/src/composables/useHouses.ts new file mode 100644 index 0000000..0d6e972 --- /dev/null +++ b/src/composables/useHouses.ts @@ -0,0 +1,63 @@ +import { ref, computed } from 'vue' +import * as api from '@/api/houses' +import type { House } from '@/api/types' + +const houses = ref([]) +const loaded = ref(false) +const loading = ref(false) +const error = ref(null) + +async function load(force = false): Promise { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/src/composables/useLastHouse.ts b/src/composables/useLastHouse.ts new file mode 100644 index 0000000..f912797 --- /dev/null +++ b/src/composables/useLastHouse.ts @@ -0,0 +1,8 @@ +import * as api from '@/api/prefs' + +export function useLastHouse() { + return { + get: api.getLastHouse, + set: api.setLastHouse, + } +} diff --git a/src/composables/useShoppingList.ts b/src/composables/useShoppingList.ts new file mode 100644 index 0000000..af3aaff --- /dev/null +++ b/src/composables/useShoppingList.ts @@ -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([]) + const loading = ref(false) + const error = ref(null) + + async function load(): Promise { + 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 { + const created = await api.createList(houseId, name, description) + lists.value = [...lists.value, created] + return created + } + + async function rename(listId: number, name: string): Promise { + 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 { + 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([]) + const loading = ref(false) + const error = ref(null) + + async function load(): Promise { + 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 { + const created = await api.addItem(houseId, listId, input) + items.value = [...items.value, created] + return created + } + + async function update(itemId: number, patch: Partial): Promise { + 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 { + // 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 { + await api.deleteItem(houseId, listId, itemId) + items.value = items.value.filter((i) => i.id !== itemId) + } + + return { items, loading, error, load, add, update, toggle, remove } +} diff --git a/src/router.ts b/src/router.ts index 9e05703..52cc860 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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')), diff --git a/src/views/AppView.vue b/src/views/AppView.vue deleted file mode 100644 index c6600ea..0000000 --- a/src/views/AppView.vue +++ /dev/null @@ -1,458 +0,0 @@ - - - - - diff --git a/src/views/HomeRedirect.vue b/src/views/HomeRedirect.vue new file mode 100644 index 0000000..76fc4cd --- /dev/null +++ b/src/views/HomeRedirect.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/views/HouseLayout.vue b/src/views/HouseLayout.vue new file mode 100644 index 0000000..c8fd439 --- /dev/null +++ b/src/views/HouseLayout.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/views/HouseNavigation.vue b/src/views/HouseNavigation.vue new file mode 100644 index 0000000..f874548 --- /dev/null +++ b/src/views/HouseNavigation.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/views/HouseSettingsView.vue b/src/views/HouseSettingsView.vue new file mode 100644 index 0000000..fb8243b --- /dev/null +++ b/src/views/HouseSettingsView.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/views/HousesList.vue b/src/views/HousesList.vue new file mode 100644 index 0000000..455d148 --- /dev/null +++ b/src/views/HousesList.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/src/views/HousesNavigation.vue b/src/views/HousesNavigation.vue new file mode 100644 index 0000000..a266dc8 --- /dev/null +++ b/src/views/HousesNavigation.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/views/MembersView.vue b/src/views/MembersView.vue new file mode 100644 index 0000000..4bc5981 --- /dev/null +++ b/src/views/MembersView.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/views/NotesWallStub.vue b/src/views/NotesWallStub.vue new file mode 100644 index 0000000..6eb640b --- /dev/null +++ b/src/views/NotesWallStub.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/views/PhotoBoardStub.vue b/src/views/PhotoBoardStub.vue new file mode 100644 index 0000000..da310e6 --- /dev/null +++ b/src/views/PhotoBoardStub.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/views/ShoppingListDetail.vue b/src/views/ShoppingListDetail.vue new file mode 100644 index 0000000..910ddbe --- /dev/null +++ b/src/views/ShoppingListDetail.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/src/views/ShoppingListsView.vue b/src/views/ShoppingListsView.vue new file mode 100644 index 0000000..8185f27 --- /dev/null +++ b/src/views/ShoppingListsView.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/tests/unit/Controller/ApiTest.php b/tests/unit/Controller/ApiTest.php deleted file mode 100644 index 504eda4..0000000 --- a/tests/unit/Controller/ApiTest.php +++ /dev/null @@ -1,78 +0,0 @@ -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']); - } -} diff --git a/tests/unit/Service/HouseAuthServiceTest.php b/tests/unit/Service/HouseAuthServiceTest.php new file mode 100644 index 0000000..426e20a --- /dev/null +++ b/tests/unit/Service/HouseAuthServiceTest.php @@ -0,0 +1,77 @@ + +// 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); + } +} diff --git a/tests/unit/Service/RecurrenceServiceTest.php b/tests/unit/Service/RecurrenceServiceTest.php new file mode 100644 index 0000000..5307bba --- /dev/null +++ b/tests/unit/Service/RecurrenceServiceTest.php @@ -0,0 +1,62 @@ + +// 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')); + } +} diff --git a/tests/unit/Service/ShoppingListServiceTest.php b/tests/unit/Service/ShoppingListServiceTest.php new file mode 100644 index 0000000..a28deaa --- /dev/null +++ b/tests/unit/Service/ShoppingListServiceTest.php @@ -0,0 +1,139 @@ + +// 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']); + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 8c5e73e..8a96e45 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -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/*"] } } }