You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1411 lines
47 KiB

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<String, List<String>> _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<void> 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<void> reinitializeForSession({
required String newDbPath,
}) async {
await initialize(sessionDbPath: newDbPath);
}
/// Creates the recipes table if it doesn't exist.
Future<void> _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<void> _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<String> _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<void> _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<RecipeModel> 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<RecipeModel> 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<void> 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<RecipeModel?> getRecipe(String recipeId, {bool includeDeleted = false}) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<List<RecipeModel>> getAllRecipes({bool includeDeleted = false}) async {
try {
// Try to ensure database is initialized, or reinitialize if needed
await _ensureInitializedOrReinitialize();
final List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<List<RecipeModel>> 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<List<RecipeModel>> getFavouriteRecipes() async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> 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<void> 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<BookmarkCategory> 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<List<BookmarkCategory>> getAllBookmarkCategories() async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<void> 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<void> 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<void> 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<void> 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<List<BookmarkCategory>> getCategoriesForRecipe(String recipeId) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<List<RecipeModel>> getRecipesByCategory(String categoryId) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<bool> isRecipeBookmarked(String recipeId) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<int> 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 = <RecipeModel>[];
final Set<String> 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.
/// 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<Map<String, (RecipeModel, List<String>)>> _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<String, RecipeModel> recipeMap = {}; // Track by 'd' tag value
final Map<String, List<String>> 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<String, dynamic>;
// Extract additional metadata from tags
final imageUrls = <String>[];
final tags = <String>[];
final bookmarkCategoryIds = <String>[];
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<dynamic>?)?.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<dynamic>?)?.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 = <String, (RecipeModel, List<String>)>{};
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;
}
}
/// Publishes a recipe to Nostr as a kind 30000 event (NIP-33 parameterized replaceable event).
Future<void> _publishRecipeToNostr(RecipeModel recipe) async {
if (_nostrService == null || _nostrKeyPair == null) {
return;
}
try {
// Create tags
final tags = <List<String>>[
['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<void> _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 = <List<String>>[
['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<int> 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<String, BookmarkCategory> 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<List<BookmarkCategory>> _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<String, BookmarkCategory> 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<String, dynamic>;
// 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<void> _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 = <List<String>>[
['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;
}
}
}

Powered by TurnKey Linux.