Skip to main content

gled_model/
asset.rs

1pub mod animation;
2pub mod curve;
3pub mod midi_controller;
4pub mod output_device;
5pub mod palette;
6pub mod picture;
7pub mod project;
8pub mod scene;
9
10use crate::asset_id::AssetId;
11use crate::collection::Collections;
12use crate::naming::unique_name;
13use serde::{Deserialize, Serialize, de::DeserializeOwned};
14use std::{fmt::Debug, io::Read, sync::Arc};
15
16/// The format of asset files this gled writes. Every file carries its
17/// version next to its name; a file of an older version is migrated when gled
18/// starts (`gled-migrate`), one of a newer version is refused.
19///
20/// - 0: files before versions. Projects may still embed their installation
21///   as an SVG with lamp groups instead of a patch.
22/// - 1: projects hold a patch; lamps are fixture definitions.
23/// - 2: layout backgrounds and fixture photos are named by their path in the
24///   asset repository (`picture`), not by one on the machine that chose them.
25pub const ASSET_VERSION: u32 = 2;
26
27pub trait AssetTrait:
28    Serialize + DeserializeOwned + Debug + Default + Send + Sync + Clone + 'static + PartialEq
29{
30    const DIR_NAME: &'static str;
31    const NAME: &'static str;
32    const SHOW_NAME_IF_SELECTED: bool;
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(bound = "T: DeserializeOwned + Serialize")]
37struct AssetOnDisk<T: AssetTrait> {
38    name: Vec<String>,
39    /// The file's format, see [`ASSET_VERSION`]; files from before versions
40    /// have none and are version 0.
41    #[serde(default)]
42    version: u32,
43    #[serde(flatten)]
44    data: T,
45}
46
47/// Why an asset file could not be read.
48#[derive(Debug, thiserror::Error)]
49pub enum ReadError {
50    #[error(transparent)]
51    Json(#[from] serde_json::Error),
52    #[error("written by a newer gled (asset version {version}, this gled reads up to {supported})")]
53    Newer { version: u32, supported: u32 },
54}
55
56/// An asset with its id. Serialized as such for the wire; files on disk use
57/// `AssetOnDisk`, which takes the id from the file name.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(bound = "T: DeserializeOwned + Serialize")]
60pub struct Asset<T: AssetTrait> {
61    pub id: AssetId<T>,
62    pub path: Vec<String>,
63    pub data: T,
64}
65
66impl<T: AssetTrait> Default for Asset<T> {
67    fn default() -> Self {
68        Self::new(vec![format!("New {}", T::NAME)])
69    }
70}
71
72impl<T: AssetTrait> Asset<T> {
73    pub fn new(path: Vec<String>) -> Self {
74        Self {
75            id: AssetId::<T>::new(),
76            path,
77            data: T::default(),
78        }
79    }
80
81    /// Create a copy of an asset with a new uuid and a name no other asset in
82    /// its folder has ("Name copy", "Name copy 2", …), so the copy shows up
83    /// as its own entry in the asset tree.
84    pub fn copy(&self, collections: &Collections) -> Self {
85        let mut path = self.path.clone();
86        path.pop();
87        let all = Self::all(collections);
88        let siblings = all
89            .iter()
90            .filter_map(|asset| asset.path.split_last())
91            .filter(|(_, folder)| *folder == path.as_slice())
92            .map(|(name, _)| name.as_str());
93        path.push(unique_name(&format!("{} copy", self.name()), siblings));
94
95        Self {
96            id: AssetId::<T>::new(),
97            path,
98            data: self.data.clone(),
99        }
100    }
101
102    pub fn get(id: AssetId<T>, collections: &Collections) -> Option<Arc<Self>> {
103        collections.get::<T>()?.get(&id).cloned()
104    }
105
106    pub fn get_asset_from_index(index: usize, collections: &Collections) -> Option<Arc<Self>> {
107        match index {
108            0 => None,
109            index => Self::all(collections).get(index - 1).cloned(),
110        }
111    }
112
113    pub fn all(collections: &Collections) -> Vec<Arc<Asset<T>>> {
114        let Some(collections) = collections.get::<T>() else {
115            return vec![];
116        };
117
118        collections.assets()
119    }
120
121    /// Reads an asset from its JSON file. Older versions read as far as serde
122    /// takes them; migrating them is `gled-migrate`'s, before loading.
123    ///
124    /// A file whose `name` is empty is named after its id, the name of the
125    /// file it came from, in the top folder. Its content stays reachable and
126    /// everything that refers to it by id keeps working; the user can rename
127    /// it, and the next save writes the name.
128    pub fn read(reader: impl Read, id: AssetId<T>) -> Result<Arc<Self>, ReadError> {
129        let asset: AssetOnDisk<T> = serde_json::from_reader(reader)?;
130        if asset.version > ASSET_VERSION {
131            return Err(ReadError::Newer {
132                version: asset.version,
133                supported: ASSET_VERSION,
134            });
135        }
136
137        let mut path = asset.name;
138        if path.is_empty() {
139            tracing::warn!(
140                "The {} {} has no name; it is named after its id",
141                T::NAME,
142                id.id
143            );
144            path.push(id.id.to_string());
145        }
146
147        Ok(Arc::new(Self {
148            id,
149            path,
150            data: asset.data,
151        }))
152    }
153
154    pub fn into_json(self) -> Result<String, serde_json::Error> {
155        let asset = AssetOnDisk {
156            name: self.path,
157            version: ASSET_VERSION,
158            data: self.data,
159        };
160
161        serde_json::to_string_pretty(&asset)
162    }
163
164    /// The folders the asset is in: its path without its own name. An asset
165    /// without a path (one built in code or received over the wire; files
166    /// get a name when read) is in the top folder.
167    pub fn dir(&self) -> Vec<String> {
168        self.path
169            .split_last()
170            .map(|(_, dir)| dir.to_vec())
171            .unwrap_or_default()
172    }
173
174    pub fn name(&self) -> &str {
175        self.path
176            .last()
177            .map(|name| name.as_str())
178            .unwrap_or("No name")
179    }
180
181    pub fn change_dir(&mut self, new_dir: &[String]) {
182        let name = self.path.pop();
183        self.path = new_dir.to_owned();
184        if let Some(name) = name {
185            self.path.push(name);
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::asset::palette::Palette;
194
195    fn palette_file(version: Option<u32>) -> String {
196        let version = version
197            .map(|v| format!(r#""version": {v},"#))
198            .unwrap_or_default();
199        format!(r#"{{"name": ["Warm"], {version} "primary": {{"rgb": [1.0, 0.5, 0.0]}}}}"#)
200    }
201
202    #[test]
203    fn a_file_from_before_versions_reads_and_saves_with_the_current_version() {
204        let asset =
205            Asset::<Palette>::read(palette_file(None).as_bytes(), AssetId::new()).expect("reads");
206        let saved: serde_json::Value =
207            serde_json::from_str(&(*asset).clone().into_json().expect("saves")).expect("json");
208        assert_eq!(saved["version"], ASSET_VERSION);
209        assert_eq!(saved["name"], serde_json::json!(["Warm"]));
210    }
211
212    #[test]
213    fn a_file_from_a_newer_gled_is_refused() {
214        let newer = palette_file(Some(ASSET_VERSION + 1));
215        assert!(matches!(
216            Asset::<Palette>::read(newer.as_bytes(), AssetId::new()),
217            Err(ReadError::Newer { .. })
218        ));
219    }
220
221    #[test]
222    fn a_file_without_a_name_is_named_after_its_id() {
223        let file = r#"{"name": [], "primary": {"rgb": [1.0, 0.5, 0.0]}}"#;
224        let id = AssetId::new();
225        let asset = Asset::<Palette>::read(file.as_bytes(), id).expect("reads");
226
227        assert_eq!(asset.path, [id.id.to_string()]);
228        assert_eq!(asset.name(), id.id.to_string());
229        assert!(asset.dir().is_empty());
230    }
231
232    #[test]
233    fn an_asset_without_a_path_is_in_the_top_folder_and_copies() {
234        let mut collections = Collections::default();
235        let nameless = Asset::<Palette>::new(vec![]);
236        collections.get_mut::<Palette>().set_asset(nameless.clone());
237
238        assert!(nameless.dir().is_empty());
239        assert_eq!(nameless.name(), "No name");
240        assert_eq!(nameless.copy(&collections).path, ["No name copy"]);
241    }
242
243    fn names(collections: &Collections) -> Vec<Vec<String>> {
244        let mut names: Vec<_> = Asset::<Palette>::all(collections)
245            .iter()
246            .map(|asset| asset.path.clone())
247            .collect();
248        names.sort();
249        names
250    }
251
252    #[test]
253    fn a_copy_gets_a_name_no_other_asset_in_its_folder_has() {
254        let mut collections = Collections::default();
255        let original = Asset::<Palette>::new(vec!["Show".into(), "Warm".into()]);
256        collections.get_mut::<Palette>().set_asset(original.clone());
257
258        let first = original.copy(&collections);
259        collections.get_mut::<Palette>().set_asset(first.clone());
260        let second = original.copy(&collections);
261        collections.get_mut::<Palette>().set_asset(second.clone());
262
263        assert_ne!(first.id, original.id);
264        assert_eq!(first.data, original.data);
265        assert_eq!(
266            names(&collections),
267            [
268                vec!["Show".to_owned(), "Warm".to_owned()],
269                vec!["Show".to_owned(), "Warm copy".to_owned()],
270                vec!["Show".to_owned(), "Warm copy 2".to_owned()],
271            ]
272        );
273    }
274}