use crate::api::DiscourseClient;
use crate::cli::ListFormat;
use crate::commands::common::{ensure_api_credentials, select_discourse};
use crate::config::Config;
use crate::utils::slugify;
use anyhow::{Context, Result, anyhow};
use base64::Engine;
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
pub fn pull_emojis(
config: &Config,
discourse_name: &str,
output_dir: &Path,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let emojis = client.list_custom_emojis()?;
if emojis.is_empty() {
println!("No custom emoji found on {}.", discourse.name);
return Ok(());
}
fs::create_dir_all(output_dir)
.with_context(|| format!("creating directory {}", output_dir.display()))?;
let http = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("building HTTP client for emoji download")?;
let bar = ProgressBar::new(emojis.len() as u64);
bar.set_style(
ProgressStyle::default_bar()
.template("[{bar:30}] {pos}/{len} {msg}")
.unwrap(),
);
let mut downloaded = 0u64;
let mut skipped = 0u64;
let mut failed = 0u64;
for emoji in &emojis {
bar.set_message(emoji.name.clone());
let ext = emoji
.url
.rsplit('.')
.next()
.and_then(|e| {
let lower = e.to_lowercase();
if matches!(lower.as_str(), "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp") {
Some(lower)
} else {
None
}
})
.unwrap_or_else(|| "png".to_string());
let dest = output_dir.join(format!("{}.{}", emoji.name, ext));
if dest.exists() {
skipped += 1;
bar.inc(1);
continue;
}
let url = if emoji.url.starts_with("http") {
emoji.url.clone()
} else {
format!("{}{}", client.baseurl(), emoji.url)
};
match http.get(&url).send() {
Ok(resp) if resp.status().is_success() => {
match resp.bytes() {
Ok(bytes) => {
if let Err(e) = fs::write(&dest, &bytes) {
eprintln!("Failed to write {}: {}", dest.display(), e);
failed += 1;
} else {
downloaded += 1;
}
}
Err(e) => {
eprintln!("Failed to read response for {}: {}", emoji.name, e);
failed += 1;
}
}
}
Ok(resp) => {
eprintln!("HTTP {} downloading {}", resp.status(), emoji.name);
failed += 1;
}
Err(e) => {
eprintln!("Failed to download {}: {}", emoji.name, e);
failed += 1;
}
}
bar.inc(1);
}
bar.finish_and_clear();
println!(
"Emoji pull complete: {} downloaded, {} skipped (existing), {} failed",
downloaded, skipped, failed
);
Ok(())
}
pub fn add_emoji(
config: &Config,
discourse_name: &str,
emoji_path: &Path,
emoji_name: Option<&str>,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let mut existing_names = existing_emoji_names(&client).unwrap_or_else(|err| {
eprintln!(
"Warning: failed to list existing emojis for idempotent checks: {}",
err
);
HashSet::new()
});
if emoji_path.is_dir() {
if emoji_name.is_some() {
return Err(anyhow!(
"emoji name is not allowed when uploading a directory"
));
}
let mut files = Vec::new();
for entry in
fs::read_dir(emoji_path).with_context(|| format!("reading {}", emoji_path.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if !is_emoji_file(&path) {
continue;
}
files.push(path);
}
files.sort();
if files.is_empty() {
return Err(anyhow!("no emoji image files found in directory"));
}
let mut uploaded = 0usize;
let mut skipped_existing = 0usize;
let mut failures: Vec<(String, String, String)> = Vec::new();
let bar = ProgressBar::new(files.len() as u64);
bar.set_style(
ProgressStyle::with_template("{bar:30} {pos}/{len} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_bar()),
);
for path in files {
let name = emoji_name_from_path(&path)?;
let key = emoji_key(&name);
bar.set_message(name.clone());
if existing_names.contains(&key) {
skipped_existing += 1;
bar.println(format!("skip {} (already exists)", name));
bar.inc(1);
continue;
}
match client.upload_emoji(&path, &name) {
Ok(_) => {
uploaded += 1;
existing_names.insert(key);
bar.println(format!("add {}", name));
}
Err(err) if is_duplicate_emoji_error(&err) => {
skipped_existing += 1;
existing_names.insert(key);
bar.println(format!("skip {} (already exists)", name));
}
Err(err) => {
bar.println(format!("FAIL {} — {}", name, err));
failures.push((name, path.display().to_string(), err.to_string()));
}
}
bar.inc(1);
}
bar.finish_and_clear();
if !failures.is_empty() {
eprintln!("Emoji upload failures:");
for (name, path, reason) in &failures {
eprintln!("- {} ({}) => {}", name, path, reason);
}
}
println!(
"Emoji bulk upload summary: uploaded={}, skipped_existing={}, failed={}",
uploaded,
skipped_existing,
failures.len()
);
if !failures.is_empty() {
return Err(anyhow!(
"{} emoji uploads failed; see failure summary above",
failures.len()
));
}
return Ok(());
}
let name = match emoji_name {
Some(name) => name.to_string(),
None => emoji_name_from_path(emoji_path)?,
};
let key = emoji_key(&name);
if existing_names.contains(&key) {
println!(
"Skipped existing emoji {} from {}",
name,
emoji_path.display()
);
return Ok(());
}
match client.upload_emoji(emoji_path, &name) {
Ok(_) => {
println!("Uploaded emoji {} from {}", name, emoji_path.display());
Ok(())
}
Err(err) if is_duplicate_emoji_error(&err) => {
println!(
"Skipped existing emoji {} from {}",
name,
emoji_path.display()
);
Ok(())
}
Err(err) => Err(err),
}?;
Ok(())
}
pub fn list_emojis(
config: &Config,
discourse_name: &str,
format: ListFormat,
verbose: bool,
inline: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let mut emojis = client.list_custom_emojis()?;
emojis.sort_by(|a, b| a.name.cmp(&b.name));
match format {
ListFormat::Text => {
if emojis.is_empty() && !verbose {
println!("No emojis found.");
return Ok(());
}
if inline {
if let Some(protocol) = detect_inline_protocol() {
print_inline_emojis(&emojis, protocol)?;
} else {
print_emojis_table(&emojis);
}
} else {
print_emojis_table(&emojis);
}
}
ListFormat::Json => {
if inline {
return Err(anyhow!("--inline is only supported with --format text"));
}
let raw = serde_json::to_string_pretty(&emojis)?;
println!("{}", raw);
}
ListFormat::Yaml => {
if inline {
return Err(anyhow!("--inline is only supported with --format text"));
}
let raw = serde_yaml::to_string(&emojis)?;
println!("{}", raw);
}
}
Ok(())
}
fn print_emojis_table(emojis: &[crate::api::CustomEmoji]) {
println!("name\turl");
for emoji in emojis {
println!("{}\t{}", emoji.name, emoji.url);
}
}
#[derive(Clone, Copy)]
enum InlineProtocol {
Iterm2,
Kitty,
}
fn detect_inline_protocol() -> Option<InlineProtocol> {
if let Ok(value) = std::env::var("DSC_EMOJI_INLINE_PROTOCOL") {
let value = value.trim().to_ascii_lowercase();
if value == "iterm2" || value == "iterm" {
return Some(InlineProtocol::Iterm2);
}
if value == "kitty" {
return Some(InlineProtocol::Kitty);
}
if value == "off" || value == "0" {
return None;
}
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
if term_program == "iTerm.app" || term_program == "WezTerm" {
return Some(InlineProtocol::Iterm2);
}
}
if std::env::var("KITTY_WINDOW_ID").is_ok()
|| std::env::var("KITTY_SESSION_ID").is_ok()
|| std::env::var("TERM")
.map(|t| t.contains("kitty"))
.unwrap_or(false)
{
return Some(InlineProtocol::Kitty);
}
None
}
fn print_inline_emojis(emojis: &[crate::api::CustomEmoji], protocol: InlineProtocol) -> Result<()> {
let client = reqwest::blocking::Client::new();
for emoji in emojis {
let image = client.get(&emoji.url).send();
let image = match image {
Ok(response) if response.status().is_success() => response.bytes(),
_ => {
println!("{}\t{}", emoji.name, emoji.url);
continue;
}
};
let image = match image {
Ok(bytes) => bytes,
Err(_) => {
println!("{}\t{}", emoji.name, emoji.url);
continue;
}
};
let encoded = base64::engine::general_purpose::STANDARD.encode(&image);
match protocol {
InlineProtocol::Iterm2 => {
let sequence = format!(
"\u{1b}]1337;File=inline=1;width=1;height=1;preserveAspectRatio=1:{}\u{7}",
encoded
);
println!("{} {}", emoji.name, sequence);
}
InlineProtocol::Kitty => {
let sequence = format!("\u{1b}_Gf=100,t=d;{}\u{1b}\\", encoded);
println!("{} {}", emoji.name, sequence);
}
}
}
Ok(())
}
fn emoji_name_from_path(path: &Path) -> Result<String> {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("emoji path missing filename: {}", path.display()))?;
let slug = slugify(stem);
let name = slug.replace('-', "_");
if name.is_empty() {
return Err(anyhow!("emoji name is empty for {}", path.display()));
}
Ok(name)
}
fn is_emoji_file(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "svg"
)
}
fn existing_emoji_names(client: &DiscourseClient) -> Result<HashSet<String>> {
let emojis = client.list_custom_emojis()?;
Ok(emojis
.into_iter()
.map(|emoji| emoji_key(&emoji.name))
.collect())
}
fn emoji_key(name: &str) -> String {
name.trim().to_ascii_lowercase()
}
fn is_duplicate_emoji_error(err: &anyhow::Error) -> bool {
let msg = err.to_string().to_ascii_lowercase();
msg.contains("already been taken")
|| msg.contains("already exists")
|| msg.contains("emoji already exists")
}
#[cfg(test)]
mod tests {
use super::is_duplicate_emoji_error;
use anyhow::anyhow;
#[test]
fn duplicate_error_is_detected() {
let err = anyhow!(
"emoji upload failed with 422: {{\"errors\":[\"Name has already been taken\"]}}"
);
assert!(is_duplicate_emoji_error(&err));
}
#[test]
fn non_duplicate_error_is_not_detected() {
let err = anyhow!("emoji upload failed with 413: payload too large");
assert!(!is_duplicate_emoji_error(&err));
}
}