// - Recipe name:
Name
// - Non-recipe:
Name
// Find all day cells with data-date
doc.Find("[data-date]").Each(func(_ int, dayEl *goquery.Selection) {
dateStr, exists := dayEl.Attr("data-date")
if !exists {
return
}
mealDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return
}
// Skip dates outside our range
if mealDate.Before(today) || mealDate.After(endDate) {
return
}
// Find meal sections within this day
dayEl.Find("div.time[data-section]").Each(func(_ int, sectionEl *goquery.Selection) {
section, _ := sectionEl.Attr("data-section")
mealType := normalizeMealType(section)
// Find meal items within this section
sectionEl.Find("[data-id]").Each(func(_ int, itemEl *goquery.Selection) {
meal := models.Meal{
Date: mealDate,
MealType: mealType,
}
// Get ID
if id, exists := itemEl.Attr("data-id"); exists {
meal.ID = id
}
// Try to get recipe name from a.title.recipe or span.title.item
if recipeLink := itemEl.Find("a.title.recipe, a.title").First(); recipeLink.Length() > 0 {
meal.RecipeName = strings.TrimSpace(recipeLink.Text())
if href, exists := recipeLink.Attr("href"); exists {
// Convert /recipes/{id}/{event_id} to /recipes/{id}/cook/{event_id}
if strings.HasPrefix(href, "/recipes/") {
parts := strings.Split(href, "/")
if len(parts) == 4 { // ["", "recipes", "id", "event_id"]
href = fmt.Sprintf("/recipes/%s/cook/%s", parts[2], parts[3])
}
}
if !strings.HasPrefix(href, "http") {
meal.RecipeURL = planToEatWebURL + href
} else {
meal.RecipeURL = href
}
}
} else if titleSpan := itemEl.Find("span.title.item, span.title").First(); titleSpan.Length() > 0 {
meal.RecipeName = strings.TrimSpace(titleSpan.Text())
}
if meal.RecipeName != "" {
meals = append(meals, meal)
}
})
})
})
log.Printf("DEBUG [PlanToEat/Meals]: Found %d meals", len(meals))
return meals, nil
}
// normalizeMealType ensures meal type matches DB constraint (breakfast, lunch, dinner, snack)
func normalizeMealType(mealType string) string {
lower := strings.ToLower(strings.TrimSpace(mealType))
switch lower {
case "breakfast":
return "breakfast"
case "lunch":
return "lunch"
case "dinner":
return "dinner"
case "snack", "xtra", "snacks":
return "snack"
default:
return "dinner"
}
}
// GetRecipes fetches recipes (for Phase 2)
func (c *PlanToEatClient) GetRecipes(ctx context.Context) error {
return fmt.Errorf("not implemented yet")
}
// AddMealToPlanner adds a meal to the planner (for Phase 2)
func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error {
return fmt.Errorf("not implemented yet")
}
// GetShoppingList fetches the shopping list by scraping the web interface
// Requires a valid session cookie set via SetSessionCookie
func (c *PlanToEatClient) GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error) {
if c.sessionCookie == "" {
return nil, fmt.Errorf("session cookie required for shopping list - use SetSessionCookie")
}
req, err := http.NewRequestWithContext(ctx, "GET", planToEatWebURL+"/shopping_lists", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Cookie", c.sessionCookie)
req.Header.Set("Accept", "text/html")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
log.Printf("DEBUG [PlanToEat/Shopping]: Fetching %s", planToEatWebURL+"/shopping_lists")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
log.Printf("DEBUG [PlanToEat/Shopping]: Response status %d", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d (session may be expired)", resp.StatusCode)
}
return parseShoppingListHTML(resp.Body)
}
// parseShoppingListHTML extracts shopping items from the HTML response
func parseShoppingListHTML(body io.Reader) ([]models.ShoppingItem, error) {
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, fmt.Errorf("failed to parse HTML: %w", err)
}
var items []models.ShoppingItem
currentStore := ""
currentCategory := ""
// Debug: log page title and structure hints
title := doc.Find("title").First().Text()
log.Printf("DEBUG [PlanToEat/Shopping]: Page title: %q", title)
// Check if we got a login page
titleLower := strings.ToLower(title)
if strings.Contains(titleLower, "login") || strings.Contains(titleLower, "log in") || strings.Contains(titleLower, "sign in") {
return nil, fmt.Errorf("session expired - got login page (title: %s)", title)
}
// Debug: count elements to understand structure
storeCount := doc.Find("div.store").Length()
log.Printf("DEBUG [PlanToEat/Shopping]: Found %d div.store elements", storeCount)
// Iterate through stores
doc.Find("div.store").Each(func(_ int, store *goquery.Selection) {
currentStore = strings.TrimSpace(store.Find("h4.store_name").First().Text())
// Clean up store name (remove count and icons)
if idx := strings.Index(currentStore, "("); idx > 0 {
currentStore = strings.TrimSpace(currentStore[:idx])
}
log.Printf("DEBUG [PlanToEat/Shopping]: Processing store: %q", currentStore)
// Iterate through categories within store
store.Find("div.category-box").Each(func(_ int, catBox *goquery.Selection) {
currentCategory = strings.TrimSpace(catBox.Find("p.category-title span").First().Text())
// Iterate through items in category
catBox.Find("li.sli").Each(func(_ int, li *goquery.Selection) {
item := models.ShoppingItem{
Store: currentStore,
Category: currentCategory,
}
// Extract ID from class (e.g., "sli i493745889")
if class, exists := li.Attr("class"); exists {
for _, c := range strings.Fields(class) {
if strings.HasPrefix(c, "i") && len(c) > 1 {
item.ID = c[1:] // Remove 'i' prefix
break
}
}
}
// Extract name
item.Name = strings.TrimSpace(li.Find("strong").First().Text())
// Extract quantity
item.Quantity = strings.TrimSpace(li.Find("span.quan").First().Text())
// Clean up HTML entities in quantity
item.Quantity = cleanQuantity(item.Quantity)
// Check if item is checked (has specific class or attribute)
if li.HasClass("checked") || li.HasClass("crossed") {
item.Checked = true
}
if item.Name != "" {
items = append(items, item)
}
})
})
})
log.Printf("DEBUG [PlanToEat/Shopping]: Parsed %d items total", len(items))
return items, nil
}
// cleanQuantity removes HTML entities and extra whitespace from quantity strings
func cleanQuantity(q string) string {
q = strings.ReplaceAll(q, "\u00a0", " ") // non-breaking space
q = strings.ReplaceAll(q, " ", " ")
return strings.TrimSpace(q)
}