mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
cal block initial commit
This commit is contained in:
parent
484231b224
commit
51bd9d5ec6
9 changed files with 932 additions and 41 deletions
|
|
@ -703,7 +703,7 @@ export function setupIpcHandlers() {
|
||||||
return { success: false, error: 'Unknown format' };
|
return { success: false, error: 'Unknown format' };
|
||||||
},
|
},
|
||||||
'meeting:summarize': async (_event, args) => {
|
'meeting:summarize': async (_event, args) => {
|
||||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime);
|
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||||
return { notes };
|
return { notes };
|
||||||
},
|
},
|
||||||
'inline-task:classifySchedule': async (_event, args) => {
|
'inline-task:classifySchedule': async (_event, args) => {
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||||
import { useMeetingTranscription, type MeetingTranscriptionState } from '@/hooks/useMeetingTranscription'
|
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||||
|
|
||||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||||
type RunEventType = z.infer<typeof RunEvent>
|
type RunEventType = z.infer<typeof RunEvent>
|
||||||
|
|
@ -283,9 +283,12 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten date-based folder hierarchy under Meetings/ source folders.
|
* Organize Meetings/ source folders into date-grouped subfolders.
|
||||||
* Turns Meetings/granola/2026/03/15/Title.md into a flat list under
|
*
|
||||||
* Meetings/granola/ with display name "2026-03-15 Title".
|
* - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is
|
||||||
|
* - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders
|
||||||
|
* - Files directly under a source folder (no date subfolder) are grouped
|
||||||
|
* by the date prefix in their filename (e.g. meeting-2026-03-17T...).
|
||||||
*/
|
*/
|
||||||
function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
||||||
return nodes.flatMap(node => {
|
return nodes.flatMap(node => {
|
||||||
|
|
@ -294,14 +297,18 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
||||||
const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
|
const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
|
||||||
if (sourceNode.kind !== 'dir') return [sourceNode]
|
if (sourceNode.kind !== 'dir') return [sourceNode]
|
||||||
|
|
||||||
// Collect all files recursively from the date hierarchy
|
// Collect all files with their date group label
|
||||||
const files: TreeNode[] = []
|
const dateGroups = new Map<string, TreeNode[]>()
|
||||||
|
|
||||||
function collectFiles(n: TreeNode, dateParts: string[]) {
|
function collectFiles(n: TreeNode, dateParts: string[]) {
|
||||||
for (const child of n.children ?? []) {
|
for (const child of n.children ?? []) {
|
||||||
if (child.kind === 'file') {
|
if (child.kind === 'file') {
|
||||||
const dateStr = dateParts.join('-')
|
const dateStr = dateParts.join('-')
|
||||||
const displayName = dateStr ? `${dateStr} ${child.name}` : child.name
|
// If file is at root of source folder, try to extract date from filename
|
||||||
files.push({ ...child, name: displayName })
|
const groupKey = dateStr || extractDateFromFilename(child.name) || 'other'
|
||||||
|
const group = dateGroups.get(groupKey) ?? []
|
||||||
|
group.push(child)
|
||||||
|
dateGroups.set(groupKey, group)
|
||||||
} else if (child.kind === 'dir') {
|
} else if (child.kind === 'dir') {
|
||||||
collectFiles(child, [...dateParts, child.name])
|
collectFiles(child, [...dateParts, child.name])
|
||||||
}
|
}
|
||||||
|
|
@ -309,13 +316,24 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
||||||
}
|
}
|
||||||
collectFiles(sourceNode, [])
|
collectFiles(sourceNode, [])
|
||||||
|
|
||||||
// Hide empty source folders
|
if (dateGroups.size === 0) return []
|
||||||
if (files.length === 0) return []
|
|
||||||
|
|
||||||
// Sort files reverse chronologically (newest first)
|
// Build date folder nodes, sorted reverse chronologically
|
||||||
|
const dateFolderNodes: TreeNode[] = [...dateGroups.entries()]
|
||||||
|
.sort(([a], [b]) => b.localeCompare(a))
|
||||||
|
.map(([dateKey, files]) => {
|
||||||
|
// Sort files within each date group reverse chronologically
|
||||||
files.sort((a, b) => b.name.localeCompare(a.name))
|
files.sort((a, b) => b.name.localeCompare(a.name))
|
||||||
|
return {
|
||||||
|
name: dateKey,
|
||||||
|
path: `${sourceNode.path}/${dateKey}`,
|
||||||
|
kind: 'dir' as const,
|
||||||
|
children: files,
|
||||||
|
loaded: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return [{ ...sourceNode, children: files }]
|
return [{ ...sourceNode, children: dateFolderNodes }]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hide Meetings folder entirely if no source folders have files
|
// Hide Meetings folder entirely if no source folders have files
|
||||||
|
|
@ -325,6 +343,12 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */
|
||||||
|
function extractDateFromFilename(name: string): string | null {
|
||||||
|
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
// Build tree structure from flat entries
|
// Build tree structure from flat entries
|
||||||
function buildTree(entries: DirEntry[]): TreeNode[] {
|
function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||||
const treeMap = new Map<string, TreeNode>()
|
const treeMap = new Map<string, TreeNode>()
|
||||||
|
|
@ -3362,13 +3386,16 @@ function App() {
|
||||||
}, [loadDirectory, navigateToFile, fileTabs])
|
}, [loadDirectory, navigateToFile, fileTabs])
|
||||||
|
|
||||||
const meetingNotePathRef = useRef<string | null>(null)
|
const meetingNotePathRef = useRef<string | null>(null)
|
||||||
|
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
|
||||||
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
|
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
|
||||||
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
|
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
|
||||||
|
|
||||||
const startMeetingAfterPermissions = useCallback(async () => {
|
const startMeetingAfterPermissions = useCallback(async () => {
|
||||||
setShowMeetingPermissions(false)
|
setShowMeetingPermissions(false)
|
||||||
localStorage.setItem('meeting-permissions-acknowledged', '1')
|
localStorage.setItem('meeting-permissions-acknowledged', '1')
|
||||||
const notePath = await meetingTranscription.start()
|
const calEvent = pendingCalendarEventRef.current
|
||||||
|
pendingCalendarEventRef.current = undefined
|
||||||
|
const notePath = await meetingTranscription.start(calEvent)
|
||||||
if (notePath) {
|
if (notePath) {
|
||||||
meetingNotePathRef.current = notePath
|
meetingNotePathRef.current = notePath
|
||||||
await handleVoiceNoteCreated(notePath)
|
await handleVoiceNoteCreated(notePath)
|
||||||
|
|
@ -3387,16 +3414,25 @@ function App() {
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
|
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
|
||||||
const fileContent = result.data
|
const fileContent = result.data
|
||||||
if (fileContent && fileContent.trim()) {
|
if (fileContent && fileContent.trim()) {
|
||||||
// Extract meeting start time from frontmatter for calendar matching
|
// Extract meeting start time and calendar event from frontmatter
|
||||||
const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m)
|
const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m)
|
||||||
const meetingStartTime = dateMatch?.[1]
|
const meetingStartTime = dateMatch?.[1]
|
||||||
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime })
|
// If a calendar event was linked, pass it directly so the summarizer
|
||||||
|
// skips scanning and uses this event for attendee/title info.
|
||||||
|
const calEventMatch = fileContent.match(/^calendar_event:\s*'(.+)'$/m)
|
||||||
|
const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'")
|
||||||
|
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson })
|
||||||
if (notes) {
|
if (notes) {
|
||||||
// Prepend meeting notes below the title but above the transcript
|
// Prepend meeting notes below the title but above the transcript
|
||||||
const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent)
|
const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent)
|
||||||
// Strip the "# Meeting note" title from transcript body — we'll put it first
|
// Use frontmatter title as the heading (set from calendar event summary)
|
||||||
const bodyWithoutTitle = transcriptBody.replace(/^#\s+Meeting note\s*\n*/, '')
|
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
|
||||||
const newBody = '# Meeting note\n\n' + notes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle
|
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting note'
|
||||||
|
// Strip any existing top-level heading from body
|
||||||
|
const bodyWithoutTitle = transcriptBody.replace(/^#\s+.+\s*\n*/, '')
|
||||||
|
// Also strip any title/heading the LLM may have generated
|
||||||
|
const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '')
|
||||||
|
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle
|
||||||
const newContent = fm ? `${fm}\n${newBody}` : newBody
|
const newContent = fm ? `${fm}\n${newBody}` : newBody
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
path: notePath,
|
path: notePath,
|
||||||
|
|
@ -3419,7 +3455,9 @@ function App() {
|
||||||
setShowMeetingPermissions(true)
|
setShowMeetingPermissions(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const notePath = await meetingTranscription.start()
|
const calEvent = pendingCalendarEventRef.current
|
||||||
|
pendingCalendarEventRef.current = undefined
|
||||||
|
const notePath = await meetingTranscription.start(calEvent)
|
||||||
if (notePath) {
|
if (notePath) {
|
||||||
meetingNotePathRef.current = notePath
|
meetingNotePathRef.current = notePath
|
||||||
await handleVoiceNoteCreated(notePath)
|
await handleVoiceNoteCreated(notePath)
|
||||||
|
|
@ -3428,6 +3466,30 @@ function App() {
|
||||||
}, [meetingTranscription, handleVoiceNoteCreated])
|
}, [meetingTranscription, handleVoiceNoteCreated])
|
||||||
handleToggleMeetingRef.current = handleToggleMeeting
|
handleToggleMeetingRef.current = handleToggleMeeting
|
||||||
|
|
||||||
|
// Listen for calendar block "join meeting & take notes" events
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
// Read calendar event data set by the calendar block on window
|
||||||
|
const pending = window.__pendingCalendarEvent
|
||||||
|
window.__pendingCalendarEvent = undefined
|
||||||
|
if (pending) {
|
||||||
|
pendingCalendarEventRef.current = {
|
||||||
|
summary: pending.summary,
|
||||||
|
start: pending.start,
|
||||||
|
end: pending.end,
|
||||||
|
location: pending.location,
|
||||||
|
htmlLink: pending.htmlLink,
|
||||||
|
conferenceLink: pending.conferenceLink,
|
||||||
|
source: pending.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use the same toggle flow — it will pick up pendingCalendarEventRef
|
||||||
|
handleToggleMeetingRef.current?.()
|
||||||
|
}
|
||||||
|
window.addEventListener('calendar-block:join-meeting', handler)
|
||||||
|
return () => window.removeEventListener('calendar-block:join-meeting', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||||
const resolvedPath = toKnowledgePath(wikiPath)
|
const resolvedPath = toKnowledgePath(wikiPath)
|
||||||
if (!resolvedPath) return null
|
if (!resolvedPath) return null
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ import { ImageBlockExtension } from '@/extensions/image-block'
|
||||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||||
import { TableBlockExtension } from '@/extensions/table-block'
|
import { TableBlockExtension } from '@/extensions/table-block'
|
||||||
|
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||||
import { Markdown } from 'tiptap-markdown'
|
import { Markdown } from 'tiptap-markdown'
|
||||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
// Zero-width space used as invisible marker for blank lines
|
// Zero-width space used as invisible marker for blank lines
|
||||||
const BLANK_LINE_MARKER = '\u200B'
|
const BLANK_LINE_MARKER = '\u200B'
|
||||||
|
|
@ -148,6 +150,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
} else if (node.type === 'tableBlock') {
|
} else if (node.type === 'tableBlock') {
|
||||||
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
|
} else if (node.type === 'calendarBlock') {
|
||||||
|
blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
} else if (node.type === 'codeBlock') {
|
} else if (node.type === 'codeBlock') {
|
||||||
const lang = (node.attrs?.language as string) || ''
|
const lang = (node.attrs?.language as string) || ''
|
||||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||||
|
|
@ -218,6 +222,117 @@ type WikiLinkConfig = {
|
||||||
onCreate: (path: string) => void | Promise<void>
|
onCreate: (path: string) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Meeting Event Banner ---
|
||||||
|
|
||||||
|
interface ParsedCalendarEvent {
|
||||||
|
summary?: string
|
||||||
|
start?: string
|
||||||
|
end?: string
|
||||||
|
location?: string
|
||||||
|
htmlLink?: string
|
||||||
|
conferenceLink?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCalendarEvent(raw: string | undefined): ParsedCalendarEvent | null {
|
||||||
|
if (!raw) return null
|
||||||
|
// Strip surrounding quotes if present (YAML single-quoted string)
|
||||||
|
let json = raw
|
||||||
|
if ((json.startsWith("'") && json.endsWith("'")) || (json.startsWith('"') && json.endsWith('"'))) {
|
||||||
|
json = json.slice(1, -1)
|
||||||
|
}
|
||||||
|
// Unescape doubled single quotes from YAML
|
||||||
|
json = json.replace(/''/g, "'")
|
||||||
|
try {
|
||||||
|
return JSON.parse(json) as ParsedCalendarEvent
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(start?: string, end?: string): string {
|
||||||
|
if (!start) return ''
|
||||||
|
const s = new Date(start)
|
||||||
|
const startStr = s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
const startTime = s.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
if (!end) return `${startStr} \u00b7 ${startTime}`
|
||||||
|
const e = new Date(end)
|
||||||
|
const endTime = e.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
return `${startStr} \u00b7 ${startTime} \u2013 ${endTime}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventDate(start?: string): string {
|
||||||
|
if (!start) return ''
|
||||||
|
const s = new Date(start)
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const tomorrow = new Date(today)
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
|
||||||
|
if (s.toDateString() === today.toDateString()) return 'Today'
|
||||||
|
if (s.toDateString() === yesterday.toDateString()) return 'Yesterday'
|
||||||
|
if (s.toDateString() === tomorrow.toDateString()) return 'Tomorrow'
|
||||||
|
return s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeetingEventBanner({ frontmatter }: { frontmatter: string | null | undefined }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!frontmatter) return null
|
||||||
|
const fields = extractAllFrontmatterValues(frontmatter)
|
||||||
|
if (fields.type !== 'meeting') return null
|
||||||
|
|
||||||
|
const calStr = typeof fields.calendar_event === 'string' ? fields.calendar_event : undefined
|
||||||
|
const cal = parseCalendarEvent(calStr)
|
||||||
|
if (!cal) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meeting-event-banner" ref={ref}>
|
||||||
|
<button
|
||||||
|
className="meeting-event-pill"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<Calendar size={13} />
|
||||||
|
{formatEventDate(cal.start)}
|
||||||
|
<ChevronDown size={12} className={`meeting-event-chevron ${open ? 'meeting-event-chevron-open' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="meeting-event-dropdown">
|
||||||
|
<div className="meeting-event-dropdown-header">
|
||||||
|
<span className="meeting-event-dropdown-dot" />
|
||||||
|
<div className="meeting-event-dropdown-info">
|
||||||
|
<div className="meeting-event-dropdown-title">{cal.summary || 'Meeting'}</div>
|
||||||
|
<div className="meeting-event-dropdown-time">{formatEventTime(cal.start, cal.end)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cal.htmlLink && (
|
||||||
|
<button
|
||||||
|
className="meeting-event-dropdown-link"
|
||||||
|
onClick={() => window.open(cal.htmlLink, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
Open in Google Calendar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Editor ---
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
content: string
|
content: string
|
||||||
onChange: (markdown: string) => void
|
onChange: (markdown: string) => void
|
||||||
|
|
@ -447,6 +562,7 @@ export function MarkdownEditor({
|
||||||
EmbedBlockExtension,
|
EmbedBlockExtension,
|
||||||
ChartBlockExtension,
|
ChartBlockExtension,
|
||||||
TableBlockExtension,
|
TableBlockExtension,
|
||||||
|
CalendarBlockExtension,
|
||||||
WikiLink.configure({
|
WikiLink.configure({
|
||||||
onCreate: wikiLinks?.onCreate
|
onCreate: wikiLinks?.onCreate
|
||||||
? (path) => {
|
? (path) => {
|
||||||
|
|
@ -1201,6 +1317,7 @@ export function MarkdownEditor({
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MeetingEventBanner frontmatter={frontmatter} />
|
||||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{wikiLinks ? (
|
{wikiLinks ? (
|
||||||
|
|
|
||||||
330
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
330
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import { mergeAttributes, Node } from '@tiptap/react'
|
||||||
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import { X, Calendar, Video } from 'lucide-react'
|
||||||
|
import { blocks } from '@x/shared'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateParts(dateStr: string): { day: number; month: string; weekday: string } {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return {
|
||||||
|
day: d.getDate(),
|
||||||
|
month: d.toLocaleDateString([], { month: 'long' }),
|
||||||
|
weekday: d.toLocaleDateString([], { weekday: 'short' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventDate(event: blocks.CalendarEvent): string {
|
||||||
|
return event.start?.dateTime || event.start?.date || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllDay(event: blocks.CalendarEvent): boolean {
|
||||||
|
return !event.start?.dateTime && !!event.start?.date
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeRange(event: blocks.CalendarEvent): string {
|
||||||
|
if (isAllDay(event)) return 'All day'
|
||||||
|
const start = event.start?.dateTime
|
||||||
|
const end = event.end?.dateTime
|
||||||
|
if (!start) return ''
|
||||||
|
const startTime = formatTime(start)
|
||||||
|
if (!end) return startTime
|
||||||
|
const endTime = formatTime(end)
|
||||||
|
return `${startTime} \u2013 ${endTime}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a video conference link from raw Google Calendar event JSON.
|
||||||
|
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||||
|
* to conferenceLink if already set.
|
||||||
|
*/
|
||||||
|
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||||
|
// Check conferenceData.entryPoints for video entry
|
||||||
|
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||||
|
if (confData?.entryPoints) {
|
||||||
|
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||||
|
if (video?.uri) return video.uri
|
||||||
|
}
|
||||||
|
// Check hangoutLink (Google Meet shortcut)
|
||||||
|
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||||
|
// Fall back to conferenceLink if present
|
||||||
|
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedEvent {
|
||||||
|
event: blocks.CalendarEvent
|
||||||
|
loaded: blocks.CalendarEvent | null
|
||||||
|
conferenceLink?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_BAR_COLOR = '#7ec8c8'
|
||||||
|
|
||||||
|
// Shared global to pass calendar event data to App.tsx when joining a meeting.
|
||||||
|
// Set before dispatching the custom event, read by the handler in App.tsx.
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__pendingCalendarEvent?: {
|
||||||
|
summary?: string
|
||||||
|
start?: { dateTime?: string; date?: string }
|
||||||
|
end?: { dateTime?: string; date?: string }
|
||||||
|
location?: string
|
||||||
|
htmlLink?: string
|
||||||
|
conferenceLink?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||||
|
const raw = node.attrs.data as string
|
||||||
|
let config: blocks.CalendarBlock | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
config = blocks.CalendarBlockSchema.parse(JSON.parse(raw))
|
||||||
|
} catch {
|
||||||
|
// fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
const [resolvedEvents, setResolvedEvents] = useState<ResolvedEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return
|
||||||
|
|
||||||
|
const eventsWithSources = config.events.filter(e => e.source)
|
||||||
|
if (eventsWithSources.length === 0) {
|
||||||
|
setResolvedEvents(config.events.map(e => ({ event: e, loaded: null })))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<{ data: string }> } }).ipc
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
config.events.map(async (event): Promise<ResolvedEvent> => {
|
||||||
|
if (!event.source) return { event, loaded: null }
|
||||||
|
try {
|
||||||
|
const result = await ipc.invoke('workspace:readFile', { path: event.source, encoding: 'utf8' })
|
||||||
|
const content = typeof result === 'string' ? result : result.data
|
||||||
|
const rawEvent = JSON.parse(content) as Record<string, unknown>
|
||||||
|
const parsed = blocks.CalendarEventSchema.parse(rawEvent)
|
||||||
|
const conferenceLink = extractConferenceLink(rawEvent)
|
||||||
|
return { event, loaded: parsed, conferenceLink }
|
||||||
|
} catch {
|
||||||
|
return { event, loaded: null }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).then(results => {
|
||||||
|
setResolvedEvents(results)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [raw])
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
|
||||||
|
<div className="calendar-block-card calendar-block-error">
|
||||||
|
<Calendar size={16} />
|
||||||
|
<span>Invalid calendar block</span>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showJoinButton = config.showJoinButton === true
|
||||||
|
|
||||||
|
const events = resolvedEvents.map(r => {
|
||||||
|
const e = r.loaded || r.event
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
htmlLink: e.htmlLink || r.event.htmlLink,
|
||||||
|
conferenceLink: r.conferenceLink || e.conferenceLink || r.event.conferenceLink,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group events by date
|
||||||
|
const dateGroups: { dateKey: string; dateStr: string; events: (blocks.CalendarEvent & { _idx: number; conferenceLink?: string })[] }[] = []
|
||||||
|
|
||||||
|
let globalIdx = 0
|
||||||
|
for (const event of events) {
|
||||||
|
const dateStr = getEventDate(event)
|
||||||
|
const dateKey = dateStr ? new Date(dateStr).toDateString() : 'Unknown'
|
||||||
|
|
||||||
|
let group = dateGroups.find(g => g.dateKey === dateKey)
|
||||||
|
if (!group) {
|
||||||
|
group = { dateKey, dateStr, events: [] }
|
||||||
|
dateGroups.push(group)
|
||||||
|
}
|
||||||
|
group.events.push({ ...event, _idx: globalIdx++ })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventClick = (event: blocks.CalendarEvent) => {
|
||||||
|
if (event.htmlLink) {
|
||||||
|
window.open(event.htmlLink, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoinMeeting = (event: blocks.CalendarEvent & { conferenceLink?: string }, resolvedIdx: number) => {
|
||||||
|
const meetingUrl = event.conferenceLink
|
||||||
|
if (meetingUrl) {
|
||||||
|
window.open(meetingUrl, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the original source path from config
|
||||||
|
const originalEvent = config!.events[resolvedIdx]
|
||||||
|
|
||||||
|
// Set calendar event data on window so App.tsx handler can read it
|
||||||
|
window.__pendingCalendarEvent = {
|
||||||
|
summary: event.summary,
|
||||||
|
start: event.start,
|
||||||
|
end: event.end,
|
||||||
|
location: event.location,
|
||||||
|
htmlLink: event.htmlLink,
|
||||||
|
conferenceLink: event.conferenceLink,
|
||||||
|
source: originalEvent?.source,
|
||||||
|
}
|
||||||
|
// Dispatch custom event so App.tsx can start meeting transcription
|
||||||
|
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
|
||||||
|
<div className="calendar-block-card">
|
||||||
|
<button
|
||||||
|
className="calendar-block-delete"
|
||||||
|
onClick={deleteNode}
|
||||||
|
aria-label="Delete calendar block"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
{config.title && <div className="calendar-block-title">{config.title}</div>}
|
||||||
|
{loading ? (
|
||||||
|
<div className="calendar-block-loading">Loading events...</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className="calendar-block-empty">No events</div>
|
||||||
|
) : (
|
||||||
|
<div className="calendar-block-list">
|
||||||
|
{dateGroups.map((group, groupIdx) => {
|
||||||
|
const parts = group.dateStr ? getDateParts(group.dateStr) : null
|
||||||
|
return (
|
||||||
|
<div key={group.dateKey} className="calendar-block-date-group">
|
||||||
|
{groupIdx > 0 && <div className="calendar-block-separator" />}
|
||||||
|
<div className="calendar-block-date-row">
|
||||||
|
<div className="calendar-block-date-left">
|
||||||
|
{parts ? (
|
||||||
|
<>
|
||||||
|
<span className="calendar-block-day">{parts.day}</span>
|
||||||
|
<div className="calendar-block-month-weekday">
|
||||||
|
<span className="calendar-block-month">{parts.month}</span>
|
||||||
|
<span className="calendar-block-weekday">{parts.weekday}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="calendar-block-day">?</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="calendar-block-events">
|
||||||
|
{group.events.map(event => (
|
||||||
|
<div
|
||||||
|
key={event._idx}
|
||||||
|
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="calendar-block-event-bar"
|
||||||
|
style={{ backgroundColor: EVENT_BAR_COLOR }}
|
||||||
|
/>
|
||||||
|
<div className="calendar-block-event-content">
|
||||||
|
<div className="calendar-block-event-title">
|
||||||
|
{event.summary || 'Untitled event'}
|
||||||
|
</div>
|
||||||
|
<div className="calendar-block-event-time">
|
||||||
|
{getTimeRange(event)}
|
||||||
|
</div>
|
||||||
|
{showJoinButton && event.conferenceLink && (
|
||||||
|
<button
|
||||||
|
className="calendar-block-join-btn"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleJoinMeeting(event, event._idx) }}
|
||||||
|
>
|
||||||
|
<Video size={13} />
|
||||||
|
Join meeting & take notes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CalendarBlockExtension = Node.create({
|
||||||
|
name: 'calendarBlock',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
default: '{}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'pre',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs(element) {
|
||||||
|
const code = element.querySelector('code')
|
||||||
|
if (!code) return false
|
||||||
|
const cls = code.className || ''
|
||||||
|
if (cls.includes('language-calendar')) {
|
||||||
|
return { data: code.textContent || '{}' }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||||
|
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'calendar-block' })]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(CalendarBlockView)
|
||||||
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
markdown: {
|
||||||
|
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||||
|
state.write('```calendar\n' + node.attrs.data + '\n```')
|
||||||
|
state.closeBlock(node)
|
||||||
|
},
|
||||||
|
parse: {
|
||||||
|
// handled by parseHTML
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -47,18 +47,46 @@ interface TranscriptEntry {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTranscript(entries: TranscriptEntry[], date: string): string {
|
export interface CalendarEventMeta {
|
||||||
|
summary?: string
|
||||||
|
start?: { dateTime?: string; date?: string }
|
||||||
|
end?: { dateTime?: string; date?: string }
|
||||||
|
location?: string
|
||||||
|
htmlLink?: string
|
||||||
|
conferenceLink?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
|
||||||
|
const noteTitle = calendarEvent?.summary || 'Meeting note';
|
||||||
const lines = [
|
const lines = [
|
||||||
'---',
|
'---',
|
||||||
'type: meeting',
|
'type: meeting',
|
||||||
'source: rowboat',
|
'source: rowboat',
|
||||||
'title: Meeting note',
|
`title: ${noteTitle}`,
|
||||||
`date: "${date}"`,
|
`date: "${date}"`,
|
||||||
|
];
|
||||||
|
if (calendarEvent) {
|
||||||
|
// Serialize as a JSON string on one line — the frontmatter system
|
||||||
|
// only supports flat key: value pairs, not nested YAML objects.
|
||||||
|
const eventObj: Record<string, string> = {}
|
||||||
|
if (calendarEvent.summary) eventObj.summary = calendarEvent.summary
|
||||||
|
if (calendarEvent.start?.dateTime) eventObj.start = calendarEvent.start.dateTime
|
||||||
|
else if (calendarEvent.start?.date) eventObj.start = calendarEvent.start.date
|
||||||
|
if (calendarEvent.end?.dateTime) eventObj.end = calendarEvent.end.dateTime
|
||||||
|
else if (calendarEvent.end?.date) eventObj.end = calendarEvent.end.date
|
||||||
|
if (calendarEvent.location) eventObj.location = calendarEvent.location
|
||||||
|
if (calendarEvent.htmlLink) eventObj.htmlLink = calendarEvent.htmlLink
|
||||||
|
if (calendarEvent.conferenceLink) eventObj.conferenceLink = calendarEvent.conferenceLink
|
||||||
|
if (calendarEvent.source) eventObj.source = calendarEvent.source
|
||||||
|
lines.push(`calendar_event: '${JSON.stringify(eventObj).replace(/'/g, "''")}'`)
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
'---',
|
'---',
|
||||||
'',
|
'',
|
||||||
'# Meeting note',
|
`# ${noteTitle}`,
|
||||||
'',
|
'',
|
||||||
];
|
);
|
||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
@ -87,6 +115,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
const onAutoStopRef = useRef(onAutoStop);
|
const onAutoStopRef = useRef(onAutoStop);
|
||||||
onAutoStopRef.current = onAutoStop;
|
onAutoStopRef.current = onAutoStop;
|
||||||
const dateRef = useRef<string>('');
|
const dateRef = useRef<string>('');
|
||||||
|
const calendarEventRef = useRef<CalendarEventMeta | undefined>(undefined);
|
||||||
|
|
||||||
const writeTranscriptToFile = useCallback(async () => {
|
const writeTranscriptToFile = useCallback(async () => {
|
||||||
if (!notePathRef.current) return;
|
if (!notePathRef.current) return;
|
||||||
|
|
@ -100,7 +129,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
const content = formatTranscript(entries, dateRef.current);
|
const content = formatTranscript(entries, dateRef.current, calendarEventRef.current);
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
path: notePathRef.current,
|
path: notePathRef.current,
|
||||||
|
|
@ -151,7 +180,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const start = useCallback(async (): Promise<string | null> => {
|
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||||
if (state !== 'idle') return null;
|
if (state !== 'idle') return null;
|
||||||
setState('connecting');
|
setState('connecting');
|
||||||
|
|
||||||
|
|
@ -345,10 +374,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
dateRef.current = dateStr;
|
dateRef.current = dateStr;
|
||||||
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
|
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
|
||||||
const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, '');
|
const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, '');
|
||||||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/meeting-${timestamp}.md`;
|
const filename = calendarEvent?.summary
|
||||||
|
? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim()
|
||||||
|
: `meeting-${timestamp}`;
|
||||||
|
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
|
||||||
notePathRef.current = notePath;
|
notePathRef.current = notePath;
|
||||||
|
calendarEventRef.current = calendarEvent;
|
||||||
const initialContent = formatTranscript([], dateStr);
|
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
path: notePath,
|
path: notePath,
|
||||||
data: initialContent,
|
data: initialContent,
|
||||||
|
|
|
||||||
|
|
@ -616,14 +616,16 @@
|
||||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||||
.tiptap-editor .ProseMirror .table-block-wrapper {
|
.tiptap-editor .ProseMirror .table-block-wrapper,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-wrapper {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-card,
|
.tiptap-editor .ProseMirror .image-block-card,
|
||||||
.tiptap-editor .ProseMirror .embed-block-card,
|
.tiptap-editor .ProseMirror .embed-block-card,
|
||||||
.tiptap-editor .ProseMirror .chart-block-card,
|
.tiptap-editor .ProseMirror .chart-block-card,
|
||||||
.tiptap-editor .ProseMirror .table-block-card {
|
.tiptap-editor .ProseMirror .table-block-card,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -636,14 +638,16 @@
|
||||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||||
.tiptap-editor .ProseMirror .table-block-card:hover {
|
.tiptap-editor .ProseMirror .table-block-card:hover,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-card:hover {
|
||||||
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card {
|
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card {
|
||||||
outline: 2px solid var(--primary);
|
outline: 2px solid var(--primary);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +655,8 @@
|
||||||
.tiptap-editor .ProseMirror .image-block-delete,
|
.tiptap-editor .ProseMirror .image-block-delete,
|
||||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||||
.tiptap-editor .ProseMirror .table-block-delete {
|
.tiptap-editor .ProseMirror .table-block-delete,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-delete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
|
|
@ -673,14 +678,16 @@
|
||||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete {
|
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||||
.tiptap-editor .ProseMirror .table-block-delete:hover {
|
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-delete:hover {
|
||||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
@ -836,6 +843,264 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calendar block */
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-loading,
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-date-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-date-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-date-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
width: 140px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-day {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-month-weekday {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-month {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-weekday {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-events {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event-clickable:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event-bar {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-event-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-join-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #7ec8c8;
|
||||||
|
background: color-mix(in srgb, #7ec8c8 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .calendar-block-join-btn:hover {
|
||||||
|
background: color-mix(in srgb, #7ec8c8 22%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #7ec8c8 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meeting event banner */
|
||||||
|
.meeting-event-banner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-pill:hover {
|
||||||
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-chevron {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-chevron-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 20px;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 260px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #7ec8c8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event-dropdown-link:hover {
|
||||||
|
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode overrides */
|
/* Dark mode overrides */
|
||||||
.dark .tiptap-editor .ProseMirror {
|
.dark .tiptap-editor .ProseMirror {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting tr
|
||||||
|
|
||||||
## Calendar matching
|
## Calendar matching
|
||||||
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
|
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
|
||||||
- Use the calendar event title as the meeting title (output it as the first line: "## <event title>")
|
- Do NOT output a title or heading — the title is already set by the caller.
|
||||||
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
|
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
|
||||||
- "You" in the transcript is the local user — if the calendar event has an organizer or you can identify who "You" is from context, use their name.
|
- "You" in the transcript is the local user — if the calendar event has an organizer or you can identify who "You" is from context, use their name.
|
||||||
|
|
||||||
If no calendar event matches with high confidence, or if no calendar events are provided, skip the title line and use "They" for all non-"You" speakers.
|
If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers.
|
||||||
|
|
||||||
## Format rules
|
## Format rules
|
||||||
|
- Do NOT output a title or top-level heading (# or ##). Start directly with section content.
|
||||||
- Use ### for section headers that group related discussion topics
|
- Use ### for section headers that group related discussion topics
|
||||||
- Section headers should be in sentence case (e.g. "### Onboarding flow status"), NOT Title Case
|
- Section headers should be in sentence case (e.g. "### Onboarding flow status"), NOT Title Case
|
||||||
- Use bullet points with sub-bullets for details
|
- Use bullet points with sub-bullets for details
|
||||||
|
|
@ -83,13 +84,70 @@ function loadRecentCalendarEvents(meetingTime: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function summarizeMeeting(transcript: string, meetingStartTime?: string): Promise<string> {
|
/**
|
||||||
|
* Load a specific calendar event from the calendar_sync directory using
|
||||||
|
* the calendar_event JSON stored in the meeting note frontmatter.
|
||||||
|
* If a `source` field is present, loads the full event file for richer
|
||||||
|
* details (attendees, organizer, etc.).
|
||||||
|
*/
|
||||||
|
function loadCalendarEventContext(calendarEventJson: string): string {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(calendarEventJson) as {
|
||||||
|
summary?: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
location?: string;
|
||||||
|
htmlLink?: string;
|
||||||
|
conferenceLink?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to load the full event from source file for attendee info
|
||||||
|
let attendees = '';
|
||||||
|
let organizer = '';
|
||||||
|
if (meta.source) {
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(WorkDir, meta.source);
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
const event = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
||||||
|
attendees = (event.attendees || [])
|
||||||
|
.map((a: { displayName?: string; email?: string }) => a.displayName || a.email)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
organizer = event.organizer?.displayName || event.organizer?.email || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through — use metadata only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventStr =
|
||||||
|
`- Title: ${meta.summary || 'Untitled'}\n` +
|
||||||
|
` Start: ${meta.start || ''}\n` +
|
||||||
|
` End: ${meta.end || ''}\n` +
|
||||||
|
` Organizer: ${organizer || 'unknown'}\n` +
|
||||||
|
` Attendees: ${attendees || 'none listed'}`;
|
||||||
|
|
||||||
|
return `\n\n## Calendar event for this meeting\n\n${eventStr}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
|
||||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||||
const config = await repo.getConfig();
|
const config = await repo.getConfig();
|
||||||
const provider = createProvider(config.provider);
|
const provider = createProvider(config.provider);
|
||||||
const model = provider.languageModel(config.model);
|
const model = provider.languageModel(config.model);
|
||||||
|
|
||||||
const calendarContext = meetingStartTime ? loadRecentCalendarEvents(meetingStartTime) : '';
|
// If a specific calendar event was linked, use it directly.
|
||||||
|
// Otherwise fall back to scanning events within ±3 hours.
|
||||||
|
let calendarContext: string;
|
||||||
|
if (calendarEventJson) {
|
||||||
|
calendarContext = loadCalendarEventContext(calendarEventJson);
|
||||||
|
} else {
|
||||||
|
calendarContext = meetingStartTime ? loadRecentCalendarEvents(meetingStartTime) : '';
|
||||||
|
}
|
||||||
|
|
||||||
const prompt = `Meeting recording started at: ${meetingStartTime || 'unknown'}\n\n${transcript}${calendarContext}`;
|
const prompt = `Meeting recording started at: ${meetingStartTime || 'unknown'}\n\n${transcript}${calendarContext}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,29 @@ export const TableBlockSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TableBlock = z.infer<typeof TableBlockSchema>;
|
export type TableBlock = z.infer<typeof TableBlockSchema>;
|
||||||
|
|
||||||
|
export const CalendarEventSchema = z.object({
|
||||||
|
summary: z.string().optional(),
|
||||||
|
start: z.object({
|
||||||
|
dateTime: z.string().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
end: z.object({
|
||||||
|
dateTime: z.string().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
location: z.string().optional(),
|
||||||
|
htmlLink: z.string().optional(),
|
||||||
|
conferenceLink: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarEvent = z.infer<typeof CalendarEventSchema>;
|
||||||
|
|
||||||
|
export const CalendarBlockSchema = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
events: z.array(CalendarEventSchema),
|
||||||
|
showJoinButton: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,7 @@ const ipcSchemas = {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
transcript: z.string(),
|
transcript: z.string(),
|
||||||
meetingStartTime: z.string().optional(),
|
meetingStartTime: z.string().optional(),
|
||||||
|
calendarEventJson: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue