// lib/db/services/NostrIntegration.ts import { SQLiteDatabase } from 'expo-sqlite'; import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import { findTagValue, getTagValues } from '@/utils/nostr-utils'; import { BaseExercise, ExerciseType, ExerciseCategory, Equipment, ExerciseFormat, ExerciseFormatUnits } from '@/types/exercise'; import { WorkoutTemplate, TemplateType, TemplateCategory, TemplateExerciseConfig } from '@/types/templates'; import { generateId } from '@/utils/ids'; /** * Helper class for converting between Nostr events and local models */ export class NostrIntegration { private db: SQLiteDatabase; constructor(db: SQLiteDatabase) { this.db = db; } /** * Convert a Nostr exercise event to a local Exercise model */ convertNostrExerciseToLocal(exerciseEvent: NDKEvent): BaseExercise { const id = generateId(); const title = findTagValue(exerciseEvent.tags, 'title') || 'Unnamed Exercise'; const equipmentTag = findTagValue(exerciseEvent.tags, 'equipment') || 'barbell'; const difficultyTag = findTagValue(exerciseEvent.tags, 'difficulty') || ''; const formatTag = exerciseEvent.tags.find(t => t[0] === 'format'); const formatUnitsTag = exerciseEvent.tags.find(t => t[0] === 'format_units'); // Get tags const tags = getTagValues(exerciseEvent.tags, 't'); // Map equipment to valid type const equipment: Equipment = this.mapToValidEquipment(equipmentTag); // Map to valid exercise type const type: ExerciseType = this.mapEquipmentToType(equipment); // Map to valid category (using first tag if available) const category: ExerciseCategory = this.mapToCategory(tags[0] || ''); // Parse format and format_units const format: ExerciseFormat = {}; const formatUnits: ExerciseFormatUnits = {}; if (formatTag && formatUnitsTag && formatTag.length > 1 && formatUnitsTag.length > 1) { // Process format parameters for (let i = 1; i < formatTag.length; i++) { const param = formatTag[i]; const unit = formatUnitsTag[i] || ''; if (param === 'weight') { format.weight = true; formatUnits.weight = (unit === 'kg' || unit === 'lbs') ? unit : 'kg'; } else if (param === 'reps') { format.reps = true; formatUnits.reps = 'count'; } else if (param === 'rpe') { format.rpe = true; formatUnits.rpe = '0-10'; } else if (param === 'set_type') { format.set_type = true; formatUnits.set_type = 'warmup|normal|drop|failure'; } } } else { // Set default format if none provided format.weight = true; format.reps = true; format.rpe = true; format.set_type = true; formatUnits.weight = 'kg'; formatUnits.reps = 'count'; formatUnits.rpe = '0-10'; formatUnits.set_type = 'warmup|normal|drop|failure'; } // Create the exercise object const exercise: BaseExercise = { id, title, type, category, equipment, description: exerciseEvent.content, tags, format, format_units: formatUnits, availability: { source: ['nostr'], }, created_at: exerciseEvent.created_at ? exerciseEvent.created_at * 1000 : Date.now() }; return exercise; } /** * Map string to valid Equipment type */ private mapToValidEquipment(equipment: string): Equipment { switch (equipment.toLowerCase()) { case 'barbell': return 'barbell'; case 'dumbbell': return 'dumbbell'; case 'kettlebell': return 'kettlebell'; case 'machine': return 'machine'; case 'cable': return 'cable'; case 'bodyweight': return 'bodyweight'; default: return 'other'; } } /** * Map Equipment value to exercise type */ private mapEquipmentToType(equipment: Equipment): ExerciseType { switch (equipment) { case 'barbell': case 'dumbbell': case 'kettlebell': case 'machine': case 'cable': case 'other': return 'strength'; case 'bodyweight': return 'bodyweight'; default: return 'strength'; } } /** * Map string to valid category */ private mapToCategory(category: string): ExerciseCategory { const normalized = category.toLowerCase(); if (normalized.includes('push')) return 'Push'; if (normalized.includes('pull')) return 'Pull'; if (normalized.includes('leg')) return 'Legs'; if (normalized.includes('core') || normalized.includes('abs')) return 'Core'; // Default to Push if no match return 'Push'; } /** * Convert a Nostr template event to a local Template model */ convertNostrTemplateToLocal(templateEvent: NDKEvent): WorkoutTemplate { const id = generateId(); const title = findTagValue(templateEvent.tags, 'title') || 'Unnamed Template'; const typeTag = findTagValue(templateEvent.tags, 'type') || 'strength'; // Convert string to valid TemplateType const type: TemplateType = (typeTag === 'strength' || typeTag === 'circuit' || typeTag === 'emom' || typeTag === 'amrap') ? typeTag as TemplateType : 'strength'; // Get rounds, duration, interval if available const rounds = parseInt(findTagValue(templateEvent.tags, 'rounds') || '0') || undefined; const duration = parseInt(findTagValue(templateEvent.tags, 'duration') || '0') || undefined; const interval = parseInt(findTagValue(templateEvent.tags, 'interval') || '0') || undefined; // Get tags const tags = getTagValues(templateEvent.tags, 't'); // Map to valid category const category: TemplateCategory = this.mapToTemplateCategory(tags[0] || ''); // Create exercises placeholder (will be populated later) const exercises: TemplateExerciseConfig[] = []; // Create the template object const template: WorkoutTemplate = { id, title, type, category, description: templateEvent.content, tags, rounds, duration, interval, exercises, isPublic: true, version: 1, availability: { source: ['nostr'] }, created_at: templateEvent.created_at ? templateEvent.created_at * 1000 : Date.now(), lastUpdated: Date.now(), nostrEventId: templateEvent.id }; return template; } /** * Map string to valid template category */ private mapToTemplateCategory(category: string): TemplateCategory { const normalized = category.toLowerCase(); if (normalized.includes('full') && normalized.includes('body')) return 'Full Body'; if (normalized.includes('push') || normalized.includes('pull') || normalized.includes('leg')) return 'Push/Pull/Legs'; if (normalized.includes('upper') || normalized.includes('lower')) return 'Upper/Lower'; if (normalized.includes('cardio')) return 'Cardio'; if (normalized.includes('crossfit')) return 'CrossFit'; if (normalized.includes('strength')) return 'Strength'; if (normalized.includes('condition')) return 'Conditioning'; // Default if no match return 'Custom'; } /** * Get exercise references from a template event */ getTemplateExerciseRefs(templateEvent: NDKEvent): string[] { const exerciseRefs: string[] = []; for (const tag of templateEvent.tags) { if (tag[0] === 'exercise' && tag.length > 1) { exerciseRefs.push(tag[1]); } } return exerciseRefs; } /** * Save an imported exercise to the database */ async saveImportedExercise(exercise: BaseExercise): Promise { try { // Convert format objects to JSON strings const formatJson = JSON.stringify(exercise.format || {}); const formatUnitsJson = JSON.stringify(exercise.format_units || {}); await this.db.runAsync( `INSERT INTO exercises (id, title, type, category, equipment, description, format_json, format_units_json, created_at, updated_at, source, nostr_event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ exercise.id, exercise.title, exercise.type, exercise.category, exercise.equipment || 'other', exercise.description || '', formatJson, formatUnitsJson, exercise.created_at, Date.now(), 'nostr', exercise.id // Using exercise ID as nostr_event_id since we don't have the actual event ID ] ); // Save tags if (exercise.tags && exercise.tags.length > 0) { for (const tag of exercise.tags) { await this.db.runAsync( `INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`, [exercise.id, tag] ); } } return exercise.id; } catch (error) { console.error('Error saving imported exercise:', error); throw error; } } /** * Save an imported template to the database */ async saveImportedTemplate(template: WorkoutTemplate): Promise { try { await this.db.runAsync( `INSERT INTO templates (id, title, type, description, created_at, updated_at, source, nostr_event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ template.id, template.title, template.type, template.description || '', template.created_at, template.lastUpdated || Date.now(), 'nostr', template.nostrEventId || null ] ); return template.id; } catch (error) { console.error('Error saving imported template:', error); throw error; } } /** * Save template exercise relationships */ async saveTemplateExercisesWithParams( templateId: string, exerciseIds: string[], exerciseRefs: string[] ): Promise { try { console.log(`Saving ${exerciseIds.length} exercise relationships for template ${templateId}`); // Create template exercise records for (const [index, exerciseId] of exerciseIds.entries()) { const templateExerciseId = generateId(); const now = Date.now(); // Get the corresponding exercise reference with parameters const exerciseRef = exerciseRefs[index] || ''; // Parse the reference format: kind:pubkey:d-tag::sets:reps:weight let targetSets = null; let targetReps = null; let targetWeight = null; // Check if reference contains parameters if (exerciseRef.includes('::')) { const parts = exerciseRef.split('::'); if (parts.length > 1) { const params = parts[1].split(':'); if (params.length > 0) targetSets = parseInt(params[0]) || null; if (params.length > 1) targetReps = parseInt(params[1]) || null; if (params.length > 2) targetWeight = parseFloat(params[2]) || null; } } console.log(`Template exercise ${index}: ${exerciseId} with sets=${targetSets}, reps=${targetReps}, weight=${targetWeight}`); await this.db.runAsync( `INSERT INTO template_exercises (id, template_id, exercise_id, display_order, target_sets, target_reps, target_weight, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ templateExerciseId, templateId, exerciseId, index, targetSets, targetReps, targetWeight, now, now ] ); } console.log(`Successfully saved all template-exercise relationships for template ${templateId}`); } catch (error) { console.error('Error saving template exercises with parameters:', error); throw error; } } }