diff --git a/README.md b/README.md index a2db285..e553efa 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,29 @@ transcribe *.mp4 --force - При ошибке в одном файле остальные продолжают обрабатываться - `--output` несовместим с несколькими файлами +### Контекстное меню проводника (Windows) + +Установить пункт `Transcribe` в меню «Отправить»: + +```bash +uv run transcribe --install-menu +``` + +Использование: выделите один или несколько аудио/видеофайлов в проводнике, откройте контекстное меню правой кнопкой. В Windows 11 выберите «Показать дополнительные параметры» или нажмите Shift+F10, затем «Отправить» → «Transcribe». Несколько выделенных файлов передаются в один процесс и обрабатываются одним батчем. + +Удалить пункт меню: + +```bash +uv run transcribe --uninstall-menu +``` + +Если что-то пошло не так, пункт можно удалить вручную: Win+R → `shell:sendto` → удалить `Transcribe.cmd`. + +Известные ограничения: + +- После переноса или пересоздания проекта/venv выполните `--install-menu` заново: внутри `Transcribe.cmd` хранится абсолютный путь к `transcribe.exe`. +- Очень большой мультивыбор с суммарной длиной путей ≳8000 символов упирается в лимит командной строки cmd.exe. Обрабатывайте такие файлы частями. + ### Опции CLI | Опция | Сокращение | По умолчанию | Описание | diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 4c543d4..59e26f2 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -9,6 +9,8 @@ from rich.console import Console from rich.status import Status from .config import apply_device_defaults, load_config, resolve_defaults +from .context_menu import install_menu as install_context_menu +from .context_menu import uninstall_menu as uninstall_context_menu from .formatter import format_transcript, write_transcript from .transcriber import ( Segment, @@ -45,7 +47,7 @@ def _format_device_info(device_used: str) -> str: @app.command() def main( - files: list[Path] = typer.Argument(..., help="Пути к аудио/видеофайлам"), + files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"), model: str | None = typer.Option( None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]" ), @@ -67,11 +69,48 @@ def main( ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"), + install_menu: bool = typer.Option(False, "--install-menu", help="Установить пункт Transcribe в SendTo"), + uninstall_menu: bool = typer.Option(False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"), ) -> None: """Транскрибирует аудио/видеофайлы в markdown с таймкодами. Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты. """ + files = [] if files is None else files + + if install_menu or uninstall_menu: + if install_menu and uninstall_menu: + console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold") + raise SystemExit(2) + if files: + console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold") + raise SystemExit(2) + if sys.platform != "win32": + console.print("Пункт меню SendTo доступен только на Windows.", style="red bold") + raise SystemExit(1) + + try: + if install_menu: + cmd_path = install_context_menu() + console.print(f"Пункт меню установлен: \"{cmd_path}\"", style="green") + else: + cmd_path = uninstall_context_menu() + if cmd_path is None: + console.print("Пункт меню не был установлен.", style="yellow") + else: + console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green") + except RuntimeError as exc: + console.print(f"Ошибка: {exc}", style="red bold") + raise SystemExit(1) + return + + if not files: + console.print( + "Укажите хотя бы один файл или используйте --install-menu/--uninstall-menu.", + style="red bold", + ) + raise SystemExit(2) + try: config = load_config() cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type} diff --git a/src/local_transcriber/context_menu.py b/src/local_transcriber/context_menu.py new file mode 100644 index 0000000..3490ed2 --- /dev/null +++ b/src/local_transcriber/context_menu.py @@ -0,0 +1,57 @@ +"""Установка пункта Transcribe в меню SendTo проводника Windows.""" + +import os +import sys +from pathlib import Path + +CMD_NAME = "Transcribe.cmd" +CMD_ENCODING = "oem" + + +def get_sendto_dir() -> Path: + """Возвращает путь к пользовательской папке SendTo.""" + appdata = os.environ.get("APPDATA") + if appdata is None: + raise RuntimeError("Переменная окружения APPDATA не задана.") + return Path(appdata) / "Microsoft" / "Windows" / "SendTo" + + +def get_transcribe_exe() -> Path: + """Возвращает путь к transcribe.exe рядом с текущим интерпретатором.""" + transcribe_exe = Path(sys.executable).parent / "transcribe.exe" + if not transcribe_exe.exists(): + raise RuntimeError( + f"Не найден transcribe.exe рядом с Python: {transcribe_exe}. " + "Выполните uv sync и повторите установку пункта меню." + ) + return transcribe_exe + + +def install_menu() -> Path: + """Создаёт или обновляет Transcribe.cmd в папке SendTo.""" + sendto_dir = get_sendto_dir() + transcribe_exe = get_transcribe_exe() + cmd_path = sendto_dir / CMD_NAME + content = f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n' + + try: + encoded_content = content.encode(CMD_ENCODING) + except UnicodeEncodeError as exc: + raise RuntimeError( + "Путь к transcribe.exe содержит символы, которые нельзя записать " + "в OEM-кодировке cmd.exe. Установите проект в путь без таких символов " + "и повторите --install-menu." + ) from exc + + sendto_dir.mkdir(parents=True, exist_ok=True) + cmd_path.write_bytes(encoded_content) + return cmd_path + + +def uninstall_menu() -> Path | None: + """Удаляет Transcribe.cmd из папки SendTo, если он существует.""" + cmd_path = get_sendto_dir() / CMD_NAME + if not cmd_path.exists(): + return None + cmd_path.unlink() + return cmd_path diff --git a/tests/test_cli.py b/tests/test_cli.py index 9f6afb0..c2c934c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -921,3 +921,100 @@ def test_cli_threads_negative_rejected(tmp_path): audio.write_bytes(b"fake") out = runner.invoke(app, [str(audio), "--threads", "-1"]) assert out.exit_code != 0 + + +# === SendTo context menu flags === + + +def test_cli_install_menu_success(tmp_path): + cmd_path = tmp_path / "Transcribe.cmd" + + with ( + patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install, + patch("local_transcriber.cli.load_config") as mock_load_config, + patch("local_transcriber.cli.sys") as mock_sys, + ): + mock_sys.platform = "win32" + out = runner.invoke(app, ["--install-menu"]) + + assert out.exit_code == 0 + assert "Пункт меню установлен" in out.output + assert cmd_path.name in out.output + mock_install.assert_called_once_with() + mock_load_config.assert_not_called() + + +def test_cli_uninstall_menu_success(tmp_path): + cmd_path = tmp_path / "Transcribe.cmd" + + with ( + patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall, + patch("local_transcriber.cli.load_config") as mock_load_config, + patch("local_transcriber.cli.sys") as mock_sys, + ): + mock_sys.platform = "win32" + out = runner.invoke(app, ["--uninstall-menu"]) + + assert out.exit_code == 0 + assert "Пункт меню удалён" in out.output + assert cmd_path.name in out.output + mock_uninstall.assert_called_once_with() + mock_load_config.assert_not_called() + + +def test_cli_uninstall_menu_missing_is_success(): + with ( + patch("local_transcriber.cli.uninstall_context_menu", return_value=None), + patch("local_transcriber.cli.sys") as mock_sys, + ): + mock_sys.platform = "win32" + out = runner.invoke(app, ["--uninstall-menu"]) + + assert out.exit_code == 0 + assert "не был установлен" in out.output + + +def test_cli_menu_flags_are_mutually_exclusive(): + out = runner.invoke(app, ["--install-menu", "--uninstall-menu"]) + + assert out.exit_code == 2 + assert "несовместимы" in out.output + + +def test_cli_menu_flag_with_file_is_rejected(tmp_path): + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + out = runner.invoke(app, [str(audio), "--install-menu"]) + + assert out.exit_code == 2 + assert "нельзя использовать вместе с файлами" in out.output + + +def test_cli_no_files_and_no_menu_flags_is_rejected(): + out = runner.invoke(app, []) + + assert out.exit_code == 2 + assert "Укажите хотя бы один файл" in out.output + + +def test_cli_menu_flags_available_only_on_windows(): + with patch("local_transcriber.cli.sys") as mock_sys: + mock_sys.platform = "linux" + out = runner.invoke(app, ["--install-menu"]) + + assert out.exit_code == 1 + assert "только на Windows" in out.output + + +def test_cli_menu_runtime_error_has_no_verbose_hint(): + with ( + patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")), + patch("local_transcriber.cli.sys") as mock_sys, + ): + mock_sys.platform = "win32" + out = runner.invoke(app, ["--install-menu"]) + + assert out.exit_code == 1 + assert "нет APPDATA" in out.output + assert "--verbose" not in out.output diff --git a/tests/test_context_menu.py b/tests/test_context_menu.py new file mode 100644 index 0000000..1e33c3d --- /dev/null +++ b/tests/test_context_menu.py @@ -0,0 +1,128 @@ +import sys +from pathlib import Path + +import pytest + +from local_transcriber import context_menu + + +def _prepare_exe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + scripts_dir = tmp_path / "venv" / "Scripts" + scripts_dir.mkdir(parents=True) + python_exe = scripts_dir / "python.exe" + transcribe_exe = scripts_dir / "transcribe.exe" + python_exe.write_bytes(b"") + transcribe_exe.write_bytes(b"") + monkeypatch.setattr(context_menu.sys, "executable", str(python_exe)) + return transcribe_exe + + +def test_install_menu_creates_expected_cmd(tmp_path, monkeypatch): + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) + monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8") + transcribe_exe = _prepare_exe(tmp_path, monkeypatch) + + cmd_path = context_menu.install_menu() + + assert cmd_path.name == "Transcribe.cmd" + assert cmd_path.exists() + assert cmd_path.read_bytes() == ( + f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("utf-8") + ) + assert b"chcp" not in cmd_path.read_bytes().lower() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Кодировка oem доступна только на Windows") +def test_install_menu_writes_real_oem_encoding_on_windows(tmp_path, monkeypatch): + appdata = tmp_path / "AppData" / "Roaming" + scripts_dir = tmp_path / "проект" / "Scripts" + scripts_dir.mkdir(parents=True) + python_exe = scripts_dir / "python.exe" + transcribe_exe = scripts_dir / "transcribe.exe" + python_exe.write_bytes(b"") + transcribe_exe.write_bytes(b"") + monkeypatch.setenv("APPDATA", str(appdata)) + monkeypatch.setattr(context_menu.sys, "executable", str(python_exe)) + + cmd_path = context_menu.install_menu() + + assert context_menu.CMD_ENCODING == "oem" + assert cmd_path.read_bytes() == ( + f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("oem") + ) + + +def test_install_menu_overwrites_existing_file(tmp_path, monkeypatch): + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) + monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8") + _prepare_exe(tmp_path, monkeypatch) + + cmd_path = context_menu.install_menu() + cmd_path.write_text("old", encoding="utf-8") + + second_path = context_menu.install_menu() + + assert second_path == cmd_path + assert "old" not in cmd_path.read_text(encoding="utf-8") + assert "%*" in cmd_path.read_text(encoding="utf-8") + + +def test_install_menu_creates_missing_sendto_dir(tmp_path, monkeypatch): + appdata = tmp_path / "AppData" / "Roaming" + monkeypatch.setenv("APPDATA", str(appdata)) + monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8") + _prepare_exe(tmp_path, monkeypatch) + + cmd_path = context_menu.install_menu() + + assert cmd_path.parent == appdata / "Microsoft" / "Windows" / "SendTo" + assert cmd_path.parent.is_dir() + + +def test_install_menu_oem_encoding_error_is_runtime_error(tmp_path, monkeypatch): + appdata = tmp_path / "AppData" / "Roaming" + cmd_path = appdata / "Microsoft" / "Windows" / "SendTo" / "Transcribe.cmd" + monkeypatch.setenv("APPDATA", str(appdata)) + monkeypatch.setattr(context_menu, "CMD_ENCODING", "ascii") + monkeypatch.setattr( + context_menu, + "get_transcribe_exe", + lambda: tmp_path / "测试" / "transcribe.exe", + ) + + with pytest.raises(RuntimeError, match="OEM"): + context_menu.install_menu() + + assert not cmd_path.exists() + + +def test_uninstall_menu_removes_file_and_missing_is_not_error(tmp_path, monkeypatch): + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) + monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8") + _prepare_exe(tmp_path, monkeypatch) + cmd_path = context_menu.install_menu() + + removed_path = context_menu.uninstall_menu() + missing_path = context_menu.uninstall_menu() + + assert removed_path == cmd_path + assert not cmd_path.exists() + assert missing_path is None + + +def test_get_sendto_dir_requires_appdata(monkeypatch): + monkeypatch.delenv("APPDATA", raising=False) + + with pytest.raises(RuntimeError, match="APPDATA"): + context_menu.get_sendto_dir() + + +def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch): + scripts_dir = tmp_path / "venv" / "Scripts" + scripts_dir.mkdir(parents=True) + python_exe = scripts_dir / "python.exe" + python_exe.write_bytes(b"") + monkeypatch.setattr(context_menu.sys, "executable", str(python_exe)) + + with pytest.raises(RuntimeError, match="uv sync"): + context_menu.get_transcribe_exe() \ No newline at end of file