feat: name ad-hoc meeting notes by platform with same-day counter

This commit is contained in:
Gagancreates 2026-05-15 15:30:50 +05:30 committed by Arjun
parent 8da40bd9bb
commit 2901379d23
6 changed files with 284 additions and 64 deletions

View file

@ -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<void> {
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();
});
});

View file

@ -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 - <Platform>" 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<string> {
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<number> {
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;
}
}

View file

@ -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<BrowserMeetingMatch | null> {
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<BrowserMeetingMatch | null> {
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. */

View file

@ -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 <exe>"` 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<ForegroundWindow | null> {
if (process.platform === "win32") return getForegroundWindowWindows();
if (process.platform === "darwin") return getForegroundWindowMacOS();
export async function getWindowSnapshot(executable?: string): Promise<WindowSnapshot | null> {
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<WindowSnapshot | null> {
// 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<ForegroundWindow | null> {
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<ForegroundWindow | null> {
async function getWindowSnapshotMacOS(): Promise<WindowSnapshot | null> {
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;
}
}

View file

@ -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", () => {

View file

@ -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<BrowserMeetingMatch | null>;
type Matcher = (executable?: string) => Promise<BrowserMeetingMatch | null>;
type Correlator = (now: Date) => Promise<CorrelatedEvent | null>;
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 - <Platform> [#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",
},
};