feat(cli): add emoji export and import subcommands (#882)

This commit is contained in:
Will Pfleger
2026-06-05 15:33:31 -04:00
committed by GitHub
parent b84f8e6a01
commit 7129cd6f23
2 changed files with 195 additions and 2 deletions
+161
View File
@@ -1,3 +1,5 @@
use std::io::Read;
use crate::client::{normalize_write_response, SproutClient};
use crate::error::CliError;
use sprout_sdk::CustomEmoji;
@@ -139,6 +141,159 @@ async fn cmd_rm(client: &SproutClient, shortcode: &str) -> Result<(), CliError>
publish_own_set(client, &emojis).await
}
/// 10 MiB — a safety rail against runaway producers. An emoji manifest will
/// never approach this size in practice.
const STDIN_MAX_BYTES: u64 = 10_000_000;
/// Read from a file path or stdin. Returns `CliError::Usage` on empty stdin,
/// `CliError::Other` on I/O failure.
fn read_source(file: Option<&str>) -> Result<String, CliError> {
match file {
Some(path) => std::fs::read_to_string(path)
.map_err(|e| CliError::Other(format!("failed to read file '{path}': {e}"))),
None => {
let mut buf = String::new();
std::io::stdin()
.take(STDIN_MAX_BYTES)
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
if buf.is_empty() {
return Err(CliError::Usage(
"no input: provide --file or pipe JSON to stdin".into(),
));
}
Ok(buf)
}
}
}
/// Write to a file path or stdout.
fn write_output(output: &str, file: Option<&str>) -> Result<(), CliError> {
match file {
Some(path) => std::fs::write(path, output)
.map_err(|e| CliError::Other(format!("failed to write file '{path}': {e}"))),
None => {
println!("{output}");
Ok(())
}
}
}
/// Export custom emojis to stdout or a file.
async fn cmd_export(
client: &SproutClient,
file: Option<&str>,
scope: &crate::EmojiScope,
) -> Result<(), CliError> {
let entries: Vec<EmojiEntry> = match scope {
crate::EmojiScope::Own => {
let mut entries: Vec<EmojiEntry> = fetch_own_emoji(client)
.await?
.into_iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode,
url: e.url,
})
.collect();
// Sort to match union_custom_emoji output order so repeated
// export | import --replace cycles are stable.
entries.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
entries
}
crate::EmojiScope::Workspace => {
let filter = serde_json::json!({
"kinds": [sprout_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
union_custom_emoji(&events)
}
};
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
write_output(&output, file)
}
/// Import custom emojis from stdin or a file into the caller's own set.
async fn cmd_import(
client: &SproutClient,
file: Option<&str>,
replace: bool,
dry_run: bool,
) -> Result<(), CliError> {
// 1. Read raw JSON
let raw = read_source(file)?;
// 2. Parse and extract ["emojis"] array
let parsed: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| CliError::Usage(format!("invalid JSON: {e}")))?;
let arr = parsed
.get("emojis")
.and_then(|v| v.as_array())
.ok_or_else(|| {
CliError::Usage("input must be a JSON object with an \"emojis\" array".into())
})?;
// 3–4. Parse each element and normalize shortcodes
let mut import_entries: Vec<CustomEmoji> = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
let shortcode = item
.get("shortcode")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"shortcode\" field")))?;
let url = item
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"url\" field")))?;
let normalized = sprout_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Usage(format!("emojis[{i}]: invalid shortcode: {e}")))?;
import_entries.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
}
// 5. Deduplicate within the import batch (first occurrence wins)
let mut seen = std::collections::HashSet::new();
import_entries.retain(|e| seen.insert(e.shortcode.clone()));
// 6. Build final set
let final_set: Vec<CustomEmoji> = if replace {
import_entries
} else {
let mut existing = fetch_own_emoji(client).await?;
let existing_shortcodes: std::collections::HashSet<String> =
existing.iter().map(|e| e.shortcode.clone()).collect();
for entry in import_entries {
if !existing_shortcodes.contains(&entry.shortcode) {
existing.push(entry);
}
}
existing
};
// 7. Dry-run: print final set to stdout, warn to stderr
if dry_run {
let entries: Vec<EmojiEntry> = final_set
.iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode.clone(),
url: e.url.clone(),
})
.collect();
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
println!("{output}");
eprintln!("(dry run — not published)");
return Ok(());
}
// 8. Publish
publish_own_set(client, &final_set).await
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
@@ -149,6 +304,12 @@ pub async fn dispatch(cmd: crate::EmojiCmd, client: &SproutClient) -> Result<(),
EmojiCmd::List => cmd_list(client).await,
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
EmojiCmd::Export { file, scope } => cmd_export(client, file.as_deref(), &scope).await,
EmojiCmd::Import {
file,
replace,
dry_run,
} => cmd_import(client, file.as_deref(), replace, dry_run).await,
}
}
+34 -2
View File
@@ -139,6 +139,14 @@ pub enum PresenceStatus {
Offline,
}
#[derive(Clone, clap::ValueEnum)]
pub enum EmojiScope {
#[value(name = "own")]
Own,
#[value(name = "workspace")]
Workspace,
}
impl std::fmt::Display for PresenceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -599,6 +607,27 @@ pub enum EmojiCmd {
#[arg(long)]
shortcode: String,
},
/// Export custom emojis to stdout or a file
Export {
/// Write JSON to this file path instead of stdout
#[arg(long)]
file: Option<String>,
/// Export your own set (default) or the full workspace palette
#[arg(long, value_enum, default_value = "own")]
scope: EmojiScope,
},
/// Import custom emojis from stdin or a file into your own set
Import {
/// Read JSON from this file path instead of stdin
#[arg(long)]
file: Option<String>,
/// Replace your entire set instead of merging
#[arg(long, default_value_t = false)]
replace: bool,
/// Print what would be published without writing
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
// ---------------------------------------------------------------------------
@@ -1286,7 +1315,10 @@ mod tests {
);
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
assert_eq!(names(&cmd, "emoji"), vec!["list", "rm", "set"]);
assert_eq!(
names(&cmd, "emoji"),
vec!["export", "import", "list", "rm", "set"]
);
assert_eq!(
names(&cmd, "dms"),
vec!["add-member", "hide", "list", "open"]
@@ -1323,7 +1355,7 @@ mod tests {
("canvas", 2),
("channels", 16),
("dms", 4),
("emoji", 3),
("emoji", 5),
("feed", 1),
("messages", 8),
("pack", 2),