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.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +190 -11
- meeting_noter/config.py +34 -0
- meeting_noter/daemon.py +38 -0
- meeting_noter/mic_monitor.py +29 -1
- meeting_noter/output/favorites.py +189 -0
- meeting_noter/output/searcher.py +218 -0
- meeting_noter/transcription/live_transcription.py +17 -13
- meeting_noter/ui/__init__.py +5 -0
- meeting_noter/ui/app.py +68 -0
- meeting_noter/ui/screens/__init__.py +17 -0
- meeting_noter/ui/screens/logs.py +166 -0
- meeting_noter/ui/screens/main.py +346 -0
- meeting_noter/ui/screens/recordings.py +241 -0
- meeting_noter/ui/screens/search.py +191 -0
- meeting_noter/ui/screens/settings.py +184 -0
- meeting_noter/ui/screens/viewer.py +116 -0
- meeting_noter/ui/styles/app.tcss +257 -0
- meeting_noter/ui/widgets/__init__.py +1 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/METADATA +2 -1
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/RECORD +24 -11
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/top_level.txt +0 -0
meeting_noter/__init__.py
CHANGED
meeting_noter/cli.py
CHANGED
|
@@ -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
|
|
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
|
|
409
|
-
"""
|
|
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
|
|
414
|
-
meeting-noter
|
|
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.
|
|
480
|
+
from meeting_noter.output.searcher import search_transcripts
|
|
417
481
|
|
|
418
482
|
config = get_config()
|
|
419
|
-
path = Path(
|
|
420
|
-
|
|
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()
|
|
@@ -686,6 +804,25 @@ def watcher(foreground: bool):
|
|
|
686
804
|
click.echo("Use 'meeting-noter shutdown' to stop.")
|
|
687
805
|
|
|
688
806
|
|
|
807
|
+
def _disable_app_nap():
|
|
808
|
+
"""Disable App Nap for this process to prevent macOS from throttling it.
|
|
809
|
+
|
|
810
|
+
App Nap can suspend background processes, causing meeting detection to fail
|
|
811
|
+
after long idle periods.
|
|
812
|
+
"""
|
|
813
|
+
try:
|
|
814
|
+
from Foundation import NSProcessInfo
|
|
815
|
+
info = NSProcessInfo.processInfo()
|
|
816
|
+
# Disable App Nap and sudden termination
|
|
817
|
+
# NSActivityUserInitiated keeps the process responsive
|
|
818
|
+
info.beginActivityWithOptions_reason_(
|
|
819
|
+
0x00FFFFFF, # NSActivityUserInitiatedAllowingIdleSystemSleep
|
|
820
|
+
"Meeting detection watcher"
|
|
821
|
+
)
|
|
822
|
+
except Exception:
|
|
823
|
+
pass # Not critical if this fails
|
|
824
|
+
|
|
825
|
+
|
|
689
826
|
def _run_watcher_loop():
|
|
690
827
|
"""Run the watcher loop (foreground)."""
|
|
691
828
|
import time
|
|
@@ -695,6 +832,9 @@ def _run_watcher_loop():
|
|
|
695
832
|
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
|
|
696
833
|
from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
|
|
697
834
|
|
|
835
|
+
# Disable App Nap to prevent macOS from throttling this process
|
|
836
|
+
_disable_app_nap()
|
|
837
|
+
|
|
698
838
|
# Write PID file
|
|
699
839
|
WATCHER_PID_FILE.write_text(str(os.getpid()))
|
|
700
840
|
atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
|
|
@@ -706,8 +846,23 @@ def _run_watcher_loop():
|
|
|
706
846
|
mic_monitor = MicrophoneMonitor()
|
|
707
847
|
current_meeting_name = None
|
|
708
848
|
|
|
849
|
+
# Heartbeat tracking - log every 30 minutes to confirm watcher is alive
|
|
850
|
+
last_heartbeat = time.time()
|
|
851
|
+
heartbeat_interval = 30 * 60 # 30 minutes
|
|
852
|
+
|
|
709
853
|
try:
|
|
710
854
|
while True:
|
|
855
|
+
# Periodic heartbeat to confirm watcher is running
|
|
856
|
+
now = time.time()
|
|
857
|
+
if now - last_heartbeat >= heartbeat_interval:
|
|
858
|
+
# Write to log file so user can verify watcher is alive
|
|
859
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
860
|
+
with open(log_path, "a") as f:
|
|
861
|
+
from datetime import datetime
|
|
862
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
863
|
+
f.write(f"{timestamp} Watcher heartbeat - still monitoring for meetings\n")
|
|
864
|
+
last_heartbeat = now
|
|
865
|
+
|
|
711
866
|
mic_started, mic_stopped, app_name = mic_monitor.check()
|
|
712
867
|
|
|
713
868
|
is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
|
|
@@ -872,6 +1027,30 @@ def open_folder(what: str):
|
|
|
872
1027
|
click.echo(f"Opened: {path}")
|
|
873
1028
|
|
|
874
1029
|
|
|
1030
|
+
@cli.command()
|
|
1031
|
+
def ui():
|
|
1032
|
+
"""Launch the Terminal User Interface.
|
|
1033
|
+
|
|
1034
|
+
Opens an interactive TUI for managing recordings, searching transcripts,
|
|
1035
|
+
and configuring settings.
|
|
1036
|
+
|
|
1037
|
+
\b
|
|
1038
|
+
Examples:
|
|
1039
|
+
meeting-noter ui # Launch the TUI
|
|
1040
|
+
|
|
1041
|
+
\b
|
|
1042
|
+
Keyboard shortcuts:
|
|
1043
|
+
1-5 Switch between screens
|
|
1044
|
+
q Quit
|
|
1045
|
+
? Help
|
|
1046
|
+
r Start/stop recording (on dashboard)
|
|
1047
|
+
"""
|
|
1048
|
+
from meeting_noter.ui import MeetingNoterApp
|
|
1049
|
+
|
|
1050
|
+
app = MeetingNoterApp()
|
|
1051
|
+
app.run()
|
|
1052
|
+
|
|
1053
|
+
|
|
875
1054
|
@cli.command()
|
|
876
1055
|
@click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
|
|
877
1056
|
def completion(shell: str):
|
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))
|
meeting_noter/daemon.py
CHANGED
|
@@ -174,6 +174,7 @@ def _run_capture_loop(
|
|
|
174
174
|
from meeting_noter.audio.encoder import RecordingSession
|
|
175
175
|
|
|
176
176
|
config = get_config()
|
|
177
|
+
saved_filepath = None # Track saved file for auto-transcription
|
|
177
178
|
|
|
178
179
|
# Live transcription (imported here to avoid loading Whisper before fork)
|
|
179
180
|
live_transcriber = None
|
|
@@ -306,6 +307,23 @@ def _run_capture_loop(
|
|
|
306
307
|
filepath, duration = session.stop()
|
|
307
308
|
if filepath:
|
|
308
309
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
310
|
+
saved_filepath = filepath
|
|
311
|
+
# Auto-transcribe if enabled
|
|
312
|
+
if config.auto_transcribe:
|
|
313
|
+
print("Auto-transcribing...")
|
|
314
|
+
sys.stdout.flush()
|
|
315
|
+
try:
|
|
316
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
317
|
+
transcribe_file(
|
|
318
|
+
str(filepath),
|
|
319
|
+
output_dir,
|
|
320
|
+
config.whisper_model,
|
|
321
|
+
config.transcripts_dir,
|
|
322
|
+
)
|
|
323
|
+
print("Transcription complete.")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
print(f"Transcription error: {e}")
|
|
326
|
+
sys.stdout.flush()
|
|
309
327
|
else:
|
|
310
328
|
print("Recording discarded (too short)")
|
|
311
329
|
silence_detector.reset()
|
|
@@ -313,6 +331,8 @@ def _run_capture_loop(
|
|
|
313
331
|
|
|
314
332
|
except Exception as e:
|
|
315
333
|
print(f"Error in capture loop: {e}")
|
|
334
|
+
import sys
|
|
335
|
+
sys.stdout.flush()
|
|
316
336
|
finally:
|
|
317
337
|
capture.stop()
|
|
318
338
|
|
|
@@ -325,6 +345,24 @@ def _run_capture_loop(
|
|
|
325
345
|
filepath, duration = session.stop()
|
|
326
346
|
if filepath:
|
|
327
347
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
348
|
+
saved_filepath = filepath
|
|
349
|
+
# Auto-transcribe if enabled
|
|
350
|
+
if config.auto_transcribe:
|
|
351
|
+
print("Auto-transcribing...")
|
|
352
|
+
import sys
|
|
353
|
+
sys.stdout.flush()
|
|
354
|
+
try:
|
|
355
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
356
|
+
transcribe_file(
|
|
357
|
+
str(filepath),
|
|
358
|
+
output_dir,
|
|
359
|
+
config.whisper_model,
|
|
360
|
+
config.transcripts_dir,
|
|
361
|
+
)
|
|
362
|
+
print("Transcription complete.")
|
|
363
|
+
except Exception as e:
|
|
364
|
+
print(f"Transcription error: {e}")
|
|
365
|
+
sys.stdout.flush()
|
|
328
366
|
|
|
329
367
|
print("Daemon stopped.")
|
|
330
368
|
|
meeting_noter/mic_monitor.py
CHANGED
|
@@ -36,11 +36,21 @@ class _AudioObjectPropertyAddress(Structure):
|
|
|
36
36
|
_core_audio = None
|
|
37
37
|
_AudioObjectGetPropertyDataSize = None
|
|
38
38
|
_AudioObjectGetPropertyData = None
|
|
39
|
+
_coreaudio_init_time = 0 # Track when CoreAudio was initialized
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reset_coreaudio():
|
|
43
|
+
"""Reset CoreAudio framework (for reinitializing after long idle)."""
|
|
44
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
|
|
45
|
+
_core_audio = None
|
|
46
|
+
_AudioObjectGetPropertyDataSize = None
|
|
47
|
+
_AudioObjectGetPropertyData = None
|
|
48
|
+
_coreaudio_init_time = 0
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def _init_coreaudio():
|
|
42
52
|
"""Initialize CoreAudio framework."""
|
|
43
|
-
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData
|
|
53
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
|
|
44
54
|
|
|
45
55
|
if _core_audio is not None:
|
|
46
56
|
return True
|
|
@@ -60,11 +70,26 @@ def _init_coreaudio():
|
|
|
60
70
|
]
|
|
61
71
|
_AudioObjectGetPropertyData.restype = c_int32
|
|
62
72
|
|
|
73
|
+
_coreaudio_init_time = time.time()
|
|
63
74
|
return True
|
|
64
75
|
except Exception:
|
|
65
76
|
return False
|
|
66
77
|
|
|
67
78
|
|
|
79
|
+
# Reinitialize CoreAudio every 30 minutes to prevent stale handles
|
|
80
|
+
_COREAUDIO_REFRESH_INTERVAL = 30 * 60
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _maybe_refresh_coreaudio():
|
|
84
|
+
"""Reinitialize CoreAudio if it's been too long since last init.
|
|
85
|
+
|
|
86
|
+
This prevents stale CoreAudio handles after system sleep or long idle.
|
|
87
|
+
"""
|
|
88
|
+
global _coreaudio_init_time
|
|
89
|
+
if _core_audio is not None and time.time() - _coreaudio_init_time > _COREAUDIO_REFRESH_INTERVAL:
|
|
90
|
+
_reset_coreaudio()
|
|
91
|
+
|
|
92
|
+
|
|
68
93
|
def is_mic_in_use_by_another_app() -> bool:
|
|
69
94
|
"""Check if the microphone is being used by another application.
|
|
70
95
|
|
|
@@ -74,6 +99,9 @@ def is_mic_in_use_by_another_app() -> bool:
|
|
|
74
99
|
Returns:
|
|
75
100
|
True if another app is using the microphone
|
|
76
101
|
"""
|
|
102
|
+
# Refresh CoreAudio if it's been too long (prevents stale handles)
|
|
103
|
+
_maybe_refresh_coreaudio()
|
|
104
|
+
|
|
77
105
|
if not _init_coreaudio():
|
|
78
106
|
return False
|
|
79
107
|
|
|
@@ -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")
|