meeting-noter 1.1.0__py3-none-any.whl → 1.3.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__ = "1.1.0"
3
+ __version__ = "1.3.0"
meeting_noter/cli.py CHANGED
@@ -90,20 +90,6 @@ class SuggestCommand(click.Command):
90
90
  SuggestGroup.command_class = SuggestCommand
91
91
 
92
92
 
93
- def _launch_gui_background():
94
- """Launch the GUI in background and return immediately."""
95
- import subprocess
96
- import sys
97
-
98
- subprocess.Popen(
99
- [sys.executable, "-m", "meeting_noter.gui"],
100
- stdout=subprocess.DEVNULL,
101
- stderr=subprocess.DEVNULL,
102
- start_new_session=True,
103
- )
104
- click.echo("Meeting Noter GUI launched.")
105
-
106
-
107
93
  def _handle_version():
108
94
  """Handle --version: show version, check for updates, auto-update if enabled."""
109
95
  import subprocess
@@ -276,7 +262,12 @@ def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
276
262
 
277
263
  @cli.command()
278
264
  def status():
279
- """Show Meeting Noter status."""
265
+ """Show Meeting Noter status.
266
+
267
+ \b
268
+ Examples:
269
+ meeting-noter status # Check if recording or watching
270
+ """
280
271
  import os
281
272
  from meeting_noter.daemon import read_pid_file, is_process_running
282
273
 
@@ -290,17 +281,6 @@ def status():
290
281
  except (ProcessLookupError, ValueError, FileNotFoundError):
291
282
  pass
292
283
 
293
- # Check menubar
294
- menubar_running = False
295
- menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
296
- if menubar_pid_file.exists():
297
- try:
298
- pid = int(menubar_pid_file.read_text().strip())
299
- os.kill(pid, 0)
300
- menubar_running = True
301
- except (ProcessLookupError, ValueError, FileNotFoundError):
302
- pass
303
-
304
284
  # Check daemon (recording)
305
285
  daemon_running = False
306
286
  daemon_pid = read_pid_file(DEFAULT_PID_FILE)
@@ -313,9 +293,8 @@ def status():
313
293
  # Get current recording name from log
314
294
  recording_name = _get_current_recording_name()
315
295
  click.echo(f"🔴 Recording: {recording_name or 'In progress'}")
316
- elif watcher_running or menubar_running:
317
- mode = "watcher" if watcher_running else "menubar"
318
- click.echo(f"👀 Ready to record ({mode} active)")
296
+ elif watcher_running:
297
+ click.echo("👀 Ready to record (watcher active)")
319
298
  else:
320
299
  click.echo("⏹️ Stopped (run 'meeting-noter' to start)")
321
300
 
@@ -324,7 +303,6 @@ def status():
324
303
  # Show details
325
304
  click.echo("Components:")
326
305
  click.echo(f" Watcher: {'running' if watcher_running else 'stopped'}")
327
- click.echo(f" Menubar: {'running' if menubar_running else 'stopped'}")
328
306
  click.echo(f" Recorder: {'recording' if daemon_running else 'idle'}")
329
307
  click.echo()
330
308
 
@@ -358,7 +336,12 @@ def _get_current_recording_name() -> str | None:
358
336
 
359
337
  @cli.command()
360
338
  def shutdown():
361
- """Stop all Meeting Noter processes (daemon, watcher, menubar, GUI)."""
339
+ """Stop all Meeting Noter processes (daemon, watcher).
340
+
341
+ \b
342
+ Examples:
343
+ meeting-noter shutdown # Stop recording and watcher
344
+ """
362
345
  import subprocess
363
346
  import os
364
347
  import signal
@@ -381,17 +364,6 @@ def shutdown():
381
364
  except (ProcessLookupError, ValueError):
382
365
  WATCHER_PID_FILE.unlink(missing_ok=True)
383
366
 
384
- # Stop menubar
385
- menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
386
- if menubar_pid_file.exists():
387
- try:
388
- pid = int(menubar_pid_file.read_text().strip())
389
- os.kill(pid, signal.SIGTERM)
390
- menubar_pid_file.unlink()
391
- stopped.append("menubar")
392
- except (ProcessLookupError, ValueError):
393
- menubar_pid_file.unlink(missing_ok=True)
394
-
395
367
  # Kill any remaining meeting-noter processes
396
368
  result = subprocess.run(
397
369
  ["pkill", "-f", "meeting_noter"],
@@ -410,7 +382,14 @@ def shutdown():
410
382
  @click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)")
411
383
  @click.option("--lines", "-n", default=50, help="Number of lines to show")
412
384
  def logs(follow: bool, lines: int):
413
- """View Meeting Noter logs."""
385
+ """View Meeting Noter logs.
386
+
387
+ \b
388
+ Examples:
389
+ meeting-noter logs # Show last 50 lines
390
+ meeting-noter logs -n 100 # Show last 100 lines
391
+ meeting-noter logs -f # Follow log output (Ctrl+C to stop)
392
+ """
414
393
  import subprocess
415
394
 
416
395
  log_file = Path.home() / ".meeting-noter.log"
@@ -430,6 +409,11 @@ def logs(follow: bool, lines: int):
430
409
 
431
410
 
432
411
  @cli.command("list")
412
+ @click.option(
413
+ "--transcripts", "-t",
414
+ is_flag=True,
415
+ help="List transcripts instead of recordings",
416
+ )
433
417
  @click.option(
434
418
  "--output-dir", "-o",
435
419
  type=click.Path(exists=True),
@@ -440,22 +424,118 @@ def logs(follow: bool, lines: int):
440
424
  "--limit", "-n",
441
425
  type=int,
442
426
  default=10,
443
- help="Number of recordings to show",
427
+ help="Number of items to show",
428
+ )
429
+ @require_setup
430
+ def list_recordings(transcripts: bool, output_dir: Optional[str], limit: int):
431
+ """List recent meeting recordings or transcripts.
432
+
433
+ \b
434
+ Examples:
435
+ meeting-noter list # Show last 10 recordings
436
+ meeting-noter list -t # Show last 10 transcripts
437
+ meeting-noter list -t -n 20 # Show last 20 transcripts
438
+ """
439
+ config = get_config()
440
+
441
+ if transcripts:
442
+ from meeting_noter.output.favorites import list_transcripts_with_favorites
443
+ path = Path(output_dir) if output_dir else config.transcripts_dir
444
+ list_transcripts_with_favorites(path, limit)
445
+ else:
446
+ from meeting_noter.output.writer import list_recordings as _list_recordings
447
+ path = Path(output_dir) if output_dir else config.recordings_dir
448
+ _list_recordings(path, limit)
449
+
450
+
451
+ @cli.command("search")
452
+ @click.argument("query")
453
+ @click.option(
454
+ "--case-sensitive", "-c",
455
+ is_flag=True,
456
+ help="Case-sensitive search",
457
+ )
458
+ @click.option(
459
+ "--limit", "-n",
460
+ type=int,
461
+ default=20,
462
+ help="Max results to show",
463
+ )
464
+ @click.option(
465
+ "--transcripts-dir", "-d",
466
+ type=click.Path(),
467
+ default=None,
468
+ help="Override transcripts directory",
444
469
  )
445
470
  @require_setup
446
- def list_recordings(output_dir: Optional[str], limit: int):
447
- """List recent meeting recordings.
471
+ def search(query: str, case_sensitive: bool, limit: int, transcripts_dir: Optional[str]):
472
+ """Search across all meeting transcripts.
448
473
 
449
474
  \b
450
475
  Examples:
451
- meeting-noter list # Show last 10 recordings
452
- meeting-noter list -n 20 # Show last 20 recordings
476
+ meeting-noter search "action items"
477
+ meeting-noter search "API" --case-sensitive
478
+ meeting-noter search "standup" -n 5
453
479
  """
454
- from meeting_noter.output.writer import list_recordings as _list_recordings
480
+ from meeting_noter.output.searcher import search_transcripts
455
481
 
456
482
  config = get_config()
457
- path = Path(output_dir) if output_dir else config.recordings_dir
458
- _list_recordings(path, limit)
483
+ path = Path(transcripts_dir) if transcripts_dir else config.transcripts_dir
484
+ search_transcripts(path, query, case_sensitive, limit)
485
+
486
+
487
+ @cli.group("favorites", invoke_without_command=True)
488
+ @click.pass_context
489
+ @require_setup
490
+ def favorites(ctx):
491
+ """Manage favorite transcripts.
492
+
493
+ \b
494
+ Examples:
495
+ meeting-noter favorites # List all favorites
496
+ meeting-noter favorites add file.txt # Add to favorites
497
+ meeting-noter favorites add --latest # Add most recent transcript
498
+ meeting-noter favorites remove file # Remove from favorites
499
+ """
500
+ if ctx.invoked_subcommand is None:
501
+ # Default: list favorites
502
+ from meeting_noter.output.favorites import list_favorites
503
+
504
+ config = get_config()
505
+ list_favorites(config.transcripts_dir)
506
+
507
+
508
+ @favorites.command("add")
509
+ @click.argument("filename", required=False)
510
+ @click.option("--latest", "-l", is_flag=True, help="Add the most recent transcript")
511
+ @require_setup
512
+ def favorites_add(filename: Optional[str], latest: bool):
513
+ """Add a transcript to favorites.
514
+
515
+ \b
516
+ Examples:
517
+ meeting-noter favorites add meeting.txt
518
+ meeting-noter favorites add --latest
519
+ """
520
+ from meeting_noter.output.favorites import add_favorite
521
+
522
+ config = get_config()
523
+ add_favorite(config.transcripts_dir, filename, latest)
524
+
525
+
526
+ @favorites.command("remove")
527
+ @click.argument("filename")
528
+ @require_setup
529
+ def favorites_remove(filename: str):
530
+ """Remove a transcript from favorites.
531
+
532
+ \b
533
+ Examples:
534
+ meeting-noter favorites remove meeting.txt
535
+ """
536
+ from meeting_noter.output.favorites import remove_favorite
537
+
538
+ remove_favorite(filename)
459
539
 
460
540
 
461
541
  @cli.command()
@@ -584,62 +664,6 @@ def live():
584
664
  click.echo("\n" + click.style("Stopped watching.", fg="cyan"))
585
665
 
586
666
 
587
- @cli.command()
588
- @click.option(
589
- "--foreground", "-f",
590
- is_flag=True,
591
- help="Run in foreground instead of background",
592
- )
593
- @require_setup
594
- def menubar(foreground: bool):
595
- """Launch menu bar app for daemon control.
596
-
597
- Adds a menu bar icon for one-click start/stop of the recording daemon.
598
- The icon shows "MN" when idle and "MN [filename]" when recording.
599
-
600
- By default, runs in background. Use -f for foreground (debugging).
601
- """
602
- import subprocess
603
- import sys
604
-
605
- if foreground:
606
- from meeting_noter.menubar import run_menubar
607
- run_menubar()
608
- else:
609
- # Spawn as background process
610
- subprocess.Popen(
611
- [sys.executable, "-m", "meeting_noter.menubar"],
612
- stdout=subprocess.DEVNULL,
613
- stderr=subprocess.DEVNULL,
614
- start_new_session=True,
615
- )
616
- click.echo("Menu bar app started in background.")
617
-
618
-
619
- @cli.command()
620
- @click.option(
621
- "--foreground", "-f",
622
- is_flag=True,
623
- help="Run in foreground instead of background",
624
- )
625
- @require_setup
626
- def gui(foreground: bool):
627
- """Launch the desktop GUI application.
628
-
629
- Opens a window with tabs for:
630
- - Recording: Start/stop recordings with meeting names
631
- - Meetings: Browse, play, and manage recordings
632
- - Settings: Configure directories, models, and preferences
633
-
634
- By default runs in background. Use -f for foreground.
635
- """
636
- if foreground:
637
- from meeting_noter.gui import run_gui
638
- run_gui()
639
- else:
640
- _launch_gui_background()
641
-
642
-
643
667
  # Config key mappings (CLI name -> config attribute)
644
668
  CONFIG_KEYS = {
645
669
  "recordings-dir": ("recordings_dir", "path", "Directory for audio recordings"),
meeting_noter/config.py CHANGED
@@ -160,6 +160,40 @@ class Config:
160
160
  """Set setup completion status."""
161
161
  self._data["setup_complete"] = value
162
162
 
163
+ @property
164
+ def favorites(self) -> list[str]:
165
+ """Get list of favorite transcript filenames."""
166
+ return self._data.get("favorites", [])
167
+
168
+ @favorites.setter
169
+ def favorites(self, value: list[str]) -> None:
170
+ """Set list of favorite transcript filenames."""
171
+ self._data["favorites"] = value
172
+
173
+ def add_favorite(self, filename: str) -> bool:
174
+ """Add a transcript to favorites. Returns True if added, False if already exists."""
175
+ favorites = self.favorites
176
+ if filename not in favorites:
177
+ favorites.append(filename)
178
+ self.favorites = favorites
179
+ self.save()
180
+ return True
181
+ return False
182
+
183
+ def remove_favorite(self, filename: str) -> bool:
184
+ """Remove a transcript from favorites. Returns True if removed, False if not found."""
185
+ favorites = self.favorites
186
+ if filename in favorites:
187
+ favorites.remove(filename)
188
+ self.favorites = favorites
189
+ self.save()
190
+ return True
191
+ return False
192
+
193
+ def is_favorite(self, filename: str) -> bool:
194
+ """Check if a transcript is a favorite."""
195
+ return filename in self.favorites
196
+
163
197
  def __getitem__(self, key: str) -> Any:
164
198
  """Get config value by key."""
165
199
  return self._data.get(key, DEFAULT_CONFIG.get(key))
@@ -0,0 +1,189 @@
1
+ """Favorites management for meeting transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from meeting_noter.config import get_config
11
+
12
+
13
+ def list_favorites(transcripts_dir: Path) -> None:
14
+ """List all favorite transcripts.
15
+
16
+ Args:
17
+ transcripts_dir: Directory containing transcript files
18
+ """
19
+ config = get_config()
20
+ favorites = config.favorites
21
+
22
+ if not favorites:
23
+ click.echo(click.style("No favorites yet.", fg="yellow"))
24
+ click.echo("\nAdd favorites with: meeting-noter favorites add <filename>")
25
+ click.echo("Or use: meeting-noter favorites add --latest")
26
+ return
27
+
28
+ click.echo()
29
+ click.echo(click.style("Favorite Transcripts", bold=True))
30
+ click.echo("=" * 50)
31
+ click.echo()
32
+
33
+ found_count = 0
34
+ missing_count = 0
35
+
36
+ for idx, filename in enumerate(favorites, 1):
37
+ filepath = transcripts_dir / filename
38
+ if filepath.exists():
39
+ found_count += 1
40
+ # Get file info
41
+ stat = filepath.stat()
42
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
43
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
44
+
45
+ # Display: index, filename prominently, then date
46
+ click.echo(
47
+ click.style(f" {idx}. ", fg="cyan")
48
+ + click.style(filename, fg="green", bold=True)
49
+ )
50
+ click.echo(f" ★ {date_str}")
51
+ else:
52
+ missing_count += 1
53
+ click.echo(
54
+ click.style(f" {idx}. ", fg="cyan")
55
+ + click.style(filename, fg="red", strikethrough=True)
56
+ )
57
+ click.echo(click.style(" (file not found)", fg="red"))
58
+
59
+ click.echo()
60
+ click.echo(f"Total: {found_count} favorites")
61
+ if missing_count > 0:
62
+ click.echo(
63
+ click.style(
64
+ f"Warning: {missing_count} favorite(s) no longer exist",
65
+ fg="yellow"
66
+ )
67
+ )
68
+ click.echo()
69
+
70
+
71
+ def add_favorite(
72
+ transcripts_dir: Path,
73
+ filename: Optional[str] = None,
74
+ latest: bool = False
75
+ ) -> None:
76
+ """Add a transcript to favorites.
77
+
78
+ Args:
79
+ transcripts_dir: Directory containing transcript files
80
+ filename: Name of transcript file to add
81
+ latest: If True, add the most recent transcript
82
+ """
83
+ config = get_config()
84
+
85
+ if latest:
86
+ # Find the most recent transcript
87
+ txt_files = sorted(
88
+ transcripts_dir.glob("*.txt"),
89
+ key=lambda p: p.stat().st_mtime,
90
+ reverse=True,
91
+ )
92
+ if not txt_files:
93
+ click.echo(click.style("No transcripts found.", fg="red"))
94
+ return
95
+ filename = txt_files[0].name
96
+
97
+ if not filename:
98
+ click.echo(click.style("Error: Specify a filename or use --latest", fg="red"))
99
+ return
100
+
101
+ # Check if file exists
102
+ filepath = transcripts_dir / filename
103
+ if not filepath.exists():
104
+ # Try adding .txt extension
105
+ if not filename.endswith(".txt"):
106
+ filepath = transcripts_dir / f"{filename}.txt"
107
+ if filepath.exists():
108
+ filename = f"{filename}.txt"
109
+ else:
110
+ click.echo(click.style(f"File not found: {filename}", fg="red"))
111
+ click.echo(f"Looking in: {transcripts_dir}")
112
+ return
113
+ else:
114
+ click.echo(click.style(f"File not found: {filename}", fg="red"))
115
+ click.echo(f"Looking in: {transcripts_dir}")
116
+ return
117
+
118
+ if config.add_favorite(filename):
119
+ click.echo(
120
+ click.style("★ ", fg="yellow")
121
+ + f"Added to favorites: "
122
+ + click.style(filename, fg="green")
123
+ )
124
+ else:
125
+ click.echo(click.style(f"Already a favorite: {filename}", fg="yellow"))
126
+
127
+
128
+ def remove_favorite(filename: str) -> None:
129
+ """Remove a transcript from favorites.
130
+
131
+ Args:
132
+ filename: Name of transcript file to remove
133
+ """
134
+ config = get_config()
135
+
136
+ # Handle with or without .txt extension
137
+ if not filename.endswith(".txt"):
138
+ if f"{filename}.txt" in config.favorites:
139
+ filename = f"{filename}.txt"
140
+
141
+ if config.remove_favorite(filename):
142
+ click.echo(
143
+ click.style("☆ ", fg="cyan")
144
+ + f"Removed from favorites: "
145
+ + click.style(filename, fg="green")
146
+ )
147
+ else:
148
+ click.echo(click.style(f"Not a favorite: {filename}", fg="yellow"))
149
+
150
+
151
+ def list_transcripts_with_favorites(transcripts_dir: Path, limit: int = 10) -> None:
152
+ """List transcripts with favorite status indicated.
153
+
154
+ Args:
155
+ transcripts_dir: Directory containing transcript files
156
+ limit: Maximum number of transcripts to show
157
+ """
158
+ config = get_config()
159
+
160
+ if not transcripts_dir.exists():
161
+ click.echo(click.style(f"Directory not found: {transcripts_dir}", fg="red"))
162
+ return
163
+
164
+ txt_files = sorted(
165
+ transcripts_dir.glob("*.txt"),
166
+ key=lambda p: p.stat().st_mtime,
167
+ reverse=True,
168
+ )
169
+
170
+ if not txt_files:
171
+ click.echo(click.style("No transcripts found.", fg="yellow"))
172
+ return
173
+
174
+ click.echo(f"\nTranscripts in {transcripts_dir}:\n")
175
+
176
+ for txt_file in txt_files[:limit]:
177
+ stat = txt_file.stat()
178
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
179
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
180
+
181
+ is_fav = config.is_favorite(txt_file.name)
182
+ star = click.style("★ ", fg="yellow") if is_fav else " "
183
+
184
+ click.echo(f"{star}{date_str} {txt_file.name}")
185
+
186
+ if len(txt_files) > limit:
187
+ click.echo(f"\n ... and {len(txt_files) - limit} more")
188
+
189
+ click.echo(f"\nTotal: {len(txt_files)} transcripts")