@@ -2,9 +2,8 @@ import type { useTranslations } from 'next-intl'
22
33import type { ChatSource , ChatSourceType } from './types'
44
5- // Video/image sources have no page/chapter data — just a type label — while
6- // documents and plain links show page/chapter info. Shared by the sources
7- // grid (sources-section.tsx) and the inline citation hover preview
5+ // Video/image sources get their own compact grid in `sources-section.tsx`.
6+ // Shared by the sources grid and the inline citation hover preview
87// (citation-chip.tsx) so both agree on what a source "is".
98export const MEDIA_SOURCE_TYPES : readonly ChatSourceType [ ] = [ 'video' , 'image' ]
109
@@ -18,27 +17,181 @@ export function isMediaSource(source: Pick<ChatSource, 'type'>): boolean {
1817// excessively deep" against the full Messages union.
1918export type Translate = ReturnType < typeof useTranslations < never > >
2019
20+ const MAX_DISPLAY_URL_LENGTH = 48
21+
22+ /** `754` -> `12:34`, `3723` -> `1:02:03`. Minutes stay unpadded. */
23+ export function formatTimestamp ( totalSeconds : number ) : string {
24+ const safe = Math . max ( 0 , Math . floor ( totalSeconds ) )
25+ const hours = Math . floor ( safe / 3600 )
26+ const minutes = Math . floor ( ( safe % 3600 ) / 60 )
27+ const seconds = safe % 60
28+ const paddedSeconds = String ( seconds ) . padStart ( 2 , '0' )
29+
30+ if ( hours > 0 ) {
31+ return `${ hours } :${ String ( minutes ) . padStart ( 2 , '0' ) } :${ paddedSeconds } `
32+ }
33+ return `${ minutes } :${ paddedSeconds } `
34+ }
35+
36+ /**
37+ * Seconds from the time notations a course video link or a chunk label
38+ * realistically uses: plain seconds (`90`), clock form (`1:30`, `1:02:03`),
39+ * and YouTube's compound form (`1m30s`, `1h2m3s`). Returns `undefined` for
40+ * anything else, so a non-time `labeled_page_number` such as `"Kapitel IV"`
41+ * is never mistaken for a timestamp.
42+ */
43+ export function parseTimestampSeconds ( value : string ) : number | undefined {
44+ const raw = value . trim ( ) . toLowerCase ( )
45+ if ( ! raw ) return undefined
46+
47+ if ( / ^ \d + $ / . test ( raw ) ) return Number ( raw )
48+ if ( / ^ \d + s $ / . test ( raw ) ) return Number ( raw . slice ( 0 , - 1 ) )
49+
50+ const clock = / ^ (?: ( \d + ) : ) ? ( \d { 1 , 2 } ) : ( \d { 1 , 2 } ) $ / . exec ( raw )
51+ if ( clock ) {
52+ const [ , hours , minutes , seconds ] = clock
53+ if ( Number ( minutes ) > 59 || Number ( seconds ) > 59 ) return undefined
54+ return Number ( hours ?? 0 ) * 3600 + Number ( minutes ) * 60 + Number ( seconds )
55+ }
56+
57+ const compound = / ^ (?: ( \d + ) h ) ? (?: ( \d + ) m ) ? (?: ( \d + ) s ) ? $ / . exec ( raw )
58+ if ( compound && ( compound [ 1 ] || compound [ 2 ] || compound [ 3 ] ) ) {
59+ const [ , hours , minutes , seconds ] = compound
60+ return (
61+ Number ( hours ?? 0 ) * 3600 +
62+ Number ( minutes ?? 0 ) * 60 +
63+ Number ( seconds ?? 0 )
64+ )
65+ }
66+
67+ return undefined
68+ }
69+
70+ /**
71+ * A video position for the card and the hover preview.
72+ *
73+ * doc_query has no timestamp field (its source shape is `source_url`,
74+ * `source_type`, `file_name`, `page_number`, `labeled_page_number`), so this
75+ * reads the two channels a timestamp can actually arrive in today: a free-form
76+ * `labeled_page_number` the ingestion side may set to a clock value, and the
77+ * time parameter a course video URL usually carries. A dedicated field is
78+ * phase-2 work in the doc-query service; until then a video without either
79+ * simply shows its type label.
80+ */
81+ export function getSourceTimestamp ( source : ChatSource ) : string | undefined {
82+ if ( source . labeledPage ) {
83+ const labeled = parseTimestampSeconds ( source . labeledPage )
84+ if ( labeled !== undefined ) return formatTimestamp ( labeled )
85+ }
86+
87+ if ( ! source . url ) return undefined
88+
89+ try {
90+ const parsed = new URL ( source . url )
91+ const candidates = [
92+ parsed . searchParams . get ( 't' ) ,
93+ parsed . searchParams . get ( 'start' ) ,
94+ parsed . searchParams . get ( 'time_continue' ) ,
95+ // `#t=90` / `#t=1m30s`
96+ / ^ # t = ( .+ ) $ / . exec ( parsed . hash ) ?. [ 1 ] ,
97+ ]
98+
99+ for ( const candidate of candidates ) {
100+ if ( ! candidate ) continue
101+ const seconds = parseTimestampSeconds ( candidate )
102+ if ( seconds !== undefined ) return formatTimestamp ( seconds )
103+ }
104+ } catch {
105+ return undefined
106+ }
107+
108+ return undefined
109+ }
110+
111+ /**
112+ * The readable form of a web source's address: no scheme, no `www.`, no
113+ * trailing slash, truncated in the middle of the path rather than at the end
114+ * so the host always stays visible.
115+ */
116+ export function getDisplayUrl ( url : string ) : string | undefined {
117+ let host : string
118+ let rest : string
119+
120+ try {
121+ const parsed = new URL ( url )
122+ host = parsed . host . replace ( / ^ w w w \. / i, '' )
123+ rest = `${ parsed . pathname } ${ parsed . search } ${ parsed . hash } ` . replace ( / \/ $ / , '' )
124+ } catch {
125+ return undefined
126+ }
127+
128+ if ( ! host ) return undefined
129+ if ( rest === '/' || rest === '' ) return host
130+
131+ const full = `${ host } ${ rest } `
132+ if ( full . length <= MAX_DISPLAY_URL_LENGTH ) return full
133+
134+ const keep = Math . max ( 0 , MAX_DISPLAY_URL_LENGTH - host . length - 1 )
135+ return `${ host } …${ rest . slice ( rest . length - keep ) } `
136+ }
137+
21138/**
22- * "S. 4 · IV" when both a numeric page and a human page label are present,
23- * either one alone, a type label for video/image, or `null` when none of
24- * these apply. Shared so the source card and the citation preview render an
25- * identical secondary line for the same source.
139+ * The locator line under a source's name, by what that kind of source is
140+ * actually addressed by: a page for documents, a position for videos, an
141+ * address for web links. Falls back to the address when a document carries no
142+ * page at all, so the line stays informative instead of empty. `null` only
143+ * when nothing at all is known.
144+ *
145+ * Shared so the source card and the citation preview render an identical
146+ * secondary line for the same source.
26147 */
27148export function getSourceSecondaryLine (
28149 source : ChatSource ,
29150 t : Translate
30151) : string | null {
31152 const parts : string [ ] = [ ]
32153
33- if ( isMediaSource ( source ) ) {
34- parts . push (
35- t ( source . type === 'video' ? 'chat.sources.video' : 'chat.sources.image' )
36- )
154+ if ( source . type === 'video' ) {
155+ const timestamp = getSourceTimestamp ( source )
156+ parts . push ( timestamp ?? t ( 'chat.sources.video' ) )
157+ if ( timestamp === undefined && typeof source . page === 'number' ) {
158+ parts . push ( t ( 'chat.sources.page' , { page : source . page } ) )
159+ }
160+ return parts . join ( ' · ' )
37161 }
162+
163+ if ( source . type === 'image' ) {
164+ parts . push ( t ( 'chat.sources.image' ) )
165+ if ( typeof source . page === 'number' ) {
166+ parts . push ( t ( 'chat.sources.page' , { page : source . page } ) )
167+ }
168+ return parts . join ( ' · ' )
169+ }
170+
171+ // A web link is addressed by its URL, so that leads here even when the
172+ // payload also carried a page. Documents lead with the page and only fall
173+ // back to the URL when they have no page at all, which keeps the common
174+ // "lecture-01.pdf / p. 12" pairing intact.
175+ const displayUrl = source . url ? getDisplayUrl ( source . url ) : undefined
176+
177+ if ( source . type === 'link' && displayUrl ) {
178+ return displayUrl
179+ }
180+
38181 if ( typeof source . page === 'number' ) {
39182 parts . push ( t ( 'chat.sources.page' , { page : source . page } ) )
40183 }
41- if ( source . labeledPage ) parts . push ( source . labeledPage )
184+ // A labeled page ("IV", "A-3") is the publisher's own numbering and only
185+ // adds something next to the numeric page; a labeled timestamp already went
186+ // to the video branch above.
187+ if (
188+ source . labeledPage &&
189+ parseTimestampSeconds ( source . labeledPage ) === undefined
190+ ) {
191+ parts . push ( source . labeledPage )
192+ }
193+
194+ if ( parts . length === 0 && displayUrl ) parts . push ( displayUrl )
42195
43196 return parts . length > 0 ? parts . join ( ' · ' ) : null
44197}
0 commit comments