> */ 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> */ 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 = mb_strtolower(trim($name), 'UTF-8'); $s = mb_strtolower(trim($size), 'UTF-8'); return $storeId . '|' . $n . '|' . $s; } /** * @return array{byStore: array>>} */ function defaultGroceryListsShape(): array { return ['byStore' => []]; } /** * @param mixed $raw * @return array{byStore: array>>} */ 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> $stores */ function findStoreById(array $stores, string $id): ?array { foreach ($stores as $s) { if (($s['id'] ?? '') === $id) { return $s; } } return null; } /** * @param array> $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> $catalog * @return array> */ 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>, lists: array{byStore: array>>}, 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]; } /** * Create catalog row + list line (used by grocery_item_create and meal → grocery). * * @param array> $stores * @return array{ok: bool, item?: array, 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 { 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); $catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json')); $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; } $lists = normalizeGroceryLists(readJsonFile('grocery_lists.json')); if (!isset($lists['byStore'][$storeId])) { $lists['byStore'][$storeId] = []; } $lists['byStore'][$storeId][] = $line; 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 ['ok' => true, 'item' => $line]; } /** * @param array> $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 $item * @return array */ 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>>} $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); }