import 'dart:async'; import 'dart:convert'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../local/local_storage_service.dart'; import '../nostr/nostr_service.dart'; import '../nostr/models/nostr_event.dart'; import '../nostr/models/nostr_keypair.dart'; import '../../core/logger.dart'; import '../../core/service_locator.dart'; import 'models/recipe_model.dart'; import 'models/bookmark_category_model.dart'; /// Service for managing recipes with local storage and Nostr sync. class RecipeService { /// Local storage service. final LocalStorageService _localStorage; /// Nostr service (optional). final NostrService? _nostrService; /// Nostr keypair for signing events (optional). NostrKeyPair? _nostrKeyPair; /// Database instance (null until initialized). Database? _db; /// Current database path (for debugging and verification). String? _currentDbPath; /// Optional database path for testing (null uses default). final String? _testDbPath; /// Pending bookmark associations to restore after recipes are fetched from Nostr. /// Map of recipeId -> list of categoryIds. final Map> _pendingBookmarks = {}; /// Creates a [RecipeService] instance. RecipeService({ required LocalStorageService localStorage, NostrService? nostrService, NostrKeyPair? nostrKeyPair, String? testDbPath, }) : _localStorage = localStorage, _nostrService = nostrService, _nostrKeyPair = nostrKeyPair, _testDbPath = testDbPath; /// Sets the Nostr keypair for signing events. void setNostrKeyPair(NostrKeyPair keypair) { _nostrKeyPair = keypair; } /// Initializes the recipes table in the database. /// /// Must be called before using any other methods. Future initialize({String? sessionDbPath}) async { await _localStorage.initialize(); // Get database path final dbPath = sessionDbPath ?? _testDbPath ?? await _getDatabasePath(); // Close existing database if switching sessions if (_db != null) { await _db!.close(); _db = null; } // Open database _db = await openDatabase( dbPath, version: 2, // Incremented for bookmark tables onCreate: _onCreate, onUpgrade: _onUpgrade, ); // Store the current database path _currentDbPath = dbPath; Logger.info('RecipeService initialized with database: $dbPath'); // Log the database path for debugging Logger.debug('Current RecipeService database path: $dbPath'); } /// Reinitializes the service with a new database path (for session switching). /// /// [newDbPath] - New database path to use. /// /// Throws [Exception] if reinitialization fails. Future reinitializeForSession({ required String newDbPath, }) async { await initialize(sessionDbPath: newDbPath); } /// Creates the recipes table if it doesn't exist. Future _onCreate(Database db, int version) async { // Recipes table await db.execute(''' CREATE TABLE IF NOT EXISTS recipes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, tags TEXT NOT NULL, rating INTEGER NOT NULL DEFAULT 0, is_favourite INTEGER NOT NULL DEFAULT 0, image_urls TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, nostr_event_id TEXT ) '''); // Bookmark categories table await db.execute(''' CREATE TABLE IF NOT EXISTS bookmark_categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, color TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); // Junction table for recipe-bookmark relationships await db.execute(''' CREATE TABLE IF NOT EXISTS recipe_bookmarks ( recipe_id TEXT NOT NULL, category_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (recipe_id, category_id), FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES bookmark_categories(id) ON DELETE CASCADE ) '''); // Create indexes await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_created_at ON recipes(created_at DESC) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_is_deleted ON recipes(is_deleted) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_recipe_id ON recipe_bookmarks(recipe_id) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_category_id ON recipe_bookmarks(category_id) '''); } /// Handles database migrations when version changes. Future _onUpgrade(Database db, int oldVersion, int newVersion) async { if (oldVersion < 2) { // Migration to version 2: Add bookmark tables await db.execute(''' CREATE TABLE IF NOT EXISTS bookmark_categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, color TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); await db.execute(''' CREATE TABLE IF NOT EXISTS recipe_bookmarks ( recipe_id TEXT NOT NULL, category_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (recipe_id, category_id), FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES bookmark_categories(id) ON DELETE CASCADE ) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_recipe_id ON recipe_bookmarks(recipe_id) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_category_id ON recipe_bookmarks(category_id) '''); Logger.info('Database migrated from version $oldVersion to $newVersion'); } } /// Gets the path to the database file. /// /// Note: This is the default path. For user-specific paths, use initialize(sessionDbPath: ...) /// or reinitializeForSession(newDbPath: ...). Future _getDatabasePath() async { final documentsDirectory = await getApplicationDocumentsDirectory(); return path.join(documentsDirectory.path, 'recipes.db'); } /// Ensures the database is initialized and open. void _ensureInitialized() { if (_db == null) { throw Exception('RecipeService not initialized. Call initialize() first.'); } // Check if database is still open (sqflite doesn't have isOpen, but we can catch the error) // If database was closed, we need to reinitialize // Note: We can't easily check if database is open without trying to use it // The error will be caught by the calling method } /// Ensures the database is initialized, and reinitializes if needed. /// This is called before operations that might fail if the database was closed. Future _ensureInitializedOrReinitialize() async { if (_db == null) { // Try to reinitialize with the current user's database path final sessionService = ServiceLocator.instance.sessionService; if (sessionService != null && sessionService.currentUser != null) { final user = sessionService.currentUser!; try { // Get the user-specific database path final appDir = await getApplicationDocumentsDirectory(); final recipesDbPath = path.join(appDir.path, 'users', user.id, 'recipes.db'); await reinitializeForSession(newDbPath: recipesDbPath); Logger.info('RecipeService reinitialized automatically'); } catch (e) { Logger.warning('Failed to auto-reinitialize RecipeService: $e'); throw Exception('RecipeService not initialized. Call initialize() first.'); } } else { throw Exception('RecipeService not initialized. Call initialize() first.'); } } } /// Creates a new recipe. /// /// [recipe] - The recipe to create. /// /// Returns the created recipe with updated timestamps. /// /// Throws [Exception] if creation fails. Future createRecipe(RecipeModel recipe) async { // Ensure database is initialized, or reinitialize if needed await _ensureInitializedOrReinitialize(); try { final now = DateTime.now().millisecondsSinceEpoch; final recipeWithTimestamps = recipe.copyWith( createdAt: now, updatedAt: now, ); await _db!.insert( 'recipes', recipeWithTimestamps.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.info('Recipe created: ${recipe.id}'); Logger.debug('Recipe stored in database at: $_currentDbPath'); // Publish to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { Logger.info('Publishing recipe ${recipe.id} to Nostr...'); await _publishRecipeToNostr(recipeWithTimestamps); Logger.info('Recipe ${recipe.id} published to Nostr successfully'); } catch (e) { Logger.warning('Failed to publish recipe to Nostr: $e'); // Don't fail the creation if Nostr publish fails } } else { if (_nostrService == null) { Logger.warning('NostrService not available, skipping recipe publish'); } if (_nostrKeyPair == null) { Logger.warning('Nostr keypair not set, skipping recipe publish. Call setNostrKeyPair() first.'); } } return recipeWithTimestamps; } catch (e) { throw Exception('Failed to create recipe: $e'); } } /// Updates an existing recipe. /// /// [recipe] - The recipe with updated data. /// /// Returns the updated recipe. /// /// Throws [Exception] if update fails or recipe doesn't exist. Future updateRecipe(RecipeModel recipe) async { _ensureInitialized(); try { final updatedRecipe = recipe.copyWith( updatedAt: DateTime.now().millisecondsSinceEpoch, ); final updated = await _db!.update( 'recipes', updatedRecipe.toMap(), where: 'id = ?', whereArgs: [recipe.id], ); if (updated == 0) { throw Exception('Recipe with id ${recipe.id} not found'); } Logger.info('Recipe updated: ${recipe.id}'); // Publish to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { await _publishRecipeToNostr(updatedRecipe); } catch (e) { Logger.warning('Failed to publish recipe update to Nostr: $e'); // Don't fail the update if Nostr publish fails } } return updatedRecipe; } catch (e) { throw Exception('Failed to update recipe: $e'); } } /// Deletes a recipe. /// /// [recipeId] - The ID of the recipe to delete. /// /// Throws [Exception] if deletion fails. Future deleteRecipe(String recipeId) async { await _ensureInitializedOrReinitialize(); try { // Get the recipe BEFORE marking it as deleted (to get nostrEventId) final recipe = await getRecipe(recipeId, includeDeleted: false); final nostrEventId = recipe?.nostrEventId; // Mark as deleted instead of actually deleting final updated = await _db!.update( 'recipes', { 'is_deleted': 1, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [recipeId], ); if (updated == 0) { throw Exception('Recipe with id $recipeId not found'); } Logger.info('Recipe deleted: $recipeId'); // Publish deletion event to Nostr if available if (_nostrService != null && _nostrKeyPair != null && nostrEventId != null) { try { await _publishRecipeDeletionToNostr(recipeId, nostrEventId); } catch (e) { Logger.warning('Failed to publish recipe deletion to Nostr: $e'); // Don't fail the deletion if Nostr publish fails } } else if (nostrEventId == null) { Logger.warning('Recipe $recipeId has no Nostr event ID, skipping deletion event'); } } catch (e) { throw Exception('Failed to delete recipe: $e'); } } /// Gets a recipe by ID. /// /// [recipeId] - The ID of the recipe. /// [includeDeleted] - Whether to include deleted recipes (default: false). /// /// Returns the recipe if found, null otherwise. /// /// Throws [Exception] if retrieval fails. Future getRecipe(String recipeId, {bool includeDeleted = false}) async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'recipes', where: includeDeleted ? 'id = ?' : 'id = ? AND is_deleted = 0', whereArgs: [recipeId], limit: 1, ); if (maps.isEmpty) { return null; } return RecipeModel.fromMap(maps.first); } catch (e) { throw Exception('Failed to get recipe: $e'); } } /// Gets all recipes. /// /// [includeDeleted] - Whether to include deleted recipes (default: false). /// /// Returns a list of all recipes, ordered by creation date (newest first). /// /// Throws [Exception] if retrieval fails. Future> getAllRecipes({bool includeDeleted = false}) async { try { // Try to ensure database is initialized, or reinitialize if needed await _ensureInitializedOrReinitialize(); final List> maps = await _db!.query( 'recipes', where: includeDeleted ? null : 'is_deleted = 0', orderBy: 'created_at DESC', ); Logger.debug('getAllRecipes: Querying database at: $_currentDbPath'); Logger.debug('getAllRecipes: Found ${maps.length} recipe(s) in database'); final recipeList = maps.map((map) => RecipeModel.fromMap(map)).toList(); Logger.debug('getAllRecipes: Converted to ${recipeList.length} RecipeModel(s)'); if (recipeList.isNotEmpty) { Logger.debug('getAllRecipes: First recipe: ${recipeList.first.title} (id: ${recipeList.first.id})'); } return recipeList; } catch (e) { // If error is about database being closed, try to reinitialize once final errorStr = e.toString().toLowerCase(); if (errorStr.contains('database_closed')) { try { Logger.warning('Database was closed, attempting to reinitialize...'); // Clear the closed database reference _db = null; // Reinitialize with current user's database path await _ensureInitializedOrReinitialize(); // Retry the query final List> maps = await _db!.query( 'recipes', where: includeDeleted ? null : 'is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (retryError) { throw Exception('Failed to get all recipes: $retryError'); } } throw Exception('Failed to get all recipes: $e'); } } /// Gets recipes by tag. /// /// [tag] - The tag to filter by. /// /// Returns a list of recipes with the specified tag. /// /// Throws [Exception] if retrieval fails. Future> getRecipesByTag(String tag) async { try { final allRecipes = await getAllRecipes(); return allRecipes.where((recipe) => recipe.tags.contains(tag)).toList(); } catch (e) { throw Exception('Failed to get recipes by tag: $e'); } } /// Gets favourite recipes. /// /// Returns a list of recipes marked as favourite. /// /// Throws [Exception] if retrieval fails. Future> getFavouriteRecipes() async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'recipes', where: 'is_favourite = 1 AND is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (e) { // If error is about database being closed, try to reinitialize once final errorStr = e.toString().toLowerCase(); if (errorStr.contains('database_closed')) { try { Logger.warning('Database was closed, attempting to reinitialize...'); _db = null; await _ensureInitializedOrReinitialize(); final List> maps = await _db!.query( 'recipes', where: 'is_favourite = 1 AND is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (retryError) { throw Exception('Failed to get favourite recipes: $retryError'); } } throw Exception('Failed to get favourite recipes: $e'); } } /// Clears all recipes from the database. /// /// Used on logout to ensure user data isolation. /// /// Throws [Exception] if clearing fails. Future clearAllRecipes() async { _ensureInitialized(); try { await _db!.delete('recipes'); Logger.info('All recipes cleared from database'); } catch (e) { throw Exception('Failed to clear recipes: $e'); } } /// Closes the database connection. /// /// Should be called when switching users or cleaning up. Future close() async { if (_db != null) { await _db!.close(); _db = null; _currentDbPath = null; Logger.info('RecipeService database closed'); } } // ==================== Bookmark Category Methods ==================== /// Creates a new bookmark category. /// /// [name] - The name of the category. /// [color] - Optional color for the category (hex string). /// /// Returns the created category. /// /// Throws [Exception] if creation fails. Future createBookmarkCategory({ required String name, String? color, }) async { await _ensureInitializedOrReinitialize(); try { final category = BookmarkCategory( id: 'category_${DateTime.now().millisecondsSinceEpoch}', name: name, color: color, ); await _db!.insert( 'bookmark_categories', category.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.info('Bookmark category created: ${category.id}'); // Publish to Nostr (kind 30001 for bookmark categories) if (_nostrService != null && _nostrKeyPair != null) { try { await _publishBookmarkCategoryToNostr(category); Logger.info('Bookmark category ${category.id} published to Nostr'); } catch (e) { Logger.warning('Failed to publish bookmark category to Nostr: $e'); } } return category; } catch (e) { throw Exception('Failed to create bookmark category: $e'); } } /// Gets all bookmark categories. /// /// Returns a list of all bookmark categories, ordered by name. /// /// Throws [Exception] if retrieval fails. Future> getAllBookmarkCategories() async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'bookmark_categories', orderBy: 'name ASC', ); return maps.map((map) => BookmarkCategory.fromMap(map)).toList(); } catch (e) { throw Exception('Failed to get bookmark categories: $e'); } } /// Deletes a bookmark category and removes all recipe associations. /// /// [categoryId] - The ID of the category to delete. /// /// Throws [Exception] if deletion fails. Future deleteBookmarkCategory(String categoryId) async { await _ensureInitializedOrReinitialize(); try { // Cascade delete will handle recipe_bookmarks entries await _db!.delete( 'bookmark_categories', where: 'id = ?', whereArgs: [categoryId], ); Logger.info('Bookmark category deleted: $categoryId'); } catch (e) { throw Exception('Failed to delete bookmark category: $e'); } } /// Updates a bookmark category. /// /// [category] - The category with updated data. /// /// Throws [Exception] if update fails. Future updateBookmarkCategory(BookmarkCategory category) async { await _ensureInitializedOrReinitialize(); try { final updatedCategory = category.copyWith( updatedAt: DateTime.now().millisecondsSinceEpoch, ); await _db!.update( 'bookmark_categories', updatedCategory.toMap(), where: 'id = ?', whereArgs: [category.id], ); Logger.info('Bookmark category updated: ${category.id}'); // Publish to Nostr (kind 30001 for bookmark categories) if (_nostrService != null && _nostrKeyPair != null) { try { await _publishBookmarkCategoryToNostr(updatedCategory); Logger.info('Bookmark category ${category.id} updated in Nostr'); } catch (e) { Logger.warning('Failed to publish bookmark category update to Nostr: $e'); } } } catch (e) { throw Exception('Failed to update bookmark category: $e'); } } /// Adds a recipe to a bookmark category. /// /// [recipeId] - The ID of the recipe to bookmark. /// [categoryId] - The ID of the category to add the recipe to. /// /// Throws [Exception] if operation fails. Future addRecipeToCategory({ required String recipeId, required String categoryId, }) async { await _ensureInitializedOrReinitialize(); try { await _db!.insert( 'recipe_bookmarks', { 'recipe_id': recipeId, 'category_id': categoryId, 'created_at': DateTime.now().millisecondsSinceEpoch, }, conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.info('Recipe $recipeId added to category $categoryId'); // Republish recipe to Nostr to sync bookmark changes try { final recipe = await getRecipe(recipeId); if (recipe != null && _nostrService != null && _nostrKeyPair != null) { await _publishRecipeToNostr(recipe); Logger.debug('Republished recipe $recipeId to Nostr with updated bookmarks'); } } catch (e) { Logger.warning('Failed to republish recipe $recipeId after bookmark change: $e'); } } catch (e) { throw Exception('Failed to add recipe to category: $e'); } } /// Removes a recipe from a bookmark category. /// /// [recipeId] - The ID of the recipe to unbookmark. /// [categoryId] - The ID of the category to remove the recipe from. /// /// Throws [Exception] if operation fails. Future removeRecipeFromCategory({ required String recipeId, required String categoryId, }) async { await _ensureInitializedOrReinitialize(); try { await _db!.delete( 'recipe_bookmarks', where: 'recipe_id = ? AND category_id = ?', whereArgs: [recipeId, categoryId], ); Logger.info('Recipe $recipeId removed from category $categoryId'); // Republish recipe to Nostr to sync bookmark changes try { final recipe = await getRecipe(recipeId); if (recipe != null && _nostrService != null && _nostrKeyPair != null) { await _publishRecipeToNostr(recipe); Logger.debug('Republished recipe $recipeId to Nostr with updated bookmarks'); } } catch (e) { Logger.warning('Failed to republish recipe $recipeId after bookmark change: $e'); } } catch (e) { throw Exception('Failed to remove recipe from category: $e'); } } /// Gets all categories for a specific recipe. /// /// [recipeId] - The ID of the recipe. /// /// Returns a list of categories the recipe belongs to. /// /// Throws [Exception] if retrieval fails. Future> getCategoriesForRecipe(String recipeId) async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.rawQuery(''' SELECT bc.* FROM bookmark_categories bc INNER JOIN recipe_bookmarks rb ON bc.id = rb.category_id WHERE rb.recipe_id = ? ORDER BY bc.name ASC ''', [recipeId]); return maps.map((map) => BookmarkCategory.fromMap(map)).toList(); } catch (e) { throw Exception('Failed to get categories for recipe: $e'); } } /// Gets all recipes in a specific category. /// /// [categoryId] - The ID of the category. /// /// Returns a list of recipes in the category, ordered by creation date (newest first). /// /// Throws [Exception] if retrieval fails. Future> getRecipesByCategory(String categoryId) async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.rawQuery(''' SELECT r.* FROM recipes r INNER JOIN recipe_bookmarks rb ON r.id = rb.recipe_id WHERE rb.category_id = ? AND r.is_deleted = 0 ORDER BY r.created_at DESC ''', [categoryId]); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (e) { throw Exception('Failed to get recipes by category: $e'); } } /// Checks if a recipe is bookmarked in any category. /// /// [recipeId] - The ID of the recipe to check. /// /// Returns true if the recipe is in at least one category, false otherwise. /// /// Throws [Exception] if check fails. Future isRecipeBookmarked(String recipeId) async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'recipe_bookmarks', where: 'recipe_id = ?', whereArgs: [recipeId], limit: 1, ); return maps.isNotEmpty; } catch (e) { throw Exception('Failed to check if recipe is bookmarked: $e'); } } /// Fetches recipes from Nostr for a given public key. /// /// Queries kind 30000 events (NIP-33 parameterized replaceable events) /// from enabled relays and stores them locally. /// /// [publicKey] - The public key (hex format) to fetch recipes for. /// [timeout] - Timeout for the request (default: 30 seconds). /// /// Returns the number of recipes fetched and stored. /// /// Throws [Exception] if fetch fails. Future fetchRecipesFromNostr( String publicKey, { Duration timeout = const Duration(seconds: 30), }) async { if (_nostrService == null) { throw Exception('Nostr service not available'); } // Ensure database is initialized before fetching await _ensureInitializedOrReinitialize(); // First, fetch bookmark categories so they exist when we restore bookmark associations try { await fetchBookmarkCategoriesFromNostr(publicKey, timeout: timeout); } catch (e) { Logger.warning('Failed to fetch bookmark categories, continuing with recipes: $e'); // Continue even if category fetch fails } try { Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...'); // Get all enabled relays (including those that may be connecting) // We'll try to connect to them if needed final allRelays = _nostrService!.getRelays(); final enabledRelays = allRelays.where((relay) => relay.isEnabled).toList(); if (enabledRelays.isEmpty) { Logger.warning('No enabled relays available for fetching recipes'); return 0; } // Try to fetch from connected relays first final recipes = []; final Set seenRecipeIds = {}; // Track by recipe ID to handle duplicates for (final relay in enabledRelays) { if (!relay.isConnected) { try { await _nostrService!.connectRelay(relay.url).timeout( const Duration(seconds: 5), onTimeout: () { throw Exception('Connection timeout'); }, ); } catch (e) { Logger.warning('Failed to connect to relay ${relay.url}: $e'); continue; } } try { final relayData = await _queryRecipeEventsFromRelayWithBookmarks( publicKey, relay.url, timeout, ); // Add recipes and bookmark data, avoiding duplicates for (final entry in relayData.entries) { final recipeId = entry.key; final (recipe, bookmarkCategoryIds) = entry.value; if (!seenRecipeIds.contains(recipeId)) { recipes.add(recipe); seenRecipeIds.add(recipeId); // Store bookmark associations for later restoration if (bookmarkCategoryIds.isNotEmpty) { if (!_pendingBookmarks.containsKey(recipeId)) { _pendingBookmarks[recipeId] = []; } _pendingBookmarks[recipeId]!.addAll(bookmarkCategoryIds); } } } } catch (e) { Logger.warning('Failed to fetch recipes from relay ${relay.url}: $e'); // Continue to next relay } } // Store fetched recipes in database int storedCount = 0; for (final recipe in recipes) { try { // Check if recipe already exists (by ID) final existing = await getRecipe(recipe.id); if (existing == null) { // Insert new recipe await _db!.insert( 'recipes', recipe.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.debug('Stored recipe ${recipe.id} (${recipe.title}) in database'); storedCount++; } else { // Update existing recipe (might have newer data from Nostr) await updateRecipe(recipe); storedCount++; } } catch (e) { Logger.warning('Failed to store recipe ${recipe.id}: $e'); // Continue with other recipes } } // Restore bookmark associations from Nostr events int bookmarkCount = 0; for (final entry in _pendingBookmarks.entries) { final recipeId = entry.key; final categoryIds = entry.value; try { // Verify recipe exists final recipe = await getRecipe(recipeId); if (recipe == null) { Logger.warning('Cannot restore bookmarks for recipe $recipeId: recipe not found'); continue; } // Restore bookmark associations for (final categoryId in categoryIds) { try { // Check if category exists, create if it doesn't (with a default name) final category = await _db!.query( 'bookmark_categories', where: 'id = ?', whereArgs: [categoryId], limit: 1, ); if (category.isEmpty) { // Category should have been fetched from Nostr, but if it doesn't exist, // create a default category as fallback Logger.warning('Bookmark category $categoryId not found, creating default'); await _db!.insert( 'bookmark_categories', { 'id': categoryId, 'name': 'Category $categoryId', 'color': null, 'created_at': DateTime.now().millisecondsSinceEpoch, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.debug('Created default bookmark category: $categoryId'); } // Add recipe to category await addRecipeToCategory(recipeId: recipeId, categoryId: categoryId); bookmarkCount++; } catch (e) { Logger.warning('Failed to restore bookmark $categoryId for recipe $recipeId: $e'); } } } catch (e) { Logger.warning('Failed to restore bookmarks for recipe $recipeId: $e'); } } // Clear pending bookmarks after processing _pendingBookmarks.clear(); Logger.info('Fetched and stored $storedCount recipe(s) from Nostr'); if (bookmarkCount > 0) { Logger.info('Restored $bookmarkCount bookmark association(s) from Nostr'); } return storedCount; } catch (e) { Logger.error('Failed to fetch recipes from Nostr', e); throw Exception('Failed to fetch recipes from Nostr: $e'); } } /// Fetches recipes from a specific relay. /// /// Uses NostrService's internal message stream to query events. Future> _fetchRecipesFromRelay( String publicKey, String relayUrl, Duration timeout, ) async { // We need to use NostrService's queryEvents method or access the stream // For now, we'll use a similar approach to fetchProfile // This requires accessing the message stream, which we'll do via a helper return await _queryRecipeEventsFromRelay(publicKey, relayUrl, timeout); } /// Queries recipe events from a relay and returns recipes with bookmark data. /// Returns a map where key is recipe ID and value is a tuple of (recipe, bookmarkCategoryIds). Future)>> _queryRecipeEventsFromRelayWithBookmarks( String publicKey, String relayUrl, Duration timeout, ) async { try { // Use NostrService's queryEvents method to get kind 30000 events final events = await _nostrService!.queryEvents( publicKey, relayUrl, [30000], timeout: timeout, ); final Map recipeMap = {}; // Track by 'd' tag value final Map> recipeBookmarks = {}; // Track bookmark associations: recipeId -> [categoryIds] for (final event in events) { try { // Extract recipe ID from 'd' tag String? recipeId; for (final tag in event.tags) { if (tag.isNotEmpty && tag[0] == 'd') { recipeId = tag.length > 1 ? tag[1] : null; break; } } if (recipeId == null) { Logger.warning('Recipe event missing "d" tag: ${event.id}'); continue; } // Parse content as JSON final contentMap = jsonDecode(event.content) as Map; // Extract additional metadata from tags final imageUrls = []; final tags = []; final bookmarkCategoryIds = []; int rating = 0; bool isFavourite = false; for (final tag in event.tags) { if (tag.isEmpty) continue; switch (tag[0]) { case 'image': if (tag.length > 1) imageUrls.add(tag[1]); break; case 't': if (tag.length > 1) tags.add(tag[1]); break; case 'rating': if (tag.length > 1) { rating = int.tryParse(tag[1]) ?? 0; } break; case 'favourite': if (tag.length > 1) { isFavourite = tag[1].toLowerCase() == 'true'; } break; case 'bookmark': if (tag.length > 1) { bookmarkCategoryIds.add(tag[1]); } break; } } // Create RecipeModel from event final recipe = RecipeModel( id: recipeId, title: contentMap['title'] as String? ?? 'Untitled Recipe', description: contentMap['description'] as String?, tags: tags.isNotEmpty ? tags : (contentMap['tags'] as List?)?.map((e) => e.toString()).toList() ?? [], rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0), isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false), imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), nostrEventId: event.id, ); // Use replaceable event logic: keep the latest version final existing = recipeMap[recipeId]; if (existing == null || recipe.updatedAt > existing.updatedAt) { recipeMap[recipeId] = recipe; // Store bookmark associations if (bookmarkCategoryIds.isNotEmpty) { recipeBookmarks[recipeId] = bookmarkCategoryIds; } } } catch (e) { Logger.warning('Failed to parse recipe from event ${event.id}: $e'); } } // Return map of recipeId -> (recipe, bookmarkCategoryIds) final result = )>{}; for (final entry in recipeMap.entries) { result[entry.key] = (entry.value, recipeBookmarks[entry.key] ?? []); } return result; } catch (e) { Logger.error('Failed to query recipes from relay $relayUrl', e); rethrow; } } /// Queries recipe events from a relay using NostrService's queryEvents method. Future> _queryRecipeEventsFromRelay( String publicKey, String relayUrl, Duration timeout, ) async { try { // Use NostrService's queryEvents method to get kind 30000 events final events = await _nostrService!.queryEvents( publicKey, relayUrl, [30000], timeout: timeout, ); final Map recipeMap = {}; // Track by 'd' tag value final Map> recipeBookmarks = {}; // Track bookmark associations: recipeId -> [categoryIds] for (final event in events) { try { // Extract recipe ID from 'd' tag String? recipeId; for (final tag in event.tags) { if (tag.isNotEmpty && tag[0] == 'd') { recipeId = tag.length > 1 ? tag[1] : null; break; } } if (recipeId == null) { Logger.warning('Recipe event missing "d" tag: ${event.id}'); continue; } // Parse content as JSON final contentMap = jsonDecode(event.content) as Map; // Extract additional metadata from tags final imageUrls = []; final tags = []; final bookmarkCategoryIds = []; int rating = 0; bool isFavourite = false; for (final tag in event.tags) { if (tag.isEmpty) continue; switch (tag[0]) { case 'image': if (tag.length > 1) imageUrls.add(tag[1]); break; case 't': if (tag.length > 1) tags.add(tag[1]); break; case 'rating': if (tag.length > 1) { rating = int.tryParse(tag[1]) ?? 0; } break; case 'favourite': if (tag.length > 1) { isFavourite = tag[1].toLowerCase() == 'true'; } break; case 'bookmark': if (tag.length > 1) { bookmarkCategoryIds.add(tag[1]); } break; } } // Create RecipeModel from event final recipe = RecipeModel( id: recipeId, title: contentMap['title'] as String? ?? 'Untitled Recipe', description: contentMap['description'] as String?, tags: tags.isNotEmpty ? tags : (contentMap['tags'] as List?)?.map((e) => e.toString()).toList() ?? [], rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0), isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false), imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), nostrEventId: event.id, ); // Use replaceable event logic: keep the latest version final existing = recipeMap[recipeId]; if (existing == null || recipe.updatedAt > existing.updatedAt) { recipeMap[recipeId] = recipe; // Store bookmark associations for later processing if (bookmarkCategoryIds.isNotEmpty) { recipeBookmarks[recipeId] = bookmarkCategoryIds; } } } catch (e) { Logger.warning('Failed to parse recipe from event ${event.id}: $e'); } } final recipes = recipeMap.values.toList(); // Store bookmark associations in the database for each recipe // Note: Recipes will be stored in fetchRecipesFromNostr, but we can prepare bookmark data here for (final entry in recipeBookmarks.entries) { final recipeId = entry.key; final categoryIds = entry.value; // Check if recipe exists in database (it should after fetchRecipesFromNostr stores it) // We'll restore bookmarks in fetchRecipesFromNostr after recipes are stored // For now, attach bookmark data to recipes as metadata // Actually, we need to return bookmark data separately } // Return recipes - bookmark restoration will happen in fetchRecipesFromNostr return recipes; } catch (e) { Logger.error('Failed to query recipes from relay $relayUrl', e); rethrow; } } /// Publishes a recipe to Nostr as a kind 30000 event (NIP-33 parameterized replaceable event). Future _publishRecipeToNostr(RecipeModel recipe) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Create tags final tags = >[ ['d', recipe.id], // Replaceable event identifier ]; // Add image tags for (final imageUrl in recipe.imageUrls) { tags.add(['image', imageUrl]); } // Add tag tags for (final tag in recipe.tags) { tags.add(['t', tag]); } // Add rating tag tags.add(['rating', recipe.rating.toString()]); // Add favourite tag tags.add(['favourite', recipe.isFavourite.toString()]); // Add bookmark category tags try { final bookmarkCategories = await getCategoriesForRecipe(recipe.id); for (final category in bookmarkCategories) { tags.add(['bookmark', category.id]); } } catch (e) { Logger.warning('Failed to get bookmark categories for recipe ${recipe.id}: $e'); } // Create event content as JSON final content = recipe.toJson(); // Create and sign the event // Using kind 30000 (NIP-33 parameterized replaceable event) final event = NostrEvent.create( content: jsonEncode(content), kind: 30000, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published recipe ${recipe.id} to $successCount/${results.length} relays'); // Update recipe with Nostr event ID final updatedRecipe = recipe.copyWith(nostrEventId: event.id); await _db!.update( 'recipes', updatedRecipe.toMap(), where: 'id = ?', whereArgs: [recipe.id], ); } catch (e) { Logger.error('Failed to publish recipe to Nostr', e); rethrow; } } /// Publishes a bookmark category to Nostr as a kind 30001 event. /// /// [category] - The bookmark category to publish. Future _publishBookmarkCategoryToNostr(BookmarkCategory category) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Create tags - use category ID as the 'd' tag for replaceable events final tags = >[ ['d', category.id], // Replaceable event identifier ]; // Create event content as JSON final content = category.toJson(); // Create and sign the event // Using kind 30001 for bookmark categories (NIP-33 parameterized replaceable event) final event = NostrEvent.create( content: jsonEncode(content), kind: 30001, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published bookmark category ${category.id} to $successCount/${results.length} relays'); } catch (e) { Logger.error('Failed to publish bookmark category to Nostr', e); rethrow; } } /// Fetches bookmark categories from Nostr for a given public key. /// /// Queries kind 30001 events (bookmark categories) from enabled relays and stores them locally. /// /// [publicKey] - The public key (hex format) to fetch categories for. /// [timeout] - Timeout for the request (default: 30 seconds). /// /// Returns the number of categories fetched and stored. /// /// Throws [Exception] if fetch fails. Future fetchBookmarkCategoriesFromNostr( String publicKey, { Duration timeout = const Duration(seconds: 30), }) async { if (_nostrService == null) { throw Exception('NostrService not available'); } await _ensureInitializedOrReinitialize(); try { // Get all enabled relays (including those that may be connecting) // We'll try to connect to them if needed final allRelays = _nostrService!.getRelays(); final enabledRelays = allRelays.where((r) => r.isEnabled).toList(); if (enabledRelays.isEmpty) { Logger.warning('No enabled relays available for fetching bookmark categories'); return 0; } // Try to connect to relays that aren't connected yet for (final relay in enabledRelays) { if (!relay.isConnected) { try { await _nostrService!.connectRelay(relay.url).timeout( const Duration(seconds: 5), onTimeout: () { throw Exception('Connection timeout'); }, ); } catch (e) { Logger.warning('Failed to connect to relay ${relay.url} for bookmark categories: $e'); // Continue to next relay } } } // Filter to only connected relays after connection attempts final connectedRelays = enabledRelays.where((r) => r.isConnected).toList(); if (connectedRelays.isEmpty) { Logger.warning('No connected relays available for fetching bookmark categories'); return 0; } final Map categoryMap = {}; // Track by category ID // Fetch from all connected relays for (final relay in connectedRelays) { try { final relayCategories = await _queryBookmarkCategoriesFromRelay( publicKey, relay.url, timeout, ); // Add categories, keeping the latest version (by updatedAt) for (final category in relayCategories) { final existing = categoryMap[category.id]; if (existing == null || category.updatedAt > existing.updatedAt) { categoryMap[category.id] = category; } } } catch (e) { Logger.warning('Failed to fetch bookmark categories from relay ${relay.url}: $e'); // Continue to next relay } } // Store fetched categories in database int storedCount = 0; for (final category in categoryMap.values) { try { await _db!.insert( 'bookmark_categories', category.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.debug('Stored bookmark category ${category.id} (${category.name}) in database'); storedCount++; } catch (e) { Logger.warning('Failed to store bookmark category ${category.id}: $e'); // Continue with other categories } } Logger.info('Fetched and stored $storedCount bookmark category(ies) from Nostr'); return storedCount; } catch (e) { Logger.error('Failed to fetch bookmark categories from Nostr', e); throw Exception('Failed to fetch bookmark categories from Nostr: $e'); } } /// Queries bookmark category events from a relay using NostrService's queryEvents method. Future> _queryBookmarkCategoriesFromRelay( String publicKey, String relayUrl, Duration timeout, ) async { try { // Use NostrService's queryEvents method to get kind 30001 events final events = await _nostrService!.queryEvents( publicKey, relayUrl, [30001], timeout: timeout, ); final Map categoryMap = {}; // Track by 'd' tag value for (final event in events) { try { // Extract category ID from 'd' tag String? categoryId; for (final tag in event.tags) { if (tag.isNotEmpty && tag[0] == 'd') { categoryId = tag.length > 1 ? tag[1] : null; break; } } if (categoryId == null) { Logger.warning('Bookmark category event missing "d" tag: ${event.id}'); continue; } // Parse content as JSON final contentMap = jsonDecode(event.content) as Map; // Create BookmarkCategory from event final category = BookmarkCategory( id: categoryId, name: contentMap['name'] as String? ?? 'Unnamed Category', color: contentMap['color'] as String?, createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), ); // Use replaceable event logic: keep the latest version final existing = categoryMap[categoryId]; if (existing == null || category.updatedAt > existing.updatedAt) { categoryMap[categoryId] = category; } } catch (e) { Logger.warning('Failed to parse bookmark category from event ${event.id}: $e'); } } return categoryMap.values.toList(); } catch (e) { Logger.error('Failed to query bookmark categories from relay $relayUrl', e); rethrow; } } /// Publishes a recipe deletion to Nostr as a kind 5 event. /// /// [recipeId] - The ID of the recipe being deleted. /// [nostrEventId] - The Nostr event ID of the original recipe event. Future _publishRecipeDeletionToNostr(String recipeId, String nostrEventId) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Create a kind 5 deletion event // Tags should reference the event IDs to delete final tags = >[ ['e', nostrEventId], // Reference the original event ]; // Create event content (can be empty for deletions) final event = NostrEvent.create( content: '', kind: 5, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published recipe deletion for $recipeId to $successCount/${results.length} relays'); } catch (e) { Logger.error('Failed to publish recipe deletion to Nostr', e); rethrow; } } }