Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb9263125 |
@@ -316,17 +316,32 @@ class ArtistService:
|
|||||||
cleaned = (prefix or "").strip()
|
cleaned = (prefix or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return []
|
return []
|
||||||
like = f"%{cleaned.lower()}%"
|
low = cleaned.lower()
|
||||||
prefix_like = f"{cleaned.lower()}%"
|
like = f"%{low}%"
|
||||||
# Rank: exact (0) < prefix (1) < substring (2).
|
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(
|
rank = case(
|
||||||
(func.lower(Artist.name) == cleaned.lower(), 0),
|
(func.lower(Artist.name) == low, 0),
|
||||||
(func.lower(Artist.name).like(prefix_like), 1),
|
(name_squashed == squashed, 1),
|
||||||
else_=2,
|
(func.lower(Artist.name).like(prefix_like), 2),
|
||||||
|
(func.lower(Artist.name).like(like), 3),
|
||||||
|
else_=4,
|
||||||
).label("rank")
|
).label("rank")
|
||||||
rows = (await self.session.execute(
|
rows = (await self.session.execute(
|
||||||
select(Artist, rank)
|
select(Artist, rank)
|
||||||
.where(func.lower(Artist.name).like(like))
|
.where(or_(*matches))
|
||||||
.order_by(rank, Artist.name.asc())
|
.order_by(rank, Artist.name.asc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)).all()
|
)).all()
|
||||||
|
|||||||
@@ -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--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; }
|
.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); }
|
||||||
|
|||||||
@@ -157,7 +157,6 @@
|
|||||||
closePanel();
|
closePanel();
|
||||||
const d = probe.discord || {};
|
const d = probe.discord || {};
|
||||||
const choice = discordPanelDefaults(probe);
|
const choice = discordPanelDefaults(probe);
|
||||||
let searchSeq = 0;
|
|
||||||
|
|
||||||
const scopeRow = (value, label, disabled) => {
|
const scopeRow = (value, label, disabled) => {
|
||||||
const input = el('input', {
|
const input = el('input', {
|
||||||
@@ -168,12 +167,21 @@
|
|||||||
return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]);
|
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', {
|
const nameInput = el('input', {
|
||||||
type: 'text', class: 'fc-panel__input', value: choice.artistName,
|
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,
|
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 hint = el('div', { class: 'fc-panel__hint' });
|
||||||
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
|
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
|
||||||
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
|
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('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
|
||||||
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
|
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
|
||||||
el('div', { class: 'fc-panel__label', text: 'Artist' }),
|
el('div', { class: 'fc-panel__label', text: 'Artist' }),
|
||||||
nameInput,
|
el('div', { class: 'fc-panel__combo' }, [nameInput, results]),
|
||||||
results,
|
|
||||||
hint,
|
hint,
|
||||||
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
|
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() {
|
function refresh() {
|
||||||
const req = discordAddRequest(choice);
|
const req = discordAddRequest(choice);
|
||||||
addBtn.disabled = !req;
|
addBtn.disabled = !req;
|
||||||
if (!req) hint.textContent = 'Pick an artist or type a name.';
|
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 if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`;
|
||||||
else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`;
|
else hint.textContent = `Creates a new artist “${req.artistName}”.`;
|
||||||
|
hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showResults(rows) {
|
function pick(artist) {
|
||||||
results.replaceChildren(...rows.map((a) => {
|
choice.artist = artist ? { id: artist.id, name: artist.name } : null;
|
||||||
const row = el('button', { class: 'fc-panel__result', text: a.name });
|
if (artist) {
|
||||||
row.addEventListener('click', () => {
|
choice.artistName = artist.name;
|
||||||
choice.artist = { id: a.id, name: a.name };
|
nameInput.value = artist.name;
|
||||||
choice.artistName = a.name;
|
}
|
||||||
nameInput.value = a.name;
|
closeList();
|
||||||
results.replaceChildren();
|
refresh();
|
||||||
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;
|
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;
|
let debounce = null;
|
||||||
nameInput.addEventListener('input', () => {
|
let searchSeq = 0;
|
||||||
choice.artistName = nameInput.value;
|
// `autofill` is false for deletions: filling the name back in as you
|
||||||
refresh();
|
// backspace would make it impossible to delete.
|
||||||
|
function search(autofill) {
|
||||||
clearTimeout(debounce);
|
clearTimeout(debounce);
|
||||||
const q = nameInput.value.trim();
|
const q = nameInput.value.trim();
|
||||||
if (!q) { results.replaceChildren(); return; }
|
if (!q) { rows = []; closeList(); return; }
|
||||||
debounce = setTimeout(async () => {
|
debounce = setTimeout(async () => {
|
||||||
const mine = ++searchSeq;
|
const mine = ++searchSeq;
|
||||||
let r;
|
let r;
|
||||||
@@ -228,14 +278,69 @@
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mine !== searchSeq || r?.error) return;
|
// Stale: a later keystroke has its own search coming.
|
||||||
showResults(r.artists || []);
|
if (mine !== searchSeq || r?.error || nameInput.value.trim() !== q) return;
|
||||||
}, 200);
|
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.
|
// Keep Discord's global shortcuts from eating keystrokes meant for us.
|
||||||
panel.addEventListener('keydown', (e) => {
|
panel.addEventListener('keydown', (e) => {
|
||||||
e.stopPropagation();
|
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();
|
if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,6 +358,10 @@
|
|||||||
document.body.appendChild(panel);
|
document.body.appendChild(panel);
|
||||||
refresh();
|
refresh();
|
||||||
nameInput.focus();
|
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) {
|
function showToast(text, kind) {
|
||||||
|
|||||||
@@ -78,3 +78,30 @@ function discordAddRequest(choice) {
|
|||||||
const name = (choice.artistName || '').trim();
|
const name = (choice.artistName || '').trim();
|
||||||
return name ? { url, artistName: name } : null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,3 +106,38 @@ describe('Discord Add panel', () => {
|
|||||||
expect(discordAddRequest(choice)).toBe(null)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -50,6 +50,34 @@ async def test_autocomplete_ranks_exact_prefix_substring(db):
|
|||||||
assert "Bob" not in names
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_autocomplete_empty_query_returns_empty(db):
|
async def test_autocomplete_empty_query_returns_empty(db):
|
||||||
svc = ArtistService(db)
|
svc = ArtistService(db)
|
||||||
|
|||||||
Reference in New Issue
Block a user