mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: home with lists poc
This commit is contained in:
@@ -6,9 +6,16 @@
|
||||
-->
|
||||
<id>pantry</id>
|
||||
<name>Pantry</name>
|
||||
<summary>Enter your app summary here.</summary>
|
||||
<summary>Manage your household: shared shopping lists, photos and notes.</summary>
|
||||
<description><![CDATA[
|
||||
Enter your app description here.
|
||||
Pantry helps households stay organized in Nextcloud.
|
||||
|
||||
- **Houses** group members and their shared data. A user can belong to multiple houses and switch between them.
|
||||
- **Shopping lists** support recurring items (e.g. milk every week) that automatically reappear when due.
|
||||
- **Photo boards** (coming soon) let you keep shared reference photos — the right brand of dog food, a favorite recipe card, and so on.
|
||||
- **Notes wall** (coming soon) gives the household a lightweight shared space for reminders.
|
||||
|
||||
All data is scoped to a house; members only see the houses they belong to.
|
||||
]]></description>
|
||||
<version>1.0.0</version>
|
||||
<licence>agpl</licence>
|
||||
@@ -26,7 +33,7 @@ Enter your app description here.
|
||||
<repository>https://github.com/chenasraf/nextcloud-pantry</repository>
|
||||
<screenshot>https://raw.githubusercontent.com/chenasraf/nextcloud-pantry/refs/heads/master/promo.png</screenshot>
|
||||
<dependencies>
|
||||
<nextcloud min-version="29" max-version="32"/>
|
||||
<nextcloud min-version="29" max-version="34"/>
|
||||
</dependencies>
|
||||
<settings>
|
||||
<admin>OCA\Pantry\Settings\Admin</admin>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"chriskonnertz/bbcode": "^1.1"
|
||||
"sabre/vobject": "^4.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8",
|
||||
|
||||
230
composer.lock
generated
230
composer.lock
generated
@@ -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": [
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\AppInfo;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
|
||||
final class ApiController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l10n,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->config = $config;
|
||||
$this->l10n = $l10n;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hello
|
||||
*
|
||||
* Returns a simple hello message and the last time the server said hello.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{message: string, at: string|null}, array{}>
|
||||
*
|
||||
* 200: Data returned successfully.
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/hello')]
|
||||
#[NoAdminRequired]
|
||||
public function getHello(): DataResponse {
|
||||
$lastAt = $this->config->getValueString(AppInfo\Application::APP_ID, 'last_hello_at', '');
|
||||
$at = $lastAt !== '' ? $lastAt : null;
|
||||
|
||||
$message = (string)$this->l10n->t('👋 Hello from server!');
|
||||
|
||||
return new DataResponse([
|
||||
'message' => $message,
|
||||
'at' => $at,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/hello
|
||||
*
|
||||
* Accepts example payload and returns a message + timestamp.
|
||||
*
|
||||
* @param array{
|
||||
* name?: string,
|
||||
* theme?: string,
|
||||
* items?: list<string>,
|
||||
* counter?: int
|
||||
* } $data Request payload for creating a hello message.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{message: string, at: string}, array{}>
|
||||
*
|
||||
* 200: Data returned successfully.
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/hello')]
|
||||
#[NoAdminRequired]
|
||||
public function postHello(mixed $data = []): DataResponse {
|
||||
// Normalize incoming payload (be permissive for the example)
|
||||
$name = isset($data['name']) && is_string($data['name']) ? trim($data['name']) : '';
|
||||
$theme = isset($data['theme']) && is_string($data['theme']) ? $data['theme'] : null;
|
||||
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
|
||||
$counter = isset($data['counter']) && is_int($data['counter']) ? $data['counter'] : 0;
|
||||
|
||||
// Build a friendly message (localized)
|
||||
$who = $name !== '' ? $name : (string)$this->l10n->t('there');
|
||||
$message = (string)$this->l10n->t('Hello, %s!', [$who]);
|
||||
|
||||
// Optionally include a tiny summary (kept simple for the example)
|
||||
if ($theme !== null) {
|
||||
$message .= ' ' . (string)$this->l10n->t('Theme: %s.', [$theme]);
|
||||
}
|
||||
if (!empty($items)) {
|
||||
$message .= ' ' . (string)$this->l10n->t('Items: %d.', [count($items)]);
|
||||
}
|
||||
if ($counter !== 0) {
|
||||
$message .= ' ' . (string)$this->l10n->t('Counter: %d.', [$counter]);
|
||||
}
|
||||
|
||||
// Stamp "now" and persist as the last hello time
|
||||
$now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DATE_ATOM);
|
||||
$this->config->setValueString(AppInfo\Application::APP_ID, 'last_hello_at', $now);
|
||||
|
||||
return new DataResponse([
|
||||
'message' => $message,
|
||||
'at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
322
lib/Controller/HouseController.php
Normal file
322
lib/Controller/HouseController.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\Db\House;
|
||||
use OCA\Pantry\Db\HouseMember;
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\ResponseDefinitions;
|
||||
use OCA\Pantry\Service\HouseAuthService;
|
||||
use OCA\Pantry\Service\HouseService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* @psalm-import-type PantryHouse from ResponseDefinitions
|
||||
* @psalm-import-type PantryMember from ResponseDefinitions
|
||||
* @psalm-import-type PantrySuccess from ResponseDefinitions
|
||||
*/
|
||||
final class HouseController extends OCSController {
|
||||
use TranslatesDomainExceptions;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private HouseService $houseService,
|
||||
private HouseAuthService $auth,
|
||||
private IUserSession $userSession,
|
||||
private \OCP\IUserManager $userManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* List houses the current user belongs to
|
||||
*
|
||||
* @param int<1, 500> $limit Maximum number of houses to return.
|
||||
* @param int<0, max> $offset Number of houses to skip.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<PantryHouse>, array{}>
|
||||
*
|
||||
* 200: Houses returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses')]
|
||||
#[NoAdminRequired]
|
||||
public function index(int $limit = 100, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($limit, $offset): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$houses = $this->houseService->listForUser($uid);
|
||||
$sliced = array_slice($houses, max(0, $offset), max(0, $limit));
|
||||
$out = [];
|
||||
foreach ($sliced as $house) {
|
||||
$member = $this->auth->requireMember((int)$house->getId(), $uid);
|
||||
$out[] = $this->serializeHouseWithRole($house, $member->getRole());
|
||||
}
|
||||
return new DataResponse($out);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new house
|
||||
*
|
||||
* The caller becomes the owner.
|
||||
*
|
||||
* @param string $name House name.
|
||||
* @param string|null $description Optional description.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryHouse, array{}>
|
||||
*
|
||||
* 200: House created
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses')]
|
||||
#[NoAdminRequired]
|
||||
public function create(string $name, ?string $description = null): DataResponse {
|
||||
return $this->runAction(function () use ($name, $description): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$house = $this->houseService->create($uid, $name, $description);
|
||||
return new DataResponse($this->serializeHouseWithRole($house, HouseMember::ROLE_OWNER));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single house
|
||||
*
|
||||
* The caller must be a member.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryHouse, array{}>
|
||||
*
|
||||
* 200: House returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}')]
|
||||
#[NoAdminRequired]
|
||||
public function show(int $houseId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$member = $this->auth->requireMember($houseId, $uid);
|
||||
$house = $this->houseService->get($houseId);
|
||||
return new DataResponse($this->serializeHouseWithRole($house, $member->getRole()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a house
|
||||
*
|
||||
* Requires admin or owner role.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param string|null $name New name.
|
||||
* @param string|null $description New description.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryHouse, array{}>
|
||||
*
|
||||
* 200: House updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}')]
|
||||
#[NoAdminRequired]
|
||||
public function update(int $houseId, ?string $name = null, ?string $description = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $name, $description): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$member = $this->auth->requireAdmin($houseId, $uid);
|
||||
$patch = [];
|
||||
if ($name !== null) {
|
||||
$patch['name'] = $name;
|
||||
}
|
||||
if ($description !== null) {
|
||||
$patch['description'] = $description;
|
||||
}
|
||||
$house = $this->houseService->update($houseId, $patch);
|
||||
return new DataResponse($this->serializeHouseWithRole($house, $member->getRole()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a house and all of its data
|
||||
*
|
||||
* Owner only. Removes all lists, items, photos, notes and member records.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: House deleted
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}')]
|
||||
#[NoAdminRequired]
|
||||
public function destroy(int $houseId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireOwner($houseId, $uid);
|
||||
$this->houseService->delete($houseId);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List members of a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int<1, 500> $limit Maximum number of members to return.
|
||||
* @param int<0, max> $offset Number of members to skip.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<PantryMember>, array{}>
|
||||
*
|
||||
* 200: Members returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/members')]
|
||||
#[NoAdminRequired]
|
||||
public function listMembers(int $houseId, int $limit = 100, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
$members = array_slice(
|
||||
$this->houseService->listMembers($houseId),
|
||||
max(0, $offset),
|
||||
max(0, $limit),
|
||||
);
|
||||
$out = array_map(fn (HouseMember $m) => $this->serializeMember($m), $members);
|
||||
return new DataResponse($out);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a house
|
||||
*
|
||||
* Requires admin or owner role.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param string $userId Nextcloud user id to add.
|
||||
* @param string $role Role: "admin" or "member".
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryMember, array{}>
|
||||
*
|
||||
* 200: Member added
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/members')]
|
||||
#[NoAdminRequired]
|
||||
public function addMember(int $houseId, string $userId, string $role = HouseMember::ROLE_MEMBER): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $userId, $role): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireAdmin($houseId, $uid);
|
||||
$member = $this->houseService->addMember($houseId, $userId, $role);
|
||||
return new DataResponse($this->serializeMember($member));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a member's role
|
||||
*
|
||||
* Requires admin or owner role. The owner's role cannot be changed.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $memberId Member id.
|
||||
* @param string $role New role.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryMember, array{}>
|
||||
*
|
||||
* 200: Role updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/members/{memberId}')]
|
||||
#[NoAdminRequired]
|
||||
public function updateMember(int $houseId, int $memberId, string $role): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $memberId, $role): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireAdmin($houseId, $uid);
|
||||
$member = $this->houseService->updateMemberRole($houseId, $memberId, $role);
|
||||
return new DataResponse($this->serializeMember($member));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a house
|
||||
*
|
||||
* Requires admin or owner role. The owner cannot be removed.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $memberId Member id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Member removed
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/members/{memberId}')]
|
||||
#[NoAdminRequired]
|
||||
public function removeMember(int $houseId, int $memberId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $memberId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireAdmin($houseId, $uid);
|
||||
$this->houseService->removeMember($houseId, $memberId);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a house
|
||||
*
|
||||
* Any non-owner member may call this. The owner must transfer ownership first.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Left house
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/leave')]
|
||||
#[NoAdminRequired]
|
||||
public function leave(int $houseId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->houseService->leaveHouse($houseId, $uid);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
private function requireUid(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PantryHouse
|
||||
*/
|
||||
private function serializeHouseWithRole(House $house, string $role): array {
|
||||
return [
|
||||
'id' => (int)$house->getId(),
|
||||
'name' => $house->getName(),
|
||||
'description' => $house->getDescription(),
|
||||
'ownerUid' => $house->getOwnerUid(),
|
||||
'createdAt' => $house->getCreatedAt(),
|
||||
'updatedAt' => $house->getUpdatedAt(),
|
||||
'role' => $role,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PantryMember
|
||||
*/
|
||||
private function serializeMember(HouseMember $member): array {
|
||||
$user = $this->userManager->get($member->getUserId());
|
||||
return [
|
||||
'id' => (int)$member->getId(),
|
||||
'houseId' => $member->getHouseId(),
|
||||
'userId' => $member->getUserId(),
|
||||
'displayName' => $user !== null ? $user->getDisplayName() : $member->getUserId(),
|
||||
'role' => $member->getRole(),
|
||||
'joinedAt' => $member->getJoinedAt(),
|
||||
];
|
||||
}
|
||||
}
|
||||
93
lib/Controller/PrefsController.php
Normal file
93
lib/Controller/PrefsController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\ResponseDefinitions;
|
||||
use OCA\Pantry\Service\HouseAuthService;
|
||||
use OCA\Pantry\Service\PrefsService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* @psalm-import-type PantryLastHouse from ResponseDefinitions
|
||||
*/
|
||||
final class PrefsController extends OCSController {
|
||||
use TranslatesDomainExceptions;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private PrefsService $prefs,
|
||||
private HouseAuthService $auth,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's last-used house id
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryLastHouse, array{}>
|
||||
*
|
||||
* 200: Last house returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/prefs/last-house')]
|
||||
#[NoAdminRequired]
|
||||
public function getLastHouse(): DataResponse {
|
||||
return $this->runAction(function (): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$houseId = $this->prefs->getLastHouseId($uid);
|
||||
// If the saved house is no longer accessible, forget it.
|
||||
if ($houseId !== null) {
|
||||
try {
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
} catch (ForbiddenException) {
|
||||
$this->prefs->setLastHouseId($uid, null);
|
||||
$houseId = null;
|
||||
}
|
||||
}
|
||||
return new DataResponse(['houseId' => $houseId]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current user's last-used house id
|
||||
*
|
||||
* @param int|null $houseId House id, or null to clear.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryLastHouse, array{}>
|
||||
*
|
||||
* 200: Last house updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/prefs/last-house')]
|
||||
#[NoAdminRequired]
|
||||
public function setLastHouse(?int $houseId = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
if ($houseId !== null) {
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
}
|
||||
$this->prefs->setLastHouseId($uid, $houseId);
|
||||
return new DataResponse(['houseId' => $houseId]);
|
||||
});
|
||||
}
|
||||
|
||||
private function requireUid(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
}
|
||||
357
lib/Controller/ShoppingListController.php
Normal file
357
lib/Controller/ShoppingListController.php
Normal file
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCA\Pantry\ResponseDefinitions;
|
||||
use OCA\Pantry\Service\HouseAuthService;
|
||||
use OCA\Pantry\Service\ShoppingListService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* @psalm-import-type PantryList from ResponseDefinitions
|
||||
* @psalm-import-type PantryListItem from ResponseDefinitions
|
||||
* @psalm-import-type PantrySuccess from ResponseDefinitions
|
||||
*/
|
||||
final class ShoppingListController extends OCSController {
|
||||
use TranslatesDomainExceptions;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private ShoppingListService $lists,
|
||||
private HouseAuthService $auth,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all shopping lists in a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int<1, 500> $limit Maximum number of lists to return.
|
||||
* @param int<0, max> $offset Number of lists to skip.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<PantryList>, array{}>
|
||||
*
|
||||
* 200: Lists returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists')]
|
||||
#[NoAdminRequired]
|
||||
public function indexLists(int $houseId, int $limit = 100, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$all = $this->lists->listForHouse($houseId);
|
||||
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
|
||||
$out = array_map(fn ($l) => $l->jsonSerialize(), $sliced);
|
||||
return new DataResponse($out);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shopping list in a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param string $name List name.
|
||||
* @param string|null $description Optional description.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryList, array{}>
|
||||
*
|
||||
* 200: List created
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists')]
|
||||
#[NoAdminRequired]
|
||||
public function createList(int $houseId, string $name, ?string $description = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $name, $description): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$list = $this->lists->createList($houseId, $name, $description);
|
||||
return new DataResponse($list->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shopping list
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryList, array{}>
|
||||
*
|
||||
* 200: List returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists/{listId}')]
|
||||
#[NoAdminRequired]
|
||||
public function showList(int $houseId, int $listId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$list = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
return new DataResponse($list->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a shopping list
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param string|null $name New name.
|
||||
* @param string|null $description New description.
|
||||
* @param int|null $sortOrder New sort order.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryList, array{}>
|
||||
*
|
||||
* 200: List updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/lists/{listId}')]
|
||||
#[NoAdminRequired]
|
||||
public function updateList(int $houseId, int $listId, ?string $name = null, ?string $description = null, ?int $sortOrder = null): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $name, $description, $sortOrder): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$existing = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($existing->getHouseId(), $houseId);
|
||||
$patch = [];
|
||||
if ($name !== null) {
|
||||
$patch['name'] = $name;
|
||||
}
|
||||
if ($description !== null) {
|
||||
$patch['description'] = $description;
|
||||
}
|
||||
if ($sortOrder !== null) {
|
||||
$patch['sortOrder'] = $sortOrder;
|
||||
}
|
||||
$list = $this->lists->updateList($listId, $patch);
|
||||
return new DataResponse($list->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a shopping list
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: List deleted
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}')]
|
||||
#[NoAdminRequired]
|
||||
public function deleteList(int $houseId, int $listId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$existing = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($existing->getHouseId(), $houseId);
|
||||
$this->lists->deleteList($listId);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List items in a shopping list
|
||||
*
|
||||
* Auto-reopens recurring items whose next occurrence has arrived.
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param int<1, 1000> $limit Maximum number of items to return.
|
||||
* @param int<0, max> $offset Number of items to skip.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<PantryListItem>, array{}>
|
||||
*
|
||||
* 200: Items returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists/{listId}/items')]
|
||||
#[NoAdminRequired]
|
||||
public function indexItems(int $houseId, int $listId, int $limit = 200, int $offset = 0): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $limit, $offset): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$list = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
$all = $this->lists->listItems($listId);
|
||||
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
|
||||
$items = array_map(fn ($i) => $i->jsonSerialize(), $sliced);
|
||||
return new DataResponse($items);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to a list
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param string $name Item name.
|
||||
* @param string|null $category Optional category label.
|
||||
* @param string|null $quantity Optional quantity string.
|
||||
* @param string|null $rrule Optional RFC 5545 RRULE for recurrence.
|
||||
* @param int|null $sortOrder Optional sort order.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
|
||||
*
|
||||
* 200: Item added
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items')]
|
||||
#[NoAdminRequired]
|
||||
public function addItem(
|
||||
int $houseId,
|
||||
int $listId,
|
||||
string $name,
|
||||
?string $category = null,
|
||||
?string $quantity = null,
|
||||
?string $rrule = null,
|
||||
?int $sortOrder = null,
|
||||
): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $name, $category, $quantity, $rrule, $sortOrder): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$list = $this->lists->getList($listId);
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
$item = $this->lists->addItem($listId, [
|
||||
'name' => $name,
|
||||
'category' => $category,
|
||||
'quantity' => $quantity,
|
||||
'rrule' => $rrule,
|
||||
'sortOrder' => $sortOrder ?? 0,
|
||||
]);
|
||||
return new DataResponse($item->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param int $itemId Item id.
|
||||
* @param string|null $name New name.
|
||||
* @param string|null $category New category (empty string clears).
|
||||
* @param string|null $quantity New quantity (empty string clears).
|
||||
* @param string|null $rrule New RRULE (empty string clears).
|
||||
* @param int|null $sortOrder New sort order.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
|
||||
*
|
||||
* 200: Item updated
|
||||
*/
|
||||
#[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}')]
|
||||
#[NoAdminRequired]
|
||||
public function updateItem(
|
||||
int $houseId,
|
||||
int $listId,
|
||||
int $itemId,
|
||||
?string $name = null,
|
||||
?string $category = null,
|
||||
?string $quantity = null,
|
||||
?string $rrule = null,
|
||||
?int $sortOrder = null,
|
||||
): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $category, $quantity, $rrule, $sortOrder): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$item = $this->lists->getItem($itemId);
|
||||
$list = $this->lists->getList($item->getListId());
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
if ($item->getListId() !== $listId) {
|
||||
throw new NotFoundException('Item does not belong to this list');
|
||||
}
|
||||
$patch = [];
|
||||
if ($name !== null) {
|
||||
$patch['name'] = $name;
|
||||
}
|
||||
if ($category !== null) {
|
||||
$patch['category'] = $category;
|
||||
}
|
||||
if ($quantity !== null) {
|
||||
$patch['quantity'] = $quantity;
|
||||
}
|
||||
if ($rrule !== null) {
|
||||
$patch['rrule'] = $rrule;
|
||||
}
|
||||
if ($sortOrder !== null) {
|
||||
$patch['sortOrder'] = $sortOrder;
|
||||
}
|
||||
$updated = $this->lists->updateItem($itemId, $patch);
|
||||
return new DataResponse($updated->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an item's bought status
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param int $itemId Item id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
|
||||
*
|
||||
* 200: Item toggled
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/toggle')]
|
||||
#[NoAdminRequired]
|
||||
public function toggleItem(int $houseId, int $listId, int $itemId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
|
||||
$uid = $this->requireUid();
|
||||
$this->auth->requireMember($houseId, $uid);
|
||||
$item = $this->lists->getItem($itemId);
|
||||
$list = $this->lists->getList($item->getListId());
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
if ($item->getListId() !== $listId) {
|
||||
throw new NotFoundException('Item does not belong to this list');
|
||||
}
|
||||
$toggled = $this->lists->toggleItem($itemId, $uid);
|
||||
return new DataResponse($toggled->jsonSerialize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an item
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
* @param int $listId List id.
|
||||
* @param int $itemId Item id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
|
||||
*
|
||||
* 200: Item deleted
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}')]
|
||||
#[NoAdminRequired]
|
||||
public function deleteItem(int $houseId, int $listId, int $itemId): DataResponse {
|
||||
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
|
||||
$this->auth->requireMember($houseId, $this->requireUid());
|
||||
$item = $this->lists->getItem($itemId);
|
||||
$list = $this->lists->getList($item->getListId());
|
||||
$this->assertListInHouse($list->getHouseId(), $houseId);
|
||||
if ($item->getListId() !== $listId) {
|
||||
throw new NotFoundException('Item does not belong to this list');
|
||||
}
|
||||
$this->lists->deleteItem($itemId);
|
||||
return new DataResponse(['success' => true]);
|
||||
});
|
||||
}
|
||||
|
||||
private function requireUid(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
|
||||
private function assertListInHouse(int $listHouseId, int $routeHouseId): void {
|
||||
if ($listHouseId !== $routeHouseId) {
|
||||
throw new NotFoundException('List does not belong to this house');
|
||||
}
|
||||
}
|
||||
}
|
||||
36
lib/Controller/TranslatesDomainExceptions.php
Normal file
36
lib/Controller/TranslatesDomainExceptions.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Controller;
|
||||
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCP\AppFramework\OCS\OCSBadRequestException;
|
||||
use OCP\AppFramework\OCS\OCSForbiddenException;
|
||||
use OCP\AppFramework\OCS\OCSNotFoundException;
|
||||
|
||||
/**
|
||||
* Wraps controller actions so domain exceptions become proper OCS errors.
|
||||
*/
|
||||
trait TranslatesDomainExceptions {
|
||||
/**
|
||||
* @template T
|
||||
* @param callable():T $fn
|
||||
* @return T
|
||||
*/
|
||||
private function runAction(callable $fn) {
|
||||
try {
|
||||
return $fn();
|
||||
} catch (ForbiddenException $e) {
|
||||
throw new OCSForbiddenException($e->getMessage(), $e);
|
||||
} catch (NotFoundException $e) {
|
||||
throw new OCSNotFoundException($e->getMessage(), $e);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
throw new OCSBadRequestException($e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/Db/House.php
Normal file
46
lib/Db/House.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method string|null getDescription()
|
||||
* @method void setDescription(?string $description)
|
||||
* @method string getOwnerUid()
|
||||
* @method void setOwnerUid(string $ownerUid)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $createdAt)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $updatedAt)
|
||||
*/
|
||||
class House extends Entity implements \JsonSerializable {
|
||||
protected string $name = '';
|
||||
protected ?string $description = null;
|
||||
protected string $ownerUid = '';
|
||||
protected int $createdAt = 0;
|
||||
protected int $updatedAt = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'ownerUid' => $this->ownerUid,
|
||||
'createdAt' => $this->createdAt,
|
||||
'updatedAt' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
54
lib/Db/HouseMapper.php
Normal file
54
lib/Db/HouseMapper.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<House>
|
||||
*/
|
||||
class HouseMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, Application::tableName('houses'), House::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return House[]
|
||||
*/
|
||||
public function findAllForUser(string $uid): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('h.*')
|
||||
->from($this->getTableName(), 'h')
|
||||
->innerJoin(
|
||||
'h',
|
||||
Application::tableName('house_members'),
|
||||
'm',
|
||||
$qb->expr()->eq('m.house_id', 'h.id'),
|
||||
)
|
||||
->where($qb->expr()->eq('m.user_id', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)))
|
||||
->orderBy('h.name', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findById(int $id): House {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
}
|
||||
54
lib/Db/HouseMember.php
Normal file
54
lib/Db/HouseMember.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getHouseId()
|
||||
* @method void setHouseId(int $houseId)
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $userId)
|
||||
* @method string getRole()
|
||||
* @method void setRole(string $role)
|
||||
* @method int getJoinedAt()
|
||||
* @method void setJoinedAt(int $joinedAt)
|
||||
*/
|
||||
class HouseMember extends Entity implements \JsonSerializable {
|
||||
public const ROLE_OWNER = 'owner';
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_MEMBER = 'member';
|
||||
|
||||
protected int $houseId = 0;
|
||||
protected string $userId = '';
|
||||
protected string $role = self::ROLE_MEMBER;
|
||||
protected int $joinedAt = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('houseId', 'integer');
|
||||
$this->addType('joinedAt', 'integer');
|
||||
}
|
||||
|
||||
public function isAtLeastAdmin(): bool {
|
||||
return $this->role === self::ROLE_OWNER || $this->role === self::ROLE_ADMIN;
|
||||
}
|
||||
|
||||
public function isOwner(): bool {
|
||||
return $this->role === self::ROLE_OWNER;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'houseId' => $this->houseId,
|
||||
'userId' => $this->userId,
|
||||
'role' => $this->role,
|
||||
'joinedAt' => $this->joinedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
69
lib/Db/HouseMemberMapper.php
Normal file
69
lib/Db/HouseMemberMapper.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<HouseMember>
|
||||
*/
|
||||
class HouseMemberMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, Application::tableName('house_members'), HouseMember::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HouseMember[]
|
||||
*/
|
||||
public function findByHouse(int $houseId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('joined_at', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function findForUserAndHouse(string $uid, int $houseId): ?HouseMember {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)));
|
||||
|
||||
try {
|
||||
return $this->findEntity($qb);
|
||||
} catch (DoesNotExistException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findById(int $id): HouseMember {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function deleteByHouse(int $houseId): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
52
lib/Db/ShoppingList.php
Normal file
52
lib/Db/ShoppingList.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getHouseId()
|
||||
* @method void setHouseId(int $houseId)
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method string|null getDescription()
|
||||
* @method void setDescription(?string $description)
|
||||
* @method int getSortOrder()
|
||||
* @method void setSortOrder(int $sortOrder)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $createdAt)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $updatedAt)
|
||||
*/
|
||||
class ShoppingList extends Entity implements \JsonSerializable {
|
||||
protected int $houseId = 0;
|
||||
protected string $name = '';
|
||||
protected ?string $description = null;
|
||||
protected int $sortOrder = 0;
|
||||
protected int $createdAt = 0;
|
||||
protected int $updatedAt = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('houseId', 'integer');
|
||||
$this->addType('sortOrder', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'houseId' => $this->houseId,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'sortOrder' => $this->sortOrder,
|
||||
'createdAt' => $this->createdAt,
|
||||
'updatedAt' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
79
lib/Db/ShoppingListItem.php
Normal file
79
lib/Db/ShoppingListItem.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getListId()
|
||||
* @method void setListId(int $listId)
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method string|null getCategory()
|
||||
* @method void setCategory(?string $category)
|
||||
* @method string|null getQuantity()
|
||||
* @method void setQuantity(?string $quantity)
|
||||
* @method bool getBought()
|
||||
* @method void setBought(bool $bought)
|
||||
* @method int|null getBoughtAt()
|
||||
* @method void setBoughtAt(?int $boughtAt)
|
||||
* @method string|null getBoughtBy()
|
||||
* @method void setBoughtBy(?string $boughtBy)
|
||||
* @method string|null getRrule()
|
||||
* @method void setRrule(?string $rrule)
|
||||
* @method int|null getNextDueAt()
|
||||
* @method void setNextDueAt(?int $nextDueAt)
|
||||
* @method int getSortOrder()
|
||||
* @method void setSortOrder(int $sortOrder)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $createdAt)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $updatedAt)
|
||||
*/
|
||||
class ShoppingListItem extends Entity implements \JsonSerializable {
|
||||
protected int $listId = 0;
|
||||
protected string $name = '';
|
||||
protected ?string $category = null;
|
||||
protected ?string $quantity = null;
|
||||
protected bool $bought = false;
|
||||
protected ?int $boughtAt = null;
|
||||
protected ?string $boughtBy = null;
|
||||
protected ?string $rrule = null;
|
||||
protected ?int $nextDueAt = null;
|
||||
protected int $sortOrder = 0;
|
||||
protected int $createdAt = 0;
|
||||
protected int $updatedAt = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('listId', 'integer');
|
||||
$this->addType('bought', 'boolean');
|
||||
$this->addType('boughtAt', 'integer');
|
||||
$this->addType('nextDueAt', 'integer');
|
||||
$this->addType('sortOrder', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'listId' => $this->listId,
|
||||
'name' => $this->name,
|
||||
'category' => $this->category,
|
||||
'quantity' => $this->quantity,
|
||||
'bought' => $this->bought,
|
||||
'boughtAt' => $this->boughtAt,
|
||||
'boughtBy' => $this->boughtBy,
|
||||
'rrule' => $this->rrule,
|
||||
'nextDueAt' => $this->nextDueAt,
|
||||
'sortOrder' => $this->sortOrder,
|
||||
'createdAt' => $this->createdAt,
|
||||
'updatedAt' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
lib/Db/ShoppingListItemMapper.php
Normal file
56
lib/Db/ShoppingListItemMapper.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<ShoppingListItem>
|
||||
*/
|
||||
class ShoppingListItemMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, Application::tableName('list_items'), ShoppingListItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShoppingListItem[]
|
||||
*/
|
||||
public function findByList(int $listId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->addOrderBy('created_at', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findById(int $id): ShoppingListItem {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function deleteByList(int $listId): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
56
lib/Db/ShoppingListMapper.php
Normal file
56
lib/Db/ShoppingListMapper.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Db;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<ShoppingList>
|
||||
*/
|
||||
class ShoppingListMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, Application::tableName('lists'), ShoppingList::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShoppingList[]
|
||||
*/
|
||||
public function findByHouse(int $houseId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->addOrderBy('name', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findById(int $id): ShoppingList {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function deleteByHouse(int $houseId): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
11
lib/Exception/ForbiddenException.php
Normal file
11
lib/Exception/ForbiddenException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Exception;
|
||||
|
||||
class ForbiddenException extends \RuntimeException {
|
||||
}
|
||||
11
lib/Exception/NotFoundException.php
Normal file
11
lib/Exception/NotFoundException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Exception;
|
||||
|
||||
class NotFoundException extends \RuntimeException {
|
||||
}
|
||||
189
lib/Migration/Version1Date20260405000000.php
Normal file
189
lib/Migration/Version1Date20260405000000.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Initial schema for Pantry: houses, members, shopping lists, list items.
|
||||
*/
|
||||
class Version1Date20260405000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param Closure():ISchemaWrapper $schemaClosure
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
// ---- pantry_houses ----
|
||||
$housesTable = Application::tableName('houses');
|
||||
if (!$schema->hasTable($housesTable)) {
|
||||
$table = $schema->createTable($housesTable);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('name', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('description', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('owner_uid', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('created_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('updated_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['owner_uid'], 'pantry_houses_owner_idx');
|
||||
}
|
||||
|
||||
// ---- pantry_house_members ----
|
||||
$membersTable = Application::tableName('house_members');
|
||||
if (!$schema->hasTable($membersTable)) {
|
||||
$table = $schema->createTable($membersTable);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('house_id', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('user_id', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('role', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
]);
|
||||
$table->addColumn('joined_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['house_id', 'user_id'], 'pantry_members_house_user_uq');
|
||||
$table->addIndex(['user_id'], 'pantry_members_user_idx');
|
||||
}
|
||||
|
||||
// ---- pantry_lists ----
|
||||
$listsTable = Application::tableName('lists');
|
||||
if (!$schema->hasTable($listsTable)) {
|
||||
$table = $schema->createTable($listsTable);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('house_id', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('name', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('description', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('sort_order', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
$table->addColumn('created_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('updated_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['house_id'], 'pantry_lists_house_idx');
|
||||
}
|
||||
|
||||
// ---- pantry_list_items ----
|
||||
$itemsTable = Application::tableName('list_items');
|
||||
if (!$schema->hasTable($itemsTable)) {
|
||||
$table = $schema->createTable($itemsTable);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('list_id', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('name', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('category', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 128,
|
||||
]);
|
||||
$table->addColumn('quantity', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('bought', Types::BOOLEAN, [
|
||||
'notnull' => true,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('bought_at', Types::BIGINT, [
|
||||
'notnull' => false,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('bought_by', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('rrule', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 512,
|
||||
]);
|
||||
$table->addColumn('next_due_at', Types::BIGINT, [
|
||||
'notnull' => false,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('sort_order', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
$table->addColumn('created_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->addColumn('updated_at', Types::BIGINT, [
|
||||
'notnull' => true,
|
||||
'length' => 20,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['list_id'], 'pantry_items_list_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
61
lib/ResponseDefinitions.php
Normal file
61
lib/ResponseDefinitions.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry;
|
||||
|
||||
/**
|
||||
* @psalm-type PantryHouse = array{
|
||||
* id: int,
|
||||
* name: string,
|
||||
* description: string|null,
|
||||
* ownerUid: string,
|
||||
* createdAt: int,
|
||||
* updatedAt: int,
|
||||
* role: string,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryMember = array{
|
||||
* id: int,
|
||||
* houseId: int,
|
||||
* userId: string,
|
||||
* displayName: string,
|
||||
* role: string,
|
||||
* joinedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryList = array{
|
||||
* id: int,
|
||||
* houseId: int,
|
||||
* name: string,
|
||||
* description: string|null,
|
||||
* sortOrder: int,
|
||||
* createdAt: int,
|
||||
* updatedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantryListItem = array{
|
||||
* id: int,
|
||||
* listId: int,
|
||||
* name: string,
|
||||
* category: string|null,
|
||||
* quantity: string|null,
|
||||
* bought: bool,
|
||||
* boughtAt: int|null,
|
||||
* boughtBy: string|null,
|
||||
* rrule: string|null,
|
||||
* nextDueAt: int|null,
|
||||
* sortOrder: int,
|
||||
* createdAt: int,
|
||||
* updatedAt: int,
|
||||
* }
|
||||
*
|
||||
* @psalm-type PantrySuccess = array{success: true}
|
||||
*
|
||||
* @psalm-type PantryLastHouse = array{houseId: int|null}
|
||||
*/
|
||||
class ResponseDefinitions {
|
||||
}
|
||||
43
lib/Service/HouseAuthService.php
Normal file
43
lib/Service/HouseAuthService.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use OCA\Pantry\Db\HouseMember;
|
||||
use OCA\Pantry\Db\HouseMemberMapper;
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
|
||||
class HouseAuthService {
|
||||
public function __construct(
|
||||
private HouseMemberMapper $memberMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function requireMember(int $houseId, string $uid): HouseMember {
|
||||
$member = $this->memberMapper->findForUserAndHouse($uid, $houseId);
|
||||
if ($member === null) {
|
||||
throw new ForbiddenException('Not a member of this house');
|
||||
}
|
||||
return $member;
|
||||
}
|
||||
|
||||
public function requireAdmin(int $houseId, string $uid): HouseMember {
|
||||
$member = $this->requireMember($houseId, $uid);
|
||||
if (!$member->isAtLeastAdmin()) {
|
||||
throw new ForbiddenException('Admin privileges required');
|
||||
}
|
||||
return $member;
|
||||
}
|
||||
|
||||
public function requireOwner(int $houseId, string $uid): HouseMember {
|
||||
$member = $this->requireMember($houseId, $uid);
|
||||
if (!$member->isOwner()) {
|
||||
throw new ForbiddenException('Owner privileges required');
|
||||
}
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
198
lib/Service/HouseService.php
Normal file
198
lib/Service/HouseService.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use OCA\Pantry\Db\House;
|
||||
use OCA\Pantry\Db\HouseMapper;
|
||||
use OCA\Pantry\Db\HouseMember;
|
||||
use OCA\Pantry\Db\HouseMemberMapper;
|
||||
use OCA\Pantry\Db\ShoppingListItemMapper;
|
||||
use OCA\Pantry\Db\ShoppingListMapper;
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IUserManager;
|
||||
|
||||
class HouseService {
|
||||
public function __construct(
|
||||
private HouseMapper $houseMapper,
|
||||
private HouseMemberMapper $memberMapper,
|
||||
private ShoppingListMapper $listMapper,
|
||||
private ShoppingListItemMapper $itemMapper,
|
||||
private IDBConnection $db,
|
||||
private IUserManager $userManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return House[]
|
||||
*/
|
||||
public function listForUser(string $uid): array {
|
||||
return $this->houseMapper->findAllForUser($uid);
|
||||
}
|
||||
|
||||
public function get(int $houseId): House {
|
||||
try {
|
||||
return $this->houseMapper->findById($houseId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new NotFoundException('House not found');
|
||||
}
|
||||
}
|
||||
|
||||
public function create(string $uid, string $name, ?string $description): House {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('House name cannot be empty');
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$now = time();
|
||||
|
||||
$house = new House();
|
||||
$house->setName($name);
|
||||
$house->setDescription($description !== null && $description !== '' ? $description : null);
|
||||
$house->setOwnerUid($uid);
|
||||
$house->setCreatedAt($now);
|
||||
$house->setUpdatedAt($now);
|
||||
/** @var House $house */
|
||||
$house = $this->houseMapper->insert($house);
|
||||
|
||||
$member = new HouseMember();
|
||||
$member->setHouseId((int)$house->getId());
|
||||
$member->setUserId($uid);
|
||||
$member->setRole(HouseMember::ROLE_OWNER);
|
||||
$member->setJoinedAt($now);
|
||||
$this->memberMapper->insert($member);
|
||||
|
||||
$this->db->commit();
|
||||
return $house;
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function update(int $houseId, array $patch): House {
|
||||
$house = $this->get($houseId);
|
||||
if (isset($patch['name'])) {
|
||||
$name = trim((string)$patch['name']);
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('House name cannot be empty');
|
||||
}
|
||||
$house->setName($name);
|
||||
}
|
||||
if (array_key_exists('description', $patch)) {
|
||||
$desc = $patch['description'];
|
||||
$house->setDescription(is_string($desc) && $desc !== '' ? $desc : null);
|
||||
}
|
||||
$house->setUpdatedAt(time());
|
||||
$this->houseMapper->update($house);
|
||||
return $house;
|
||||
}
|
||||
|
||||
public function delete(int $houseId): void {
|
||||
$house = $this->get($houseId);
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
// Delete all items under all lists of this house
|
||||
foreach ($this->listMapper->findByHouse($houseId) as $list) {
|
||||
$this->itemMapper->deleteByList((int)$list->getId());
|
||||
}
|
||||
$this->listMapper->deleteByHouse($houseId);
|
||||
$this->memberMapper->deleteByHouse($houseId);
|
||||
$this->houseMapper->delete($house);
|
||||
$this->db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HouseMember[]
|
||||
*/
|
||||
public function listMembers(int $houseId): array {
|
||||
return $this->memberMapper->findByHouse($houseId);
|
||||
}
|
||||
|
||||
public function addMember(int $houseId, string $userId, string $role): HouseMember {
|
||||
$role = $this->normalizeAssignableRole($role);
|
||||
|
||||
if ($this->userManager->get($userId) === null) {
|
||||
throw new NotFoundException('User not found: ' . $userId);
|
||||
}
|
||||
|
||||
if ($this->memberMapper->findForUserAndHouse($userId, $houseId) !== null) {
|
||||
throw new \InvalidArgumentException('User is already a member of this house');
|
||||
}
|
||||
|
||||
$member = new HouseMember();
|
||||
$member->setHouseId($houseId);
|
||||
$member->setUserId($userId);
|
||||
$member->setRole($role);
|
||||
$member->setJoinedAt(time());
|
||||
/** @var HouseMember $saved */
|
||||
$saved = $this->memberMapper->insert($member);
|
||||
return $saved;
|
||||
}
|
||||
|
||||
public function updateMemberRole(int $houseId, int $memberId, string $role): HouseMember {
|
||||
$role = $this->normalizeAssignableRole($role);
|
||||
$member = $this->getMember($houseId, $memberId);
|
||||
if ($member->isOwner()) {
|
||||
throw new ForbiddenException('Cannot change the role of the house owner');
|
||||
}
|
||||
$member->setRole($role);
|
||||
$this->memberMapper->update($member);
|
||||
return $member;
|
||||
}
|
||||
|
||||
public function removeMember(int $houseId, int $memberId): void {
|
||||
$member = $this->getMember($houseId, $memberId);
|
||||
if ($member->isOwner()) {
|
||||
throw new ForbiddenException('Cannot remove the house owner');
|
||||
}
|
||||
$this->memberMapper->delete($member);
|
||||
}
|
||||
|
||||
public function leaveHouse(int $houseId, string $uid): void {
|
||||
$member = $this->memberMapper->findForUserAndHouse($uid, $houseId);
|
||||
if ($member === null) {
|
||||
throw new NotFoundException('Not a member of this house');
|
||||
}
|
||||
if ($member->isOwner()) {
|
||||
throw new ForbiddenException('Owner cannot leave the house. Transfer ownership or delete the house.');
|
||||
}
|
||||
$this->memberMapper->delete($member);
|
||||
}
|
||||
|
||||
private function getMember(int $houseId, int $memberId): HouseMember {
|
||||
try {
|
||||
$member = $this->memberMapper->findById($memberId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new NotFoundException('Member not found');
|
||||
}
|
||||
if ($member->getHouseId() !== $houseId) {
|
||||
throw new NotFoundException('Member does not belong to this house');
|
||||
}
|
||||
return $member;
|
||||
}
|
||||
|
||||
private function normalizeAssignableRole(string $role): string {
|
||||
if ($role === HouseMember::ROLE_ADMIN) {
|
||||
return HouseMember::ROLE_ADMIN;
|
||||
}
|
||||
if ($role === HouseMember::ROLE_MEMBER) {
|
||||
return HouseMember::ROLE_MEMBER;
|
||||
}
|
||||
throw new \InvalidArgumentException('Role must be "admin" or "member"');
|
||||
}
|
||||
}
|
||||
36
lib/Service/PrefsService.php
Normal file
36
lib/Service/PrefsService.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCP\IConfig;
|
||||
|
||||
class PrefsService {
|
||||
private const KEY_LAST_HOUSE = 'last_house_id';
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getLastHouseId(string $uid): ?int {
|
||||
$value = $this->config->getUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE, '');
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
public function setLastHouseId(string $uid, ?int $houseId): void {
|
||||
if ($houseId === null) {
|
||||
$this->config->deleteUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE);
|
||||
return;
|
||||
}
|
||||
$this->config->setUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE, (string)$houseId);
|
||||
}
|
||||
}
|
||||
63
lib/Service/RecurrenceService.php
Normal file
63
lib/Service/RecurrenceService.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use Sabre\VObject\InvalidDataException;
|
||||
use Sabre\VObject\Recur\RRuleIterator;
|
||||
|
||||
/**
|
||||
* Thin wrapper around sabre/vobject's RRuleIterator.
|
||||
*/
|
||||
class RecurrenceService {
|
||||
/**
|
||||
* Validate an RRULE string (RFC 5545). Accepts either a bare rule ("FREQ=WEEKLY;INTERVAL=1")
|
||||
* or a full "RRULE:..." line.
|
||||
*
|
||||
* @throws \InvalidArgumentException if the rule is malformed.
|
||||
*/
|
||||
public function validate(string $rrule): void {
|
||||
try {
|
||||
new RRuleIterator($this->normalize($rrule), new \DateTimeImmutable('2000-01-01T00:00:00Z'));
|
||||
} catch (InvalidDataException $e) {
|
||||
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next occurrence strictly after $from.
|
||||
*
|
||||
* The iterator's semantics are: the first item equals DTSTART; subsequent items are
|
||||
* successive occurrences per the rule. We seed with DTSTART = $from and advance once.
|
||||
*/
|
||||
public function computeNextOccurrence(string $rrule, \DateTimeImmutable $from): ?\DateTimeImmutable {
|
||||
try {
|
||||
$iter = new RRuleIterator($this->normalize($rrule), $from);
|
||||
} catch (InvalidDataException $e) {
|
||||
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
// First call yields DTSTART itself. Advance to the next one.
|
||||
$iter->next();
|
||||
if (!$iter->valid()) {
|
||||
return null;
|
||||
}
|
||||
$current = $iter->current();
|
||||
if (!$current instanceof \DateTimeInterface) {
|
||||
return null;
|
||||
}
|
||||
return \DateTimeImmutable::createFromInterface($current);
|
||||
}
|
||||
|
||||
private function normalize(string $rrule): string {
|
||||
$trim = trim($rrule);
|
||||
if (stripos($trim, 'RRULE:') === 0) {
|
||||
return substr($trim, 6);
|
||||
}
|
||||
return $trim;
|
||||
}
|
||||
}
|
||||
237
lib/Service/ShoppingListService.php
Normal file
237
lib/Service/ShoppingListService.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Service;
|
||||
|
||||
use OCA\Pantry\Db\ShoppingList;
|
||||
use OCA\Pantry\Db\ShoppingListItem;
|
||||
use OCA\Pantry\Db\ShoppingListItemMapper;
|
||||
use OCA\Pantry\Db\ShoppingListMapper;
|
||||
use OCA\Pantry\Exception\NotFoundException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
|
||||
class ShoppingListService {
|
||||
public function __construct(
|
||||
private ShoppingListMapper $listMapper,
|
||||
private ShoppingListItemMapper $itemMapper,
|
||||
private RecurrenceService $recurrence,
|
||||
) {
|
||||
}
|
||||
|
||||
// ----- Lists -----
|
||||
|
||||
/**
|
||||
* @return ShoppingList[]
|
||||
*/
|
||||
public function listForHouse(int $houseId): array {
|
||||
return $this->listMapper->findByHouse($houseId);
|
||||
}
|
||||
|
||||
public function getList(int $listId): ShoppingList {
|
||||
try {
|
||||
return $this->listMapper->findById($listId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
}
|
||||
|
||||
public function createList(int $houseId, string $name, ?string $description): ShoppingList {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('List name cannot be empty');
|
||||
}
|
||||
$now = time();
|
||||
$list = new ShoppingList();
|
||||
$list->setHouseId($houseId);
|
||||
$list->setName($name);
|
||||
$list->setDescription($description !== null && $description !== '' ? $description : null);
|
||||
$list->setSortOrder(0);
|
||||
$list->setCreatedAt($now);
|
||||
$list->setUpdatedAt($now);
|
||||
/** @var ShoppingList $saved */
|
||||
$saved = $this->listMapper->insert($list);
|
||||
return $saved;
|
||||
}
|
||||
|
||||
public function updateList(int $listId, array $patch): ShoppingList {
|
||||
$list = $this->getList($listId);
|
||||
if (isset($patch['name'])) {
|
||||
$name = trim((string)$patch['name']);
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('List name cannot be empty');
|
||||
}
|
||||
$list->setName($name);
|
||||
}
|
||||
if (array_key_exists('description', $patch)) {
|
||||
$desc = $patch['description'];
|
||||
$list->setDescription(is_string($desc) && $desc !== '' ? $desc : null);
|
||||
}
|
||||
if (isset($patch['sortOrder'])) {
|
||||
$list->setSortOrder((int)$patch['sortOrder']);
|
||||
}
|
||||
$list->setUpdatedAt(time());
|
||||
$this->listMapper->update($list);
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function deleteList(int $listId): void {
|
||||
$list = $this->getList($listId);
|
||||
$this->itemMapper->deleteByList((int)$list->getId());
|
||||
$this->listMapper->delete($list);
|
||||
}
|
||||
|
||||
// ----- Items -----
|
||||
|
||||
/**
|
||||
* List items for a list, auto-unchecking any recurring items whose next_due_at has passed.
|
||||
*
|
||||
* @return ShoppingListItem[]
|
||||
*/
|
||||
public function listItems(int $listId, ?int $now = null): array {
|
||||
$now ??= time();
|
||||
$items = $this->itemMapper->findByList($listId);
|
||||
$refreshed = [];
|
||||
foreach ($items as $item) {
|
||||
if ($item->getBought() && $item->getNextDueAt() !== null && $item->getNextDueAt() <= $now) {
|
||||
$item->setBought(false);
|
||||
$item->setBoughtAt(null);
|
||||
$item->setBoughtBy(null);
|
||||
$item->setNextDueAt(null);
|
||||
$item->setUpdatedAt($now);
|
||||
$this->itemMapper->update($item);
|
||||
}
|
||||
$refreshed[] = $item;
|
||||
}
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function getItem(int $itemId): ShoppingListItem {
|
||||
try {
|
||||
return $this->itemMapper->findById($itemId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new NotFoundException('Item not found');
|
||||
}
|
||||
}
|
||||
|
||||
public function addItem(int $listId, array $data): ShoppingListItem {
|
||||
// Ensure the list exists.
|
||||
$this->getList($listId);
|
||||
|
||||
$name = trim((string)($data['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('Item name cannot be empty');
|
||||
}
|
||||
|
||||
$rrule = isset($data['rrule']) && is_string($data['rrule']) && trim($data['rrule']) !== ''
|
||||
? trim($data['rrule'])
|
||||
: null;
|
||||
if ($rrule !== null) {
|
||||
$this->recurrence->validate($rrule);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$item = new ShoppingListItem();
|
||||
$item->setListId($listId);
|
||||
$item->setName($name);
|
||||
$item->setCategory($this->strOrNull($data['category'] ?? null));
|
||||
$item->setQuantity($this->strOrNull($data['quantity'] ?? null));
|
||||
$item->setBought(false);
|
||||
$item->setBoughtAt(null);
|
||||
$item->setBoughtBy(null);
|
||||
$item->setRrule($rrule);
|
||||
$item->setNextDueAt(null);
|
||||
$item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0);
|
||||
$item->setCreatedAt($now);
|
||||
$item->setUpdatedAt($now);
|
||||
/** @var ShoppingListItem $saved */
|
||||
$saved = $this->itemMapper->insert($item);
|
||||
return $saved;
|
||||
}
|
||||
|
||||
public function updateItem(int $itemId, array $patch): ShoppingListItem {
|
||||
$item = $this->getItem($itemId);
|
||||
|
||||
if (isset($patch['name'])) {
|
||||
$name = trim((string)$patch['name']);
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('Item name cannot be empty');
|
||||
}
|
||||
$item->setName($name);
|
||||
}
|
||||
if (array_key_exists('category', $patch)) {
|
||||
$item->setCategory($this->strOrNull($patch['category']));
|
||||
}
|
||||
if (array_key_exists('quantity', $patch)) {
|
||||
$item->setQuantity($this->strOrNull($patch['quantity']));
|
||||
}
|
||||
if (array_key_exists('rrule', $patch)) {
|
||||
$rrule = $patch['rrule'];
|
||||
if ($rrule === null || (is_string($rrule) && trim($rrule) === '')) {
|
||||
$item->setRrule(null);
|
||||
// Clearing recurrence also clears any scheduled re-open.
|
||||
if ($item->getBought()) {
|
||||
$item->setNextDueAt(null);
|
||||
}
|
||||
} else {
|
||||
$rrule = trim((string)$rrule);
|
||||
$this->recurrence->validate($rrule);
|
||||
$item->setRrule($rrule);
|
||||
// If already bought, recompute next due from now.
|
||||
if ($item->getBought()) {
|
||||
$next = $this->recurrence->computeNextOccurrence($rrule, new \DateTimeImmutable('@' . time()));
|
||||
$item->setNextDueAt($next?->getTimestamp());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($patch['sortOrder'])) {
|
||||
$item->setSortOrder((int)$patch['sortOrder']);
|
||||
}
|
||||
|
||||
$item->setUpdatedAt(time());
|
||||
$this->itemMapper->update($item);
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function toggleItem(int $itemId, string $uid, ?int $now = null): ShoppingListItem {
|
||||
$item = $this->getItem($itemId);
|
||||
$now ??= time();
|
||||
|
||||
if (!$item->getBought()) {
|
||||
$item->setBought(true);
|
||||
$item->setBoughtAt($now);
|
||||
$item->setBoughtBy($uid);
|
||||
if ($item->getRrule() !== null) {
|
||||
$next = $this->recurrence->computeNextOccurrence(
|
||||
$item->getRrule(),
|
||||
(new \DateTimeImmutable())->setTimestamp($now),
|
||||
);
|
||||
$item->setNextDueAt($next?->getTimestamp());
|
||||
}
|
||||
} else {
|
||||
$item->setBought(false);
|
||||
$item->setBoughtAt(null);
|
||||
$item->setBoughtBy(null);
|
||||
$item->setNextDueAt(null);
|
||||
}
|
||||
$item->setUpdatedAt($now);
|
||||
$this->itemMapper->update($item);
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function deleteItem(int $itemId): void {
|
||||
$item = $this->getItem($itemId);
|
||||
$this->itemMapper->delete($item);
|
||||
}
|
||||
|
||||
private function strOrNull(mixed $v): ?string {
|
||||
if (!is_string($v)) {
|
||||
return null;
|
||||
}
|
||||
$t = trim($v);
|
||||
return $t === '' ? null : $t;
|
||||
}
|
||||
}
|
||||
2707
openapi.json
2707
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
"node": "^22.19.0",
|
||||
"pnpm": "^10.17.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@nextcloud/vue": "^9.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"rrule": "^2.8.1",
|
||||
"vue": "^3.5.32",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
@@ -38,7 +40,7 @@
|
||||
"@nextcloud/stylelint-config": "^3.2.1",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.2.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"husky": "^9.1.7",
|
||||
@@ -47,9 +49,9 @@
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sass": "^1.99.0",
|
||||
"sass-embedded": "^1.99.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vitest": "^4.1.2",
|
||||
"vue-router": "^5.0.4",
|
||||
|
||||
797
pnpm-lock.yaml
generated
797
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
118
src/App.vue
118
src/App.vue
@@ -1,60 +1,8 @@
|
||||
<template>
|
||||
<NcContent app-name="pantry">
|
||||
<!-- Left sidebar -->
|
||||
<NcAppNavigation>
|
||||
<template #search>
|
||||
<NcAppNavigationSearch
|
||||
v-model="searchValue"
|
||||
:label="strings.searchLabel"
|
||||
:placeholder="strings.searchPlaceholder"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navHome"
|
||||
:to="{ path: '/' }"
|
||||
:active="$route.path === '/' || $route.path === ''"
|
||||
>
|
||||
<template #icon>
|
||||
<HomeIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navExamples"
|
||||
:to="{ path: basePath + '/examples' }"
|
||||
:active="isPrefixRoute(basePath + '/examples')"
|
||||
>
|
||||
<template #icon>
|
||||
<PuzzleIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAbout"
|
||||
:to="{ path: basePath + '/about' }"
|
||||
:active="isPrefixRoute(basePath + '/about')"
|
||||
>
|
||||
<template #icon>
|
||||
<InfoIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Optional footer controls -->
|
||||
</template>
|
||||
</NcAppNavigation>
|
||||
|
||||
<!-- Main content -->
|
||||
<NcAppContent id="hello-main">
|
||||
<header class="page-header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted" v-html="strings.subtitle"></p>
|
||||
</header>
|
||||
|
||||
<div id="hello-router">
|
||||
<router-view name="navigation" />
|
||||
<NcAppContent id="pantry-main">
|
||||
<div id="pantry-router">
|
||||
<div v-if="isRouterLoading" class="router-loading">
|
||||
<NcLoadingIcon :size="48" />
|
||||
</div>
|
||||
@@ -65,61 +13,28 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import HomeIcon from '@icons/Home.vue'
|
||||
import PuzzleIcon from '@icons/Puzzle.vue'
|
||||
import InfoIcon from '@icons/Information.vue'
|
||||
|
||||
export default {
|
||||
name: 'AppUserWrapper',
|
||||
name: 'PantryApp',
|
||||
components: {
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcAppNavigationSearch,
|
||||
NcLoadingIcon,
|
||||
HomeIcon,
|
||||
PuzzleIcon,
|
||||
InfoIcon,
|
||||
},
|
||||
// Tell NcContent we *do* have a sidebar so it arranges layout properly
|
||||
provide() {
|
||||
return { 'NcContent:setHasAppNavigation': () => true }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchValue: '',
|
||||
isRouterLoading: false,
|
||||
// Mount path for this app section; adjust to your mount.
|
||||
basePath: '/apps/pantry',
|
||||
strings: {
|
||||
title: t('pantry', 'Hello World — App'),
|
||||
subtitle: t(
|
||||
'pantry',
|
||||
'Use the sidebar to navigate between views. Backend calls use {cStart}axios{cEnd} and OCS responses.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
searchLabel: t('pantry', 'Search'),
|
||||
searchPlaceholder: t('pantry', 'Type to filter…'),
|
||||
navHome: t('pantry', 'Home'),
|
||||
navExamples: t('pantry', 'Examples'),
|
||||
navAbout: t('pantry', 'About'),
|
||||
},
|
||||
_removeBeforeEach: null,
|
||||
_removeAfterEach: null,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// Show a loading overlay while routes are changing
|
||||
this._removeBeforeEach = this.$router.beforeEach((to, from, next) => {
|
||||
this.isRouterLoading = true
|
||||
next()
|
||||
@@ -129,42 +44,21 @@ export default {
|
||||
})
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up router guards
|
||||
if (typeof this._removeBeforeEach === 'function') this._removeBeforeEach()
|
||||
if (typeof this._removeAfterEach === 'function') this._removeAfterEach()
|
||||
},
|
||||
methods: {
|
||||
isPrefixRoute(prefix) {
|
||||
return this.$route.path.startsWith(prefix)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#hello-main {
|
||||
#pantry-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
/* fills viewport next to sidebar */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
#hello-router {
|
||||
#pantry-router {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
|
||||
442
src/Settings.vue
442
src/Settings.vue
@@ -1,360 +1,33 @@
|
||||
<template>
|
||||
<div id="pantry-content" class="section">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
|
||||
<!-- Information / quick start -->
|
||||
<NcAppSettingsSection :name="strings.infoTitle">
|
||||
<p v-html="strings.infoIntro"></p>
|
||||
|
||||
<ol class="ol">
|
||||
<li v-for="li in strings.gettingStartedList" :key="li" v-html="li"></li>
|
||||
</ol>
|
||||
|
||||
<NcNoteCard type="info">
|
||||
<p v-html="strings.tipsNote"></p>
|
||||
</NcNoteCard>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Live examples -->
|
||||
<NcAppSettingsSection :name="strings.examplesHeader">
|
||||
<section class="example-grid">
|
||||
<!-- v-model example -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ strings.nameInputHeader }}</h3>
|
||||
<NcTextField
|
||||
v-model="name"
|
||||
:label="strings.nameInputLabel"
|
||||
:placeholder="strings.nameInputPlaceholder"
|
||||
/>
|
||||
<p class="mt-8">
|
||||
{{ strings.livePreview }} <b>{{ greeting }}</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Select + computed example -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ strings.themeHeader }}</h3>
|
||||
<NcSelect
|
||||
v-model="themeLabel"
|
||||
:options="themeOptionsLabels"
|
||||
:input-label="strings.themeLabel"
|
||||
/>
|
||||
<p class="mt-8">
|
||||
{{ strings.themePreview }}
|
||||
<code>{{ activeTheme.value }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Counter + events example -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ strings.counterHeader }}</h3>
|
||||
<div class="row gap-8">
|
||||
<NcButton @click="decrement">{{ strings.minus }}</NcButton>
|
||||
<span class="counter">{{ counter }}</span>
|
||||
<NcButton @click="increment">{{ strings.plus }}</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Table + add/remove items example -->
|
||||
<NcAppSettingsSection :name="strings.itemsHeader">
|
||||
<div class="row align-start gap-16">
|
||||
<div style="max-width: 320px">
|
||||
<NcTextField
|
||||
v-model="newItem"
|
||||
:label="strings.newItemLabel"
|
||||
:placeholder="strings.newItemPlaceholder"
|
||||
trailing-button-icon="plus"
|
||||
:show-trailing-button="newItem.trim() !== ''"
|
||||
@trailing-button-click="addItem"
|
||||
/>
|
||||
</div>
|
||||
<NcButton @click="addItem" :disabled="newItem.trim() === ''">{{ strings.add }}</NcButton>
|
||||
<NcButton type="secondary" @click="clearItems" :disabled="items.length === 0">
|
||||
{{ strings.clear }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<table class="mt-16">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60%">{{ strings.tableItem }}</th>
|
||||
<th style="width: 40%">{{ strings.tableActions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in items" :key="item.id">
|
||||
<td>
|
||||
<input class="inline-input" :aria-label="strings.editItemAria" v-model="item.label" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="row gap-8">
|
||||
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
|
||||
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="2" class="muted">{{ strings.noItems }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Backend calls example -->
|
||||
<NcAppSettingsSection :name="strings.backendHeader">
|
||||
<form @submit.prevent @submit="save">
|
||||
<div class="row gap-16 align-center">
|
||||
<NcButton @click="fetchHello" :disabled="loading">{{ strings.fetchHello }}</NcButton>
|
||||
<NcButton :disabled="loading" @click="submit">{{ strings.save }}</NcButton>
|
||||
|
||||
<span>
|
||||
<span v-if="loading">{{ strings.loading }}</span>
|
||||
<span v-else-if="lastHelloAt">
|
||||
{{ strings.lastHelloAt }}
|
||||
<NcDateTime :timestamp="lastHelloAt.valueOf()" />
|
||||
</span>
|
||||
<span v-else class="muted">{{ strings.never }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<NcNoteCard v-if="serverMessage" type="success" class="mt-12">
|
||||
<p>
|
||||
{{ strings.serverSaid }} <code>{{ serverMessage }}</code>
|
||||
</p>
|
||||
</NcNoteCard>
|
||||
<NcAppSettingsSection :name="strings.aboutHeader">
|
||||
<p>{{ strings.aboutBody }}</p>
|
||||
</NcAppSettingsSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'HelloWorld',
|
||||
name: 'PantrySettings',
|
||||
components: {
|
||||
NcAppSettingsSection,
|
||||
NcButton,
|
||||
NcDateTime,
|
||||
NcNoteCard,
|
||||
NcSelect,
|
||||
NcTextField,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// UI state
|
||||
loading: false,
|
||||
|
||||
// Example: simple input
|
||||
name: '',
|
||||
|
||||
// Example: select with label <-> value mapping (like your intervals)
|
||||
themeLabel: null,
|
||||
themeOptions: [
|
||||
{ label: t('pantry', 'Light'), value: 'light' },
|
||||
{ label: t('pantry', 'Dark'), value: 'dark' },
|
||||
{
|
||||
label: n('pantry', 'System (1 option)', 'System (%n options)', 2),
|
||||
value: 'system',
|
||||
},
|
||||
],
|
||||
|
||||
// Example: small counter
|
||||
counter: 0,
|
||||
|
||||
// Example: simple items table
|
||||
items: [],
|
||||
newItem: '',
|
||||
|
||||
// Example: tracking server interactions
|
||||
lastHelloAt: null,
|
||||
serverMessage: '',
|
||||
|
||||
// All user-visible strings go here
|
||||
strings: {
|
||||
// Titles / headers
|
||||
title: t('pantry', 'Hello World — App Template'),
|
||||
infoTitle: t('pantry', 'Information'),
|
||||
examplesHeader: t('pantry', 'Quick Examples'),
|
||||
itemsHeader: t('pantry', 'Editable List'),
|
||||
backendHeader: t('pantry', 'Backend Calls'),
|
||||
|
||||
// Info
|
||||
infoIntro: t(
|
||||
title: t('pantry', 'Pantry'),
|
||||
aboutHeader: t('pantry', 'About:'),
|
||||
aboutBody: t(
|
||||
'pantry',
|
||||
'This view shows {bStart}small, focused examples{bEnd} for inputs, lists, selections, and backend calls.',
|
||||
{ bStart: '<b>', bEnd: '</b>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
'Pantry is a household organizer. Open the Pantry app from the top navigation to manage your houses, shopping lists, photos and notes.',
|
||||
),
|
||||
|
||||
gettingStartedList: [
|
||||
t(
|
||||
'pantry',
|
||||
'Import UI parts from {cStart}@nextcloud/vue{cEnd} and wire them with {cStart}v-model{cEnd}.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
t(
|
||||
'pantry',
|
||||
'Use {cStart}axios{cEnd} for API calls; return OCS data as needed.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
t(
|
||||
'pantry',
|
||||
'Keep user-facing text in a central {cStart}strings{cEnd} object with {cStart}t/n{cEnd}.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
],
|
||||
|
||||
tipsNote: t(
|
||||
'pantry',
|
||||
'Pro tip: keep labels in {cStart}label{cEnd} and values in {cStart}value{cEnd} to simplify mapping.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
|
||||
// Name example
|
||||
nameInputHeader: t('pantry', 'Chen Asraf'),
|
||||
nameInputLabel: t('pantry', 'Name'),
|
||||
nameInputPlaceholder: t('pantry', 'e.g. Ada Lovelace'),
|
||||
livePreview: t('pantry', 'Live preview:'),
|
||||
|
||||
// Theme example
|
||||
themeHeader: t('pantry', 'Theme'),
|
||||
themeLabel: t('pantry', 'Choose a theme'),
|
||||
themePreview: t('pantry', 'Active value:'),
|
||||
|
||||
// Counter example
|
||||
counterHeader: t('pantry', 'Counter'),
|
||||
plus: t('pantry', '+1'),
|
||||
minus: t('pantry', '-1'),
|
||||
|
||||
// Items table
|
||||
newItemLabel: t('pantry', 'New item'),
|
||||
newItemPlaceholder: t('pantry', 'e.g. Hello item'),
|
||||
add: t('pantry', 'Add'),
|
||||
clear: t('pantry', 'Clear'),
|
||||
tableItem: t('pantry', 'Item'),
|
||||
tableActions: t('pantry', 'Actions'),
|
||||
editItemAria: t('pantry', 'Edit item'),
|
||||
duplicate: t('pantry', 'Duplicate'),
|
||||
remove: t('pantry', 'Remove'),
|
||||
noItems: t('pantry', 'No items yet'),
|
||||
|
||||
// Backend
|
||||
fetchHello: t('pantry', 'Fetch Hello'),
|
||||
save: t('pantry', 'Save'),
|
||||
loading: t('pantry', 'Loading…'),
|
||||
lastHelloAt: t('pantry', 'Last hello at:'),
|
||||
never: t('pantry', 'Never'),
|
||||
serverSaid: t('pantry', 'Server said:'),
|
||||
},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// Load initial data if you want
|
||||
this.fetchHello()
|
||||
},
|
||||
computed: {
|
||||
// Map selected theme label -> full option
|
||||
activeTheme() {
|
||||
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
|
||||
},
|
||||
// Convenience list for NcSelect (labels only)
|
||||
themeOptionsLabels() {
|
||||
return this.themeOptions.map((x) => x.label)
|
||||
},
|
||||
// Live greeting preview (reacts to "name")
|
||||
greeting() {
|
||||
return this.name.trim() ? `Hello, ${this.name.trim()}!` : 'Hello!'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Counter handlers
|
||||
increment() {
|
||||
this.counter++
|
||||
},
|
||||
decrement() {
|
||||
this.counter--
|
||||
},
|
||||
|
||||
// Items handlers
|
||||
addItem() {
|
||||
const label = this.newItem.trim()
|
||||
if (!label) return
|
||||
this.items.push({ id: cryptoRandom(), label })
|
||||
this.newItem = ''
|
||||
},
|
||||
duplicate(index) {
|
||||
const src = this.items[index]
|
||||
if (!src) return
|
||||
this.items.splice(index + 1, 0, { id: cryptoRandom(), label: src.label })
|
||||
},
|
||||
remove(index) {
|
||||
this.items.splice(index, 1)
|
||||
},
|
||||
clearItems() {
|
||||
this.items = []
|
||||
},
|
||||
|
||||
// Backend examples (adjust endpoints to your app’s routes)
|
||||
async fetchHello() {
|
||||
try {
|
||||
this.loading = true
|
||||
// Example GET -> /hello (expects: { ocs: { data: { message: string, at: string }}})
|
||||
const resp = await ocs.get('/hello')
|
||||
this.serverMessage = resp.data.message ?? '👋'
|
||||
// If backend returns ISO date strings, store a Date instance
|
||||
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch hello', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
try {
|
||||
this.loading = true
|
||||
// Example POST -> /hello (send minimal payload)
|
||||
const payload = {
|
||||
name: this.name.trim() || null,
|
||||
theme: this.activeTheme.value,
|
||||
items: this.items.map((x) => x.label),
|
||||
counter: this.counter,
|
||||
}
|
||||
const resp = await ocs.post('/hello', { data: payload })
|
||||
// Update preview/message
|
||||
if (resp.data.message) this.serverMessage = resp.data.message
|
||||
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
|
||||
} catch (e) {
|
||||
console.error('Failed to save hello', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Small helper for local IDs (no crypto dep) */
|
||||
function cryptoRandom() {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -363,104 +36,5 @@ function cryptoRandom() {
|
||||
h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
|
||||
&.align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.gap-8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&.gap-16 {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.example-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.counter {
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ol {
|
||||
padding-left: 2.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border);
|
||||
margin-top: 8px;
|
||||
|
||||
tr:not(:last-child),
|
||||
thead tr {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody tr {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 6px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
60
src/api/houses.ts
Normal file
60
src/api/houses.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ocs } from '@/axios'
|
||||
import type { House, HouseMember, HouseRole } from './types'
|
||||
|
||||
export async function listHouses(): Promise<House[]> {
|
||||
const resp = await ocs.get<House[]>('/houses')
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
export async function getHouse(houseId: number): Promise<House> {
|
||||
const resp = await ocs.get<House>(`/houses/${houseId}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function createHouse(name: string, description?: string | null): Promise<House> {
|
||||
const resp = await ocs.post<House>('/houses', { name, description: description ?? null })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateHouse(
|
||||
houseId: number,
|
||||
patch: { name?: string; description?: string | null },
|
||||
): Promise<House> {
|
||||
const resp = await ocs.patch<House>(`/houses/${houseId}`, patch)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteHouse(houseId: number): Promise<void> {
|
||||
await ocs.delete(`/houses/${houseId}`)
|
||||
}
|
||||
|
||||
export async function listMembers(houseId: number): Promise<HouseMember[]> {
|
||||
const resp = await ocs.get<HouseMember[]>(`/houses/${houseId}/members`)
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
export async function addMember(
|
||||
houseId: number,
|
||||
userId: string,
|
||||
role: HouseRole = 'member',
|
||||
): Promise<HouseMember> {
|
||||
const resp = await ocs.post<HouseMember>(`/houses/${houseId}/members`, { userId, role })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateMemberRole(
|
||||
houseId: number,
|
||||
memberId: number,
|
||||
role: HouseRole,
|
||||
): Promise<HouseMember> {
|
||||
const resp = await ocs.patch<HouseMember>(`/houses/${houseId}/members/${memberId}`, { role })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function removeMember(houseId: number, memberId: number): Promise<void> {
|
||||
await ocs.delete(`/houses/${houseId}/members/${memberId}`)
|
||||
}
|
||||
|
||||
export async function leaveHouse(houseId: number): Promise<void> {
|
||||
await ocs.post(`/houses/${houseId}/leave`)
|
||||
}
|
||||
87
src/api/lists.ts
Normal file
87
src/api/lists.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ocs } from '@/axios'
|
||||
import type { ShoppingList, ShoppingListItem } from './types'
|
||||
|
||||
export async function listLists(houseId: number): Promise<ShoppingList[]> {
|
||||
const resp = await ocs.get<ShoppingList[]>(`/houses/${houseId}/lists`)
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
export async function createList(
|
||||
houseId: number,
|
||||
name: string,
|
||||
description?: string | null,
|
||||
): Promise<ShoppingList> {
|
||||
const resp = await ocs.post<ShoppingList>(`/houses/${houseId}/lists`, {
|
||||
name,
|
||||
description: description ?? null,
|
||||
})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function getList(houseId: number, listId: number): Promise<ShoppingList> {
|
||||
const resp = await ocs.get<ShoppingList>(`/houses/${houseId}/lists/${listId}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateList(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
patch: { name?: string; description?: string | null; sortOrder?: number },
|
||||
): Promise<ShoppingList> {
|
||||
const resp = await ocs.patch<ShoppingList>(`/houses/${houseId}/lists/${listId}`, patch)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteList(houseId: number, listId: number): Promise<void> {
|
||||
await ocs.delete(`/houses/${houseId}/lists/${listId}`)
|
||||
}
|
||||
|
||||
export async function listItems(houseId: number, listId: number): Promise<ShoppingListItem[]> {
|
||||
const resp = await ocs.get<ShoppingListItem[]>(`/houses/${houseId}/lists/${listId}/items`)
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
export interface ItemInput {
|
||||
name: string
|
||||
category?: string | null
|
||||
quantity?: string | null
|
||||
rrule?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export async function addItem(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
input: ItemInput,
|
||||
): Promise<ShoppingListItem> {
|
||||
const resp = await ocs.post<ShoppingListItem>(`/houses/${houseId}/lists/${listId}/items`, input)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateItem(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
itemId: number,
|
||||
patch: Partial<ItemInput>,
|
||||
): Promise<ShoppingListItem> {
|
||||
const resp = await ocs.patch<ShoppingListItem>(
|
||||
`/houses/${houseId}/lists/${listId}/items/${itemId}`,
|
||||
patch,
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function toggleItem(
|
||||
houseId: number,
|
||||
listId: number,
|
||||
itemId: number,
|
||||
): Promise<ShoppingListItem> {
|
||||
const resp = await ocs.post<ShoppingListItem>(
|
||||
`/houses/${houseId}/lists/${listId}/items/${itemId}/toggle`,
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteItem(houseId: number, listId: number, itemId: number): Promise<void> {
|
||||
await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`)
|
||||
}
|
||||
10
src/api/prefs.ts
Normal file
10
src/api/prefs.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ocs } from '@/axios'
|
||||
|
||||
export async function getLastHouse(): Promise<number | null> {
|
||||
const resp = await ocs.get<{ houseId: number | null }>('/prefs/last-house')
|
||||
return resp.data?.houseId ?? null
|
||||
}
|
||||
|
||||
export async function setLastHouse(houseId: number | null): Promise<void> {
|
||||
await ocs.put('/prefs/last-house', { houseId })
|
||||
}
|
||||
46
src/api/types.ts
Normal file
46
src/api/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface House {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
ownerUid: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
role: HouseRole
|
||||
}
|
||||
|
||||
export type HouseRole = 'owner' | 'admin' | 'member'
|
||||
|
||||
export interface HouseMember {
|
||||
id: number
|
||||
houseId: number
|
||||
userId: string
|
||||
displayName: string
|
||||
role: HouseRole
|
||||
joinedAt: number
|
||||
}
|
||||
|
||||
export interface ShoppingList {
|
||||
id: number
|
||||
houseId: number
|
||||
name: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
id: number
|
||||
listId: number
|
||||
name: string
|
||||
category: string | null
|
||||
quantity: string | null
|
||||
bought: boolean
|
||||
boughtAt: number | null
|
||||
boughtBy: string | null
|
||||
rrule: string | null
|
||||
nextDueAt: number | null
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
161
src/components/RecurrenceEditor.vue
Normal file
161
src/components/RecurrenceEditor.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<NcDialog :name="strings.title" :open="open" @update:open="$emit('update:open', $event)">
|
||||
<div class="pantry-recurrence">
|
||||
<NcSelect
|
||||
v-model="selectedPreset"
|
||||
:options="presetOptions"
|
||||
:input-label="strings.repeatLabel"
|
||||
/>
|
||||
|
||||
<div v-if="selectedPreset?.value === 'custom'" class="pantry-recurrence__custom">
|
||||
<NcTextField
|
||||
v-model="customRrule"
|
||||
:label="strings.customLabel"
|
||||
:placeholder="strings.customPlaceholder"
|
||||
/>
|
||||
<p class="pantry-recurrence__hint">{{ strings.customHint }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="pantry-recurrence__error">{{ error }}</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
|
||||
<NcButton v-if="hasExisting" variant="tertiary" @click="clear">
|
||||
{{ strings.clearButton }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="submit">{{ strings.save }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import { RRule } from 'rrule'
|
||||
|
||||
type PresetValue = 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom'
|
||||
interface PresetOption {
|
||||
label: string
|
||||
value: PresetValue
|
||||
rrule: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
modelValue: string | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', open: boolean): void
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const presetOptions = computed<PresetOption[]>(() => [
|
||||
{ label: t('pantry', 'No repeat'), value: 'none', rrule: null },
|
||||
{ label: t('pantry', 'Daily'), value: 'daily', rrule: 'FREQ=DAILY' },
|
||||
{ label: t('pantry', 'Weekly'), value: 'weekly', rrule: 'FREQ=WEEKLY' },
|
||||
{ label: t('pantry', 'Every two weeks'), value: 'biweekly', rrule: 'FREQ=WEEKLY;INTERVAL=2' },
|
||||
{ label: t('pantry', 'Monthly'), value: 'monthly', rrule: 'FREQ=MONTHLY' },
|
||||
{ label: t('pantry', 'Custom …'), value: 'custom', rrule: null },
|
||||
])
|
||||
|
||||
const selectedPreset = ref<PresetOption | null>(presetOptions.value[0] ?? null)
|
||||
const customRrule = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const hasExisting = computed(() => !!props.modelValue)
|
||||
|
||||
function matchPreset(rrule: string | null): PresetOption {
|
||||
const all = presetOptions.value
|
||||
if (!rrule) return all[0]!
|
||||
const normalized = rrule.trim().replace(/^RRULE:/i, '')
|
||||
const found = all.find((p) => p.rrule === normalized)
|
||||
return found ?? all[all.length - 1]! // custom
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.modelValue] as const,
|
||||
([isOpen, value]) => {
|
||||
if (isOpen) {
|
||||
error.value = null
|
||||
selectedPreset.value = matchPreset(value)
|
||||
customRrule.value = selectedPreset.value.value === 'custom' ? (value ?? '') : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function submit() {
|
||||
try {
|
||||
const preset = selectedPreset.value
|
||||
if (!preset || preset.value === 'none') {
|
||||
emit('update:modelValue', null)
|
||||
emit('update:open', false)
|
||||
return
|
||||
}
|
||||
if (preset.value === 'custom') {
|
||||
const raw = customRrule.value.trim().replace(/^RRULE:/i, '')
|
||||
if (!raw) {
|
||||
error.value = t('pantry', 'Please enter a rule.')
|
||||
return
|
||||
}
|
||||
// Validate on the client via rrule library.
|
||||
RRule.fromString('RRULE:' + raw)
|
||||
emit('update:modelValue', raw)
|
||||
} else if (preset.rrule) {
|
||||
emit('update:modelValue', preset.rrule)
|
||||
}
|
||||
emit('update:open', false)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || t('pantry', 'Invalid recurrence rule.')
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', null)
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Recurrence'),
|
||||
repeatLabel: t('pantry', 'Repeat:'),
|
||||
customLabel: t('pantry', 'Custom rule (RFC 5545):'),
|
||||
customPlaceholder: t('pantry', 'e.g. FREQ=WEEKLY;BYDAY=MO,FR'),
|
||||
customHint: t(
|
||||
'pantry',
|
||||
'Specify a standard iCalendar RRULE value. Leave off the "RRULE:" prefix.',
|
||||
),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
save: t('pantry', 'Save'),
|
||||
clearButton: t('pantry', 'Remove recurrence'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pantry-recurrence {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.pantry-recurrence__custom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pantry-recurrence__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pantry-recurrence__error {
|
||||
margin: 0;
|
||||
color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
63
src/composables/useCurrentHouse.ts
Normal file
63
src/composables/useCurrentHouse.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { computed, watch, ref, type ComputedRef, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useHouses } from './useHouses'
|
||||
import * as api from '@/api/houses'
|
||||
import type { House } from '@/api/types'
|
||||
|
||||
export function useCurrentHouse(): {
|
||||
house: Ref<House | null>
|
||||
houseId: ComputedRef<number | null>
|
||||
loading: Ref<boolean>
|
||||
canEdit: ComputedRef<boolean>
|
||||
canAdmin: ComputedRef<boolean>
|
||||
isOwner: ComputedRef<boolean>
|
||||
refresh: () => Promise<void>
|
||||
} {
|
||||
const route = useRoute()
|
||||
const { findById, load } = useHouses()
|
||||
const house = ref<House | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const houseId = computed<number | null>(() => {
|
||||
const raw = route.params.houseId
|
||||
if (!raw) return null
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw)
|
||||
return Number.isFinite(id) ? id : null
|
||||
})
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const id = houseId.value
|
||||
if (id === null) {
|
||||
house.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
// Fast path: use cached list if present.
|
||||
await load()
|
||||
const cached = findById(id)
|
||||
if (cached) {
|
||||
house.value = cached
|
||||
} else {
|
||||
house.value = await api.getHouse(id)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(houseId, refresh, { immediate: true })
|
||||
|
||||
return {
|
||||
house,
|
||||
houseId,
|
||||
loading,
|
||||
canEdit: computed(() => house.value !== null),
|
||||
canAdmin: computed(() => {
|
||||
const role = house.value?.role
|
||||
return role === 'owner' || role === 'admin'
|
||||
}),
|
||||
isOwner: computed(() => house.value?.role === 'owner'),
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
63
src/composables/useHouses.ts
Normal file
63
src/composables/useHouses.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from '@/api/houses'
|
||||
import type { House } from '@/api/types'
|
||||
|
||||
const houses = ref<House[]>([])
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(force = false): Promise<House[]> {
|
||||
if (loaded.value && !force) return houses.value
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
houses.value = await api.listHouses()
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return houses.value
|
||||
}
|
||||
|
||||
async function create(name: string, description?: string | null): Promise<House> {
|
||||
const house = await api.createHouse(name, description)
|
||||
houses.value = [...houses.value, house]
|
||||
return house
|
||||
}
|
||||
|
||||
async function update(
|
||||
id: number,
|
||||
patch: { name?: string; description?: string | null },
|
||||
): Promise<House> {
|
||||
const updated = await api.updateHouse(id, patch)
|
||||
houses.value = houses.value.map((h) => (h.id === id ? updated : h))
|
||||
return updated
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.deleteHouse(id)
|
||||
houses.value = houses.value.filter((h) => h.id !== id)
|
||||
}
|
||||
|
||||
function findById(id: number): House | undefined {
|
||||
return houses.value.find((h) => h.id === id)
|
||||
}
|
||||
|
||||
export function useHouses() {
|
||||
return {
|
||||
houses,
|
||||
loaded,
|
||||
loading,
|
||||
error,
|
||||
isEmpty: computed(() => loaded.value && houses.value.length === 0),
|
||||
load,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
findById,
|
||||
}
|
||||
}
|
||||
8
src/composables/useLastHouse.ts
Normal file
8
src/composables/useLastHouse.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as api from '@/api/prefs'
|
||||
|
||||
export function useLastHouse() {
|
||||
return {
|
||||
get: api.getLastHouse,
|
||||
set: api.setLastHouse,
|
||||
}
|
||||
}
|
||||
93
src/composables/useShoppingList.ts
Normal file
93
src/composables/useShoppingList.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/lists'
|
||||
import type { ShoppingList, ShoppingListItem } from '@/api/types'
|
||||
|
||||
export function useShoppingLists(houseId: number) {
|
||||
const lists = ref<ShoppingList[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
lists.value = await api.listLists(houseId)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(name: string, description?: string | null): Promise<ShoppingList> {
|
||||
const created = await api.createList(houseId, name, description)
|
||||
lists.value = [...lists.value, created]
|
||||
return created
|
||||
}
|
||||
|
||||
async function rename(listId: number, name: string): Promise<void> {
|
||||
const updated = await api.updateList(houseId, listId, { name })
|
||||
lists.value = lists.value.map((l) => (l.id === listId ? updated : l))
|
||||
}
|
||||
|
||||
async function remove(listId: number): Promise<void> {
|
||||
await api.deleteList(houseId, listId)
|
||||
lists.value = lists.value.filter((l) => l.id !== listId)
|
||||
}
|
||||
|
||||
return { lists, loading, error, load, create, rename, remove }
|
||||
}
|
||||
|
||||
export function useShoppingListItems(houseId: number, listId: number) {
|
||||
const items = ref<ShoppingListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await api.listItems(houseId, listId)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function add(input: api.ItemInput): Promise<ShoppingListItem> {
|
||||
const created = await api.addItem(houseId, listId, input)
|
||||
items.value = [...items.value, created]
|
||||
return created
|
||||
}
|
||||
|
||||
async function update(itemId: number, patch: Partial<api.ItemInput>): Promise<void> {
|
||||
const updated = await api.updateItem(houseId, listId, itemId, patch)
|
||||
items.value = items.value.map((i) => (i.id === itemId ? updated : i))
|
||||
}
|
||||
|
||||
async function toggle(itemId: number): Promise<void> {
|
||||
// Optimistic flip.
|
||||
const prev = items.value.find((i) => i.id === itemId)
|
||||
if (prev) {
|
||||
items.value = items.value.map((i) => (i.id === itemId ? { ...i, bought: !i.bought } : i))
|
||||
}
|
||||
try {
|
||||
const updated = await api.toggleItem(houseId, listId, itemId)
|
||||
items.value = items.value.map((i) => (i.id === itemId ? updated : i))
|
||||
} catch (e) {
|
||||
// Roll back on failure.
|
||||
if (prev) {
|
||||
items.value = items.value.map((i) => (i.id === itemId ? prev : i))
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(itemId: number): Promise<void> {
|
||||
await api.deleteItem(houseId, listId, itemId)
|
||||
items.value = items.value.filter((i) => i.id !== itemId)
|
||||
}
|
||||
|
||||
return { items, loading, error, load, add, update, toggle, remove }
|
||||
}
|
||||
@@ -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')),
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
<template>
|
||||
<div class="user-inner">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div style="max-width: 320px">
|
||||
<NcTextField
|
||||
v-model="search"
|
||||
:label="strings.searchLabel"
|
||||
:placeholder="strings.searchPlaceholder"
|
||||
trailing-button-icon="close"
|
||||
:show-trailing-button="search !== ''"
|
||||
@trailing-button-click="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<NcButton type="secondary" @click="toggleForm">
|
||||
{{ formOpen ? strings.hideForm : strings.showForm }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick info / doc -->
|
||||
<NcNoteCard class="mt-12" type="info">
|
||||
<p v-html="strings.quickHelp"></p>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Add item form -->
|
||||
<section v-if="formOpen" class="card mt-16">
|
||||
<h3 class="card-title">{{ strings.formHeader }}</h3>
|
||||
<div class="row gap-16 align-start">
|
||||
<div style="max-width: 260px">
|
||||
<NcTextField
|
||||
v-model="name"
|
||||
:label="strings.nameInputLabel"
|
||||
:placeholder="strings.nameInputPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 220px">
|
||||
<NcSelect
|
||||
v-model="themeLabel"
|
||||
:options="themeOptionsLabels"
|
||||
:input-label="strings.themeLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row gap-8 align-center">
|
||||
<NcButton @click="addFromForm" :disabled="name.trim() === '' || loading">
|
||||
{{ strings.add }}
|
||||
</NcButton>
|
||||
<NcButton type="tertiary" @click="clearForm" :disabled="loading">
|
||||
{{ strings.clear }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-12">
|
||||
{{ strings.livePreview }} <b>{{ previewGreeting }}</b>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div class="center mt-16" v-if="loading">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else-if="filteredHellos.length === 0"
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="seedOne">{{ strings.addExample }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- List -->
|
||||
<section v-else class="mt-16">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40%">{{ strings.colMessage }}</th>
|
||||
<th style="width: 15%">{{ strings.colStatus }}</th>
|
||||
<th style="width: 25%">{{ strings.colAt }}</th>
|
||||
<th style="width: 20%">{{ strings.colActions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(hello, idx) in filteredHellos" :key="hello.id">
|
||||
<td class="ellipsis">
|
||||
<span class="mono">{{ hello.message }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
:status="hello.synced ? 'success' : 'pending'"
|
||||
:label="hello.synced ? strings.statusSynced : strings.statusLocal"
|
||||
/>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
|
||||
<span v-else class="muted">{{ strings.never }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row gap-8">
|
||||
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
|
||||
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="row gap-12 mt-12">
|
||||
<NcButton type="secondary" @click="refresh" :disabled="loading">{{
|
||||
strings.refresh
|
||||
}}</NcButton>
|
||||
<NcButton type="secondary" @click="clearAll" :disabled="loading || hellos.length === 0">
|
||||
{{ strings.clearAll }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Inner view rendered inside AppUserWrapper via <router-view>.
|
||||
* Uses the Hello controller (GET/POST /hello).
|
||||
*/
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'AppUserHome',
|
||||
components: {
|
||||
NcButton,
|
||||
NcNoteCard,
|
||||
NcTextField,
|
||||
NcSelect,
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcDateTime,
|
||||
StatusBadge,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
formOpen: true,
|
||||
|
||||
// Toolbar
|
||||
search: '',
|
||||
|
||||
// Form data
|
||||
name: '',
|
||||
themeLabel: null,
|
||||
themeOptions: [
|
||||
{ label: t('pantry', 'Light'), value: 'light' },
|
||||
{ label: t('pantry', 'Dark'), value: 'dark' },
|
||||
{
|
||||
label: n('pantry', 'System (1 option)', 'System (%n options)', 2),
|
||||
value: 'system',
|
||||
},
|
||||
],
|
||||
|
||||
// List of "hellos"
|
||||
hellos: [],
|
||||
|
||||
strings: {
|
||||
// Toolbar
|
||||
searchLabel: t('pantry', 'Search'),
|
||||
searchPlaceholder: t('pantry', 'Filter messages…'),
|
||||
refresh: t('pantry', 'Refresh'),
|
||||
showForm: t('pantry', 'Show form'),
|
||||
hideForm: t('pantry', 'Hide form'),
|
||||
|
||||
// Info
|
||||
quickHelp: t(
|
||||
'pantry',
|
||||
'Use the form to post a hello. The list shows recent hellos fetched from the server. All user-visible text is centralized in {cStart}strings{cEnd}.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
|
||||
// Form
|
||||
formHeader: t('pantry', 'Say hello'),
|
||||
nameInputLabel: t('pantry', 'Name'),
|
||||
nameInputPlaceholder: t('pantry', 'e.g. Ada'),
|
||||
themeLabel: t('pantry', 'Theme'),
|
||||
add: t('pantry', 'Add'),
|
||||
clear: t('pantry', 'Clear'),
|
||||
livePreview: t('pantry', 'Preview:'),
|
||||
|
||||
// List
|
||||
loading: t('pantry', 'Loading…'),
|
||||
emptyTitle: t('pantry', 'No hellos yet'),
|
||||
emptyDesc: t('pantry', 'Try adding one using the form above.'),
|
||||
addExample: t('pantry', 'Add example'),
|
||||
colMessage: t('pantry', 'Message'),
|
||||
colStatus: t('pantry', 'Status'),
|
||||
colAt: t('pantry', 'Time'),
|
||||
colActions: t('pantry', 'Actions'),
|
||||
statusSynced: t('pantry', 'Synced'),
|
||||
statusLocal: t('pantry', 'Local'),
|
||||
duplicate: t('pantry', 'Duplicate'),
|
||||
remove: t('pantry', 'Remove'),
|
||||
clearAll: t('pantry', 'Clear all'),
|
||||
never: t('pantry', 'Never'),
|
||||
},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.refresh()
|
||||
},
|
||||
computed: {
|
||||
themeOptionsLabels() {
|
||||
return this.themeOptions.map((x) => x.label)
|
||||
},
|
||||
activeTheme() {
|
||||
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
|
||||
},
|
||||
previewGreeting() {
|
||||
const n = this.name.trim()
|
||||
return n ? `Hello, ${n}!` : 'Hello!'
|
||||
},
|
||||
filteredHellos() {
|
||||
const q = this.search.trim().toLowerCase()
|
||||
if (!q) return this.hellos
|
||||
return this.hellos.filter((h) => h.message.toLowerCase().includes(q))
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleForm() {
|
||||
this.formOpen = !this.formOpen
|
||||
},
|
||||
clearForm() {
|
||||
this.name = ''
|
||||
this.themeLabel = null
|
||||
},
|
||||
clearSearch() {
|
||||
this.search = ''
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
this.loading = true
|
||||
// GET /hello -> { ocs: { data: { message, at } } }
|
||||
const resp = await ocs.get('/hello')
|
||||
const data = resp.data
|
||||
if (data?.message) {
|
||||
this.hellos.unshift({
|
||||
id: genId(),
|
||||
message: data.message,
|
||||
at: data.at ?? null,
|
||||
synced: true,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async addFromForm() {
|
||||
const name = this.name.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
this.loading = true
|
||||
const payload = {
|
||||
name,
|
||||
theme: this.activeTheme.value,
|
||||
items: [],
|
||||
counter: 0,
|
||||
}
|
||||
// POST /hello -> { ocs: { data: { message, at } } }
|
||||
const resp = await ocs.post('/hello', { data: payload })
|
||||
const data = resp.data
|
||||
const message = data?.message ?? `Hello, ${name}!`
|
||||
const at = data?.at ?? new Date().toISOString()
|
||||
this.hellos.unshift({ id: genId(), message, at, synced: true })
|
||||
this.clearForm()
|
||||
this.formOpen = false
|
||||
} catch (e) {
|
||||
console.error('Failed to add hello', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
duplicate(index) {
|
||||
const src = this.hellos[index]
|
||||
if (!src) return
|
||||
// Duplicated items are local-only until synced
|
||||
this.hellos.splice(index + 1, 0, { ...src, id: genId(), synced: false })
|
||||
},
|
||||
|
||||
remove(index) {
|
||||
this.hellos.splice(index, 1)
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
this.hellos = []
|
||||
},
|
||||
|
||||
seedOne() {
|
||||
// Seeded examples are local-only
|
||||
this.hellos.push({
|
||||
id: genId(),
|
||||
message: '👋 Hello example',
|
||||
at: new Date().toISOString(),
|
||||
synced: false,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-inner {
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
|
||||
&.align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.gap-8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&.gap-12 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&.gap-16 {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-main-background);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
thead tr,
|
||||
tr:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody tr {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
src/views/HomeRedirect.vue
Normal file
43
src/views/HomeRedirect.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="pantry-loading">
|
||||
<NcLoadingIcon :size="48" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { useHouses } from '@/composables/useHouses'
|
||||
import { useLastHouse } from '@/composables/useLastHouse'
|
||||
|
||||
const router = useRouter()
|
||||
const { load, houses } = useHouses()
|
||||
const lastHouse = useLastHouse()
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
if (houses.value.length === 0) {
|
||||
await router.replace({ name: 'houses' })
|
||||
return
|
||||
}
|
||||
const lastId = await lastHouse.get()
|
||||
const first = houses.value[0]
|
||||
if (!first) {
|
||||
await router.replace({ name: 'houses' })
|
||||
return
|
||||
}
|
||||
const target = lastId !== null && houses.value.some((h) => h.id === lastId) ? lastId : first.id
|
||||
await router.replace({ name: 'lists', params: { houseId: String(target) } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pantry-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
56
src/views/HouseLayout.vue
Normal file
56
src/views/HouseLayout.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div v-if="loading" class="pantry-loading">
|
||||
<NcLoadingIcon :size="48" />
|
||||
</div>
|
||||
<NcEmptyContent
|
||||
v-else-if="!house"
|
||||
:name="strings.notFoundTitle"
|
||||
:description="strings.notFoundBody"
|
||||
>
|
||||
<template #icon>
|
||||
<HomeIcon />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<router-view v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import HomeIcon from '@icons/Home.vue'
|
||||
import { useCurrentHouse } from '@/composables/useCurrentHouse'
|
||||
import { useLastHouse } from '@/composables/useLastHouse'
|
||||
|
||||
const { house, houseId, loading } = useCurrentHouse()
|
||||
const lastHouse = useLastHouse()
|
||||
|
||||
async function persistLastHouse() {
|
||||
if (houseId.value !== null) {
|
||||
try {
|
||||
await lastHouse.set(houseId.value)
|
||||
} catch {
|
||||
// Non-critical; swallow.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(persistLastHouse)
|
||||
watch(houseId, persistLastHouse)
|
||||
|
||||
const strings = {
|
||||
notFoundTitle: t('pantry', 'House not found'),
|
||||
notFoundBody: t('pantry', 'This house does not exist or you no longer have access.'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pantry-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
</style>
|
||||
99
src/views/HouseNavigation.vue
Normal file
99
src/views/HouseNavigation.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<NcAppNavigation>
|
||||
<template #list>
|
||||
<NcAppNavigationItem :name="strings.allHouses" :to="{ name: 'houses' }">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<li v-if="house" class="pantry-nav-house-name" :title="house.name">
|
||||
{{ house.name }}
|
||||
</li>
|
||||
|
||||
<NcAppNavigationItem
|
||||
:name="strings.lists"
|
||||
:to="{ name: 'lists', params: { houseId: String(houseId) } }"
|
||||
>
|
||||
<template #icon>
|
||||
<CartIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
:name="strings.photos"
|
||||
:to="{ name: 'photos', params: { houseId: String(houseId) } }"
|
||||
>
|
||||
<template #icon>
|
||||
<ImageIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
:name="strings.notes"
|
||||
:to="{ name: 'notes', params: { houseId: String(houseId) } }"
|
||||
>
|
||||
<template #icon>
|
||||
<NoteIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
:name="strings.members"
|
||||
:to="{ name: 'members', params: { houseId: String(houseId) } }"
|
||||
>
|
||||
<template #icon>
|
||||
<AccountGroupIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
v-if="canAdmin"
|
||||
:name="strings.houseSettings"
|
||||
:to="{ name: 'house-settings', params: { houseId: String(houseId) } }"
|
||||
>
|
||||
<template #icon>
|
||||
<CogIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
</NcAppNavigation>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import CartIcon from '@icons/Cart.vue'
|
||||
import ImageIcon from '@icons/Image.vue'
|
||||
import NoteIcon from '@icons/Note.vue'
|
||||
import AccountGroupIcon from '@icons/AccountGroup.vue'
|
||||
import CogIcon from '@icons/Cog.vue'
|
||||
import { useCurrentHouse } from '@/composables/useCurrentHouse'
|
||||
|
||||
const { house, houseId, canAdmin } = useCurrentHouse()
|
||||
|
||||
const strings = {
|
||||
allHouses: t('pantry', 'All houses'),
|
||||
lists: t('pantry', 'Shopping lists'),
|
||||
photos: t('pantry', 'Photo board'),
|
||||
notes: t('pantry', 'Notes wall'),
|
||||
members: t('pantry', 'Members'),
|
||||
houseSettings: t('pantry', 'House settings'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pantry-nav-house-name {
|
||||
padding: 8px 16px 4px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
159
src/views/HouseSettingsView.vue
Normal file
159
src/views/HouseSettingsView.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="pantry-house-settings">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
|
||||
<form v-if="house" class="pantry-form" @submit.prevent="save">
|
||||
<NcTextField
|
||||
v-model="name"
|
||||
:label="strings.nameLabel"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="description"
|
||||
:label="strings.descriptionLabel"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
/>
|
||||
<div class="pantry-form__actions">
|
||||
<NcButton type="submit" variant="primary" :disabled="saving || !name.trim()">
|
||||
{{ saving ? strings.saving : strings.save }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr v-if="isOwner" class="pantry-divider" />
|
||||
|
||||
<section v-if="isOwner" class="pantry-danger">
|
||||
<h3>{{ strings.dangerTitle }}</h3>
|
||||
<p>{{ strings.dangerBody }}</p>
|
||||
<NcButton variant="error" @click="confirmingDelete = true">
|
||||
{{ strings.deleteButton }}
|
||||
</NcButton>
|
||||
</section>
|
||||
|
||||
<NcDialog
|
||||
v-if="confirmingDelete"
|
||||
:name="strings.deleteDialogTitle"
|
||||
:open="confirmingDelete"
|
||||
@update:open="confirmingDelete = $event"
|
||||
>
|
||||
<p>{{ strings.deleteConfirmBody }}</p>
|
||||
<template #actions>
|
||||
<NcButton @click="confirmingDelete = false">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="error" @click="deleteHouse">{{ strings.deleteButton }}</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import { useCurrentHouse } from '@/composables/useCurrentHouse'
|
||||
import { useHouses } from '@/composables/useHouses'
|
||||
|
||||
const router = useRouter()
|
||||
const { house, isOwner, refresh } = useCurrentHouse()
|
||||
const { update, remove } = useHouses()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const saving = ref(false)
|
||||
const confirmingDelete = ref(false)
|
||||
|
||||
watch(
|
||||
house,
|
||||
(h) => {
|
||||
if (h) {
|
||||
name.value = h.name
|
||||
description.value = h.description ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!house.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await update(house.value.id, {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || null,
|
||||
})
|
||||
await refresh()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHouse() {
|
||||
if (!house.value) return
|
||||
await remove(house.value.id)
|
||||
confirmingDelete.value = false
|
||||
await router.push({ name: 'houses' })
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'House settings'),
|
||||
nameLabel: t('pantry', 'Name:'),
|
||||
namePlaceholder: t('pantry', 'House name'),
|
||||
descriptionLabel: t('pantry', 'Description:'),
|
||||
descriptionPlaceholder: t('pantry', 'A short description'),
|
||||
save: t('pantry', 'Save changes'),
|
||||
saving: t('pantry', 'Saving …'),
|
||||
dangerTitle: t('pantry', 'Danger zone'),
|
||||
dangerBody: t(
|
||||
'pantry',
|
||||
'Deleting a house permanently removes all of its lists, items, and membership records. This cannot be undone.',
|
||||
),
|
||||
deleteButton: t('pantry', 'Delete house'),
|
||||
deleteDialogTitle: t('pantry', 'Delete this house?'),
|
||||
deleteConfirmBody: t(
|
||||
'pantry',
|
||||
'All lists, items and member records for this house will be permanently deleted.',
|
||||
),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-house-settings {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-divider {
|
||||
margin: 2rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pantry-danger {
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
253
src/views/HousesList.vue
Normal file
253
src/views/HousesList.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="pantry-houses">
|
||||
<header class="pantry-houses__header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<NcButton variant="primary" @click="showCreate = true">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createButton }}
|
||||
</NcButton>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !loaded" class="pantry-houses__loading">
|
||||
<NcLoadingIcon :size="48" />
|
||||
</div>
|
||||
|
||||
<NcEmptyContent
|
||||
v-else-if="loaded && houses.length === 0"
|
||||
:name="strings.emptyTitle"
|
||||
:description="strings.emptyBody"
|
||||
>
|
||||
<template #icon>
|
||||
<HomeIcon />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton variant="primary" @click="showCreate = true">
|
||||
{{ strings.createButton }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<ul v-else class="pantry-houses__grid">
|
||||
<li v-for="house in houses" :key="house.id">
|
||||
<router-link
|
||||
class="pantry-house-card"
|
||||
:to="{ name: 'lists', params: { houseId: String(house.id) } }"
|
||||
>
|
||||
<div class="pantry-house-card__icon">
|
||||
<HomeIcon :size="32" />
|
||||
</div>
|
||||
<div class="pantry-house-card__body">
|
||||
<h3 class="pantry-house-card__name">{{ house.name }}</h3>
|
||||
<p v-if="house.description" class="pantry-house-card__desc">
|
||||
{{ house.description }}
|
||||
</p>
|
||||
<span class="pantry-house-card__role">{{ roleLabel(house.role) }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NcDialog
|
||||
v-if="showCreate"
|
||||
:name="strings.createDialogTitle"
|
||||
:open="showCreate"
|
||||
@update:open="showCreate = $event"
|
||||
>
|
||||
<form class="pantry-create-form" @submit.prevent="submitCreate">
|
||||
<NcTextField
|
||||
v-model="newName"
|
||||
:label="strings.nameLabel"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="newDescription"
|
||||
:label="strings.descriptionLabel"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
/>
|
||||
<p v-if="createError" class="pantry-form-error">{{ createError }}</p>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="creating || !newName.trim()" @click="submitCreate">
|
||||
{{ creating ? strings.creatingLabel : strings.createButton }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import HomeIcon from '@icons/Home.vue'
|
||||
import { useHouses } from '@/composables/useHouses'
|
||||
import type { HouseRole } from '@/api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const { houses, loaded, loading, load, create } = useHouses()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const newName = ref('')
|
||||
const newDescription = ref('')
|
||||
const creating = ref(false)
|
||||
const createError = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
async function submitCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
creating.value = true
|
||||
createError.value = null
|
||||
try {
|
||||
const house = await create(name, newDescription.value.trim() || null)
|
||||
showCreate.value = false
|
||||
newName.value = ''
|
||||
newDescription.value = ''
|
||||
await router.push({ name: 'lists', params: { houseId: String(house.id) } })
|
||||
} catch (e) {
|
||||
createError.value = (e as Error).message || t('pantry', 'Could not create house.')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function roleLabel(role: HouseRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return t('pantry', 'Owner')
|
||||
case 'admin':
|
||||
return t('pantry', 'Administrator')
|
||||
default:
|
||||
return t('pantry', 'Member')
|
||||
}
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Your houses'),
|
||||
createButton: t('pantry', 'New house'),
|
||||
creatingLabel: t('pantry', 'Creating …'),
|
||||
createDialogTitle: t('pantry', 'Create a house'),
|
||||
nameLabel: t('pantry', 'Name:'),
|
||||
namePlaceholder: t('pantry', 'e.g. Home, Beach house'),
|
||||
descriptionLabel: t('pantry', 'Description (optional):'),
|
||||
descriptionPlaceholder: t('pantry', 'A short description'),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
emptyTitle: t('pantry', 'No houses yet'),
|
||||
emptyBody: t(
|
||||
'pantry',
|
||||
'Create a house to start organizing your shopping lists, photos and notes.',
|
||||
),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-houses {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-house-card {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large, 12px);
|
||||
background: var(--color-main-background);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--color-primary-element);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0 0 6px 0;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&__role {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary-element-light);
|
||||
color: var(--color-primary-element-light-text);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.pantry-form-error {
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
26
src/views/HousesNavigation.vue
Normal file
26
src/views/HousesNavigation.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<NcAppNavigation>
|
||||
<template #list>
|
||||
<NcAppNavigationItem
|
||||
:name="strings.allHouses"
|
||||
:to="{ name: 'houses' }"
|
||||
:active="$route.name === 'houses' || $route.name === 'home'"
|
||||
>
|
||||
<template #icon>
|
||||
<HomeIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
</NcAppNavigation>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import HomeIcon from '@icons/Home.vue'
|
||||
|
||||
const strings = {
|
||||
allHouses: t('pantry', 'All houses'),
|
||||
}
|
||||
</script>
|
||||
256
src/views/MembersView.vue
Normal file
256
src/views/MembersView.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="pantry-members">
|
||||
<header class="pantry-members__header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<NcButton v-if="canAdmin" variant="primary" @click="showAdd = true">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.addMember }}
|
||||
</NcButton>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="pantry-center">
|
||||
<NcLoadingIcon :size="36" />
|
||||
</div>
|
||||
|
||||
<table v-else class="pantry-members__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ strings.colUser }}</th>
|
||||
<th>{{ strings.colRole }}</th>
|
||||
<th>{{ strings.colJoined }}</th>
|
||||
<th class="pantry-members__actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="member.id">
|
||||
<td>{{ member.displayName }}</td>
|
||||
<td>
|
||||
<select
|
||||
v-if="canAdmin && member.role !== 'owner'"
|
||||
:value="member.role"
|
||||
@change="changeRole(member.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="admin">{{ roleLabel('admin') }}</option>
|
||||
<option value="member">{{ roleLabel('member') }}</option>
|
||||
</select>
|
||||
<span v-else>{{ roleLabel(member.role) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<NcDateTime :timestamp="member.joinedAt * 1000" />
|
||||
</td>
|
||||
<td class="pantry-members__actions">
|
||||
<NcButton
|
||||
v-if="canAdmin && member.role !== 'owner'"
|
||||
variant="tertiary"
|
||||
:aria-label="strings.removeMember"
|
||||
@click="removeExisting(member.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<DeleteIcon :size="18" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!isOwner" class="pantry-members__leave">
|
||||
<NcButton variant="secondary" @click="leave">
|
||||
{{ strings.leaveButton }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<NcDialog
|
||||
v-if="showAdd"
|
||||
:name="strings.addDialogTitle"
|
||||
:open="showAdd"
|
||||
@update:open="showAdd = $event"
|
||||
>
|
||||
<form class="pantry-form" @submit.prevent="submitAdd">
|
||||
<NcTextField
|
||||
v-model="newUserId"
|
||||
:label="strings.userIdLabel"
|
||||
:placeholder="strings.userIdPlaceholder"
|
||||
/>
|
||||
<NcSelect v-model="newRoleLabel" :options="roleOptions" :input-label="strings.roleLabel" />
|
||||
<p v-if="addError" class="pantry-form-error">{{ addError }}</p>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="showAdd = false">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="!newUserId.trim()" @click="submitAdd">
|
||||
{{ strings.addMember }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import * as api from '@/api/houses'
|
||||
import type { HouseMember, HouseRole } from '@/api/types'
|
||||
import { useCurrentHouse } from '@/composables/useCurrentHouse'
|
||||
|
||||
const props = defineProps<{ houseId: string }>()
|
||||
const router = useRouter()
|
||||
const houseIdNum = computed(() => Number(props.houseId))
|
||||
const { canAdmin, isOwner } = useCurrentHouse()
|
||||
|
||||
const members = ref<HouseMember[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const showAdd = ref(false)
|
||||
const newUserId = ref('')
|
||||
interface RoleOption {
|
||||
label: string
|
||||
value: HouseRole
|
||||
}
|
||||
const roleOptions = computed<RoleOption[]>(() => [
|
||||
{ label: t('pantry', 'Member'), value: 'member' },
|
||||
{ label: t('pantry', 'Administrator'), value: 'admin' },
|
||||
])
|
||||
const newRoleLabel = ref<RoleOption>(roleOptions.value[0]!)
|
||||
const addError = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
members.value = await api.listMembers(houseIdNum.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function submitAdd() {
|
||||
const uid = newUserId.value.trim()
|
||||
if (!uid) return
|
||||
addError.value = null
|
||||
try {
|
||||
const role: HouseRole = newRoleLabel.value?.value ?? 'member'
|
||||
const member = await api.addMember(houseIdNum.value, uid, role)
|
||||
members.value = [...members.value, member]
|
||||
showAdd.value = false
|
||||
newUserId.value = ''
|
||||
} catch (e) {
|
||||
addError.value = (e as Error).message || t('pantry', 'Could not add member.')
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(memberId: number, role: string) {
|
||||
if (role !== 'admin' && role !== 'member') return
|
||||
const updated = await api.updateMemberRole(houseIdNum.value, memberId, role)
|
||||
members.value = members.value.map((m) => (m.id === memberId ? updated : m))
|
||||
}
|
||||
|
||||
async function removeExisting(memberId: number) {
|
||||
await api.removeMember(houseIdNum.value, memberId)
|
||||
members.value = members.value.filter((m) => m.id !== memberId)
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
await api.leaveHouse(houseIdNum.value)
|
||||
await router.push({ name: 'houses' })
|
||||
}
|
||||
|
||||
function roleLabel(role: HouseRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return t('pantry', 'Owner')
|
||||
case 'admin':
|
||||
return t('pantry', 'Administrator')
|
||||
default:
|
||||
return t('pantry', 'Member')
|
||||
}
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Members'),
|
||||
addMember: t('pantry', 'Add member'),
|
||||
removeMember: t('pantry', 'Remove member'),
|
||||
leaveButton: t('pantry', 'Leave this house'),
|
||||
colUser: t('pantry', 'Account'),
|
||||
colRole: t('pantry', 'Role'),
|
||||
colJoined: t('pantry', 'Joined'),
|
||||
addDialogTitle: t('pantry', 'Add a member'),
|
||||
userIdLabel: t('pantry', 'Account ID:'),
|
||||
userIdPlaceholder: t('pantry', 'The Nextcloud username'),
|
||||
roleLabel: t('pantry', 'Role:'),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-members {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions-col {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__leave {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.pantry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.pantry-form-error {
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
18
src/views/NotesWallStub.vue
Normal file
18
src/views/NotesWallStub.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<NcEmptyContent :name="strings.title" :description="strings.body">
|
||||
<template #icon>
|
||||
<NoteIcon />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NoteIcon from '@icons/Note.vue'
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Notes wall'),
|
||||
body: t('pantry', 'A shared space for household notes and reminders is coming soon.'),
|
||||
}
|
||||
</script>
|
||||
21
src/views/PhotoBoardStub.vue
Normal file
21
src/views/PhotoBoardStub.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<NcEmptyContent :name="strings.title" :description="strings.body">
|
||||
<template #icon>
|
||||
<ImageIcon />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import ImageIcon from '@icons/Image.vue'
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Photo board'),
|
||||
body: t(
|
||||
'pantry',
|
||||
'Shared reference photos are coming soon. You will be able to upload and categorize pictures for the whole household.',
|
||||
),
|
||||
}
|
||||
</script>
|
||||
299
src/views/ShoppingListDetail.vue
Normal file
299
src/views/ShoppingListDetail.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="pantry-detail">
|
||||
<header class="pantry-detail__header">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.back"
|
||||
@click="$router.push({ name: 'lists', params: { houseId } })"
|
||||
>
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<h2 v-if="list">{{ list.name }}</h2>
|
||||
<h2 v-else> </h2>
|
||||
</header>
|
||||
|
||||
<form class="pantry-detail__add" @submit.prevent="submitAdd">
|
||||
<NcTextField
|
||||
v-model="newName"
|
||||
:label="strings.newItemLabel"
|
||||
:placeholder="strings.newItemPlaceholder"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="newQuantity"
|
||||
:label="strings.quantityLabel"
|
||||
:placeholder="strings.quantityPlaceholder"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="newCategory"
|
||||
:label="strings.categoryLabel"
|
||||
:placeholder="strings.categoryPlaceholder"
|
||||
/>
|
||||
<NcButton variant="tertiary" @click="showRecurrenceEditor = true">
|
||||
<template #icon>
|
||||
<RepeatIcon :size="20" />
|
||||
</template>
|
||||
{{ newRrule ? strings.recurrenceSet : strings.recurrenceButton }}
|
||||
</NcButton>
|
||||
<NcButton type="submit" variant="primary" :disabled="!newName.trim() || adding">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.add }}
|
||||
</NcButton>
|
||||
</form>
|
||||
|
||||
<div v-if="loading" class="pantry-center">
|
||||
<NcLoadingIcon :size="36" />
|
||||
</div>
|
||||
|
||||
<NcEmptyContent
|
||||
v-else-if="items.length === 0"
|
||||
:name="strings.emptyTitle"
|
||||
:description="strings.emptyBody"
|
||||
>
|
||||
<template #icon>
|
||||
<CartIcon />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<ul v-else class="pantry-items">
|
||||
<li
|
||||
v-for="item in sortedItems"
|
||||
:key="item.id"
|
||||
class="pantry-item"
|
||||
:class="{ 'pantry-item--bought': item.bought }"
|
||||
>
|
||||
<NcCheckboxRadioSwitch
|
||||
:model-value="item.bought"
|
||||
@update:model-value="handleToggle(item.id)"
|
||||
>
|
||||
<span class="pantry-item__name">{{ item.name }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
<div class="pantry-item__meta">
|
||||
<span v-if="item.quantity" class="pantry-item__quantity">{{ item.quantity }}</span>
|
||||
<span v-if="item.category" class="pantry-item__category">{{ item.category }}</span>
|
||||
<span v-if="item.rrule" class="pantry-item__recurrence" :title="item.rrule">
|
||||
<RepeatIcon :size="14" />
|
||||
{{ formatRrule(item.rrule) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pantry-item__actions">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.removeItem"
|
||||
@click="handleRemove(item.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<DeleteIcon :size="18" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<RecurrenceEditor v-model:open="showRecurrenceEditor" v-model="newRrule" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import RepeatIcon from '@icons/Repeat.vue'
|
||||
import CartIcon from '@icons/Cart.vue'
|
||||
import RecurrenceEditor from '@/components/RecurrenceEditor.vue'
|
||||
import { useShoppingListItems } from '@/composables/useShoppingList'
|
||||
import { getList } from '@/api/lists'
|
||||
import type { ShoppingList } from '@/api/types'
|
||||
import { RRule } from 'rrule'
|
||||
|
||||
const props = defineProps<{ houseId: string; listId: string }>()
|
||||
|
||||
const houseIdNum = computed(() => Number(props.houseId))
|
||||
const listIdNum = computed(() => Number(props.listId))
|
||||
|
||||
const list = ref<ShoppingList | null>(null)
|
||||
const { items, loading, load, add, toggle, remove } = useShoppingListItems(
|
||||
houseIdNum.value,
|
||||
listIdNum.value,
|
||||
)
|
||||
|
||||
const newName = ref('')
|
||||
const newQuantity = ref('')
|
||||
const newCategory = ref('')
|
||||
const newRrule = ref<string | null>(null)
|
||||
const adding = ref(false)
|
||||
const showRecurrenceEditor = ref(false)
|
||||
|
||||
async function loadList() {
|
||||
list.value = await getList(houseIdNum.value, listIdNum.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadList(), load()])
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.houseId, props.listId],
|
||||
async () => {
|
||||
await Promise.all([loadList(), load()])
|
||||
},
|
||||
)
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
return [...items.value].sort((a, b) => {
|
||||
if (a.bought !== b.bought) return a.bought ? 1 : -1
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
async function submitAdd() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
adding.value = true
|
||||
try {
|
||||
await add({
|
||||
name,
|
||||
quantity: newQuantity.value.trim() || null,
|
||||
category: newCategory.value.trim() || null,
|
||||
rrule: newRrule.value,
|
||||
})
|
||||
newName.value = ''
|
||||
newQuantity.value = ''
|
||||
newCategory.value = ''
|
||||
newRrule.value = null
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(itemId: number) {
|
||||
await toggle(itemId)
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: number) {
|
||||
await remove(itemId)
|
||||
}
|
||||
|
||||
function formatRrule(rrule: string): string {
|
||||
try {
|
||||
const rule = RRule.fromString('RRULE:' + rrule.replace(/^RRULE:/i, ''))
|
||||
return rule.toText()
|
||||
} catch {
|
||||
return rrule
|
||||
}
|
||||
}
|
||||
|
||||
const strings = {
|
||||
back: t('pantry', 'Back to lists'),
|
||||
add: t('pantry', 'Add'),
|
||||
newItemLabel: t('pantry', 'Item:'),
|
||||
newItemPlaceholder: t('pantry', 'e.g. Milk'),
|
||||
quantityLabel: t('pantry', 'Quantity:'),
|
||||
quantityPlaceholder: t('pantry', 'e.g. 2 L'),
|
||||
categoryLabel: t('pantry', 'Category:'),
|
||||
categoryPlaceholder: t('pantry', 'e.g. Dairy'),
|
||||
recurrenceButton: t('pantry', 'Repeat …'),
|
||||
recurrenceSet: t('pantry', 'Repeat: set'),
|
||||
removeItem: t('pantry', 'Remove item'),
|
||||
emptyTitle: t('pantry', 'No items yet'),
|
||||
emptyBody: t('pantry', 'Add items using the form above.'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-detail {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__add {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr auto auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.pantry-items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pantry-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
background: var(--color-main-background);
|
||||
|
||||
&--bought {
|
||||
opacity: 0.6;
|
||||
|
||||
.pantry-item__name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
&__quantity,
|
||||
&__category,
|
||||
&__recurrence {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
210
src/views/ShoppingListsView.vue
Normal file
210
src/views/ShoppingListsView.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="pantry-lists">
|
||||
<header class="pantry-lists__header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<NcButton variant="primary" @click="showCreate = true">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.newList }}
|
||||
</NcButton>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="pantry-center">
|
||||
<NcLoadingIcon :size="36" />
|
||||
</div>
|
||||
|
||||
<NcEmptyContent
|
||||
v-else-if="lists.length === 0"
|
||||
:name="strings.emptyTitle"
|
||||
:description="strings.emptyBody"
|
||||
>
|
||||
<template #icon>
|
||||
<CartIcon />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton variant="primary" @click="showCreate = true">
|
||||
{{ strings.newList }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<ul v-else class="pantry-lists__grid">
|
||||
<li v-for="list in lists" :key="list.id">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'list-detail',
|
||||
params: { houseId: String(houseIdNum), listId: String(list.id) },
|
||||
}"
|
||||
class="pantry-list-card"
|
||||
>
|
||||
<CartIcon :size="28" class="pantry-list-card__icon" />
|
||||
<div class="pantry-list-card__body">
|
||||
<h3>{{ list.name }}</h3>
|
||||
<p v-if="list.description">{{ list.description }}</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NcDialog
|
||||
v-if="showCreate"
|
||||
:name="strings.createDialogTitle"
|
||||
:open="showCreate"
|
||||
@update:open="showCreate = $event"
|
||||
>
|
||||
<form class="pantry-form" @submit.prevent="submitCreate">
|
||||
<NcTextField
|
||||
v-model="newName"
|
||||
:label="strings.nameLabel"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="newDescription"
|
||||
:label="strings.descriptionLabel"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
/>
|
||||
</form>
|
||||
<template #actions>
|
||||
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="!newName.trim()" @click="submitCreate">
|
||||
{{ strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import CartIcon from '@icons/Cart.vue'
|
||||
import { useShoppingLists } from '@/composables/useShoppingList'
|
||||
|
||||
const props = defineProps<{ houseId: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const houseIdNum = computed(() => Number(props.houseId))
|
||||
const { lists, loading, load, create } = useShoppingLists(houseIdNum.value)
|
||||
|
||||
onMounted(load)
|
||||
watch(
|
||||
() => props.houseId,
|
||||
() => load(),
|
||||
)
|
||||
|
||||
const showCreate = ref(false)
|
||||
const newName = ref('')
|
||||
const newDescription = ref('')
|
||||
|
||||
async function submitCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
const list = await create(name, newDescription.value.trim() || null)
|
||||
showCreate.value = false
|
||||
newName.value = ''
|
||||
newDescription.value = ''
|
||||
await router.push({
|
||||
name: 'list-detail',
|
||||
params: { houseId: String(houseIdNum.value), listId: String(list.id) },
|
||||
})
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Shopping lists'),
|
||||
newList: t('pantry', 'New list'),
|
||||
create: t('pantry', 'Create'),
|
||||
cancel: t('pantry', 'Cancel'),
|
||||
createDialogTitle: t('pantry', 'Create a shopping list'),
|
||||
nameLabel: t('pantry', 'Name:'),
|
||||
namePlaceholder: t('pantry', 'e.g. Weekly groceries'),
|
||||
descriptionLabel: t('pantry', 'Description (optional):'),
|
||||
descriptionPlaceholder: t('pantry', 'A short description'),
|
||||
emptyTitle: t('pantry', 'No lists yet'),
|
||||
emptyBody: t('pantry', 'Create your first shopping list to start adding items.'),
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pantry-lists {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-list-card {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large, 12px);
|
||||
background: var(--color-main-background);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pantry-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.pantry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Controller;
|
||||
|
||||
use OCA\Pantry\AppInfo\Application;
|
||||
use OCA\Pantry\Controller\ApiController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiTest extends TestCase {
|
||||
private ApiController $controller;
|
||||
/** @var IRequest&MockObject */
|
||||
private IRequest $request;
|
||||
/** @var IAppConfig&MockObject */
|
||||
private IAppConfig $config;
|
||||
/** @var IL10N&MockObject */
|
||||
private IL10N $l10n;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->config = $this->createMock(IAppConfig::class);
|
||||
$this->l10n = $this->createMock(IL10N::class);
|
||||
|
||||
// Mock translation to return a simple string by default
|
||||
$this->l10n->method('t')
|
||||
->willReturnCallback(function ($text, $params = []) {
|
||||
if (empty($params)) {
|
||||
return $text;
|
||||
}
|
||||
return vsprintf(str_replace('%s', '%s', $text), $params);
|
||||
});
|
||||
|
||||
$this->controller = new ApiController(
|
||||
Application::APP_ID,
|
||||
$this->request,
|
||||
$this->config,
|
||||
$this->l10n
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetHello(): void {
|
||||
// Mock config to return empty string (no previous hello)
|
||||
$this->config->method('getValueString')
|
||||
->willReturn('');
|
||||
|
||||
$resp = $this->controller->getHello()->getData();
|
||||
|
||||
$this->assertIsArray($resp);
|
||||
$this->assertArrayHasKey('message', $resp);
|
||||
$this->assertArrayHasKey('at', $resp);
|
||||
$this->assertEquals('👋 Hello from server!', $resp['message']);
|
||||
$this->assertNull($resp['at']);
|
||||
}
|
||||
|
||||
public function testPostHello(): void {
|
||||
// Expect setValueString to be called to save the timestamp
|
||||
$this->config->expects($this->once())
|
||||
->method('setValueString');
|
||||
|
||||
$resp = $this->controller->postHello([
|
||||
'name' => 'World',
|
||||
'theme' => 'dark',
|
||||
'items' => ['item1', 'item2'],
|
||||
'counter' => 5
|
||||
])->getData();
|
||||
|
||||
$this->assertIsArray($resp);
|
||||
$this->assertArrayHasKey('message', $resp);
|
||||
$this->assertArrayHasKey('at', $resp);
|
||||
$this->assertStringContainsString('World', $resp['message']);
|
||||
$this->assertNotEmpty($resp['at']);
|
||||
}
|
||||
}
|
||||
77
tests/unit/Service/HouseAuthServiceTest.php
Normal file
77
tests/unit/Service/HouseAuthServiceTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Tests\Unit\Service;
|
||||
|
||||
use OCA\Pantry\Db\HouseMember;
|
||||
use OCA\Pantry\Db\HouseMemberMapper;
|
||||
use OCA\Pantry\Exception\ForbiddenException;
|
||||
use OCA\Pantry\Service\HouseAuthService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HouseAuthServiceTest extends TestCase {
|
||||
/** @var HouseMemberMapper&MockObject */
|
||||
private HouseMemberMapper $mapper;
|
||||
private HouseAuthService $svc;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->mapper = $this->createMock(HouseMemberMapper::class);
|
||||
$this->svc = new HouseAuthService($this->mapper);
|
||||
}
|
||||
|
||||
private function makeMember(string $role): HouseMember {
|
||||
$m = new HouseMember();
|
||||
$m->setHouseId(1);
|
||||
$m->setUserId('alice');
|
||||
$m->setRole($role);
|
||||
$m->setJoinedAt(0);
|
||||
return $m;
|
||||
}
|
||||
|
||||
public function testRequireMemberAllowsAnyRole(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_MEMBER));
|
||||
$this->svc->requireMember(1, 'alice');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testRequireMemberForbidsNonMember(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn(null);
|
||||
$this->expectException(ForbiddenException::class);
|
||||
$this->svc->requireMember(1, 'bob');
|
||||
}
|
||||
|
||||
public function testRequireAdminAllowsAdmin(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_ADMIN));
|
||||
$this->svc->requireAdmin(1, 'alice');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testRequireAdminAllowsOwner(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_OWNER));
|
||||
$this->svc->requireAdmin(1, 'alice');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testRequireAdminForbidsPlainMember(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_MEMBER));
|
||||
$this->expectException(ForbiddenException::class);
|
||||
$this->svc->requireAdmin(1, 'alice');
|
||||
}
|
||||
|
||||
public function testRequireOwnerForbidsAdmin(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_ADMIN));
|
||||
$this->expectException(ForbiddenException::class);
|
||||
$this->svc->requireOwner(1, 'alice');
|
||||
}
|
||||
|
||||
public function testRequireOwnerAllowsOwner(): void {
|
||||
$this->mapper->method('findForUserAndHouse')->willReturn($this->makeMember(HouseMember::ROLE_OWNER));
|
||||
$this->svc->requireOwner(1, 'alice');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
62
tests/unit/Service/RecurrenceServiceTest.php
Normal file
62
tests/unit/Service/RecurrenceServiceTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Tests\Unit\Service;
|
||||
|
||||
use OCA\Pantry\Service\RecurrenceService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RecurrenceServiceTest extends TestCase {
|
||||
private RecurrenceService $svc;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->svc = new RecurrenceService();
|
||||
}
|
||||
|
||||
public function testValidateAcceptsBareRule(): void {
|
||||
$this->svc->validate('FREQ=WEEKLY;INTERVAL=1');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testValidateAcceptsPrefixedRule(): void {
|
||||
$this->svc->validate('RRULE:FREQ=DAILY');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testValidateRejectsGarbage(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->validate('not-an-rrule');
|
||||
}
|
||||
|
||||
public function testWeeklyNextOccurrence(): void {
|
||||
$from = new \DateTimeImmutable('2026-04-05T12:00:00Z');
|
||||
$next = $this->svc->computeNextOccurrence('FREQ=WEEKLY;INTERVAL=1', $from);
|
||||
$this->assertNotNull($next);
|
||||
$this->assertSame('2026-04-12', $next->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testDailyNextOccurrence(): void {
|
||||
$from = new \DateTimeImmutable('2026-04-05T08:00:00Z');
|
||||
$next = $this->svc->computeNextOccurrence('FREQ=DAILY', $from);
|
||||
$this->assertNotNull($next);
|
||||
$this->assertSame('2026-04-06', $next->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testMonthlyNextOccurrence(): void {
|
||||
$from = new \DateTimeImmutable('2026-04-05T00:00:00Z');
|
||||
$next = $this->svc->computeNextOccurrence('FREQ=MONTHLY;INTERVAL=1', $from);
|
||||
$this->assertNotNull($next);
|
||||
$this->assertSame('2026-05-05', $next->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testBiweeklyNextOccurrence(): void {
|
||||
$from = new \DateTimeImmutable('2026-04-05T00:00:00Z');
|
||||
$next = $this->svc->computeNextOccurrence('FREQ=WEEKLY;INTERVAL=2', $from);
|
||||
$this->assertNotNull($next);
|
||||
$this->assertSame('2026-04-19', $next->format('Y-m-d'));
|
||||
}
|
||||
}
|
||||
139
tests/unit/Service/ShoppingListServiceTest.php
Normal file
139
tests/unit/Service/ShoppingListServiceTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Pantry\Tests\Unit\Service;
|
||||
|
||||
use OCA\Pantry\Db\ShoppingList;
|
||||
use OCA\Pantry\Db\ShoppingListItem;
|
||||
use OCA\Pantry\Db\ShoppingListItemMapper;
|
||||
use OCA\Pantry\Db\ShoppingListMapper;
|
||||
use OCA\Pantry\Service\RecurrenceService;
|
||||
use OCA\Pantry\Service\ShoppingListService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ShoppingListServiceTest extends TestCase {
|
||||
/** @var ShoppingListMapper&MockObject */
|
||||
private ShoppingListMapper $listMapper;
|
||||
/** @var ShoppingListItemMapper&MockObject */
|
||||
private ShoppingListItemMapper $itemMapper;
|
||||
private ShoppingListService $svc;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->listMapper = $this->createMock(ShoppingListMapper::class);
|
||||
$this->itemMapper = $this->createMock(ShoppingListItemMapper::class);
|
||||
$this->svc = new ShoppingListService(
|
||||
$this->listMapper,
|
||||
$this->itemMapper,
|
||||
new RecurrenceService(),
|
||||
);
|
||||
}
|
||||
|
||||
private function makeItem(array $overrides = []): ShoppingListItem {
|
||||
$item = new ShoppingListItem();
|
||||
$item->setListId($overrides['listId'] ?? 1);
|
||||
$item->setName($overrides['name'] ?? 'Milk');
|
||||
$item->setCategory($overrides['category'] ?? null);
|
||||
$item->setQuantity($overrides['quantity'] ?? null);
|
||||
$item->setBought($overrides['bought'] ?? false);
|
||||
$item->setBoughtAt($overrides['boughtAt'] ?? null);
|
||||
$item->setBoughtBy($overrides['boughtBy'] ?? null);
|
||||
$item->setRrule($overrides['rrule'] ?? null);
|
||||
$item->setNextDueAt($overrides['nextDueAt'] ?? null);
|
||||
$item->setSortOrder($overrides['sortOrder'] ?? 0);
|
||||
$item->setCreatedAt($overrides['createdAt'] ?? 0);
|
||||
$item->setUpdatedAt($overrides['updatedAt'] ?? 0);
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function testListItemsAutoUnchecksDueRecurring(): void {
|
||||
$now = 2_000_000_000;
|
||||
$dueItem = $this->makeItem([
|
||||
'bought' => true,
|
||||
'boughtAt' => $now - 86400 * 8,
|
||||
'boughtBy' => 'alice',
|
||||
'rrule' => 'FREQ=WEEKLY',
|
||||
'nextDueAt' => $now - 10,
|
||||
]);
|
||||
$freshItem = $this->makeItem([
|
||||
'bought' => true,
|
||||
'boughtAt' => $now - 3600,
|
||||
'boughtBy' => 'alice',
|
||||
'rrule' => 'FREQ=WEEKLY',
|
||||
'nextDueAt' => $now + 86400 * 3,
|
||||
]);
|
||||
|
||||
$this->itemMapper->method('findByList')->willReturn([$dueItem, $freshItem]);
|
||||
$this->itemMapper->expects($this->once())
|
||||
->method('update')
|
||||
->with($this->callback(function (ShoppingListItem $i) {
|
||||
return $i->getBought() === false
|
||||
&& $i->getBoughtAt() === null
|
||||
&& $i->getBoughtBy() === null
|
||||
&& $i->getNextDueAt() === null;
|
||||
}));
|
||||
|
||||
$result = $this->svc->listItems(1, $now);
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertFalse($result[0]->getBought(), 'Due item should be reopened');
|
||||
$this->assertTrue($result[1]->getBought(), 'Fresh item should stay bought');
|
||||
}
|
||||
|
||||
public function testToggleItemOnNonRecurringDoesNotSetNextDue(): void {
|
||||
$item = $this->makeItem();
|
||||
$this->itemMapper->method('findById')->willReturn($item);
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', 1_000_000_000);
|
||||
$this->assertTrue($toggled->getBought());
|
||||
$this->assertSame('alice', $toggled->getBoughtBy());
|
||||
$this->assertSame(1_000_000_000, $toggled->getBoughtAt());
|
||||
$this->assertNull($toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
public function testToggleItemOnRecurringComputesNextDue(): void {
|
||||
$now = 1_700_000_000; // 2023-11-14 22:13:20 UTC
|
||||
$item = $this->makeItem(['rrule' => 'FREQ=WEEKLY']);
|
||||
$this->itemMapper->method('findById')->willReturn($item);
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', $now);
|
||||
$this->assertTrue($toggled->getBought());
|
||||
$this->assertNotNull($toggled->getNextDueAt());
|
||||
$this->assertSame($now + 7 * 86400, $toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
public function testToggleItemCheckingOffClearsEverything(): void {
|
||||
$item = $this->makeItem([
|
||||
'bought' => true,
|
||||
'boughtAt' => 123,
|
||||
'boughtBy' => 'alice',
|
||||
'rrule' => 'FREQ=WEEKLY',
|
||||
'nextDueAt' => 456,
|
||||
]);
|
||||
$this->itemMapper->method('findById')->willReturn($item);
|
||||
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
|
||||
|
||||
$toggled = $this->svc->toggleItem(42, 'alice', 999);
|
||||
$this->assertFalse($toggled->getBought());
|
||||
$this->assertNull($toggled->getBoughtAt());
|
||||
$this->assertNull($toggled->getBoughtBy());
|
||||
$this->assertNull($toggled->getNextDueAt());
|
||||
}
|
||||
|
||||
public function testAddItemRejectsEmptyName(): void {
|
||||
$this->listMapper->method('findById')->willReturn(new ShoppingList());
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->addItem(1, ['name' => ' ']);
|
||||
}
|
||||
|
||||
public function testAddItemRejectsBadRrule(): void {
|
||||
$this->listMapper->method('findById')->willReturn(new ShoppingList());
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->svc->addItem(1, ['name' => 'Eggs', 'rrule' => 'not valid']);
|
||||
}
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user