mini-film 10.2.2

Apply Lightroom-style film emulation profiles to RAW files with RawTherapee and HALD workflows.
Documentation
use super::{model::*, prelude::*, store::*};
use std::{
    fs::OpenOptions,
    io::{BufWriter, Write},
};

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct HistoryEntry {
    title: String,
    lines: Vec<String>,
}

impl HistoryEntry {
    fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            lines: Vec::new(),
        }
    }

    fn line(&mut self, line: impl Into<String>) {
        self.lines.push(line.into());
    }

    fn change<T>(&mut self, label: &str, before: T, after: T)
    where
        T: PartialEq + std::fmt::Display,
    {
        if before != after {
            self.line(format!("{label}: {before} -> {after}"));
        }
    }

    fn optional_change<T>(&mut self, label: &str, before: Option<T>, after: Option<T>)
    where
        T: PartialEq + std::fmt::Display,
    {
        if before != after {
            self.line(format!(
                "{label}: {} -> {}",
                display_optional(before),
                display_optional(after)
            ));
        }
    }

    fn is_empty(&self) -> bool {
        self.lines.is_empty()
    }
}

pub(super) fn append_history_entry(output_root: &Path, entry: HistoryEntry) -> Result<()> {
    fs::create_dir_all(output_root)
        .with_context(|| format!("creating {}", output_root.display()))?;
    let path = output_root.join("history.txt");
    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .with_context(|| format!("opening {}", path.display()))?;
    let mut writer = BufWriter::new(file);
    writeln!(writer, "{} | {}", now_string(), entry.title)
        .with_context(|| format!("writing {}", path.display()))?;
    for line in entry.lines {
        writeln!(writer, "  {line}").with_context(|| format!("writing {}", path.display()))?;
    }
    writeln!(writer).with_context(|| format!("writing {}", path.display()))?;
    writer
        .flush()
        .with_context(|| format!("flushing {}", path.display()))
}

pub(super) fn history_server_started(
    input_root: &Path,
    output_root: &Path,
    profiles: &[ReviewProfile],
) -> HistoryEntry {
    let mut entry = HistoryEntry::new("review server started");
    entry.line(format!("input: {}", input_root.display()));
    entry.line(format!("output: {}", output_root.display()));
    entry.line(format!("profiles: {}", profiles.len()));
    if !profiles.is_empty() {
        entry.line(format!(
            "profile stems: {}",
            profiles
                .iter()
                .map(|profile| profile.stem.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    entry
}

pub(super) fn history_image_discovered(
    image: &ReviewImage,
    discovered: bool,
    preview_queued: bool,
) -> HistoryEntry {
    let mut entry = HistoryEntry::new(format!(
        "review image {} #{}",
        image.relative_path, image.id
    ));
    if discovered {
        entry.line("discovered raw");
    }
    if preview_queued {
        entry.line("preview queued");
    }
    entry.line(format!("raw: {}", image.raw_path.display()));
    entry.line(format!("profiles: {}", image.profiles.len()));
    entry
}

pub(super) fn history_preview_changed(
    image: &ReviewImage,
    before: &ReviewPreview,
    after: &ReviewPreview,
) -> Option<HistoryEntry> {
    let mut entry = HistoryEntry::new(format!(
        "review preview changed {} #{}",
        image.relative_path, image.id
    ));
    entry.change(
        "status",
        render_status_text(before.status),
        render_status_text(after.status),
    );
    entry.optional_change(
        "path",
        before.path.as_ref().map(|path| path.display().to_string()),
        after.path.as_ref().map(|path| path.display().to_string()),
    );
    entry.optional_change("error", before.error.as_deref(), after.error.as_deref());
    (!entry.is_empty()).then_some(entry)
}

pub(super) fn history_render_changed(
    image: &ReviewImage,
    before: &ReviewProfileRender,
    after: &ReviewProfileRender,
) -> Option<HistoryEntry> {
    let mut entry = HistoryEntry::new(format!(
        "review render changed {} #{} [{}]",
        image.relative_path, image.id, after.profile_stem
    ));
    entry.change(
        "status",
        render_status_text(before.status),
        render_status_text(after.status),
    );
    entry.optional_change(
        "output",
        before
            .output_path
            .as_ref()
            .map(|path| path.display().to_string()),
        after
            .output_path
            .as_ref()
            .map(|path| path.display().to_string()),
    );
    entry.optional_change("error", before.error.as_deref(), after.error.as_deref());
    entry.optional_change("duration", before.duration_ms, after.duration_ms);
    entry.optional_change(
        "retouch key",
        before.render_key.as_deref(),
        after.render_key.as_deref(),
    );
    (!entry.is_empty()).then_some(entry)
}

pub(super) fn history_codex_changed(
    image: &ReviewImage,
    before: &ReviewCodexAnalysis,
    after: &ReviewCodexAnalysis,
) -> Option<HistoryEntry> {
    let mut entry = HistoryEntry::new(format!(
        "review Codex changed {} #{}",
        image.relative_path, image.id
    ));
    entry.change(
        "status",
        codex_status_text(before.status),
        codex_status_text(after.status),
    );
    entry.change("flags", before.flags.key(), after.flags.key());
    entry.change("model", quoted(&before.model), quoted(&after.model));
    entry.optional_change("error", before.error.as_deref(), after.error.as_deref());
    (!entry.is_empty()).then_some(entry)
}

pub(super) fn history_review_changed(
    before: &ReviewImage,
    after: &ReviewImage,
) -> Option<HistoryEntry> {
    let mut entry = HistoryEntry::new(format!(
        "review metadata changed {} #{}",
        after.relative_path, after.id
    ));
    entry.change("rating", before.rating, after.rating);
    entry.change("labels", labels_text(before), labels_text(after));
    entry.change("tags", list_text(&before.tags), list_text(&after.tags));
    entry.change("notes", quoted(&before.notes), quoted(&after.notes));
    entry.change(
        "selected profile",
        profile_index_text(before.selected_profile_index, before),
        profile_index_text(after.selected_profile_index, after),
    );
    entry.change(
        "publish profiles",
        publish_profiles_text(before),
        publish_profiles_text(after),
    );
    if before.retouch != after.retouch {
        entry.line(format!(
            "retouch: {} -> {}",
            before.retouch.summary(),
            after.retouch.summary()
        ));
    }
    (!entry.is_empty()).then_some(entry)
}

pub(super) fn history_ui_changed(
    store: &ReviewStore,
    before: &ReviewUiState,
    after: &ReviewUiState,
) -> Option<HistoryEntry> {
    let mut entry = HistoryEntry::new("review UI changed");
    entry.change("minimum rating", before.min_rating, after.min_rating);
    entry.change(
        "current image",
        image_id_text(store, before.current_image_id),
        image_id_text(store, after.current_image_id),
    );
    (!entry.is_empty()).then_some(entry)
}

pub(super) fn history_publish_started(
    job: &ReviewPublishJob,
    args: &ReviewPublishCommandArgs,
) -> HistoryEntry {
    let mut entry = HistoryEntry::new(format!("review publish job #{} started", job.id));
    entry.line(format!("album: {}", job.album));
    entry.line(format!("rating >= {}", args.min_rating));
    entry.line(format!("labels: {}", list_text(&args.labels)));
    entry.line(format!("tags: {}", list_text(&args.tags)));
    entry.line(format!("format: {}", args.output_format));
    entry.line(format!("rerender raw: {}", yes_no(args.rerender_raw)));
    entry.line(format!("jobs: {}", args.jobs));
    if let Some(gallery) = args.gallery {
        entry.line(format!(
            "gallery: {gallery} ({} columns, {} px thumbnails)",
            args.gallery_columns, args.gallery_thumbnail_long_edge
        ));
    } else {
        entry.line("gallery: none");
    }
    entry
}

pub(super) fn history_publish_changed(
    before: &ReviewPublishJob,
    after: &ReviewPublishJob,
) -> Option<HistoryEntry> {
    let mut entry = HistoryEntry::new(format!("review publish job #{} changed", after.id));
    entry.change(
        "status",
        publish_status_text(before.status),
        publish_status_text(after.status),
    );
    entry.change("step", before.step.as_str(), after.step.as_str());
    entry.optional_change(
        "current",
        before.current.as_deref(),
        after.current.as_deref(),
    );
    entry.change("processed", before.processed, after.processed);
    entry.change("total", before.total, after.total);
    entry.change("linked", before.linked, after.linked);
    entry.change("skipped", before.skipped, after.skipped);
    entry.change("galleries", before.galleries, after.galleries);
    entry.optional_change("error", before.error.as_deref(), after.error.as_deref());
    entry.optional_change(
        "finished at",
        before.finished_at.as_deref(),
        after.finished_at.as_deref(),
    );
    (!entry.is_empty()).then_some(entry)
}

fn image_id_text(store: &ReviewStore, image_id: Option<u64>) -> String {
    let Some(image_id) = image_id else {
        return "none".to_string();
    };
    store
        .images
        .iter()
        .find(|image| image.id == image_id)
        .map(|image| format!("#{} {}", image.id, image.relative_path))
        .unwrap_or_else(|| format!("#{image_id}"))
}

fn labels_text(image: &ReviewImage) -> String {
    let labels = image_review_labels(image)
        .into_iter()
        .map(review_label_name)
        .collect::<Vec<_>>();
    if labels.is_empty() {
        "none".to_string()
    } else {
        labels.join(", ")
    }
}

fn publish_profiles_text(image: &ReviewImage) -> String {
    let indexes = effective_publish_profile_indexes(image)
        .into_iter()
        .map(|index| profile_index_text(index, image))
        .collect::<Vec<_>>();
    list_text(&indexes)
}

fn profile_index_text(index: usize, image: &ReviewImage) -> String {
    image
        .profiles
        .iter()
        .find(|profile| profile.profile_index == index)
        .map(|profile| format!("{index}:{}", profile.profile_stem))
        .unwrap_or_else(|| index.to_string())
}

fn render_status_text(status: ReviewRenderStatus) -> &'static str {
    match status {
        ReviewRenderStatus::Missing => "missing",
        ReviewRenderStatus::Queued => "queued",
        ReviewRenderStatus::Processing => "processing",
        ReviewRenderStatus::Done => "done",
        ReviewRenderStatus::Failed => "failed",
    }
}

fn codex_status_text(status: ReviewCodexStatus) -> &'static str {
    match status {
        ReviewCodexStatus::Missing => "missing",
        ReviewCodexStatus::Queued => "queued",
        ReviewCodexStatus::Processing => "processing",
        ReviewCodexStatus::Done => "done",
        ReviewCodexStatus::Failed => "failed",
        ReviewCodexStatus::Skipped => "skipped",
    }
}

fn publish_status_text(status: ReviewPublishJobStatus) -> &'static str {
    match status {
        ReviewPublishJobStatus::Running => "running",
        ReviewPublishJobStatus::Done => "done",
        ReviewPublishJobStatus::Failed => "failed",
    }
}

fn list_text<T>(items: &[T]) -> String
where
    T: AsRef<str>,
{
    if items.is_empty() {
        "none".to_string()
    } else {
        items
            .iter()
            .map(AsRef::as_ref)
            .collect::<Vec<_>>()
            .join(", ")
    }
}

fn quoted(value: &str) -> String {
    if value.is_empty() {
        "none".to_string()
    } else {
        format!("{value:?}")
    }
}

fn yes_no(value: bool) -> &'static str {
    if value { "yes" } else { "no" }
}

fn display_optional<T>(value: Option<T>) -> String
where
    T: std::fmt::Display,
{
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "none".to_string())
}