transmute-cli 0.2.3a0__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.
transmute/style.py ADDED
@@ -0,0 +1,32 @@
1
+ """Theme and user-facing text constants."""
2
+
3
+ from prompt_toolkit.styles import Style
4
+
5
+ STYLE = Style.from_dict(
6
+ {
7
+ "header.bar": "#22d3ee",
8
+ "header.title": "#22d3ee bold",
9
+ "header.desc": "#bbbbbb",
10
+ "subsection": "#7dd3fc",
11
+ "dim": "#777777",
12
+ "ok": "#4ade80",
13
+ "err": "#f87171",
14
+ "warn": "#facc15",
15
+ "accent": "#22d3ee",
16
+ "prompt": "#888888",
17
+ "placeholder": "#666666 italic",
18
+ "input.hint": "#666666",
19
+ "input.warn": "#facc15",
20
+ "rule": "#333333",
21
+ "toolbar": "#7dd3fc bg:#16303a",
22
+ "selected": "bg:#1b3a46",
23
+ }
24
+ )
25
+
26
+ TAGLINE = "Convert YouTube or SoundCloud links into rich MP3s. Bring your own provider."
27
+ PLACEHOLDER = "Paste a YouTube or SoundCloud link"
28
+ HINT_PLACEHOLDER = "type a hint (low confidence)"
29
+ HELP_HINT = "/help for more commands"
30
+
31
+ HISTORY_SHOWN = 14
32
+ MESSAGES_SHOWN = 8
transmute/sync.py ADDED
@@ -0,0 +1,532 @@
1
+ """UI-independent, additive synchronization of local MP3 files and Spotify Documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from collections.abc import Callable
11
+ from contextlib import AbstractContextManager
12
+ from dataclasses import dataclass
13
+ from difflib import SequenceMatcher
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import BinaryIO, Literal, Protocol
16
+ from uuid import uuid4
17
+
18
+ from .config import STATE_DIR
19
+
20
+ CHUNK_SIZE = 1024 * 1024
21
+ MAX_REASON = 240
22
+ _MANIFEST_VERSION = 1
23
+ _PARTIAL_RE = re.compile(r"(?:\.part(?:ial)?|\.tmp|\.temp)$", re.IGNORECASE)
24
+
25
+
26
+ class SyncError(RuntimeError):
27
+ """A bounded, actionable sync integration or validation error."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TrackFile:
32
+ relative_path: str
33
+ size: int
34
+ sha256: str
35
+ artist: str = ""
36
+ title: str = ""
37
+ duration: float | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SyncItem:
42
+ id: str
43
+ direction: Literal["to_phone", "to_computer", "matched"]
44
+ track: TrackFile
45
+ target_name: str
46
+ confidence: Literal["high", "low"]
47
+ reason: str = ""
48
+ duplicate_of: str | None = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SyncPlan:
53
+ device_id: str
54
+ device_name: str
55
+ out_dir: Path
56
+ items: tuple[SyncItem, ...]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SyncProgress:
61
+ phase: str
62
+ current: int = 0
63
+ total: int = 0
64
+ filename: str = ""
65
+ item_id: str | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class SyncResult:
70
+ item_id: str
71
+ status: Literal["done", "error", "cancelled"]
72
+ message: str = ""
73
+ target_name: str = ""
74
+
75
+
76
+ class PhoneStore(Protocol):
77
+ device_id: str
78
+ device_name: str
79
+
80
+ def list_files(self) -> list[str]: ...
81
+ def exists(self, relative_path: str) -> bool: ...
82
+ def read_to(self, relative_path: str, sink: BinaryIO) -> None: ...
83
+ def write_from(self, relative_path: str, source: BinaryIO) -> None: ...
84
+ def publish(self, temporary_path: str, destination_path: str) -> None: ...
85
+ def remove(self, relative_path: str) -> None: ...
86
+
87
+
88
+ PhoneFactory = Callable[[str | None], AbstractContextManager[PhoneStore]]
89
+ ProgressCallback = Callable[[SyncProgress], None]
90
+
91
+
92
+ def _safe_relative(value: str) -> str:
93
+ if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
94
+ raise SyncError("Phone returned an unsafe file path.")
95
+ path = PurePosixPath(value)
96
+ if path.is_absolute() or any(part in ("", ".", "..") for part in value.split("/")):
97
+ raise SyncError("Phone returned an unsafe file path.")
98
+ return path.as_posix()
99
+
100
+
101
+ def _safe_basename(value: str) -> str:
102
+ name = PurePosixPath(value).name
103
+ name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip(" .")
104
+ if not name or name.startswith(".") or _PARTIAL_RE.search(name):
105
+ raise SyncError("A sync filename is hidden or temporary and cannot be transferred.")
106
+ suffix = Path(name).suffix
107
+ stem = name[: -len(suffix)] if suffix else name
108
+ name = _utf8_prefix(stem, 240 - len(suffix.encode("utf-8"))) + suffix
109
+ return name
110
+
111
+
112
+ def _utf8_prefix(value: str, max_bytes: int) -> str:
113
+ result: list[str] = []
114
+ used = 0
115
+ for char in value:
116
+ width = len(char.encode("utf-8"))
117
+ if used + width > max_bytes:
118
+ break
119
+ result.append(char)
120
+ used += width
121
+ return "".join(result)
122
+
123
+
124
+ def _unique_target(base: str, occupied: set[str]) -> str:
125
+ safe_base = _safe_basename(base)
126
+ if safe_base.casefold() not in occupied:
127
+ return safe_base
128
+ suffix = Path(safe_base).suffix
129
+ stem = safe_base[: -len(suffix)] if suffix else safe_base
130
+ number = 2
131
+ while True:
132
+ marker = f" ({number})"
133
+ stem_limit = 240 - len((marker + suffix).encode("utf-8"))
134
+ candidate = _safe_basename(_utf8_prefix(stem, stem_limit) + marker + suffix)
135
+ if candidate.casefold() not in occupied:
136
+ return candidate
137
+ number += 1
138
+
139
+
140
+ def _hash_stream(stream: BinaryIO) -> tuple[str, int]:
141
+ digest = hashlib.sha256()
142
+ size = 0
143
+ while True:
144
+ block = stream.read(CHUNK_SIZE)
145
+ if not block:
146
+ break
147
+ digest.update(block)
148
+ size += len(block)
149
+ return digest.hexdigest(), size
150
+
151
+
152
+ def _metadata(path: Path, relative: str) -> TrackFile:
153
+ artist = title = ""
154
+ duration: float | None = None
155
+ try:
156
+ from mutagen import File as MutagenFile
157
+
158
+ audio = MutagenFile(path, easy=True)
159
+ if audio is not None:
160
+ artist = _first_tag(audio, "artist")
161
+ title = _first_tag(audio, "title")
162
+ raw_duration = getattr(getattr(audio, "info", None), "length", None)
163
+ duration = float(raw_duration) if raw_duration is not None else None
164
+ except Exception: # noqa: BLE001 - metadata parsing must not block byte copying.
165
+ pass
166
+ digest, size = _hash_file(path)
167
+ return TrackFile(relative, size, digest, artist[:160], title[:160], duration)
168
+
169
+
170
+ def _metadata_stream(stream: BinaryIO) -> tuple[str, str, float | None]:
171
+ """Read embedded tags from an already-open phone readback stream."""
172
+ artist = title = ""
173
+ duration: float | None = None
174
+ try:
175
+ from mutagen import File as MutagenFile
176
+
177
+ stream.seek(0)
178
+ audio = MutagenFile(stream, easy=True)
179
+ if audio is not None:
180
+ artist = _first_tag(audio, "artist")
181
+ title = _first_tag(audio, "title")
182
+ raw_duration = getattr(getattr(audio, "info", None), "length", None)
183
+ duration = float(raw_duration) if raw_duration is not None else None
184
+ except Exception: # noqa: BLE001 - unreadable embedded metadata does not block byte sync.
185
+ pass
186
+ finally:
187
+ stream.seek(0)
188
+ return artist[:160], title[:160], duration
189
+
190
+
191
+ def _first_tag(audio: object, key: str) -> str:
192
+ value = getattr(audio, "get", lambda _key: None)(key)
193
+ if isinstance(value, (list, tuple)):
194
+ value = value[0] if value else ""
195
+ return str(value or "").strip()
196
+
197
+
198
+ def _hash_file(path: Path) -> tuple[str, int]:
199
+ with path.open("rb") as stream:
200
+ return _hash_stream(stream)
201
+
202
+
203
+ def _phone_hash(phone: PhoneStore, relative_path: str) -> str:
204
+ sink = tempfile.SpooledTemporaryFile(max_size=CHUNK_SIZE)
205
+ try:
206
+ phone.read_to(relative_path, sink)
207
+ sink.seek(0)
208
+ return _hash_stream(sink)[0]
209
+ finally:
210
+ sink.close()
211
+
212
+
213
+ def _eligible_path(path: Path, root: Path) -> bool:
214
+ try:
215
+ relative = path.relative_to(root)
216
+ if path.is_symlink() or not path.is_file():
217
+ return False
218
+ if any(part.startswith(".") for part in relative.parts):
219
+ return False
220
+ return path.suffix.lower() == ".mp3" and not _PARTIAL_RE.search(path.name)
221
+ except (OSError, ValueError):
222
+ return False
223
+
224
+
225
+ def _fingerprint(text: str) -> str:
226
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
227
+
228
+
229
+ def _norm(text: str) -> str:
230
+ folded = text.casefold()
231
+ return " ".join("".join(char if char.isalnum() else " " for char in folded).split())
232
+
233
+
234
+ def _near(a: TrackFile, b: TrackFile) -> bool:
235
+ left_artist = _norm(a.artist)
236
+ right_artist = _norm(b.artist)
237
+ left_title = _norm(a.title or Path(a.relative_path).stem)
238
+ right_title = _norm(b.title or Path(b.relative_path).stem)
239
+ artist_match = bool(left_artist and right_artist and left_artist == right_artist)
240
+ title_match = bool(left_title and right_title and left_title == right_title)
241
+ title_similarity = SequenceMatcher(None, left_title, right_title).ratio() if left_title and right_title else 0.0
242
+ left_filename = _norm(Path(a.relative_path).stem)
243
+ right_filename = _norm(Path(b.relative_path).stem)
244
+ filename_match = bool(left_filename and right_filename and left_filename == right_filename)
245
+ filename_similarity = SequenceMatcher(None, left_filename, right_filename).ratio() if left_filename and right_filename else 0.0
246
+ duration_match = (
247
+ a.duration is not None and b.duration is not None
248
+ and abs(a.duration - b.duration) <= 3
249
+ )
250
+ if artist_match and title_match:
251
+ return True
252
+ # Similarity only lowers transfer confidence. Require independent artist
253
+ # and duration evidence so distinct tracks by one artist remain selectable.
254
+ if artist_match and duration_match and title_similarity >= 0.82:
255
+ return True
256
+ return filename_match or (duration_match and filename_similarity >= 0.9)
257
+
258
+
259
+ def _manifest_key(device_id: str, out_dir: Path) -> str:
260
+ return _fingerprint(f"{device_id}\0{out_dir.resolve()}")
261
+
262
+
263
+ class SyncService:
264
+ """Build read-only plans and transfer explicitly selected files."""
265
+
266
+ def __init__(
267
+ self,
268
+ phone_factory: PhoneFactory | None = None,
269
+ manifest_dir: Path | None = None,
270
+ ) -> None:
271
+ self._phone_factory = phone_factory
272
+ self.manifest_dir = Path(manifest_dir) if manifest_dir is not None else STATE_DIR / "sync"
273
+
274
+ def _open_phone(self, device_id: str | None = None) -> AbstractContextManager[PhoneStore]:
275
+ factory = self._phone_factory
276
+ if factory is None:
277
+ from .iphone import open_spotify
278
+
279
+ factory = open_spotify
280
+ return factory(device_id)
281
+
282
+ def preview(
283
+ self,
284
+ out_dir: Path,
285
+ excluded_paths: tuple[Path, ...] = (),
286
+ progress: ProgressCallback | None = None,
287
+ ) -> SyncPlan:
288
+ root = Path(out_dir).expanduser().resolve()
289
+ if not root.is_dir():
290
+ raise SyncError("Output folder is unavailable; check /out and try again.")
291
+ excluded = {Path(p).expanduser().resolve() for p in excluded_paths}
292
+ local: list[TrackFile] = []
293
+ for path in sorted(root.rglob("*")):
294
+ if not _eligible_path(path, root) or path.resolve() in excluded:
295
+ continue
296
+ try:
297
+ before = path.stat()
298
+ track = _metadata(path, path.relative_to(root).as_posix())
299
+ after = path.stat()
300
+ if (before.st_size, before.st_mtime_ns, before.st_ino) != (after.st_size, after.st_mtime_ns, after.st_ino):
301
+ continue
302
+ local.append(track)
303
+ except OSError:
304
+ continue
305
+ with self._open_phone() as phone:
306
+ remote: list[TrackFile] = []
307
+ for index, raw_path in enumerate(phone.list_files(), 1):
308
+ rel = _safe_relative(raw_path)
309
+ if rel.startswith(".") or any(part.startswith(".") for part in rel.split("/")) or _PARTIAL_RE.search(rel):
310
+ continue
311
+ if not rel.lower().endswith(".mp3"):
312
+ continue
313
+ sink = tempfile.SpooledTemporaryFile(max_size=CHUNK_SIZE)
314
+ try:
315
+ phone.read_to(rel, sink)
316
+ sink.seek(0)
317
+ digest, size = _hash_stream(sink)
318
+ sink.seek(0)
319
+ artist, title, duration = _metadata_stream(sink)
320
+ finally:
321
+ sink.close()
322
+ remote.append(TrackFile(rel, size, digest, artist, title or Path(rel).stem, duration))
323
+ if progress:
324
+ progress(SyncProgress("scan_phone", index, 0, Path(rel).name))
325
+ items = _build_items(local, remote)
326
+ return SyncPlan(phone.device_id, phone.device_name, root, tuple(items))
327
+
328
+ def transfer(
329
+ self,
330
+ plan: SyncPlan,
331
+ selected_ids: set[str],
332
+ progress: ProgressCallback | None = None,
333
+ cancelled: Callable[[], bool] | None = None,
334
+ ) -> tuple[SyncResult, ...]:
335
+ selected = [item for item in plan.items if item.id in selected_ids and item.direction != "matched"]
336
+ known = {item.id for item in plan.items if item.direction != "matched"}
337
+ if selected_ids - known:
338
+ raise SyncError("The sync selection is stale; preview the folders again.")
339
+ if not selected:
340
+ return ()
341
+ results: list[SyncResult] = []
342
+ with self._open_phone(plan.device_id) as phone:
343
+ if phone.device_id != plan.device_id:
344
+ raise SyncError("The connected phone changed; preview again before syncing.")
345
+ manifest = self._load_manifest(plan, phone.device_id)
346
+ for index, item in enumerate(selected, 1):
347
+ if cancelled and cancelled():
348
+ results.extend(SyncResult(rest.id, "cancelled", "Cancelled before transfer.", rest.target_name) for rest in selected[index - 1 :])
349
+ break
350
+ if progress:
351
+ progress(SyncProgress("transfer", index, len(selected), item.target_name, item.id))
352
+ try:
353
+ if item.direction == "to_phone":
354
+ result = self._to_phone(phone, plan.out_dir, item)
355
+ else:
356
+ result = self._to_computer(phone, plan.out_dir, item)
357
+ if result.status == "done":
358
+ manifest[item.id] = {"sha256": item.track.sha256, "target": result.target_name}
359
+ try:
360
+ self._save_manifest(plan, phone.device_id, manifest)
361
+ except OSError:
362
+ result = SyncResult(item.id, "error", "Copied, but sync history could not be saved; retry is safe.", result.target_name)
363
+ results.append(result)
364
+ except Exception as exc: # noqa: BLE001 - isolate device and filesystem failures per item.
365
+ results.append(SyncResult(item.id, "error", _bounded_error(exc), item.target_name))
366
+ return tuple(results)
367
+
368
+ def _to_phone(self, phone: PhoneStore, out_dir: Path, item: SyncItem) -> SyncResult:
369
+ source = _source_path(item, out_dir)
370
+ before = source.stat()
371
+ digest, size = _hash_file(source)
372
+ if digest != item.track.sha256 or size != item.track.size:
373
+ raise SyncError("Source changed after preview; preview the folders again.")
374
+ destination = item.target_name
375
+ if phone.exists(destination):
376
+ if _phone_hash(phone, destination) == item.track.sha256:
377
+ return SyncResult(item.id, "done", "Already present with identical content.", destination)
378
+ raise FileExistsError(destination)
379
+ temporary = f".transmute-sync-{uuid4().hex}.partial"
380
+ try:
381
+ with source.open("rb") as stream:
382
+ phone.write_from(temporary, stream)
383
+ if _phone_hash(phone, temporary) != item.track.sha256:
384
+ raise SyncError("Phone readback did not match; the file was not published.")
385
+ after = source.stat()
386
+ if (before.st_size, before.st_mtime_ns, before.st_ino) != (after.st_size, after.st_mtime_ns, after.st_ino):
387
+ raise SyncError("Source changed during transfer; preview again.")
388
+ try:
389
+ phone.publish(temporary, destination)
390
+ except FileExistsError:
391
+ if _phone_hash(phone, destination) == item.track.sha256:
392
+ return SyncResult(item.id, "done", "Already present with identical content.", destination)
393
+ raise
394
+ return SyncResult(item.id, "done", "Copied to phone.", destination)
395
+ finally:
396
+ try:
397
+ phone.remove(temporary)
398
+ except Exception: # noqa: BLE001 - cleanup failure must not mask transfer outcome.
399
+ pass
400
+
401
+ def _to_computer(self, phone: PhoneStore, out_dir: Path, item: SyncItem) -> SyncResult:
402
+ source = _safe_relative(item.track.relative_path)
403
+ if not phone.exists(source):
404
+ raise SyncError("Phone source disappeared after preview; preview again.")
405
+ target = out_dir / item.target_name
406
+ if target.exists() or target.is_symlink():
407
+ if target.is_file() and not target.is_symlink() and _hash_file(target)[0] == item.track.sha256:
408
+ return SyncResult(item.id, "done", "Already present with identical content.", item.target_name)
409
+ raise FileExistsError(item.target_name)
410
+ descriptor, name = tempfile.mkstemp(prefix=".transmute-sync-", suffix=".partial", dir=out_dir)
411
+ os.close(descriptor)
412
+ temporary = Path(name)
413
+ try:
414
+ with temporary.open("wb") as sink:
415
+ phone.read_to(source, sink)
416
+ sink.flush()
417
+ os.fsync(sink.fileno())
418
+ digest, size = _hash_file(temporary)
419
+ if digest != item.track.sha256 or size != item.track.size:
420
+ raise SyncError("Phone readback did not match; the file was not published.")
421
+ if not phone.exists(source) or _phone_hash(phone, source) != item.track.sha256:
422
+ raise SyncError("Phone source changed during transfer; preview again.")
423
+ try:
424
+ os.link(temporary, target)
425
+ except FileExistsError:
426
+ if target.is_file() and _hash_file(target)[0] == item.track.sha256:
427
+ return SyncResult(item.id, "done", "Already present with identical content.", item.target_name)
428
+ raise
429
+ return SyncResult(item.id, "done", "Copied to computer.", item.target_name)
430
+ finally:
431
+ temporary.unlink(missing_ok=True)
432
+
433
+ def _manifest_path(self, plan: SyncPlan, device_id: str) -> Path:
434
+ return self.manifest_dir / f"{_manifest_key(device_id, plan.out_dir)}.json"
435
+
436
+ def _load_manifest(self, plan: SyncPlan, device_id: str) -> dict[str, dict[str, str]]:
437
+ path = self._manifest_path(plan, device_id)
438
+ try:
439
+ data = json.loads(path.read_text(encoding="utf-8"))
440
+ except FileNotFoundError:
441
+ return {}
442
+ except (OSError, json.JSONDecodeError):
443
+ return {}
444
+ if not isinstance(data, dict) or data.get("version") != _MANIFEST_VERSION or not isinstance(data.get("successes"), dict):
445
+ return {}
446
+ return {str(k): v for k, v in data["successes"].items() if isinstance(v, dict) and isinstance(v.get("sha256"), str)}
447
+
448
+ def _save_manifest(self, plan: SyncPlan, device_id: str, successes: dict[str, dict[str, str]]) -> None:
449
+ path = self._manifest_path(plan, device_id)
450
+ path.parent.mkdir(parents=True, exist_ok=True)
451
+ payload = json.dumps({"version": _MANIFEST_VERSION, "successes": successes}, sort_keys=True)
452
+ descriptor, name = tempfile.mkstemp(prefix=".sync-", suffix=".tmp", dir=path.parent)
453
+ tmp = Path(name)
454
+ try:
455
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
456
+ stream.write(payload)
457
+ stream.flush()
458
+ os.fsync(stream.fileno())
459
+ os.replace(tmp, path)
460
+ finally:
461
+ tmp.unlink(missing_ok=True)
462
+
463
+
464
+ def _build_items(local: list[TrackFile], remote: list[TrackFile]) -> list[SyncItem]:
465
+ items: list[SyncItem] = []
466
+ matched_local: set[str] = set()
467
+ matched_remote: set[str] = set()
468
+ remote_by_hash: dict[str, TrackFile] = {}
469
+ for track in remote:
470
+ remote_by_hash.setdefault(track.sha256, track)
471
+ seen_exact: set[str] = set()
472
+ for track in local:
473
+ counterpart = remote_by_hash.get(track.sha256)
474
+ if counterpart:
475
+ matched_local.add(track.relative_path)
476
+ matched_remote.update(candidate.relative_path for candidate in remote if candidate.sha256 == track.sha256)
477
+ if track.sha256 not in seen_exact:
478
+ seen_exact.add(track.sha256)
479
+ items.append(_item("matched", track, Path(track.relative_path).name, "high", "Exact SHA256 match."))
480
+ for direction, sources, other, already in (
481
+ ("to_phone", local, remote, matched_local),
482
+ ("to_computer", remote, local, matched_remote),
483
+ ):
484
+ other_hashes = {track.sha256 for track in other}
485
+ other_names = {_safe_basename(Path(track.relative_path).name).casefold() for track in other}
486
+ occupied = set(other_names)
487
+ for track in sources:
488
+ if track.relative_path in already or track.sha256 in other_hashes:
489
+ continue
490
+ similar = next((candidate for candidate in other if _near(track, candidate)), None)
491
+ name_conflict = Path(track.relative_path).name.casefold() in other_names
492
+ confidence: Literal["high", "low"] = "low" if similar or name_conflict else "high"
493
+ reason = "Possible duplicate based on metadata or filename." if similar else "Same filename has different content." if name_conflict else ""
494
+ base = _safe_basename(Path(track.relative_path).name)
495
+ target = _unique_target(base, occupied)
496
+ occupied.add(target.casefold())
497
+ if target != base:
498
+ reason = (reason + " Flattened filename collision.").strip()
499
+ confidence = "low"
500
+ items.append(_item(direction, track, target, confidence, reason))
501
+ return items
502
+
503
+
504
+ def _item(direction: Literal["to_phone", "to_computer", "matched"], track: TrackFile, target: str, confidence: Literal["high", "low"], reason: str) -> SyncItem:
505
+ stable_id = _fingerprint(f"{direction}\0{track.relative_path}\0{track.sha256}")[:20]
506
+ return SyncItem(stable_id, direction, track, _safe_basename(target), confidence, reason[:MAX_REASON])
507
+
508
+
509
+ def _bounded_error(exc: Exception) -> str:
510
+ if isinstance(exc, FileExistsError):
511
+ return "Destination already exists; no file was replaced. Preview again."
512
+ if isinstance(exc, SyncError):
513
+ return str(exc)[:MAX_REASON]
514
+ if isinstance(exc, OSError) and exc.errno in (28,):
515
+ return "Destination storage is full; free space and retry."
516
+ return "Transfer failed; check the phone connection and destination, then retry."
517
+
518
+
519
+ def _source_path(item: SyncItem, out_dir: Path) -> Path:
520
+ rel = _safe_relative(item.track.relative_path)
521
+ root = out_dir.resolve()
522
+ path = root
523
+ for part in rel.split("/"):
524
+ path = path / part
525
+ if path.is_symlink():
526
+ raise SyncError("Planned source is now a symlink; preview the folders again.")
527
+ resolved = path.resolve()
528
+ if not resolved.is_relative_to(root):
529
+ raise SyncError("Planned source path escaped the output folder; preview again.")
530
+ if not resolved.is_file():
531
+ raise SyncError("Planned source is no longer a regular file; preview the folders again.")
532
+ return resolved
transmute/widgets.py ADDED
@@ -0,0 +1,86 @@
1
+ """Reusable prompt_toolkit pieces with no app-state dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from types import NotImplementedType
9
+
10
+ from prompt_toolkit.completion import Completer, Completion
11
+ from prompt_toolkit.formatted_text.base import AnyFormattedText
12
+ from prompt_toolkit.layout.controls import FormattedTextControl
13
+ from prompt_toolkit.layout.processors import Processor, Transformation
14
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
15
+
16
+
17
+ class HomeDirCompleter(Completer):
18
+ """Suggests existing directories under ~/ for the output-directory picker.
19
+
20
+ The picker holds only the path *relative to home* in the input buffer (the
21
+ "~/" is a fixed prompt prefix), so completions resolve against
22
+ ``Path.home()``. `active` gates completion to the folder editor's input stage.
23
+ """
24
+
25
+ def __init__(self, active: Callable[[], bool]) -> None:
26
+ self.active = active
27
+
28
+ def get_completions(self, document, complete_event):
29
+ if not self.active():
30
+ return
31
+ head, _, tail = document.text_before_cursor.lstrip("/").rpartition("/")
32
+ base = Path.home() / head if head else Path.home()
33
+ try:
34
+ names = sorted(p.name for p in base.iterdir() if p.is_dir())
35
+ except OSError:
36
+ return
37
+ for name in names:
38
+ if name.startswith(tail):
39
+ yield Completion(name + "/", start_position=-len(tail))
40
+
41
+
42
+ @dataclass
43
+ class Modal:
44
+ """A temporary takeover of the bottom input line (reusable for any command).
45
+
46
+ on_submit receives the entered text; empty or untouched text means
47
+ "do nothing". Esc cancels without calling on_submit.
48
+ """
49
+
50
+ prefix: str
51
+ placeholder: str
52
+ on_submit: Callable[[str], None]
53
+ initial: str = ""
54
+ hint: str = "enter applies · esc cancels"
55
+
56
+
57
+ class PlaceholderProcessor(Processor):
58
+ def __init__(self, text: str | Callable[[], str]) -> None:
59
+ self.text = text
60
+
61
+ def apply_transformation(self, ti):
62
+ if ti.lineno == 0 and not ti.document.text:
63
+ text = self.text() if callable(self.text) else self.text
64
+ return Transformation([("class:placeholder", text)])
65
+ return Transformation(ti.fragments)
66
+
67
+
68
+ class ScrollableHelpControl(FormattedTextControl):
69
+ """Formatted help content that forwards wheel scrolling to its owner."""
70
+
71
+ def __init__(
72
+ self, text: AnyFormattedText, on_scroll: Callable[[int], None], *, focusable: bool = False
73
+ ) -> None:
74
+ super().__init__(text, focusable=focusable)
75
+ self.on_scroll = on_scroll
76
+
77
+ def mouse_handler(
78
+ self, mouse_event: MouseEvent
79
+ ) -> None | NotImplementedType:
80
+ if mouse_event.event_type == MouseEventType.SCROLL_UP:
81
+ self.on_scroll(-3)
82
+ return None
83
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
84
+ self.on_scroll(3)
85
+ return None
86
+ return NotImplemented