lr2-oxytabler 0.10.1

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: 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 diff = TableDiff::new(&old.0.entries, &data.entries, &data.folder_order);
        let summary_changelog = diff.format_summary()?;
        let full_changelog =
            diff.format_full(accessors, &data.symbol, old.1.user_symbol.as_deref())?;
        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
            },
        );
        if !new.1.summary_changelog.is_empty() {
            log::info!(
                "table {} updated: {}\n{}",
                new.0.name,
                new.1.summary_changelog,
                new.1.full_changelog
            );
            log::debug!(
                "changed={} deleted={} new={}",
                new.1.entry_diff_to_save_to_db.changed.len(),
                new.1.entry_diff_to_save_to_db.deleted.len(),
                new.1.entry_diff_to_save_to_db.new.len(),
            );
        }
        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: crate::changelog::TableDiff::default(),
                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: crate::changelog::TableDiff::default(),
                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_updated_changelog(mut self, old_entries: &[Entry]) -> Self {
        self.1.entry_diff_to_save_to_db = TableDiff::new(old_entries, &self.0.entries, &[]);
        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_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_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_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
    }
}