familyHub/includes/grocery_helpers.php
2026-04-03 20:43:00 -04:00

519 lines
15 KiB
PHP

<?php
require_once __DIR__ . '/utils.php';
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeStoresList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeCatalogList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string {
$n = familyHubStrLowerUtf8(trim($name));
$s = familyHubStrLowerUtf8(trim($size));
return $storeId . '|' . $n . '|' . $s;
}
/**
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
*/
function defaultGroceryListsShape(): array {
return ['byStore' => []];
}
/**
* @param mixed $raw
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
*/
function normalizeGroceryLists($raw): array {
$base = defaultGroceryListsShape();
if (!is_array($raw)) {
return $base;
}
if (isset($raw['byStore']) && is_array($raw['byStore'])) {
$base['byStore'] = [];
foreach ($raw['byStore'] as $storeId => $items) {
$sid = (string) $storeId;
if ($sid === '') {
continue;
}
if (!is_array($items) || !array_is_list($items)) {
$base['byStore'][$sid] = [];
continue;
}
$clean = [];
foreach ($items as $it) {
if (is_array($it) && !empty($it['id']) && is_string($it['id'])) {
$clean[] = $it;
}
}
$base['byStore'][$sid] = $clean;
}
return $base;
}
return $base;
}
/**
* @param array<int, array<string, mixed>> $stores
*/
function findStoreById(array $stores, string $id): ?array {
foreach ($stores as $s) {
if (($s['id'] ?? '') === $id) {
return $s;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $catalog
*/
function findCatalogById(array $catalog, string $id): ?array {
foreach ($catalog as $c) {
if (($c['id'] ?? '') === $id) {
return $c;
}
}
return null;
}
/**
* Deduped rows for picker: one per dedupeKey (first wins for label).
*
* @param array<int, array<string, mixed>> $catalog
* @return array<int, array<string, mixed>>
*/
function groceryCatalogPickerOptions(array $catalog, ?string $storeId = null): array {
$seen = [];
$out = [];
foreach ($catalog as $row) {
if ($storeId !== null && (string) ($row['storeId'] ?? '') !== $storeId) {
continue;
}
$key = (string) ($row['dedupeKey'] ?? '');
if ($key === '') {
$key = groceryCatalogDedupeKey(
(string) ($row['storeId'] ?? ''),
(string) ($row['name'] ?? ''),
(string) ($row['defaultSize'] ?? '')
);
}
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
}
usort($out, static function ($a, $b) {
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return $out;
}
/**
* Migrate legacy groceries.json flat list into stores + lists + empty catalog.
*
* @return array{stores: array<int, array<string, mixed>>, lists: array{byStore: array<string, array<int, array<string, mixed>>>}, migrated: bool}
*/
function migrateLegacyGroceriesIfNeeded(): array {
$listsPath = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$hasNew = count($listsPath['byStore']) > 0;
$legacy = readJsonFile('groceries.json');
$legacyList = is_array($legacy) && array_is_list($legacy) ? $legacy : [];
if ($hasNew || count($legacyList) === 0) {
return ['stores' => normalizeStoresList(readJsonFile('stores.json')), 'lists' => $listsPath, 'migrated' => false];
}
$storeId = bin2hex(random_bytes(8));
$stores = [[
'id' => $storeId,
'name' => 'General',
'sort' => 0,
]];
$items = [];
foreach ($legacyList as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$cat = trim((string) ($row['category'] ?? ''));
$desc = trim((string) ($row['description'] ?? ''));
if ($cat !== '' && $desc !== '') {
$desc = 'Category: ' . $cat . "\n" . $desc;
} elseif ($cat !== '') {
$desc = 'Category: ' . $cat;
}
$items[] = [
'id' => bin2hex(random_bytes(8)),
'catalogId' => null,
'name' => $name,
'description' => $desc,
'size' => '',
'quantity' => (string) ($row['quantity'] ?? '1'),
'price' => '',
'image' => '',
'status' => 'active',
'purchasedAt' => null,
'source' => 'manual',
'recurringIntervalDays' => 0,
'addedAt' => gmdate('c'),
];
}
$listsPath['byStore'][$storeId] = $items;
writeJsonFile('stores.json', $stores);
writeJsonFile('grocery_lists.json', $listsPath);
return ['stores' => $stores, 'lists' => $listsPath, 'migrated' => true];
}
/**
* Append one shopping line using already-loaded catalog and lists (mutates arrays).
*
* @param array<int, array<string, mixed>> $catalog
* @param array{byStore: array<string, array<int, array<string, mixed>>>} $lists
* @param array<int, array<string, mixed>> $stores
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
*/
function groceryAppendShoppingLineIntoBuffers(
array &$catalog,
array &$lists,
array $stores,
string $storeId,
string $name,
string $description = '',
string $size = '',
string $quantity = '1',
string $price = '',
string $image = '',
string $source = 'manual',
int $recurringIntervalDays = 0,
?string $mealId = null,
?string $mealTitleMeta = null
): array {
if (findStoreById($stores, $storeId) === null) {
return ['ok' => false, 'error' => 'Invalid store'];
}
$name = trim($name);
if ($name === '') {
return ['ok' => false, 'error' => 'Name is required'];
}
$description = trim($description);
$size = trim($size);
$quantity = trim($quantity) !== '' ? trim($quantity) : '1';
$price = trim($price);
$image = trim($image);
$dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size);
$catalogId = null;
foreach ($catalog as $i => $c) {
if ((string) ($c['storeId'] ?? '') === $storeId && (string) ($c['dedupeKey'] ?? '') === $dedupeKey) {
$catalogId = (string) $c['id'];
$catalog[$i]['name'] = $name;
$catalog[$i]['description'] = $description;
$catalog[$i]['defaultSize'] = $size;
$catalog[$i]['defaultImage'] = $image;
$catalog[$i]['dedupeKey'] = $dedupeKey;
break;
}
}
if ($catalogId === null) {
$catalogId = bin2hex(random_bytes(8));
$catalog[] = [
'id' => $catalogId,
'storeId' => $storeId,
'dedupeKey' => $dedupeKey,
'name' => $name,
'description' => $description,
'defaultSize' => $size,
'defaultImage' => $image,
'lastPurchaseAt' => null,
'recurringIntervalDays' => 0,
'nextDueDate' => null,
];
}
$status = 'active';
if ($source === 'meal_plan' || $source === 'pending_review') {
$status = 'pending_review';
}
$src = $source === 'pending_review' ? 'meal_plan' : $source;
if (!in_array($src, ['manual', 'meal_plan'], true)) {
$src = 'manual';
}
$line = normalizeGroceryLineItem([
'id' => bin2hex(random_bytes(8)),
'catalogId' => $catalogId,
'name' => $name,
'description' => $description,
'size' => $size,
'quantity' => $quantity,
'price' => $price,
'image' => $image,
'status' => $status,
'purchasedAt' => null,
'source' => $src,
'recurringIntervalDays' => max(0, $recurringIntervalDays),
'addedAt' => gmdate('c'),
]);
if ($mealId !== null && $mealId !== '') {
$line['mealId'] = $mealId;
}
if ($mealTitleMeta !== null && $mealTitleMeta !== '') {
$line['mealTitle'] = $mealTitleMeta;
}
if (!isset($lists['byStore'][$storeId])) {
$lists['byStore'][$storeId] = [];
}
$lists['byStore'][$storeId][] = $line;
return ['ok' => true, 'item' => $line];
}
/**
* Create catalog row + list line (used by grocery_item_create and meal → grocery).
*
* @param array<int, array<string, mixed>> $stores
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
*/
function groceryAppendShoppingLine(
array $stores,
string $storeId,
string $name,
string $description = '',
string $size = '',
string $quantity = '1',
string $price = '',
string $image = '',
string $source = 'manual',
int $recurringIntervalDays = 0,
?string $mealId = null,
?string $mealTitleMeta = null
): array {
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$res = groceryAppendShoppingLineIntoBuffers(
$catalog,
$lists,
$stores,
$storeId,
$name,
$description,
$size,
$quantity,
$price,
$image,
$source,
$recurringIntervalDays,
$mealId,
$mealTitleMeta
);
if (!$res['ok']) {
return $res;
}
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
return ['ok' => false, 'error' => 'Failed to save catalog'];
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
return ['ok' => false, 'error' => 'Failed to save grocery list'];
}
return $res;
}
/**
* Append several meal shopping lines in one disk transaction (catalog + lists).
*
* @param array<int, array<string, mixed>> $stores
* @param list<array{name?:mixed, storeId?:mixed, description?:mixed, size?:mixed, quantity?:mixed, price?:mixed, image?:mixed}> $itemRows
* @return int number of lines successfully added
*/
function groceryAppendMealItemRowsBatch(
array $stores,
array $itemRows,
string $mealId,
string $mealTitle
): int {
if (count($itemRows) === 0) {
return 0;
}
$defaultStore = groceryFirstStoreId($stores);
if ($defaultStore === '') {
return 0;
}
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$added = 0;
foreach ($itemRows as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$sid = trim((string) ($row['storeId'] ?? ''));
if ($sid === '' || findStoreById($stores, $sid) === null) {
$sid = $defaultStore;
}
$res = groceryAppendShoppingLineIntoBuffers(
$catalog,
$lists,
$stores,
$sid,
$name,
trim((string) ($row['description'] ?? '')),
trim((string) ($row['size'] ?? '')),
trim((string) ($row['quantity'] ?? '1')) ?: '1',
trim((string) ($row['price'] ?? '')),
trim((string) ($row['image'] ?? '')),
'meal_plan',
0,
$mealId !== '' ? $mealId : null,
$mealTitle !== '' ? $mealTitle : null
);
if ($res['ok']) {
$added++;
}
}
if ($added === 0) {
return 0;
}
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
return 0;
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
return 0;
}
return $added;
}
/**
* @param array<int, array<string, mixed>> $stores
*/
function groceryFirstStoreId(array $stores): string {
if (count($stores) === 0) {
return '';
}
$copy = $stores;
usort($copy, static function ($a, $b) {
$sa = (int) ($a['sort'] ?? 0);
$sb = (int) ($b['sort'] ?? 0);
if ($sa !== $sb) {
return $sa <=> $sb;
}
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return (string) ($copy[0]['id'] ?? '');
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
function normalizeGroceryLineItem(array $item): array {
if (empty($item['id']) || !is_string($item['id'])) {
$item['id'] = bin2hex(random_bytes(8));
}
$item['name'] = trim((string) ($item['name'] ?? ''));
$item['description'] = trim((string) ($item['description'] ?? ''));
$item['size'] = trim((string) ($item['size'] ?? ''));
$item['quantity'] = trim((string) ($item['quantity'] ?? '1'));
$item['price'] = trim((string) ($item['price'] ?? ''));
$item['image'] = trim((string) ($item['image'] ?? ''));
$st = (string) ($item['status'] ?? 'active');
$item['status'] = in_array($st, ['active', 'purchased', 'pending_review'], true) ? $st : 'active';
$item['purchasedAt'] = isset($item['purchasedAt']) && is_string($item['purchasedAt']) ? $item['purchasedAt'] : null;
$item['source'] = in_array($item['source'] ?? 'manual', ['manual', 'meal_plan', 'meal_detail'], true)
? $item['source']
: 'manual';
$item['recurringIntervalDays'] = max(0, (int) ($item['recurringIntervalDays'] ?? 0));
$item['catalogId'] = isset($item['catalogId']) && is_string($item['catalogId']) ? $item['catalogId'] : null;
if (empty($item['addedAt'])) {
$item['addedAt'] = gmdate('c');
}
if (!empty($item['mealId']) && is_string($item['mealId'])) {
$item['mealId'] = trim($item['mealId']);
} else {
unset($item['mealId']);
}
if (isset($item['mealTitle']) && is_string($item['mealTitle'])) {
$item['mealTitle'] = trim($item['mealTitle']);
} else {
unset($item['mealTitle']);
}
return $item;
}
/**
* @param array{byStore: array<string, array<int, array<string, mixed>>>} $lists
*/
function groceryStoreHasItems(array $lists, string $storeId): bool {
$items = $lists['byStore'][$storeId] ?? [];
return is_array($items) && count($items) > 0;
}
/**
* If there are no stores yet, create a single "Home" store and empty list bucket.
*/
function ensureDefaultGroceryStore(): void {
$stores = normalizeStoresList(readJsonFile('stores.json'));
if (count($stores) > 0) {
return;
}
$id = bin2hex(random_bytes(8));
writeJsonFile('stores.json', [['id' => $id, 'name' => 'Home', 'sort' => 0]]);
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (!isset($lists['byStore'][$id])) {
$lists['byStore'][$id] = [];
}
writeJsonFile('grocery_lists.json', $lists);
}