mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 00:19:16 +02:00
Compare commits
8 Commits
shawn/Pyth
...
user/muyua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df45e56511 | ||
|
|
f8bca48db3 | ||
|
|
5104d0846c | ||
|
|
8ca6c4d2ec | ||
|
|
574aca841b | ||
|
|
0833b68907 | ||
|
|
2051c13bf9 | ||
|
|
4c7bf3df79 |
203
doc/devdocs/modules/advancedpaste-python-scripts.md
Normal file
203
doc/devdocs/modules/advancedpaste-python-scripts.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 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. Add the required header comments at the top (see [Header format](#header-format)).
|
||||
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
|
||||
|
||||
## Header format
|
||||
|
||||
Every script must start with one or more **header comment lines**. Each line follows the pattern:
|
||||
|
||||
```
|
||||
# @advancedpaste:<tag> <value>
|
||||
```
|
||||
|
||||
The parser reads the first 50 lines of each file; only lines beginning with `#` are inspected.
|
||||
|
||||
### Supported tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| `name` | **Yes** | Display name shown in the Advanced Paste UI. |
|
||||
| `desc` | No | Short description / tooltip. |
|
||||
| `formats` | No | Comma-separated list of supported clipboard formats. Defaults to **all** formats when omitted. |
|
||||
| `platform` | No | `windows` (default) or `linux`. Determines the execution mode (see below). |
|
||||
| `version` | No | Free-form version string (reserved for future use). |
|
||||
| `requires` | No | Space-separated Python package requirements. See [Declaring dependencies](#declaring-dependencies). |
|
||||
|
||||
### Formats
|
||||
|
||||
| Value | Clipboard content |
|
||||
|-------|--------------------|
|
||||
| `text` | Plain or Unicode text (`CF_UNICODETEXT`) |
|
||||
| `html` | HTML fragment (`CF_HTML`) |
|
||||
| `image` | Bitmap / PNG image |
|
||||
| `audio` | Audio file(s) |
|
||||
| `video` | Video file(s) |
|
||||
| `files` or `file` | File paths (`CF_HDROP` / `StorageItems`) |
|
||||
| `any` | All of the above |
|
||||
|
||||
Multiple values can be combined with commas:
|
||||
|
||||
```python
|
||||
# @advancedpaste:formats text,html
|
||||
```
|
||||
|
||||
## Execution modes
|
||||
|
||||
### Windows mode (`platform windows`)
|
||||
|
||||
The script runs directly on Windows via the configured Python interpreter.
|
||||
It **owns the clipboard** — use a library like `pywin32` (`win32clipboard`) to read
|
||||
and write clipboard data.
|
||||
|
||||
**Invocation:**
|
||||
|
||||
```
|
||||
python.exe "<script.py>" --format <detected_format> --work-dir "<temp_dir>"
|
||||
```
|
||||
|
||||
**Minimal example — reverse text:**
|
||||
|
||||
```python
|
||||
# @advancedpaste:name Reverse text
|
||||
# @advancedpaste:formats text
|
||||
# @advancedpaste:platform windows
|
||||
import win32clipboard
|
||||
|
||||
win32clipboard.OpenClipboard()
|
||||
text = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
|
||||
win32clipboard.EmptyClipboard()
|
||||
win32clipboard.SetClipboardData(win32clipboard.CF_UNICODETEXT, text[::-1])
|
||||
win32clipboard.CloseClipboard()
|
||||
```
|
||||
|
||||
After the script exits with code 0, Advanced Paste re-reads the clipboard and pastes
|
||||
the result. A non-zero exit code signals failure; stderr is shown in the error UI.
|
||||
|
||||
### WSL / Linux mode (`platform linux`)
|
||||
|
||||
The script runs inside WSL via `wsl.exe bash -l -c "python3 -X utf8 <script>"`.
|
||||
Instead of direct clipboard access, data is exchanged via **JSON**:
|
||||
|
||||
| Direction | Channel | Schema |
|
||||
|-----------|---------|--------|
|
||||
| **Input** (C# → Python) | `stdin` (JSON) | See [Input payload](#input-payload) |
|
||||
| **Output** (Python → C#) | `stdout` (JSON) | See [Output payload](#output-payload) |
|
||||
|
||||
#### Input payload
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 2,
|
||||
"format": ["text"], // array of detected clipboard format names
|
||||
"work_dir": "/mnt/c/...", // writable temp directory (WSL path)
|
||||
"text": "Hello, world!", // present when clipboard has text
|
||||
"html": "<b>Hello</b>", // present when clipboard has HTML
|
||||
"image_path": "/mnt/c/.../input.png", // present when clipboard has an image
|
||||
"file_paths": ["/mnt/c/.../file.txt"] // present when clipboard has files
|
||||
}
|
||||
```
|
||||
|
||||
#### Output payload
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"result_type": "text", // "text" | "html" | "image" | "file" | "files"
|
||||
"text": "HELLO, WORLD!", // for result_type "text"
|
||||
"html": "<b>HELLO</b>", // for result_type "html"
|
||||
"image_path": "/mnt/c/.../output.png", // for result_type "image"
|
||||
"file_paths": ["/mnt/c/.../out.txt"] // for result_type "file"/"files"
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** File paths in the output must use `/mnt/<drive>/...` format so that
|
||||
> Advanced Paste can map them back to Windows paths.
|
||||
|
||||
**Minimal example — uppercase text (WSL):**
|
||||
|
||||
```python
|
||||
# @advancedpaste:name WSL Upper Case
|
||||
# @advancedpaste:formats text
|
||||
# @advancedpaste:platform linux
|
||||
import sys, json
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
text = data.get("text", "")
|
||||
json.dump({"result_type": "text", "text": text.upper()}, sys.stdout)
|
||||
```
|
||||
|
||||
## Declaring dependencies
|
||||
|
||||
Use `requires` to declare Python packages the script needs:
|
||||
|
||||
```python
|
||||
# @advancedpaste:requires markitdown='markitdown[all]'
|
||||
# @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`).
|
||||
|
||||
Multiple tokens on one line are space-separated. You can also use multiple `requires` lines.
|
||||
|
||||
### 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.
|
||||
|
||||
A built-in mapping table handles common mismatches (e.g. `win32clipboard` → `pywin32`,
|
||||
`cv2` → `opencv-python`, `PIL` → `Pillow`). For uncommon packages where the import name
|
||||
differs from the pip name, add an explicit `requires` entry.
|
||||
|
||||
## 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 following settings are available under **Settings → Advanced Paste → Python scripts**:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
|
||||
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
|
||||
|
||||
## Tips
|
||||
|
||||
- Put reusable helper functions in a separate `.py` file without a `# @advancedpaste:name`
|
||||
header — it will be ignored by the script discovery and can be imported by other scripts.
|
||||
- For complex WSL scripts that need packages not available via `apt`, consider using
|
||||
a virtual environment. The script can re-exec itself with the venv interpreter:
|
||||
```python
|
||||
import os, sys
|
||||
venv = os.path.expanduser("~/my_env/bin/python3")
|
||||
if os.path.exists(venv) and sys.executable != venv:
|
||||
os.execv(venv, [venv] + sys.argv)
|
||||
```
|
||||
- The `--work-dir` argument (Windows mode) and `work_dir` JSON field (WSL mode) point to
|
||||
a temporary directory that is cleaned up after execution. Use it for intermediate files.
|
||||
@@ -57,6 +57,16 @@ 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 int PythonScriptTimeoutSeconds => 30;
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
|
||||
|
||||
public event EventHandler Changed;
|
||||
|
||||
public Task SetActiveAIProviderAsync(string providerId)
|
||||
@@ -65,4 +75,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void StoreTrustedScriptHash(string scriptPath, string hash)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// 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 AdvancedPaste.Services.PythonScripts;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AdvancedPaste.UnitTests.ServicesTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PythonScriptServiceTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
</Page.KeyboardAccelerators>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -299,7 +300,57 @@
|
||||
</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}}" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
|
||||
|
||||
public bool IsPythonScriptsEnabled { get; }
|
||||
|
||||
public string PythonScriptsFolder { get; }
|
||||
|
||||
public string PythonExecutablePath { get; }
|
||||
|
||||
@@ -57,6 +57,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public string PythonScriptsFolder { get; private set; }
|
||||
|
||||
public bool IsPythonScriptsEnabled { get; private set; }
|
||||
|
||||
public string PythonExecutablePath { get; private set; }
|
||||
|
||||
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
|
||||
@@ -159,6 +161,7 @@ namespace AdvancedPaste.Settings
|
||||
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
|
||||
|
||||
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
|
||||
IsPythonScriptsEnabled = pythonScripts.IsEnabled;
|
||||
PythonScriptsFolder = string.IsNullOrWhiteSpace(pythonScripts.ScriptsFolder)
|
||||
? GetDefaultScriptsFolder()
|
||||
: pythonScripts.ScriptsFolder;
|
||||
|
||||
@@ -15,4 +15,5 @@ public sealed record PythonScriptMetadata(
|
||||
ClipboardFormat SupportedFormats,
|
||||
string Platform,
|
||||
string Version,
|
||||
bool IsEnabled,
|
||||
IReadOnlyList<PythonRequirement> Requirements);
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -64,6 +65,9 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"),
|
||||
new InvalidOperationException("Failed to start Python process."));
|
||||
|
||||
// Start reading stderr immediately to avoid truncation and deadlocks.
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
int timeoutMs = _userSettings.PythonScriptTimeoutSeconds * 1000;
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeoutMs);
|
||||
@@ -83,11 +87,12 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
new TimeoutException());
|
||||
}
|
||||
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
var errorMsg = stderr.Length > 1500 ? stderr[..1500] : stderr;
|
||||
throw new PasteActionException(errorMsg, new InvalidOperationException($"Script exited with code {process.ExitCode}."));
|
||||
var (summary, details) = ParsePythonError(stderr);
|
||||
throw new PasteActionException(summary, new InvalidOperationException($"Script exited with code {process.ExitCode}."), aiServiceMessage: details);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -181,8 +186,8 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var errorMsg = stderr.Length > 1500 ? stderr[..1500] : stderr;
|
||||
throw new PasteActionException(errorMsg, new InvalidOperationException($"WSL script exited with code {process.ExitCode}."));
|
||||
var (summary, details) = ParsePythonError(stderr);
|
||||
throw new PasteActionException(summary, new InvalidOperationException($"WSL script exited with code {process.ExitCode}."), aiServiceMessage: details);
|
||||
}
|
||||
|
||||
return await ParseWslOutputAsync(stdout, workDir, cancellationToken);
|
||||
@@ -210,7 +215,9 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
ClipboardFormat.Text | ClipboardFormat.Html |
|
||||
ClipboardFormat.Image | ClipboardFormat.Audio |
|
||||
ClipboardFormat.Video | ClipboardFormat.File;
|
||||
var requirements = new List<PythonRequirement>();
|
||||
var explicitRequirements = new List<PythonRequirement>();
|
||||
var allLines = new List<string>();
|
||||
var isEnabled = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -226,54 +233,71 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
}
|
||||
|
||||
lineCount++;
|
||||
line = line.Trim();
|
||||
allLines.Add(line);
|
||||
var trimmed = line.Trim();
|
||||
|
||||
if (!line.StartsWith('#'))
|
||||
if (!trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tag = ParseTag(line, "@advancedpaste:name");
|
||||
var tag = ParseTag(trimmed, "@advancedpaste:name");
|
||||
if (tag != null)
|
||||
{
|
||||
name = tag;
|
||||
continue;
|
||||
}
|
||||
|
||||
tag = ParseTag(line, "@advancedpaste:desc");
|
||||
tag = ParseTag(trimmed, "@advancedpaste:desc");
|
||||
if (tag != null)
|
||||
{
|
||||
description = tag;
|
||||
continue;
|
||||
}
|
||||
|
||||
tag = ParseTag(line, "@advancedpaste:platform");
|
||||
tag = ParseTag(trimmed, "@advancedpaste:platform");
|
||||
if (tag != null)
|
||||
{
|
||||
platform = tag.ToLowerInvariant();
|
||||
continue;
|
||||
}
|
||||
|
||||
tag = ParseTag(line, "@advancedpaste:version");
|
||||
tag = ParseTag(trimmed, "@advancedpaste:version");
|
||||
if (tag != null)
|
||||
{
|
||||
version = tag;
|
||||
continue;
|
||||
}
|
||||
|
||||
tag = ParseTag(line, "@advancedpaste:formats");
|
||||
tag = ParseTag(trimmed, "@advancedpaste:formats");
|
||||
if (tag != null)
|
||||
{
|
||||
supportedFormats = ParseFormats(tag);
|
||||
continue;
|
||||
}
|
||||
|
||||
tag = ParseTag(line, "@advancedpaste:requires");
|
||||
tag = ParseTag(trimmed, "@advancedpaste:requires");
|
||||
if (tag != null)
|
||||
{
|
||||
requirements.AddRange(ParseRequirements(tag));
|
||||
explicitRequirements.AddRange(ParseRequirements(tag));
|
||||
continue;
|
||||
}
|
||||
|
||||
tag = ParseTag(trimmed, "@advancedpaste:enabled");
|
||||
if (tag != null)
|
||||
{
|
||||
isEnabled = !string.Equals(tag, "false", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(tag, "0", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(tag, "no", StringComparison.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining lines for import scanning
|
||||
string remainingLine;
|
||||
while ((remainingLine = reader.ReadLine()) is not null)
|
||||
{
|
||||
allLines.Add(remainingLine);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -281,7 +305,9 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
Logger.LogError($"Failed to read metadata from {scriptPath}", ex);
|
||||
}
|
||||
|
||||
return new PythonScriptMetadata(scriptPath, name, description, supportedFormats, platform, version, requirements);
|
||||
var mergedRequirements = MergeWithAutoDetectedImports(allLines, explicitRequirements);
|
||||
|
||||
return new PythonScriptMetadata(scriptPath, name, description, supportedFormats, platform, version, isEnabled, mergedRequirements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -305,6 +331,193 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common mappings from Python import names to pip package names where they differ.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> WellKnownImportToPip = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["PIL"] = "Pillow",
|
||||
["cv2"] = "opencv-python",
|
||||
["sklearn"] = "scikit-learn",
|
||||
["skimage"] = "scikit-image",
|
||||
["yaml"] = "PyYAML",
|
||||
["bs4"] = "beautifulsoup4",
|
||||
["gi"] = "PyGObject",
|
||||
["attr"] = "attrs",
|
||||
["dateutil"] = "python-dateutil",
|
||||
["dotenv"] = "python-dotenv",
|
||||
["git"] = "GitPython",
|
||||
["serial"] = "pyserial",
|
||||
["usb"] = "pyusb",
|
||||
["wx"] = "wxPython",
|
||||
["Crypto"] = "pycryptodome",
|
||||
["lxml"] = "lxml",
|
||||
["magic"] = "python-magic",
|
||||
["docx"] = "python-docx",
|
||||
["pptx"] = "python-pptx",
|
||||
["win32clipboard"] = "pywin32",
|
||||
["win32com"] = "pywin32",
|
||||
["win32api"] = "pywin32",
|
||||
["win32gui"] = "pywin32",
|
||||
["win32con"] = "pywin32",
|
||||
["win32print"] = "pywin32",
|
||||
["win32security"] = "pywin32",
|
||||
["win32service"] = "pywin32",
|
||||
["win32event"] = "pywin32",
|
||||
["win32file"] = "pywin32",
|
||||
["win32pipe"] = "pywin32",
|
||||
["win32process"] = "pywin32",
|
||||
["win32ts"] = "pywin32",
|
||||
["pywintypes"] = "pywin32",
|
||||
["pythoncom"] = "pywin32",
|
||||
["wmi"] = "WMI",
|
||||
["llama_cpp"] = "llama-cpp-python",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Python standard library module names (CPython 3.12). Imports in this set are never
|
||||
/// treated as third-party requirements.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> PythonStdlib = new(StringComparer.Ordinal)
|
||||
{
|
||||
"__future__", "_thread", "abc", "aifc", "argparse", "array", "ast", "asynchat",
|
||||
"asyncio", "asyncore", "atexit", "audioop", "base64", "bdb", "binascii", "binhex",
|
||||
"bisect", "builtins", "bz2", "calendar", "cgi", "cgitb", "chunk", "cmath", "cmd",
|
||||
"code", "codecs", "codeop", "collections", "colorsys", "compileall", "concurrent",
|
||||
"configparser", "contextlib", "contextvars", "copy", "copyreg", "cProfile", "crypt",
|
||||
"csv", "ctypes", "curses", "dataclasses", "datetime", "dbm", "decimal", "difflib",
|
||||
"dis", "distutils", "doctest", "email", "encodings", "enum", "errno", "faulthandler",
|
||||
"fcntl", "filecmp", "fileinput", "fnmatch", "fractions", "ftplib", "functools", "gc",
|
||||
"getopt", "getpass", "gettext", "glob", "grp", "gzip", "hashlib", "heapq", "hmac",
|
||||
"html", "http", "idlelib", "imaplib", "imghdr", "imp", "importlib", "inspect", "io",
|
||||
"ipaddress", "itertools", "json", "keyword", "lib2to3", "linecache", "locale",
|
||||
"logging", "lzma", "mailbox", "mailcap", "marshal", "math", "mimetypes", "mmap",
|
||||
"modulefinder", "multiprocessing", "netrc", "nis", "nntplib", "numbers", "operator",
|
||||
"optparse", "os", "ossaudiodev", "pathlib", "pdb", "pickle", "pickletools", "pipes",
|
||||
"pkgutil", "platform", "plistlib", "poplib", "posix", "posixpath", "pprint",
|
||||
"profile", "pstats", "pty", "pwd", "py_compile", "pyclbr", "pydoc", "queue",
|
||||
"quopri", "random", "re", "readline", "reprlib", "resource", "rlcompleter",
|
||||
"runpy", "sched", "secrets", "select", "selectors", "shelve", "shlex", "shutil",
|
||||
"signal", "site", "smtpd", "smtplib", "sndhdr", "socket", "socketserver", "spwd",
|
||||
"sqlite3", "ssl", "stat", "statistics", "string", "stringprep", "struct",
|
||||
"subprocess", "sunau", "symtable", "sys", "sysconfig", "syslog", "tabnanny",
|
||||
"tarfile", "telnetlib", "tempfile", "termios", "test", "textwrap", "threading",
|
||||
"time", "timeit", "tkinter", "token", "tokenize", "tomllib", "trace", "traceback",
|
||||
"tracemalloc", "tty", "turtle", "turtledemo", "types", "typing", "unicodedata",
|
||||
"unittest", "urllib", "uu", "uuid", "venv", "warnings", "wave", "weakref",
|
||||
"webbrowser", "winreg", "winsound", "wsgiref", "xdrlib", "xml", "xmlrpc",
|
||||
"zipapp", "zipfile", "zipimport", "zlib", "_io", "_collections_abc",
|
||||
"typing_extensions", "ntpath", "posixpath", "genericpath", "stat",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Scans all lines in the script for top-level <c>import X</c> and <c>from X import Y</c>
|
||||
/// statements, then merges auto-detected third-party imports with the explicit
|
||||
/// <c>@advancedpaste:requires</c> entries. Explicit entries always take precedence.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<PythonRequirement> MergeWithAutoDetectedImports(
|
||||
IReadOnlyList<string> lines,
|
||||
IReadOnlyList<PythonRequirement> explicitRequirements)
|
||||
{
|
||||
// Build a set of import names already covered by explicit requirements.
|
||||
var explicitImports = new HashSet<string>(
|
||||
explicitRequirements.Select(r => r.ImportName),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var autoDetected = new Dictionary<string, PythonRequirement>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
|
||||
// Skip comments and blank lines
|
||||
if (line.Length == 0 || line[0] == '#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match "import X", "import X as Y", "import X, Y, Z"
|
||||
if (line.StartsWith("import ", StringComparison.Ordinal))
|
||||
{
|
||||
var rest = line["import ".Length..].Trim();
|
||||
|
||||
// Stop at inline comment
|
||||
var commentIdx = rest.IndexOf('#');
|
||||
if (commentIdx >= 0)
|
||||
{
|
||||
rest = rest[..commentIdx].Trim();
|
||||
}
|
||||
|
||||
foreach (var segment in rest.Split(','))
|
||||
{
|
||||
var moduleName = segment.Trim().Split(' ')[0].Split('.')[0]; // "X as Y" → "X"; "X.sub" → "X"
|
||||
TryAddAutoImport(moduleName, explicitImports, autoDetected);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match "from X import Y"
|
||||
if (line.StartsWith("from ", StringComparison.Ordinal))
|
||||
{
|
||||
var rest = line["from ".Length..].Trim();
|
||||
var importIdx = rest.IndexOf(" import ", StringComparison.Ordinal);
|
||||
if (importIdx < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var moduleName = rest[..importIdx].Trim().Split('.')[0]; // "X.sub" → "X"
|
||||
TryAddAutoImport(moduleName, explicitImports, autoDetected);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: explicit first, then auto-detected
|
||||
var merged = new List<PythonRequirement>(explicitRequirements);
|
||||
merged.AddRange(autoDetected.Values);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static void TryAddAutoImport(
|
||||
string moduleName,
|
||||
HashSet<string> explicitImports,
|
||||
Dictionary<string, PythonRequirement> autoDetected)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(moduleName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip relative imports (leading dot)
|
||||
if (moduleName[0] == '.')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip stdlib modules
|
||||
if (PythonStdlib.Contains(moduleName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already covered by explicit requires
|
||||
if (explicitImports.Contains(moduleName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already auto-detected
|
||||
if (autoDetected.ContainsKey(moduleName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up well-known import→pip mapping
|
||||
var pipPackage = WellKnownImportToPip.TryGetValue(moduleName, out var mapped) ? mapped : moduleName;
|
||||
autoDetected[moduleName] = new PythonRequirement(moduleName, pipPackage);
|
||||
}
|
||||
|
||||
private static string ParseTag(string line, string tag)
|
||||
{
|
||||
var idx = line.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -439,7 +652,19 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
return false;
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
|
||||
@@ -457,7 +682,19 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
return false;
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
|
||||
@@ -484,23 +721,45 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"),
|
||||
new InvalidOperationException("Failed to start pip."));
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
int timeoutMs = _userSettings.PythonScriptTimeoutSeconds * 1000;
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
throw new PasteActionException(
|
||||
string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallTimeout"),
|
||||
packages,
|
||||
_userSettings.PythonScriptTimeoutSeconds),
|
||||
new TimeoutException());
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
var (summary, _) = ParsePipInstallError(stderr);
|
||||
throw new PasteActionException(
|
||||
string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallFailed"),
|
||||
packages,
|
||||
stderr.Length > 300 ? stderr[..300] : stderr),
|
||||
new InvalidOperationException($"pip exited with code {process.ExitCode}."));
|
||||
summary),
|
||||
new InvalidOperationException($"pip exited with code {process.ExitCode}."),
|
||||
aiServiceMessage: stderr.Length > 3000 ? stderr[^3000..] : stderr);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InstallInWslAsync(string packages, CancellationToken cancellationToken)
|
||||
private async Task InstallInWslAsync(string packages, CancellationToken cancellationToken)
|
||||
{
|
||||
int timeoutMs = _userSettings.PythonScriptTimeoutSeconds * 1000;
|
||||
|
||||
// Try plain pip3 first; if the environment is managed (PEP 668), retry with --break-system-packages.
|
||||
foreach (var extraArgs in (string[])[string.Empty, "--break-system-packages"])
|
||||
{
|
||||
@@ -521,7 +780,24 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
continue;
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
throw new PasteActionException(
|
||||
string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallTimeout"),
|
||||
packages,
|
||||
_userSettings.PythonScriptTimeoutSeconds),
|
||||
new TimeoutException());
|
||||
}
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
@@ -533,13 +809,15 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
// If it failed for a reason OTHER than externally-managed-environment, throw immediately.
|
||||
if (!stderr.Contains("externally-managed-environment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var (summary, _) = ParsePipInstallError(stderr);
|
||||
throw new PasteActionException(
|
||||
string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallFailed"),
|
||||
packages,
|
||||
stderr.Length > 300 ? stderr[..300] : stderr),
|
||||
new InvalidOperationException($"pip3 exited with code {process.ExitCode}."));
|
||||
summary),
|
||||
new InvalidOperationException($"pip3 exited with code {process.ExitCode}."),
|
||||
aiServiceMessage: stderr.Length > 3000 ? stderr[^3000..] : stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,4 +1348,203 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
|
||||
new FileNotFoundException("Script file not found.", scriptPath));
|
||||
}
|
||||
}
|
||||
|
||||
// Matches Python traceback lines: File "path/to/file.py", line 10
|
||||
private static readonly Regex TracebackFileLineRegex = new(
|
||||
@"^\s*File\s+""(.+?)"",\s*line\s+(\d+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Parses Python stderr output and returns a user-friendly summary plus the full traceback
|
||||
/// as details. Identifies common error types (ModuleNotFoundError, SyntaxError, etc.)
|
||||
/// for clearer messaging. Extracts script name, line, and column from the traceback
|
||||
/// so the user can quickly locate the failure.
|
||||
/// </summary>
|
||||
internal static (string Summary, string Details) ParsePythonError(string stderr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
return (ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"), string.Empty);
|
||||
}
|
||||
|
||||
var fullDetails = stderr.Length > 3000 ? stderr[^3000..] : stderr;
|
||||
|
||||
// Find the last exception line — Python tracebacks end with "ErrorType: message"
|
||||
var lines = stderr.Split('\n');
|
||||
string lastExceptionLine = null;
|
||||
|
||||
for (int i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var trimmed = lines[i].Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Python exception lines look like "ModuleNotFoundError: No module named 'xyz'"
|
||||
// or "SyntaxError: invalid syntax"
|
||||
if (trimmed.Contains("Error:") || trimmed.Contains("Exception:"))
|
||||
{
|
||||
lastExceptionLine = trimmed;
|
||||
break;
|
||||
}
|
||||
|
||||
// If the very last non-blank line doesn't have "Error:", use it anyway
|
||||
// (some scripts just print an error message to stderr)
|
||||
lastExceptionLine ??= trimmed;
|
||||
}
|
||||
|
||||
if (lastExceptionLine is null)
|
||||
{
|
||||
return (ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"), fullDetails);
|
||||
}
|
||||
|
||||
// Extract location (script, line, column) from the traceback
|
||||
var location = ExtractLastTracebackLocation(lines);
|
||||
|
||||
// Build a user-friendly summary for well-known error types
|
||||
var errorMessage = lastExceptionLine switch
|
||||
{
|
||||
string s when s.StartsWith("ModuleNotFoundError:", StringComparison.Ordinal) =>
|
||||
FormatModuleNotFoundSummary(s),
|
||||
string s when s.StartsWith("SyntaxError:", StringComparison.Ordinal) =>
|
||||
$"Python syntax error: {s["SyntaxError:".Length..].Trim()}",
|
||||
string s when s.StartsWith("FileNotFoundError:", StringComparison.Ordinal) =>
|
||||
$"File not found: {s["FileNotFoundError:".Length..].Trim()}",
|
||||
string s when s.StartsWith("PermissionError:", StringComparison.Ordinal) =>
|
||||
$"Permission denied: {s["PermissionError:".Length..].Trim()}",
|
||||
string s when s.StartsWith("TypeError:", StringComparison.Ordinal) =>
|
||||
$"Type error: {s["TypeError:".Length..].Trim()}",
|
||||
string s when s.StartsWith("ValueError:", StringComparison.Ordinal) =>
|
||||
$"Value error: {s["ValueError:".Length..].Trim()}",
|
||||
string s when s.StartsWith("KeyError:", StringComparison.Ordinal) =>
|
||||
$"Key error: {s["KeyError:".Length..].Trim()}",
|
||||
_ => lastExceptionLine.Length > 200 ? lastExceptionLine[..200] + "…" : lastExceptionLine,
|
||||
};
|
||||
|
||||
// Prepend location info so the user can quickly find the failure
|
||||
var summary = FormatErrorWithLocation(location, errorMessage);
|
||||
|
||||
return (summary, fullDetails);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the traceback lines for the last <c>File "...", line N</c> reference and
|
||||
/// optionally extracts the column from a caret (<c>^</c>) indicator line.
|
||||
/// </summary>
|
||||
internal static (string FileName, int Line, int? Column)? ExtractLastTracebackLocation(string[] lines)
|
||||
{
|
||||
string lastFile = null;
|
||||
int lastLine = 0;
|
||||
int lastFileLineIndex = -1;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var match = TracebackFileLineRegex.Match(lines[i]);
|
||||
if (match.Success)
|
||||
{
|
||||
lastFile = match.Groups[1].Value;
|
||||
lastLine = int.Parse(match.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
lastFileLineIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastFile is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract column from a caret line. Python tracebacks show:
|
||||
// File "test.py", line 5
|
||||
// def foo(
|
||||
// ^
|
||||
// The caret line (index lastFileLineIndex+2) marks the error column.
|
||||
int? column = null;
|
||||
int caretIdx = lastFileLineIndex + 2;
|
||||
if (caretIdx < lines.Length)
|
||||
{
|
||||
var caretLine = lines[caretIdx];
|
||||
var caretPos = caretLine.IndexOf('^');
|
||||
if (caretPos >= 0 && caretLine.Trim().Replace("^", string.Empty, StringComparison.Ordinal).Trim().Length == 0)
|
||||
{
|
||||
// The code line sits at lastFileLineIndex+1; compute column relative to code indent
|
||||
var codeLine = lines[lastFileLineIndex + 1];
|
||||
var codeIndent = codeLine.Length - codeLine.TrimStart().Length;
|
||||
var col = caretPos - codeIndent + 1; // 1-based
|
||||
if (col >= 1)
|
||||
{
|
||||
column = col;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (Path.GetFileName(lastFile), lastLine, column);
|
||||
}
|
||||
|
||||
private static string FormatErrorWithLocation((string FileName, int Line, int? Column)? location, string errorMessage)
|
||||
{
|
||||
if (location is not { } loc)
|
||||
{
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
var locationText = loc.Column is { } col
|
||||
? $"{loc.FileName}, line {loc.Line}, col {col}"
|
||||
: $"{loc.FileName}, line {loc.Line}";
|
||||
|
||||
return $"{locationText}: {errorMessage}";
|
||||
}
|
||||
|
||||
private static string FormatModuleNotFoundSummary(string errorLine)
|
||||
{
|
||||
// Extract module name from: "ModuleNotFoundError: No module named 'xyz'"
|
||||
var singleQuoteStart = errorLine.IndexOf('\'');
|
||||
var singleQuoteEnd = singleQuoteStart >= 0 ? errorLine.IndexOf('\'', singleQuoteStart + 1) : -1;
|
||||
|
||||
if (singleQuoteStart >= 0 && singleQuoteEnd > singleQuoteStart)
|
||||
{
|
||||
var moduleName = errorLine[(singleQuoteStart + 1)..singleQuoteEnd];
|
||||
var pipHint = WellKnownImportToPip.TryGetValue(moduleName, out var pipName) ? pipName : moduleName;
|
||||
return $"Module '{moduleName}' not found. Try: pip install {pipHint}";
|
||||
}
|
||||
|
||||
return errorLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a concise summary from pip install stderr output.
|
||||
/// pip typically ends with "ERROR: ..." lines; we grab the last one.
|
||||
/// </summary>
|
||||
internal static (string Summary, string FullStderr) ParsePipInstallError(string stderr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
return ("unknown error", string.Empty);
|
||||
}
|
||||
|
||||
var lines = stderr.Split('\n');
|
||||
|
||||
// Find the last line starting with "ERROR:" — pip's standard error prefix.
|
||||
for (int i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var trimmed = lines[i].Trim();
|
||||
if (trimmed.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var msg = trimmed["ERROR:".Length..].Trim();
|
||||
return (msg.Length > 300 ? msg[..300] + "…" : msg, stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the last non-empty line.
|
||||
for (int i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var trimmed = lines[i].Trim();
|
||||
if (trimmed.Length > 0)
|
||||
{
|
||||
return (trimmed.Length > 300 ? trimmed[..300] + "…" : trimmed, stderr);
|
||||
}
|
||||
}
|
||||
|
||||
return ("unknown error", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,4 +428,17 @@ Install them now?</value>
|
||||
<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>
|
||||
@@ -433,6 +433,11 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
|
||||
{
|
||||
if (!_userSettings.IsPythonScriptsEnabled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var folder = _userSettings.PythonScriptsFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
@@ -449,7 +454,7 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
foreach (var meta in discoveredScripts)
|
||||
{
|
||||
if (hiddenPaths.Contains(meta.ScriptPath))
|
||||
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
|
||||
@@ -15,6 +16,11 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
|
||||
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();
|
||||
|
||||
[JsonPropertyName("scriptPath")]
|
||||
@@ -45,6 +51,69 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
|
||||
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
|
||||
{
|
||||
@@ -62,6 +131,104 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
|
||||
[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
|
||||
@@ -70,7 +237,12 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
IsShown = IsShown,
|
||||
Shortcut = Shortcut,
|
||||
IsEnabled = IsEnabled,
|
||||
Platform = Platform,
|
||||
Formats = Formats,
|
||||
Requires = Requires,
|
||||
RequiresAutoDetect = RequiresAutoDetect,
|
||||
Shortcut = Shortcut != null ? new HotkeySettings(Shortcut.Win, Shortcut.Ctrl, Shortcut.Alt, Shortcut.Shift, Shortcut.Code) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptSettings
|
||||
{
|
||||
[JsonPropertyName("isEnabled")]
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -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.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 WriteScriptHeader_OverwritesExistingBlankDescriptionTag()
|
||||
{
|
||||
var scriptPath = Path.Combine(Path.GetTempPath(), $"AdvancedPaste-{Guid.NewGuid():N}.py");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllLines(
|
||||
scriptPath,
|
||||
[
|
||||
"# @advancedpaste:name reverse text",
|
||||
"# @advancedpaste:formats text",
|
||||
"# @advancedpaste:platform windows",
|
||||
"# @advancedpaste:desc ",
|
||||
"# @advancedpaste:enabled true",
|
||||
"print('hello')",
|
||||
]);
|
||||
|
||||
var action = new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = scriptPath,
|
||||
Name = "reverse text",
|
||||
Description = "Updated description",
|
||||
Platform = "windows",
|
||||
Formats = "text",
|
||||
IsEnabled = true,
|
||||
RequiresAutoDetect = true,
|
||||
};
|
||||
|
||||
var writeScriptHeader = typeof(AdvancedPasteViewModel)
|
||||
.GetMethod("WriteScriptHeader", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.IsNotNull(writeScriptHeader);
|
||||
|
||||
writeScriptHeader.Invoke(null, [action]);
|
||||
|
||||
var descLines = File.ReadAllLines(scriptPath)
|
||||
.Where(line => line.Contains("@advancedpaste:desc", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
Assert.AreEqual(1, descLines.Length);
|
||||
Assert.AreEqual("# @advancedpaste:desc Updated description", descLines[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(scriptPath))
|
||||
{
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +392,41 @@
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard">
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_EnableCard">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=TwoWay}" />
|
||||
</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>
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
@@ -411,6 +445,50 @@
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Discovered Python Scripts -->
|
||||
<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 Name, Mode=OneWay}"
|
||||
Description="{x:Bind ScriptPath, Mode=OneWay}"
|
||||
IsActionIconVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ToggleSwitch
|
||||
IsOn="{x:Bind IsEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
Toggled="ScriptEnabledToggle_Toggled"
|
||||
OffContent=""
|
||||
OnContent="" />
|
||||
<Button
|
||||
Content=""
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
Click="EditPythonScript_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="{x:Bind}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock Text="Edit script settings" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
</DataTemplate>
|
||||
</tkcontrols:SettingsExpander.ItemTemplate>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
</StackPanel>
|
||||
</controls:SettingsPageControl.ModuleContent>
|
||||
@@ -450,7 +528,74 @@
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
|
||||
<!-- Paste AI provider dialog -->
|
||||
<!-- Python Script Edit Dialog -->
|
||||
<ContentDialog
|
||||
x:Name="PythonScriptEditDialog"
|
||||
Title="Edit script settings"
|
||||
CloseButtonText="Cancel"
|
||||
PrimaryButtonText="Apply changes"
|
||||
PrimaryButtonStyle="{ThemeResource AccentButtonStyle}">
|
||||
<ContentDialog.Resources>
|
||||
<x:Double x:Key="ContentDialogMaxWidth">600</x:Double>
|
||||
</ContentDialog.Resources>
|
||||
<StackPanel Spacing="14" MinWidth="400">
|
||||
<TextBlock
|
||||
x:Name="ScriptEditFilePath"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
IsTextSelectionEnabled="True" />
|
||||
<TextBox
|
||||
x:Name="ScriptEditName"
|
||||
Header="Name"
|
||||
Width="380"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBox
|
||||
x:Name="ScriptEditDescription"
|
||||
Header="Description"
|
||||
Width="380"
|
||||
HorizontalAlignment="Left" />
|
||||
<ComboBox
|
||||
x:Name="ScriptEditPlatform"
|
||||
Header="Platform"
|
||||
MinWidth="160">
|
||||
<x:String>windows</x:String>
|
||||
<x:String>linux</x:String>
|
||||
<x:String>any</x:String>
|
||||
</ComboBox>
|
||||
<TextBlock
|
||||
Text="Supported formats"
|
||||
Style="{StaticResource BodyTextBlockStyle}"
|
||||
Margin="0,4,0,0" />
|
||||
<Grid ColumnSpacing="12" RowSpacing="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<CheckBox x:Name="ScriptEditText" Content="Text" Grid.Row="0" Grid.Column="0" />
|
||||
<CheckBox x:Name="ScriptEditHtml" Content="HTML" Grid.Row="0" Grid.Column="1" />
|
||||
<CheckBox x:Name="ScriptEditImage" Content="Image" Grid.Row="0" Grid.Column="2" />
|
||||
<CheckBox x:Name="ScriptEditAudio" Content="Audio" Grid.Row="1" Grid.Column="0" />
|
||||
<CheckBox x:Name="ScriptEditVideo" Content="Video" Grid.Row="1" Grid.Column="1" />
|
||||
<CheckBox x:Name="ScriptEditFiles" Content="Files" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<ToggleSwitch
|
||||
x:Name="ScriptEditAutoDetectDeps"
|
||||
Header="Dependencies"
|
||||
OnContent="Auto-detect from imports"
|
||||
OffContent="Manual" />
|
||||
<TextBox
|
||||
x:Name="ScriptEditRequires"
|
||||
Header="Required packages (space-separated)"
|
||||
Width="380"
|
||||
HorizontalAlignment="Left"
|
||||
PlaceholderText="e.g. numpy requests cv2=opencv-python-headless" />
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
<ContentDialog
|
||||
x:Name="PasteAIProviderConfigurationDialog"
|
||||
x:Uid="AdvancedPaste_EndpointDialog"
|
||||
|
||||
@@ -21,6 +21,7 @@ using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
{
|
||||
@@ -280,6 +281,231 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
}
|
||||
|
||||
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 async void EditPythonScript_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: AdvancedPastePythonScriptAction action })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot initial values to detect changes
|
||||
var initialName = action.Name;
|
||||
var initialDesc = action.Description;
|
||||
var initialPlatform = action.Platform;
|
||||
var initialText = action.SupportsText;
|
||||
var initialHtml = action.SupportsHtml;
|
||||
var initialImage = action.SupportsImage;
|
||||
var initialAudio = action.SupportsAudio;
|
||||
var initialVideo = action.SupportsVideo;
|
||||
var initialFiles = action.SupportsFiles;
|
||||
var initialAutoDetect = action.RequiresAutoDetect;
|
||||
var initialRequires = action.Requires;
|
||||
|
||||
// Populate dialog fields from the action
|
||||
ScriptEditFilePath.Text = action.ScriptPath;
|
||||
ScriptEditName.Text = action.Name;
|
||||
ScriptEditDescription.Text = action.Description;
|
||||
ScriptEditPlatform.SelectedItem = action.Platform;
|
||||
ScriptEditText.IsChecked = action.SupportsText;
|
||||
ScriptEditHtml.IsChecked = action.SupportsHtml;
|
||||
ScriptEditImage.IsChecked = action.SupportsImage;
|
||||
ScriptEditAudio.IsChecked = action.SupportsAudio;
|
||||
ScriptEditVideo.IsChecked = action.SupportsVideo;
|
||||
ScriptEditFiles.IsChecked = action.SupportsFiles;
|
||||
ScriptEditAutoDetectDeps.IsOn = action.RequiresAutoDetect;
|
||||
ScriptEditRequires.Text = action.Requires;
|
||||
ScriptEditRequires.IsEnabled = !action.RequiresAutoDetect;
|
||||
|
||||
void OnDepsToggled(object s, RoutedEventArgs args) =>
|
||||
ScriptEditRequires.IsEnabled = !ScriptEditAutoDetectDeps.IsOn;
|
||||
|
||||
// Change detection: disable Apply button when nothing has changed
|
||||
PythonScriptEditDialog.IsPrimaryButtonEnabled = false;
|
||||
|
||||
void CheckForChanges(object s, object args)
|
||||
{
|
||||
bool hasChanges =
|
||||
ScriptEditName.Text != initialName ||
|
||||
ScriptEditDescription.Text != initialDesc ||
|
||||
(ScriptEditPlatform.SelectedItem as string ?? "windows") != initialPlatform ||
|
||||
(ScriptEditText.IsChecked == true) != initialText ||
|
||||
(ScriptEditHtml.IsChecked == true) != initialHtml ||
|
||||
(ScriptEditImage.IsChecked == true) != initialImage ||
|
||||
(ScriptEditAudio.IsChecked == true) != initialAudio ||
|
||||
(ScriptEditVideo.IsChecked == true) != initialVideo ||
|
||||
(ScriptEditFiles.IsChecked == true) != initialFiles ||
|
||||
ScriptEditAutoDetectDeps.IsOn != initialAutoDetect ||
|
||||
ScriptEditRequires.Text != initialRequires;
|
||||
PythonScriptEditDialog.IsPrimaryButtonEnabled = hasChanges;
|
||||
}
|
||||
|
||||
void OnTextChanged(object s, TextChangedEventArgs args) => CheckForChanges(s, args);
|
||||
void OnChecked(object s, RoutedEventArgs args) => CheckForChanges(s, args);
|
||||
void OnToggled(object s, RoutedEventArgs args)
|
||||
{
|
||||
OnDepsToggled(s, args);
|
||||
CheckForChanges(s, args);
|
||||
}
|
||||
|
||||
void OnSelectionChanged(object s, SelectionChangedEventArgs args) => CheckForChanges(s, args);
|
||||
|
||||
ScriptEditName.TextChanged += OnTextChanged;
|
||||
ScriptEditDescription.TextChanged += OnTextChanged;
|
||||
ScriptEditRequires.TextChanged += OnTextChanged;
|
||||
ScriptEditPlatform.SelectionChanged += OnSelectionChanged;
|
||||
ScriptEditText.Checked += OnChecked;
|
||||
ScriptEditText.Unchecked += OnChecked;
|
||||
ScriptEditHtml.Checked += OnChecked;
|
||||
ScriptEditHtml.Unchecked += OnChecked;
|
||||
ScriptEditImage.Checked += OnChecked;
|
||||
ScriptEditImage.Unchecked += OnChecked;
|
||||
ScriptEditAudio.Checked += OnChecked;
|
||||
ScriptEditAudio.Unchecked += OnChecked;
|
||||
ScriptEditVideo.Checked += OnChecked;
|
||||
ScriptEditVideo.Unchecked += OnChecked;
|
||||
ScriptEditFiles.Checked += OnChecked;
|
||||
ScriptEditFiles.Unchecked += OnChecked;
|
||||
ScriptEditAutoDetectDeps.Toggled += OnToggled;
|
||||
|
||||
try
|
||||
{
|
||||
PythonScriptEditDialog.XamlRoot = Content.XamlRoot;
|
||||
var result = await PythonScriptEditDialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
// Write dialog values back to the action
|
||||
action.Name = ScriptEditName.Text;
|
||||
action.Description = ScriptEditDescription.Text;
|
||||
action.Platform = ScriptEditPlatform.SelectedItem as string ?? "windows";
|
||||
action.SupportsText = ScriptEditText.IsChecked == true;
|
||||
action.SupportsHtml = ScriptEditHtml.IsChecked == true;
|
||||
action.SupportsImage = ScriptEditImage.IsChecked == true;
|
||||
action.SupportsAudio = ScriptEditAudio.IsChecked == true;
|
||||
action.SupportsVideo = ScriptEditVideo.IsChecked == true;
|
||||
action.SupportsFiles = ScriptEditFiles.IsChecked == true;
|
||||
action.RequiresAutoDetect = ScriptEditAutoDetectDeps.IsOn;
|
||||
action.Requires = ScriptEditRequires.Text;
|
||||
|
||||
await ApplySingleActionAsync(action);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScriptEditName.TextChanged -= OnTextChanged;
|
||||
ScriptEditDescription.TextChanged -= OnTextChanged;
|
||||
ScriptEditRequires.TextChanged -= OnTextChanged;
|
||||
ScriptEditPlatform.SelectionChanged -= OnSelectionChanged;
|
||||
ScriptEditText.Checked -= OnChecked;
|
||||
ScriptEditText.Unchecked -= OnChecked;
|
||||
ScriptEditHtml.Checked -= OnChecked;
|
||||
ScriptEditHtml.Unchecked -= OnChecked;
|
||||
ScriptEditImage.Checked -= OnChecked;
|
||||
ScriptEditImage.Unchecked -= OnChecked;
|
||||
ScriptEditAudio.Checked -= OnChecked;
|
||||
ScriptEditAudio.Unchecked -= OnChecked;
|
||||
ScriptEditVideo.Checked -= OnChecked;
|
||||
ScriptEditVideo.Unchecked -= OnChecked;
|
||||
ScriptEditFiles.Checked -= OnChecked;
|
||||
ScriptEditFiles.Unchecked -= OnChecked;
|
||||
ScriptEditAutoDetectDeps.Toggled -= OnToggled;
|
||||
}
|
||||
}
|
||||
|
||||
private async void ScriptEnabledToggle_Toggled(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is ToggleSwitch toggle && toggle.DataContext is AdvancedPastePythonScriptAction action)
|
||||
{
|
||||
action.IsEnabled = toggle.IsOn;
|
||||
await ApplySingleActionAsync(action);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySingleActionAsync(AdvancedPastePythonScriptAction action)
|
||||
{
|
||||
try
|
||||
{
|
||||
ViewModel?.ApplySingleScriptChange(action);
|
||||
}
|
||||
catch (System.IO.IOException)
|
||||
{
|
||||
var headerText = ViewModels.AdvancedPasteViewModel.GenerateHeaderText(action);
|
||||
var errorDialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = Content.XamlRoot,
|
||||
Title = "Unable to write to file",
|
||||
PrimaryButtonText = "Copy header",
|
||||
CloseButtonText = "OK",
|
||||
PrimaryButtonStyle = (Style)Application.Current.Resources["AccentButtonStyle"],
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"The file is locked by another process and cannot be updated:\n{action.ScriptPath}\n\nCopy the header below and paste it at the top of the file manually:",
|
||||
TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap,
|
||||
},
|
||||
new TextBox
|
||||
{
|
||||
Text = headerText,
|
||||
IsReadOnly = true,
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap,
|
||||
FontFamily = new FontFamily("Consolas"),
|
||||
MaxHeight = 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var dialogResult = await errorDialog.ShowAsync();
|
||||
if (dialogResult == ContentDialogResult.Primary)
|
||||
{
|
||||
var dataPackage = new DataPackage();
|
||||
dataPackage.SetText(headerText);
|
||||
Clipboard.SetContent(dataPackage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -601,6 +601,21 @@ opera.exe</value>
|
||||
<data name="AdvancedPaste_PythonScripts_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Python scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_EnableCard.Header" xml:space="preserve">
|
||||
<value>Enable Python scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_EnableCard.Description" xml:space="preserve">
|
||||
<value>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>
|
||||
@@ -610,6 +625,18 @@ opera.exe</value>
|
||||
<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_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,28 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
|
||||
|
||||
public bool IsPythonScriptsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
return scripts?.IsEnabled ?? false;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
if (scripts.IsEnabled != value)
|
||||
{
|
||||
scripts.IsEnabled = value;
|
||||
OnPropertyChanged(nameof(IsPythonScriptsEnabled));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ScriptsDiscovered => _scriptsDiscovered;
|
||||
|
||||
public string PythonExecutablePath
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.PythonExecutablePath ?? string.Empty;
|
||||
@@ -308,6 +330,349 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public string ScriptsFolder
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.ScriptsFolder ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
if (!string.Equals(scripts.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.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;
|
||||
|
||||
using var reader = new System.IO.StreamReader(filePath, System.Text.Encoding.UTF8);
|
||||
int lineCount = 0;
|
||||
while (lineCount < 50)
|
||||
{
|
||||
var line = reader.ReadLine();
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lineCount++;
|
||||
var trimmed = line.Trim();
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
private static bool ContainsTag(string line, string tag)
|
||||
{
|
||||
return line.IndexOf(tag, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes changed metadata back to each script's header and saves settings.
|
||||
/// </summary>
|
||||
public void ApplyPythonScriptChanges()
|
||||
{
|
||||
foreach (var action in PythonScriptActions)
|
||||
{
|
||||
try
|
||||
{
|
||||
WriteScriptHeader(action);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip files that can't be written
|
||||
}
|
||||
}
|
||||
|
||||
// Save the actions to settings
|
||||
var pythonSettings = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
pythonSettings.Value = [.. PythonScriptActions.Select(a => (AdvancedPastePythonScriptAction)a.Clone())];
|
||||
SaveAndNotifySettings();
|
||||
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies changes for a single script action. Writes the header to file and saves settings.
|
||||
/// Throws IOException if the file is locked.
|
||||
/// </summary>
|
||||
public void ApplySingleScriptChange(AdvancedPastePythonScriptAction action)
|
||||
{
|
||||
WriteScriptHeader(action);
|
||||
|
||||
var pythonSettings = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
pythonSettings.Value = [.. PythonScriptActions.Select(a => (AdvancedPastePythonScriptAction)a.Clone())];
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the full header text for a script action, for manual copy/paste fallback.
|
||||
/// </summary>
|
||||
public static string GenerateHeaderText(AdvancedPastePythonScriptAction action)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(string.Concat("# @advancedpaste:name ", action.Name));
|
||||
sb.AppendLine(string.Concat("# @advancedpaste:desc ", action.Description));
|
||||
sb.AppendLine(string.Concat("# @advancedpaste:platform ", action.Platform));
|
||||
sb.AppendLine(string.Concat("# @advancedpaste:formats ", action.Formats));
|
||||
sb.AppendLine(string.Concat("# @advancedpaste:enabled ", action.IsEnabled ? "true" : "false"));
|
||||
if (!action.RequiresAutoDetect && !string.IsNullOrWhiteSpace(action.Requires))
|
||||
{
|
||||
sb.AppendLine(string.Concat("# @advancedpaste:requires ", action.Requires));
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static void WriteScriptHeader(AdvancedPastePythonScriptAction action)
|
||||
{
|
||||
if (!System.IO.File.Exists(action.ScriptPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = System.IO.File.ReadAllLines(action.ScriptPath, System.Text.Encoding.UTF8).ToList();
|
||||
|
||||
var tagUpdates = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["@advancedpaste:name"] = action.Name,
|
||||
["@advancedpaste:desc"] = action.Description,
|
||||
["@advancedpaste:platform"] = action.Platform,
|
||||
["@advancedpaste:formats"] = action.Formats,
|
||||
["@advancedpaste:enabled"] = action.IsEnabled ? "true" : "false",
|
||||
};
|
||||
|
||||
// Only write requires tag in manual mode
|
||||
if (!action.RequiresAutoDetect && !string.IsNullOrWhiteSpace(action.Requires))
|
||||
{
|
||||
tagUpdates["@advancedpaste:requires"] = action.Requires;
|
||||
}
|
||||
|
||||
var updatedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int lastTagLine = -1;
|
||||
|
||||
// Collect indices of duplicate tag lines and requires lines to remove
|
||||
var linesToRemove = new HashSet<int>();
|
||||
|
||||
for (int i = 0; i < Math.Min(lines.Count, 50); i++)
|
||||
{
|
||||
var trimmed = lines[i].Trim();
|
||||
if (!trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle requires tag separately (may need full removal in auto mode)
|
||||
if (ContainsTag(trimmed, "@advancedpaste:requires"))
|
||||
{
|
||||
lastTagLine = i;
|
||||
|
||||
if (action.RequiresAutoDetect)
|
||||
{
|
||||
// Auto-detect mode: remove all requires lines
|
||||
linesToRemove.Add(i);
|
||||
}
|
||||
else if (!updatedTags.Contains("@advancedpaste:requires"))
|
||||
{
|
||||
// Manual mode: update first occurrence
|
||||
if (tagUpdates.ContainsKey("@advancedpaste:requires"))
|
||||
{
|
||||
lines[i] = $"# @advancedpaste:requires {action.Requires}";
|
||||
updatedTags.Add("@advancedpaste:requires");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Duplicate requires line: remove
|
||||
linesToRemove.Add(i);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (tag, newValue) in tagUpdates)
|
||||
{
|
||||
if (ContainsTag(trimmed, tag))
|
||||
{
|
||||
if (!updatedTags.Contains(tag))
|
||||
{
|
||||
// First occurrence: update in place
|
||||
lines[i] = $"# {tag} {newValue}";
|
||||
updatedTags.Add(tag);
|
||||
lastTagLine = i;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Duplicate: mark for removal
|
||||
linesToRemove.Add(i);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.Contains("@advancedpaste:"))
|
||||
{
|
||||
lastTagLine = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate/unwanted lines in reverse order to preserve indices
|
||||
foreach (var idx in linesToRemove.OrderByDescending(x => x))
|
||||
{
|
||||
lines.RemoveAt(idx);
|
||||
}
|
||||
|
||||
// Recalculate insertion point after removals
|
||||
var removedBeforeLastTag = linesToRemove.Count(idx => idx <= lastTagLine);
|
||||
var insertionPoint = lastTagLine >= 0 ? lastTagLine + 1 - removedBeforeLastTag : 0;
|
||||
|
||||
var newLines = new List<string>();
|
||||
foreach (var (tag, newValue) in tagUpdates)
|
||||
{
|
||||
if (!updatedTags.Contains(tag))
|
||||
{
|
||||
newLines.Add($"# {tag} {newValue}");
|
||||
}
|
||||
}
|
||||
|
||||
if (newLines.Count > 0)
|
||||
{
|
||||
insertionPoint = Math.Min(insertionPoint, lines.Count);
|
||||
lines.InsertRange(insertionPoint, newLines);
|
||||
}
|
||||
|
||||
System.IO.File.WriteAllLines(action.ScriptPath, lines, new System.Text.UTF8Encoding(false));
|
||||
}
|
||||
|
||||
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