speaksy 0.1.0__py3-none-any.whl
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.
- speaksy/__init__.py +3 -0
- speaksy/__main__.py +6 -0
- speaksy/cli.py +307 -0
- speaksy/config.py +157 -0
- speaksy/core.py +540 -0
- speaksy/runner.py +31 -0
- speaksy/service.py +205 -0
- speaksy/setup_wizard.py +216 -0
- speaksy-0.1.0.dist-info/METADATA +246 -0
- speaksy-0.1.0.dist-info/RECORD +13 -0
- speaksy-0.1.0.dist-info/WHEEL +4 -0
- speaksy-0.1.0.dist-info/entry_points.txt +2 -0
- speaksy-0.1.0.dist-info/licenses/LICENSE +21 -0
speaksy/__init__.py
ADDED
speaksy/__main__.py
ADDED
speaksy/cli.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Interactive CLI for Speaksy."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Prompt, Confirm
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from speaksy import __version__
|
|
11
|
+
from speaksy import config
|
|
12
|
+
from speaksy import service
|
|
13
|
+
from speaksy.setup_wizard import run_setup
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_banner():
|
|
19
|
+
"""Print the speaksy banner."""
|
|
20
|
+
banner = """
|
|
21
|
+
[bold cyan]╭────────────────────────────────────────╮[/bold cyan]
|
|
22
|
+
[bold cyan]│[/bold cyan] [bold white]SPEAKSY[/bold white] [bold cyan]│[/bold cyan]
|
|
23
|
+
[bold cyan]│[/bold cyan] [dim]talk it. type it. ship it.[/dim] [bold cyan]│[/bold cyan]
|
|
24
|
+
[bold cyan]╰────────────────────────────────────────╯[/bold cyan]
|
|
25
|
+
"""
|
|
26
|
+
console.print(banner)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_status_display() -> str:
|
|
30
|
+
"""Get the current status as a styled string."""
|
|
31
|
+
if not config.is_configured():
|
|
32
|
+
return "[cyan]waiting for setup[/cyan]"
|
|
33
|
+
elif service.is_running():
|
|
34
|
+
return "[green]vibing[/green]"
|
|
35
|
+
else:
|
|
36
|
+
return "[yellow]sleeping[/yellow]"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def print_status_line():
|
|
40
|
+
"""Print the current status line."""
|
|
41
|
+
status = get_status_display()
|
|
42
|
+
console.print(f" [bold]Status:[/bold] {status}")
|
|
43
|
+
|
|
44
|
+
if config.is_configured():
|
|
45
|
+
ptt, toggle = config.get_hotkeys()
|
|
46
|
+
ptt_display = ptt.replace("Key.", "").replace("_", " ").title()
|
|
47
|
+
toggle_display = toggle.replace("Key.", "").upper()
|
|
48
|
+
console.print(f" [bold]Hotkeys:[/bold] {ptt_display} (hold) | {toggle_display} (toggle)")
|
|
49
|
+
console.print()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_commands():
|
|
53
|
+
"""Print available commands."""
|
|
54
|
+
console.print(" [dim]Commands:[/dim]")
|
|
55
|
+
console.print(" [cyan]/setup[/cyan] get your keys in here")
|
|
56
|
+
console.print(" [cyan]/start[/cyan] let's gooo")
|
|
57
|
+
console.print(" [cyan]/stop[/cyan] take a break")
|
|
58
|
+
console.print(" [cyan]/status[/cyan] what's the vibe?")
|
|
59
|
+
console.print(" [cyan]/logs[/cyan] receipts")
|
|
60
|
+
console.print(" [cyan]/config[/cyan] tweak the drip")
|
|
61
|
+
console.print(" [cyan]/help[/cyan] need backup?")
|
|
62
|
+
console.print(" [cyan]/quit[/cyan] peace out")
|
|
63
|
+
console.print()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_setup():
|
|
67
|
+
"""Run the setup wizard."""
|
|
68
|
+
run_setup()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_start():
|
|
72
|
+
"""Start the service."""
|
|
73
|
+
if not config.is_configured():
|
|
74
|
+
console.print("[yellow]hold up, run /setup first[/yellow]")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if service.is_running():
|
|
78
|
+
console.print("[yellow]already vibing fam[/yellow]")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
console.print("[dim]starting...[/dim]")
|
|
82
|
+
if service.start_service():
|
|
83
|
+
console.print("[green]lesss gooo! speaksy is now listening...[/green]")
|
|
84
|
+
ptt, toggle = config.get_hotkeys()
|
|
85
|
+
ptt_display = ptt.replace("Key.", "").replace("_", " ").title()
|
|
86
|
+
toggle_display = toggle.replace("Key.", "").upper()
|
|
87
|
+
console.print(f" hold [cyan]{ptt_display}[/cyan] or tap [cyan]{toggle_display}[/cyan] to speak")
|
|
88
|
+
else:
|
|
89
|
+
console.print("[red]couldn't start the service[/red]")
|
|
90
|
+
console.print("[dim]check /logs for more info[/dim]")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_stop():
|
|
94
|
+
"""Stop the service."""
|
|
95
|
+
if not service.is_running():
|
|
96
|
+
console.print("[yellow]already sleeping[/yellow]")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
console.print("[dim]stopping...[/dim]")
|
|
100
|
+
if service.stop_service():
|
|
101
|
+
console.print("[yellow]aight speaksy is taking a nap. run /start when you need me[/yellow]")
|
|
102
|
+
else:
|
|
103
|
+
console.print("[red]couldn't stop the service[/red]")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_status():
|
|
107
|
+
"""Show detailed status."""
|
|
108
|
+
console.print()
|
|
109
|
+
console.print("[bold]the vibe check:[/bold]")
|
|
110
|
+
|
|
111
|
+
status = service.get_status()
|
|
112
|
+
|
|
113
|
+
if status["running"]:
|
|
114
|
+
uptime = status.get("uptime", "unknown")
|
|
115
|
+
console.print(f" [green]├─ service: running for {uptime}[/green]")
|
|
116
|
+
elif status["installed"]:
|
|
117
|
+
console.print(" [yellow]├─ service: stopped[/yellow]")
|
|
118
|
+
else:
|
|
119
|
+
console.print(" [red]├─ service: not installed[/red]")
|
|
120
|
+
|
|
121
|
+
if config.api_key_exists():
|
|
122
|
+
console.print(" [green]├─ api key: configured ✓[/green]")
|
|
123
|
+
else:
|
|
124
|
+
console.print(" [red]├─ api key: not set[/red]")
|
|
125
|
+
|
|
126
|
+
mode = config.get_privacy_mode()
|
|
127
|
+
if mode == "local":
|
|
128
|
+
console.print(" [cyan]└─ mode: local (privacy mode)[/cyan]")
|
|
129
|
+
else:
|
|
130
|
+
console.print(" [green]└─ mode: cloud (groq)[/green]")
|
|
131
|
+
|
|
132
|
+
console.print()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_logs():
|
|
136
|
+
"""Show recent logs."""
|
|
137
|
+
console.print()
|
|
138
|
+
console.print("[bold]receipts:[/bold]")
|
|
139
|
+
console.print()
|
|
140
|
+
logs = service.get_logs(lines=15)
|
|
141
|
+
console.print(f"[dim]{logs}[/dim]")
|
|
142
|
+
console.print()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_config():
|
|
146
|
+
"""Interactive config editor."""
|
|
147
|
+
console.print()
|
|
148
|
+
console.print("[bold]settings:[/bold]")
|
|
149
|
+
console.print()
|
|
150
|
+
console.print(" 1. API key")
|
|
151
|
+
console.print(" 2. Hotkeys")
|
|
152
|
+
console.print(" 3. Privacy mode (cloud/local)")
|
|
153
|
+
console.print(" 4. Text cleanup on/off")
|
|
154
|
+
console.print(" 5. Back")
|
|
155
|
+
console.print()
|
|
156
|
+
|
|
157
|
+
choice = Prompt.ask("[cyan]pick one[/cyan]", choices=["1", "2", "3", "4", "5"], default="5")
|
|
158
|
+
|
|
159
|
+
if choice == "1":
|
|
160
|
+
console.print()
|
|
161
|
+
new_key = Prompt.ask("[cyan]new API key[/cyan]", password=True)
|
|
162
|
+
if new_key:
|
|
163
|
+
config.save_api_key(new_key)
|
|
164
|
+
console.print("[green]saved ✓[/green]")
|
|
165
|
+
if service.is_running():
|
|
166
|
+
service.restart_service()
|
|
167
|
+
console.print("[dim]service restarted[/dim]")
|
|
168
|
+
|
|
169
|
+
elif choice == "2":
|
|
170
|
+
console.print()
|
|
171
|
+
console.print("[dim]examples: Key.ctrl_r, Key.f8, Key.alt_l[/dim]")
|
|
172
|
+
ptt, toggle = config.get_hotkeys()
|
|
173
|
+
|
|
174
|
+
new_ptt = Prompt.ask(f"[cyan]push-to-talk[/cyan]", default=ptt)
|
|
175
|
+
new_toggle = Prompt.ask(f"[cyan]toggle[/cyan]", default=toggle)
|
|
176
|
+
|
|
177
|
+
config.set_hotkeys(new_ptt, new_toggle)
|
|
178
|
+
console.print("[green]saved ✓[/green]")
|
|
179
|
+
|
|
180
|
+
if service.is_running():
|
|
181
|
+
service.restart_service()
|
|
182
|
+
console.print("[dim]service restarted[/dim]")
|
|
183
|
+
|
|
184
|
+
elif choice == "3":
|
|
185
|
+
console.print()
|
|
186
|
+
current = config.get_privacy_mode()
|
|
187
|
+
console.print(f"[dim]currently: {current}[/dim]")
|
|
188
|
+
console.print()
|
|
189
|
+
console.print(" 1. [green]cloud[/green] - Groq API, <1s response")
|
|
190
|
+
console.print(" 2. [cyan]local[/cyan] - runs on your CPU, ~3-5s response")
|
|
191
|
+
console.print()
|
|
192
|
+
|
|
193
|
+
mode_choice = Prompt.ask("[cyan]pick[/cyan]", choices=["1", "2"], default="1" if current == "cloud" else "2")
|
|
194
|
+
new_mode = "cloud" if mode_choice == "1" else "local"
|
|
195
|
+
config.set_privacy_mode(new_mode)
|
|
196
|
+
|
|
197
|
+
if new_mode == "local":
|
|
198
|
+
console.print("[cyan]switched to local-only mode[/cyan]")
|
|
199
|
+
console.print("[dim]your voice never leaves your machine[/dim]")
|
|
200
|
+
else:
|
|
201
|
+
console.print("[green]switched to cloud mode[/green]")
|
|
202
|
+
|
|
203
|
+
if service.is_running():
|
|
204
|
+
service.restart_service()
|
|
205
|
+
|
|
206
|
+
elif choice == "4":
|
|
207
|
+
console.print()
|
|
208
|
+
current = config.get_cleanup_enabled()
|
|
209
|
+
console.print(f"[dim]currently: {'on' if current else 'off'}[/dim]")
|
|
210
|
+
|
|
211
|
+
new_state = Confirm.ask("[cyan]enable text cleanup?[/cyan]", default=current)
|
|
212
|
+
config.set_cleanup_enabled(new_state)
|
|
213
|
+
console.print(f"[green]cleanup {'enabled' if new_state else 'disabled'} ✓[/green]")
|
|
214
|
+
|
|
215
|
+
if service.is_running():
|
|
216
|
+
service.restart_service()
|
|
217
|
+
|
|
218
|
+
console.print()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_help():
|
|
222
|
+
"""Show help."""
|
|
223
|
+
console.print()
|
|
224
|
+
console.print("[bold]speaksy commands:[/bold]")
|
|
225
|
+
console.print()
|
|
226
|
+
|
|
227
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
228
|
+
table.add_column("Command", style="cyan")
|
|
229
|
+
table.add_column("Description")
|
|
230
|
+
|
|
231
|
+
table.add_row("/setup", "Run the setup wizard (API key, hotkeys, etc.)")
|
|
232
|
+
table.add_row("/start", "Start the voice typing service")
|
|
233
|
+
table.add_row("/stop", "Stop the voice typing service")
|
|
234
|
+
table.add_row("/status", "Show detailed status info")
|
|
235
|
+
table.add_row("/logs", "View recent service logs")
|
|
236
|
+
table.add_row("/config", "Edit settings (hotkeys, privacy mode, etc.)")
|
|
237
|
+
table.add_row("/help", "Show this help message")
|
|
238
|
+
table.add_row("/quit", "Exit CLI (service keeps running)")
|
|
239
|
+
|
|
240
|
+
console.print(table)
|
|
241
|
+
console.print()
|
|
242
|
+
console.print("[dim]tip: service runs in background, so you can close this anytime[/dim]")
|
|
243
|
+
console.print()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run_repl():
|
|
247
|
+
"""Run the interactive REPL."""
|
|
248
|
+
commands = {
|
|
249
|
+
"/setup": cmd_setup,
|
|
250
|
+
"/start": cmd_start,
|
|
251
|
+
"/stop": cmd_stop,
|
|
252
|
+
"/status": cmd_status,
|
|
253
|
+
"/logs": cmd_logs,
|
|
254
|
+
"/config": cmd_config,
|
|
255
|
+
"/help": cmd_help,
|
|
256
|
+
"/quit": lambda: None,
|
|
257
|
+
"/exit": lambda: None,
|
|
258
|
+
"/q": lambda: None,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
while True:
|
|
262
|
+
try:
|
|
263
|
+
user_input = Prompt.ask("[bold magenta]speaksy>[/bold magenta]").strip().lower()
|
|
264
|
+
|
|
265
|
+
if not user_input:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if user_input in ("/quit", "/exit", "/q"):
|
|
269
|
+
console.print("[dim]peace out ✌️[/dim]")
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
if user_input in commands:
|
|
273
|
+
commands[user_input]()
|
|
274
|
+
elif user_input.startswith("/"):
|
|
275
|
+
console.print(f"[red]unknown command: {user_input}[/red]")
|
|
276
|
+
console.print("[dim]type /help for available commands[/dim]")
|
|
277
|
+
else:
|
|
278
|
+
console.print("[dim]commands start with / (try /help)[/dim]")
|
|
279
|
+
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
console.print()
|
|
282
|
+
console.print("[dim]peace out ✌️[/dim]")
|
|
283
|
+
break
|
|
284
|
+
except EOFError:
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main():
|
|
289
|
+
"""Main entry point."""
|
|
290
|
+
print_banner()
|
|
291
|
+
print_status_line()
|
|
292
|
+
|
|
293
|
+
# Auto-run setup if not configured
|
|
294
|
+
if not config.is_configured():
|
|
295
|
+
console.print("[yellow]looks like this is your first time![/yellow]")
|
|
296
|
+
console.print("[dim]let's get you set up...[/dim]")
|
|
297
|
+
console.print()
|
|
298
|
+
run_setup()
|
|
299
|
+
console.print()
|
|
300
|
+
else:
|
|
301
|
+
print_commands()
|
|
302
|
+
|
|
303
|
+
run_repl()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
main()
|
speaksy/config.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Configuration management for Speaksy."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
# XDG config directory
|
|
9
|
+
CONFIG_DIR = Path.home() / ".config" / "speaksy"
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
11
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG = {
|
|
14
|
+
"stt": {
|
|
15
|
+
"primary": "groq",
|
|
16
|
+
"groq_model": "whisper-large-v3-turbo",
|
|
17
|
+
"local_model": "base",
|
|
18
|
+
"local_device": "cpu",
|
|
19
|
+
"local_compute_type": "int8",
|
|
20
|
+
"language": "en",
|
|
21
|
+
},
|
|
22
|
+
"cleanup": {
|
|
23
|
+
"enabled": True,
|
|
24
|
+
"model": "llama-3.1-8b-instant",
|
|
25
|
+
},
|
|
26
|
+
"audio": {
|
|
27
|
+
"sample_rate": 16000,
|
|
28
|
+
"channels": 1,
|
|
29
|
+
"pre_buffer_seconds": 0.5,
|
|
30
|
+
},
|
|
31
|
+
"hotkeys": {
|
|
32
|
+
"push_to_talk": "Key.ctrl_r",
|
|
33
|
+
"toggle": "Key.f8",
|
|
34
|
+
},
|
|
35
|
+
"text_injection": {
|
|
36
|
+
"method": "clipboard",
|
|
37
|
+
"restore_clipboard": True,
|
|
38
|
+
},
|
|
39
|
+
"tray": {
|
|
40
|
+
"enabled": True,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def deep_merge(base: dict, override: dict) -> dict:
|
|
46
|
+
"""Recursively merge override into base dict."""
|
|
47
|
+
result = base.copy()
|
|
48
|
+
for key, value in override.items():
|
|
49
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
50
|
+
result[key] = deep_merge(result[key], value)
|
|
51
|
+
else:
|
|
52
|
+
result[key] = value
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_config_dir():
|
|
57
|
+
"""Create config directory if it doesn't exist."""
|
|
58
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def config_exists() -> bool:
|
|
62
|
+
"""Check if config file exists."""
|
|
63
|
+
return CONFIG_FILE.exists()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config() -> dict:
|
|
67
|
+
"""Load config from YAML, merge with defaults."""
|
|
68
|
+
config = DEFAULT_CONFIG.copy()
|
|
69
|
+
if CONFIG_FILE.exists():
|
|
70
|
+
with open(CONFIG_FILE) as f:
|
|
71
|
+
user_config = yaml.safe_load(f) or {}
|
|
72
|
+
config = deep_merge(DEFAULT_CONFIG, user_config)
|
|
73
|
+
return config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_config(config: dict):
|
|
77
|
+
"""Save config to YAML file."""
|
|
78
|
+
ensure_config_dir()
|
|
79
|
+
with open(CONFIG_FILE, "w") as f:
|
|
80
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_api_key() -> str:
|
|
84
|
+
"""Get API key from .env file."""
|
|
85
|
+
if ENV_FILE.exists():
|
|
86
|
+
with open(ENV_FILE) as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if line.startswith("GROQ_API_KEY="):
|
|
90
|
+
return line.split("=", 1)[1].strip()
|
|
91
|
+
return os.getenv("GROQ_API_KEY", "")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def save_api_key(api_key: str):
|
|
95
|
+
"""Save API key to .env file."""
|
|
96
|
+
ensure_config_dir()
|
|
97
|
+
with open(ENV_FILE, "w") as f:
|
|
98
|
+
f.write(f"GROQ_API_KEY={api_key}\n")
|
|
99
|
+
# Set restrictive permissions
|
|
100
|
+
os.chmod(ENV_FILE, 0o600)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def api_key_exists() -> bool:
|
|
104
|
+
"""Check if API key is configured."""
|
|
105
|
+
return bool(get_api_key())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_configured() -> bool:
|
|
109
|
+
"""Check if speaksy is fully configured."""
|
|
110
|
+
return config_exists() and api_key_exists()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_hotkeys() -> tuple:
|
|
114
|
+
"""Get current hotkey settings."""
|
|
115
|
+
config = load_config()
|
|
116
|
+
hotkeys = config.get("hotkeys", {})
|
|
117
|
+
return (
|
|
118
|
+
hotkeys.get("push_to_talk", "Key.ctrl_r"),
|
|
119
|
+
hotkeys.get("toggle", "Key.f8"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def set_hotkeys(push_to_talk: str, toggle: str):
|
|
124
|
+
"""Update hotkey settings."""
|
|
125
|
+
config = load_config()
|
|
126
|
+
config["hotkeys"]["push_to_talk"] = push_to_talk
|
|
127
|
+
config["hotkeys"]["toggle"] = toggle
|
|
128
|
+
save_config(config)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_privacy_mode() -> str:
|
|
132
|
+
"""Get current privacy mode (cloud or local)."""
|
|
133
|
+
config = load_config()
|
|
134
|
+
primary = config.get("stt", {}).get("primary", "groq")
|
|
135
|
+
return "local" if primary == "local" else "cloud"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def set_privacy_mode(mode: str):
|
|
139
|
+
"""Set privacy mode (cloud or local)."""
|
|
140
|
+
config = load_config()
|
|
141
|
+
config["stt"]["primary"] = "local" if mode == "local" else "groq"
|
|
142
|
+
save_config(config)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_cleanup_enabled() -> bool:
|
|
146
|
+
"""Check if text cleanup is enabled."""
|
|
147
|
+
config = load_config()
|
|
148
|
+
return config.get("cleanup", {}).get("enabled", True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def set_cleanup_enabled(enabled: bool):
|
|
152
|
+
"""Enable or disable text cleanup."""
|
|
153
|
+
config = load_config()
|
|
154
|
+
if "cleanup" not in config:
|
|
155
|
+
config["cleanup"] = {}
|
|
156
|
+
config["cleanup"]["enabled"] = enabled
|
|
157
|
+
save_config(config)
|