// 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 (array_key_exists('imageUploadedBy', $patch)) { $v = $patch['imageUploadedBy']; $item->setImageUploadedBy(is_string($v) && $v !== '' ? $v : null); } // 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; } }