mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +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";
|
||||
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue