diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index b7de9dc..b3d87a2 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -316,17 +316,32 @@ class ArtistService: cleaned = (prefix or "").strip() if not cleaned: return [] - like = f"%{cleaned.lower()}%" - prefix_like = f"{cleaned.lower()}%" - # Rank: exact (0) < prefix (1) < substring (2). + low = cleaned.lower() + like = f"%{low}%" + prefix_like = f"{low}%" + # Spacing- and punctuation-insensitive too, so "Tamada Heijun" finds + # "TamadaHeijun" and "sabu_art" finds "Sabu Art" — the same creator is + # spelled differently on every platform, and the browser extension's + # Add panel matches a Discord server name against these (milestone 429). + # [[:alnum:]] keeps non-Latin letters; a query with none (all + # punctuation) skips this arm rather than matching every artist. + squashed = "".join(ch for ch in low if ch.isalnum()) + name_squashed = func.regexp_replace(func.lower(Artist.name), "[^[:alnum:]]", "", "g") + matches = [func.lower(Artist.name).like(like)] + if squashed: + matches.append(name_squashed.like(f"%{squashed}%")) + # Rank: exact (0) < exact ignoring spacing (1) < prefix (2) < + # substring (3) < substring ignoring spacing (4). rank = case( - (func.lower(Artist.name) == cleaned.lower(), 0), - (func.lower(Artist.name).like(prefix_like), 1), - else_=2, + (func.lower(Artist.name) == low, 0), + (name_squashed == squashed, 1), + (func.lower(Artist.name).like(prefix_like), 2), + (func.lower(Artist.name).like(like), 3), + else_=4, ).label("rank") rows = (await self.session.execute( select(Artist, rank) - .where(func.lower(Artist.name).like(like)) + .where(or_(*matches)) .order_by(rank, Artist.name.asc()) .limit(limit) )).all() diff --git a/extension/content/content-script.css b/extension/content/content-script.css index 6ac3bdf..629e3bd 100644 --- a/extension/content/content-script.css +++ b/extension/content/content-script.css @@ -82,3 +82,18 @@ } .fc-panel__btn--primary { border-color: rgb(244, 186, 122); background: rgb(244, 186, 122); color: rgb(20, 23, 26); } .fc-panel__btn:disabled { opacity: 0.5; cursor: default; } + +/* Artist autocomplete — the list sits under the field, inside the panel. */ +.fc-panel__combo { position: relative; } +.fc-panel__results { + margin-top: 4px; border-radius: 6px; + background: rgb(12, 14, 16); +} +.fc-panel__results:empty { display: none; } +.fc-panel__results:not(:empty) { border: 1px solid rgb(70, 74, 80); padding: 3px; } +.fc-panel__result { display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box; } +.fc-panel__result--active { background: rgb(52, 44, 32); outline: 1px solid rgb(244, 186, 122); } +.fc-panel__result--new { color: rgb(244, 186, 122); } +.fc-panel__result-tag { font-size: 11px; color: rgb(140, 220, 160); } +.fc-panel__empty { padding: 6px 9px; font-size: 12px; color: rgb(170, 166, 156); } +.fc-panel__hint--match { color: rgb(140, 220, 160); } diff --git a/extension/content/content-script.js b/extension/content/content-script.js index 1d57474..293e163 100644 --- a/extension/content/content-script.js +++ b/extension/content/content-script.js @@ -157,7 +157,6 @@ closePanel(); const d = probe.discord || {}; const choice = discordPanelDefaults(probe); - let searchSeq = 0; const scopeRow = (value, label, disabled) => { const input = el('input', { @@ -168,12 +167,21 @@ return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]); }; + // The artist field is an autocomplete over FC's artists: it searches as + // soon as the panel opens (with the prefilled name) and on every keystroke, + // lists the matches under the field, fills in the rest of the top match as + // you type (Tab or Enter accepts it, typing on replaces it), and picks an + // artist whose name IS the text, spacing and case aside, without being + // asked. ↑/↓ walk the list; the last row creates a new artist instead. const nameInput = el('input', { type: 'text', class: 'fc-panel__input', value: choice.artistName, - placeholder: 'Artist name — search or type a new one', + placeholder: 'Search artists or type a new name', autocomplete: 'off', spellcheck: false, + role: 'combobox', }); - const results = el('div', { class: 'fc-panel__results' }); + nameInput.setAttribute('aria-autocomplete', 'both'); + nameInput.setAttribute('aria-expanded', 'false'); + const results = el('div', { class: 'fc-panel__results', role: 'listbox' }); const hint = el('div', { class: 'fc-panel__hint' }); const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' }); const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' }); @@ -185,41 +193,83 @@ scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id), scopeRow('server', `Every channel in ${serverLabel(d)}`, false), el('div', { class: 'fc-panel__label', text: 'Artist' }), - nameInput, - results, + el('div', { class: 'fc-panel__combo' }, [nameInput, results]), hint, el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]), ]); + // Search state: the rows on screen, which one ↑/↓ has highlighted (-1 = + // none), and whether the list is open. + let rows = []; + let active = -1; + let listOpen = false; + function refresh() { const req = discordAddRequest(choice); addBtn.disabled = !req; if (!req) hint.textContent = 'Pick an artist or type a name.'; - else if (req.artistId != null) hint.textContent = `Connects to ${choice.artist.name} in FabledCurator.`; - else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`; + else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`; + else hint.textContent = `Creates a new artist “${req.artistName}”.`; + hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null); } - function showResults(rows) { - results.replaceChildren(...rows.map((a) => { - const row = el('button', { class: 'fc-panel__result', text: a.name }); - row.addEventListener('click', () => { - choice.artist = { id: a.id, name: a.name }; - choice.artistName = a.name; - nameInput.value = a.name; - results.replaceChildren(); - refresh(); - }); + function pick(artist) { + choice.artist = artist ? { id: artist.id, name: artist.name } : null; + if (artist) { + choice.artistName = artist.name; + nameInput.value = artist.name; + } + closeList(); + refresh(); + } + + function closeList() { + listOpen = false; + active = -1; + results.replaceChildren(); + nameInput.setAttribute('aria-expanded', 'false'); + } + + function renderList() { + const typed = nameInput.value.trim(); + const exact = exactArtistMatch(typed, rows); + const items = rows.map((a, i) => { + const row = el('button', { type: 'button', class: 'fc-panel__result', role: 'option' }, [ + el('span', { text: a.name }), + ]); + if (choice.artist && choice.artist.id === a.id) { + row.appendChild(el('span', { class: 'fc-panel__result-tag', text: 'selected' })); + } + row.classList.toggle('fc-panel__result--active', i === active); + // mousedown, not click: it fires before the input's blur closes the list. + row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(a); }); return row; - })); + }); + if (typed && !exact) { + const i = rows.length; + const create = el('button', { type: 'button', class: 'fc-panel__result fc-panel__result--new', role: 'option', + text: `+ New artist “${typed}”` }); + create.classList.toggle('fc-panel__result--active', i === active); + create.addEventListener('mousedown', (e) => { e.preventDefault(); pick(null); choice.artistName = typed; refresh(); }); + items.push(create); + } + if (typed && !rows.length) { + items.unshift(el('div', { class: 'fc-panel__empty', text: 'No FabledCurator artist matches.' })); + } + results.replaceChildren(...items); + listOpen = items.length > 0; + nameInput.setAttribute('aria-expanded', String(listOpen)); + results.querySelector('.fc-panel__result--active')?.scrollIntoView({ block: 'nearest' }); } let debounce = null; - nameInput.addEventListener('input', () => { - choice.artistName = nameInput.value; - refresh(); + let searchSeq = 0; + // `autofill` is false for deletions: filling the name back in as you + // backspace would make it impossible to delete. + function search(autofill) { clearTimeout(debounce); const q = nameInput.value.trim(); - if (!q) { results.replaceChildren(); return; } + if (!q) { rows = []; closeList(); return; } debounce = setTimeout(async () => { const mine = ++searchSeq; let r; @@ -228,14 +278,69 @@ } catch { return; } - if (mine !== searchSeq || r?.error) return; - showResults(r.artists || []); - }, 200); + // Stale: a later keystroke has its own search coming. + if (mine !== searchSeq || r?.error || nameInput.value.trim() !== q) return; + rows = r.artists || []; + active = -1; + const exact = exactArtistMatch(q, rows); + if (exact && !choice.artist) { + choice.artist = { id: exact.id, name: exact.name }; + } else if (autofill && document.activeElement === nameInput) { + const hit = inlineCompletion(nameInput.value, rows); + const caret = nameInput.value.length; + if (hit && nameInput.selectionStart === caret) { + nameInput.value = nameInput.value + hit.name.slice(caret); + nameInput.setSelectionRange(caret, hit.name.length); + choice.artist = { id: hit.id, name: hit.name }; + choice.artistName = hit.name; + } + } + renderList(); + refresh(); + }, 150); + } + + nameInput.addEventListener('input', (e) => { + choice.artistName = nameInput.value; + // Any edit un-picks: the artist is whatever the field now says. + choice.artist = null; + refresh(); + search(!String(e.inputType || '').startsWith('delete')); }); + nameInput.addEventListener('focus', () => { if (rows.length) renderList(); }); + nameInput.addEventListener('blur', () => closeList()); + // Keep Discord's global shortcuts from eating keystrokes meant for us. panel.addEventListener('keydown', (e) => { e.stopPropagation(); - if (e.key === 'Escape') closePanel(); + const count = results.querySelectorAll('.fc-panel__result').length; + if (e.target === nameInput && listOpen && count) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + active = e.key === 'ArrowDown' ? (active + 1) % count : (active <= 0 ? count - 1 : active - 1); + renderList(); + return; + } + if ((e.key === 'Enter' || e.key === 'Tab') && active >= 0) { + e.preventDefault(); + results.querySelectorAll('.fc-panel__result')[active] + .dispatchEvent(new MouseEvent('mousedown', { cancelable: true })); + return; + } + if ((e.key === 'Tab' || e.key === 'Enter') && choice.artist + && nameInput.selectionEnd > nameInput.selectionStart) { + // Accept the inline autofill — Enter too, but only to accept: the + // add itself takes a second Enter, once the hint names the artist. + e.preventDefault(); + pick(choice.artist); + return; + } + } + if (e.key === 'Escape') { + if (listOpen) closeList(); + else closePanel(); + return; + } if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click(); }); @@ -253,6 +358,10 @@ document.body.appendChild(panel); refresh(); nameInput.focus(); + nameInput.select(); + // Search what the field opens with — the server's name, usually — so an + // artist it already matches is picked before the operator types anything. + if (!choice.artist && nameInput.value.trim()) search(false); } function showToast(text, kind) { diff --git a/extension/lib/chip.js b/extension/lib/chip.js index b17fddb..b698970 100644 --- a/extension/lib/chip.js +++ b/extension/lib/chip.js @@ -78,3 +78,30 @@ function discordAddRequest(choice) { const name = (choice.artistName || '').trim(); return name ? { url, artistName: name } : null; } + +/** + * An artist name reduced to what identifies it: lowercase letters and digits + * of any script, nothing else — so "Tamada Heijun", "tamada_heijun" and + * "TamadaHeijun" are one name. Mirrors the server's autocomplete (#429). + */ +function squashName(name) { + return String(name || '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); +} + +/** The search result that IS the query, spacing aside, else null. */ +function exactArtistMatch(query, results) { + const q = squashName(query); + if (!q) return null; + return (results || []).find((a) => squashName(a.name) === q) || null; +} + +/** + * Inline autofill: the first result whose name extends what was typed + * (case-insensitive), so the panel can fill in the rest and select it — + * typing on overwrites it, Tab or Enter accepts it. null when none does. + */ +function inlineCompletion(typed, results) { + const t = String(typed || '').toLowerCase(); + if (!t) return null; + return (results || []).find((a) => a.name.length > t.length && a.name.toLowerCase().startsWith(t)) || null; +} diff --git a/extension/test/chip.spec.js b/extension/test/chip.spec.js index b6955d5..14812c7 100644 --- a/extension/test/chip.spec.js +++ b/extension/test/chip.spec.js @@ -106,3 +106,38 @@ describe('Discord Add panel', () => { expect(discordAddRequest(choice)).toBe(null) }) }) + +const { squashName, exactArtistMatch, inlineCompletion } = loadLib('chip.js', [ + 'squashName', + 'exactArtistMatch', + 'inlineCompletion' +]) + +describe('artist matching for the Add panel', () => { + const results = [ + { id: 1, name: 'TamadaHeijun' }, + { id: 2, name: 'Tamago' }, + { id: 3, name: 'Sabu Art' } + ] + + it('treats spacing, case and punctuation as the same name', () => { + expect(squashName('Tamada Heijun')).toBe('tamadaheijun') + expect(squashName('sabu_art!')).toBe('sabuart') + expect(squashName('玉田 平順')).toBe('玉田平順') + }) + + it('finds the result that is the query, spacing aside', () => { + expect(exactArtistMatch('tamada heijun', results)).toEqual({ id: 1, name: 'TamadaHeijun' }) + expect(exactArtistMatch('Tama', results)).toBe(null) + expect(exactArtistMatch(' ', results)).toBe(null) + }) + + it('autofills the first name that extends what was typed', () => { + expect(inlineCompletion('tamad', results)).toEqual({ id: 1, name: 'TamadaHeijun' }) + expect(inlineCompletion('Sab', results).name).toBe('Sabu Art') + // Nothing to add once the name is complete, or when nothing extends it. + expect(inlineCompletion('Sabu Art', results)).toBe(null) + expect(inlineCompletion('heijun', results)).toBe(null) + expect(inlineCompletion('', results)).toBe(null) + }) +}) diff --git a/tests/test_artist_service_find_or_create.py b/tests/test_artist_service_find_or_create.py index e9bb838..1520dd1 100644 --- a/tests/test_artist_service_find_or_create.py +++ b/tests/test_artist_service_find_or_create.py @@ -50,6 +50,34 @@ async def test_autocomplete_ranks_exact_prefix_substring(db): assert "Bob" not in names +@pytest.mark.asyncio +async def test_autocomplete_ignores_spacing_and_punctuation(db): + """The same creator is spelled differently per platform — a Discord server + "Tamada Heijun" must find the Patreon artist "TamadaHeijun" (milestone 429).""" + db.add_all([ + Artist(name="TamadaHeijun", slug="tamadaheijun"), + Artist(name="Sabu Art", slug="sabu-art"), + Artist(name="Bob", slug="bob"), + ]) + await db.flush() + svc = ArtistService(db) + assert [r.name for r in await svc.autocomplete("Tamada Heijun")] == ["TamadaHeijun"] + assert [r.name for r in await svc.autocomplete("sabu_art")] == ["Sabu Art"] + # All punctuation: no squashed arm, so it does not match everyone. + assert await svc.autocomplete("--") == [] + + +@pytest.mark.asyncio +async def test_autocomplete_ranks_an_exact_match_ignoring_spacing_above_a_prefix(db): + db.add_all([ + Artist(name="Sabu Artworks", slug="sabu-artworks"), + Artist(name="SabuArt", slug="sabuart"), + ]) + await db.flush() + names = [r.name for r in await ArtistService(db).autocomplete("sabu art")] + assert names == ["SabuArt", "Sabu Artworks"] + + @pytest.mark.asyncio async def test_autocomplete_empty_query_returns_empty(db): svc = ArtistService(db)