gled-model 2.32.1

The data model of gled: projects, scenes, effects, animations, curves and assets
Documentation
pub mod animation;
pub mod curve;
pub mod midi_controller;
pub mod output_device;
pub mod palette;
pub mod picture;
pub mod project;
pub mod scene;

use crate::asset_id::AssetId;
use crate::collection::Collections;
use crate::naming::unique_name;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{fmt::Debug, io::Read, sync::Arc};

/// The format of asset files this gled writes. Every file carries its
/// version next to its name; a file of an older version is migrated when gled
/// starts (`gled-migrate`), one of a newer version is refused.
///
/// - 0: files before versions. Projects may still embed their installation
///   as an SVG with lamp groups instead of a patch.
/// - 1: projects hold a patch; lamps are fixture definitions.
/// - 2: layout backgrounds and fixture photos are named by their path in the
///   asset repository (`picture`), not by one on the machine that chose them.
pub const ASSET_VERSION: u32 = 2;

pub trait AssetTrait:
    Serialize + DeserializeOwned + Debug + Default + Send + Sync + Clone + 'static + PartialEq
{
    const DIR_NAME: &'static str;
    const NAME: &'static str;
    const SHOW_NAME_IF_SELECTED: bool;
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound = "T: DeserializeOwned + Serialize")]
struct AssetOnDisk<T: AssetTrait> {
    name: Vec<String>,
    /// The file's format, see [`ASSET_VERSION`]; files from before versions
    /// have none and are version 0.
    #[serde(default)]
    version: u32,
    #[serde(flatten)]
    data: T,
}

/// Why an asset file could not be read.
#[derive(Debug, thiserror::Error)]
pub enum ReadError {
    #[error(transparent)]
    Json(#[from] serde_json::Error),
    #[error("written by a newer gled (asset version {version}, this gled reads up to {supported})")]
    Newer { version: u32, supported: u32 },
}

/// An asset with its id. Serialized as such for the wire; files on disk use
/// `AssetOnDisk`, which takes the id from the file name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound = "T: DeserializeOwned + Serialize")]
pub struct Asset<T: AssetTrait> {
    pub id: AssetId<T>,
    pub path: Vec<String>,
    pub data: T,
}

impl<T: AssetTrait> Default for Asset<T> {
    fn default() -> Self {
        Self::new(vec![format!("New {}", T::NAME)])
    }
}

impl<T: AssetTrait> Asset<T> {
    pub fn new(path: Vec<String>) -> Self {
        Self {
            id: AssetId::<T>::new(),
            path,
            data: T::default(),
        }
    }

    /// Create a copy of an asset with a new uuid and a name no other asset in
    /// its folder has ("Name copy", "Name copy 2", …), so the copy shows up
    /// as its own entry in the asset tree.
    pub fn copy(&self, collections: &Collections) -> Self {
        let mut path = self.path.clone();
        path.pop();
        let all = Self::all(collections);
        let siblings = all
            .iter()
            .filter_map(|asset| asset.path.split_last())
            .filter(|(_, folder)| *folder == path.as_slice())
            .map(|(name, _)| name.as_str());
        path.push(unique_name(&format!("{} copy", self.name()), siblings));

        Self {
            id: AssetId::<T>::new(),
            path,
            data: self.data.clone(),
        }
    }

    pub fn get(id: AssetId<T>, collections: &Collections) -> Option<Arc<Self>> {
        collections.get::<T>()?.get(&id).cloned()
    }

    pub fn get_asset_from_index(index: usize, collections: &Collections) -> Option<Arc<Self>> {
        match index {
            0 => None,
            index => Self::all(collections).get(index - 1).cloned(),
        }
    }

    pub fn all(collections: &Collections) -> Vec<Arc<Asset<T>>> {
        let Some(collections) = collections.get::<T>() else {
            return vec![];
        };

        collections.assets()
    }

    /// Reads an asset from its JSON file. Older versions read as far as serde
    /// takes them; migrating them is `gled-migrate`'s, before loading.
    ///
    /// A file whose `name` is empty is named after its id, the name of the
    /// file it came from, in the top folder. Its content stays reachable and
    /// everything that refers to it by id keeps working; the user can rename
    /// it, and the next save writes the name.
    pub fn read(reader: impl Read, id: AssetId<T>) -> Result<Arc<Self>, ReadError> {
        let asset: AssetOnDisk<T> = serde_json::from_reader(reader)?;
        if asset.version > ASSET_VERSION {
            return Err(ReadError::Newer {
                version: asset.version,
                supported: ASSET_VERSION,
            });
        }

        let mut path = asset.name;
        if path.is_empty() {
            tracing::warn!(
                "The {} {} has no name; it is named after its id",
                T::NAME,
                id.id
            );
            path.push(id.id.to_string());
        }

        Ok(Arc::new(Self {
            id,
            path,
            data: asset.data,
        }))
    }

    pub fn into_json(self) -> Result<String, serde_json::Error> {
        let asset = AssetOnDisk {
            name: self.path,
            version: ASSET_VERSION,
            data: self.data,
        };

        serde_json::to_string_pretty(&asset)
    }

    /// The folders the asset is in: its path without its own name. An asset
    /// without a path (one built in code or received over the wire; files
    /// get a name when read) is in the top folder.
    pub fn dir(&self) -> Vec<String> {
        self.path
            .split_last()
            .map(|(_, dir)| dir.to_vec())
            .unwrap_or_default()
    }

    pub fn name(&self) -> &str {
        self.path
            .last()
            .map(|name| name.as_str())
            .unwrap_or("No name")
    }

    pub fn change_dir(&mut self, new_dir: &[String]) {
        let name = self.path.pop();
        self.path = new_dir.to_owned();
        if let Some(name) = name {
            self.path.push(name);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::asset::palette::Palette;

    fn palette_file(version: Option<u32>) -> String {
        let version = version
            .map(|v| format!(r#""version": {v},"#))
            .unwrap_or_default();
        format!(r#"{{"name": ["Warm"], {version} "primary": {{"rgb": [1.0, 0.5, 0.0]}}}}"#)
    }

    #[test]
    fn a_file_from_before_versions_reads_and_saves_with_the_current_version() {
        let asset =
            Asset::<Palette>::read(palette_file(None).as_bytes(), AssetId::new()).expect("reads");
        let saved: serde_json::Value =
            serde_json::from_str(&(*asset).clone().into_json().expect("saves")).expect("json");
        assert_eq!(saved["version"], ASSET_VERSION);
        assert_eq!(saved["name"], serde_json::json!(["Warm"]));
    }

    #[test]
    fn a_file_from_a_newer_gled_is_refused() {
        let newer = palette_file(Some(ASSET_VERSION + 1));
        assert!(matches!(
            Asset::<Palette>::read(newer.as_bytes(), AssetId::new()),
            Err(ReadError::Newer { .. })
        ));
    }

    #[test]
    fn a_file_without_a_name_is_named_after_its_id() {
        let file = r#"{"name": [], "primary": {"rgb": [1.0, 0.5, 0.0]}}"#;
        let id = AssetId::new();
        let asset = Asset::<Palette>::read(file.as_bytes(), id).expect("reads");

        assert_eq!(asset.path, [id.id.to_string()]);
        assert_eq!(asset.name(), id.id.to_string());
        assert!(asset.dir().is_empty());
    }

    #[test]
    fn an_asset_without_a_path_is_in_the_top_folder_and_copies() {
        let mut collections = Collections::default();
        let nameless = Asset::<Palette>::new(vec![]);
        collections.get_mut::<Palette>().set_asset(nameless.clone());

        assert!(nameless.dir().is_empty());
        assert_eq!(nameless.name(), "No name");
        assert_eq!(nameless.copy(&collections).path, ["No name copy"]);
    }

    fn names(collections: &Collections) -> Vec<Vec<String>> {
        let mut names: Vec<_> = Asset::<Palette>::all(collections)
            .iter()
            .map(|asset| asset.path.clone())
            .collect();
        names.sort();
        names
    }

    #[test]
    fn a_copy_gets_a_name_no_other_asset_in_its_folder_has() {
        let mut collections = Collections::default();
        let original = Asset::<Palette>::new(vec!["Show".into(), "Warm".into()]);
        collections.get_mut::<Palette>().set_asset(original.clone());

        let first = original.copy(&collections);
        collections.get_mut::<Palette>().set_asset(first.clone());
        let second = original.copy(&collections);
        collections.get_mut::<Palette>().set_asset(second.clone());

        assert_ne!(first.id, original.id);
        assert_eq!(first.data, original.data);
        assert_eq!(
            names(&collections),
            [
                vec!["Show".to_owned(), "Warm".to_owned()],
                vec!["Show".to_owned(), "Warm copy".to_owned()],
                vec!["Show".to_owned(), "Warm copy 2".to_owned()],
            ]
        );
    }
}