PayPerTranscript 0.2.9__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PKG-INFO +2 -1
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/PKG-INFO +2 -1
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/SOURCES.txt +1 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/README.md +1 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/__init__.py +1 -1
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/config.py +28 -8
- paypertranscript-0.3.1/paypertranscript/core/context_detector.py +255 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/hotkey.py +42 -7
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/text_inserter.py +23 -8
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/pipeline/transcription.py +107 -4
- paypertranscript-0.3.1/paypertranscript/providers/groq_provider.py +273 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/app.py +87 -6
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/overlay.py +43 -1
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/tray.py +15 -9
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/pyproject.toml +1 -1
- paypertranscript-0.2.9/paypertranscript/providers/groq_provider.py +0 -193
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/LICENSE +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/dependency_links.txt +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/entry_points.txt +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/requires.txt +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/top_level.txt +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/__main__.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/app.ico +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/app.png +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/app_big.png +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/arrow_down.svg +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/tray.png +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/tray_green.png +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/tray_orange.png +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/sounds/start.wav +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/sounds/stop.wav +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/styles/dark.qss +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/__init__.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/audio_manager.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/cost_tracker.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/logging.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/paths.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/recorder.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/session_logger.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/updater.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/window_detector.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/pipeline/__init__.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/providers/__init__.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/providers/base.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/__init__.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/animated.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/constants.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/main_window.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/__init__.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/home_page.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/settings_page.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/statistics_page.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/window_mapping_page.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/word_list_page.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/setup_wizard.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/sidebar.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/widgets.py +0 -0
- {paypertranscript-0.2.9 → paypertranscript-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PayPerTranscript
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
|
|
5
5
|
Author: PayPerTranscript Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -65,6 +65,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
|
|
|
65
65
|
- **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
|
|
66
66
|
- **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
|
|
67
67
|
- **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
|
|
68
|
+
- **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
|
|
68
69
|
- **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
|
|
69
70
|
|
|
70
71
|
### 📊 Transparenz & Kontrolle
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PayPerTranscript
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
|
|
5
5
|
Author: PayPerTranscript Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -65,6 +65,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
|
|
|
65
65
|
- **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
|
|
66
66
|
- **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
|
|
67
67
|
- **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
|
|
68
|
+
- **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
|
|
68
69
|
- **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
|
|
69
70
|
|
|
70
71
|
### 📊 Transparenz & Kontrolle
|
|
@@ -22,6 +22,7 @@ paypertranscript/assets/styles/dark.qss
|
|
|
22
22
|
paypertranscript/core/__init__.py
|
|
23
23
|
paypertranscript/core/audio_manager.py
|
|
24
24
|
paypertranscript/core/config.py
|
|
25
|
+
paypertranscript/core/context_detector.py
|
|
25
26
|
paypertranscript/core/cost_tracker.py
|
|
26
27
|
paypertranscript/core/hotkey.py
|
|
27
28
|
paypertranscript/core/logging.py
|
|
@@ -36,6 +36,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
|
|
|
36
36
|
- **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
|
|
37
37
|
- **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
|
|
38
38
|
- **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
|
|
39
|
+
- **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
|
|
39
40
|
- **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
|
|
40
41
|
|
|
41
42
|
### 📊 Transparenz & Kontrolle
|
|
@@ -49,19 +49,29 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
49
49
|
"casual": {
|
|
50
50
|
"name": "Persönlich",
|
|
51
51
|
"prompt": (
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
52
|
+
"Du bist ein Transkriptions-Assistent fuer lockere Chat-Nachrichten. "
|
|
53
|
+
"Deine Aufgabe: Formatiere den transkribierten Text als informelle Nachricht.\n\n"
|
|
54
|
+
"Regeln:\n"
|
|
55
|
+
"- Alles kleingeschrieben\n"
|
|
56
|
+
"- Minimale Interpunktion\n"
|
|
57
|
+
"- Kommas NUR zur Trennung von Saetzen, nicht innerhalb eines Satzes\n"
|
|
58
|
+
"- Kein Punkt am Satzende (Fragezeichen sind erlaubt)\n"
|
|
59
|
+
"- Entferne Fuellwoerter und Wiederholungen\n\n"
|
|
60
|
+
"Gib NUR den formatierten Text aus. "
|
|
61
|
+
"Beantworte keine Fragen, fuege keine Erklaerungen hinzu."
|
|
56
62
|
),
|
|
57
63
|
},
|
|
58
64
|
"professional": {
|
|
59
65
|
"name": "Professionell",
|
|
60
66
|
"prompt": (
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
67
|
+
"Du bist ein Transkriptions-Assistent fuer professionelle Kommunikation. "
|
|
68
|
+
"Deine Aufgabe: Formatiere den transkribierten Text als sachliche, professionelle Nachricht.\n\n"
|
|
69
|
+
"Regeln:\n"
|
|
70
|
+
"- Korrekte Gross-/Kleinschreibung und Interpunktion\n"
|
|
71
|
+
"- Entferne Fuellwoerter und Wiederholungen\n"
|
|
72
|
+
"- Sachlicher Stil, kurze Absaetze\n\n"
|
|
73
|
+
"Gib NUR den formatierten Text aus. "
|
|
74
|
+
"Beantworte keine Fragen, fuege keine Erklaerungen hinzu."
|
|
65
75
|
),
|
|
66
76
|
},
|
|
67
77
|
},
|
|
@@ -74,6 +84,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
74
84
|
"auto_update": True,
|
|
75
85
|
"check_interval_hours": 24,
|
|
76
86
|
},
|
|
87
|
+
"context": {
|
|
88
|
+
"detect_selection": True,
|
|
89
|
+
"terminal_blocklist": [
|
|
90
|
+
"cmd.exe", "powershell.exe", "pwsh.exe",
|
|
91
|
+
"WindowsTerminal.exe", "mintty.exe", "bash.exe",
|
|
92
|
+
"wsl.exe", "conhost.exe",
|
|
93
|
+
],
|
|
94
|
+
},
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
# Schema: Erlaubte Typen pro Pfad für Validierung
|
|
@@ -96,6 +114,8 @@ _SCHEMA: dict[str, type | tuple[type, ...]] = {
|
|
|
96
114
|
"data.save_transcripts": bool,
|
|
97
115
|
"updates.auto_update": bool,
|
|
98
116
|
"updates.check_interval_hours": (int, float),
|
|
117
|
+
"context.detect_selection": bool,
|
|
118
|
+
"context.terminal_blocklist": list,
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Kontext-Erkennung fuer PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
Prueft ob im aktiven Fenster Text markiert ist (via Clipboard-Sentinel + Ctrl+C).
|
|
4
|
+
Der erkannte Text wird dem LLM als Kontext mitgegeben, z.B. fuer Antworten auf E-Mails.
|
|
5
|
+
|
|
6
|
+
Die Erkennung laeuft parallel zum STT-API-Call und fuegt 0ms zusaetzliche Latenz hinzu.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import Future
|
|
15
|
+
|
|
16
|
+
import pyautogui
|
|
17
|
+
import pyperclip
|
|
18
|
+
|
|
19
|
+
from paypertranscript.core.config import ConfigManager
|
|
20
|
+
from paypertranscript.core.logging import get_logger
|
|
21
|
+
from paypertranscript.core.window_detector import WindowInfo
|
|
22
|
+
|
|
23
|
+
log = get_logger("core.context_detector")
|
|
24
|
+
|
|
25
|
+
# Sentinel: Null-Bytes koennen nicht in normalem Clipboard-Text vorkommen
|
|
26
|
+
_SENTINEL = "\x00__PPT_SENTINEL__\x00"
|
|
27
|
+
|
|
28
|
+
# Wartezeit nach Ctrl+C bevor Clipboard gelesen wird (ms)
|
|
29
|
+
_CLIPBOARD_WAIT_MS = 80
|
|
30
|
+
|
|
31
|
+
# Timeout fuer Modifier-Release-Wait (ms)
|
|
32
|
+
_MODIFIER_RELEASE_TIMEOUT_MS = 400
|
|
33
|
+
|
|
34
|
+
# Virtual-Key-Codes fuer Modifier-Keys (Win32)
|
|
35
|
+
_VK_MODIFIERS = (
|
|
36
|
+
0x10, # VK_SHIFT
|
|
37
|
+
0x11, # VK_CONTROL
|
|
38
|
+
0x12, # VK_MENU (Alt)
|
|
39
|
+
0x5B, # VK_LWIN
|
|
40
|
+
0x5C, # VK_RWIN
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Terminal-Prozesse in denen Ctrl+C nicht gesendet werden darf (SIGINT-Gefahr)
|
|
44
|
+
_DEFAULT_TERMINAL_BLOCKLIST = frozenset({
|
|
45
|
+
"cmd.exe", "powershell.exe", "pwsh.exe",
|
|
46
|
+
"windowsterminal.exe", "mintty.exe", "bash.exe",
|
|
47
|
+
"wsl.exe", "conhost.exe", "alacritty.exe",
|
|
48
|
+
"wezterm-gui.exe", "hyper.exe",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_selected_text(
|
|
53
|
+
window: WindowInfo | None,
|
|
54
|
+
config: ConfigManager,
|
|
55
|
+
cancel_event: threading.Event | None = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Prueft ob im aktiven Fenster Text markiert ist und gibt ihn zurueck.
|
|
58
|
+
|
|
59
|
+
Ablauf:
|
|
60
|
+
1. Feature-Flag und Terminal-Blocklist pruefen
|
|
61
|
+
2. Clipboard sichern → Sentinel setzen → Ctrl+C → Clipboard lesen → wiederherstellen
|
|
62
|
+
3. Wenn Clipboard != Sentinel: markierter Text gefunden
|
|
63
|
+
|
|
64
|
+
Gibt in ALLEN Fehler-/Abbruch-Faellen "" zurueck — wirft nie Exceptions.
|
|
65
|
+
Die Pipeline wird dadurch nie blockiert oder gestoert.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
window: Info ueber das aktive Fenster (fuer Blocklist-Check).
|
|
69
|
+
config: ConfigManager-Instanz.
|
|
70
|
+
cancel_event: Optionales Event zum Abbrechen der Erkennung.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Markierter Text oder "" wenn nichts markiert / Fehler / deaktiviert.
|
|
74
|
+
"""
|
|
75
|
+
t_start = time.perf_counter()
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# 1. Feature-Flag pruefen
|
|
79
|
+
if not config.get("context.detect_selection", True):
|
|
80
|
+
log.debug("Context detection disabled by config")
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
# 2. Abbruch pruefen
|
|
84
|
+
if cancel_event and cancel_event.is_set():
|
|
85
|
+
log.debug("Context detection cancelled before start")
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
# 3. Terminal-Blocklist pruefen
|
|
89
|
+
if window and window.process_name:
|
|
90
|
+
process_lower = window.process_name.lower()
|
|
91
|
+
blocklist = config.get("context.terminal_blocklist", [])
|
|
92
|
+
blocklist_lower = {p.lower() for p in blocklist}
|
|
93
|
+
# Auch Default-Blocklist pruefen
|
|
94
|
+
blocklist_lower.update(p.lower() for p in _DEFAULT_TERMINAL_BLOCKLIST)
|
|
95
|
+
|
|
96
|
+
if process_lower in blocklist_lower:
|
|
97
|
+
log.debug(
|
|
98
|
+
"Context detection skipped: terminal process '%s'",
|
|
99
|
+
window.process_name,
|
|
100
|
+
)
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
log.debug(
|
|
104
|
+
"Context detection started for window '%s'",
|
|
105
|
+
window.process_name,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
log.debug("Context detection started (no window info)")
|
|
109
|
+
|
|
110
|
+
# 4. Clipboard sichern
|
|
111
|
+
try:
|
|
112
|
+
original_clipboard = pyperclip.paste()
|
|
113
|
+
except Exception:
|
|
114
|
+
original_clipboard = ""
|
|
115
|
+
log.debug("Clipboard backed up (%d chars)", len(original_clipboard))
|
|
116
|
+
|
|
117
|
+
# 5. Sentinel auf Clipboard setzen
|
|
118
|
+
try:
|
|
119
|
+
pyperclip.copy(_SENTINEL)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
log.warning("Context detection: clipboard write failed: %s", e)
|
|
122
|
+
return ""
|
|
123
|
+
log.debug("Sentinel placed on clipboard")
|
|
124
|
+
|
|
125
|
+
# 6. Abbruch pruefen
|
|
126
|
+
if cancel_event and cancel_event.is_set():
|
|
127
|
+
_restore_clipboard(original_clipboard)
|
|
128
|
+
log.debug("Context detection cancelled before Ctrl+C")
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
# 7. Warten bis Modifier-Keys losgelassen sind (noetig fuer Toggle-Hotkey:
|
|
132
|
+
# User haelt noch Ctrl+Alt → Ctrl+C wuerde als Ctrl+Alt+C ankommen)
|
|
133
|
+
_wait_for_modifiers_released()
|
|
134
|
+
|
|
135
|
+
# 8. Ctrl+C senden
|
|
136
|
+
pyautogui.hotkey("ctrl", "c")
|
|
137
|
+
t_ctrlc = time.perf_counter()
|
|
138
|
+
log.debug("Ctrl+C sent (%.1fms after start)", (t_ctrlc - t_start) * 1000)
|
|
139
|
+
|
|
140
|
+
# 9. Warten bis Clipboard aktualisiert
|
|
141
|
+
time.sleep(_CLIPBOARD_WAIT_MS / 1000)
|
|
142
|
+
|
|
143
|
+
# 10. Clipboard lesen
|
|
144
|
+
try:
|
|
145
|
+
clipboard_content = pyperclip.paste()
|
|
146
|
+
except Exception as e:
|
|
147
|
+
log.warning("Context detection: clipboard read failed: %s", e)
|
|
148
|
+
_restore_clipboard(original_clipboard)
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
t_read = time.perf_counter()
|
|
152
|
+
log.debug(
|
|
153
|
+
"Clipboard read after Ctrl+C (%.1fms after start)",
|
|
154
|
+
(t_read - t_start) * 1000,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# 11. Auswerten: Hat Ctrl+C den Sentinel ueberschrieben?
|
|
158
|
+
if clipboard_content == _SENTINEL:
|
|
159
|
+
# Sentinel unveraendert → nichts war markiert
|
|
160
|
+
_restore_clipboard(original_clipboard)
|
|
161
|
+
t_end = time.perf_counter()
|
|
162
|
+
log.debug("No text selected (%.1fms total)", (t_end - t_start) * 1000)
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
# Text war markiert!
|
|
166
|
+
selected_text = clipboard_content.strip()
|
|
167
|
+
|
|
168
|
+
# 12. Original-Clipboard wiederherstellen
|
|
169
|
+
_restore_clipboard(original_clipboard)
|
|
170
|
+
|
|
171
|
+
t_end = time.perf_counter()
|
|
172
|
+
if selected_text:
|
|
173
|
+
log.info(
|
|
174
|
+
"Selected text detected: %d chars (%.1fms total)",
|
|
175
|
+
len(selected_text),
|
|
176
|
+
(t_end - t_start) * 1000,
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
log.debug("No text selected (empty after strip, %.1fms total)", (t_end - t_start) * 1000)
|
|
180
|
+
|
|
181
|
+
return selected_text
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
t_end = time.perf_counter()
|
|
185
|
+
log.warning(
|
|
186
|
+
"Context detection failed (%.1fms): %s",
|
|
187
|
+
(t_end - t_start) * 1000,
|
|
188
|
+
e,
|
|
189
|
+
)
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _wait_for_modifiers_released() -> None:
|
|
194
|
+
"""Wartet bis alle Modifier-Keys (Ctrl, Alt, Shift, Win) losgelassen sind.
|
|
195
|
+
|
|
196
|
+
Noetig fuer Toggle-Hotkey: Der User haelt noch Ctrl+Alt wenn die
|
|
197
|
+
Context-Detection startet. Ctrl+C waehrend Ctrl+Alt gehalten wird,
|
|
198
|
+
wuerde als Ctrl+Alt+C interpretiert und Copy nicht ausloesen.
|
|
199
|
+
|
|
200
|
+
Beim Hold-Hotkey sind die Keys bereits losgelassen → returned sofort.
|
|
201
|
+
"""
|
|
202
|
+
user32 = ctypes.windll.user32
|
|
203
|
+
deadline = time.perf_counter() + _MODIFIER_RELEASE_TIMEOUT_MS / 1000
|
|
204
|
+
|
|
205
|
+
while time.perf_counter() < deadline:
|
|
206
|
+
if not any(user32.GetAsyncKeyState(vk) & 0x8000 for vk in _VK_MODIFIERS):
|
|
207
|
+
return
|
|
208
|
+
time.sleep(0.01) # 10ms polling
|
|
209
|
+
|
|
210
|
+
# Timeout: Modifier immer noch gehalten — trotzdem weitermachen
|
|
211
|
+
log.debug(
|
|
212
|
+
"Modifier keys still held after %dms timeout",
|
|
213
|
+
_MODIFIER_RELEASE_TIMEOUT_MS,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _restore_clipboard(content: str) -> None:
|
|
218
|
+
"""Stellt den Clipboard-Inhalt wieder her (best-effort)."""
|
|
219
|
+
try:
|
|
220
|
+
pyperclip.copy(content)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
log.warning("Context detection: clipboard restore failed: %s", e)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def detect_selected_text_async(
|
|
226
|
+
window: WindowInfo | None,
|
|
227
|
+
config: ConfigManager,
|
|
228
|
+
cancel_event: threading.Event | None = None,
|
|
229
|
+
) -> Future[str]:
|
|
230
|
+
"""Startet detect_selected_text() in einem daemon-Thread.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
window: Info ueber das aktive Fenster.
|
|
234
|
+
config: ConfigManager-Instanz.
|
|
235
|
+
cancel_event: Optionales Event zum Abbrechen.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Future[str] das den markierten Text (oder "") enthaelt.
|
|
239
|
+
"""
|
|
240
|
+
future: Future[str] = Future()
|
|
241
|
+
|
|
242
|
+
def _worker() -> None:
|
|
243
|
+
try:
|
|
244
|
+
result = detect_selected_text(window, config, cancel_event)
|
|
245
|
+
future.set_result(result)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
future.set_exception(e)
|
|
248
|
+
|
|
249
|
+
thread = threading.Thread(
|
|
250
|
+
target=_worker,
|
|
251
|
+
daemon=True,
|
|
252
|
+
name="context-detector",
|
|
253
|
+
)
|
|
254
|
+
thread.start()
|
|
255
|
+
return future
|
|
@@ -5,6 +5,7 @@ Unterstützt Hold-to-Record und Toggle-Modus.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import threading
|
|
8
|
+
import time
|
|
8
9
|
from collections.abc import Callable
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -54,9 +55,18 @@ _MODIFIER_GROUPS: dict[str, set[keyboard.Key]] = {
|
|
|
54
55
|
"cmd": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
# Alle Modifier-Keys (flach) fuer Exakt-Match-Pruefung
|
|
59
|
+
_ALL_MODIFIER_KEYS: set[keyboard.Key] = set()
|
|
60
|
+
for _grp in _MODIFIER_GROUPS.values():
|
|
61
|
+
_ALL_MODIFIER_KEYS |= _grp
|
|
62
|
+
|
|
57
63
|
# Alt-Keys fuer Menu-Bar-Workaround (Windows aktiviert Menueleiste bei bare Alt-Release)
|
|
58
64
|
_ALT_KEYS: set[keyboard.Key] = {keyboard.Key.alt_l, keyboard.Key.alt_r}
|
|
59
65
|
|
|
66
|
+
# Minimale Zeit zwischen zwei Toggle-Ausloesungen (Sekunden).
|
|
67
|
+
# Verhindert Ghost-Toggles durch synthetische Key-Events (z.B. pyautogui Ctrl+C).
|
|
68
|
+
_TOGGLE_DEBOUNCE_S = 0.5
|
|
69
|
+
|
|
60
70
|
|
|
61
71
|
def _resolve_key(key_str: str) -> keyboard.Key | keyboard.KeyCode:
|
|
62
72
|
"""Löst einen Config-String in ein pynput-Key-Objekt auf."""
|
|
@@ -136,6 +146,7 @@ class HotkeyListener:
|
|
|
136
146
|
self._pressed_keys: set[keyboard.Key | keyboard.KeyCode] = set()
|
|
137
147
|
self._hold_active = False
|
|
138
148
|
self._toggle_combo_held = False
|
|
149
|
+
self._toggle_last_fired: float = 0.0
|
|
139
150
|
self._listener: keyboard.Listener | None = None
|
|
140
151
|
self._lock = threading.Lock()
|
|
141
152
|
self._kb_controller: keyboard.Controller | None = None
|
|
@@ -149,18 +160,33 @@ class HotkeyListener:
|
|
|
149
160
|
target_keys: list[keyboard.Key | keyboard.KeyCode],
|
|
150
161
|
modifier_groups: list[set[keyboard.Key]],
|
|
151
162
|
) -> bool:
|
|
152
|
-
"""Prüft ob eine Tastenkombination aktuell gedrückt ist.
|
|
163
|
+
"""Prüft ob eine Tastenkombination aktuell gedrückt ist.
|
|
164
|
+
|
|
165
|
+
Exaktes Modifier-Matching: es muessen genau die konfigurierten Modifier
|
|
166
|
+
gedrueckt sein, keine zusaetzlichen. Damit wird verhindert, dass z.B.
|
|
167
|
+
Ctrl+Win auch durch Ctrl+Shift+Alt+F9 ausgeloest wird.
|
|
168
|
+
"""
|
|
153
169
|
if not target_keys:
|
|
154
170
|
return False
|
|
155
171
|
|
|
172
|
+
# Sammle welche Modifier-Gruppen zum Hotkey gehoeren
|
|
173
|
+
required_modifier_keys: set[keyboard.Key] = set()
|
|
174
|
+
|
|
156
175
|
for i, target_key in enumerate(target_keys):
|
|
157
176
|
# Für Modifier: prüfe ob *irgendein* Key aus der Gruppe gedrückt ist
|
|
158
177
|
if i < len(modifier_groups) and modifier_groups[i]:
|
|
159
178
|
if not (modifier_groups[i] & self._pressed_keys):
|
|
160
179
|
return False
|
|
180
|
+
required_modifier_keys |= modifier_groups[i]
|
|
161
181
|
else:
|
|
162
182
|
if target_key not in self._pressed_keys:
|
|
163
183
|
return False
|
|
184
|
+
|
|
185
|
+
# Pruefe ob Extra-Modifier gedrueckt sind, die nicht zum Hotkey gehoeren
|
|
186
|
+
extra_modifiers = (self._pressed_keys & _ALL_MODIFIER_KEYS) - required_modifier_keys
|
|
187
|
+
if extra_modifiers:
|
|
188
|
+
return False
|
|
189
|
+
|
|
164
190
|
return True
|
|
165
191
|
|
|
166
192
|
def _combo_uses_alt(self, target_keys: list[keyboard.Key | keyboard.KeyCode]) -> bool:
|
|
@@ -192,12 +218,20 @@ class HotkeyListener:
|
|
|
192
218
|
if self._on_hold_start:
|
|
193
219
|
threading.Thread(target=self._on_hold_start, daemon=True).start()
|
|
194
220
|
|
|
195
|
-
# Toggle-Hotkey prüfen
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
221
|
+
# Toggle-Hotkey prüfen (Guard + Debounce gegen synthetische Key-Events)
|
|
222
|
+
if (self._toggle_keys
|
|
223
|
+
and not self._toggle_combo_held
|
|
224
|
+
and self._check_combo(self._toggle_keys, self._toggle_modifier_groups)):
|
|
225
|
+
now = time.monotonic()
|
|
226
|
+
if (now - self._toggle_last_fired) >= _TOGGLE_DEBOUNCE_S:
|
|
227
|
+
self._toggle_combo_held = True
|
|
228
|
+
self._toggle_last_fired = now
|
|
229
|
+
log.debug("Toggle-Hotkey gedrückt")
|
|
230
|
+
if self._on_toggle:
|
|
231
|
+
threading.Thread(target=self._on_toggle, daemon=True).start()
|
|
232
|
+
else:
|
|
233
|
+
self._toggle_combo_held = True
|
|
234
|
+
log.debug("Toggle-Hotkey ignoriert (Debounce)")
|
|
201
235
|
|
|
202
236
|
def _on_release(self, key: keyboard.Key | keyboard.KeyCode) -> None:
|
|
203
237
|
"""Callback für Key-Release-Events."""
|
|
@@ -281,6 +315,7 @@ class HotkeyListener:
|
|
|
281
315
|
# State zurücksetzen
|
|
282
316
|
self._hold_active = False
|
|
283
317
|
self._toggle_combo_held = False
|
|
318
|
+
self._toggle_last_fired = 0.0
|
|
284
319
|
self._pressed_keys.clear()
|
|
285
320
|
|
|
286
321
|
@property
|
|
@@ -19,6 +19,27 @@ log = get_logger("core.text_inserter")
|
|
|
19
19
|
pyautogui.FAILSAFE = False
|
|
20
20
|
pyautogui.PAUSE = 0
|
|
21
21
|
|
|
22
|
+
# Clipboard-Wiederherstellung: Retry-Konfiguration
|
|
23
|
+
_CLIPBOARD_RESTORE_RETRIES = 3
|
|
24
|
+
_CLIPBOARD_RESTORE_DELAY = 0.05 # 50ms zwischen Versuchen
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _restore_clipboard(content: str) -> None:
|
|
28
|
+
"""Stellt die Zwischenablage wieder her mit Retry-Logik.
|
|
29
|
+
|
|
30
|
+
Andere Apps (Clipboard-Manager, Password-Manager) koennen die
|
|
31
|
+
Zwischenablage kurzzeitig sperren. Daher mehrere Versuche.
|
|
32
|
+
"""
|
|
33
|
+
for attempt in range(1, _CLIPBOARD_RESTORE_RETRIES + 1):
|
|
34
|
+
try:
|
|
35
|
+
pyperclip.copy(content)
|
|
36
|
+
return
|
|
37
|
+
except Exception:
|
|
38
|
+
if attempt < _CLIPBOARD_RESTORE_RETRIES:
|
|
39
|
+
time.sleep(_CLIPBOARD_RESTORE_DELAY)
|
|
40
|
+
else:
|
|
41
|
+
log.warning("Zwischenablage konnte nicht wiederhergestellt werden (nach %d Versuchen)", _CLIPBOARD_RESTORE_RETRIES)
|
|
42
|
+
|
|
22
43
|
|
|
23
44
|
def insert_text(text: str) -> None:
|
|
24
45
|
"""Fügt Text an der aktuellen Cursor-Position ein.
|
|
@@ -62,10 +83,7 @@ def insert_text(text: str) -> None:
|
|
|
62
83
|
|
|
63
84
|
finally:
|
|
64
85
|
# 5. Alte Zwischenablage wiederherstellen
|
|
65
|
-
|
|
66
|
-
pyperclip.copy(old_clipboard)
|
|
67
|
-
except Exception:
|
|
68
|
-
log.debug("Zwischenablage konnte nicht wiederhergestellt werden")
|
|
86
|
+
_restore_clipboard(old_clipboard)
|
|
69
87
|
|
|
70
88
|
|
|
71
89
|
# Intervall (Sekunden) zwischen Chunk-Pastes bei Streaming-Typing
|
|
@@ -125,7 +143,4 @@ def insert_text_streaming(chunks: Iterator[str]) -> None:
|
|
|
125
143
|
log.error("Auch Fallback-Paste fehlgeschlagen")
|
|
126
144
|
|
|
127
145
|
finally:
|
|
128
|
-
|
|
129
|
-
pyperclip.copy(old_clipboard)
|
|
130
|
-
except Exception:
|
|
131
|
-
log.debug("Zwischenablage konnte nicht wiederhergestellt werden")
|
|
146
|
+
_restore_clipboard(old_clipboard)
|