meeting-noter 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (48) hide show
  1. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/PKG-INFO +1 -1
  2. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/pyproject.toml +1 -1
  3. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/__init__.py +1 -1
  4. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/cli.py +129 -11
  5. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/config.py +34 -0
  6. meeting_noter-1.3.0/src/meeting_noter/output/favorites.py +189 -0
  7. meeting_noter-1.3.0/src/meeting_noter/output/searcher.py +218 -0
  8. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/transcription/live_transcription.py +17 -13
  9. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter.egg-info/PKG-INFO +1 -1
  10. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter.egg-info/SOURCES.txt +5 -1
  11. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/tests/test_cli.py +195 -0
  12. meeting_noter-1.3.0/tests/test_favorites.py +255 -0
  13. meeting_noter-1.3.0/tests/test_searcher.py +327 -0
  14. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/README.md +0 -0
  15. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/setup.cfg +0 -0
  16. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/__main__.py +0 -0
  17. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/audio/__init__.py +0 -0
  18. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/audio/capture.py +0 -0
  19. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/audio/encoder.py +0 -0
  20. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/audio/system_audio.py +0 -0
  21. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/daemon.py +0 -0
  22. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/install/__init__.py +0 -0
  23. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/install/macos.py +0 -0
  24. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/meeting_detector.py +0 -0
  25. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/mic_monitor.py +0 -0
  26. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/output/__init__.py +0 -0
  27. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/output/writer.py +0 -0
  28. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/__init__.py +0 -0
  29. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon.icns +0 -0
  30. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon.png +0 -0
  31. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon_128.png +0 -0
  32. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon_16.png +0 -0
  33. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon_256.png +0 -0
  34. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon_32.png +0 -0
  35. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon_512.png +0 -0
  36. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/resources/icon_64.png +0 -0
  37. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/transcription/__init__.py +0 -0
  38. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/transcription/engine.py +0 -0
  39. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter/update_checker.py +0 -0
  40. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter.egg-info/dependency_links.txt +0 -0
  41. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter.egg-info/entry_points.txt +0 -0
  42. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter.egg-info/requires.txt +0 -0
  43. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/src/meeting_noter.egg-info/top_level.txt +0 -0
  44. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/tests/test_config.py +0 -0
  45. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/tests/test_daemon.py +0 -0
  46. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/tests/test_meeting_detector.py +0 -0
  47. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/tests/test_mic_monitor.py +0 -0
  48. {meeting_noter-1.2.0 → meeting_noter-1.3.0}/tests/test_output_writer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meeting-noter"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Offline meeting transcription for macOS with automatic meeting detection"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,3 +1,3 @@
1
1
  """Meeting Noter - Offline meeting transcription with virtual audio devices."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -262,7 +262,12 @@ def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
262
262
 
263
263
  @cli.command()
264
264
  def status():
265
- """Show Meeting Noter status."""
265
+ """Show Meeting Noter status.
266
+
267
+ \b
268
+ Examples:
269
+ meeting-noter status # Check if recording or watching
270
+ """
266
271
  import os
267
272
  from meeting_noter.daemon import read_pid_file, is_process_running
268
273
 
@@ -331,7 +336,12 @@ def _get_current_recording_name() -> str | None:
331
336
 
332
337
  @cli.command()
333
338
  def shutdown():
334
- """Stop all Meeting Noter processes (daemon, watcher)."""
339
+ """Stop all Meeting Noter processes (daemon, watcher).
340
+
341
+ \b
342
+ Examples:
343
+ meeting-noter shutdown # Stop recording and watcher
344
+ """
335
345
  import subprocess
336
346
  import os
337
347
  import signal
@@ -372,7 +382,14 @@ def shutdown():
372
382
  @click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)")
373
383
  @click.option("--lines", "-n", default=50, help="Number of lines to show")
374
384
  def logs(follow: bool, lines: int):
375
- """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
+ """
376
393
  import subprocess
377
394
 
378
395
  log_file = Path.home() / ".meeting-noter.log"
@@ -392,6 +409,11 @@ def logs(follow: bool, lines: int):
392
409
 
393
410
 
394
411
  @cli.command("list")
412
+ @click.option(
413
+ "--transcripts", "-t",
414
+ is_flag=True,
415
+ help="List transcripts instead of recordings",
416
+ )
395
417
  @click.option(
396
418
  "--output-dir", "-o",
397
419
  type=click.Path(exists=True),
@@ -402,22 +424,118 @@ def logs(follow: bool, lines: int):
402
424
  "--limit", "-n",
403
425
  type=int,
404
426
  default=10,
405
- 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",
406
469
  )
407
470
  @require_setup
408
- def list_recordings(output_dir: Optional[str], limit: int):
409
- """List recent meeting recordings.
471
+ def search(query: str, case_sensitive: bool, limit: int, transcripts_dir: Optional[str]):
472
+ """Search across all meeting transcripts.
410
473
 
411
474
  \b
412
475
  Examples:
413
- meeting-noter list # Show last 10 recordings
414
- 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
415
479
  """
416
- from meeting_noter.output.writer import list_recordings as _list_recordings
480
+ from meeting_noter.output.searcher import search_transcripts
417
481
 
418
482
  config = get_config()
419
- path = Path(output_dir) if output_dir else config.recordings_dir
420
- _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)
421
539
 
422
540
 
423
541
  @cli.command()
@@ -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")
@@ -0,0 +1,218 @@
1
+ """Search functionality for meeting transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import click
7
+ from pathlib import Path
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class SearchMatch:
14
+ """A single match within a transcript file."""
15
+
16
+ line_number: int
17
+ line: str
18
+ timestamp: Optional[str] = None
19
+
20
+
21
+ @dataclass
22
+ class FileSearchResult:
23
+ """Search results for a single transcript file."""
24
+
25
+ filepath: Path
26
+ matches: list[SearchMatch]
27
+
28
+ @property
29
+ def match_count(self) -> int:
30
+ return len(self.matches)
31
+
32
+
33
+ def _extract_timestamp(line: str) -> Optional[str]:
34
+ """Extract timestamp from line if present (e.g., [05:32] or [01:23:45])."""
35
+ match = re.match(r"^\[(\d{1,2}:\d{2}(?::\d{2})?)\]", line.strip())
36
+ if match:
37
+ return match.group(1)
38
+ return None
39
+
40
+
41
+ def _truncate_line(line: str, max_length: int = 80) -> str:
42
+ """Truncate line to max length with ellipsis."""
43
+ line = line.strip()
44
+ if len(line) <= max_length:
45
+ return line
46
+ return line[: max_length - 3] + "..."
47
+
48
+
49
+ def _highlight_match(line: str, query: str, case_sensitive: bool) -> str:
50
+ """Highlight matching text in the line."""
51
+ if case_sensitive:
52
+ pattern = re.escape(query)
53
+ else:
54
+ pattern = re.compile(re.escape(query), re.IGNORECASE)
55
+
56
+ def replace_with_highlight(match):
57
+ return click.style(match.group(0), bold=True, fg="yellow")
58
+
59
+ if case_sensitive:
60
+ return re.sub(pattern, replace_with_highlight, line)
61
+ else:
62
+ return pattern.sub(replace_with_highlight, line)
63
+
64
+
65
+ def _search_file(
66
+ filepath: Path,
67
+ query: str,
68
+ case_sensitive: bool,
69
+ context_lines: int = 1,
70
+ ) -> Optional[FileSearchResult]:
71
+ """Search a single file for the query.
72
+
73
+ Returns FileSearchResult if matches found, None otherwise.
74
+ """
75
+ try:
76
+ content = filepath.read_text(encoding="utf-8", errors="ignore")
77
+ except Exception:
78
+ return None
79
+
80
+ lines = content.splitlines()
81
+ matches: list[SearchMatch] = []
82
+
83
+ search_query = query if case_sensitive else query.lower()
84
+
85
+ for i, line in enumerate(lines):
86
+ search_line = line if case_sensitive else line.lower()
87
+
88
+ if search_query in search_line:
89
+ timestamp = _extract_timestamp(line)
90
+ matches.append(
91
+ SearchMatch(
92
+ line_number=i + 1,
93
+ line=line,
94
+ timestamp=timestamp,
95
+ )
96
+ )
97
+
98
+ if matches:
99
+ return FileSearchResult(filepath=filepath, matches=matches)
100
+ return None
101
+
102
+
103
+ def search_transcripts(
104
+ transcripts_dir: Path,
105
+ query: str,
106
+ case_sensitive: bool = False,
107
+ limit: int = 20,
108
+ context_lines: int = 1,
109
+ ) -> None:
110
+ """Search across all meeting transcripts.
111
+
112
+ Args:
113
+ transcripts_dir: Directory containing transcript files
114
+ query: Search query string
115
+ case_sensitive: Whether to perform case-sensitive search
116
+ limit: Maximum number of matches to display
117
+ context_lines: Number of context lines around matches (not yet implemented)
118
+ """
119
+ if not query.strip():
120
+ click.echo(click.style("Error: Search query cannot be empty.", fg="red"))
121
+ return
122
+
123
+ if not transcripts_dir.exists():
124
+ click.echo(click.style(f"Directory not found: {transcripts_dir}", fg="red"))
125
+ return
126
+
127
+ # Find all transcript files
128
+ txt_files = sorted(
129
+ transcripts_dir.glob("*.txt"),
130
+ key=lambda p: p.stat().st_mtime,
131
+ reverse=True,
132
+ )
133
+
134
+ if not txt_files:
135
+ click.echo(click.style("No transcripts found.", fg="yellow"))
136
+ click.echo(f"\nTranscripts directory: {transcripts_dir}")
137
+ click.echo("Record and transcribe meetings to search them.")
138
+ return
139
+
140
+ # Search all files
141
+ results: list[FileSearchResult] = []
142
+ for txt_file in txt_files:
143
+ result = _search_file(txt_file, query, case_sensitive, context_lines)
144
+ if result:
145
+ results.append(result)
146
+
147
+ if not results:
148
+ click.echo(click.style(f'No results found for "{query}"', fg="yellow"))
149
+ click.echo(f"\nSearched {len(txt_files)} transcripts in {transcripts_dir}")
150
+ if not case_sensitive:
151
+ click.echo("Tip: Use --case-sensitive for exact matching.")
152
+ return
153
+
154
+ # Sort by match count (most matches first)
155
+ results.sort(key=lambda r: r.match_count, reverse=True)
156
+
157
+ # Count total matches
158
+ total_matches = sum(r.match_count for r in results)
159
+ total_files = len(results)
160
+
161
+ # Display header
162
+ click.echo()
163
+ matches_word = "match" if total_matches == 1 else "matches"
164
+ files_word = "transcript" if total_files == 1 else "transcripts"
165
+ click.echo(
166
+ click.style(
167
+ f"Found {total_matches} {matches_word} in {total_files} {files_word}:",
168
+ bold=True,
169
+ )
170
+ )
171
+ click.echo()
172
+
173
+ # Display results
174
+ matches_shown = 0
175
+ limit_reached = False
176
+ for result in results:
177
+ if matches_shown >= limit:
178
+ limit_reached = True
179
+ break
180
+
181
+ # File header
182
+ match_word = "match" if result.match_count == 1 else "matches"
183
+ click.echo(
184
+ click.style(f"{result.filepath.name}", fg="green", bold=True)
185
+ + f" ({result.match_count} {match_word})"
186
+ )
187
+
188
+ # Show matches (limited)
189
+ for match in result.matches:
190
+ if matches_shown >= limit:
191
+ limit_reached = True
192
+ break
193
+
194
+ # Format the line
195
+ prefix = f" [{match.timestamp}] " if match.timestamp else " "
196
+ line_text = match.line
197
+ if match.timestamp:
198
+ # Remove timestamp from line since we're showing it in prefix
199
+ line_text = re.sub(r"^\[\d{1,2}:\d{2}(?::\d{2})?\]\s*", "", line_text)
200
+
201
+ truncated = _truncate_line(line_text, 70)
202
+ highlighted = _highlight_match(truncated, query, case_sensitive)
203
+
204
+ click.echo(f"{prefix}...{highlighted}...")
205
+ matches_shown += 1
206
+
207
+ click.echo()
208
+
209
+ # Show remaining count if limit was reached
210
+ if limit_reached and matches_shown < total_matches:
211
+ remaining = total_matches - matches_shown
212
+ click.echo(
213
+ click.style(f"... and {remaining} more matches", fg="cyan")
214
+ )
215
+ click.echo()
216
+
217
+ # Footer
218
+ click.echo(f"Searched {len(txt_files)} transcripts in {transcripts_dir}")