lr2-oxytabler 0.10.2

Table manager for Lunatic Rave 2
Documentation
use crate::{changelog::TableDiff, output::OutputFolderKey, time::UnixEpochTs, url::ResolvedUrl};
use anyhow::{Context as _, Result, ensure};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Table(pub Data, pub Context);

/// Table as fetched from the internet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Data {
    /// URL to the html page. That page contains header URL in its meta tags.
    /// Tables can be uniquely identified by this field. Other fields may get updated.
    pub web_url: ResolvedUrl,
    pub name: String,
    pub symbol: String,

    pub entries: Vec<Entry>,
    pub folder_order: Vec<String>,
}

/// Additional data we associate with a given table.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Context {
    // DB data.
    pub last_update: Option<UnixEpochTs>,
    pub playlist_id: Option<crate::db::TableId>,
    /// Table name defined by the user.
    pub user_name: Option<String>,
    /// Table symbol defined by the user.
    pub user_symbol: Option<String>,
    pub output: OutputFolderKey,

    // Runtime data.
    pub summary_changelog: String,
    pub full_changelog: String,
    pub entry_diff_to_save_to_db: Vec<TableDiff>,
    /// Checked on save, if 'true', this table will be deleted from the list.
    pub pending_removal: bool,
    pub status: Status,

    // GUI-only data.
    // We could template Table and add additional per-UI-context, but why overcomplicate.
    pub edited_name: Option<String>,
    pub edited_symbol: Option<String>,
    pub edited_url: Option<String>,
}

#[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 {
    pub fn apply_update(
        accessors: &[&dyn 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 new_diff = TableDiff::new(&old.0.entries, &data.entries, &data.folder_order);
        let summary_changelog = new_diff.format_summary()?;
        let full_changelog =
            new_diff.format_full(accessors, &data.symbol, old.1.user_symbol.as_deref())?;
        if !summary_changelog.is_empty() {
            log::info!(
                "table {} updated: {}\n{}",
                data.name,
                summary_changelog,
                full_changelog
            );
            log::debug!(
                "changed={} deleted={} new={}",
                new_diff.changed.len(),
                new_diff.deleted.len(),
                new_diff.new.len(),
            );
        }
        let mut diff = old.1.entry_diff_to_save_to_db;
        diff.push(new_diff);
        let new = Self(
            data,
            Context {
                status: Status::Ready,
                summary_changelog,
                full_changelog,
                entry_diff_to_save_to_db: diff,
                last_update: Some(now),
                ..old.1
            },
        );
        let idx = tables.partition_point(|t| t.0.name < new.0.name);
        tables.insert(idx, new);
        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 {
                name: {
                    let mut name = web_url.as_str().to_string();
                    let forbidden_in_fs = ['\n', '\r', '\'', '/', '\\'];
                    for c in forbidden_in_fs {
                        while let Some(i) = name.find(c) {
                            name.replace_range(i..=i, "-");
                        }
                    }
                    name
                },
                web_url,
                symbol: "?".to_string(),
                entries: vec![],
                folder_order: vec![],
            },
            Context {
                summary_changelog: String::new(),
                full_changelog: String::new(),
                entry_diff_to_save_to_db: vec![],
                edited_name: None,
                edited_symbol: None,
                edited_url: None,
                last_update: None,
                pending_removal: false,
                playlist_id: None,
                status: Status::Ready,
                user_name: None,
                user_symbol: None,
                output,
            },
        );
        tables.insert(tables.partition_point(|t| t.0.name < table.0.name), table);
        Ok(())
    }

    #[must_use]
    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 {
                summary_changelog: String::new(),
                full_changelog: String::new(),
                entry_diff_to_save_to_db: vec![],
                edited_name: None,
                edited_symbol: None,
                edited_url: None,
                last_update: None,
                pending_removal: false,
                playlist_id: None,
                user_name: None,
                user_symbol: None,
                output: OutputFolderKey(String::new()),
                status: Status::Ready,
            },
        )
    }

    #[must_use]
    pub fn with_entry(mut self, md5: usize, level: impl Into<String>) -> Self {
        self.0.entries.push(Entry {
            md5: format!("foodfoodfoodfoodfoodfoodfood{md5:0>4x}"),
            level: level.into(),
        });
        self
    }
    #[must_use]
    pub fn with_id(mut self, id: crate::db::TableId) -> Self {
        self.1.playlist_id = Some(id);
        self
    }
    #[must_use]
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.0.name = name.into();
        self
    }
    #[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_updated_changelog(mut self, old_entries: &[Entry]) -> Self {
        self.1
            .entry_diff_to_save_to_db
            .push(TableDiff::new(old_entries, &self.0.entries, &[]));
        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_name(mut self, name: impl Into<String>) -> Self {
        self.1.user_name = Some(name.into());
        self
    }
    #[must_use]
    pub fn with_user_symbol(mut self, symbol: impl Into<String>) -> Self {
        self.1.user_symbol = Some(symbol.into());
        self
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn apply_update_with_remaining_db_diff() {
        use super::Table;
        use crate::{changelog::TableDiff, fetch::TableLocator, time::UnixEpochTs};

        let now = UnixEpochTs(0);

        let mut tables = vec![
            Table::empty()
                .with_entry(0, "from")
                .with_entry(1, "deleted"),
        ];

        {
            let new_data = Table::empty().with_entry(0, "to").with_entry(2, "new").0;
            let locator = TableLocator::new_for_table(&tables[0]);
            Table::apply_update(&[], &mut tables, new_data, &locator, now).unwrap();
        }

        assert_eq!(
            tables[0].1.entry_diff_to_save_to_db,
            [TableDiff {
                changed: vec![(
                    "foodfoodfoodfoodfoodfoodfood0000".to_string(),
                    "from".to_string(),
                    "to".to_string(),
                )],
                deleted: vec![(
                    "foodfoodfoodfoodfoodfoodfood0001".to_string(),
                    "deleted".to_string(),
                )],
                new: vec![(
                    "foodfoodfoodfoodfoodfoodfood0002".to_string(),
                    "new".to_string(),
                )],
            }]
        );

        // User presses 'update' again before saving...
        // Old diff is kept, new diff is appended after it
        {
            let new_data = tables[0].clone().with_entry(3, "another-new").0;
            let locator = TableLocator::new_for_table(&tables[0]);
            Table::apply_update(&[], &mut tables, new_data, &locator, now).unwrap();
        }
        {
            assert_eq!(
                tables[0].1.entry_diff_to_save_to_db,
                [
                    TableDiff {
                        changed: vec![(
                            "foodfoodfoodfoodfoodfoodfood0000".to_string(),
                            "from".to_string(),
                            "to".to_string(),
                        )],
                        deleted: vec![(
                            "foodfoodfoodfoodfoodfoodfood0001".to_string(),
                            "deleted".to_string(),
                        )],
                        new: vec![(
                            "foodfoodfoodfoodfoodfoodfood0002".to_string(),
                            "new".to_string(),
                        )],
                    },
                    TableDiff {
                        changed: vec![],
                        deleted: vec![],
                        new: vec![(
                            "foodfoodfoodfoodfoodfoodfood0003".to_string(),
                            "another-new".to_string(),
                        ),],
                    }
                ]
            );
        }
    }
}