// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr // Copyright (C) 2025 Awiteb // // 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 . 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(tag: &TagStandard) -> Option { tag_value(tag).parse().ok() } /// Gets all values from the tag. If any value fails to parse, returns an empty /// vector. fn tag_values(tag: &TagStandard) -> Vec where T: FromStr + fmt::Debug, ::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::>() .unwrap_or_default() } /// Convert [`Event`] to [`GitRepositoryAnnouncement`] pub fn event_into_repo(event: Event, repo_id: impl Into) -> 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(iter: I) -> Vec where T: std::cmp::Ord, I: Iterator, { let mut vector: Vec = 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 { Nip19Event::new(event_id) .relays( dedup(relays.iter().cloned()) .into_iter() .take(3) .collect::>(), ) .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, pubk: PublicKey, relays: &[RelayUrl], ) -> N34Result { 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 { 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 { 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 { 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>, quoted_content: Option>, file_suffix: &str, ) -> N34Result { 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 { 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>, address_file_path: &Path, ) -> N34Result> { 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(), ], ) }