From 2901379d2338bdbc8294a9b8a61b2d64e9f0cf3a Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 15:30:50 +0530 Subject: [PATCH] feat: name ad-hoc meeting notes by platform with same-day counter --- .../src/meeting-detect/ad-hoc-title.test.ts | 89 +++++++++++++++ .../main/src/meeting-detect/ad-hoc-title.ts | 101 ++++++++++++++++++ .../main/src/meeting-detect/browser-match.ts | 19 ++-- .../src/meeting-detect/foreground-window.ts | 99 +++++++++-------- .../main/src/meeting-detect/service.test.ts | 7 ++ .../x/apps/main/src/meeting-detect/service.ts | 33 +++++- 6 files changed, 284 insertions(+), 64 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts diff --git a/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts new file mode 100644 index 00000000..97bbf92b --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; + +let tmpRoot: string; +const NOW = new Date(2026, 4, 15, 14, 0, 0); // 2026-05-15 14:00 local + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rb-adhoc-title-")); +}); + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }); +}); + +async function writeNote(day: string, filename: string): Promise { + const dir = path.join(tmpRoot, day); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, filename), "stub", "utf-8"); +} + +describe("buildAdHocTitle", () => { + it("returns the bare title for the first occurrence of the day", async () => { + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("appends #2 when one already exists", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #2"); + }); + + it("increments past #2 (#3, #4, ...)", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#2.md"); + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#3.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #4"); + }); + + it("doesn't cross-count platforms (Meet vs Zoom stay distinct)", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Meet", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Meet"); + }); + + it("resets the counter on a different day", async () => { + await writeNote("2026-05-14", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("ignores non-meeting notes in the same folder", async () => { + await writeNote("2026-05-15", "standup.md"); + await writeNote("2026-05-15", "random_note.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("matches slug-variant filenames (different separators)", async () => { + // Whatever the renderer's slugifier does, normalize() should match. + await writeNote("2026-05-15", "Meeting Notes - Zoom.md"); + await writeNote("2026-05-15", "Meeting-Notes--Zoom.md"); // hypothetical alt slug + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #3"); + }); +}); + +describe("shortPlatformLabel", () => { + it("maps browser platforms to short labels", () => { + expect(shortPlatformLabel({ browserPlatform: "google-meet", kind: "browser" })).toBe("Meet"); + expect(shortPlatformLabel({ browserPlatform: "zoom-web", kind: "browser" })).toBe("Zoom"); + expect(shortPlatformLabel({ browserPlatform: "teams-web", kind: "browser" })).toBe("Teams"); + }); + + it("maps native kinds to short labels", () => { + expect(shortPlatformLabel({ kind: "zoom" })).toBe("Zoom"); + expect(shortPlatformLabel({ kind: "teams" })).toBe("Teams"); + expect(shortPlatformLabel({ kind: "discord" })).toBe("Discord"); + }); + + it("returns null for unmatched browser / unknown", () => { + expect(shortPlatformLabel({ kind: "browser" })).toBeNull(); + expect(shortPlatformLabel({ kind: "unknown" })).toBeNull(); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts new file mode 100644 index 00000000..e71baaf7 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts @@ -0,0 +1,101 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +// Ad-hoc meeting titles: "Meeting Notes - " with a per-day counter +// suffix when there's already one for the same platform on the same day. +// +// first Zoom today → "Meeting Notes - Zoom" +// second Zoom today → "Meeting Notes - Zoom #2" +// first Zoom tomorrow → "Meeting Notes - Zoom" (fresh folder, fresh count) + +const MEETINGS_ROOT = path.join(WorkDir, "knowledge", "Meetings", "rowboat"); +const TITLE_PREFIX = "Meeting Notes - "; + +export interface AdHocTitleOptions { + platformLabel: string; + now?: Date; + // Override for tests; defaults to the user's real meetings folder. + root?: string; +} + +export async function buildAdHocTitle(opts: AdHocTitleOptions): Promise { + const platform = opts.platformLabel; + const base = `${TITLE_PREFIX}${platform}`; + + const now = opts.now ?? new Date(); + const dayFolder = path.join(opts.root ?? MEETINGS_ROOT, formatDay(now)); + + const existing = await countMatching(dayFolder, base); + if (existing === 0) return base; + return `${base} #${existing + 1}`; +} + +function formatDay(d: Date): string { + // YYYY-MM-DD in local time — matches the existing knowledge/Meetings/rowboat layout. + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +async function countMatching(dir: string, baseTitle: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch { + return 0; + } + const needle = normalize(baseTitle); + let count = 0; + for (const name of entries) { + if (!name.endsWith(".md")) continue; + const stem = name.slice(0, -3); // strip .md + if (normalize(stem).startsWith(needle)) count++; + } + return count; +} + +/** + * Normalize a title or filename to alphanumerics-only-lowercase so we can + * compare across slugification rules: + * "Meeting Notes - Zoom" → "meetingnoteszoom" + * "Meeting_Notes_-_Zoom.md" → "meetingnoteszoom" (after .md strip) + * "Meeting Notes - Zoom #2" → "meetingnoteszoom2" + * + * Anchoring with startsWith() then catches both the bare title and any + * counter-suffixed variant, without colliding across platforms ("Meet" + * vs "Zoom" stay distinct because the platform name appears after the + * common "meetingnotes" prefix). + */ +function normalize(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +// Map our internal platform/kind names to user-facing short labels. +// Re-exported so service.ts can produce both the popup body label and the +// note title from the same source of truth. +export function shortPlatformLabel(input: { + browserPlatform?: "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; + kind: "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown"; +}): string | null { + if (input.browserPlatform) { + switch (input.browserPlatform) { + case "google-meet": return "Meet"; + case "zoom-web": return "Zoom"; + case "teams-web": return "Teams"; + case "slack-huddle": return "Slack"; + case "webex-web": return "Webex"; + } + } + switch (input.kind) { + case "zoom": return "Zoom"; + case "teams": return "Teams"; + case "slack": return "Slack"; + case "discord": return "Discord"; + case "webex": return "Webex"; + case "browser": + case "unknown": + return null; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.ts b/apps/x/apps/main/src/meeting-detect/browser-match.ts index efbf829f..6de404f9 100644 --- a/apps/x/apps/main/src/meeting-detect/browser-match.ts +++ b/apps/x/apps/main/src/meeting-detect/browser-match.ts @@ -1,4 +1,4 @@ -import { getForegroundWindow } from "./foreground-window.js"; +import { getWindowSnapshot } from "./foreground-window.js"; export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; @@ -36,13 +36,16 @@ const RULES: TitleRule[] = [ * mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we * only ask the OS when there's a reason to ask. */ -export async function matchBrowserMeeting(): Promise { - const win = await getForegroundWindow(); - if (!win) return null; - // We only have a title (no URL from these OS calls), but Chrome / Edge / - // Firefox include the tab title in the window title, which contains the - // meeting service name for Meet/Zoom-web/Teams-web pages. - return matchTitleOrUrl(win.title, undefined); +export async function matchBrowserMeeting(executable?: string): Promise { + const snap = await getWindowSnapshot(executable); + if (!snap) return null; + // Scan ALL known window titles — on Windows tasklist returns every window, + // so even a backgrounded Meet tab still matches while Chrome holds the mic. + for (const title of snap.titles) { + const m = matchTitleOrUrl(title, undefined); + if (m) return m; + } + return null; } /** Pure matcher — exposed for tests; no OS calls. */ diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts index 72c1c8ce..e9f523d0 100644 --- a/apps/x/apps/main/src/meeting-detect/foreground-window.ts +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -3,70 +3,66 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -export interface ForegroundWindow { - title: string; - // Best-effort process name; we don't always get this from osascript. - appName?: string; +export interface WindowSnapshot { + // Window titles we know about. Implementations may return one (foreground) + // or many (all titles for a process). browser-match scans the whole list, + // so we don't need to identify which is foreground. + titles: string[]; } /** - * Read the title of whatever window is in the foreground. Cross-platform, - * zero native deps — shells out to a built-in OS tool. Returns null if the - * platform isn't supported or the call fails. + * Best-effort look at currently-open window titles for a given executable. + * On Windows: `tasklist /v /fi "imagename eq "` — fast because it skips + * every system process. On macOS: AppleScript for the frontmost window. * - * We dropped `active-win` because its prebuilt native binary depends on - * runtime package.json lookups that don't survive esbuild bundling. + * Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure; + * an empty title list means "process is running but no window has a title." */ -export async function getForegroundWindow(): Promise { - if (process.platform === "win32") return getForegroundWindowWindows(); - if (process.platform === "darwin") return getForegroundWindowMacOS(); +export async function getWindowSnapshot(executable?: string): Promise { + if (process.platform === "win32") return getWindowSnapshotWindows(executable); + if (process.platform === "darwin") return getWindowSnapshotMacOS(); return null; } -// Win32 GetForegroundWindow + GetWindowText via inline P/Invoke in PowerShell. -// Single one-shot call; cheap enough to run on every meeting-active event. -const WINDOWS_SCRIPT = ` -$src = @' -using System; -using System.Runtime.InteropServices; -using System.Text; -public class RowboatFW { - [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll", CharSet=CharSet.Auto, SetLastError=true)] - public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); - [DllImport("user32.dll", SetLastError=true)] - public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); -} -'@ -Add-Type -TypeDefinition $src -ErrorAction SilentlyContinue -$hwnd = [RowboatFW]::GetForegroundWindow() -$sb = New-Object System.Text.StringBuilder 1024 -[RowboatFW]::GetWindowText($hwnd, $sb, $sb.Capacity) | Out-Null -$pid2 = 0 -[RowboatFW]::GetWindowThreadProcessId($hwnd, [ref]$pid2) | Out-Null -$proc = $null -try { $proc = (Get-Process -Id $pid2 -ErrorAction SilentlyContinue).ProcessName } catch {} -[PSCustomObject]@{ Title = $sb.ToString(); App = $proc } | ConvertTo-Json -Compress -`.trim(); +async function getWindowSnapshotWindows(executable?: string): Promise { + // Reduce to a basename — full paths can't be passed to tasklist's + // imagename filter, and the filter wants e.g. "chrome.exe", not the path. + const imageName = executable ? executable.replace(/^.*[\\/]/, "") : ""; + const args = ["/v", "/fo", "csv", "/nh"]; + if (imageName) args.push("/fi", `imagename eq ${imageName}`); -async function getForegroundWindowWindows(): Promise { try { const { stdout } = await execFileAsync( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT], - { timeout: 5_000, windowsHide: true }, + "tasklist.exe", + args, + { timeout: 10_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, ); - const trimmed = stdout.trim(); - if (!trimmed) return null; - const parsed = JSON.parse(trimmed) as { Title?: string; App?: string }; - if (typeof parsed.Title !== "string") return null; - return { title: parsed.Title, appName: parsed.App }; + const titles: string[] = []; + for (const line of stdout.split(/\r?\n/)) { + if (!line) continue; + const fields = parseCsvLine(line); + if (fields.length === 0) continue; + const title = fields[fields.length - 1]; + if (!title || title === "N/A") continue; + titles.push(title); + } + return { titles }; } catch (err) { - console.error("[MeetingDetect] foreground-window (windows) failed:", err); + console.error("[MeetingDetect] window-snapshot (windows) failed:", err); return null; } } +function parseCsvLine(line: string): string[] { + // tasklist /fo csv quotes every field and doesn't embed quotes within fields, + // so a simple comma-split between quoted segments works. + const out: string[] = []; + const re = /"([^"]*)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) out.push(m[1]); + return out; +} + // macOS via osascript — title of the frontmost window of the frontmost app. // Requires Accessibility permission for the Electron app; without it, the // `name of front window` lookup returns empty. @@ -83,15 +79,16 @@ tell application "System Events" end tell `.trim(); -async function getForegroundWindowMacOS(): Promise { +async function getWindowSnapshotMacOS(): Promise { try { const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], { timeout: 5_000, }); - const [appName, ...titleParts] = stdout.trim().split("\n"); - return { title: titleParts.join("\n"), appName }; + const [, ...titleParts] = stdout.trim().split("\n"); + const title = titleParts.join("\n"); + return { titles: title ? [title] : [] }; } catch (err) { - console.error("[MeetingDetect] foreground-window (macOS) failed:", err); + console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); return null; } } diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 870ddb9b..7586efde 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -37,6 +37,13 @@ describe("buildPopup", () => { expect(popup?.notify.title).toBe("You're in a meeting"); expect(popup?.notify.link).toContain("title="); expect(popup?.notify.link).not.toContain("eventId="); + // Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom". + expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom"); + }); + + it("uses the precomputed ad-hoc title when provided (counter case)", () => { + const popup = buildPopup("zoom", null, null, "Meeting Notes - Zoom #2"); + expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom #2"); }); it("uses browser match platform label when kind=browser", () => { diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 91080ee0..5e38c521 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -4,6 +4,7 @@ import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.j import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; import { Suppression } from "./suppression.js"; import type { MeetingAppKind } from "./meeting-apps.js"; +import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; // Glue layer: turns detector events into popup notifications, gated by browser // tab matching, calendar correlation, and the suppression store. @@ -11,7 +12,7 @@ import type { MeetingAppKind } from "./meeting-apps.js"; // Tests inject their own detector + notification service + suppression so this // runs without touching the OS. -type Matcher = () => Promise; +type Matcher = (executable?: string) => Promise; type Correlator = (now: Date) => Promise; export interface MeetingDetectServiceOptions { @@ -87,12 +88,30 @@ export class MeetingDetectService { // otherwise we'd popup for YouTube, Spotify web, etc. let browserMatch: BrowserMeetingMatch | null = null; if (event.kind === "browser") { - browserMatch = await this.matchBrowser(); + browserMatch = await this.matchBrowser(event.executable); if (!browserMatch) return; } const correlated = await this.correlate(new Date()).catch(() => null); - const payload = buildPopup(event.kind, browserMatch, correlated); + + // Ad-hoc only: compute "Meeting Notes - [#N]" so the note + // file lands with a useful title. Skip when we have a real calendar + // event — that already provides the right summary. + let adHocTitle: string | undefined; + if (!correlated) { + const short = shortPlatformLabel({ + browserPlatform: browserMatch?.platform, + kind: event.kind, + }); + if (short) { + adHocTitle = await buildAdHocTitle({ platformLabel: short }).catch((err) => { + console.error("[MeetingDetect] buildAdHocTitle failed:", err); + return `Meeting Notes - ${short}`; + }); + } + } + + const payload = buildPopup(event.kind, browserMatch, correlated, adHocTitle); if (!payload) return; try { @@ -118,6 +137,7 @@ export function buildPopup( kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null, correlated: CorrelatedEvent | null, + adHocTitle?: string, ): BuiltPopup | null { const platformLabel = describePlatform(kind, browserMatch); if (!platformLabel) return null; @@ -133,12 +153,15 @@ export function buildPopup( }; } - // Ad-hoc — no calendar event matched. Still offer notes, with generic copy. + // Ad-hoc — no calendar event matched. Use the precomputed counter-aware + // title ("Meeting Notes - Zoom" / "... #2") if available; fall back to a + // simple platform-suffixed title. + const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`; return { notify: { title: "You're in a meeting", message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`, - link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(`Ad-hoc ${platformLabel} call`)}`, + link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`, actionLabel: "Take notes", }, };