PayPerTranscript 0.3.0__tar.gz → 0.3.2__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.
Files changed (69) hide show
  1. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PKG-INFO +3 -1
  2. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/PKG-INFO +3 -1
  3. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/SOURCES.txt +14 -1
  4. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/requires.txt +1 -0
  5. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/README.md +1 -0
  6. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/__init__.py +1 -1
  7. paypertranscript-0.3.2/paypertranscript/core/atomic_io.py +29 -0
  8. paypertranscript-0.3.2/paypertranscript/core/clipboard_manager.py +263 -0
  9. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/config.py +38 -16
  10. paypertranscript-0.3.2/paypertranscript/core/context_detector.py +250 -0
  11. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/hotkey.py +50 -26
  12. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/recorder.py +9 -8
  13. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/session_logger.py +4 -2
  14. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/text_inserter.py +49 -40
  15. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/updater.py +7 -5
  16. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/pipeline/transcription.py +71 -8
  17. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/providers/groq_provider.py +2 -2
  18. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/app.py +42 -6
  19. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/main_window.py +9 -5
  20. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/pages/home_page.py +2 -2
  21. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/pages/settings_page.py +9 -2
  22. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/tray.py +3 -0
  23. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/pyproject.toml +2 -1
  24. paypertranscript-0.3.2/tests/test_atomic_and_session_logger.py +104 -0
  25. paypertranscript-0.3.2/tests/test_clipboard_integration.py +73 -0
  26. paypertranscript-0.3.2/tests/test_clipboard_manager_formats.py +60 -0
  27. paypertranscript-0.3.2/tests/test_groq_provider_logging.py +42 -0
  28. paypertranscript-0.3.2/tests/test_home_page_config_key.py +94 -0
  29. paypertranscript-0.3.2/tests/test_hotkey_settings_and_timer.py +112 -0
  30. paypertranscript-0.3.2/tests/test_pipeline_privacy_and_transcripts.py +89 -0
  31. paypertranscript-0.3.2/tests/test_recorder_thread_safety.py +39 -0
  32. paypertranscript-0.3.2/tests/test_release_script.py +48 -0
  33. paypertranscript-0.3.2/tests/test_updater_version.py +14 -0
  34. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/LICENSE +0 -0
  35. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/dependency_links.txt +0 -0
  36. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/entry_points.txt +0 -0
  37. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/top_level.txt +0 -0
  38. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/__main__.py +0 -0
  39. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app.ico +0 -0
  40. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app.png +0 -0
  41. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app_big.png +0 -0
  42. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/arrow_down.svg +0 -0
  43. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray.png +0 -0
  44. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray_green.png +0 -0
  45. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray_orange.png +0 -0
  46. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/sounds/start.wav +0 -0
  47. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/sounds/stop.wav +0 -0
  48. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/assets/styles/dark.qss +0 -0
  49. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/__init__.py +0 -0
  50. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/audio_manager.py +0 -0
  51. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/cost_tracker.py +0 -0
  52. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/logging.py +0 -0
  53. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/paths.py +0 -0
  54. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/core/window_detector.py +0 -0
  55. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/pipeline/__init__.py +0 -0
  56. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/providers/__init__.py +0 -0
  57. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/providers/base.py +0 -0
  58. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/__init__.py +0 -0
  59. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/animated.py +0 -0
  60. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/constants.py +0 -0
  61. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/overlay.py +0 -0
  62. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/pages/__init__.py +0 -0
  63. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/pages/statistics_page.py +0 -0
  64. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/pages/window_mapping_page.py +0 -0
  65. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/pages/word_list_page.py +0 -0
  66. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/setup_wizard.py +0 -0
  67. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/sidebar.py +0 -0
  68. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/paypertranscript/ui/widgets.py +0 -0
  69. {paypertranscript-0.3.0 → paypertranscript-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PayPerTranscript
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
5
5
  Author: PayPerTranscript Contributors
6
6
  License-Expression: MIT
@@ -22,6 +22,7 @@ Requires-Dist: pyperclip
22
22
  Requires-Dist: pyautogui
23
23
  Requires-Dist: keyring
24
24
  Requires-Dist: soundfile
25
+ Requires-Dist: packaging
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: build; extra == "dev"
27
28
  Requires-Dist: pytest; extra == "dev"
@@ -65,6 +66,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
65
66
  - **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
66
67
  - **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
67
68
  - **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
69
+ - **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
68
70
  - **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
69
71
 
70
72
  ### 📊 Transparenz & Kontrolle
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PayPerTranscript
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
5
5
  Author: PayPerTranscript Contributors
6
6
  License-Expression: MIT
@@ -22,6 +22,7 @@ Requires-Dist: pyperclip
22
22
  Requires-Dist: pyautogui
23
23
  Requires-Dist: keyring
24
24
  Requires-Dist: soundfile
25
+ Requires-Dist: packaging
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: build; extra == "dev"
27
28
  Requires-Dist: pytest; extra == "dev"
@@ -65,6 +66,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
65
66
  - **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
66
67
  - **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
67
68
  - **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
69
+ - **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
68
70
  - **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
69
71
 
70
72
  ### 📊 Transparenz & Kontrolle
@@ -20,8 +20,11 @@ paypertranscript/assets/sounds/start.wav
20
20
  paypertranscript/assets/sounds/stop.wav
21
21
  paypertranscript/assets/styles/dark.qss
22
22
  paypertranscript/core/__init__.py
23
+ paypertranscript/core/atomic_io.py
23
24
  paypertranscript/core/audio_manager.py
25
+ paypertranscript/core/clipboard_manager.py
24
26
  paypertranscript/core/config.py
27
+ paypertranscript/core/context_detector.py
25
28
  paypertranscript/core/cost_tracker.py
26
29
  paypertranscript/core/hotkey.py
27
30
  paypertranscript/core/logging.py
@@ -51,4 +54,14 @@ paypertranscript/ui/pages/home_page.py
51
54
  paypertranscript/ui/pages/settings_page.py
52
55
  paypertranscript/ui/pages/statistics_page.py
53
56
  paypertranscript/ui/pages/window_mapping_page.py
54
- paypertranscript/ui/pages/word_list_page.py
57
+ paypertranscript/ui/pages/word_list_page.py
58
+ tests/test_atomic_and_session_logger.py
59
+ tests/test_clipboard_integration.py
60
+ tests/test_clipboard_manager_formats.py
61
+ tests/test_groq_provider_logging.py
62
+ tests/test_home_page_config_key.py
63
+ tests/test_hotkey_settings_and_timer.py
64
+ tests/test_pipeline_privacy_and_transcripts.py
65
+ tests/test_recorder_thread_safety.py
66
+ tests/test_release_script.py
67
+ tests/test_updater_version.py
@@ -9,6 +9,7 @@ pyperclip
9
9
  pyautogui
10
10
  keyring
11
11
  soundfile
12
+ packaging
12
13
 
13
14
  [dev]
14
15
  build
@@ -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
@@ -1,3 +1,3 @@
1
1
  """PayPerTranscript - Voice-to-Text mit Pay-per-Use Pricing."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -0,0 +1,29 @@
1
+ """Atomic file writing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+
10
+ def atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
11
+ """Atomically write text content to a file."""
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ fd, tmp_name = tempfile.mkstemp(
14
+ prefix=f".{path.name}.",
15
+ suffix=".tmp",
16
+ dir=str(path.parent),
17
+ )
18
+ try:
19
+ with os.fdopen(fd, "w", encoding=encoding, newline="") as tmp_file:
20
+ tmp_file.write(content)
21
+ tmp_file.flush()
22
+ os.fsync(tmp_file.fileno())
23
+ os.replace(tmp_name, path)
24
+ except Exception:
25
+ try:
26
+ os.unlink(tmp_name)
27
+ except OSError:
28
+ pass
29
+ raise
@@ -0,0 +1,263 @@
1
+ """Windows clipboard snapshot/restore helpers.
2
+
3
+ Designed for low-latency clipboard preservation during context detection and paste
4
+ operations. The implementation preserves clipboard formats best-effort by copying
5
+ raw global-memory payloads for each available format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Final
15
+
16
+ from paypertranscript.core.logging import get_logger
17
+
18
+ log = get_logger("core.clipboard_manager")
19
+
20
+ if ctypes.sizeof(ctypes.c_void_p) == 8:
21
+ _SIZE_T = ctypes.c_uint64
22
+ else:
23
+ _SIZE_T = ctypes.c_uint32
24
+
25
+ _user32 = ctypes.WinDLL("user32", use_last_error=True)
26
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
27
+
28
+ _user32.OpenClipboard.argtypes = [ctypes.c_void_p]
29
+ _user32.OpenClipboard.restype = ctypes.c_int
30
+ _user32.CloseClipboard.argtypes = []
31
+ _user32.CloseClipboard.restype = ctypes.c_int
32
+ _user32.EmptyClipboard.argtypes = []
33
+ _user32.EmptyClipboard.restype = ctypes.c_int
34
+ _user32.EnumClipboardFormats.argtypes = [ctypes.c_uint]
35
+ _user32.EnumClipboardFormats.restype = ctypes.c_uint
36
+ _user32.GetClipboardData.argtypes = [ctypes.c_uint]
37
+ _user32.GetClipboardData.restype = ctypes.c_void_p
38
+ _user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
39
+ _user32.SetClipboardData.restype = ctypes.c_void_p
40
+ _user32.IsClipboardFormatAvailable.argtypes = [ctypes.c_uint]
41
+ _user32.IsClipboardFormatAvailable.restype = ctypes.c_int
42
+ _user32.GetClipboardSequenceNumber.argtypes = []
43
+ _user32.GetClipboardSequenceNumber.restype = ctypes.c_uint
44
+
45
+ _kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, _SIZE_T]
46
+ _kernel32.GlobalAlloc.restype = ctypes.c_void_p
47
+ _kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
48
+ _kernel32.GlobalLock.restype = ctypes.c_void_p
49
+ _kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
50
+ _kernel32.GlobalUnlock.restype = ctypes.c_int
51
+ _kernel32.GlobalSize.argtypes = [ctypes.c_void_p]
52
+ _kernel32.GlobalSize.restype = _SIZE_T
53
+ _kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
54
+ _kernel32.GlobalFree.restype = ctypes.c_void_p
55
+
56
+ _GMEM_MOVEABLE: Final[int] = 0x0002
57
+ _CF_UNICODETEXT: Final[int] = 13
58
+
59
+ _OPEN_RETRIES: Final[int] = 5
60
+ _OPEN_DELAY_SECONDS: Final[float] = 0.002
61
+
62
+ _lock = threading.RLock()
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ClipboardFormatPayload:
67
+ """Raw bytes for a single clipboard format."""
68
+
69
+ format_id: int
70
+ data: bytes
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ClipboardSnapshot:
75
+ """Clipboard snapshot containing copied format payloads."""
76
+
77
+ sequence_number: int
78
+ formats: tuple[ClipboardFormatPayload, ...]
79
+
80
+
81
+ _EMPTY_SNAPSHOT = ClipboardSnapshot(sequence_number=0, formats=())
82
+
83
+
84
+ def _open_clipboard() -> bool:
85
+ for attempt in range(_OPEN_RETRIES):
86
+ if _user32.OpenClipboard(None):
87
+ return True
88
+ if attempt < _OPEN_RETRIES - 1:
89
+ time.sleep(_OPEN_DELAY_SECONDS)
90
+ return False
91
+
92
+
93
+ def _copy_hglobal(handle: ctypes.c_void_p) -> bytes | None:
94
+ size = int(_kernel32.GlobalSize(handle))
95
+ if size < 0:
96
+ return None
97
+
98
+ ptr = _kernel32.GlobalLock(handle)
99
+ if not ptr:
100
+ return None
101
+
102
+ try:
103
+ if size == 0:
104
+ return b""
105
+ raw = (ctypes.c_ubyte * size).from_address(ptr)
106
+ return bytes(raw)
107
+ finally:
108
+ _kernel32.GlobalUnlock(handle)
109
+
110
+
111
+ def _alloc_hglobal(data: bytes) -> ctypes.c_void_p:
112
+ size = max(len(data), 1)
113
+ handle = _kernel32.GlobalAlloc(_GMEM_MOVEABLE, size)
114
+ if not handle:
115
+ return ctypes.c_void_p()
116
+
117
+ ptr = _kernel32.GlobalLock(handle)
118
+ if not ptr:
119
+ _kernel32.GlobalFree(handle)
120
+ return ctypes.c_void_p()
121
+
122
+ try:
123
+ if data:
124
+ ctypes.memmove(ptr, data, len(data))
125
+ if size > len(data):
126
+ ctypes.memset(ptr + len(data), 0, size - len(data))
127
+ finally:
128
+ _kernel32.GlobalUnlock(handle)
129
+
130
+ return handle
131
+
132
+
133
+ def snapshot_clipboard() -> ClipboardSnapshot:
134
+ """Capture the current clipboard in a best-effort, format-preserving way."""
135
+ start = time.perf_counter()
136
+ with _lock:
137
+ if not _open_clipboard():
138
+ log.debug("Clipboard snapshot skipped: OpenClipboard failed")
139
+ return _EMPTY_SNAPSHOT
140
+
141
+ try:
142
+ sequence_number = int(_user32.GetClipboardSequenceNumber())
143
+ payloads: list[ClipboardFormatPayload] = []
144
+
145
+ fmt = 0
146
+ while True:
147
+ fmt = int(_user32.EnumClipboardFormats(fmt))
148
+ if fmt == 0:
149
+ break
150
+
151
+ handle = _user32.GetClipboardData(fmt)
152
+ if not handle:
153
+ continue
154
+
155
+ copied = _copy_hglobal(handle)
156
+ if copied is None:
157
+ continue
158
+
159
+ payloads.append(ClipboardFormatPayload(format_id=fmt, data=copied))
160
+
161
+ snapshot = ClipboardSnapshot(
162
+ sequence_number=sequence_number,
163
+ formats=tuple(payloads),
164
+ )
165
+ elapsed_ms = (time.perf_counter() - start) * 1000
166
+ log.debug(
167
+ "Clipboard snapshot: %d formats in %.2fms",
168
+ len(snapshot.formats),
169
+ elapsed_ms,
170
+ )
171
+ return snapshot
172
+ finally:
173
+ _user32.CloseClipboard()
174
+
175
+
176
+ def restore_clipboard(snapshot: ClipboardSnapshot) -> bool:
177
+ """Restore a previously captured clipboard snapshot."""
178
+ start = time.perf_counter()
179
+ with _lock:
180
+ if not _open_clipboard():
181
+ log.warning("Clipboard restore failed: OpenClipboard failed")
182
+ return False
183
+
184
+ try:
185
+ if not _user32.EmptyClipboard():
186
+ log.warning("Clipboard restore failed: EmptyClipboard failed")
187
+ return False
188
+
189
+ restored_count = 0
190
+ for payload in snapshot.formats:
191
+ handle = _alloc_hglobal(payload.data)
192
+ if not handle:
193
+ continue
194
+
195
+ result = _user32.SetClipboardData(payload.format_id, handle)
196
+ if not result:
197
+ _kernel32.GlobalFree(handle)
198
+ continue
199
+
200
+ restored_count += 1
201
+
202
+ elapsed_ms = (time.perf_counter() - start) * 1000
203
+ log.debug(
204
+ "Clipboard restore: %d/%d formats in %.2fms",
205
+ restored_count,
206
+ len(snapshot.formats),
207
+ elapsed_ms,
208
+ )
209
+ return restored_count == len(snapshot.formats)
210
+ finally:
211
+ _user32.CloseClipboard()
212
+
213
+
214
+ def get_clipboard_text() -> str:
215
+ """Read UTF-16 text from clipboard if available."""
216
+ with _lock:
217
+ if not _open_clipboard():
218
+ return ""
219
+
220
+ try:
221
+ if not _user32.IsClipboardFormatAvailable(_CF_UNICODETEXT):
222
+ return ""
223
+
224
+ handle = _user32.GetClipboardData(_CF_UNICODETEXT)
225
+ if not handle:
226
+ return ""
227
+
228
+ ptr = _kernel32.GlobalLock(handle)
229
+ if not ptr:
230
+ return ""
231
+
232
+ try:
233
+ return ctypes.wstring_at(ptr) or ""
234
+ finally:
235
+ _kernel32.GlobalUnlock(handle)
236
+ finally:
237
+ _user32.CloseClipboard()
238
+
239
+
240
+ def set_clipboard_text(text: str) -> bool:
241
+ """Replace clipboard contents with UTF-16 text."""
242
+ utf16 = text.encode("utf-16-le") + b"\x00\x00"
243
+
244
+ with _lock:
245
+ if not _open_clipboard():
246
+ return False
247
+
248
+ try:
249
+ if not _user32.EmptyClipboard():
250
+ return False
251
+
252
+ handle = _alloc_hglobal(utf16)
253
+ if not handle:
254
+ return False
255
+
256
+ result = _user32.SetClipboardData(_CF_UNICODETEXT, handle)
257
+ if not result:
258
+ _kernel32.GlobalFree(handle)
259
+ return False
260
+
261
+ return True
262
+ finally:
263
+ _user32.CloseClipboard()
@@ -11,6 +11,7 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ from paypertranscript.core.atomic_io import atomic_write_text
14
15
  from paypertranscript.core.logging import APPDATA_DIR, get_logger
15
16
 
16
17
  log = get_logger("core.config")
@@ -49,19 +50,29 @@ DEFAULT_CONFIG: dict[str, Any] = {
49
50
  "casual": {
50
51
  "name": "Persönlich",
51
52
  "prompt": (
52
- "Formatiere den folgenden transkribierten Text als lockere "
53
- "Chat-Nachricht. Alles kleingeschrieben, minimale Interpunktion, "
54
- "Kommas zur Trennung von Gedanken. Kein Punkt am Ende. "
55
- "Gib NUR den formatierten Text aus, keine Erklärungen."
53
+ "Du bist ein Transkriptions-Assistent fuer lockere Chat-Nachrichten. "
54
+ "Deine Aufgabe: Formatiere den transkribierten Text als informelle Nachricht.\n\n"
55
+ "Regeln:\n"
56
+ "- Alles kleingeschrieben\n"
57
+ "- Minimale Interpunktion\n"
58
+ "- Kommas NUR zur Trennung von Saetzen, nicht innerhalb eines Satzes\n"
59
+ "- Kein Punkt am Satzende (Fragezeichen sind erlaubt)\n"
60
+ "- Entferne Fuellwoerter und Wiederholungen\n\n"
61
+ "Gib NUR den formatierten Text aus. "
62
+ "Beantworte keine Fragen, fuege keine Erklaerungen hinzu."
56
63
  ),
57
64
  },
58
65
  "professional": {
59
66
  "name": "Professionell",
60
67
  "prompt": (
61
- "Formatiere den folgenden transkribierten Text als professionelle "
62
- "Nachricht. Korrekte Groß-/Kleinschreibung, saubere Interpunktion, "
63
- "entferne Füllwörter und Wiederholungen. Sachlicher Stil, kurze "
64
- "Absätze. Gib NUR den formatierten Text aus, keine Erklärungen."
68
+ "Du bist ein Transkriptions-Assistent fuer professionelle Kommunikation. "
69
+ "Deine Aufgabe: Formatiere den transkribierten Text als sachliche, professionelle Nachricht.\n\n"
70
+ "Regeln:\n"
71
+ "- Korrekte Gross-/Kleinschreibung und Interpunktion\n"
72
+ "- Entferne Fuellwoerter und Wiederholungen\n"
73
+ "- Sachlicher Stil, kurze Absaetze\n\n"
74
+ "Gib NUR den formatierten Text aus. "
75
+ "Beantworte keine Fragen, fuege keine Erklaerungen hinzu."
65
76
  ),
66
77
  },
67
78
  },
@@ -74,6 +85,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
74
85
  "auto_update": True,
75
86
  "check_interval_hours": 24,
76
87
  },
88
+ "context": {
89
+ "detect_selection": True,
90
+ "terminal_blocklist": [
91
+ "cmd.exe", "powershell.exe", "pwsh.exe",
92
+ "WindowsTerminal.exe", "mintty.exe", "bash.exe",
93
+ "wsl.exe", "conhost.exe",
94
+ ],
95
+ },
77
96
  }
78
97
 
79
98
  # Schema: Erlaubte Typen pro Pfad für Validierung
@@ -96,6 +115,8 @@ _SCHEMA: dict[str, type | tuple[type, ...]] = {
96
115
  "data.save_transcripts": bool,
97
116
  "updates.auto_update": bool,
98
117
  "updates.check_interval_hours": (int, float),
118
+ "context.detect_selection": bool,
119
+ "context.terminal_blocklist": list,
99
120
  }
100
121
 
101
122
 
@@ -144,14 +165,14 @@ def _validate_config(config: dict) -> dict:
144
165
 
145
166
  key = parts[-1]
146
167
  if key not in node:
147
- # Fehlender Wert Default einsetzen
168
+ # Fehlender Wert -> Default einsetzen
148
169
  node[key] = copy.deepcopy(default_node[key])
149
- log.warning("Config: Fehlender Wert '%s' Default verwendet", path)
170
+ log.warning("Config: Fehlender Wert '%s' -> Default verwendet", path)
150
171
  elif not isinstance(node[key], expected_type):
151
172
  old_val = node[key]
152
173
  node[key] = copy.deepcopy(default_node[key])
153
174
  log.warning(
154
- "Config: Ungültiger Typ für '%s' (%s statt %s) Default verwendet",
175
+ "Config: Ungültiger Typ für '%s' (%s statt %s) -> Default verwendet",
155
176
  path,
156
177
  type(old_val).__name__,
157
178
  expected_type,
@@ -184,13 +205,13 @@ class ConfigManager:
184
205
  try:
185
206
  raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
186
207
  if not isinstance(raw, dict):
187
- log.warning("Config-Datei enthält kein Dict Defaults verwendet")
208
+ log.warning("Config-Datei enthält kein Dict -> Defaults verwendet")
188
209
  raw = {}
189
210
  except (json.JSONDecodeError, OSError) as e:
190
- log.warning("Config-Datei konnte nicht gelesen werden: %s Defaults verwendet", e)
211
+ log.warning("Config-Datei konnte nicht gelesen werden: %s -> Defaults verwendet", e)
191
212
  raw = {}
192
213
  else:
193
- log.info("Keine Config-Datei gefunden Defaults werden verwendet")
214
+ log.info("Keine Config-Datei gefunden -> Defaults werden verwendet")
194
215
  raw = {}
195
216
 
196
217
  merged = _deep_merge(DEFAULT_CONFIG, raw)
@@ -201,7 +222,8 @@ class ConfigManager:
201
222
  """Speichert aktuelle Config in Datei."""
202
223
  _ensure_dirs()
203
224
  try:
204
- CONFIG_FILE.write_text(
225
+ atomic_write_text(
226
+ CONFIG_FILE,
205
227
  json.dumps(self._config, indent=2, ensure_ascii=False),
206
228
  encoding="utf-8",
207
229
  )
@@ -238,7 +260,7 @@ class ConfigManager:
238
260
  path: Punkt-separierter Pfad (z.B. 'general.language').
239
261
  value: Neuer Wert.
240
262
  """
241
- # Aktuellen Stand von Disk lesen (falls von außen geändert)
263
+ # Aktuellen Stand von Disk lesen (falls von aussen geändert)
242
264
  if CONFIG_FILE.exists():
243
265
  try:
244
266
  disk_config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))