From ba522a58cde3630452f3816fe89634897bd245e2 Mon Sep 17 00:00:00 2001 From: Chidi Williams Date: Fri, 15 Mar 2024 17:38:35 +0000 Subject: [PATCH] add transcript viewer (#686) --- buzz/db/dao/dao.py | 30 +++-- buzz/db/dao/transcription_segment_dao.py | 15 +++ buzz/db/entity/transcription.py | 7 ++ buzz/db/entity/transcription_segment.py | 1 + buzz/db/service/transcription_service.py | 3 + buzz/settings/settings.py | 4 +- buzz/settings/shortcut.py | 4 +- buzz/settings/shortcut_settings.py | 21 ---- buzz/settings/shortcuts.py | 24 ++++ buzz/transcriber/file_transcriber.py | 1 + buzz/widgets/application.py | 3 +- buzz/widgets/icon.py | 7 ++ buzz/widgets/main_window.py | 21 ++-- buzz/widgets/main_window_toolbar.py | 28 ++--- buzz/widgets/menu_bar.py | 21 ++-- buzz/widgets/openai_api_key_line_edit.py | 6 +- .../preferences_dialog/preferences_dialog.py | 8 +- .../shortcuts_editor_preferences_widget.py | 23 ++-- buzz/widgets/toolbar.py | 5 +- .../transcription_tasks_table_widget.py | 3 + .../export_transcription_button.py | 30 ----- .../export_transcription_menu.py | 61 ++++++++++ .../transcription_segments_editor_widget.py | 2 + .../transcription_view_mode_tool_button.py | 37 ++++++ .../transcription_viewer_widget.py | 111 +++++++++++------- tests/conftest.py | 20 ++++ .../widgets/export_transcription_menu_test.py | 63 ++++++++++ tests/widgets/menu_bar_test.py | 7 +- .../preferences_dialog_test.py | 5 +- tests/widgets/shortcuts_editor_widget_test.py | 20 +++- tests/widgets/transcription_viewer_test.py | 38 ++---- 31 files changed, 423 insertions(+), 206 deletions(-) delete mode 100644 buzz/settings/shortcut_settings.py create mode 100644 buzz/settings/shortcuts.py delete mode 100644 buzz/widgets/transcription_viewer/export_transcription_button.py create mode 100644 buzz/widgets/transcription_viewer/export_transcription_menu.py create mode 100644 buzz/widgets/transcription_viewer/transcription_view_mode_tool_button.py create mode 100644 tests/widgets/export_transcription_menu_test.py diff --git a/buzz/db/dao/dao.py b/buzz/db/dao/dao.py index 16a6548567..e173d19e9e 100644 --- a/buzz/db/dao/dao.py +++ b/buzz/db/dao/dao.py @@ -1,6 +1,6 @@ # Adapted from https://github.com/zhiyiYo/Groove from abc import ABC -from typing import TypeVar, Generic, Any, Type +from typing import TypeVar, Generic, Any, Type, List from PyQt6.QtSql import QSqlDatabase, QSqlQuery, QSqlRecord @@ -11,6 +11,7 @@ class DAO(ABC, Generic[T]): entity: Type[T] + ignore_fields = [] def __init__(self, table: str, db: QSqlDatabase): self.db = db @@ -18,15 +19,18 @@ def __init__(self, table: str, db: QSqlDatabase): def insert(self, record: T): query = self._create_query() - keys = record.__dict__.keys() + fields = [ + field for field in record.__dict__.keys() if field not in self.ignore_fields + ] query.prepare( f""" - INSERT INTO {self.table} ({", ".join(keys)}) - VALUES ({", ".join([f":{key}" for key in keys])}) + INSERT INTO {self.table} ({", ".join(fields)}) + VALUES ({", ".join([f":{key}" for key in fields])}) """ ) - for key, value in record.__dict__.items(): - query.bindValue(f":{key}", value) + for field in fields: + query.bindValue(f":{field}", getattr(record, field)) + if not query.exec(): raise Exception(query.lastError().text()) @@ -37,10 +41,8 @@ def find_by_id(self, id: Any) -> T | None: return self._execute(query) def to_entity(self, record: QSqlRecord) -> T: - entity = self.entity() - for i in range(record.count()): - setattr(entity, record.fieldName(i), record.value(i)) - return entity + kwargs = {record.fieldName(i): record.value(i) for i in range(record.count())} + return self.entity(**kwargs) def _execute(self, query: QSqlQuery) -> T | None: if not query.exec(): @@ -49,5 +51,13 @@ def _execute(self, query: QSqlQuery) -> T | None: return None return self.to_entity(query.record()) + def _execute_all(self, query: QSqlQuery) -> List[T]: + if not query.exec(): + raise Exception(query.lastError().text()) + entities = [] + while query.next(): + entities.append(self.to_entity(query.record())) + return entities + def _create_query(self): return QSqlQuery(self.db) diff --git a/buzz/db/dao/transcription_segment_dao.py b/buzz/db/dao/transcription_segment_dao.py index fa2c6ef562..bfff19fabf 100644 --- a/buzz/db/dao/transcription_segment_dao.py +++ b/buzz/db/dao/transcription_segment_dao.py @@ -1,3 +1,6 @@ +from typing import List +from uuid import UUID + from PyQt6.QtSql import QSqlDatabase from buzz.db.dao.dao import DAO @@ -6,6 +9,18 @@ class TranscriptionSegmentDAO(DAO[TranscriptionSegment]): entity = TranscriptionSegment + ignore_fields = ["id"] def __init__(self, db: QSqlDatabase): super().__init__("transcription_segment", db) + + def get_segments(self, transcription_id: UUID) -> List[TranscriptionSegment]: + query = self._create_query() + query.prepare( + f""" + SELECT * FROM {self.table} + WHERE transcription_id = :transcription_id + """ + ) + query.bindValue(":transcription_id", str(transcription_id)) + return self._execute_all(query) diff --git a/buzz/db/entity/transcription.py b/buzz/db/entity/transcription.py index d4d3731f78..9055fa25b4 100644 --- a/buzz/db/entity/transcription.py +++ b/buzz/db/entity/transcription.py @@ -20,6 +20,13 @@ class Transcription(Entity): error_message: str | None = None file: str | None = None time_queued: str = datetime.datetime.now().isoformat() + progress: float = 0.0 + time_ended: str | None = None + time_started: str | None = None + export_formats: str | None = None + output_folder: str | None = None + source: str | None = None + url: str | None = None @property def id_as_uuid(self): diff --git a/buzz/db/entity/transcription_segment.py b/buzz/db/entity/transcription_segment.py index 539ee46e3b..0e2f5d1fbb 100644 --- a/buzz/db/entity/transcription_segment.py +++ b/buzz/db/entity/transcription_segment.py @@ -9,3 +9,4 @@ class TranscriptionSegment(Entity): end_time: int text: str transcription_id: str + id: int = -1 diff --git a/buzz/db/service/transcription_service.py b/buzz/db/service/transcription_service.py index 0b9c90cf89..00972c3d52 100644 --- a/buzz/db/service/transcription_service.py +++ b/buzz/db/service/transcription_service.py @@ -42,3 +42,6 @@ def update_transcription_as_completed(self, id: UUID, segments: List[Segment]): transcription_id=str(id), ) ) + + def get_transcription_segments(self, transcription_id: UUID): + return self.transcription_segment_dao.get_segments(transcription_id) diff --git a/buzz/settings/settings.py b/buzz/settings/settings.py index 6d6cca77ec..22c169d704 100644 --- a/buzz/settings/settings.py +++ b/buzz/settings/settings.py @@ -7,8 +7,8 @@ class Settings: - def __init__(self): - self.settings = QSettings(APP_NAME) + def __init__(self, application=""): + self.settings = QSettings(APP_NAME, application) self.settings.sync() class Key(enum.Enum): diff --git a/buzz/settings/shortcut.py b/buzz/settings/shortcut.py index f9939d9a2d..32cc238b6e 100644 --- a/buzz/settings/shortcut.py +++ b/buzz/settings/shortcut.py @@ -18,7 +18,9 @@ def __new__(cls, sequence: str, description: str): OPEN_IMPORT_URL_WINDOW = ("Ctrl+U", "Import URL") OPEN_PREFERENCES_WINDOW = ("Ctrl+,", "Open Preferences Window") - OPEN_TRANSCRIPT_EDITOR = ("Ctrl+E", "Open Transcript Viewer") + VIEW_TRANSCRIPT_TEXT = ("Ctrl+E", "View Transcript Text") + VIEW_TRANSCRIPT_TIMESTAMPS = ("Ctrl+T", "View Transcript Timestamps") + CLEAR_HISTORY = ("Ctrl+S", "Clear History") STOP_TRANSCRIPTION = ("Ctrl+X", "Cancel Transcription") diff --git a/buzz/settings/shortcut_settings.py b/buzz/settings/shortcut_settings.py deleted file mode 100644 index 7465723e34..0000000000 --- a/buzz/settings/shortcut_settings.py +++ /dev/null @@ -1,21 +0,0 @@ -import typing - -from buzz.settings.settings import Settings -from buzz.settings.shortcut import Shortcut - - -class ShortcutSettings: - def __init__(self, settings: Settings): - self.settings = settings - - def load(self) -> typing.Dict[str, str]: - shortcuts = Shortcut.get_default_shortcuts() - custom_shortcuts: typing.Dict[str, str] = self.settings.value( - Settings.Key.SHORTCUTS, {} - ) - for shortcut_name in custom_shortcuts: - shortcuts[shortcut_name] = custom_shortcuts[shortcut_name] - return shortcuts - - def save(self, shortcuts: typing.Dict[str, str]) -> None: - self.settings.set_value(Settings.Key.SHORTCUTS, shortcuts) diff --git a/buzz/settings/shortcuts.py b/buzz/settings/shortcuts.py new file mode 100644 index 0000000000..5e4b99d45d --- /dev/null +++ b/buzz/settings/shortcuts.py @@ -0,0 +1,24 @@ +import typing + +from buzz.settings.settings import Settings +from buzz.settings.shortcut import Shortcut + + +class Shortcuts: + def __init__(self, settings: Settings): + self.settings = settings + + def get(self, shortcut: Shortcut) -> str: + custom_shortcuts = self.get_custom_shortcuts() + return custom_shortcuts.get(shortcut.name, shortcut.sequence) + + def set(self, shortcut: Shortcut, sequence: str) -> None: + custom_shortcuts = self.get_custom_shortcuts() + custom_shortcuts[shortcut.name] = sequence + self.settings.set_value(Settings.Key.SHORTCUTS, custom_shortcuts) + + def clear(self) -> None: + self.settings.set_value(Settings.Key.SHORTCUTS, {}) + + def get_custom_shortcuts(self) -> typing.Dict[str, str]: + return self.settings.value(Settings.Key.SHORTCUTS, {}) diff --git a/buzz/transcriber/file_transcriber.py b/buzz/transcriber/file_transcriber.py index c9e8c86754..07151fb44a 100644 --- a/buzz/transcriber/file_transcriber.py +++ b/buzz/transcriber/file_transcriber.py @@ -102,6 +102,7 @@ def stop(self): ... +# TODO: Move to transcription service def write_output(path: str, segments: List[Segment], output_format: OutputFormat): logging.debug( "Writing transcription output, path = %s, output format = %s, number of segments = %s", diff --git a/buzz/widgets/application.py b/buzz/widgets/application.py index 4efa2f28a5..57bb2ee557 100644 --- a/buzz/widgets/application.py +++ b/buzz/widgets/application.py @@ -1,5 +1,6 @@ import sys +from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QApplication from buzz.__version__ import VERSION @@ -22,7 +23,7 @@ def __init__(self, argv: list) -> None: self.setApplicationVersion(VERSION) if sys.platform == "darwin": - self.setStyle("Fusion") + self.setFont(QFont("SF Pro", self.font().pointSize())) db = setup_app_db() transcription_service = TranscriptionService( diff --git a/buzz/widgets/icon.py b/buzz/widgets/icon.py index e6da0ab630..e86fa7fc7a 100644 --- a/buzz/widgets/icon.py +++ b/buzz/widgets/icon.py @@ -74,6 +74,13 @@ def __init__(self, parent: QWidget): super().__init__(get_path("assets/file_download_black_24dp.svg"), parent) +class VisibilityIcon(Icon): + def __init__(self, parent: QWidget): + super().__init__( + get_path("assets/visibility_FILL0_wght700_GRAD0_opsz48.svg"), parent + ) + + BUZZ_ICON_PATH = get_path("assets/buzz.ico") BUZZ_LARGE_ICON_PATH = get_path("assets/buzz-icon-1024.png") diff --git a/buzz/widgets/main_window.py b/buzz/widgets/main_window.py index 1e71e9a241..a45e3597fd 100644 --- a/buzz/widgets/main_window.py +++ b/buzz/widgets/main_window.py @@ -19,7 +19,7 @@ from buzz.file_transcriber_queue_worker import FileTranscriberQueueWorker from buzz.locale import _ from buzz.settings.settings import APP_NAME, Settings -from buzz.settings.shortcut_settings import ShortcutSettings +from buzz.settings.shortcuts import Shortcuts from buzz.store.keyring_store import set_password, Key from buzz.transcriber.transcriber import ( FileTranscriptionTask, @@ -59,8 +59,7 @@ def __init__(self, transcription_service: TranscriptionService): self.settings = Settings() - self.shortcut_settings = ShortcutSettings(settings=self.settings) - self.shortcuts = self.shortcut_settings.load() + self.shortcuts = Shortcuts(settings=self.settings) self.transcription_service = transcription_service @@ -326,7 +325,11 @@ def on_table_double_clicked(self, index: QModelIndex): def open_transcription_viewer(self, transcription: Transcription): transcription_viewer_widget = TranscriptionViewerWidget( - transcription=transcription, parent=self, flags=Qt.WindowType.Window + transcription=transcription, + transcription_service=self.transcription_service, + shortcuts=self.shortcuts, + parent=self, + flags=Qt.WindowType.Window, ) transcription_viewer_widget.show() @@ -354,15 +357,12 @@ def on_task_completed(self, task: FileTranscriptionTask, segments: List[Segment] self.table_widget.refresh_row(task.uid) def on_task_error(self, task: FileTranscriptionTask, error: str): - logging.debug("FAILED!!!!") self.transcription_service.update_transcription_as_failed(task.uid, error) self.table_widget.refresh_row(task.uid) - def on_shortcuts_changed(self, shortcuts: dict): - self.shortcuts = shortcuts - self.menu_bar.set_shortcuts(shortcuts=self.shortcuts) - self.toolbar.set_shortcuts(shortcuts=self.shortcuts) - self.shortcut_settings.save(shortcuts=self.shortcuts) + def on_shortcuts_changed(self): + self.menu_bar.reset_shortcuts() + self.toolbar.reset_shortcuts() def resizeEvent(self, event): self.save_geometry() @@ -373,7 +373,6 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: self.transcriber_worker.stop() self.transcriber_thread.quit() self.transcriber_thread.wait() - self.shortcut_settings.save(shortcuts=self.shortcuts) super().closeEvent(event) def save_geometry(self): diff --git a/buzz/widgets/main_window_toolbar.py b/buzz/widgets/main_window_toolbar.py index 01a963c282..74f4ced9cd 100644 --- a/buzz/widgets/main_window_toolbar.py +++ b/buzz/widgets/main_window_toolbar.py @@ -1,10 +1,14 @@ -from typing import Dict, Optional +from typing import Optional from PyQt6.QtCore import pyqtSignal, Qt from PyQt6.QtGui import QKeySequence from PyQt6.QtWidgets import QWidget from buzz.action import Action +from buzz.locale import _ +from buzz.settings.shortcut import Shortcut +from buzz.settings.shortcuts import Shortcuts +from buzz.widgets.icon import Icon from buzz.widgets.icon import ( RECORD_ICON_PATH, ADD_ICON_PATH, @@ -12,9 +16,6 @@ CANCEL_ICON_PATH, TRASH_ICON_PATH, ) -from buzz.locale import _ -from buzz.settings.shortcut import Shortcut -from buzz.widgets.icon import Icon from buzz.widgets.recording_transcriber_widget import RecordingTranscriberWidget from buzz.widgets.toolbar import ToolBar @@ -26,9 +27,11 @@ class MainWindowToolbar(ToolBar): ICON_LIGHT_THEME_BACKGROUND = "#555" ICON_DARK_THEME_BACKGROUND = "#AAA" - def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget]): + def __init__(self, shortcuts: Shortcuts, parent: Optional[QWidget]): super().__init__(parent) + self.shortcuts = shortcuts + self.record_action = Action(Icon(RECORD_ICON_PATH, self), _("Record"), self) self.record_action.triggered.connect(self.on_record_action_triggered) @@ -59,7 +62,7 @@ def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget]): self.clear_history_action_triggered = self.clear_history_action.triggered self.clear_history_action.setDisabled(True) - self.set_shortcuts(shortcuts) + self.reset_shortcuts() self.addAction(self.record_action) self.addSeparator() @@ -74,21 +77,18 @@ def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget]): self.setMovable(False) self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - def set_shortcuts(self, shortcuts: Dict[str, str]): + def reset_shortcuts(self): self.record_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.OPEN_RECORD_WINDOW.name]) + QKeySequence.fromString(self.shortcuts.get(Shortcut.OPEN_RECORD_WINDOW)) ) self.new_transcription_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.OPEN_IMPORT_WINDOW.name]) - ) - self.open_transcript_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.OPEN_TRANSCRIPT_EDITOR.name]) + QKeySequence.fromString(self.shortcuts.get(Shortcut.OPEN_IMPORT_WINDOW)) ) self.stop_transcription_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.STOP_TRANSCRIPTION.name]) + QKeySequence.fromString(self.shortcuts.get(Shortcut.STOP_TRANSCRIPTION)) ) self.clear_history_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.CLEAR_HISTORY.name]) + QKeySequence.fromString(self.shortcuts.get(Shortcut.CLEAR_HISTORY)) ) def on_record_action_triggered(self): diff --git a/buzz/widgets/menu_bar.py b/buzz/widgets/menu_bar.py index f897e0db8f..d441593a3a 100644 --- a/buzz/widgets/menu_bar.py +++ b/buzz/widgets/menu_bar.py @@ -1,5 +1,5 @@ import webbrowser -from typing import Dict, Optional +from typing import Optional from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QAction, QKeySequence @@ -8,6 +8,7 @@ from buzz.locale import _ from buzz.settings.settings import APP_NAME from buzz.settings.shortcut import Shortcut +from buzz.settings.shortcuts import Shortcuts from buzz.widgets.about_dialog import AboutDialog from buzz.widgets.preferences_dialog.models.preferences import Preferences from buzz.widgets.preferences_dialog.preferences_dialog import ( @@ -18,14 +19,14 @@ class MenuBar(QMenuBar): import_action_triggered = pyqtSignal() import_url_action_triggered = pyqtSignal() - shortcuts_changed = pyqtSignal(dict) + shortcuts_changed = pyqtSignal() openai_api_key_changed = pyqtSignal(str) preferences_changed = pyqtSignal(Preferences) preferences_dialog: Optional[PreferencesDialog] = None def __init__( self, - shortcuts: Dict[str, str], + shortcuts: Shortcuts, preferences: Preferences, parent: Optional[QWidget] = None, ): @@ -49,7 +50,7 @@ def __init__( help_action = QAction(f'{_("Help")}', self) help_action.triggered.connect(self.on_help_action_triggered) - self.set_shortcuts(shortcuts) + self.reset_shortcuts() file_menu = self.addMenu(_("File")) file_menu.addAction(self.import_action) @@ -86,15 +87,15 @@ def on_preferences_dialog_finished(self, result): def on_help_action_triggered(self): webbrowser.open("https://chidiwilliams.github.io/buzz/docs") - def set_shortcuts(self, shortcuts: Dict[str, str]): - self.shortcuts = shortcuts - + def reset_shortcuts(self): self.import_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.OPEN_IMPORT_WINDOW.name]) + QKeySequence.fromString(self.shortcuts.get(Shortcut.OPEN_IMPORT_WINDOW)) ) self.import_url_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.OPEN_IMPORT_URL_WINDOW.name]) + QKeySequence.fromString(self.shortcuts.get(Shortcut.OPEN_IMPORT_URL_WINDOW)) ) self.preferences_action.setShortcut( - QKeySequence.fromString(shortcuts[Shortcut.OPEN_PREFERENCES_WINDOW.name]) + QKeySequence.fromString( + self.shortcuts.get(Shortcut.OPEN_PREFERENCES_WINDOW) + ) ) diff --git a/buzz/widgets/openai_api_key_line_edit.py b/buzz/widgets/openai_api_key_line_edit.py index 6b484829d9..0acc1c4e24 100644 --- a/buzz/widgets/openai_api_key_line_edit.py +++ b/buzz/widgets/openai_api_key_line_edit.py @@ -4,7 +4,7 @@ from PyQt6.QtWidgets import QWidget, QLineEdit from buzz.assets import get_path -from buzz.widgets.icon import Icon +from buzz.widgets.icon import Icon, VisibilityIcon from buzz.widgets.line_edit import LineEdit @@ -16,9 +16,7 @@ def __init__(self, key: str, parent: Optional[QWidget] = None): self.key = key - self.visible_on_icon = Icon( - get_path("assets/visibility_FILL0_wght700_GRAD0_opsz48.svg"), self - ) + self.visible_on_icon = VisibilityIcon(self) self.visible_off_icon = Icon( get_path("assets/visibility_off_FILL0_wght700_GRAD0_opsz48.svg"), self ) diff --git a/buzz/widgets/preferences_dialog/preferences_dialog.py b/buzz/widgets/preferences_dialog/preferences_dialog.py index c6da9d8063..1c27d21cec 100644 --- a/buzz/widgets/preferences_dialog/preferences_dialog.py +++ b/buzz/widgets/preferences_dialog/preferences_dialog.py @@ -1,10 +1,11 @@ import copy -from typing import Dict, Optional +from typing import Optional from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QDialog, QWidget, QVBoxLayout, QTabWidget, QDialogButtonBox from buzz.locale import _ +from buzz.settings.shortcuts import Shortcuts from buzz.widgets.preferences_dialog.folder_watch_preferences_widget import ( FolderWatchPreferencesWidget, ) @@ -24,15 +25,14 @@ class PreferencesDialog(QDialog): - shortcuts_changed = pyqtSignal(dict) + shortcuts_changed = pyqtSignal() openai_api_key_changed = pyqtSignal(str) folder_watch_config_changed = pyqtSignal(FolderWatchPreferences) preferences_changed = pyqtSignal(Preferences) def __init__( self, - # TODO: move shortcuts and default export file name into preferences - shortcuts: Dict[str, str], + shortcuts: Shortcuts, preferences: Preferences, parent: Optional[QWidget] = None, ) -> None: diff --git a/buzz/widgets/preferences_dialog/shortcuts_editor_preferences_widget.py b/buzz/widgets/preferences_dialog/shortcuts_editor_preferences_widget.py index 47fdf0b36c..d7c9e881b1 100644 --- a/buzz/widgets/preferences_dialog/shortcuts_editor_preferences_widget.py +++ b/buzz/widgets/preferences_dialog/shortcuts_editor_preferences_widget.py @@ -1,26 +1,27 @@ -from typing import Optional, Dict +from typing import Optional from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QKeySequence from PyQt6.QtWidgets import QWidget, QFormLayout, QPushButton from buzz.settings.shortcut import Shortcut +from buzz.settings.shortcuts import Shortcuts from buzz.widgets.sequence_edit import SequenceEdit class ShortcutsEditorPreferencesWidget(QWidget): - shortcuts_changed = pyqtSignal(dict) + shortcuts_changed = pyqtSignal() - def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget] = None): + def __init__(self, shortcuts: Shortcuts, parent: Optional[QWidget] = None): super().__init__(parent) self.shortcuts = shortcuts self.layout = QFormLayout(self) for shortcut in Shortcut: - sequence_edit = SequenceEdit(shortcuts.get(shortcut.name, ""), self) + sequence_edit = SequenceEdit(shortcuts.get(shortcut), self) sequence_edit.keySequenceChanged.connect( - self.get_key_sequence_changed(shortcut.name) + self.get_key_sequence_changed(shortcut) ) self.layout.addRow(shortcut.description, sequence_edit) @@ -31,21 +32,21 @@ def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget] = None): self.layout.addWidget(reset_to_defaults_button) - def get_key_sequence_changed(self, shortcut_name: str): + def get_key_sequence_changed(self, shortcut: Shortcut): def key_sequence_changed(sequence: QKeySequence): - self.shortcuts[shortcut_name] = sequence.toString() - self.shortcuts_changed.emit(self.shortcuts) + self.shortcuts.set(shortcut, sequence.toString()) + self.shortcuts_changed.emit() return key_sequence_changed def reset_to_defaults(self): - self.shortcuts = Shortcut.get_default_shortcuts() + self.shortcuts.clear() for i, shortcut in enumerate(Shortcut): sequence_edit = self.layout.itemAt( i, QFormLayout.ItemRole.FieldRole ).widget() assert isinstance(sequence_edit, SequenceEdit) - sequence_edit.setKeySequence(QKeySequence(self.shortcuts[shortcut.name])) + sequence_edit.setKeySequence(QKeySequence(self.shortcuts.get(shortcut))) - self.shortcuts_changed.emit(self.shortcuts) + self.shortcuts_changed.emit() diff --git a/buzz/widgets/toolbar.py b/buzz/widgets/toolbar.py index 36fde2a9fa..1ac595f825 100644 --- a/buzz/widgets/toolbar.py +++ b/buzz/widgets/toolbar.py @@ -14,9 +14,10 @@ def __init__(self, parent: typing.Optional[QWidget] = None): self.setStyleSheet("QToolButton{margin: 6px 3px;}") self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - def addAction(self, action: QtGui.QAction) -> None: - super().addAction(action) + def addAction(self, *args): + action = super().addAction(*args) self.fix_spacing_on_mac() + return action def addActions(self, actions: typing.Iterable[QtGui.QAction]) -> None: super().addActions(actions) diff --git a/buzz/widgets/transcription_tasks_table_widget.py b/buzz/widgets/transcription_tasks_table_widget.py index faf8d5fb8d..f6d3f8ea93 100644 --- a/buzz/widgets/transcription_tasks_table_widget.py +++ b/buzz/widgets/transcription_tasks_table_widget.py @@ -187,6 +187,9 @@ def __init__(self, parent: Optional[QWidget] = None): self.verticalHeader().hide() self.setAlternatingRowColors(True) + # Show date added before date completed + self.horizontalHeader().swapSections(11, 12) + def contextMenuEvent(self, event): menu = QMenu(self) for definition in column_definitions: diff --git a/buzz/widgets/transcription_viewer/export_transcription_button.py b/buzz/widgets/transcription_viewer/export_transcription_button.py deleted file mode 100644 index aaf1a7c64f..0000000000 --- a/buzz/widgets/transcription_viewer/export_transcription_button.py +++ /dev/null @@ -1,30 +0,0 @@ -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QPushButton, QWidget, QMenu - -from buzz.transcriber.transcriber import ( - OutputFormat, -) -from buzz.widgets.icon import FileDownloadIcon - - -class ExportTranscriptionButton(QPushButton): - on_export_triggered = pyqtSignal(OutputFormat) - - def __init__(self, parent: QWidget): - super().__init__(parent) - - export_button_menu = QMenu() - actions = [ - QAction(text=output_format.value.upper(), parent=self) - for output_format in OutputFormat - ] - export_button_menu.addActions(actions) - export_button_menu.triggered.connect(self.on_menu_triggered) - - self.setMenu(export_button_menu) - self.setIcon(FileDownloadIcon(self)) - - def on_menu_triggered(self, action: QAction): - output_format = OutputFormat[action.text()] - self.on_export_triggered.emit(output_format) diff --git a/buzz/widgets/transcription_viewer/export_transcription_menu.py b/buzz/widgets/transcription_viewer/export_transcription_menu.py new file mode 100644 index 0000000000..55e9c8f5b5 --- /dev/null +++ b/buzz/widgets/transcription_viewer/export_transcription_menu.py @@ -0,0 +1,61 @@ +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QWidget, QMenu, QFileDialog + +from buzz.db.entity.transcription import Transcription +from buzz.db.service.transcription_service import TranscriptionService +from buzz.locale import _ +from buzz.transcriber.file_transcriber import write_output +from buzz.transcriber.transcriber import ( + OutputFormat, + Segment, +) + + +class ExportTranscriptionMenu(QMenu): + def __init__( + self, + transcription: Transcription, + transcription_service: TranscriptionService, + parent: QWidget | None = None, + ): + super().__init__(parent) + + self.transcription = transcription + self.transcription_service = transcription_service + + actions = [ + QAction(text=output_format.value.upper(), parent=self) + for output_format in OutputFormat + ] + self.addActions(actions) + self.triggered.connect(self.on_menu_triggered) + + def on_menu_triggered(self, action: QAction): + output_format = OutputFormat[action.text()] + + default_path = self.transcription.get_output_file_path( + output_format=output_format + ) + + (output_file_path, nil) = QFileDialog.getSaveFileName( + self, + _("Save File"), + default_path, + _("Text files") + f" (*.{output_format.value})", + ) + + if output_file_path == "": + return + + segments = [ + Segment(start=segment.start_time, end=segment.end_time, text=segment.text) + for segment in self.transcription_service.get_transcription_segments( + transcription_id=self.transcription.id_as_uuid + ) + ] + + write_output( + path=output_file_path, + segments=segments, + output_format=output_format, + ) diff --git a/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py b/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py index e35f21a011..4f4f2b0d9b 100644 --- a/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py +++ b/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py @@ -90,6 +90,8 @@ def __init__(self, transcription_id: UUID, parent: Optional[QWidget]): self.selectionModel().selectionChanged.connect(self.on_selection_changed) model.select() + # Show start before end + self.horizontalHeader().swapSections(1, 2) self.resizeColumnsToContents() def on_selection_changed( diff --git a/buzz/widgets/transcription_viewer/transcription_view_mode_tool_button.py b/buzz/widgets/transcription_viewer/transcription_view_mode_tool_button.py new file mode 100644 index 0000000000..6f3557ddd6 --- /dev/null +++ b/buzz/widgets/transcription_viewer/transcription_view_mode_tool_button.py @@ -0,0 +1,37 @@ +from typing import Optional + +from PyQt6.QtCore import pyqtSignal, Qt +from PyQt6.QtGui import QKeySequence +from PyQt6.QtWidgets import QToolButton, QWidget, QMenu + +from buzz.locale import _ +from buzz.settings.shortcut import Shortcut +from buzz.settings.shortcuts import Shortcuts +from buzz.widgets.icon import VisibilityIcon + + +class TranscriptionViewModeToolButton(QToolButton): + view_mode_changed = pyqtSignal(bool) # is_timestamps? + + def __init__(self, shortcuts: Shortcuts, parent: Optional[QWidget] = None): + super().__init__(parent) + + self.setText("View") + self.setIcon(VisibilityIcon(self)) + self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + menu = QMenu(self) + + menu.addAction( + _("Text"), + QKeySequence(shortcuts.get(Shortcut.VIEW_TRANSCRIPT_TEXT)), + lambda: self.view_mode_changed.emit(False), + ) + + menu.addAction( + _("Timestamps"), + QKeySequence(shortcuts.get(Shortcut.VIEW_TRANSCRIPT_TIMESTAMPS)), + lambda: self.view_mode_changed.emit(True), + ) + self.setMenu(menu) diff --git a/buzz/widgets/transcription_viewer/transcription_viewer_widget.py b/buzz/widgets/transcription_viewer/transcription_viewer_widget.py index e98b24dfe0..861168f218 100644 --- a/buzz/widgets/transcription_viewer/transcription_viewer_widget.py +++ b/buzz/widgets/transcription_viewer/transcription_viewer_widget.py @@ -3,28 +3,35 @@ from uuid import UUID from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont from PyQt6.QtMultimedia import QMediaPlayer from PyQt6.QtSql import QSqlRecord from PyQt6.QtWidgets import ( QWidget, - QHBoxLayout, + QVBoxLayout, + QToolButton, QLabel, - QGridLayout, - QFileDialog, ) from buzz.db.entity.transcription import Transcription -from buzz.locale import _ +from buzz.db.service.transcription_service import TranscriptionService from buzz.paths import file_path_as_title -from buzz.transcriber.file_transcriber import write_output -from buzz.transcriber.transcriber import OutputFormat, Segment +from buzz.settings.shortcuts import Shortcuts from buzz.widgets.audio_player import AudioPlayer -from buzz.widgets.transcription_viewer.export_transcription_button import ( - ExportTranscriptionButton, +from buzz.widgets.icon import ( + FileDownloadIcon, +) +from buzz.widgets.text_display_box import TextDisplayBox +from buzz.widgets.toolbar import ToolBar +from buzz.widgets.transcription_viewer.export_transcription_menu import ( + ExportTranscriptionMenu, ) from buzz.widgets.transcription_viewer.transcription_segments_editor_widget import ( TranscriptionSegmentsEditorWidget, ) +from buzz.widgets.transcription_viewer.transcription_view_mode_tool_button import ( + TranscriptionViewModeToolButton, +) class TranscriptionViewerWidget(QWidget): @@ -33,22 +40,31 @@ class TranscriptionViewerWidget(QWidget): def __init__( self, transcription: Transcription, + transcription_service: TranscriptionService, + shortcuts: Shortcuts, parent: Optional["QWidget"] = None, flags: Qt.WindowType = Qt.WindowType.Widget, ) -> None: super().__init__(parent, flags) self.transcription = transcription + self.transcription_service = transcription_service self.setMinimumWidth(800) self.setMinimumHeight(500) self.setWindowTitle(file_path_as_title(transcription.file)) + self.is_showing_timestamps = True + self.table_widget = TranscriptionSegmentsEditorWidget( transcription_id=UUID(hex=transcription.id), parent=self ) self.table_widget.segment_selected.connect(self.on_segment_selected) + self.text_display_box = TextDisplayBox(self) + font = QFont(self.text_display_box.font().family(), 14) + self.text_display_box.setFont(font) + self.audio_player: Optional[AudioPlayer] = None if platform.system() != "Linux": self.audio_player = AudioPlayer(file_path=transcription.file) @@ -56,56 +72,61 @@ def __init__( self.on_audio_player_position_ms_changed ) - self.current_segment_label = QLabel() - self.current_segment_label.setText("") + self.current_segment_label = QLabel("", self) self.current_segment_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.current_segment_label.setContentsMargins(0, 0, 0, 10) - buttons_layout = QHBoxLayout() - buttons_layout.addStretch() + layout = QVBoxLayout(self) - export_button = ExportTranscriptionButton(parent=self) - export_button.on_export_triggered.connect(self.on_export_triggered) + toolbar = ToolBar(self) - layout = QGridLayout(self) - layout.addWidget(self.table_widget, 0, 0, 1, 2) - - if self.audio_player is not None: - layout.addWidget(self.audio_player, 1, 0, 1, 1) - layout.addWidget(export_button, 1, 1, 1, 1) - layout.addWidget(self.current_segment_label, 2, 0, 1, 2) - - self.setLayout(layout) + view_mode_tool_button = TranscriptionViewModeToolButton(shortcuts, self) + view_mode_tool_button.view_mode_changed.connect(self.on_view_mode_changed) + toolbar.addWidget(view_mode_tool_button) - def on_export_triggered(self, output_format: OutputFormat) -> None: - default_path = self.transcription.get_output_file_path( - output_format=output_format + export_tool_button = QToolButton() + export_tool_button.setText("Export") + export_tool_button.setIcon(FileDownloadIcon(self)) + export_tool_button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextBesideIcon ) - (output_file_path, nil) = QFileDialog.getSaveFileName( - self, - _("Save File"), - default_path, - _("Text files") + f" (*.{output_format.value})", + export_transcription_menu = ExportTranscriptionMenu( + transcription, transcription_service, self ) + export_tool_button.setMenu(export_transcription_menu) + export_tool_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + toolbar.addWidget(export_tool_button) - if output_file_path == "": - return + layout.setMenuBar(toolbar) - segments = [ - Segment( - start=segment.value("start_time"), - end=segment.value("end_time"), - text=segment.value("text"), + layout.addWidget(self.table_widget) + layout.addWidget(self.text_display_box) + if self.audio_player is not None: + layout.addWidget(self.audio_player) + layout.addWidget(self.current_segment_label) + + self.setLayout(layout) + + self.reset_view() + + def reset_view(self): + if self.is_showing_timestamps: + self.text_display_box.hide() + self.table_widget.show() + else: + segments = self.transcription_service.get_transcription_segments( + transcription_id=self.transcription.id_as_uuid + ) + self.text_display_box.setPlainText( + " ".join(segment.text.strip() for segment in segments) ) - for segment in self.table_widget.segments() - ] + self.text_display_box.show() + self.table_widget.hide() - write_output( - path=output_file_path, - segments=segments, - output_format=output_format, - ) + def on_view_mode_changed(self, is_timestamps: bool) -> None: + self.is_showing_timestamps = is_timestamps + self.reset_view() def on_segment_selected(self, segment: QSqlRecord): if self.audio_player is not None and ( diff --git a/tests/conftest.py b/tests/conftest.py index e9503f6c55..aa1e1f3ad3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ import os +import random +import string import pytest from PyQt6.QtSql import QSqlDatabase @@ -8,6 +10,8 @@ from buzz.db.dao.transcription_segment_dao import TranscriptionSegmentDAO from buzz.db.db import setup_test_db from buzz.db.service.transcription_service import TranscriptionService +from buzz.settings.settings import Settings +from buzz.settings.shortcuts import Shortcuts from buzz.widgets.application import Application @@ -52,3 +56,19 @@ def qapp_args(request): return [] return request.param + + +@pytest.fixture(scope="session") +def settings(): + application = "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(6) + ) + + settings = Settings(application=application) + yield settings + settings.clear() + + +@pytest.fixture(scope="session") +def shortcuts(settings): + return Shortcuts(settings) diff --git a/tests/widgets/export_transcription_menu_test.py b/tests/widgets/export_transcription_menu_test.py new file mode 100644 index 0000000000..548b075d29 --- /dev/null +++ b/tests/widgets/export_transcription_menu_test.py @@ -0,0 +1,63 @@ +import pathlib +import uuid + +import pytest +from pytestqt.qtbot import QtBot + +from buzz.db.entity.transcription import Transcription +from buzz.db.entity.transcription_segment import TranscriptionSegment +from buzz.model_loader import ModelType, WhisperModelSize +from buzz.transcriber.transcriber import Task +from buzz.widgets.transcription_viewer.export_transcription_menu import ( + ExportTranscriptionMenu, +) + + +class TestExportTranscriptionMenu: + @pytest.fixture() + def transcription( + self, transcription_dao, transcription_segment_dao + ) -> Transcription: + id = uuid.uuid4() + transcription_dao.insert( + Transcription( + id=str(id), + status="completed", + file="testdata/whisper-french.mp3", + task=Task.TRANSCRIBE.value, + model_type=ModelType.WHISPER.value, + whisper_model_size=WhisperModelSize.SMALL.value, + ) + ) + transcription_segment_dao.insert(TranscriptionSegment(40, 299, "Bien", str(id))) + transcription_segment_dao.insert( + TranscriptionSegment(299, 329, "venue dans", str(id)) + ) + + return transcription_dao.find_by_id(str(id)) + + def test_should_export_segments( + self, + tmp_path: pathlib.Path, + qtbot: QtBot, + transcription, + transcription_service, + shortcuts, + mocker, + ): + output_file_path = tmp_path / "whisper.txt" + mocker.patch( + "PyQt6.QtWidgets.QFileDialog.getSaveFileName", + return_value=(str(output_file_path), ""), + ) + + widget = ExportTranscriptionMenu( + transcription, + transcription_service, + ) + qtbot.add_widget(widget) + + widget.actions()[0].trigger() + + with open(output_file_path, encoding="utf-8") as output_file: + assert "Bien\nvenue dans" in output_file.read() diff --git a/tests/widgets/menu_bar_test.py b/tests/widgets/menu_bar_test.py index 9b1f5f5c4d..5934ddea2f 100644 --- a/tests/widgets/menu_bar_test.py +++ b/tests/widgets/menu_bar_test.py @@ -1,17 +1,14 @@ from PyQt6.QtCore import QSettings -from buzz.settings.settings import Settings -from buzz.settings.shortcut_settings import ShortcutSettings from buzz.widgets.menu_bar import MenuBar from buzz.widgets.preferences_dialog.models.preferences import Preferences from buzz.widgets.preferences_dialog.preferences_dialog import PreferencesDialog class TestMenuBar: - def test_open_preferences_dialog(self, qtbot): + def test_open_preferences_dialog(self, qtbot, shortcuts): menu_bar = MenuBar( - shortcuts=ShortcutSettings(Settings()).load(), - preferences=Preferences.load(QSettings()), + shortcuts=shortcuts, preferences=Preferences.load(QSettings()) ) qtbot.add_widget(menu_bar) diff --git a/tests/widgets/preferences_dialog/preferences_dialog_test.py b/tests/widgets/preferences_dialog/preferences_dialog_test.py index 5d0c25886b..ac47bc191c 100644 --- a/tests/widgets/preferences_dialog/preferences_dialog_test.py +++ b/tests/widgets/preferences_dialog/preferences_dialog_test.py @@ -7,10 +7,9 @@ class TestPreferencesDialog: - def test_create(self, qtbot: QtBot): + def test_create(self, qtbot: QtBot, shortcuts): dialog = PreferencesDialog( - shortcuts={}, - preferences=Preferences.load(QSettings()), + shortcuts=shortcuts, preferences=Preferences.load(QSettings()) ) qtbot.add_widget(dialog) diff --git a/tests/widgets/shortcuts_editor_widget_test.py b/tests/widgets/shortcuts_editor_widget_test.py index f92c773c84..951a88876c 100644 --- a/tests/widgets/shortcuts_editor_widget_test.py +++ b/tests/widgets/shortcuts_editor_widget_test.py @@ -1,6 +1,6 @@ from PyQt6.QtWidgets import QPushButton, QLabel +from pytestqt.qtbot import QtBot -from buzz.settings.shortcut import Shortcut from buzz.widgets.preferences_dialog.shortcuts_editor_preferences_widget import ( ShortcutsEditorPreferencesWidget, ) @@ -8,10 +8,17 @@ class TestShortcutsEditorWidget: - def test_should_reset_to_defaults(self, qtbot): - widget = ShortcutsEditorPreferencesWidget( - shortcuts=Shortcut.get_default_shortcuts() - ) + def test_should_update_shortcuts(self, qtbot: QtBot, shortcuts): + widget = ShortcutsEditorPreferencesWidget(shortcuts=shortcuts) + qtbot.add_widget(widget) + + sequence_edit = widget.findChild(SequenceEdit) + assert sequence_edit.keySequence().toString() == "Ctrl+R" + with qtbot.wait_signal(widget.shortcuts_changed, timeout=1000): + sequence_edit.setKeySequence("Ctrl+Shift+R") + + def test_should_reset_to_defaults(self, qtbot, shortcuts): + widget = ShortcutsEditorPreferencesWidget(shortcuts=shortcuts) qtbot.add_widget(widget) reset_button = widget.findChild(QPushButton) @@ -26,7 +33,8 @@ def test_should_reset_to_defaults(self, qtbot): ("Import File", "Ctrl+O"), ("Import URL", "Ctrl+U"), ("Open Preferences Window", "Ctrl+,"), - ("Open Transcript Viewer", "Ctrl+E"), + ("View Transcript Text", "Ctrl+E"), + ("View Transcript Timestamps", "Ctrl+T"), ("Clear History", "Ctrl+S"), ("Cancel Transcription", "Ctrl+X"), ) diff --git a/tests/widgets/transcription_viewer_test.py b/tests/widgets/transcription_viewer_test.py index aa2ba30b19..85b303bfe1 100644 --- a/tests/widgets/transcription_viewer_test.py +++ b/tests/widgets/transcription_viewer_test.py @@ -1,9 +1,6 @@ -import pathlib import uuid -from unittest.mock import patch import pytest -from PyQt6.QtWidgets import QPushButton from pytestqt.qtbot import QtBot from buzz.db.entity.transcription import Transcription @@ -41,8 +38,12 @@ def transcription( return transcription_dao.find_by_id(str(id)) - def test_should_display_segments(self, qtbot: QtBot, transcription): - widget = TranscriptionViewerWidget(transcription) + def test_should_display_segments( + self, qtbot: QtBot, transcription, transcription_service, shortcuts + ): + widget = TranscriptionViewerWidget( + transcription, transcription_service, shortcuts + ) qtbot.add_widget(widget) assert widget.windowTitle() == "whisper-french.mp3" @@ -54,30 +55,15 @@ def test_should_display_segments(self, qtbot: QtBot, transcription): assert editor.model().index(0, 2).data() == 40 assert editor.model().index(0, 3).data() == "Bien" - def test_should_update_segment_text(self, qtbot, transcription): - widget = TranscriptionViewerWidget(transcription) + def test_should_update_segment_text( + self, qtbot, transcription, transcription_service, shortcuts + ): + widget = TranscriptionViewerWidget( + transcription, transcription_service, shortcuts + ) qtbot.add_widget(widget) editor = widget.findChild(TranscriptionSegmentsEditorWidget) assert isinstance(editor, TranscriptionSegmentsEditorWidget) editor.model().setData(editor.model().index(0, 3), "Biens") - - def test_should_export_segments( - self, tmp_path: pathlib.Path, qtbot: QtBot, transcription - ): - widget = TranscriptionViewerWidget(transcription) - qtbot.add_widget(widget) - - export_button = widget.findChild(QPushButton) - assert isinstance(export_button, QPushButton) - - output_file_path = tmp_path / "whisper.txt" - with patch( - "PyQt6.QtWidgets.QFileDialog.getSaveFileName" - ) as save_file_name_mock: - save_file_name_mock.return_value = (str(output_file_path), "") - export_button.menu().actions()[0].trigger() - - with open(output_file_path, encoding="utf-8") as output_file: - assert "Bien\nvenue dans" in output_file.read()