/** * Next Meeting Widget * * Displays the next upcoming calendar event with time until start and optional join button. * Uses icalBuddy for fast calendar access with proper recurring event support. * * Requirements: * - macOS with Calendar.app or compatible calendar * - icalBuddy installed (brew install ical-buddy) * - Calendar access permissions granted * * @module next-meeting */ import * as Uebersicht from "uebersicht"; import * as DataWidget from "./data-widget.jsx"; import * as DataWidgetLoader from "./data-widget-loader.jsx"; import * as Icons from "../icons/icons.jsx"; import useWidgetRefresh from "../../hooks/use-widget-refresh"; import useServerSocket from "../../hooks/use-server-socket"; import { useSimpleBarContext } from "../simple-bar-context.jsx"; import * as Utils from "../../utils"; export { nextMeetingStyles as styles } from "../../styles/components/data/next-meeting"; const { React } = Uebersicht; const DEFAULT_REFRESH_FREQUENCY = 60000; // 1 minute // Timing thresholds (in minutes) const URGENT_THRESHOLD = 5; // Red pulsing when ≤5 min until meeting const UPCOMING_THRESHOLD = 15; // Yellow when ≤15 min until meeting // Uses icalBuddy for fast calendar access (handles recurring events properly) // Note: Shell script automatically skips meetings that started more than 15 minutes ago const BASE_COMMAND = `./simple-bar/lib/scripts/next-meeting.sh`; /** * Builds the shell command with optional custom icalBuddy path and look-ahead hours. * @param {string} icalBuddyPath - Optional custom path to icalBuddy binary * @param {number} lookAheadHours - Hours to look ahead for meetings (default: 12) * @returns {string} Complete shell command */ function buildCommand(icalBuddyPath, lookAheadHours) { const hours = lookAheadHours || 12; if (icalBuddyPath && icalBuddyPath.trim()) { // Escape special shell characters to prevent injection const safePath = icalBuddyPath.replace(/["$`\\]/g, "\\$&"); return `${BASE_COMMAND} "${safePath}" ${hours}`; } return `${BASE_COMMAND} "" ${hours}`; } // Patterns to extract meeting URLs from notes const MEETING_URL_PATTERNS = [ /https?:\/\/[\w-]*\.?zoom\.us\/[^\s<>"]+/gi, /https?:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s<>"]+/gi, /https?:\/\/meet\.google\.com\/[^\s<>"]+/gi, /https?:\/\/[\w-]*\.?webex\.com\/[^\s<>"]+/gi, ]; // SafeLinks pattern (Microsoft Outlook protection wrapper) const SAFELINKS_PATTERN = /https?:\/\/[\w.-]*safelinks\.protection\.outlook\.com\/[^\s<>">\]]+/gi; /** * Decode a URL that may be URL-encoded * @param {string} url - Possibly encoded URL * @returns {string} Decoded URL */ function decodeUrl(url) { try { return decodeURIComponent(url); } catch { return url; } } /** * Extract the real URL from a SafeLink wrapper * @param {string} safeLink - SafeLink URL * @returns {string} Extracted real URL or original */ function extractFromSafeLink(safeLink) { const urlParam = safeLink.match(/[?&]url=([^&]+)/i); if (urlParam && urlParam[1]) { return decodeUrl(urlParam[1]); } return safeLink; } /** * Extract meeting URL from event URL or notes * @param {string} url - Event URL * @param {string} notes - Event notes/description * @returns {string|null} Meeting URL or null */ function extractMeetingUrl(url, notes) { // First check direct URL if (url && url.trim() && url !== "missing value") { for (const pattern of MEETING_URL_PATTERNS) { pattern.lastIndex = 0; const match = url.match(pattern); if (match) return match[0]; } if (url.startsWith("http")) return url; } // Check notes for meeting links if (notes && notes.trim()) { // First try direct meeting URLs for (const pattern of MEETING_URL_PATTERNS) { pattern.lastIndex = 0; const match = notes.match(pattern); if (match) return match[0]; } // Check for SafeLinks that contain meeting URLs SAFELINKS_PATTERN.lastIndex = 0; const safeLinks = notes.match(SAFELINKS_PATTERN); if (safeLinks) { for (const safeLink of safeLinks) { const realUrl = extractFromSafeLink(safeLink); for (const pattern of MEETING_URL_PATTERNS) { pattern.lastIndex = 0; if (pattern.test(realUrl)) { return realUrl; } } } } } return null; } /** * Format time until meeting * @param {number} minutes - Minutes until meeting * @returns {string} Formatted time string */ function formatTimeUntil(minutes) { if (minutes < 0) return "now"; if (minutes < 1) return "<1m"; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); const mins = minutes % 60; if (mins === 0) return `${hours}h`; return `${hours}h ${mins}m`; } /** * Parse meeting data from shell script output * @param {string} output - Raw command output * @returns {Object|null} Meeting object or null */ function parseMeetingData(output) { if (!output || !output.trim()) return null; const parts = output.trim().split("|"); if (parts.length < 5) return null; const [title, startTime, url, notes, minutesUntil] = parts; if (!title) return null; const meetingUrl = extractMeetingUrl(url, notes); return { title: title.trim(), startTime: startTime, url: meetingUrl, minutesUntil: parseInt(minutesUntil, 10) || 0, }; } /** * Join button sub-component for meeting URLs. * @component * @param {Object} props - Component props * @param {string} props.url - Meeting URL to open * @returns {JSX.Element} Join button element */ const JoinButton = React.memo(({ url }) => { /** * Opens the meeting URL when clicked. * @param {React.MouseEvent} e - Click event */ const handleJoin = async (e) => { e.stopPropagation(); Utils.clickEffect(e); // Escape special shell characters to prevent injection const safeUrl = url.replace(/["$`\\]/g, "\\$&"); await Uebersicht.run(`open "${safeUrl}"`); }; return ( ); }); JoinButton.displayName = "JoinButton"; /** * Next Meeting widget component * Shows the next upcoming meeting with optional join button * @returns {JSX.Element|null} The next meeting widget */ export const Widget = React.memo(() => { const { displayIndex, settings } = useSimpleBarContext(); const { widgets, nextMeetingWidgetOptions, dateWidgetOptions } = settings; const { nextMeetingWidget } = widgets; const { refreshFrequency, showOnDisplay, showJoinButton, showTimeOnly, icalBuddyPath, lookAheadHours, } = nextMeetingWidgetOptions; // Calculate the refresh frequency for the widget const refresh = React.useMemo( () => Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY), [refreshFrequency], ); // Determine if the widget should be visible based on display settings const visible = Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && nextMeetingWidget; const [state, setState] = React.useState(null); const [loading, setLoading] = React.useState(visible); /** * Resets the widget state. */ const resetWidget = React.useCallback(() => { setState(null); setLoading(false); }, []); /** * Fetches next meeting and updates the state. */ const getMeeting = React.useCallback(async () => { if (!visible) return; try { const command = buildCommand(icalBuddyPath, lookAheadHours); const output = await Uebersicht.run(command); const meeting = parseMeetingData(output); setState(meeting); } catch (error) { // eslint-disable-next-line no-console console.error("Error fetching next meeting:", error); setState(null); } setLoading(false); }, [visible, icalBuddyPath, lookAheadHours]); // Server socket for real-time updates useServerSocket("next-meeting", visible, getMeeting, resetWidget, setLoading); // Refresh the widget at the specified interval useWidgetRefresh(visible, getMeeting, refresh); if (loading) return ; if (!state) return null; const { title, minutesUntil, url } = state; const timeDisplay = formatTimeUntil(minutesUntil); const isUrgent = minutesUntil <= URGENT_THRESHOLD; // Red pulsing: ≤5 min or in progress (up to 15 min) const isUpcoming = minutesUntil <= UPCOMING_THRESHOLD; // Yellow: ≤15 min (but not urgent/in-progress) const widgetClasses = Utils.classNames("next-meeting", { "next-meeting--urgent": isUrgent, "next-meeting--upcoming": isUpcoming && !isUrgent, }); /** * Handle click event to open the calendar application * @param {Event} e - The click event */ const openCalendar = async (e) => { Utils.clickEffect(e); const calendarApp = dateWidgetOptions?.calendarApp || "Calendar"; await Uebersicht.run(`open -a "${calendarApp}"`); }; return ( {!showTimeOnly && {title}} {timeDisplay} {showJoinButton && url && } ); }); Widget.displayName = "NextMeeting";