mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
feat: name ad-hoc meeting notes by platform with same-day counter
This commit is contained in:
parent
8da40bd9bb
commit
2901379d23
6 changed files with 284 additions and 64 deletions
89
apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts
Normal file
89
apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts
Normal file
101
apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
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
|
* mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we
|
||||||
* only ask the OS when there's a reason to ask.
|
* only ask the OS when there's a reason to ask.
|
||||||
*/
|
*/
|
||||||
export async function matchBrowserMeeting(): Promise<BrowserMeetingMatch | null> {
|
export async function matchBrowserMeeting(executable?: string): Promise<BrowserMeetingMatch | null> {
|
||||||
const win = await getForegroundWindow();
|
const snap = await getWindowSnapshot(executable);
|
||||||
if (!win) return null;
|
if (!snap) return null;
|
||||||
// We only have a title (no URL from these OS calls), but Chrome / Edge /
|
// Scan ALL known window titles — on Windows tasklist returns every window,
|
||||||
// Firefox include the tab title in the window title, which contains the
|
// so even a backgrounded Meet tab still matches while Chrome holds the mic.
|
||||||
// meeting service name for Meet/Zoom-web/Teams-web pages.
|
for (const title of snap.titles) {
|
||||||
return matchTitleOrUrl(win.title, undefined);
|
const m = matchTitleOrUrl(title, undefined);
|
||||||
|
if (m) return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure matcher — exposed for tests; no OS calls. */
|
/** Pure matcher — exposed for tests; no OS calls. */
|
||||||
|
|
|
||||||
|
|
@ -3,70 +3,66 @@ import { promisify } from "node:util";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export interface ForegroundWindow {
|
export interface WindowSnapshot {
|
||||||
title: string;
|
// Window titles we know about. Implementations may return one (foreground)
|
||||||
// Best-effort process name; we don't always get this from osascript.
|
// or many (all titles for a process). browser-match scans the whole list,
|
||||||
appName?: string;
|
// so we don't need to identify which is foreground.
|
||||||
|
titles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the title of whatever window is in the foreground. Cross-platform,
|
* Best-effort look at currently-open window titles for a given executable.
|
||||||
* zero native deps — shells out to a built-in OS tool. Returns null if the
|
* On Windows: `tasklist /v /fi "imagename eq <exe>"` — fast because it skips
|
||||||
* platform isn't supported or the call fails.
|
* every system process. On macOS: AppleScript for the frontmost window.
|
||||||
*
|
*
|
||||||
* We dropped `active-win` because its prebuilt native binary depends on
|
* Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure;
|
||||||
* runtime package.json lookups that don't survive esbuild bundling.
|
* an empty title list means "process is running but no window has a title."
|
||||||
*/
|
*/
|
||||||
export async function getForegroundWindow(): Promise<ForegroundWindow | null> {
|
export async function getWindowSnapshot(executable?: string): Promise<WindowSnapshot | null> {
|
||||||
if (process.platform === "win32") return getForegroundWindowWindows();
|
if (process.platform === "win32") return getWindowSnapshotWindows(executable);
|
||||||
if (process.platform === "darwin") return getForegroundWindowMacOS();
|
if (process.platform === "darwin") return getWindowSnapshotMacOS();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Win32 GetForegroundWindow + GetWindowText via inline P/Invoke in PowerShell.
|
async function getWindowSnapshotWindows(executable?: string): Promise<WindowSnapshot | null> {
|
||||||
// Single one-shot call; cheap enough to run on every meeting-active event.
|
// Reduce to a basename — full paths can't be passed to tasklist's
|
||||||
const WINDOWS_SCRIPT = `
|
// imagename filter, and the filter wants e.g. "chrome.exe", not the path.
|
||||||
$src = @'
|
const imageName = executable ? executable.replace(/^.*[\\/]/, "") : "";
|
||||||
using System;
|
const args = ["/v", "/fo", "csv", "/nh"];
|
||||||
using System.Runtime.InteropServices;
|
if (imageName) args.push("/fi", `imagename eq ${imageName}`);
|
||||||
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 getForegroundWindowWindows(): Promise<ForegroundWindow | null> {
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
"powershell.exe",
|
"tasklist.exe",
|
||||||
["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT],
|
args,
|
||||||
{ timeout: 5_000, windowsHide: true },
|
{ timeout: 10_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
||||||
);
|
);
|
||||||
const trimmed = stdout.trim();
|
const titles: string[] = [];
|
||||||
if (!trimmed) return null;
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
const parsed = JSON.parse(trimmed) as { Title?: string; App?: string };
|
if (!line) continue;
|
||||||
if (typeof parsed.Title !== "string") return null;
|
const fields = parseCsvLine(line);
|
||||||
return { title: parsed.Title, appName: parsed.App };
|
if (fields.length === 0) continue;
|
||||||
|
const title = fields[fields.length - 1];
|
||||||
|
if (!title || title === "N/A") continue;
|
||||||
|
titles.push(title);
|
||||||
|
}
|
||||||
|
return { titles };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[MeetingDetect] foreground-window (windows) failed:", err);
|
console.error("[MeetingDetect] window-snapshot (windows) failed:", err);
|
||||||
return null;
|
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.
|
// macOS via osascript — title of the frontmost window of the frontmost app.
|
||||||
// Requires Accessibility permission for the Electron app; without it, the
|
// Requires Accessibility permission for the Electron app; without it, the
|
||||||
// `name of front window` lookup returns empty.
|
// `name of front window` lookup returns empty.
|
||||||
|
|
@ -83,15 +79,16 @@ tell application "System Events"
|
||||||
end tell
|
end tell
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
async function getForegroundWindowMacOS(): Promise<ForegroundWindow | null> {
|
async function getWindowSnapshotMacOS(): Promise<WindowSnapshot | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], {
|
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], {
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
const [appName, ...titleParts] = stdout.trim().split("\n");
|
const [, ...titleParts] = stdout.trim().split("\n");
|
||||||
return { title: titleParts.join("\n"), appName };
|
const title = titleParts.join("\n");
|
||||||
|
return { titles: title ? [title] : [] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[MeetingDetect] foreground-window (macOS) failed:", err);
|
console.error("[MeetingDetect] window-snapshot (macOS) failed:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@ describe("buildPopup", () => {
|
||||||
expect(popup?.notify.title).toBe("You're in a meeting");
|
expect(popup?.notify.title).toBe("You're in a meeting");
|
||||||
expect(popup?.notify.link).toContain("title=");
|
expect(popup?.notify.link).toContain("title=");
|
||||||
expect(popup?.notify.link).not.toContain("eventId=");
|
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", () => {
|
it("uses browser match platform label when kind=browser", () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.j
|
||||||
import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js";
|
import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js";
|
||||||
import { Suppression } from "./suppression.js";
|
import { Suppression } from "./suppression.js";
|
||||||
import type { MeetingAppKind } from "./meeting-apps.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
|
// Glue layer: turns detector events into popup notifications, gated by browser
|
||||||
// tab matching, calendar correlation, and the suppression store.
|
// 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
|
// Tests inject their own detector + notification service + suppression so this
|
||||||
// runs without touching the OS.
|
// runs without touching the OS.
|
||||||
|
|
||||||
type Matcher = () => Promise<BrowserMeetingMatch | null>;
|
type Matcher = (executable?: string) => Promise<BrowserMeetingMatch | null>;
|
||||||
type Correlator = (now: Date) => Promise<CorrelatedEvent | null>;
|
type Correlator = (now: Date) => Promise<CorrelatedEvent | null>;
|
||||||
|
|
||||||
export interface MeetingDetectServiceOptions {
|
export interface MeetingDetectServiceOptions {
|
||||||
|
|
@ -87,12 +88,30 @@ export class MeetingDetectService {
|
||||||
// otherwise we'd popup for YouTube, Spotify web, etc.
|
// otherwise we'd popup for YouTube, Spotify web, etc.
|
||||||
let browserMatch: BrowserMeetingMatch | null = null;
|
let browserMatch: BrowserMeetingMatch | null = null;
|
||||||
if (event.kind === "browser") {
|
if (event.kind === "browser") {
|
||||||
browserMatch = await this.matchBrowser();
|
browserMatch = await this.matchBrowser(event.executable);
|
||||||
if (!browserMatch) return;
|
if (!browserMatch) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const correlated = await this.correlate(new Date()).catch(() => null);
|
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;
|
if (!payload) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,6 +137,7 @@ export function buildPopup(
|
||||||
kind: MeetingAppKind,
|
kind: MeetingAppKind,
|
||||||
browserMatch: BrowserMeetingMatch | null,
|
browserMatch: BrowserMeetingMatch | null,
|
||||||
correlated: CorrelatedEvent | null,
|
correlated: CorrelatedEvent | null,
|
||||||
|
adHocTitle?: string,
|
||||||
): BuiltPopup | null {
|
): BuiltPopup | null {
|
||||||
const platformLabel = describePlatform(kind, browserMatch);
|
const platformLabel = describePlatform(kind, browserMatch);
|
||||||
if (!platformLabel) return null;
|
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 {
|
return {
|
||||||
notify: {
|
notify: {
|
||||||
title: "You're in a meeting",
|
title: "You're in a meeting",
|
||||||
message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`,
|
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",
|
actionLabel: "Take notes",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue