Files
n34/src/nostr_utils/utils.rs
2025-06-04 12:45:36 +00:00

254 lines
7.7 KiB
Rust

// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{
fmt,
fs,
path::{Path, PathBuf},
str::FromStr,
sync::atomic::Ordering,
};
use nostr::{
event::{Event, EventId, Kind, Tag, TagKind, TagStandard},
filter::{Alphabet, SingleLetterTag},
key::PublicKey,
nips::{
nip01::Coordinate,
nip10::Marker,
nip19::{Nip19Coordinate, Nip19Event, ToBech32},
nip34::GitRepositoryAnnouncement,
nip65::{self, RelayMetadata},
},
types::RelayUrl,
};
use super::traits::TagsExt;
use crate::{
cli::{NOSTR_ADDRESS_FILE, parsers},
error::{N34Error, N34Result},
};
/// Returns the value of the given tag
fn tag_value(tag: &TagStandard) -> String {
tag.clone().to_vec().remove(1)
}
/// Parses the tag value into type `T` if possible.
fn parse_value<T: FromStr>(tag: &TagStandard) -> Option<T> {
tag_value(tag).parse().ok()
}
/// Gets all values from the tag. If any value fails to parse, returns an empty
/// vector.
fn tag_values<T>(tag: &TagStandard) -> Vec<T>
where
T: FromStr + fmt::Debug,
<T as FromStr>::Err: fmt::Debug,
{
tag.clone()
.to_vec()
.into_iter()
.skip(1)
.map(|t| {
let result = T::from_str(t.as_str());
tracing::trace!("Parsing `{t}` result: `{result:?}`");
result
})
.collect::<Result<_, _>>()
.unwrap_or_default()
}
/// Convert [`Event`] to [`GitRepositoryAnnouncement`]
pub fn event_into_repo(event: Event, repo_id: impl Into<String>) -> GitRepositoryAnnouncement {
let tags = &event.tags;
GitRepositoryAnnouncement {
id: repo_id.into(),
name: tags.map_tag(TagKind::Name, tag_value),
description: tags.map_tag(TagKind::Description, tag_value),
euc: tags
.map_marker(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)),
"euc",
parse_value,
)
.flatten(),
web: tags.dmap_tag(TagKind::Web, tag_values),
clone: tags.dmap_tag(TagKind::Clone, tag_values),
relays: tags.dmap_tag(TagKind::Relays, tag_values),
maintainers: tags.dmap_tag(TagKind::Maintainers, tag_values),
}
}
/// Returns a new string with leading and trailing whitespace removed.
pub fn str_trim(s: String) -> String {
s.trim().to_owned()
}
/// Returns a vector with duplicate elements removed.
pub fn dedup<I, T>(iter: I) -> Vec<T>
where
T: std::cmp::Ord,
I: Iterator<Item = T>,
{
let mut vector: Vec<T> = iter.collect();
vector.sort_unstable();
vector.dedup();
vector
}
/// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay
/// URLs.
pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> {
Nip19Event::new(event_id)
.relays(
dedup(relays.iter().cloned())
.into_iter()
.take(3)
.collect::<Vec<_>>(),
)
.to_bech32()
.map_err(N34Error::from)
}
/// Creates a NIP-19 naddr string for a git repository announcement and up to 3
/// unique relay URLs.
pub fn repo_naddr(
repo_id: impl Into<String>,
pubk: PublicKey,
relays: &[RelayUrl],
) -> N34Result<String> {
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, pubk).identifier(repo_id),
dedup(relays.iter().cloned()).iter().take(3),
)
.expect("Valid relays")
.to_bech32()
.map_err(N34Error::from)
}
/// Extracts write relay URLs from an event if present, otherwise returns an
/// empty vector.
pub fn add_write_relays(event: Option<&Event>) -> Vec<RelayUrl> {
let mut vector = Vec::new();
if let Some(event) = event {
vector.extend(
nip65::extract_owned_relay_list(event.clone())
.filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Write).then_some(r)),
);
}
vector
}
/// Extracts read relay URLs from an event if present, otherwise returns an
/// empty vector.
pub fn add_read_relays(event: Option<&Event>) -> Vec<RelayUrl> {
let mut vector = Vec::new();
if let Some(event) = event {
vector.extend(
nip65::extract_owned_relay_list(event.clone())
.filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Read).then_some(r)),
);
}
vector
}
/// Opens the user's default editor ($EDITOR) to edit a temporary file with
/// given suffix, then reads and returns the file contents. The temporary file
/// is automatically deleted.
pub fn read_editor(file_pre_content: Option<&str>, file_suffix: &str) -> N34Result<String> {
let Ok(editor) = std::env::var("EDITOR") else {
return Err(N34Error::EditorNotFound);
};
let temp_path = tempfile::NamedTempFile::with_suffix(file_suffix)?.into_temp_path();
if let Some(pre_content) = file_pre_content {
fs::write(&temp_path, pre_content)?;
}
// Disable the logs to not show up in a terminal text editor
crate::EDITOR_OPEN.store(true, Ordering::Relaxed);
let exit_status = std::process::Command::new(editor)
.arg(temp_path.to_str().expect("The path is valid utf8"))
.spawn()?
.wait()?;
crate::EDITOR_OPEN.store(false, Ordering::Relaxed);
if !exit_status.success() {
if let Some(code) = exit_status.code() {
tracing::warn!("The editor exit with `{code}` status")
}
}
let content = fs::read_to_string(&temp_path)
.map_err(N34Error::from)?
.trim()
.to_owned();
if content.is_empty() {
return Err(N34Error::EmptyEditorFile);
}
Ok(content)
}
/// Returns the given content if it's `Option::Some` or call [`read_editor`]
pub fn get_content(
content: Option<impl AsRef<str>>,
quoted_content: Option<impl AsRef<str>>,
file_suffix: &str,
) -> N34Result<String> {
if let Some(content) = content {
return Ok(content.as_ref().trim().to_owned());
}
read_editor(
quoted_content.map(|s| s.as_ref().to_owned()).as_deref(),
file_suffix,
)
}
/// Path to the `nostr-address` file in current directory.
pub fn nostr_address_path() -> std::io::Result<PathBuf> {
std::env::current_dir().map(|p| p.join(NOSTR_ADDRESS_FILE))
}
/// If the given coordinate is Some, return it. Otherwise, try to read and parse
/// the first non-comment line from the `nostr-address` file.
pub fn naddrs_or_file(
naddrs: Option<Vec<Nip19Coordinate>>,
address_file_path: &Path,
) -> N34Result<Vec<Nip19Coordinate>> {
if let Some(naddrs) = naddrs {
return Ok(naddrs);
}
parsers::parse_nostr_address_file(address_file_path)
}
/// Generate a reply tag for an event with the given ID, relay URL (if any), and
/// marker.
pub fn event_reply_tag(reply_to: &EventId, relay: Option<&RelayUrl>, marker: Marker) -> Tag {
Tag::custom(
TagKind::single_letter(Alphabet::E, false),
[
reply_to.to_hex(),
relay.map(|r| r.to_string()).unwrap_or_default(),
marker.to_string(),
],
)
}