qframe/widgets/duration_input/
parse.rs1use std::time::Duration;
5
6use crate::i18n::{Arg, I18n};
7
8pub(crate) const LONGEST: u64 = 99 * 3600 + 59 * 60 + 59;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DurationUnit {
15 Hours,
17 Minutes,
19 Seconds,
21}
22
23impl DurationUnit {
24 pub(crate) const ALL: [Self; 3] = [Self::Hours, Self::Minutes, Self::Seconds];
26
27 pub(crate) fn seconds(self) -> u64 {
29 match self {
30 Self::Hours => 3600,
31 Self::Minutes => 60,
32 Self::Seconds => 1,
33 }
34 }
35
36 fn stem(self) -> &'static str {
38 match self {
39 Self::Hours => "hour",
40 Self::Minutes => "minute",
41 Self::Seconds => "second",
42 }
43 }
44
45 fn below(self) -> Option<Self> {
47 match self {
48 Self::Hours => Some(Self::Minutes),
49 Self::Minutes => Some(Self::Seconds),
50 Self::Seconds => None,
51 }
52 }
53
54 pub(crate) fn short(self, i18n: &I18n) -> String {
56 i18n.translate(&format!("quvyta.duration.{}s", self.stem()), &[])
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum DurationError {
64 Empty,
66 Character(char),
68 UnknownUnit(String),
70 BadNumber(String),
72 MissingNumber(String),
74 MissingUnit(String),
76 RepeatedUnit(DurationUnit),
78 BadClock(String),
80 TooLarge,
82}
83
84impl DurationError {
85 #[must_use]
87 pub fn message(&self, i18n: &I18n) -> String {
88 let (key, text) = match self {
89 Self::Empty => ("empty", String::new()),
90 Self::Character(c) => ("character", c.to_string()),
91 Self::UnknownUnit(word) => ("unit", word.clone()),
92 Self::BadNumber(number) => ("number", number.clone()),
93 Self::MissingNumber(word) => ("no-number", word.clone()),
94 Self::MissingUnit(number) => ("no-unit", number.clone()),
95 Self::RepeatedUnit(unit) => {
96 ("repeated", i18n.translate(&format!("quvyta.duration.{}-name", unit.stem()), &[]))
97 }
98 Self::BadClock(text) => ("clock", text.clone()),
99 Self::TooLarge => ("too-large", write(Duration::from_secs(LONGEST), true, i18n)),
100 };
101 i18n.translate(&format!("quvyta.duration.{key}"), &[("text", Arg::Text(text))])
102 }
103}
104
105pub fn parse_duration(text: &str, i18n: &I18n) -> Result<Duration, DurationError> {
125 let text = text.trim();
126 if text.is_empty() {
127 return Err(DurationError::Empty);
128 }
129 let seconds = if text.contains(':') { clock(text)? } else { with_units(&tokens(text)?, i18n)? };
130 if seconds > u128::from(LONGEST) {
131 return Err(DurationError::TooLarge);
132 }
133 Ok(Duration::from_secs(u64::try_from(seconds).unwrap_or(LONGEST)))
134}
135
136pub(crate) fn write(duration: Duration, seconds: bool, i18n: &I18n) -> String {
139 let total = duration.as_secs();
140 let parts = [total / 3600, total / 60 % 60, total % 60];
141 let shown = if seconds { 3 } else { 2 };
142 let written: Vec<String> = DurationUnit::ALL[..shown]
143 .iter()
144 .zip(parts)
145 .filter(|(_, value)| *value > 0)
146 .map(|(unit, value)| format!("{value} {}", unit.short(i18n)))
147 .collect();
148 if written.is_empty() { format!("0 {}", DurationUnit::Minutes.short(i18n)) } else { written.join(" ") }
149}
150
151const MAX_DIGITS: usize = 12;
153
154#[derive(Debug)]
156struct Number {
157 text: String,
158 whole: u128,
159 fraction: String,
160}
161
162impl Number {
163 fn seconds(&self, unit: DurationUnit) -> u128 {
165 let unit = u128::from(unit.seconds());
166 let digits = &self.fraction[..self.fraction.len().min(9)];
168 let scale = 10u128.pow(u32::try_from(digits.len()).unwrap_or(0));
169 let fraction: u128 = digits.parse().unwrap_or(0);
170 self.whole * unit + (fraction * unit + scale / 2) / scale
171 }
172}
173
174#[derive(Debug)]
175enum Token {
176 Number(Number),
177 Word(String),
178}
179
180fn tokens(text: &str) -> Result<Vec<Token>, DurationError> {
183 let chars: Vec<char> = text.chars().collect();
184 let is_mark = |c: char| c == '.' || c == ',';
185 let digit_at = |i: usize| chars.get(i).is_some_and(char::is_ascii_digit);
186 let mut out = Vec::new();
187 let mut i = 0;
188 while let Some(&c) = chars.get(i) {
189 let start = i;
190 if c.is_ascii_digit() || (is_mark(c) && digit_at(i + 1)) {
191 let run = |i: &mut usize| {
192 while digit_at(*i) {
193 *i += 1;
194 }
195 };
196 run(&mut i);
197 let whole_end = i;
198 let mut marks = 0;
199 while chars.get(i).is_some_and(|c| is_mark(*c)) && digit_at(i + 1) {
200 marks += 1;
201 i += 1;
202 run(&mut i);
203 }
204 let written: String = chars[start..i].iter().collect();
205 let whole: String = chars[start..whole_end].iter().collect();
206 if marks > 1 || whole.is_empty() {
207 return Err(DurationError::BadNumber(written));
208 }
209 if whole.trim_start_matches('0').len() > MAX_DIGITS {
210 return Err(DurationError::TooLarge);
211 }
212 let fraction = if marks == 1 { chars[whole_end + 1..i].iter().collect() } else { String::new() };
213 out.push(Token::Number(Number { text: written, whole: whole.parse().unwrap_or(0), fraction }));
214 } else if c.is_alphabetic() {
215 while chars.get(i).is_some_and(|c| c.is_alphabetic()) {
216 i += 1;
217 }
218 out.push(Token::Word(chars[start..i].iter().collect()));
219 } else if c.is_whitespace() || is_mark(c) {
220 i += 1;
221 } else {
222 return Err(DurationError::Character(c));
223 }
224 }
225 Ok(out)
226}
227
228fn fold(word: &str) -> String {
231 word.chars()
232 .flat_map(|c| match c {
233 'İ' | 'I' | 'ı' => vec!['i'],
234 c => c.to_lowercase().collect(),
235 })
236 .collect()
237}
238
239fn unit_of(word: &str, i18n: &I18n) -> Option<DurationUnit> {
241 let word = fold(word);
242 let names = |unit: DurationUnit| format!("quvyta.duration.{}-words", unit.stem());
243 let matches = |list: &str| list.split(',').any(|candidate| fold(candidate.trim()) == word);
244 let active = DurationUnit::ALL.into_iter().find(|unit| matches(&i18n.translate(&names(*unit), &[])));
245 active.or_else(|| {
246 DurationUnit::ALL.into_iter().find(|unit| i18n.in_every_locale(&names(*unit)).iter().any(|list| matches(list)))
247 })
248}
249
250fn with_units(tokens: &[Token], i18n: &I18n) -> Result<u128, DurationError> {
252 if tokens.is_empty() {
253 return Err(DurationError::Empty);
254 }
255 let mut total = 0u128;
256 let mut seen = Vec::new();
257 let mut last = None;
258 let mut waiting: Option<&Number> = None;
259 let mut add = |number: &Number, unit: DurationUnit, seen: &mut Vec<DurationUnit>| {
260 if seen.contains(&unit) {
261 return Err(DurationError::RepeatedUnit(unit));
262 }
263 seen.push(unit);
264 total += number.seconds(unit);
265 Ok(())
266 };
267 for token in tokens {
268 match token {
269 Token::Number(number) => {
270 if let Some(previous) = waiting {
271 return Err(DurationError::MissingUnit(previous.text.clone()));
272 }
273 waiting = Some(number);
274 }
275 Token::Word(word) => {
276 let unit = unit_of(word, i18n).ok_or_else(|| DurationError::UnknownUnit(word.clone()))?;
277 let number = waiting.take().ok_or_else(|| DurationError::MissingNumber(word.clone()))?;
278 add(number, unit, &mut seen)?;
279 last = Some(unit);
280 }
281 }
282 }
283 if let Some(number) = waiting {
284 let unit = match last {
285 None => DurationUnit::Minutes,
286 Some(unit) => unit.below().ok_or_else(|| DurationError::MissingUnit(number.text.clone()))?,
287 };
288 add(number, unit, &mut seen)?;
289 }
290 Ok(total)
291}
292
293fn clock(text: &str) -> Result<u128, DurationError> {
295 let bad = || DurationError::BadClock(text.to_owned());
296 let parts: Vec<&str> = text.split(':').map(str::trim).collect();
297 if !(2..=3).contains(&parts.len())
298 || parts.iter().any(|part| part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()))
299 {
300 return Err(bad());
301 }
302 if parts[0].trim_start_matches('0').len() > MAX_DIGITS {
303 return Err(DurationError::TooLarge);
304 }
305 let mut total: u128 = parts[0].parse::<u128>().map_err(|_| bad())? * 3600;
306 for (part, unit) in parts[1..].iter().zip([60u128, 1]) {
307 let value: u128 = part.parse().map_err(|_| bad())?;
308 if part.len() > 2 || value >= 60 {
309 return Err(bad());
310 }
311 total += value * unit;
312 }
313 Ok(total)
314}