htx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- disk_cli/__init__.py +3 -0
- disk_cli/__main__.py +5 -0
- disk_cli/api.py +368 -0
- disk_cli/cli.py +775 -0
- disk_cli/config.py +67 -0
- htx_cli-0.1.0.dist-info/METADATA +157 -0
- htx_cli-0.1.0.dist-info/RECORD +10 -0
- htx_cli-0.1.0.dist-info/WHEEL +5 -0
- htx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- htx_cli-0.1.0.dist-info/top_level.txt +1 -0
disk_cli/cli.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import webbrowser
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qs, urlparse
|
|
13
|
+
|
|
14
|
+
from rich import box
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.progress import (
|
|
18
|
+
BarColumn,
|
|
19
|
+
DownloadColumn,
|
|
20
|
+
Progress,
|
|
21
|
+
SpinnerColumn,
|
|
22
|
+
TaskProgressColumn,
|
|
23
|
+
TextColumn,
|
|
24
|
+
TimeRemainingColumn,
|
|
25
|
+
TransferSpeedColumn,
|
|
26
|
+
)
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
from .api import DiskAPIError, DiskClient
|
|
32
|
+
from .config import DEFAULT_STATE_PATH, DiskSettings
|
|
33
|
+
|
|
34
|
+
RTYPE_FILE = 0
|
|
35
|
+
RTYPE_FOLDER = 1
|
|
36
|
+
RTYPE_LINK = 2
|
|
37
|
+
|
|
38
|
+
STATUS_LABELS = {
|
|
39
|
+
0: "public",
|
|
40
|
+
1: "private",
|
|
41
|
+
2: "protected",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
TYPE_LABELS = {
|
|
45
|
+
RTYPE_FILE: "file",
|
|
46
|
+
RTYPE_FOLDER: "dir",
|
|
47
|
+
RTYPE_LINK: "link",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
TYPE_STYLES = {
|
|
51
|
+
RTYPE_FILE: "white",
|
|
52
|
+
RTYPE_FOLDER: "bold cyan",
|
|
53
|
+
RTYPE_LINK: "bold magenta",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
STATUS_STYLES = {
|
|
57
|
+
0: "bold green",
|
|
58
|
+
1: "bold yellow",
|
|
59
|
+
2: "bold red",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console = Console()
|
|
63
|
+
error_console = Console(stderr=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CLIUsageError(RuntimeError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class CommandContext:
|
|
72
|
+
settings: DiskSettings
|
|
73
|
+
client: DiskClient
|
|
74
|
+
|
|
75
|
+
def require_login(self) -> None:
|
|
76
|
+
if not self.settings.token:
|
|
77
|
+
raise CLIUsageError("Please run `htx login` first.")
|
|
78
|
+
|
|
79
|
+
def sync_user(self) -> dict[str, Any]:
|
|
80
|
+
self.require_login()
|
|
81
|
+
user = self.client.get_current_user()
|
|
82
|
+
self.settings.user = user
|
|
83
|
+
self.settings.save()
|
|
84
|
+
return user
|
|
85
|
+
|
|
86
|
+
def root_res_id(self) -> str:
|
|
87
|
+
user = self.settings.user or self.sync_user()
|
|
88
|
+
root_res = user.get("root_res")
|
|
89
|
+
if not root_res:
|
|
90
|
+
user = self.sync_user()
|
|
91
|
+
root_res = user.get("root_res")
|
|
92
|
+
if not root_res:
|
|
93
|
+
raise CLIUsageError("The current user does not have a root resource folder.")
|
|
94
|
+
return root_res
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
prog="htx",
|
|
100
|
+
description="Command-line client for the Disk backend API.",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--config",
|
|
104
|
+
type=Path,
|
|
105
|
+
default=DEFAULT_STATE_PATH,
|
|
106
|
+
help=f"State file path (default: {DEFAULT_STATE_PATH})",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument("--version", action="version", version=f"htx-cli {__version__}")
|
|
109
|
+
|
|
110
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
111
|
+
|
|
112
|
+
login = subparsers.add_parser("login", help="Authenticate via the SSO flow.")
|
|
113
|
+
login.add_argument("--code", help="SSO authorization code or callback URL.")
|
|
114
|
+
login.add_argument(
|
|
115
|
+
"--print-url",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Only print the SSO authorization URL and exit.",
|
|
118
|
+
)
|
|
119
|
+
login.add_argument(
|
|
120
|
+
"--no-open-browser",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="Do not try to open the authorization URL in a browser.",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
whoami = subparsers.add_parser("whoami", help="Show the authenticated Disk user.")
|
|
126
|
+
whoami.add_argument("--json", action="store_true", help="Print the raw API payload.")
|
|
127
|
+
subparsers.add_parser("logout", help="Clear the stored login token.")
|
|
128
|
+
|
|
129
|
+
ls_cmd = subparsers.add_parser("ls", help="List a remote Disk directory.")
|
|
130
|
+
ls_cmd.add_argument(
|
|
131
|
+
"remote_path",
|
|
132
|
+
nargs="?",
|
|
133
|
+
default="/",
|
|
134
|
+
help="Remote path, @<res_str_id>, or id:<res_str_id>.",
|
|
135
|
+
)
|
|
136
|
+
ls_cmd.add_argument("--json", action="store_true", help="Print the raw API payload.")
|
|
137
|
+
|
|
138
|
+
upload = subparsers.add_parser("upload", help="Upload a local file or directory.")
|
|
139
|
+
upload.add_argument("local_path", type=Path, help="Local file or directory to upload.")
|
|
140
|
+
upload.add_argument(
|
|
141
|
+
"remote_path",
|
|
142
|
+
nargs="?",
|
|
143
|
+
default="/",
|
|
144
|
+
help="Target remote directory path, @<res_str_id>, or id:<res_str_id>.",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
mkdir = subparsers.add_parser("mkdir", help="Create a remote directory.")
|
|
148
|
+
mkdir.add_argument("name", help="Directory name to create.")
|
|
149
|
+
mkdir.add_argument(
|
|
150
|
+
"parent",
|
|
151
|
+
nargs="?",
|
|
152
|
+
default="/",
|
|
153
|
+
help="Parent remote directory path, @<res_str_id>, or id:<res_str_id>.",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
rm = subparsers.add_parser("rm", help="Delete a remote file or directory.")
|
|
157
|
+
rm.add_argument("remote_path", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
|
|
158
|
+
rm.add_argument(
|
|
159
|
+
"-r",
|
|
160
|
+
"--recursive",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="Recursively delete a directory and its contents.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
mv = subparsers.add_parser("mv", help="Move a remote resource into a different directory.")
|
|
166
|
+
mv.add_argument("source", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
|
|
167
|
+
mv.add_argument("target_dir", help="Target directory path, @<res_str_id>, or id:<res_str_id>.")
|
|
168
|
+
|
|
169
|
+
rename = subparsers.add_parser("rename", help="Rename a remote resource.")
|
|
170
|
+
rename.add_argument("remote_path", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
|
|
171
|
+
rename.add_argument("new_name", help="New name for the resource.")
|
|
172
|
+
|
|
173
|
+
download = subparsers.add_parser("download", help="Download a remote file, link target, or directory.")
|
|
174
|
+
download.add_argument("remote_path", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
|
|
175
|
+
download.add_argument(
|
|
176
|
+
"local_path",
|
|
177
|
+
nargs="?",
|
|
178
|
+
type=Path,
|
|
179
|
+
help="Optional output file path or existing directory.",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
link = subparsers.add_parser("link", help="Create a remote link resource.")
|
|
183
|
+
link.add_argument("name", help="Link resource name.")
|
|
184
|
+
link.add_argument("url", help="Link target URL.")
|
|
185
|
+
link.add_argument(
|
|
186
|
+
"parent",
|
|
187
|
+
nargs="?",
|
|
188
|
+
default="/",
|
|
189
|
+
help="Parent remote directory path, @<res_str_id>, or id:<res_str_id>.",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return parser
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def extract_code(value: str) -> str:
|
|
196
|
+
candidate = value.strip()
|
|
197
|
+
if not candidate:
|
|
198
|
+
raise CLIUsageError("An authorization code is required.")
|
|
199
|
+
if "code=" not in candidate:
|
|
200
|
+
return candidate
|
|
201
|
+
|
|
202
|
+
parsed = urlparse(candidate)
|
|
203
|
+
query = parse_qs(parsed.query)
|
|
204
|
+
code = query.get("code", [None])[0]
|
|
205
|
+
if not code:
|
|
206
|
+
raise CLIUsageError("Could not find a `code` query parameter in the pasted URL.")
|
|
207
|
+
return code
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def format_remote_path(remote_path: str) -> str:
|
|
211
|
+
if not remote_path or remote_path == "/":
|
|
212
|
+
return "/"
|
|
213
|
+
return "/" + remote_path.strip("/")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def format_resource_ref(res_str_id: str) -> str:
|
|
217
|
+
return f"@{res_str_id}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def format_timestamp(timestamp: Any) -> str:
|
|
221
|
+
if timestamp in (None, ""):
|
|
222
|
+
return "-"
|
|
223
|
+
try:
|
|
224
|
+
return dt.datetime.fromtimestamp(float(timestamp)).strftime("%Y-%m-%d %H:%M")
|
|
225
|
+
except (TypeError, ValueError, OSError):
|
|
226
|
+
return "-"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def format_size(size: Any) -> str:
|
|
230
|
+
if size is None:
|
|
231
|
+
return "-"
|
|
232
|
+
try:
|
|
233
|
+
value = float(size)
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
return "-"
|
|
236
|
+
if value < 0:
|
|
237
|
+
return "-"
|
|
238
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
239
|
+
index = 0
|
|
240
|
+
while value >= 1024 and index < len(units) - 1:
|
|
241
|
+
value /= 1024
|
|
242
|
+
index += 1
|
|
243
|
+
if index == 0:
|
|
244
|
+
return f"{int(value)} {units[index]}"
|
|
245
|
+
return f"{value:.1f} {units[index]}"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def type_text(rtype: int) -> Text:
|
|
249
|
+
return Text(TYPE_LABELS.get(rtype, "unknown"), style=TYPE_STYLES.get(rtype, "white"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def status_text(status: int) -> Text:
|
|
253
|
+
return Text(
|
|
254
|
+
STATUS_LABELS.get(status, str(status)),
|
|
255
|
+
style=STATUS_STYLES.get(status, "white"),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resource_name_text(resource: dict[str, Any]) -> Text:
|
|
260
|
+
text = Text()
|
|
261
|
+
text.append(format_resource_ref(resource["res_str_id"]), style="bold yellow")
|
|
262
|
+
text.append(" ")
|
|
263
|
+
text.append(str(resource["rname"]), style="white")
|
|
264
|
+
return text
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def display_size(resource: dict[str, Any]) -> str:
|
|
268
|
+
if resource.get("rtype") != RTYPE_FILE:
|
|
269
|
+
return "-"
|
|
270
|
+
return format_size(resource.get("rsize"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def progress_columns() -> list[Any]:
|
|
274
|
+
return [
|
|
275
|
+
SpinnerColumn(style="bold cyan"),
|
|
276
|
+
TextColumn("[progress.description]{task.description}"),
|
|
277
|
+
BarColumn(),
|
|
278
|
+
TaskProgressColumn(),
|
|
279
|
+
DownloadColumn(binary_units=True),
|
|
280
|
+
TransferSpeedColumn(),
|
|
281
|
+
TimeRemainingColumn(),
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def print_success(message: str) -> None:
|
|
286
|
+
console.print(f"[bold green]OK[/bold green] {message}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def print_note(message: str) -> None:
|
|
290
|
+
console.print(f"[bold cyan]INFO[/bold cyan] {message}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def print_warning(message: str) -> None:
|
|
294
|
+
error_console.print(f"[bold yellow]WARN[/bold yellow] {message}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def sort_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
298
|
+
return sorted(
|
|
299
|
+
children,
|
|
300
|
+
key=lambda item: (
|
|
301
|
+
0 if item.get("rtype") == RTYPE_FOLDER else 1,
|
|
302
|
+
item.get("rname", "").lower(),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def ensure_folder_resource(resource: dict[str, Any], *, label: str) -> None:
|
|
308
|
+
info = resource.get("info", resource)
|
|
309
|
+
if info.get("rtype") != RTYPE_FOLDER:
|
|
310
|
+
raise CLIUsageError(f"{label} must refer to a remote directory.")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_resource_reference(reference: str) -> tuple[str | None, str]:
|
|
314
|
+
if reference.startswith("@"):
|
|
315
|
+
return reference[1:], "resource id"
|
|
316
|
+
if reference.startswith("id:"):
|
|
317
|
+
return reference[3:], "resource id"
|
|
318
|
+
return None, "path"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def resolve_remote_reference(ctx: CommandContext, reference: str) -> dict[str, Any]:
|
|
322
|
+
ctx.require_login()
|
|
323
|
+
resource_id, _ = parse_resource_reference(reference)
|
|
324
|
+
if resource_id:
|
|
325
|
+
return ctx.client.get_resource(resource_id)
|
|
326
|
+
|
|
327
|
+
current_id = ctx.root_res_id()
|
|
328
|
+
normalized = reference.strip("/")
|
|
329
|
+
if not normalized:
|
|
330
|
+
return ctx.client.get_resource(current_id)
|
|
331
|
+
|
|
332
|
+
current_resource = ctx.client.get_resource(current_id)
|
|
333
|
+
for segment in [part for part in normalized.split("/") if part]:
|
|
334
|
+
ensure_folder_resource(current_resource, label=format_remote_path(reference))
|
|
335
|
+
selector = ctx.client.get_selector(current_resource["info"]["res_str_id"])
|
|
336
|
+
child = next(
|
|
337
|
+
(item for item in selector.get("child_list", []) if item.get("rname") == segment),
|
|
338
|
+
None,
|
|
339
|
+
)
|
|
340
|
+
if child is None:
|
|
341
|
+
raise CLIUsageError(
|
|
342
|
+
f"Remote path segment `{segment}` does not exist under {format_remote_path(reference)}."
|
|
343
|
+
)
|
|
344
|
+
current_resource = ctx.client.get_resource(child["res_str_id"])
|
|
345
|
+
return current_resource
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def find_child_folder(ctx: CommandContext, parent_id: str, folder_name: str) -> dict[str, Any] | None:
|
|
349
|
+
selector = ctx.client.get_selector(parent_id)
|
|
350
|
+
for child in selector.get("child_list", []):
|
|
351
|
+
if child.get("rname") == folder_name:
|
|
352
|
+
if child.get("rtype") != RTYPE_FOLDER:
|
|
353
|
+
raise CLIUsageError(
|
|
354
|
+
f"Remote item `{folder_name}` exists under the target path, but it is not a directory."
|
|
355
|
+
)
|
|
356
|
+
return child
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def find_child_resource(ctx: CommandContext, parent_id: str, resource_name: str) -> dict[str, Any] | None:
|
|
361
|
+
selector = ctx.client.get_selector(parent_id)
|
|
362
|
+
for child in selector.get("child_list", []):
|
|
363
|
+
if child.get("rname") == resource_name:
|
|
364
|
+
return child
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def ensure_remote_folder(ctx: CommandContext, parent_id: str, folder_name: str) -> str:
|
|
369
|
+
existing = find_child_folder(ctx, parent_id, folder_name)
|
|
370
|
+
if existing:
|
|
371
|
+
return existing["res_str_id"]
|
|
372
|
+
created = ctx.client.create_folder(parent_id, folder_name)
|
|
373
|
+
print_success(f"created remote folder {folder_name} ({format_resource_ref(created['res_str_id'])})")
|
|
374
|
+
return created["res_str_id"]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str) -> dict[str, Any]:
|
|
378
|
+
with console.status(f"[bold cyan]Preparing upload for[/bold cyan] {local_path.name}", spinner="dots"):
|
|
379
|
+
upload_token = ctx.client.get_upload_token(parent_id, local_path.name)
|
|
380
|
+
|
|
381
|
+
total = local_path.stat().st_size
|
|
382
|
+
with Progress(*progress_columns(), console=console, transient=True) as progress:
|
|
383
|
+
task_id = progress.add_task(f"Uploading {local_path.name}", total=total)
|
|
384
|
+
|
|
385
|
+
def on_progress(transferred: int, callback_total: int | None) -> None:
|
|
386
|
+
progress.update(task_id, completed=transferred, total=callback_total or total)
|
|
387
|
+
|
|
388
|
+
uploaded = ctx.client.upload_file(upload_token, local_path, progress_callback=on_progress)
|
|
389
|
+
print_success(
|
|
390
|
+
f"uploaded {local_path.name} -> {uploaded['rname']} ({format_resource_ref(uploaded['res_str_id'])})"
|
|
391
|
+
)
|
|
392
|
+
return uploaded
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def upload_directory_to_remote(ctx: CommandContext, local_dir: Path, parent_id: str) -> None:
|
|
396
|
+
root_remote_id = ensure_remote_folder(ctx, parent_id, local_dir.name)
|
|
397
|
+
remote_folder_map: dict[Path, str] = {Path("."): root_remote_id}
|
|
398
|
+
|
|
399
|
+
for current_root, dirnames, filenames in os.walk(local_dir):
|
|
400
|
+
dirnames.sort()
|
|
401
|
+
filenames.sort()
|
|
402
|
+
current_root_path = Path(current_root)
|
|
403
|
+
relative_root = current_root_path.relative_to(local_dir)
|
|
404
|
+
remote_parent_id = remote_folder_map[relative_root]
|
|
405
|
+
|
|
406
|
+
for dirname in dirnames:
|
|
407
|
+
child_relative = relative_root / dirname
|
|
408
|
+
remote_folder_map[child_relative] = ensure_remote_folder(ctx, remote_parent_id, dirname)
|
|
409
|
+
|
|
410
|
+
for filename in filenames:
|
|
411
|
+
upload_file_to_remote(ctx, current_root_path / filename, remote_parent_id)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def resolve_download_destination(resource: dict[str, Any], local_path: Path | None) -> Path:
|
|
415
|
+
resource_name = resource["info"]["rname"]
|
|
416
|
+
if local_path is None:
|
|
417
|
+
return Path.cwd() / resource_name
|
|
418
|
+
|
|
419
|
+
resolved = local_path.expanduser()
|
|
420
|
+
if resolved.exists() and resolved.is_dir():
|
|
421
|
+
return resolved / resource_name
|
|
422
|
+
return resolved
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def download_file_to_local(ctx: CommandContext, resource: dict[str, Any], destination: Path) -> str:
|
|
426
|
+
with Progress(*progress_columns(), console=console, transient=True) as progress:
|
|
427
|
+
task_id = progress.add_task(f"Downloading {resource['info']['rname']}", total=None)
|
|
428
|
+
|
|
429
|
+
def on_progress(transferred: int, total: int | None) -> None:
|
|
430
|
+
progress.update(task_id, completed=transferred, total=total)
|
|
431
|
+
|
|
432
|
+
return ctx.client.download_resource(
|
|
433
|
+
resource["info"]["res_str_id"],
|
|
434
|
+
destination,
|
|
435
|
+
progress_callback=on_progress,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def download_directory_to_local(
|
|
440
|
+
ctx: CommandContext,
|
|
441
|
+
resource: dict[str, Any],
|
|
442
|
+
destination: Path,
|
|
443
|
+
) -> tuple[int, int]:
|
|
444
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
445
|
+
file_count = 0
|
|
446
|
+
directory_count = 1
|
|
447
|
+
|
|
448
|
+
for child in sort_children(resource.get("child_list", [])):
|
|
449
|
+
child_resource = ctx.client.get_resource(child["res_str_id"])
|
|
450
|
+
child_info = child_resource["info"]
|
|
451
|
+
child_destination = destination / child_info["rname"]
|
|
452
|
+
if child_info["rtype"] == RTYPE_FOLDER:
|
|
453
|
+
child_files, child_dirs = download_directory_to_local(ctx, child_resource, child_destination)
|
|
454
|
+
file_count += child_files
|
|
455
|
+
directory_count += child_dirs
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
final_url = download_file_to_local(ctx, child_resource, child_destination)
|
|
459
|
+
file_count += 1
|
|
460
|
+
print_note(
|
|
461
|
+
f"downloaded {child_info['rname']} ({format_resource_ref(child_info['res_str_id'])})"
|
|
462
|
+
f" -> {child_destination}"
|
|
463
|
+
)
|
|
464
|
+
print_note(f"source: {final_url}")
|
|
465
|
+
|
|
466
|
+
return file_count, directory_count
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def delete_resource_recursive(ctx: CommandContext, resource: dict[str, Any]) -> None:
|
|
470
|
+
info = resource["info"]
|
|
471
|
+
if info["rtype"] == RTYPE_FOLDER:
|
|
472
|
+
for child in sort_children(resource.get("child_list", [])):
|
|
473
|
+
child_resource = ctx.client.get_resource(child["res_str_id"])
|
|
474
|
+
delete_resource_recursive(ctx, child_resource)
|
|
475
|
+
ctx.client.delete_resource(info["res_str_id"])
|
|
476
|
+
print_success(f"deleted {info['rname']} ({format_resource_ref(info['res_str_id'])})")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def print_resource_listing(resource: dict[str, Any]) -> None:
|
|
480
|
+
info = resource["info"]
|
|
481
|
+
child_list = sort_children(resource.get("child_list", []))
|
|
482
|
+
|
|
483
|
+
summary = Table.grid(padding=(0, 2))
|
|
484
|
+
summary.add_column(style="bold cyan", justify="right")
|
|
485
|
+
summary.add_column()
|
|
486
|
+
summary.add_row("Name", Text(str(info["rname"]), style="bold white"))
|
|
487
|
+
summary.add_row("Ref", Text(format_resource_ref(info["res_str_id"]), style="bold yellow"))
|
|
488
|
+
summary.add_row("Type", type_text(info["rtype"]))
|
|
489
|
+
summary.add_row("Status", status_text(info["status"]))
|
|
490
|
+
summary.add_row(
|
|
491
|
+
"Owner",
|
|
492
|
+
Text(str(info["owner"]["nickname"]), style="white"),
|
|
493
|
+
)
|
|
494
|
+
summary.add_row("Time", Text(format_timestamp(info.get("create_time")), style="white"))
|
|
495
|
+
|
|
496
|
+
if info["rtype"] != RTYPE_FOLDER:
|
|
497
|
+
console.print(Panel(summary, border_style="bright_black", box=box.ROUNDED))
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
summary.add_row("Items", Text(str(len(child_list)), style="white"))
|
|
501
|
+
console.print(Panel(summary, border_style="bright_black", box=box.ROUNDED))
|
|
502
|
+
|
|
503
|
+
if not child_list:
|
|
504
|
+
console.print("[dim](empty)[/dim]")
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
table = Table(
|
|
508
|
+
box=box.SIMPLE_HEAVY,
|
|
509
|
+
expand=True,
|
|
510
|
+
show_edge=False,
|
|
511
|
+
header_style="bold cyan",
|
|
512
|
+
pad_edge=False,
|
|
513
|
+
)
|
|
514
|
+
table.add_column("TYPE", no_wrap=True)
|
|
515
|
+
table.add_column("STATUS", no_wrap=True)
|
|
516
|
+
table.add_column("SIZE", justify="right", no_wrap=True)
|
|
517
|
+
table.add_column("CREATED", no_wrap=True)
|
|
518
|
+
table.add_column("RESOURCE", ratio=1, overflow="ellipsis", no_wrap=True)
|
|
519
|
+
|
|
520
|
+
for child in child_list:
|
|
521
|
+
table.add_row(
|
|
522
|
+
type_text(child["rtype"]),
|
|
523
|
+
status_text(child["status"]),
|
|
524
|
+
Text(display_size(child), style="white"),
|
|
525
|
+
Text(format_timestamp(child.get("create_time")), style="white"),
|
|
526
|
+
resource_name_text(child),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
console.print(table)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def print_user_summary(user: dict[str, Any]) -> None:
|
|
533
|
+
summary = Table.grid(padding=(0, 2))
|
|
534
|
+
summary.add_column(style="bold cyan", justify="right")
|
|
535
|
+
summary.add_column()
|
|
536
|
+
summary.add_row("Nickname", Text(str(user.get("nickname", "-")), style="bold white"))
|
|
537
|
+
summary.add_row("Avatar", Text(str(user.get("avatar", "-")), style="white"))
|
|
538
|
+
root_res = user.get("root_res", "-")
|
|
539
|
+
summary.add_row(
|
|
540
|
+
"Root Ref",
|
|
541
|
+
Text(format_resource_ref(root_res) if root_res != "-" else root_res, style="bold yellow"),
|
|
542
|
+
)
|
|
543
|
+
console.print(Panel(summary, border_style="bright_black", box=box.ROUNDED))
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def handle_login(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
547
|
+
auth_url = ctx.settings.oauth_authorize_url()
|
|
548
|
+
if args.print_url:
|
|
549
|
+
console.print(auth_url)
|
|
550
|
+
return 0
|
|
551
|
+
|
|
552
|
+
if not args.code:
|
|
553
|
+
console.print(
|
|
554
|
+
Panel(
|
|
555
|
+
Text(
|
|
556
|
+
"Open the SSO URL below, authorize the app, then paste the callback URL or code back here.\n\n"
|
|
557
|
+
f"{auth_url}",
|
|
558
|
+
style="white",
|
|
559
|
+
),
|
|
560
|
+
title="SSO Login",
|
|
561
|
+
border_style="cyan",
|
|
562
|
+
box=box.ROUNDED,
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
if not args.no_open_browser:
|
|
566
|
+
webbrowser.open(auth_url)
|
|
567
|
+
args.code = input("callback URL or code: ").strip()
|
|
568
|
+
|
|
569
|
+
code = extract_code(args.code)
|
|
570
|
+
with console.status("[bold cyan]Exchanging authorization code[/bold cyan]", spinner="dots"):
|
|
571
|
+
oauth_body = ctx.client.exchange_code(code)
|
|
572
|
+
ctx.settings.user = oauth_body.get("user") or ctx.client.get_current_user()
|
|
573
|
+
ctx.settings.save()
|
|
574
|
+
user = ctx.settings.user or {}
|
|
575
|
+
print_success(
|
|
576
|
+
f"logged in as {user.get('nickname', 'unknown user')} (root: {format_resource_ref(user.get('root_res', 'n/a'))})"
|
|
577
|
+
)
|
|
578
|
+
return 0
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def handle_whoami(ctx: CommandContext, _: argparse.Namespace) -> int:
|
|
582
|
+
with console.status("[bold cyan]Fetching user profile[/bold cyan]", spinner="dots"):
|
|
583
|
+
user = ctx.sync_user()
|
|
584
|
+
if _.json:
|
|
585
|
+
console.print_json(json.dumps(user, ensure_ascii=False, indent=2))
|
|
586
|
+
return 0
|
|
587
|
+
print_user_summary(user)
|
|
588
|
+
return 0
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def handle_logout(ctx: CommandContext, _: argparse.Namespace) -> int:
|
|
592
|
+
ctx.settings.clear_auth()
|
|
593
|
+
ctx.settings.save()
|
|
594
|
+
print_success("stored credentials cleared")
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def handle_ls(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
599
|
+
with console.status("[bold cyan]Loading resource listing[/bold cyan]", spinner="dots"):
|
|
600
|
+
resource = resolve_remote_reference(ctx, args.remote_path)
|
|
601
|
+
if args.json:
|
|
602
|
+
console.print_json(json.dumps(resource, ensure_ascii=False, indent=2))
|
|
603
|
+
return 0
|
|
604
|
+
print_resource_listing(resource)
|
|
605
|
+
return 0
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def handle_upload(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
609
|
+
local_path = args.local_path.expanduser().resolve()
|
|
610
|
+
if not local_path.exists():
|
|
611
|
+
raise CLIUsageError(f"Local path does not exist: {local_path}")
|
|
612
|
+
|
|
613
|
+
with console.status("[bold cyan]Resolving target directory[/bold cyan]", spinner="dots"):
|
|
614
|
+
target_resource = resolve_remote_reference(ctx, args.remote_path)
|
|
615
|
+
ensure_folder_resource(target_resource, label=args.remote_path)
|
|
616
|
+
target_id = target_resource["info"]["res_str_id"]
|
|
617
|
+
|
|
618
|
+
if local_path.is_file():
|
|
619
|
+
upload_file_to_remote(ctx, local_path, target_id)
|
|
620
|
+
return 0
|
|
621
|
+
|
|
622
|
+
upload_directory_to_remote(ctx, local_path, target_id)
|
|
623
|
+
return 0
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def handle_mkdir(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
627
|
+
with console.status("[bold cyan]Resolving parent directory[/bold cyan]", spinner="dots"):
|
|
628
|
+
target_resource = resolve_remote_reference(ctx, args.parent)
|
|
629
|
+
ensure_folder_resource(target_resource, label=args.parent)
|
|
630
|
+
target_id = target_resource["info"]["res_str_id"]
|
|
631
|
+
|
|
632
|
+
existing = find_child_resource(ctx, target_id, args.name)
|
|
633
|
+
if existing is not None:
|
|
634
|
+
raise CLIUsageError(f"`{args.name}` already exists under {args.parent}.")
|
|
635
|
+
|
|
636
|
+
with console.status(f"[bold cyan]Creating directory[/bold cyan] {args.name}", spinner="dots"):
|
|
637
|
+
created = ctx.client.create_folder(target_id, args.name)
|
|
638
|
+
print_success(f"created {created['rname']} ({format_resource_ref(created['res_str_id'])})")
|
|
639
|
+
return 0
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def handle_rm(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
643
|
+
with console.status("[bold cyan]Resolving resource[/bold cyan]", spinner="dots"):
|
|
644
|
+
resource = resolve_remote_reference(ctx, args.remote_path)
|
|
645
|
+
info = resource["info"]
|
|
646
|
+
if info["rtype"] == RTYPE_FOLDER and resource.get("child_list") and not args.recursive:
|
|
647
|
+
raise CLIUsageError("Directory is not empty. Re-run with `htx rm --recursive`.")
|
|
648
|
+
|
|
649
|
+
if info["rtype"] == RTYPE_FOLDER and args.recursive:
|
|
650
|
+
delete_resource_recursive(ctx, resource)
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
ctx.client.delete_resource(info["res_str_id"])
|
|
654
|
+
print_success(f"deleted {info['rname']} ({format_resource_ref(info['res_str_id'])})")
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def handle_mv(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
659
|
+
with console.status("[bold cyan]Resolving source and target[/bold cyan]", spinner="dots"):
|
|
660
|
+
source = resolve_remote_reference(ctx, args.source)
|
|
661
|
+
target_dir = resolve_remote_reference(ctx, args.target_dir)
|
|
662
|
+
ensure_folder_resource(target_dir, label=args.target_dir)
|
|
663
|
+
|
|
664
|
+
source_info = source["info"]
|
|
665
|
+
target_info = target_dir["info"]
|
|
666
|
+
if source_info["res_str_id"] == target_info["res_str_id"]:
|
|
667
|
+
raise CLIUsageError("Source and target directory cannot be the same resource.")
|
|
668
|
+
|
|
669
|
+
with console.status("[bold cyan]Moving resource[/bold cyan]", spinner="dots"):
|
|
670
|
+
ctx.client.update_resource(
|
|
671
|
+
source_info["res_str_id"],
|
|
672
|
+
parent_str_id=target_info["res_str_id"],
|
|
673
|
+
)
|
|
674
|
+
print_success(
|
|
675
|
+
f"moved {source_info['rname']} ({format_resource_ref(source_info['res_str_id'])})"
|
|
676
|
+
f" -> {target_info['rname']} ({format_resource_ref(target_info['res_str_id'])})"
|
|
677
|
+
)
|
|
678
|
+
return 0
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def handle_rename(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
682
|
+
with console.status("[bold cyan]Resolving resource[/bold cyan]", spinner="dots"):
|
|
683
|
+
resource = resolve_remote_reference(ctx, args.remote_path)
|
|
684
|
+
info = resource["info"]
|
|
685
|
+
with console.status("[bold cyan]Renaming resource[/bold cyan]", spinner="dots"):
|
|
686
|
+
ctx.client.update_resource(info["res_str_id"], rname=args.new_name)
|
|
687
|
+
print_success(
|
|
688
|
+
f"renamed {info['rname']} ({format_resource_ref(info['res_str_id'])})"
|
|
689
|
+
f" -> {args.new_name}"
|
|
690
|
+
)
|
|
691
|
+
return 0
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def handle_download(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
695
|
+
with console.status("[bold cyan]Resolving remote resource[/bold cyan]", spinner="dots"):
|
|
696
|
+
resource = resolve_remote_reference(ctx, args.remote_path)
|
|
697
|
+
destination = resolve_download_destination(resource, args.local_path)
|
|
698
|
+
if resource["info"]["rtype"] == RTYPE_FOLDER:
|
|
699
|
+
file_count, directory_count = download_directory_to_local(ctx, resource, destination)
|
|
700
|
+
print_success(
|
|
701
|
+
f"downloaded directory {resource['info']['rname']} ({format_resource_ref(resource['info']['res_str_id'])})"
|
|
702
|
+
f" -> {destination}"
|
|
703
|
+
)
|
|
704
|
+
print_note(f"entries: {file_count} files across {directory_count} directories")
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
final_url = download_file_to_local(ctx, resource, destination)
|
|
708
|
+
print_success(
|
|
709
|
+
f"downloaded {resource['info']['rname']} ({format_resource_ref(resource['info']['res_str_id'])})"
|
|
710
|
+
f" -> {destination}"
|
|
711
|
+
)
|
|
712
|
+
print_note(f"source: {final_url}")
|
|
713
|
+
return 0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def handle_link(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
717
|
+
with console.status("[bold cyan]Resolving parent directory[/bold cyan]", spinner="dots"):
|
|
718
|
+
target_resource = resolve_remote_reference(ctx, args.parent)
|
|
719
|
+
ensure_folder_resource(target_resource, label=args.parent)
|
|
720
|
+
target_id = target_resource["info"]["res_str_id"]
|
|
721
|
+
|
|
722
|
+
existing = find_child_resource(ctx, target_id, args.name)
|
|
723
|
+
if existing is not None:
|
|
724
|
+
raise CLIUsageError(f"`{args.name}` already exists under {args.parent}.")
|
|
725
|
+
|
|
726
|
+
with console.status(f"[bold cyan]Creating link[/bold cyan] {args.name}", spinner="dots"):
|
|
727
|
+
created = ctx.client.create_link(target_id, args.name, args.url)
|
|
728
|
+
print_success(
|
|
729
|
+
f"created link {created['rname']} ({format_resource_ref(created['res_str_id'])})"
|
|
730
|
+
f" -> {args.url}"
|
|
731
|
+
)
|
|
732
|
+
return 0
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
736
|
+
settings = DiskSettings.load(args.config)
|
|
737
|
+
client = DiskClient(settings)
|
|
738
|
+
ctx = CommandContext(settings=settings, client=client)
|
|
739
|
+
|
|
740
|
+
handlers = {
|
|
741
|
+
"login": handle_login,
|
|
742
|
+
"whoami": handle_whoami,
|
|
743
|
+
"logout": handle_logout,
|
|
744
|
+
"ls": handle_ls,
|
|
745
|
+
"upload": handle_upload,
|
|
746
|
+
"mkdir": handle_mkdir,
|
|
747
|
+
"rm": handle_rm,
|
|
748
|
+
"mv": handle_mv,
|
|
749
|
+
"rename": handle_rename,
|
|
750
|
+
"download": handle_download,
|
|
751
|
+
"link": handle_link,
|
|
752
|
+
}
|
|
753
|
+
return handlers[args.command](ctx, args)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def main(argv: list[str] | None = None) -> int:
|
|
757
|
+
parser = build_parser()
|
|
758
|
+
args = parser.parse_args(argv)
|
|
759
|
+
try:
|
|
760
|
+
return dispatch(args)
|
|
761
|
+
except CLIUsageError as exc:
|
|
762
|
+
error_console.print(f"[bold red]ERROR[/bold red] {exc}")
|
|
763
|
+
return 2
|
|
764
|
+
except DiskAPIError as exc:
|
|
765
|
+
error_console.print(f"[bold red]API[/bold red] {exc}")
|
|
766
|
+
if exc.debug_msg:
|
|
767
|
+
print_warning(f"debug: {exc.debug_msg}")
|
|
768
|
+
return 1
|
|
769
|
+
except KeyboardInterrupt:
|
|
770
|
+
error_console.print("[bold yellow]ABORTED[/bold yellow]")
|
|
771
|
+
return 130
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
if __name__ == "__main__":
|
|
775
|
+
raise SystemExit(main())
|