use crate::bridge::export_thread::filename_template::civil_from_unix_seconds;
use indicatrix_vault::model::design_variant::VariantSummary;
const MINUTE_SECS: i64 = 60;
const HOUR_SECS: i64 = 3_600;
const DAY_SECS: i64 = 86_400;
const RELATIVE_DAYS: i64 = 30;
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
pub(super) const CURRENT_LABEL: &str = "Current design";
pub(super) const NAME_LIMIT: usize = 80;
pub(super) const NOTE_LIMIT: usize = 400;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct VariantRow {
pub(super) id: i64,
pub(super) name: String,
pub(super) note: String,
pub(super) age: String,
pub(super) exact: String,
pub(super) parent: String,
pub(super) opened_from: bool,
}
fn newest_first(list: &[VariantSummary]) -> Vec<&VariantSummary> {
let mut ordered: Vec<&VariantSummary> = list.iter().collect();
ordered.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then(b.variant_id.cmp(&a.variant_id))
});
ordered
}
pub(super) fn build_rows(
list: &[VariantSummary],
branch: Option<i64>,
now: i64,
) -> Vec<VariantRow> {
newest_first(list)
.into_iter()
.map(|variant| VariantRow {
id: variant.variant_id,
name: variant.name.clone(),
note: variant.note.clone().unwrap_or_default(),
age: age_text(now, variant.created_at),
exact: exact_text(variant.created_at),
parent: parent_text(list, variant.parent_variant_id),
opened_from: branch == Some(variant.variant_id),
})
.collect()
}
fn parent_text(list: &[VariantSummary], parent: Option<i64>) -> String {
parent
.and_then(|id| list.iter().find(|variant| variant.variant_id == id))
.map_or_else(String::new, |variant| format!("from: {}", variant.name))
}
fn counted(count: i64, word: &str) -> String {
if count == 1 {
format!("1 {word}")
} else {
format!("{count} {word}s")
}
}
pub(super) fn age_text(now: i64, created: i64) -> String {
let elapsed = now.saturating_sub(created);
if elapsed < MINUTE_SECS {
"Just now".to_owned()
} else if elapsed < HOUR_SECS {
format!("{} ago", counted(elapsed / MINUTE_SECS, "minute"))
} else if elapsed < DAY_SECS {
format!("{} ago", counted(elapsed / HOUR_SECS, "hour"))
} else if elapsed < RELATIVE_DAYS * DAY_SECS {
format!("{} ago", counted(elapsed / DAY_SECS, "day"))
} else {
date_text(created)
}
}
fn date_text(created: i64) -> String {
let (year, month, day, ..) = civil_from_unix_seconds(created);
let month_name = usize::try_from(month.saturating_sub(1))
.ok()
.and_then(|index| MONTHS.get(index))
.copied()
.unwrap_or("?");
format!("{day} {month_name} {year}")
}
pub(super) fn exact_text(created: i64) -> String {
let (year, month, day, hour, minute, second) = civil_from_unix_seconds(created);
format!("{year}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} UTC")
}
fn numbered(name: &str) -> Option<u64> {
name.strip_prefix("Variant ")?.trim().parse().ok()
}
pub(super) fn default_name(list: &[VariantSummary]) -> String {
let highest = list
.iter()
.filter_map(|variant| numbered(&variant.name))
.max()
.unwrap_or(0);
let count = u64::try_from(list.len()).unwrap_or(u64::MAX);
let mut number = highest.max(count).saturating_add(1);
loop {
let candidate = format!("Variant {number}");
if !list
.iter()
.any(|variant| variant.name.eq_ignore_ascii_case(&candidate))
{
return candidate;
}
number = number.saturating_add(1);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Choice {
Current,
Variant(i64),
}
pub(super) fn build_choices(list: &[VariantSummary]) -> (Vec<Choice>, Vec<String>) {
let ordered = newest_first(list);
let mut choices = vec![Choice::Current];
let mut labels = vec![CURRENT_LABEL.to_owned()];
for variant in &ordered {
choices.push(Choice::Variant(variant.variant_id));
let clashes = variant.name == CURRENT_LABEL
|| ordered
.iter()
.filter(|other| other.name == variant.name)
.count()
> 1;
labels.push(if clashes {
format!(
"{} (saved {})",
variant.name,
exact_text(variant.created_at)
)
} else {
variant.name.clone()
});
}
make_unique(&mut labels, &choices);
(choices, labels)
}
fn make_unique(labels: &mut [String], choices: &[Choice]) {
let snapshot = labels.to_vec();
for (label, choice) in labels.iter_mut().zip(choices) {
let repeated = snapshot
.iter()
.filter(|other| other.as_str() == label.as_str())
.count()
> 1;
if let (true, Choice::Variant(id)) = (repeated, choice) {
*label = format!("{label}, number {id}");
}
}
}
pub(super) fn reselect(old: &[Choice], first: i32, second: i32, new: &[Choice]) -> (i32, i32) {
let find = |index: i32| -> Option<i32> {
let choice = old.get(usize::try_from(index).ok()?)?;
let position = new.iter().position(|other| other == choice)?;
i32::try_from(position).ok()
};
let other_than_current = i32::from(new.len() > 1);
let first = find(first).unwrap_or(0);
let second = find(second).unwrap_or(other_than_current);
if second != first {
return (first, second);
}
let instead = if first == 0 { other_than_current } else { 0 };
(first, instead)
}
pub(super) fn resolve_pair(
choices: &[Choice],
first: i32,
second: i32,
) -> Result<(Choice, Choice), &'static str> {
let pick = |index: i32| usize::try_from(index).ok().and_then(|i| choices.get(i));
match (pick(first), pick(second)) {
(Some(a), Some(b)) if a != b => Ok((*a, *b)),
(Some(_), Some(_)) => Err("Choose two different designs to compare."),
_ => Err("Choose the two designs to compare."),
}
}
pub(super) fn clean_name(text: &str) -> Result<String, String> {
let name = text.trim();
if name.is_empty() {
return Err("Give the variant a name.".to_owned());
}
if name.chars().count() > NAME_LIMIT {
return Err(format!(
"Use a shorter name ({NAME_LIMIT} characters at most)."
));
}
Ok(name.to_owned())
}
pub(super) fn clean_note(text: &str) -> Result<Option<String>, String> {
let note = text.trim();
if note.chars().count() > NOTE_LIMIT {
return Err(format!(
"Use a shorter note ({NOTE_LIMIT} characters at most)."
));
}
Ok((!note.is_empty()).then(|| note.to_owned()))
}