diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 11c0817d..47082829 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-25) — Resolve the App Registered for a File Type + +Find out *which* app opens a file type — assert "PDFs open in Acrobat, not the browser". Full reference: [`docs/source/Eng/doc/new_features/v205_features_doc.rst`](docs/source/Eng/doc/new_features/v205_features_doc.rst). + +- **`normalize_ext` / `file_association`** (`AC_normalize_ext`, `AC_file_association`): `open_path` (`shell_open`) opens a file with whatever app is registered for it; this answers the inverse, read-only question — *which* app is that? Given `report.pdf` (or a bare `.pdf` / `pdf`) `file_association` returns the registered executable, friendly app name, open command line and MIME content type via the Windows `AssocQueryStringW` shell API. `normalize_ext` is the pure path/`.ext`/bare-`ext` → `.ext` helper. The assembly logic is unit-testable without Windows through an injectable `resolver` seam (the real shell API by default). The natural companion to `open_path`: one tells you what would open a file, the other opens it. Third feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ## What's new (2026-06-25) — Idle Detection + Keep the Machine Awake Run only when the user has stepped away, and stop an overnight run from sleeping. Full reference: [`docs/source/Eng/doc/new_features/v204_features_doc.rst`](docs/source/Eng/doc/new_features/v204_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v205_features_doc.rst b/docs/source/Eng/doc/new_features/v205_features_doc.rst new file mode 100644 index 00000000..13176912 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v205_features_doc.rst @@ -0,0 +1,44 @@ +Resolve the App Registered for a File Type +========================================== + +:func:`open_path` (``shell_open``) opens a file with whatever app is registered +for it; ``file_assoc`` answers the inverse, read-only question — *which* app is +that? Given ``report.pdf`` (or a bare ``.pdf`` / ``pdf``) it returns the +registered executable, the friendly app name, the open command line and the MIME +content type, via the Windows ``AssocQueryStringW`` shell API. + +* :func:`normalize_ext` — pure helper turning a path / ``.ext`` / bare ``ext`` + into a lowercased ``.ext``, +* :func:`file_association` — run the lookup through an injectable ``resolver`` + seam (the real shell API by default). + +The assembly logic is unit-testable without Windows via the injectable +``resolver``. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import file_association, normalize_ext + + normalize_ext("report.PDF") # ".pdf" + normalize_ext("archive.tar.gz") # ".gz" + + file_association("report.pdf") + # {"ext": ".pdf", "command": "...AcroRd32.exe \"%1\"", + # "exe": "...AcroRd32.exe", "friendly": "Adobe Acrobat", + # "content_type": "application/pdf"} + +The app fields are ``None`` when nothing is registered for the type. This is the +natural companion to :func:`open_path`: ``file_association`` tells you *what* +would open a file (assert "PDFs open in Acrobat, not the browser"), and +``open_path`` actually opens it. The live lookup uses the Windows shell API; on +other platforms pass your own ``resolver``. + +Executor commands +----------------- + +``AC_normalize_ext`` (``target`` → ``{ext}``, pure) and ``AC_file_association`` +(``target`` → the association dict). They are exposed as the matching ``ac_*`` +MCP tools (both read-only) and as Script Builder commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v205_features_doc.rst b/docs/source/Zh/doc/new_features/v205_features_doc.rst new file mode 100644 index 00000000..2911bec4 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v205_features_doc.rst @@ -0,0 +1,37 @@ +解析檔案類型已註冊的應用程式 +============================ + +:func:`open_path`(``shell_open``)用註冊的應用程式開啟檔案;``file_assoc`` 回答相反的唯讀問題—— +那個應用程式是「哪一個」?給定 ``report.pdf``(或裸的 ``.pdf`` / ``pdf``),它會透過 Windows +``AssocQueryStringW`` shell API 回傳已註冊的執行檔、友善應用程式名稱、開啟命令列與 MIME 內容類型。 + +* :func:`normalize_ext` ——純輔助函式,把路徑 / ``.ext`` / 裸 ``ext`` 轉成小寫的 ``.ext``, +* :func:`file_association` ——透過可注入的 ``resolver`` 接縫執行查詢(預設為真正的 shell API)。 + +組裝邏輯可透過可注入的 ``resolver`` 在非 Windows 上單元測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import file_association, normalize_ext + + normalize_ext("report.PDF") # ".pdf" + normalize_ext("archive.tar.gz") # ".gz" + + file_association("report.pdf") + # {"ext": ".pdf", "command": "...AcroRd32.exe \"%1\"", + # "exe": "...AcroRd32.exe", "friendly": "Adobe Acrobat", + # "content_type": "application/pdf"} + +當該類型未註冊任何應用程式時,應用程式欄位為 ``None``。這是 :func:`open_path` 的自然搭檔: +``file_association`` 告訴你「什麼」會開啟檔案(可斷言「PDF 用 Acrobat 開,不是瀏覽器」),而 +``open_path`` 實際開啟它。即時查詢使用 Windows shell API;其他平台請傳入自己的 ``resolver``。 + +執行器指令 +---------- + +``AC_normalize_ext``(``target`` → ``{ext}``,純)與 ``AC_file_association`` +(``target`` → 關聯 dict)。皆以對應的 ``ac_*`` MCP 工具(皆唯讀)及 Script Builder 指令 +(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index e6d113fa..c360611f 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -98,6 +98,8 @@ allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on, plan_keep_awake, ) +# Resolve which application is registered to open a given file type +from je_auto_control.utils.file_assoc import file_association, normalize_ext # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1710,6 +1712,7 @@ def start_autocontrol_gui(*args, **kwargs): "plan_open", "open_path", "idle_seconds", "is_idle", "plan_keep_awake", "keep_awake", "keep_awake_on", "allow_sleep", + "normalize_ext", "file_association", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index ef1c0395..ba1809b2 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4302,6 +4302,22 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: fields=(), description="Release a previously-started keep-awake.", )) + specs.append(CommandSpec( + "AC_normalize_ext", "Shell", "Normalize Extension", + fields=( + FieldSpec("target", FieldType.STRING, + placeholder="report.pdf or .pdf or pdf"), + ), + description="Lowercased file extension (with dot) of a path / ext.", + )) + specs.append(CommandSpec( + "AC_file_association", "Shell", "File Association (default app)", + fields=( + FieldSpec("target", FieldType.STRING, + placeholder="report.pdf or .pdf"), + ), + description="Which app is registered to open a file type (Windows).", + )) specs.append(CommandSpec( "AC_take_golden", "Report", "Capture Golden Image", fields=(FieldSpec("path", FieldType.FILE_PATH),), diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index b84a7790..9f8cf9b3 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2652,6 +2652,18 @@ def _allow_sleep() -> Dict[str, Any]: return {"released": bool(allow_sleep())} +def _normalize_ext(target: str) -> Dict[str, Any]: + """Adapter: the lowercased extension of a path / bare ext (pure).""" + from je_auto_control.utils.file_assoc import normalize_ext + return {"ext": normalize_ext(str(target))} + + +def _file_association(target: str) -> Dict[str, Any]: + """Adapter: the app registered to open ``target``'s file type.""" + from je_auto_control.utils.file_assoc import file_association + return file_association(str(target)) + + def _get_control_text(name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Dict[str, Any]: @@ -6649,6 +6661,8 @@ def __init__(self): "AC_plan_keep_awake": _plan_keep_awake, "AC_keep_awake_on": _keep_awake_on, "AC_allow_sleep": _allow_sleep, + "AC_normalize_ext": _normalize_ext, + "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, "AC_find_control_text": _find_control_text, "AC_select_control_text": _select_control_text, diff --git a/je_auto_control/utils/file_assoc/__init__.py b/je_auto_control/utils/file_assoc/__init__.py new file mode 100644 index 00000000..1b8241f5 --- /dev/null +++ b/je_auto_control/utils/file_assoc/__init__.py @@ -0,0 +1,6 @@ +"""Resolve which application is registered to open a given file type.""" +from je_auto_control.utils.file_assoc.file_assoc import ( + file_association, normalize_ext, +) + +__all__ = ["normalize_ext", "file_association"] diff --git a/je_auto_control/utils/file_assoc/file_assoc.py b/je_auto_control/utils/file_assoc/file_assoc.py new file mode 100644 index 00000000..245827d3 --- /dev/null +++ b/je_auto_control/utils/file_assoc/file_assoc.py @@ -0,0 +1,86 @@ +"""Resolve which application is registered to open a given file type. + +:func:`shell_open` opens a file with whatever app is registered for it; +``file_assoc`` answers the inverse, read-only question — *which* app is that? +Given ``report.pdf`` (or a bare ``.pdf`` / ``pdf``) it returns the registered +executable, the friendly app name, the open command line and the MIME content +type, via the Windows ``AssocQueryStringW`` shell API. + +:func:`normalize_ext` is a pure helper (path / bare-extension -> ``.ext``), +fully unit-testable. :func:`file_association` runs the lookup through an +injectable ``resolver`` seam (the real shell API by default), so the assembly +logic is testable without Windows. Imports no ``PySide6``. +""" +import ctypes +import os +import sys +from typing import Any, Callable, Dict, Optional + +# ASSOCSTR query ids (shlwapi AssocQueryStringW) -> result-dict keys. +_ASSOC_FIELDS = { + "command": 1, # ASSOCSTR_COMMAND + "exe": 2, # ASSOCSTR_EXECUTABLE + "friendly": 4, # ASSOCSTR_FRIENDLYAPPNAME + "content_type": 12, # ASSOCSTR_CONTENTTYPE +} + +# A resolver: ext (".pdf") -> {command, exe, friendly, content_type}. +AssocResolver = Callable[[str], Dict[str, Any]] + + +def normalize_ext(target: str) -> str: + """Return the lowercased extension (with leading dot) of a path or ext. + + Accepts ``report.pdf``, ``C:\\x\\y.PDF``, ``.pdf`` or bare ``pdf``. Raises + ``ValueError`` for an empty target or one with no resolvable extension. + """ + text = str(target).strip() + if not text: + raise ValueError("target is empty") + _, ext = os.path.splitext(os.path.basename(text)) + if not ext: + ext = text if text.startswith(".") else "." + text + ext = ext.lower() + if len(ext) < 2 or any(char in ext for char in "/\\ \t"): + raise ValueError(f"no file extension in target: {target!r}") + return ext + + +def _assoc_query(ext: str, assoc_str: int) -> Optional[str]: + """Run one AssocQueryStringW lookup; return the string or None.""" + shlwapi = ctypes.windll.shlwapi + size = ctypes.c_ulong(0) + shlwapi.AssocQueryStringW(0, assoc_str, ext, None, None, ctypes.byref(size)) + if size.value == 0: + return None + buf = ctypes.create_unicode_buffer(size.value) + result = shlwapi.AssocQueryStringW(0, assoc_str, ext, None, buf, + ctypes.byref(size)) + return buf.value if result == 0 else None + + +def _default_resolver(ext: str) -> Dict[str, Any]: + """Resolve ``ext``'s registered app via the Windows shell API.""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "file association lookup is Windows-only; pass resolver=") + return {key: _assoc_query(ext, assoc_str) + for key, assoc_str in _ASSOC_FIELDS.items()} + + +def file_association(target: str, *, + resolver: Optional[AssocResolver] = None) -> Dict[str, Any]: + """Return the app registered to open ``target``'s file type. + + Returns ``{ext, command, exe, friendly, content_type}`` (the app fields are + None when nothing is registered). Pass ``resolver`` (``ext -> dict``) to + intercept the lookup in tests; the default uses the Windows shell API. + """ + ext = normalize_ext(target) + resolve = resolver if resolver is not None else _default_resolver + info = resolve(ext) + return {"ext": ext, + "command": info.get("command"), + "exe": info.get("exe"), + "friendly": info.get("friendly"), + "content_type": info.get("content_type")} diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 67a25d1a..7a9b95cd 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2149,6 +2149,25 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.allow_sleep, annotations=SIDE_EFFECT_ONLY, ), + MCPTool( + name="ac_normalize_ext", + description=("Return the lowercased file extension (with leading " + "dot) of a path or bare extension (pure): {ext}."), + input_schema=schema({"target": {"type": "string"}}, + required=["target"]), + handler=h.normalize_ext, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_file_association", + description=("Which application is registered to open a file type. " + "'target' is a path / .ext / bare ext. Returns {ext, " + "command, exe, friendly, content_type} (Windows)."), + input_schema=schema({"target": {"type": "string"}}, + required=["target"]), + handler=h.file_association, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 723b7a35..8069a557 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -593,6 +593,16 @@ def allow_sleep(): return _allow_sleep() +def normalize_ext(target): + from je_auto_control.utils.executor.action_executor import _normalize_ext + return _normalize_ext(target) + + +def file_association(target): + from je_auto_control.utils.executor.action_executor import _file_association + return _file_association(target) + + def get_clipboard() -> str: from je_auto_control.utils.clipboard.clipboard import get_clipboard as _get return _get() diff --git a/test/unit_test/headless/test_file_assoc_batch.py b/test/unit_test/headless/test_file_assoc_batch.py new file mode 100644 index 00000000..8be1c928 --- /dev/null +++ b/test/unit_test/headless/test_file_assoc_batch.py @@ -0,0 +1,79 @@ +"""Headless tests for file-association lookup (pure normalize + injected resolver).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.file_assoc import file_association, normalize_ext + + +def test_normalize_ext_from_path(): + assert normalize_ext("report.pdf") == ".pdf" + assert normalize_ext(r"C:\Users\me\report.PDF") == ".pdf" + + +def test_normalize_ext_from_bare_and_dotted(): + assert normalize_ext("pdf") == ".pdf" + assert normalize_ext(".PDF") == ".pdf" + + +def test_normalize_ext_takes_last_extension(): + assert normalize_ext("archive.tar.gz") == ".gz" + + +def test_normalize_ext_rejects_empty_and_extensionless(): + for bad in ("", " ", "folder/", "no_dot_here/"): + with pytest.raises(ValueError): + normalize_ext(bad) + + +def test_file_association_uses_injected_resolver(): + def fake_resolver(ext): + assert ext == ".pdf" + return {"command": "acro.exe \"%1\"", "exe": "acro.exe", + "friendly": "Acrobat", "content_type": "application/pdf"} + + info = file_association("report.pdf", resolver=fake_resolver) + assert info["ext"] == ".pdf" + assert info["exe"] == "acro.exe" + assert info["friendly"] == "Acrobat" + assert info["content_type"] == "application/pdf" + + +def test_file_association_missing_fields_default_to_none(): + info = file_association(".xyz", resolver=lambda ext: {}) + assert info["ext"] == ".xyz" + assert info["exe"] is None and info["friendly"] is None + assert info["command"] is None and info["content_type"] is None + + +def test_file_association_normalizes_before_resolving(): + seen = {} + + def fake_resolver(ext): + seen["ext"] = ext + return {} + + file_association("DOC", resolver=fake_resolver) + assert seen["ext"] == ".doc" + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_normalize_path(): + from je_auto_control.utils.executor.action_executor import _normalize_ext + assert _normalize_ext("report.pdf") == {"ext": ".pdf"} + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_normalize_ext", "AC_file_association"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_normalize_ext", "ac_file_association"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_normalize_ext", "AC_file_association"} <= specs + + +def test_facade_exports(): + for name in ("normalize_ext", "file_association"): + assert hasattr(ac, name) and name in ac.__all__