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(" {
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!(
"",
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")
}
}