1use sofie_core::DisplayZone;
2use sofie_values::{Instant, Money};
3
4use crate::escape::push_escaped;
5use crate::render::push_display;
6
7const NO_BREAK_SPACE: char = '\u{a0}';
9
10const MONTHS: [&str; 12] = [
11 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
12];
13
14#[derive(Debug)]
15pub(crate) struct Conventions {
16 decimal: char,
17 group: Option<char>,
18 money: Placement,
19 days: DayOrder,
20}
21
22#[derive(Debug)]
23enum Placement {
24 CodeAfter,
26 SymbolBefore,
28 SymbolAfter,
30}
31
32#[derive(Debug)]
33enum DayOrder {
34 Iso,
36 MonthNameFirst,
38 DayFirstDotted,
40}
41
42pub(crate) const NEUTRAL: Conventions = Conventions {
43 decimal: '.',
44 group: None,
45 money: Placement::CodeAfter,
46 days: DayOrder::Iso,
47};
48
49const ENGLISH: Conventions = Conventions {
50 decimal: '.',
51 group: Some(','),
52 money: Placement::SymbolBefore,
53 days: DayOrder::MonthNameFirst,
54};
55
56const GERMAN: Conventions = Conventions {
57 decimal: ',',
58 group: Some('.'),
59 money: Placement::SymbolAfter,
60 days: DayOrder::DayFirstDotted,
61};
62
63const TURKISH: Conventions = Conventions {
64 decimal: ',',
65 group: Some('.'),
66 money: Placement::SymbolBefore,
67 days: DayOrder::DayFirstDotted,
68};
69
70pub(crate) fn for_language(language: &str) -> &'static Conventions {
72 match language {
73 "en" => &ENGLISH,
74 "de" => &GERMAN,
75 "tr" => &TURKISH,
76 _ => &NEUTRAL,
77 }
78}
79
80impl Conventions {
81 #[cfg(feature = "clients")]
83 pub(crate) fn describe(&self) -> serde_json::Value {
84 serde_json::json!({
85 "decimal": self.decimal.to_string(),
86 "group": self.group.map(|group| group.to_string()),
87 "money": match self.money {
88 Placement::CodeAfter => "code-after",
89 Placement::SymbolBefore => "symbol-before",
90 Placement::SymbolAfter => "symbol-after",
91 },
92 "days": match self.days {
93 DayOrder::Iso => "iso",
94 DayOrder::MonthNameFirst => "month-name-first",
95 DayOrder::DayFirstDotted => "day-first-dotted",
96 },
97 })
98 }
99
100 pub(crate) fn write_number(&self, out: &mut String, plain: &str) {
102 if plain.contains(['e', 'E']) {
103 out.push_str(plain);
104 return;
105 }
106 let (sign, digits) = split_sign(plain);
107 out.push_str(sign);
108 let (whole, fraction) = digits
109 .split_once('.')
110 .map_or((digits, None), |(whole, fraction)| (whole, Some(fraction)));
111 self.write_grouped(out, whole);
112 if let Some(fraction) = fraction {
113 out.push(self.decimal);
114 out.push_str(fraction);
115 }
116 }
117
118 fn write_grouped(&self, out: &mut String, whole: &str) {
119 let Some(group) = self.group else {
120 out.push_str(whole);
121 return;
122 };
123 for (index, digit) in whole.char_indices() {
124 if index > 0 && (whole.len() - index).is_multiple_of(3) {
125 out.push(group);
126 }
127 out.push(digit);
128 }
129 }
130
131 pub(crate) fn write_money(&self, out: &mut String, amount: Money, scratch: &mut String) {
133 let currency = amount.currency();
134 let code = currency.as_str();
135 scratch.clear();
136 amount.write_major(scratch);
137 match self.money {
138 Placement::CodeAfter => {
139 self.write_number(out, scratch);
140 out.push(' ');
141 out.push_str(code);
142 }
143 Placement::SymbolBefore => {
144 let (sign, digits) = split_sign(scratch);
145 out.push_str(sign);
146 if let Some(symbol) = symbol(code) {
147 out.push_str(symbol);
148 } else {
149 out.push_str(code);
150 out.push(NO_BREAK_SPACE);
151 }
152 self.write_number(out, digits);
153 }
154 Placement::SymbolAfter => {
155 self.write_number(out, scratch);
156 out.push(NO_BREAK_SPACE);
157 out.push_str(symbol(code).unwrap_or(code));
158 }
159 }
160 }
161
162 pub(crate) fn write_date(&self, out: &mut String, text: &str, scratch: &mut String) {
164 if is_day(text) {
165 self.write_day(out, text);
166 return;
167 }
168 if let Some((day, minute)) = wall_clock(text) {
169 self.write_day_and_minute(out, day, minute);
170 return;
171 }
172 let Ok(instant) = Instant::parse(text) else {
173 push_escaped(out, text);
174 return;
175 };
176 self.write_instant(out, instant, DisplayZone::current(), scratch);
177 }
178
179 pub(crate) fn write_instant(
180 &self,
181 out: &mut String,
182 instant: Instant,
183 zone: &DisplayZone,
184 scratch: &mut String,
185 ) {
186 scratch.clear();
187 push_display(scratch, zone.wall_clock(instant));
188 let (Some(day), Some(minute)) = (scratch.get(..10), scratch.get(11..16)) else {
189 out.push_str(scratch);
190 return;
191 };
192 self.write_day_and_minute(out, day, minute);
193 out.push(' ');
194 if let Some(offset) = zone.repeated_offset(instant) {
195 out.push_str(&offset);
196 out.push(' ');
197 }
198 push_escaped(out, zone.name());
199 }
200
201 pub(crate) fn write_local_date_time(&self, out: &mut String, local: &str, zone: &str) {
203 match (local.get(..10), local.get(10..11), local.get(11..16)) {
204 (Some(day), Some("T"), Some(minute)) if is_day(day) && is_minute(minute) => {
205 self.write_day_and_minute(out, day, minute);
206 }
207 _ => push_escaped(out, local),
208 }
209 out.push(' ');
210 push_escaped(out, zone);
211 }
212
213 fn write_day_and_minute(&self, out: &mut String, day: &str, minute: &str) {
214 self.write_day(out, day);
215 out.push_str(match self.days {
216 DayOrder::MonthNameFirst => ", ",
217 DayOrder::Iso | DayOrder::DayFirstDotted => " ",
218 });
219 out.push_str(minute);
220 }
221
222 fn write_day(&self, out: &mut String, iso: &str) {
223 let parts = (iso.get(..4), iso.get(5..7), iso.get(8..10));
224 let (Some(year), Some(month), Some(day), 10) = (parts.0, parts.1, parts.2, iso.len())
225 else {
226 out.push_str(iso);
227 return;
228 };
229 match self.days {
230 DayOrder::Iso => out.push_str(iso),
231 DayOrder::MonthNameFirst => {
232 let month_name = month
233 .parse::<usize>()
234 .ok()
235 .and_then(|number| MONTHS.get(number.checked_sub(1)?))
236 .unwrap_or(&"");
237 out.push_str(month_name);
238 out.push(' ');
239 out.push_str(day.trim_start_matches('0'));
240 out.push_str(", ");
241 out.push_str(year);
242 }
243 DayOrder::DayFirstDotted => {
244 for part in [day, ".", month, ".", year] {
245 out.push_str(part);
246 }
247 }
248 }
249 }
250}
251
252fn is_day(text: &str) -> bool {
253 text.len() == 10
254 && text.bytes().enumerate().all(|(index, byte)| match index {
255 4 | 7 => byte == b'-',
256 _ => byte.is_ascii_digit(),
257 })
258}
259
260fn wall_clock(text: &str) -> Option<(&str, &str)> {
263 let (day, rest) = (text.get(..10)?, text.get(10..)?);
264 let minute = rest.strip_prefix('T')?.get(..5)?;
265 let seconds = rest.get(6..)?;
266 let bare = seconds
267 .bytes()
268 .all(|byte| byte.is_ascii_digit() || byte == b':' || byte == b'.');
269 (is_day(day) && is_minute(minute) && bare).then_some((day, minute))
270}
271
272fn is_minute(text: &str) -> bool {
273 text.len() == 5
274 && text.bytes().enumerate().all(|(index, byte)| match index {
275 2 => byte == b':',
276 _ => byte.is_ascii_digit(),
277 })
278}
279
280fn split_sign(plain: &str) -> (&str, &str) {
281 plain
282 .strip_prefix('-')
283 .map_or(("", plain), |digits| ("-", digits))
284}
285
286pub const CURRENCY_SYMBOLS: [(&str, &str); 5] = [
289 ("EUR", "€"),
290 ("USD", "$"),
291 ("GBP", "£"),
292 ("JPY", "¥"),
293 ("TRY", "₺"),
294];
295
296fn symbol(code: &str) -> Option<&'static str> {
297 CURRENCY_SYMBOLS
298 .iter()
299 .find(|(known, _)| *known == code)
300 .map(|(_, symbol)| *symbol)
301}
302
303#[cfg(test)]
304mod tests {
305 use sofie_core::DisplayZone;
306 use sofie_values::{Currency, Instant, Money};
307
308 use super::{Conventions, NEUTRAL, for_language};
309
310 fn number(conventions: &Conventions, plain: &str) -> String {
311 let mut out = String::new();
312 conventions.write_number(&mut out, plain);
313 out
314 }
315
316 fn money(language: &str, minor: i64, code: &str) -> String {
317 let mut out = String::new();
318 let amount = Money::new(minor, Currency::parse(code).unwrap());
319 for_language(language).write_money(&mut out, amount, &mut String::new());
320 out
321 }
322
323 fn date(language: &str, text: &str) -> String {
324 let mut out = String::new();
325 for_language(language).write_date(&mut out, text, &mut String::new());
326 out
327 }
328
329 #[test]
330 fn groups_and_separates_numbers_by_language() {
331 assert_eq!(number(for_language("en"), "-1234567.25"), "-1,234,567.25");
332 assert_eq!(number(for_language("de"), "1234.5"), "1.234,5");
333 assert_eq!(number(for_language("tr"), "123"), "123");
334 assert_eq!(number(&NEUTRAL, "1234.5"), "1234.5");
335 assert_eq!(number(for_language("en"), "1e300"), "1e300");
336 }
337
338 #[test]
339 fn places_the_currency_by_language() {
340 assert_eq!(money("en", 123_450, "EUR"), "€1,234.50");
341 assert_eq!(money("en", -5, "USD"), "-$0.05");
342 assert_eq!(money("en", 1500, "CHF"), "CHF\u{a0}15.00");
343 assert_eq!(money("de", 123_450, "EUR"), "1.234,50\u{a0}€");
344 assert_eq!(money("tr", 123_450, "TRY"), "₺1.234,50");
345 assert_eq!(money("fr", 123_450, "EUR"), "1234.50 EUR");
346 }
347
348 #[test]
349 fn writes_days_and_instants_by_language() {
350 assert_eq!(date("en", "2026-10-01"), "Oct 1, 2026");
351 assert_eq!(date("de", "2026-10-01"), "01.10.2026");
352 assert_eq!(date("fr", "2026-10-01"), "2026-10-01");
353 assert_eq!(
354 date("en", "2026-10-11T23:30:00+03:00"),
355 "Oct 11, 2026, 20:30 UTC"
356 );
357 assert_eq!(date("tr", "2026-10-11T09:05:59.5Z"), "11.10.2026 09:05 UTC");
358 assert_eq!(date("en", "soon <b>"), "soon <b>");
359 }
360
361 #[test]
362 fn writes_an_instant_on_the_display_zone_s_wall_clock_naming_the_zone() {
363 let zone = DisplayZone::named("Europe/Istanbul").unwrap();
364 let instant = Instant::parse("2026-10-11T22:30:00Z").unwrap();
365 let mut out = String::new();
366
367 for_language("en").write_instant(&mut out, instant, &zone, &mut String::new());
368
369 assert_eq!(out, "Oct 12, 2026, 01:30 Europe/Istanbul");
370 }
371
372 #[test]
373 fn writes_the_offset_before_the_zone_where_the_wall_clock_repeats() {
374 let zone = DisplayZone::named("Europe/Berlin").unwrap();
375 let written = |at: &str| {
376 let mut out = String::new();
377 let instant = Instant::parse(at).unwrap();
378 for_language("de").write_instant(&mut out, instant, &zone, &mut String::new());
379 out
380 };
381
382 assert_eq!(
383 written("2026-10-25T00:30:00Z"),
384 "25.10.2026 02:30 +02:00 Europe/Berlin"
385 );
386 assert_eq!(
387 written("2026-10-25T01:30:00Z"),
388 "25.10.2026 02:30 +01:00 Europe/Berlin"
389 );
390 assert_eq!(
391 written("2026-10-25T02:30:00Z"),
392 "25.10.2026 03:30 Europe/Berlin"
393 );
394 }
395
396 #[test]
397 fn a_date_time_without_an_offset_is_written_on_its_own_wall_clock() {
398 assert_eq!(date("en", "2026-10-20T09:30:00"), "Oct 20, 2026, 09:30");
399 assert_eq!(date("de", "2026-10-20T09:30"), "20.10.2026 09:30");
400 }
401}