35 Commits

Author SHA1 Message Date
Awiteb
ce0068be53 chore: Bump the version to v0.2.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:32:24 +00:00
Awiteb
4c6578ccb1 feat: Support relays and naddrs sets
This commit introduces support for 'sets', a feature that simplifies
referencing multiple relays or naddrs with a single word.

Sets are stored in the config directory under `n34` as `config.toml` in
`TOML` format. The config path follows the `$XDG_CONFIG_HOME` standard,
defaulting to `$HOME/.config` if unset.

The `n34 sets` command allows managing sets, adding, removing, updating,
or displaying them. When using the main `--relays` flag, a set name can
be provided to automatically expand its relays (an error occurs if the
set contains no relays). Similarly, any command accepting naddrs can
reference a set to extract its naddrs.

Sets can also be merged by updating a set while specifying another
set in `--repo` merges their naddrs, while `--set-relays` merges their
relays. Removal follows the same pattern.

Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:12:09 +00:00
Awiteb
d6d3f8a52f chore: Add more relays to the repo address
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 11:59:49 +00:00
Awiteb
84bfafa2e9 deps: Add serde@1.0.219, dirs@6.0.0 and toml@0.8.22
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 11:59:12 +00:00
Awiteb
24e8522268 chore: Move the write relays definition to the end of the commands
To make it clear, we can review exactly what was collected instead of
having it spread throughout the body of the command.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 19:20:42 +00:00
Awiteb
f670859b92 chore: Remove the vector parameter from add_{read,write}_relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 12:35:26 +00:00
Awiteb
bca86f115f perf(NostrClient::send_event_to): Join the event sending
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 12:00:10 +00:00
Awiteb
a9b9a7a02a chore: Remove unused import
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 11:04:58 +00:00
Awiteb
1abb8e3f36 feat: Events and naddrs can starts with nostr:
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Fixes: note1jwgfl095vuhduepxxpwhdqme0dnlhawzvcal0xnl0e6d82wx5jys5z92hj
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 10:48:33 +00:00
Awiteb
15d834dc0f chore: Deduplicate tags
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:45:20 +00:00
Awiteb
ea862dac5f chore: Add a newline after the naddr
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:29:36 +00:00
Awiteb
55a4868813 fix(utils-repo_naddr): Create a valid naddr string
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:29:13 +00:00
Awiteb
60978f43b5 chore: Add a header to nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:02:37 +00:00
Awiteb
37cf601c01 refactor: Support more than one naddr instead of one
This commit supports multiple naddrs for sending issues to and searching
for repositories by extracting the embedded relays, and appending to the
address file instead of overwriting its previous contents. A header has
also been added to the `nostr-address` file to clarify its purpose.

This enhancement accommodates maintainers who distribute issue tracking
across multiple repositories, preventing confusion if the primary
maintainer steps down. Now, issues and patches will reference all
relevant repositories, not just one.

Note that repositories won't automatically include all maintainers'
addresses unless explicitly specified. Maintainers may manage a
repository under a project-specific public key rather than individual
keys. Addresses are only added to issues and patches when explicitly
provided via the command or written to the address file. This prevents
unintended inclusions and ensures clarity.

Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 07:05:13 +00:00
Awiteb
87da5035da chore(todo): Some tasks
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 14:08:25 +00:00
Awiteb
ddea5028ee feat: Make the relays list optional
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 12:30:40 +00:00
Awiteb
de68d61c3f feat: Enter repository as nip5
For example, `4rs.nl/n34` and `_@4rs.nl/n34`

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 11:00:37 +00:00
Awiteb
f7e837e3ab deps: Enable nip05 feature of nostr crate
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 10:57:15 +00:00
Awiteb
02070c2868 feat: A --quote-to flag to quote the replied to content in the editor
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 06:06:32 +00:00
Awiteb
998ef8f4b1 deps: Add chrono@0.4.41 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 06:03:17 +00:00
Awiteb
6fdf0dbcf5 feat: Read the nostr-address file in reply command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 13:38:21 +00:00
Awiteb
15c0eaa5dc chore: Make commands module
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 13:15:37 +00:00
Awiteb
226909ef16 feat: Read the nostr-address file in issue new command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 12:38:26 +00:00
Awiteb
8ca88804dd feat: Read the nostr-address file in repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 12:36:48 +00:00
Awiteb
55f9b9194e chore: Utils to work with the nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 20:45:01 +00:00
Awiteb
a26116702d chore: Errors to work with the nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 20:44:41 +00:00
Awiteb
35d376ca6c chore: Add nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 19:19:27 +00:00
Awiteb
57b48c7368 feat: Add --address-file flag to repo announce command
This flag creates a file in the current working directory and writes
the `naddr` into it. This allows n34 and other tools to recognize the
repository address automatically, eliminating the need for manual input
by the user.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 19:17:07 +00:00
Awiteb
b29937a04e chore(changelog): Update the body
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 15:15:49 +00:00
Awiteb
06374fe1fc feat: add --force-id flag to bypass case validation in repo announce
The new flag allows users to skip the kebkab case ID check when announcing
repositories.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
2d5c0bf549 chore(git-cliff): Upper first after the trim
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
0a5b4656c3 chore(taplo): Exclude target dir
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
cad2c7faa3 chore: Update CONTRIBUTING.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
19cfecb9ea chore(readme): Add Contributions & Changes section
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 06:11:28 +00:00
Awiteb
89d0710ce5 chore(justfile): Add a subject to the tag message
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 23:57:16 +00:00
34 changed files with 2580 additions and 552 deletions

View File

@@ -1,3 +1,5 @@
exclude = ["target/**/*.toml", "Cargo.lock"]
[formatting] [formatting]
align_entries = true align_entries = true
indent_tables = true indent_tables = true

View File

@@ -5,31 +5,61 @@ 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/), 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). 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 ## [0.1.0] - 2025-05-21
### Added ### Added
- Setup the CLI and create `repo view` command - (Awiteb at [d962732](https://git.4rs.nl/awiteb/n34.git/commit/?id=d962732e5251b3af81b6d12327e258b49e3e603e)) - Setup the CLI and create `repo view` command - by Awiteb
- Add `repo announce` command - (Awiteb at [b444aeb](https://git.4rs.nl/awiteb/n34.git/commit/?id=b444aeba0ac6d19cb32715fb70c7dc1da297f8e9)) - Add `repo announce` command - by Awiteb
- Support PoW - (Awiteb at [c0a5e47](https://git.4rs.nl/awiteb/n34.git/commit/?id=c0a5e4779385024c8b65ae7626081a54ccbcb5b7)) - Support PoW - by Awiteb
- Add `issue new` command - (Awiteb at [54f1c7e](https://git.4rs.nl/awiteb/n34.git/commit/?id=54f1c7e0e68670926a99993651db6f4e15087250)) - Add `issue new` command - by Awiteb
- New `reply` command - (Awiteb at [9444fc1](https://git.4rs.nl/awiteb/n34.git/commit/?id=9444fc1a8c75dcfb9f74be5e7ea7df6759a5ce6c)) - New `reply` command - by Awiteb
### Dependencies ### Dependencies
- Add nedded dependencies - (Awiteb at [d37c696](https://git.4rs.nl/awiteb/n34.git/commit/?id=d37c6964f7111097550a04d25768085d4c6694d6)) - Add nedded dependencies - by Awiteb
- Add `easy_ext@1.0.2` to the dependencies - (Awiteb at [fb77a8c](https://git.4rs.nl/awiteb/n34.git/commit/?id=fb77a8ca141e885f351ef671680dbc3f54229303)) - Add `easy_ext@1.0.2` to the dependencies - by Awiteb
- Add `convert_case@0.8.0` to the dependencies - (Awiteb at [8a553f4](https://git.4rs.nl/awiteb/n34.git/commit/?id=8a553f4de85891c40965a9100f2853034919b6c8)) - Add `convert_case@0.8.0` to the dependencies - by Awiteb
- Add `tempfile@3.19.1` to the dependencies - (Awiteb at [ce58f29](https://git.4rs.nl/awiteb/n34.git/commit/?id=ce58f2902246637c8ebd8b66d14cbdf958c09543)) - Add `tempfile@3.19.1` to the dependencies - by Awiteb
- Add `futures@0.3.31` to the dependencies - (Awiteb at [bd08653](https://git.4rs.nl/awiteb/n34.git/commit/?id=bd086538fdeed1b39d3d8a355fc453722ab04bc2)) - Add `futures@0.3.31` to the dependencies - by Awiteb
- Bump `nostr` and `nostr_sdk` to `0.42.0` - (Awiteb at [724e270](https://git.4rs.nl/awiteb/n34.git/commit/?id=724e2707c69fc8ac16f05331970919439619346e)) - Bump `nostr` and `nostr_sdk` to `0.42.0` - by Awiteb
## [0.0.0] - 2025-05-01 ## [0.0.0] - 2025-05-01
### Added ### Added
- Initialize the project - (Awiteb at [c3594c6](https://git.4rs.nl/awiteb/n34.git/commit/?id=c3594c64671f622386a6dab12cb97b9f44c1611e)) - 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.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 [0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0

View File

@@ -8,6 +8,22 @@ Before submitting changes, please read the [Developer Certificate of Origin](DCO
All patches must include a `Signed-off-by: NAME <EMAIL>` line to acknowledge All patches must include a `Signed-off-by: NAME <EMAIL>` line to acknowledge
your agreement with the DCO. your agreement with the DCO.
Ensure your Git name and email are correctly configured. While you dont 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 theyre 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 We welcome all contributions, whether it be bug reports, fixes, feature
submissions, feature requests, or improving documentation or testing. Enjoy submissions, feature requests, or improving documentation or testing. Enjoy
collaborating! collaborating!
@@ -49,6 +65,9 @@ to their read relays, most tools handle this automatically.
#### Patch Guidelines #### Patch Guidelines
- Keep patches small: Focused changes are easier to review and merge. - 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: - Use [Conventional Commits]: Start the patch subject with one of these types:
- `feat`: New feature - `feat`: New feature
- `fix`: Bug fix - `fix`: Bug fix
@@ -62,7 +81,6 @@ to their read relays, most tools handle this automatically.
- For all other changes, use `chore`. - For all other changes, use `chore`.
- Add `!` to the subject if your patch contains a breacking change, e.g. - Add `!` to the subject if your patch contains a breacking change, e.g.
`remove!: text` and `fix(reply)!: text` `remove!: text` and `fix(reply)!: text`
- Run `just ci` before submitting your patch.
#### Code Style #### Code Style

656
Cargo.lock generated
View File

@@ -36,6 +36,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.18" version = "0.6.18"
@@ -147,7 +162,7 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
"object", "object",
"rustc-demangle", "rustc-demangle",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -272,6 +287,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.9.1" version = "0.9.1"
@@ -296,6 +317,20 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -372,6 +407,12 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -409,6 +450,27 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -432,6 +494,12 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.12" version = "0.3.12"
@@ -582,9 +650,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasi 0.14.2+wasi-0.2.4", "wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -605,6 +675,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "hashbrown"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -646,12 +722,116 @@ dependencies = [
"itoa", "itoa",
] ]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
dependencies = [
"futures-util",
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 0.26.11",
]
[[package]]
name = "hyper-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"libc",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.0.0" version = "2.0.0"
@@ -759,6 +939,16 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "indexmap"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -781,6 +971,12 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@@ -815,6 +1011,16 @@ version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags",
"libc",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.9.4" version = "0.9.4"
@@ -845,6 +1051,12 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.8" version = "0.8.8"
@@ -867,18 +1079,22 @@ dependencies = [
[[package]] [[package]]
name = "n34" name = "n34"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"chrono",
"clap", "clap",
"clap-verbosity-flag", "clap-verbosity-flag",
"convert_case", "convert_case",
"dirs",
"easy-ext", "easy-ext",
"futures", "futures",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"serde",
"tempfile", "tempfile",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -905,6 +1121,7 @@ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.16",
"instant", "instant",
"regex", "regex",
"reqwest",
"scrypt", "scrypt",
"secp256k1", "secp256k1",
"serde", "serde",
@@ -965,6 +1182,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@@ -986,6 +1212,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "overload" name = "overload"
version = "0.1.1" version = "0.1.1"
@@ -1069,6 +1301,60 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quinn"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.12",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b"
dependencies = [
"bytes",
"getrandom 0.3.3",
"rand 0.9.1",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.12",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@@ -1143,6 +1429,17 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
] ]
[[package]]
name = "redox_users"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.11.1" version = "1.11.1"
@@ -1172,6 +1469,50 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-socks",
"tower",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 0.26.11",
"windows-registry",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1192,6 +1533,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.7" version = "1.0.7"
@@ -1219,12 +1566,22 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.12.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -1324,6 +1681,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -1415,6 +1793,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.13.2" version = "0.13.2"
@@ -1579,6 +1966,74 @@ dependencies = [
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
] ]
[[package]]
name = "toml"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.41" version = "0.1.41"
@@ -1636,6 +2091,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.26.2" version = "0.26.2"
@@ -1740,6 +2201,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"
@@ -1836,6 +2306,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@@ -1876,13 +2356,92 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-registry"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -1891,7 +2450,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -1900,14 +2459,30 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
] ]
[[package]] [[package]]
@@ -1916,48 +2491,105 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen-rt" name = "wit-bindgen-rt"
version = "0.39.0" version = "0.39.0"

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "n34" name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr" description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"] authors = ["Awiteb <a@4rs.nl>"]
@@ -12,17 +12,21 @@ categories = ["command-line-utilities"]
rust-version = "1.85.1" rust-version = "1.85.1"
[dependencies] [dependencies]
chrono = "0.4.41"
clap = { version = "4.5.37", features = ["derive"] } clap = { version = "4.5.37", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [ clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [
"tracing", "tracing",
] } ] }
convert_case = "0.8.0" convert_case = "0.8.0"
dirs = "6.0.0"
easy-ext = "1.0.2" easy-ext = "1.0.2"
futures = "0.3.31" futures = "0.3.31"
nostr = { version = "0.42.0", features = ["parser"] } nostr = { version = "0.42.0", features = ["parser", "nip05"] }
nostr-sdk = "0.42.0" nostr-sdk = "0.42.0"
serde = { version = "1.0.219", features = ["derive"] }
tempfile = "3.19.1" tempfile = "3.19.1"
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
toml = "0.8.22"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"

View File

@@ -61,6 +61,11 @@ reports or feature requests) or submitting patches. **All contributions must be
submitted through Nostr**. For more details on the process, please refer to the submitted through Nostr**. For more details on the process, please refer to the
[CONTRIBUTING.md](CONTRIBUTING.md) file. Your support is greatly appreciated! [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 ## License
n34 is licensed under the GPL-3.0 License. This means that you are free to use, n34 is licensed under the GPL-3.0 License. This means that you are free to use,

View File

@@ -22,8 +22,8 @@ body = """
{% for group, commits in commits | group_by(attribute="group") %} {% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }} ### {{ group | upper_first }}
{% for commit in commits %} {% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | upper_first | trim }} - \ - {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | trim | upper_first }} - \
({{ commit.author.name}} at [{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/?id={{ commit.id }}))\ by {{ commit.author.name}}\
{% endfor %} {% endfor %}
{% endfor %}\n {% endfor %}\n
""" """

View File

@@ -64,7 +64,7 @@ changelog:
[script] [script]
release version: release version:
set -e set -e
TAG_MSG=$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}') 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 sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
taplo fmt --config ./.taplo.toml ./Cargo.toml taplo fmt --config ./.taplo.toml ./Cargo.toml
{{ JUST_EXECUTABLE }} ci {{ JUST_EXECUTABLE }} ci

15
nostr-address Normal file
View 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

View File

@@ -16,12 +16,22 @@
use clap::{ArgGroup, Args}; use clap::{ArgGroup, Args};
use nostr::{event::EventBuilder, nips::nip19::Nip19Coordinate}; use futures::future;
use nostr::event::{EventBuilder, Tag};
use crate::{ use crate::{
cli::{CliOptions, CommandRunner, parsers}, cli::{
CliConfig,
CliOptions,
CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::N34Result, error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils}, nostr_utils::{
NostrClient,
traits::{NaddrsUtils, NewGitRepositoryAnnouncement, ReposUtils},
utils,
},
}; };
@@ -39,9 +49,12 @@ use crate::{
) )
)] )]
pub struct NewArgs { pub struct NewArgs {
/// Repository address /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
#[arg(short, long, value_parser = parsers::repo_naddr)] /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
naddr: Nip19Coordinate, ///
/// 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 /// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag. /// `--editor` flag.
#[arg(short, long)] #[arg(short, long)]
@@ -69,7 +82,7 @@ impl NewArgs {
return Ok((None, content.trim().to_owned())); return Ok((None, content.trim().to_owned()));
} }
// If the `self.content` is `None` then the `self.editor` is `true` // If the `self.content` is `None` then the `self.editor` is `true`
let file_content = utils::read_editor(".md")?; let file_content = utils::read_editor(None, ".md")?;
if file_content.contains('\n') { if file_content.contains('\n') {
Ok(file_content Ok(file_content
.split_once('\n') .split_once('\n')
@@ -84,30 +97,60 @@ impl NewArgs {
impl CommandRunner for NewArgs { impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await; 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 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 relays_list = client.user_relays_list(user_pubk).await?;
let mut write_relays =
utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
client.add_relays(&options.relays).await;
client.add_relays(&self.naddr.relays).await;
write_relays.extend(client.fetch_repo(&self.naddr.coordinate).await?.relays);
let (subject, content) = self.issue_content()?; let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await; let content_details = client.parse_content(&content).await;
write_relays.extend(content_details.write_relays.clone());
let event = EventBuilder::new_git_issue( let event = EventBuilder::new_git_issue(
self.naddr.coordinate.clone(), naddrs_iter
.next()
.expect("There is at least one address")
.coordinate
.clone(),
content, content,
subject, subject,
self.label, self.label,
)? )?
.tags(content_details.into_tags()) .dedup_tags()
.pow(options.pow) .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); .build(user_pubk);
let event_id = event.id.expect("There is an id"); 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"); tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client let success = client
.send_event_to(event, relays_list.as_ref(), &write_relays) .send_event_to(event, relays_list.as_ref(), &write_relays)

141
src/cli/commands/mod.rs Normal file
View 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
View 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()
}
}

View 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(())
}
}

View 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")
}

View 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,
}
}
}

View 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)
}
}

View 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)
}
}

View 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
)
}

View 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
View 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
View 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"))
}

View File

@@ -14,27 +14,24 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue` subcommands /// Commands module
mod issue; pub mod commands;
/// The CLI config
pub mod config;
/// Default lazy values for CLI arguments
pub mod defaults;
/// CLI arguments parsers /// CLI arguments parsers
pub mod parsers; pub mod parsers;
/// 'reply` command
mod reply;
/// `repo` subcommands
mod repo;
/// CLI traits /// CLI traits
mod traits; pub mod traits;
/// Common helper types used throughout the CLI.
pub mod types;
use std::fmt; use clap::Parser;
use clap::{ArgGroup, Args, Parser};
use clap_verbosity_flag::Verbosity; use clap_verbosity_flag::Verbosity;
use nostr::key::Keys;
use nostr::{PublicKey, RelayUrl, SecretKey};
use self::issue::IssueSubcommands; pub use self::commands::*;
use self::reply::ReplyArgs; pub use self::config::*;
use self::repo::RepoSubcommands;
use self::traits::CommandRunner; use self::traits::CommandRunner;
use crate::error::N34Result; use crate::error::N34Result;
@@ -49,26 +46,8 @@ Git repository: https://git.4rs.nl/awiteb/n34.git"#;
/// Footer message, used in the help message /// Footer message, used in the help message
const FOOTER: &str = r#"Please report bugs to <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#; const FOOTER: &str = r#"Please report bugs to <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#;
/// The command-line interface options /// Name of the file storing the repository address
#[derive(Args, Clone)] pub const NOSTR_ADDRESS_FILE: &str = "nostr-address";
#[clap(
group(
ArgGroup::new("auth")
.args(&["secret_key"])
.required(true)
)
)]
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long)]
pub secret_key: Option<SecretKey>,
/// Where your relays list. And repository relays if not included in naddr
#[arg(short, long, required = true)]
pub relays: Vec<RelayUrl>,
/// Proof of Work difficulty when creatring events
#[arg(long, default_value_t = 0)]
pub pow: u8,
}
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(about, version, before_long_help = HEADER, after_long_help = FOOTER)] #[command(about, version, before_long_help = HEADER, after_long_help = FOOTER)]
@@ -76,36 +55,15 @@ pub struct CliOptions {
/// code-related stuff. /// code-related stuff.
pub struct Cli { pub struct Cli {
#[command(flatten)] #[command(flatten)]
pub options: CliOptions, pub options: commands::CliOptions,
/// Controls the verbosity level of output /// Controls the verbosity level of output
#[command(flatten)] #[command(flatten)]
pub verbosity: Verbosity, pub verbosity: Verbosity,
/// The subcommand to execute /// The subcommand to execute
#[command(subcommand)] #[command(subcommand)]
pub command: Commands, pub command: commands::Commands,
} }
/// N34 commands
#[derive(Parser, Debug)]
pub enum Commands {
/// 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 Cli { impl Cli {
/// Executes the command /// Executes the command
@@ -113,34 +71,3 @@ impl Cli {
self.command.run(self.options).await self.command.run(self.options).await
} }
} }
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,
}
}
}
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")
}
}
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()
}
}

View File

@@ -14,26 +14,52 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fs, path::Path};
use nostr::{ use nostr::{
Kind, Kind,
nips::nip19::{FromBech32, Nip19Coordinate}, nips::nip19::{FromBech32, Nip19Coordinate},
}; };
/// Parses a Nostr naddr string into a Git repository announcement coordinate. use super::Cli;
/// use crate::{
/// # Errors cli::DEFAULT_FALLBACK_PATH,
/// Returns an error if: error::{N34Error, N34Result},
/// - The bech32 decoding fails };
/// - The naddr doesn't represent a Git repository announcement (kind != 30617)
pub fn repo_naddr(naddr: &str) -> Result<Nip19Coordinate, String> {
let naddr = Nip19Coordinate::from_bech32(naddr).map_err(|err| err.to_string())?;
if naddr.kind != Kind::GitRepoAnnouncement {
return Err("The naddr is not repo announcement address".to_owned());
}
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() { if naddr.relays.is_empty() {
tracing::warn!("The repository naddr does not contain any relay hints"); tracing::warn!("The repository naddr does not contain any relay hints");
} }
Ok(naddr) (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)
} }

View File

@@ -1,178 +0,0 @@
// 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 clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, EventId, Kind, Tag},
filter::Filter,
nips::nip19::{self, FromBech32, Nip19Coordinate},
types::RelayUrl,
};
use super::{CliOptions, CommandRunner, parsers};
use crate::{
error::{N34Error, N34Result},
nostr_utils::{NostrClient, utils},
};
/// 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> {
if s.trim().starts_with("nevent1") {
let event = nip19::Nip19Event::from_bech32(s).map_err(|e| e.to_string())?;
Ok(Self::new(event.event_id, event.relays))
} else if s.trim().starts_with("note1") {
Ok(Self::new(
EventId::from_bech32(s).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)
)
)]
pub struct ReplyArgs {
/// The issue, patch, or comment to reply to
#[arg(long)]
to: NostrEvent,
/// Repository address
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Option<Nip19Coordinate>,
/// 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 client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let mut write_relays =
utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
client.add_relays(&options.relays).await;
client.add_relays(&self.to.relays).await;
if let Some(ref naddr) = self.naddr {
client.add_relays(&naddr.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 repo_naddr = if let Some(naddr) = self.naddr {
naddr.coordinate
} else if let Some(ref root_event) = root {
root_event
.tags
.coordinates()
.find(|c| c.kind == Kind::GitRepoAnnouncement)
.ok_or_else(|| {
N34Error::InvalidEvent(
"The Git issue/patch does not specify a target repository".to_owned(),
)
})?
.clone()
} else {
return Err(N34Error::NotFoundRepo);
};
let repo = client.fetch_repo(&repo_naddr).await?;
write_relays.extend(repo.relays.clone());
write_relays = client
.read_relays_from_user(write_relays, reply_to.pubkey)
.await;
if let Some(root_event) = &root {
write_relays = client
.read_relays_from_user(write_relays, root_event.pubkey)
.await;
}
let content = utils::get_content(self.comment.as_ref(), ".txt")?;
let content_details = client.parse_content(&content).await;
write_relays.extend(content_details.write_relays.clone());
let event = EventBuilder::comment(
content,
&reply_to,
root.as_ref(),
repo.relays.first().cloned(),
)
.tag(Tag::public_key(repo_naddr.public_key))
.tags(content_details.into_tags())
.pow(options.pow)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let author_read_relays = utils::add_read_relays(Vec::new(), relays_list.as_ref());
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(())
}
}

View File

@@ -1,88 +0,0 @@
// 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::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
cli::{CliOptions, CommandRunner},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// 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>,
}
impl CommandRunner for AnnounceArgs {
async fn run(mut self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
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,
options.relays.clone(),
self.maintainers,
self.label.into_iter().map(utils::str_trim).collect(),
)?
.pow(options.pow)
.build(user_pubk);
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
let naddr = utils::repo_naddr(user_pubk, &options.relays)?;
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
Ok(())
}
}

View File

@@ -1,86 +0,0 @@
// 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 nostr::nips::nip19::Nip19Coordinate;
use crate::{
cli::{CliOptions, CommandRunner, parsers},
error::N34Result,
nostr_utils::NostrClient,
};
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Nostr repository address
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Nip19Coordinate,
}
impl CommandRunner for ViewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
if !self.naddr.relays.is_empty() {
client.add_relays(&self.naddr.relays).await;
}
let repo = client.fetch_repo(&self.naddr.coordinate).await?;
let mut msg = format!("ID: {}", repo.id);
if let Some(name) = repo.name {
msg.push_str(&format!("\nName: {name}"));
}
if let Some(desc) = repo.description {
msg.push_str(&format!("\nDescription: {desc}"));
}
if !repo.web.is_empty() {
msg.push_str(&format!("\nWebpages:\n{}", format_list(repo.web)));
}
if !repo.clone.is_empty() {
msg.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone)));
}
if !repo.relays.is_empty() {
msg.push_str(&format!("\nRelays:\n{}", format_list(repo.relays)));
}
if let Some(euc) = repo.euc {
msg.push_str(&format!("\nEarliest unique commit: {euc}"));
}
if !repo.maintainers.is_empty() {
msg.push_str(&format!(
"\nMaintainers:\n{}",
format_list(repo.maintainers)
));
}
println!("{msg}");
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")
}

194
src/cli/types.rs Normal file
View 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()
}
}

View File

@@ -19,6 +19,8 @@ use std::process::ExitCode;
use nostr::event::builder::Error as EventBuilderError; use nostr::event::builder::Error as EventBuilderError;
use nostr_sdk::client::Error as ClientError; use nostr_sdk::client::Error as ClientError;
use crate::cli::ConfigError;
pub type N34Result<T> = Result<T, N34Error>; pub type N34Result<T> = Result<T, N34Error>;
/// N34 errors /// N34 errors
@@ -26,6 +28,8 @@ pub type N34Result<T> = Result<T, N34Error>;
pub enum N34Error { pub enum N34Error {
#[error("IO: {0}")] #[error("IO: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("{0}")]
Config(#[from] ConfigError),
#[error("No editor specified in the `EDITOR` environment variable")] #[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound, EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")] #[error("The file you edited is empty. Please save your changes before exiting the editor.")]
@@ -51,6 +55,30 @@ pub enum N34Error {
to." to."
)] )]
CanNotReplyToEvent, 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 { impl N34Error {

View File

@@ -61,7 +61,14 @@ fn set_log_level(verbosity: Verbosity) {
#[tokio::main] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
let cli = Cli::parse(); 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); set_log_level(cli.verbosity);
if let Err(err) = cli.run().await { if let Err(err) = cli.run().await {

View File

@@ -42,6 +42,7 @@ use crate::{
const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500); const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500);
/// Parsed content details /// Parsed content details
#[derive(Clone)]
pub struct ContentDetails { pub struct ContentDetails {
/// Public keys of users mentioned in the content. /// Public keys of users mentioned in the content.
pub p_tagged: HashSet<PublicKey>, pub p_tagged: HashSet<PublicKey>,
@@ -82,6 +83,7 @@ impl ContentDetails {
tags.extend(self.hashtags.into_iter().map(Tag::hashtag)); tags.extend(self.hashtags.into_iter().map(Tag::hashtag));
tags.extend(self.p_tagged.into_iter().map(Tag::public_key)); tags.extend(self.p_tagged.into_iter().map(Tag::public_key));
tags.extend(self.quotes.into_iter().map(|(event_id, relay_url)| { 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 { Tag::from_standardized(TagStandard::Quote {
event_id, event_id,
relay_url, relay_url,
@@ -100,7 +102,7 @@ impl NostrClient {
/// Initializes a new [`NostrClient`] instance and connects to the specified /// Initializes a new [`NostrClient`] instance and connects to the specified
/// relays. /// relays.
pub async fn init(options: &CliOptions) -> Self { pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self {
let client = Self::new( let client = Self::new(
Client::builder() Client::builder()
.signer(Keys::new( .signer(Keys::new(
@@ -113,7 +115,7 @@ impl NostrClient {
.build(), .build(),
); );
client.add_relays(&options.relays).await; client.add_relays(relays).await;
client client
} }
@@ -162,18 +164,24 @@ impl NostrClient {
relays_list: Option<&Event>, relays_list: Option<&Event>,
relays: &[RelayUrl], relays: &[RelayUrl],
) -> N34Result<Vec<RelayUrl>> { ) -> N34Result<Vec<RelayUrl>> {
event.ensure_id();
self.add_relays(relays).await; self.add_relays(relays).await;
let event_id = event.id.expect("It's there"); 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 { if let Some(event) = relays_list {
let _ = self.client.send_event_to(relays, event).await; let _ = self.client.send_event_to(relays, event).await;
} }
}
let result = self );
.client let result = result?;
.send_event_to(relays, &event.sign(&self.client.signer().await?).await?)
.await?;
for relay in &result.success { for relay in &result.success {
tracing::info!(event_id = %event_id, relay = %relay, "Event sent successfully"); tracing::info!(event_id = %event_id, relay = %relay, "Event sent successfully");
@@ -195,21 +203,28 @@ impl NostrClient {
.first_owned()) .first_owned())
} }
/// Try to fetch a repository and returns it /// Try to fetch the reposotoies and returns them
pub async fn fetch_repo( pub async fn fetch_repos(
&self, &self,
repo_naddr: &Coordinate, repo_naddrs: &[Coordinate],
) -> N34Result<GitRepositoryAnnouncement> { ) -> N34Result<Vec<GitRepositoryAnnouncement>> {
let filter = Filter::new() future::join_all(repo_naddrs.iter().map(|c| {
.author(repo_naddr.public_key) async {
.kind(Kind::GitRepoAnnouncement) self.fetch_event(
.identifier(&repo_naddr.identifier); Filter::new()
.author(c.public_key)
self.fetch_event(filter) .identifier(&c.identifier)
.kind(Kind::GitRepoAnnouncement),
)
.await? .await?
.map(|e| utils::event_into_repo(e, &repo_naddr.identifier)) .map(|e| utils::event_into_repo(e, &c.identifier))
.ok_or(N34Error::NotFoundRepo) .ok_or(N34Error::NotFoundRepo)
} }
}))
.await
.into_iter()
.collect()
}
/// Finds the root issue or patch for a given event. If the event is already /// 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 /// a root (issue/patch), returns it directly. For comments, follows
@@ -268,16 +283,10 @@ impl NostrClient {
.map(|e| e.pubkey)) .map(|e| e.pubkey))
} }
/// Adds read relays from the user to the given vector of relays. /// Returns the read relays of the given user if found, otherwise empty
pub async fn read_relays_from_user( /// vector
&self, pub async fn read_relays_from_user(&self, user: PublicKey) -> Vec<RelayUrl> {
vector: Vec<RelayUrl>, utils::add_read_relays(self.user_relays_list(user).await.ok().flatten().as_ref())
user: PublicKey,
) -> Vec<RelayUrl> {
utils::add_read_relays(
vector,
self.user_relays_list(user).await.ok().flatten().as_ref(),
)
} }
/// Parse the given content and returns the details that inside it /// Parse the given content and returns the details that inside it
@@ -300,14 +309,14 @@ impl NostrClient {
for (user, relays) in &p_tagged_users { for (user, relays) in &p_tagged_users {
self.add_relays(relays).await; self.add_relays(relays).await;
write_relays = self.read_relays_from_user(write_relays, *user).await write_relays.extend(self.read_relays_from_user(*user).await);
} }
for (event_id, relays) in &quotes { for (event_id, relays) in &quotes {
self.add_relays(relays).await; self.add_relays(relays).await;
// Add the event author to the p-tagged users // Add the event author to the p-tagged users
if let Ok(Some(author)) = self.event_author(*event_id).await { if let Ok(Some(author)) = self.event_author(*event_id).await {
p_tagged_users.push((author, Vec::new())); p_tagged_users.push((author, Vec::new()));
write_relays = self.read_relays_from_user(write_relays, author).await; write_relays.extend(self.read_relays_from_user(author).await);
} }
} }

View File

@@ -20,6 +20,7 @@ use nostr::{
key::PublicKey, key::PublicKey,
nips::{ nips::{
nip01::Coordinate, nip01::Coordinate,
nip19::Nip19Coordinate,
nip21::Nip21, nip21::Nip21,
nip34::{GitIssue, GitRepositoryAnnouncement}, nip34::{GitIssue, GitRepositoryAnnouncement},
}, },
@@ -76,9 +77,17 @@ impl EventBuilder {
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
maintainers: Vec<PublicKey>, maintainers: Vec<PublicKey>,
labels: Vec<String>, labels: Vec<String>,
force_id: bool,
) -> N34Result<EventBuilder> { ) -> N34Result<EventBuilder> {
let repo_id = repo_id.trim(); let repo_id = repo_id.trim();
if repo_id.is_empty() || repo_id != repo_id.to_case(Case::Kebab) { 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); return Err(N34Error::InvalidRepoId);
} }
@@ -93,6 +102,7 @@ impl EventBuilder {
euc: None, euc: None,
maintainers, maintainers,
})? })?
.dedup_tags()
.tags(labels.into_iter().map(Tag::hashtag)), .tags(labels.into_iter().map(Tag::hashtag)),
) )
} }
@@ -156,3 +166,26 @@ impl Token<'_> {
} }
} }
} }
/// 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()
}
}

View File

@@ -14,7 +14,13 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fmt, fs, str::FromStr, sync::atomic::Ordering}; use std::{
fmt,
fs,
path::{Path, PathBuf},
str::FromStr,
sync::atomic::Ordering,
};
use nostr::{ use nostr::{
event::{Event, EventId, Kind, TagKind, TagStandard}, event::{Event, EventId, Kind, TagKind, TagStandard},
@@ -30,7 +36,10 @@ use nostr::{
}; };
use super::traits::TagsExt; use super::traits::TagsExt;
use crate::error::{N34Error, N34Result}; use crate::{
cli::{NOSTR_ADDRESS_FILE, parsers},
error::{N34Error, N34Result},
};
/// Returns the value of the given tag /// Returns the value of the given tag
fn tag_value(tag: &TagStandard) -> String { fn tag_value(tag: &TagStandard) -> String {
@@ -117,9 +126,13 @@ pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> {
/// Creates a NIP-19 naddr string for a git repository announcement and up to 3 /// Creates a NIP-19 naddr string for a git repository announcement and up to 3
/// unique relay URLs. /// unique relay URLs.
pub fn repo_naddr(pubk: PublicKey, relays: &[RelayUrl]) -> N34Result<String> { pub fn repo_naddr(
repo_id: impl Into<String>,
pubk: PublicKey,
relays: &[RelayUrl],
) -> N34Result<String> {
Nip19Coordinate::new( Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, pubk), Coordinate::new(Kind::GitRepoAnnouncement, pubk).identifier(repo_id),
dedup(relays.iter().cloned()).iter().take(3), dedup(relays.iter().cloned()).iter().take(3),
) )
.expect("Valid relays") .expect("Valid relays")
@@ -127,8 +140,10 @@ pub fn repo_naddr(pubk: PublicKey, relays: &[RelayUrl]) -> N34Result<String> {
.map_err(N34Error::from) .map_err(N34Error::from)
} }
/// Extends the given vector with write relays found in the event (if any). /// Extracts write relay URLs from an event if present, otherwise returns an
pub fn add_write_relays(mut vector: Vec<RelayUrl>, event: Option<&Event>) -> Vec<RelayUrl> { /// empty vector.
pub fn add_write_relays(event: Option<&Event>) -> Vec<RelayUrl> {
let mut vector = Vec::new();
if let Some(event) = event { if let Some(event) = event {
vector.extend( vector.extend(
nip65::extract_owned_relay_list(event.clone()) nip65::extract_owned_relay_list(event.clone())
@@ -138,8 +153,10 @@ pub fn add_write_relays(mut vector: Vec<RelayUrl>, event: Option<&Event>) -> Vec
vector vector
} }
/// Extends the given vector with read relays found in the event (if any). /// Extracts read relay URLs from an event if present, otherwise returns an
pub fn add_read_relays(mut vector: Vec<RelayUrl>, event: Option<&Event>) -> Vec<RelayUrl> { /// empty vector.
pub fn add_read_relays(event: Option<&Event>) -> Vec<RelayUrl> {
let mut vector = Vec::new();
if let Some(event) = event { if let Some(event) = event {
vector.extend( vector.extend(
nip65::extract_owned_relay_list(event.clone()) nip65::extract_owned_relay_list(event.clone())
@@ -153,13 +170,17 @@ pub fn add_read_relays(mut vector: Vec<RelayUrl>, event: Option<&Event>) -> Vec<
/// Opens the user's default editor ($EDITOR) to edit a temporary file with /// 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 /// given suffix, then reads and returns the file contents. The temporary file
/// is automatically deleted. /// is automatically deleted.
pub fn read_editor(file_suffix: &str) -> N34Result<String> { pub fn read_editor(file_pre_content: Option<&str>, file_suffix: &str) -> N34Result<String> {
let Ok(editor) = std::env::var("EDITOR") else { let Ok(editor) = std::env::var("EDITOR") else {
return Err(N34Error::EditorNotFound); return Err(N34Error::EditorNotFound);
}; };
let temp_path = tempfile::NamedTempFile::with_suffix(file_suffix)?.into_temp_path(); 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 // Disable the logs to not show up in a terminal text editor
crate::EDITOR_OPEN.store(true, Ordering::Relaxed); crate::EDITOR_OPEN.store(true, Ordering::Relaxed);
let exit_status = std::process::Command::new(editor) let exit_status = std::process::Command::new(editor)
@@ -186,12 +207,33 @@ pub fn read_editor(file_suffix: &str) -> N34Result<String> {
} }
/// Returns the given content if it's `Option::Some` or call [`read_editor`] /// Returns the given content if it's `Option::Some` or call [`read_editor`]
pub fn get_content<S>(content: Option<S>, file_suffix: &str) -> N34Result<String> pub fn get_content(
where content: Option<impl AsRef<str>>,
S: AsRef<str>, quoted_content: Option<impl AsRef<str>>,
{ file_suffix: &str,
) -> N34Result<String> {
if let Some(content) = content { if let Some(content) = content {
return Ok(content.as_ref().trim().to_owned()); return Ok(content.as_ref().trim().to_owned());
} }
read_editor(file_suffix) 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)
} }