mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
cal block initial commit
This commit is contained in:
parent
484231b224
commit
c03882f43f
9 changed files with 932 additions and 41 deletions
|
|
@ -703,7 +703,7 @@ export function setupIpcHandlers() {
|
|||
return { success: false, error: 'Unknown format' };
|
||||
},
|
||||
'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 };
|
||||
},
|
||||
'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 { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||
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 RunEventType = z.infer<typeof RunEvent>
|
||||
|
|
@ -283,9 +283,12 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Flatten date-based folder hierarchy under Meetings/ source folders.
|
||||
* Turns Meetings/granola/2026/03/15/Title.md into a flat list under
|
||||
* Meetings/granola/ with display name "2026-03-15 Title".
|
||||
* Organize Meetings/ source folders into date-grouped subfolders.
|
||||
*
|
||||
* - 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[] {
|
||||
return nodes.flatMap(node => {
|
||||
|
|
@ -294,14 +297,18 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
|||
const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
|
||||
if (sourceNode.kind !== 'dir') return [sourceNode]
|
||||
|
||||
// Collect all files recursively from the date hierarchy
|
||||
const files: TreeNode[] = []
|
||||
// Collect all files with their date group label
|
||||
const dateGroups = new Map<string, TreeNode[]>()
|
||||
|
||||
function collectFiles(n: TreeNode, dateParts: string[]) {
|
||||
for (const child of n.children ?? []) {
|
||||
if (child.kind === 'file') {
|
||||
const dateStr = dateParts.join('-')
|
||||
const displayName = dateStr ? `${dateStr} ${child.name}` : child.name
|
||||
files.push({ ...child, name: displayName })
|
||||
// If file is at root of source folder, try to extract date from filename
|
||||
const groupKey = dateStr || extractDateFromFilename(child.name) || 'other'
|
||||
const group = dateGroups.get(groupKey) ?? []
|
||||
group.push(child)
|
||||
dateGroups.set(groupKey, group)
|
||||
} else if (child.kind === 'dir') {
|
||||
collectFiles(child, [...dateParts, child.name])
|
||||
}
|
||||
|
|
@ -309,13 +316,24 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
|||
}
|
||||
collectFiles(sourceNode, [])
|
||||
|
||||
// Hide empty source folders
|
||||
if (files.length === 0) return []
|
||||
if (dateGroups.size === 0) return []
|
||||
|
||||
// Sort files reverse chronologically (newest first)
|
||||
files.sort((a, b) => b.name.localeCompare(a.name))
|
||||
// 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))
|
||||
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
|
||||
|
|
@ -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
|
||||
function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||
const treeMap = new Map<string, TreeNode>()
|
||||
|
|
@ -3362,13 +3386,16 @@ function App() {
|
|||
}, [loadDirectory, navigateToFile, fileTabs])
|
||||
|
||||
const meetingNotePathRef = useRef<string | null>(null)
|
||||
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
|
||||
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
|
||||
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
|
||||
|
||||
const startMeetingAfterPermissions = useCallback(async () => {
|
||||
setShowMeetingPermissions(false)
|
||||
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) {
|
||||
meetingNotePathRef.current = notePath
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
|
|
@ -3387,16 +3414,25 @@ function App() {
|
|||
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
|
||||
const fileContent = result.data
|
||||
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 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) {
|
||||
// Prepend meeting notes below the title but above the transcript
|
||||
const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent)
|
||||
// Strip the "# Meeting note" title from transcript body — we'll put it first
|
||||
const bodyWithoutTitle = transcriptBody.replace(/^#\s+Meeting note\s*\n*/, '')
|
||||
const newBody = '# Meeting note\n\n' + notes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle
|
||||
// Use frontmatter title as the heading (set from calendar event summary)
|
||||
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
|
||||
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
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
|
|
@ -3419,7 +3455,9 @@ function App() {
|
|||
setShowMeetingPermissions(true)
|
||||
return
|
||||
}
|
||||
const notePath = await meetingTranscription.start()
|
||||
const calEvent = pendingCalendarEventRef.current
|
||||
pendingCalendarEventRef.current = undefined
|
||||
const notePath = await meetingTranscription.start(calEvent)
|
||||
if (notePath) {
|
||||
meetingNotePathRef.current = notePath
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
|
|
@ -3428,6 +3466,30 @@ function App() {
|
|||
}, [meetingTranscription, handleVoiceNoteCreated])
|
||||
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 resolvedPath = toKnowledgePath(wikiPath)
|
||||
if (!resolvedPath) return null
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import { ImageBlockExtension } from '@/extensions/image-block'
|
|||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
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
|
||||
const BLANK_LINE_MARKER = '\u200B'
|
||||
|
|
@ -148,6 +150,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'tableBlock') {
|
||||
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') {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
|
|
@ -218,6 +222,117 @@ type WikiLinkConfig = {
|
|||
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 {
|
||||
content: string
|
||||
onChange: (markdown: string) => void
|
||||
|
|
@ -447,6 +562,7 @@ export function MarkdownEditor({
|
|||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
@ -1201,6 +1317,7 @@ export function MarkdownEditor({
|
|||
editable={editable}
|
||||
/>
|
||||
)}
|
||||
<MeetingEventBanner frontmatter={frontmatter} />
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{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;
|
||||
}
|
||||
|
||||
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 = [
|
||||
'---',
|
||||
'type: meeting',
|
||||
'source: rowboat',
|
||||
'title: Meeting note',
|
||||
`title: ${noteTitle}`,
|
||||
`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++) {
|
||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||
lines.push('');
|
||||
|
|
@ -87,6 +115,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const onAutoStopRef = useRef(onAutoStop);
|
||||
onAutoStopRef.current = onAutoStop;
|
||||
const dateRef = useRef<string>('');
|
||||
const calendarEventRef = useRef<CalendarEventMeta | undefined>(undefined);
|
||||
|
||||
const writeTranscriptToFile = useCallback(async () => {
|
||||
if (!notePathRef.current) return;
|
||||
|
|
@ -100,7 +129,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
}
|
||||
}
|
||||
if (entries.length === 0) return;
|
||||
const content = formatTranscript(entries, dateRef.current);
|
||||
const content = formatTranscript(entries, dateRef.current, calendarEventRef.current);
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
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;
|
||||
setState('connecting');
|
||||
|
||||
|
|
@ -345,10 +374,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
dateRef.current = dateStr;
|
||||
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
|
||||
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;
|
||||
|
||||
const initialContent = formatTranscript([], dateStr);
|
||||
calendarEventRef.current = calendarEvent;
|
||||
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: initialContent,
|
||||
|
|
|
|||
|
|
@ -616,14 +616,16 @@
|
|||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-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;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-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;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -636,14 +638,16 @@
|
|||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-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);
|
||||
}
|
||||
|
||||
.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 .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-offset: 1px;
|
||||
}
|
||||
|
|
@ -651,7 +655,8 @@
|
|||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-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;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
|
|
@ -673,14 +678,16 @@
|
|||
.tiptap-editor .ProseMirror .image-block-card:hover .image-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 .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;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-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);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
|
@ -836,6 +843,264 @@
|
|||
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 .tiptap-editor .ProseMirror {
|
||||
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
|
||||
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.
|
||||
- "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
|
||||
- Do NOT output a title or top-level heading (# or ##). Start directly with section content.
|
||||
- Use ### for section headers that group related discussion topics
|
||||
- Section headers should be in sentence case (e.g. "### Onboarding flow status"), NOT Title Case
|
||||
- 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 config = await repo.getConfig();
|
||||
const provider = createProvider(config.provider);
|
||||
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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,3 +34,29 @@ export const TableBlockSchema = z.object({
|
|||
});
|
||||
|
||||
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({
|
||||
transcript: z.string(),
|
||||
meetingStartTime: z.string().optional(),
|
||||
calendarEventJson: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
notes: z.string(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue