Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
44 changes: 44 additions & 0 deletions docs/source/Eng/doc/new_features/v205_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
37 changes: 37 additions & 0 deletions docs/source/Zh/doc/new_features/v205_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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** 分類下)形式提供。
3 changes: 3 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),),
Expand Down
14 changes: 14 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/utils/file_assoc/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
86 changes: 86 additions & 0 deletions je_auto_control/utils/file_assoc/file_assoc.py
Original file line number Diff line number Diff line change
@@ -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")}
19 changes: 19 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]


Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
79 changes: 79 additions & 0 deletions test/unit_test/headless/test_file_assoc_batch.py
Original file line number Diff line number Diff line change
@@ -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__
Loading