[Twenty Fireflies] Add calendar event summary and transcript UI components (#22667)
## Summary Add comprehensive UI components and hooks for displaying call recording summaries and transcripts on calendar event pages. This includes markdown parsing for summaries, diarized transcript rendering, and data fetching hooks integrated with the Twenty SDK. ## Key Changes ### New Hooks - `useCalendarEventSummary`: Fetches and manages summary markdown for a calendar event's call recordings - `useCalendarEventTranscript`: Fetches and manages transcript data for a calendar event's call recordings ### Summary Components - `CalendarEventSummary`: Top-level component that displays summary for selected calendar event - `CalendarEventSummaryContent`: Container with header and content frame - `CalendarEventSummaryBody`: Handles loading, error, and empty states - `SummaryMarkdown`: Renders parsed markdown with support for headings, lists, and paragraphs - `SummaryInlineSegments`: Renders inline text with bold formatting support ### Transcript Components - `CalendarEventTranscript`: Top-level component that displays transcript for selected calendar event - `CalendarEventTranscriptContent`: Container with header and scrollable content frame - `CalendarEventTranscriptBody`: Handles loading, error, and empty states - `TranscriptEntryList`: Renders list of transcript entries - `TranscriptEntryListItem`: Individual transcript entry with speaker avatar, timestamp, and text - `TranscriptErrorBox`: Styled error state display ### Utilities - `parseSummaryMarkdownBlocks`: Parses markdown into structured blocks (headings, lists, paragraphs) - `parseSummaryInlineSegments`: Parses inline markdown for bold text formatting - `parseTranscriptEntries`: Parses diarized transcript format into structured entries with speaker info and timestamps - `formatSecondsAsClockTimestamp`: Formats seconds into HH:MM:SS or MM:SS format - `asRecord`: Type guard utility for converting values to records ### Page Layout Configuration - `calendar-event-summary-tab.ts`: Defines Summary tab for calendar event page layout - `calendar-event-transcript-tab.ts`: Defines Transcript tab for calendar event page layout - Front component definitions for both summary and transcript ### Types - `TranscriptEntry` and `TranscriptWord`: Structured transcript data types - `SummaryMarkdownBlock`: Block-level markdown structure - `SummaryInlineSegment`: Inline text segment with formatting ## Implementation Details - Uses `CoreApiClient` from Twenty SDK to query call recordings filtered by calendar event ID - Implements proper cleanup with cancellation tokens to prevent state updates on unmounted components - Supports both loading and error states with user-friendly messaging - Markdown parser handles headings (h1-h6), bullet lists, and paragraphs with inline bold formatting - Transcript parser validates diarized format and gracefully handles malformed entries - Styled with emotion and theme constants from twenty-ui for consistent design - Integrates with `useSelectedRecordIds` hook to track selected calendar event https://github.com/user-attachments/assets/0bd63590-bb1e-4f89-8b96-e3fbb659473e <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22667?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -21,7 +21,12 @@
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sniptt/guards": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
|
||||
@@ -30,6 +35,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"twenty-client-sdk": "^2.18.0",
|
||||
"twenty-sdk": "^2.18.0",
|
||||
"twenty-ui": "^1.0.0-alpha.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^4.0.0"
|
||||
|
||||
@@ -21,3 +21,25 @@ export const FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
|
||||
|
||||
export const FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER =
|
||||
'cb2dd01d-8dca-4222-acce-1d1dbdef9146';
|
||||
|
||||
// Shared core CalendarEvent record page layout that apps attach tabs to.
|
||||
export const CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'b9b10e40-9ce2-4704-8ac6-c6e92e2563c1';
|
||||
|
||||
export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER =
|
||||
'f4fed96d-1551-40dc-a0c1-0b4d2a782733';
|
||||
|
||||
export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER =
|
||||
'240925c4-7599-44e1-84ee-4330d450a736';
|
||||
|
||||
export const CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'64bd540d-f611-4f9e-8b8d-ae8443c310cd';
|
||||
|
||||
export const CALENDAR_EVENT_TRANSCRIPT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER =
|
||||
'02acda71-77e0-4176-a134-c79f40510419';
|
||||
|
||||
export const CALENDAR_EVENT_TRANSCRIPT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER =
|
||||
'ac9d7c53-15f8-4c89-85ce-f6451ace2eb2';
|
||||
|
||||
export const CALENDAR_EVENT_TRANSCRIPT_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'364642a6-addb-4df1-8c57-25228b826259';
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
|
||||
import { CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { CalendarEventSummary } from 'src/front-components/components/CalendarEventSummary';
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'calendar-event-summary',
|
||||
description:
|
||||
'Read-only Fireflies AI summary viewer for the calendar event record page.',
|
||||
component: CalendarEventSummary,
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
|
||||
import { CALENDAR_EVENT_TRANSCRIPT_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { CalendarEventTranscript } from 'src/front-components/components/CalendarEventTranscript';
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
CALENDAR_EVENT_TRANSCRIPT_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'calendar-event-transcript',
|
||||
description:
|
||||
'Read-only diarized Fireflies transcript viewer for the calendar event record page.',
|
||||
component: CalendarEventTranscript,
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { CalendarEventSummaryContent } from 'src/front-components/components/CalendarEventSummaryContent';
|
||||
|
||||
const StyledCenteredState = styled.div`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: ${() => themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const CalendarEventSummary = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const calendarEventId =
|
||||
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
|
||||
|
||||
if (isUndefined(calendarEventId)) {
|
||||
return (
|
||||
<StyledCenteredState>
|
||||
Open a calendar event to see its summary.
|
||||
</StyledCenteredState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarEventSummaryContent
|
||||
key={calendarEventId}
|
||||
calendarEventId={calendarEventId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { SummaryMarkdown } from 'src/front-components/components/SummaryMarkdown';
|
||||
import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox';
|
||||
|
||||
const StyledCenteredState = styled.div`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: ${() => themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
type CalendarEventSummaryBodyProps = {
|
||||
summaryMarkdown: string | undefined;
|
||||
isCalendarEventSummaryQueryLoading: boolean;
|
||||
errorMessage: string | undefined;
|
||||
};
|
||||
|
||||
export const CalendarEventSummaryBody = ({
|
||||
summaryMarkdown,
|
||||
isCalendarEventSummaryQueryLoading,
|
||||
errorMessage,
|
||||
}: CalendarEventSummaryBodyProps) => {
|
||||
if (!isUndefined(errorMessage)) {
|
||||
return (
|
||||
<TranscriptErrorBox
|
||||
title="Failed to load the summary"
|
||||
description={errorMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCalendarEventSummaryQueryLoading) {
|
||||
return <StyledCenteredState>Loading summary…</StyledCenteredState>;
|
||||
}
|
||||
|
||||
if (isUndefined(summaryMarkdown)) {
|
||||
return (
|
||||
<StyledCenteredState>
|
||||
No summary for this calendar event yet.
|
||||
</StyledCenteredState>
|
||||
);
|
||||
}
|
||||
|
||||
return <SummaryMarkdown markdown={summaryMarkdown} />;
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { CalendarEventSummaryBody } from 'src/front-components/components/CalendarEventSummaryBody';
|
||||
import { useCalendarEventSummary } from 'src/front-components/hooks/use-calendar-event-summary';
|
||||
|
||||
const StyledSummaryShell = styled.div`
|
||||
background: ${() => themeCssVariables.background.primary};
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${() => themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSummaryHeader = styled.div`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: ${() => themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledSummaryTitle = styled.h2`
|
||||
color: ${() => themeCssVariables.font.color.primary};
|
||||
flex: 1;
|
||||
font-size: ${() => themeCssVariables.font.size.md};
|
||||
font-weight: ${() => themeCssVariables.font.weight.medium};
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: ${() => themeCssVariables.spacing[1]};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledSummaryBody = styled.div`
|
||||
box-sizing: border-box;
|
||||
margin-top: ${() => themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledSummaryContentFrame = styled.div`
|
||||
background-color: ${() => themeCssVariables.background.secondary};
|
||||
border: 1px solid ${() => themeCssVariables.border.color.medium};
|
||||
border-radius: ${() => themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
padding: ${() => themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
type CalendarEventSummaryContentProps = {
|
||||
calendarEventId: string;
|
||||
};
|
||||
|
||||
export const CalendarEventSummaryContent = ({
|
||||
calendarEventId,
|
||||
}: CalendarEventSummaryContentProps) => {
|
||||
const { summaryMarkdown, isCalendarEventSummaryQueryLoading, errorMessage } =
|
||||
useCalendarEventSummary(calendarEventId);
|
||||
|
||||
return (
|
||||
<StyledSummaryShell>
|
||||
<StyledSummaryHeader>
|
||||
<StyledSummaryTitle>Summary</StyledSummaryTitle>
|
||||
</StyledSummaryHeader>
|
||||
<StyledSummaryBody>
|
||||
<StyledSummaryContentFrame>
|
||||
<CalendarEventSummaryBody
|
||||
summaryMarkdown={summaryMarkdown}
|
||||
isCalendarEventSummaryQueryLoading={
|
||||
isCalendarEventSummaryQueryLoading
|
||||
}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</StyledSummaryContentFrame>
|
||||
</StyledSummaryBody>
|
||||
</StyledSummaryShell>
|
||||
);
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { CalendarEventTranscriptContent } from 'src/front-components/components/CalendarEventTranscriptContent';
|
||||
|
||||
const StyledCenteredState = styled.div`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: ${() => themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const CalendarEventTranscript = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const calendarEventId =
|
||||
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
|
||||
|
||||
if (isUndefined(calendarEventId)) {
|
||||
return (
|
||||
<StyledCenteredState>
|
||||
Open a calendar event to see its transcript.
|
||||
</StyledCenteredState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarEventTranscriptContent
|
||||
key={calendarEventId}
|
||||
calendarEventId={calendarEventId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { useMemo } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { TranscriptEntryList } from 'src/front-components/components/TranscriptEntryList';
|
||||
import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox';
|
||||
import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util';
|
||||
|
||||
const StyledCenteredState = styled.div`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: ${() => themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
type CalendarEventTranscriptBodyProps = {
|
||||
transcript: unknown;
|
||||
isCalendarEventTranscriptQueryLoading: boolean;
|
||||
errorMessage: string | undefined;
|
||||
};
|
||||
|
||||
export const CalendarEventTranscriptBody = ({
|
||||
transcript,
|
||||
isCalendarEventTranscriptQueryLoading,
|
||||
errorMessage,
|
||||
}: CalendarEventTranscriptBodyProps) => {
|
||||
const entries = useMemo(
|
||||
() => parseTranscriptEntries(transcript),
|
||||
[transcript],
|
||||
);
|
||||
|
||||
if (!isUndefined(errorMessage)) {
|
||||
return (
|
||||
<TranscriptErrorBox
|
||||
title="Failed to load the transcript"
|
||||
description={errorMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCalendarEventTranscriptQueryLoading) {
|
||||
return <StyledCenteredState>Loading transcript…</StyledCenteredState>;
|
||||
}
|
||||
|
||||
if (isUndefined(transcript)) {
|
||||
return (
|
||||
<StyledCenteredState>
|
||||
No transcript for this calendar event yet.
|
||||
</StyledCenteredState>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUndefined(entries)) {
|
||||
return (
|
||||
<TranscriptErrorBox
|
||||
title="Unrecognized transcript format"
|
||||
description="The stored transcript does not match the expected diarized format."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <StyledCenteredState>The transcript is empty.</StyledCenteredState>;
|
||||
}
|
||||
|
||||
return <TranscriptEntryList entries={entries} />;
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { CalendarEventTranscriptBody } from 'src/front-components/components/CalendarEventTranscriptBody';
|
||||
import { useCalendarEventTranscript } from 'src/front-components/hooks/use-calendar-event-transcript';
|
||||
|
||||
const StyledTranscriptShell = styled.div`
|
||||
background: ${() => themeCssVariables.background.primary};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
height: 100%;
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTranscriptHeader = styled.div`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: ${() => themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledTranscriptTitle = styled.h2`
|
||||
color: ${() => themeCssVariables.font.color.primary};
|
||||
flex: 1;
|
||||
font-size: ${() => themeCssVariables.font.size.md};
|
||||
font-weight: ${() => themeCssVariables.font.weight.medium};
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: ${() => themeCssVariables.spacing[1]};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledTranscriptBody = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-top: ${() => themeCssVariables.spacing[2]};
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const StyledTranscriptContentFrame = styled.div`
|
||||
background-color: ${() => themeCssVariables.background.secondary};
|
||||
border: 1px solid ${() => themeCssVariables.border.color.medium};
|
||||
border-radius: ${() => themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: ${() => themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
type CalendarEventTranscriptContentProps = {
|
||||
calendarEventId: string;
|
||||
};
|
||||
|
||||
export const CalendarEventTranscriptContent = ({
|
||||
calendarEventId,
|
||||
}: CalendarEventTranscriptContentProps) => {
|
||||
const { transcript, isCalendarEventTranscriptQueryLoading, errorMessage } =
|
||||
useCalendarEventTranscript(calendarEventId);
|
||||
|
||||
return (
|
||||
<StyledTranscriptShell>
|
||||
<StyledTranscriptHeader>
|
||||
<StyledTranscriptTitle>Transcript</StyledTranscriptTitle>
|
||||
</StyledTranscriptHeader>
|
||||
<StyledTranscriptBody>
|
||||
<StyledTranscriptContentFrame>
|
||||
<CalendarEventTranscriptBody
|
||||
transcript={transcript}
|
||||
isCalendarEventTranscriptQueryLoading={
|
||||
isCalendarEventTranscriptQueryLoading
|
||||
}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</StyledTranscriptContentFrame>
|
||||
</StyledTranscriptBody>
|
||||
</StyledTranscriptShell>
|
||||
);
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Fragment } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
|
||||
|
||||
const StyledBold = styled.strong`
|
||||
color: ${() => themeCssVariables.font.color.primary};
|
||||
font-weight: ${() => themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
type SummaryInlineSegmentsProps = {
|
||||
segments: SummaryInlineSegment[];
|
||||
};
|
||||
|
||||
export const SummaryInlineSegments = ({
|
||||
segments,
|
||||
}: SummaryInlineSegmentsProps) => (
|
||||
<>
|
||||
{segments.map((segment, index) =>
|
||||
segment.isBold ? (
|
||||
<StyledBold key={index}>{segment.text}</StyledBold>
|
||||
) : (
|
||||
<Fragment key={index}>{segment.text}</Fragment>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { SummaryInlineSegments } from 'src/front-components/components/SummaryInlineSegments';
|
||||
import { parseSummaryMarkdownBlocks } from 'src/front-components/utils/parse-summary-markdown-blocks.util';
|
||||
|
||||
const TOP_LEVEL_HEADING_MAX = 2;
|
||||
|
||||
const StyledSummary = styled.div`
|
||||
color: ${() => themeCssVariables.font.color.secondary};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
gap: ${() => themeCssVariables.spacing[2]};
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.h3<{ $isTopLevel: boolean }>`
|
||||
color: ${() => themeCssVariables.font.color.primary};
|
||||
font-size: ${({ $isTopLevel }) =>
|
||||
$isTopLevel
|
||||
? themeCssVariables.font.size.md
|
||||
: themeCssVariables.font.size.sm};
|
||||
font-weight: ${() => themeCssVariables.font.weight.medium};
|
||||
margin: 0;
|
||||
margin-top: ${() => themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledParagraph = styled.p`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledList = styled.ul`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${() => themeCssVariables.spacing[1]};
|
||||
margin: 0;
|
||||
padding-left: ${() => themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
type SummaryMarkdownProps = {
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
export const SummaryMarkdown = ({ markdown }: SummaryMarkdownProps) => {
|
||||
const blocks = useMemo(
|
||||
() => parseSummaryMarkdownBlocks(markdown),
|
||||
[markdown],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSummary>
|
||||
{blocks.map((block, index) => {
|
||||
if (block.type === 'heading') {
|
||||
return (
|
||||
<StyledHeading
|
||||
key={index}
|
||||
$isTopLevel={block.level <= TOP_LEVEL_HEADING_MAX}
|
||||
>
|
||||
<SummaryInlineSegments segments={block.segments} />
|
||||
</StyledHeading>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
return (
|
||||
<StyledList key={index}>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li key={itemIndex}>
|
||||
<SummaryInlineSegments segments={item} />
|
||||
</li>
|
||||
))}
|
||||
</StyledList>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledParagraph key={index}>
|
||||
<SummaryInlineSegments segments={block.segments} />
|
||||
</StyledParagraph>
|
||||
);
|
||||
})}
|
||||
</StyledSummary>
|
||||
);
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { TranscriptEntryListItem } from 'src/front-components/components/TranscriptEntryListItem';
|
||||
import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
|
||||
|
||||
const StyledTranscriptContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: ${() => themeCssVariables.spacing[2]};
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
type TranscriptEntryListProps = {
|
||||
entries: TranscriptEntry[];
|
||||
};
|
||||
|
||||
export const TranscriptEntryList = ({ entries }: TranscriptEntryListProps) => (
|
||||
<StyledTranscriptContainer>
|
||||
{entries.map((entry, entryIndex) => (
|
||||
<TranscriptEntryListItem key={entryIndex} entry={entry} />
|
||||
))}
|
||||
</StyledTranscriptContainer>
|
||||
);
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { Avatar, Chip, ChipVariant } from 'twenty-ui/data-display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
|
||||
import { formatSecondsAsClockTimestamp } from 'src/front-components/utils/format-seconds-as-clock-timestamp.util';
|
||||
|
||||
const StyledEntry = styled.div`
|
||||
align-items: flex-start;
|
||||
border-radius: ${() => themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${() => themeCssVariables.spacing[2]};
|
||||
justify-content: center;
|
||||
padding: ${() => themeCssVariables.spacing[2]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEntryHeader = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: ${() => themeCssVariables.spacing[2]};
|
||||
min-height: ${() => themeCssVariables.spacing[6]};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledTimestamp = styled.span`
|
||||
color: ${() => themeCssVariables.font.color.tertiary};
|
||||
font-size: ${() => themeCssVariables.font.size.xs};
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const StyledEntryText = styled.p`
|
||||
align-self: stretch;
|
||||
color: ${() => themeCssVariables.font.color.secondary};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
type TranscriptEntryListItemProps = {
|
||||
entry: TranscriptEntry;
|
||||
};
|
||||
|
||||
export const TranscriptEntryListItem = ({
|
||||
entry,
|
||||
}: TranscriptEntryListItemProps) => (
|
||||
<StyledEntry>
|
||||
<StyledEntryHeader>
|
||||
<Chip
|
||||
clickable={false}
|
||||
isBold
|
||||
label={entry.speakerName}
|
||||
variant={ChipVariant.Transparent}
|
||||
leftComponent={
|
||||
<Avatar
|
||||
avatarUrl={undefined}
|
||||
placeholder={entry.speakerName}
|
||||
placeholderColorSeed={entry.speakerName}
|
||||
size="md"
|
||||
type="rounded"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!isUndefined(entry.startSeconds) && (
|
||||
<StyledTimestamp>
|
||||
{formatSecondsAsClockTimestamp(entry.startSeconds)}
|
||||
</StyledTimestamp>
|
||||
)}
|
||||
</StyledEntryHeader>
|
||||
<StyledEntryText>{entry.text}</StyledEntryText>
|
||||
</StyledEntry>
|
||||
);
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledStateContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
font-family: ${() => themeCssVariables.font.family};
|
||||
height: 100%;
|
||||
padding: ${() => themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledErrorBox = styled.div`
|
||||
background: ${() => themeCssVariables.background.transparent.danger};
|
||||
border: 1px solid ${() => themeCssVariables.border.color.danger};
|
||||
border-radius: ${() => themeCssVariables.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${() => themeCssVariables.spacing[1]};
|
||||
padding: ${() => themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledErrorTitle = styled.span`
|
||||
color: ${() => themeCssVariables.font.color.danger};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
font-weight: ${() => themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledErrorDescription = styled.span`
|
||||
color: ${() => themeCssVariables.font.color.secondary};
|
||||
font-size: ${() => themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
type TranscriptErrorBoxProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const TranscriptErrorBox = ({
|
||||
title,
|
||||
description,
|
||||
}: TranscriptErrorBoxProps) => (
|
||||
<StyledStateContainer>
|
||||
<StyledErrorBox>
|
||||
<StyledErrorTitle>{title}</StyledErrorTitle>
|
||||
<StyledErrorDescription>{description}</StyledErrorDescription>
|
||||
</StyledErrorBox>
|
||||
</StyledStateContainer>
|
||||
);
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
type CalendarEventSummaryState = {
|
||||
summaryMarkdown: string | undefined;
|
||||
isCalendarEventSummaryQueryLoading: boolean;
|
||||
errorMessage: string | undefined;
|
||||
};
|
||||
|
||||
type CalendarEventSummaryCallRecordingNode = {
|
||||
id: string;
|
||||
summary: { markdown: string | null } | null;
|
||||
};
|
||||
|
||||
type CalendarEventSummaryCallRecordingEdge = {
|
||||
node: CalendarEventSummaryCallRecordingNode;
|
||||
};
|
||||
|
||||
const CALENDAR_EVENT_SUMMARY_LOOKUP_LIMIT = 10;
|
||||
const CALENDAR_EVENT_SUMMARY_ERROR_MESSAGE = 'Please try again later.';
|
||||
|
||||
const selectSummaryMarkdown = (
|
||||
callRecordingNodes: CalendarEventSummaryCallRecordingNode[],
|
||||
): string | undefined => {
|
||||
const summaryMarkdown = callRecordingNodes.find((callRecordingNode) =>
|
||||
isNonEmptyString(callRecordingNode.summary?.markdown),
|
||||
)?.summary?.markdown;
|
||||
|
||||
return isNonEmptyString(summaryMarkdown) ? summaryMarkdown : undefined;
|
||||
};
|
||||
|
||||
export const useCalendarEventSummary = (
|
||||
calendarEventId: string | undefined,
|
||||
): CalendarEventSummaryState => {
|
||||
const [state, setState] = useState<CalendarEventSummaryState>({
|
||||
summaryMarkdown: undefined,
|
||||
isCalendarEventSummaryQueryLoading: !isUndefined(calendarEventId),
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isUndefined(calendarEventId)) {
|
||||
setState({
|
||||
summaryMarkdown: undefined,
|
||||
isCalendarEventSummaryQueryLoading: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchSummary = async () => {
|
||||
setState({
|
||||
summaryMarkdown: undefined,
|
||||
isCalendarEventSummaryQueryLoading: true,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const queryResult = await client.query({
|
||||
callRecordings: {
|
||||
__args: {
|
||||
filter: { calendarEventId: { eq: calendarEventId } },
|
||||
orderBy: [{ startedAt: 'DescNullsLast' }],
|
||||
first: CALENDAR_EVENT_SUMMARY_LOOKUP_LIMIT,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
summary: { markdown: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callRecordingEdges = (queryResult.callRecordings?.edges ??
|
||||
[]) as CalendarEventSummaryCallRecordingEdge[];
|
||||
const callRecordingNodes = callRecordingEdges.map(
|
||||
(callRecordingEdge) => callRecordingEdge.node,
|
||||
);
|
||||
|
||||
setState({
|
||||
summaryMarkdown: selectSummaryMarkdown(callRecordingNodes),
|
||||
isCalendarEventSummaryQueryLoading: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
summaryMarkdown: undefined,
|
||||
isCalendarEventSummaryQueryLoading: false,
|
||||
errorMessage: CALENDAR_EVENT_SUMMARY_ERROR_MESSAGE,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [calendarEventId]);
|
||||
|
||||
return state;
|
||||
};
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
type CalendarEventTranscriptState = {
|
||||
transcript: unknown;
|
||||
isCalendarEventTranscriptQueryLoading: boolean;
|
||||
errorMessage: string | undefined;
|
||||
};
|
||||
|
||||
type CalendarEventTranscriptCallRecordingNode = {
|
||||
id: string;
|
||||
transcript: unknown;
|
||||
};
|
||||
|
||||
type CalendarEventTranscriptCallRecordingEdge = {
|
||||
node: CalendarEventTranscriptCallRecordingNode;
|
||||
};
|
||||
|
||||
const CALENDAR_EVENT_TRANSCRIPT_LOOKUP_LIMIT = 10;
|
||||
const CALENDAR_EVENT_TRANSCRIPT_ERROR_MESSAGE = 'Please try again later.';
|
||||
|
||||
const hasTranscript = (
|
||||
callRecordingNode: CalendarEventTranscriptCallRecordingNode,
|
||||
): boolean =>
|
||||
!isUndefined(callRecordingNode.transcript) &&
|
||||
callRecordingNode.transcript !== null;
|
||||
|
||||
const selectTranscript = (
|
||||
callRecordingNodes: CalendarEventTranscriptCallRecordingNode[],
|
||||
): unknown => callRecordingNodes.find(hasTranscript)?.transcript ?? undefined;
|
||||
|
||||
export const useCalendarEventTranscript = (
|
||||
calendarEventId: string | undefined,
|
||||
): CalendarEventTranscriptState => {
|
||||
const [state, setState] = useState<CalendarEventTranscriptState>({
|
||||
transcript: undefined,
|
||||
isCalendarEventTranscriptQueryLoading: !isUndefined(calendarEventId),
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isUndefined(calendarEventId)) {
|
||||
setState({
|
||||
transcript: undefined,
|
||||
isCalendarEventTranscriptQueryLoading: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchTranscript = async () => {
|
||||
setState({
|
||||
transcript: undefined,
|
||||
isCalendarEventTranscriptQueryLoading: true,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const queryResult = await client.query({
|
||||
callRecordings: {
|
||||
__args: {
|
||||
filter: { calendarEventId: { eq: calendarEventId } },
|
||||
orderBy: [{ startedAt: 'DescNullsLast' }],
|
||||
first: CALENDAR_EVENT_TRANSCRIPT_LOOKUP_LIMIT,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
transcript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callRecordingEdges = (queryResult.callRecordings?.edges ??
|
||||
[]) as CalendarEventTranscriptCallRecordingEdge[];
|
||||
const callRecordingNodes = callRecordingEdges.map(
|
||||
(callRecordingEdge) => callRecordingEdge.node,
|
||||
);
|
||||
|
||||
setState({
|
||||
transcript: selectTranscript(callRecordingNodes),
|
||||
isCalendarEventTranscriptQueryLoading: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
transcript: undefined,
|
||||
isCalendarEventTranscriptQueryLoading: false,
|
||||
errorMessage: CALENDAR_EVENT_TRANSCRIPT_ERROR_MESSAGE,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranscript();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [calendarEventId]);
|
||||
|
||||
return state;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SummaryInlineSegment = {
|
||||
text: string;
|
||||
isBold: boolean;
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
|
||||
|
||||
export type SummaryMarkdownBlock =
|
||||
| { type: 'heading'; level: number; segments: SummaryInlineSegment[] }
|
||||
| { type: 'paragraph'; segments: SummaryInlineSegment[] }
|
||||
| { type: 'list'; items: SummaryInlineSegment[][] };
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export type TranscriptWord = {
|
||||
text: string;
|
||||
startSeconds: number | undefined;
|
||||
endSeconds: number | undefined;
|
||||
};
|
||||
|
||||
export type TranscriptEntry = {
|
||||
speakerName: string;
|
||||
startSeconds: number | undefined;
|
||||
endSeconds: number | undefined;
|
||||
text: string;
|
||||
words: TranscriptWord[];
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSummaryInlineSegments } from 'src/front-components/utils/parse-summary-inline-segments.util';
|
||||
|
||||
describe('parseSummaryInlineSegments', () => {
|
||||
it('should return a single plain segment for text without markers', () => {
|
||||
expect(parseSummaryInlineSegments('plain text')).toEqual([
|
||||
{ text: 'plain text', isBold: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse **bold** runs', () => {
|
||||
expect(parseSummaryInlineSegments('a **b** c')).toEqual([
|
||||
{ text: 'a ', isBold: false },
|
||||
{ text: 'b', isBold: true },
|
||||
{ text: ' c', isBold: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse __bold__ runs', () => {
|
||||
expect(parseSummaryInlineSegments('a __b__ c')).toEqual([
|
||||
{ text: 'a ', isBold: false },
|
||||
{ text: 'b', isBold: true },
|
||||
{ text: ' c', isBold: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not match mismatched delimiters', () => {
|
||||
expect(parseSummaryInlineSegments('**b__')).toEqual([
|
||||
{ text: '**b__', isBold: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSummaryMarkdownBlocks } from 'src/front-components/utils/parse-summary-markdown-blocks.util';
|
||||
|
||||
describe('parseSummaryMarkdownBlocks', () => {
|
||||
it('should parse headings, paragraphs and lists preserving order', () => {
|
||||
expect(
|
||||
parseSummaryMarkdownBlocks('## Overview\n\nSome text\n\n- first\n- second'),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
segments: [{ text: 'Overview', isBold: false }],
|
||||
},
|
||||
{ type: 'paragraph', segments: [{ text: 'Some text', isBold: false }] },
|
||||
{
|
||||
type: 'list',
|
||||
items: [
|
||||
[{ text: 'first', isBold: false }],
|
||||
[{ text: 'second', isBold: false }],
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should flush a trailing list at end of input', () => {
|
||||
expect(parseSummaryMarkdownBlocks('- only item')).toEqual([
|
||||
{ type: 'list', items: [[{ text: 'only item', isBold: false }]] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array for empty input', () => {
|
||||
expect(parseSummaryMarkdownBlocks('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util';
|
||||
|
||||
describe('parseTranscriptEntries', () => {
|
||||
it('should parse a diarized entry with speaker and relative timestamps', () => {
|
||||
expect(
|
||||
parseTranscriptEntries([
|
||||
{
|
||||
participant: { name: 'Sarah' },
|
||||
words: [
|
||||
{
|
||||
text: 'Hello',
|
||||
start_timestamp: { relative: 1.5 },
|
||||
end_timestamp: { relative: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
speakerName: 'Sarah',
|
||||
startSeconds: 1.5,
|
||||
endSeconds: 2,
|
||||
text: 'Hello',
|
||||
words: [{ text: 'Hello', startSeconds: 1.5, endSeconds: 2 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fall back to "Unknown speaker" for a whitespace-only name', () => {
|
||||
const entries = parseTranscriptEntries([
|
||||
{ participant: { name: ' ' }, words: [{ text: 'Hi' }] },
|
||||
]);
|
||||
|
||||
expect(entries?.[0]?.speakerName).toBe('Unknown speaker');
|
||||
});
|
||||
|
||||
it('should drop entries whose words are all whitespace', () => {
|
||||
expect(
|
||||
parseTranscriptEntries([
|
||||
{ participant: { name: 'Sarah' }, words: [{ text: ' ' }] },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return undefined for a non-array transcript', () => {
|
||||
expect(parseTranscriptEntries({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { isArray, isObject } from '@sniptt/guards';
|
||||
|
||||
export const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
isObject(value) && !isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
export const formatSecondsAsClockTimestamp = (totalSeconds: number): string => {
|
||||
const safeSeconds = Number.isFinite(totalSeconds)
|
||||
? Math.max(0, Math.floor(totalSeconds))
|
||||
: 0;
|
||||
const hours = Math.floor(safeSeconds / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(safeSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
|
||||
);
|
||||
const seconds = safeSeconds % SECONDS_PER_MINUTE;
|
||||
const paddedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${paddedSeconds}`;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
|
||||
// Trimming variant of @sniptt/guards isNonEmptyString, for normalizing at read boundaries.
|
||||
export const isNonEmptyString = (value: unknown): value is string =>
|
||||
isString(value) && value.trim() !== '';
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
|
||||
|
||||
const BOLD_PATTERN = /(\*\*|__)(.+?)\1/g;
|
||||
|
||||
// Splits a markdown line into plain and bold (**bold** or __bold__) runs,
|
||||
// preserving surrounding whitespace so adjacent bold runs don't collapse together.
|
||||
export const parseSummaryInlineSegments = (
|
||||
text: string,
|
||||
): SummaryInlineSegment[] => {
|
||||
const boldMatches = [...text.matchAll(BOLD_PATTERN)];
|
||||
|
||||
const { segments, cursor } = boldMatches.reduce<{
|
||||
segments: SummaryInlineSegment[];
|
||||
cursor: number;
|
||||
}>(
|
||||
(accumulator, match) => {
|
||||
const matchStart = match.index ?? 0;
|
||||
const leadingText = text.slice(accumulator.cursor, matchStart);
|
||||
const leadingSegments =
|
||||
leadingText.length > 0 ? [{ text: leadingText, isBold: false }] : [];
|
||||
|
||||
return {
|
||||
segments: [
|
||||
...accumulator.segments,
|
||||
...leadingSegments,
|
||||
{ text: match[2], isBold: true },
|
||||
],
|
||||
cursor: matchStart + match[0].length,
|
||||
};
|
||||
},
|
||||
{ segments: [], cursor: 0 },
|
||||
);
|
||||
|
||||
const trailingText = text.slice(cursor);
|
||||
|
||||
return trailingText.length > 0
|
||||
? [...segments, { text: trailingText, isBold: false }]
|
||||
: segments;
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
|
||||
import { type SummaryMarkdownBlock } from 'src/front-components/types/summary-markdown-block.type';
|
||||
import { parseSummaryInlineSegments } from 'src/front-components/utils/parse-summary-inline-segments.util';
|
||||
|
||||
const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/;
|
||||
const BULLET_PATTERN = /^[-*]\s+(.+)$/;
|
||||
|
||||
export const parseSummaryMarkdownBlocks = (
|
||||
markdown: string,
|
||||
): SummaryMarkdownBlock[] => {
|
||||
const blocks: SummaryMarkdownBlock[] = [];
|
||||
let pendingListItems: SummaryInlineSegment[][] = [];
|
||||
|
||||
const flushPendingList = () => {
|
||||
if (pendingListItems.length > 0) {
|
||||
blocks.push({ type: 'list', items: pendingListItems });
|
||||
pendingListItems = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of markdown.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line === '') {
|
||||
flushPendingList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = HEADING_PATTERN.exec(line);
|
||||
|
||||
if (headingMatch !== null) {
|
||||
flushPendingList();
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
level: headingMatch[1].length,
|
||||
segments: parseSummaryInlineSegments(headingMatch[2]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const bulletMatch = BULLET_PATTERN.exec(line);
|
||||
|
||||
if (bulletMatch !== null) {
|
||||
pendingListItems.push(parseSummaryInlineSegments(bulletMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingList();
|
||||
blocks.push({
|
||||
type: 'paragraph',
|
||||
segments: parseSummaryInlineSegments(line),
|
||||
});
|
||||
}
|
||||
|
||||
flushPendingList();
|
||||
|
||||
return blocks;
|
||||
};
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { isArray, isNumber, isUndefined } from '@sniptt/guards';
|
||||
|
||||
import {
|
||||
type TranscriptEntry,
|
||||
type TranscriptWord,
|
||||
} from 'src/front-components/types/transcript-entry.type';
|
||||
import { asRecord } from 'src/front-components/utils/as-record.util';
|
||||
import { isNonEmptyString } from 'src/front-components/utils/is-non-empty-string.util';
|
||||
|
||||
type TranscriptRecord = NonNullable<ReturnType<typeof asRecord>>;
|
||||
|
||||
const isTranscriptRecord = (
|
||||
candidate: TranscriptRecord | undefined,
|
||||
): candidate is TranscriptRecord => !isUndefined(candidate);
|
||||
|
||||
const readRelativeTimestamp = (
|
||||
timestamp: TranscriptRecord | undefined,
|
||||
): number | undefined => {
|
||||
const relativeTimestamp = timestamp?.relative;
|
||||
|
||||
return isNumber(relativeTimestamp) && Number.isFinite(relativeTimestamp)
|
||||
? relativeTimestamp
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const readTranscriptWord = (
|
||||
candidate: TranscriptRecord,
|
||||
): TranscriptWord | undefined => {
|
||||
if (!isNonEmptyString(candidate.text)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
text: candidate.text.trim(),
|
||||
startSeconds: readRelativeTimestamp(asRecord(candidate.start_timestamp)),
|
||||
endSeconds: readRelativeTimestamp(asRecord(candidate.end_timestamp)),
|
||||
};
|
||||
};
|
||||
|
||||
const readSpeakerName = (participant: TranscriptRecord | undefined): string => {
|
||||
const name = participant?.name;
|
||||
|
||||
return isNonEmptyString(name) ? name.trim() : 'Unknown speaker';
|
||||
};
|
||||
|
||||
const readTranscriptEntry = (
|
||||
candidate: TranscriptRecord,
|
||||
): TranscriptEntry | undefined => {
|
||||
if (!isArray(candidate.words)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const words = candidate.words
|
||||
.map(asRecord)
|
||||
.filter(isTranscriptRecord)
|
||||
.map(readTranscriptWord)
|
||||
.filter((word): word is TranscriptWord => !isUndefined(word));
|
||||
|
||||
if (words.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
speakerName: readSpeakerName(asRecord(candidate.participant)),
|
||||
startSeconds: words[0].startSeconds,
|
||||
endSeconds: words[words.length - 1].endSeconds,
|
||||
text: words.map((word) => word.text).join(' '),
|
||||
words,
|
||||
};
|
||||
};
|
||||
|
||||
// Undefined means the value is not a diarized transcript; malformed entries are skipped, not fatal.
|
||||
export const parseTranscriptEntries = (
|
||||
transcript: unknown,
|
||||
): TranscriptEntry[] | undefined => {
|
||||
if (!isArray(transcript)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return transcript
|
||||
.map(asRecord)
|
||||
.filter(isTranscriptRecord)
|
||||
.map(readTranscriptEntry)
|
||||
.filter((entry): entry is TranscriptEntry => !isUndefined(entry));
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier:
|
||||
CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Summary',
|
||||
position: 16,
|
||||
icon: 'IconFileText',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
pageLayoutUniversalIdentifier:
|
||||
CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier:
|
||||
CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Summary',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_EVENT_TRANSCRIPT_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_EVENT_TRANSCRIPT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_EVENT_TRANSCRIPT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier:
|
||||
CALENDAR_EVENT_TRANSCRIPT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Transcript',
|
||||
position: 17,
|
||||
icon: 'IconMicrophone',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
pageLayoutUniversalIdentifier:
|
||||
CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier:
|
||||
CALENDAR_EVENT_TRANSCRIPT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Transcript',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
CALENDAR_EVENT_TRANSCRIPT_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user