diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffbde3..1dc90f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- SQLite database implementation with development seeding + - Successfully integrated SQLite with proper transaction handling + - Added mock exercise library with 10 initial exercises + - Implemented development database seeder + - Added debug logging for database operations +- Event caching system for future Nostr integration + - Added EventCache service for Nostr event handling + - Implemented proper transaction management + - Added cache metadata tracking +- Database schema improvements + - Added nostr_events and event_tags tables + - Added cache_metadata table for performance optimization + - Added exercise_media table for future media support - Alphabetical quick scroll in exercise library - Dynamic letter highlighting for available sections - Smooth scrolling to selected sections @@ -40,7 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Exercise deletion functionality - Keyboard overlap issues in exercise creation form -- SQLite transaction handling for exercise operations +- SQLite transaction nesting issues +- TypeScript parameter typing in database services +- Null value handling in database operations +- Development seeding duplicate prevention ### Technical Details 1. Database Schema Enforcement: @@ -61,6 +77,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved error propagation in LibraryService - Added transaction rollback on constraint violations +4. Database Services: + - Added EventCache service for Nostr events + - Improved ExerciseService with transaction awareness + - Added DevSeederService for development data + - Enhanced error handling and logging + ### Migration Notes - Exercise creation now enforces schema constraints - Input validation prevents invalid data entry diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx index e9bc2d4..ba37ebb 100644 --- a/components/DatabaseProvider.tsx +++ b/components/DatabaseProvider.tsx @@ -1,7 +1,25 @@ +// components/DatabaseProvider.tsx import React from 'react'; import { View, ActivityIndicator, Text } from 'react-native'; -import { SQLiteProvider, openDatabaseSync } from 'expo-sqlite'; +import { SQLiteProvider, openDatabaseSync, SQLiteDatabase } from 'expo-sqlite'; import { schema } from '@/lib/db/schema'; +import { ExerciseService } from '@/lib/db/services/ExerciseService'; +import { EventCache } from '@/lib/db/services/EventCache'; +import { DevSeederService } from '@/lib/db/services/DevSeederService'; +import { logDatabaseInfo } from '@/lib/db/debug'; + +// Create context for services +interface DatabaseServicesContextValue { + exerciseService: ExerciseService | null; + eventCache: EventCache | null; + devSeeder: DevSeederService | null; +} + +const DatabaseServicesContext = React.createContext({ + exerciseService: null, + eventCache: null, + devSeeder: null, +}); interface DatabaseProviderProps { children: React.ReactNode; @@ -10,6 +28,11 @@ interface DatabaseProviderProps { export function DatabaseProvider({ children }: DatabaseProviderProps) { const [isReady, setIsReady] = React.useState(false); const [error, setError] = React.useState(null); + const [services, setServices] = React.useState({ + exerciseService: null, + eventCache: null, + devSeeder: null, + }); React.useEffect(() => { async function initDatabase() { @@ -19,6 +42,26 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { console.log('[DB] Creating schema...'); await schema.createTables(db); + + // Initialize services + console.log('[DB] Initializing services...'); + const eventCache = new EventCache(db); + const exerciseService = new ExerciseService(db); + const devSeeder = new DevSeederService(db, exerciseService, eventCache); + + // Set services + setServices({ + exerciseService, + eventCache, + devSeeder, + }); + + // Seed development database + if (__DEV__) { + console.log('[DB] Seeding development database...'); + await devSeeder.seedDatabase(); + await logDatabaseInfo(); + } console.log('[DB] Database initialized successfully'); setIsReady(true); @@ -51,7 +94,34 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { return ( - {children} + + {children} + ); +} + +// Hooks for accessing services +export function useExerciseService() { + const context = React.useContext(DatabaseServicesContext); + if (!context.exerciseService) { + throw new Error('Exercise service not initialized'); + } + return context.exerciseService; +} + +export function useEventCache() { + const context = React.useContext(DatabaseServicesContext); + if (!context.eventCache) { + throw new Error('Event cache not initialized'); + } + return context.eventCache; +} + +export function useDevSeeder() { + const context = React.useContext(DatabaseServicesContext); + if (!context.devSeeder) { + throw new Error('Dev seeder not initialized'); + } + return context.devSeeder; } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 2b84f0c..0c33705 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -2,12 +2,11 @@ import { SQLiteDatabase } from 'expo-sqlite'; import { Platform } from 'react-native'; -export const SCHEMA_VERSION = 2; +export const SCHEMA_VERSION = 3; // Incrementing version for new tables class Schema { private async getCurrentVersion(db: SQLiteDatabase): Promise { try { - // First check if the table exists const tableExists = await db.getFirstAsync<{ count: number }>( `SELECT count(*) as count FROM sqlite_master WHERE type='table' AND name='schema_version'` @@ -26,7 +25,7 @@ class Schema { return version?.version ?? 0; } catch (error) { console.log('[Schema] Error getting version:', error); - return 0; // If table doesn't exist yet + return 0; } } @@ -43,7 +42,7 @@ class Schema { ); `); - if (currentVersion === 0) { + if (currentVersion < 1) { console.log('[Schema] Performing fresh install'); // Drop existing tables if they exist @@ -79,7 +78,6 @@ class Schema { CREATE INDEX idx_exercise_tags ON exercise_tags(tag); `); - // Set initial version await db.runAsync( 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', [1, Date.now()] @@ -88,7 +86,7 @@ class Schema { console.log('[Schema] Base tables created successfully'); } - // Update to version 2 if needed + // Update to version 2 if needed - Nostr support if (currentVersion < 2) { console.log('[Schema] Upgrading to version 2'); @@ -116,7 +114,7 @@ class Schema { CREATE INDEX IF NOT EXISTS idx_event_tags ON event_tags(name, value); `); - // Add Nostr reference to exercises if not exists + // Add Nostr reference to exercises try { await db.execAsync(`ALTER TABLE exercises ADD COLUMN nostr_event_id TEXT REFERENCES nostr_events(id)`); } catch (e) { @@ -131,6 +129,41 @@ class Schema { console.log('[Schema] Version 2 upgrade completed'); } + // Update to version 3 if needed - Event Cache + if (currentVersion < 3) { + console.log('[Schema] Upgrading to version 3'); + + // Create cache metadata table + await db.execAsync(` + CREATE TABLE IF NOT EXISTS cache_metadata ( + content_id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + last_accessed INTEGER NOT NULL, + access_count INTEGER NOT NULL DEFAULT 0, + cache_priority INTEGER NOT NULL DEFAULT 0 + ); + `); + + // Create media cache table + await db.execAsync(` + CREATE TABLE IF NOT EXISTS exercise_media ( + exercise_id TEXT NOT NULL, + media_type TEXT NOT NULL, + content BLOB NOT NULL, + thumbnail BLOB, + created_at INTEGER NOT NULL, + FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE + ); + `); + + await db.runAsync( + 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', + [3, Date.now()] + ); + + console.log('[Schema] Version 3 upgrade completed'); + } + // Verify final schema const tables = await db.getAllAsync<{ name: string }>( "SELECT name FROM sqlite_master WHERE type='table'" diff --git a/lib/db/services/DevSeederService.ts b/lib/db/services/DevSeederService.ts new file mode 100644 index 0000000..c014040 --- /dev/null +++ b/lib/db/services/DevSeederService.ts @@ -0,0 +1,97 @@ +// lib/db/services/DevSeederService.ts +import { SQLiteDatabase } from 'expo-sqlite'; +import { ExerciseService } from './ExerciseService'; +import { EventCache } from './EventCache'; +import { logDatabaseInfo } from '../debug'; +import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises'; + +export class DevSeederService { + private db: SQLiteDatabase; + private exerciseService: ExerciseService; + private eventCache: EventCache; + + constructor( + db: SQLiteDatabase, + exerciseService: ExerciseService, + eventCache: EventCache + ) { + this.db = db; + this.exerciseService = exerciseService; + this.eventCache = eventCache; + } + + async seedDatabase() { + if (!__DEV__) return; + + try { + console.log('Starting development database seeding...'); + + // Log initial database state + await logDatabaseInfo(); + + // Check if we already have exercises + const existingCount = (await this.exerciseService.getAllExercises()).length; + + if (existingCount > 0) { + console.log('Database already seeded with', existingCount, 'exercises'); + return; + } + + // Start transaction for all seeding operations + await this.db.withTransactionAsync(async () => { + console.log('Seeding mock exercises...'); + + // Process all events within the same transaction + for (const event of mockExerciseEvents) { + // Pass true to indicate we're in a transaction + await this.eventCache.setEvent(event, true); + const exercise = convertNostrToExercise(event); + await this.exerciseService.createExercise(exercise, true); + } + + console.log('Successfully seeded', mockExerciseEvents.length, 'exercises'); + }); + + // Log final database state + await logDatabaseInfo(); + + } catch (error) { + console.error('Error seeding database:', error); + throw error; + } + } + + async clearDatabase() { + if (!__DEV__) return; + + try { + console.log('Clearing development database...'); + + await this.db.withTransactionAsync(async () => { + const tables = [ + 'exercises', + 'exercise_tags', + 'nostr_events', + 'event_tags', + 'cache_metadata' + ]; + + for (const table of tables) { + await this.db.runAsync(`DELETE FROM ${table}`); + } + }); + + console.log('Successfully cleared database'); + } catch (error) { + console.error('Error clearing database:', error); + throw error; + } + } + + async resetDatabase() { + if (!__DEV__) return; + + await this.clearDatabase(); + await this.seedDatabase(); + } +} \ No newline at end of file diff --git a/lib/db/services/EventCache.ts b/lib/db/services/EventCache.ts new file mode 100644 index 0000000..a705c12 --- /dev/null +++ b/lib/db/services/EventCache.ts @@ -0,0 +1,115 @@ +// lib/db/services/EventCache.ts +import { SQLiteDatabase } from 'expo-sqlite'; +import { NostrEvent } from '@/types/nostr'; + +export class EventCache { + private db: SQLiteDatabase; + private writeBuffer: { query: string; params: any[] }[] = []; + + constructor(db: SQLiteDatabase) { + this.db = db; + } + + async setEvent(event: NostrEvent, inTransaction: boolean = false): Promise { + try { + // Store queries to execute + const queries = [ + { + query: `INSERT OR REPLACE INTO nostr_events + (id, pubkey, kind, created_at, content, sig, raw_event, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + params: [ + event.id || '', // Convert undefined to empty string + event.pubkey || '', + event.kind, + event.created_at, + event.content, + event.sig || '', + JSON.stringify(event), + Date.now() + ] + }, + // Add metadata query + { + query: `INSERT OR REPLACE INTO cache_metadata + (content_id, content_type, last_accessed, access_count) + VALUES (?, ?, ?, 1) + ON CONFLICT(content_id) DO UPDATE SET + last_accessed = ?, + access_count = access_count + 1`, + params: [ + event.id || '', + 'event', + Date.now(), + Date.now() + ] + } + ]; + + // Add tag queries + event.tags.forEach((tag, index) => { + queries.push({ + query: `INSERT OR REPLACE INTO event_tags + (event_id, name, value, index_num) + VALUES (?, ?, ?, ?)`, + params: [ + event.id || '', + tag[0] || '', + tag[1] || '', + index + ] + }); + }); + + // If we're already in a transaction, just execute the queries + if (inTransaction) { + for (const { query, params } of queries) { + await this.db.runAsync(query, params); + } + } else { + // Otherwise, wrap in our own transaction + await this.db.withTransactionAsync(async () => { + for (const { query, params } of queries) { + await this.db.runAsync(query, params); + } + }); + } + } catch (error) { + console.error('Error caching event:', error); + throw error; + } + } + + async getEvent(id: string): Promise { + try { + const event = await this.db.getFirstAsync( + `SELECT * FROM nostr_events WHERE id = ?`, + [id] + ); + + if (!event) return null; + + // Get tags + const tags = await this.db.getAllAsync<{ name: string; value: string }>( + `SELECT name, value FROM event_tags WHERE event_id = ? ORDER BY index_num`, + [id] + ); + + // Update access metadata + await this.db.runAsync( + `UPDATE cache_metadata + SET last_accessed = ?, access_count = access_count + 1 + WHERE content_id = ?`, + [Date.now(), id] + ); + + return { + ...event, + tags: tags.map(tag => [tag.name, tag.value]) + }; + } catch (error) { + console.error('Error getting event from cache:', error); + return null; + } + } +} \ No newline at end of file diff --git a/lib/db/services/ExerciseService.ts b/lib/db/services/ExerciseService.ts index 979eac5..2d33ac4 100644 --- a/lib/db/services/ExerciseService.ts +++ b/lib/db/services/ExerciseService.ts @@ -43,12 +43,15 @@ export class ExerciseService { } // Update createExercise to handle all required fields - async createExercise(exercise: Omit): Promise { + async createExercise( + exercise: Omit, + inTransaction: boolean = false + ): Promise { const id = generateId(); const timestamp = Date.now(); - + try { - await this.db.withTransactionAsync(async () => { + const runQueries = async () => { await this.db.runAsync( `INSERT INTO exercises ( id, title, type, category, equipment, description, @@ -68,7 +71,7 @@ export class ExerciseService { exercise.source || 'local' ] ); - + if (exercise.tags?.length) { for (const tag of exercise.tags) { await this.db.runAsync( @@ -77,8 +80,14 @@ export class ExerciseService { ); } } - }); - + }; + + if (inTransaction) { + await runQueries(); + } else { + await this.db.withTransactionAsync(runQueries); + } + return id; } catch (error) { console.error('Error creating exercise:', error); diff --git a/lib/mocks/exercises.ts b/lib/mocks/exercises.ts new file mode 100644 index 0000000..3e696d8 --- /dev/null +++ b/lib/mocks/exercises.ts @@ -0,0 +1,277 @@ +// lib/mocks/exercises.ts +import { NostrEvent } from '@/types/nostr'; +import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; +import { generateId } from '@/utils/ids'; + +// Mock exercise definitions that will become our initial POWR library +export const mockExerciseEvents: NostrEvent[] = [ + { + kind: 33401, + content: "Stand with feet hip-width apart, barbell racked on shoulders. Bend knees and hips to squat down, keeping chest up. Drive through heels to stand.", + tags: [ + ["d", "bb-back-squat"], + ["title", "Barbell Back Squat"], + ["format", "weight", "reps", "rpe", "set_type"], + ["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"], + ["equipment", "barbell"], + ["difficulty", "intermediate"], + ["category", "legs"], + ["t", "compound"], + ["t", "squat"], + ["t", "legs"], + ["t", "quadriceps"] + ], + created_at: 1708300800, // Feb 19, 2024 + id: generateId('nostr'), + pubkey: "powr", // We'll update this when we create the POWR relay + sig: undefined + }, + { + kind: 33401, + content: "Stand with feet shoulder-width apart, barbell on floor. Hinge at hips, grip bar outside knees. Keep back flat, drive through heels to lift.", + tags: [ + ["d", "bb-deadlift"], + ["title", "Barbell Deadlift"], + ["format", "weight", "reps", "rpe", "set_type"], + ["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"], + ["equipment", "barbell"], + ["difficulty", "intermediate"], + ["category", "legs"], + ["t", "compound"], + ["t", "hinge"], + ["t", "legs"], + ["t", "posterior"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Lie on bench, feet flat on floor. Grip barbell slightly wider than shoulders. Lower bar to chest, press back up to start.", + tags: [ + ["d", "bb-bench-press"], + ["title", "Barbell Bench Press"], + ["format", "weight", "reps", "rpe", "set_type"], + ["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"], + ["equipment", "barbell"], + ["difficulty", "intermediate"], + ["category", "push"], + ["t", "compound"], + ["t", "push"], + ["t", "chest"], + ["t", "triceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Start in plank position. Lower body by bending elbows, keeping body straight. Push back up to start position.", + tags: [ + ["d", "pushup"], + ["title", "Push-Up"], + ["format", "reps", "set_type"], + ["format_units", "count", "warmup|normal|drop|failure"], + ["equipment", "bodyweight"], + ["difficulty", "beginner"], + ["category", "push"], + ["t", "bodyweight"], + ["t", "push"], + ["t", "chest"], + ["t", "triceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Hang from pull-up bar with overhand grip. Pull body up until chin clears bar, lower back to start.", + tags: [ + ["d", "pullup"], + ["title", "Pull-Up"], + ["format", "reps", "set_type"], + ["format_units", "count", "warmup|normal|drop|failure"], + ["equipment", "bodyweight"], + ["difficulty", "intermediate"], + ["category", "pull"], + ["t", "bodyweight"], + ["t", "pull"], + ["t", "back"], + ["t", "biceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Sit at machine, grip handles at shoulder height. Press handles up overhead, return to start position.", + tags: [ + ["d", "shoulder-press-machine"], + ["title", "Shoulder Press Machine"], + ["format", "weight", "reps", "set_type"], + ["format_units", "kg", "count", "warmup|normal|drop|failure"], + ["equipment", "machine"], + ["difficulty", "beginner"], + ["category", "push"], + ["t", "machine"], + ["t", "push"], + ["t", "shoulders"], + ["t", "triceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Stand with dumbbell in each hand at sides. Curl weights toward shoulders, keeping elbows close to body. Lower back down.", + tags: [ + ["d", "db-bicep-curl"], + ["title", "Dumbbell Bicep Curl"], + ["format", "weight", "reps", "set_type"], + ["format_units", "kg", "count", "warmup|normal|drop|failure"], + ["equipment", "dumbbell"], + ["difficulty", "beginner"], + ["category", "pull"], + ["t", "isolation"], + ["t", "pull"], + ["t", "biceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Attach rope to cable machine at top. Grip ends, pull down to chest level keeping elbows close. Control return.", + tags: [ + ["d", "cable-tricep-pushdown"], + ["title", "Cable Tricep Pushdown"], + ["format", "weight", "reps", "set_type"], + ["format_units", "kg", "count", "warmup|normal|drop|failure"], + ["equipment", "cable"], + ["difficulty", "beginner"], + ["category", "push"], + ["t", "isolation"], + ["t", "push"], + ["t", "triceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Kneel before cable machine, rope attachment at bottom. Pull rope toward forehead, keeping upper arms still. Lower with control.", + tags: [ + ["d", "cable-face-pull"], + ["title", "Cable Face Pull"], + ["format", "weight", "reps", "set_type"], + ["format_units", "kg", "count", "warmup|normal|drop|failure"], + ["equipment", "cable"], + ["difficulty", "intermediate"], + ["category", "pull"], + ["t", "isolation"], + ["t", "pull"], + ["t", "rear-deltoids"], + ["t", "upper-back"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + }, + { + kind: 33401, + content: "Stand with feet hip-width, holding kettlebell by horns at chest. Squat down keeping chest up, stand back up.", + tags: [ + ["d", "kb-goblet-squat"], + ["title", "Kettlebell Goblet Squat"], + ["format", "weight", "reps", "set_type"], + ["format_units", "kg", "count", "warmup|normal|drop|failure"], + ["equipment", "kettlebell"], + ["difficulty", "beginner"], + ["category", "legs"], + ["t", "compound"], + ["t", "squat"], + ["t", "legs"], + ["t", "quadriceps"] + ], + created_at: 1708300800, + id: generateId('nostr'), + pubkey: "powr", + sig: undefined + } +]; + +function getTagValue(tags: string[][], name: string): string | undefined { + const tag = tags.find((tag: string[]) => tag[0] === name); + return tag ? tag[1] : undefined; + } + + function getTags(tags: string[][]): string[] { + return tags + .filter((tag: string[]) => tag[0] === 't') + .map((tag: string[]) => tag[1]); + } + + export function convertNostrToExercise(event: NostrEvent): Exercise { + return { + id: event.id || '', + title: getTagValue(event.tags, 'title') || '', + type: getTagValue(event.tags, 'equipment') === 'bodyweight' + ? 'bodyweight' + : 'strength' as ExerciseType, + category: getTagValue(event.tags, 'category') as ExerciseCategory, + equipment: getTagValue(event.tags, 'equipment') as Equipment, + description: event.content, + format: getTagValue(event.tags, 'format') + ?.split(',') + .reduce((acc: Record, curr: string) => ({ + ...acc, + [curr]: true + }), {}), + format_units: getTagValue(event.tags, 'format_units') + ?.split(',') + .reduce((acc: Record, curr: string, i: number) => { + const format = getTagValue(event.tags, 'format')?.split(',')[i]; + return format ? { ...acc, [format]: curr } : acc; + }, {}), + tags: getTags(event.tags), + availability: { + source: ['powr'] + }, + created_at: event.created_at * 1000, + source: 'powr' + }; + } + +// Export pre-converted exercises for easy testing +export const mockExercises = mockExerciseEvents.map(convertNostrToExercise); + +// Helper to seed the database +export async function seedExercises(exerciseService: any) { + try { + const existingCount = (await exerciseService.getAllExercises()).length; + if (existingCount === 0) { + console.log('Seeding database with mock exercises...'); + for (const exercise of mockExercises) { + await exerciseService.createExercise(exercise); + } + console.log('Successfully seeded database'); + } + } catch (error) { + console.error('Error seeding database:', error); + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d8f1a34..ea33520 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,9 +4,7 @@ "strict": true, "baseUrl": ".", "paths": { - "@/*": [ - "*" - ] + "@/*": ["./*"] } }, "include": [ diff --git a/types/nostr.ts b/types/nostr.ts new file mode 100644 index 0000000..ca80418 --- /dev/null +++ b/types/nostr.ts @@ -0,0 +1,32 @@ +// types/nostr.ts +export interface NostrEvent { + id?: string; + pubkey?: string; + content: string; + created_at: number; + kind: number; + tags: string[][]; + sig?: string; + } + + export enum NostrEventKind { + EXERCISE = 33401, + TEMPLATE = 33402, + WORKOUT = 33403 + } + + export interface NostrTag { + name: string; + value: string; + index?: number; + } + + // Helper functions + export function getTagValue(tags: string[][], name: string): string | undefined { + const tag = tags.find(t => t[0] === name); + return tag ? tag[1] : undefined; + } + + export function getTagValues(tags: string[][], name: string): string[] { + return tags.filter(t => t[0] === name).map(t => t[1]); + } \ No newline at end of file