glp-companion/lib/features/recipes/data/datasources/mealie_api_client.dart

383 lines
14 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/utils/uuid.dart';
import '../../../meal_planner/domain/entities/macro_profile.dart';
import '../../../meal_planner/domain/entities/recipe.dart';
import '../../../meal_planner/domain/entities/recipe_ingredient.dart';
/// Thin REST client for a self-hosted Mealie v1.x server (Phase 4B: private
/// recipe sources — architecture §4.9).
///
/// Every call authenticates with `Authorization: Bearer <api token>`; the
/// token is passed in per call (read from `CryptoManager`'s platform vault by
/// the caller) so the client itself never retains credentials.
///
/// Normalization contract (docs/architecture.md §4.9.2):
/// * listing — `GET {base}/api/recipes?perPage=-1` returns every summary;
/// * details — `GET {base}/api/recipes/{slug}` adds `recipeIngredient`,
/// `recipeInstructions`, and the `nutrition` object;
/// * image — `{base}/api/media/recipes/{id}/images/min-original.webp`;
/// * source — `{base}/recipe/{slug}`, also used as the stable primary key:
/// the Mealie recipe UUID becomes `Recipe.id`, so re-syncs are idempotent
/// `insertOnConflictUpdate` upserts.
class MealieApiClient {
MealieApiClient({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
/// Per-request network budget: a healthy LAN/instance answers well under
/// this; a timeout surfaces as a failed connection test instead of a hung
/// dialog.
static const Duration requestTimeout = Duration(seconds: 15);
/// Default instance offered by the connect dialog hint.
static const String defaultBaseUrl = 'https://mealie.kellfamilyhome.com';
/// Trims whitespace and trailing slashes so URL joins never double up.
static String normalizeBaseUrl(String baseUrl) {
var trimmed = baseUrl.trim();
while (trimmed.endsWith('/')) {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
return trimmed;
}
/// Whether [sourceUrl] points at the Mealie web route `{base}/recipe/…` of
/// [baseUrl] — used to badge/filter privately synced recipes. A host-based
/// fallback keeps already-synced rows recognized after scheme/port drift.
static bool isMealieSourceUrl(String sourceUrl, String baseUrl) {
final base = normalizeBaseUrl(baseUrl);
if (base.isEmpty) return false;
final url = sourceUrl.trim();
if (url.startsWith('$base/recipe/')) return true;
try {
final source = Uri.parse(url);
final server = Uri.parse(base);
return source.host.isNotEmpty &&
source.host == server.host &&
source.path.startsWith('/recipe/');
} on FormatException {
return false;
}
}
/// Verifies [baseUrl] + [token] against the instance and returns `true`
/// only for HTTP 200. Probes the cheap `/api/app/about` endpoint first and
/// falls back to a one-row recipe listing (older builds gate `/about`
/// behind wider scopes).
Future<bool> testConnection(String baseUrl, String token) async {
final base = normalizeBaseUrl(baseUrl);
final trimmedToken = token.trim();
if (base.isEmpty || trimmedToken.isEmpty) return false;
const probes = <String>['/api/app/about', '/api/recipes?perPage=1'];
for (final path in probes) {
try {
final response = await _client
.get(Uri.parse('$base$path'), headers: _headers(trimmedToken))
.timeout(requestTimeout);
if (response.statusCode == 200) return true;
} catch (_) {
// Network/parse failure — fall through to the next probe.
}
}
return false;
}
/// Downloads the full recipe catalog and maps every entry onto the domain
/// [Recipe] entity:
///
/// 1. `GET {base}/api/recipes?perPage=-1` lists every recipe summary.
/// 2. Each summary is enriched with `GET {base}/api/recipes/{slug}` for
/// `recipeIngredient`, `recipeInstructions`, and `nutrition`; a failed
/// detail fetch degrades to the summary-only payload instead of
/// aborting the whole sync.
/// 3. Image and source URLs are constructed per the §4.9.2 contract.
///
/// Throws [MealieApiException] when the listing itself is rejected.
Future<List<Recipe>> fetchAllRecipes(String baseUrl, String token) async {
final base = normalizeBaseUrl(baseUrl);
final response = await _client
.get(Uri.parse('$base/api/recipes?perPage=-1'), headers: _headers(token))
.timeout(requestTimeout);
if (response.statusCode != 200) {
throw MealieApiException(
'Mealie responded with HTTP ${response.statusCode} while listing '
'recipes.',
);
}
final recipes = <Recipe>[];
for (final summary in _decodeList(response.body)) {
final payload = await _fetchDetails(base, token, summary);
final recipe = mapRecipe(payload, baseUrl: base);
if (recipe != null) recipes.add(recipe);
}
return recipes;
}
/// Maps a Mealie recipe payload (summary merged with detail fields) onto
/// the domain [Recipe]; returns `null` when no usable title exists.
///
/// Handles both Mealie payload shapes: structured objects (ingredients as
/// `{quantity, unit, food, note}` maps, instructions as `{name, text}`
/// sections) and legacy plain-string entries.
static Recipe? mapRecipe(
Map<String, dynamic> payload, {
required String baseUrl,
}) {
final base = normalizeBaseUrl(baseUrl);
final title = payload['name']?.toString().trim() ?? '';
if (title.isEmpty) return null;
// The Mealie recipe UUID doubles as the local primary key so repeated
// syncs upsert the same row; fall back to a generated UUID only when the
// server omits `id`.
final mealieId = payload['id']?.toString().trim() ?? '';
final id = mealieId.isEmpty ? generateUuid() : mealieId;
final slug = payload['slug']?.toString().trim() ?? '';
final description = payload['description']?.toString().trim() ?? '';
return Recipe(
id: id,
title: title,
sourceUrl: slug.isEmpty ? '$base/recipe/$id' : '$base/recipe/$slug',
imageUrl: '$base/api/media/recipes/$id/images/min-original.webp',
description: description.isEmpty ? null : description,
prepTimeMinutes: _parseDurationMinutes(payload['prepTime']),
cookTimeMinutes: _parseDurationMinutes(payload['cookTime']),
servings: _parseServings(payload),
ingredients: _parseIngredients(payload['recipeIngredient']),
instructions: _parseInstructions(payload['recipeInstructions']),
macrosPerServing: _parseNutrition(payload['nutrition']),
);
}
// ---------------------------------------------------------------------------
// Networking
// ---------------------------------------------------------------------------
static Map<String, String> _headers(String token) => <String, String>{
'Authorization': 'Bearer ${token.trim()}',
'Accept': 'application/json',
'User-Agent': 'GLP-1 Companion/0.1.0 (private recipe sync)',
};
/// Merges the listing summary with the detail payload for [slug]; falls
/// back to the summary alone when the slug is missing or the detail call
/// fails (partial sync beats no sync).
Future<Map<String, dynamic>> _fetchDetails(
String base,
String token,
Map<String, dynamic> summary,
) async {
final slug = summary['slug']?.toString().trim() ?? '';
if (slug.isEmpty) return summary;
try {
final response = await _client
.get(
Uri.parse('$base/api/recipes/${Uri.encodeComponent(slug)}'),
headers: _headers(token),
)
.timeout(requestTimeout);
if (response.statusCode != 200) return summary;
final decoded = jsonDecode(response.body);
if (decoded is! Map) return summary;
return <String, dynamic>{
...summary,
...Map<String, dynamic>.from(decoded),
};
} catch (_) {
return summary;
}
}
/// Accepts both response shapes of `GET /api/recipes`: a bare JSON array
/// (`perPage=-1`) and a paginated `{items: []}` envelope.
static List<Map<String, dynamic>> _decodeList(String body) {
final decoded = jsonDecode(body);
final List<dynamic> raw;
if (decoded is List) {
raw = decoded;
} else if (decoded is Map && decoded['items'] is List) {
raw = decoded['items'] as List<dynamic>;
} else {
raw = const <dynamic>[];
}
return <Map<String, dynamic>>[
for (final entry in raw)
if (entry is Map) Map<String, dynamic>.from(entry),
];
}
// ---------------------------------------------------------------------------
// Payload → domain normalization
// ---------------------------------------------------------------------------
/// Mealie durations are ISO-8601 (`PT30M`, `P1DT2H`); plain minute counts
/// are accepted as a lenient fallback. Unparsable values map to `0`, the
/// domain's "unknown" convention.
static int _parseDurationMinutes(Object? value) {
if (value is num) return value.round();
if (value is! String) return 0;
final trimmed = value.trim();
if (trimmed.isEmpty) return 0;
if (RegExp(r'^\d+$').hasMatch(trimmed)) return int.parse(trimmed);
final match = RegExp(
r'^P(?:(\d+(?:\.\d+)?)D)?'
r'(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$',
caseSensitive: false,
).firstMatch(trimmed);
if (match == null) return 0;
double group(int index) => double.tryParse(match.group(index) ?? '') ?? 0;
final totalMinutes =
group(1) * 1440 + group(2) * 60 + group(3) + group(4) / 60;
return totalMinutes.round();
}
/// Yield from `recipeYieldQuantity` when present, else the first integer in
/// the human `recipeYield` string (`"6 servings"` → 6), else 1.
static int _parseServings(Map<String, dynamic> payload) {
final quantity = payload['recipeYieldQuantity'];
if (quantity is num && quantity > 0) return quantity.round();
final yieldValue = payload['recipeYield'];
if (yieldValue is num && yieldValue > 0) return yieldValue.round();
if (yieldValue is String) {
final match = RegExp(r'\d+').firstMatch(yieldValue);
if (match != null) return int.parse(match.group(0)!);
}
return 1;
}
/// Ingredients arrive as structured objects (Mealie ≥1.x) or plain strings
/// (legacy exports). Objects keep their exact quantity/unit fields; note
/// text rides after a comma (`"2 cup ricotta, whole milk"`).
static List<RecipeIngredient> _parseIngredients(Object? raw) {
if (raw is! List) return const <RecipeIngredient>[];
final ingredients = <RecipeIngredient>[];
for (final item in raw) {
if (item is String) {
final line = item.trim();
if (line.isNotEmpty) ingredients.add(RecipeIngredient.parse(line));
continue;
}
if (item is! Map) continue;
final food = item['food']?.toString().trim() ?? '';
final note = item['note']?.toString().trim() ?? '';
final quantity = (item['quantity'] as num?)?.toDouble();
final unit = _unitName(item['unit'])?.trim().toLowerCase();
final normalizedUnit = (unit == null || unit.isEmpty) ? null : unit;
if (food.isEmpty && note.isEmpty && quantity == null) continue;
final buffer = StringBuffer();
if (quantity != null) buffer.write('${_trimDouble(quantity)} ');
if (normalizedUnit != null) buffer.write('$normalizedUnit ');
buffer.write(food);
if (note.isNotEmpty) {
if (food.isNotEmpty) buffer.write(', ');
buffer.write(note);
}
ingredients.add(
RecipeIngredient(
rawText: buffer.toString().trim(),
parsedItemName:
note.isEmpty ? food : (food.isEmpty ? note : '$food, $note'),
quantity: quantity,
unit: normalizedUnit,
),
);
}
return ingredients;
}
/// Instructions arrive as step sections (`{name, text}`) or plain strings;
/// sections flatten to their text (or name when the text is empty), keeping
/// the server's ordering.
static List<String> _parseInstructions(Object? raw) {
if (raw is! List) return const <String>[];
final steps = <String>[];
for (final item in raw) {
if (item is String) {
final text = item.trim();
if (text.isNotEmpty) steps.add(text);
continue;
}
if (item is! Map) continue;
final text = item['text']?.toString().trim() ?? '';
if (text.isNotEmpty) {
steps.add(text);
continue;
}
final name = item['name']?.toString().trim() ?? '';
if (name.isNotEmpty) steps.add(name);
}
return steps;
}
/// Nutrition values mix numbers and display strings (`"512 kcal"`,
/// `"30.5 g"`); both parse through the same first-number extraction.
static MacroProfile _parseNutrition(Object? raw) {
if (raw is! Map) return const MacroProfile();
return MacroProfile(
calories: _parseNumber(raw['calories']).round(),
proteinGrams: _parseNumber(raw['proteinContent']),
carbsGrams: _parseNumber(raw['carbohydrateContent']),
fatGrams: _parseNumber(raw['fatContent']),
);
}
static double _parseNumber(Object? value) {
if (value is num) return value.toDouble();
if (value is String) {
final match =
RegExp(r'-?\d+(?:\.\d+)?').firstMatch(value.replaceAll(',', ''));
if (match != null) return double.tryParse(match.group(0)!) ?? 0;
}
return 0;
}
/// Mealie units may be a bare string (`"cup"`) or a unit object with a
/// `name` / `abbreviation` (Mealie group units).
static String? _unitName(Object? unit) {
if (unit is String) return unit;
if (unit is Map) {
final name = unit['name']?.toString().trim();
if (name != null && name.isNotEmpty) return name;
final abbreviation = unit['abbreviation']?.toString().trim();
if (abbreviation != null && abbreviation.isNotEmpty) {
return abbreviation;
}
}
return null;
}
/// Renders `2.0` as `"2"` so raw ingredient text matches what humans read.
static String _trimDouble(double value) {
final rounded = value.roundToDouble();
return rounded == value ? rounded.toStringAsFixed(0) : value.toString();
}
/// Releases the underlying HTTP client.
void close() => _client.close();
}
/// Non-fatal failure surfaced by [MealieApiClient]; callers degrade to an
/// inline error message (connection test / sync status).
class MealieApiException implements Exception {
const MealieApiException(this.message);
final String message;
@override
String toString() => 'MealieApiException($message)';
}