|
|
|
@ -1,3 +1,4 @@
|
|
|
|
|
|
|
|
import 'dart:async';
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:sqflite/sqflite.dart';
|
|
|
|
import 'package:sqflite/sqflite.dart';
|
|
|
|
import 'package:path/path.dart' as path;
|
|
|
|
import 'package:path/path.dart' as path;
|
|
|
|
@ -7,6 +8,7 @@ import '../nostr/nostr_service.dart';
|
|
|
|
import '../nostr/models/nostr_event.dart';
|
|
|
|
import '../nostr/models/nostr_event.dart';
|
|
|
|
import '../nostr/models/nostr_keypair.dart';
|
|
|
|
import '../nostr/models/nostr_keypair.dart';
|
|
|
|
import '../../core/logger.dart';
|
|
|
|
import '../../core/logger.dart';
|
|
|
|
|
|
|
|
import '../../core/service_locator.dart';
|
|
|
|
import 'models/recipe_model.dart';
|
|
|
|
import 'models/recipe_model.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// Service for managing recipes with local storage and Nostr sync.
|
|
|
|
/// Service for managing recipes with local storage and Nostr sync.
|
|
|
|
@ -23,6 +25,9 @@ class RecipeService {
|
|
|
|
/// Database instance (null until initialized).
|
|
|
|
/// Database instance (null until initialized).
|
|
|
|
Database? _db;
|
|
|
|
Database? _db;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Current database path (for debugging and verification).
|
|
|
|
|
|
|
|
String? _currentDbPath;
|
|
|
|
|
|
|
|
|
|
|
|
/// Optional database path for testing (null uses default).
|
|
|
|
/// Optional database path for testing (null uses default).
|
|
|
|
final String? _testDbPath;
|
|
|
|
final String? _testDbPath;
|
|
|
|
|
|
|
|
|
|
|
|
@ -51,6 +56,12 @@ class RecipeService {
|
|
|
|
// Get database path
|
|
|
|
// Get database path
|
|
|
|
final dbPath = sessionDbPath ?? _testDbPath ?? await _getDatabasePath();
|
|
|
|
final dbPath = sessionDbPath ?? _testDbPath ?? await _getDatabasePath();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Close existing database if switching sessions
|
|
|
|
|
|
|
|
if (_db != null) {
|
|
|
|
|
|
|
|
await _db!.close();
|
|
|
|
|
|
|
|
_db = null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Open database
|
|
|
|
// Open database
|
|
|
|
_db = await openDatabase(
|
|
|
|
_db = await openDatabase(
|
|
|
|
dbPath,
|
|
|
|
dbPath,
|
|
|
|
@ -58,7 +69,23 @@ class RecipeService {
|
|
|
|
onCreate: _onCreate,
|
|
|
|
onCreate: _onCreate,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
Logger.info('RecipeService initialized');
|
|
|
|
// 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.
|
|
|
|
/// Creates the recipes table if it doesn't exist.
|
|
|
|
@ -89,16 +116,48 @@ class RecipeService {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Gets the path to the database file.
|
|
|
|
/// 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 {
|
|
|
|
Future<String> _getDatabasePath() async {
|
|
|
|
final documentsDirectory = await getApplicationDocumentsDirectory();
|
|
|
|
final documentsDirectory = await getApplicationDocumentsDirectory();
|
|
|
|
return path.join(documentsDirectory.path, 'recipes.db');
|
|
|
|
return path.join(documentsDirectory.path, 'recipes.db');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Ensures the database is initialized.
|
|
|
|
/// Ensures the database is initialized and open.
|
|
|
|
void _ensureInitialized() {
|
|
|
|
void _ensureInitialized() {
|
|
|
|
if (_db == null) {
|
|
|
|
if (_db == null) {
|
|
|
|
throw Exception('RecipeService not initialized. Call initialize() first.');
|
|
|
|
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.
|
|
|
|
/// Creates a new recipe.
|
|
|
|
@ -109,7 +168,9 @@ class RecipeService {
|
|
|
|
///
|
|
|
|
///
|
|
|
|
/// Throws [Exception] if creation fails.
|
|
|
|
/// Throws [Exception] if creation fails.
|
|
|
|
Future<RecipeModel> createRecipe(RecipeModel recipe) async {
|
|
|
|
Future<RecipeModel> createRecipe(RecipeModel recipe) async {
|
|
|
|
_ensureInitialized();
|
|
|
|
// Ensure database is initialized, or reinitialize if needed
|
|
|
|
|
|
|
|
await _ensureInitializedOrReinitialize();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final recipeWithTimestamps = recipe.copyWith(
|
|
|
|
final recipeWithTimestamps = recipe.copyWith(
|
|
|
|
@ -124,15 +185,25 @@ class RecipeService {
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
Logger.info('Recipe created: ${recipe.id}');
|
|
|
|
Logger.info('Recipe created: ${recipe.id}');
|
|
|
|
|
|
|
|
Logger.debug('Recipe stored in database at: $_currentDbPath');
|
|
|
|
|
|
|
|
|
|
|
|
// Publish to Nostr if available
|
|
|
|
// Publish to Nostr if available
|
|
|
|
if (_nostrService != null && _nostrKeyPair != null) {
|
|
|
|
if (_nostrService != null && _nostrKeyPair != null) {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
|
|
|
|
Logger.info('Publishing recipe ${recipe.id} to Nostr...');
|
|
|
|
await _publishRecipeToNostr(recipeWithTimestamps);
|
|
|
|
await _publishRecipeToNostr(recipeWithTimestamps);
|
|
|
|
|
|
|
|
Logger.info('Recipe ${recipe.id} published to Nostr successfully');
|
|
|
|
} catch (e) {
|
|
|
|
} catch (e) {
|
|
|
|
Logger.warning('Failed to publish recipe to Nostr: $e');
|
|
|
|
Logger.warning('Failed to publish recipe to Nostr: $e');
|
|
|
|
// Don't fail the creation if Nostr publish fails
|
|
|
|
// 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;
|
|
|
|
return recipeWithTimestamps;
|
|
|
|
@ -190,8 +261,13 @@ class RecipeService {
|
|
|
|
///
|
|
|
|
///
|
|
|
|
/// Throws [Exception] if deletion fails.
|
|
|
|
/// Throws [Exception] if deletion fails.
|
|
|
|
Future<void> deleteRecipe(String recipeId) async {
|
|
|
|
Future<void> deleteRecipe(String recipeId) async {
|
|
|
|
_ensureInitialized();
|
|
|
|
await _ensureInitializedOrReinitialize();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
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
|
|
|
|
// Mark as deleted instead of actually deleting
|
|
|
|
final updated = await _db!.update(
|
|
|
|
final updated = await _db!.update(
|
|
|
|
'recipes',
|
|
|
|
'recipes',
|
|
|
|
@ -210,13 +286,15 @@ class RecipeService {
|
|
|
|
Logger.info('Recipe deleted: $recipeId');
|
|
|
|
Logger.info('Recipe deleted: $recipeId');
|
|
|
|
|
|
|
|
|
|
|
|
// Publish deletion event to Nostr if available
|
|
|
|
// Publish deletion event to Nostr if available
|
|
|
|
if (_nostrService != null && _nostrKeyPair != null) {
|
|
|
|
if (_nostrService != null && _nostrKeyPair != null && nostrEventId != null) {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
await _publishRecipeDeletionToNostr(recipeId);
|
|
|
|
await _publishRecipeDeletionToNostr(recipeId, nostrEventId);
|
|
|
|
} catch (e) {
|
|
|
|
} catch (e) {
|
|
|
|
Logger.warning('Failed to publish recipe deletion to Nostr: $e');
|
|
|
|
Logger.warning('Failed to publish recipe deletion to Nostr: $e');
|
|
|
|
// Don't fail the deletion if Nostr publish fails
|
|
|
|
// 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) {
|
|
|
|
} catch (e) {
|
|
|
|
throw Exception('Failed to delete recipe: $e');
|
|
|
|
throw Exception('Failed to delete recipe: $e');
|
|
|
|
@ -226,16 +304,18 @@ class RecipeService {
|
|
|
|
/// Gets a recipe by ID.
|
|
|
|
/// Gets a recipe by ID.
|
|
|
|
///
|
|
|
|
///
|
|
|
|
/// [recipeId] - The ID of the recipe.
|
|
|
|
/// [recipeId] - The ID of the recipe.
|
|
|
|
|
|
|
|
/// [includeDeleted] - Whether to include deleted recipes (default: false).
|
|
|
|
///
|
|
|
|
///
|
|
|
|
/// Returns the recipe if found, null otherwise.
|
|
|
|
/// Returns the recipe if found, null otherwise.
|
|
|
|
///
|
|
|
|
///
|
|
|
|
/// Throws [Exception] if retrieval fails.
|
|
|
|
/// Throws [Exception] if retrieval fails.
|
|
|
|
Future<RecipeModel?> getRecipe(String recipeId) async {
|
|
|
|
Future<RecipeModel?> getRecipe(String recipeId, {bool includeDeleted = false}) async {
|
|
|
|
_ensureInitialized();
|
|
|
|
await _ensureInitializedOrReinitialize();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
final List<Map<String, dynamic>> maps = await _db!.query(
|
|
|
|
final List<Map<String, dynamic>> maps = await _db!.query(
|
|
|
|
'recipes',
|
|
|
|
'recipes',
|
|
|
|
where: 'id = ? AND is_deleted = 0',
|
|
|
|
where: includeDeleted ? 'id = ?' : 'id = ? AND is_deleted = 0',
|
|
|
|
whereArgs: [recipeId],
|
|
|
|
whereArgs: [recipeId],
|
|
|
|
limit: 1,
|
|
|
|
limit: 1,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
@ -258,16 +338,45 @@ class RecipeService {
|
|
|
|
///
|
|
|
|
///
|
|
|
|
/// Throws [Exception] if retrieval fails.
|
|
|
|
/// Throws [Exception] if retrieval fails.
|
|
|
|
Future<List<RecipeModel>> getAllRecipes({bool includeDeleted = false}) async {
|
|
|
|
Future<List<RecipeModel>> getAllRecipes({bool includeDeleted = false}) async {
|
|
|
|
_ensureInitialized();
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
|
|
|
|
// Try to ensure database is initialized, or reinitialize if needed
|
|
|
|
|
|
|
|
await _ensureInitializedOrReinitialize();
|
|
|
|
|
|
|
|
|
|
|
|
final List<Map<String, dynamic>> maps = await _db!.query(
|
|
|
|
final List<Map<String, dynamic>> maps = await _db!.query(
|
|
|
|
'recipes',
|
|
|
|
'recipes',
|
|
|
|
where: includeDeleted ? null : 'is_deleted = 0',
|
|
|
|
where: includeDeleted ? null : 'is_deleted = 0',
|
|
|
|
orderBy: 'created_at DESC',
|
|
|
|
orderBy: 'created_at DESC',
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return maps.map((map) => RecipeModel.fromMap(map)).toList();
|
|
|
|
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) {
|
|
|
|
} 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');
|
|
|
|
throw Exception('Failed to get all recipes: $e');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -308,7 +417,251 @@ class RecipeService {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Publishes a recipe to Nostr as a kind 30078 event.
|
|
|
|
/// 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');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// 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();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Get all enabled relays
|
|
|
|
|
|
|
|
final enabledRelays = _nostrService!.getRelays()
|
|
|
|
|
|
|
|
.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 relayRecipes = await _fetchRecipesFromRelay(
|
|
|
|
|
|
|
|
publicKey,
|
|
|
|
|
|
|
|
relay.url,
|
|
|
|
|
|
|
|
timeout,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Add recipes, avoiding duplicates (by recipe ID from 'd' tag)
|
|
|
|
|
|
|
|
for (final recipe in relayRecipes) {
|
|
|
|
|
|
|
|
if (!seenRecipeIds.contains(recipe.id)) {
|
|
|
|
|
|
|
|
recipes.add(recipe);
|
|
|
|
|
|
|
|
seenRecipeIds.add(recipe.id);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} 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
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Logger.info('Fetched and stored $storedCount recipe(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<List<RecipeModel>> _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 using NostrService's queryEvents method.
|
|
|
|
|
|
|
|
Future<List<RecipeModel>> _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<String, RecipeModel> recipeMap = {}; // Track by 'd' tag value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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>[];
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
Logger.warning('Failed to parse recipe from event ${event.id}: $e');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return recipeMap.values.toList();
|
|
|
|
|
|
|
|
} 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 {
|
|
|
|
Future<void> _publishRecipeToNostr(RecipeModel recipe) async {
|
|
|
|
if (_nostrService == null || _nostrKeyPair == null) {
|
|
|
|
if (_nostrService == null || _nostrKeyPair == null) {
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
@ -340,9 +693,10 @@ class RecipeService {
|
|
|
|
final content = recipe.toJson();
|
|
|
|
final content = recipe.toJson();
|
|
|
|
|
|
|
|
|
|
|
|
// Create and sign the event
|
|
|
|
// Create and sign the event
|
|
|
|
|
|
|
|
// Using kind 30000 (NIP-33 parameterized replaceable event)
|
|
|
|
final event = NostrEvent.create(
|
|
|
|
final event = NostrEvent.create(
|
|
|
|
content: jsonEncode(content),
|
|
|
|
content: jsonEncode(content),
|
|
|
|
kind: 30078,
|
|
|
|
kind: 30000,
|
|
|
|
privateKey: _nostrKeyPair!.privateKey,
|
|
|
|
privateKey: _nostrKeyPair!.privateKey,
|
|
|
|
tags: tags,
|
|
|
|
tags: tags,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
@ -369,23 +723,19 @@ class RecipeService {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Publishes a recipe deletion to Nostr as a kind 5 event.
|
|
|
|
/// Publishes a recipe deletion to Nostr as a kind 5 event.
|
|
|
|
Future<void> _publishRecipeDeletionToNostr(String recipeId) async {
|
|
|
|
///
|
|
|
|
|
|
|
|
/// [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) {
|
|
|
|
if (_nostrService == null || _nostrKeyPair == null) {
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
// Get the recipe to find its Nostr event ID
|
|
|
|
|
|
|
|
final recipe = await getRecipe(recipeId);
|
|
|
|
|
|
|
|
if (recipe == null || recipe.nostrEventId == null) {
|
|
|
|
|
|
|
|
Logger.warning('Recipe $recipeId has no Nostr event ID, skipping deletion event');
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create a kind 5 deletion event
|
|
|
|
// Create a kind 5 deletion event
|
|
|
|
// Tags should reference the event IDs to delete
|
|
|
|
// Tags should reference the event IDs to delete
|
|
|
|
final tags = <List<String>>[
|
|
|
|
final tags = <List<String>>[
|
|
|
|
['e', recipe.nostrEventId!], // Reference the original event
|
|
|
|
['e', nostrEventId], // Reference the original event
|
|
|
|
];
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Create event content (can be empty for deletions)
|
|
|
|
// Create event content (can be empty for deletions)
|
|
|
|
|