meeting-noter 1.2.0__py3-none-any.whl → 2.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.

@@ -0,0 +1,346 @@
1
+ """Main dashboard screen for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ from pathlib import Path
8
+ from typing import Optional, Tuple
9
+
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Container, Horizontal, Vertical, Center
12
+ from textual.screen import Screen
13
+ from textual.widgets import Button, Input, Label, RichLog, Static, Switch
14
+
15
+ from meeting_noter.config import get_config, generate_meeting_name
16
+ from meeting_noter.daemon import read_pid_file, is_process_running
17
+
18
+
19
+ # PID file paths
20
+ DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
21
+ WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
22
+
23
+
24
+ class StatusDisplay(Static):
25
+ """Widget to display current status."""
26
+
27
+ def __init__(self, *args, **kwargs):
28
+ super().__init__(*args, **kwargs)
29
+ self._status = "Checking..."
30
+
31
+ def on_mount(self) -> None:
32
+ """Start status refresh timer."""
33
+ self.set_interval(2.0, self.refresh_status)
34
+ self.refresh_status()
35
+
36
+ def refresh_status(self) -> None:
37
+ """Refresh the status display."""
38
+ watcher_running = self._check_watcher()
39
+ daemon_running, recording_name = self._check_daemon()
40
+
41
+ if daemon_running:
42
+ status_icon = "[red]●[/red]"
43
+ status_text = f"Recording: {recording_name or 'In progress'}"
44
+ elif watcher_running:
45
+ status_icon = "[green]●[/green]"
46
+ status_text = "Ready (watcher active)"
47
+ else:
48
+ status_icon = "[dim]●[/dim]"
49
+ status_text = "Stopped"
50
+
51
+ self.update(
52
+ f"{status_icon} {status_text}\n\n"
53
+ f"[dim]Watcher:[/dim] {'running' if watcher_running else 'stopped'}\n"
54
+ f"[dim]Recorder:[/dim] {'recording' if daemon_running else 'idle'}"
55
+ )
56
+
57
+ # Notify parent screen about recording state
58
+ try:
59
+ screen = self.screen
60
+ if hasattr(screen, "_update_recording_state"):
61
+ screen._update_recording_state(daemon_running)
62
+ except Exception:
63
+ pass
64
+
65
+ def _check_watcher(self) -> bool:
66
+ """Check if watcher is running."""
67
+ if WATCHER_PID_FILE.exists():
68
+ try:
69
+ pid = int(WATCHER_PID_FILE.read_text().strip())
70
+ os.kill(pid, 0)
71
+ return True
72
+ except (ProcessLookupError, ValueError, FileNotFoundError):
73
+ pass
74
+ return False
75
+
76
+ def _check_daemon(self) -> Tuple[bool, Optional[str]]:
77
+ """Check if daemon is running and get recording name."""
78
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
79
+ if daemon_pid and is_process_running(daemon_pid):
80
+ recording_name = self._get_current_recording_name()
81
+ return True, recording_name
82
+ return False, None
83
+
84
+ def _get_current_recording_name(self) -> Optional[str]:
85
+ """Get the name of the current recording from the log file."""
86
+ log_path = Path.home() / ".meeting-noter.log"
87
+ if not log_path.exists():
88
+ return None
89
+
90
+ try:
91
+ with open(log_path, "r") as f:
92
+ lines = f.readlines()
93
+
94
+ for line in reversed(lines[-50:]):
95
+ if "Recording started:" in line:
96
+ parts = line.split("Recording started:")
97
+ if len(parts) > 1:
98
+ filename = parts[1].strip().replace(".mp3", "")
99
+ name_parts = filename.split("_", 2)
100
+ if len(name_parts) >= 3:
101
+ return name_parts[2]
102
+ return filename
103
+ elif "Recording saved:" in line or "Recording discarded" in line:
104
+ break
105
+ return None
106
+ except Exception:
107
+ return None
108
+
109
+
110
+ class LiveTranscriptDisplay(RichLog):
111
+ """Widget to display live transcription."""
112
+
113
+ def __init__(self, *args, **kwargs):
114
+ super().__init__(*args, highlight=True, markup=True, **kwargs)
115
+ self._live_file: Optional[Path] = None
116
+ self._last_content = ""
117
+ self._is_recording = False
118
+
119
+ def on_mount(self) -> None:
120
+ """Start watching for live transcript updates."""
121
+ self.set_interval(0.5, self._check_live_transcript)
122
+
123
+ def set_recording(self, is_recording: bool) -> None:
124
+ """Set the recording state."""
125
+ self._is_recording = is_recording
126
+ if not is_recording:
127
+ self._live_file = None
128
+ self._last_content = ""
129
+
130
+ def _check_live_transcript(self) -> None:
131
+ """Check for live transcript updates."""
132
+ if not self._is_recording:
133
+ return
134
+
135
+ config = get_config()
136
+ live_dir = config.recordings_dir / "live"
137
+
138
+ if not live_dir.exists():
139
+ return
140
+
141
+ # Find the most recent live file
142
+ live_files = sorted(
143
+ live_dir.glob("*.live.txt"),
144
+ key=lambda p: p.stat().st_mtime,
145
+ reverse=True,
146
+ )
147
+
148
+ if not live_files:
149
+ return
150
+
151
+ live_file = live_files[0]
152
+
153
+ # Read and display new content
154
+ try:
155
+ content = live_file.read_text()
156
+ if len(content) > len(self._last_content):
157
+ new_content = content[len(self._last_content):]
158
+ for line in new_content.splitlines():
159
+ if line.strip() and line.startswith("["):
160
+ self.write(f"[cyan]{line}[/cyan]")
161
+ self._last_content = content
162
+ except Exception:
163
+ pass
164
+
165
+
166
+ class MainScreen(Screen):
167
+ """Main dashboard screen."""
168
+
169
+ BINDINGS = [
170
+ ("r", "toggle_recording", "Record"),
171
+ ]
172
+
173
+ def __init__(self, *args, **kwargs):
174
+ super().__init__(*args, **kwargs)
175
+ self._is_recording = False
176
+
177
+ def compose(self) -> ComposeResult:
178
+ """Compose the main screen layout."""
179
+ yield Container(
180
+ Static("[bold]Meeting Noter[/bold]", classes="title"),
181
+ Static("", classes="spacer"),
182
+ StatusDisplay(id="status"),
183
+ Static("", classes="spacer"),
184
+ Center(
185
+ Vertical(
186
+ Horizontal(
187
+ Label("Meeting name:", classes="input-label"),
188
+ Input(
189
+ placeholder=generate_meeting_name(),
190
+ id="meeting-name",
191
+ classes="meeting-input",
192
+ ),
193
+ classes="centered-input-row",
194
+ ),
195
+ Horizontal(
196
+ Label("Live transcription:", classes="input-label"),
197
+ Switch(value=True, id="live-transcription"),
198
+ classes="centered-switch-row",
199
+ ),
200
+ classes="input-section",
201
+ ),
202
+ ),
203
+ Static("", classes="spacer"),
204
+ Center(
205
+ Horizontal(
206
+ Button("Start Recording", id="record-btn", variant="success"),
207
+ Button("Stop", id="stop-btn", variant="error"),
208
+ classes="centered-button-row",
209
+ ),
210
+ ),
211
+ Static("", classes="spacer"),
212
+ Static("[dim]Live Transcription[/dim]", id="live-header", classes="section-header"),
213
+ LiveTranscriptDisplay(id="live-transcript"),
214
+ Static("", classes="spacer"),
215
+ Static("[dim]Quick Navigation[/dim]", classes="section-header"),
216
+ Center(
217
+ Horizontal(
218
+ Button("Recordings", id="nav-recordings"),
219
+ Button("Search", id="nav-search"),
220
+ Button("Settings", id="nav-settings"),
221
+ Button("Logs", id="nav-logs"),
222
+ classes="centered-button-row",
223
+ ),
224
+ ),
225
+ id="main-container",
226
+ )
227
+
228
+ def _update_recording_state(self, is_recording: bool) -> None:
229
+ """Update UI based on recording state."""
230
+ if is_recording != self._is_recording:
231
+ self._is_recording = is_recording
232
+ live_display = self.query_one("#live-transcript", LiveTranscriptDisplay)
233
+ live_switch = self.query_one("#live-transcription", Switch)
234
+
235
+ if is_recording and live_switch.value:
236
+ live_display.set_recording(True)
237
+ live_display.clear()
238
+ live_display.write("[dim]Waiting for transcription...[/dim]")
239
+ else:
240
+ live_display.set_recording(False)
241
+
242
+ def action_toggle_recording(self) -> None:
243
+ """Toggle recording state."""
244
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
245
+ if daemon_pid and is_process_running(daemon_pid):
246
+ self._on_stop_button()
247
+ else:
248
+ self._on_record_button()
249
+
250
+ def on_button_pressed(self, event: Button.Pressed) -> None:
251
+ """Handle button presses."""
252
+ button_id = event.button.id
253
+
254
+ if button_id == "record-btn":
255
+ self._on_record_button()
256
+ elif button_id == "stop-btn":
257
+ self._on_stop_button()
258
+ elif button_id == "nav-recordings":
259
+ self.app.action_switch_screen("recordings")
260
+ elif button_id == "nav-search":
261
+ self.app.action_switch_screen("search")
262
+ elif button_id == "nav-settings":
263
+ self.app.action_switch_screen("settings")
264
+ elif button_id == "nav-logs":
265
+ self.app.action_switch_screen("logs")
266
+
267
+ def _on_record_button(self) -> None:
268
+ """Handle record button press."""
269
+ import subprocess
270
+ import sys
271
+
272
+ # Check if already recording
273
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
274
+ if daemon_pid and is_process_running(daemon_pid):
275
+ self.notify("Already recording. Stop first.", severity="warning")
276
+ return
277
+
278
+ # Get meeting name
279
+ name_input = self.query_one("#meeting-name", Input)
280
+ meeting_name = name_input.value.strip() or generate_meeting_name()
281
+
282
+ # Clear live transcript display
283
+ live_display = self.query_one("#live-transcript", LiveTranscriptDisplay)
284
+ live_display.clear()
285
+ live_display.write(f"[green]Starting recording: {meeting_name}[/green]")
286
+
287
+ # Start recording
288
+ subprocess.Popen(
289
+ [sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
290
+ stdout=subprocess.DEVNULL,
291
+ stderr=subprocess.DEVNULL,
292
+ )
293
+
294
+ self.notify(f"Recording started: {meeting_name}", severity="information")
295
+
296
+ # Clear the input
297
+ name_input.value = ""
298
+
299
+ # Refresh status
300
+ self.query_one("#status", StatusDisplay).refresh_status()
301
+
302
+ def _on_stop_button(self) -> None:
303
+ """Handle stop button press - gracefully stop daemon."""
304
+ # Get daemon PID and send SIGTERM for graceful shutdown
305
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
306
+ watcher_pid = None
307
+
308
+ if WATCHER_PID_FILE.exists():
309
+ try:
310
+ watcher_pid = int(WATCHER_PID_FILE.read_text().strip())
311
+ except (ValueError, FileNotFoundError):
312
+ pass
313
+
314
+ stopped = []
315
+
316
+ # Stop daemon gracefully (allows auto-transcribe to run)
317
+ if daemon_pid and is_process_running(daemon_pid):
318
+ try:
319
+ os.kill(daemon_pid, signal.SIGTERM)
320
+ stopped.append("recording")
321
+ self.notify("Stopping recording (will auto-transcribe if enabled)...", severity="information")
322
+ except ProcessLookupError:
323
+ pass
324
+
325
+ # Stop watcher
326
+ if watcher_pid:
327
+ try:
328
+ os.kill(watcher_pid, signal.SIGTERM)
329
+ stopped.append("watcher")
330
+ except ProcessLookupError:
331
+ pass
332
+ WATCHER_PID_FILE.unlink(missing_ok=True)
333
+
334
+ # Update live display
335
+ live_display = self.query_one("#live-transcript", LiveTranscriptDisplay)
336
+ live_display.set_recording(False)
337
+ if stopped:
338
+ live_display.write(f"\n[yellow]Stopped: {', '.join(stopped)}[/yellow]")
339
+
340
+ if not stopped:
341
+ self.notify("No processes running", severity="warning")
342
+ else:
343
+ self.notify(f"Stopped: {', '.join(stopped)}", severity="information")
344
+
345
+ # Refresh status after a short delay
346
+ self.set_timer(1.0, lambda: self.query_one("#status", StatusDisplay).refresh_status())
@@ -0,0 +1,241 @@
1
+ """Recordings browser screen for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Container, Horizontal
12
+ from textual.screen import Screen
13
+ from textual.widgets import Button, DataTable, Label, Static
14
+
15
+ from meeting_noter.config import get_config
16
+ from meeting_noter.output.writer import format_duration, format_size, get_audio_duration
17
+
18
+
19
+ @dataclass
20
+ class RecordingInfo:
21
+ """Information about a recording."""
22
+
23
+ filepath: Path
24
+ date: datetime
25
+ duration: Optional[float]
26
+ size: int
27
+ has_transcript: bool
28
+ is_favorite: bool
29
+
30
+
31
+ def get_recordings_list(output_dir: Path, limit: int = 50) -> list[RecordingInfo]:
32
+ """Get list of recordings with metadata."""
33
+ config = get_config()
34
+
35
+ if not output_dir.exists():
36
+ return []
37
+
38
+ mp3_files = sorted(
39
+ output_dir.glob("*.mp3"),
40
+ key=lambda p: p.stat().st_mtime,
41
+ reverse=True,
42
+ )
43
+
44
+ # Get transcripts directory from config
45
+ transcripts_dir = config.transcripts_dir
46
+
47
+ recordings = []
48
+ for mp3 in mp3_files[:limit]:
49
+ stat = mp3.stat()
50
+ transcript_name = mp3.stem + ".txt"
51
+
52
+ # Check for transcript in transcripts directory
53
+ transcript_path = transcripts_dir / transcript_name
54
+ has_transcript = transcript_path.exists()
55
+
56
+ recordings.append(
57
+ RecordingInfo(
58
+ filepath=mp3,
59
+ date=datetime.fromtimestamp(stat.st_mtime),
60
+ duration=get_audio_duration(mp3),
61
+ size=stat.st_size,
62
+ has_transcript=has_transcript,
63
+ is_favorite=config.is_favorite(transcript_name),
64
+ )
65
+ )
66
+
67
+ return recordings
68
+
69
+
70
+ class RecordingsScreen(Screen):
71
+ """Recordings browser screen."""
72
+
73
+ BINDINGS = [
74
+ ("t", "transcribe", "Transcribe"),
75
+ ("enter", "view", "View"),
76
+ ("f", "toggle_favorite", "Favorite"),
77
+ ("r", "refresh", "Refresh"),
78
+ ]
79
+
80
+ def compose(self) -> ComposeResult:
81
+ """Compose the recordings screen layout."""
82
+ yield Container(
83
+ Static("[bold]Recordings[/bold]", classes="title"),
84
+ Static("", classes="spacer"),
85
+ DataTable(id="recordings-table", cursor_type="row"),
86
+ Static("", classes="spacer"),
87
+ Horizontal(
88
+ Button("View", id="view-btn"),
89
+ Button("Transcribe", id="transcribe-btn", variant="primary"),
90
+ Button("Toggle Favorite", id="favorite-btn"),
91
+ Button("Refresh", id="refresh-btn"),
92
+ classes="button-row",
93
+ ),
94
+ Label("", id="status-label"),
95
+ id="recordings-container",
96
+ )
97
+
98
+ def on_mount(self) -> None:
99
+ """Load recordings on mount."""
100
+ self._setup_table()
101
+ self._load_recordings()
102
+
103
+ def _setup_table(self) -> None:
104
+ """Set up the recordings table."""
105
+ table = self.query_one("#recordings-table", DataTable)
106
+ table.add_columns("★", "Date", "Duration", "Size", "Transcript", "File")
107
+
108
+ def _load_recordings(self) -> None:
109
+ """Load recordings into the table."""
110
+ config = get_config()
111
+ table = self.query_one("#recordings-table", DataTable)
112
+ status = self.query_one("#status-label", Label)
113
+
114
+ table.clear()
115
+
116
+ recordings = get_recordings_list(config.recordings_dir)
117
+
118
+ if not recordings:
119
+ status.update("[yellow]No recordings found[/yellow]")
120
+ return
121
+
122
+ for rec in recordings:
123
+ star = "★" if rec.is_favorite else ""
124
+ date_str = rec.date.strftime("%Y-%m-%d %H:%M")
125
+ duration_str = format_duration(rec.duration) if rec.duration else "?"
126
+ size_str = format_size(rec.size)
127
+ transcript_str = "Yes" if rec.has_transcript else "No"
128
+
129
+ table.add_row(
130
+ star,
131
+ date_str,
132
+ duration_str,
133
+ size_str,
134
+ transcript_str,
135
+ rec.filepath.name,
136
+ key=str(rec.filepath),
137
+ )
138
+
139
+ status.update(f"[dim]{len(recordings)} recordings[/dim]")
140
+
141
+ def on_button_pressed(self, event: Button.Pressed) -> None:
142
+ """Handle button presses."""
143
+ button_id = event.button.id
144
+
145
+ if button_id == "view-btn":
146
+ self.action_view()
147
+ elif button_id == "transcribe-btn":
148
+ self.action_transcribe()
149
+ elif button_id == "favorite-btn":
150
+ self.action_toggle_favorite()
151
+ elif button_id == "refresh-btn":
152
+ self.action_refresh()
153
+
154
+ def _get_selected_filepath(self) -> Optional[Path]:
155
+ """Get the filepath of the currently selected recording."""
156
+ table = self.query_one("#recordings-table", DataTable)
157
+
158
+ if table.row_count == 0:
159
+ return None
160
+
161
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
162
+ if row_key:
163
+ return Path(row_key.value)
164
+ return None
165
+
166
+ def action_view(self) -> None:
167
+ """View the selected transcript."""
168
+ filepath = self._get_selected_filepath()
169
+ if not filepath:
170
+ self.notify("No recording selected", severity="warning")
171
+ return
172
+
173
+ config = get_config()
174
+ transcript_name = filepath.stem + ".txt"
175
+ transcript = config.transcripts_dir / transcript_name
176
+
177
+ if not transcript.exists():
178
+ self.notify("No transcript available. Transcribe first.", severity="warning")
179
+ return
180
+
181
+ # Push the viewer screen with the transcript path
182
+ from meeting_noter.ui.screens.viewer import ViewerScreen
183
+
184
+ self.app.push_screen(ViewerScreen(transcript))
185
+
186
+ def action_transcribe(self) -> None:
187
+ """Transcribe the selected recording."""
188
+ import subprocess
189
+ import sys
190
+
191
+ filepath = self._get_selected_filepath()
192
+ if not filepath:
193
+ self.notify("No recording selected", severity="warning")
194
+ return
195
+
196
+ config = get_config()
197
+ transcript_name = filepath.stem + ".txt"
198
+ transcript = config.transcripts_dir / transcript_name
199
+
200
+ if transcript.exists():
201
+ self.notify("Transcript already exists", severity="warning")
202
+ return
203
+
204
+ # Run transcription in background
205
+ subprocess.Popen(
206
+ [
207
+ sys.executable,
208
+ "-m",
209
+ "meeting_noter.cli",
210
+ "transcribe",
211
+ str(filepath),
212
+ ],
213
+ stdout=subprocess.DEVNULL,
214
+ stderr=subprocess.DEVNULL,
215
+ )
216
+
217
+ self.notify(f"Transcribing: {filepath.name}", severity="information")
218
+
219
+ def action_toggle_favorite(self) -> None:
220
+ """Toggle favorite status for the selected recording."""
221
+ filepath = self._get_selected_filepath()
222
+ if not filepath:
223
+ self.notify("No recording selected", severity="warning")
224
+ return
225
+
226
+ config = get_config()
227
+ transcript_name = filepath.with_suffix(".txt").name
228
+
229
+ if config.is_favorite(transcript_name):
230
+ config.remove_favorite(transcript_name)
231
+ self.notify(f"Removed from favorites: {transcript_name}")
232
+ else:
233
+ config.add_favorite(transcript_name)
234
+ self.notify(f"Added to favorites: {transcript_name}")
235
+
236
+ self._load_recordings()
237
+
238
+ def action_refresh(self) -> None:
239
+ """Refresh the recordings list."""
240
+ self._load_recordings()
241
+ self.notify("Refreshed")