hgame 0.26.4

CG production management structs, e.g. of assets, personnels, progress, etc.
Documentation
use super::*;
#[cfg(all(feature = "image_processing", feature = "gui"))]
use crate::pixel;

use mkutil::finder;
use std::io::{Error, ErrorKind};

/// Only allows for maximum 300KB thumbnail image.
const MAX_THUMB_SIZE_BYTES: u64 = 307200;

#[async_trait]
pub trait ThumbStore: DynClone + fmt::Debug + Send + Sync {
    /// Gets the main thumbnail.
    async fn thumbnail(&self, project: &Project) -> Result<ProjThumb, DatabaseError>;

    /// Adds or replaces the main thumbnail.
    async fn upload(&self, project: &Project, img: &ProjThumb) -> Result<(), ModificationError>;

    /// Removes the main thumbnail.
    async fn delete(&self, project: &Project, by: Option<Staff>) -> Result<(), ModificationError>;
}

dyn_clone::clone_trait_object!(ThumbStore);

// -------------------------------------------------------------------------------
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ProjThumb {
    #[serde(skip)]
    ext: ThumbExt,

    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,

    #[serde(with = "serde_bytes", rename = "thumbnail")]

    /// This field, by contrast, is `serde_bytes`, therefore for legacy documents
    /// which do not contain this field, deser will fail even if we put it behind an `Option`.
    img_bytes: Option<Vec<u8>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    last_modified_by: Option<String>,
}

impl BsonId for ProjThumb {
    fn bson_id_as_ref(&self) -> Option<&ObjectId> {
        self.id.as_ref()
    }

    fn bson_id(&self) -> AnyResult<&ObjectId> {
        self.id.as_ref().context("ProjThumb without BSON ObjectId")
    }
}

impl ProjThumb {
    pub fn empty() -> Self {
        Self {
            ext: ThumbExt::empty(),
            ..Default::default()
        }
    }

    pub fn modified_by(mut self, user: Staff) -> Self {
        self.last_modified_by = Some(user.name_owned());
        self
    }

    pub fn modified_mut(&mut self, by: Option<Staff>) {
        self.last_modified_by = by.map(|s| s.name_owned());
    }

    pub fn error(err: anyhow::Error) -> Self {
        let mut t = Self::empty();
        t.ext.error_mut(err);
        t
    }

    pub fn has_replacement(&self) -> bool {
        self.ext.replacement.is_some()
    }

    pub fn replacement_as_ref_unwrap(&self) -> &PathBuf {
        self.ext.replacement.as_ref().unwrap()
    }

    pub fn has_parsed_image(&self) -> bool {
        self.ext.parsed.is_ok()
    }

    pub fn parsed_image_as_ref(&self) -> Result<&RetainedImage, &anyhow::Error> {
        self.ext.parsed.as_ref()
    }

    pub fn bytes_as_ref_unwrap(&self) -> &[u8] {
        self.img_bytes.as_ref().unwrap()
    }

    pub fn with_bytes(mut self, bytes: Vec<u8>) -> Self {
        self.img_bytes = Some(bytes);
        self
    }

    #[cfg(all(feature = "image_processing", feature = "gui"))]
    /// Makes the [`RetainedImage`] out of the bytes loaded back from the DB.
    pub fn retained_img_mut(mut self) -> Self {
        if let Some(bytes) = &self.img_bytes {
            self.ext.retained_image_mut(bytes);
        };
        self
    }

    pub fn img_bytes_mut(&mut self, bytes: Option<Vec<u8>>) {
        self.img_bytes = bytes;
    }

    /// Rejects if file size is too large.
    pub fn check_replacement_size(&self) -> std::io::Result<()> {
        self.ext.check_replacement_size()
    }

    /// Reads the bytes of the file at the path in the draft into
    /// `Self::img_bytes`. This should only be invoked after `Self::check_replacement_size` is.
    pub fn img_bytes_mut_from_draft(&mut self) -> std::io::Result<()> {
        self.img_bytes_mut(Some(self.ext.img_bytes_from_draft()?));
        Ok(())
    }

    #[cfg(feature = "gui")]
    /// Shows the already parsed thumbnail
    pub fn read_mode_ui_with_size(&mut self, ui: &mut egui::Ui, size: impl Into<egui::Vec2>) {
        self.ext.read_mode_ui(ui, size);
    }

    #[cfg(feature = "gui")]
    pub fn write_suggest_unlocked_mut_ui(&mut self, ui: &mut egui::Ui, unlocked: &mut bool) {
        self.ext.write_suggest_ui(ui, unlocked);
    }
}

impl ReadWriteSuggest for ProjThumb {
    fn write_suggest() -> Self {
        Self::empty().with_mode(MediaMode::WriteSuggest)
    }

    fn with_mode(mut self, mode: MediaMode) -> Self {
        self.mode_mut(mode);
        self
    }

    fn mode(&self) -> &MediaMode {
        &self.ext.mode
    }

    fn mode_mut(&mut self, mode: MediaMode) {
        self.ext.mode = mode;
    }

    #[cfg(feature = "gui")]
    fn write_compose_ui(&mut self, ui: &mut egui::Ui) {
        self.ext.write_compose_ui(ui);
    }
}

// -------------------------------------------------------------------------------
struct ThumbExt {
    mode: MediaMode,

    parsed: AnyResult<RetainedImage>,

    /// A path use selects for thumbnail replacement.
    replacement: Option<PathBuf>,
}

impl ThumbExt {
    fn empty() -> Self {
        Self {
            mode: MediaMode::default(),
            parsed: Err(DatabaseError::Uninitialized.into()),
            replacement: None,
        }
    }

    fn error_mut(&mut self, err: anyhow::Error) {
        self.parsed = Err(err);
    }

    #[cfg(all(feature = "image_processing", feature = "gui"))]
    fn retained_image_mut(&mut self, bytes: &[u8]) {
        self.parsed = pixel::retained_image_from_bytes(bytes);
    }

    fn check_replacement_size(&self) -> std::io::Result<()> {
        if self.replacement.is_none() {
            return Err(Error::new(
                ErrorKind::NotFound,
                "No replacement image was chosen",
            ));
        };
        let img = self.replacement.as_ref().unwrap();
        match finder::file_size(img)?.cmp(&MAX_THUMB_SIZE_BYTES) {
            Ordering::Greater => {
                // disallow
                return Err(Error::new(
                    ErrorKind::Other,
                    format!(
                        "File size too large. Only images under {}KB are allowed.",
                        MAX_THUMB_SIZE_BYTES / 1024,
                    ),
                ));
            }
            _ => Ok(()),
        }
    }

    fn img_bytes_from_draft(&self) -> std::io::Result<Vec<u8>> {
        // SAFETY: caller is responsible for making sure `Self::replacement` `is_some`
        Ok(finder::read_file_bytes(self.replacement.as_ref().unwrap())?)
    }

    #[cfg(all(feature = "image_processing", feature = "gui"))]
    fn read_mode_ui(&self, ui: &mut egui::Ui, size: impl Into<egui::Vec2>) {
        match &self.parsed {
            Ok(thumb) => {
                ui.image(thumb.texture_id(ui.ctx()), size);
            }
            Err(e) => {
                // shows a placeholder thumbnail
                ui.image(
                    IconCel::icon_book()
                        .get_included(embedded_icons::NO_PROJ_THUMB)
                        .texture_id(ui.ctx()),
                    size,
                );
                ui.colored_label(Color32::LIGHT_RED, "No project thumbnail")
                    .on_hover_text(e.to_string());
            }
        }
    }

    #[cfg(feature = "gui")]
    fn write_suggest_ui(&mut self, ui: &mut egui::Ui, unlocked: &mut bool) {
        ui.horizontal(|ui| {
            if ui.button("❌ Cancel").clicked() {
                // not taking since we want to retain the draft
                // self.replacement.take();
                // getting out of edit mode is enough
                *unlocked = false;
            };
            if ui.button("📁 Browse").clicked() {
                use mkutil::dialog;
                // shows file dialog to browse a new image
                self.replacement = dialog::pick_one_image();
            };
        });
    }

    #[cfg(feature = "gui")]
    fn write_compose_ui(&mut self, ui: &mut egui::Ui) {
        match &self.replacement {
            Some(path) => {
                ui.label(
                    RichText::new(format!(
                        "Will replace with: {:?}",
                        path.file_name().unwrap_or_default()
                    ))
                    .color(Color32::YELLOW),
                )
                .on_hover_text(format!("{}", path.display()));
            }
            None => {
                ui.label(RichText::new("No replacement chosen").strong());
            }
        };
    }
}

impl fmt::Debug for ThumbExt {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ThumbExt")
            .field("mode", &self.mode)
            // .field("parsed", &self.parsed)
            .field("replacement", &self.replacement)
            .finish()
    }
}

impl std::clone::Clone for ThumbExt {
    fn clone(&self) -> Self {
        Self {
            mode: self.mode.clone(),
            parsed: Err(anyhow!("Unable to clone RetainedImage")),
            replacement: self.replacement.clone(),
        }
    }
}

impl Default for ThumbExt {
    fn default() -> Self {
        Self::empty()
    }
}