mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Let the history be pruned, and keep the settings window open on save
The History tab was read-only, so the only way to get rid of a dictation was to edit history.jsonl by hand. Entries can now be removed: right-click or Delete drops the selection, "Clear history" empties the file, and the Ctrl/Shift selection lets several go at once. Deleting more than one asks first, since there is no undo. The cap on the file has existed since the first commit but was never reachable from the interface. It is now a spinbox in the same tab, applied the moment you save rather than on the next dictation, and 0 turns it off. Trimming rewrites the file only when it is actually over the cap, instead of on every single dictation, and does it through a temporary file so a crash cannot leave a half-written history behind. Deletion matches an entry on its whole content, not on its line number: the worker may well have appended a new dictation while the window sat open. Save no longer closes the window. It confirms and stays put, so you can settle several things in one sitting; the cross closes it. Cancel is gone with it, because it would be a lie next to a Save that already wrote.
This commit is contained in:
@@ -210,12 +210,15 @@ def append_history(entry):
|
||||
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def read_history(limit=200):
|
||||
def read_history(limit=None):
|
||||
"""Newest last. A limit of None (or 0) reads the whole file."""
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()[-limit:]
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
return []
|
||||
if limit:
|
||||
lines = lines[-limit:]
|
||||
out = []
|
||||
for line in lines:
|
||||
try:
|
||||
@@ -225,10 +228,43 @@ def read_history(limit=200):
|
||||
return out
|
||||
|
||||
|
||||
def _write_history(lines):
|
||||
"""Replace the file in one go, so a crash cannot leave it half written."""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = HISTORY_FILE.with_suffix(".jsonl.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
tmp.replace(HISTORY_FILE)
|
||||
|
||||
|
||||
def trim_history(limit):
|
||||
rows = read_history(limit)
|
||||
if not rows:
|
||||
"""Drop the oldest entries once the file passes `limit` rows. 0 means keep all."""
|
||||
if not limit or limit < 0:
|
||||
return
|
||||
with open(HISTORY_FILE, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
return
|
||||
if len(lines) <= limit:
|
||||
return
|
||||
_write_history(lines[-limit:])
|
||||
|
||||
|
||||
def _row_key(row):
|
||||
return json.dumps(row, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def delete_history(rows):
|
||||
"""Remove the given entries, matched on their whole content rather than on a
|
||||
line number: the worker may have appended a new one since the list was read."""
|
||||
doomed = {_row_key(row) for row in rows}
|
||||
if not doomed:
|
||||
return
|
||||
kept = [json.dumps(row, ensure_ascii=False) + "\n"
|
||||
for row in read_history() if _row_key(row) not in doomed]
|
||||
_write_history(kept)
|
||||
|
||||
|
||||
def clear_history():
|
||||
HISTORY_FILE.unlink(missing_ok=True)
|
||||
|
||||
@@ -111,7 +111,7 @@ TR = {
|
||||
"Shortcut": "Kısayol",
|
||||
"History": "Geçmiş",
|
||||
"Save": "Kaydet",
|
||||
"Cancel": "Vazgeç",
|
||||
"Saved successfully.": "Başarıyla kaydedildi.",
|
||||
"Interface language": "Arayüz dili",
|
||||
"Automatic (system)": "Otomatik (sistem)",
|
||||
"Turkish": "Türkçe",
|
||||
@@ -245,6 +245,18 @@ TR = {
|
||||
|
||||
# --- settings: history --------------------------------------------------
|
||||
"Copy selected to clipboard": "Seçiliyi panoya kopyala",
|
||||
"Delete selected": "Seçiliyi sil",
|
||||
"Clear history": "Geçmişi temizle",
|
||||
"Reload": "Yenile",
|
||||
"{ts} ({duration} s)": "{ts} ({duration} sn)",
|
||||
"Keep at most": "En fazla",
|
||||
" entries": " kayıt",
|
||||
"no limit": "sınırsız",
|
||||
"Once the history passes this many entries, the oldest one is dropped "
|
||||
"every time a new one arrives. Set it to 0 to keep everything.":
|
||||
"Geçmiş bu sayıyı aştıktan sonra, her yeni kayıt geldiğinde en eski kayıt "
|
||||
"silinir. Hepsini tutmak için 0 yaz.",
|
||||
"Delete the {count} selected entries?": "Seçili {count} kayıt silinsin mi?",
|
||||
"Delete the whole history? This cannot be undone.":
|
||||
"Geçmişin tamamı silinsin mi? Bu geri alınamaz.",
|
||||
}
|
||||
|
||||
+115
-17
@@ -4,12 +4,12 @@ import os
|
||||
import threading
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QGuiApplication
|
||||
from PyQt6.QtGui import QGuiApplication, QKeySequence, QShortcut
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
|
||||
QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox,
|
||||
QTabWidget, QVBoxLayout, QWidget,
|
||||
QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||
QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
import api
|
||||
@@ -59,13 +59,12 @@ class SettingsWindow(QDialog):
|
||||
tabs.addTab(self._shortcut_tab(), t("Shortcut"))
|
||||
tabs.addTab(self._history_tab(), t("History"))
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
# Save keeps the window open, so the window is closed with the titlebar
|
||||
# cross (or Escape) instead. A "Cancel" next to it would be a lie: the
|
||||
# settings are already on disk by then.
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save)
|
||||
buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Save"))
|
||||
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("Cancel"))
|
||||
buttons.accepted.connect(self._save)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(tabs)
|
||||
@@ -344,16 +343,44 @@ class SettingsWindow(QDialog):
|
||||
layout = QVBoxLayout(page)
|
||||
self.history = QListWidget()
|
||||
self.history.setWordWrap(True)
|
||||
self.history.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection
|
||||
)
|
||||
self.history.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.history.customContextMenuRequested.connect(self._history_menu)
|
||||
delete_key = QShortcut(QKeySequence.StandardKey.Delete, self.history)
|
||||
delete_key.setContext(Qt.ShortcutContext.WidgetShortcut)
|
||||
delete_key.activated.connect(self._delete_history)
|
||||
layout.addWidget(self.history, 1)
|
||||
|
||||
self.history_limit = QSpinBox()
|
||||
self.history_limit.setRange(0, 10000)
|
||||
self.history_limit.setSpecialValueText(t("no limit"))
|
||||
self.history_limit.setSuffix(t(" entries"))
|
||||
self.history_limit.setToolTip(t(
|
||||
"Once the history passes this many entries, the oldest one is dropped "
|
||||
"every time a new one arrives. Set it to 0 to keep everything."
|
||||
))
|
||||
limit_row = QHBoxLayout()
|
||||
limit_row.addWidget(QLabel(t("Keep at most")))
|
||||
limit_row.addWidget(self.history_limit)
|
||||
limit_row.addStretch(1)
|
||||
layout.addLayout(limit_row)
|
||||
|
||||
copy = QPushButton(t("Copy selected to clipboard"))
|
||||
copy.clicked.connect(self._copy_history)
|
||||
delete = QPushButton(t("Delete selected"))
|
||||
delete.clicked.connect(self._delete_history)
|
||||
clear = QPushButton(t("Clear history"))
|
||||
clear.clicked.connect(self._clear_history)
|
||||
reload_ = QPushButton(t("Reload"))
|
||||
reload_.clicked.connect(self._load_history)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(copy)
|
||||
row.addWidget(reload_)
|
||||
row.addWidget(delete)
|
||||
row.addStretch(1)
|
||||
row.addWidget(clear)
|
||||
row.addWidget(reload_)
|
||||
layout.addLayout(row)
|
||||
return page
|
||||
|
||||
@@ -395,6 +422,8 @@ class SettingsWindow(QDialog):
|
||||
self.shortcut.setText(conf["shortcut"])
|
||||
self.evdev_enabled.setChecked(conf["evdev_hotkey"])
|
||||
|
||||
self.history_limit.setValue(max(0, int(conf["history_limit"])))
|
||||
|
||||
self._refresh_shortcut_status()
|
||||
self._load_history()
|
||||
|
||||
@@ -430,9 +459,16 @@ class SettingsWindow(QDialog):
|
||||
|
||||
conf["shortcut"] = self.shortcut.text().strip() or "Ctrl+Space"
|
||||
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
||||
conf["history_limit"] = self.history_limit.value()
|
||||
conf.save()
|
||||
# A lowered limit should bite now, not on the next dictation.
|
||||
try:
|
||||
cfg.trim_history(conf["history_limit"])
|
||||
except OSError as exc:
|
||||
print(f"dikte: could not trim the history ({exc})")
|
||||
self._load_history() # the trim may just have dropped rows from the list
|
||||
self.applied.emit()
|
||||
self.accept()
|
||||
QMessageBox.information(self, t("Dikte Settings"), t("Saved successfully."))
|
||||
|
||||
@staticmethod
|
||||
def _select_data(combo, value):
|
||||
@@ -601,16 +637,78 @@ class SettingsWindow(QDialog):
|
||||
|
||||
def _load_history(self):
|
||||
self.history.clear()
|
||||
for row in reversed(cfg.read_history(200)):
|
||||
for row in reversed(cfg.read_history(self.conf["history_limit"])):
|
||||
text = (row.get("text") or "").replace("\n", " ")
|
||||
preview = text[:110] + ("…" if len(text) > 110 else "")
|
||||
header = t("{ts} ({duration} s)",
|
||||
ts=row.get("ts", ""), duration=row.get("duration", 0))
|
||||
item = QListWidgetItem(f"{header}\n{preview}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, row.get("text", ""))
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
self.history.addItem(item)
|
||||
|
||||
def _selected_rows(self):
|
||||
"""Selected entries, newest first, the order they are listed in."""
|
||||
items = sorted(self.history.selectedItems(), key=self.history.row)
|
||||
return [item.data(Qt.ItemDataRole.UserRole) for item in items]
|
||||
|
||||
def _copy_history(self):
|
||||
item = self.history.currentItem()
|
||||
if item:
|
||||
QGuiApplication.clipboard().setText(item.data(Qt.ItemDataRole.UserRole))
|
||||
rows = self._selected_rows()
|
||||
if rows:
|
||||
QGuiApplication.clipboard().setText(
|
||||
"\n\n".join(row.get("text", "") for row in rows)
|
||||
)
|
||||
|
||||
def _delete_history(self):
|
||||
rows = self._selected_rows()
|
||||
if not rows:
|
||||
return
|
||||
# One entry goes without asking; a multi-selection is easy to make by
|
||||
# accident, and there is no undo.
|
||||
if len(rows) > 1 and not self._confirm(
|
||||
t("Delete the {count} selected entries?", count=len(rows))
|
||||
):
|
||||
return
|
||||
self._rewrite_history(lambda: cfg.delete_history(rows))
|
||||
|
||||
def _clear_history(self):
|
||||
if not self.history.count():
|
||||
return
|
||||
if not self._confirm(t("Delete the whole history? This cannot be undone.")):
|
||||
return
|
||||
self._rewrite_history(cfg.clear_history)
|
||||
|
||||
def _confirm(self, question):
|
||||
answer = QMessageBox.question(
|
||||
self, t("History"), question,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
return answer == QMessageBox.StandardButton.Yes
|
||||
|
||||
def _rewrite_history(self, action):
|
||||
try:
|
||||
action()
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(self, t("History"), t("Failed: {error}", error=exc))
|
||||
self._load_history()
|
||||
|
||||
def _history_menu(self, pos):
|
||||
item = self.history.itemAt(pos)
|
||||
if item is not None and not item.isSelected():
|
||||
self.history.setCurrentItem(item)
|
||||
menu = QMenu(self)
|
||||
copy = menu.addAction(t("Copy selected to clipboard"))
|
||||
delete = menu.addAction(t("Delete selected"))
|
||||
menu.addSeparator()
|
||||
clear = menu.addAction(t("Clear history"))
|
||||
has_selection = bool(self.history.selectedItems())
|
||||
copy.setEnabled(has_selection)
|
||||
delete.setEnabled(has_selection)
|
||||
clear.setEnabled(self.history.count() > 0)
|
||||
chosen = menu.exec(self.history.viewport().mapToGlobal(pos))
|
||||
if chosen is copy:
|
||||
self._copy_history()
|
||||
elif chosen is delete:
|
||||
self._delete_history()
|
||||
elif chosen is clear:
|
||||
self._clear_history()
|
||||
|
||||
@@ -113,7 +113,10 @@ class Pipeline(QObject):
|
||||
"raw": raw,
|
||||
"text": text,
|
||||
})
|
||||
try:
|
||||
cfg.trim_history(conf["history_limit"])
|
||||
except OSError as exc:
|
||||
print(f"dikte: could not trim the history: {exc}", file=sys.stderr)
|
||||
self.finished.emit(raw, text, warning)
|
||||
|
||||
except (api.ApiError, paste.PasteError) as exc:
|
||||
|
||||
Reference in New Issue
Block a user