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.
412 lines
12 KiB
412 lines
12 KiB
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 'models/recipe_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;
|
|
|
|
/// Optional database path for testing (null uses default).
|
|
final String? _testDbPath;
|
|
|
|
/// 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();
|
|
|
|
// Open database
|
|
_db = await openDatabase(
|
|
dbPath,
|
|
version: 1,
|
|
onCreate: _onCreate,
|
|
);
|
|
|
|
Logger.info('RecipeService initialized');
|
|
}
|
|
|
|
/// Creates the recipes table if it doesn't exist.
|
|
Future<void> _onCreate(Database db, int version) async {
|
|
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
|
|
)
|
|
''');
|
|
|
|
// Create index for faster queries
|
|
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)
|
|
''');
|
|
}
|
|
|
|
/// Gets the path to the database file.
|
|
Future<String> _getDatabasePath() async {
|
|
final documentsDirectory = await getApplicationDocumentsDirectory();
|
|
return path.join(documentsDirectory.path, 'recipes.db');
|
|
}
|
|
|
|
/// Ensures the database is initialized.
|
|
void _ensureInitialized() {
|
|
if (_db == null) {
|
|
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 {
|
|
_ensureInitialized();
|
|
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}');
|
|
|
|
// Publish to Nostr if available
|
|
if (_nostrService != null && _nostrKeyPair != null) {
|
|
try {
|
|
await _publishRecipeToNostr(recipeWithTimestamps);
|
|
} catch (e) {
|
|
Logger.warning('Failed to publish recipe to Nostr: $e');
|
|
// Don't fail the creation if Nostr publish fails
|
|
}
|
|
}
|
|
|
|
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 {
|
|
_ensureInitialized();
|
|
try {
|
|
// 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) {
|
|
try {
|
|
await _publishRecipeDeletionToNostr(recipeId);
|
|
} catch (e) {
|
|
Logger.warning('Failed to publish recipe deletion to Nostr: $e');
|
|
// Don't fail the deletion if Nostr publish fails
|
|
}
|
|
}
|
|
} catch (e) {
|
|
throw Exception('Failed to delete recipe: $e');
|
|
}
|
|
}
|
|
|
|
/// Gets a recipe by ID.
|
|
///
|
|
/// [recipeId] - The ID of the recipe.
|
|
///
|
|
/// Returns the recipe if found, null otherwise.
|
|
///
|
|
/// Throws [Exception] if retrieval fails.
|
|
Future<RecipeModel?> getRecipe(String recipeId) async {
|
|
_ensureInitialized();
|
|
try {
|
|
final List<Map<String, dynamic>> maps = await _db!.query(
|
|
'recipes',
|
|
where: '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 {
|
|
_ensureInitialized();
|
|
try {
|
|
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 (e) {
|
|
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 {
|
|
_ensureInitialized();
|
|
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) {
|
|
throw Exception('Failed to get favourite recipes: $e');
|
|
}
|
|
}
|
|
|
|
/// Publishes a recipe to Nostr as a kind 30078 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()]);
|
|
|
|
// Create event content as JSON
|
|
final content = recipe.toJson();
|
|
|
|
// Create and sign the event
|
|
final event = NostrEvent.create(
|
|
content: jsonEncode(content),
|
|
kind: 30078,
|
|
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 recipe deletion to Nostr as a kind 5 event.
|
|
Future<void> _publishRecipeDeletionToNostr(String recipeId) async {
|
|
if (_nostrService == null || _nostrKeyPair == null) {
|
|
return;
|
|
}
|
|
|
|
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
|
|
// Tags should reference the event IDs to delete
|
|
final tags = <List<String>>[
|
|
['e', recipe.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;
|
|
}
|
|
}
|
|
}
|
|
|