// lib/db/services/POWRPackService.ts import { SQLiteDatabase } from 'expo-sqlite'; import { generateId } from '@/utils/ids'; import { POWRPack, POWRPackItem, POWRPackWithContent, POWRPackImport, POWRPackSelection } from '@/types/powr-pack'; import { BaseExercise } from '@/types/exercise'; import { WorkoutTemplate } from '@/types/templates'; import NDK, { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk-mobile'; import { nip19 } from 'nostr-tools'; import { findTagValue, getTagValues } from '@/utils/nostr-utils'; import { NostrIntegration } from './NostrIntegration'; class POWRPackService { private db: SQLiteDatabase; constructor(db: SQLiteDatabase) { this.db = db; } /** * Fetches a POWR Pack from a nostr address (naddr) * @param naddr The naddr string pointing to a NIP-51 list * @param ndk The NDK instance to use for fetching * @returns Promise with the pack data and its contents */ async fetchPackFromNaddr(naddr: string, ndk: NDK): Promise { try { console.log(`Fetching POWR Pack from naddr: ${naddr}`); // 1. Decode the naddr const decoded = nip19.decode(naddr); if (decoded.type !== 'naddr') { throw new Error('Invalid naddr format'); } const { pubkey, kind, identifier } = decoded.data as { pubkey: string, kind: number, identifier?: string }; console.log(`Decoded naddr: pubkey=${pubkey}, kind=${kind}, identifier=${identifier}`); // 2. Check that it's a curation list (kind 30004) if (kind !== 30004) { throw new Error('Not a valid NIP-51 curation list'); } // 3. Create a filter to fetch the pack event const packFilter: NDKFilter = { kinds: [kind], authors: [pubkey], '#d': identifier ? [identifier] : undefined }; console.log(`Fetching pack with filter: ${JSON.stringify(packFilter)}`); // 4. Fetch the pack event const packEvents = await ndk.fetchEvents(packFilter); if (packEvents.size === 0) { throw new Error('Pack not found'); } const packEvent = Array.from(packEvents)[0]; console.log(`Fetched pack event: ${packEvent.id}`); console.log(`Pack tags: ${JSON.stringify(packEvent.tags)}`); // 5. Extract template and exercise references const templateRefs: string[] = []; const exerciseRefs: string[] = []; for (const tag of packEvent.tags) { if (tag[0] === 'a' && tag.length > 1) { const addressPointer = tag[1]; // Format is kind:pubkey:d-tag if (addressPointer.startsWith('33402:')) { // Workout template templateRefs.push(addressPointer); console.log(`Found template reference: ${addressPointer}`); } else if (addressPointer.startsWith('33401:')) { // Exercise exerciseRefs.push(addressPointer); console.log(`Found exercise reference: ${addressPointer}`); } } } console.log(`Found ${templateRefs.length} template refs and ${exerciseRefs.length} exercise refs`); // 6. Fetch templates and exercises console.log('Fetching referenced templates...'); const templates = await this.fetchReferencedEvents(ndk, templateRefs); console.log('Fetching referenced exercises...'); const exercises = await this.fetchReferencedEvents(ndk, exerciseRefs); console.log(`Fetched ${templates.length} templates and ${exercises.length} exercises`); // 7. Return the complete pack data return { packEvent, templates, exercises }; } catch (error) { console.error('Error fetching pack from naddr:', error); throw error; } } /** * Helper function to fetch events from address pointers */ async fetchReferencedEvents(ndk: NDK, addressPointers: string[]): Promise { const events: NDKEvent[] = []; console.log("Fetching references:", addressPointers); for (const pointer of addressPointers) { try { // Parse the pointer (kind:pubkey:d-tag) const parts = pointer.split(':'); if (parts.length < 3) { console.error(`Invalid address pointer format: ${pointer}`); continue; } // Extract the components const kindStr = parts[0]; const hexPubkey = parts[1]; const dTagOrEventId = parts[2]; const kind = parseInt(kindStr); if (isNaN(kind)) { console.error(`Invalid kind in pointer: ${kindStr}`); continue; } console.log(`Fetching ${kind} event with d-tag ${dTagOrEventId} from author ${hexPubkey}`); // Try direct event ID fetching first try { console.log(`Trying to fetch event directly by ID: ${dTagOrEventId}`); const directEvent = await ndk.fetchEvent({ids: [dTagOrEventId]}); if (directEvent) { console.log(`Successfully fetched event by ID: ${dTagOrEventId}`); events.push(directEvent); continue; // Skip to next loop iteration } } catch (directFetchError) { console.log(`Direct fetch failed, falling back to filters: ${directFetchError}`); } // Create a filter as fallback const filter: NDKFilter = { kinds: [kind], authors: [hexPubkey], }; if (dTagOrEventId && dTagOrEventId.length > 0) { // For parameterized replaceable events, use d-tag filter['#d'] = [dTagOrEventId]; } console.log("Using filter:", JSON.stringify(filter)); // Fetch the events with a timeout const fetchPromise = ndk.fetchEvents(filter); const timeoutPromise = new Promise>((_, reject) => setTimeout(() => reject(new Error('Fetch timeout')), 10000) ); const fetchedEvents = await Promise.race([fetchPromise, timeoutPromise]); console.log(`Found ${fetchedEvents.size} events for ${pointer}`); if (fetchedEvents.size > 0) { events.push(...Array.from(fetchedEvents)); } } catch (error) { console.error(`Error fetching event with pointer ${pointer}:`, error); // Continue with other events even if one fails } } console.log(`Total fetched referenced events: ${events.length}`); return events; } /** * Analyzes templates and identifies their exercise dependencies */ analyzeDependencies(templates: NDKEvent[], exercises: NDKEvent[]): Record { const dependencies: Record = {}; const exerciseMap: Record = {}; console.log(`Analyzing dependencies for ${templates.length} templates and ${exercises.length} exercises`); // Create lookup map for exercises by reference exercises.forEach(exercise => { const dTag = findTagValue(exercise.tags, 'd'); if (dTag) { const exerciseRef = `33401:${exercise.pubkey}:${dTag}`; exerciseMap[exerciseRef] = exercise.id; console.log(`Mapped exercise ${exercise.id} to reference ${exerciseRef}`); } else { console.log(`Exercise ${exercise.id} has no d-tag`); } }); // Analyze each template for exercise references templates.forEach(template => { const requiredExercises: string[] = []; const templateName = findTagValue(template.tags, 'title') || template.id.substring(0, 8); console.log(`Analyzing template ${templateName} (${template.id})`); // Find exercise references in template tags template.tags.forEach(tag => { if (tag[0] === 'exercise' && tag.length > 1) { const exerciseRefFull = tag[1]; // Split the reference to get the base part (without parameters) const refParts = exerciseRefFull.split('::'); const baseRef = refParts[0]; const exerciseId = exerciseMap[baseRef]; if (exerciseId) { requiredExercises.push(exerciseId); console.log(`Template ${templateName} requires exercise ${exerciseId} via ref ${baseRef}`); } else { console.log(`Template ${templateName} references unknown exercise ${exerciseRefFull}`); } } }); dependencies[template.id] = requiredExercises; console.log(`Template ${templateName} has ${requiredExercises.length} dependencies`); }); return dependencies; } /** * Import a POWR Pack and selected items into the database */ async importPack( packImport: POWRPackImport, selection: POWRPackSelection ): Promise { try { const { packEvent, templates, exercises } = packImport; const { selectedTemplates, selectedExercises } = selection; // Create integration helper const nostrIntegration = new NostrIntegration(this.db); // 1. Extract pack metadata const title = findTagValue(packEvent.tags, 'name') || 'Unnamed Pack'; const description = findTagValue(packEvent.tags, 'about') || packEvent.content; // 2. Create pack record const packId = generateId(); const now = Date.now(); await this.db.withTransactionAsync(async () => { // Insert pack record await this.db.runAsync( `INSERT INTO powr_packs (id, title, description, author_pubkey, nostr_event_id, import_date, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [packId, title, description, packEvent.pubkey, packEvent.id, now, now] ); // 3. Process and import selected exercises const exercisesToImport = exercises.filter((e: NDKEvent) => selectedExercises.includes(e.id)); const importedExerciseIds: string[] = []; const exerciseIdMap = new Map(); // Map Nostr event ID to local ID console.log(`Importing ${exercisesToImport.length} exercises...`); for (const exerciseEvent of exercisesToImport) { // Convert to local model const exercise = nostrIntegration.convertNostrExerciseToLocal(exerciseEvent); // Save to database await nostrIntegration.saveImportedExercise(exercise); // Track imported exercise importedExerciseIds.push(exercise.id); exerciseIdMap.set(exerciseEvent.id, exercise.id); console.log(`Imported exercise: ${exercise.title} (${exercise.id}) from Nostr event ${exerciseEvent.id}`); // Create pack item record await this.createPackItemRecord(packId, exercise.id, 'exercise', exerciseEvent.id); } // 4. Process and import selected templates const templatesToImport = templates.filter((t: NDKEvent) => selectedTemplates.includes(t.id)); console.log(`Importing ${templatesToImport.length} templates...`); for (const templateEvent of templatesToImport) { // Convert to local model const templateModel = nostrIntegration.convertNostrTemplateToLocal(templateEvent); // Save to database await nostrIntegration.saveImportedTemplate(templateModel); console.log(`Imported template: ${templateModel.title} (${templateModel.id}) from Nostr event ${templateEvent.id}`); // Get exercise references from this template const exerciseRefs = nostrIntegration.getTemplateExerciseRefs(templateEvent); console.log(`Template has ${exerciseRefs.length} exercise references:`); exerciseRefs.forEach(ref => console.log(` - ${ref}`)); // Find the corresponding imported exercise IDs const templateExerciseIds: string[] = []; const matchedRefs: string[] = []; for (const ref of exerciseRefs) { // Extract the base reference (before any parameters) const refParts = ref.split('::'); const baseRef = refParts[0]; console.log(`Looking for matching exercise for reference: ${baseRef}`); // Find the event that matches this reference const matchingEvent = exercises.find(e => { const dTag = findTagValue(e.tags, 'd'); if (!dTag) return false; const fullRef = `33401:${e.pubkey}:${dTag}`; const match = baseRef === fullRef; if (match) { console.log(`Found matching event: ${e.id} with d-tag: ${dTag}`); } return match; }); if (matchingEvent && exerciseIdMap.has(matchingEvent.id)) { const localExerciseId = exerciseIdMap.get(matchingEvent.id) || ''; templateExerciseIds.push(localExerciseId); matchedRefs.push(ref); // Keep the full reference including parameters console.log(`Mapped Nostr event ${matchingEvent.id} to local exercise ID ${localExerciseId}`); } else { console.log(`No matching exercise found for reference: ${baseRef}`); } } // Save template-exercise relationships with parameters if (templateExerciseIds.length > 0) { await nostrIntegration.saveTemplateExercisesWithParams(templateModel.id, templateExerciseIds, matchedRefs); } else { console.log(`No exercise relationships to save for template ${templateModel.id}`); } // Create pack item record await this.createPackItemRecord(packId, templateModel.id, 'template', templateEvent.id); // Add diagnostic logging console.log(`Checking saved template: ${templateModel.id}`); const exerciseCount = await this.db.getFirstAsync<{count: number}>( 'SELECT COUNT(*) as count FROM template_exercises WHERE template_id = ?', [templateModel.id] ); console.log(`Template ${templateModel.title} has ${exerciseCount?.count || 0} exercises associated`); } // Final diagnostic check const templateCount = await this.db.getFirstAsync<{count: number}>( 'SELECT COUNT(*) as count FROM templates WHERE source = "nostr"' ); console.log(`Total nostr templates in database: ${templateCount?.count || 0}`); const templateIds = await this.db.getAllAsync<{id: string, title: string}>( 'SELECT id, title FROM templates WHERE source = "nostr"' ); console.log(`Template IDs:`); templateIds.forEach(t => console.log(` - ${t.title}: ${t.id}`)); }); return packId; } catch (error) { console.error('Error importing POWR pack:', error); throw error; } } /** * Create a record of a pack item */ private async createPackItemRecord( packId: string, itemId: string, itemType: 'exercise' | 'template', nostrEventId?: string, itemOrder?: number ): Promise { await this.db.runAsync( `INSERT INTO powr_pack_items (pack_id, item_id, item_type, item_order, is_imported, nostr_event_id) VALUES (?, ?, ?, ?, ?, ?)`, [packId, itemId, itemType, itemOrder || 0, 1, nostrEventId || null] ); } /** * Get all imported packs */ async getImportedPacks(): Promise { try { // 1. Get all packs const packs = await this.db.getAllAsync( `SELECT id, title, description, author_pubkey as authorPubkey, nostr_event_id as nostrEventId, import_date as importDate, updated_at as updatedAt FROM powr_packs ORDER BY import_date DESC` ); // 2. Get content for each pack const result: POWRPackWithContent[] = []; for (const pack of packs) { // Get exercises const exercises = await this.db.getAllAsync( `SELECT e.* FROM exercises e JOIN powr_pack_items ppi ON e.id = ppi.item_id WHERE ppi.pack_id = ? AND ppi.item_type = 'exercise' AND ppi.is_imported = 1`, [pack.id] ); // Get templates const templates = await this.db.getAllAsync( `SELECT t.* FROM templates t JOIN powr_pack_items ppi ON t.id = ppi.item_id WHERE ppi.pack_id = ? AND ppi.item_type = 'template' AND ppi.is_imported = 1`, [pack.id] ); result.push({ pack, exercises, templates }); } return result; } catch (error) { console.error('Error getting imported packs:', error); throw error; } } /** * Get a specific pack by ID */ async getPackById(packId: string): Promise { try { // 1. Get pack info const pack = await this.db.getFirstAsync( `SELECT id, title, description, author_pubkey as authorPubkey, nostr_event_id as nostrEventId, import_date as importDate, updated_at as updatedAt FROM powr_packs WHERE id = ?`, [packId] ); if (!pack) { return null; } // 2. Get exercises const exercises = await this.db.getAllAsync( `SELECT e.* FROM exercises e JOIN powr_pack_items ppi ON e.id = ppi.item_id WHERE ppi.pack_id = ? AND ppi.item_type = 'exercise' AND ppi.is_imported = 1`, [packId] ); // 3. Get templates const templates = await this.db.getAllAsync( `SELECT t.* FROM templates t JOIN powr_pack_items ppi ON t.id = ppi.item_id WHERE ppi.pack_id = ? AND ppi.item_type = 'template' AND ppi.is_imported = 1`, [packId] ); return { pack, exercises, templates }; } catch (error) { console.error('Error getting pack by ID:', error); throw error; } } /** * Delete a pack and optionally its contents */ async deletePack(packId: string, keepItems: boolean = false): Promise { try { await this.db.withTransactionAsync(async () => { if (!keepItems) { // Get the items first so we can delete them from their respective tables const items = await this.db.getAllAsync( `SELECT * FROM powr_pack_items WHERE pack_id = ? AND is_imported = 1`, [packId] ); // Delete each exercise and template for (const item of items as POWRPackItem[]) { if (item.itemType === 'exercise') { // Delete exercise await this.db.runAsync(`DELETE FROM exercises WHERE id = ?`, [item.itemId]); } else if (item.itemType === 'template') { // Delete template and its relationships await this.db.runAsync(`DELETE FROM template_exercises WHERE template_id = ?`, [item.itemId]); await this.db.runAsync(`DELETE FROM templates WHERE id = ?`, [item.itemId]); } } } // Delete the pack items await this.db.runAsync( `DELETE FROM powr_pack_items WHERE pack_id = ?`, [packId] ); // Delete the pack await this.db.runAsync( `DELETE FROM powr_packs WHERE id = ?`, [packId] ); }); } catch (error) { console.error('Error deleting pack:', error); throw error; } } /** * Create an naddr for sharing a pack */ createShareableNaddr(packEvent: NDKEvent): string { try { // Extract the d-tag (identifier) const dTags = packEvent.getMatchingTags('d'); const identifier = dTags[0]?.[1] || ''; // Ensure kind is a definite number (use 30004 as default if undefined) const kind = packEvent.kind !== undefined ? packEvent.kind : 30004; // Create the naddr const naddr = nip19.naddrEncode({ pubkey: packEvent.pubkey, kind: kind, // Now this is always a number identifier }); return naddr; } catch (error) { console.error('Error creating shareable naddr:', error); throw error; } } } export default POWRPackService;