1use sofie_core::{Cast, Error, Result, SqlType, SqlValue};
2use sofie_values::{LATIN_COMBINING_ACCENTS, is_slug_character};
3use unicode_normalization::UnicodeNormalization as _;
4
5use crate::behavior::stored;
6use crate::{
7 Aggregate, Behavior, Column, Dialect, Field, Plan, Query, Read, Source, Step, Uses, Write,
8};
9
10const SLUG_LENGTH: usize = 100;
11const CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
13const CODE_LENGTH: usize = 10;
14
15pub struct Slugged {
16 pub from: &'static str,
17}
18
19impl Behavior for Slugged {
20 fn columns(&self) -> &'static [Field] {
21 const { &[Field::new("slug", String::COLUMNS, false)] }
22 }
23
24 fn written(&self, write: &Write<'_>, values: &mut [SqlValue]) {
26 let Some(SqlValue::Text(text)) = write.value(self.from) else {
27 return;
28 };
29 let kept = matches!(values[0], SqlValue::Text(_))
30 && write.loaded(self.from) == write.value(self.from);
31 if !kept {
32 values[0] = SqlValue::Text(slug(text));
33 }
34 }
35
36 fn planned(&self, write: &Write<'_>, plan: &mut Plan<'_>) -> Result<()> {
38 let index = format!("{}_slug", write.table.name);
39 plan.unique_column(write.table.schema, &index, self.from);
40 let table = record_table(write);
41 if write.deleted() == (false, true) {
42 let params = vec![table, SqlValue::Uuid(write.id)];
43 plan.push(previous_slugs(plan.dialect(), FORGET_RECORD, params));
44 return Ok(());
45 }
46 let Some((previous, current)) = renamed(write) else {
47 return Ok(());
48 };
49 if current.is_empty() {
50 return Err(Error::invalid(
51 self.from,
52 "needs a letter or digit to name it by",
53 ));
54 }
55 let params = vec![table.clone(), SqlValue::Text(current.to_owned())];
56 plan.push(previous_slugs(plan.dialect(), FORGET_SLUG, params));
57 if let Some(previous) = previous {
58 let params = vec![
59 table,
60 SqlValue::Text(previous.to_owned()),
61 SqlValue::Uuid(write.id),
62 SqlValue::Timestamp(write.stamp.now.timestamp()),
63 ];
64 plan.push(previous_slugs(plan.dialect(), KEEP, params));
65 }
66 Ok(())
67 }
68}
69
70const FORGET_RECORD: &str = "DELETE FROM {table} WHERE record_table = {1} AND record = {2}";
71const FORGET_SLUG: &str = "DELETE FROM {table} WHERE record_table = {1} AND slug = {2}";
72const KEEP: &str = "INSERT INTO {table} (record_table, slug, record, changed_at) \
73 VALUES ({1}, {2}, {3}, {4}) ON CONFLICT (record_table, slug) \
74 DO UPDATE SET record = excluded.record, changed_at = excluded.changed_at";
75
76fn renamed<'w>(write: &'w Write<'_>) -> Option<(Option<&'w str>, &'w str)> {
78 let Some(SqlValue::Text(current)) = write.value("slug") else {
79 return None;
80 };
81 match write.loaded("slug") {
82 Some(SqlValue::Text(previous)) if previous == current => None,
83 Some(SqlValue::Text(previous)) if !previous.is_empty() => Some((Some(previous), current)),
84 _ => Some((None, current)),
85 }
86}
87
88fn record_table(write: &Write<'_>) -> SqlValue {
89 SqlValue::Text(format!("{}.{}", write.table.schema, write.table.name))
90}
91
92fn previous_slugs(dialect: &dyn Dialect, template: &str, params: Vec<SqlValue>) -> Step {
93 let mut sql = template.replace("{table}", &dialect.qualified("sofie", "previous_slug"));
94 for index in 1..=params.len() {
95 sql = sql.replace(&format!("{{{index}}}"), &dialect.placeholder(index));
96 }
97 Step::statement(sql, params, None)
98}
99
100fn slug(text: &str) -> String {
101 let mut slug = String::new();
102 for word in words(text).split('-').filter(|word| !word.is_empty()) {
103 if slug.len() + word.len() + 1 > SLUG_LENGTH {
104 if slug.is_empty() {
105 slug.push_str(cut(word, SLUG_LENGTH));
106 }
107 break;
108 }
109 if !slug.is_empty() {
110 slug.push('-');
111 }
112 slug.push_str(word);
113 }
114 slug
115}
116
117fn words(text: &str) -> String {
118 let mut words = String::with_capacity(text.len());
119 for character in text.nfc() {
120 if let Some(latin) = latin(character) {
121 words.push_str(latin);
122 continue;
123 }
124 for lowered in character.to_lowercase() {
125 match latin(lowered) {
126 Some(latin) => words.push_str(latin),
127 None if LATIN_COMBINING_ACCENTS.contains(&lowered) => {}
129 None if is_slug_character(lowered) => words.push(lowered),
130 None => words.push('-'),
131 }
132 }
133 }
134 words
135}
136
137fn cut(word: &str, length: usize) -> &str {
138 let mut end = length;
139 while !word.is_char_boundary(end) {
140 end -= 1;
141 }
142 &word[..end]
143}
144
145fn latin(character: char) -> Option<&'static str> {
147 Some(match character {
148 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
149 'æ' => "ae",
150 'ç' | 'ć' | 'č' => "c",
151 'ď' | 'đ' | 'ð' => "d",
152 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ę' | 'ě' => "e",
153 'ğ' => "g",
154 'ì' | 'í' | 'î' | 'ï' | 'ī' | 'ı' | 'İ' => "i",
155 'ł' => "l",
156 'ñ' | 'ń' | 'ň' => "n",
157 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ő' => "o",
158 'œ' => "oe",
159 'ř' => "r",
160 'ß' => "ss",
161 'ś' | 'ş' | 'š' | 'ș' => "s",
162 'ț' | 'ţ' | 'ť' => "t",
163 'þ' => "th",
164 'ù' | 'ú' | 'û' | 'ü' | 'ū' | 'ů' | 'ű' => "u",
165 'ý' | 'ÿ' => "y",
166 'ź' | 'ż' | 'ž' => "z",
167 _ => return cyrillic(character),
168 })
169}
170
171fn cyrillic(character: char) -> Option<&'static str> {
172 Some(match character {
173 'а' => "a",
174 'б' => "b",
175 'в' => "v",
176 'г' | 'ґ' => "g",
177 'д' => "d",
178 'е' | 'ё' | 'э' => "e",
179 'є' => "ye",
180 'ж' => "zh",
181 'з' => "z",
182 'и' | 'і' => "i",
183 'ї' => "yi",
184 'й' | 'ы' => "y",
185 'к' => "k",
186 'л' => "l",
187 'м' => "m",
188 'н' => "n",
189 'о' => "o",
190 'п' => "p",
191 'р' => "r",
192 'с' => "s",
193 'т' => "t",
194 'у' => "u",
195 'ф' => "f",
196 'х' => "kh",
197 'ц' => "ts",
198 'ч' => "ch",
199 'ш' => "sh",
200 'щ' => "shch",
201 'ъ' | 'ь' => "",
202 'ю' => "yu",
203 'я' => "ya",
204 _ => return None,
205 })
206}
207
208pub trait Slugs: Aggregate {
209 const SLUG: Column<Self, String> = Column::new("slug");
210
211 fn slug(&self) -> &str {
212 stored_text(self, "slug")
213 }
214}
215
216impl<A: Uses<Slugged>> Slugs for A {}
217
218impl<T: Source> Query<T>
219where
220 T::Base: Slugs,
221{
222 pub async fn by_slug(self, read: &Read, slug: &str) -> Result<T> {
224 let named = self
225 .clone()
226 .where_(T::Base::SLUG.eq(slug.to_owned()))
227 .first(read)
228 .await?;
229 if let Some(record) = named {
230 return Ok(record);
231 }
232 let Some(current) = current_slug::<T::Base>(read, slug).await? else {
233 return Err(Error::NotFound);
234 };
235 if self
236 .where_(T::Base::SLUG.eq(current.clone()))
237 .exists(read)
238 .await?
239 {
240 return Err(Error::Moved {
241 from: slug.to_owned(),
242 to: current,
243 });
244 }
245 Err(Error::NotFound)
246 }
247}
248
249async fn current_slug<A: Aggregate>(read: &Read, previous: &str) -> Result<Option<String>> {
250 let dialect = read.dialect();
251 let sql = format!(
252 "SELECT named.slug FROM {} AS moved JOIN {} AS named ON named.id = moved.record \
253 WHERE moved.record_table = {} AND moved.slug = {}",
254 dialect.qualified("sofie", "previous_slug"),
255 dialect.qualified(A::MODULE, A::NAME),
256 dialect.placeholder(1),
257 dialect.placeholder(2)
258 );
259 let params = [
260 SqlValue::Text(format!("{}.{}", A::MODULE, A::NAME)),
261 SqlValue::Text(previous.to_owned()),
262 ];
263 let slugs = read
264 .decoded(&sql, ¶ms, 1, vec![Some(SqlType::Text)], String::decode)
265 .await?;
266 Ok(slugs.into_iter().next())
267}
268
269pub struct UniqueCode;
271
272impl Behavior for UniqueCode {
273 fn columns(&self) -> &'static [Field] {
274 const { &[Field::new("code", String::COLUMNS, false)] }
275 }
276
277 fn written(&self, write: &Write<'_>, values: &mut [SqlValue]) {
278 if values[0] == SqlValue::Null {
279 values[0] = SqlValue::Text(code(write.id.as_bytes()));
280 }
281 }
282}
283
284fn code(id: &[u8; 16]) -> String {
286 let random = u64::from_be_bytes(id[8..].try_into().expect("eight bytes"));
287 (0..CODE_LENGTH)
288 .map(|index| {
289 let digit = (random >> (5 * index)) & 0b1_1111;
290 char::from(CODE_ALPHABET[usize::try_from(digit).expect("a five-bit digit")])
291 })
292 .collect()
293}
294
295pub trait Coded: Aggregate {
296 const CODE: Column<Self, String> = Column::new("code");
297
298 fn code(&self) -> &str {
299 stored_text(self, "code")
300 }
301}
302
303impl<A: Uses<UniqueCode>> Coded for A {}
304
305fn stored_text<'a, A: Aggregate>(aggregate: &'a A, column: &str) -> &'a str {
306 match stored(aggregate, column) {
307 Some(SqlValue::Text(text)) => text,
308 _ => "",
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn a_slug_joins_lowercase_words_by_hyphens() {
318 assert_eq!(slug(" Blue Wool -- Scarf (2026)!"), "blue-wool-scarf-2026");
319 assert_eq!(slug("a/b_c.d,e:f"), "a-b-c-d-e-f");
320 assert_eq!(slug("!?"), "");
321 }
322
323 #[test]
324 fn a_slug_spells_turkish_german_and_accented_latin_letters_in_ascii() {
325 assert_eq!(slug("Öğle Yemeği"), "ogle-yemegi");
326 assert_eq!(
327 slug("İstanbul'da Çay Şöleni ılık"),
328 "istanbul-da-cay-soleni-ilik"
329 );
330 assert_eq!(slug("Große Straße für Äpfel"), "grosse-strasse-fur-apfel");
331 assert_eq!(
332 slug("Crème brûlée à la française"),
333 "creme-brulee-a-la-francaise"
334 );
335 assert_eq!(slug("Cre\u{300}me"), "creme");
336 assert_eq!(
337 slug("Smørrebrød Æble Œuvre Łódź Đak Ñandú Åre"),
338 "smorrebrod-aeble-oeuvre-lodz-dak-nandu-are"
339 );
340 }
341
342 #[test]
343 fn a_slug_spells_cyrillic_in_latin_and_keeps_other_scripts() {
344 assert_eq!(slug("Привет, мир!"), "privet-mir");
345 assert_eq!(slug("Щедрий вечір, Україно"), "shchedriy-vechir-ukrayino");
346 assert_eq!(slug("关于我们 2026"), "关于我们-2026");
347 assert_eq!(slug("Ελλάδα"), "ελλάδα");
348 }
349
350 #[test]
351 fn a_decomposed_title_slugs_as_its_composed_form() {
352 assert_eq!(slug("Мой край"), slug("Мои\u{306} край"));
353 assert_eq!(slug("Ελλάδα"), slug("Ελλα\u{301}δα"));
354 }
355
356 #[test]
357 fn a_slug_keeps_the_marks_of_other_scripts_in_their_word() {
358 assert_eq!(slug("नमस्ते दुनिया"), "नमस्ते-दुनिया");
359 assert_eq!(slug("ไม่ ใช่"), "ไม่-ใช่");
360 assert_eq!(slug("关于 — 我们 🎉 «x»"), "关于-我们-x");
361 }
362
363 #[test]
364 fn a_slug_stops_before_the_word_that_passes_its_length() {
365 assert_eq!(slug(&"word ".repeat(30)).len(), 99);
366 let long = slug(&"ğüş ".repeat(40));
367 assert!(long.len() <= SLUG_LENGTH && long.ends_with("gus"), "{long}");
368 let chinese = slug(&"关".repeat(40));
369 assert_eq!(chinese, "关".repeat(33));
370 }
371
372 #[test]
373 fn a_code_spells_the_random_bits_of_the_id() {
374 let first = code(&[0; 16]);
375 let other = code(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
376
377 assert_eq!(first, "0000000000");
378 assert_eq!(other, "1000000000");
379 }
380}