diff --git a/Ten31Transcripts/Session/SessionController.swift b/Ten31Transcripts/Session/SessionController.swift index 1fb095b..9ba0128 100644 --- a/Ten31Transcripts/Session/SessionController.swift +++ b/Ten31Transcripts/Session/SessionController.swift @@ -99,6 +99,11 @@ final class SessionController: ObservableObject { /// Bumped each time a start/stop Task is spawned (Task is a value type, so this /// is how `prepareForTermination` detects a newly-spawned transition). private var lifecycleGeneration = 0 + /// The meeting-name prompt currently on screen, if any, so a quit can end it + /// instead of blocking termination on user input (set in `askMeetingName`). + private weak var activeNamingAlert: NSAlert? + /// Set once `prepareForTermination` begins, so we skip the post-stop naming prompt. + private var isTerminating = false init(settings: AppSettings) { self.settings = settings @@ -324,6 +329,9 @@ final class SessionController: ObservableObject { lifecycleTask = Task { let result = await recorder.stop() let visual = await self.stopVisualAndTimeline(result, folder: folder) + // Interactive stop only: ask for a meeting name and give the folder a + // readable name before `finish()` captures it for backend processing. + self.promptMeetingNameAndRename() self.finish(result, timeline: visual.timeline, selfSpans: visual.selfSpans, visualRan: visual.visualRan) } } @@ -338,13 +346,18 @@ final class SessionController: ObservableObject { if let folder = currentFolder { writeSelfSpans(spans: selfSpans, result: result, to: folder) let visualCount = visualRan ? timeline.count : nil // `timeline` is the remote vision segments + // Re-derive the track URLs from `folder`: a meeting-name rename may have + // moved the session after `result` captured its original paths. + let micURL = folder.appendingPathComponent("mic.wav") + let systemURL = folder.appendingPathComponent("system.wav") + let mixedURL = folder.appendingPathComponent("mixed_mono_16k.wav") lastSession = SessionInfo( - folder: folder, mixedURL: result.mixedURL, + folder: folder, mixedURL: mixedURL, duration: result.duration, selfSpanCount: selfSpans.count, visualSegmentCount: visualCount) lastProcess = ProcessInputs( folder: folder, sessionId: folder.lastPathComponent, app: currentLabel, - micURL: result.micURL, systemURL: result.systemURL, mixedURL: result.mixedURL, + micURL: micURL, systemURL: systemURL, mixedURL: mixedURL, timeline: timeline, selfSpans: selfSpans, selfName: settings.selfName, systemHealthy: result.systemNote == nil) } @@ -419,24 +432,13 @@ final class SessionController: ObservableObject { guard settings.recapEnabled, !resolved.segments.isEmpty else { return } let analyzer = RecapAnalyzer(llm: llm, model: model) guard let result = try? await analyzer.recap(file: resolved, template: settings.defaultTemplate) else { return } - let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId) + let title = SessionNaming.recapTitle(app: inputs.app, sessionId: inputs.sessionId) try? RecapRenderer.write(file: resolved, result: result, title: title, to: inputs.folder) try? RecapFile(title: title, result: result).write(to: inputs.folder.appendingPathComponent("recap.json")) let url = inputs.folder.appendingPathComponent("recap.html") if FileManager.default.fileExists(atPath: url.path) { self.recapURL = url } } - /// Friendly recap title, e.g. "Google Meet call — 2026-06-06 11:43". - private static func recapTitle(app: String, sessionId: String) -> String { - let appName = CallDetector.DetectedApp(rawValue: app)?.display ?? app.capitalized - let stamp = sessionId.split(separator: "_").first.map(String.init) ?? sessionId - let parts = stamp.split(separator: "T") - let date = parts.first.map(String.init) ?? "" - let timeBits = parts.count > 1 ? parts[1].split(separator: "-") : [] - let time = timeBits.count >= 2 ? "\(timeBits[0]):\(timeBits[1])" : "" - return "\(appName) call — \(date) \(time)".trimmingCharacters(in: .whitespaces) - } - // MARK: - Speaker corrections /// True once the last session has a transcribed `speakers.json` to correct. @@ -584,6 +586,11 @@ final class SessionController: ObservableObject { /// its WAV headers are finalized before the process exits. Handles quit while /// `.starting` and `.finishing`, not just `.recording`. func prepareForTermination() async { + isTerminating = true + // If the meeting-name prompt is open, end its modal loop so quit isn't blocked + // waiting on the user — the session keeps its auto timestamped name. (Falls + // back to the user answering the on-screen dialog if the abort isn't serviced.) + if activeNamingAlert != nil { NSApp.abortModal() } // Cancel any in-flight backend transcription (audio is already saved; the // user can resend). The pipeline's checkCancellation + defer clean up chunks. processTask?.cancel() @@ -649,6 +656,59 @@ final class SessionController: ObservableObject { return f.string(from: Date()) } + /// Ask the user to name the just-finished recording, then rename its folder to + /// a readable `__` (dropping the HH-MM-SS auto stamp). Skipping + /// or leaving it blank keeps the timestamped name. Must run BEFORE `finish()` so + /// the renamed folder is what flows to backend processing. The recorder and + /// visual capture have both finished by now, so every session file is closed and + /// the move is safe. Never called from the quit path — we don't block a quit on + /// a prompt. + private func promptMeetingNameAndRename() { + // A quit can begin while we're finishing — don't put a blocking prompt in its + // way; keep the auto timestamped name and let termination drain. + guard !isTerminating, let folder = currentFolder, + let name = askMeetingName() else { return } // nil = skipped / blank + let base = folder.deletingLastPathComponent() + let date = SessionNaming.datePrefix(ofSessionNamed: folder.lastPathComponent) + let fm = FileManager.default + var counter = 0 + while counter < 100 { + guard let leaf = SessionNaming.renamedLeaf( + date: date, app: currentLabel, meetingName: name, counter: counter) else { return } + let target = base.appendingPathComponent(leaf, isDirectory: true) + if fm.fileExists(atPath: target.path) { counter += 1; continue } // disambiguate + do { + try fm.moveItem(at: folder, to: target) + currentFolder = target + } catch { + NSLog("Session rename to “\(leaf)” failed: \(error.localizedDescription)") // keep the original folder + } + return + } + NSLog("Session rename: kept “\(folder.lastPathComponent)” — 100 name collisions") + } + + /// Modal prompt for a meeting name. Registers the alert so `prepareForTermination` + /// can end it on quit. Returns the trimmed name, or nil if the user skipped, left + /// it empty, or a quit aborted the prompt (caller keeps the auto folder name). + private func askMeetingName() -> String? { + let alert = NSAlert() + alert.messageText = "Name this recording" + alert.informativeText = "Give the meeting a name so its folder is easy to find in your sessions. Leave blank to keep the timestamped name." + alert.addButton(withTitle: "Save") // .alertFirstButtonReturn + alert.addButton(withTitle: "Skip") // .alertSecondButtonReturn + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + field.placeholderString = "Meeting name" + alert.accessoryView = field + alert.window.initialFirstResponder = field + NSApp.activate(ignoringOtherApps: true) + activeNamingAlert = alert + defer { activeNamingAlert = nil } + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let text = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + /// Debug artifact: the channel-verified "self" spans actually sent to the backend /// as `self_vad` (mic active AND louder than system). Lets us eyeball self detection. private func writeSelfSpans(spans: [VADSpan], result: RecordingResult, to folder: URL) { diff --git a/Ten31Transcripts/Session/SessionNaming.swift b/Ten31Transcripts/Session/SessionNaming.swift new file mode 100644 index 0000000..069cd62 --- /dev/null +++ b/Ten31Transcripts/Session/SessionNaming.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Pure helpers for session-folder names. A session folder is created at start +/// with an auto name `_`; when the user names the +/// recording on stop it's renamed to `__` (no HH-MM-SS), +/// which is far easier to scan in `sessions/`. The app label always stays the +/// LAST `_`-separated segment so `SessionController.appLabel(from:)` keeps working +/// even when the meeting name itself contains spaces or underscores. +enum SessionNaming { + /// Filesystem- and parse-safe meeting name: trims, turns path separators into + /// dashes, drops control characters, collapses whitespace runs, removes leading + /// dots (no hidden/`.`/`..` folders), and caps the length. Returns "" if nothing + /// usable is left, which callers treat as "skip the rename". + static func sanitize(_ raw: String) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + // Path-hostile separators (`/` and the classic Mac `:`, plus `\`) → dash. + s = s.components(separatedBy: CharacterSet(charactersIn: "/:\\")).joined(separator: "-") + // Strip control characters outright. + s = s.components(separatedBy: .controlCharacters).joined() + // Collapse internal whitespace runs to single spaces. + s = s.split(whereSeparator: { $0 == " " || $0 == "\t" }).joined(separator: " ") + while s.hasPrefix(".") { s.removeFirst() } + s = s.trimmingCharacters(in: .whitespaces) + if s.count > 60 { s = String(s.prefix(60)).trimmingCharacters(in: .whitespaces) } + return s + } + + /// The date prefix of a session leaf name, e.g. `2026-06-17T09-59-48_signal` + /// → `2026-06-17`. Already-renamed leaves (`2026-06-17_name_signal`) return the + /// same date, so this is safe to call on either form. + static func datePrefix(ofSessionNamed leaf: String) -> String { + let head = leaf.split(separator: "_").first.map(String.init) ?? leaf + return head.split(separator: "T").first.map(String.init) ?? head + } + + /// Compose the renamed leaf `__`. A positive `counter` + /// disambiguates a collision by suffixing the NAME segment (`-2`) so the + /// trailing `_` stays parseable. Returns nil when the name sanitizes to + /// empty (the caller keeps the auto timestamped name). + static func renamedLeaf(date: String, app: String, meetingName: String, counter: Int = 0) -> String? { + let clean = sanitize(meetingName) + guard !clean.isEmpty else { return nil } + let suffix = counter > 0 ? "-\(counter + 1)" : "" + return "\(date)_\(clean)\(suffix)_\(app)" + } + + /// Friendly recap title from a session id, understanding both folder forms: + /// `2026-06-06T11-43-02_meet` → "Google Meet call — 2026-06-06 11:43" + /// `2026-06-06_Weekly sync_meet` → "Weekly sync — Google Meet (2026-06-06)" + static func recapTitle(app: String, sessionId: String) -> String { + let appName = CallDetector.DetectedApp(rawValue: app)?.display ?? app.capitalized + var parts = sessionId.split(separator: "_").map(String.init) + if parts.count > 1 { parts.removeLast() } // drop the trailing "_" + let head = parts.first ?? sessionId + let tBits = head.split(separator: "T").map(String.init) + let date = tBits.first ?? head + let time: String = { + guard tBits.count > 1 else { return "" } + let b = tBits[1].split(separator: "-") + return b.count >= 2 ? "\(b[0]):\(b[1])" : "" + }() + let when = [date, time].filter { !$0.isEmpty }.joined(separator: " ") + // Rejoin with "_" — the faithful inverse of split("_") — so a name that + // itself contained underscores survives the round-trip through the folder name. + let name = parts.count > 1 ? parts[1...].joined(separator: "_") : "" + if name.isEmpty { + return "\(appName) call — \(when)".trimmingCharacters(in: .whitespaces) + } + return "\(name) — \(appName) (\(when))".trimmingCharacters(in: .whitespaces) + } +} diff --git a/Ten31TranscriptsTests/SessionNamingTests.swift b/Ten31TranscriptsTests/SessionNamingTests.swift new file mode 100644 index 0000000..785cb9f --- /dev/null +++ b/Ten31TranscriptsTests/SessionNamingTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import Ten31Transcripts + +final class SessionNamingTests: XCTestCase { + + // MARK: sanitize + + func testSanitizeTrimsAndKeepsSpaces() { + XCTAssertEqual(SessionNaming.sanitize(" Weekly Sync "), "Weekly Sync") + } + + func testSanitizeReplacesPathSeparators() { + XCTAssertEqual(SessionNaming.sanitize("9/10 standup"), "9-10 standup") + XCTAssertEqual(SessionNaming.sanitize("a:b\\c"), "a-b-c") + } + + func testSanitizeCollapsesWhitespaceRuns() { + XCTAssertEqual(SessionNaming.sanitize("board 1:1"), "board 1-1") + } + + func testSanitizeStripsLeadingDots() { + XCTAssertEqual(SessionNaming.sanitize("...hidden"), "hidden") + XCTAssertEqual(SessionNaming.sanitize(".."), "") + } + + func testSanitizeEmptyForBlankOrWhitespace() { + XCTAssertEqual(SessionNaming.sanitize(""), "") + XCTAssertEqual(SessionNaming.sanitize(" \n\t "), "") + } + + func testSanitizeCapsLength() { + let long = String(repeating: "x", count: 200) + XCTAssertEqual(SessionNaming.sanitize(long).count, 60) + } + + func testSanitizeStripsControlCharacters() { + XCTAssertEqual(SessionNaming.sanitize("a\u{0000}b\u{001F}c"), "abc") + } + + // MARK: datePrefix + + func testDatePrefixFromAutoName() { + XCTAssertEqual(SessionNaming.datePrefix(ofSessionNamed: "2026-06-17T09-59-48_signal"), "2026-06-17") + } + + func testDatePrefixFromRenamedName() { + XCTAssertEqual(SessionNaming.datePrefix(ofSessionNamed: "2026-06-17_Weekly sync_signal"), "2026-06-17") + } + + // MARK: renamedLeaf + + func testRenamedLeafBasic() { + XCTAssertEqual( + SessionNaming.renamedLeaf(date: "2026-06-17", app: "signal", meetingName: "Weekly sync"), + "2026-06-17_Weekly sync_signal") + } + + func testRenamedLeafAppStaysLastSegment() { + // The meeting name may contain underscores; the app must remain parseable as + // the final "_"-segment (what SessionController.appLabel reads). + let leaf = SessionNaming.renamedLeaf(date: "2026-06-17", app: "meet", meetingName: "q3_planning") + XCTAssertEqual(leaf, "2026-06-17_q3_planning_meet") + XCTAssertEqual(leaf?.split(separator: "_").last.map(String.init), "meet") + } + + func testRenamedLeafNilForBlankName() { + XCTAssertNil(SessionNaming.renamedLeaf(date: "2026-06-17", app: "signal", meetingName: " ")) + } + + func testRenamedLeafCounterDisambiguatesNameSegment() { + // A collision suffixes the NAME, not the whole leaf, so "_app" stays last. + let leaf = SessionNaming.renamedLeaf(date: "2026-06-17", app: "signal", meetingName: "sync", counter: 1) + XCTAssertEqual(leaf, "2026-06-17_sync-2_signal") + XCTAssertEqual(leaf?.split(separator: "_").last.map(String.init), "signal") + } + + func testRenamedLeafAppStaysLastAtMaxCollisionDepth() { + // The 100-collision cap is counter 0…99; the app must still parse out last. + let leaf = SessionNaming.renamedLeaf(date: "2026-06-17", app: "signal", meetingName: "q3_sync", counter: 99) + XCTAssertEqual(leaf, "2026-06-17_q3_sync-100_signal") + XCTAssertEqual(leaf?.split(separator: "_").last.map(String.init), "signal") + } + + // MARK: recapTitle + + func testRecapTitleAutoNamePreservesLegacyFormat() { + XCTAssertEqual( + SessionNaming.recapTitle(app: "meet", sessionId: "2026-06-06T11-43-02_meet"), + "Google Meet call — 2026-06-06 11:43") + } + + func testRecapTitleNamedSession() { + XCTAssertEqual( + SessionNaming.recapTitle(app: "meet", sessionId: "2026-06-06_Weekly sync_meet"), + "Weekly sync — Google Meet (2026-06-06)") + } + + func testRecapTitleNamePreservesUnderscores() { + // A meeting name with underscores must survive the split/join round-trip. + XCTAssertEqual( + SessionNaming.recapTitle(app: "meet", sessionId: "2026-06-06_q3_planning_meet"), + "q3_planning — Google Meet (2026-06-06)") + } + + func testRecapTitleUnknownAppCapitalizes() { + XCTAssertEqual( + SessionNaming.recapTitle(app: "manual", sessionId: "2026-06-06T11-43-02_manual"), + "Manual call — 2026-06-06 11:43") + } +}