meeting-noter 2.0.0__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of meeting-noter might be problematic. Click here for more details.

meeting_noter/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Meeting Noter - Offline meeting transcription with virtual audio devices."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "3.0.0"
meeting_noter/cli.py CHANGED
@@ -1051,6 +1051,48 @@ def ui():
1051
1051
  app.run()
1052
1052
 
1053
1053
 
1054
+ @cli.command()
1055
+ @click.option("--foreground", "-f", is_flag=True, help="Run in foreground (block terminal)")
1056
+ def gui(foreground: bool):
1057
+ """Launch the Desktop GUI.
1058
+
1059
+ Opens the Meeting Noter desktop application with a modern dark theme.
1060
+ Runs in background by default so your terminal stays free.
1061
+ Requires PySide6: pip install meeting-noter[gui]
1062
+
1063
+ \b
1064
+ Examples:
1065
+ meeting-noter gui # Launch GUI in background
1066
+ meeting-noter gui -f # Launch GUI in foreground (blocks terminal)
1067
+ mn gui # Same with short alias
1068
+ """
1069
+ try:
1070
+ # Check if PySide6 is available
1071
+ import PySide6 # noqa: F401
1072
+ except ImportError:
1073
+ click.echo(click.style("Error: ", fg="red") + "PySide6 not installed.")
1074
+ click.echo("Install with: pip install meeting-noter[gui]")
1075
+ click.echo("Or: pipx install 'meeting-noter[gui]'")
1076
+ raise SystemExit(1)
1077
+
1078
+ if foreground:
1079
+ # Run in foreground (blocks terminal)
1080
+ from meeting_noter.gui import run_gui
1081
+ run_gui()
1082
+ else:
1083
+ # Run in background
1084
+ import subprocess
1085
+ import sys
1086
+
1087
+ subprocess.Popen(
1088
+ [sys.executable, "-m", "meeting_noter.cli", "gui", "-f"],
1089
+ stdout=subprocess.DEVNULL,
1090
+ stderr=subprocess.DEVNULL,
1091
+ start_new_session=True,
1092
+ )
1093
+ click.echo("Meeting Noter GUI launched.")
1094
+
1095
+
1054
1096
  @cli.command()
1055
1097
  @click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
1056
1098
  def completion(shell: str):
@@ -0,0 +1,51 @@
1
+ """Meeting Noter Desktop GUI - Modern PySide6 interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def run_gui():
7
+ """Launch the GUI application with system tray icon."""
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from PySide6.QtCore import Qt
12
+ from PySide6.QtGui import QIcon
13
+ from PySide6.QtWidgets import QApplication
14
+
15
+ from meeting_noter.gui.main_window import MainWindow
16
+ from meeting_noter.gui.menubar import MenuBarIcon
17
+
18
+ # Enable High DPI scaling
19
+ QApplication.setHighDpiScaleFactorRoundingPolicy(
20
+ Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
21
+ )
22
+
23
+ app = QApplication(sys.argv)
24
+ app.setApplicationName("Meeting Noter")
25
+ app.setOrganizationName("Meeting Noter")
26
+
27
+ # Set application icon (for dock)
28
+ resources_dir = Path(__file__).parent.parent / "resources"
29
+ icon_path = resources_dir / "icon.icns"
30
+ if not icon_path.exists():
31
+ icon_path = resources_dir / "icon.png"
32
+ if icon_path.exists():
33
+ app.setWindowIcon(QIcon(str(icon_path)))
34
+
35
+ # Don't quit when last window closes (we have tray icon)
36
+ app.setQuitOnLastWindowClosed(False)
37
+
38
+ # Create main window
39
+ window = MainWindow()
40
+
41
+ # Create system tray icon
42
+ tray_icon = MenuBarIcon(window)
43
+ tray_icon.show()
44
+
45
+ # Give window reference to tray icon
46
+ window.set_tray_icon(tray_icon)
47
+
48
+ # Show window
49
+ window.show()
50
+
51
+ sys.exit(app.exec())
@@ -0,0 +1,219 @@
1
+ """Main window for Meeting Noter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+
7
+ from PySide6.QtCore import Qt
8
+ from PySide6.QtWidgets import (
9
+ QApplication,
10
+ QHBoxLayout,
11
+ QMainWindow,
12
+ QStackedWidget,
13
+ QWidget,
14
+ )
15
+
16
+ # macOS dock visibility control
17
+ if platform.system() == "Darwin":
18
+ try:
19
+ from AppKit import NSApp, NSApplicationActivationPolicyAccessory, NSApplicationActivationPolicyRegular
20
+ HAS_APPKIT = True
21
+ except ImportError:
22
+ HAS_APPKIT = False
23
+ else:
24
+ HAS_APPKIT = False
25
+
26
+ from meeting_noter.gui.screens import (
27
+ DashboardScreen,
28
+ LogsScreen,
29
+ RecordingsScreen,
30
+ SearchScreen,
31
+ SettingsScreen,
32
+ ViewerScreen,
33
+ )
34
+ from meeting_noter.gui.theme import apply_theme
35
+ from meeting_noter.gui.utils.signals import get_app_state
36
+ from meeting_noter.gui.utils.workers import StatusPollingWorker
37
+ from meeting_noter.gui.widgets import Sidebar
38
+
39
+
40
+ class MainWindow(QMainWindow):
41
+ """Main application window with sidebar navigation."""
42
+
43
+ def __init__(self, tray_icon=None):
44
+ super().__init__()
45
+ self.setWindowTitle("Meeting Noter")
46
+ self.setMinimumSize(1000, 700)
47
+ self.resize(1200, 800)
48
+
49
+ self._tray_icon = tray_icon
50
+ self._force_quit = False
51
+
52
+ # Apply dark theme
53
+ apply_theme(self.app())
54
+
55
+ self._setup_ui()
56
+ self._setup_workers()
57
+ self._connect_signals()
58
+
59
+ # Select dashboard initially
60
+ self._show_screen("dashboard")
61
+
62
+ def app(self):
63
+ """Get the QApplication instance."""
64
+ return QApplication.instance()
65
+
66
+ def set_tray_icon(self, tray_icon):
67
+ """Set the tray icon reference."""
68
+ self._tray_icon = tray_icon
69
+
70
+ def _setup_ui(self):
71
+ """Set up the main UI."""
72
+ # Central widget
73
+ central = QWidget()
74
+ self.setCentralWidget(central)
75
+
76
+ # Main layout (horizontal: sidebar + content)
77
+ layout = QHBoxLayout(central)
78
+ layout.setContentsMargins(0, 0, 0, 0)
79
+ layout.setSpacing(0)
80
+
81
+ # Sidebar
82
+ self._sidebar = Sidebar()
83
+ self._sidebar.quit_requested.connect(self._quit_app)
84
+ layout.addWidget(self._sidebar)
85
+
86
+ # Content area (stacked widget)
87
+ self._content = QStackedWidget()
88
+ self._content.setObjectName("content-area")
89
+ layout.addWidget(self._content, 1) # Stretch factor 1
90
+
91
+ # Create screens
92
+ self._screens: dict[str, QWidget] = {}
93
+ self._create_screens()
94
+
95
+ def _create_screens(self):
96
+ """Create all screen widgets."""
97
+ # Dashboard
98
+ self._screens["dashboard"] = DashboardScreen()
99
+ self._content.addWidget(self._screens["dashboard"])
100
+
101
+ # Recordings
102
+ self._screens["recordings"] = RecordingsScreen()
103
+ self._content.addWidget(self._screens["recordings"])
104
+
105
+ # Search
106
+ self._screens["search"] = SearchScreen()
107
+ self._content.addWidget(self._screens["search"])
108
+
109
+ # Settings
110
+ self._screens["settings"] = SettingsScreen()
111
+ self._content.addWidget(self._screens["settings"])
112
+
113
+ # Logs
114
+ self._screens["logs"] = LogsScreen()
115
+ self._content.addWidget(self._screens["logs"])
116
+
117
+ # Viewer (for transcripts)
118
+ self._screens["viewer"] = ViewerScreen()
119
+ self._content.addWidget(self._screens["viewer"])
120
+
121
+ def _setup_workers(self):
122
+ """Set up background workers."""
123
+ # Status polling
124
+ self._status_worker = StatusPollingWorker()
125
+ self._status_worker.status_updated.connect(self._on_status_updated)
126
+ self._status_worker.start()
127
+
128
+ def _connect_signals(self):
129
+ """Connect signals."""
130
+ # Sidebar navigation
131
+ self._sidebar.screen_selected.connect(self._show_screen)
132
+
133
+ # App state signals
134
+ app_state = get_app_state()
135
+ app_state.navigate_to.connect(self._show_screen)
136
+ app_state.open_transcript.connect(self._open_transcript)
137
+
138
+ def _show_screen(self, screen_id: str):
139
+ """Show a screen by ID."""
140
+ if screen_id in self._screens:
141
+ self._content.setCurrentWidget(self._screens[screen_id])
142
+ self._sidebar.select_screen(screen_id)
143
+
144
+ # Refresh screen if it has a refresh method
145
+ screen = self._screens[screen_id]
146
+ if hasattr(screen, "refresh"):
147
+ screen.refresh()
148
+
149
+ def _open_transcript(self, filepath: str):
150
+ """Open a transcript in the viewer."""
151
+ viewer = self._screens.get("viewer")
152
+ if viewer and hasattr(viewer, "load_transcript"):
153
+ viewer.load_transcript(filepath)
154
+ self._show_screen("viewer")
155
+
156
+ def _on_status_updated(self, watcher_running: bool, daemon_running: bool, meeting_name: str):
157
+ """Handle status update from worker."""
158
+ # Update sidebar status
159
+ self._sidebar.update_status(daemon_running, meeting_name)
160
+
161
+ # Update dashboard status indicator
162
+ dashboard = self._screens.get("dashboard")
163
+ if dashboard and hasattr(dashboard, "update_status"):
164
+ dashboard.update_status(watcher_running, daemon_running, meeting_name)
165
+
166
+ # Update tray icon
167
+ if self._tray_icon:
168
+ self._tray_icon.update_status(watcher_running, daemon_running, meeting_name)
169
+
170
+ # Update app state
171
+ app_state = get_app_state()
172
+ app_state._is_recording = daemon_running
173
+ app_state._current_meeting = meeting_name
174
+ app_state._watcher_running = watcher_running
175
+
176
+ def _quit_app(self):
177
+ """Quit the application completely."""
178
+ self._force_quit = True
179
+ self._cleanup()
180
+ QApplication.instance().quit()
181
+
182
+ def _cleanup(self):
183
+ """Clean up workers and resources."""
184
+ # Stop workers
185
+ if hasattr(self, "_status_worker"):
186
+ self._status_worker.stop()
187
+
188
+ # Stop screen workers
189
+ for screen in self._screens.values():
190
+ if hasattr(screen, "stop_workers"):
191
+ screen.stop_workers()
192
+
193
+ def closeEvent(self, event):
194
+ """Handle window close - hide to tray instead of quitting."""
195
+ if self._force_quit or self._tray_icon is None:
196
+ # Actually quit
197
+ self._cleanup()
198
+ event.accept()
199
+ else:
200
+ # Hide to tray instead of closing
201
+ event.ignore()
202
+ self.hide()
203
+ self._hide_from_dock()
204
+
205
+ def show(self):
206
+ """Show window and add to dock."""
207
+ super().show()
208
+ self._show_in_dock()
209
+
210
+ def _hide_from_dock(self):
211
+ """Hide app from macOS dock."""
212
+ if HAS_APPKIT:
213
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
214
+
215
+ def _show_in_dock(self):
216
+ """Show app in macOS dock."""
217
+ if HAS_APPKIT:
218
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyRegular)
219
+ NSApp.activateIgnoringOtherApps_(True)
@@ -0,0 +1,248 @@
1
+ """Menu bar (system tray) integration for Meeting Noter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QTimer
9
+ from PySide6.QtGui import QAction, QIcon, QPixmap, QPainter, QColor, QFont
10
+ from PySide6.QtWidgets import QMenu, QSystemTrayIcon
11
+
12
+ if TYPE_CHECKING:
13
+ from meeting_noter.gui.main_window import MainWindow
14
+
15
+
16
+ class MenuBarIcon(QSystemTrayIcon):
17
+ """System tray / menu bar icon for Meeting Noter."""
18
+
19
+ # Status states
20
+ STATUS_STOPPED = "stopped"
21
+ STATUS_READY = "ready"
22
+ STATUS_RECORDING = "recording"
23
+
24
+ def __init__(self, main_window: "MainWindow"):
25
+ super().__init__()
26
+ self._main_window = main_window
27
+ self._current_status = self.STATUS_STOPPED
28
+ self._recording_name = ""
29
+
30
+ self._setup_icons()
31
+ self._setup_menu()
32
+ self._setup_polling()
33
+
34
+ # Set initial icon
35
+ self._update_icon()
36
+
37
+ # Connect signals
38
+ self.activated.connect(self._on_activated)
39
+
40
+ def _setup_icons(self):
41
+ """Create status icons programmatically."""
42
+ self._icons = {}
43
+
44
+ # Create icons for each status
45
+ for status, color in [
46
+ (self.STATUS_STOPPED, "#5a5a5a"), # Gray
47
+ (self.STATUS_READY, "#4ec9b0"), # Green/Cyan
48
+ (self.STATUS_RECORDING, "#f14c4c"), # Red
49
+ ]:
50
+ self._icons[status] = self._create_icon(color)
51
+
52
+ def _create_icon(self, color: str) -> QIcon:
53
+ """Create a simple colored circle icon."""
54
+ size = 22
55
+ pixmap = QPixmap(size, size)
56
+ pixmap.fill(QColor(0, 0, 0, 0)) # Transparent background
57
+
58
+ painter = QPainter(pixmap)
59
+ painter.setRenderHint(QPainter.Antialiasing)
60
+
61
+ # Draw filled circle
62
+ painter.setBrush(QColor(color))
63
+ painter.setPen(QColor(color))
64
+ margin = 3
65
+ painter.drawEllipse(margin, margin, size - margin * 2, size - margin * 2)
66
+
67
+ painter.end()
68
+ return QIcon(pixmap)
69
+
70
+ def _setup_menu(self):
71
+ """Set up the context menu."""
72
+ self._menu = QMenu()
73
+ self._menu.setStyleSheet("""
74
+ QMenu {
75
+ background-color: #1a1a1a;
76
+ border: 1px solid #2a2a2a;
77
+ border-radius: 6px;
78
+ padding: 5px;
79
+ }
80
+ QMenu::item {
81
+ color: #d4d4d4;
82
+ padding: 8px 20px;
83
+ border-radius: 4px;
84
+ }
85
+ QMenu::item:selected {
86
+ background-color: #2a2a2a;
87
+ }
88
+ QMenu::separator {
89
+ height: 1px;
90
+ background-color: #2a2a2a;
91
+ margin: 5px 10px;
92
+ }
93
+ """)
94
+
95
+ # Status label (non-clickable)
96
+ self._status_action = QAction("● Stopped")
97
+ self._status_action.setEnabled(False)
98
+ self._menu.addAction(self._status_action)
99
+
100
+ self._menu.addSeparator()
101
+
102
+ # Open GUI
103
+ self._open_action = QAction("Open Meeting Noter")
104
+ self._open_action.triggered.connect(self._show_main_window)
105
+ self._menu.addAction(self._open_action)
106
+
107
+ self._menu.addSeparator()
108
+
109
+ # Quit
110
+ self._quit_action = QAction("Quit Meeting Noter")
111
+ self._quit_action.triggered.connect(self._quit_app)
112
+ self._menu.addAction(self._quit_action)
113
+
114
+ self.setContextMenu(self._menu)
115
+
116
+ def _setup_polling(self):
117
+ """Set up status polling timer."""
118
+ self._poll_timer = QTimer()
119
+ self._poll_timer.timeout.connect(self._poll_status)
120
+ self._poll_timer.start(2000) # Poll every 2 seconds
121
+
122
+ def _poll_status(self):
123
+ """Poll for status updates."""
124
+ import os
125
+ from meeting_noter.daemon import is_process_running, read_pid_file
126
+
127
+ DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
128
+ WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
129
+
130
+ # Check watcher
131
+ watcher_running = False
132
+ if WATCHER_PID_FILE.exists():
133
+ try:
134
+ pid = int(WATCHER_PID_FILE.read_text().strip())
135
+ os.kill(pid, 0)
136
+ watcher_running = True
137
+ except (ProcessLookupError, ValueError, FileNotFoundError, PermissionError):
138
+ pass
139
+
140
+ # Check daemon
141
+ daemon_running = False
142
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
143
+ if daemon_pid and is_process_running(daemon_pid):
144
+ daemon_running = True
145
+
146
+ # Determine status
147
+ if daemon_running:
148
+ new_status = self.STATUS_RECORDING
149
+ # Get recording name
150
+ recording_name = self._get_recording_name()
151
+ self._recording_name = recording_name or "Recording..."
152
+ elif watcher_running:
153
+ new_status = self.STATUS_READY
154
+ self._recording_name = ""
155
+ else:
156
+ new_status = self.STATUS_STOPPED
157
+ self._recording_name = ""
158
+
159
+ if new_status != self._current_status:
160
+ self._current_status = new_status
161
+ self._update_icon()
162
+ self._update_status_text()
163
+
164
+ def _get_recording_name(self) -> str:
165
+ """Get the current recording name from logs."""
166
+ log_path = Path.home() / ".meeting-noter.log"
167
+ if not log_path.exists():
168
+ return ""
169
+
170
+ try:
171
+ with open(log_path, "r") as f:
172
+ lines = f.readlines()
173
+
174
+ for line in reversed(lines[-50:]):
175
+ if "Recording started:" in line:
176
+ parts = line.split("Recording started:")
177
+ if len(parts) > 1:
178
+ filename = parts[1].strip().replace(".mp3", "")
179
+ name_parts = filename.split("_", 2)
180
+ if len(name_parts) >= 3:
181
+ return name_parts[2]
182
+ return filename
183
+ elif "Recording saved:" in line or "Recording discarded" in line:
184
+ break
185
+ return ""
186
+ except Exception:
187
+ return ""
188
+
189
+ def _update_icon(self):
190
+ """Update the tray icon based on status."""
191
+ self.setIcon(self._icons[self._current_status])
192
+
193
+ def _update_status_text(self):
194
+ """Update the status text in menu."""
195
+ if self._current_status == self.STATUS_RECORDING:
196
+ text = f"● Recording: {self._recording_name}"
197
+ elif self._current_status == self.STATUS_READY:
198
+ text = "● Ready"
199
+ else:
200
+ text = "● Stopped"
201
+
202
+ self._status_action.setText(text)
203
+
204
+ # Update tooltip
205
+ self.setToolTip(f"Meeting Noter - {text}")
206
+
207
+ def _on_activated(self, reason):
208
+ """Handle tray icon activation."""
209
+ if reason == QSystemTrayIcon.ActivationReason.Trigger:
210
+ # Single click - toggle window visibility
211
+ self._toggle_main_window()
212
+ elif reason == QSystemTrayIcon.ActivationReason.DoubleClick:
213
+ # Double click - show window
214
+ self._show_main_window()
215
+
216
+ def _toggle_main_window(self):
217
+ """Toggle main window visibility."""
218
+ if self._main_window.isVisible():
219
+ self._main_window.hide()
220
+ else:
221
+ self._show_main_window()
222
+
223
+ def _show_main_window(self):
224
+ """Show and raise the main window."""
225
+ self._main_window.show() # This also shows in dock
226
+ self._main_window.raise_()
227
+ self._main_window.activateWindow()
228
+
229
+ def _quit_app(self):
230
+ """Quit the application completely."""
231
+ self._poll_timer.stop()
232
+ from PySide6.QtWidgets import QApplication
233
+ QApplication.instance().quit()
234
+
235
+ def update_status(self, watcher_running: bool, daemon_running: bool, meeting_name: str):
236
+ """Update status from external source."""
237
+ if daemon_running:
238
+ self._current_status = self.STATUS_RECORDING
239
+ self._recording_name = meeting_name or "Recording..."
240
+ elif watcher_running:
241
+ self._current_status = self.STATUS_READY
242
+ self._recording_name = ""
243
+ else:
244
+ self._current_status = self.STATUS_STOPPED
245
+ self._recording_name = ""
246
+
247
+ self._update_icon()
248
+ self._update_status_text()
@@ -0,0 +1,17 @@
1
+ """Screen modules for Meeting Noter GUI."""
2
+
3
+ from meeting_noter.gui.screens.dashboard import DashboardScreen
4
+ from meeting_noter.gui.screens.logs import LogsScreen
5
+ from meeting_noter.gui.screens.recordings import RecordingsScreen
6
+ from meeting_noter.gui.screens.search import SearchScreen
7
+ from meeting_noter.gui.screens.settings import SettingsScreen
8
+ from meeting_noter.gui.screens.viewer import ViewerScreen
9
+
10
+ __all__ = [
11
+ "DashboardScreen",
12
+ "RecordingsScreen",
13
+ "SearchScreen",
14
+ "ViewerScreen",
15
+ "SettingsScreen",
16
+ "LogsScreen",
17
+ ]