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:
co-authored by
Jatin K Malik
parent
ff57244dce
commit
442a646a57
+80
-8
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user