meeting-noter 1.3.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.

Files changed (40) hide show
  1. meeting_noter/__init__.py +1 -1
  2. meeting_noter/cli.py +103 -0
  3. meeting_noter/daemon.py +38 -0
  4. meeting_noter/gui/__init__.py +51 -0
  5. meeting_noter/gui/main_window.py +219 -0
  6. meeting_noter/gui/menubar.py +248 -0
  7. meeting_noter/gui/screens/__init__.py +17 -0
  8. meeting_noter/gui/screens/dashboard.py +262 -0
  9. meeting_noter/gui/screens/logs.py +184 -0
  10. meeting_noter/gui/screens/recordings.py +279 -0
  11. meeting_noter/gui/screens/search.py +229 -0
  12. meeting_noter/gui/screens/settings.py +232 -0
  13. meeting_noter/gui/screens/viewer.py +140 -0
  14. meeting_noter/gui/theme/__init__.py +5 -0
  15. meeting_noter/gui/theme/dark_theme.py +53 -0
  16. meeting_noter/gui/theme/styles.qss +504 -0
  17. meeting_noter/gui/utils/__init__.py +15 -0
  18. meeting_noter/gui/utils/signals.py +82 -0
  19. meeting_noter/gui/utils/workers.py +258 -0
  20. meeting_noter/gui/widgets/__init__.py +6 -0
  21. meeting_noter/gui/widgets/sidebar.py +210 -0
  22. meeting_noter/gui/widgets/status_indicator.py +108 -0
  23. meeting_noter/mic_monitor.py +29 -1
  24. meeting_noter/ui/__init__.py +5 -0
  25. meeting_noter/ui/app.py +68 -0
  26. meeting_noter/ui/screens/__init__.py +17 -0
  27. meeting_noter/ui/screens/logs.py +166 -0
  28. meeting_noter/ui/screens/main.py +346 -0
  29. meeting_noter/ui/screens/recordings.py +241 -0
  30. meeting_noter/ui/screens/search.py +191 -0
  31. meeting_noter/ui/screens/settings.py +184 -0
  32. meeting_noter/ui/screens/viewer.py +116 -0
  33. meeting_noter/ui/styles/app.tcss +257 -0
  34. meeting_noter/ui/widgets/__init__.py +1 -0
  35. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/METADATA +4 -1
  36. meeting_noter-3.0.0.dist-info/RECORD +65 -0
  37. meeting_noter-1.3.0.dist-info/RECORD +0 -35
  38. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/WHEEL +0 -0
  39. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/entry_points.txt +0 -0
  40. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/top_level.txt +0 -0
@@ -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
+ ]
@@ -0,0 +1,262 @@
1
+ """Dashboard screen for Meeting Noter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from PySide6.QtCore import Qt
12
+ from PySide6.QtGui import QFont
13
+ from PySide6.QtWidgets import (
14
+ QCheckBox,
15
+ QFrame,
16
+ QHBoxLayout,
17
+ QLabel,
18
+ QLineEdit,
19
+ QPushButton,
20
+ QTextEdit,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from meeting_noter.config import generate_meeting_name, get_config
26
+ from meeting_noter.daemon import is_process_running, read_pid_file
27
+ from meeting_noter.gui.utils.workers import LiveTranscriptWatcher
28
+ from meeting_noter.gui.widgets.status_indicator import StatusIndicator
29
+
30
+
31
+ DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
32
+ WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
33
+
34
+
35
+ class DashboardScreen(QWidget):
36
+ """Main dashboard screen with recording controls."""
37
+
38
+ def __init__(self, parent=None):
39
+ super().__init__(parent)
40
+ self._setup_ui()
41
+ self._setup_workers()
42
+ self._connect_signals()
43
+
44
+ def _setup_ui(self):
45
+ """Set up the dashboard UI."""
46
+ mono_font = QFont("Menlo, Monaco, Courier New, monospace")
47
+
48
+ layout = QVBoxLayout(self)
49
+ layout.setContentsMargins(30, 30, 30, 30)
50
+ layout.setSpacing(20)
51
+
52
+ # Title
53
+ title = QLabel("Dashboard")
54
+ title.setProperty("class", "heading")
55
+ title_font = QFont("Menlo, Monaco, Courier New, monospace", 22)
56
+ title_font.setBold(True)
57
+ title.setFont(title_font)
58
+ title.setStyleSheet("color: #ffffff;")
59
+ layout.addWidget(title)
60
+
61
+ # Status indicator
62
+ self._status_indicator = StatusIndicator()
63
+ layout.addWidget(self._status_indicator)
64
+
65
+ # Recording controls panel
66
+ controls_panel = QFrame()
67
+ controls_panel.setProperty("class", "panel")
68
+ controls_panel.setStyleSheet(
69
+ "background-color: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 8px; padding: 20px;"
70
+ )
71
+ controls_layout = QVBoxLayout(controls_panel)
72
+ controls_layout.setSpacing(15)
73
+
74
+ # Meeting name input
75
+ name_row = QHBoxLayout()
76
+ name_label = QLabel("Meeting name:")
77
+ name_label.setFont(mono_font)
78
+ name_label.setFixedWidth(140)
79
+ self._name_input = QLineEdit()
80
+ self._name_input.setPlaceholderText(generate_meeting_name())
81
+ self._name_input.setFont(mono_font)
82
+ name_row.addWidget(name_label)
83
+ name_row.addWidget(self._name_input)
84
+ controls_layout.addLayout(name_row)
85
+
86
+ # Live transcription toggle
87
+ toggle_row = QHBoxLayout()
88
+ toggle_label = QLabel("Live transcription:")
89
+ toggle_label.setFont(mono_font)
90
+ toggle_label.setFixedWidth(140)
91
+ self._live_toggle = QCheckBox("Show live transcript")
92
+ self._live_toggle.setFont(mono_font)
93
+ self._live_toggle.setChecked(True)
94
+ toggle_row.addWidget(toggle_label)
95
+ toggle_row.addWidget(self._live_toggle)
96
+ toggle_row.addStretch()
97
+ controls_layout.addLayout(toggle_row)
98
+
99
+ # Buttons
100
+ button_row = QHBoxLayout()
101
+ button_row.setSpacing(12)
102
+
103
+ self._start_btn = QPushButton("▶ Start Recording")
104
+ self._start_btn.setProperty("class", "success")
105
+ self._start_btn.setFont(mono_font)
106
+ self._start_btn.setStyleSheet(
107
+ "background-color: #4ec9b0; color: #0d0d0d; min-width: 160px; padding: 12px 24px; font-weight: 600;"
108
+ )
109
+
110
+ self._stop_btn = QPushButton("■ Stop")
111
+ self._stop_btn.setProperty("class", "danger")
112
+ self._stop_btn.setFont(mono_font)
113
+ self._stop_btn.setStyleSheet(
114
+ "background-color: #f14c4c; color: #ffffff; min-width: 100px; padding: 12px 24px; font-weight: 600;"
115
+ )
116
+
117
+ button_row.addWidget(self._start_btn)
118
+ button_row.addWidget(self._stop_btn)
119
+ button_row.addStretch()
120
+ controls_layout.addLayout(button_row)
121
+
122
+ layout.addWidget(controls_panel)
123
+
124
+ # Live transcription display
125
+ transcript_label = QLabel("LIVE TRANSCRIPTION")
126
+ transcript_label.setFont(mono_font)
127
+ transcript_label.setStyleSheet("font-size: 11px; font-weight: 600; color: #5a5a5a; letter-spacing: 2px;")
128
+ layout.addWidget(transcript_label)
129
+
130
+ self._transcript_display = QTextEdit()
131
+ self._transcript_display.setReadOnly(True)
132
+ self._transcript_display.setPlaceholderText("Waiting for transcription...")
133
+ self._transcript_display.setMinimumHeight(200)
134
+ self._transcript_display.setFont(mono_font)
135
+ self._transcript_display.setStyleSheet(
136
+ "background-color: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 6px; "
137
+ "color: #4ec9b0; padding: 12px; font-size: 13px;"
138
+ )
139
+ layout.addWidget(self._transcript_display, 1) # Stretch factor
140
+
141
+ def _setup_workers(self):
142
+ """Set up background workers."""
143
+ self._transcript_watcher = LiveTranscriptWatcher()
144
+ self._transcript_watcher.new_content.connect(self._on_new_transcript)
145
+ self._transcript_watcher.file_changed.connect(self._on_transcript_file_changed)
146
+ self._transcript_watcher.recording_ended.connect(self._on_recording_ended)
147
+ self._transcript_watcher.start()
148
+
149
+ def _connect_signals(self):
150
+ """Connect button signals."""
151
+ self._start_btn.clicked.connect(self._start_recording)
152
+ self._stop_btn.clicked.connect(self._stop_recording)
153
+ self._live_toggle.toggled.connect(self._on_live_toggle)
154
+
155
+ def _start_recording(self):
156
+ """Start a new recording."""
157
+ # Check if already recording
158
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
159
+ if daemon_pid and is_process_running(daemon_pid):
160
+ self._show_message("Already recording. Stop first.")
161
+ return
162
+
163
+ # Get meeting name
164
+ meeting_name = self._name_input.text().strip() or generate_meeting_name()
165
+
166
+ # Start recording daemon
167
+ subprocess.Popen(
168
+ [sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
169
+ stdout=subprocess.DEVNULL,
170
+ stderr=subprocess.DEVNULL,
171
+ )
172
+
173
+ # Clear input and transcript
174
+ self._name_input.clear()
175
+ self._name_input.setPlaceholderText(generate_meeting_name())
176
+ self._transcript_display.clear()
177
+ self._transcript_display.append(f"Starting recording: {meeting_name}\n")
178
+
179
+ def _stop_recording(self):
180
+ """Stop recording."""
181
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
182
+ watcher_pid = None
183
+
184
+ if WATCHER_PID_FILE.exists():
185
+ try:
186
+ watcher_pid = int(WATCHER_PID_FILE.read_text().strip())
187
+ except (ValueError, FileNotFoundError):
188
+ pass
189
+
190
+ stopped = []
191
+
192
+ # Stop daemon gracefully
193
+ if daemon_pid and is_process_running(daemon_pid):
194
+ try:
195
+ os.kill(daemon_pid, signal.SIGTERM)
196
+ stopped.append("recording")
197
+ except ProcessLookupError:
198
+ pass
199
+
200
+ # Stop watcher
201
+ if watcher_pid:
202
+ try:
203
+ os.kill(watcher_pid, signal.SIGTERM)
204
+ stopped.append("watcher")
205
+ except ProcessLookupError:
206
+ pass
207
+ WATCHER_PID_FILE.unlink(missing_ok=True)
208
+
209
+ if not stopped:
210
+ self._show_message("Nothing running")
211
+ else:
212
+ self._show_message(f"Stopped: {', '.join(stopped)}")
213
+
214
+ def _on_live_toggle(self, checked: bool):
215
+ """Handle live transcription toggle."""
216
+ if checked:
217
+ self._transcript_watcher.start()
218
+ else:
219
+ self._transcript_watcher.stop()
220
+
221
+ def _on_new_transcript(self, text: str):
222
+ """Handle new transcript content."""
223
+ if self._live_toggle.isChecked():
224
+ # Format timestamps in cyan
225
+ lines = text.splitlines()
226
+ for line in lines:
227
+ if line.strip() and line.startswith("["):
228
+ self._transcript_display.append(line)
229
+ # Auto-scroll to bottom
230
+ scrollbar = self._transcript_display.verticalScrollBar()
231
+ scrollbar.setValue(scrollbar.maximum())
232
+
233
+ def _on_transcript_file_changed(self, meeting_name: str):
234
+ """Handle new transcript file."""
235
+ self._transcript_display.clear()
236
+ self._transcript_display.append(f"Transcribing: {meeting_name}\n")
237
+
238
+ def _on_recording_ended(self):
239
+ """Handle recording end."""
240
+ self._transcript_display.append("\n--- Recording ended ---")
241
+
242
+ def _show_message(self, message: str):
243
+ """Show a message in the transcript area."""
244
+ self._transcript_display.append(f"\n{message}")
245
+
246
+ def update_status(self, watcher_running: bool, daemon_running: bool, meeting_name: str):
247
+ """Update status indicator."""
248
+ self._status_indicator.update_status(watcher_running, daemon_running, meeting_name)
249
+
250
+ # Update button states
251
+ self._start_btn.setEnabled(not daemon_running)
252
+ self._stop_btn.setEnabled(daemon_running or watcher_running)
253
+
254
+ def refresh(self):
255
+ """Refresh the dashboard."""
256
+ # Update placeholder with new timestamp
257
+ self._name_input.setPlaceholderText(generate_meeting_name())
258
+
259
+ def stop_workers(self):
260
+ """Stop background workers."""
261
+ if hasattr(self, "_transcript_watcher"):
262
+ self._transcript_watcher.stop()