diff --git a/scripts/benchmark-search.js b/scripts/benchmark-search.js new file mode 100644 index 0000000..040bd8f --- /dev/null +++ b/scripts/benchmark-search.js @@ -0,0 +1,37 @@ +import { performance } from 'perf_hooks'; +import { searchCards } from '../src/services/cardService.js'; + +async function run() { + const queries = ['lightning', 'island', 'sol', 'dragon', 'guild', 'counterspell']; + console.log('Running search benchmarks (warmup + timed runs)...'); + + // Warmup + for (const q of queries) { + searchCards(q, 10); + } + + const results = []; + for (const q of queries) { + const runs = 5; + let total = 0; + for (let i = 0; i < runs; i++) { + const t0 = performance.now(); + const res = searchCards(q, 20); + const t1 = performance.now(); + const ms = t1 - t0; + total += ms; + } + const avg = total / runs; + results.push({ query: q, avg_ms: avg }); + } + + console.log('Benchmark results:'); + for (const r of results) { + console.log(`- ${r.query}: ${r.avg_ms.toFixed(2)} ms (avg)`); + } +} + +run().catch(err => { + console.error('Benchmark failed:', err); + process.exit(1); +}); diff --git a/scripts/import-mtgjson.js b/scripts/import-mtgjson.js index 675daeb..99dd8d9 100644 --- a/scripts/import-mtgjson.js +++ b/scripts/import-mtgjson.js @@ -444,7 +444,7 @@ async function importCards(sourceDb, targetDb) { // Import foreign data console.log('Importing foreign card data...'); const foreignData = srcDb.prepare(` - SELECT c.name, fd.language, fd.faceName, fd.text, fd.type, fd.flavorText + SELECT c.name as english_name, fd.language, fd.name as faceName, fd.text, fd.type, fd.flavorText FROM cardForeignData fd JOIN cards c ON fd.uuid = c.uuid WHERE c.name IS NOT NULL AND fd.language IS NOT NULL @@ -460,12 +460,12 @@ async function importCards(sourceDb, targetDb) { let inserted = 0; for (const fd of data) { // Only insert if the card exists in our target database - const cardExists = targetDb.prepare('SELECT 1 FROM cards WHERE name = ? LIMIT 1').get(fd.name); + const cardExists = targetDb.prepare('SELECT 1 FROM cards WHERE name = ? LIMIT 1').get(fd.english_name); if (cardExists) { insertForeign.run( - fd.name, + fd.english_name, // card_name: English card name fd.language, - fd.faceName, + fd.faceName, // foreign_name: translated name fd.text, fd.type, fd.flavorText diff --git a/src/db/migrations/010-add-foreign-data.js b/src/db/migrations/010-add-foreign-data.js index ff57b59..89fdb78 100644 --- a/src/db/migrations/010-add-foreign-data.js +++ b/src/db/migrations/010-add-foreign-data.js @@ -12,6 +12,7 @@ export function up(db) { ); CREATE INDEX idx_foreign_card_name ON card_foreign_data(card_name); CREATE INDEX idx_foreign_language ON card_foreign_data(language); + CREATE INDEX idx_foreign_name ON card_foreign_data(foreign_name); `); console.log('✓ Created card_foreign_data table'); } diff --git a/src/db/migrations/017-add-card-search-fts.js b/src/db/migrations/017-add-card-search-fts.js new file mode 100644 index 0000000..916aa4b --- /dev/null +++ b/src/db/migrations/017-add-card-search-fts.js @@ -0,0 +1,16 @@ +export function up(db) { + // FTS5 table for fast multilingual searching across card names and foreign names + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS card_search USING fts5( + name, + foreign_names, + card_id UNINDEXED + ); + `); + console.log('Created card_search FTS5 table'); +} + +export function down(db) { + db.exec(`DROP TABLE IF EXISTS card_search;`); + console.log('Dropped card_search FTS5 table'); +} diff --git a/src/services/cardService.js b/src/services/cardService.js index c8b4a15..06395bf 100644 --- a/src/services/cardService.js +++ b/src/services/cardService.js @@ -18,35 +18,45 @@ function generateImageUrls(uuid) { } /** - * Search cards by name (for autocomplete) + * Search cards by name (autocomplete) - returns individual printings + * Supports English and foreign names, plus second parts of split cards. */ export function searchCards(query, limit = 20) { - const searchTerm = `%${query}%`; + const prefix = `${query}%`; + const infix = `%${query}%`; - // Update the query to include image_url - const cards = db.all( + const rows = db.all( `SELECT c.id, c.name, c.mana_cost, c.cmc, c.colors, c.type_line, c.oracle_text, p.image_url, - (SELECT p.uuid FROM printings p WHERE p.card_id = c.id LIMIT 1) as sample_uuid + p.set_code, + s.name as set_name, + p.collector_number, + p.rarity, + p.uuid as sample_uuid, + CASE + WHEN c.name LIKE ? THEN 0 + WHEN c.name LIKE '%//%' AND c.name LIKE ? THEN 1 + WHEN EXISTS(SELECT 1 FROM card_foreign_data f WHERE f.card_name = c.name AND f.foreign_name LIKE ? LIMIT 1) THEN 2 + WHEN EXISTS(SELECT 1 FROM card_foreign_data f WHERE f.card_name = c.name AND f.foreign_name LIKE '%//%' AND f.foreign_name LIKE ? LIMIT 1) THEN 3 + ELSE 4 + END as match_priority FROM cards c LEFT JOIN printings p ON p.card_id = c.id + LEFT JOIN sets s ON p.set_code = s.code WHERE c.name LIKE ? - ORDER BY - CASE - WHEN c.name LIKE ? THEN 0 - ELSE 1 - END, - c.name + OR (c.name LIKE '%//%' AND c.name LIKE ?) + OR EXISTS(SELECT 1 FROM card_foreign_data f WHERE f.card_name = c.name AND f.foreign_name LIKE ? LIMIT 1) + OR EXISTS(SELECT 1 FROM card_foreign_data f WHERE f.card_name = c.name AND f.foreign_name LIKE '%//%' AND f.foreign_name LIKE ? LIMIT 1) + ORDER BY match_priority, c.name, p.set_code, p.collector_number LIMIT ?`, - [searchTerm, `${query}%`, limit] + [prefix, infix, prefix, infix, prefix, infix, prefix, infix, limit] ); - // Add image URLs from database - return cards.map(card => ({ - ...card, - image_url: card.image_url, - large_image_url: card.image_url ? card.image_url.replace('/normal/', '/large/') : null, - art_crop_url: card.image_url ? card.image_url.replace('/normal/', '/art_crop/') : null + return rows.map(row => ({ + ...row, + image_url: row.image_url, + large_image_url: row.image_url ? row.image_url.replace('/normal/', '/large/') : null, + art_crop_url: row.image_url ? row.image_url.replace('/normal/', '/art_crop/') : null })); }