meeting-noter 3.0.1__py3-none-any.whl → 3.1.1__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__ = "3.0.1"
3
+ __version__ = "3.1.1"
meeting_noter/cli.py CHANGED
@@ -445,7 +445,73 @@ def list_recordings(transcripts: bool, output_dir: Optional[str], limit: int):
445
445
  else:
446
446
  from meeting_noter.output.writer import list_recordings as _list_recordings
447
447
  path = Path(output_dir) if output_dir else config.recordings_dir
448
- _list_recordings(path, limit)
448
+ _list_recordings(path, limit, config)
449
+
450
+
451
+ @cli.command("rename")
452
+ @click.argument("file", required=False)
453
+ @click.option(
454
+ "--name", "-n",
455
+ required=True,
456
+ help="New meeting name",
457
+ )
458
+ @require_setup
459
+ def rename(file: Optional[str], name: str):
460
+ """Rename a meeting recording and its transcript.
461
+
462
+ FILE is the recording filename (optional). If not provided,
463
+ renames the most recent recording.
464
+
465
+ \b
466
+ Examples:
467
+ meeting-noter rename --name "Weekly Standup"
468
+ meeting-noter rename "2025-01-29_143015.mp3" --name "Team Meeting"
469
+ mn rename -n "Sprint Planning"
470
+ """
471
+ from meeting_noter.output.writer import rename_meeting
472
+
473
+ config = get_config()
474
+ recordings_dir = config.recordings_dir
475
+
476
+ # Find the file to rename
477
+ if file:
478
+ # Try as absolute path first, then relative to recordings dir
479
+ file_path = Path(file)
480
+ if not file_path.exists():
481
+ file_path = recordings_dir / file
482
+ if not file_path.exists():
483
+ click.echo(click.style(f"Error: File not found: {file}", fg="red"))
484
+ raise SystemExit(1)
485
+ else:
486
+ # Find most recent recording
487
+ mp3_files = sorted(
488
+ recordings_dir.glob("*.mp3"),
489
+ key=lambda p: p.stat().st_mtime,
490
+ reverse=True,
491
+ )
492
+ if not mp3_files:
493
+ click.echo(click.style("No recordings found.", fg="red"))
494
+ raise SystemExit(1)
495
+ file_path = mp3_files[0]
496
+ click.echo(f"Renaming most recent recording: {file_path.name}")
497
+
498
+ try:
499
+ new_path = rename_meeting(file_path, name, config)
500
+ click.echo(click.style("Renamed successfully:", fg="green"))
501
+ click.echo(f" {file_path.name} → {new_path.name}")
502
+
503
+ # Check if transcript was also renamed
504
+ old_transcript = file_path.with_suffix(".txt")
505
+ new_transcript = new_path.with_suffix(".txt")
506
+ if new_transcript.exists():
507
+ click.echo(f" {old_transcript.name} → {new_transcript.name}")
508
+
509
+ except FileExistsError as e:
510
+ click.echo(click.style(f"Error: {e}", fg="red"))
511
+ raise SystemExit(1)
512
+ except PermissionError:
513
+ click.echo(click.style("Error: Permission denied. Cannot rename files.", fg="red"))
514
+ raise SystemExit(1)
449
515
 
450
516
 
451
517
  @cli.command("search")
@@ -538,6 +604,151 @@ def favorites_remove(filename: str):
538
604
  remove_favorite(filename)
539
605
 
540
606
 
607
+ @cli.group("tags", invoke_without_command=True)
608
+ @click.pass_context
609
+ @require_setup
610
+ def tags(ctx):
611
+ """Manage tags for recordings.
612
+
613
+ \b
614
+ Examples:
615
+ meeting-noter tags # List all unique tags
616
+ meeting-noter tags list file.mp3 # List tags for a file
617
+ meeting-noter tags add file.mp3 "Project X"
618
+ meeting-noter tags remove file.mp3 "Project X"
619
+ meeting-noter tags search "Project X"
620
+ """
621
+ if ctx.invoked_subcommand is None:
622
+ # Show all unique tags
623
+ config = get_config()
624
+ all_tags = config.get_all_tags()
625
+ if all_tags:
626
+ click.echo("\nAll tags:")
627
+ for tag in sorted(all_tags):
628
+ files = config.get_files_by_tag(tag)
629
+ click.echo(f" • {tag} ({len(files)} recording{'s' if len(files) != 1 else ''})")
630
+ else:
631
+ click.echo("No tags found.")
632
+ click.echo("\nAdd tags with: meeting-noter tags add <file> <tag>")
633
+
634
+
635
+ @tags.command("list")
636
+ @click.argument("file")
637
+ @require_setup
638
+ def tags_list(file: str):
639
+ """List tags for a specific recording.
640
+
641
+ \b
642
+ Examples:
643
+ meeting-noter tags list recording.mp3
644
+ mn tags list recording.mp3
645
+ """
646
+ config = get_config()
647
+
648
+ # Find the file
649
+ file_path = Path(file)
650
+ if not file_path.exists():
651
+ file_path = config.recordings_dir / file
652
+ if not file_path.exists():
653
+ click.echo(click.style(f"File not found: {file}", fg="red"))
654
+ raise SystemExit(1)
655
+
656
+ file_tags = config.get_tags(file_path.name)
657
+ if file_tags:
658
+ click.echo(f"\nTags for {file_path.name}:")
659
+ for tag in file_tags:
660
+ click.echo(f" • {tag}")
661
+ else:
662
+ click.echo(f"No tags for {file_path.name}")
663
+ click.echo(f"\nAdd tags with: meeting-noter tags add {file_path.name} <tag>")
664
+
665
+
666
+ @tags.command("add")
667
+ @click.argument("file")
668
+ @click.argument("tag")
669
+ @require_setup
670
+ def tags_add(file: str, tag: str):
671
+ """Add a tag to a recording.
672
+
673
+ \b
674
+ Examples:
675
+ meeting-noter tags add recording.mp3 "Project X"
676
+ meeting-noter tags add recording.mp3 "Sprint Planning"
677
+ mn tags add recording.mp3 "Q1 Review"
678
+ """
679
+ config = get_config()
680
+
681
+ # Find the file
682
+ file_path = Path(file)
683
+ if not file_path.exists():
684
+ file_path = config.recordings_dir / file
685
+ if not file_path.exists():
686
+ click.echo(click.style(f"File not found: {file}", fg="red"))
687
+ raise SystemExit(1)
688
+
689
+ if config.add_tag(file_path.name, tag):
690
+ click.echo(click.style(f"Added tag '{tag}' to {file_path.name}", fg="green"))
691
+ else:
692
+ click.echo(f"Tag '{tag}' already exists on {file_path.name}")
693
+
694
+
695
+ @tags.command("remove")
696
+ @click.argument("file")
697
+ @click.argument("tag")
698
+ @require_setup
699
+ def tags_remove(file: str, tag: str):
700
+ """Remove a tag from a recording.
701
+
702
+ \b
703
+ Examples:
704
+ meeting-noter tags remove recording.mp3 "Project X"
705
+ """
706
+ config = get_config()
707
+
708
+ # Find the file
709
+ file_path = Path(file)
710
+ if not file_path.exists():
711
+ file_path = config.recordings_dir / file
712
+ if not file_path.exists():
713
+ click.echo(click.style(f"File not found: {file}", fg="red"))
714
+ raise SystemExit(1)
715
+
716
+ if config.remove_tag(file_path.name, tag):
717
+ click.echo(click.style(f"Removed tag '{tag}' from {file_path.name}", fg="green"))
718
+ else:
719
+ click.echo(f"Tag '{tag}' not found on {file_path.name}")
720
+
721
+
722
+ @tags.command("search")
723
+ @click.argument("tag")
724
+ @require_setup
725
+ def tags_search(tag: str):
726
+ """Search recordings by tag.
727
+
728
+ \b
729
+ Examples:
730
+ meeting-noter tags search "Project X"
731
+ mn tags search "Sprint Planning"
732
+ """
733
+ config = get_config()
734
+ files = config.get_files_by_tag(tag)
735
+
736
+ if files:
737
+ click.echo(f"\nRecordings with tag '{tag}':\n")
738
+ for filename in sorted(files):
739
+ file_path = config.recordings_dir / filename
740
+ if file_path.exists():
741
+ stat = file_path.stat()
742
+ from datetime import datetime
743
+ date = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
744
+ click.echo(f" {date} {filename}")
745
+ else:
746
+ click.echo(f" (missing) {filename}")
747
+ click.echo(f"\nTotal: {len(files)} recording{'s' if len(files) != 1 else ''}")
748
+ else:
749
+ click.echo(f"No recordings found with tag '{tag}'")
750
+
751
+
541
752
  @cli.command()
542
753
  @click.argument("file", required=False)
543
754
  @click.option(
meeting_noter/config.py CHANGED
@@ -194,6 +194,64 @@ class Config:
194
194
  """Check if a transcript is a favorite."""
195
195
  return filename in self.favorites
196
196
 
197
+ @property
198
+ def tags(self) -> dict[str, list[str]]:
199
+ """Get tags dictionary mapping filename to list of tags."""
200
+ if "tags" not in self._data:
201
+ self._data["tags"] = {}
202
+ return self._data["tags"]
203
+
204
+ @tags.setter
205
+ def tags(self, value: dict[str, list[str]]) -> None:
206
+ """Set tags dictionary."""
207
+ self._data["tags"] = value
208
+
209
+ def add_tag(self, filename: str, tag: str) -> bool:
210
+ """Add a tag to a recording. Returns True if added, False if already exists."""
211
+ tags = self.tags # Now returns the actual dict from _data
212
+ if filename not in tags:
213
+ tags[filename] = []
214
+ tag = tag.strip()
215
+ if tag and tag not in tags[filename]:
216
+ tags[filename].append(tag)
217
+ self.save()
218
+ return True
219
+ return False
220
+
221
+ def remove_tag(self, filename: str, tag: str) -> bool:
222
+ """Remove a tag from a recording. Returns True if removed, False if not found."""
223
+ tags = self.tags # Now returns the actual dict from _data
224
+ if filename in tags and tag in tags[filename]:
225
+ tags[filename].remove(tag)
226
+ if not tags[filename]:
227
+ del tags[filename]
228
+ self.save()
229
+ return True
230
+ return False
231
+
232
+ def get_tags(self, filename: str) -> list[str]:
233
+ """Get tags for a recording."""
234
+ return self.tags.get(filename, [])
235
+
236
+ def get_all_tags(self) -> set[str]:
237
+ """Get all unique tags across all recordings."""
238
+ all_tags: set[str] = set()
239
+ for tag_list in self.tags.values():
240
+ all_tags.update(tag_list)
241
+ return all_tags
242
+
243
+ def get_files_by_tag(self, tag: str) -> list[str]:
244
+ """Get all filenames that have a specific tag."""
245
+ return [filename for filename, tag_list in self.tags.items() if tag in tag_list]
246
+
247
+ def rename_file_tags(self, old_filename: str, new_filename: str) -> None:
248
+ """Update tags when a file is renamed."""
249
+ tags = self.tags
250
+ if old_filename in tags:
251
+ tags[new_filename] = tags.pop(old_filename)
252
+ self.tags = tags
253
+ self.save()
254
+
197
255
  def __getitem__(self, key: str) -> Any:
198
256
  """Get config value by key."""
199
257
  return self._data.get(key, DEFAULT_CONFIG.get(key))
@@ -14,7 +14,9 @@ from PySide6.QtWidgets import (
14
14
  QAbstractItemView,
15
15
  QHBoxLayout,
16
16
  QHeaderView,
17
+ QInputDialog,
17
18
  QLabel,
19
+ QMessageBox,
18
20
  QPushButton,
19
21
  QTableWidget,
20
22
  QTableWidgetItem,
@@ -37,6 +39,7 @@ class RecordingInfo:
37
39
  size: int
38
40
  has_transcript: bool
39
41
  is_favorite: bool
42
+ tags: list[str]
40
43
 
41
44
 
42
45
  class RecordingsScreen(QWidget):
@@ -61,9 +64,9 @@ class RecordingsScreen(QWidget):
61
64
 
62
65
  # Table
63
66
  self._table = QTableWidget()
64
- self._table.setColumnCount(6)
67
+ self._table.setColumnCount(7)
65
68
  self._table.setHorizontalHeaderLabels(
66
- ["\u2605", "Date", "Duration", "Size", "Transcript", "File"]
69
+ ["\u2605", "Date", "Duration", "Size", "Transcript", "Tags", "File"]
67
70
  )
68
71
  self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
69
72
  self._table.setSelectionMode(QAbstractItemView.SingleSelection)
@@ -79,7 +82,8 @@ class RecordingsScreen(QWidget):
79
82
  header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
80
83
  header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
81
84
  header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
82
- header.setSectionResizeMode(5, QHeaderView.Stretch)
85
+ header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
86
+ header.setSectionResizeMode(6, QHeaderView.Stretch)
83
87
 
84
88
  self._table.doubleClicked.connect(self._on_row_double_clicked)
85
89
  layout.addWidget(self._table, 1)
@@ -90,6 +94,8 @@ class RecordingsScreen(QWidget):
90
94
 
91
95
  self._view_btn = QPushButton("View Transcript")
92
96
  self._transcribe_btn = QPushButton("Transcribe")
97
+ self._rename_btn = QPushButton("Rename")
98
+ self._tags_btn = QPushButton("Tags")
93
99
  self._favorite_btn = QPushButton("\u2605 Favorite")
94
100
  self._refresh_btn = QPushButton("Refresh")
95
101
  self._refresh_btn.setProperty("class", "secondary")
@@ -97,6 +103,8 @@ class RecordingsScreen(QWidget):
97
103
 
98
104
  button_row.addWidget(self._view_btn)
99
105
  button_row.addWidget(self._transcribe_btn)
106
+ button_row.addWidget(self._rename_btn)
107
+ button_row.addWidget(self._tags_btn)
100
108
  button_row.addWidget(self._favorite_btn)
101
109
  button_row.addStretch()
102
110
  button_row.addWidget(self._refresh_btn)
@@ -111,6 +119,8 @@ class RecordingsScreen(QWidget):
111
119
  """Connect button signals."""
112
120
  self._view_btn.clicked.connect(self._view_transcript)
113
121
  self._transcribe_btn.clicked.connect(self._transcribe_selected)
122
+ self._rename_btn.clicked.connect(self._rename_selected)
123
+ self._tags_btn.clicked.connect(self._manage_tags)
114
124
  self._favorite_btn.clicked.connect(self._toggle_favorite)
115
125
  self._refresh_btn.clicked.connect(self.refresh)
116
126
 
@@ -155,6 +165,9 @@ class RecordingsScreen(QWidget):
155
165
  # Check favorite status
156
166
  is_favorite = config.is_favorite(mp3_path.name)
157
167
 
168
+ # Get tags
169
+ file_tags = config.get_tags(mp3_path.name)
170
+
158
171
  recordings.append(
159
172
  RecordingInfo(
160
173
  filepath=mp3_path,
@@ -163,6 +176,7 @@ class RecordingsScreen(QWidget):
163
176
  size=size,
164
177
  has_transcript=has_transcript,
165
178
  is_favorite=is_favorite,
179
+ tags=file_tags,
166
180
  )
167
181
  )
168
182
  except Exception:
@@ -206,9 +220,15 @@ class RecordingsScreen(QWidget):
206
220
  transcript_item.setForeground(Qt.green)
207
221
  self._table.setItem(row, 4, transcript_item)
208
222
 
223
+ # Tags
224
+ tags_str = ", ".join(rec.tags) if rec.tags else ""
225
+ tags_item = QTableWidgetItem(tags_str)
226
+ tags_item.setForeground(Qt.cyan)
227
+ self._table.setItem(row, 5, tags_item)
228
+
209
229
  # Filename
210
230
  file_item = QTableWidgetItem(rec.filepath.name)
211
- self._table.setItem(row, 5, file_item)
231
+ self._table.setItem(row, 6, file_item)
212
232
 
213
233
  self._status_label.setText(f"{len(self._recordings)} recordings")
214
234
 
@@ -277,3 +297,106 @@ class RecordingsScreen(QWidget):
277
297
 
278
298
  # Refresh to update star display
279
299
  self.refresh()
300
+
301
+ def _rename_selected(self):
302
+ """Rename the selected recording."""
303
+ rec = self._get_selected_recording()
304
+ if not rec:
305
+ self._status_label.setText("No recording selected.")
306
+ return
307
+
308
+ # Get current name (without timestamp prefix and extension)
309
+ from meeting_noter.output.writer import extract_timestamp_prefix
310
+
311
+ stem = rec.filepath.stem
312
+ timestamp = extract_timestamp_prefix(stem)
313
+ if timestamp:
314
+ # Extract the name part after the timestamp
315
+ current_name = stem[len(timestamp) + 1:] if len(stem) > len(timestamp) else ""
316
+ else:
317
+ current_name = stem
318
+
319
+ # Replace underscores with spaces for display
320
+ current_name = current_name.replace("_", " ")
321
+
322
+ # Show input dialog
323
+ new_name, ok = QInputDialog.getText(
324
+ self,
325
+ "Rename Meeting",
326
+ "Enter new meeting name:",
327
+ text=current_name,
328
+ )
329
+
330
+ if not ok or not new_name.strip():
331
+ return
332
+
333
+ new_name = new_name.strip()
334
+
335
+ # Perform the rename
336
+ from meeting_noter.output.writer import rename_meeting
337
+
338
+ config = get_config()
339
+ try:
340
+ new_path = rename_meeting(rec.filepath, new_name, config)
341
+ self._status_label.setText(f"Renamed to: {new_path.name}")
342
+ self.refresh()
343
+ except FileExistsError:
344
+ QMessageBox.warning(
345
+ self,
346
+ "Rename Failed",
347
+ f"A file with that name already exists.",
348
+ )
349
+ except PermissionError:
350
+ QMessageBox.warning(
351
+ self,
352
+ "Rename Failed",
353
+ "Permission denied. Cannot rename files.",
354
+ )
355
+ except Exception as e:
356
+ QMessageBox.warning(
357
+ self,
358
+ "Rename Failed",
359
+ f"Error renaming file: {e}",
360
+ )
361
+
362
+ def _manage_tags(self):
363
+ """Manage tags for the selected recording."""
364
+ rec = self._get_selected_recording()
365
+ if not rec:
366
+ self._status_label.setText("No recording selected.")
367
+ return
368
+
369
+ config = get_config()
370
+ current_tags = config.get_tags(rec.filepath.name)
371
+ current_tags_str = ", ".join(current_tags) if current_tags else ""
372
+
373
+ # Show input dialog with current tags
374
+ new_tags_str, ok = QInputDialog.getText(
375
+ self,
376
+ "Manage Tags",
377
+ "Enter tags (comma-separated):\n\nExample: Project X, Sprint Planning, Q1 Review",
378
+ text=current_tags_str,
379
+ )
380
+
381
+ if not ok:
382
+ return
383
+
384
+ # Parse new tags
385
+ new_tags = [tag.strip() for tag in new_tags_str.split(",") if tag.strip()]
386
+
387
+ # Remove old tags that are no longer present
388
+ for old_tag in current_tags:
389
+ if old_tag not in new_tags:
390
+ config.remove_tag(rec.filepath.name, old_tag)
391
+
392
+ # Add new tags
393
+ for new_tag in new_tags:
394
+ if new_tag not in current_tags:
395
+ config.add_tag(rec.filepath.name, new_tag)
396
+
397
+ if new_tags:
398
+ self._status_label.setText(f"Tags updated: {', '.join(new_tags)}")
399
+ else:
400
+ self._status_label.setText("Tags cleared")
401
+
402
+ self.refresh()
@@ -2,10 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import click
6
7
  from pathlib import Path
7
8
  from datetime import datetime
8
- from typing import Optional
9
+ from typing import Optional, TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from meeting_noter.config import Config
9
13
 
10
14
 
11
15
  def format_duration(seconds: float) -> str:
@@ -45,7 +49,7 @@ def get_audio_duration(filepath: Path) -> Optional[float]:
45
49
  return None
46
50
 
47
51
 
48
- def list_recordings(output_dir: Path, limit: int = 10):
52
+ def list_recordings(output_dir: Path, limit: int = 10, config: Optional["Config"] = None):
49
53
  """List recent meeting recordings."""
50
54
  if not output_dir.exists():
51
55
  click.echo(click.style(f"Directory not found: {output_dir}", fg="red"))
@@ -89,8 +93,137 @@ def list_recordings(output_dir: Path, limit: int = 10):
89
93
 
90
94
  click.echo(f" {date_str:<20} {duration_str:<12} {size_str:<10} {has_transcript:<12} {mp3.name}")
91
95
 
96
+ # Show tags if config is provided and file has tags
97
+ if config:
98
+ file_tags = config.get_tags(mp3.name)
99
+ if file_tags:
100
+ tags_str = ", ".join(file_tags)
101
+ click.echo(click.style(f" Tags: {tags_str}", fg="cyan"))
102
+
92
103
  if len(mp3_files) > limit:
93
104
  click.echo(f"\n ... and {len(mp3_files) - limit} more recordings")
94
105
 
95
106
  click.echo(f"\nTotal: {len(mp3_files)} recordings")
96
107
  click.echo("\nTo transcribe: meeting-noter transcribe [filename]")
108
+
109
+
110
+ def sanitize_filename(name: str, max_length: int = 50) -> str:
111
+ """Sanitize a string for use as a filename.
112
+
113
+ Args:
114
+ name: The name to sanitize
115
+ max_length: Maximum length for the sanitized name
116
+
117
+ Returns:
118
+ A safe filename string with spaces replaced by underscores
119
+ """
120
+ # Replace spaces with underscores
121
+ name = name.replace(" ", "_")
122
+ # Remove any character that isn't alphanumeric, underscore, or hyphen
123
+ name = re.sub(r"[^\w\-]", "", name)
124
+ # Truncate to max length
125
+ if len(name) > max_length:
126
+ name = name[:max_length]
127
+ # Remove trailing underscores/hyphens
128
+ name = name.rstrip("_-")
129
+ return name
130
+
131
+
132
+ def extract_timestamp_prefix(filename: str) -> Optional[str]:
133
+ """Extract the timestamp prefix from a recording filename.
134
+
135
+ Handles formats like:
136
+ - 2025-01-29_143015_Meeting_Name.mp3 -> 2025-01-29_143015
137
+ - 2025-01-29_143015.mp3 -> 2025-01-29_143015
138
+ - 29_Jan_2026_1430_Meeting_Name.mp3 -> 29_Jan_2026_1430
139
+
140
+ Returns:
141
+ The timestamp prefix or None if not found
142
+ """
143
+ # Pattern 1: YYYY-MM-DD_HHMMSS format
144
+ match = re.match(r"^(\d{4}-\d{2}-\d{2}_\d{6})", filename)
145
+ if match:
146
+ return match.group(1)
147
+
148
+ # Pattern 2: DD_Mon_YYYY_HHMM format
149
+ match = re.match(r"^(\d{2}_[A-Z][a-z]{2}_\d{4}_\d{4})", filename)
150
+ if match:
151
+ return match.group(1)
152
+
153
+ return None
154
+
155
+
156
+ def rename_meeting(
157
+ old_path: Path,
158
+ new_name: str,
159
+ config: "Config",
160
+ ) -> Path:
161
+ """Rename a meeting recording and its associated transcript.
162
+
163
+ Args:
164
+ old_path: Path to the current MP3 file
165
+ new_name: New meeting name (will be sanitized)
166
+ config: Config object for updating favorites
167
+
168
+ Returns:
169
+ Path to the renamed MP3 file
170
+
171
+ Raises:
172
+ FileNotFoundError: If the recording doesn't exist
173
+ FileExistsError: If the new filename already exists
174
+ PermissionError: If unable to rename files
175
+ """
176
+ if not old_path.exists():
177
+ raise FileNotFoundError(f"Recording not found: {old_path}")
178
+
179
+ # Extract timestamp prefix from old filename
180
+ old_stem = old_path.stem
181
+ timestamp = extract_timestamp_prefix(old_stem)
182
+
183
+ # Build new filename
184
+ sanitized_name = sanitize_filename(new_name)
185
+ if timestamp:
186
+ new_stem = f"{timestamp}_{sanitized_name}"
187
+ else:
188
+ # No timestamp found, just use the sanitized name
189
+ new_stem = sanitized_name
190
+
191
+ new_mp3_path = old_path.parent / f"{new_stem}.mp3"
192
+
193
+ # Check if target already exists
194
+ if new_mp3_path.exists() and new_mp3_path != old_path:
195
+ raise FileExistsError(f"File already exists: {new_mp3_path}")
196
+
197
+ # Rename MP3 file
198
+ old_path.rename(new_mp3_path)
199
+
200
+ # Rename transcript if it exists (check both same dir and transcripts_dir)
201
+ old_transcript = old_path.with_suffix(".txt")
202
+ if old_transcript.exists():
203
+ new_transcript = new_mp3_path.with_suffix(".txt")
204
+ old_transcript.rename(new_transcript)
205
+
206
+ # Also check transcripts_dir if different from recordings dir
207
+ transcripts_dir = config.transcripts_dir
208
+ if transcripts_dir != old_path.parent:
209
+ old_transcript_in_dir = transcripts_dir / f"{old_stem}.txt"
210
+ if old_transcript_in_dir.exists():
211
+ new_transcript_in_dir = transcripts_dir / f"{new_stem}.txt"
212
+ old_transcript_in_dir.rename(new_transcript_in_dir)
213
+
214
+ # Rename live transcript if it exists
215
+ live_dir = old_path.parent / "live"
216
+ old_live = live_dir / f"{old_stem}.live.txt"
217
+ if old_live.exists():
218
+ new_live = live_dir / f"{new_stem}.live.txt"
219
+ old_live.rename(new_live)
220
+
221
+ # Update favorites if needed
222
+ if config.is_favorite(old_path.name):
223
+ config.remove_favorite(old_path.name)
224
+ config.add_favorite(new_mp3_path.name)
225
+
226
+ # Update tags if needed
227
+ config.rename_file_tags(old_path.name, new_mp3_path.name)
228
+
229
+ return new_mp3_path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 3.0.1
3
+ Version: 3.1.1
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -1,7 +1,7 @@
1
- meeting_noter/__init__.py,sha256=tJJj2S_obRUM6T2U9g2hFRONK0a443TOCfvgpTZDlYo,103
1
+ meeting_noter/__init__.py,sha256=7NjAkxDn5PvZ_baFmrb-ftDo9SBS16h25wdRYwBK970,103
2
2
  meeting_noter/__main__.py,sha256=6sSOqH1o3jvgvkVzsVKmF6-xVGcUAbNVQkRl2CrygdE,120
3
- meeting_noter/cli.py,sha256=IZTqVGcdJKvnKjQlMaMq41qAM1TswqHREju4Xbk8wbA,38148
4
- meeting_noter/config.py,sha256=vdnTh6W6-DUPJCj0ekFu1Q1m87O7gx0QD3E5DrRGpXk,7537
3
+ meeting_noter/cli.py,sha256=x8Ut_H-4ZAqHFjpIv165ZmJenPUdWXs8L6t7O6sTXls,44791
4
+ meeting_noter/config.py,sha256=byIveLb6bPzULuEFmu5e6KDVLJxHIPH64NmwlyXGy1A,9697
5
5
  meeting_noter/daemon.py,sha256=i81IXgEnsoAoMewknySO5_ECa1nCGOsVLfXtJXJf0rw,21611
6
6
  meeting_noter/meeting_detector.py,sha256=St0qoMkvUERP4BaxnXO1M6fZDJpWqBf9In7z2SgWcWg,10564
7
7
  meeting_noter/mic_monitor.py,sha256=SIsgb8X6uhYuA0CgsRhTjYi8aA-oSUm9vL0r_kQki3g,17239
@@ -16,7 +16,7 @@ meeting_noter/gui/menubar.py,sha256=hyqm74Vp1I0eB57yzvYEvtzk4y-Y5GRCZau-erGYr_I,
16
16
  meeting_noter/gui/screens/__init__.py,sha256=_ETRs61OD0xm_FkKTvhv286hpSEsmeZ8v15bKUSUs8Y,549
17
17
  meeting_noter/gui/screens/dashboard.py,sha256=_5VzZuh4lUDKoW9iGdM9YZZAqliZtK8azYagI6zFimg,9492
18
18
  meeting_noter/gui/screens/logs.py,sha256=acqNhx0H2emVDQ4uCrhkVxbExG58Z99mUrMySF321xI,5746
19
- meeting_noter/gui/screens/recordings.py,sha256=rQ7_hLv2uq-qyFTlmnR1NuH9h0Yp5u7rh0o6Gv1Vfw8,9500
19
+ meeting_noter/gui/screens/recordings.py,sha256=uNSATrRhTsOF5vvSTAhDO6WKl_zjx4q4jWkMDJ0o9oI,13617
20
20
  meeting_noter/gui/screens/search.py,sha256=emdfAGQR3rhtrbj5rfCYjgqPY8i6gzUO4z1wZ6naFsA,7345
21
21
  meeting_noter/gui/screens/settings.py,sha256=u-L0l6I6h25Tp2PIBQW8KipKEBc2VYGsxrXwEENLJxU,8016
22
22
  meeting_noter/gui/screens/viewer.py,sha256=TEVCO1tcYVO1li1pt846fKx5VxTxk-lWywnoBV2XuUc,4383
@@ -34,7 +34,7 @@ meeting_noter/install/macos.py,sha256=dO-86zbNKRtt0l4D8naVn7kFWjzI8TufWLWE3FRLHQ
34
34
  meeting_noter/output/__init__.py,sha256=F7xPlOrqweZcPbZtDrhved1stBI59vnWnLYfGwdu6oY,31
35
35
  meeting_noter/output/favorites.py,sha256=kYtEshq5E5xxaqjG36JMY5a-r6C2w98iqmKsGsxpgag,5764
36
36
  meeting_noter/output/searcher.py,sha256=ZGbEewodNuqq5mM4XvVtxELv_6UQByjs-LmEwodc-Ug,6448
37
- meeting_noter/output/writer.py,sha256=zO8y6FAFUAp0EEtALY-M5e2Ja5P-hgV38JjcKW7c-bA,3017
37
+ meeting_noter/output/writer.py,sha256=WHBLtc1GUWpo-EFZTSmYD8aXHp_3iMrvLmdsMBkw9Q4,7328
38
38
  meeting_noter/resources/__init__.py,sha256=yzHNxgypkuVDFZWv6xZjUygOVB_Equ9NNX_HGRvN7VM,43
39
39
  meeting_noter/resources/icon.icns,sha256=zMWqXCq7pI5acS0tbekFgFDvLt66EKUBP5-5IgztwPM,35146
40
40
  meeting_noter/resources/icon.png,sha256=nK0hM6CPMor_xXRFYURpm0muA2O7yzotyydUQx-ukyg,2390
@@ -58,8 +58,8 @@ meeting_noter/ui/screens/settings.py,sha256=Zb9-1rkPap1oUNcu4SJcOIemI8DmVC_5uqMd
58
58
  meeting_noter/ui/screens/viewer.py,sha256=5LVhghDcpshlisCrygk9H3uwOh48vmonMd8d4NH77hU,3991
59
59
  meeting_noter/ui/styles/app.tcss,sha256=-uE-1mE3oqKBZW1-pySmOZhK9oxjEQqcG7LDprwxBg4,3396
60
60
  meeting_noter/ui/widgets/__init__.py,sha256=6aVXLDGFXL9PnX5bCnMAqRqOvsv4VuzK_T2UTySQlTg,36
61
- meeting_noter-3.0.1.dist-info/METADATA,sha256=lDxx9chFyviafRwP-kz9dhTZW6nCY0xhceYQU4UQA0c,7000
62
- meeting_noter-3.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
63
- meeting_noter-3.0.1.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
64
- meeting_noter-3.0.1.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
65
- meeting_noter-3.0.1.dist-info/RECORD,,
61
+ meeting_noter-3.1.1.dist-info/METADATA,sha256=rcJA5fKb154FHFfJJJ8oJwvu_SwlCx44D1hE5JhYWw0,7000
62
+ meeting_noter-3.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
63
+ meeting_noter-3.1.1.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
64
+ meeting_noter-3.1.1.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
65
+ meeting_noter-3.1.1.dist-info/RECORD,,