use crate::{
changelog::TableUpdateChangelog, output::OutputFolderKey, time::UnixEpochTs, url::ResolvedUrl,
};
use anyhow::{Context as _, Result, ensure};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Table(pub Data, pub Context);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Data {
pub web_url: ResolvedUrl,
pub name: String,
pub symbol: String,
pub entries: Vec<Entry>,
pub folder_order: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Context {
pub last_update: Option<UnixEpochTs>,
pub playlist_id: Option<crate::db::TableId>,
pub user_symbol: Option<String>,
pub output: OutputFolderKey,
pub changelog: TableUpdateChangelog,
pub edited_symbol: Option<String>,
pub edited_url: Option<String>,
pub pending_removal: bool,
pub status: Status,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Entry {
pub md5: String,
pub level: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Ready,
Updating,
Error(String),
}
impl Table {
#[expect(clippy::similar_names, reason = "'now' vs 'new'")]
pub fn apply_update(
song_loader: &crate::changelog::SongNameAccessor,
tables: &mut Vec<Self>,
data: Data,
locator: &crate::fetch::TableLocator,
now: UnixEpochTs,
) -> Result<()> {
let old = locator
.locate(tables)
.with_context(|| format!("failed to find table {locator}"))?;
let old = tables.remove(old);
let changelog = TableUpdateChangelog::try_new(
song_loader,
&old.0.entries,
&data.entries,
&data.folder_order,
old.1.user_symbol.as_deref().unwrap_or(&data.symbol),
)?;
let new = Self(
data,
Context {
status: Status::Ready,
changelog,
last_update: Some(now),
..old.1
},
);
if !new.1.changelog.summary().is_empty() {
log::info!(
"{}: {}\n{}",
new.0.name,
new.1.changelog.summary(),
new.1.changelog.full()
);
}
let idx = tables.partition_point(|t| t.0.name < new.0.name);
tables.insert(idx, new);
debug_assert!(tables.is_sorted_by(|l, r| l.0.name < r.0.name));
Ok(())
}
pub fn commit_removals(tables: &mut Vec<Self>) {
let old_len = tables.len();
tables.retain(|t| !t.1.pending_removal);
if tables.len() != old_len {
log::info!("Removed {} table(s) while saving", old_len - tables.len());
}
}
pub fn insert_new(
tables: &mut Vec<Self>,
web_url: ResolvedUrl,
output: OutputFolderKey,
) -> Result<()> {
ensure!(
!tables.iter().any(|t| t.0.web_url == web_url),
"Table {} already exists",
web_url.as_str()
);
let table = Self(
Data {
web_url,
name: "NEW TABLE".to_string(),
symbol: "?".to_string(),
entries: vec![],
folder_order: vec![],
},
Context {
changelog: TableUpdateChangelog::default(),
edited_symbol: None,
edited_url: None,
last_update: None,
pending_removal: false,
playlist_id: None,
status: Status::Ready,
user_symbol: None,
output,
},
);
tables.insert(tables.partition_point(|t| t.0.name < table.0.name), table);
debug_assert!(tables.is_sorted_by(|l, r| l.0.name < r.0.name));
Ok(())
}
pub fn wants_updating(&self, now: UnixEpochTs, update_interval: UnixEpochTs) -> bool {
pub const NEGATIVE_NEVER: UnixEpochTs = UnixEpochTs(0);
if self.1.pending_removal || self.1.status == Status::Updating {
return false;
}
if self.0.web_url.as_str().starts_with("file://") {
return true;
}
self.1.last_update.unwrap_or(NEGATIVE_NEVER).0 + update_interval.0 < now.0
}
}
#[cfg(test)]
impl Table {
#[must_use]
pub fn empty() -> Self {
Self(
Data {
web_url: "http://".try_into().unwrap(),
name: String::new(),
symbol: String::new(),
entries: vec![],
folder_order: vec![],
},
Context {
changelog: TableUpdateChangelog::default(),
edited_symbol: None,
edited_url: None,
last_update: None,
pending_removal: false,
playlist_id: None,
user_symbol: None,
output: OutputFolderKey(String::new()),
status: Status::Ready,
},
)
}
#[must_use]
pub fn with_any_entry(self) -> Self {
self.with_any_entry_leveled("1")
}
#[must_use]
pub fn with_any_entry_leveled(self, level: impl Into<String>) -> Self {
let idx = self.0.entries.len();
self.with_new_entry(idx, level)
}
#[must_use]
pub fn with_entry(mut self, entry: Entry) -> Self {
self.0.entries.push(entry);
self
}
#[must_use]
pub fn with_id(mut self, id: &mut crate::db::TableId) -> Self {
self.1.playlist_id = Some(*id);
id.0 += 1;
self
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.0.name = name.into();
self
}
pub fn with_new_entry(self, md5: usize, level: impl Into<String>) -> Self {
self.with_entry(Entry {
md5: format!("foodfoodfoodfoodfoodfoodfood{md5:0>4}"),
level: level.into(),
})
}
#[must_use]
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.1.output = OutputFolderKey(output.into());
self
}
#[must_use]
pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
self.0.symbol = symbol.into();
self
}
#[must_use]
pub fn with_url(mut self, web_url: impl Into<String>) -> Self {
self.0.web_url = web_url.into().try_into().unwrap();
self
}
#[must_use]
pub fn with_user_symbol(mut self, symbol: impl Into<String>) -> Self {
self.1.user_symbol = Some(symbol.into());
self
}
}