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 a2925a5..0288d49 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/020-add-foreign-name-index.js b/src/db/migrations/020-add-foreign-name-index.js new file mode 100644 index 0000000..52d95be --- /dev/null +++ b/src/db/migrations/020-add-foreign-name-index.js @@ -0,0 +1,14 @@ +export function up(db) { + // Index foreign_name so multilingual card search can use it. + // This lives in its own migration (rather than being added to 010) because 010 has + // already been applied on existing installs and would never re-run for them. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_foreign_name ON card_foreign_data(foreign_name); + `); + console.log('✓ Added idx_foreign_name index on card_foreign_data'); +} + +export function down(db) { + db.exec(`DROP INDEX IF EXISTS idx_foreign_name;`); + console.log('✓ Dropped idx_foreign_name index'); +} diff --git a/src/services/cardService.js b/src/services/cardService.js index 2ae8ae3..de92ff4 100644 --- a/src/services/cardService.js +++ b/src/services/cardService.js @@ -18,40 +18,56 @@ 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, typeFilter = null) { - const searchTerm = `%${query}%`; + const prefix = `${query}%`; + const infix = `%${query}%`; + // Optional card-type filter (used by the price-watch type filter) must apply to the + // whole name-match group, hence the parenthesised OR block below. const typeClause = typeFilter ? 'AND c.type_line LIKE ?' : ''; - // Param order must match SQL placeholder order: - // 1. WHERE c.name LIKE ? 2. [AND type_line LIKE ?] 3. WHEN c.name LIKE ? 4. LIMIT ? - const params = typeFilter - ? [searchTerm, `%${typeFilter}%`, `${query}%`, limit] - : [searchTerm, `${query}%`, limit]; - const cards = db.all( + // Placeholder order: 4 for match_priority CASE, 4 for the WHERE name group, + // then [type filter], then LIMIT. + const params = [prefix, infix, prefix, infix, prefix, infix, prefix, infix]; + if (typeFilter) params.push(`%${typeFilter}%`); + params.push(limit); + + 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 ?) 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 ?) THEN 3 + ELSE 4 + END as match_priority FROM cards c LEFT JOIN printings p ON p.card_id = c.id - WHERE c.name LIKE ? ${typeClause} - ORDER BY - CASE - WHEN c.name LIKE ? THEN 0 - ELSE 1 - END, - c.name + LEFT JOIN sets s ON p.set_code = s.code + WHERE ( + c.name LIKE ? + 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 ?) + 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 ?) + ) ${typeClause} + ORDER BY match_priority, c.name, p.set_code, p.collector_number LIMIT ?`, params ); - // 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 })); }