cal block initial commit

This commit is contained in:
Arjun 2026-03-20 15:03:34 +05:30 committed by arkml
parent 484231b224
commit c03882f43f
9 changed files with 932 additions and 41 deletions

View file

@ -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) => {

View file

@ -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

View file

@ -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 ? (

View 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
},
},
}
},
})

View file

@ -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,

View file

@ -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);