Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.
Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.
The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.
Closes #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
5.5 KiB
GDScript
148 lines
5.5 KiB
GDScript
class_name SoundtrackSearch
|
|
extends RefCounted
|
|
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
|
|
## special characters. Searches of at least [code]Settings.LETTERS_FOR_LOOSE_SEARCH[/code]
|
|
## letters also find the letters in order with others in between.
|
|
##
|
|
## Used by the Search popup and the statistics view, so both find the same soundtracks.
|
|
##
|
|
## A soundtrack is also found by its alternative names, e.g. "FF7" for "Final Fantasy
|
|
## VII". The results are always the real names; [method matched_alternative] tells which
|
|
## alternative name matched.
|
|
|
|
## Each searchable name as a Dictionary: the soundtrack's real name ("original"), the name
|
|
## searched in ("text"), and its lowercase and cleaned forms.
|
|
var _names: Array[Dictionary] = []
|
|
# The alternative name that matched in the last search, by real name
|
|
var _matched_alternatives: Dictionary = {}
|
|
|
|
## Compiled patterns for the letters-in-order search, by cleaned search term.
|
|
var _regex_cache: Dictionary = {}
|
|
|
|
|
|
## Replaces the names to search in. [param alternatives] has the other names of a
|
|
## soundtrack, by its real name.
|
|
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
|
|
_names.clear()
|
|
_matched_alternatives.clear()
|
|
for soundtrack: String in names:
|
|
_add_name(soundtrack, soundtrack)
|
|
for alternative: String in alternatives.get(soundtrack, []):
|
|
_add_name(soundtrack, alternative)
|
|
|
|
|
|
## The alternative name of [param soundtrack_name] that matched in the last
|
|
## [method find_matches], or an empty String when its real name matched.
|
|
func matched_alternative(soundtrack_name: String) -> String:
|
|
return _matched_alternatives.get(soundtrack_name, "")
|
|
|
|
|
|
## Returns the names matching [param search_text], best matches first: plain
|
|
## case-insensitive matches, then matches ignoring spaces and special characters,
|
|
## then matches with the letters in order but not next to each other.
|
|
func find_matches(search_text: String) -> Array[String]:
|
|
var seen: Dictionary = {}
|
|
var search_text_lower: String = search_text.to_lower()
|
|
var clean_search_text: String = clean_term(search_text)
|
|
var passes: Array[Callable] = [
|
|
func(entry: Dictionary) -> bool: return entry.lowercase.contains(search_text_lower),
|
|
func(entry: Dictionary) -> bool: return entry.cleaned.contains(clean_search_text),
|
|
]
|
|
if clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
|
|
var regex: RegEx = _compile_regex_cached(clean_search_text)
|
|
passes.append(
|
|
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
|
|
|
|
var matches: Array[String] = []
|
|
_matched_alternatives.clear()
|
|
for matches_pass: Callable in passes:
|
|
for entry: Dictionary in _names:
|
|
if !seen.has(entry.original) && matches_pass.call(entry):
|
|
seen[entry.original] = true
|
|
matches.append(entry.original)
|
|
if entry.text != entry.original:
|
|
_matched_alternatives[entry.original] = entry.text
|
|
return matches
|
|
|
|
|
|
## The positions of the letters in [param soundtrack_name] that [param search_text]
|
|
## matched, for highlighting them: the text itself if the name contains it, otherwise the
|
|
## letters found in order the way [method find_matches] finds them. Empty if it does not
|
|
## match.
|
|
static func matched_letters(soundtrack_name: String, search_text: String) -> Array[int]:
|
|
var positions: Array[int] = []
|
|
if search_text.is_empty():
|
|
return positions
|
|
var start: int = soundtrack_name.to_lower().find(search_text.to_lower())
|
|
if start >= 0:
|
|
for i: int in search_text.length():
|
|
positions.append(start + i)
|
|
return positions
|
|
|
|
# Clean the name piece by piece, remembering which letter each cleaned letter came from
|
|
var cleaned: String = ""
|
|
var source: Array[int] = []
|
|
var i: int = 0
|
|
while i < soundtrack_name.length():
|
|
var piece: String
|
|
var length: int = 1
|
|
if soundtrack_name.substr(i, 2).to_lower() == "'n":
|
|
piece = "and"
|
|
length = 2
|
|
else:
|
|
piece = clean_term(soundtrack_name[i])
|
|
for letter: String in piece:
|
|
cleaned += letter
|
|
source.append(i)
|
|
i += length
|
|
|
|
var clean_search_text: String = clean_term(search_text)
|
|
var found: int = cleaned.find(clean_search_text)
|
|
var cleaned_positions: Array[int] = []
|
|
if found >= 0:
|
|
for j: int in clean_search_text.length():
|
|
cleaned_positions.append(found + j)
|
|
elif clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
|
|
var at: int = 0
|
|
for letter: String in clean_search_text:
|
|
at = cleaned.find(letter, at)
|
|
if at < 0:
|
|
return positions
|
|
cleaned_positions.append(at)
|
|
at += 1
|
|
for cleaned_position: int in cleaned_positions:
|
|
if !positions.has(source[cleaned_position]):
|
|
positions.append(source[cleaned_position])
|
|
return positions
|
|
|
|
|
|
## Cleans a term by removing spaces and replacing special characters.
|
|
## Matches MusicServer's clean_term() function.
|
|
static func clean_term(term: String) -> String:
|
|
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
|
|
.replace("'n", "and").to_lower()
|
|
|
|
|
|
func _add_name(soundtrack: String, text: String) -> void:
|
|
_names.append({
|
|
"original": soundtrack,
|
|
"text": text,
|
|
"lowercase": text.to_lower(),
|
|
"cleaned": clean_term(text),
|
|
})
|
|
|
|
|
|
# Compiles the letters-in-order pattern for [param search_term], once per term
|
|
func _compile_regex_cached(search_term: String) -> RegEx:
|
|
if not search_term in _regex_cache:
|
|
var regex: RegEx = RegEx.new()
|
|
var regex_text: String = ".*"
|
|
for letter: String in search_term:
|
|
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
|
|
if "\\^$.|?*+()[]{}".contains(letter):
|
|
regex_text += "\\"
|
|
regex_text += letter + ".*"
|
|
regex.compile(regex_text)
|
|
_regex_cache[search_term] = regex
|
|
return _regex_cache[search_term]
|