mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 07:59:36 +02:00
Compare commits
28 Commits
autoUpgrad
...
user/muyua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f283a568c3 | ||
|
|
873aa42d7f | ||
|
|
32f6b9e18c | ||
|
|
240bf6957d | ||
|
|
b3fe9f608e | ||
|
|
1af0dba08c | ||
|
|
4d7575fcfb | ||
|
|
8c1cf09802 | ||
|
|
796b232035 | ||
|
|
a48efdb6cb | ||
|
|
78d05c9082 | ||
|
|
107c7c4cdc | ||
|
|
4394357013 | ||
|
|
214d80415a | ||
|
|
7418da8806 | ||
|
|
8778af2e32 | ||
|
|
aa3f1165b3 | ||
|
|
df45e56511 | ||
|
|
f8bca48db3 | ||
|
|
5104d0846c | ||
|
|
8ca6c4d2ec | ||
|
|
574aca841b | ||
|
|
0833b68907 | ||
|
|
2051c13bf9 | ||
|
|
4c7bf3df79 | ||
|
|
879163f48e | ||
|
|
4b84c00300 | ||
|
|
6062bdc2f8 |
285
doc/devdocs/modules/advancedpaste-python-scripts.md
Normal file
285
doc/devdocs/modules/advancedpaste-python-scripts.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Advanced Paste – Python Scripts
|
||||
|
||||
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
|
||||
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
|
||||
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
|
||||
2. Drop a `.py` file into the folder.
|
||||
3. Define one `advanced_paste_from_<input>_to_<output>` function (see [Writing a script](#writing-a-script)).
|
||||
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
|
||||
|
||||
> **Important:** Each `.py` file must define exactly one `advanced_paste_from_<input>_to_<output>`
|
||||
> function. Scripts with zero or multiple such functions are ignored.
|
||||
|
||||
## Writing a script
|
||||
|
||||
You write a single Python function whose **name** declares both what clipboard input it accepts
|
||||
and what output type it produces.
|
||||
No imports from PowerToys are needed — zero setup, zero dependencies on our side.
|
||||
|
||||
### Function naming convention
|
||||
|
||||
The function name follows the pattern:
|
||||
|
||||
```
|
||||
advanced_paste_from_<input>_to_<output>(<param>)
|
||||
```
|
||||
|
||||
**Input types** (what the function receives):
|
||||
|
||||
| Input | Parameter | When it runs |
|
||||
|-------|-----------|--------------|
|
||||
| `text` | `str` — clipboard text | Clipboard has text |
|
||||
| `html` | `str` — clipboard HTML | Clipboard has HTML |
|
||||
| `image` | `str` — path to temp image file | Clipboard has an image |
|
||||
| `audio` | `str` — path to audio file | Clipboard has an audio file |
|
||||
| `video` | `str` — path to video file | Clipboard has a video file |
|
||||
| `files` | `list[str]` — file paths | Clipboard has files |
|
||||
|
||||
**Output types** (what the function produces — declared via `_to_` suffix):
|
||||
|
||||
| Output | Effect |
|
||||
|--------|--------|
|
||||
| `text` | Sets clipboard to text |
|
||||
| `html` | Sets clipboard to HTML |
|
||||
| `image` | Sets clipboard to image |
|
||||
| `audio` | Sets clipboard to audio file |
|
||||
| `video` | Sets clipboard to video file |
|
||||
| `file` | Sets clipboard to a file |
|
||||
| `files` | Sets clipboard to multiple files |
|
||||
|
||||
### Return value
|
||||
|
||||
The return value is interpreted according to the declared output type:
|
||||
|
||||
| Output type | Expected return value |
|
||||
|-------------|---------------------|
|
||||
| `text` | `str` (or any value — will be converted via `str()`) |
|
||||
| `html` | `str` containing HTML |
|
||||
| `image` | `str` or `pathlib.Path` pointing to an image file |
|
||||
| `file` | `str` or `pathlib.Path` pointing to a file |
|
||||
| `files` | `list` of `str`/`pathlib.Path` file paths |
|
||||
|
||||
Returning `None` produces an empty result (no-op).
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal — uppercase text
|
||||
|
||||
```python
|
||||
def advanced_paste_from_text_to_text(text):
|
||||
return text.upper()
|
||||
```
|
||||
|
||||
That's it. No headers required, no imports from PowerToys.
|
||||
|
||||
### With optional metadata
|
||||
|
||||
```python
|
||||
# @advancedpaste:name Reverse Text
|
||||
# @advancedpaste:desc Reverses clipboard text character by character
|
||||
|
||||
def advanced_paste_from_text_to_text(text):
|
||||
return text[::-1]
|
||||
```
|
||||
|
||||
### Text to HTML
|
||||
|
||||
```python
|
||||
# @advancedpaste:name Markdown Table to HTML
|
||||
# @advancedpaste:desc Convert a markdown table to an HTML table
|
||||
|
||||
def advanced_paste_from_text_to_html(text):
|
||||
headers = text.splitlines()[0].split("|")
|
||||
return f"<table><tr>{''.join(f'<th>{h.strip()}</th>' for h in headers if h.strip())}</tr></table>"
|
||||
```
|
||||
|
||||
### Image to text (OCR)
|
||||
|
||||
```python
|
||||
# @advancedpaste:requires pytesseract
|
||||
|
||||
def advanced_paste_from_image_to_text(image_path):
|
||||
import pytesseract
|
||||
return pytesseract.image_to_string(image_path).strip()
|
||||
```
|
||||
|
||||
### Save text as file
|
||||
|
||||
```python
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
def advanced_paste_from_text_to_file(text):
|
||||
# Use ADVANCED_PASTE_WORK_DIR for WSL compatibility; falls back to temp dir on Windows.
|
||||
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
|
||||
out = Path(out_dir) / "clipboard.txt"
|
||||
out.write_text(text, encoding="utf-8")
|
||||
return out
|
||||
```
|
||||
|
||||
### Image processing (image → image)
|
||||
|
||||
```python
|
||||
import os
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
def advanced_paste_from_image_to_image(image_path):
|
||||
"""Convert image to grayscale."""
|
||||
img = Image.open(image_path).convert("L")
|
||||
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
|
||||
out = Path(out_dir) / "gray.png"
|
||||
img.save(out)
|
||||
return out
|
||||
```
|
||||
|
||||
### File listing (files → text)
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def advanced_paste_from_files_to_text(file_paths):
|
||||
lines = []
|
||||
for p in file_paths:
|
||||
size = os.path.getsize(p)
|
||||
lines.append(f"{os.path.basename(p)} ({size} bytes)")
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
## Header tags
|
||||
|
||||
All header tags are **optional**. Tags are placed in comment lines at the top of the script.
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `name` | Display name in the Advanced Paste UI. If omitted, the filename is used. |
|
||||
| `desc` | Short description / tooltip. |
|
||||
| `disabled` | Presence of this tag disables the script (it won't appear in the UI). |
|
||||
| `requires` | Declare Python package dependencies (see [Dependencies](#declaring-dependencies)). |
|
||||
|
||||
### Example header
|
||||
|
||||
```python
|
||||
# @advancedpaste:name My Formatter
|
||||
# @advancedpaste:desc Formats clipboard text as markdown table
|
||||
```
|
||||
|
||||
To disable a script without deleting it, add:
|
||||
|
||||
```python
|
||||
# @advancedpaste:disabled
|
||||
```
|
||||
|
||||
Remove the line to re-enable.
|
||||
|
||||
## Declaring dependencies
|
||||
|
||||
Use `requires` to declare Python packages the script needs:
|
||||
|
||||
```python
|
||||
# @advancedpaste:requires PIL=Pillow
|
||||
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
|
||||
```
|
||||
|
||||
Each token is either:
|
||||
|
||||
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
|
||||
- **`import_name=pip_package`** — when the import name differs from the pip package
|
||||
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
|
||||
|
||||
### Automatic import detection
|
||||
|
||||
Advanced Paste also scans the script body for `import` and `from ... import` statements
|
||||
and cross-references them against the Python standard library. Any non-stdlib import
|
||||
that is not already installed triggers a prompt to install it automatically.
|
||||
|
||||
## Security — script trust
|
||||
|
||||
The first time a script is executed (or after it has been modified), Advanced Paste
|
||||
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
|
||||
Subsequent runs of the unchanged file skip the dialog.
|
||||
|
||||
## Error handling
|
||||
|
||||
When a script fails, Advanced Paste extracts the Python traceback from stderr and
|
||||
displays a user-friendly summary in the UI:
|
||||
|
||||
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
|
||||
- **SyntaxError** — shows the file and line number.
|
||||
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
|
||||
- **Other errors** — shows the last line of the traceback as a summary, with the full
|
||||
traceback available in the expandable *Details* section.
|
||||
|
||||
## Settings
|
||||
|
||||
The Python scripts feature uses a **mode selector** (dropdown) with three options:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Disabled** | Python scripts are not active. |
|
||||
| **Windows** | Scripts run using a native Windows Python interpreter. |
|
||||
| **WSL** | Scripts run inside Windows Subsystem for Linux. |
|
||||
|
||||
Each mode maintains its own independent settings (scripts folder, interpreter path, etc.),
|
||||
so switching between Windows and WSL does not lose your previous configuration.
|
||||
|
||||
### Windows mode settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
|
||||
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
|
||||
|
||||
### WSL mode settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Scripts folder | Folder to scan for `.py` scripts (Windows path — auto-translated to `/mnt/...`). | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
|
||||
| WSL distribution | Which WSL distro to use (e.g. `Ubuntu`). Leave blank for the default distribution. | *(default)* |
|
||||
|
||||
### Scripts list
|
||||
|
||||
The Settings page shows a read-only list of discovered scripts. For each script you can see:
|
||||
|
||||
- **Name** — from `@advancedpaste:name` tag, or the filename if not set.
|
||||
- **Description** — from `@advancedpaste:desc` tag.
|
||||
- **Conversion** — the input → output types detected from the function name (e.g. "text → image").
|
||||
|
||||
The list is **not editable** from Settings. To change a script's name, description, enabled state,
|
||||
or any other metadata, open the script file directly (click the "Open in editor" button) and edit
|
||||
the `# @advancedpaste:...` header tags. After saving, click **Refresh** in Settings to reload.
|
||||
|
||||
### WSL mode details
|
||||
|
||||
When **WSL** mode is selected:
|
||||
|
||||
- Scripts are executed via `wsl.exe bash -l -c "python3 ..."` using the configured distribution.
|
||||
- The scripts folder remains on the Windows filesystem; paths are automatically translated
|
||||
to `/mnt/c/...` format for WSL access.
|
||||
- Package installation uses `pip3 install` inside the WSL environment.
|
||||
- Output files from scripts must be written under `/mnt/` (the Windows-mounted filesystem)
|
||||
so they can be accessed from Windows. The runner sets the `ADVANCED_PASTE_WORK_DIR` environment
|
||||
variable to a temp directory under `/mnt/c/...` — use it instead of `tempfile.gettempdir()`
|
||||
when producing file output for cross-platform compatibility.
|
||||
|
||||
> **Tip:** If you have Python installed only in WSL (not on Windows), select WSL mode
|
||||
> to use your existing WSL Python environment with all its packages.
|
||||
|
||||
## Tips
|
||||
|
||||
- Each `.py` file must contain exactly one `advanced_paste_from_<input>_to_<output>` function.
|
||||
If you need to handle multiple input types, create separate script files for each.
|
||||
- A `.py` file without any matching function is ignored — use this for helper modules
|
||||
that other scripts can import.
|
||||
- Scripts can be tested from the command line:
|
||||
```
|
||||
echo {"format":["text"],"text":"hello"} | python _runner.py my_script.py
|
||||
```
|
||||
- The script's directory is added to `sys.path` at runtime, so you can import sibling `.py`
|
||||
files as helper modules.
|
||||
@@ -57,6 +57,22 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration => _configuration;
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => Array.Empty<AdvancedPastePythonScriptAction>();
|
||||
|
||||
public string PythonScriptsFolder => string.Empty;
|
||||
|
||||
public string PythonExecutablePath => string.Empty;
|
||||
|
||||
public bool PythonUseWsl => false;
|
||||
|
||||
public string PythonWslDistribution => string.Empty;
|
||||
|
||||
public int PythonScriptTimeoutSeconds => 30;
|
||||
|
||||
public bool IsPythonScriptsEnabled => true;
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
|
||||
|
||||
public event EventHandler Changed;
|
||||
|
||||
public Task SetActiveAIProviderAsync(string providerId)
|
||||
@@ -65,4 +81,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void StoreTrustedScriptHash(string scriptPath, string hash)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using AdvancedPaste.UnitTests.Mocks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AdvancedPaste.UnitTests.ServicesTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PythonScriptServiceTests
|
||||
{
|
||||
private PythonScriptService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_service = new PythonScriptService(new IntegrationTestUserSettings());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_DetectsSimpleImports()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"# @advancedpaste:name test",
|
||||
"import requests",
|
||||
"import numpy",
|
||||
"import os",
|
||||
"import sys",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(2, result.Count); // requests + numpy; os and sys are stdlib
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "requests" && r.PipPackage == "requests"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "numpy" && r.PipPackage == "numpy"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_DetectsFromImports()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"from PIL import Image",
|
||||
"from markitdown import MarkItDown",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "PIL" && r.PipPackage == "Pillow"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "markitdown" && r.PipPackage == "markitdown"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_WellKnownMappings()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import cv2",
|
||||
"import win32clipboard",
|
||||
"import yaml",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(3, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "cv2" && r.PipPackage == "opencv-python"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "win32clipboard" && r.PipPackage == "pywin32"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "yaml" && r.PipPackage == "PyYAML"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_ExplicitRequirementsTakePrecedence()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import cv2",
|
||||
"import requests",
|
||||
};
|
||||
|
||||
var explicitReqs = new List<PythonRequirement>
|
||||
{
|
||||
new("cv2", "opencv-python-headless"),
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, explicitReqs);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
|
||||
// cv2 should use the explicit pip package name, not the well-known mapping
|
||||
var cv2Req = result.First(r => r.ImportName == "cv2");
|
||||
Assert.AreEqual("opencv-python-headless", cv2Req.PipPackage);
|
||||
|
||||
// requests should be auto-detected
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_SkipsStdlib()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import os",
|
||||
"import sys",
|
||||
"import json",
|
||||
"import io",
|
||||
"import pathlib",
|
||||
"import tempfile",
|
||||
"import subprocess",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_SkipsComments()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"# import requests",
|
||||
"# from PIL import Image",
|
||||
"import json",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_HandlesMultipleImportsOnOneLine()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import requests, numpy, pandas",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(3, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "numpy"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "pandas"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_HandlesSubmoduleImport()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import win32com.client",
|
||||
"from llama_cpp import Llama",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "win32com" && r.PipPackage == "pywin32"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "llama_cpp" && r.PipPackage == "llama-cpp-python"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_ModuleNotFoundError()
|
||||
{
|
||||
var stderr = """
|
||||
Traceback (most recent call last):
|
||||
File "C:\scripts\reverse.py", line 4, in <module>
|
||||
import win32clipboard
|
||||
ModuleNotFoundError: No module named 'win32clipboard'
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("reverse.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 4"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("win32clipboard"), $"Summary should mention the module: {summary}");
|
||||
Assert.IsTrue(summary.Contains("pywin32"), $"Summary should suggest pip package: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_SyntaxError()
|
||||
{
|
||||
var stderr = """
|
||||
File "test.py", line 5
|
||||
def foo(
|
||||
^
|
||||
SyntaxError: unexpected EOF while parsing
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 5"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_SyntaxErrorWithColumn()
|
||||
{
|
||||
var stderr = " File \"script.py\", line 3\n x = (1 +\n ^\nSyntaxError: '(' was never closed\n";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("script.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 3"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("col"), $"Summary should mention the column: {summary}");
|
||||
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_GenericError()
|
||||
{
|
||||
var stderr = """
|
||||
Traceback (most recent call last):
|
||||
File "test.py", line 10, in <module>
|
||||
result = 1 / 0
|
||||
ZeroDivisionError: division by zero
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 10"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("ZeroDivisionError"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_NestedTraceback_ShowsLastFrame()
|
||||
{
|
||||
var stderr = """
|
||||
Traceback (most recent call last):
|
||||
File "main.py", line 5, in <module>
|
||||
helper()
|
||||
File "helper.py", line 12, in helper
|
||||
do_work()
|
||||
File "worker.py", line 8, in do_work
|
||||
raise RuntimeError("bad state")
|
||||
RuntimeError: bad state
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("worker.py"), $"Summary should mention the last script in the chain: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 8"), $"Summary should mention the line of the last frame: {summary}");
|
||||
Assert.IsTrue(summary.Contains("bad state"), $"Summary should contain the error message: {summary}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_EmptyStderr()
|
||||
{
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(string.Empty);
|
||||
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(summary));
|
||||
Assert.AreEqual(string.Empty, details);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_NoTraceback_PlainStderr()
|
||||
{
|
||||
var stderr = "Something went wrong in the script\n";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
// No File "..." reference, so no location — just the message
|
||||
Assert.IsTrue(summary.Contains("Something went wrong"), $"Summary: {summary}");
|
||||
Assert.IsFalse(summary.Contains("line"), $"Summary should not contain 'line' without a traceback: {summary}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_BasicTraceback()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"Traceback (most recent call last):",
|
||||
" File \"script.py\", line 10, in <module>",
|
||||
" result = 1 / 0",
|
||||
"ZeroDivisionError: division by zero",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNotNull(location);
|
||||
Assert.AreEqual("script.py", location.Value.FileName);
|
||||
Assert.AreEqual(10, location.Value.Line);
|
||||
Assert.IsNull(location.Value.Column);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_WithCaret()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
" File \"test.py\", line 5",
|
||||
" def foo(",
|
||||
" ^",
|
||||
"SyntaxError: unexpected EOF while parsing",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNotNull(location);
|
||||
Assert.AreEqual("test.py", location.Value.FileName);
|
||||
Assert.AreEqual(5, location.Value.Line);
|
||||
Assert.IsNotNull(location.Value.Column);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_FullPath_ReturnsBasename()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"Traceback (most recent call last):",
|
||||
" File \"C:\\Users\\user\\scripts\\my_script.py\", line 42, in <module>",
|
||||
" some_call()",
|
||||
"ValueError: invalid value",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNotNull(location);
|
||||
Assert.AreEqual("my_script.py", location.Value.FileName);
|
||||
Assert.AreEqual(42, location.Value.Line);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_NoFileLine_ReturnsNull()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"Some random error output",
|
||||
"No traceback here",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNull(location);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePipInstallError_ExtractsErrorLine()
|
||||
{
|
||||
var stderr = """
|
||||
Collecting some-package
|
||||
Downloading some-package-1.0.tar.gz (15 kB)
|
||||
ERROR: Could not find a version that satisfies the requirement some-package (from versions: none)
|
||||
ERROR: No matching distribution found for some-package
|
||||
""";
|
||||
|
||||
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("No matching distribution"), $"Summary should contain the last ERROR line: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePipInstallError_NoErrorPrefix_UsesLastLine()
|
||||
{
|
||||
var stderr = "permission denied: /usr/lib/python3/dist-packages\n";
|
||||
|
||||
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("permission denied"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePipInstallError_EmptyStderr()
|
||||
{
|
||||
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(string.Empty);
|
||||
|
||||
Assert.AreEqual("unknown error", summary);
|
||||
Assert.AreEqual(string.Empty, fullStderr);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Text()
|
||||
{
|
||||
// The new interface uses function names like advanced_paste_from_text_to_text(...)
|
||||
// to determine supported formats, not parameter signatures.
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_text(text):\n return text.upper()\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Html()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_html_to_text(html: str) -> str:\n return html\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Html, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Image()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_image_to_text(image_path):\n return 'desc'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Image, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Files()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_text(file_paths):\n return ''\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.File, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Image()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_image(text):\n return '/path/img.png'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual("image", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_File()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_file(text):\n return '/path/out.txt'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual("file", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Files()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_files(file_paths):\n return file_paths\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual("files", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_RejectsMultipleFunctions()
|
||||
{
|
||||
var scriptPath = CreateTempScript(
|
||||
"def advanced_paste_from_text_to_text(text):\n return text\n\n" +
|
||||
"def advanced_paste_from_html_to_text(html):\n return html\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNull(metadata);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_RejectsNoFunction()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def some_other_function(text):\n return text\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNull(metadata);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_RejectsOldFormatWithoutTo()
|
||||
{
|
||||
// Old format (advanced_paste_from_text without _to_) should be rejected.
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text(text):\n return text\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNull(metadata);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Audio()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_audio_to_text(audio_path):\n return 'transcribed'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Audio, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Video()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_video_to_text(video_path):\n return 'description'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Video, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Audio()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_audio(text):\n return '/path/out.mp3'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
|
||||
Assert.AreEqual("audio", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Video()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_video(text):\n return '/path/out.mp4'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
|
||||
Assert.AreEqual("video", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
private static string CreateTempScript(string content)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test_script_{Guid.NewGuid():N}.py");
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -155,5 +155,9 @@
|
||||
<Content Include="Assets\AdvancedPaste\SemanticKernel.svg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
||||
<Content Include="Services\PythonScripts\_runner.py">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -14,6 +14,7 @@ using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Models;
|
||||
using AdvancedPaste.Services;
|
||||
using AdvancedPaste.Services.CustomActions;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using AdvancedPaste.Settings;
|
||||
using AdvancedPaste.ViewModels;
|
||||
using ManagedCommon;
|
||||
@@ -83,6 +84,8 @@ namespace AdvancedPaste
|
||||
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
|
||||
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
|
||||
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
|
||||
services.AddSingleton<IPythonScriptService, PythonScriptService>();
|
||||
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
|
||||
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
|
||||
services.AddSingleton<OptionsViewModel>();
|
||||
}).Build();
|
||||
|
||||
@@ -755,63 +755,7 @@
|
||||
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
|
||||
</animations:Implicit.HideAnimations>
|
||||
</TextBlock>
|
||||
<Grid
|
||||
x:Name="ErrorMessageGrid"
|
||||
x:Uid="ErrorMessageGrid"
|
||||
Grid.Row="1"
|
||||
Margin="8,8,0,0"
|
||||
ColumnSpacing="8"
|
||||
Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip VerticalOffset="-105" Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Orientation="Vertical">
|
||||
<TextBox
|
||||
x:Name="AIErrorMessage"
|
||||
x:Uid="AIErrorMessage"
|
||||
FontSize="12"
|
||||
IsReadOnly="True"
|
||||
Text="{x:Bind ViewModel.PasteActionError.Details, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ToolTip>
|
||||
</ToolTipService.ToolTip>
|
||||
<FontIcon
|
||||
Margin="0,3,3,0"
|
||||
VerticalAlignment="Top"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<TextBlock
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<HyperlinkButton
|
||||
x:Uid="SettingsBtn"
|
||||
Grid.Column="1"
|
||||
Margin="0,-1,0,0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Command="{x:Bind ViewModel.OpenSettingsCommand}"
|
||||
FontSize="12" />
|
||||
<animations:Implicit.ShowAnimations>
|
||||
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
|
||||
</animations:Implicit.ShowAnimations>
|
||||
<animations:Implicit.HideAnimations>
|
||||
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
|
||||
</animations:Implicit.HideAnimations>
|
||||
</Grid>
|
||||
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="DefaultState" />
|
||||
@@ -832,7 +776,6 @@
|
||||
<VisualState.Setters>
|
||||
<Setter Target="InputTxtBox.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
<Setter Target="DisclaimerPresenter.Visibility" Value="Collapsed" />
|
||||
<Setter Target="ErrorMessageGrid.Visibility" Value="Visible" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
@@ -43,7 +43,8 @@ namespace AdvancedPaste
|
||||
double GetHeight(int maxCustomActionCount) =>
|
||||
baseHeight +
|
||||
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
|
||||
|
||||
MinHeight = GetHeight(1);
|
||||
Height = GetHeight(5);
|
||||
@@ -59,6 +60,7 @@ namespace AdvancedPaste
|
||||
UpdateHeight();
|
||||
}
|
||||
};
|
||||
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
|
||||
|
||||
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
|
||||
this.ExtendsContentIntoTitleBar = true;
|
||||
@@ -141,11 +143,7 @@ namespace AdvancedPaste
|
||||
internal void FinishLoading(bool success)
|
||||
{
|
||||
MainPage.CustomFormatTextBox.IsLoading(false);
|
||||
|
||||
if (success)
|
||||
{
|
||||
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
|
||||
}
|
||||
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
</Page.KeyboardAccelerators>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -250,10 +251,12 @@
|
||||
Grid.Row="1"
|
||||
Margin="20,0,20,0"
|
||||
x:FieldModifier="public"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
|
||||
IsEnabled="True"
|
||||
TabIndex="0">
|
||||
<controls:PromptBox.Footer>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Visibility="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<TextBlock
|
||||
x:Uid="AIMistakeNote"
|
||||
Margin="0,0,2,0"
|
||||
@@ -299,19 +302,70 @@
|
||||
</StackPanel>
|
||||
</controls:PromptBox.Footer>
|
||||
</controls:PromptBox>
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<Grid
|
||||
x:Name="ErrorMessageGrid"
|
||||
Grid.Row="2"
|
||||
Margin="20,4,20,0"
|
||||
ColumnSpacing="8"
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
Grid.Column="0"
|
||||
Margin="0,3,3,0"
|
||||
VerticalAlignment="Top"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
MaxLines="2"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="Wrap"
|
||||
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
|
||||
<HyperlinkButton
|
||||
x:Name="ShowErrorDetailsBtn"
|
||||
x:Uid="ShowErrorDetailsBtn"
|
||||
Grid.Column="2"
|
||||
Margin="4,-1,0,0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Click="ShowErrorDetailsBtn_Click"
|
||||
FontSize="12"
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<HyperlinkButton
|
||||
x:Uid="SettingsBtn"
|
||||
Grid.Column="3"
|
||||
Margin="0,-1,0,0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Command="{x:Bind ViewModel.OpenSettingsCommand}"
|
||||
FontSize="12" />
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<Grid RowSpacing="4">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ListView
|
||||
x:Name="PasteOptionsListView"
|
||||
Grid.Row="0"
|
||||
VerticalAlignment="Bottom"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="PasteFormat_ItemClick"
|
||||
ItemContainerTransitions="{x:Null}"
|
||||
@@ -341,6 +395,27 @@
|
||||
ScrollViewer.VerticalScrollMode="Disabled"
|
||||
SelectionMode="None"
|
||||
TabIndex="2" />
|
||||
|
||||
<Rectangle
|
||||
Grid.Row="3"
|
||||
Height="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
Visibility="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
|
||||
|
||||
<ListView
|
||||
x:Name="PythonScriptsListView"
|
||||
Grid.Row="4"
|
||||
VerticalAlignment="Top"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="PasteFormat_ItemClick"
|
||||
ItemContainerTransitions="{x:Null}"
|
||||
ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.PythonScriptPasteFormats, Mode=OneWay}"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollMode="Disabled"
|
||||
SelectionMode="None"
|
||||
TabIndex="3" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
@@ -208,5 +208,43 @@ namespace AdvancedPaste.Pages
|
||||
Clipboard.SetHistoryItemAsContent(item.Item);
|
||||
}
|
||||
}
|
||||
|
||||
private async void ShowErrorDetailsBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var details = ViewModel.PasteActionError?.Details;
|
||||
if (string.IsNullOrEmpty(details))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scrollViewer = new ScrollViewer
|
||||
{
|
||||
MaxHeight = 400,
|
||||
MinWidth = 400,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
};
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = details,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
|
||||
FontSize = 12,
|
||||
IsTextSelectionEnabled = true,
|
||||
};
|
||||
|
||||
scrollViewer.Content = textBlock;
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogTitle"),
|
||||
CloseButtonText = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogClose"),
|
||||
Content = scrollViewer,
|
||||
XamlRoot = this.XamlRoot,
|
||||
};
|
||||
|
||||
await dialog.ShowAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,26 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; }
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
|
||||
|
||||
public bool IsPythonScriptsEnabled { get; }
|
||||
|
||||
public string PythonScriptsFolder { get; }
|
||||
|
||||
public string PythonExecutablePath { get; }
|
||||
|
||||
public bool PythonUseWsl { get; }
|
||||
|
||||
public string PythonWslDistribution { get; }
|
||||
|
||||
public int PythonScriptTimeoutSeconds { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
|
||||
|
||||
public event EventHandler Changed;
|
||||
|
||||
Task SetActiveAIProviderAsync(string providerId);
|
||||
|
||||
void StoreTrustedScriptHash(string scriptPath, string hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -25,6 +26,10 @@ namespace AdvancedPaste.Settings
|
||||
private readonly Lock _loadingSettingsLock = new();
|
||||
private readonly List<PasteFormats> _additionalActions;
|
||||
private readonly List<AdvancedPasteCustomAction> _customActions;
|
||||
private readonly List<AdvancedPastePythonScriptAction> _pythonScriptActions;
|
||||
private FileSystemWatcher _scriptFolderWatcher;
|
||||
private CancellationTokenSource _scriptFolderDebounce;
|
||||
private string _watchedScriptsFolder = string.Empty;
|
||||
|
||||
private const string AdvancedPasteModuleName = "AdvancedPaste";
|
||||
private const int MaxNumberOfRetry = 5;
|
||||
@@ -48,6 +53,22 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
|
||||
|
||||
public string PythonScriptsFolder { get; private set; }
|
||||
|
||||
public bool IsPythonScriptsEnabled { get; private set; }
|
||||
|
||||
public string PythonExecutablePath { get; private set; }
|
||||
|
||||
public bool PythonUseWsl { get; private set; }
|
||||
|
||||
public string PythonWslDistribution { get; private set; } = string.Empty;
|
||||
|
||||
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
|
||||
|
||||
public UserSettings(IFileSystem fileSystem)
|
||||
{
|
||||
_settingsUtils = new SettingsUtils(fileSystem);
|
||||
@@ -57,8 +78,14 @@ namespace AdvancedPaste.Settings
|
||||
CloseAfterLosingFocus = false;
|
||||
EnableClipboardPreview = true;
|
||||
PasteAIConfiguration = new PasteAIConfiguration();
|
||||
PythonScriptsFolder = GetDefaultScriptsFolder();
|
||||
PythonExecutablePath = string.Empty;
|
||||
PythonUseWsl = false;
|
||||
PythonWslDistribution = string.Empty;
|
||||
PythonScriptTimeoutSeconds = 30;
|
||||
_additionalActions = [];
|
||||
_customActions = [];
|
||||
_pythonScriptActions = [];
|
||||
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
||||
|
||||
LoadSettingsFromJson();
|
||||
@@ -66,6 +93,14 @@ namespace AdvancedPaste.Settings
|
||||
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
|
||||
}
|
||||
|
||||
private static string GetDefaultScriptsFolder() =>
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"AdvancedPaste",
|
||||
"Scripts");
|
||||
|
||||
private void OnSettingsFileChanged()
|
||||
{
|
||||
lock (_loadingSettingsLock)
|
||||
@@ -131,6 +166,42 @@ namespace AdvancedPaste.Settings
|
||||
_customActions.Clear();
|
||||
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
|
||||
|
||||
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
|
||||
pythonScripts.MigrateLegacyIfNeeded();
|
||||
|
||||
var mode = pythonScripts.Mode ?? "disabled";
|
||||
IsPythonScriptsEnabled = !string.Equals(mode, "disabled", StringComparison.OrdinalIgnoreCase);
|
||||
PythonUseWsl = string.Equals(mode, "wsl", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (PythonUseWsl)
|
||||
{
|
||||
var wslSettings = pythonScripts.WslSettings ?? new PythonScriptWslSettings();
|
||||
PythonScriptsFolder = string.IsNullOrWhiteSpace(wslSettings.ScriptsFolder)
|
||||
? GetDefaultScriptsFolder()
|
||||
: wslSettings.ScriptsFolder;
|
||||
PythonExecutablePath = string.Empty;
|
||||
PythonWslDistribution = wslSettings.Distribution ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var winSettings = pythonScripts.WindowsSettings ?? new PythonScriptWindowsSettings();
|
||||
PythonScriptsFolder = string.IsNullOrWhiteSpace(winSettings.ScriptsFolder)
|
||||
? GetDefaultScriptsFolder()
|
||||
: winSettings.ScriptsFolder;
|
||||
PythonExecutablePath = winSettings.PythonExecutablePath ?? string.Empty;
|
||||
PythonWslDistribution = string.Empty;
|
||||
}
|
||||
|
||||
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
|
||||
TrustedScriptHashes = new Dictionary<string, string>(
|
||||
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_pythonScriptActions.Clear();
|
||||
_pythonScriptActions.AddRange(pythonScripts.Value.Where(a => a.IsShown));
|
||||
|
||||
UpdateScriptFolderWatcher(PythonScriptsFolder);
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -295,6 +366,102 @@ namespace AdvancedPaste.Settings
|
||||
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void UpdateScriptFolderWatcher(string folderPath)
|
||||
{
|
||||
if (string.Equals(_watchedScriptsFolder, folderPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptFolderWatcher?.Dispose();
|
||||
_scriptFolderWatcher = null;
|
||||
_watchedScriptsFolder = folderPath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(folderPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!System.IO.Directory.Exists(folderPath))
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(folderPath);
|
||||
}
|
||||
|
||||
_scriptFolderWatcher = new FileSystemWatcher(folderPath, "*.py")
|
||||
{
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = true,
|
||||
IncludeSubdirectories = false,
|
||||
};
|
||||
|
||||
_scriptFolderWatcher.Changed += OnScriptFolderChanged;
|
||||
_scriptFolderWatcher.Created += OnScriptFolderChanged;
|
||||
_scriptFolderWatcher.Deleted += OnScriptFolderChanged;
|
||||
_scriptFolderWatcher.Renamed += OnScriptFolderChanged;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to set up script folder watcher for {folderPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScriptFolderChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
lock (_loadingSettingsLock)
|
||||
{
|
||||
_scriptFolderDebounce?.Cancel();
|
||||
_scriptFolderDebounce = new CancellationTokenSource();
|
||||
|
||||
Task.Delay(TimeSpan.FromMilliseconds(500))
|
||||
.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
Task.Factory
|
||||
.StartNew(
|
||||
() => Changed?.Invoke(this, EventArgs.Empty),
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.None,
|
||||
_taskScheduler)
|
||||
.Wait();
|
||||
},
|
||||
_scriptFolderDebounce.Token,
|
||||
TaskContinuationOptions.NotOnCanceled,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
public void StoreTrustedScriptHash(string scriptPath, string hash)
|
||||
{
|
||||
lock (_loadingSettingsLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName);
|
||||
if (settings?.Properties?.PythonScripts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settings.Properties.PythonScripts.TrustedScriptHashes ??= new Dictionary<string, string>();
|
||||
settings.Properties.PythonScripts.TrustedScriptHashes[scriptPath] = hash;
|
||||
settings.Save(_settingsUtils);
|
||||
|
||||
// Update in-memory cache.
|
||||
var updated = new Dictionary<string, string>(TrustedScriptHashes, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[scriptPath] = hash,
|
||||
};
|
||||
TrustedScriptHashes = updated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to store trusted script hash", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetActiveAIProviderAsync(string providerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
@@ -387,6 +554,8 @@ namespace AdvancedPaste.Settings
|
||||
if (disposing)
|
||||
{
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_scriptFolderDebounce?.Dispose();
|
||||
_scriptFolderWatcher?.Dispose();
|
||||
_watcher?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ public sealed class PasteFormat
|
||||
IsSavedQuery = isSavedQuery,
|
||||
};
|
||||
|
||||
public static PasteFormat CreatePythonScriptFormat(string name, string scriptPath, ClipboardFormat availableFormats) =>
|
||||
new(PasteFormats.PythonScript, availableFormats, isAIServiceEnabled: false)
|
||||
{
|
||||
Name = name,
|
||||
Prompt = scriptPath,
|
||||
IsSavedQuery = true,
|
||||
};
|
||||
|
||||
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
|
||||
|
||||
public string IconGlyph => Metadata.IconGlyph;
|
||||
|
||||
@@ -122,4 +122,13 @@ public enum PasteFormats
|
||||
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
|
||||
RequiresPrompt = true)]
|
||||
CustomTextTransformation,
|
||||
|
||||
[PasteFormatMetadata(
|
||||
IsCoreAction = false,
|
||||
IconGlyph = "\uE943",
|
||||
RequiresAIService = false,
|
||||
CanPreview = true,
|
||||
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File,
|
||||
KernelFunctionDescription = "Runs a user-provided Python script on clipboard content.")]
|
||||
PythonScript,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
@@ -9,15 +9,23 @@ using System.Threading.Tasks;
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Models;
|
||||
using AdvancedPaste.Services.CustomActions;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
namespace AdvancedPaste.Services;
|
||||
|
||||
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
|
||||
public sealed class PasteFormatExecutor(
|
||||
IKernelService kernelService,
|
||||
ICustomActionTransformService customActionTransformService,
|
||||
IPythonScriptService pythonScriptService,
|
||||
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
|
||||
{
|
||||
private readonly IKernelService _kernelService = kernelService;
|
||||
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
|
||||
private readonly IPythonScriptService _pythonScriptService = pythonScriptService;
|
||||
private readonly IPythonScriptTrustService _pythonScriptTrustService = pythonScriptTrustService;
|
||||
|
||||
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
|
||||
{
|
||||
@@ -32,6 +40,15 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
|
||||
|
||||
var clipboardData = Clipboard.GetContent();
|
||||
|
||||
// PythonScript must NOT run inside Task.Run: the trust confirmation (ContentDialog)
|
||||
// requires the UI (XAML) thread and will throw if called from a thread-pool thread.
|
||||
// Python script execution is fully async (process.WaitForExitAsync), so it is safe
|
||||
// to await it directly without wrapping in Task.Run.
|
||||
if (format == PasteFormats.PythonScript)
|
||||
{
|
||||
return await ExecutePythonScriptAsync(pasteFormat.Prompt, clipboardData, cancellationToken, progress);
|
||||
}
|
||||
|
||||
// Run on thread-pool; although we use Async routines consistently, some actions still occasionally take a long time without yielding.
|
||||
return await Task.Run(async () =>
|
||||
pasteFormat.Format switch
|
||||
@@ -42,6 +59,92 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<DataPackage> ExecutePythonScriptAsync(
|
||||
string scriptPath,
|
||||
DataPackageView clipboardData,
|
||||
CancellationToken cancellationToken,
|
||||
IProgress<double> progress)
|
||||
{
|
||||
// Security: ensure the script is trusted before executing.
|
||||
if (!_pythonScriptTrustService.IsTrusted(scriptPath))
|
||||
{
|
||||
var hash = _pythonScriptTrustService.ComputeHash(scriptPath);
|
||||
var approved = await _pythonScriptTrustService.RequestTrustAsync(scriptPath, hash);
|
||||
|
||||
if (!approved)
|
||||
{
|
||||
throw new OperationCanceledException("User declined to trust the Python script.");
|
||||
}
|
||||
|
||||
_pythonScriptTrustService.StoreTrust(scriptPath, hash);
|
||||
}
|
||||
|
||||
var metadata = _pythonScriptService.ReadMetadata(scriptPath);
|
||||
|
||||
// Pre-flight: check for missing packages and offer to install them.
|
||||
var missingPackages = await _pythonScriptService.GetMissingRequirementsAsync(metadata, cancellationToken);
|
||||
if (missingPackages.Count > 0)
|
||||
{
|
||||
var approved = await _pythonScriptTrustService.RequestInstallAsync(metadata.Name, missingPackages);
|
||||
if (!approved)
|
||||
{
|
||||
throw new OperationCanceledException("User declined to install missing Python packages.");
|
||||
}
|
||||
|
||||
await _pythonScriptService.InstallRequirementsAsync(missingPackages, metadata.Platform, cancellationToken);
|
||||
}
|
||||
|
||||
var detectedFormat = await clipboardData.GetAvailableFormatsAsync();
|
||||
|
||||
// V2 interface: script defines advanced_paste_from_*_to_*() — use unified runner.
|
||||
if (metadata.IsV2)
|
||||
{
|
||||
return await _pythonScriptService.ExecuteScriptAsync(scriptPath, metadata.Platform, clipboardData, detectedFormat, cancellationToken, progress);
|
||||
}
|
||||
|
||||
// Legacy paths for backward compatibility.
|
||||
if (string.Equals(metadata.Platform, "linux", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await _pythonScriptService.ExecuteWslScriptAsync(scriptPath, clipboardData, detectedFormat, cancellationToken, progress);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Windows mode: script modifies the clipboard in-process; we return the updated clipboard.
|
||||
await _pythonScriptService.ExecuteWindowsScriptAsync(scriptPath, detectedFormat, cancellationToken, progress);
|
||||
|
||||
// Re-read clipboard after script has run.
|
||||
return Clipboard.GetContent() is { } updatedView
|
||||
? await DataPackageFromViewAsync(updatedView)
|
||||
: new DataPackage();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<DataPackage> DataPackageFromViewAsync(DataPackageView view)
|
||||
{
|
||||
var pkg = new DataPackage();
|
||||
|
||||
if (view.Contains(StandardDataFormats.Text))
|
||||
{
|
||||
pkg.SetText(await view.GetTextAsync());
|
||||
}
|
||||
else if (view.Contains(StandardDataFormats.Html))
|
||||
{
|
||||
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
|
||||
}
|
||||
else if (view.Contains(StandardDataFormats.StorageItems))
|
||||
{
|
||||
var items = await view.GetStorageItemsAsync();
|
||||
pkg.SetStorageItems(items);
|
||||
}
|
||||
else if (view.Contains(StandardDataFormats.Bitmap))
|
||||
{
|
||||
var bitmap = await view.GetBitmapAsync();
|
||||
pkg.SetBitmap(bitmap);
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
|
||||
{
|
||||
switch (source)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using AdvancedPaste.Models;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public interface IPythonScriptService
|
||||
{
|
||||
/// <summary>
|
||||
/// V2 unified execution: C# reads the clipboard, pipes data as JSON to the runner,
|
||||
/// and receives a DataPackage from JSON stdout. Works on both Windows and WSL
|
||||
/// depending on the specified platform.
|
||||
/// </summary>
|
||||
Task<DataPackage> ExecuteScriptAsync(string scriptPath, string platform, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||
|
||||
/// <summary>
|
||||
/// Legacy Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
|
||||
/// Kept for backward compatibility with scripts that use win32clipboard directly.
|
||||
/// </summary>
|
||||
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||
|
||||
/// <summary>
|
||||
/// Legacy WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
|
||||
/// Kept for backward compatibility with scripts that use json.load(sys.stdin) directly.
|
||||
/// </summary>
|
||||
Task<DataPackage> ExecuteWslScriptAsync(string scriptPath, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the @advancedpaste: header comments from a Python script file.
|
||||
/// </summary>
|
||||
PythonScriptMetadata ReadMetadata(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all .py scripts in <paramref name="folderPath"/> and returns their metadata.
|
||||
/// </summary>
|
||||
IReadOnlyList<PythonScriptMetadata> DiscoverScripts(string folderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the Python executable to use. Returns null if none is found.
|
||||
/// </summary>
|
||||
string TryFindPythonExecutable(string overridePath = null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if wsl.exe is available on this machine.
|
||||
/// </summary>
|
||||
bool IsWslAvailable();
|
||||
|
||||
/// <summary>
|
||||
/// Checks which of the declared requirements are not yet importable.
|
||||
/// Returns an empty list if all packages are installed.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PythonRequirement>> GetMissingRequirementsAsync(
|
||||
PythonScriptMetadata metadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Installs the given packages via pip / pip3.
|
||||
/// </summary>
|
||||
Task InstallRequirementsAsync(
|
||||
IReadOnlyList<PythonRequirement> requirements,
|
||||
string platform,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public interface IPythonScriptTrustService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the script at <paramref name="scriptPath"/> is currently trusted (hash matches stored value).
|
||||
/// </summary>
|
||||
bool IsTrusted(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a UI confirmation dialog for the script. Returns true if the user approved execution.
|
||||
/// </summary>
|
||||
Task<bool> RequestTrustAsync(string scriptPath, string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the trust entry for <paramref name="scriptPath"/> with the given <paramref name="hash"/>.
|
||||
/// </summary>
|
||||
void StoreTrust(string scriptPath, string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-256 hash of the script file and returns the hex string.
|
||||
/// </summary>
|
||||
string ComputeHash(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a confirmation dialog listing the missing packages and asking the user
|
||||
/// whether to install them. Returns true if the user approved installation.
|
||||
/// </summary>
|
||||
Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single Python package requirement declared via
|
||||
/// <c># @advancedpaste:requires import_name=pip_package</c>.
|
||||
/// </summary>
|
||||
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
|
||||
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
|
||||
public sealed record PythonRequirement(string ImportName, string PipPackage);
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
using AdvancedPaste.Models;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public sealed record PythonScriptMetadata(
|
||||
string ScriptPath,
|
||||
string Name,
|
||||
string Description,
|
||||
ClipboardFormat SupportedFormats,
|
||||
string Platform,
|
||||
string Version,
|
||||
bool IsEnabled,
|
||||
IReadOnlyList<PythonRequirement> Requirements,
|
||||
bool IsV2 = false,
|
||||
string OutputTypeHint = null);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Settings;
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public sealed class PythonScriptTrustService(IUserSettings userSettings) : IPythonScriptTrustService
|
||||
{
|
||||
private readonly IUserSettings _userSettings = userSettings;
|
||||
|
||||
public bool IsTrusted(string scriptPath)
|
||||
{
|
||||
var hashes = _userSettings.TrustedScriptHashes;
|
||||
if (hashes is null || !hashes.TryGetValue(scriptPath, out var storedHash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var currentHash = ComputeHash(scriptPath);
|
||||
return string.Equals(currentHash, storedHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to compute hash for {scriptPath}", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RequestTrustAsync(string scriptPath, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = resourceLoader.GetString("PythonScriptTrustTitle"),
|
||||
Content = string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
resourceLoader.GetString("PythonScriptTrustContent"),
|
||||
scriptPath),
|
||||
PrimaryButtonText = resourceLoader.GetString("PythonScriptTrustConfirm"),
|
||||
CloseButtonText = resourceLoader.GetString("PythonScriptTrustCancel"),
|
||||
};
|
||||
|
||||
// XamlRoot must be set for ContentDialog to function.
|
||||
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
|
||||
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
|
||||
{
|
||||
dialog.XamlRoot = xamlRoot;
|
||||
}
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
return result == ContentDialogResult.Primary;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to show trust dialog", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void StoreTrust(string scriptPath, string hash)
|
||||
{
|
||||
_userSettings.StoreTrustedScriptHash(scriptPath, hash);
|
||||
}
|
||||
|
||||
public string ComputeHash(string scriptPath)
|
||||
{
|
||||
using var stream = File.OpenRead(scriptPath);
|
||||
var hashBytes = SHA256.HashData(stream);
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
public async Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
var packageList = string.Join("\n", missingPackages.Select(r =>
|
||||
string.Equals(r.ImportName, r.PipPackage, StringComparison.Ordinal)
|
||||
? $" • {r.PipPackage}"
|
||||
: $" • {r.PipPackage} (import: {r.ImportName})"));
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = resourceLoader.GetString("PythonPackageInstallTitle"),
|
||||
Content = string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
resourceLoader.GetString("PythonPackageInstallContent"),
|
||||
scriptName,
|
||||
packageList),
|
||||
PrimaryButtonText = resourceLoader.GetString("PythonPackageInstallConfirm"),
|
||||
CloseButtonText = resourceLoader.GetString("PythonPackageInstallCancel"),
|
||||
};
|
||||
|
||||
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
|
||||
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
|
||||
{
|
||||
dialog.XamlRoot = xamlRoot;
|
||||
}
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
return result == ContentDialogResult.Primary;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to show package install dialog", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
# Copyright (c) Microsoft Corporation
|
||||
# The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
# See the LICENSE file in the project root for more information.
|
||||
|
||||
"""
|
||||
Advanced Paste – Script Runner (V3 Named Function Interface)
|
||||
|
||||
This runner is shipped with PowerToys and is NOT user-editable.
|
||||
It loads a user script, discovers the single advanced_paste_from_<input>_to_<output>
|
||||
function by name convention, calls it with the current clipboard data, and formats
|
||||
the return value into JSON on stdout.
|
||||
|
||||
Each script must define exactly one function matching the pattern:
|
||||
def advanced_paste_from_<input>_to_<output>(<param>)
|
||||
|
||||
Supported input types:
|
||||
- text, html, image, files
|
||||
|
||||
Required output types (declared via _to_ suffix):
|
||||
- text, html, image, file, files
|
||||
|
||||
Examples:
|
||||
- advanced_paste_from_text_to_text(text: str) → output is text
|
||||
- advanced_paste_from_text_to_image(text: str) → output is image
|
||||
- advanced_paste_from_image_to_text(image_path) → output is text
|
||||
- advanced_paste_from_files_to_text(file_paths) → output is text
|
||||
|
||||
Protocol:
|
||||
- Input: JSON on stdin (clipboard data from C#)
|
||||
- Output: JSON on stdout (result for C# to set on clipboard)
|
||||
- Errors: stderr (displayed to user on failure)
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _apply_output_hint(result, hint: str) -> dict:
|
||||
"""
|
||||
Force the output to the type specified by the _to_ suffix in the function name.
|
||||
Converts the return value to match the hinted type.
|
||||
"""
|
||||
if result is None:
|
||||
if hint == "text":
|
||||
return {"result_type": "text", "text": ""}
|
||||
elif hint == "html":
|
||||
return {"result_type": "html", "html": ""}
|
||||
elif hint == "image":
|
||||
return {"result_type": "image", "image_path": ""}
|
||||
elif hint == "audio":
|
||||
return {"result_type": "audio", "audio_path": ""}
|
||||
elif hint == "video":
|
||||
return {"result_type": "video", "video_path": ""}
|
||||
elif hint in ("file", "files"):
|
||||
return {"result_type": hint, "file_paths": []}
|
||||
|
||||
if hint == "text":
|
||||
return {"result_type": "text", "text": str(result) if not isinstance(result, str) else result}
|
||||
elif hint == "html":
|
||||
return {"result_type": "html", "html": str(result) if not isinstance(result, str) else result}
|
||||
elif hint == "image":
|
||||
path = str(result)
|
||||
return {"result_type": "image", "image_path": path}
|
||||
elif hint == "audio":
|
||||
path = str(result)
|
||||
return {"result_type": "audio", "audio_path": path}
|
||||
elif hint == "video":
|
||||
path = str(result)
|
||||
return {"result_type": "video", "video_path": path}
|
||||
elif hint == "file":
|
||||
if isinstance(result, (list, tuple)):
|
||||
paths = [str(p) for p in result]
|
||||
else:
|
||||
paths = [str(result)]
|
||||
return {"result_type": "file", "file_paths": paths}
|
||||
elif hint == "files":
|
||||
if isinstance(result, (list, tuple)):
|
||||
paths = [str(p) for p in result]
|
||||
else:
|
||||
paths = [str(result)]
|
||||
return {"result_type": "files", "file_paths": paths}
|
||||
|
||||
# Fallback (shouldn't happen with valid hints)
|
||||
return {"result_type": "text", "text": str(result)}
|
||||
|
||||
# Pattern matching advanced_paste_from_<input>_to_<output> function names.
|
||||
_AP_FUNCTION_PATTERN = re.compile(
|
||||
r"^advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)$"
|
||||
)
|
||||
|
||||
|
||||
def _load_user_module(script_path: str):
|
||||
"""Dynamically load the user script as a Python module."""
|
||||
spec = importlib.util.spec_from_file_location("_user_script", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot load script: {script_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Add the script's directory to sys.path so relative imports/helpers work.
|
||||
script_dir = os.path.dirname(os.path.abspath(script_path))
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _discover_ap_function(module) -> tuple:
|
||||
"""
|
||||
Discover the single advanced_paste_from_<input>_to_<output> function in the module.
|
||||
Returns a tuple (input_type, output_type, function) or None if not found.
|
||||
Exits with error if multiple functions are defined.
|
||||
"""
|
||||
matches = []
|
||||
for name in dir(module):
|
||||
match = _AP_FUNCTION_PATTERN.match(name)
|
||||
if match:
|
||||
fn = getattr(module, name)
|
||||
if callable(fn):
|
||||
input_type = match.group(1)
|
||||
output_type = match.group(2)
|
||||
matches.append((input_type, output_type, fn))
|
||||
|
||||
if len(matches) == 0:
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
names = [f"advanced_paste_from_{m[0]}_to_{m[1]}" for m in matches]
|
||||
print(
|
||||
f"Error: script defines multiple advanced_paste_from_*_to_* functions "
|
||||
f"({', '.join(names)}). Only one is allowed per script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _format_output(result, output_type: str) -> dict:
|
||||
"""
|
||||
Format the return value according to the declared output type from the function name.
|
||||
The output_type comes from the _to_ suffix and is always provided.
|
||||
"""
|
||||
if result is None:
|
||||
if output_type in ("file", "files"):
|
||||
return {"result_type": output_type, "file_paths": []}
|
||||
elif output_type == "image":
|
||||
return {"result_type": "image", "image_path": ""}
|
||||
elif output_type == "audio":
|
||||
return {"result_type": "audio", "audio_path": ""}
|
||||
elif output_type == "video":
|
||||
return {"result_type": "video", "video_path": ""}
|
||||
elif output_type == "html":
|
||||
return {"result_type": "html", "html": ""}
|
||||
return {"result_type": "text", "text": ""}
|
||||
|
||||
return _apply_output_hint(result, output_type)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: _runner.py <script_path>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
script_path = sys.argv[1]
|
||||
|
||||
if not os.path.isfile(script_path):
|
||||
print(f"Error: script not found: {script_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Read input payload from stdin.
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load the user script.
|
||||
module = _load_user_module(script_path)
|
||||
|
||||
# Discover the single advanced_paste_from_* function.
|
||||
ap_result = _discover_ap_function(module)
|
||||
|
||||
if ap_result is None:
|
||||
print(
|
||||
f"Error: script '{os.path.basename(script_path)}' does not define an "
|
||||
f"advanced_paste_from_<input>_to_<output> function.\n"
|
||||
f"Example: def advanced_paste_from_text_to_text(text): return text.upper()",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
input_type, output_type, fn = ap_result
|
||||
|
||||
# Determine the input data key for this function's input type.
|
||||
input_map = {
|
||||
"text": "text",
|
||||
"html": "html",
|
||||
"image": "image_path",
|
||||
"audio": "audio_path",
|
||||
"video": "video_path",
|
||||
"files": "file_paths",
|
||||
}
|
||||
|
||||
key = input_map.get(input_type, input_type)
|
||||
input_value = data.get(key)
|
||||
|
||||
# Expose work_dir as environment variable so scripts can write output files
|
||||
# to a location accessible from both WSL and Windows (under /mnt/c/...).
|
||||
work_dir = data.get("work_dir", "")
|
||||
if work_dir:
|
||||
os.environ["ADVANCED_PASTE_WORK_DIR"] = work_dir
|
||||
|
||||
# Check if the clipboard has matching data for this script's input type.
|
||||
formats = data.get("format", ["text"])
|
||||
if isinstance(formats, str):
|
||||
formats = [formats]
|
||||
|
||||
if input_type not in formats:
|
||||
print(
|
||||
f"Error: script expects '{input_type}' input but clipboard has [{', '.join(formats)}].",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if not input_value:
|
||||
print(
|
||||
f"Error: no data available for format '{input_type}' "
|
||||
f"(expected '{key}' in input payload).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Call the function.
|
||||
result = fn(input_value)
|
||||
output = _format_output(result, output_type)
|
||||
|
||||
# Output JSON result.
|
||||
json.dump(output, sys.stdout, ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -212,10 +212,10 @@
|
||||
<value>Delete</value>
|
||||
</data>
|
||||
<data name="CustomFormatTextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Describe what format you want..</value>
|
||||
<value>Search or describe what format you want..</value>
|
||||
</data>
|
||||
<data name="InputTxtBoxTooltip.Text" xml:space="preserve">
|
||||
<value>Describe what format you want..</value>
|
||||
<value>Search or describe what format you want..</value>
|
||||
</data>
|
||||
<data name="LearnMoreLink.Text" xml:space="preserve">
|
||||
<value>Privacy</value>
|
||||
@@ -372,4 +372,73 @@
|
||||
<value>Unable to load Foundry Local model: {0}</value>
|
||||
<comment>{0} is the model identifier. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonNotFound" xml:space="preserve">
|
||||
<value>Python was not found. Please install Python or configure the path in Settings.</value>
|
||||
</data>
|
||||
<data name="WslNotAvailable" xml:space="preserve">
|
||||
<value>WSL is not installed or not available. Cannot run Linux scripts.</value>
|
||||
</data>
|
||||
<data name="PythonScriptFailed" xml:space="preserve">
|
||||
<value>The Python script failed to execute.</value>
|
||||
</data>
|
||||
<data name="PythonScriptTimeout" xml:space="preserve">
|
||||
<value>Script execution timed out ({0} seconds).</value>
|
||||
<comment>{0} is the configured timeout in seconds. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonScriptNotFound" xml:space="preserve">
|
||||
<value>Script file not found: {0}</value>
|
||||
<comment>{0} is the script file path. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonScriptInvalidJson" xml:space="preserve">
|
||||
<value>The script output is not valid JSON.</value>
|
||||
</data>
|
||||
<data name="PythonScriptTrustTitle" xml:space="preserve">
|
||||
<value>Run Python Script?</value>
|
||||
</data>
|
||||
<data name="PythonScriptTrustContent" xml:space="preserve">
|
||||
<value>This script has not been verified. Running untrusted scripts can be a security risk. Do you want to run the following script?
|
||||
|
||||
{0}</value>
|
||||
<comment>{0} is the script file path. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonScriptTrustConfirm" xml:space="preserve">
|
||||
<value>Run</value>
|
||||
</data>
|
||||
<data name="PythonScriptTrustCancel" xml:space="preserve">
|
||||
<value>Cancel</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallTitle" xml:space="preserve">
|
||||
<value>Install Missing Packages?</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallContent" xml:space="preserve">
|
||||
<value>The script "{0}" requires the following Python packages that are not installed:
|
||||
|
||||
{1}
|
||||
|
||||
Install them now?</value>
|
||||
<comment>{0} = script display name, {1} = bullet list of package names. Do not translate package names.</comment>
|
||||
</data>
|
||||
<data name="PythonPackageInstallConfirm" xml:space="preserve">
|
||||
<value>Install</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallCancel" xml:space="preserve">
|
||||
<value>Skip</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallFailed" xml:space="preserve">
|
||||
<value>Failed to install package(s) "{0}": {1}</value>
|
||||
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
|
||||
</data>
|
||||
<data name="PythonPackageInstallTimeout" xml:space="preserve">
|
||||
<value>Package installation for "{0}" timed out ({1} seconds).</value>
|
||||
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
|
||||
</data>
|
||||
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
|
||||
<value>Show details</value>
|
||||
</data>
|
||||
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
|
||||
<value>Error Details</value>
|
||||
</data>
|
||||
<data name="ErrorDetailsDialogClose" xml:space="preserve">
|
||||
<value>Close</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -16,6 +16,7 @@ using System.Threading.Tasks;
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Models;
|
||||
using AdvancedPaste.Services;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using AdvancedPaste.Settings;
|
||||
using Common.UI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -41,6 +42,7 @@ namespace AdvancedPaste.ViewModels
|
||||
private readonly IUserSettings _userSettings;
|
||||
private readonly IPasteFormatExecutor _pasteFormatExecutor;
|
||||
private readonly IAICredentialsProvider _credentialsProvider;
|
||||
private readonly IPythonScriptService _pythonScriptService;
|
||||
|
||||
private CancellationTokenSource _pasteActionCancellationTokenSource;
|
||||
|
||||
@@ -100,6 +102,8 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
|
||||
|
||||
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
|
||||
|
||||
public bool IsCustomAIServiceEnabled
|
||||
{
|
||||
get
|
||||
@@ -258,11 +262,12 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public event EventHandler PreviewRequested;
|
||||
|
||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
|
||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
|
||||
{
|
||||
_credentialsProvider = credentialsProvider;
|
||||
_userSettings = userSettings;
|
||||
_pasteFormatExecutor = pasteFormatExecutor;
|
||||
_pythonScriptService = pythonScriptService;
|
||||
|
||||
GeneratedResponses = [];
|
||||
GeneratedResponses.CollectionChanged += (s, e) =>
|
||||
@@ -413,12 +418,51 @@ namespace AdvancedPaste.ViewModels
|
||||
}
|
||||
|
||||
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
|
||||
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
|
||||
.Where(format => format != PasteFormats.PythonScript &&
|
||||
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
|
||||
.Select(CreateStandardPasteFormat));
|
||||
|
||||
UpdateFormats(
|
||||
CustomActionPasteFormats,
|
||||
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
|
||||
|
||||
UpdateFormats(
|
||||
PythonScriptPasteFormats,
|
||||
BuildPythonScriptFormats());
|
||||
}
|
||||
|
||||
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
|
||||
{
|
||||
if (!_userSettings.IsPythonScriptsEnabled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var folder = _userSettings.PythonScriptsFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var discoveredScripts = _pythonScriptService.DiscoverScripts(folder);
|
||||
var scriptActions = _userSettings.PythonScriptActions;
|
||||
|
||||
// Use metadata from discovered scripts, but apply IsShown from saved settings.
|
||||
var hiddenPaths = new System.Collections.Generic.HashSet<string>(
|
||||
scriptActions.Where(a => !a.IsShown).Select(a => a.ScriptPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var meta in discoveredScripts)
|
||||
{
|
||||
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by intersection: only pass clipboard formats the script supports.
|
||||
var filteredFormats = AvailableClipboardFormats & meta.SupportedFormats;
|
||||
yield return PasteFormat.CreatePythonScriptFormat(meta.Name, meta.ScriptPath, filteredFormats);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -692,7 +736,10 @@ namespace AdvancedPaste.ViewModels
|
||||
_pasteActionCancellationTokenSource = new();
|
||||
TransformProgress = double.NaN;
|
||||
PasteActionError = PasteActionError.None;
|
||||
Query = pasteFormat.Query;
|
||||
|
||||
// For Python scripts the Prompt field holds the file path, not a user-visible query.
|
||||
// Setting Query to the path would show it in the AI prompt box, which is misleading.
|
||||
Query = pasteFormat.Format == PasteFormats.PythonScript ? string.Empty : pasteFormat.Query;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -732,7 +779,7 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
internal async Task ExecutePasteFormatAsync(VirtualKey key)
|
||||
{
|
||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
|
||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
|
||||
.Where(pasteFormat => pasteFormat.IsEnabled)
|
||||
.ElementAtOrDefault(key - VirtualKey.Number1);
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; set; }
|
||||
|
||||
[JsonPropertyName("python-scripts")]
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public AdvancedPastePythonScriptSettings PythonScripts { get; set; } = new();
|
||||
|
||||
public override string ToString()
|
||||
=> JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPasteAction, ICloneable
|
||||
{
|
||||
private string _scriptPath = string.Empty;
|
||||
private string _name = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private bool _isShown = true;
|
||||
private bool _isEnabled = true;
|
||||
private string _platform = "windows";
|
||||
private string _formats = "any";
|
||||
private string _requires = string.Empty;
|
||||
private bool _requiresAutoDetect = true;
|
||||
private HotkeySettings _shortcut = new();
|
||||
private string _inputType = string.Empty;
|
||||
private string _outputType = string.Empty;
|
||||
|
||||
[JsonPropertyName("scriptPath")]
|
||||
public string ScriptPath
|
||||
{
|
||||
get => _scriptPath;
|
||||
set => Set(ref _scriptPath, value ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
if (Set(ref _name, value ?? string.Empty))
|
||||
{
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Name if non-empty, otherwise falls back to the script filename without extension.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string DisplayName =>
|
||||
!string.IsNullOrWhiteSpace(_name)
|
||||
? _name
|
||||
: System.IO.Path.GetFileNameWithoutExtension(_scriptPath);
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set => Set(ref _description, value ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonPropertyName("isShown")]
|
||||
public bool IsShown
|
||||
{
|
||||
get => _isShown;
|
||||
set => Set(ref _isShown, value);
|
||||
}
|
||||
|
||||
[JsonPropertyName("isEnabled")]
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => Set(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform
|
||||
{
|
||||
get => _platform;
|
||||
set => Set(ref _platform, value ?? "windows");
|
||||
}
|
||||
|
||||
[JsonPropertyName("formats")]
|
||||
public string Formats
|
||||
{
|
||||
get => _formats;
|
||||
set => Set(ref _formats, value ?? "any");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Space-separated requires entries, e.g. "cv2=opencv-python-headless numpy requests".
|
||||
/// Only written to header when RequiresAutoDetect is false (manual mode).
|
||||
/// </summary>
|
||||
[JsonPropertyName("requires")]
|
||||
public string Requires
|
||||
{
|
||||
get => _requires;
|
||||
set => Set(ref _requires, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, dependencies are auto-detected from import statements.
|
||||
/// When false, the manual <see cref="Requires"/> value is used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresAutoDetect")]
|
||||
public bool RequiresAutoDetect
|
||||
{
|
||||
get => _requiresAutoDetect;
|
||||
set => Set(ref _requiresAutoDetect, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverted view of RequiresAutoDetect for UI binding.
|
||||
/// Uses a separate field to avoid circular property change notifications.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsRequiresManual
|
||||
{
|
||||
get => !_requiresAutoDetect;
|
||||
set
|
||||
{
|
||||
var newAuto = !value;
|
||||
if (_requiresAutoDetect != newAuto)
|
||||
{
|
||||
_requiresAutoDetect = newAuto;
|
||||
OnPropertyChanged(nameof(RequiresAutoDetect));
|
||||
OnPropertyChanged(nameof(IsRequiresManual));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonPropertyName("shortcut")]
|
||||
public HotkeySettings Shortcut
|
||||
{
|
||||
get => _shortcut;
|
||||
set
|
||||
{
|
||||
if (_shortcut != value)
|
||||
{
|
||||
_shortcut = value ?? new();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The input type declared in the function name (e.g. "text", "image", "audio").
|
||||
/// Read-only display property populated during script discovery.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string InputType
|
||||
{
|
||||
get => _inputType;
|
||||
set => Set(ref _inputType, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The output type declared in the function name (e.g. "text", "image", "file").
|
||||
/// Read-only display property populated during script discovery.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string OutputType
|
||||
{
|
||||
get => _outputType;
|
||||
set => Set(ref _outputType, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable conversion summary, e.g. "text → image".
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ConversionSummary =>
|
||||
!string.IsNullOrEmpty(_inputType) && !string.IsNullOrEmpty(_outputType)
|
||||
? $"{_inputType} → {_outputType}"
|
||||
: string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public IEnumerable<IAdvancedPasteAction> SubActions => [];
|
||||
|
||||
// Convenience properties for format checkboxes
|
||||
[JsonIgnore]
|
||||
public bool SupportsText
|
||||
{
|
||||
get => FormatContains("text");
|
||||
set => ToggleFormat("text", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsHtml
|
||||
{
|
||||
get => FormatContains("html");
|
||||
set => ToggleFormat("html", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsImage
|
||||
{
|
||||
get => FormatContains("image");
|
||||
set => ToggleFormat("image", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsAudio
|
||||
{
|
||||
get => FormatContains("audio");
|
||||
set => ToggleFormat("audio", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsVideo
|
||||
{
|
||||
get => FormatContains("video");
|
||||
set => ToggleFormat("video", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsFiles
|
||||
{
|
||||
get => FormatContains("files") || FormatContains("file");
|
||||
set => ToggleFormat("files", value);
|
||||
}
|
||||
|
||||
private bool FormatContains(string format)
|
||||
{
|
||||
if (string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Formats.Split(',', StringSplitOptions.TrimEntries)
|
||||
.Any(f => string.Equals(f, format, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool _isTogglingFormat;
|
||||
|
||||
private void ToggleFormat(string format, bool include)
|
||||
{
|
||||
if (_isTogglingFormat)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTogglingFormat = true;
|
||||
try
|
||||
{
|
||||
var currentFormats = string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase)
|
||||
? new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(Formats.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Normalize file/files
|
||||
currentFormats.Remove("file");
|
||||
|
||||
if (include)
|
||||
{
|
||||
currentFormats.Add(format);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentFormats.Remove(format);
|
||||
}
|
||||
|
||||
var allFormats = new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase);
|
||||
Formats = currentFormats.SetEquals(allFormats) ? "any" : string.Join(", ", currentFormats);
|
||||
|
||||
OnPropertyChanged(nameof(SupportsText));
|
||||
OnPropertyChanged(nameof(SupportsHtml));
|
||||
OnPropertyChanged(nameof(SupportsImage));
|
||||
OnPropertyChanged(nameof(SupportsAudio));
|
||||
OnPropertyChanged(nameof(SupportsVideo));
|
||||
OnPropertyChanged(nameof(SupportsFiles));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isTogglingFormat = false;
|
||||
}
|
||||
}
|
||||
|
||||
public object Clone()
|
||||
{
|
||||
return new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = ScriptPath,
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
IsShown = IsShown,
|
||||
IsEnabled = IsEnabled,
|
||||
Platform = Platform,
|
||||
Formats = Formats,
|
||||
Requires = Requires,
|
||||
RequiresAutoDetect = RequiresAutoDetect,
|
||||
InputType = InputType,
|
||||
OutputType = OutputType,
|
||||
Shortcut = Shortcut != null ? new HotkeySettings(Shortcut.Win, Shortcut.Ctrl, Shortcut.Alt, Shortcut.Shift, Shortcut.Code) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Execution mode: "disabled", "windows", or "wsl".
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public string Mode { get; set; } = "disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Settings specific to Windows-native Python execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("windowsSettings")]
|
||||
public PythonScriptWindowsSettings WindowsSettings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Settings specific to WSL Python execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("wslSettings")]
|
||||
public PythonScriptWslSettings WslSettings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public List<AdvancedPastePythonScriptAction> Value { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("trustedScriptHashes")]
|
||||
public Dictionary<string, string> TrustedScriptHashes { get; set; } = [];
|
||||
|
||||
// Legacy properties — read for migration, never written back
|
||||
[JsonPropertyName("isEnabled")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("useWsl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? UseWsl { get; set; }
|
||||
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string ScriptsFolder { get; set; }
|
||||
|
||||
[JsonPropertyName("pythonExecutablePath")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string PythonExecutablePath { get; set; }
|
||||
|
||||
[JsonPropertyName("wslDistribution")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string WslDistribution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Migrates legacy settings (isEnabled/useWsl) to new mode format on first load.
|
||||
/// </summary>
|
||||
public void MigrateLegacyIfNeeded()
|
||||
{
|
||||
// Only migrate if Mode hasn't been set by the new UI yet
|
||||
// (i.e., still at default "disabled") AND legacy fields are present.
|
||||
if (IsEnabled.HasValue && string.Equals(Mode, "disabled", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Migrate from old format
|
||||
if (!IsEnabled.Value)
|
||||
{
|
||||
Mode = "disabled";
|
||||
}
|
||||
else if (UseWsl == true)
|
||||
{
|
||||
Mode = "wsl";
|
||||
}
|
||||
else
|
||||
{
|
||||
Mode = "windows";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ScriptsFolder))
|
||||
{
|
||||
WindowsSettings.ScriptsFolder = ScriptsFolder;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(PythonExecutablePath))
|
||||
{
|
||||
WindowsSettings.PythonExecutablePath = PythonExecutablePath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(WslDistribution))
|
||||
{
|
||||
WslSettings.Distribution = WslDistribution;
|
||||
}
|
||||
}
|
||||
|
||||
// Always clear legacy fields so they don't persist
|
||||
IsEnabled = null;
|
||||
UseWsl = null;
|
||||
ScriptsFolder = null;
|
||||
PythonExecutablePath = null;
|
||||
WslDistribution = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class PythonScriptWindowsSettings
|
||||
{
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pythonExecutablePath")]
|
||||
public string PythonExecutablePath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class PythonScriptWslSettings
|
||||
{
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("distribution")]
|
||||
public string Distribution { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -138,6 +138,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonSerializable(typeof(AdvancedPasteAdditionalAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePasteAsFileAction))]
|
||||
[JsonSerializable(typeof(AdvancedPasteTranscodeAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePythonScriptAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePythonScriptSettings))]
|
||||
[JsonSerializable(typeof(System.Collections.Generic.List<AdvancedPastePythonScriptAction>))]
|
||||
[JsonSerializable(typeof(System.Collections.Generic.Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(ImageResizerSizes))]
|
||||
[JsonSerializable(typeof(ImageResizerCustomSizeProperty))]
|
||||
[JsonSerializable(typeof(KeyboardKeysProperty))]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.ViewModels;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ViewModelTests
|
||||
{
|
||||
[TestClass]
|
||||
public class AdvancedPaste
|
||||
{
|
||||
[TestMethod]
|
||||
public void CreateActionFromScript_ExtractsConversionTypes()
|
||||
{
|
||||
var scriptPath = Path.Combine(Path.GetTempPath(), $"AdvancedPaste-{Guid.NewGuid():N}.py");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllLines(
|
||||
scriptPath,
|
||||
[
|
||||
"# @advancedpaste:name transcribe audio",
|
||||
"# @advancedpaste:desc Transcribes audio to text",
|
||||
string.Empty,
|
||||
"def advanced_paste_from_audio_to_text(audio_path):",
|
||||
" return 'transcribed'",
|
||||
]);
|
||||
|
||||
var createActionFromScript = typeof(AdvancedPasteViewModel)
|
||||
.GetMethod("CreateActionFromScript", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.IsNotNull(createActionFromScript);
|
||||
|
||||
var action = (AdvancedPastePythonScriptAction)createActionFromScript.Invoke(
|
||||
null, [scriptPath, new System.Collections.Generic.List<AdvancedPastePythonScriptAction>()]);
|
||||
|
||||
Assert.AreEqual("transcribe audio", action.Name);
|
||||
Assert.AreEqual("Transcribes audio to text", action.Description);
|
||||
Assert.AreEqual("audio", action.InputType);
|
||||
Assert.AreEqual("text", action.OutputType);
|
||||
Assert.AreEqual("audio → text", action.ConversionSummary);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(scriptPath))
|
||||
{
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,124 @@
|
||||
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
|
||||
Severity="Warning" />
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_RuntimeCard">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.PythonScriptsModeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem Content="Off" />
|
||||
<ComboBoxItem Content="Windows Python" />
|
||||
<ComboBoxItem Content="WSL Python" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_ScriptsFolder_SettingsCard"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
x:Name="ScriptsFolderTextBox"
|
||||
x:Uid="AdvancedPaste_ScriptsFolder_TextBox"
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.ScriptsFolder, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowseScriptsFolder_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="OpenScriptsFolder_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
ToolTipService.ToolTip="Open folder" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Windows-mode settings -->
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard"
|
||||
Visibility="{x:Bind ViewModel.IsWindowsMode, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
x:Name="PythonExecutablePathTextBox"
|
||||
x:Uid="AdvancedPaste_PythonExecutablePath_TextBox"
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.PythonExecutablePath, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowsePythonExecutablePath_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- WSL-mode settings -->
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_WslDistribution_SettingsCard"
|
||||
Visibility="{x:Bind ViewModel.IsWslMode, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ComboBox
|
||||
MinWidth="200"
|
||||
ItemsSource="{x:Bind ViewModel.WslDistroDisplayNames, Mode=OneWay}"
|
||||
SelectedIndex="{x:Bind ViewModel.WslDistributionIndex, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Discovered Python Scripts (read-only) -->
|
||||
<tkcontrols:SettingsExpander
|
||||
x:Name="PythonScriptListExpander"
|
||||
x:Uid="AdvancedPaste_PythonScriptList"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}"
|
||||
IsExpanded="True"
|
||||
ItemsSource="{x:Bind ViewModel.PythonScriptActions, Mode=OneWay}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="RefreshScriptsButton"
|
||||
Click="RefreshPythonScripts_Click"
|
||||
Style="{ThemeResource AccentButtonStyle}"
|
||||
Content="Load scripts" />
|
||||
</StackPanel>
|
||||
<tkcontrols:SettingsExpander.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:AdvancedPastePythonScriptAction">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Header="{x:Bind DisplayName, Mode=OneWay}"
|
||||
Description="{x:Bind Description, Mode=OneWay}"
|
||||
IsActionIconVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<TextBlock
|
||||
Text="{x:Bind ConversionSummary, Mode=OneWay}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
<Button
|
||||
Content=""
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
Click="OpenPythonScript_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="{x:Bind}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock Text="Open script in editor" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
</DataTemplate>
|
||||
</tkcontrols:SettingsExpander.ItemTemplate>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
</StackPanel>
|
||||
</controls:SettingsPageControl.ModuleContent>
|
||||
<controls:SettingsPageControl.PrimaryLinks>
|
||||
@@ -428,7 +546,6 @@
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
|
||||
<!-- Paste AI provider dialog -->
|
||||
<ContentDialog
|
||||
x:Name="PasteAIProviderConfigurationDialog"
|
||||
x:Uid="AdvancedPaste_EndpointDialog"
|
||||
|
||||
@@ -65,6 +65,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
ViewModel.OnPageLoaded();
|
||||
UpdatePasteAIUIVisibility();
|
||||
await UpdateFoundryLocalUIAsync();
|
||||
|
||||
if (ViewModel.IsWslMode)
|
||||
{
|
||||
ViewModel.RefreshWslDistros();
|
||||
}
|
||||
};
|
||||
|
||||
Unloaded += (_, _) =>
|
||||
@@ -264,6 +269,75 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowsePythonExecutablePath_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string selectedFile = PickFileDialog(
|
||||
"Python Executable\0python.exe;python3.exe\0All Executables\0*.exe\0",
|
||||
"Select Python Executable");
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedFile))
|
||||
{
|
||||
PythonExecutablePathTextBox.Text = selectedFile;
|
||||
if (ViewModel is not null)
|
||||
{
|
||||
ViewModel.PythonExecutablePath = selectedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseScriptsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
|
||||
string selectedFolder = ShellGetFolder.GetFolderDialogWithFlags(
|
||||
windowHandle,
|
||||
ShellGetFolder.FolderDialogFlags._BIF_NEWDIALOGSTYLE);
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedFolder))
|
||||
{
|
||||
ScriptsFolderTextBox.Text = selectedFolder;
|
||||
if (ViewModel is not null)
|
||||
{
|
||||
ViewModel.ScriptsFolder = selectedFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenScriptsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var folder = ViewModel?.ScriptsFolder;
|
||||
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = folder,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPythonScripts_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.RefreshPythonScripts();
|
||||
RefreshScriptsButton.Content = "Refresh scripts";
|
||||
}
|
||||
|
||||
private void OpenPythonScript_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: AdvancedPastePythonScriptAction action })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(action.ScriptPath))
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo(action.ScriptPath)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
};
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
|
||||
{
|
||||
// Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions
|
||||
|
||||
@@ -598,6 +598,57 @@ opera.exe</value>
|
||||
<data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Custom actions</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Python scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_RuntimeCard.Header" xml:space="preserve">
|
||||
<value>Python runtime</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_RuntimeCard.Description" xml:space="preserve">
|
||||
<value>Choose how to run custom Python scripts from the Advanced Paste menu</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Scripts folder</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Folder to scan for Python scripts (.py files). Leave blank to use the default location.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_TextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Default (%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts)</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Python interpreter</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Path to the Python executable used to run scripts. Leave blank to detect automatically (supports Anaconda, Miniconda, system Python).</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_TextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Auto-detect (e.g. C:\Users\<user>\anaconda3\python.exe)</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_UseWsl_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Use WSL</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_UseWsl_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Run Python scripts in Windows Subsystem for Linux instead of native Windows Python.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_WslDistribution_SettingsCard.Header" xml:space="preserve">
|
||||
<value>WSL distribution</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_WslDistribution_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Select which WSL distribution to use for running Python scripts.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScriptList.Header" xml:space="preserve">
|
||||
<value>Discovered scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScriptList.Description" xml:space="preserve">
|
||||
<value>Python scripts found in the scripts folder. Changes to settings are written back to the script file headers.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScript_RefreshScripts.Content" xml:space="preserve">
|
||||
<value>Refresh scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScript_ApplyChanges.Content" xml:space="preserve">
|
||||
<value>Apply changes</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve">
|
||||
<value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value>
|
||||
</data>
|
||||
|
||||
@@ -293,6 +293,403 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
|
||||
|
||||
public bool IsPythonScriptsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
return scripts != null && !string.Equals(scripts.Mode, "disabled", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ComboBox index: 0 = Disabled, 1 = Windows, 2 = WSL.
|
||||
/// </summary>
|
||||
public int PythonScriptsModeIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
var mode = scripts?.Mode ?? "disabled";
|
||||
return mode switch
|
||||
{
|
||||
"windows" => 1,
|
||||
"wsl" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
var newMode = value switch
|
||||
{
|
||||
1 => "windows",
|
||||
2 => "wsl",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
if (!string.Equals(scripts.Mode, newMode, StringComparison.Ordinal))
|
||||
{
|
||||
scripts.Mode = newMode;
|
||||
OnPropertyChanged(nameof(PythonScriptsModeIndex));
|
||||
OnPropertyChanged(nameof(IsPythonScriptsEnabled));
|
||||
OnPropertyChanged(nameof(IsWindowsMode));
|
||||
OnPropertyChanged(nameof(IsWslMode));
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||
OnPropertyChanged(nameof(WslDistribution));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (newMode == "wsl")
|
||||
{
|
||||
RefreshWslDistros();
|
||||
}
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsWindowsMode => PythonScriptsModeIndex == 1;
|
||||
|
||||
public bool IsWslMode => PythonScriptsModeIndex == 2;
|
||||
|
||||
public bool ScriptsDiscovered => _scriptsDiscovered;
|
||||
|
||||
public string PythonExecutablePath
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.WindowsSettings?.PythonExecutablePath ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
scripts.WindowsSettings ??= new PythonScriptWindowsSettings();
|
||||
if (!string.Equals(scripts.WindowsSettings.PythonExecutablePath, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WindowsSettings.PythonExecutablePath = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string WslDistribution
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.WslSettings?.Distribution ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
scripts.WslSettings ??= new PythonScriptWslSettings();
|
||||
if (!string.Equals(scripts.WslSettings.Distribution, value, StringComparison.Ordinal))
|
||||
{
|
||||
scripts.WslSettings.Distribution = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(WslDistribution));
|
||||
OnPropertyChanged(nameof(WslDistributionIndex));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> _availableWslDistros = [string.Empty];
|
||||
|
||||
/// <summary>
|
||||
/// Available WSL distributions. First item is "" (system default).
|
||||
/// </summary>
|
||||
public List<string> AvailableWslDistros
|
||||
{
|
||||
get => _availableWslDistros;
|
||||
private set
|
||||
{
|
||||
_availableWslDistros = value;
|
||||
OnPropertyChanged(nameof(AvailableWslDistros));
|
||||
OnPropertyChanged(nameof(WslDistroDisplayNames));
|
||||
OnPropertyChanged(nameof(WslDistributionIndex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display names for the ComboBox (maps empty string to "(System default)").
|
||||
/// </summary>
|
||||
public List<string> WslDistroDisplayNames =>
|
||||
_availableWslDistros.Select(d => string.IsNullOrEmpty(d) ? "(System default)" : d).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Selected index into AvailableWslDistros for ComboBox binding.
|
||||
/// </summary>
|
||||
public int WslDistributionIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = WslDistribution;
|
||||
var idx = _availableWslDistros.IndexOf(current);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (value >= 0 && value < _availableWslDistros.Count)
|
||||
{
|
||||
WslDistribution = _availableWslDistros[value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries installed WSL distributions and populates AvailableWslDistros.
|
||||
/// </summary>
|
||||
public void RefreshWslDistros()
|
||||
{
|
||||
var distros = new List<string> { string.Empty }; // first = system default
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo("wsl.exe", "-l -q")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
StandardOutputEncoding = System.Text.Encoding.Unicode,
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
var names = output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim().Trim('\0', '\r'))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name));
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
distros.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WSL not available — just show the default option
|
||||
}
|
||||
|
||||
AvailableWslDistros = distros;
|
||||
}
|
||||
|
||||
public string ScriptsFolder
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
if (scripts == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Equals(scripts.Mode, "wsl", StringComparison.OrdinalIgnoreCase)
|
||||
? scripts.WslSettings?.ScriptsFolder ?? string.Empty
|
||||
: scripts.WindowsSettings?.ScriptsFolder ?? string.Empty;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
if (string.Equals(scripts.Mode, "wsl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WslSettings ??= new PythonScriptWslSettings();
|
||||
if (!string.Equals(scripts.WslSettings.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WslSettings.ScriptsFolder = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
scripts.WindowsSettings ??= new PythonScriptWindowsSettings();
|
||||
if (!string.Equals(scripts.WindowsSettings.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WindowsSettings.ScriptsFolder = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ObservableCollection<AdvancedPastePythonScriptAction> _pythonScriptActions = [];
|
||||
private bool _scriptsDiscovered;
|
||||
|
||||
public ObservableCollection<AdvancedPastePythonScriptAction> PythonScriptActions
|
||||
{
|
||||
get => _pythonScriptActions;
|
||||
set
|
||||
{
|
||||
_pythonScriptActions = value;
|
||||
OnPropertyChanged(nameof(PythonScriptActions));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the scripts folder for .py files and populates PythonScriptActions
|
||||
/// with metadata from their headers. Existing settings (hotkeys) are preserved.
|
||||
/// </summary>
|
||||
public void RefreshPythonScripts()
|
||||
{
|
||||
_scriptsDiscovered = true;
|
||||
OnPropertyChanged(nameof(ScriptsDiscovered));
|
||||
|
||||
var folder = ScriptsFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder) || !System.IO.Directory.Exists(folder))
|
||||
{
|
||||
PythonScriptActions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var scripts = new ObservableCollection<AdvancedPastePythonScriptAction>();
|
||||
var savedActions = _advancedPasteSettings.Properties.PythonScripts?.Value ?? [];
|
||||
|
||||
foreach (var file in System.IO.Directory.EnumerateFiles(folder, "*.py", System.IO.SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = CreateActionFromScript(file, savedActions);
|
||||
scripts.Add(action);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip scripts that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
PythonScriptActions = scripts;
|
||||
}
|
||||
|
||||
private static AdvancedPastePythonScriptAction CreateActionFromScript(
|
||||
string filePath,
|
||||
List<AdvancedPastePythonScriptAction> savedActions)
|
||||
{
|
||||
// Read header metadata from the .py file
|
||||
var name = System.IO.Path.GetFileNameWithoutExtension(filePath);
|
||||
var description = string.Empty;
|
||||
var platform = "windows";
|
||||
var formats = "any";
|
||||
var enabled = true;
|
||||
var requires = string.Empty;
|
||||
var hasExplicitRequires = false;
|
||||
var inputType = string.Empty;
|
||||
var outputType = string.Empty;
|
||||
|
||||
using var reader = new System.IO.StreamReader(filePath, System.Text.Encoding.UTF8);
|
||||
int lineCount = 0;
|
||||
while (lineCount < 100)
|
||||
{
|
||||
var line = reader.ReadLine();
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lineCount++;
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Detect the function definition to extract input/output types
|
||||
if (trimmed.StartsWith("def advanced_paste_from_", StringComparison.Ordinal))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
trimmed,
|
||||
@"^def advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)\s*\(");
|
||||
if (match.Success)
|
||||
{
|
||||
inputType = match.Groups[1].Value;
|
||||
outputType = match.Groups[2].Value;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseTag(trimmed, "@advancedpaste:name", out var val))
|
||||
{
|
||||
name = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:desc", out val))
|
||||
{
|
||||
description = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:platform", out val))
|
||||
{
|
||||
platform = val.ToLowerInvariant();
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:formats", out val))
|
||||
{
|
||||
formats = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:enabled", out val))
|
||||
{
|
||||
enabled = !string.Equals(val, "false", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(val, "0", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(val, "no", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:requires", out val))
|
||||
{
|
||||
// Accumulate multiple requires tags
|
||||
requires = string.IsNullOrEmpty(requires) ? val : $"{requires} {val}";
|
||||
hasExplicitRequires = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing saved settings (hotkeys, IsShown)
|
||||
var saved = savedActions?.FirstOrDefault(a =>
|
||||
string.Equals(a.ScriptPath, filePath, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = filePath,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Platform = platform,
|
||||
Formats = formats,
|
||||
IsEnabled = enabled,
|
||||
Requires = requires,
|
||||
RequiresAutoDetect = !hasExplicitRequires,
|
||||
InputType = inputType,
|
||||
OutputType = outputType,
|
||||
IsShown = saved?.IsShown ?? true,
|
||||
Shortcut = saved?.Shortcut ?? new HotkeySettings(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseTag(string line, string tag, out string value)
|
||||
{
|
||||
var idx = line.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = line[(idx + tag.Length)..].Trim();
|
||||
return value.Length > 0;
|
||||
}
|
||||
|
||||
public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -201,6 +201,10 @@ function Ensure-VsDevEnvironment {
|
||||
if (-not $instPaths) {
|
||||
try { $p2 = & $vswhere -latest -products * -property installationPath 2>$null; if ($p2) { $instPaths += $p2 } } catch {}
|
||||
}
|
||||
# Fallback: include incomplete installations (e.g. pending reboot after component install)
|
||||
if (-not $instPaths) {
|
||||
try { $p3 = & $vswhere -latest -products * -all -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null; if ($p3) { $instPaths += $p3 } } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
# Add explicit common year-based candidates as a last resort
|
||||
|
||||
Reference in New Issue
Block a user