Files
nextcloud-pantry/lib/Service/ChecklistService.php

365 lines
11 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, string $sortBy = 'custom', ?int $now = null): array {
// Eagerly reopen any due recurring items in this list before returning.
$this->reopenDueItems($now);
return $this->itemMapper->findByList($listId, $sortBy);
}
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->setDescription($this->strOrNull($data['description'] ?? null));
$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('description', $patch)) {
$item->setDescription($this->strOrNull($patch['description']));
}
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;
}
/**
* Batch reorder items within a list.
*
* @param int $listId List id.
* @param array<array{id: int, sortOrder: int}> $items Reorder entries.
*/
public function reorderItems(int $listId, array $items): void {
foreach ($items as $entry) {
$id = (int)($entry['id'] ?? 0);
$sortOrder = (int)($entry['sortOrder'] ?? 0);
if ($id <= 0) {
continue;
}
try {
$item = $this->itemMapper->findById($id);
} catch (DoesNotExistException) {
continue;
}
if ($item->getListId() !== $listId) {
continue;
}
$item->setSortOrder($sortOrder);
$item->setUpdatedAt(time());
$this->itemMapper->update($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;
}
}