light-phone-cli-tui 0.1.0__tar.gz
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.
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: light-phone-cli-tui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial CLI/TUI for the Light Phone
|
|
5
|
+
Author-email: Alexis Garado <alexisgarado@proton.me>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: click
|
|
8
|
+
Requires-Dist: light-phone-api
|
|
9
|
+
Requires-Dist: pyperclip
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
Requires-Dist: rich-click
|
|
12
|
+
Requires-Dist: textual
|
|
File without changes
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""
|
|
2
|
+
█░░ █ █▀▀ █░█ ▀█▀ █▀▀ █░░ █ ▄█▄ ▀█▀ █░█ █
|
|
3
|
+
█▄▄ █ █▄█ █▀█ ░█░ █▄▄ █▄▄ █ ░▀░ ░█░ █▄█ █
|
|
4
|
+
|
|
5
|
+
Command line tools for Light devices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import rich_click as click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import Progress, TaskID, TextColumn, BarColumn, TaskProgressColumn
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from light_api.client import Light
|
|
16
|
+
from light_api.music import SortMode
|
|
17
|
+
from light_api.tools import ToolName
|
|
18
|
+
from light_api import with_light
|
|
19
|
+
from light_cli_tui.tui import LightConfig, run_tui
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
click.rich_click.USE_RICH_MARKUP = True
|
|
23
|
+
click.rich_click.USE_MARKDOWN = True
|
|
24
|
+
click.rich_click.SHOW_ARGUMENTS = True
|
|
25
|
+
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
|
|
26
|
+
click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 3)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
log = logging.getLogger(f"light.{__name__}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
@click.option("--email", default=None, help="Light account email address.")
|
|
34
|
+
@click.option("--email-file", default=None, help="Path to file containing email.")
|
|
35
|
+
@click.option("--password", default=None, help="Light account password.")
|
|
36
|
+
@click.option("--password-file", default=None, help="Path to file containing password.")
|
|
37
|
+
@click.option("--phone-number", default=None, help="Phone number.")
|
|
38
|
+
@click.option(
|
|
39
|
+
"--phone-number-file", default=None, help="Path to file containing phone number."
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--log-level",
|
|
43
|
+
default="WARNING",
|
|
44
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
|
|
45
|
+
help="Log level.",
|
|
46
|
+
)
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def cli(
|
|
49
|
+
ctx,
|
|
50
|
+
email,
|
|
51
|
+
email_file,
|
|
52
|
+
password,
|
|
53
|
+
password_file,
|
|
54
|
+
phone_number,
|
|
55
|
+
phone_number_file,
|
|
56
|
+
log_level,
|
|
57
|
+
):
|
|
58
|
+
"""**Unofficial CLI for the Light Phone.**
|
|
59
|
+
|
|
60
|
+
Manages music, podcasts, and notes on your Light device from the terminal.
|
|
61
|
+
|
|
62
|
+
Credentials can be provided via options, files, or environment variables
|
|
63
|
+
(`LIGHT_EMAIL`, `LIGHT_PASSWORD`, `LIGHT_PHONE_NUMBER`).
|
|
64
|
+
"""
|
|
65
|
+
logging.basicConfig(format="%(name)s %(levelname)s %(message)s")
|
|
66
|
+
logging.getLogger("light").setLevel(log_level.upper())
|
|
67
|
+
|
|
68
|
+
ctx.ensure_object(dict)
|
|
69
|
+
ctx.obj.update(
|
|
70
|
+
{
|
|
71
|
+
"email": email,
|
|
72
|
+
"email_file": email_file,
|
|
73
|
+
"password": password,
|
|
74
|
+
"password_file": password_file,
|
|
75
|
+
"phone_number": phone_number,
|
|
76
|
+
"phone_number_file": phone_number_file,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cli.group()
|
|
82
|
+
def music():
|
|
83
|
+
"""Music library management.
|
|
84
|
+
|
|
85
|
+
Upload tracks, delete them, sort your playlist, and update metadata.
|
|
86
|
+
"""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@cli.group()
|
|
91
|
+
def podcast():
|
|
92
|
+
"""Podcast management.
|
|
93
|
+
|
|
94
|
+
Add podcasts by RSS feed URL and remove ones you no longer want.
|
|
95
|
+
"""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cli.group()
|
|
100
|
+
def notes():
|
|
101
|
+
"""Notes management.
|
|
102
|
+
|
|
103
|
+
List, add, download, and watch for changes to text and audio notes.
|
|
104
|
+
"""
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -- Podcast commands ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@podcast.command("add")
|
|
112
|
+
@with_light
|
|
113
|
+
@click.argument("rss_feed_url")
|
|
114
|
+
def podcast_add(light: Light, rss_feed_url):
|
|
115
|
+
"""Subscribe to a podcast by RSS feed URL.
|
|
116
|
+
|
|
117
|
+
The server resolves the title and publisher automatically from the feed.
|
|
118
|
+
|
|
119
|
+
**Example:**
|
|
120
|
+
|
|
121
|
+
`light podcast add https://feeds.simplecast.com/FO6kxYGj`
|
|
122
|
+
"""
|
|
123
|
+
p = light.podcast.add_podcast(rss_feed_url)
|
|
124
|
+
console.print(f"[green]Added:[/green] {p.title or rss_feed_url}")
|
|
125
|
+
if p.publisher:
|
|
126
|
+
console.print(f"[dim]Publisher:[/dim] {p.publisher}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@podcast.command("list")
|
|
130
|
+
@with_light
|
|
131
|
+
def podcast_list(light: Light):
|
|
132
|
+
"""List all followed podcasts on your device."""
|
|
133
|
+
podcasts = light.podcast.get_podcasts()
|
|
134
|
+
|
|
135
|
+
if not podcasts:
|
|
136
|
+
console.print("[dim]No podcasts followed.[/dim]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
table = Table(show_header=True)
|
|
140
|
+
table.add_column("#", style="dim", width=4)
|
|
141
|
+
table.add_column("Title")
|
|
142
|
+
table.add_column("Publisher")
|
|
143
|
+
|
|
144
|
+
for i, p in enumerate(podcasts, 1):
|
|
145
|
+
table.add_row(str(i), p.title, p.publisher)
|
|
146
|
+
|
|
147
|
+
console.print(table)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@podcast.command("delete")
|
|
151
|
+
@with_light
|
|
152
|
+
@click.argument("title")
|
|
153
|
+
def podcast_delete(light: Light, title):
|
|
154
|
+
"""Unfollow a podcast by title.
|
|
155
|
+
|
|
156
|
+
Uses exact title matching. Run `light podcast list` to see titles.
|
|
157
|
+
"""
|
|
158
|
+
podcasts = light.podcast.get_podcasts()
|
|
159
|
+
matches = [p for p in podcasts if p.title == title]
|
|
160
|
+
|
|
161
|
+
if not matches:
|
|
162
|
+
console.print(f"[yellow]No podcast found with title: {title}[/yellow]")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
for p in matches:
|
|
166
|
+
console.print(f" {p.title}")
|
|
167
|
+
if not click.confirm("Unfollow?"):
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
light.podcast.delete_podcast_by_title(title)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- Music commands -------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@music.command("upload")
|
|
177
|
+
@with_light
|
|
178
|
+
@click.argument("songs", nargs=-1, required=True)
|
|
179
|
+
@click.option(
|
|
180
|
+
"--allow-duplicates",
|
|
181
|
+
is_flag=True,
|
|
182
|
+
help="Skip duplicate checking and always upload.",
|
|
183
|
+
)
|
|
184
|
+
@click.option(
|
|
185
|
+
"--match-title-by",
|
|
186
|
+
"-m",
|
|
187
|
+
type=click.Choice(["filename", "metadata"]),
|
|
188
|
+
default="metadata",
|
|
189
|
+
show_default=True,
|
|
190
|
+
help="How to match existing tracks when checking for duplicates.",
|
|
191
|
+
)
|
|
192
|
+
@click.option(
|
|
193
|
+
"--no-convert-flac",
|
|
194
|
+
is_flag=True,
|
|
195
|
+
default=False,
|
|
196
|
+
help="Skip FLAC to MP3 conversion (conversion is on by default to preserve metadata).",
|
|
197
|
+
)
|
|
198
|
+
def music_upload(light: Light, songs, allow_duplicates, match_title_by, no_convert_flac):
|
|
199
|
+
"""Upload one or more audio files to your device.
|
|
200
|
+
|
|
201
|
+
Duplicate detection is on by default - existing tracks with a matching
|
|
202
|
+
title will be replaced. Use `--allow-duplicates` to skip this.
|
|
203
|
+
|
|
204
|
+
**Example:**
|
|
205
|
+
|
|
206
|
+
`light music upload track1.mp3 track2.mp3`
|
|
207
|
+
"""
|
|
208
|
+
files = list(songs)
|
|
209
|
+
|
|
210
|
+
if not allow_duplicates:
|
|
211
|
+
from mutagen._file import File as MutagenFile
|
|
212
|
+
import os as _os
|
|
213
|
+
|
|
214
|
+
if match_title_by == "metadata":
|
|
215
|
+
titles = []
|
|
216
|
+
for s in files:
|
|
217
|
+
f = MutagenFile(s, easy=True)
|
|
218
|
+
titles.append(
|
|
219
|
+
f.get("title", [_os.path.splitext(_os.path.basename(s))[0]])[0]
|
|
220
|
+
if f
|
|
221
|
+
else _os.path.splitext(_os.path.basename(s))[0]
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
titles = [_os.path.splitext(_os.path.basename(s))[0] for s in files]
|
|
225
|
+
|
|
226
|
+
existing = light.music.get_tracks()
|
|
227
|
+
to_overwrite = [t for t in existing if t.title in set(titles)]
|
|
228
|
+
|
|
229
|
+
if to_overwrite:
|
|
230
|
+
console.print(f"Tracks to overwrite ({len(to_overwrite)}):")
|
|
231
|
+
for t in to_overwrite:
|
|
232
|
+
console.print(f" {t.artist} — {t.title}")
|
|
233
|
+
if not click.confirm("Proceed?"):
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
with Progress(
|
|
237
|
+
TextColumn("[progress.description]{task.description}"),
|
|
238
|
+
BarColumn(),
|
|
239
|
+
TaskProgressColumn(),
|
|
240
|
+
console=console,
|
|
241
|
+
) as progress:
|
|
242
|
+
task_id: TaskID | None = None
|
|
243
|
+
current_file: str | None = None
|
|
244
|
+
|
|
245
|
+
def on_progress(filename: str, sent: int, total: int) -> None:
|
|
246
|
+
nonlocal task_id, current_file
|
|
247
|
+
if filename != current_file:
|
|
248
|
+
if task_id is not None:
|
|
249
|
+
progress.update(task_id, completed=100)
|
|
250
|
+
current_file = filename
|
|
251
|
+
task_id = progress.add_task(f"uploading {filename}", total=100)
|
|
252
|
+
progress.update(task_id, completed=int(sent / total * 100))
|
|
253
|
+
|
|
254
|
+
light.music.upload_tracks(
|
|
255
|
+
files,
|
|
256
|
+
allow_duplicates=allow_duplicates,
|
|
257
|
+
match_title_by=match_title_by,
|
|
258
|
+
convert_flac=not no_convert_flac,
|
|
259
|
+
on_progress=on_progress,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@music.command("delete-all")
|
|
264
|
+
@with_light
|
|
265
|
+
def music_delete_all(light: Light):
|
|
266
|
+
"""Delete ALL tracks on device."""
|
|
267
|
+
if not click.confirm("This will delete ALL tracks on the device. Proceed?"):
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if input('Type "yes i am sure" to confirm: ') != "yes i am sure":
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
light.music.delete_all_tracks()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@music.command("delete")
|
|
277
|
+
@with_light
|
|
278
|
+
@click.argument("songs", nargs=-1, required=True)
|
|
279
|
+
def music_delete(light: Light, songs):
|
|
280
|
+
"""Delete tracks by title.
|
|
281
|
+
|
|
282
|
+
Uses exact title matching. Run `light music list` to see track titles.
|
|
283
|
+
|
|
284
|
+
**Example:**
|
|
285
|
+
|
|
286
|
+
`light music delete "Song Title" "Another Song"`
|
|
287
|
+
"""
|
|
288
|
+
titles = list(songs)
|
|
289
|
+
tracks = light.music.get_tracks()
|
|
290
|
+
to_delete = [t for t in tracks if t.title in set(titles)]
|
|
291
|
+
|
|
292
|
+
if not to_delete:
|
|
293
|
+
console.print("[yellow]No matching tracks.[/yellow]")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
console.print(f"Tracks to delete ({len(to_delete)}):")
|
|
297
|
+
for t in to_delete:
|
|
298
|
+
console.print(f" {t.artist} — {t.title}")
|
|
299
|
+
if not click.confirm("Proceed?"):
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
light.music.delete_tracks_by_title(titles)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@music.command("sort")
|
|
306
|
+
@with_light
|
|
307
|
+
@click.argument("field", type=click.Choice(["artist", "title", "artist-album", "none"]))
|
|
308
|
+
@click.option(
|
|
309
|
+
"--asc",
|
|
310
|
+
"order",
|
|
311
|
+
flag_value="ascending",
|
|
312
|
+
default=True,
|
|
313
|
+
help="Sort ascending (default).",
|
|
314
|
+
)
|
|
315
|
+
@click.option("--desc", "order", flag_value="descending", help="Sort descending.")
|
|
316
|
+
def music_sort(light: Light, field, order):
|
|
317
|
+
"""Sort tracks by artist, title, or reset to manual order.
|
|
318
|
+
|
|
319
|
+
`none` resets to the manual ordering you set in the app.
|
|
320
|
+
|
|
321
|
+
**Examples:**
|
|
322
|
+
|
|
323
|
+
`light music sort artist --desc`
|
|
324
|
+
|
|
325
|
+
`light music sort title`
|
|
326
|
+
|
|
327
|
+
`light music sort none`
|
|
328
|
+
"""
|
|
329
|
+
descending = order == "descending"
|
|
330
|
+
|
|
331
|
+
if field == "artist":
|
|
332
|
+
light.music.set_sort_mode(
|
|
333
|
+
SortMode.ARTIST_DESC if descending else SortMode.ARTIST_ASC
|
|
334
|
+
)
|
|
335
|
+
elif field == "title":
|
|
336
|
+
light.music.set_sort_mode(
|
|
337
|
+
SortMode.TITLE_DESC if descending else SortMode.TITLE_ASC
|
|
338
|
+
)
|
|
339
|
+
elif field == "artist-album":
|
|
340
|
+
light.music.set_sort_mode(
|
|
341
|
+
SortMode.ARTIST_ALBUM_DESC if descending else SortMode.ARTIST_ALBUM_ASC
|
|
342
|
+
)
|
|
343
|
+
elif field == "none":
|
|
344
|
+
light.music.set_sort_mode(SortMode.RANK)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@music.command("update")
|
|
348
|
+
@with_light
|
|
349
|
+
@click.argument("title")
|
|
350
|
+
@click.option("--new-title", default=None, help="New track title.")
|
|
351
|
+
@click.option("--new-artist", default=None, help="New artist name.")
|
|
352
|
+
@click.option("--new-album", default=None, help="New album name.")
|
|
353
|
+
def music_update(light: Light, title, new_title, new_artist, new_album):
|
|
354
|
+
"""Update metadata for a track.
|
|
355
|
+
|
|
356
|
+
Matches by exact title. At least one of `--new-title`, `--new-artist`, or `--new-album` must be provided.
|
|
357
|
+
|
|
358
|
+
**Example:**
|
|
359
|
+
|
|
360
|
+
`light music update "Old Title" --new-title "New Title" --new-artist "Artist" --new-album "Album"`
|
|
361
|
+
"""
|
|
362
|
+
tracks = light.music.get_tracks()
|
|
363
|
+
matches = [t for t in tracks if t.title == title]
|
|
364
|
+
|
|
365
|
+
if not matches:
|
|
366
|
+
console.print(f"[yellow]No track found with title: {title}[/yellow]")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
for track in matches:
|
|
370
|
+
light.music.update_track_metadata(
|
|
371
|
+
track.audio_id, title=new_title, artist=new_artist, album=new_album
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@music.command("list")
|
|
376
|
+
@with_light
|
|
377
|
+
def music_list(light: Light):
|
|
378
|
+
"""List all tracks on your device."""
|
|
379
|
+
tracks = light.music.get_tracks()
|
|
380
|
+
|
|
381
|
+
table = Table(show_header=True)
|
|
382
|
+
table.add_column("#", style="dim", width=4)
|
|
383
|
+
table.add_column("Title")
|
|
384
|
+
table.add_column("Artist")
|
|
385
|
+
table.add_column("Album")
|
|
386
|
+
|
|
387
|
+
for i, track in enumerate(tracks, 1):
|
|
388
|
+
table.add_row(str(i), track.title, track.artist, track.album)
|
|
389
|
+
|
|
390
|
+
console.print(table)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# -- Notes commands -------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@notes.command("list")
|
|
397
|
+
@with_light
|
|
398
|
+
@click.option(
|
|
399
|
+
"--id",
|
|
400
|
+
"-i",
|
|
401
|
+
"show_id",
|
|
402
|
+
default=False,
|
|
403
|
+
type=bool,
|
|
404
|
+
is_flag=True,
|
|
405
|
+
help="Include note ID in output (use with `notes watch`).",
|
|
406
|
+
)
|
|
407
|
+
@click.option(
|
|
408
|
+
"--content-preview",
|
|
409
|
+
"-c",
|
|
410
|
+
default=False,
|
|
411
|
+
type=bool,
|
|
412
|
+
is_flag=True,
|
|
413
|
+
help="Include content preview in output.",
|
|
414
|
+
)
|
|
415
|
+
def notes_list(light: Light, show_id=False, content_preview=False):
|
|
416
|
+
"""List all notes on your device.
|
|
417
|
+
|
|
418
|
+
Shows the first line of text notes and labels audio notes.
|
|
419
|
+
"""
|
|
420
|
+
all_notes = light.notes.get_notes()
|
|
421
|
+
|
|
422
|
+
if content_preview:
|
|
423
|
+
console.print(f"[dim]Content preview enabled. This might take a while.[/dim]")
|
|
424
|
+
|
|
425
|
+
for i, note in enumerate(all_notes, 1):
|
|
426
|
+
if content_preview:
|
|
427
|
+
content = light.notes.get_note_content(note)
|
|
428
|
+
else:
|
|
429
|
+
content = ""
|
|
430
|
+
|
|
431
|
+
if note.note_type == "audio":
|
|
432
|
+
preview = f"[dim](audio)[/dim] {note.title}"
|
|
433
|
+
elif content and content.strip():
|
|
434
|
+
preview = f"[dim]({note.title})[/dim] {content.splitlines()[0]}"
|
|
435
|
+
else:
|
|
436
|
+
preview = "[dim](empty)[/dim]"
|
|
437
|
+
|
|
438
|
+
id_prefix = f"{note.id} " if show_id else ""
|
|
439
|
+
console.print(f"[dim]{i}.[/dim] {id_prefix}{preview}")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@notes.command("download")
|
|
443
|
+
@with_light
|
|
444
|
+
@click.argument("path")
|
|
445
|
+
def notes_download(light: Light, path: str):
|
|
446
|
+
"""Download all notes to a directory.
|
|
447
|
+
|
|
448
|
+
Text notes are saved as `.txt`, audio notes as `.m4a`.
|
|
449
|
+
If two notes share a title, the timestamp is appended to disambiguate.
|
|
450
|
+
|
|
451
|
+
**Example:**
|
|
452
|
+
|
|
453
|
+
`light notes download ~/my-notes`
|
|
454
|
+
"""
|
|
455
|
+
light.notes.download_notes(path)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@notes.command("add")
|
|
459
|
+
@with_light
|
|
460
|
+
@click.argument("title")
|
|
461
|
+
@click.argument("content", default=None, required=False)
|
|
462
|
+
@click.option(
|
|
463
|
+
"--file",
|
|
464
|
+
"-f",
|
|
465
|
+
"content_file",
|
|
466
|
+
default=None,
|
|
467
|
+
type=click.Path(exists=True),
|
|
468
|
+
help="Read note content from a file instead of inline.",
|
|
469
|
+
)
|
|
470
|
+
def notes_add(light: Light, title: str, content: str | None, content_file: str | None):
|
|
471
|
+
"""Create a new text note.
|
|
472
|
+
|
|
473
|
+
Provide content inline as an argument, or from a file with `--file`.
|
|
474
|
+
|
|
475
|
+
**Examples:**
|
|
476
|
+
|
|
477
|
+
`light notes add "Shopping list" "eggs, milk, bread"`
|
|
478
|
+
|
|
479
|
+
`light notes add "Meeting notes" --file notes.txt`
|
|
480
|
+
"""
|
|
481
|
+
if content is None and content_file is None:
|
|
482
|
+
raise click.UsageError("Provide CONTENT or --file.")
|
|
483
|
+
|
|
484
|
+
if content is not None and content_file is not None:
|
|
485
|
+
raise click.UsageError("CONTENT and --file are mutually exclusive.")
|
|
486
|
+
|
|
487
|
+
if content_file:
|
|
488
|
+
light.notes.create_text_note(title, content_file, content_is_path=True)
|
|
489
|
+
else:
|
|
490
|
+
light.notes.create_text_note(title, content)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@notes.command("watch")
|
|
494
|
+
@with_light
|
|
495
|
+
@click.argument("note_id")
|
|
496
|
+
def notes_watch(light: Light, note_id: str):
|
|
497
|
+
"""Poll a note for changes and print its content when updated.
|
|
498
|
+
|
|
499
|
+
Checks every 5 seconds and prints content when `updated_at` changes.
|
|
500
|
+
Useful for watching a note you're actively editing on your phone.
|
|
501
|
+
|
|
502
|
+
Run `light notes list --id` to find the note ID.
|
|
503
|
+
|
|
504
|
+
**Example:**
|
|
505
|
+
|
|
506
|
+
`light notes watch 4f1d3063-085b-4738-8ba1-582c5d1cd9ac`
|
|
507
|
+
"""
|
|
508
|
+
note = light.notes.get_note_metadata(note_id)
|
|
509
|
+
last_updated_at = note.updated_at
|
|
510
|
+
|
|
511
|
+
while True:
|
|
512
|
+
time.sleep(5)
|
|
513
|
+
note = light.notes.get_note_metadata(note_id)
|
|
514
|
+
if note.updated_at != last_updated_at:
|
|
515
|
+
content = light.notes.get_note_content(note)
|
|
516
|
+
console.print(f"[green]Updated at {note.updated_at}:[/green]")
|
|
517
|
+
console.print(content.decode())
|
|
518
|
+
last_updated_at = note.updated_at
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# -- Tools commands ------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@cli.group()
|
|
525
|
+
def tools():
|
|
526
|
+
"""Installed tools introspection."""
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@tools.command("list")
|
|
531
|
+
@with_light
|
|
532
|
+
def tools_list(light: Light):
|
|
533
|
+
"""List all tools installed on your device."""
|
|
534
|
+
all_tools = light.tools.get_tools()
|
|
535
|
+
|
|
536
|
+
table = Table(show_header=True)
|
|
537
|
+
table.add_column("Title")
|
|
538
|
+
table.add_column("Namespace")
|
|
539
|
+
|
|
540
|
+
for t in all_tools:
|
|
541
|
+
table.add_row(t.title, t.namespace)
|
|
542
|
+
|
|
543
|
+
console.print(table)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@tools.command("add")
|
|
547
|
+
@with_light
|
|
548
|
+
@click.argument(
|
|
549
|
+
"name", type=click.Choice([t.value for t in ToolName], case_sensitive=False)
|
|
550
|
+
)
|
|
551
|
+
def tools_add(light: Light, name: str):
|
|
552
|
+
"""Install a tool on your device."""
|
|
553
|
+
tool = light.tools.add_tool(name)
|
|
554
|
+
console.print(f"[green]Installed:[/green] {tool.title}")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@tools.command("remove")
|
|
558
|
+
@with_light
|
|
559
|
+
@click.argument(
|
|
560
|
+
"name", type=click.Choice([t.value for t in ToolName], case_sensitive=False)
|
|
561
|
+
)
|
|
562
|
+
def tools_remove(light: Light, name: str):
|
|
563
|
+
"""Uninstall a tool from your device."""
|
|
564
|
+
if not click.confirm(f"Remove {name}?"):
|
|
565
|
+
return
|
|
566
|
+
light.tools.remove_tool(name)
|
|
567
|
+
console.print("[green]Removed.[/green]")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# -- TUI ------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@cli.command()
|
|
574
|
+
@click.pass_context
|
|
575
|
+
def tui(ctx):
|
|
576
|
+
"""Launch the interactive terminal UI.
|
|
577
|
+
|
|
578
|
+
A full-screen interface for browsing and managing your music library
|
|
579
|
+
with vim-style keybindings.
|
|
580
|
+
"""
|
|
581
|
+
obj = ctx.obj or {}
|
|
582
|
+
run_tui(
|
|
583
|
+
LightConfig(
|
|
584
|
+
email=obj.get("email"),
|
|
585
|
+
email_file=obj.get("email_file"),
|
|
586
|
+
password=obj.get("password"),
|
|
587
|
+
password_file=obj.get("password_file"),
|
|
588
|
+
phone=obj.get("phone_number"),
|
|
589
|
+
phone_file=obj.get("phone_number_file"),
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
cli()
|