Prompt for a meeting name on stop; rename the session folder
When a recording finishes, ask for a meeting name and rename the session folder from the auto stamp `<yyyy-MM-dd'T'HH-mm-ss>_<app>` to the readable `<date>_<name>_<app>` (dropping HH-MM-SS), so sessions/ is easy to scan. Skipping or leaving it blank keeps the timestamped name. The rename runs after the recorder and visual capture finish (files closed) and before finish() captures the folder for backend processing, so the renamed folder is what flows downstream; finish() re-derives the track URLs from the possibly-moved folder. The quit path never prompts, and a quit with the prompt open ends its modal so termination isn't blocked. Naming/parsing logic lives in a pure, unit-tested SessionNaming; recapTitle moves there and now understands both folder forms.
This commit is contained in:
@@ -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 `<date>_<name>_<app>` (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) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure helpers for session-folder names. A session folder is created at start
|
||||
/// with an auto name `<yyyy-MM-dd'T'HH-mm-ss>_<app>`; when the user names the
|
||||
/// recording on stop it's renamed to `<yyyy-MM-dd>_<name>_<app>` (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 `<date>_<name>_<app>`. A positive `counter`
|
||||
/// disambiguates a collision by suffixing the NAME segment (`<name>-2`) so the
|
||||
/// trailing `_<app>` 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 "_<app>"
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user