feat(history): add opt-in JSONL persistence for transcripts (#907)

* feat(history): add opt-in JSONL persistence for transcripts

Adds history.persist (default off) mirroring the bounded snippet list to
history.jsonl under the XDG data directory so transcripts survive
restarts. Entries carry timestamp, duration, language and model when
known. Clearing history truncates the file, turning persistence off
deletes it, and disabling history removes it so nothing is retained
anywhere. Settings gains a Keep on Disk switch and a Clear button in the
Transcription History group.

Fixes #758

* test(history): rebind real tempfile when siblings mock sys.modules

test_recognition_manager and test_speech_recognition replace
sys.modules["tempfile"] with a MagicMock at import time, so the new
persistence tests were sharing one mock-named store path in full-suite
runs. Guard the TemporaryDirectory binding the same way
test_whisper_model_info already does.

* fix(history): sweep stale temp files, survive damaged stores, and keep session fields on late commits

* fix(history): reject lone-surrogate records and survive encode errors on write

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-07 17:13:40 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent ff57244dce
commit 442a646a57
6 changed files with 647 additions and 32 deletions
+80 -8
View File
@@ -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:
+5 -1
View File
@@ -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
+54 -2
View File
@@ -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
+283 -21
View File
@@ -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,
)
+10
View File
@@ -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)
+215
View File
@@ -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()