396 lines
12 KiB
PHP
396 lines
12 KiB
PHP
<?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 = mb_strtolower(trim($name), 'UTF-8');
|
|
$s = mb_strtolower(trim($size), 'UTF-8');
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
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<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);
|
|
}
|