1use std::collections::HashMap;
4
5use rusqlite::Connection;
6use unicode_normalization::UnicodeNormalization;
7
8use crate::db::connection::DbError;
9use crate::db::queries::batch::{CreditsOf, credited_artists};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CreditPart {
15 pub text: String,
16 pub artist_id: Option<i64>,
17}
18
19pub fn credit_parts<S: AsRef<str>>(
29 credit: &str,
30 filed: Option<i64>,
31 artists: &[(i64, S)],
32) -> Vec<CreditPart> {
33 if artists.len() < 2 {
34 return vec![CreditPart {
35 text: credit.to_string(),
36 artist_id: filed,
37 }];
38 }
39 let credit: String = credit.nfc().collect();
40 let folded = Folded::new(&credit);
41 let mut parts = Vec::with_capacity(artists.len() * 2 + 1);
42 let mut at = 0;
43 let mut byte = 0;
44 for (id, name) in artists {
45 let name = Folded::name(name.as_ref());
46 let Some(found) = folded.find(&name, at) else {
47 return comma_joined(artists);
48 };
49 let (start, end) = folded.bytes(found, name.len());
50 if start > byte {
51 parts.push(CreditPart {
52 text: credit[byte..start].to_string(),
53 artist_id: None,
54 });
55 }
56 parts.push(CreditPart {
57 text: credit[start..end].to_string(),
58 artist_id: Some(*id),
59 });
60 at = found + name.len();
61 byte = end;
62 }
63 if byte < credit.len() {
64 parts.push(CreditPart {
65 text: credit[byte..].to_string(),
66 artist_id: None,
67 });
68 }
69 parts
70}
71
72struct Folded {
78 chars: Vec<char>,
79 spans: Vec<(usize, usize)>,
80}
81
82impl Folded {
83 fn new(credit: &str) -> Self {
84 let mut chars = Vec::with_capacity(credit.len());
85 let mut spans: Vec<(usize, usize)> = Vec::with_capacity(credit.len());
86 for (at, c) in credit.char_indices() {
87 let span = (at, at + c.len_utf8());
88 if c.is_whitespace() {
89 if chars.last() == Some(&' ') {
90 spans.last_mut().expect("a space was pushed").1 = span.1;
91 } else {
92 chars.push(' ');
93 spans.push(span);
94 }
95 } else {
96 for lower in c.to_lowercase() {
97 chars.push(if lower == 'ς' { 'σ' } else { lower });
98 spans.push(span);
99 }
100 }
101 }
102 Self { chars, spans }
103 }
104
105 fn name(name: &str) -> Vec<char> {
106 let name: String = name.nfc().collect();
107 let mut chars = Self::new(&name).chars;
108 while chars.last() == Some(&' ') {
109 chars.pop();
110 }
111 let lead = chars.iter().take_while(|c| **c == ' ').count();
112 chars.split_off(lead)
113 }
114
115 fn find(&self, name: &[char], from: usize) -> Option<usize> {
118 if name.is_empty() || name.len() > self.chars.len() {
119 return None;
120 }
121 let word = |i: usize| self.chars.get(i).is_some_and(|c| c.is_alphanumeric());
122 (from..=self.chars.len() - name.len()).find(|&i| {
123 self.chars[i..i + name.len()] == *name
124 && !(name[0].is_alphanumeric() && i > 0 && word(i - 1))
125 && !(name[name.len() - 1].is_alphanumeric() && word(i + name.len()))
126 })
127 }
128
129 fn bytes(&self, at: usize, len: usize) -> (usize, usize) {
131 (self.spans[at].0, self.spans[at + len - 1].1)
132 }
133}
134
135fn comma_joined<S: AsRef<str>>(artists: &[(i64, S)]) -> Vec<CreditPart> {
136 let mut parts = Vec::with_capacity(artists.len() * 2 - 1);
137 for (i, (id, name)) in artists.iter().enumerate() {
138 if i > 0 {
139 parts.push(CreditPart {
140 text: ", ".to_string(),
141 artist_id: None,
142 });
143 }
144 parts.push(CreditPart {
145 text: name.as_ref().to_string(),
146 artist_id: Some(*id),
147 });
148 }
149 parts
150}
151
152pub fn credits_by_id(
155 conn: &Connection,
156 of: CreditsOf,
157 ids: &[i64],
158) -> Result<HashMap<i64, Vec<(i64, String)>>, DbError> {
159 Ok(credited_artists(conn, of, ids)?
160 .into_iter()
161 .map(|(owner, artists)| {
162 let named = artists
163 .into_iter()
164 .map(|c| (c.artist.id, c.artist.name))
165 .collect();
166 (owner, named)
167 })
168 .collect())
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn read(parts: &[CreditPart]) -> Vec<(&str, Option<i64>)> {
176 parts
177 .iter()
178 .map(|p| (p.text.as_str(), p.artist_id))
179 .collect()
180 }
181
182 #[test]
183 fn each_name_in_the_credit_links_its_artist() {
184 let credit = "Orchestre Tout Puissant Marcel Duchamp × Frànçois & the Atlas Mountains";
185 let parts = credit_parts(
186 credit,
187 Some(9),
188 &[
189 (1, "Orchestre Tout Puissant Marcel Duchamp"),
190 (2, "Frànçois & the Atlas Mountains"),
191 ],
192 );
193 assert_eq!(
194 read(&parts),
195 [
196 ("Orchestre Tout Puissant Marcel Duchamp", Some(1)),
197 (" × ", None),
198 ("Frànçois & the Atlas Mountains", Some(2)),
199 ]
200 );
201 }
202
203 #[test]
204 fn text_around_the_names_is_kept_plain() {
205 let parts = credit_parts("A feat. B (live)", None, &[(1, "A"), (2, "B")]);
206 assert_eq!(
207 read(&parts),
208 [
209 ("A", Some(1)),
210 (" feat. ", None),
211 ("B", Some(2)),
212 (" (live)", None)
213 ]
214 );
215 }
216
217 #[test]
218 fn a_name_not_in_the_credit_lists_the_artists_by_commas() {
219 let parts = credit_parts("Bjork & Friend", None, &[(1, "Björk"), (2, "Friend")]);
220 assert_eq!(
221 read(&parts),
222 [("Björk", Some(1)), (", ", None), ("Friend", Some(2))]
223 );
224 }
225
226 #[test]
227 fn a_name_is_found_whatever_its_case_and_reads_as_the_credit_spells_it() {
228 let parts = credit_parts(
229 "Boards Of Canada & Hood",
230 None,
231 &[(1, "Boards of Canada"), (2, "Hood")],
232 );
233 assert_eq!(
234 read(&parts),
235 [
236 ("Boards Of Canada", Some(1)),
237 (" & ", None),
238 ("Hood", Some(2))
239 ]
240 );
241 }
242
243 #[test]
244 fn a_name_is_matched_only_as_a_whole_word() {
245 let parts = credit_parts("Sonsie & Sons", None, &[(1, "Sons"), (2, "Sonsie")]);
246 assert_eq!(
247 read(&parts),
248 [("Sons", Some(1)), (", ", None), ("Sonsie", Some(2))]
249 );
250 let parts = credit_parts("Ann & Annabel", None, &[(1, "Ann"), (2, "Annabel")]);
251 assert_eq!(
252 read(&parts),
253 [("Ann", Some(1)), (" & ", None), ("Annabel", Some(2))]
254 );
255 }
256
257 #[test]
258 fn a_name_ending_in_sigma_is_found() {
259 let parts = credit_parts("ΧΆΟΣ & Βήμα", None, &[(1, "Χάος"), (2, "Βήμα")]);
260 assert_eq!(
261 read(&parts),
262 [("ΧΆΟΣ", Some(1)), (" & ", None), ("Βήμα", Some(2))]
263 );
264 }
265
266 #[test]
267 fn names_are_found_in_credit_order() {
268 let parts = credit_parts("B & A", None, &[(1, "A"), (2, "B")]);
269 assert_eq!(read(&parts), [("A", Some(1)), (", ", None), ("B", Some(2))]);
270 }
271
272 #[test]
273 fn a_credit_naming_no_artists_links_where_it_is_filed() {
274 assert_eq!(
275 read(&credit_parts::<&str>("Solo", Some(4), &[])),
276 [("Solo", Some(4))]
277 );
278 assert_eq!(read(&credit_parts::<&str>("", None, &[])), [("", None)]);
279 }
280}