psitool 0.6.0

The 100% free and open-source Remote Viewing toolset.
Documentation
use anyhow::Context;
use log::{debug, info, warn};
use rand::prelude::IndexedRandom;
use rand::rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::fs::File;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use crate::cache::{CacheMap, CachedHash};
use crate::rvuid::Rvuid;

#[derive(Clone, Debug, serde::Serialize, Deserialize)]
pub struct YamlData {
    pub query: String,
    pub frontloading: Vec<String>,
    pub image_description: serde_json::Value,
    pub datetime_original: serde_json::Value,
    pub img_metadata: HashMap<String, serde_json::Value>,
    pub license: String,
    pub license_meta: HashMap<String, serde_json::Value>,
}

impl YamlData {
    pub fn serialize(&self) -> HashMap<String, String> {
        let mut map: HashMap<String, String> = HashMap::new();
        map.insert("Query".to_string(), self.query.to_string());
        map.insert(
            "Description".to_string(),
            self.image_description.to_string(),
        );
        map.insert("Datetime".to_string(), self.datetime_original.to_string());
        map.insert("License".to_string(), self.license.to_string());
        map
    }
}

#[derive(Clone, Debug, PartialEq)]
pub enum TargetType {
    Text,
    Jpeg,
    Svg,
}

impl TargetType {
    pub fn parse(path: &Path) -> Option<Self> {
        let ext = path
            .extension()
            .and_then(OsStr::to_str)
            .map(|s| s.to_ascii_lowercase());
        match ext {
            Some(ext) if ext == "jpg" || ext == "jpeg" => Some(TargetType::Jpeg),
            Some(ext) if ext == "svg" => Some(TargetType::Svg),
            Some(ext) if ext == "target" => Some(TargetType::Text),
            Some(ext) if ext == "yaml" || ext == "yml" => None,
            None => {
                warn!("Extension not parsed from: {}", path.display());
                None
            }
            _ => {
                debug!("Ignoring non-target extension: {}", path.display());
                None
            }
        }
    }
}

#[derive(Clone, Debug)]
pub struct Target {
    pub rvuid: Rvuid,
    pub path: PathBuf,
    pub meta_path: Option<PathBuf>,
    pub frontloading: Vec<String>,
    pub target_type: TargetType,
    pub meta: HashMap<String, String>,
}

impl fmt::Display for Target {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Target[{}]", self.rvuid)
    }
}

impl Target {
    pub fn parse(path: &Path) -> anyhow::Result<Self> {
        let target_bytes = fs::read(path)
            .with_context(|| format!("failed to read bytes from {}", path.display()))?;
        let rvuid = Rvuid::from_bytes(&target_bytes);
        let maybe_meta_path = Self::maybe_yaml(path);
        let meta_path = maybe_meta_path.clone();
        let target_type = TargetType::parse(path)
            .ok_or_else(|| anyhow::anyhow!("no target at {}", path.display()))?;
        if let Some(unp_meta_path) = maybe_meta_path {
            let text = fs::read_to_string(unp_meta_path)?;
            let yaml_data: YamlData = serde_yaml::from_str(&text)?;
            let frontloading = yaml_data.frontloading.clone();
            Ok(Target {
                rvuid,
                target_type,
                path: path.to_path_buf(),
                meta_path,
                frontloading,
                meta: yaml_data.clone().serialize(),
            })
        } else {
            debug!("No metadata found for file: {:?}", path);
            let frontloading: Vec<String> = Vec::new();
            let blank: HashMap<String, String> = HashMap::new();
            Ok(Target {
                rvuid,
                target_type,
                path: path.to_path_buf(),
                meta_path,
                frontloading,
                meta: blank,
            })
        }
    }

    fn maybe_yaml(path: &Path) -> Option<PathBuf> {
        let new_path = path.with_file_name(format!(
            "{}.yaml",
            path.file_name().unwrap().to_string_lossy()
        ));
        if new_path.exists() {
            Some(new_path)
        } else {
            None
        }
    }
}

impl Target {
    pub fn all_from_dir(
        dir: &Path,
        completed_rvuids: &[Rvuid],
        cachemap: &mut CacheMap,
    ) -> anyhow::Result<Vec<CachedHash>> {
        let cached_hashes: Vec<CachedHash> = fs::read_dir(dir)?
            .filter_map(|entry| {
                entry.ok().and_then(|e| {
                    if let Some(ch) = cachemap.get(&e.path()) {
                        Some(ch.clone())
                    } else {
                        let target: Option<Target> = Target::parse(&e.path()).ok();
                        if let Some(target) = target {
                            // Parsed a new one, so save this back to the mutable cachemap.
                            let ch: CachedHash = target.into();
                            cachemap.insert(ch.path.clone(), ch.clone());
                            Some(ch)
                        } else {
                            // Could be a YAML file for example, any non-target.
                            None
                        }
                    }
                })
            })
            .filter(|ch| !completed_rvuids.contains(&ch.rvuid))
            .collect();
        Ok(cached_hashes)
    }

    pub fn random_from_dir(
        dir: &Path,
        completed_rvuids: &[Rvuid],
        cachemap: &mut CacheMap,
    ) -> anyhow::Result<Self> {
        let cached_hashes = Self::all_from_dir(dir, completed_rvuids, cachemap)?;
        if cached_hashes.is_empty() {
            anyhow::bail!("no JPG/JPEG/SVG or TARGET files found in {}", dir.display());
        }

        let mut rng = rng();
        let chosen = cached_hashes
            .choose(&mut rng)
            .cloned()
            .expect("should have chosen a target");

        chosen.try_into()
    }

    pub fn iter_meta(&self) -> Vec<(String, String)> {
        let keys: Vec<String> = vec![
            "Query".into(),
            "Description".into(),
            "Datetime".into(),
            "License".into(),
        ];
        keys.into_iter()
            .map(|key| {
                (
                    key.clone(),
                    self.meta.get(&key).map_or("".to_string(), |v| v.clone()),
                )
            })
            .collect()
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct CompletedTarget {
    pub rvuid: Rvuid,
    pub path: String,
    pub meta_path: Option<String>,
    pub hit: Option<bool>,
    pub score: Option<u32>,
    pub notes: Option<String>,
}

impl CompletedTarget {
    pub fn parse(path: &str) -> anyhow::Result<Vec<Self>> {
        let expanded = shellexpand::tilde(path).into_owned();
        let pbuf = PathBuf::from(&expanded);
        if !pbuf.exists() {
            let targs: Vec<Self> = Vec::new();
            return Ok(targs);
        }
        let text = fs::read_to_string(pbuf)?;
        let targs: Vec<Self> = serde_yaml::from_str(&text)?;
        Ok(targs)
    }
    pub fn dump(completed_targets: &Vec<Self>, path: &str) -> anyhow::Result<()> {
        let expanded = shellexpand::tilde(path).into_owned();
        let pbuf = PathBuf::from(&expanded);
        debug!(
            "Writing {} completed targets to {} ...",
            completed_targets.len(),
            pbuf.display()
        );
        let yaml = serde_yaml::to_string(completed_targets)?;
        let mut file = File::create(pbuf.clone())?;
        file.write_all(yaml.as_bytes())?;
        debug!(
            "Succesfully wrote {} completed targets to {}",
            completed_targets.len(),
            pbuf.display()
        );
        Ok(())
    }
    pub fn interactive_ask_results(&mut self) {
        print!("Was it a hit ([y]es, [n]o, otherwise not saved/recorded)? ");
        io::stdout().flush().unwrap();
        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap();
        self.hit = match input.trim().to_lowercase().as_str() {
            "y" | "yes" => Some(true),
            "n" | "no" => Some(false),
            _ => {
                debug!("Not recording whether it's a hit or not.");
                None
            }
        };

        print!("Score out of 100 (0 to 100 or otherwise not saved/recorded)? ");
        io::stdout().flush().unwrap();
        input.clear();
        io::stdin().read_line(&mut input).unwrap();
        self.score = match input.trim().parse::<u32>() {
            Ok(n) if (0..=100).contains(&n) => {
                info!("Recording score of {}", n);
                Some(n)
            }
            _ => {
                debug!("Not recording score.");
                None
            }
        };

        print!("Any notes? Press enter to end (or blank to not save anything): ");
        io::stdout().flush().unwrap();
        input.clear();
        io::stdin().read_line(&mut input).unwrap();
        self.notes = match input.trim() {
            "" => None,
            s => Some(s.to_string()),
        };
    }
}

impl From<Target> for CompletedTarget {
    fn from(target: Target) -> Self {
        CompletedTarget {
            rvuid: target.rvuid,
            path: target.path.display().to_string(),
            meta_path: target.meta_path.map(|p| p.display().to_string()),
            hit: None,
            score: None,
            notes: None,
        }
    }
}

impl fmt::Display for CompletedTarget {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(hit) = self.hit {
            write!(f, "CompletedTarget[{}, {}]", self.rvuid, hit)
        } else {
            write!(f, "CompletedTarget[{}]", self.rvuid)
        }
    }
}