383 lines
14 KiB
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)';
|
|
}
|
|
|
|
|