mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
330 lines
9.8 KiB
PHP
330 lines
9.8 KiB
PHP
<?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\Checklist;
|
|
use OCA\Pantry\Db\ChecklistItem;
|
|
use OCA\Pantry\Db\ChecklistItemMapper;
|
|
use OCA\Pantry\Db\ChecklistMapper;
|
|
use OCA\Pantry\Exception\NotFoundException;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
|
|
class ChecklistService {
|
|
public function __construct(
|
|
private ChecklistMapper $listMapper,
|
|
private ChecklistItemMapper $itemMapper,
|
|
private RecurrenceService $recurrence,
|
|
) {
|
|
}
|
|
|
|
// ----- Lists -----
|
|
|
|
/**
|
|
* @return Checklist[]
|
|
*/
|
|
public function listForHouse(int $houseId): array {
|
|
return $this->listMapper->findByHouse($houseId);
|
|
}
|
|
|
|
public function getList(int $listId): Checklist {
|
|
try {
|
|
return $this->listMapper->findById($listId);
|
|
} catch (DoesNotExistException) {
|
|
throw new NotFoundException('List not found');
|
|
}
|
|
}
|
|
|
|
public function createList(int $houseId, string $name, ?string $description, ?string $icon = null): Checklist {
|
|
$name = trim($name);
|
|
if ($name === '') {
|
|
throw new \InvalidArgumentException('List name cannot be empty');
|
|
}
|
|
$now = time();
|
|
$list = new Checklist();
|
|
$list->setHouseId($houseId);
|
|
$list->setName($name);
|
|
$list->setDescription($description !== null && $description !== '' ? $description : null);
|
|
if ($icon !== null && $icon !== '') {
|
|
$list->setIcon($icon);
|
|
}
|
|
$list->setSortOrder(0);
|
|
$list->setCreatedAt($now);
|
|
$list->setUpdatedAt($now);
|
|
/** @var Checklist $saved */
|
|
$saved = $this->listMapper->insert($list);
|
|
return $saved;
|
|
}
|
|
|
|
public function updateList(int $listId, array $patch): Checklist {
|
|
$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['icon'])) {
|
|
$icon = trim((string)$patch['icon']);
|
|
if ($icon !== '') {
|
|
$list->setIcon($icon);
|
|
}
|
|
}
|
|
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 ChecklistItem[]
|
|
*/
|
|
public function listItems(int $listId, ?int $now = null): array {
|
|
// Eagerly reopen any due recurring items in this list before returning.
|
|
$this->reopenDueItems($now);
|
|
return $this->itemMapper->findByList($listId);
|
|
}
|
|
|
|
public function getItem(int $itemId): ChecklistItem {
|
|
try {
|
|
return $this->itemMapper->findById($itemId);
|
|
} catch (DoesNotExistException) {
|
|
throw new NotFoundException('Item not found');
|
|
}
|
|
}
|
|
|
|
public function addItem(int $listId, array $data): ChecklistItem {
|
|
// 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 ChecklistItem();
|
|
$item->setListId($listId);
|
|
$item->setName($name);
|
|
$item->setCategoryId($this->intOrNull($data['categoryId'] ?? null));
|
|
$item->setQuantity($this->strOrNull($data['quantity'] ?? null));
|
|
$item->setDone(false);
|
|
$item->setDoneAt(null);
|
|
$item->setDoneBy(null);
|
|
$item->setRrule($rrule);
|
|
$repeatFromCompletion = !empty($data['repeatFromCompletion']);
|
|
$item->setRepeatFromCompletion($repeatFromCompletion);
|
|
// For fixed-schedule items, compute the first due time immediately.
|
|
if ($rrule !== null && !$repeatFromCompletion) {
|
|
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
|
} else {
|
|
$item->setNextDueAt(null);
|
|
}
|
|
$item->setImageFileId($this->intOrNull($data['imageFileId'] ?? null));
|
|
$item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0);
|
|
$item->setCreatedAt($now);
|
|
$item->setUpdatedAt($now);
|
|
/** @var ChecklistItem $saved */
|
|
$saved = $this->itemMapper->insert($item);
|
|
return $saved;
|
|
}
|
|
|
|
public function updateItem(int $itemId, array $patch): ChecklistItem {
|
|
$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('categoryId', $patch)) {
|
|
$item->setCategoryId($this->intOrNull($patch['categoryId']));
|
|
}
|
|
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->getDone()) {
|
|
$item->setNextDueAt(null);
|
|
}
|
|
} else {
|
|
$rrule = trim((string)$rrule);
|
|
$this->recurrence->validate($rrule);
|
|
$item->setRrule($rrule);
|
|
}
|
|
}
|
|
if (array_key_exists('repeatFromCompletion', $patch)) {
|
|
$item->setRepeatFromCompletion((bool)$patch['repeatFromCompletion']);
|
|
}
|
|
if (array_key_exists('imageFileId', $patch)) {
|
|
$item->setImageFileId($this->intOrNull($patch['imageFileId']));
|
|
}
|
|
// If already done and rrule or mode changed, recompute next due.
|
|
if ($item->getDone() && $item->getRrule() !== null
|
|
&& (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) {
|
|
$item->setNextDueAt($this->computeNextDueAt($item, time())?->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): ChecklistItem {
|
|
$item = $this->getItem($itemId);
|
|
$now ??= time();
|
|
|
|
if (!$item->getDone()) {
|
|
$item->setDone(true);
|
|
$item->setDoneAt($now);
|
|
$item->setDoneBy($uid);
|
|
if ($item->getRrule() !== null) {
|
|
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
|
}
|
|
} else {
|
|
$item->setDone(false);
|
|
$item->setDoneAt(null);
|
|
$item->setDoneBy(null);
|
|
$item->setNextDueAt(null);
|
|
}
|
|
$item->setUpdatedAt($now);
|
|
$this->itemMapper->update($item);
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* Compute the next due time for an item that was just marked done.
|
|
*
|
|
* - "from completion" mode: interval counts from now — next occurrence = now + one step.
|
|
* - "fixed schedule" mode: the schedule is anchored at the item's creation time; next
|
|
* occurrence is the first one strictly after now on that anchored series.
|
|
*/
|
|
private function computeNextDueAt(ChecklistItem $item, int $now): ?\DateTimeImmutable {
|
|
$rrule = $item->getRrule();
|
|
if ($rrule === null) {
|
|
return null;
|
|
}
|
|
$nowDt = (new \DateTimeImmutable())->setTimestamp($now);
|
|
if ($item->getRepeatFromCompletion()) {
|
|
return $this->recurrence->computeNextOccurrence($rrule, $nowDt);
|
|
}
|
|
$anchor = (new \DateTimeImmutable())->setTimestamp($item->getCreatedAt() ?: $now);
|
|
return $this->recurrence->nextOccurrenceAfter($rrule, $anchor, $nowDt);
|
|
}
|
|
|
|
/**
|
|
* Reopen all recurring items whose next_due_at has passed.
|
|
*
|
|
* Called both lazily from listItems() and periodically by the background job.
|
|
*
|
|
* @return ChecklistItem[] The items that were reopened.
|
|
*/
|
|
public function reopenDueItems(?int $now = null): array {
|
|
$now ??= time();
|
|
$items = $this->itemMapper->findDueRecurring($now);
|
|
foreach ($items as $item) {
|
|
$item->setDone(false);
|
|
$item->setDoneAt(null);
|
|
$item->setDoneBy(null);
|
|
if ($item->getRepeatFromCompletion()) {
|
|
// Completion-based: next interval starts when the user checks
|
|
// the item off again, so clear the schedule for now.
|
|
$item->setNextDueAt(null);
|
|
} else {
|
|
// Fixed schedule: immediately compute the next occurrence so the
|
|
// item keeps cycling even if the user never interacts with it.
|
|
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
|
}
|
|
$item->setUpdatedAt($now);
|
|
$this->itemMapper->update($item);
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Advance fixed-schedule undone items whose next_due_at has passed.
|
|
*
|
|
* Unlike reopenDueItems(), these items are already undone — nothing to
|
|
* reopen. We just bump next_due_at to the next occurrence so the
|
|
* background job can re-notify on the next cycle.
|
|
*
|
|
* @return ChecklistItem[] The items whose schedule was advanced.
|
|
*/
|
|
public function advanceDueReminders(?int $now = null): array {
|
|
$now ??= time();
|
|
$items = $this->itemMapper->findDueFixedScheduleUndone($now);
|
|
foreach ($items as $item) {
|
|
$item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp());
|
|
$item->setUpdatedAt($now);
|
|
$this->itemMapper->update($item);
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private function intOrNull(mixed $v): ?int {
|
|
if ($v === null || $v === '' || $v === false) {
|
|
return null;
|
|
}
|
|
if (is_int($v)) {
|
|
return $v;
|
|
}
|
|
if (is_string($v) && ctype_digit($v)) {
|
|
return (int)$v;
|
|
}
|
|
if (is_numeric($v)) {
|
|
return (int)$v;
|
|
}
|
|
return null;
|
|
}
|
|
}
|