diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index d0fc656..b6b8bd3 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -11,7 +11,7 @@ import sys import threading import time from concurrent.futures import Future, ThreadPoolExecutor -from typing import Optional +from typing import Any, Dict, Optional from .utils.vosk_model_info import SUPPORTED_LANGUAGES from .version import __version__ @@ -424,6 +424,8 @@ def main(): logger.warning("After installing, log out and back in (or restart GNOME Shell).") # Now it's safe to import GTK-dependent modules + import os + from .common_types import RecognitionState from .custom_dictionary import CustomDictionaryManager from .speech_recognition import recognition_manager @@ -437,6 +439,7 @@ def main(): TranscriptionHistory, sanitize_max_items, ) + from .utils.paths import data_dir # Initialize logging manager early initialize_logging() @@ -537,6 +540,9 @@ def main(): # A hand-edited config.json can hold a non-numeric or out-of-range value; # sanitize on load so a bad preference cannot abort startup. history_max_items = sanitize_max_items(history_settings.get("max_items", DEFAULT_MAX_ITEMS)) + # Opt-in on-disk record of snippets (#758). Off by default: dictated + # text stays memory-only unless the user asks for it to persist. + history_persist = bool(history_settings.get("persist", False)) dictionary_file = getattr(args, "dictionary_file", None) transient_terms_path = ( @@ -599,12 +605,37 @@ def main(): # Initialize action handler action_handler = ActionHandler(text_system) - # Transcription history: an in-memory, newest-first list of recent - # dictation snippets surfaced in the tray menu. One snippet == one - # dictation session (everything said between start and stop). + # Transcription history: a newest-first list of recent dictation + # snippets surfaced in the tray menu. One snippet == one dictation + # session (everything said between start and stop). Memory-only by + # default; history.persist mirrors it to history.jsonl under the + # data directory so snippets survive restarts (#758). transcription_history = TranscriptionHistory( - max_items=history_max_items, enabled=history_enabled + max_items=history_max_items, + enabled=history_enabled, + persist=history_persist, + store_path=os.path.join(data_dir(), "history.jsonl"), ) + + def _history_record_fields() -> Dict[str, Any]: + """Optional details stored with a persisted snippet (#758). + + Language and model come off the engine; commit sites that know + the session bounds add duration themselves. + """ + fields: Dict[str, Any] = {} + language = getattr(speech_engine, "_session_language", None) or getattr( + speech_engine, "language", None + ) + if language: + fields["language"] = language + engine = getattr(speech_engine, "engine", "") + model_size = getattr(speech_engine, "model_size", "") + model = "/".join(str(part) for part in (engine, model_size) if part) + if model: + fields["model"] = model + return fields + # Segments dictated during the open session, joined and committed to # history when the session ends (state returns to IDLE). Each entry # keeps the monotonic time its audio capture began: a history.clear() @@ -641,6 +672,17 @@ def main(): # committed since, so one session's stragglers can never leak into a # newer entry or split across several snippets. ended_worker_snippets: dict[threading.Thread, tuple[int, int]] = {} + # Record fields (language, model) captured when a session opens, so a + # snippet committed after the engine has already moved on still + # carries the language that produced its audio (#758). + session_record_fields: Optional[Dict[str, Any]] = None + # Fields of the most recently ended session, for late deliveries that + # open their own snippet: they belong to that session, not the live + # one the engine is already configured for. + ended_session_fields: Dict[str, Any] = {} + # Per-worker copy of those fields, mirroring ended_worker_snippets: + # a straggler keeps its own session's language across sessions. + ended_worker_fields: dict[threading.Thread, Dict[str, Any]] = {} # Every worker that has delivered in-session segments; a delivery on # a thread never associated with a session is treated as the # just-ended session's trailing decode, while a worker seen producing @@ -732,7 +774,7 @@ def main(): across a clear is still recoverable. """ nonlocal session_worker, latest_snippet_id, latest_snippet_epoch - nonlocal session_workers_seen, ended_worker_snippets + nonlocal session_workers_seen, ended_worker_snippets, ended_worker_fields if not transcription_history.enabled: return segment = _normalize_segment_text(segment) @@ -749,6 +791,7 @@ def main(): ended_worker_snippets = { w: s for w, s in ended_worker_snippets.items() if w.is_alive() } + ended_worker_fields = {w: f for w, f in ended_worker_fields.items() if w.is_alive()} if started_at <= transcription_history.cleared_at: # Captured before the last clear — must not re-enter. return @@ -809,11 +852,17 @@ def main(): return # Otherwise the late segments are the session's only output # and form their own snippet, which its worker keeps owning. - snippet_id = transcription_history.add(segment, expected_epoch=ended_session_epoch) + # The fields come from the session that produced the audio, + # not the one the engine may already have moved on to. + owner_fields = ended_worker_fields.get(worker, ended_session_fields) + snippet_id = transcription_history.add( + segment, expected_epoch=ended_session_epoch, **owner_fields + ) if snippet_id is not None: latest_snippet_id = snippet_id latest_snippet_epoch = ended_session_epoch ended_worker_snippets[worker] = (snippet_id, ended_session_epoch) + ended_worker_fields[worker] = owner_fields session_workers_seen.add(worker) def inject_transcription(text_to_inject: str, to_pad: Optional[bool] = None) -> None: @@ -1191,6 +1240,7 @@ def main(): """ nonlocal session_open, session_worker, latest_snippet_id nonlocal ended_session_epoch, session_started_floor, latest_snippet_epoch + nonlocal session_record_fields, ended_session_fields if state in (RecognitionState.IDLE, RecognitionState.ERROR): if state == RecognitionState.IDLE: with pending_jobs_lock: @@ -1208,6 +1258,12 @@ def main(): session_open = False closing_worker = session_worker session_worker = None + # Snapshot before re-reading the engine: the fields belong + # to the session being closed, and the live values could + # already describe a reconfigure that landed mid-close. + closing_fields = session_record_fields or _history_record_fields() + session_record_fields = None + ended_session_fields = closing_fields ended_session_epoch = transcription_history.epoch # Only segments captured after the last clear() join the # snippet — speech captured before it is gone for good, @@ -1223,9 +1279,18 @@ def main(): # segments still trickling out of the worker; a session # that produced no text leaves no entry to merge into. latest_snippet_id = transcription_history.add( - joined, expected_epoch=ended_session_epoch + joined, + expected_epoch=ended_session_epoch, + duration=( + time.monotonic() - session_started_floor + if session_started_floor + else None + ), + **closing_fields, ) latest_snippet_epoch = ended_session_epoch + if closing_worker is not None: + ended_worker_fields[closing_worker] = closing_fields if latest_snippet_id is not None and closing_worker is not None: ended_worker_snippets[closing_worker] = ( latest_snippet_id, @@ -1237,7 +1302,13 @@ def main(): session_open = True leftover_worker = session_worker session_worker = None + # Leftover segments belong to the displaced session; + # grab its fields before the new session re-captures. + leftover_fields = session_record_fields or ended_session_fields + if leftover_worker is not None: + ended_worker_fields[leftover_worker] = leftover_fields session_started_floor = time.monotonic() + session_record_fields = _history_record_fields() # Segments left over by a session that ended without a # closing state commit as their own snippet rather # than leaking into the new session's — still only @@ -1256,6 +1327,7 @@ def main(): if started_at > cleared_at ), expected_epoch=stray_epoch, + **leftover_fields, ) latest_snippet_epoch = stray_epoch if latest_snippet_id is not None and leftover_worker is not None: diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 47b876c..0abf11a 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -205,7 +205,11 @@ DEFAULT_CONFIG = { }, "history": { "enabled": True, # Keep recent dictation snippets in the tray menu - "max_items": 10, # How many snippets to retain (in-memory only, cleared on quit) + "max_items": 10, # How many snippets to retain + # Also save snippets to $XDG_DATA_HOME/vocalinux/history.jsonl so they + # survive restarts. Off by default: dictated text must not silently + # accumulate on disk unless the user asks for it (#758). + "persist": False, }, "post_processing": { "script_path": "", # Path to executable; empty = disabled diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index eb744a5..6158d21 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2601,6 +2601,7 @@ class SettingsDialog(Gtk.Dialog): hotkey_listener_update_callback: Optional[Callable[[], None]] = None, language_shortcuts_update_callback: Optional[Callable[[], None]] = None, history_update_callback: Optional[Callable[[], None]] = None, + history_clear_callback: Optional[Callable[[], None]] = None, ): super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0) # Force window decorations (title-bar close) on all WMs. An in-window @@ -2618,6 +2619,7 @@ class SettingsDialog(Gtk.Dialog): self.hotkey_listener_update_callback = hotkey_listener_update_callback self.language_shortcuts_update_callback = language_shortcuts_update_callback self.history_update_callback = history_update_callback + self.history_clear_callback = history_clear_callback # Per-language shortcut rows (#805): a dict of row widgets per binding, # populated by _build_language_shortcuts_section. self._language_shortcut_rows: list[dict] = [] @@ -3348,8 +3350,8 @@ class SettingsDialog(Gtk.Dialog): history_group = PreferencesGroup( title="Transcription History", description="Recent dictation snippets are kept in memory and shown " - "in the tray menu. Nothing is written to disk; history clears on quit. " - "Changes take effect after restarting Vocalinux.", + "in the tray menu. History clears on quit unless Keep on Disk is on, " + "which saves them to history.jsonl under the Vocalinux data directory.", ) self.history_enabled_switch = Gtk.Switch() @@ -3373,6 +3375,32 @@ class SettingsDialog(Gtk.Dialog): ) history_group.add_row(history_max_items_row) + self.history_persist_switch = Gtk.Switch() + self.history_persist_switch.set_tooltip_text( + "Save snippets to disk so they survive restarts (off by default for privacy)" + ) + history_persist_row = PreferenceRow( + title="Keep on Disk", + subtitle="Save snippets to history.jsonl in the data directory across restarts", + widget=self.history_persist_switch, + keywords=("persist", "save", "disk", "transcript"), + ) + history_group.add_row(history_persist_row) + + self.history_clear_button = Gtk.Button(label="Clear") + self.history_clear_button.set_tooltip_text( + "Delete every saved snippet, including any written to disk" + ) + _style_action_button(self.history_clear_button) + self.history_clear_button.connect("clicked", self._on_history_clear_clicked) + history_clear_row = PreferenceRow( + title="Clear History", + subtitle="Remove every saved snippet now", + widget=self.history_clear_button, + keywords=("delete", "erase", "wipe", "transcript"), + ) + history_group.add_row(history_clear_row) + self.general_tab.pack_start(history_group, False, False, 0) self.autostart_switch.connect("state-set", self._on_autostart_toggled) @@ -3381,6 +3409,7 @@ class SettingsDialog(Gtk.Dialog): self.show_overlay_switch.connect("state-set", self._on_show_overlay_toggled) self.history_enabled_switch.connect("state-set", self._on_history_enabled_toggled) self.history_max_items_spin.connect("value-changed", self._on_history_max_items_changed) + self.history_persist_switch.connect("state-set", self._on_history_persist_toggled) def _build_dictionary_section(self) -> None: """Build the Custom Dictionary page for terms and transcript corrections.""" @@ -4306,6 +4335,28 @@ class SettingsDialog(Gtk.Dialog): if self.history_update_callback: self.history_update_callback() + def _on_history_persist_toggled(self, widget: Gtk.Switch, state: bool) -> bool: + """Handle toggle of the keep-on-disk switch.""" + if self._initializing or self._applying_settings: + return False + + persist = bool(state) + logger.info(f"Transcription history persistence toggled: {persist}") + self.config_manager.set("history", "persist", persist) + self.config_manager.save_settings() + if self.history_update_callback: + self.history_update_callback() + return False + + def _on_history_clear_clicked(self, widget: Gtk.Button) -> None: + """Handle click of the clear-history button.""" + if self._initializing or self._applying_settings: + return + + logger.info("Clear history requested from settings") + if self.history_clear_callback: + self.history_clear_callback() + def _on_autostart_toggled(self, widget, state): """Handle toggle of the autostart switch.""" if _handlers_suppressed(self): @@ -7386,6 +7437,7 @@ class SettingsDialog(Gtk.Dialog): self.history_enabled_switch.set_active(history_enabled) self.history_max_items_spin.set_value(history_max_items) + self.history_persist_switch.set_active(bool(history_settings.get("persist", False))) disable_internal_hotkey = self.config_manager.get_bool( "shortcuts", "disable_internal_hotkey", False diff --git a/src/vocalinux/ui/transcription_history.py b/src/vocalinux/ui/transcription_history.py index 7891646..a062a91 100644 --- a/src/vocalinux/ui/transcription_history.py +++ b/src/vocalinux/ui/transcription_history.py @@ -1,19 +1,33 @@ -"""In-memory transcription history for Vocalinux. +"""Recent-transcription history for Vocalinux. Keeps a bounded, newest-first list of recent dictation snippets so the user can review and re-copy recent voice input from the tray menu. -The history lives only for the lifetime of the running process — nothing is -written to disk — so dictated text never persists past the current session. -This is a deliberate privacy choice: a dictation tool sees everything the user -types by voice, and that should not silently accumulate in a file. +By default the history lives only for the lifetime of the running process — +nothing is written to disk — so dictated text never persists past the +current session. This is a deliberate privacy choice: a dictation tool sees +everything the user types by voice, and that should not silently accumulate +in a file. + +Users who want transcripts to survive restarts can opt in via +``history.persist`` in config.json. Each snippet is then stored as one JSON +object per line in a ``history.jsonl`` file under the Vocalinux data +directory — timestamped, with the session's duration, language, and model +recorded when the pipeline reported them — and the file is rewritten on +every change so it always mirrors the in-memory list. Turning persistence +back off deletes the file, and disabling history deletes it too, so "keep +no records" stays airtight on disk as well as in memory. """ +import json import logging +import os +import tempfile import threading import time from collections import deque -from typing import Any, Callable, List, Optional +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) @@ -42,7 +56,7 @@ def sanitize_max_items(value: Any) -> int: class TranscriptionHistory: - """A bounded, thread-safe, in-memory store of recent dictation snippets. + """A bounded, thread-safe store of recent dictation snippets. A "snippet" is the text of a single dictation session (everything said between starting and stopping voice typing). Entries are stored oldest to @@ -53,12 +67,31 @@ class TranscriptionHistory: optional change callback lets the UI refresh when entries are added, cleared, or trimmed; callers are responsible for marshalling that callback onto the correct thread (e.g. via ``GLib.idle_add``). + + When ``persist`` is on, every mutation also rewrites ``store_path`` (one + JSON object per entry per line) so the file always mirrors memory — the + bounded cap holds on disk too. The file is small (``max_items`` entries) + so a full atomic rewrite stays cheaper than tracking line-level edits. """ - def __init__(self, max_items: int = DEFAULT_MAX_ITEMS, enabled: bool = True) -> None: + def __init__( + self, + max_items: int = DEFAULT_MAX_ITEMS, + enabled: bool = True, + persist: bool = False, + store_path: Optional[str] = None, + ) -> None: self._max_items = sanitize_max_items(max_items) self._enabled = bool(enabled) - self._entries: deque[tuple[int, str]] = deque(maxlen=self._max_items) + self._persist = bool(persist) + self._store_path = store_path + if self._persist and not self._store_path: + logger.warning( + "Transcription history persistence requested without a store " + "path; keeping history in memory only" + ) + self._persist = False + self._entries: deque[Dict[str, Any]] = deque(maxlen=self._max_items) self._lock = threading.Lock() self._change_callback: Optional[Callable[[], None]] = None # Monotonic id stamped on each snippet so late segments can extend @@ -71,6 +104,16 @@ class TranscriptionHistory: # audio capture began, so text decoded from speech captured before # this point can be refused however late it arrives. self._cleared_at = 0.0 + if self._store_path: + # A crash between the temp write and the rename leaves deleted or + # stale transcripts behind; sweep them before anything reads. + self._sweep_stale_store_temps() + if self._persist and self._enabled: + self._load_store() + elif self._store_path: + # Stale store from an earlier opt-in or a hand-edited config: + # "not persisting" must leave no record behind. + self._remove_store() def set_change_callback(self, callback: Optional[Callable[[], None]]) -> None: """Register a callback invoked whenever the history changes.""" @@ -81,6 +124,11 @@ class TranscriptionHistory: """Whether new snippets are being recorded.""" return self._enabled + @property + def persist_enabled(self) -> bool: + """Whether snippets are also being written to disk.""" + return self._persist and self._enabled + @property def max_items(self) -> int: """The maximum number of snippets retained.""" @@ -118,6 +166,7 @@ class TranscriptionHistory: self._max_items = max_items # deque(maxlen=...) keeps the rightmost (newest) items on trim. self._entries = deque(self._entries, maxlen=max_items) + self._write_store() self._notify() def set_enabled(self, enabled: bool) -> None: @@ -131,15 +180,54 @@ class TranscriptionHistory: self._entries.clear() self._epoch += 1 self._cleared_at = time.monotonic() + # "Keep no records" is airtight on disk too: the store is + # removed even when persistence stays configured, and is + # recreated only if recording resumes. + self._remove_store() self._notify() - def add(self, text: str, *, expected_epoch: Optional[int] = None) -> Optional[int]: + def set_persist(self, persist: bool) -> None: + """Enable or disable on-disk persistence. + + Enabling writes the current entries out immediately. Disabling + deletes the store: "stop keeping a record" must mean none is left + behind. Persistence without a store path is refused. + """ + persist = bool(persist) + with self._lock: + if persist == self._persist: + return + if persist and not self._store_path: + logger.warning( + "Transcription history persistence requested without a " + "store path; keeping history in memory only" + ) + return + self._persist = persist + if persist: + self._write_store() + else: + self._remove_store() + + def add( + self, + text: str, + *, + expected_epoch: Optional[int] = None, + duration: Optional[float] = None, + language: Optional[str] = None, + model: Optional[str] = None, + ) -> Optional[int]: """Add a snippet, returning its id when recorded (``None`` on refusal). No-op when disabled or when text is empty. With ``expected_epoch`` the add is also refused once the epoch has advanced — i.e. the history was cleared since that epoch was observed — so text produced before the clear cannot reappear as a new entry. + + ``duration`` (seconds), ``language`` and ``model`` are optional + details stored with the snippet when persistence is on and the + caller knows them. """ if not text: return None @@ -152,7 +240,19 @@ class TranscriptionHistory: if expected_epoch is not None and expected_epoch != self._epoch: return None self._next_id += 1 - self._entries.append((self._next_id, text)) + entry: Dict[str, Any] = { + "id": self._next_id, + "text": text, + "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + if duration is not None: + entry["duration"] = duration + if language: + entry["language"] = language + if model: + entry["model"] = model + self._entries.append(entry) + self._write_store() self._notify() return self._next_id @@ -172,12 +272,13 @@ class TranscriptionHistory: return False if expected_epoch is not None and expected_epoch != self._epoch: return False - for index, (entry_id, existing) in enumerate(self._entries): - if entry_id == snippet_id: - self._entries[index] = (entry_id, f"{existing} {text.strip()}") + for entry in self._entries: + if entry["id"] == snippet_id: + entry["text"] = f"{entry['text']} {text.strip()}" break else: return False + self._write_store() self._notify() return True @@ -202,30 +303,36 @@ class TranscriptionHistory: return False if expected_epoch is not None and expected_epoch != self._epoch: return False - entry_id, existing = self._entries[-1] - self._entries[-1] = (entry_id, f"{existing} {text.strip()}") + latest = self._entries[-1] + latest["text"] = f"{latest['text']} {text.strip()}" + self._write_store() self._notify() return True def get_all(self) -> List[str]: """Return all snippets, newest first.""" with self._lock: - return [text for _, text in reversed(self._entries)] + return [entry["text"] for entry in reversed(self._entries)] def clear(self) -> None: """Remove all snippets. The epoch advances even when the history is already empty: the call still expresses "forget everything dictated so far", so - text still in flight from before it must not re-enter. + text still in flight from before it must not re-enter. With + persistence on, the store is truncated so nothing survives on + disk either. """ with self._lock: self._epoch += 1 self._cleared_at = time.monotonic() - if not self._entries: - return + had_entries = bool(self._entries) self._entries.clear() - self._notify() + # Write even when memory was already empty: records that failed + # to load are still on disk, and Clear must drop them too. + self._write_store() + if had_entries: + self._notify() def __len__(self) -> int: with self._lock: @@ -240,3 +347,158 @@ class TranscriptionHistory: except (RuntimeError, TypeError, ValueError) as e: # A misbehaving UI callback must never break recording. logger.exception("Transcription history change callback failed: %s", e) + + # --- Persistence ------------------------------------------------------- + # Callers hold ``self._lock`` so the file can never interleave two + # mutations mid-write. A disk failure is logged, never raised: a storage + # hiccup must not break recording. + + def _load_store(self) -> None: + """Load persisted snippets, skipping malformed lines. + + Entries append in file order into the bounded deque, so an + over-long file quietly trims to the newest ``max_items``. Ids resume + from the highest stored id so reloaded entries stay addressable by + ``extend_entry``. + """ + if not self._store_path: + return + try: + with open(self._store_path, "rb") as handle: + raw_lines = handle.read().splitlines() + except FileNotFoundError: + return + except OSError as e: + logger.warning("Could not read transcription history store %s: %s", self._store_path, e) + return + skipped = 0 + loaded = 0 + for raw in raw_lines: + try: + line = raw.decode("utf-8") + except UnicodeDecodeError: + # A damaged line must not keep the valid ones, or the app, + # from loading. + skipped += 1 + continue + entry = self._entry_from_json(line) + if entry is None: + if line.strip(): + skipped += 1 + continue + loaded += 1 + self._entries.append(entry) + self._next_id = max(self._next_id, entry["id"]) + if skipped: + logger.warning("Skipped %d malformed line(s) in %s", skipped, self._store_path) + if skipped or loaded > len(self._entries): + # The file is not an exact mirror of memory — malformed lines, or + # a cap lowered since the file was written trimmed entries — so + # rewrite it now instead of letting stale text sit on disk. + self._write_store() + + @staticmethod + def _entry_from_json(line: str) -> Optional[Dict[str, Any]]: + """Parse one JSONL line into a history entry, or ``None`` when malformed.""" + try: + record = json.loads(line) + except json.JSONDecodeError: + return None + if not isinstance(record, dict): + return None + text = record.get("text") + if not isinstance(text, str) or not text.strip(): + return None + try: + text.encode("utf-8") + except UnicodeEncodeError: + # json.loads accepts lone surrogates, but the UTF-8 store cannot + # write them back; reject the record instead of letting the + # startup rewrite raise. + return None + raw_id = record.get("id") + if not isinstance(raw_id, int) or isinstance(raw_id, bool) or raw_id <= 0: + return None + entry: Dict[str, Any] = {"id": raw_id, "text": text} + for key in ("timestamp", "language", "model"): + value = record.get(key) + if isinstance(value, str) and value: + entry[key] = value + duration = record.get("duration") + if isinstance(duration, (int, float)) and not isinstance(duration, bool): + entry["duration"] = duration + return entry + + def _write_store(self) -> None: + """Rewrite the JSONL store so it mirrors the in-memory entries. + + The entries are bounded, so a full rewrite stays small; writing a + temp file and renaming keeps the store intact if the process dies + mid-write. A ``disabled`` or non-persisting history writes nothing. + """ + if not (self._enabled and self._persist and self._store_path): + return + try: + directory = os.path.dirname(self._store_path) + os.makedirs(directory, exist_ok=True) + fd, temp_path = tempfile.mkstemp(dir=directory, prefix=".history-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + for entry in self._entries: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + os.replace(temp_path, self._store_path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + except (OSError, UnicodeError) as e: + logger.warning( + "Could not write transcription history store %s: %s", + self._store_path, + e, + ) + + def _sweep_stale_store_temps(self) -> None: + """Delete temp files abandoned by a crash mid-write. + + ``_write_store`` drafts to ``.history-*.tmp`` next to the store, so a + process that dies between mkstemp and the rename leaves transcript + text on disk in a file nothing reads again. + """ + if not self._store_path: + return + directory = os.path.dirname(self._store_path) + try: + names = os.listdir(directory) + except OSError as e: + logger.warning( + "Could not list transcription history directory %s: %s", + directory, + e, + ) + return + for name in names: + if not (name.startswith(".history-") and name.endswith(".tmp")): + continue + try: + os.unlink(os.path.join(directory, name)) + except OSError as e: + logger.warning( + "Could not remove stale transcription history temp %s: %s", + name, + e, + ) + + def _remove_store(self) -> None: + """Delete the persisted store; a missing file is fine.""" + if not self._store_path: + return + try: + os.unlink(self._store_path) + except FileNotFoundError: + pass + except OSError as e: + logger.warning( + "Could not remove transcription history store %s: %s", + self._store_path, + e, + ) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 3bf517d..aa6ca37 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -464,8 +464,10 @@ class TrayIndicator: return enabled = self.config_manager.get_bool("history", "enabled", True) max_items = self.config_manager.get_int("history", "max_items", DEFAULT_MAX_ITEMS) + persist = self.config_manager.get_bool("history", "persist", False) self.transcription_history.set_max_items(max_items) self.transcription_history.set_enabled(enabled) + self.transcription_history.set_persist(persist) def _stop_language_shortcut_managers(self) -> None: """Stop every per-language listener and drop the managers.""" @@ -1293,6 +1295,13 @@ class TrayIndicator: def _on_clear_history_clicked(self, widget: Gtk.MenuItem) -> None: """Clear all stored snippets.""" logger.debug("Clear history clicked") + self.clear_transcription_history() + + def clear_transcription_history(self) -> None: + """Wipe every stored snippet, on disk as well as in memory. + + Shared by the tray menu item and the Settings clear button. + """ if self.transcription_history is not None: self.transcription_history.clear() @@ -1335,6 +1344,7 @@ class TrayIndicator: hotkey_listener_update_callback=self._setup_keyboard_shortcuts, language_shortcuts_update_callback=self.refresh_language_shortcuts, history_update_callback=self._apply_history_settings, + history_clear_callback=self.clear_transcription_history, ) dialog.connect("response", self._on_settings_dialog_response) dialog.connect("destroy", self._on_settings_dialog_destroyed) diff --git a/tests/test_transcription_history.py b/tests/test_transcription_history.py index 608c85f..4f83b28 100644 --- a/tests/test_transcription_history.py +++ b/tests/test_transcription_history.py @@ -2,7 +2,19 @@ Tests for the in-memory transcription history. """ +import json +import os +import sys import unittest +from tempfile import TemporaryDirectory +from typing import List + +# test_recognition_manager / test_speech_recognition replace sys.modules +# ["tempfile"] with a MagicMock at import time and never put it back. Bind +# TemporaryDirectory from the real stdlib module even when that happens. +if not isinstance(TemporaryDirectory, type): + sys.modules.pop("tempfile", None) + from tempfile import TemporaryDirectory from vocalinux.ui.transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory @@ -298,5 +310,208 @@ class TestTranscriptionHistory(unittest.TestCase): self.assertGreater(history.cleared_at, 0.0) +class TestTranscriptionHistoryPersistence(unittest.TestCase): + """Persistence of snippets to a JSONL store under the data directory (#758).""" + + def setUp(self) -> None: + self._tmp = TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.store_path = os.path.join(self._tmp.name, "history.jsonl") + + def _read_records(self) -> List[dict]: + """Parse the store file into a list of records (oldest first).""" + with open(self.store_path, "r", encoding="utf-8") as handle: + return [json.loads(line) for line in handle.read().splitlines() if line.strip()] + + def test_persist_round_trip(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("first") + history.add("second") + history.add("third") + + reloaded = TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertEqual(reloaded.get_all(), ["third", "second", "first"]) + self.assertEqual(len(reloaded), 3) + + def test_persist_records_metadata_when_available(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("hello", duration=2.5, language="en-us", model="whisper_cpp/base") + + records = self._read_records() + self.assertEqual(len(records), 1) + record = records[0] + self.assertEqual(record["text"], "hello") + self.assertEqual(record["duration"], 2.5) + self.assertEqual(record["language"], "en-us") + self.assertEqual(record["model"], "whisper_cpp/base") + self.assertIn("timestamp", record) + self.assertIn("id", record) + + def test_persist_writes_one_json_object_per_line(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("one") + history.add("two") + + with open(self.store_path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + self.assertEqual(len(lines), 2) + for line in lines: + self.assertIsInstance(json.loads(line), dict) + + def test_ids_continue_from_loaded_entries(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + first = history.add("one") + reloaded = TranscriptionHistory(persist=True, store_path=self.store_path) + second = reloaded.add("two") + self.assertIsNotNone(second) + self.assertNotEqual(first, second) + self.assertTrue(reloaded.extend_entry(first, "tail")) + self.assertEqual(reloaded.get_all(), ["two", "one tail"]) + + def test_clear_truncates_store(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("a") + history.add("b") + history.clear() + self.assertTrue(os.path.exists(self.store_path)) + with open(self.store_path, "r", encoding="utf-8") as handle: + self.assertEqual(handle.read(), "") + + def test_disabled_writes_nothing(self) -> None: + history = TranscriptionHistory(enabled=False, persist=True, store_path=self.store_path) + self.assertIsNone(history.add("ignored")) + self.assertFalse(os.path.exists(self.store_path)) + + def test_set_enabled_false_removes_store(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("a") + self.assertTrue(os.path.exists(self.store_path)) + history.set_enabled(False) + self.assertFalse(os.path.exists(self.store_path)) + self.assertFalse(history.persist_enabled) + + def test_persist_off_writes_nothing(self) -> None: + history = TranscriptionHistory(persist=False, store_path=self.store_path) + history.add("a") + self.assertFalse(os.path.exists(self.store_path)) + + def test_set_persist_off_removes_store(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("a") + history.set_persist(False) + self.assertFalse(os.path.exists(self.store_path)) + + def test_set_persist_on_writes_current_entries(self) -> None: + history = TranscriptionHistory(persist=False, store_path=self.store_path) + history.add("a") + history.set_persist(True) + self.assertEqual([r["text"] for r in self._read_records()], ["a"]) + + def test_persist_without_store_path_is_refused(self) -> None: + history = TranscriptionHistory(persist=True) + self.assertFalse(history.persist_enabled) + history.add("a") + history.set_persist(True) + self.assertFalse(history.persist_enabled) + + def test_stale_store_removed_when_not_persisting(self) -> None: + with open(self.store_path, "w", encoding="utf-8") as handle: + handle.write('{"id": 1, "text": "old"}\n') + TranscriptionHistory(persist=False, store_path=self.store_path) + self.assertFalse(os.path.exists(self.store_path)) + + def test_malformed_lines_are_skipped(self) -> None: + with open(self.store_path, "w", encoding="utf-8") as handle: + handle.write('{"id": 1, "text": "good"}\n') + handle.write("not json\n") + handle.write('{"text": "missing id"}\n') + handle.write('{"id": "x", "text": "bad id"}\n') + handle.write('["a", "list"]\n') + handle.write("\n") + handle.write('{"id": 2, "text": "also good", "language": "en-us"}\n') + + history = TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertEqual(history.get_all(), ["also good", "good"]) + + def test_invalid_utf8_line_is_skipped(self) -> None: + """A damaged line must not block startup or hide the valid ones.""" + with open(self.store_path, "wb") as handle: + handle.write(b'{"id": 1, "text": "good"}\n') + handle.write(b'{"id": 2, "text": "bad \xff\xfe"}\n') + handle.write(b'{"id": 3, "text": "also good"}\n') + + history = TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertEqual(history.get_all(), ["also good", "good"]) + + def test_lone_surrogate_text_is_skipped(self) -> None: + """A record JSON can parse but UTF-8 cannot write must not block startup.""" + with open(self.store_path, "w", encoding="utf-8") as handle: + handle.write('{"id": 1, "text": "\\ud800"}\n') + handle.write('{"id": 2, "text": "good"}\n') + + history = TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertEqual(history.get_all(), ["good"]) + + def test_load_rewrites_store_to_match_memory(self) -> None: + """Skipped lines and a lowered cap leave the file mirroring memory.""" + with open(self.store_path, "w", encoding="utf-8") as handle: + for index in range(1, 8): + handle.write(json.dumps({"id": index, "text": f"s{index}"}) + "\n") + handle.write("not json\n") + + TranscriptionHistory(max_items=3, persist=True, store_path=self.store_path) + self.assertEqual([r["text"] for r in self._read_records()], ["s5", "s6", "s7"]) + + def test_clear_writes_store_even_when_memory_is_empty(self) -> None: + """Clear also truncates a store whose records never made it to memory.""" + history = TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertEqual(len(history), 0) + history.clear() + self.assertEqual(self._read_records(), []) + + def test_abandoned_temp_files_are_swept(self) -> None: + """A crash mid-write must not leave transcript text behind.""" + stale = os.path.join(self._tmp.name, ".history-dead.tmp") + with open(stale, "w", encoding="utf-8") as handle: + handle.write('{"id": 1, "text": "deleted"}\n') + TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertFalse(os.path.exists(stale)) + + def test_abandoned_temp_files_are_swept_when_not_persisting(self) -> None: + stale = os.path.join(self._tmp.name, ".history-dead.tmp") + with open(stale, "w", encoding="utf-8") as handle: + handle.write('{"id": 1, "text": "deleted"}\n') + TranscriptionHistory(persist=False, store_path=self.store_path) + self.assertFalse(os.path.exists(stale)) + + def test_load_honors_max_items(self) -> None: + with open(self.store_path, "w", encoding="utf-8") as handle: + for index in range(1, 8): + handle.write(json.dumps({"id": index, "text": f"s{index}"}) + "\n") + + history = TranscriptionHistory(max_items=3, persist=True, store_path=self.store_path) + self.assertEqual(history.get_all(), ["s7", "s6", "s5"]) + + def test_extend_latest_updates_store(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + history.add("one") + history.extend_latest("tail") + self.assertEqual([r["text"] for r in self._read_records()], ["one tail"]) + + def test_trim_updates_store(self) -> None: + history = TranscriptionHistory(max_items=3, persist=True, store_path=self.store_path) + for text in ["a", "b", "c", "d"]: + history.add(text) + self.assertEqual([r["text"] for r in self._read_records()], ["b", "c", "d"]) + history.set_max_items(2) + self.assertEqual([r["text"] for r in self._read_records()], ["c", "d"]) + + def test_missing_store_loads_empty(self) -> None: + history = TranscriptionHistory(persist=True, store_path=self.store_path) + self.assertEqual(history.get_all(), []) + history.add("a") + self.assertEqual(history.get_all(), ["a"]) + + if __name__ == "__main__": unittest.main()