Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0068be53 | ||
|
|
4c6578ccb1 | ||
|
|
d6d3f8a52f | ||
|
|
84bfafa2e9 | ||
|
|
24e8522268 | ||
|
|
f670859b92 | ||
|
|
bca86f115f | ||
|
|
a9b9a7a02a | ||
|
|
1abb8e3f36 | ||
|
|
15d834dc0f | ||
|
|
ea862dac5f | ||
|
|
55a4868813 | ||
|
|
60978f43b5 | ||
|
|
37cf601c01 | ||
|
|
87da5035da | ||
|
|
ddea5028ee | ||
|
|
de68d61c3f | ||
|
|
f7e837e3ab | ||
|
|
02070c2868 | ||
|
|
998ef8f4b1 | ||
|
|
6fdf0dbcf5 | ||
|
|
15c0eaa5dc | ||
|
|
226909ef16 | ||
|
|
8ca88804dd | ||
|
|
55f9b9194e | ||
|
|
a26116702d | ||
|
|
35d376ca6c | ||
|
|
57b48c7368 | ||
|
|
b29937a04e | ||
|
|
06374fe1fc | ||
|
|
2d5c0bf549 | ||
|
|
0a5b4656c3 | ||
|
|
cad2c7faa3 | ||
|
|
19cfecb9ea | ||
|
|
89d0710ce5 | ||
|
|
c1afa9a93d | ||
|
|
92165de6fd | ||
|
|
c5085b3af7 | ||
|
|
ecee54c9a3 | ||
|
|
55aa48c1fe | ||
|
|
0cc656b780 | ||
|
|
8bff000b33 | ||
|
|
5c201b0cec | ||
|
|
c608f2120b | ||
|
|
60ad1fe5bb | ||
|
|
b3b7ce7a4b | ||
|
|
a137a3f9de | ||
|
|
724e2707c6 | ||
|
|
9444fc1a8c | ||
|
|
a324ba2ff7 | ||
|
|
56316d36d3 | ||
|
|
074245733e | ||
|
|
661266c67b | ||
|
|
a9b088d8a5 | ||
|
|
1af731ef67 | ||
|
|
4298ca5d2d | ||
|
|
70ee4a59b2 | ||
|
|
bd086538fd | ||
|
|
54f1c7e0e6 | ||
|
|
f1378a2eb7 | ||
|
|
2627fe7980 | ||
|
|
42508fc8b9 | ||
|
|
c69fbca2a0 | ||
|
|
552136400d | ||
|
|
ac7c1e6216 | ||
|
|
5139f8ea11 | ||
|
|
6aac4b7a85 | ||
|
|
ce58f29022 | ||
|
|
d9fe376746 | ||
|
|
d9f8ce2418 | ||
|
|
577de41054 | ||
|
|
3857753b7d | ||
|
|
93c8743dc7 | ||
|
|
c0a5e47793 | ||
|
|
a56acf5752 | ||
|
|
9b4dfb9374 | ||
|
|
6fc59bfe59 | ||
|
|
b444aeba0a | ||
|
|
273058fe3f | ||
|
|
4dea4785b4 | ||
|
|
4314cebfbb | ||
|
|
8a553f4de8 | ||
|
|
57434ac39b | ||
|
|
99e46d7633 | ||
|
|
fb77a8ca14 | ||
|
|
d962732e52 | ||
|
|
654aee620e | ||
|
|
d37c6964f7 |
11
.taplo.toml
Normal file
11
.taplo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
exclude = ["target/**/*.toml", "Cargo.lock"]
|
||||
|
||||
[formatting]
|
||||
align_entries = true
|
||||
indent_tables = true
|
||||
reorder_keys = false
|
||||
|
||||
[[rule]]
|
||||
include = ["**/Cargo.toml"]
|
||||
keys = ["dependencies"]
|
||||
formatting.reorder_keys = true
|
||||
1
AUTHORS
Normal file
1
AUTHORS
Normal file
@@ -0,0 +1 @@
|
||||
Awiteb <a@4rs.nl> (nprofile1qqsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgpzfmhxue69uhkummnw3ezudrjwvhxumq3dg0ly)
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -5,11 +5,62 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.0] - 2025-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- Add `--force-id` flag to bypass case validation in `repo announce` - by Awiteb
|
||||
- Add `--address-file` flag to `repo announce` command - by Awiteb
|
||||
- Read the `nostr-address` file in `repo view` command - by Awiteb
|
||||
- Read the `nostr-address` file in `issue new` command - by Awiteb
|
||||
- Read the `nostr-address` file in `reply` command - by Awiteb
|
||||
- A `--quote-to` flag to quote the replied to content in the editor - by Awiteb
|
||||
- Enter repository as nip5 - by Awiteb
|
||||
- Make the relays list optional - by Awiteb
|
||||
- Events and naddrs can starts with `nostr:` - by Awiteb
|
||||
- Support relays and naddrs sets - by Awiteb
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Add `chrono@0.4.41` to the dependencies - by Awiteb
|
||||
- Enable `nip05` feature of `nostr` crate - by Awiteb
|
||||
- Add `serde@1.0.219`, `dirs@6.0.0` and `toml@0.8.22` - by Awiteb
|
||||
|
||||
### Fixed
|
||||
|
||||
- Create a valid naddr string - by Awiteb
|
||||
|
||||
### Refactor
|
||||
|
||||
- Support more than one naddr instead of one - by Awiteb
|
||||
|
||||
## [0.1.0] - 2025-05-21
|
||||
|
||||
### Added
|
||||
|
||||
- Setup the CLI and create `repo view` command - by Awiteb
|
||||
- Add `repo announce` command - by Awiteb
|
||||
- Support PoW - by Awiteb
|
||||
- Add `issue new` command - by Awiteb
|
||||
- New `reply` command - by Awiteb
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Add nedded dependencies - by Awiteb
|
||||
- Add `easy_ext@1.0.2` to the dependencies - by Awiteb
|
||||
- Add `convert_case@0.8.0` to the dependencies - by Awiteb
|
||||
- Add `tempfile@3.19.1` to the dependencies - by Awiteb
|
||||
- Add `futures@0.3.31` to the dependencies - by Awiteb
|
||||
- Bump `nostr` and `nostr_sdk` to `0.42.0` - by Awiteb
|
||||
|
||||
## [0.0.0] - 2025-05-01
|
||||
|
||||
### Added
|
||||
|
||||
- Feat: Initialize the project
|
||||
- Initialize the project - by Awiteb
|
||||
|
||||
[0.2.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.2.0
|
||||
[0.1.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.1.0
|
||||
[0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0
|
||||
|
||||
<!-- generated by git-cliff -->
|
||||
|
||||
104
CONTRIBUTING.md
Normal file
104
CONTRIBUTING.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Contributing to `n34`
|
||||
|
||||
For basic information about the `n34` project, please read the
|
||||
[README.md](README.md). The project is licensed under **GPL-3.0**, and by
|
||||
contributing, your work will also be licensed under the same terms.
|
||||
|
||||
Before submitting changes, please read the [Developer Certificate of Origin](DCO).
|
||||
All patches must include a `Signed-off-by: NAME <EMAIL>` line to acknowledge
|
||||
your agreement with the DCO.
|
||||
|
||||
Ensure your Git name and email are correctly configured. While you don’t need to
|
||||
use your real details, avoid leaving them as the default values. To verify your
|
||||
current settings, run:
|
||||
|
||||
```bash
|
||||
git config user.name
|
||||
git config user.email
|
||||
```
|
||||
|
||||
If they’re incorrect or unset, update them using:
|
||||
|
||||
```bash
|
||||
git config --global user.name "Your Name"
|
||||
git config --global user.email "your.email@example.com"
|
||||
```
|
||||
|
||||
We welcome all contributions, whether it be bug reports, fixes, feature
|
||||
submissions, feature requests, or improving documentation or testing. Enjoy
|
||||
collaborating!
|
||||
|
||||
## Git Repository
|
||||
|
||||
The repository is hosted at <https://git.4rs.nl/awiteb/n34.git>, with `master`
|
||||
as the active development branch.
|
||||
|
||||
## Nostr Repository Address
|
||||
|
||||
You can submit issues and patches via any
|
||||
Nostr-compatible client using the address:
|
||||
`naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueq
|
||||
yf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00
|
||||
p68ytnyv4mqwuj6xc`
|
||||
|
||||
## Contribution Workflow
|
||||
|
||||
Before submitting changes, open an issue to discuss your proposed contribution.
|
||||
Clearly indicate that you intend to work on it and wait for a maintainer's
|
||||
response. If the issue remains open, you may proceed with submitting your patch.
|
||||
|
||||
### Reporting Issues
|
||||
|
||||
When opening an issue, include:
|
||||
- Detailed steps to reproduce the problem
|
||||
- Relevant error messages or logs (use the `-vvvv` flag for verbose output)
|
||||
- Expected vs. actual behavior
|
||||
|
||||
Please label your issue appropriately (e.g., `bug`, `feature`, `question`) to
|
||||
help categorize it.
|
||||
|
||||
### Your Patch
|
||||
|
||||
Ensure your patch submission tool notifies the maintainers and sends the patch
|
||||
to their read relays, most tools handle this automatically.
|
||||
|
||||
#### Patch Guidelines
|
||||
|
||||
- Keep patches small: Focused changes are easier to review and merge.
|
||||
- Run `just ci` before submitting your patch.
|
||||
- Update the change log with your patch. Run `just changelog` or `git-cliff > CHANGELOG.md`
|
||||
- Add your name to the [AUTHORS](AUTHORS) file if this is your first contribution. (alphabetical order)
|
||||
- Use [Conventional Commits]: Start the patch subject with one of these types:
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation updates
|
||||
- `refactor`: Code restructuring without behavioral changes
|
||||
- `deprecate`: Marking code as deprecated
|
||||
- `remove`: Removing deprecated code
|
||||
- `security`: Security-related changes
|
||||
- `perf`: Performance improvements
|
||||
- `test`: Test additions or corrections
|
||||
- For all other changes, use `chore`.
|
||||
- Add `!` to the subject if your patch contains a breacking change, e.g.
|
||||
`remove!: text` and `fix(reply)!: text`
|
||||
|
||||
#### Code Style
|
||||
|
||||
When writing code, make sure to folow this:
|
||||
- Using Rust's official formatting tool, `rustfmt`, to format your code.
|
||||
- Writing clear and concise code with meaningful variable and function names.
|
||||
- Adding comments to explain complex logic or algorithms.
|
||||
|
||||
#### Cover Letter Description
|
||||
|
||||
Your patch description should provide a clear and concise summary of the changes you
|
||||
have made. It should also include any relevant context or background information
|
||||
that will help the project maintainers understand the purpose of the changes.
|
||||
Make sure to reference the issue[^1] that your patch is addressing, and note any breaking
|
||||
changes that your patch introduces.
|
||||
|
||||
|
||||
|
||||
[^1]: When referencing, avoid URLs or `nevent` formats with relays. Instead, use only the note ID in `note1` bech32 format.
|
||||
|
||||
[Conventional Commits]: https://www.conventionalcommits.org/en/v1.0.0/
|
||||
2705
Cargo.lock
generated
2705
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
@@ -1,13 +1,32 @@
|
||||
[package]
|
||||
name = "n34"
|
||||
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Awiteb <a@4rs.nl>"]
|
||||
readme = "README.md"
|
||||
repository = "https://git.4rs.nl/awiteb/n34"
|
||||
keywords = ["nostr", "NIP-34", "CLI"]
|
||||
categories = ["command-line-utilities"]
|
||||
name = "n34"
|
||||
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Awiteb <a@4rs.nl>"]
|
||||
readme = "README.md"
|
||||
repository = "https://git.4rs.nl/awiteb/n34.git"
|
||||
keywords = ["nostr", "NIP-34", "CLI"]
|
||||
categories = ["command-line-utilities"]
|
||||
rust-version = "1.85.1"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.41"
|
||||
clap = { version = "4.5.37", features = ["derive"] }
|
||||
clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [
|
||||
"tracing",
|
||||
] }
|
||||
convert_case = "0.8.0"
|
||||
dirs = "6.0.0"
|
||||
easy-ext = "1.0.2"
|
||||
futures = "0.3.31"
|
||||
nostr = { version = "0.42.0", features = ["parser", "nip05"] }
|
||||
nostr-sdk = "0.42.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
tempfile = "3.19.1"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.8.22"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
|
||||
34
DCO
Normal file
34
DCO
Normal file
@@ -0,0 +1,34 @@
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
70
README.md
70
README.md
@@ -1,9 +1,79 @@
|
||||
!!! warning "Work In Progress"
|
||||
This project is still work in progress and not completed yet.
|
||||
|
||||
# n34
|
||||
|
||||
A CLI to interact with NIP-34 and other stuff related to codes in nostr
|
||||
|
||||
## About
|
||||
|
||||
`n34` is a command-line interface (CLI) tool for sending and receiving Git
|
||||
issues, patches, and comments over the Nostr protocol. It supports creating,
|
||||
replying to, and managing issues and patches, making Git collaboration
|
||||
decentralized and censorship-resistant.
|
||||
|
||||
The primary goal of `n34` is to implement [NIP-34] (`git` stuff), but its
|
||||
flexible design allows for additional use cases beyond Git workflows. For more
|
||||
details, see the following section.
|
||||
|
||||
## Features
|
||||
|
||||
- [X] Repository announcements
|
||||
- [ ] Repository state announcements
|
||||
- [ ] Patches
|
||||
- [X] Issues
|
||||
- [X] Replies
|
||||
- [X] Status
|
||||
- [X] Gossip Model ([NIP-65])
|
||||
- [X] Proof of Work ([NIP-13])
|
||||
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])
|
||||
- [ ] Code Snippets ([NIP-C0])
|
||||
- [ ] In device relays and repos bookmark
|
||||
|
||||
|
||||
## Why Nostr?
|
||||
|
||||
Nostr is fundamentally different from traditional platforms because it’s not
|
||||
an application or service, it’s a decentralized protocol. This means any tool or
|
||||
app can integrate with it, enabling open, permissionless collaboration without
|
||||
relying on centralized gatekeepers. Unlike proprietary systems, Nostr doesn’t
|
||||
require emails, passwords, or accounts. You interact directly through relays,
|
||||
whether you self-host your own or use public ones, ensuring no single point of
|
||||
failure or control.
|
||||
|
||||
What makes Nostr uniquely resilient is its design, the protocol itself is just
|
||||
a set of rules, not a company or product that can disappear. Your Git issues,
|
||||
patches, and comments persist as long as relays choose to store them, immune to
|
||||
the whims of corporate shutdowns or policy changes. Nostr is infrastructure in
|
||||
its purest form, an idea that outlives any temporary implementation. `n34` taps
|
||||
into a future-proof foundation for decentralized collaboration.
|
||||
|
||||
### More about Nostr
|
||||
|
||||
- <https://nostr.com>
|
||||
- <https://nostr.org>
|
||||
- <https://nostr.how/en/what-is-nostr>
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to `n34` are welcome! You can help by opening issues (such as bug
|
||||
reports or feature requests) or submitting patches. **All contributions must be
|
||||
submitted through Nostr**. For more details on the process, please refer to the
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) file. Your support is greatly appreciated!
|
||||
|
||||
## Contributions & Changes
|
||||
|
||||
You can find the changelog at [CHANGELOG.md](CHANGELOG.md) and the list of
|
||||
contributors at [AUTHORS](AUTHORS) file.
|
||||
|
||||
## License
|
||||
|
||||
n34 is licensed under the GPL-3.0 License. This means that you are free to use,
|
||||
modify, and distribute the software under the terms of this license. Please
|
||||
refer to the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md
|
||||
[NIP-65]: https://github.com/nostr-protocol/nips/blob/master/65.md
|
||||
[NIP-13]: https://github.com/nostr-protocol/nips/blob/master/13.md
|
||||
[NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
|
||||
[NIP-C0]: https://github.com/nostr-protocol/nips/blob/master/C0.md
|
||||
|
||||
33
cliff.toml
33
cliff.toml
@@ -17,12 +17,13 @@ body = """
|
||||
{% if version -%}
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else -%}
|
||||
## [Unreleased]
|
||||
## Unreleased
|
||||
{% endif -%}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
|
||||
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | trim | upper_first }} - \
|
||||
by {{ commit.author.name}}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
@@ -31,12 +32,7 @@ body = """
|
||||
footer = """
|
||||
{% for release in releases -%}
|
||||
{% if release.version -%}
|
||||
{% if release.previous.version -%}
|
||||
[{{ release.version | trim_start_matches(pat="v") }}]: \
|
||||
https://git.4rs.nl/awiteb/n34/compare/{{ release.previous.version }}..{{ release.version }}
|
||||
{% endif -%}
|
||||
{% else -%}
|
||||
[unreleased]: https://git.4rs.nl/awiteb/n34/compare/{{ release.previous.version }}..HEAD
|
||||
[{{ release.version | trim_start_matches(pat="v") }}]: https://git.4rs.nl/awiteb/n34.git/tag/?h={{ release.version }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
<!-- generated by git-cliff -->
|
||||
@@ -44,6 +40,11 @@ footer = """
|
||||
# Remove leading and trailing whitespaces from the changelog's body.
|
||||
trim = true
|
||||
|
||||
postprocessors = [
|
||||
# Replace the placeholder `<REPO>` with a URL.
|
||||
{ pattern = '\$REPO', replace = "https://git.4rs.nl/awiteb/n34.git" },
|
||||
]
|
||||
|
||||
[git]
|
||||
# Parse commits according to the conventional commits specification.
|
||||
# See https://www.conventionalcommits.org
|
||||
@@ -54,14 +55,14 @@ filter_unconventional = false
|
||||
# Assigns commits to groups.
|
||||
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||
commit_parsers = [
|
||||
{ message = "^.*!:.*$", group = "Breaking Change" },
|
||||
{ message = "^feat", group = "Added" },
|
||||
{ message = "^deprecate", group = "Deprecated" },
|
||||
{ message = "^remove", group = "Removed" },
|
||||
{ message = "^deps", group = "Dependencies" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^fix", group = "Fixed" },
|
||||
{ message = "^docs", group = "Documentation" },
|
||||
{ message = '^.*!:.*$', group = "Breaking Change" },
|
||||
{ message = '^feat', group = "Added" },
|
||||
{ message = '^deprecate', group = "Deprecated" },
|
||||
{ message = '^remove', group = "Removed" },
|
||||
{ message = '^deps', group = "Dependencies" },
|
||||
{ message = '^refactor', group = "Refactor" },
|
||||
{ message = '^fix', group = "Fixed" },
|
||||
{ message = '^docs', group = "Documentation" },
|
||||
]
|
||||
# Prevent commits that are breaking from being excluded by commit parsers.
|
||||
filter_commits = false
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
packages = [
|
||||
just
|
||||
cargo-msrv
|
||||
git-cliff
|
||||
just
|
||||
taplo
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
||||
51
justfile
51
justfile
@@ -8,42 +8,69 @@
|
||||
set quiet
|
||||
set unstable
|
||||
set shell := ["/usr/bin/env", "bash", "-c"]
|
||||
set script-interpreter := ["/usr/bin/env", "bash", "-c"]
|
||||
set script-interpreter := ["/usr/bin/env", "bash"]
|
||||
|
||||
JUST_EXECUTABLE := "just -u -f " + justfile()
|
||||
header := "Available tasks:\n"
|
||||
# Get the MSRV from the Cargo.toml
|
||||
msrv := `cat Cargo.toml | grep "rust-version" | sed 's/.*"\(.*\)".*/\1/'`
|
||||
tag_change_body := '''{% for group, commits in commits | group_by(attribute="group") %}
|
||||
|
||||
{{ group | upper_first }}
|
||||
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | upper_first | trim }} - (by {{ commit.author.name}}){% endfor %}{% endfor %}'''
|
||||
|
||||
export TZ := "UTC"
|
||||
|
||||
_default:
|
||||
@{{JUST_EXECUTABLE}} --list-heading "{{header}}" --list
|
||||
|
||||
# Run the CI
|
||||
ci: && msrv
|
||||
ci: && msrv _done_ci
|
||||
echo "🔨 Building n34..."
|
||||
cargo build -q
|
||||
cargo fmt -- --check
|
||||
cargo clippy -- -D warnings
|
||||
echo "🔍 Checking code formatting..."
|
||||
cargo fmt -q -- --check
|
||||
taplo fmt --check --config ./.taplo.toml
|
||||
echo "🧹 Running linter checks..."
|
||||
cargo clippy -q -- -D warnings
|
||||
taplo check --config ./.taplo.toml
|
||||
|
||||
# Check that the current MSRV is correct
|
||||
msrv:
|
||||
rustup toolchain install {{msrv}}
|
||||
echo "Checking MSRV ({{msrv}})"
|
||||
cargo +{{msrv}} check -q
|
||||
echo "MSRV is correct"
|
||||
echo "🔧 Verifying MSRV ({{msrv}})..."
|
||||
rustup -q run --install {{msrv}} cargo check -q
|
||||
echo "✅ MSRV verification passed"
|
||||
|
||||
_done_ci:
|
||||
echo "🎉 CI pipeline completed successfully"
|
||||
|
||||
# Update the changelog
|
||||
[script]
|
||||
change-log:
|
||||
changelog:
|
||||
OLD_HASH=$(sha256sum CHANGELOG.md | head -c 64)
|
||||
git-cliff > CHANGELOG.md
|
||||
NEW_HASH=$(sha256sum CHANGELOG.md | head -c 64)
|
||||
if [[ $OLD_HASH != $NEW_HASH ]]; then
|
||||
TZ=UTC git add CHANGELOG.md
|
||||
TZ=UTC git commit -m 'chore(changelog): Update the changelog'
|
||||
git add CHANGELOG.md
|
||||
git commit -m 'chore(changelog): Update the changelog'
|
||||
echo 'The changes have been added to the changelog file and committed'
|
||||
else
|
||||
echo 'No changes have been added to the changelog'
|
||||
fi
|
||||
|
||||
alias cl := change-log
|
||||
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
|
||||
[script]
|
||||
release version:
|
||||
set -e
|
||||
TAG_MSG="Version {{ version }}\n\n$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')\n"
|
||||
sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
|
||||
taplo fmt --config ./.taplo.toml ./Cargo.toml
|
||||
{{ JUST_EXECUTABLE }} ci
|
||||
git-cliff -t "v{{ version }}" > CHANGELOG.md
|
||||
git add .
|
||||
git commit -m 'chore: Bump the version to `v{{ version }}`'
|
||||
git tag -s -m "$TAG_MSG" "v{{ version }}"
|
||||
git push origin master --tags
|
||||
cargo publish
|
||||
|
||||
15
nostr-address
Normal file
15
nostr-address
Normal file
@@ -0,0 +1,15 @@
|
||||
# This file contains NIP-19 `naddr` entities for repositories that accept this
|
||||
# project's issues and patches.
|
||||
#
|
||||
# The file acts as a **read-only reference** for retrieving repository relays
|
||||
# when embedded in an `naddr` and mentions those repositories when opening
|
||||
# patches or issues. Modifications here will not affect in the relays, as the
|
||||
# file is **explicitly untracked**. Its goal is to simplify contributions by
|
||||
# removing the need for manual address entry.
|
||||
#
|
||||
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
|
||||
# to assist client-side discovery.
|
||||
#
|
||||
# Empty lines are ignored. Lines starting with "#" are treated as comments.
|
||||
|
||||
naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueqyf8wumn8ghj7mn0wd68yt35wfejumnvqyt8wumn8ghj7un9d3shjtnswf5k6ctv9ehx2aqpp4mhxue69uhkummn9ekx7mqppamhxue69uhkummnw3ezumt0d5q3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qg5waehxw309ahx7um5wghx77r5wghxgetkxpx8xj
|
||||
38
rustfmt.toml
38
rustfmt.toml
@@ -1,22 +1,22 @@
|
||||
unstable_features = true
|
||||
style_edition = "2024"
|
||||
style_edition = "2024"
|
||||
|
||||
blank_lines_upper_bound = 2
|
||||
combine_control_expr = false
|
||||
wrap_comments = true
|
||||
condense_wildcard_suffixes = true
|
||||
edition = "2024"
|
||||
enum_discrim_align_threshold = 20
|
||||
force_multiline_blocks = true
|
||||
format_code_in_doc_comments = true
|
||||
format_generated_files = false
|
||||
format_macro_matchers = true
|
||||
format_strings = true
|
||||
imports_layout = "HorizontalVertical"
|
||||
newline_style = "Unix"
|
||||
normalize_comments = true
|
||||
reorder_impl_items = true
|
||||
group_imports = "StdExternalCrate"
|
||||
blank_lines_upper_bound = 2
|
||||
combine_control_expr = false
|
||||
wrap_comments = true
|
||||
condense_wildcard_suffixes = true
|
||||
edition = "2024"
|
||||
enum_discrim_align_threshold = 20
|
||||
force_multiline_blocks = true
|
||||
format_code_in_doc_comments = true
|
||||
format_generated_files = false
|
||||
format_macro_matchers = true
|
||||
format_strings = true
|
||||
imports_layout = "HorizontalVertical"
|
||||
newline_style = "Unix"
|
||||
normalize_comments = true
|
||||
reorder_impl_items = true
|
||||
group_imports = "StdExternalCrate"
|
||||
single_line_let_else_max_width = 0
|
||||
struct_field_align_threshold = 20
|
||||
use_try_shorthand = true
|
||||
struct_field_align_threshold = 20
|
||||
use_try_shorthand = true
|
||||
|
||||
38
src/cli/commands/issue/mod.rs
Normal file
38
src/cli/commands/issue/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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>.
|
||||
|
||||
/// `issue new` subcommand
|
||||
mod new;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use self::new::NewArgs;
|
||||
use super::{CliOptions, CommandRunner};
|
||||
use crate::error::N34Result;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum IssueSubcommands {
|
||||
/// Create a new repository issue
|
||||
New(NewArgs),
|
||||
}
|
||||
|
||||
impl CommandRunner for IssueSubcommands {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
match self {
|
||||
Self::New(args) => args.run(options).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/cli/commands/issue/new.rs
Normal file
164
src/cli/commands/issue/new.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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 clap::{ArgGroup, Args};
|
||||
use futures::future;
|
||||
use nostr::event::{EventBuilder, Tag};
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
CliConfig,
|
||||
CliOptions,
|
||||
CommandRunner,
|
||||
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
|
||||
},
|
||||
error::N34Result,
|
||||
nostr_utils::{
|
||||
NostrClient,
|
||||
traits::{NaddrsUtils, NewGitRepositoryAnnouncement, ReposUtils},
|
||||
utils,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/// Arguments for the `issue new` command
|
||||
#[derive(Args, Debug)]
|
||||
#[clap(
|
||||
group(
|
||||
ArgGroup::new("issue-content")
|
||||
.args(["content", "editor"])
|
||||
.required(true)
|
||||
),
|
||||
group(
|
||||
ArgGroup::new("issue-subject")
|
||||
.args(["editor", "subject"])
|
||||
)
|
||||
)]
|
||||
pub struct NewArgs {
|
||||
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
|
||||
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
|
||||
///
|
||||
/// If omitted, looks for a `nostr-address` file.
|
||||
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
|
||||
naddrs: Option<Vec<NaddrOrSet>>,
|
||||
/// Markdown content for the issue. Cannot be used together with the
|
||||
/// `--editor` flag.
|
||||
#[arg(short, long)]
|
||||
content: Option<String>,
|
||||
/// Opens the user's default editor to write issue content. The first line
|
||||
/// will be used as the issue subject.
|
||||
#[arg(short, long)]
|
||||
editor: bool,
|
||||
/// The issue subject. Cannot be used together with the `--editor` flag.
|
||||
#[arg(long)]
|
||||
subject: Option<String>,
|
||||
/// Labels for the issue. Can be specified as arguments (-l bug) or hashtags
|
||||
/// in content (#bug).
|
||||
#[arg(short, long)]
|
||||
label: Vec<String>,
|
||||
}
|
||||
|
||||
impl NewArgs {
|
||||
/// Returns the subject and the content of the issue. (subject, content)
|
||||
pub fn issue_content(&self) -> N34Result<(Option<String>, String)> {
|
||||
if let Some(content) = self.content.as_ref() {
|
||||
if let Some(subject) = self.subject.as_ref() {
|
||||
return Ok((Some(subject.trim().to_owned()), content.trim().to_owned()));
|
||||
}
|
||||
return Ok((None, content.trim().to_owned()));
|
||||
}
|
||||
// If the `self.content` is `None` then the `self.editor` is `true`
|
||||
let file_content = utils::read_editor(None, ".md")?;
|
||||
if file_content.contains('\n') {
|
||||
Ok(file_content
|
||||
.split_once('\n')
|
||||
.map(|(s, c)| (Some(s.trim().to_owned()), c.trim().to_owned()))
|
||||
.expect("There is a new line"))
|
||||
} else {
|
||||
tracing::info!("File content contains only issue body without a subject line");
|
||||
Ok((None, file_content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRunner for NewArgs {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
let config = CliConfig::load_toml(&options.config_path)?;
|
||||
let naddrs = utils::naddrs_or_file(
|
||||
self.naddrs.flat_naddrs(&config.sets)?,
|
||||
&utils::nostr_address_path()?,
|
||||
)?;
|
||||
let relays = options.relays.clone().flat_relays(&config.sets)?;
|
||||
let client = NostrClient::init(&options, &relays).await;
|
||||
let user_pubk = options.pubkey().await?;
|
||||
let mut naddrs_iter = naddrs.clone().into_iter();
|
||||
|
||||
client.add_relays(&naddrs.extract_relays()).await;
|
||||
|
||||
let relays_list = client.user_relays_list(user_pubk).await?;
|
||||
|
||||
let (subject, content) = self.issue_content()?;
|
||||
let content_details = client.parse_content(&content).await;
|
||||
|
||||
let event = EventBuilder::new_git_issue(
|
||||
naddrs_iter
|
||||
.next()
|
||||
.expect("There is at least one address")
|
||||
.coordinate
|
||||
.clone(),
|
||||
content,
|
||||
subject,
|
||||
self.label,
|
||||
)?
|
||||
.dedup_tags()
|
||||
.pow(options.pow)
|
||||
.tags(content_details.clone().into_tags())
|
||||
// p-tag the reset of the reposotoies owners
|
||||
.tags(naddrs_iter.clone().map(|n| Tag::public_key(n.public_key)))
|
||||
// a-tag the reset of the reposotoies
|
||||
.tags(naddrs_iter.clone().map(|n| Tag::coordinate(n.coordinate, n.relays.first().cloned())))
|
||||
.build(user_pubk);
|
||||
let event_id = event.id.expect("There is an id");
|
||||
|
||||
let write_relays = [
|
||||
relays,
|
||||
utils::add_write_relays(relays_list.as_ref()),
|
||||
client
|
||||
.fetch_repos(&naddrs.into_coordinates())
|
||||
.await?
|
||||
.extract_relays(),
|
||||
// Include read relays for each repository owner (if found)
|
||||
future::join_all(naddrs_iter.map(|c| client.read_relays_from_user(c.public_key)))
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
content_details.write_relays.clone().into_iter().collect(),
|
||||
]
|
||||
.concat();
|
||||
|
||||
tracing::trace!(relays = ?write_relays, "Write relays list");
|
||||
let success = client
|
||||
.send_event_to(event, relays_list.as_ref(), &write_relays)
|
||||
.await?;
|
||||
|
||||
let nevent = utils::new_nevent(event_id, &success)?;
|
||||
println!("Issue created: {nevent}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
141
src/cli/commands/mod.rs
Normal file
141
src/cli/commands/mod.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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>.
|
||||
|
||||
/// `issue` subcommands
|
||||
mod issue;
|
||||
/// 'reply` command
|
||||
mod reply;
|
||||
/// `repo` subcommands
|
||||
mod repo;
|
||||
/// `sets` subcommands
|
||||
mod sets;
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{ArgGroup, Args, Parser};
|
||||
use nostr::key::{Keys, PublicKey, SecretKey};
|
||||
|
||||
use self::issue::IssueSubcommands;
|
||||
use self::reply::ReplyArgs;
|
||||
use self::repo::RepoSubcommands;
|
||||
use self::sets::SetsSubcommands;
|
||||
use super::traits::CommandRunner;
|
||||
use super::types::RelayOrSet;
|
||||
use crate::error::{N34Error, N34Result};
|
||||
|
||||
/// Default path used when no path is provided via command line arguments.
|
||||
///
|
||||
/// This is a workaround since Clap doesn't support lazy evaluation of defaults.
|
||||
pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
|
||||
|
||||
// TODO: Make the `signer` group optional
|
||||
/// The command-line interface options
|
||||
#[derive(Args, Clone)]
|
||||
#[clap(
|
||||
group(
|
||||
ArgGroup::new("signer")
|
||||
.args(&["secret_key"])
|
||||
.required(true)
|
||||
)
|
||||
)]
|
||||
pub struct CliOptions {
|
||||
/// Your Nostr secret key
|
||||
#[arg(short, long)]
|
||||
pub secret_key: Option<SecretKey>,
|
||||
/// Fallbacks relay to write and read from it. Multiple relays can be
|
||||
/// passed.
|
||||
#[arg(short, long)]
|
||||
pub relays: Vec<RelayOrSet>,
|
||||
/// Proof of Work difficulty when creatring events
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub pow: u8,
|
||||
/// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`]
|
||||
#[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH,
|
||||
hide_default_value = true
|
||||
)]
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
/// N34 commands
|
||||
#[derive(Parser, Debug)]
|
||||
pub enum Commands {
|
||||
/// Manage reposotoies and relays sets
|
||||
Sets {
|
||||
#[command(subcommand)]
|
||||
subcommands: SetsSubcommands,
|
||||
},
|
||||
/// Manage repositories
|
||||
Repo {
|
||||
#[command(subcommand)]
|
||||
subcommands: RepoSubcommands,
|
||||
},
|
||||
/// Manage issues
|
||||
Issue {
|
||||
#[command(subcommand)]
|
||||
subcommands: IssueSubcommands,
|
||||
},
|
||||
// /// Manage patches
|
||||
// Patch {
|
||||
// #[command(subcommand)]
|
||||
// subcommands: PatchSubcommands,
|
||||
// },
|
||||
/// Reply to issues and patches.
|
||||
Reply(ReplyArgs),
|
||||
}
|
||||
|
||||
|
||||
impl CommandRunner for Commands {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
tracing::trace!("Options: {options:#?}");
|
||||
tracing::trace!("Handling: {self:#?}");
|
||||
match self {
|
||||
Self::Repo { subcommands } => subcommands.run(options).await,
|
||||
Self::Issue { subcommands } => subcommands.run(options).await,
|
||||
Commands::Reply(args) => args.run(options).await,
|
||||
Commands::Sets { subcommands } => subcommands.run(options).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement `ensure_signer` function with similar functionality to
|
||||
// `ensure_relays`
|
||||
impl CliOptions {
|
||||
/// Gets the public key of the user.
|
||||
pub async fn pubkey(&self) -> N34Result<PublicKey> {
|
||||
if let Some(sk) = &self.secret_key {
|
||||
return Ok(Keys::new(sk.clone()).public_key());
|
||||
}
|
||||
unreachable!("There is no other method until now")
|
||||
}
|
||||
|
||||
/// Returns an error if there are no relays.
|
||||
pub fn ensure_relays(&self) -> N34Result<()> {
|
||||
if self.relays.is_empty() {
|
||||
return Err(N34Error::EmptyRelays);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CliOptions {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CliOptions")
|
||||
.field("secret_key", &self.secret_key.as_ref().map(|_| "*******"))
|
||||
.field("relays", &self.relays)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
296
src/cli/commands/reply.rs
Normal file
296
src/cli/commands/reply.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
// 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::{fs, str::FromStr};
|
||||
|
||||
use clap::{ArgGroup, Args};
|
||||
use futures::future;
|
||||
use nostr::{
|
||||
event::{Event, EventBuilder, EventId, Kind},
|
||||
filter::Filter,
|
||||
nips::{
|
||||
nip01::{Coordinate, Metadata},
|
||||
nip19::{self, FromBech32, ToBech32},
|
||||
},
|
||||
types::RelayUrl,
|
||||
};
|
||||
|
||||
use super::{CliOptions, CommandRunner};
|
||||
use crate::{
|
||||
cli::{
|
||||
CliConfig,
|
||||
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
|
||||
},
|
||||
error::{N34Error, N34Result},
|
||||
nostr_utils::{
|
||||
NostrClient,
|
||||
traits::{NaddrsUtils, ReposUtils},
|
||||
utils,
|
||||
},
|
||||
};
|
||||
|
||||
/// Length of a Nostr npub (public key) in characters.
|
||||
const NPUB_LEN: usize = 63;
|
||||
/// The max date "9999-01-01 at 00:00 UTC"
|
||||
const MAX_DATE: i64 = 253370764800;
|
||||
|
||||
/// Parses and represents a Nostr `nevent1` or `note1`.
|
||||
#[derive(Debug, Clone)]
|
||||
struct NostrEvent {
|
||||
/// Unique identifier for the event.
|
||||
event_id: EventId,
|
||||
/// List of relay URLs associated with the event. Empty if parsing a
|
||||
/// `note1`.
|
||||
relays: Vec<RelayUrl>,
|
||||
}
|
||||
|
||||
impl NostrEvent {
|
||||
/// Create a new [`NostrEvent`] instance
|
||||
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
|
||||
Self { event_id, relays }
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for NostrEvent {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let str_event = s.trim().trim_start_matches("nostr:");
|
||||
if str_event.starts_with("nevent1") {
|
||||
let event = nip19::Nip19Event::from_bech32(str_event).map_err(|e| e.to_string())?;
|
||||
Ok(Self::new(event.event_id, event.relays))
|
||||
} else if str_event.starts_with("note1") {
|
||||
Ok(Self::new(
|
||||
EventId::from_bech32(str_event).map_err(|e| e.to_string())?,
|
||||
Vec::new(),
|
||||
))
|
||||
} else {
|
||||
Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Arguments for the `reply` command
|
||||
#[derive(Args, Debug)]
|
||||
#[clap(
|
||||
group(
|
||||
ArgGroup::new("comment-content")
|
||||
.args(["comment", "editor"])
|
||||
.required(true)
|
||||
),
|
||||
group(
|
||||
ArgGroup::new("quote-reply-to")
|
||||
.args(["comment", "quote_to"])
|
||||
)
|
||||
)]
|
||||
pub struct ReplyArgs {
|
||||
/// The issue, patch, or comment to reply to
|
||||
#[arg(long, value_name = "nevent1-or-note1")]
|
||||
to: NostrEvent,
|
||||
/// Quote the replied-to event in the editor
|
||||
#[arg(long)]
|
||||
quote_to: bool,
|
||||
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
|
||||
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
|
||||
///
|
||||
/// If omitted, looks for a `nostr-address` file.
|
||||
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
|
||||
naddrs: Option<Vec<NaddrOrSet>>,
|
||||
/// The comment (cannot be used with --editor)
|
||||
#[arg(short, long)]
|
||||
comment: Option<String>,
|
||||
/// Open editor to write comment (cannot be used with --content)
|
||||
#[arg(short, long)]
|
||||
editor: bool,
|
||||
}
|
||||
|
||||
impl CommandRunner for ReplyArgs {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
let nostr_address_path = utils::nostr_address_path()?;
|
||||
let config = CliConfig::load_toml(&options.config_path)?;
|
||||
let relays = options.relays.clone().flat_relays(&config.sets)?;
|
||||
let client = NostrClient::init(&options, &relays).await;
|
||||
let user_pubk = options.pubkey().await?;
|
||||
|
||||
let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&config.sets)? {
|
||||
client.add_relays(&naddrs.extract_relays()).await;
|
||||
Some(naddrs)
|
||||
} else if fs::exists(&nostr_address_path).is_ok() {
|
||||
let naddrs = utils::naddrs_or_file(None, &nostr_address_path)?;
|
||||
client.add_relays(&naddrs.extract_relays()).await;
|
||||
Some(naddrs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
client.add_relays(&self.to.relays).await;
|
||||
|
||||
let reply_to = client
|
||||
.fetch_event(Filter::new().id(self.to.event_id))
|
||||
.await?
|
||||
.ok_or(N34Error::EventNotFound)?;
|
||||
let root = client.find_root(reply_to.clone()).await?;
|
||||
|
||||
let repos_coordinate = if let Some(naddrs) = repo_naddrs {
|
||||
naddrs.into_coordinates()
|
||||
} else if let Some(ref root_event) = root {
|
||||
coordinates_from_root(root_event)?
|
||||
} else {
|
||||
return Err(N34Error::NotFoundRepo);
|
||||
};
|
||||
|
||||
let repos = client.fetch_repos(&repos_coordinate).await?;
|
||||
|
||||
let quoted_content = if self.quote_to {
|
||||
Some(quote_reply_to_content(&client, &reply_to).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let content = utils::get_content(self.comment.as_ref(), quoted_content.as_ref(), ".txt")?;
|
||||
let content_details = client.parse_content(&content).await;
|
||||
|
||||
let event = EventBuilder::comment(
|
||||
content,
|
||||
&reply_to,
|
||||
root.as_ref(),
|
||||
repos.first().and_then(|r| r.relays.first()).cloned(),
|
||||
)
|
||||
.dedup_tags()
|
||||
.pow(options.pow)
|
||||
.tags(content_details.clone().into_tags())
|
||||
.build(user_pubk);
|
||||
|
||||
let event_id = event.id.expect("There is an id");
|
||||
let relays_list = client.user_relays_list(user_pubk).await?;
|
||||
let author_read_relays =
|
||||
utils::add_read_relays(client.user_relays_list(user_pubk).await?.as_ref());
|
||||
let write_relays = [
|
||||
relays,
|
||||
utils::add_write_relays(relays_list.as_ref()),
|
||||
// Merge repository announcement relays into write relays
|
||||
repos.extract_relays(),
|
||||
// Include read relays for each repository owner (if found)
|
||||
future::join_all(
|
||||
repos_coordinate
|
||||
.iter()
|
||||
.map(|c| client.read_relays_from_user(c.public_key)),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
// read relays of the root event and the reply to event
|
||||
{
|
||||
let (r1, r2) = future::join(
|
||||
client.read_relays_from_user(reply_to.pubkey),
|
||||
event_author_read_relays(&client, root.as_ref()),
|
||||
)
|
||||
.await;
|
||||
[r1, r2].concat()
|
||||
},
|
||||
content_details.write_relays.into_iter().collect(),
|
||||
]
|
||||
.concat();
|
||||
|
||||
tracing::trace!(relays = ?write_relays, "Write relays list");
|
||||
let (success, ..) = futures::join!(
|
||||
client.send_event_to(event, relays_list.as_ref(), &write_relays),
|
||||
client.broadcast(&reply_to, &author_read_relays),
|
||||
async {
|
||||
if let Some(root_event) = root {
|
||||
let _ = client.broadcast(&root_event, &author_read_relays).await;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
let nevent = utils::new_nevent(event_id, &success?)?;
|
||||
println!("Comment created: {nevent}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a quoted reply string in the format "On yyyy-mm-dd at hh:mm UTC,
|
||||
/// <author> wrote:" followed by the event content. Uses display name if
|
||||
/// available, otherwise falls back to a shortened npub string. Dates are
|
||||
/// formatted in UTC.
|
||||
async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String {
|
||||
let author_name = client
|
||||
.fetch_event(
|
||||
Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.author(quoted_event.pubkey),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|e| Metadata::try_from(&e).ok())
|
||||
.and_then(|m| m.display_name.or(m.name))
|
||||
.unwrap_or_else(|| {
|
||||
let pubkey = quoted_event
|
||||
.pubkey
|
||||
.to_bech32()
|
||||
.expect("The error is `Infallible`");
|
||||
format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..])
|
||||
});
|
||||
|
||||
let fdate = chrono::DateTime::from_timestamp(
|
||||
quoted_event
|
||||
.created_at
|
||||
.as_u64()
|
||||
.try_into()
|
||||
.unwrap_or(MAX_DATE),
|
||||
0,
|
||||
)
|
||||
.map(|datetime| datetime.format("On %F at %R UTC, ").to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
"{fdate}{author_name} wrote:\n> {}",
|
||||
quoted_event.content.trim().replace("\n", "\n> ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Gets the repository coordinate from a root Nostr event's tags.
|
||||
/// The event must contain a coordinate tag with GitRepoAnnouncement kind.
|
||||
fn coordinates_from_root(root: &Event) -> N34Result<Vec<Coordinate>> {
|
||||
let coordinates: Vec<Coordinate> = root
|
||||
.tags
|
||||
.coordinates()
|
||||
.filter(|c| c.kind == Kind::GitRepoAnnouncement)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if coordinates.is_empty() {
|
||||
return Err(N34Error::InvalidEvent(
|
||||
"The Git issue/patch does not specify a target repository".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(coordinates)
|
||||
}
|
||||
|
||||
/// Returns the event author read relays if found, otherwise an empty vector
|
||||
async fn event_author_read_relays(client: &NostrClient, event: Option<&Event>) -> Vec<RelayUrl> {
|
||||
if let Some(root_event) = event {
|
||||
client.read_relays_from_user(root_event.pubkey).await
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
155
src/cli/commands/repo/announce.rs
Normal file
155
src/cli/commands/repo/announce.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
// 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::{fs, io::Write};
|
||||
|
||||
use clap::Args;
|
||||
use futures::future;
|
||||
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
|
||||
|
||||
use crate::{
|
||||
cli::{CliConfig, CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt},
|
||||
error::N34Result,
|
||||
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
|
||||
};
|
||||
|
||||
/// Header written to new `nostr-address` files. Contains two trailing newline
|
||||
/// for formatting.
|
||||
const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr` entities for repositories that accept this
|
||||
# project's issues and patches.
|
||||
#
|
||||
# The file acts as a **read-only reference** for retrieving repository relays
|
||||
# when embedded in an `naddr` and mentions those repositories when opening
|
||||
# patches or issues. Modifications here will not affect in the relays, as the
|
||||
# file is **explicitly untracked**. Its goal is to simplify contributions by
|
||||
# removing the need for manual address entry.
|
||||
#
|
||||
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
|
||||
# to assist client-side discovery.
|
||||
#
|
||||
# Empty lines are ignored. Lines starting with "#" are treated as comments.
|
||||
|
||||
"##;
|
||||
|
||||
/// Arguments for the `repo announce` command
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AnnounceArgs {
|
||||
/// Unique identifier for the repository in kebab-case.
|
||||
#[arg(long = "id")]
|
||||
repo_id: String,
|
||||
/// A name for the repository.
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
/// A description for the repository.
|
||||
#[arg(short, long)]
|
||||
description: Option<String>,
|
||||
/// Webpage URLs for the repository (if provided by the git server).
|
||||
#[arg(short, long)]
|
||||
web: Vec<Url>,
|
||||
/// URLs for cloning the repository.
|
||||
#[arg(short, long)]
|
||||
clone: Vec<Url>,
|
||||
/// Additional maintainers of the repository (besides yourself).
|
||||
#[arg(short, long)]
|
||||
maintainers: Vec<PublicKey>,
|
||||
/// Labels to categorize the repository. Can be specified multiple times.
|
||||
#[arg(short, long)]
|
||||
label: Vec<String>,
|
||||
/// Skip kebab-case validation for the repository ID
|
||||
#[arg(long)]
|
||||
force_id: bool,
|
||||
/// If set, creates a `nostr-address` file to enable automatic address
|
||||
/// discovery by n34
|
||||
#[arg(long)]
|
||||
address_file: bool,
|
||||
}
|
||||
|
||||
impl CommandRunner for AnnounceArgs {
|
||||
async fn run(mut self, options: CliOptions) -> N34Result<()> {
|
||||
options.ensure_relays()?;
|
||||
|
||||
let config = CliConfig::load_toml(&options.config_path)?;
|
||||
let relays = options.relays.clone().flat_relays(&config.sets)?;
|
||||
let client = NostrClient::init(&options, &relays).await;
|
||||
let user_pubk = options.pubkey().await?;
|
||||
let relays_list = client.user_relays_list(user_pubk).await?;
|
||||
|
||||
if !self.maintainers.contains(&user_pubk) {
|
||||
self.maintainers.insert(0, user_pubk);
|
||||
}
|
||||
|
||||
let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?;
|
||||
let event = EventBuilder::new_git_repo(
|
||||
self.repo_id,
|
||||
self.name.map(utils::str_trim),
|
||||
self.description.map(utils::str_trim),
|
||||
self.web,
|
||||
self.clone,
|
||||
relays.clone(),
|
||||
self.maintainers.clone(),
|
||||
self.label.into_iter().map(utils::str_trim).collect(),
|
||||
self.force_id,
|
||||
)?
|
||||
.dedup_tags()
|
||||
.pow(options.pow)
|
||||
.build(user_pubk);
|
||||
|
||||
|
||||
if self.address_file {
|
||||
let address_path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE);
|
||||
if !address_path.exists() {
|
||||
tracing::info!(
|
||||
"Creating new address file: '{NOSTR_ADDRESS_FILE}' at path '{}' with default \
|
||||
header",
|
||||
address_path.display()
|
||||
);
|
||||
fs::write(&address_path, NOSTR_ADDRESS_FILE_HEADER)?;
|
||||
}
|
||||
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&address_path)?;
|
||||
|
||||
tracing::info!("Appending naddr '{naddr}' to address file: '{NOSTR_ADDRESS_FILE}'");
|
||||
file.write_all(format!("{naddr}\n").as_bytes())?;
|
||||
tracing::info!("Successfully wrote naddr to address file");
|
||||
}
|
||||
|
||||
let write_relays = [
|
||||
relays,
|
||||
utils::add_write_relays(relays_list.as_ref()),
|
||||
// Include read relays for each maintainer (if found)
|
||||
future::join_all(
|
||||
self.maintainers
|
||||
.iter()
|
||||
.map(|pkey| client.read_relays_from_user(*pkey)),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
]
|
||||
.concat();
|
||||
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
|
||||
|
||||
client
|
||||
.send_event_to(event, relays_list.as_ref(), &write_relays)
|
||||
.await?;
|
||||
|
||||
println!("Event: {nevent}",);
|
||||
println!("Repo Address: {naddr}",);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
46
src/cli/commands/repo/mod.rs
Normal file
46
src/cli/commands/repo/mod.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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>.
|
||||
|
||||
/// `repo announce` subcommand
|
||||
mod announce;
|
||||
/// `repo view` subcommand
|
||||
mod view;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use self::announce::AnnounceArgs;
|
||||
use self::view::ViewArgs;
|
||||
use super::{CliOptions, CommandRunner};
|
||||
use crate::error::N34Result;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RepoSubcommands {
|
||||
/// View details of a nostr git repository
|
||||
View(ViewArgs),
|
||||
/// Publish information about a git repository to Nostr for collaboration
|
||||
/// and feedback. Can also be used to update an existing repository's
|
||||
/// details.
|
||||
Announce(AnnounceArgs),
|
||||
}
|
||||
|
||||
impl CommandRunner for RepoSubcommands {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
match self {
|
||||
Self::View(args) => args.run(options).await,
|
||||
Self::Announce(args) => args.run(options).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/cli/commands/repo/view.rs
Normal file
104
src/cli/commands/repo/view.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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;
|
||||
|
||||
use clap::Args;
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
CliConfig,
|
||||
CliOptions,
|
||||
CommandRunner,
|
||||
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
|
||||
},
|
||||
error::N34Result,
|
||||
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
|
||||
};
|
||||
|
||||
/// Arguments for the `repo view` command
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ViewArgs {
|
||||
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
|
||||
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
|
||||
///
|
||||
/// If omitted, looks for a `nostr-address` file.
|
||||
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
|
||||
naddrs: Option<Vec<NaddrOrSet>>,
|
||||
}
|
||||
|
||||
impl CommandRunner for ViewArgs {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
// FIXME: The signer is not required here
|
||||
|
||||
let config = CliConfig::load_toml(&options.config_path)?;
|
||||
let naddrs = utils::naddrs_or_file(
|
||||
self.naddrs.flat_naddrs(&config.sets)?,
|
||||
&utils::nostr_address_path()?,
|
||||
)?;
|
||||
let relays = options.relays.clone().flat_relays(&config.sets)?;
|
||||
let client = NostrClient::init(&options, &relays).await;
|
||||
client.add_relays(&naddrs.extract_relays()).await;
|
||||
|
||||
let repos = client.fetch_repos(&naddrs.into_coordinates()).await?;
|
||||
let mut repos_details: Vec<String> = Vec::new();
|
||||
|
||||
for repo in repos {
|
||||
let mut repo_details = format!("ID: {}", repo.id);
|
||||
|
||||
if let Some(name) = repo.name {
|
||||
repo_details.push_str(&format!("\nName: {name}"));
|
||||
}
|
||||
if let Some(desc) = repo.description {
|
||||
repo_details.push_str(&format!("\nDescription: {desc}"));
|
||||
}
|
||||
if !repo.web.is_empty() {
|
||||
repo_details.push_str(&format!("\nWebpages:\n{}", format_list(repo.web)));
|
||||
}
|
||||
if !repo.clone.is_empty() {
|
||||
repo_details.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone)));
|
||||
}
|
||||
if !repo.relays.is_empty() {
|
||||
repo_details.push_str(&format!("\nRelays:\n{}", format_list(repo.relays)));
|
||||
}
|
||||
if let Some(euc) = repo.euc {
|
||||
repo_details.push_str(&format!("\nEarliest unique commit: {euc}"));
|
||||
}
|
||||
if !repo.maintainers.is_empty() {
|
||||
repo_details.push_str(&format!(
|
||||
"\nMaintainers:\n{}",
|
||||
format_list(repo.maintainers)
|
||||
));
|
||||
}
|
||||
repos_details.push(repo_details);
|
||||
}
|
||||
|
||||
println!("{}", repos_details.join("\n----------\n"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a vector to print it
|
||||
fn format_list<T>(vector: Vec<T>) -> String
|
||||
where
|
||||
T: fmt::Display,
|
||||
{
|
||||
vector
|
||||
.into_iter()
|
||||
.map(|t| format!(" - {t}"))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
}
|
||||
57
src/cli/commands/sets/mod.rs
Normal file
57
src/cli/commands/sets/mod.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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>.
|
||||
|
||||
/// `sets new` command
|
||||
mod new;
|
||||
/// `sets remove` command
|
||||
mod remove;
|
||||
/// `sets show` commands
|
||||
mod show;
|
||||
/// `sets update` command
|
||||
mod update;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use self::new::NewArgs;
|
||||
use self::remove::RemoveArgs;
|
||||
use self::show::ShowArgs;
|
||||
use self::update::UpdateArgs;
|
||||
use super::{CliOptions, CommandRunner};
|
||||
use crate::error::N34Result;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SetsSubcommands {
|
||||
/// Remove a set, or specific repos and relays within it
|
||||
Remove(RemoveArgs),
|
||||
/// Create a new set
|
||||
New(NewArgs),
|
||||
/// Modify an existing set
|
||||
Update(UpdateArgs),
|
||||
/// Show a single set or all the stored sets
|
||||
Show(ShowArgs),
|
||||
}
|
||||
|
||||
|
||||
impl CommandRunner for SetsSubcommands {
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
match self {
|
||||
Self::Remove(args) => args.run(options).await,
|
||||
Self::New(args) => args.run(options).await,
|
||||
Self::Update(args) => args.run(options).await,
|
||||
Self::Show(args) => args.run(options).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/cli/commands/sets/new.rs
Normal file
59
src/cli/commands/sets/new.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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 clap::Args;
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
CliConfig,
|
||||
CliOptions,
|
||||
ConfigError,
|
||||
MutRepoRelaySetsExt,
|
||||
traits::CommandRunner,
|
||||
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
|
||||
},
|
||||
error::N34Result,
|
||||
};
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct NewArgs {
|
||||
/// Unique name for the set
|
||||
name: String,
|
||||
/// Optional relay to add it to the set, either as URL or set name to
|
||||
/// extract its relays. [aliases: `--sr`]
|
||||
#[arg(long = "set-relay", alias("sr"))]
|
||||
relays: Vec<RelayOrSet>,
|
||||
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
|
||||
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
|
||||
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
|
||||
naddrs: Vec<NaddrOrSet>,
|
||||
}
|
||||
|
||||
impl CommandRunner for NewArgs {
|
||||
// FIXME: The signer is not required here
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
let mut config = CliConfig::load_toml(&options.config_path)?;
|
||||
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
|
||||
let relays = self.relays.flat_relays(&config.sets)?;
|
||||
|
||||
if relays.is_empty() && naddrs.is_empty() {
|
||||
return Err(ConfigError::NewEmptySet.into());
|
||||
}
|
||||
|
||||
config.sets.push_set(self.name, naddrs, relays)?;
|
||||
config.dump_toml(&options.config_path)
|
||||
}
|
||||
}
|
||||
65
src/cli/commands/sets/remove.rs
Normal file
65
src/cli/commands/sets/remove.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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 clap::Args;
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
CliConfig,
|
||||
CliOptions,
|
||||
MutRepoRelaySetsExt,
|
||||
traits::CommandRunner,
|
||||
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
|
||||
},
|
||||
error::N34Result,
|
||||
};
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct RemoveArgs {
|
||||
/// Set name to delete
|
||||
name: String,
|
||||
/// Specific relay to remove it from the set, either as URL or set name to
|
||||
/// extract its relays. [aliases: `--sr`]
|
||||
#[arg(long = "set-relay", alias("sr"))]
|
||||
relays: Vec<RelayOrSet>,
|
||||
|
||||
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
|
||||
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
|
||||
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
|
||||
naddrs: Vec<NaddrOrSet>,
|
||||
}
|
||||
|
||||
impl CommandRunner for RemoveArgs {
|
||||
// FIXME: The signer is not required here
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
let mut config = CliConfig::load_toml(&options.config_path)?;
|
||||
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
|
||||
let relays = self.relays.flat_relays(&config.sets)?;
|
||||
|
||||
if relays.is_empty() && naddrs.is_empty() {
|
||||
config.sets.remove_set(self.name)?;
|
||||
} else {
|
||||
if !relays.is_empty() {
|
||||
config.sets.remove_relays(&self.name, relays.into_iter())?;
|
||||
}
|
||||
if !naddrs.is_empty() {
|
||||
config.sets.remove_naddrs(self.name, naddrs.into_iter())?;
|
||||
}
|
||||
}
|
||||
|
||||
config.dump_toml(&options.config_path)
|
||||
}
|
||||
}
|
||||
85
src/cli/commands/sets/show.rs
Normal file
85
src/cli/commands/sets/show.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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 clap::Args;
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
|
||||
use crate::{
|
||||
cli::{CliConfig, CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner},
|
||||
error::N34Result,
|
||||
};
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ShowArgs {
|
||||
/// Name of the set to display. If not provided, lists all available sets.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandRunner for ShowArgs {
|
||||
// FIXME: The signer is not required here
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
let config = CliConfig::load_toml(&options.config_path)?;
|
||||
|
||||
if let Some(name) = self.name {
|
||||
println!("{}", format_set(config.sets.as_slice().get_set(&name)?));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
config
|
||||
.sets
|
||||
.iter()
|
||||
.map(format_set)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n----------\n")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a set to view it to the user
|
||||
fn format_set(set: &RepoRelaySet) -> String {
|
||||
let naddrs = if set.naddrs.is_empty() {
|
||||
"Nothing".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"\n- {}",
|
||||
set.naddrs
|
||||
.iter()
|
||||
.map(|naddr| naddr.to_bech32().expect("We did decoded before"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n- ")
|
||||
)
|
||||
};
|
||||
let relays = if set.relays.is_empty() {
|
||||
"Nothing".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"\n- {}",
|
||||
set.relays
|
||||
.iter()
|
||||
.map(|relay| relay.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n- ")
|
||||
)
|
||||
};
|
||||
|
||||
format!(
|
||||
"Name: {}\nّّRepositories: {naddrs}\nRelays: {relays}",
|
||||
set.name
|
||||
)
|
||||
}
|
||||
72
src/cli/commands/sets/update.rs
Normal file
72
src/cli/commands/sets/update.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// 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::collections::HashSet;
|
||||
|
||||
use clap::Args;
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
CliConfig,
|
||||
CliOptions,
|
||||
MutRepoRelaySetsExt,
|
||||
traits::CommandRunner,
|
||||
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
|
||||
},
|
||||
error::N34Result,
|
||||
};
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct UpdateArgs {
|
||||
/// Name of the set to update
|
||||
name: String,
|
||||
/// Add relay to the set, either as URL or set name to extract its relays.
|
||||
/// [aliases: `--sr`]
|
||||
#[arg(long = "set-relay", alias("sr"))]
|
||||
relays: Vec<RelayOrSet>,
|
||||
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
|
||||
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
|
||||
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
|
||||
naddrs: Vec<NaddrOrSet>,
|
||||
/// Replace existing relays/repositories instead of adding to them
|
||||
#[arg(long = "override")]
|
||||
override_set: bool,
|
||||
}
|
||||
|
||||
impl CommandRunner for UpdateArgs {
|
||||
// FIXME: The signer is not required here
|
||||
async fn run(self, options: CliOptions) -> N34Result<()> {
|
||||
let mut config = CliConfig::load_toml(&options.config_path)?;
|
||||
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
|
||||
let relays = self.relays.flat_relays(&config.sets)?;
|
||||
|
||||
let set = config.sets.get_mut_set(&self.name)?;
|
||||
|
||||
if self.override_set {
|
||||
if !relays.is_empty() {
|
||||
set.relays = HashSet::from_iter(relays);
|
||||
}
|
||||
if !naddrs.is_empty() {
|
||||
set.naddrs = HashSet::from_iter(naddrs)
|
||||
}
|
||||
} else {
|
||||
set.relays.extend(relays);
|
||||
set.naddrs.extend(naddrs);
|
||||
}
|
||||
|
||||
config.dump_toml(&options.config_path)
|
||||
}
|
||||
}
|
||||
303
src/cli/config.rs
Normal file
303
src/cli/config.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
// 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::{collections::HashSet, fs, path::Path};
|
||||
|
||||
use nostr::{
|
||||
nips::nip19::{FromBech32, Nip19Coordinate, ToBech32},
|
||||
types::RelayUrl,
|
||||
};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
use crate::error::{N34Error, N34Result};
|
||||
|
||||
/// Errors that can occur when working with configuration files.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ConfigError {
|
||||
#[error(
|
||||
"Could not determine the default config path: both `$XDG_CONFIG_HOME` and `$HOME` \
|
||||
environment variables are missing or unset."
|
||||
)]
|
||||
CanNotFindConfigPath,
|
||||
#[error("Couldn't read the config file: {0}")]
|
||||
ReadFile(std::io::Error),
|
||||
#[error("Couldn't write in the config file: {0}")]
|
||||
WriteFile(std::io::Error),
|
||||
#[error("Couldn't serialize the config. This is a bug, please report it: {0}")]
|
||||
Serialize(toml::ser::Error),
|
||||
#[error("Failed to parse the config file: {0}")]
|
||||
ParseFile(toml::de::Error),
|
||||
#[error("Duplicate configuration set name detected: '{0}'. Each set must have a unique name.")]
|
||||
SetDuplicateName(String),
|
||||
#[error("No set with the given name `{0}`")]
|
||||
SetNotFound(String),
|
||||
#[error("You can't create an new empty set.")]
|
||||
NewEmptySet,
|
||||
}
|
||||
|
||||
/// Configuration for the command-line interface.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
|
||||
pub struct CliConfig {
|
||||
/// Groups of repositories and relays.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sets: Vec<RepoRelaySet>,
|
||||
}
|
||||
|
||||
/// A named group of repositories and relays.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default, Clone, Debug)]
|
||||
pub struct RepoRelaySet {
|
||||
/// Unique identifier for this group.
|
||||
pub name: String,
|
||||
/// Repository addresses in this group.
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "HashSet::is_empty",
|
||||
serialize_with = "ser_naddrs",
|
||||
deserialize_with = "de_naddrs"
|
||||
)]
|
||||
pub naddrs: HashSet<Nip19Coordinate>,
|
||||
/// Relay URLs in this group.
|
||||
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
|
||||
pub relays: HashSet<RelayUrl>,
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
/// Reads and parse a TOML config file from the given path, creating it if
|
||||
/// missing.
|
||||
pub fn load_toml(file_path: &Path) -> N34Result<Self> {
|
||||
tracing::info!(path = %file_path.display(), "Loading configuration from file");
|
||||
// Make sure the file is exist
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let _ = fs::File::create_new(file_path);
|
||||
|
||||
let mut config: Self =
|
||||
toml::from_str(&fs::read_to_string(file_path).map_err(ConfigError::ReadFile)?)
|
||||
.map_err(ConfigError::ParseFile)?;
|
||||
|
||||
config.post_sets()?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Dump the config as toml in a file
|
||||
pub fn dump_toml(mut self, file_path: &Path) -> N34Result<()> {
|
||||
tracing::debug!(config = ?self, "Writing configuration to {}", file_path.display());
|
||||
self.post_sets()?;
|
||||
|
||||
fs::write(
|
||||
file_path,
|
||||
toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?,
|
||||
)
|
||||
.map_err(ConfigError::WriteFile)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs post-processing validation on the sets after loading or before
|
||||
/// dumping.
|
||||
fn post_sets(&mut self) -> N34Result<()> {
|
||||
self.sets.as_slice().ensure_names()?;
|
||||
self.sets.dedup_naddrs();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoRelaySet {
|
||||
/// Create a new [`RepoRelaySet`]
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
naddrs: impl IntoIterator<Item = Nip19Coordinate>,
|
||||
relays: impl IntoIterator<Item = RelayUrl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
naddrs: HashSet::from_iter(naddrs),
|
||||
relays: HashSet::from_iter(relays),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes duplicate repository addresses by comparing their coordinates,
|
||||
/// ignoring embedded relays.
|
||||
pub fn dedup_naddrs(&mut self) {
|
||||
let mut seen = HashSet::new();
|
||||
self.naddrs.retain(|n| seen.insert(n.coordinate.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[easy_ext::ext(MutRepoRelaySetsExt)]
|
||||
impl Vec<RepoRelaySet> {
|
||||
/// Removes duplicate repository addresses from each set.
|
||||
///
|
||||
/// Relays are automatically deduplicated by the HashSet, but
|
||||
/// repository addresses may appear duplicated if relays are sorted
|
||||
/// differently or when relay counts vary. This compares addresses by
|
||||
/// their coordinates, ignoring any embedded relay details.
|
||||
pub fn dedup_naddrs(&mut self) {
|
||||
self.iter_mut().for_each(RepoRelaySet::dedup_naddrs);
|
||||
}
|
||||
|
||||
/// Finds and returns a mutable reference a set with the given name. Returns
|
||||
/// an error if no set with this name exists.
|
||||
pub fn get_mut_set(&mut self, name: impl AsRef<str>) -> N34Result<&mut RepoRelaySet> {
|
||||
let name = name.as_ref();
|
||||
let set = self
|
||||
.iter_mut()
|
||||
.find(|set| set.name == name)
|
||||
.ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
|
||||
|
||||
tracing::trace!(
|
||||
name = %name, set = ?set,
|
||||
"Successfully located a set with the giving name"
|
||||
);
|
||||
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Creates and pushes a new set with the given name.
|
||||
///
|
||||
/// Returns an error if a set with the same name already exists.
|
||||
pub fn push_set(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
repos: impl IntoIterator<Item = Nip19Coordinate>,
|
||||
relays: impl IntoIterator<Item = RelayUrl>,
|
||||
) -> N34Result<()> {
|
||||
let set_name: String = name.into();
|
||||
tracing::trace!(sets = ?self, "Pushing set '{set_name}' to sets collection");
|
||||
|
||||
if self.as_slice().exists(&set_name) {
|
||||
return Err(ConfigError::SetDuplicateName(set_name).into());
|
||||
}
|
||||
|
||||
self.push(RepoRelaySet::new(set_name, repos, relays));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the set with the given name if it exists. Returns an error if
|
||||
/// the set is not found.
|
||||
pub fn remove_set(&mut self, name: impl Into<String>) -> N34Result<()> {
|
||||
let set_name: String = name.into();
|
||||
tracing::trace!(set_name, sets = ?self, "Removing set '{set_name}' from sets collection");
|
||||
|
||||
if !self.as_slice().exists(&set_name) {
|
||||
return Err(ConfigError::SetNotFound(set_name).into());
|
||||
}
|
||||
|
||||
self.retain(|s| s.name != set_name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the given relays from the specified set.
|
||||
pub fn remove_relays(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
relays: impl Iterator<Item = RelayUrl>,
|
||||
) -> N34Result<()> {
|
||||
let relays = Vec::from_iter(relays);
|
||||
let set = self.get_mut_set(name.into())?;
|
||||
|
||||
set.relays.retain(|r| !relays.contains(r));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the given naddrs from the specified set.
|
||||
pub fn remove_naddrs(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
naddrs: impl Iterator<Item = Nip19Coordinate>,
|
||||
) -> N34Result<()> {
|
||||
let coordinates = Vec::from_iter(naddrs.map(|n| n.coordinate));
|
||||
let set = self.get_mut_set(name.into())?;
|
||||
|
||||
set.naddrs.retain(|n| !coordinates.contains(&n.coordinate));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[easy_ext::ext(RepoRelaySetsExt)]
|
||||
impl &[RepoRelaySet] {
|
||||
/// Checks for duplicate set names. Returns an error if any duplicates are
|
||||
/// found.
|
||||
pub fn ensure_names(&self) -> N34Result<()> {
|
||||
let mut names = Vec::with_capacity(self.len());
|
||||
names.extend(self.iter().map(|s| s.name.to_owned()));
|
||||
|
||||
names.sort_unstable();
|
||||
|
||||
if let Some(duplicate) = duplicate_in_sorted(&names) {
|
||||
return Err(ConfigError::SetDuplicateName(duplicate.clone()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a set with the given name exists.
|
||||
pub fn exists(&self, set_name: &str) -> bool {
|
||||
self.iter().any(|set| set.name == set_name)
|
||||
}
|
||||
|
||||
/// Finds and returns a reference a set with the given name. Returns an
|
||||
/// error if no set with this name exists.
|
||||
pub fn get_set(&self, name: impl AsRef<str>) -> N34Result<&RepoRelaySet> {
|
||||
let name = name.as_ref();
|
||||
let set = self
|
||||
.iter()
|
||||
.find(|set| set.name == name)
|
||||
.ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
|
||||
tracing::trace!(
|
||||
name = %name, set = ?set,
|
||||
"Successfully located a set with the giving name"
|
||||
);
|
||||
Ok(set)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function that checks for duplicates in a sorted slice
|
||||
fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> {
|
||||
items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0])
|
||||
}
|
||||
|
||||
fn ser_naddrs<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let str_naddrs = naddr
|
||||
.iter()
|
||||
.map(|n| n.to_bech32().map_err(|err| err.to_string()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
|
||||
str_naddrs.serialize(serializer)
|
||||
}
|
||||
|
||||
fn de_naddrs<'de, D>(deserializer: D) -> Result<HashSet<Nip19Coordinate>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Vec::<String>::deserialize(deserializer)?
|
||||
.into_iter()
|
||||
.map(|naddr| Nip19Coordinate::from_bech32(&naddr))
|
||||
.collect::<Result<HashSet<_>, _>>()
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
28
src/cli/defaults.rs
Normal file
28
src/cli/defaults.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
// 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::path::PathBuf;
|
||||
|
||||
use crate::{cli::ConfigError, error::N34Result};
|
||||
|
||||
|
||||
/// Default config path
|
||||
pub fn config_path() -> N34Result<PathBuf> {
|
||||
Ok(dirs::config_dir()
|
||||
.ok_or(ConfigError::CanNotFindConfigPath)?
|
||||
.join("n34")
|
||||
.join("config.toml"))
|
||||
}
|
||||
73
src/cli/mod.rs
Normal file
73
src/cli/mod.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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>.
|
||||
|
||||
/// Commands module
|
||||
pub mod commands;
|
||||
/// The CLI config
|
||||
pub mod config;
|
||||
/// Default lazy values for CLI arguments
|
||||
pub mod defaults;
|
||||
/// CLI arguments parsers
|
||||
pub mod parsers;
|
||||
/// CLI traits
|
||||
pub mod traits;
|
||||
/// Common helper types used throughout the CLI.
|
||||
pub mod types;
|
||||
|
||||
use clap::Parser;
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
|
||||
pub use self::commands::*;
|
||||
pub use self::config::*;
|
||||
use self::traits::CommandRunner;
|
||||
use crate::error::N34Result;
|
||||
|
||||
/// Header message, used in the help message
|
||||
const HEADER: &str = r#"Copyright (C) 2025 Awiteb <a@4rs.nl>
|
||||
License GNU GPL-3.0-or-later <https://gnu.org/licenses/gpl-3.0.html>
|
||||
This is free software: you are free to change and redistribute it.
|
||||
There is NO WARRANTY, to the extent permitted by law.
|
||||
|
||||
Git repository: https://git.4rs.nl/awiteb/n34.git"#;
|
||||
|
||||
/// Footer message, used in the help message
|
||||
const FOOTER: &str = r#"Please report bugs to <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#;
|
||||
|
||||
/// Name of the file storing the repository address
|
||||
pub const NOSTR_ADDRESS_FILE: &str = "nostr-address";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about, version, before_long_help = HEADER, after_long_help = FOOTER)]
|
||||
/// A command-line interface for interacting with NIP-34 and other Nostr
|
||||
/// code-related stuff.
|
||||
pub struct Cli {
|
||||
#[command(flatten)]
|
||||
pub options: commands::CliOptions,
|
||||
/// Controls the verbosity level of output
|
||||
#[command(flatten)]
|
||||
pub verbosity: Verbosity,
|
||||
/// The subcommand to execute
|
||||
#[command(subcommand)]
|
||||
pub command: commands::Commands,
|
||||
}
|
||||
|
||||
|
||||
impl Cli {
|
||||
/// Executes the command
|
||||
pub async fn run(self) -> N34Result<()> {
|
||||
self.command.run(self.options).await
|
||||
}
|
||||
}
|
||||
65
src/cli/parsers.rs
Normal file
65
src/cli/parsers.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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::{fs, path::Path};
|
||||
|
||||
use nostr::{
|
||||
Kind,
|
||||
nips::nip19::{FromBech32, Nip19Coordinate},
|
||||
};
|
||||
|
||||
use super::Cli;
|
||||
use crate::{
|
||||
cli::DEFAULT_FALLBACK_PATH,
|
||||
error::{N34Error, N34Result},
|
||||
};
|
||||
|
||||
pub fn parse_repo_naddr(repo_naddr: &str) -> Result<Nip19Coordinate, String> {
|
||||
let naddr = Nip19Coordinate::from_bech32(repo_naddr).map_err(|err| err.to_string())?;
|
||||
if naddr.relays.is_empty() {
|
||||
tracing::warn!("The repository naddr does not contain any relay hints");
|
||||
}
|
||||
|
||||
(naddr.kind == Kind::GitRepoAnnouncement)
|
||||
.then_some(naddr)
|
||||
.ok_or_else(|| "Invalid naddr: must be of kind 30617 (GitRepoAnnouncement)".to_owned())
|
||||
}
|
||||
|
||||
/// Parses a nostr-address file into a NIP-19 coordinates. Expects the file to
|
||||
/// contain a repository announcements.
|
||||
pub fn parse_nostr_address_file(file_path: &Path) -> N34Result<Vec<Nip19Coordinate>> {
|
||||
let addresses = fs::read_to_string(file_path)
|
||||
.map_err(N34Error::CanNotReadNostrAddressFile)?
|
||||
.split("\n")
|
||||
.filter_map(|line| {
|
||||
(!line.starts_with("#") && !line.trim().is_empty())
|
||||
.then_some(parse_repo_naddr(line).map_err(N34Error::InvalidNostrAddressFileContent))
|
||||
})
|
||||
.collect::<N34Result<Vec<Nip19Coordinate>>>()?;
|
||||
if addresses.is_empty() {
|
||||
return Err(N34Error::EmptyNostrAddressFile);
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
/// Post parse cli arguments
|
||||
pub fn post_parse_cli(mut cli: Cli) -> N34Result<Cli> {
|
||||
if let Some(DEFAULT_FALLBACK_PATH) = cli.options.config_path.to_str() {
|
||||
cli.options.config_path = super::defaults::config_path()?;
|
||||
}
|
||||
|
||||
Ok(cli)
|
||||
}
|
||||
24
src/cli/traits.rs
Normal file
24
src/cli/traits.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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 super::CliOptions;
|
||||
use crate::error::N34Result;
|
||||
|
||||
/// A trait defining the interface for command runners in the CLI.
|
||||
pub trait CommandRunner {
|
||||
/// Executes the command and returns a Result indicating success or failure.
|
||||
async fn run(self, options: CliOptions) -> N34Result<()>;
|
||||
}
|
||||
194
src/cli/types.rs
Normal file
194
src/cli/types.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
// 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::str::FromStr;
|
||||
|
||||
use nostr::{
|
||||
event::Kind,
|
||||
nips::{self, nip01::Coordinate, nip19::Nip19Coordinate},
|
||||
types::RelayUrl,
|
||||
};
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use super::{RepoRelaySetsExt, parsers};
|
||||
use crate::{
|
||||
cli::RepoRelaySet,
|
||||
error::{N34Error, N34Result},
|
||||
};
|
||||
|
||||
/// Either a NIP-19 coordinate (naddr) or a named set.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NaddrOrSet {
|
||||
/// NIP-19 coordinate.
|
||||
Naddr(Nip19Coordinate),
|
||||
/// Name of a set (may not exist).
|
||||
Set(String),
|
||||
}
|
||||
|
||||
/// Either relay URL or a named set.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RelayOrSet {
|
||||
/// Relay URL.
|
||||
Relay(RelayUrl),
|
||||
/// Name of a set (may not exist).
|
||||
Set(String),
|
||||
}
|
||||
|
||||
impl NaddrOrSet {
|
||||
/// Returns the naddr if `Naddr` or try to get the relays from the set.
|
||||
/// Returns error if the set naddrs are empty or the set not found.
|
||||
pub fn get_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> {
|
||||
match self {
|
||||
Self::Naddr(nip19_coordinate) => Ok(vec![nip19_coordinate]),
|
||||
Self::Set(name) => {
|
||||
let set = sets
|
||||
.get_set(&name)
|
||||
.map_err(|_| N34Error::InvalidNaddrArg(name.clone()))?;
|
||||
if set.naddrs.is_empty() {
|
||||
Err(N34Error::EmptySetNaddrs(name))
|
||||
} else {
|
||||
Ok(Vec::from_iter(set.naddrs.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl RelayOrSet {
|
||||
/// Returns the relay if `Relay` or try to get the relays from the set.
|
||||
/// Returns error if the set relays are empty or the set not found
|
||||
pub fn get_relays(self, sets: &[RepoRelaySet]) -> N34Result<Vec<RelayUrl>> {
|
||||
match self {
|
||||
Self::Relay(relay) => Ok(vec![relay]),
|
||||
Self::Set(name) => {
|
||||
let set = sets.get_set(&name)?;
|
||||
if set.relays.is_empty() {
|
||||
Err(N34Error::EmptySetRelays(name))
|
||||
} else {
|
||||
Ok(Vec::from_iter(set.relays.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for NaddrOrSet {
|
||||
type Err = String;
|
||||
|
||||
/// Parses a Git repository address which can be either:
|
||||
/// - A bech32-encoded naddr (e.g. "naddr1...") for Git repository
|
||||
/// announcements (kind 30617)
|
||||
/// - A NIP-05 identifier with repository ID (e.g. "4rs.nl/n34" or
|
||||
/// "_@4rs.nl/n34")
|
||||
/// - A set name.
|
||||
///
|
||||
/// Returns an error for invalid formats, failed bech32 decoding, wrong
|
||||
/// event kind.
|
||||
fn from_str(naddr_or_set: &str) -> Result<Self, Self::Err> {
|
||||
let naddr_or_set = naddr_or_set.trim();
|
||||
|
||||
if naddr_or_set.contains("/") {
|
||||
let (nip5, repo_id) = naddr_or_set.split_once("/").expect("There is a `/`");
|
||||
parse_nip5_repo(nip5, repo_id)
|
||||
} else if naddr_or_set.starts_with("naddr1") || naddr_or_set.starts_with("nostr:naddr1") {
|
||||
parsers::parse_repo_naddr(naddr_or_set.trim_start_matches("nostr:")).map(Self::Naddr)
|
||||
} else {
|
||||
Ok(Self::Set(naddr_or_set.to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RelayOrSet {
|
||||
type Err = String;
|
||||
|
||||
/// Parse a string into a relay URL or a set name.
|
||||
/// If the string is a valid URL (e.g., "wss://example.com"), it's treated
|
||||
/// as a relay URL. Otherwise, it's treated as a set name, and its
|
||||
/// associated relays will be merged.
|
||||
fn from_str(relay_or_set: &str) -> Result<Self, Self::Err> {
|
||||
let relay_or_set = relay_or_set.trim();
|
||||
|
||||
if relay_or_set.starts_with("wss://") {
|
||||
RelayUrl::from_str(relay_or_set)
|
||||
.map_err(|err| err.to_string())
|
||||
.map(Self::Relay)
|
||||
} else {
|
||||
Ok(Self::Set(relay_or_set.to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> {
|
||||
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
|
||||
|
||||
let nip5_profile = tokio::task::block_in_place(|| {
|
||||
Handle::current().block_on(async {
|
||||
nips::nip05::profile(format!("{username}@{domain}"), None)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(NaddrOrSet::Naddr(
|
||||
Nip19Coordinate::new(
|
||||
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
|
||||
nip5_profile.relays,
|
||||
)
|
||||
.expect("The relays is `RelayUrl`"),
|
||||
))
|
||||
}
|
||||
|
||||
#[easy_ext::ext(NaddrOrSetVecExt)]
|
||||
impl Vec<NaddrOrSet> {
|
||||
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
|
||||
/// [`Nip19Coordinates`] using the given sets.
|
||||
pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> {
|
||||
self.into_iter()
|
||||
.map(|n| n.get_naddrs(sets))
|
||||
.try_fold(Vec::new(), |mut acc, item| {
|
||||
acc.extend(item?);
|
||||
Ok(acc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[easy_ext::ext(RelayOrSetVecExt)]
|
||||
impl Vec<RelayOrSet> {
|
||||
/// Converts this vector of [`RelayOrSet`] into a flat vector of
|
||||
/// [`RelayUrl`] using the given sets.
|
||||
pub fn flat_relays(self, sets: &[RepoRelaySet]) -> N34Result<Vec<RelayUrl>> {
|
||||
self.into_iter()
|
||||
.map(|n| n.get_relays(sets))
|
||||
.try_fold(Vec::new(), |mut acc, item| {
|
||||
acc.extend(item?);
|
||||
Ok(acc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[easy_ext::ext(OptionNaddrOrSetVecExt)]
|
||||
impl Option<Vec<NaddrOrSet>> {
|
||||
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
|
||||
/// [`Nip19Coordinates`] using the given sets.
|
||||
pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result<Option<Vec<Nip19Coordinate>>> {
|
||||
// Clones self here to simplify command code
|
||||
self.clone()
|
||||
.map(|naddrs| naddrs.flat_naddrs(sets))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
90
src/error.rs
Normal file
90
src/error.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
// 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::process::ExitCode;
|
||||
|
||||
use nostr::event::builder::Error as EventBuilderError;
|
||||
use nostr_sdk::client::Error as ClientError;
|
||||
|
||||
use crate::cli::ConfigError;
|
||||
|
||||
pub type N34Result<T> = Result<T, N34Error>;
|
||||
|
||||
/// N34 errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum N34Error {
|
||||
#[error("IO: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("{0}")]
|
||||
Config(#[from] ConfigError),
|
||||
#[error("No editor specified in the `EDITOR` environment variable")]
|
||||
EditorNotFound,
|
||||
#[error("The file you edited is empty. Please save your changes before exiting the editor.")]
|
||||
EmptyEditorFile,
|
||||
#[error("Client Error: {0}")]
|
||||
Client(#[from] ClientError),
|
||||
#[error("Unable to locate the repository. The repository may not exists in the given relays")]
|
||||
NotFoundRepo,
|
||||
#[error("Failed building an event: {0}")]
|
||||
EventBuilder(#[from] EventBuilderError),
|
||||
#[error("Invalid repository id, it can't be empty and must be kebab-case")]
|
||||
InvalidRepoId,
|
||||
#[error("Invalid event: {0}")]
|
||||
InvalidEvent(String),
|
||||
#[error("Bech32 error: {0}")]
|
||||
Bech32(#[from] nostr::nips::nip19::Error),
|
||||
#[error("Event error: {0}")]
|
||||
Event(#[from] nostr::event::Error),
|
||||
#[error("Event not found in the specified relays")]
|
||||
EventNotFound,
|
||||
#[error(
|
||||
"Can't reply to this event. Only Git issues, patches, and their comments can be replied \
|
||||
to."
|
||||
)]
|
||||
CanNotReplyToEvent,
|
||||
#[error("No repository address given and couldn't read `nostr-address` file: {0}")]
|
||||
CanNotReadNostrAddressFile(std::io::Error),
|
||||
#[error(
|
||||
"The `nostr-address` file is empty. Please add a valid Nostr repository address (naddr) \
|
||||
to the file or provide it manually as a flag."
|
||||
)]
|
||||
EmptyNostrAddressFile,
|
||||
#[error("Invalid `nostr-address` file content: {0}")]
|
||||
InvalidNostrAddressFileContent(String),
|
||||
#[error("This command requires at least one relay, but none were provided")]
|
||||
EmptyRelays,
|
||||
#[error(
|
||||
"Invalid repository address. Expected one of these formats:\n- NIP-05 identifier with \
|
||||
repository ID: `<user@domain.com>/<repo_id>`\n- Valid NIP-19 naddr string (starts with \
|
||||
'naddr1...')\n- Existing set name (merges all repositories in set)\nError: No set named \
|
||||
'{0}' exists."
|
||||
)]
|
||||
InvalidNaddrArg(String),
|
||||
#[error(
|
||||
"The set '{0}' doesn't contain any addresses. Use 'sets update' to add addresses to it."
|
||||
)]
|
||||
EmptySetNaddrs(String),
|
||||
#[error("The set '{0}' doesn't contain any relays. Use 'sets update' to add addresses to it.")]
|
||||
EmptySetRelays(String),
|
||||
}
|
||||
|
||||
impl N34Error {
|
||||
/// Returns the exit code associated with this error
|
||||
pub fn exit_code(&self) -> ExitCode {
|
||||
// TODO: More specific exit code
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
65
src/main.rs
65
src/main.rs
@@ -14,6 +14,67 @@
|
||||
// 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>.
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
/// Command line interface module
|
||||
mod cli;
|
||||
/// N34 errors
|
||||
mod error;
|
||||
/// Nostr utils module
|
||||
mod nostr_utils;
|
||||
|
||||
use std::{
|
||||
process::ExitCode,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::{Layer, filter, layer::SubscriberExt};
|
||||
|
||||
use self::cli::Cli;
|
||||
|
||||
/// Whether the editor is currently open. Prevents logging while the editor is
|
||||
/// open.
|
||||
static EDITOR_OPEN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Configures the logging level based on the provided verbosity.
|
||||
///
|
||||
/// When verbosity is set to TRACE, includes file and line numbers in logs.
|
||||
fn set_log_level(verbosity: Verbosity) {
|
||||
let is_trace = verbosity
|
||||
.tracing_level()
|
||||
.is_some_and(|l| l == tracing::Level::TRACE);
|
||||
|
||||
let logs_filter = filter::dynamic_filter_fn(move |m, _| {
|
||||
// Disable all logs while editor is open
|
||||
verbosity.tracing_level().unwrap_or(Level::ERROR) >= *m.level()
|
||||
&& !EDITOR_OPEN.load(Ordering::Relaxed)
|
||||
});
|
||||
|
||||
let logs_layer = tracing_subscriber::fmt::layer()
|
||||
.with_file(is_trace)
|
||||
.with_line_number(is_trace)
|
||||
.without_time();
|
||||
let subscriber = tracing_subscriber::registry().with(logs_layer.with_filter(logs_filter));
|
||||
tracing::subscriber::set_global_default(subscriber).ok();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = match cli::parsers::post_parse_cli(Cli::parse()) {
|
||||
Ok(cli) => cli,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
set_log_level(cli.verbosity);
|
||||
|
||||
if let Err(err) = cli.run().await {
|
||||
tracing::error!("{err}");
|
||||
return err.exit_code();
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
330
src/nostr_utils/mod.rs
Normal file
330
src/nostr_utils/mod.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
// 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>.
|
||||
|
||||
/// Extension traits for nostr types.
|
||||
pub mod traits;
|
||||
/// Utility functions for nostr.
|
||||
pub mod utils;
|
||||
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
use futures::future;
|
||||
use nostr::{
|
||||
event::{Event, EventId, Kind, Tag, TagStandard, Tags, UnsignedEvent},
|
||||
filter::Filter,
|
||||
key::{Keys, PublicKey},
|
||||
nips::{nip01::Coordinate, nip22, nip34::GitRepositoryAnnouncement},
|
||||
parser::NostrParser,
|
||||
types::RelayUrl,
|
||||
};
|
||||
use nostr_sdk::Client;
|
||||
use traits::TokenUtils;
|
||||
|
||||
use crate::{
|
||||
cli::CliOptions,
|
||||
error::{N34Error, N34Result},
|
||||
};
|
||||
|
||||
/// Timeout duration for the clinet.
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Parsed content details
|
||||
#[derive(Clone)]
|
||||
pub struct ContentDetails {
|
||||
/// Public keys of users mentioned in the content.
|
||||
pub p_tagged: HashSet<PublicKey>,
|
||||
/// Event IDs and optional relay URLs for quoted events.
|
||||
pub quotes: HashSet<(EventId, Option<RelayUrl>)>,
|
||||
/// Hashtags found in the content.
|
||||
pub hashtags: HashSet<String>,
|
||||
/// Relays where mentioned users and quoted authors are read.
|
||||
pub write_relays: HashSet<RelayUrl>,
|
||||
}
|
||||
|
||||
/// A client for interacting with the Nostr relays
|
||||
pub struct NostrClient {
|
||||
/// The underlying Nostr client implementation
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ContentDetails {
|
||||
/// Create a new [`ContentDetails`] instance
|
||||
pub fn new(
|
||||
users: impl IntoIterator<Item = PublicKey>,
|
||||
quotes: impl IntoIterator<Item = (EventId, Option<RelayUrl>)>,
|
||||
hashtags: impl IntoIterator<Item = String>,
|
||||
write_relays: impl IntoIterator<Item = RelayUrl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
p_tagged: HashSet::from_iter(users),
|
||||
quotes: HashSet::from_iter(quotes),
|
||||
hashtags: HashSet::from_iter(hashtags),
|
||||
write_relays: HashSet::from_iter(write_relays),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the instance into a list of tags including hashtags, p-tagged
|
||||
/// users, and quoted events.
|
||||
pub fn into_tags(self) -> Tags {
|
||||
let mut tags = Tags::new();
|
||||
tags.extend(self.hashtags.into_iter().map(Tag::hashtag));
|
||||
tags.extend(self.p_tagged.into_iter().map(Tag::public_key));
|
||||
tags.extend(self.quotes.into_iter().map(|(event_id, relay_url)| {
|
||||
// TODO: Add the author public key if we know it
|
||||
Tag::from_standardized(TagStandard::Quote {
|
||||
event_id,
|
||||
relay_url,
|
||||
public_key: None,
|
||||
})
|
||||
}));
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrClient {
|
||||
/// Creates a new [`NostrClient`] with the given client and options.
|
||||
const fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Initializes a new [`NostrClient`] instance and connects to the specified
|
||||
/// relays.
|
||||
pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self {
|
||||
let client = Self::new(
|
||||
Client::builder()
|
||||
.signer(Keys::new(
|
||||
options
|
||||
.secret_key
|
||||
.as_ref()
|
||||
.expect("This the only method for now")
|
||||
.clone(),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
|
||||
client.add_relays(relays).await;
|
||||
client
|
||||
}
|
||||
|
||||
/// Add relays and connect to them
|
||||
pub async fn add_relays(&self, relays: &[RelayUrl]) {
|
||||
if relays.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for relay in relays {
|
||||
let relay = relay.clone();
|
||||
let client = self.client.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
client
|
||||
.add_relay(&relay)
|
||||
.await
|
||||
.expect("It's a valid relay url");
|
||||
if let Err(err) = client.try_connect_relay(&relay, CLIENT_TIMEOUT).await {
|
||||
tracing::error!("Failed to connect to relay '{relay}': {err}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
future::join_all(tasks).await;
|
||||
}
|
||||
|
||||
/// Add a relay hint and connect to it
|
||||
pub async fn add_relay_hint(&self, hint: Option<RelayUrl>) {
|
||||
if let Some(relay) = hint {
|
||||
self.add_relays(&[relay]).await
|
||||
}
|
||||
}
|
||||
|
||||
/// broadcast an event to the given relays
|
||||
pub async fn broadcast(&self, event: &Event, relays: &[RelayUrl]) -> N34Result<()> {
|
||||
self.client.send_event_to(relays, event).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcasts an unsigned event to given relays, optionally broadcast the
|
||||
/// relays list event. Returns URLs of relays that successfully received
|
||||
/// the event.
|
||||
pub async fn send_event_to(
|
||||
&self,
|
||||
mut event: UnsignedEvent,
|
||||
relays_list: Option<&Event>,
|
||||
relays: &[RelayUrl],
|
||||
) -> N34Result<Vec<RelayUrl>> {
|
||||
self.add_relays(relays).await;
|
||||
let event_id = event.id();
|
||||
|
||||
let (result, ..) = futures::join!(
|
||||
async {
|
||||
N34Result::Ok(
|
||||
self.client
|
||||
.send_event_to(relays, &event.sign(&self.client.signer().await?).await?)
|
||||
.await?,
|
||||
)
|
||||
},
|
||||
async {
|
||||
if let Some(event) = relays_list {
|
||||
let _ = self.client.send_event_to(relays, event).await;
|
||||
}
|
||||
}
|
||||
);
|
||||
let result = result?;
|
||||
|
||||
for relay in &result.success {
|
||||
tracing::info!(event_id = %event_id, relay = %relay, "Event sent successfully");
|
||||
}
|
||||
for (relay, reason) in &result.failed {
|
||||
tracing::warn!(event_id = %event_id, relay = %relay, reason = %reason, "Failed to send event");
|
||||
}
|
||||
|
||||
Ok(result.success.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Fetches the first event matching the given filter, or None if no event
|
||||
/// is found.
|
||||
pub async fn fetch_event(&self, filter: Filter) -> N34Result<Option<Event>> {
|
||||
Ok(self
|
||||
.client
|
||||
.fetch_events(filter, CLIENT_TIMEOUT)
|
||||
.await?
|
||||
.first_owned())
|
||||
}
|
||||
|
||||
/// Try to fetch the reposotoies and returns them
|
||||
pub async fn fetch_repos(
|
||||
&self,
|
||||
repo_naddrs: &[Coordinate],
|
||||
) -> N34Result<Vec<GitRepositoryAnnouncement>> {
|
||||
future::join_all(repo_naddrs.iter().map(|c| {
|
||||
async {
|
||||
self.fetch_event(
|
||||
Filter::new()
|
||||
.author(c.public_key)
|
||||
.identifier(&c.identifier)
|
||||
.kind(Kind::GitRepoAnnouncement),
|
||||
)
|
||||
.await?
|
||||
.map(|e| utils::event_into_repo(e, &c.identifier))
|
||||
.ok_or(N34Error::NotFoundRepo)
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finds the root issue or patch for a given event. If the event is already
|
||||
/// a root (issue/patch), returns it directly. For comments, follows
|
||||
/// parent/root references until finding the root or failing. Returns
|
||||
/// None if no root can be found.
|
||||
pub async fn find_root(&self, mut event: Event) -> N34Result<Option<Event>> {
|
||||
if !matches!(event.kind, Kind::GitIssue | Kind::GitPatch | Kind::Comment) {
|
||||
return Err(N34Error::CanNotReplyToEvent);
|
||||
}
|
||||
|
||||
loop {
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPatch) {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
|
||||
if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
|
||||
nip22::extract_root(&event)
|
||||
{
|
||||
self.add_relay_hint(relay_hint.cloned()).await;
|
||||
let root_event = self.fetch_event(Filter::new().id(*id)).await?;
|
||||
if let Some(ref root_event) = root_event {
|
||||
if !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch) {
|
||||
return Err(N34Error::CanNotReplyToEvent);
|
||||
}
|
||||
}
|
||||
return Ok(root_event);
|
||||
} else if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
|
||||
nip22::extract_parent(&event)
|
||||
{
|
||||
self.add_relay_hint(relay_hint.cloned()).await;
|
||||
if let Ok(Some(parent_event)) = self.fetch_event(Filter::new().id(*id)).await {
|
||||
event = parent_event;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Break if: no root/parent tags found, parent/root event fetch failed
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Fetches the relay list (kind 10002) for the given user. Returns None if
|
||||
/// no relays are found.
|
||||
pub async fn user_relays_list(&self, user: PublicKey) -> N34Result<Option<Event>> {
|
||||
self.fetch_event(Filter::new().author(user).kind(Kind::RelayList))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Gets the author of the specified event, if found.
|
||||
pub async fn event_author(&self, event_id: EventId) -> N34Result<Option<PublicKey>> {
|
||||
Ok(self
|
||||
.fetch_event(Filter::new().id(event_id))
|
||||
.await?
|
||||
.map(|e| e.pubkey))
|
||||
}
|
||||
|
||||
/// Returns the read relays of the given user if found, otherwise empty
|
||||
/// vector
|
||||
pub async fn read_relays_from_user(&self, user: PublicKey) -> Vec<RelayUrl> {
|
||||
utils::add_read_relays(self.user_relays_list(user).await.ok().flatten().as_ref())
|
||||
}
|
||||
|
||||
/// Parse the given content and returns the details that inside it
|
||||
pub async fn parse_content(&self, content: &str) -> ContentDetails {
|
||||
let mut write_relays = Vec::new();
|
||||
let tokens = NostrParser::new().parse(content).collect::<Vec<_>>();
|
||||
|
||||
let mut p_tagged_users = tokens
|
||||
.iter()
|
||||
.filter_map(TokenUtils::extract_public_key)
|
||||
.collect::<Vec<_>>();
|
||||
let quotes = tokens
|
||||
.iter()
|
||||
.filter_map(TokenUtils::extract_event_id)
|
||||
.collect::<Vec<_>>();
|
||||
let hashtags = tokens
|
||||
.iter()
|
||||
.filter_map(TokenUtils::extract_hashtag)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (user, relays) in &p_tagged_users {
|
||||
self.add_relays(relays).await;
|
||||
write_relays.extend(self.read_relays_from_user(*user).await);
|
||||
}
|
||||
for (event_id, relays) in "es {
|
||||
self.add_relays(relays).await;
|
||||
// Add the event author to the p-tagged users
|
||||
if let Ok(Some(author)) = self.event_author(*event_id).await {
|
||||
p_tagged_users.push((author, Vec::new()));
|
||||
write_relays.extend(self.read_relays_from_user(author).await);
|
||||
}
|
||||
}
|
||||
|
||||
ContentDetails::new(
|
||||
p_tagged_users.into_iter().map(|(p, _)| p),
|
||||
quotes.into_iter().map(|(e, r)| (e, r.first().cloned())),
|
||||
hashtags,
|
||||
write_relays,
|
||||
)
|
||||
}
|
||||
}
|
||||
191
src/nostr_utils/traits.rs
Normal file
191
src/nostr_utils/traits.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
// 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 convert_case::{Case, Casing};
|
||||
use nostr::{
|
||||
event::{EventBuilder, EventId, Tag, TagKind, TagStandard, Tags},
|
||||
key::PublicKey,
|
||||
nips::{
|
||||
nip01::Coordinate,
|
||||
nip19::Nip19Coordinate,
|
||||
nip21::Nip21,
|
||||
nip34::{GitIssue, GitRepositoryAnnouncement},
|
||||
},
|
||||
parser::Token,
|
||||
types::{RelayUrl, Url},
|
||||
};
|
||||
|
||||
use crate::error::{N34Error, N34Result};
|
||||
|
||||
|
||||
/// A trait to add helper instance function to [`Tags`] type
|
||||
#[easy_ext::ext(TagsExt)]
|
||||
impl Tags {
|
||||
/// Search for the given tag and map it value to a function
|
||||
pub fn map_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> Option<T> {
|
||||
self.find_standardized(kind).map(f)
|
||||
}
|
||||
|
||||
/// Search for the given tag and map it value to a function. If the tag not
|
||||
/// found return the default `T`
|
||||
pub fn dmap_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> T
|
||||
where
|
||||
T: Default,
|
||||
{
|
||||
self.map_tag(kind, f).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Finds the first standard tag of the given kind with the specified
|
||||
/// marker, then applies the function to the tag and returns the result.
|
||||
pub fn map_marker<T>(
|
||||
&self,
|
||||
kind: TagKind,
|
||||
marker: &str,
|
||||
f: impl FnOnce(&TagStandard) -> T,
|
||||
) -> Option<T> {
|
||||
self.filter_standardized(kind)
|
||||
.find(|t| (*t).clone().to_vec().last().is_some_and(|m| m == marker))
|
||||
.map(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for building [`GitRepositoryAnnouncement`] events
|
||||
#[easy_ext::ext(NewGitRepositoryAnnouncement)]
|
||||
impl EventBuilder {
|
||||
/// Creates a new [`GitRepositoryAnnouncement`] event builder with the given
|
||||
/// repository details.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_git_repo(
|
||||
repo_id: String,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
web: Vec<Url>,
|
||||
clone: Vec<Url>,
|
||||
relays: Vec<RelayUrl>,
|
||||
maintainers: Vec<PublicKey>,
|
||||
labels: Vec<String>,
|
||||
force_id: bool,
|
||||
) -> N34Result<EventBuilder> {
|
||||
let repo_id = repo_id.trim();
|
||||
let kebab_repo_id = repo_id.to_case(Case::Kebab);
|
||||
if repo_id.is_empty() || (!force_id && repo_id != kebab_repo_id) {
|
||||
if repo_id != kebab_repo_id {
|
||||
tracing::error!(
|
||||
"The repo id should be `{kebab_repo_id}` (kebab-case). Use `--force-id` to \
|
||||
override this check"
|
||||
);
|
||||
}
|
||||
return Err(N34Error::InvalidRepoId);
|
||||
}
|
||||
|
||||
Ok(
|
||||
EventBuilder::git_repository_announcement(GitRepositoryAnnouncement {
|
||||
id: repo_id.to_owned(),
|
||||
name,
|
||||
description,
|
||||
web,
|
||||
clone,
|
||||
relays,
|
||||
euc: None,
|
||||
maintainers,
|
||||
})?
|
||||
.dedup_tags()
|
||||
.tags(labels.into_iter().map(Tag::hashtag)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new [`GitIssue`] event builder with the given
|
||||
/// issue details.
|
||||
pub fn new_git_issue(
|
||||
repository: Coordinate,
|
||||
content: String,
|
||||
subject: Option<String>,
|
||||
labels: Vec<String>,
|
||||
) -> N34Result<EventBuilder> {
|
||||
EventBuilder::git_issue(GitIssue {
|
||||
repository,
|
||||
content,
|
||||
subject,
|
||||
labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(),
|
||||
})
|
||||
.map_err(N34Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper functions for [`Token`] type
|
||||
#[easy_ext::ext(TokenUtils)]
|
||||
impl Token<'_> {
|
||||
/// Returns `Some((public_key, relays))` from the givin token if it's npub1
|
||||
/// or nprofile1
|
||||
pub fn extract_public_key(&self) -> Option<(PublicKey, Vec<RelayUrl>)> {
|
||||
match self {
|
||||
Token::Nostr(nip21) => {
|
||||
match nip21 {
|
||||
Nip21::Pubkey(pkey) => Some((*pkey, Vec::new())),
|
||||
Nip21::Profile(profile) => Some((profile.public_key, profile.relays.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Some((note_id, relays))` from the givin token if it's note1 or
|
||||
/// nevent1
|
||||
pub fn extract_event_id(&self) -> Option<(EventId, Vec<RelayUrl>)> {
|
||||
match self {
|
||||
Token::Nostr(nip21) => {
|
||||
match nip21 {
|
||||
Nip21::EventId(event_id) => Some((*event_id, Vec::new())),
|
||||
Nip21::Event(event) => Some((event.event_id, event.relays.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Some(hashtag)` from the givin token if it's a hashtag
|
||||
pub fn extract_hashtag(&self) -> Option<String> {
|
||||
match self {
|
||||
Token::Hashtag(tag) => Some(tag.trim().to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility functions for working with lists of NIP-19 coordinates
|
||||
#[easy_ext::ext(NaddrsUtils)]
|
||||
impl Vec<Nip19Coordinate> {
|
||||
/// Converts these coordinate addresses to basic coordinates
|
||||
pub fn into_coordinates(self) -> Vec<Coordinate> {
|
||||
self.into_iter().map(|n| n.coordinate).collect()
|
||||
}
|
||||
|
||||
/// Extracts all relay URLs from these coordinates
|
||||
pub fn extract_relays(&self) -> Vec<RelayUrl> {
|
||||
self.iter().flat_map(|n| n.relays.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility functions for working with lists of repository announcement
|
||||
#[easy_ext::ext(ReposUtils)]
|
||||
impl Vec<GitRepositoryAnnouncement> {
|
||||
/// Extracts all relay URLs from these reposotoies
|
||||
pub fn extract_relays(&self) -> Vec<RelayUrl> {
|
||||
self.iter().flat_map(|n| n.relays.clone()).collect()
|
||||
}
|
||||
}
|
||||
239
src/nostr_utils/utils.rs
Normal file
239
src/nostr_utils/utils.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
// 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, TagKind, TagStandard},
|
||||
filter::{Alphabet, SingleLetterTag},
|
||||
key::PublicKey,
|
||||
nips::{
|
||||
nip01::Coordinate,
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user