keyflow-typing 1.4.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.
keyflow/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """KeyFlow – Tipptrainer fürs Terminal. Made by YnsLaf"""
2
+
3
+ __version__ = "1.4.0"
keyflow/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .app import main
4
+
5
+ sys.exit(main())
keyflow/app.py ADDED
@@ -0,0 +1,569 @@
1
+ """Programmablauf: Hauptmenü, Tests starten, Ergebnisse speichern."""
2
+
3
+ import argparse
4
+ import random
5
+ import sys
6
+ import time
7
+ import webbrowser
8
+ from datetime import date, datetime
9
+ from pathlib import Path
10
+
11
+ from . import i18n, screens, stats, stories, textgen, ui, updates
12
+ from . import terminal as T
13
+ from .engine import TypingTest
14
+ from .i18n import tr
15
+ from .storage import Store
16
+ from . import __version__
17
+
18
+
19
+ ENDLESS_START = 10.0 # Startzeit im Unendlich-Modus (Sekunden)
20
+ ENDLESS_MAX = 20.0 # mehr Zeitvorrat als das gibt es nicht
21
+ ENDLESS_PENALTY = 1.0 # Sekunden Abzug pro Tippfehler
22
+ ENDLESS_WORDS_PER_LEVEL = 20
23
+
24
+
25
+ def endless_target_wpm(level):
26
+ """Tempo, mit dem man im jeweiligen Level genau gleich viel Zeit gewinnt, wie
27
+ man verbraucht. Wer schneller tippt, baut Vorrat auf."""
28
+ return 25 + 5 * level
29
+
30
+
31
+ class Mode:
32
+ """Beschreibt einen Test.
33
+
34
+ kind: "time" (Zeitlimit), "text" (fester Text), "free" (endlos) oder
35
+ "endless" (Unendlich-Modus: Zeit läuft ab, richtige Wörter bringen Zeit).
36
+ source: liefert bei "time"/"free" immer neue Textstücke.
37
+ make_text: liefert bei "text" (text, hinweis)."""
38
+
39
+ def __init__(self, key, label, kind, limit=0, source=None, make_text=None,
40
+ on_complete=None, repeatable=True, count_words=False, extra=None):
41
+ self.key = key
42
+ self.label = label
43
+ self.kind = kind
44
+ self.limit = limit
45
+ self.source = source
46
+ self.make_text = make_text
47
+ self.on_complete = on_complete
48
+ self.repeatable = repeatable and kind == "text"
49
+ self.count_words = count_words
50
+ self.extra = extra # () -> zusätzliche Felder für den Verlauf
51
+ self.state = None # Laufzeitwerte des Unendlich-Modus
52
+
53
+
54
+ class App:
55
+ def __init__(self, term, store, rng=None):
56
+ self.term = term
57
+ self.store = store
58
+ self.rng = rng or random.Random()
59
+ i18n.set_language(store.settings["ui_language"])
60
+
61
+ # --- Hauptschleife ---------------------------------------------------
62
+ def apply_look(self):
63
+ """Farbmodus, Animationen und Terminal-Hintergrund aus den Einstellungen."""
64
+ ui.configure(self.settings)
65
+ _, rgb, opacity = ui.BACKGROUNDS[self.settings["background"]]
66
+ self.term.set_background(rgb, opacity)
67
+
68
+ def set_ui_language(self, lang):
69
+ """Oberfläche und Übungstexte auf eine Sprache umstellen."""
70
+ i18n.set_language(lang)
71
+ self.settings["ui_language"] = lang
72
+ self.settings["language"] = lang
73
+ self.store.save()
74
+
75
+ def run(self):
76
+ self.apply_look()
77
+ if self.store.needs_language:
78
+ lang = screens.language_picker(self.term)
79
+ self.settings["kb_layout"] = lang
80
+ self.set_ui_language(lang)
81
+ self.store.needs_language = False
82
+ if self.store.load_error:
83
+ path, error, backup = self.store.load_error
84
+ ui.message(self.term, tr("Hinweis", "Note"), [
85
+ tr("Die Datei %s war beschädigt (%s) und wurde nach %s verschoben.",
86
+ "The file %s was damaged (%s) and was moved to %s.") % (path, error, backup)])
87
+ self.update_achievements()
88
+ self.updater = updates.UpdateChecker(enabled=self.settings["check_updates"])
89
+ index = 0
90
+ while True:
91
+ action, index = screens.main_menu(self.term, self.store, index, self.updater)
92
+ if action in (None, "quit"):
93
+ return
94
+ if action == "quick":
95
+ self.open(self.settings["menu"]["last"])
96
+ elif action == "settings":
97
+ screens.settings_screen(self.term, self.store, on_look_change=self.apply_look,
98
+ on_language_change=self.set_ui_language)
99
+ elif action == "info":
100
+ self.info()
101
+ else:
102
+ self.category(action)
103
+
104
+ def info(self):
105
+ index = 0
106
+ while True:
107
+ action, index = screens.info_screen(self.term, self.store, self.updater, index)
108
+ if action in (None, "back"):
109
+ return
110
+ if action == "github":
111
+ webbrowser.open(updates.GITHUB_USER_URL)
112
+ elif action == "repo":
113
+ webbrowser.open(updates.GITHUB_REPO_URL)
114
+ elif action == "pypi":
115
+ webbrowser.open(updates.PROJECT_URL)
116
+ elif action == "check":
117
+ self.updater.start()
118
+ elif action == "update":
119
+ self.update_now()
120
+
121
+ def update_now(self):
122
+ with self.term.cooked():
123
+ print(tr("KeyFlow wird aktualisiert: ", "Updating KeyFlow: ") + updates.update_command_text() + "\n")
124
+ ok = updates.run_update()
125
+ print()
126
+ if ok:
127
+ print(tr("Fertig! Starte KeyFlow neu, um die neue Version zu nutzen.",
128
+ "Done! Restart KeyFlow to use the new version."))
129
+ else:
130
+ print(tr("Das Update hat nicht geklappt. Du kannst es selbst ausführen mit:",
131
+ "The update did not work. You can run it yourself with:"))
132
+ print(" " + updates.update_command_text())
133
+ try:
134
+ input(tr("\nWeiter mit Enter …", "\nPress Enter to continue …"))
135
+ except (EOFError, KeyboardInterrupt):
136
+ pass
137
+
138
+ def category(self, category):
139
+ index = 0
140
+ while True:
141
+ action, index = screens.category_menu(self.term, self.store, category, index)
142
+ if action in (None, "back"):
143
+ return
144
+ self.open(action)
145
+
146
+ def open(self, action):
147
+ """Startet einen Modus oder öffnet einen Bildschirm."""
148
+ if action == "stats":
149
+ screens.stats_screen(self.term, self.store)
150
+ elif action == "activity":
151
+ screens.activity_screen(self.term, self.store)
152
+ elif action == "achievements":
153
+ screens.achievements_screen(self.term, self.store)
154
+ elif action == "custom":
155
+ self.custom_texts()
156
+ else:
157
+ if self.settings["menu"]["last"] != action:
158
+ self.settings["menu"]["last"] = action
159
+ self.store.save()
160
+ if action == "stories":
161
+ self.stories_menu()
162
+ else:
163
+ self.run_mode(self.build_mode(action))
164
+
165
+ # --- Modi ------------------------------------------------------------
166
+ @property
167
+ def settings(self):
168
+ return self.store.settings
169
+
170
+ def _lang(self):
171
+ return self.settings["language"]
172
+
173
+ def _word_variant(self):
174
+ s = self.settings
175
+ key = s["language"] + ("-p" if s["punctuation"] else "") + ("-n" if s["numbers"] else "")
176
+ label = s["language"].upper()
177
+ if s["punctuation"]:
178
+ label += tr(" · Satzzeichen", " · punctuation")
179
+ if s["numbers"]:
180
+ label += tr(" · Zahlen", " · numbers")
181
+ return key, label
182
+
183
+ def _word_source(self):
184
+ s = self.settings
185
+ return textgen.WordSource(s["language"], s["difficulty"], s["punctuation"],
186
+ s["numbers"], s["lowercase"], rng=self.rng)
187
+
188
+ def _free_source(self, kind):
189
+ lang = self._lang()
190
+ if kind == "sentences":
191
+ return textgen.SentenceSource(lang, rng=self.rng)
192
+ if kind == "numbers":
193
+ return textgen.NumberSource(lang, rng=self.rng)
194
+ if kind == "symbols":
195
+ return textgen.SymbolSource(lang, rng=self.rng)
196
+ if kind == "mixed":
197
+ return textgen.MixedSource(lang, self.settings["difficulty"], rng=self.rng)
198
+ return self._word_source()
199
+
200
+ def _fixed(self, make_source, count):
201
+ return lambda: (make_source().text(count), "")
202
+
203
+ def build_mode(self, action):
204
+ value = self.settings["menu"][action]
205
+ lang = self._lang()
206
+ up = lang.upper()
207
+ if action == "time":
208
+ variant, label = self._word_variant()
209
+ return Mode("zeit-%d-%s" % (value, variant), tr("Zeit %d s · %s", "Time %d s · %s") % (value, label),
210
+ "time", limit=value, source=self._word_source())
211
+ if action == "words":
212
+ variant, label = self._word_variant()
213
+ return Mode("woerter-%d-%s" % (value, variant), "%s · %s" % (screens.words_label(value), label),
214
+ "text", make_text=self._fixed(self._word_source, value), count_words=True)
215
+ if action == "free":
216
+ if value == "words":
217
+ variant, label = self._word_variant()
218
+ else:
219
+ variant, label = lang, up
220
+ return Mode("frei-%s-%s" % (value, variant),
221
+ tr("Freier Modus · %s · %s", "Free mode · %s · %s") % (screens.free_label(value), label),
222
+ "free", source=self._free_source(value))
223
+ if action == "endless":
224
+ source = textgen.EndlessSource(lang, rng=self.rng)
225
+ mode = Mode("unendlich-%d-%s" % (value, lang),
226
+ tr("Unendlich ab Level %d · %s", "Endless from level %d · %s") % (value, up),
227
+ "endless", limit=value, source=source)
228
+ mode.extra = lambda: {"score": mode.state["words"], "level": mode.state["level"]}
229
+ return mode
230
+ if action == "sentences":
231
+ return Mode("saetze-%d-%s" % (value, lang), "%s · %s" % (
232
+ tr("1 Satz", "1 sentence") if value == 1 else tr("%d Sätze", "%d sentences") % value, up),
233
+ "text",
234
+ make_text=self._fixed(lambda: textgen.SentenceSource(lang, rng=self.rng), value))
235
+ if action == "numbers":
236
+ return Mode("zahlen-%d-%s" % (value, lang), tr("Zahlen %d · %s", "Numbers %d · %s") % (value, up),
237
+ "text",
238
+ make_text=self._fixed(lambda: textgen.NumberSource(lang, rng=self.rng), value))
239
+ if action == "symbols":
240
+ return Mode("zeichen-%d-%s" % (value, lang), tr("Sonderzeichen %d · %s", "Symbols %d · %s")
241
+ % (value, up), "text",
242
+ make_text=self._fixed(lambda: textgen.SymbolSource(lang, rng=self.rng), value))
243
+ if action == "mixed":
244
+ return Mode("gemischt-%d-%s" % (value, lang), tr("Gemischt %d · %s", "Mixed %d · %s") % (value, up),
245
+ "text",
246
+ make_text=self._fixed(lambda: textgen.MixedSource(
247
+ lang, self.settings["difficulty"], rng=self.rng), value))
248
+ if action == "quotes":
249
+ def make_quote():
250
+ text, source = textgen.pick_quote(lang, value, self.rng)
251
+ return text, "— " + source
252
+ return Mode("zitat-%s-%s" % (value, lang), tr("Zitat (%s) · %s", "Quote (%s) · %s") % (
253
+ screens.quote_label(value), up), "text", make_text=make_quote)
254
+ if action == "weak":
255
+ def make_weak():
256
+ weak = [row[0] for row in stats.weak_keys(self.store.key_stats)]
257
+ source = textgen.WeakSource(lang, weak, rng=self.rng)
258
+ if weak:
259
+ note = tr("Fokus auf: ", "Focus on: ") + " ".join(weak)
260
+ else:
261
+ note = tr("Noch zu wenig Daten – übe erst ein paar andere Tests.",
262
+ "Not enough data yet – do a few other tests first.")
263
+ return source.text(value), note
264
+ return Mode("schwaechen-%d-%s" % (value, lang),
265
+ tr("Schwächen-Training %d · %s", "Weak keys %d · %s") % (value, up),
266
+ "text", make_text=make_weak, count_words=True)
267
+ raise ValueError(action)
268
+
269
+ # --- Ablauf eines Tests ----------------------------------------------
270
+ def run_mode(self, mode):
271
+ text = note = None
272
+ while True:
273
+ if mode.kind == "text" and text is None:
274
+ text, note = mode.make_text()
275
+ text = textgen.finalize(text, self.settings)
276
+ outcome, test = self.run_test(mode, text, note)
277
+ if outcome == "abort":
278
+ return
279
+ if outcome == "restart":
280
+ text = None
281
+ continue
282
+ result = test.result()
283
+ info = self.save_result(mode, result)
284
+ if info["saved"] and mode.on_complete and test.complete():
285
+ mode.on_complete(result)
286
+ choice = screens.result_screen(self.term, mode, result, info, self.store)
287
+ if choice == "menu":
288
+ return
289
+ if choice == "next":
290
+ text = None
291
+
292
+ def run_test(self, mode, text, note):
293
+ settings = self.settings
294
+ endless = mode.kind == "endless"
295
+ if endless:
296
+ mode.state = {"budget": ENDLESS_START, "level": mode.limit, "words": 0, "credited": set()}
297
+ mode.source.level = mode.limit
298
+ if mode.kind == "text":
299
+ target = text
300
+ else:
301
+ target = self._next_chunk(mode)
302
+ while len(target) < (150 if endless else 400):
303
+ target += " " + self._next_chunk(mode)
304
+ test = TypingTest(target, strict=settings["strict"])
305
+ wrong = None # (falsch gedrückte Taste, Zeitpunkt) für das rote Aufblinken
306
+ self.term.flush_input()
307
+ while True:
308
+ now = time.monotonic()
309
+ if test.started:
310
+ test.sample(now)
311
+ if mode.kind == "time" and test.elapsed(now) >= mode.limit:
312
+ test.finish(test.start_time + mode.limit)
313
+ return "done", test
314
+ if endless and test.elapsed(now) >= mode.state["budget"]:
315
+ test.finish(test.start_time + max(0.0, mode.state["budget"]))
316
+ return "done", test
317
+ if mode.kind != "text" and len(test.target) - test.pos < (120 if endless else 250):
318
+ test.extend(" " + self._next_chunk(mode))
319
+ flash = wrong[0] if wrong and now - wrong[1] < 0.35 else None
320
+ screens.draw_test(self.term, test, now, mode, settings, note, flash)
321
+
322
+ key = self.term.read_key(0.08 if test.started or settings["animations"] else 0.5)
323
+ if key is None:
324
+ continue
325
+ if key in (T.ESC, T.CTRL_C):
326
+ if mode.kind in ("free", "endless") and test.pos > 0:
327
+ test.finish(time.monotonic())
328
+ return "done", test
329
+ return "abort", test
330
+ if key == T.TAB:
331
+ return "restart", test
332
+ if key == T.BACKSPACE:
333
+ test.backspace()
334
+ elif key == T.CTRL_BACKSPACE:
335
+ test.backspace_word()
336
+ elif T.is_char(key):
337
+ ok = test.type_char(key, time.monotonic())
338
+ if ok is False:
339
+ wrong = (key, time.monotonic())
340
+ if settings["bell"]:
341
+ self.term.bell()
342
+ if endless and ok is not None:
343
+ self._endless_step(mode, test, ok)
344
+ if mode.kind == "text" and test.complete():
345
+ test.finish(time.monotonic())
346
+ return "done", test
347
+
348
+ def _endless_step(self, mode, test, ok):
349
+ """Unendlich-Modus: Fehler kosten Zeit, fertige Wörter bringen Zeit."""
350
+ state = mode.state
351
+ now_elapsed = test.elapsed(time.monotonic())
352
+ if not ok:
353
+ state["budget"] -= ENDLESS_PENALTY
354
+ return
355
+ end = test.pos - 1
356
+ if test.target[end] != " " or end in state["credited"]:
357
+ return
358
+ start = test.target.rfind(" ", 0, end) + 1
359
+ if not all(test.marks[start:end + 1]):
360
+ return
361
+ state["credited"].add(end)
362
+ state["words"] += 1
363
+ seconds_per_char = 60.0 / (5 * endless_target_wpm(state["level"]))
364
+ state["budget"] = min(state["budget"] + (end + 1 - start) * seconds_per_char,
365
+ now_elapsed + ENDLESS_MAX)
366
+ state["level"] = mode.limit + state["words"] // ENDLESS_WORDS_PER_LEVEL
367
+ mode.source.level = state["level"]
368
+
369
+ def _next_chunk(self, mode):
370
+ return textgen.finalize(mode.source.chunk(), self.settings)
371
+
372
+ def save_result(self, mode, result):
373
+ if result["keystrokes"] < 5 or result["duration"] < 1:
374
+ return {"saved": False}
375
+ entry = {
376
+ "ts": datetime.now().isoformat(timespec="seconds"),
377
+ "mode": mode.key,
378
+ "label": mode.label,
379
+ "lang": self._lang(),
380
+ "wpm": result["wpm"],
381
+ "raw": result["raw"],
382
+ "acc": result["acc"],
383
+ "duration": result["duration"],
384
+ "chars": result["chars"],
385
+ "errors": result["errors"],
386
+ "consistency": result["consistency"],
387
+ }
388
+ if mode.extra:
389
+ entry.update(mode.extra())
390
+ previous = stats.records(self.store.history).get(mode.key)
391
+ record = stats.record_eligible(entry) and (
392
+ previous is None or stats.record_value(entry) > stats.record_value(previous))
393
+ self.store.add_result(entry, result["key_attempts"], result["key_errors"])
394
+ new = self.update_achievements()
395
+ return {"saved": True, "record": record, "previous": previous, "achievements": new,
396
+ "entry": entry}
397
+
398
+ def update_achievements(self):
399
+ got = stats.achieved(self.store.history, self.settings["daily_goal"], date.today())
400
+ new = [key for key, _, _ in stats.ACHIEVEMENTS
401
+ if key in got and key not in self.store.achievements]
402
+ if new:
403
+ stamp = datetime.now().isoformat(timespec="seconds")
404
+ for key in new:
405
+ self.store.achievements[key] = stamp
406
+ self.store.save()
407
+ return new
408
+
409
+ # --- Geschichten ------------------------------------------------------
410
+ def stories_menu(self):
411
+ level = self.settings["menu"]["stories"]
412
+ index = 0
413
+ while True:
414
+ action, index, _ = screens.stories_menu(self.term, self.store, level, index)
415
+ if action is None or action == "back":
416
+ return
417
+ self.run_mode(self._story_mode(level, action))
418
+
419
+ def _story_mode(self, level, first):
420
+ lang = self._lang()
421
+ items = stories.stories_for(lang, level)
422
+ label = stories.level_label(level)
423
+ story = tr("Geschichte", "Story")
424
+ state = {"i": first, "shown": first}
425
+
426
+ def make_text():
427
+ state["shown"] = state["i"]
428
+ title, text = items[state["i"]]
429
+ return text, "\"%s\" · %s %d/%d (%s)" % (title, story, state["i"] + 1, len(items), label)
430
+
431
+ def on_complete(result):
432
+ state["i"] = (state["shown"] + 1) % len(items)
433
+
434
+ key = "geschichte-%s" % level + ("-en" if lang == "en" else "")
435
+ mode = Mode(key, "%s (%s)" % (story, label), "text", make_text=make_text, on_complete=on_complete)
436
+ mode.extra = lambda: {"story": stories.story_id(level, state["shown"]), "lang": lang,
437
+ "label": "%s (%s) · %s" % (story, label, items[state["shown"]][0])}
438
+ return mode
439
+
440
+ # --- Eigene Texte ----------------------------------------------------
441
+ def custom_texts(self):
442
+ index = 0
443
+ while True:
444
+ action, index, key = screens.custom_menu(self.term, self.store, index)
445
+ if action in (None, "back"):
446
+ return
447
+ if action == "new":
448
+ self._add_pasted_text()
449
+ elif action == "file":
450
+ self._add_file_text()
451
+ elif isinstance(action, int):
452
+ entry = self.store.custom_texts[action]
453
+ if key in (T.DELETE, "d", "D"):
454
+ title = entry.get("title") or entry["text"][:30]
455
+ if ui.confirm(self.term, tr("Text löschen", "Delete text"),
456
+ tr("„%s“ wirklich löschen?", "Really delete \"%s\"?") % title):
457
+ del self.store.custom_texts[action]
458
+ self.store.save()
459
+ index = max(0, index - 1)
460
+ else:
461
+ self.run_mode(self._custom_mode(entry))
462
+
463
+ def _custom_mode(self, entry):
464
+ title = entry.get("title") or entry["text"][:30]
465
+ state = {}
466
+
467
+ def make_text():
468
+ start, end = textgen.custom_chunk(entry["text"], entry.get("pos", 0))
469
+ state["end"] = end
470
+ note = ""
471
+ if end - start < len(entry["text"]):
472
+ note = tr("Abschnitt: %d %% – %d %% des Textes", "Part: %d %% – %d %% of the text") % (
473
+ 100 * start // len(entry["text"]), 100 * end // len(entry["text"]))
474
+ return entry["text"][start:end].strip(), note
475
+
476
+ def on_complete(result):
477
+ end = state.get("end", 0)
478
+ entry["pos"] = 0 if end >= len(entry["text"]) else end
479
+ self.store.save()
480
+
481
+ return Mode("eigener", tr("Eigener Text · %s", "Own text · %s") % title[:40], "text",
482
+ make_text=make_text, on_complete=on_complete)
483
+
484
+ def _save_custom(self, title, text):
485
+ text = textgen.normalize_text(text)
486
+ if not text:
487
+ ui.message(self.term, tr("Eigener Text", "Your own text"),
488
+ [tr("Der Text ist leer – nichts gespeichert.", "The text is empty – nothing saved.")])
489
+ return
490
+ title = textgen.normalize_text(title) or text[:30]
491
+ self.store.custom_texts.append({
492
+ "title": title[:60], "text": text, "pos": 0,
493
+ "added": datetime.now().isoformat(timespec="seconds"),
494
+ })
495
+ self.store.save()
496
+
497
+ def _add_pasted_text(self):
498
+ lines, title = [], ""
499
+ with self.term.cooked():
500
+ print(tr("EIGENEN TEXT EINFÜGEN\n", "PASTE YOUR OWN TEXT\n"))
501
+ print(tr("Füge deinen Text ein (z. B. mit Cmd+V, Strg+Umschalt+V oder Rechtsklick).",
502
+ "Paste your text (e.g. with Cmd+V, Ctrl+Shift+V or right-click)."))
503
+ print(tr("Zum Abschließen zweimal Enter drücken – oder eine Zeile mit nur einem Punkt.\n",
504
+ "Press Enter twice to finish – or type a line with just a period.\n"))
505
+ empty = 0
506
+ try:
507
+ while True:
508
+ line = input()
509
+ if line.strip() == ".":
510
+ break
511
+ if not line.strip():
512
+ empty += 1
513
+ if empty >= 2 or not lines:
514
+ break
515
+ continue
516
+ empty = 0
517
+ lines.append(line)
518
+ if lines:
519
+ title = input(tr("\nTitel (optional, Enter = automatisch): ",
520
+ "\nTitle (optional, Enter = automatic): "))
521
+ except (EOFError, KeyboardInterrupt):
522
+ lines = []
523
+ if lines:
524
+ self._save_custom(title, " ".join(lines))
525
+
526
+ def _add_file_text(self):
527
+ with self.term.cooked():
528
+ print(tr("TEXT AUS DATEI LADEN\n", "LOAD TEXT FROM FILE\n"))
529
+ try:
530
+ raw = input(tr("Pfad zur Datei (leer = abbrechen): ",
531
+ "Path to the file (empty = cancel): ")).strip().strip("\"'")
532
+ except (EOFError, KeyboardInterrupt):
533
+ raw = ""
534
+ if not raw:
535
+ return
536
+ path = Path(raw).expanduser()
537
+ try:
538
+ text = path.read_text(encoding="utf-8", errors="replace")
539
+ except OSError as exc:
540
+ ui.message(self.term, tr("Datei laden", "Load file"),
541
+ [tr("Die Datei konnte nicht gelesen werden:", "The file could not be read:"), str(exc)])
542
+ return
543
+ self._save_custom(path.stem, text)
544
+
545
+
546
+ def main(argv=None):
547
+ parser = argparse.ArgumentParser(prog="keyflow",
548
+ description="KeyFlow – a typing trainer for the terminal. "
549
+ "Made by YnsLaf")
550
+ parser.add_argument("--data", "--daten", dest="data", metavar="FILE",
551
+ help="use a different data file (default: ~/.keyflow/daten.json)")
552
+ parser.add_argument("--version", action="version", version="KeyFlow %s" % __version__)
553
+ args = parser.parse_args(argv)
554
+
555
+ store = Store(args.data)
556
+ try:
557
+ with T.Terminal() as term:
558
+ try:
559
+ App(term, store).run()
560
+ except (KeyboardInterrupt, EOFError):
561
+ pass
562
+ except RuntimeError as exc:
563
+ print(exc, file=sys.stderr)
564
+ return 1
565
+ days = stats.daily_seconds(store.history)
566
+ today_minutes = days.get(date.today(), 0) / 60
567
+ print(tr("Bis bald! Heute geübt: %s min.", "See you soon! Practiced today: %s min.")
568
+ % ui.fmt_minutes(today_minutes * 60))
569
+ return 0