feat(api): search with Meilisearch (M2)
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
- artists, albums and tracks indexes are rebuilt from PostgreSQL next to the live ones and swapped in, so search keeps working; a rebuild runs on start, after every scan and when a source is removed - transliteration fields (Кино ↔ kino) and a retry in the other keyboard layout (ghbdtn → привет) reported as corrected_query - /search/suggest (3 artists, 3 albums, 5 tracks), /search by type with cursor pages, /admin/search/reindex; Meilisearch is part of /readyz - found ids are hydrated from the catalog, so a lagging index never returns deleted entries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c583991d14
commit
c1160cccd3
16 files changed
+1590
-124
No files matched your search
@@ -0,0 +1,122 @@
|
||||
// Package search — поиск по каталогу через Meilisearch (docs/library-and-storage.md#поиск).
|
||||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Кириллица → латиница (упрощённая ГОСТ-подобная схема: «Кино» → «kino», «Щедрин» → «shchedrin»).
|
||||
var cyrToLat = map[rune]string{
|
||||
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i",
|
||||
'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t",
|
||||
'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "",
|
||||
'э': "e", 'ю': "yu", 'я': "ya", 'і': "i", 'ї': "yi", 'є': "ye", 'ґ': "g",
|
||||
}
|
||||
|
||||
// Латиница → кириллица: сначала длинные сочетания.
|
||||
var latToCyr = []struct{ lat, cyr string }{
|
||||
{"shch", "щ"}, {"zh", "ж"}, {"kh", "х"}, {"ts", "ц"}, {"ch", "ч"}, {"sh", "ш"}, {"yu", "ю"}, {"ya", "я"},
|
||||
{"yo", "ё"}, {"a", "а"}, {"b", "б"}, {"v", "в"}, {"g", "г"}, {"d", "д"}, {"e", "е"}, {"z", "з"},
|
||||
{"i", "и"}, {"y", "й"}, {"k", "к"}, {"l", "л"}, {"m", "м"}, {"n", "н"}, {"o", "о"}, {"p", "п"},
|
||||
{"r", "р"}, {"s", "с"}, {"t", "т"}, {"u", "у"}, {"f", "ф"}, {"h", "х"}, {"c", "к"}, {"w", "в"},
|
||||
{"x", "кс"}, {"q", "к"}, {"j", "дж"},
|
||||
}
|
||||
|
||||
// Translit — вариант строки в другой письменности для поля *_translit: кириллица → латиница,
|
||||
// латиница → кириллица. Смешанные строки переводятся посимвольно. Пустая строка — перевод не нужен.
|
||||
func Translit(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
hasCyr, hasLat := false, false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.Is(unicode.Cyrillic, r):
|
||||
hasCyr = true
|
||||
case r < unicode.MaxASCII && unicode.IsLetter(r):
|
||||
hasLat = true
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
switch {
|
||||
case hasCyr:
|
||||
for _, r := range s {
|
||||
if v, ok := cyrToLat[r]; ok {
|
||||
b.WriteString(v)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
case hasLat:
|
||||
for i := 0; i < len(s); {
|
||||
matched := false
|
||||
for _, p := range latToCyr {
|
||||
if strings.HasPrefix(s[i:], p.lat) {
|
||||
b.WriteString(p.cyr)
|
||||
i += len(p.lat)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Раскладки ЙЦУКЕН ↔ QWERTY: та же клавиша.
|
||||
const (
|
||||
qwerty = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./~QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?"
|
||||
jcuken = "ёйцукенгшщзхъфывапролджэячсмитьбю.ЁЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,"
|
||||
)
|
||||
|
||||
var (
|
||||
toRu = map[rune]rune{}
|
||||
toEn = map[rune]rune{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
q, j := []rune(qwerty), []rune(jcuken)
|
||||
for i := range q {
|
||||
toRu[q[i]] = j[i]
|
||||
toEn[j[i]] = q[i]
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchLayout — запрос, набранный не в той раскладке: «ghbdtn» → «привет», «руддщ» → «hello».
|
||||
// Направление выбирается по большинству букв. Пустая строка — менять нечего.
|
||||
func SwitchLayout(s string) string {
|
||||
lat, cyr := 0, 0
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.Is(unicode.Cyrillic, r):
|
||||
cyr++
|
||||
case r < unicode.MaxASCII && unicode.IsLetter(r):
|
||||
lat++
|
||||
}
|
||||
}
|
||||
if lat == 0 && cyr == 0 {
|
||||
return ""
|
||||
}
|
||||
m := toRu
|
||||
if cyr > lat {
|
||||
m = toEn
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if v, ok := m[r]; ok {
|
||||
b.WriteRune(v)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == s {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user