Implements cli uploads and link generation.
This commit is contained in:
264
Cargo.lock
generated
264
Cargo.lock
generated
@@ -41,6 +41,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -465,6 +515,52 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -494,6 +590,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -517,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -530,7 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -661,6 +767,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-secret-service"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"dbus",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
@@ -1794,6 +1910,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1970,7 +2092,12 @@ version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"dbus-secret-service",
|
||||
"log",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework 3.7.0",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2145,7 +2272,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2239,6 +2366,16 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nut"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"nextcloud_client",
|
||||
"open",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
@@ -2441,6 +2578,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.1"
|
||||
@@ -3100,6 +3243,19 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3107,7 +3263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3552,7 +3708,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3978,7 +4134,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4349,6 +4507,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.25.0"
|
||||
@@ -4819,6 +4983,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4852,13 +5025,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4889,6 +5079,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4901,6 +5097,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4913,12 +5115,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4931,6 +5145,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4943,6 +5163,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4955,6 +5181,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4967,6 +5199,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -5197,6 +5435,20 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
16
Tasks.md
16
Tasks.md
@@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for
|
||||
|
||||
| ID | Title | Status | Type |
|
||||
|---|---|---|---|
|
||||
| [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature |
|
||||
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature |
|
||||
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature |
|
||||
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature |
|
||||
@@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for
|
||||
| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature |
|
||||
| [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature |
|
||||
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature |
|
||||
| [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature |
|
||||
|
||||
---
|
||||
|
||||
@@ -232,21 +232,21 @@ Support multiple Nextcloud accounts in the backend credential system.
|
||||
- NUT-006
|
||||
|
||||
|
||||
<a id="nut-008" class="task" data-status="triage" data-task-type="feature"></a>
|
||||
<a id="nut-008" class="task" data-status="done" data-task-type="feature"></a>
|
||||
### Implement CLI Frontend
|
||||
**ID:** NUT-008
|
||||
**Status:** Triage
|
||||
**Status:** Fixed
|
||||
**Type:** Feature
|
||||
|
||||
**Description:**
|
||||
Implement the CLI tool using the shared backend library, including interactive browser login via Login Flow v2 and file upload commands.
|
||||
|
||||
**Requirements:**
|
||||
- [ ] Add `login` command (browser-based Login Flow v2)
|
||||
- [ ] Add `upload` command
|
||||
- [ ] Add `--account` flag
|
||||
- [ ] Add `--stdin` support
|
||||
- [ ] Add error reporting
|
||||
- [x] Add `login` command (browser-based Login Flow v2)
|
||||
- [x] Add `upload` command
|
||||
- [x] Add `--account` flag
|
||||
- [x] Add `--stdin` support
|
||||
- [x] Add error reporting
|
||||
|
||||
**Dependencies:**
|
||||
- NUT-003
|
||||
|
||||
@@ -4,3 +4,11 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
nextcloud_client = { path = "../nextcloud_client" }
|
||||
clap = { version = "4.5", features = ["derive", "cargo"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] }
|
||||
open = "5.3"
|
||||
url = "2.5"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
350
cli/src/main.rs
350
cli/src/main.rs
@@ -1,3 +1,349 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use nextcloud_client::{
|
||||
initiate_login_flow, poll_login_flow, ClientConfig, CredentialStore, NextcloudClient,
|
||||
ProgressCallback, ProgressEvent, Result, UploadOptions,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "nut",
|
||||
author = "Tom Hicks <headhunter3@gmail.com>",
|
||||
version,
|
||||
about = "Nextcloud Upload Tool — Fast, seamless file uploads & share links",
|
||||
long_about = "Upload files to Nextcloud directly from your terminal, automatically generate public share links, and manage multiple accounts with secure OS keychain storage."
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Log in to a Nextcloud server using browser authorization (Login Flow v2)
|
||||
Login(LoginArgs),
|
||||
|
||||
/// Upload a file or stream to Nextcloud
|
||||
Upload(UploadArgs),
|
||||
|
||||
/// Manage configured Nextcloud accounts
|
||||
#[command(subcommand)]
|
||||
Account(AccountCommands),
|
||||
|
||||
/// Quick alias to list all configured accounts
|
||||
Accounts,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct LoginArgs {
|
||||
/// Base URL of the Nextcloud instance (e.g. https://cloud.example.com)
|
||||
server_url: String,
|
||||
|
||||
/// Optional friendly label for this account (e.g. "Work", "Home")
|
||||
#[arg(short, long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Set this account as the default active account
|
||||
#[arg(short, long, default_value_t = true)]
|
||||
default: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct UploadArgs {
|
||||
/// Path to local file(s) to upload
|
||||
#[arg(value_name = "FILE")]
|
||||
files: Vec<PathBuf>,
|
||||
|
||||
/// Specific account identifier or label to use (defaults to active account)
|
||||
#[arg(short, long)]
|
||||
account: Option<String>,
|
||||
|
||||
/// Remote destination folder on Nextcloud (defaults to "Uploads")
|
||||
#[arg(short = 'd', long, default_value = "Uploads")]
|
||||
remote_dir: String,
|
||||
|
||||
/// Automatically generate a public share link after upload
|
||||
#[arg(short, long)]
|
||||
share: bool,
|
||||
|
||||
/// Optional password to protect the public share link
|
||||
#[arg(short, long)]
|
||||
password: Option<String>,
|
||||
|
||||
/// Upload content from standard input (stdin)
|
||||
#[arg(long)]
|
||||
stdin: bool,
|
||||
|
||||
/// Remote filename to use when uploading via stdin
|
||||
#[arg(long, default_value = "stdin_upload.txt")]
|
||||
filename: String,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum AccountCommands {
|
||||
/// List all configured Nextcloud accounts
|
||||
List,
|
||||
|
||||
/// Set the active default account
|
||||
Default {
|
||||
/// Account ID, label, or username
|
||||
account: String,
|
||||
},
|
||||
|
||||
/// Remove a configured account and its credentials
|
||||
Delete {
|
||||
/// Account ID, label, or username
|
||||
account: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Login(args) => handle_login(args).await,
|
||||
Commands::Upload(args) => handle_upload(args).await,
|
||||
Commands::Account(AccountCommands::List) | Commands::Accounts => handle_account_list(),
|
||||
Commands::Account(AccountCommands::Default { account }) => {
|
||||
handle_account_set_default(&account)
|
||||
}
|
||||
Commands::Account(AccountCommands::Delete { account }) => handle_account_delete(&account),
|
||||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("\x1b[1;31mError:\x1b[0m {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle interactive browser login flow.
|
||||
async fn handle_login(args: LoginArgs) -> Result<()> {
|
||||
let server_url = ClientConfig::normalize_url(&args.server_url)?;
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url);
|
||||
let flow = initiate_login_flow(&http, &server_url).await?;
|
||||
|
||||
println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:");
|
||||
println!(" \x1b[1;36m{}\x1b[0m\n", flow.login);
|
||||
|
||||
// Attempt to open the default desktop browser
|
||||
if open::that(&flow.login).is_err() {
|
||||
println!(" (Could not automatically launch browser. Please copy and open the link above.)");
|
||||
}
|
||||
|
||||
print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization...");
|
||||
io::Write::flush(&mut io::stdout())?;
|
||||
|
||||
let poll_interval = Duration::from_secs(2);
|
||||
let timeout = Duration::from_secs(300); // 5 minute timeout
|
||||
let start_time = tokio::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if start_time.elapsed() >= timeout {
|
||||
println!();
|
||||
return Err(nextcloud_client::NextcloudError::Other(
|
||||
"Timed out waiting for authorization in browser".into(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
if let Some(creds) = poll_login_flow(&http, &flow.poll.endpoint, &flow.poll.token).await? {
|
||||
println!("\n\x1b[1;32m==>\x1b[0m Authorization granted for user '\x1b[1m{}\x1b[0m'!", creds.login_name);
|
||||
|
||||
// Save credentials to keychain & accounts list
|
||||
let mut account = CredentialStore::save_account(
|
||||
&creds.server,
|
||||
&creds.login_name,
|
||||
&creds.app_password,
|
||||
args.default,
|
||||
)?;
|
||||
|
||||
if let Some(lbl) = args.label {
|
||||
account.label = Some(lbl);
|
||||
let mut accounts = CredentialStore::list_accounts()?;
|
||||
if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) {
|
||||
a.label = account.label.clone();
|
||||
}
|
||||
CredentialStore::save_accounts(&accounts)?;
|
||||
}
|
||||
|
||||
println!("\x1b[1;32m==>\x1b[0m Account '\x1b[1m{}\x1b[0m' saved securely in system keychain.", account.id);
|
||||
if account.is_default {
|
||||
println!(" Set as default active account.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle file and stdin uploads.
|
||||
async fn handle_upload(args: UploadArgs) -> Result<()> {
|
||||
let client = match args.account.as_deref() {
|
||||
Some(acc) => CredentialStore::create_client_for_account(acc)?,
|
||||
None => CredentialStore::create_client_for_default()?,
|
||||
};
|
||||
|
||||
// Stdin Upload
|
||||
if args.stdin {
|
||||
println!("\x1b[1;34m==>\x1b[0m Reading data from stdin...");
|
||||
let mut buffer = Vec::new();
|
||||
io::stdin().read_to_end(&mut buffer)?;
|
||||
|
||||
let len = buffer.len() as u64;
|
||||
let remote_path = format!("{}/{}", args.remote_dir.trim_matches('/'), args.filename);
|
||||
println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path);
|
||||
|
||||
let progress_cb = create_progress_callback(len);
|
||||
let cursor = io::Cursor::new(buffer);
|
||||
let bytes = client
|
||||
.upload_reader(cursor, &remote_path, Some(len), Some(progress_cb))
|
||||
.await?;
|
||||
|
||||
println!("\n\x1b[1;32m✓\x1b[0m Upload complete! ({} bytes)", bytes);
|
||||
|
||||
if args.share {
|
||||
create_and_print_share(&client, &remote_path, args.password.as_deref()).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if args.files.is_empty() {
|
||||
return Err(nextcloud_client::NextcloudError::Other(
|
||||
"No files specified for upload. Usage: nut upload <FILE>... or nut upload --stdin".into(),
|
||||
));
|
||||
}
|
||||
|
||||
for file_path in &args.files {
|
||||
upload_single_file(&client, file_path, &args.remote_dir, args.share, args.password.as_deref()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_single_file(
|
||||
client: &NextcloudClient,
|
||||
local_path: &Path,
|
||||
remote_dir: &str,
|
||||
create_share: bool,
|
||||
password: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if !local_path.exists() {
|
||||
return Err(nextcloud_client::NextcloudError::NotFound {
|
||||
path: local_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let file_name = local_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("file");
|
||||
|
||||
let clean_dir = remote_dir.trim_matches('/');
|
||||
let remote_path = if clean_dir.is_empty() {
|
||||
file_name.to_string()
|
||||
} else {
|
||||
format!("{clean_dir}/{file_name}")
|
||||
};
|
||||
|
||||
let metadata = tokio::fs::metadata(local_path).await?;
|
||||
let file_size = metadata.len();
|
||||
|
||||
println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path);
|
||||
|
||||
let progress_cb = create_progress_callback(file_size);
|
||||
let options = UploadOptions {
|
||||
remote_path: remote_path.clone(),
|
||||
create_share,
|
||||
share_password: password.map(|s| s.to_string()),
|
||||
overwrite: true,
|
||||
};
|
||||
|
||||
let result = client
|
||||
.upload_and_share(local_path, &options, Some(progress_cb))
|
||||
.await?;
|
||||
|
||||
println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", file_name, result.bytes_uploaded);
|
||||
|
||||
if let Some(share_url) = result.share_url {
|
||||
println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url);
|
||||
}
|
||||
if let Some(direct_url) = result.direct_download_url {
|
||||
println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_and_print_share(
|
||||
client: &NextcloudClient,
|
||||
remote_path: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<()> {
|
||||
println!("\x1b[1;34m==>\x1b[0m Generating public share link...");
|
||||
let share = client.create_public_share(remote_path, password).await?;
|
||||
let direct_url = client.direct_download_url(&share.token)?;
|
||||
|
||||
println!(" \x1b[1;32mShare Link:\x1b[0m {}", share.url);
|
||||
println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_progress_callback(total_bytes: u64) -> ProgressCallback {
|
||||
Arc::new(move |event| match event {
|
||||
ProgressEvent::Progress { bytes_transferred, total_bytes: total } => {
|
||||
let tot = total.unwrap_or(total_bytes);
|
||||
if tot > 0 {
|
||||
let percent = (bytes_transferred as f64 / tot as f64) * 100.0;
|
||||
print!("\r \x1b[1;33mProgress:\x1b[0m {:>3.0}% ({}/{} bytes)", percent, bytes_transferred, tot);
|
||||
} else {
|
||||
print!("\r \x1b[1;33mProgress:\x1b[0m {} bytes transferred", bytes_transferred);
|
||||
}
|
||||
let _ = io::Write::flush(&mut io::stdout());
|
||||
}
|
||||
ProgressEvent::Completed { total_bytes } => {
|
||||
print!("\r \x1b[1;33mProgress:\x1b[0m 100% ({} bytes) ", total_bytes);
|
||||
let _ = io::Write::flush(&mut io::stdout());
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
}
|
||||
|
||||
/// List stored accounts.
|
||||
fn handle_account_list() -> Result<()> {
|
||||
let accounts = CredentialStore::list_accounts()?;
|
||||
if accounts.is_empty() {
|
||||
println!("No Nextcloud accounts configured yet.");
|
||||
println!("Run \x1b[1;36mnut login <SERVER_URL>\x1b[0m to connect an account.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\x1b[1mConfigured Nextcloud Accounts:\x1b[0m\n");
|
||||
for acc in accounts {
|
||||
let default_mark = if acc.is_default { "\x1b[1;32m* (active default)\x1b[0m" } else { "" };
|
||||
let label_str = acc.label.as_deref().unwrap_or("—");
|
||||
println!(" • \x1b[1;36m{}\x1b[0m {}", acc.id, default_mark);
|
||||
println!(" Username: {}", acc.username);
|
||||
println!(" Server: {}", acc.server_url);
|
||||
println!(" Label: {}\n", label_str);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_account_set_default(account: &str) -> Result<()> {
|
||||
CredentialStore::set_default_account(account)?;
|
||||
println!("\x1b[1;32m✓\x1b[0m Active default account set to '\x1b[1m{}\x1b[0m'.", account);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_account_delete(account: &str) -> Result<()> {
|
||||
CredentialStore::delete_account(account)?;
|
||||
println!("\x1b[1;32m✓\x1b[0m Account '\x1b[1m{}\x1b[0m' deleted.", account);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
keyring = "3"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
dirs = "6.0"
|
||||
open = "5.3"
|
||||
|
||||
@@ -69,11 +69,8 @@ mod humantime_serde {
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// Create a new `ClientConfig` by parsing a URL string and optional credentials.
|
||||
pub fn new(
|
||||
server_url_str: &str,
|
||||
credentials: Option<AccountCredentials>,
|
||||
) -> Result<Self> {
|
||||
/// Normalize a server URL string: validates http/https scheme and ensures a trailing slash.
|
||||
pub fn normalize_url(server_url_str: &str) -> Result<Url> {
|
||||
let mut server_url = Url::parse(server_url_str)?;
|
||||
|
||||
// Ensure scheme is http or https
|
||||
@@ -90,6 +87,16 @@ impl ClientConfig {
|
||||
server_url.set_path(&new_path);
|
||||
}
|
||||
|
||||
Ok(server_url)
|
||||
}
|
||||
|
||||
/// Create a new `ClientConfig` by parsing a URL string and optional credentials.
|
||||
pub fn new(
|
||||
server_url_str: &str,
|
||||
credentials: Option<AccountCredentials>,
|
||||
) -> Result<Self> {
|
||||
let server_url = Self::normalize_url(server_url_str)?;
|
||||
|
||||
Ok(Self {
|
||||
server_url,
|
||||
credentials,
|
||||
@@ -127,6 +134,12 @@ mod tests {
|
||||
assert_eq!(config.credentials.as_ref().unwrap().username, "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subpath_normalization() {
|
||||
let url = ClientConfig::normalize_url("https://disobedient.cloud/nextcloud").unwrap();
|
||||
assert_eq!(url.as_str(), "https://disobedient.cloud/nextcloud/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_scheme() {
|
||||
let result = ClientConfig::new("ftp://cloud.example.com", None);
|
||||
|
||||
@@ -31,7 +31,7 @@ pub struct StoredAccount {
|
||||
#[serde(default)]
|
||||
pub is_default: bool,
|
||||
|
||||
/// Fallback password storage (only populated if system keyring is unavailable).
|
||||
/// Fallback password storage (populated if system keyring is unavailable).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fallback_password: Option<String>,
|
||||
}
|
||||
@@ -137,7 +137,7 @@ impl CredentialStore {
|
||||
|
||||
let keyring_key = account.keyring_key();
|
||||
|
||||
// 1. Attempt retrieval from OS Keyring
|
||||
// 1. Attempt retrieval from OS Keyring (using full id username@host)
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, keyring_key) {
|
||||
if let Ok(password) = entry.get_password() {
|
||||
let username = account.username.clone();
|
||||
@@ -145,7 +145,7 @@ impl CredentialStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Also try fallback by username alone if migrated
|
||||
// Also try retrieval by username alone (if previously stored with just username)
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, &account.username) {
|
||||
if let Ok(password) = entry.get_password() {
|
||||
let username = account.username.clone();
|
||||
@@ -158,7 +158,7 @@ impl CredentialStore {
|
||||
let username = account.username.clone();
|
||||
return Ok(Some((
|
||||
account.clone(),
|
||||
AccountCredentials::new(username, pass),
|
||||
AccountCredentials::new(username, pass.clone()),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -168,14 +168,26 @@ impl CredentialStore {
|
||||
/// Retrieve the default/active account credentials.
|
||||
pub fn get_default_credentials() -> Result<Option<(StoredAccount, AccountCredentials)>> {
|
||||
let accounts = Self::list_accounts()?;
|
||||
let default_id = accounts
|
||||
if accounts.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let default_account = accounts
|
||||
.iter()
|
||||
.find(|a| a.is_default)
|
||||
.map(|a| a.id.clone())
|
||||
.or_else(|| accounts.first().map(|a| a.id.clone()));
|
||||
.or_else(|| accounts.first());
|
||||
|
||||
match default_id {
|
||||
Some(id) => Self::get_credentials(&id),
|
||||
match default_account {
|
||||
Some(acc) => match Self::get_credentials(&acc.id)? {
|
||||
Some(res) => Ok(Some(res)),
|
||||
None => Err(NextcloudError::AuthenticationFailed {
|
||||
username: acc.username.clone(),
|
||||
message: format!(
|
||||
"Account '{}' is registered, but its password was not found in Keychain. Please re-authenticate by running 'nut login {}'.",
|
||||
acc.id, acc.server_url
|
||||
),
|
||||
}),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -227,10 +239,14 @@ impl CredentialStore {
|
||||
|
||||
// Try storing password in OS Keyring
|
||||
let mut fallback_password = None;
|
||||
let keyring_result = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key())
|
||||
.and_then(|entry| entry.set_password(app_password));
|
||||
let entry_res = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key());
|
||||
let keyring_saved = match entry_res {
|
||||
Ok(ref entry) => entry.set_password(app_password).is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
if keyring_result.is_err() {
|
||||
if !keyring_saved {
|
||||
// Save in fallback password field on failure
|
||||
fallback_password = Some(app_password.to_string());
|
||||
}
|
||||
new_account.fallback_password = fallback_password;
|
||||
@@ -283,7 +299,7 @@ impl CredentialStore {
|
||||
/// Create an initialized `NextcloudClient` for an account matching `query`.
|
||||
pub fn create_client_for_account(query: &str) -> Result<NextcloudClient> {
|
||||
let (account, creds) = Self::get_credentials(query)?.ok_or_else(|| {
|
||||
NextcloudError::Other(format!("No credentials found for account '{query}'"))
|
||||
NextcloudError::Other(format!("No credentials found for account '{query}'. Run 'nut login' to authenticate."))
|
||||
})?;
|
||||
|
||||
let config = ClientConfig::new(&account.server_url, Some(creds))?;
|
||||
@@ -293,7 +309,7 @@ impl CredentialStore {
|
||||
/// Create an initialized `NextcloudClient` for the default account.
|
||||
pub fn create_client_for_default() -> Result<NextcloudClient> {
|
||||
let (account, creds) = Self::get_default_credentials()?.ok_or_else(|| {
|
||||
NextcloudError::Other("No default account configured. Please run 'nut login' or configure an account.".into())
|
||||
NextcloudError::Other("No accounts configured. Please run 'nut login <server_url>' to connect your Nextcloud instance.".into())
|
||||
})?;
|
||||
|
||||
let config = ClientConfig::new(&account.server_url, Some(creds))?;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
impl NextcloudClient {
|
||||
/// Upload a stream of bytes to a Nextcloud WebDAV destination with optional progress reporting.
|
||||
/// Automatically ensures any parent directories exist before uploading.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `remote_path` - The destination path on Nextcloud (e.g. `"Uploads/photo.jpg"`).
|
||||
@@ -32,6 +33,14 @@ impl NextcloudClient {
|
||||
S: Stream<Item = std::result::Result<Bytes, E>> + Send + Sync + 'static,
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>> + 'static,
|
||||
{
|
||||
// Ensure parent directories exist
|
||||
let clean_path = remote_path.trim_start_matches('/');
|
||||
if let Some((parent_dir, _)) = clean_path.rsplit_once('/') {
|
||||
if !parent_dir.is_empty() {
|
||||
self.create_folder_all(parent_dir).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let url = self.webdav_url(remote_path)?;
|
||||
let progress_stream = ProgressStream::new(stream, content_length, callback);
|
||||
let body = reqwest::Body::wrap_stream(progress_stream);
|
||||
@@ -58,7 +67,7 @@ impl NextcloudClient {
|
||||
})
|
||||
} else if status == reqwest::StatusCode::CONFLICT {
|
||||
Err(NextcloudError::Other(format!(
|
||||
"WebDAV Conflict (409) at '{remote_path}'. Ensure the parent directory exists on Nextcloud."
|
||||
"WebDAV Conflict (409) at '{remote_path}'. Ensure the destination directory exists."
|
||||
)))
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
@@ -149,4 +158,25 @@ impl NextcloudClient {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively ensure all parent and subdirectories exist using WebDAV `MKCOL`.
|
||||
pub async fn create_folder_all(&self, remote_dir: &str) -> Result<()> {
|
||||
let clean = remote_dir.trim_matches('/');
|
||||
if clean.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut current_path = String::new();
|
||||
for segment in clean.split('/') {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !current_path.is_empty() {
|
||||
current_path.push('/');
|
||||
}
|
||||
current_path.push_str(segment);
|
||||
self.create_folder(¤t_path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user