@codebend3r/che 1.1.0 → 1.2.0
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.
- package/README.md +38 -24
- package/bin/art.py +22 -20
- package/bin/che.py +208 -587
- package/bin/commands.py +220 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +54 -283
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1182
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +503 -431
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/menu.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""The full-screen menu's model and view: what is listed, how a key moves
|
|
2
|
+
through it, and how a frame is drawn.
|
|
3
|
+
|
|
4
|
+
Running anything is left to ``che.py``, whose menu loop suspends the screen,
|
|
5
|
+
launches the selected command and comes back. That keeps the dependency one
|
|
6
|
+
way - the dispatcher uses the menu, the menu never calls the dispatcher.
|
|
7
|
+
|
|
8
|
+
Library module: no shebang, not executable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import textwrap
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
import art
|
|
20
|
+
import tui
|
|
21
|
+
from commands import MENU_KEYS, VERSION, Category, Command, Resolved, all_commands, grouped
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=64)
|
|
25
|
+
def _which(binary: str) -> str | None:
|
|
26
|
+
"""PATH lookup, cached: the menu asks about the same binaries every frame."""
|
|
27
|
+
return shutil.which(binary)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Filtering
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fuzzy_match(query: str, command: Command) -> int | None:
|
|
36
|
+
"""Score ``command`` against ``query`` for the menu's type-to-filter.
|
|
37
|
+
|
|
38
|
+
Returns ``None`` when it does not match at all, otherwise a score where
|
|
39
|
+
lower is better: an exact name prefix beats a name substring, which beats a
|
|
40
|
+
match that only landed in the summary or tags, which beats a subsequence
|
|
41
|
+
match ("dbe" -> "delete-by-ext").
|
|
42
|
+
"""
|
|
43
|
+
if not query:
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
needle = query.lower().strip()
|
|
47
|
+
name = command.name.lower()
|
|
48
|
+
|
|
49
|
+
if name.startswith(needle):
|
|
50
|
+
return 1
|
|
51
|
+
if needle in name:
|
|
52
|
+
return 2
|
|
53
|
+
words = (command.name, command.summary, command.category, *command.tags)
|
|
54
|
+
if needle in " ".join(words).lower():
|
|
55
|
+
return 3
|
|
56
|
+
|
|
57
|
+
# Subsequence: every character of the query appears in order in the name.
|
|
58
|
+
position = 0
|
|
59
|
+
for char in needle:
|
|
60
|
+
found = name.find(char, position)
|
|
61
|
+
if found < 0:
|
|
62
|
+
return None
|
|
63
|
+
position = found + 1
|
|
64
|
+
return 4
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# State
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# What a keypress asks the caller to do; "" is nothing beyond the redraw.
|
|
73
|
+
Action = Literal["", "quit", "run", "run-raw", "help"]
|
|
74
|
+
|
|
75
|
+
# How far PgUp and PgDn move the cursor, in commands.
|
|
76
|
+
PAGE = 10
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Menu:
|
|
81
|
+
query: str = ""
|
|
82
|
+
searching: bool = False
|
|
83
|
+
cursor: int = 0
|
|
84
|
+
offset: int = 0
|
|
85
|
+
dry: dict[str, bool] = field(default_factory=dict)
|
|
86
|
+
show_help: bool = False
|
|
87
|
+
status: str = ""
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
self.first()
|
|
91
|
+
|
|
92
|
+
def matches(self) -> list[Command]:
|
|
93
|
+
scored = []
|
|
94
|
+
for command in all_commands():
|
|
95
|
+
score = fuzzy_match(self.query, command)
|
|
96
|
+
if score is None:
|
|
97
|
+
continue
|
|
98
|
+
scored.append((score, command))
|
|
99
|
+
scored.sort(key=lambda pair: (pair[0], pair[1].category, pair[1].name))
|
|
100
|
+
return [command for _, command in scored]
|
|
101
|
+
|
|
102
|
+
def rows(self) -> list[Category | Command]:
|
|
103
|
+
"""The list as drawn: commands, under their category headers unless searching."""
|
|
104
|
+
commands = self.matches()
|
|
105
|
+
if self.query:
|
|
106
|
+
return list(commands)
|
|
107
|
+
return [row for category, members in grouped(commands) for row in (category, *members)]
|
|
108
|
+
|
|
109
|
+
def items(self) -> list[int]:
|
|
110
|
+
"""The rows the cursor can land on: the commands, not the headers."""
|
|
111
|
+
return [index for index, row in enumerate(self.rows()) if isinstance(row, Command)]
|
|
112
|
+
|
|
113
|
+
def selected(self) -> Command | None:
|
|
114
|
+
rows = self.rows()
|
|
115
|
+
if not rows:
|
|
116
|
+
return None
|
|
117
|
+
self.cursor = max(0, min(self.cursor, len(rows) - 1))
|
|
118
|
+
row = rows[self.cursor]
|
|
119
|
+
return row if isinstance(row, Command) else None
|
|
120
|
+
|
|
121
|
+
def resolved(self) -> Resolved | None:
|
|
122
|
+
"""The selected command as it would run: its ``-dr`` twin while on preview."""
|
|
123
|
+
command = self.selected()
|
|
124
|
+
return None if command is None else Resolved(command, dry=self.is_dry(command))
|
|
125
|
+
|
|
126
|
+
def move(self, delta: int) -> None:
|
|
127
|
+
"""One step up or down, wrapping round at either end."""
|
|
128
|
+
items = self.items()
|
|
129
|
+
if not items:
|
|
130
|
+
return
|
|
131
|
+
if self.cursor not in items:
|
|
132
|
+
self.cursor = items[0]
|
|
133
|
+
return
|
|
134
|
+
position = items.index(self.cursor)
|
|
135
|
+
self.cursor = items[(position + delta) % len(items)]
|
|
136
|
+
|
|
137
|
+
def jump(self, delta: int) -> None:
|
|
138
|
+
"""A page up or down, stopping at either end rather than wrapping."""
|
|
139
|
+
items = self.items()
|
|
140
|
+
if not items:
|
|
141
|
+
return
|
|
142
|
+
position = items.index(self.cursor) if self.cursor in items else 0
|
|
143
|
+
self.cursor = items[max(0, min(len(items) - 1, position + delta))]
|
|
144
|
+
|
|
145
|
+
def set_query(self, query: str) -> None:
|
|
146
|
+
self.query = query
|
|
147
|
+
self.first()
|
|
148
|
+
|
|
149
|
+
def first(self) -> None:
|
|
150
|
+
items = self.items()
|
|
151
|
+
self.cursor = items[0] if items else 0
|
|
152
|
+
|
|
153
|
+
def last(self) -> None:
|
|
154
|
+
items = self.items()
|
|
155
|
+
self.cursor = items[-1] if items else 0
|
|
156
|
+
|
|
157
|
+
def is_dry(self, command: Command) -> bool:
|
|
158
|
+
# Anything that can preview previews first: the menu is a browsing UI,
|
|
159
|
+
# and "I pressed Enter to see what it does" should never delete files.
|
|
160
|
+
return self.dry.get(command.name, command.has_dry_twin)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def handle_key(state: Menu, key: str) -> Action:
|
|
164
|
+
"""Fold one keypress into the menu state. Returns an action for the caller."""
|
|
165
|
+
# A status line lasts until the next key, and any key dismisses the help.
|
|
166
|
+
state.status = ""
|
|
167
|
+
if state.show_help:
|
|
168
|
+
state.show_help = False
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
if state.searching:
|
|
172
|
+
if key == "enter":
|
|
173
|
+
state.searching = False
|
|
174
|
+
elif key == "esc":
|
|
175
|
+
state.searching = False
|
|
176
|
+
state.set_query("")
|
|
177
|
+
elif key == "backspace":
|
|
178
|
+
state.set_query(state.query[:-1])
|
|
179
|
+
elif key == "ctrl-u":
|
|
180
|
+
state.set_query("")
|
|
181
|
+
elif key in ("up", "down"):
|
|
182
|
+
state.move(-1 if key == "up" else 1)
|
|
183
|
+
elif key == "ctrl-c":
|
|
184
|
+
return "quit"
|
|
185
|
+
elif len(key) == 1 and key.isprintable():
|
|
186
|
+
state.set_query(state.query + key)
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
if key in ("q", "ctrl-c", "eof"):
|
|
190
|
+
return "quit"
|
|
191
|
+
if key == "esc":
|
|
192
|
+
if state.query:
|
|
193
|
+
state.set_query("")
|
|
194
|
+
return ""
|
|
195
|
+
return "quit"
|
|
196
|
+
if key in ("up", "k"):
|
|
197
|
+
state.move(-1)
|
|
198
|
+
elif key in ("down", "j"):
|
|
199
|
+
state.move(1)
|
|
200
|
+
elif key == "pgup":
|
|
201
|
+
state.jump(-PAGE)
|
|
202
|
+
elif key == "pgdn":
|
|
203
|
+
state.jump(PAGE)
|
|
204
|
+
elif key in ("home", "g"):
|
|
205
|
+
state.first()
|
|
206
|
+
elif key in ("end", "G"):
|
|
207
|
+
state.last()
|
|
208
|
+
elif key == "/":
|
|
209
|
+
state.searching = True
|
|
210
|
+
elif key == "?":
|
|
211
|
+
state.show_help = True
|
|
212
|
+
elif key == "d":
|
|
213
|
+
command = state.selected()
|
|
214
|
+
if command and command.has_dry_twin:
|
|
215
|
+
state.dry[command.name] = not state.is_dry(command)
|
|
216
|
+
state.status = f"dry-run {'on' if state.is_dry(command) else 'OFF'} for {command.name}"
|
|
217
|
+
elif command and command.preview:
|
|
218
|
+
state.status = f"{command.name} previews with {command.preview}, asked on ⏎"
|
|
219
|
+
elif command:
|
|
220
|
+
state.status = f"{command.name} has no preview mode"
|
|
221
|
+
elif key == "h":
|
|
222
|
+
return "help"
|
|
223
|
+
elif key == "x":
|
|
224
|
+
return "run-raw"
|
|
225
|
+
elif key == "enter":
|
|
226
|
+
return "run"
|
|
227
|
+
elif len(key) == 1 and key.isprintable():
|
|
228
|
+
# Typing anything else drops into search, the way a file picker does.
|
|
229
|
+
state.searching = True
|
|
230
|
+
state.set_query(state.query + key)
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Drawing
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
# The face heads the menu only while the list under it keeps at least this
|
|
239
|
+
# many rows. Below that the one-line header takes over, as it always did.
|
|
240
|
+
MIN_LIST_ROWS = 8
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def banner_fits(screen: tui.Screen, right: list[str]) -> bool:
|
|
244
|
+
"""Whether the face and wordmark can head the menu and still leave a list."""
|
|
245
|
+
spare = screen.rows - (art.HEIGHT + 1) - 4
|
|
246
|
+
return spare >= MIN_LIST_ROWS and art.fits(right, screen.columns)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def draw(screen: tui.Screen, state: Menu) -> None:
|
|
250
|
+
colors = tui.palette()
|
|
251
|
+
width = screen.columns
|
|
252
|
+
|
|
253
|
+
total = len(all_commands())
|
|
254
|
+
shown = len(state.matches())
|
|
255
|
+
counter = f"{shown}/{total}" if state.query else str(total)
|
|
256
|
+
if state.searching or state.query:
|
|
257
|
+
caret = f"{colors.cyan}▏{colors.reset}" if state.searching else " "
|
|
258
|
+
prompt = f" {colors.grey}search{colors.reset} {caret}{state.query}"
|
|
259
|
+
else:
|
|
260
|
+
prompt = f" {colors.grey}type to search ↑↓ move ⏎ run ? keys{colors.reset}"
|
|
261
|
+
|
|
262
|
+
right = art.wordmark(colors)
|
|
263
|
+
right += ["", f" {colors.grey}v{VERSION} {counter} commands{colors.reset}", prompt]
|
|
264
|
+
if banner_fits(screen, right) and not state.show_help:
|
|
265
|
+
# The face beside the wordmark, with the version, the count and the
|
|
266
|
+
# search line tucked under the letters.
|
|
267
|
+
screen.lines(art.banner(right, columns=width, colors=colors))
|
|
268
|
+
else:
|
|
269
|
+
title = f"{colors.bright_magenta}{colors.bold}che{colors.reset}"
|
|
270
|
+
screen.line()
|
|
271
|
+
screen.line(
|
|
272
|
+
f" {title} {colors.grey}v{VERSION}{colors.reset} "
|
|
273
|
+
f"{colors.grey}{counter} commands{colors.reset}"
|
|
274
|
+
)
|
|
275
|
+
screen.line(prompt)
|
|
276
|
+
screen.line(f" {colors.grey}{'─' * max(0, width - 4)}{colors.reset}")
|
|
277
|
+
header_rows = screen.used
|
|
278
|
+
|
|
279
|
+
if state.show_help:
|
|
280
|
+
draw_help(screen)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
body_rows = max(3, screen.rows - header_rows - 4)
|
|
284
|
+
rows = state.rows()
|
|
285
|
+
if not rows:
|
|
286
|
+
screen.line()
|
|
287
|
+
screen.line(f" {colors.yellow}No command matches {state.query!r}{colors.reset}")
|
|
288
|
+
draw_footer(screen, state)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Keep the cursor inside the viewport, with a line of context either side.
|
|
292
|
+
if state.cursor < state.offset + 1:
|
|
293
|
+
state.offset = max(0, state.cursor - 1)
|
|
294
|
+
if state.cursor >= state.offset + body_rows - 1:
|
|
295
|
+
state.offset = min(max(0, len(rows) - body_rows), state.cursor - body_rows + 2)
|
|
296
|
+
|
|
297
|
+
detail_width = 0
|
|
298
|
+
if width >= 92:
|
|
299
|
+
detail_width = max(34, width // 2 - 6)
|
|
300
|
+
list_width = width - detail_width - (4 if detail_width else 2)
|
|
301
|
+
|
|
302
|
+
detail_lines = detail_pane(state, detail_width) if detail_width else []
|
|
303
|
+
|
|
304
|
+
for index in range(state.offset, min(len(rows), state.offset + body_rows)):
|
|
305
|
+
row = rows[index]
|
|
306
|
+
if isinstance(row, Category):
|
|
307
|
+
header = f"{row.title} {row.blurb}"
|
|
308
|
+
left = tui.fit(f" {colors.bold}{colors.blue}{header}{colors.reset}", list_width)
|
|
309
|
+
else:
|
|
310
|
+
command = row
|
|
311
|
+
selected = index == state.cursor
|
|
312
|
+
pointer = f"{colors.cyan}▸{colors.reset}" if selected else " "
|
|
313
|
+
name = command.name
|
|
314
|
+
if selected:
|
|
315
|
+
name = f"{colors.bold}{colors.cyan}{name}{colors.reset}"
|
|
316
|
+
elif command.destructive:
|
|
317
|
+
name = f"{colors.yellow}{name}{colors.reset}"
|
|
318
|
+
|
|
319
|
+
badge = ""
|
|
320
|
+
if command.has_dry_twin and state.is_dry(command):
|
|
321
|
+
badge = f" {colors.grey}·dry{colors.reset}"
|
|
322
|
+
elif command.destructive:
|
|
323
|
+
badge = f" {colors.red}·live{colors.reset}"
|
|
324
|
+
|
|
325
|
+
left = f" {pointer} {command.icon} {name}{badge}"
|
|
326
|
+
if detail_width:
|
|
327
|
+
left = tui.fit(left, list_width)
|
|
328
|
+
else:
|
|
329
|
+
room = max(0, list_width - tui.display_width(left) - 2)
|
|
330
|
+
summary = tui.truncate(command.summary, room)
|
|
331
|
+
left += f" {colors.grey}{summary}{colors.reset}"
|
|
332
|
+
|
|
333
|
+
detail_index = index - state.offset
|
|
334
|
+
right = detail_lines[detail_index] if detail_index < len(detail_lines) else ""
|
|
335
|
+
if detail_width:
|
|
336
|
+
screen.line(f"{left}{colors.grey}│{colors.reset} {right}")
|
|
337
|
+
else:
|
|
338
|
+
screen.line(left)
|
|
339
|
+
|
|
340
|
+
draw_footer(screen, state)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# The detail pane's label column: "script ", "needs ", "warning ".
|
|
344
|
+
LABEL_WIDTH = 8
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def detail_pane(state: Menu, width: int) -> list[str]:
|
|
348
|
+
"""The right-hand column: what the selected command is and what it needs."""
|
|
349
|
+
colors = tui.palette()
|
|
350
|
+
command = state.selected()
|
|
351
|
+
if command is None:
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
lines: list[str] = [f"{colors.bold}{command.name}{colors.reset}", ""]
|
|
355
|
+
lines += wrap(command.summary, width, colors.grey)
|
|
356
|
+
lines.append("")
|
|
357
|
+
|
|
358
|
+
def field_line(label: str, value: str) -> str:
|
|
359
|
+
return f"{colors.grey}{label:<{LABEL_WIDTH}}{colors.reset}{value}"
|
|
360
|
+
|
|
361
|
+
def field_wrap(label: str, text: str, color: str) -> list[str]:
|
|
362
|
+
return wrap(text, width, color, indent=f"{label:<{LABEL_WIDTH}}")
|
|
363
|
+
|
|
364
|
+
if command.script:
|
|
365
|
+
lines.append(field_line("script", f"bin/{command.script}"))
|
|
366
|
+
elif command.builtin:
|
|
367
|
+
lines.append(field_line("script", "built in to che"))
|
|
368
|
+
|
|
369
|
+
if command.needs:
|
|
370
|
+
parts = []
|
|
371
|
+
for binary in command.needs:
|
|
372
|
+
found = _which(binary)
|
|
373
|
+
mark = f"{colors.green}✓{colors.reset}" if found else f"{colors.red}✗{colors.reset}"
|
|
374
|
+
parts.append(f"{mark} {binary}")
|
|
375
|
+
lines.append(field_line("needs", " ".join(parts)))
|
|
376
|
+
|
|
377
|
+
if command.flags:
|
|
378
|
+
lines += field_wrap("flags", " ".join(command.flag_names), colors.grey)
|
|
379
|
+
|
|
380
|
+
if command.has_dry_twin:
|
|
381
|
+
state_text = (
|
|
382
|
+
f"{colors.green}on{colors.reset} - previews only"
|
|
383
|
+
if state.is_dry(command)
|
|
384
|
+
else f"{colors.red}OFF{colors.reset} - changes files"
|
|
385
|
+
)
|
|
386
|
+
lines.append(field_line("dry-run", f"{state_text} {colors.grey}(d){colors.reset}"))
|
|
387
|
+
elif command.destructive:
|
|
388
|
+
reason = command.no_preview_reason or "changes files"
|
|
389
|
+
lines += field_wrap("warning", reason, colors.yellow)
|
|
390
|
+
|
|
391
|
+
if command.setup:
|
|
392
|
+
lines += field_wrap("setup", command.setup, colors.yellow)
|
|
393
|
+
|
|
394
|
+
if command.macos_only:
|
|
395
|
+
lines.append(field_line("os", "macOS only"))
|
|
396
|
+
|
|
397
|
+
lines.append("")
|
|
398
|
+
lines.append(f"{colors.grey}⏎ run x run bare h help{colors.reset}")
|
|
399
|
+
return [tui.truncate(line, width) for line in lines]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def wrap(text: str, width: int, color: str = "", indent: str = "") -> list[str]:
|
|
403
|
+
colors = tui.palette()
|
|
404
|
+
body = textwrap.wrap(text, max(10, width - len(indent))) or [""]
|
|
405
|
+
out = []
|
|
406
|
+
for index, line in enumerate(body):
|
|
407
|
+
prefix = indent if index == 0 else " " * len(indent)
|
|
408
|
+
out.append(
|
|
409
|
+
f"{colors.grey}{prefix}{colors.reset}{color}{line}{colors.reset if color else ''}"
|
|
410
|
+
)
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def draw_help(screen: tui.Screen) -> None:
|
|
415
|
+
colors = tui.palette()
|
|
416
|
+
screen.line()
|
|
417
|
+
screen.line(f" {colors.bold}Keys{colors.reset}")
|
|
418
|
+
screen.line()
|
|
419
|
+
width = max(len(key) for key, _ in MENU_KEYS) + 3
|
|
420
|
+
for key, description in MENU_KEYS:
|
|
421
|
+
screen.line(
|
|
422
|
+
f" {colors.cyan}{key:<{width}}{colors.reset}{colors.grey}{description}{colors.reset}"
|
|
423
|
+
)
|
|
424
|
+
screen.line()
|
|
425
|
+
screen.line(f" {colors.grey}Press any key to go back.{colors.reset}")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def draw_footer(screen: tui.Screen, state: Menu) -> None:
|
|
429
|
+
colors = tui.palette()
|
|
430
|
+
screen.pad_to(screen.rows - 2)
|
|
431
|
+
screen.line(f" {colors.grey}{'─' * max(0, screen.columns - 4)}{colors.reset}")
|
|
432
|
+
if state.status:
|
|
433
|
+
screen.line(f" {colors.yellow}{state.status}{colors.reset}")
|
|
434
|
+
else:
|
|
435
|
+
screen.line(
|
|
436
|
+
f" {colors.grey}⏎ run d dry-run h help / search ? keys q quit{colors.reset}"
|
|
437
|
+
)
|
package/bin/nas.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The NAS drive table and SMB mount helpers for the scripts under ``bin/drives/``.
|
|
2
|
+
|
|
3
|
+
Split out of the three drive scripts, which each carried their own copy of the
|
|
4
|
+
table, the mount check and the AppleScript mount call. Scripts pick it up the
|
|
5
|
+
same way as ``utils.py``::
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
11
|
+
from nas import mount_smb, select_drives
|
|
12
|
+
|
|
13
|
+
Standard library only, like everything else at the ``bin/`` root. ``osascript``
|
|
14
|
+
is macOS-only; callers check for it with ``require_binary``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from utils import run, warning
|
|
23
|
+
|
|
24
|
+
SMB_USER = "crivas"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NasDrive(NamedTuple):
|
|
28
|
+
"""One NAS. The share name is assumed to match the drive name, so
|
|
29
|
+
//crivas@Meleys/Meleys mounts at /Volumes/Meleys."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
ip: str
|
|
33
|
+
|
|
34
|
+
def host(self, *, use_ip: bool) -> str:
|
|
35
|
+
"""How to reach it in a share URL: by IP under ``--use-ip``, else by name."""
|
|
36
|
+
return self.ip if use_ip else self.name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
NAS_DRIVES: tuple[NasDrive, ...] = (
|
|
40
|
+
NasDrive("Meleys", "192.168.50.2"),
|
|
41
|
+
NasDrive("Vermithor", "192.168.50.3"),
|
|
42
|
+
NasDrive("Caraxes", "192.168.50.4"),
|
|
43
|
+
NasDrive("Syrax", "192.168.50.5"),
|
|
44
|
+
NasDrive("Vhagar", "192.168.50.6"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def select_drives(only: str) -> list[NasDrive]:
|
|
49
|
+
"""The drives an ``--only`` value picks: all of them when it is empty.
|
|
50
|
+
|
|
51
|
+
The match is case-insensitive. An unknown name selects nothing; see
|
|
52
|
+
:func:`warn_unknown_drive`.
|
|
53
|
+
"""
|
|
54
|
+
return [drive for drive in NAS_DRIVES if not only or drive.name.lower() == only.lower()]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def warn_unknown_drive(only: str) -> None:
|
|
58
|
+
"""Warn that ``--only`` named no drive, for the scripts that carry on.
|
|
59
|
+
|
|
60
|
+
Only a warning, keeping the shell versions' exit code: an unknown --only
|
|
61
|
+
printed a 0/0 summary and still exited 0. ``ping-nas`` alone refuses one.
|
|
62
|
+
"""
|
|
63
|
+
known = ", ".join(drive.name for drive in NAS_DRIVES)
|
|
64
|
+
warning(f"⚠️ No drive named '{only}'. Known: {known}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_mounted(name: str) -> bool:
|
|
68
|
+
"""True when something is actually mounted at ``/Volumes/<name>``.
|
|
69
|
+
|
|
70
|
+
The shell version grepped ``mount`` output for `` on /Volumes/<name> (``;
|
|
71
|
+
``os.path.ismount`` asks the kernel directly. The two agree for every
|
|
72
|
+
healthy mount. The one real difference: ``ismount`` lstats the mount root,
|
|
73
|
+
so on a wedged SMB mount it can block for the server timeout where reading
|
|
74
|
+
the mount table cannot.
|
|
75
|
+
"""
|
|
76
|
+
return os.path.ismount(f"/Volumes/{name}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def smb_url(name: str, host: str) -> str:
|
|
80
|
+
"""The share URL for drive ``name``, reached at ``host`` (its name or its IP)."""
|
|
81
|
+
return f"smb://{SMB_USER}@{host}/{name}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def mount_smb(name: str, host: str) -> bool:
|
|
85
|
+
"""Mount one share via AppleScript's ``mount volume``. True on success.
|
|
86
|
+
|
|
87
|
+
AppleScript rather than ``mount_smbfs`` takes the same NetAuth path Finder
|
|
88
|
+
does: ``/Volumes/<name>`` is created automatically and Keychain credentials
|
|
89
|
+
are used silently, with no Finder modals. On failure this prints why; the
|
|
90
|
+
caller prints its own progress and success lines.
|
|
91
|
+
"""
|
|
92
|
+
url = smb_url(name, host)
|
|
93
|
+
completed = run(["osascript", "-e", f'mount volume "{url}"'], check=False, capture=True)
|
|
94
|
+
if completed.returncode == 0:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
warning(f" ❌ mount failed for {url}")
|
|
98
|
+
stderr = (completed.stderr or "").strip()
|
|
99
|
+
if stderr:
|
|
100
|
+
warning(f" {stderr}")
|
|
101
|
+
warning(f" Check that Keychain has a password for '{SMB_USER}@{host}'.")
|
|
102
|
+
return False
|