note-to-self-cli 0.1.1

Encrypted local-first journaling CLI with sync and locked journals.
use anyhow::{bail, Context, Result};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use note_to_self_lib::{decode_bytes, encode_bytes, Attachment, Entry};
use std::env;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use uuid::Uuid;

const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;

pub fn attachments_from_paths(paths: &[PathBuf]) -> Result<Vec<Attachment>> {
    paths
        .iter()
        .map(|path| attachment_from_path(path))
        .collect()
}

pub fn body_with_images(mut body: String, attachments: &[Attachment]) -> String {
    for attachment in attachments {
        if !body.trim().is_empty() {
            body.push_str("\n\n");
        }
        body.push_str(&image_markdown(attachment));
    }
    body
}

pub fn body_preview(entry: &Entry, max_chars: usize) -> String {
    let line = entry.body.lines().next().unwrap_or_default().trim();
    let preview = if line.starts_with("![") && line.contains("](note-image:") {
        image_summary(entry)
    } else {
        line.to_string()
    };
    truncate(&preview, max_chars)
}

pub fn image_summary(entry: &Entry) -> String {
    match entry.attachments.as_slice() {
        [] => "[image]".to_string(),
        [attachment] => format!("[image: {}]", attachment.file_name),
        attachments => format!(
            "[{} images: {}]",
            attachments.len(),
            attachments[0].file_name
        ),
    }
}

pub fn attachment_labels(entry: &Entry) -> Vec<String> {
    entry.attachments.iter().map(attachment_label).collect()
}

pub fn print_entry_images(entry: &Entry) -> Result<()> {
    if entry.attachments.is_empty() {
        return Ok(());
    }

    let supported = kitty_graphics_supported();
    for attachment in &entry.attachments {
        if supported && render_kitty_image(attachment)? {
            continue;
        }
        println!("  {}", attachment_label(attachment));
    }
    Ok(())
}

fn attachment_from_path(path: &Path) -> Result<Attachment> {
    let metadata =
        fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
    if !metadata.is_file() {
        bail!("{} is not a file", path.display());
    }
    if metadata.len() > MAX_IMAGE_BYTES {
        bail!(
            "{} is too large ({}); max image size is {}",
            path.display(),
            human_size(metadata.len()),
            human_size(MAX_IMAGE_BYTES)
        );
    }

    let media_type = media_type(path)?;
    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("image")
        .to_string();
    Ok(Attachment {
        id: Uuid::now_v7().to_string(),
        file_name,
        media_type,
        data: encode_bytes(&bytes),
        size: bytes.len() as u64,
    })
}

fn image_markdown(attachment: &Attachment) -> String {
    format!(
        "![{}](note-image:{})",
        markdown_alt(&attachment.file_name),
        attachment.id
    )
}

fn attachment_label(attachment: &Attachment) -> String {
    format!(
        "[image: {} · {} · {}]",
        attachment.file_name,
        attachment.media_type,
        human_size(attachment.size)
    )
}

fn render_kitty_image(attachment: &Attachment) -> Result<bool> {
    if env::var_os("KITTY_WINDOW_ID").is_some() && command_exists("kitty") {
        if render_with_kitty_kitten(attachment)? {
            return Ok(true);
        }
    }
    render_direct_kitty_png(attachment)
}

fn render_with_kitty_kitten(attachment: &Attachment) -> Result<bool> {
    let bytes = decode_bytes(&attachment.data).context("image attachment is not valid base64")?;
    let suffix = Path::new(&attachment.file_name)
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| format!(".{ext}"))
        .unwrap_or_else(|| ".img".to_string());
    let mut temp = tempfile::Builder::new()
        .prefix("note-to-self-image-")
        .suffix(&suffix)
        .tempfile()
        .context("failed to create temporary image file")?;
    temp.write_all(&bytes)
        .context("failed to write temporary image file")?;
    temp.flush().ok();

    let status = Command::new("kitty")
        .args(["+kitten", "icat", "--stdin=no"])
        .arg(temp.path())
        .status();
    Ok(matches!(status, Ok(status) if status.success()))
}

fn render_direct_kitty_png(attachment: &Attachment) -> Result<bool> {
    if attachment.media_type != "image/png" {
        return Ok(false);
    }
    let bytes = decode_bytes(&attachment.data).context("image attachment is not valid base64")?;
    let encoded = STANDARD.encode(bytes);
    let mut stdout = io::stdout();
    let mut chunks = encoded.as_bytes().chunks(4096).peekable();
    let mut first = true;
    while let Some(chunk) = chunks.next() {
        let more = if chunks.peek().is_some() { 1 } else { 0 };
        let payload = std::str::from_utf8(chunk).expect("base64 is ascii");
        if first {
            write!(stdout, "\x1b_Ga=T,f=100,c=48,r=18,m={more};{payload}\x1b\\")?;
            first = false;
        } else {
            write!(stdout, "\x1b_Gm={more};{payload}\x1b\\")?;
        }
    }
    writeln!(stdout)?;
    stdout.flush()?;
    Ok(true)
}

fn kitty_graphics_supported() -> bool {
    if !io::stdout().is_terminal() {
        return false;
    }
    if env::var_os("KITTY_WINDOW_ID").is_some() {
        return true;
    }
    let term = env::var("TERM").unwrap_or_default().to_ascii_lowercase();
    let program = env::var("TERM_PROGRAM")
        .unwrap_or_default()
        .to_ascii_lowercase();
    term.contains("kitty") || program.contains("wezterm") || program.contains("ghostty")
}

fn command_exists(name: &str) -> bool {
    let Some(paths) = env::var_os("PATH") else {
        return false;
    };
    env::split_paths(&paths).any(|path| path.join(name).is_file())
}

fn media_type(path: &Path) -> Result<String> {
    let ext = path
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    let media_type = match ext.as_str() {
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "webp" => "image/webp",
        "avif" => "image/avif",
        "bmp" => "image/bmp",
        "svg" => "image/svg+xml",
        "tif" | "tiff" => "image/tiff",
        _ => bail!(
            "{} does not look like a supported image file",
            path.display()
        ),
    };
    Ok(media_type.to_string())
}

fn markdown_alt(value: &str) -> String {
    value.replace('\\', "\\\\").replace(']', "\\]")
}

fn truncate(value: &str, max_chars: usize) -> String {
    if value.chars().count() > max_chars {
        format!(
            "{}...",
            value
                .chars()
                .take(max_chars.saturating_sub(3))
                .collect::<String>()
        )
    } else {
        value.to_string()
    }
}

fn human_size(bytes: u64) -> String {
    const KIB: f64 = 1024.0;
    const MIB: f64 = KIB * 1024.0;
    if bytes >= 1024 * 1024 {
        format!("{:.1} MiB", bytes as f64 / MIB)
    } else if bytes >= 1024 {
        format!("{:.1} KiB", bytes as f64 / KIB)
    } else {
        format!("{bytes} B")
    }
}