2026-03-02 18:16:04 +00:00
import { ref } from 'vue'
2026-03-02 19:57:44 +00:00
import type { Film , Song , Podcast } from '@aiui/core/types/content'
2026-03-02 21:29:50 +00:00
import type { WebSearchResult } from '@aiui/core/types/message'
2026-03-02 16:48:17 +00:00
import { mockFilms } from '@/mocks/films'
2026-03-02 18:16:04 +00:00
import { mockSongs } from '@/mocks/songs'
2026-03-02 19:57:44 +00:00
import { mockPodcasts } from '@/mocks/podcasts'
2026-03-02 21:29:50 +00:00
import { generatePosterFallback , generateSongCoverFallback } from '@/composables/useImageFallback'
import { fetchRssFromUrls } from '@/composables/useRssFetch'
export type ContentTab = 'film' | 'song' | 'podcast' | 'news' | 'websites' | 'magazine'
export interface MagazineSection {
title : string
content : string
/** Optional image URL (from markdown or parsed) */
imageUrl? : string
/** Optional author (e.g. "Henrik Zeberg") */
author? : string
/** Optional link to open in iframe */
url? : string
}
2026-03-02 16:48:17 +00:00
const panelOpen = ref ( false )
const panelFilms = ref < Film [] >([])
2026-03-02 21:29:50 +00:00
const panelWebResults = ref < WebSearchResult [] >([])
const panelRssArticles = ref < WebSearchResult [] >([])
const panelWebsites = ref < WebSearchResult [] >([])
const panelMagazineSections = ref < MagazineSection [] >([])
const panelMagazineHeroImage = ref < string | null >( null )
2026-03-02 18:16:04 +00:00
const panelSongs = ref < Song [] >([])
2026-03-02 19:57:44 +00:00
const panelPodcasts = ref < Podcast [] >([])
2026-03-02 16:48:17 +00:00
const selectedFilm = ref < Film | null >( null )
2026-03-02 18:16:04 +00:00
const selectedSong = ref < Song | null >( null )
2026-03-02 19:57:44 +00:00
const selectedPodcast = ref < Podcast | null >( null )
2026-03-02 21:29:50 +00:00
const selectedArticle = ref < WebSearchResult | null >( null )
2026-03-02 16:48:17 +00:00
const panelTitle = ref ( 'Recommended Films' )
2026-03-02 21:29:50 +00:00
const panelQuery = ref ( '' )
2026-03-02 19:57:44 +00:00
const contentType = ref < 'film' | 'song' | 'podcast' > ( 'film' )
2026-03-02 21:29:50 +00:00
const activeTab = ref < ContentTab >( 'film' )
const availableTabs = ref < ContentTab [] >([])
function isNewsQuery ( q : string ) : boolean {
const lower = q . toLowerCase (). trim ()
if ( ! lower ) return false
return /\b(news|latest|recent|current|what'?s happening|updates? about)\b/ . test ( lower ) ||
/what'?s the latest|latest \w+ news/ . test ( lower ) ||
/what are people saying|what'?s the word|what do people think/ . test ( lower )
}
function isNewsLikeResponse ( text : string ) : boolean {
const lower = text . toLowerCase ()
return /for instant .* news|check these sources|for (the )?latest (bitcoin )?news|direct sources/i . test ( lower ) ||
/(have )?access to (live )?web search|want me to (go back and )?search/i . test ( lower )
}
function isWebsitesQuery ( q : string ) : boolean {
const lower = q . toLowerCase (). trim ()
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit))\b/ . test ( lower ) ||
/where (can i|should i) (check|look|find)/ . test ( lower )
}
function isWebsitesLikeResponse ( text : string ) : boolean {
const lower = text . toLowerCase ()
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i . test ( lower )
}
function extractUrlFromText ( text : string ) : string | undefined {
const mdLink = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/ . exec ( text )
2026-03-02 22:02:19 +00:00
const raw = mdLink ? mdLink [ 2 ] : ( /(https?:\/\/[^\s)\]\"'<>]+)/ . exec ( text ) ? .[ 1 ])
if ( ! raw ? . trim ()) return undefined
try {
const u = new URL ( raw . trim ())
if ( ! /^https?:$/i . test ( u . protocol )) return undefined
return u . href
} catch {
return undefined
}
2026-03-02 21:29:50 +00:00
}
function extractAuthorFromText ( text : string ) : string | undefined {
const patterns = [
2026-03-03 01:16:02 +00:00
/(?:analyst|according to)\s+\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/ ,
/\bby\s+\*{0,2}([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\*{0,2}/ ,
/(?:source|—)\s*:?\s*\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s|$|\.|,)/ ,
/\*\*([A-Z][^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/ ,
2026-03-02 21:29:50 +00:00
]
for ( const re of patterns ) {
const m = re . exec ( text )
if ( m ) {
const name = m [ 1 ]. trim (). slice ( 0 , 60 )
2026-03-03 01:16:02 +00:00
if ( name . length > 3 && name . length < 50 ) return name
2026-03-02 21:29:50 +00:00
}
}
return undefined
}
function extractFirstImageFromText ( text : string ) : string | undefined {
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/ . exec ( text )
2026-03-02 22:02:19 +00:00
const raw = mdImg ? mdImg [ 1 ] : ( /(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i . exec ( text ) ? .[ 1 ])
if ( ! raw ? . trim ()) return undefined
try {
const u = new URL ( raw . trim ())
if ( ! /^https?:$/i . test ( u . protocol )) return undefined
return u . href
} catch {
return undefined
}
2026-03-02 21:29:50 +00:00
}
2026-03-02 21:38:01 +00:00
const MAGAZINE_CONTENT_MAX = 2000
function addSection (
sections : MagazineSection [],
title : string ,
content : string ,
seen : Set < string >,
) : void {
2026-03-03 00:27:51 +00:00
const t = title . trim (). replace ( /[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
const c = content . trim (). replace ( /[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
2026-03-02 21:38:01 +00:00
if ( t . length < 2 || c . length < 15 ) return
const key = ` ${ t . slice ( 0 , 50 ) } `
if ( seen . has ( key )) return
seen . add ( key )
2026-03-02 22:02:19 +00:00
const imgMatch = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/ . exec ( content ) ? .[ 1 ]
const imageUrl = imgMatch ? (() => {
try {
const u = new URL ( imgMatch . trim ())
return /^https?:$/i . test ( u . protocol ) ? u.href : undefined
} catch { return undefined }
})() : undefined
2026-03-02 21:38:01 +00:00
sections . push ({
title : t ,
content : c ,
url : extractUrlFromText ( content ),
author : extractAuthorFromText ( content ),
2026-03-02 22:02:19 +00:00
imageUrl ,
2026-03-02 21:38:01 +00:00
})
}
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
2026-03-02 21:29:50 +00:00
function extractMagazineSections ( text : string ) : MagazineSection [] {
const sections : MagazineSection [] = []
2026-03-02 21:38:01 +00:00
const seen = new Set < string >()
const blockContents = new Set < string >() // avoid duplicate bullets already in ## blocks
2026-03-03 00:27:51 +00:00
// 1. ## or ### Heading blocks: full content until next heading or **Section**
const headingRe = /^#{2,3}\s*[^\n]*?(?:⚡|🔥|📌|✨|📺|⭐|🔄|🎬|🎭|🎵|🎧|📖|💡|🔍|🌍|💭|🧠|🔬|📊|🏆|📝)?\s*(.+?)\n\n([\s\S]+?)(?=\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\n\n---|\n\n\*\*TL;DR|\z)/gim
2026-03-02 21:38:01 +00:00
let m : RegExpExecArray | null
while (( m = headingRe . exec ( text )) !== null ) {
2026-03-03 00:27:51 +00:00
const title = m [ 1 ]. trim (). replace ( /[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').split(':')[0].trim()
2026-03-02 21:38:01 +00:00
const content = m [ 2 ]. trim ()
if ( content . length > 20 ) {
blockContents . add ( content )
addSection ( sections , title , content , seen )
2026-03-02 21:29:50 +00:00
}
}
2026-03-02 21:38:01 +00:00
// 2. **Pro/ Anti camp** blocks with their bullets
2026-03-03 00:27:51 +00:00
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|\z)/gim
2026-03-02 21:38:01 +00:00
while (( m = campRe . exec ( text )) !== null ) {
const title = m [ 1 ]. trim ()
const content = m [ 2 ]. trim ()
if ( content . length > 15 ) addSection ( sections , title , content , seen )
}
2026-03-03 00:27:51 +00:00
// 3. Bullets: - **Title**: Content (skip if already in a ## or ### block)
const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013– ]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
2026-03-02 21:38:01 +00:00
while (( m = bulletRe . exec ( text )) !== null ) {
const raw = m [ 2 ]. trim (). replace ( /\n+/g , ' ' )
if ( raw . length < 15 ) continue
const inBlock = [... blockContents ]. some (( b ) => b . includes ( raw . slice ( 0 , 100 )))
if ( ! inBlock ) addSection ( sections , m [ 1 ]. trim (), raw , seen )
}
// 4. - **Name** (Role) description — attributed quotes
2026-03-03 00:27:51 +00:00
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n#{2,3}\s|\z)/gms
2026-03-02 21:38:01 +00:00
while (( m = attrRe . exec ( text )) !== null ) {
const content = m [ 3 ]. trim (). replace ( /\n+/g , ' ' )
if ( content . length > 20 ) addSection ( sections , ` ${ m [ 1 ]. trim () } ( ${ m [ 2 ]. trim () } )` , content , seen )
}
2026-03-03 00:27:51 +00:00
// 5. Intro paragraph before first ## / ### or ---
const introMatch = /^([\s\S]+?)(?=\n#{2,3}\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m . exec ( text )
2026-03-02 21:38:01 +00:00
if ( introMatch ) {
const intro = introMatch [ 1 ]. trim (). replace ( /^[#*_\s-]+/gm , '' ). trim ()
if ( intro . length > 60 && ! seen . has ( 'Summary' )) {
addSection ( sections , 'Summary' , intro , seen )
}
}
// 6. "This is being called..." / closing paragraph
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i . exec ( text )
if ( closingMatch && ! seen . has ( 'Key' )) {
addSection ( sections , 'Key takeaway' , closingMatch [ 1 ]. trim (), seen )
}
// 7. "For deeper analysis" / podcast or further reading block
const deeperMatch = /(?:For deeper analysis[^:]*:[\s\S]+?)(?=\n\z)/im . exec ( text )
if ( deeperMatch ) {
const block = deeperMatch [ 1 ]. trim ()
if ( block . length > 30 && ! seen . has ( 'Further' )) {
addSection ( sections , 'Further reading' , block , seen )
}
}
// Preserve document order: Summary first, then ## blocks order, then camps, then key/further
const order = [ 'Summary' , 'Key takeaway' , 'Further reading' ]
sections . sort (( a , b ) => {
const ai = order . indexOf ( a . title )
const bi = order . indexOf ( b . title )
if ( ai >= 0 && bi >= 0 ) return ai - bi
if ( ai >= 0 ) return - 1
if ( bi >= 0 ) return 1
return 0
})
2026-03-02 21:29:50 +00:00
return sections
}
/** Extract first image from text for magazine hero */
function extractMagazineHeroImage ( text : string ) : string | undefined {
return extractFirstImageFromText ( text )
}
/** Extract **Name** (domain) pattern, e.g. **Bitcoin Mailing List** (gnusha.org) */
function extractBoldDomainLinks ( text : string ) : WebSearchResult [] {
const results : WebSearchResult [] = []
const seen = new Set < string >()
const re = /\*\*([^*]+)\*\*\s*\(([a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,})\)/g
let match : RegExpExecArray | null
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim (). slice ( 0 , 500 )
const domain = match [ 2 ]. trim ()
if ( title . length < 2 ) continue
const url = /^https?:\/\//i . test ( domain ) ? domain : `https:// ${ domain } `
const norm = normUrl ( url )
if ( seen . has ( norm )) continue
seen . add ( norm )
results . push ({ title , url , content : undefined })
}
return results
}
/** Infer which tab to show first from user prompt keywords */
/** Extract a short contextual phrase from the user query for display (e.g. "BIP 110" from "what is BIP 110") */
function extractQueryContext ( q : string ) : string {
const stop = /\b(what|is|are|the|a|an|latest|recent|current|news|about|for|how|why|when|where|can|could|should|would|tell|me|please|best|good)\b/gi
const cleaned = q . replace ( stop , ' ' ). replace ( /\s+/g , ' ' ). trim (). slice ( 0 , 60 )
return cleaned || ''
}
function preferredFirstTab ( userQuery : string ) : ContentTab | null {
const q = userQuery . toLowerCase (). trim ()
if ( /\b(film|movie|movies)\b/ . test ( q )) return 'film'
if ( /\b(song|music|track|album|band|artist|listen)\b/ . test ( q )) return 'song'
if ( /\b(podcast|episode|show|listen to)\b/ . test ( q )) return 'podcast'
if ( isNewsQuery ( q )) return 'news'
if ( isWebsitesQuery ( q )) return 'websites'
return null
}
/** Filter which content types to show based on query + response context (no presets).
* First tab defaults to what the user asked about when detectable. */
function filterTabsByContext (
userQuery : string ,
hasFilms : boolean ,
hasSongs : boolean ,
hasPodcasts : boolean ,
hasNews : boolean ,
hasWebsites : boolean ,
hasMagazine : boolean ,
) : ContentTab [] {
const q = userQuery . toLowerCase (). trim ()
const preferred = preferredFirstTab ( userQuery )
if ( isNewsQuery ( q )) {
const tabs : ContentTab [] = []
if ( hasMagazine ) tabs . push ( 'magazine' )
if ( hasNews ) tabs . push ( 'news' )
if ( hasWebsites ) tabs . push ( 'websites' )
if ( hasPodcasts ) tabs . push ( 'podcast' )
return tabs
}
if ( hasMagazine && ! hasFilms && ! hasSongs && ! hasPodcasts && ! hasNews && ! hasWebsites ) {
return [ 'magazine' ]
}
if ( hasWebsites && ! hasFilms && ! hasSongs && ! hasPodcasts && ! hasNews && ! hasMagazine ) {
return [ 'websites' ]
}
const all : ContentTab [] = []
if ( hasFilms ) all . push ( 'film' )
if ( hasSongs ) all . push ( 'song' )
if ( hasPodcasts ) all . push ( 'podcast' )
if ( hasMagazine ) all . push ( 'magazine' )
if ( hasNews ) all . push ( 'news' )
if ( hasWebsites ) all . push ( 'websites' )
if ( preferred && all . includes ( preferred )) {
const rest = all . filter (( t ) => t !== preferred )
return [ preferred , ... rest ]
}
return all
}
2026-03-02 16:48:17 +00:00
2026-03-02 16:49:47 +00:00
const FILM_TAG_RE = /\[\[film:(f?\d+)\]\]/gi
2026-03-02 18:16:04 +00:00
const FILM_EXT_RE = /\[\[film_ext:([^|]+)\|(\d{4})\|([^\]]+)\]\]/gi
const SONG_TAG_RE = /\[\[song:(s?\d+)\]\]/gi
const SONG_EXT_RE = /\[\[song_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
2026-03-02 21:29:50 +00:00
/** Reject obvious non-song phrases (news bullets, factual descriptions, etc.) */
function looksLikeSong ( title : string , artist : string ) : boolean {
const t = title . toLowerCase ()
const a = artist . toLowerCase ()
const bad = [
'latest news' , 'protocol updates' , 'community debates' , 'real-time information' ,
'training cutoff' , 'bip discussion' , 'beyond my training' , 'what people are saying' ,
'want me to go' , 'search for what' , 'look up things' , 'direct answer' ,
'for deeper coverage' , 'for instant' , 'check these sources' ,
'bip 110' , 'bip discussions' , 'web search' ,
'developer mailing list' , 'mailing list reactions' , 'technical opinions' ,
'community sentiment' , 'twitter' , 'reddit' , 'github' , 'stackexchange' ,
'bitcoin bips' , 'bitcoin mailing' , 'canonical source' , 'formal dev' ,
'what i\'d suggest' , 'for bip' , 'sources to' ,
]
for ( const phrase of bad ) {
if ( t . includes ( phrase ) || a . includes ( phrase )) return false
}
if ( t . length > 55 || a . length > 40 ) return false
return true
}
2026-03-02 19:57:44 +00:00
const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
2026-03-02 22:02:19 +00:00
/** Reject obvious non-podcast phrases (documentation, mailing lists, etc.) */
function looksLikePodcast ( title : string , host : string ) : boolean {
const t = title . toLowerCase ()
const h = host . toLowerCase ()
const bad = [
'bitcoin mailing list' , 'mailing list' , 'developer mailing list' , 'gnusha.org' ,
'canonical source' , 'formal dev' , 'github' , 'stackexchange' , 'reddit' , 'twitter' ,
'latest news' , 'protocol updates' , 'web search' , 'training cutoff' ,
'documentation' , 'bip discussion' , 'bip 110' , 'bitcoin bips' ,
]
for ( const phrase of bad ) {
if ( t . includes ( phrase ) || h . includes ( phrase )) return false
}
if ( t . length > 80 || h . length > 50 ) return false
return true
}
2026-03-02 21:29:50 +00:00
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
function extractMarkdownLinks ( text : string ) : WebSearchResult [] {
const results : WebSearchResult [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( MARKDOWN_LINK_RE . source , 'g' )
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim (). slice ( 0 , 500 )
const rawUrl = match [ 2 ]. trim ()
if ( title . length < 2 || rawUrl . length < 10 || ! SAFE_URL_SCHEME . test ( rawUrl )) continue
try {
new URL ( rawUrl )
} catch {
continue
}
const norm = rawUrl . toLowerCase (). replace ( /\/$/ , '' )
if ( seen . has ( norm )) continue
seen . add ( norm )
results . push ({ title , url : rawUrl , content : undefined })
}
return results
}
function normUrl ( u : string ) : string {
return u . toLowerCase (). trim (). replace ( /\/$/ , '' )
}
function mergeNewsResults ( web : WebSearchResult [], fromText : WebSearchResult []) : WebSearchResult [] {
const byUrl = new Map < string , WebSearchResult >()
for ( const r of web ) {
byUrl . set ( normUrl ( r . url ), r )
}
for ( const r of fromText ) {
const k = normUrl ( r . url )
if ( ! byUrl . has ( k )) byUrl . set ( k , r )
}
return [... byUrl . values ()]
}
2026-03-02 16:48:17 +00:00
export function useContentPanel() {
2026-03-02 16:49:47 +00:00
function normalizeFilmId ( raw : string ) : string {
return raw . startsWith ( 'f' ) ? raw : `f ${ raw } `
}
2026-03-02 16:48:17 +00:00
function extractFilmIds ( text : string ) : string [] {
const ids : string [] = []
let match : RegExpExecArray | null
2026-03-02 16:49:47 +00:00
const re = new RegExp ( FILM_TAG_RE . source , 'gi' )
2026-03-02 16:48:17 +00:00
while (( match = re . exec ( text )) !== null ) {
2026-03-02 16:49:47 +00:00
const id = normalizeFilmId ( match [ 1 ])
if ( ! ids . includes ( id )) ids . push ( id )
2026-03-02 16:48:17 +00:00
}
return ids
}
function resolveFilms ( ids : string []) : Film [] {
return ids
. map (( id ) => mockFilms . find (( f ) => f . id === id ))
. filter (( f ) : f is Film => !! f )
}
2026-03-03 00:27:51 +00:00
function extractDescriptionForTag ( text : string , matchIndex : number , matchLength : number ) : string {
const prevNewline = text . lastIndexOf ( '\n' , matchIndex - 1 )
const lineStart = prevNewline === - 1 ? 0 : prevNewline + 1
const nextNewline = text . indexOf ( '\n' , matchIndex + matchLength )
const lineEnd = nextNewline === - 1 ? text.length : nextNewline
let line = text . slice ( lineStart , lineEnd )
// Remove the tag itself
line = line . replace ( text . slice ( matchIndex , matchIndex + matchLength ), '' )
// Remove other content tags on the same line
line = line . replace ( /\[\[(?:film|song|podcast)(?:_ext)?:[^\]]*\]\]/g , '' )
// Remove leading bullet/list markers
line = line . replace ( /^\s*[-*•]\s*/ , '' ). replace ( /^\s*\d+\.\s*/ , '' )
// Remove **Bold Title** followed by separator
line = line . replace ( /\*\*[^*]+\*\*\s*[-–—:]\s*/ , '' ). replace ( /\*\*[^*]+\*\*\s*/ , '' )
// Remove stray markdown bold/italic
line = line . replace ( /\*\*/g , '' ). replace ( /\*/g , '' )
// Remove parenthetical (Year) duplicating tag data
line = line . replace ( /\(\d{4}\)\s*/g , '' )
// Clean separators at edges
line = line . replace ( /^[\s\-–—:,]+/ , '' ). replace ( /[\s\-–—:,]+$/ , '' )
const result = line . trim (). slice ( 0 , 300 )
return result . length >= 10 ? result : ''
}
2026-03-02 18:16:04 +00:00
function extractExternalFilms ( text : string ) : Film [] {
const films : Film [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( FILM_EXT_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim ()
const year = parseInt ( match [ 2 ], 10 )
const director = match [ 3 ]. trim ()
const key = ` ${ title . toLowerCase () } | ${ year } `
if ( seen . has ( key )) continue
seen . add ( key )
films . push ({
id : `ext- ${ key . replace ( /\W/g , '-' ) } ` ,
title ,
year ,
posterUrl : generatePosterFallback ( title , year ),
2026-03-03 00:27:51 +00:00
synopsis : extractDescriptionForTag ( text , match . index , match [ 0 ]. length ),
2026-03-02 18:16:04 +00:00
genres : [],
rating : 0 ,
runtime : 0 ,
director ,
cast : [],
sources : [],
})
}
return films
}
function extractAllFilms ( text : string ) : Film [] {
const libraryFilms = resolveFilms ( extractFilmIds ( text ))
const externalFilms = extractExternalFilms ( text )
return [... libraryFilms , ... externalFilms ]
}
function normalizeSongId ( raw : string ) : string {
return raw . startsWith ( 's' ) ? raw : `s ${ raw } `
}
function extractSongIds ( text : string ) : string [] {
const ids : string [] = []
let match : RegExpExecArray | null
const re = new RegExp ( SONG_TAG_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const id = normalizeSongId ( match [ 1 ])
if ( ! ids . includes ( id )) ids . push ( id )
}
return ids
}
function resolveSongs ( ids : string []) : Song [] {
return ids
. map (( id ) => mockSongs . find (( s ) => s . id === id ))
. filter (( s ) : s is Song => !! s )
}
function extractExternalSongs ( text : string ) : Song [] {
const songs : Song [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( SONG_EXT_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim ()
const artist = match [ 2 ]. trim ()
2026-03-02 21:29:50 +00:00
if ( ! looksLikeSong ( title , artist )) continue
2026-03-02 18:16:04 +00:00
const year = match [ 3 ] ? parseInt ( match [ 3 ], 10 ) : undefined
const key = ` ${ title . toLowerCase () } | ${ artist . toLowerCase () } `
if ( seen . has ( key )) continue
seen . add ( key )
songs . push ({
id : `ext- ${ key . replace ( /\W/g , '-' ) } ` ,
title ,
artist ,
year ,
2026-03-02 21:29:50 +00:00
coverUrl : generateSongCoverFallback ( title , artist ),
2026-03-02 18:16:04 +00:00
sources : [],
})
}
return songs
}
2026-03-02 19:37:00 +00:00
/** Infer songs from plain text when no tags present. Requires title+artist within 100 chars, whole-word artist. */
function extractSongsFromLibraryMatch ( text : string ) : Song [] {
2026-03-02 18:16:04 +00:00
const lower = text . toLowerCase ()
2026-03-02 19:37:00 +00:00
const found : { song : Song ; pos : number }[] = []
2026-03-02 18:16:04 +00:00
const seen = new Set < string >()
for ( const song of mockSongs ) {
const key = song . id
if ( seen . has ( key )) continue
2026-03-02 19:37:00 +00:00
const title = song . title . toLowerCase ()
const artist = song . artist . toLowerCase ()
if ( ! lower . includes ( title )) continue
const artistRe = new RegExp ( '\\b' + artist . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) + '\\b' , 'i' )
if ( ! artistRe . test ( lower )) continue
const titlePos = lower . indexOf ( title )
const artistMatch = lower . match ( artistRe )
const artistPos = artistMatch ? . index ?? - 1
if ( artistPos < 0 ) continue
const dist = Math . abs ( titlePos - artistPos )
if ( dist > 120 ) continue
seen . add ( key )
found . push ({ song , pos : Math.min ( titlePos , artistPos ) })
}
return found . sort (( a , b ) => a . pos - b . pos ). map (( f ) => f . song )
}
/** Extract song-like patterns: "Title" by Artist, Title – Artist, 1. Title - Artist */
function extractSongsFromPatterns ( text : string ) : Song [] {
const songs : { title : string ; artist : string ; pos : number }[] = []
const seen = new Set < string >()
const patterns : { re : RegExp ; titleIdx : number ; artistIdx : number }[] = [
{ re : /"([^"]{2,80})"\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/gi , titleIdx : 1 , artistIdx : 2 },
{ re : /\*\*([^*]{2,80})\*\*\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/g , titleIdx : 1 , artistIdx : 2 },
{ re : /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)?([^\n\-–—]{2,60}?)\s*[-–—]\s*([A-Za-z0-9][^,\n]{1,50}?)(?:\s*[,\n\.]|$)/gm , titleIdx : 1 , artistIdx : 2 },
{ re : /([A-Za-z0-9][^\-–—\n]{2,60}?)\s+[-–—]\s+([A-Za-z0-9][^,\n]{1,50}?)(?=\s*[,\n\.]|$)/g , titleIdx : 1 , artistIdx : 2 },
]
for ( const { re , titleIdx , artistIdx } of patterns ) {
let m : RegExpExecArray | null
const rx = new RegExp ( re . source , re . flags )
while (( m = rx . exec ( text )) !== null ) {
const title = m [ titleIdx ]. trim ()
const artist = m [ artistIdx ]. trim ()
if ( title . length < 2 || artist . length < 2 ) continue
2026-03-02 21:29:50 +00:00
if ( ! looksLikeSong ( title , artist )) continue
2026-03-02 19:37:00 +00:00
if ( /^\d{4}$/ . test ( title ) || /^\d{4}$/ . test ( artist )) continue
if ( /\[\[(film|song)(_ext)?:/ . test ( title ) || /\[\[(film|song)(_ext)?:/ . test ( artist )) continue
if ( /\*\*\[\[/ . test ( title ) || title . includes ( ']]**' )) continue
const key = ` ${ title . toLowerCase () } | ${ artist . toLowerCase () } `
if ( seen . has ( key )) continue
2026-03-02 18:16:04 +00:00
seen . add ( key )
2026-03-02 19:37:00 +00:00
songs . push ({ title , artist , pos : m.index })
2026-03-02 16:48:17 +00:00
}
}
2026-03-02 19:37:00 +00:00
return songs
. sort (( a , b ) => a . pos - b . pos )
. map (({ title , artist }) => ({
id : `ext- ${ ` ${ title } | ${ artist } ` . toLowerCase (). replace ( /\W/g , '-' ) } ` ,
title ,
artist ,
2026-03-02 21:29:50 +00:00
coverUrl : generateSongCoverFallback ( title , artist ),
2026-03-02 19:37:00 +00:00
sources : [],
}))
2026-03-02 18:16:04 +00:00
}
function extractAllSongs ( text : string ) : Song [] {
const librarySongs = resolveSongs ( extractSongIds ( text ))
const externalSongs = extractExternalSongs ( text )
if ( librarySongs . length > 0 || externalSongs . length > 0 ) {
return [... librarySongs , ... externalSongs ]
}
2026-03-02 19:37:00 +00:00
if ( extractFilmIds ( text ). length > 0 || /\[\[film_ext:/ . test ( text )) return []
2026-03-02 21:29:50 +00:00
if ( extractPodcastIds ( text ). length > 0 || /\[\[podcast_ext:/ . test ( text )) return []
if ( isNewsLikeResponse ( text )) return []
2026-03-02 19:37:00 +00:00
const libMatches = extractSongsFromLibraryMatch ( text )
const patternMatches = extractSongsFromPatterns ( text )
const libKeys = new Set ( libMatches . map (( s ) => ` ${ s . title . toLowerCase () } | ${ s . artist . toLowerCase () } ` ))
const fromPatterns = patternMatches . filter (
( p ) => ! libKeys . has ( ` ${ p . title . toLowerCase () } | ${ p . artist . toLowerCase () } ` )
)
return [... libMatches , ... fromPatterns ]
2026-03-02 18:16:04 +00:00
}
2026-03-02 19:57:44 +00:00
function normalizePodcastId ( raw : string ) : string {
return raw . startsWith ( 'p' ) ? raw : `p ${ raw } `
}
function extractPodcastIds ( text : string ) : string [] {
const ids : string [] = []
let match : RegExpExecArray | null
const re = new RegExp ( PODCAST_TAG_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const id = normalizePodcastId ( match [ 1 ])
if ( ! ids . includes ( id )) ids . push ( id )
}
return ids
}
function resolvePodcasts ( ids : string []) : Podcast [] {
return ids
. map (( id ) => mockPodcasts . find (( p ) => p . id === id ))
. filter (( p ) : p is Podcast => !! p )
}
function extractExternalPodcasts ( text : string ) : Podcast [] {
const podcasts : Podcast [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( PODCAST_EXT_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim ()
const host = match [ 2 ]. trim ()
2026-03-02 22:02:19 +00:00
if ( ! looksLikePodcast ( title , host )) continue
2026-03-02 19:57:44 +00:00
const year = match [ 3 ] ? parseInt ( match [ 3 ], 10 ) : undefined
const key = ` ${ title . toLowerCase () } | ${ host . toLowerCase () } `
if ( seen . has ( key )) continue
seen . add ( key )
podcasts . push ({
id : `ext- ${ key . replace ( /\W/g , '-' ) } ` ,
title ,
host ,
year ,
2026-03-02 21:29:50 +00:00
coverUrl : undefined ,
2026-03-02 19:57:44 +00:00
sources : [],
})
}
return podcasts
}
function extractAllPodcasts ( text : string ) : Podcast [] {
const libraryPodcasts = resolvePodcasts ( extractPodcastIds ( text ))
const externalPodcasts = extractExternalPodcasts ( text )
return [... libraryPodcasts , ... externalPodcasts ]
}
2026-03-02 21:29:50 +00:00
function updatePanelFromText ( text : string , userQuery = '' , webResults : WebSearchResult [] = []) {
panelQuery . value = userQuery . trim ()
2026-03-02 18:16:04 +00:00
const songs = extractAllSongs ( text )
const films = extractAllFilms ( text )
2026-03-02 19:57:44 +00:00
const podcasts = extractAllPodcasts ( text )
2026-03-02 21:29:50 +00:00
const fromMarkdown = extractMarkdownLinks ( text )
const boldDomains = extractBoldDomainLinks ( text )
2026-03-02 18:16:04 +00:00
2026-03-02 21:29:50 +00:00
panelRssArticles . value = [] // clear; will repopulate when RSS fetch completes
// Websites = plain links from response (markdown + bold domains).
const hasLinkableContent = fromMarkdown . length > 0 || boldDomains . length > 0
const websitesFromMarkdown = hasLinkableContent ? fromMarkdown : []
const mergedWebsites = mergeNewsResults ( websitesFromMarkdown , boldDomains )
const hasWebsites = mergedWebsites . length > 0
// Magazine = bullet-style sections (- **Title**: Content)
const magazineSections = extractMagazineSections ( text )
2026-03-02 21:38:01 +00:00
const hasMagazine = magazineSections . length >= 1 && ( isNewsQuery ( userQuery ) || isNewsLikeResponse ( text ) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i . test ( text ))
2026-03-02 21:29:50 +00:00
// News = actual articles (web search + RSS from website domains). Plain links → Websites.
const newsContext = isNewsQuery ( userQuery ) || isNewsLikeResponse ( text )
const hasNews = ( webResults . length > 0 || mergedWebsites . length > 0 ) && newsContext
const mergedNews = hasNews ? mergeNewsResults ( webResults , panelRssArticles . value ) : []
2026-03-02 22:02:19 +00:00
// Fetch RSS from website URLs only when news context — avoid surfacing RSS from docs/resource links
if ( mergedWebsites . length > 0 && newsContext ) {
2026-03-02 21:29:50 +00:00
const urls = mergedWebsites . map (( w ) => w . url )
fetchRssFromUrls ( urls ). then (( articles ) => {
if ( articles . length === 0 ) return
panelRssArticles . value = articles
const combined = mergeNewsResults ( panelWebResults . value , articles )
panelWebResults . value = combined
if ( ! availableTabs . value . includes ( 'news' )) {
availableTabs . value = [ 'news' , ... availableTabs . value ]
activeTab . value = 'news'
}
const ctx = extractQueryContext ( panelQuery . value )
panelTitle . value = ctx ? ` ${ ctx } — ${ combined . length } articles` : ` ${ combined . length } Articles`
})
}
const tabs = filterTabsByContext ( userQuery , films . length > 0 , songs . length > 0 , podcasts . length > 0 , hasNews , hasWebsites , hasMagazine )
availableTabs . value = tabs . length > 0 ? tabs : [ 'film' ]
activeTab . value = tabs [ 0 ] ?? 'film'
const showFilms = tabs . includes ( 'film' )
const showSongs = tabs . includes ( 'song' )
const showPodcasts = tabs . includes ( 'podcast' )
const showNews = tabs . includes ( 'news' )
const showWebsites = tabs . includes ( 'websites' )
const showMagazine = tabs . includes ( 'magazine' )
const visibleFilms = showFilms ? films : []
const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : []
const visibleWebsites = showWebsites ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
panelFilms . value = visibleFilms
panelSongs . value = visibleSongs
panelPodcasts . value = visiblePodcasts
panelWebResults . value = visibleNews
panelWebsites . value = visibleWebsites
panelMagazineSections . value = visibleMagazineSections
panelMagazineHeroImage . value = showMagazine
? ( extractMagazineHeroImage ( text ) ?? webResults [ 0 ] ? . imgSrc ?? null )
: null
selectedFilm . value = null
selectedSong . value = null
selectedPodcast . value = null
if ( visibleFilms . length > 0 ) contentType . value = 'film'
else if ( visibleSongs . length > 0 ) contentType . value = 'song'
else if ( visiblePodcasts . length > 0 ) contentType . value = 'podcast'
else if ( visibleNews . length > 0 ) contentType . value = 'film'
else contentType . value = 'film'
if ( visibleFilms . length === 1 ) panelTitle . value = visibleFilms [ 0 ]. title
else if ( visibleFilms . length > 1 ) panelTitle . value = ` ${ visibleFilms . length } Films`
else if ( visibleSongs . length === 1 ) panelTitle . value = visibleSongs [ 0 ]. title
else if ( visibleSongs . length > 1 ) panelTitle . value = ` ${ visibleSongs . length } Songs`
else if ( visiblePodcasts . length === 1 ) panelTitle . value = visiblePodcasts [ 0 ]. title
else if ( visiblePodcasts . length > 1 ) panelTitle . value = ` ${ visiblePodcasts . length } Podcasts`
else if ( visibleNews . length > 0 ) {
const ctx = extractQueryContext ( userQuery )
panelTitle . value = ctx ? ` ${ ctx } — ${ visibleNews . length } articles` : ` ${ visibleNews . length } Articles`
}
else if ( visibleMagazineSections . length > 0 ) {
const ctx = extractQueryContext ( userQuery )
panelTitle . value = ctx ? ` ${ ctx } — Brief` : 'Market Brief'
}
else if ( visibleWebsites . length > 0 ) panelTitle . value = ` ${ visibleWebsites . length } Websites`
else panelTitle . value = 'Content'
panelOpen . value = tabs . length > 0
}
function setActiveTab ( tab : ContentTab ) {
if ( availableTabs . value . includes ( tab )) activeTab . value = tab
}
/** Contextual films/songs/podcasts/news/websites/magazine for inline cards (respects query+response, no presets) */
function getContextualInlineContent ( text : string , userQuery : string , webResults : WebSearchResult [] = []) {
const films = extractAllFilms ( text )
const songs = extractAllSongs ( text )
const podcasts = extractAllPodcasts ( text )
const magazineSections = extractMagazineSections ( text )
2026-03-02 21:38:01 +00:00
const hasMagazine = magazineSections . length >= 1 && ( isNewsQuery ( userQuery ) || isNewsLikeResponse ( text ) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i . test ( text ))
2026-03-02 21:29:50 +00:00
const fromMarkdown = extractMarkdownLinks ( text )
const boldDomains = extractBoldDomainLinks ( text )
const hasNews = webResults . length > 0 && ( isNewsQuery ( userQuery ) || isNewsLikeResponse ( text ))
const newsLinks = hasNews ? webResults : []
const hasLinkableContent = fromMarkdown . length > 0 || boldDomains . length > 0
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
const websitesLinks = mergeNewsResults ( websitesFromMd , boldDomains )
const hasWebsites = websitesLinks . length > 0
const tabs = filterTabsByContext ( userQuery , films . length > 0 , songs . length > 0 , podcasts . length > 0 , hasNews , hasWebsites , hasMagazine )
return {
films : tabs.includes ( 'film' ) ? films : [],
songs : tabs.includes ( 'song' ) ? songs : [],
podcasts : tabs.includes ( 'podcast' ) ? podcasts : [],
newsLinks : tabs.includes ( 'news' ) ? newsLinks : [],
websitesLinks : tabs.includes ( 'websites' ) ? websitesLinks : [],
magazineSections : tabs.includes ( 'magazine' ) ? magazineSections : [],
2026-03-02 18:16:04 +00:00
}
2026-03-02 16:48:17 +00:00
}
function stripFilmTags ( text : string ) : string {
2026-03-02 18:16:04 +00:00
return text
. replace ( FILM_TAG_RE , '' )
. replace ( FILM_EXT_RE , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
function stripSongTags ( text : string ) : string {
return text
. replace ( SONG_TAG_RE , '' )
. replace ( SONG_EXT_RE , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
2026-03-02 19:57:44 +00:00
function stripPodcastTags ( text : string ) : string {
return text
. replace ( PODCAST_TAG_RE , '' )
. replace ( PODCAST_EXT_RE , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
2026-03-02 18:16:04 +00:00
function stripContentTags ( text : string ) : string {
2026-03-02 19:57:44 +00:00
return stripFilmTags ( stripSongTags ( stripPodcastTags ( text )))
2026-03-02 16:48:17 +00:00
}
2026-03-02 21:29:50 +00:00
/** Remove markdown links when surfacing as inline cards to avoid duplication */
function stripMarkdownLinks ( text : string ) : string {
return text
. replace ( /^[\s]*[-*]\s*\[[^\]]+\]\(https?:\/\/[^)\s]+\)\s*$/gm , '' )
. replace ( /\s*\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g , ( _ , title ) => ` ${ title } ` )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
2026-03-02 16:48:17 +00:00
function openFilmDetail ( film : Film ) {
selectedFilm . value = film
2026-03-02 18:16:04 +00:00
selectedSong . value = null
2026-03-02 19:57:44 +00:00
selectedPodcast . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-02 16:48:17 +00:00
}
function closeFilmDetail() {
selectedFilm . value = null
}
2026-03-02 18:16:04 +00:00
function openSongDetail ( song : Song ) {
selectedSong . value = song
selectedFilm . value = null
2026-03-02 19:57:44 +00:00
selectedPodcast . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-02 18:16:04 +00:00
}
function closeSongDetail() {
selectedSong . value = null
}
2026-03-02 19:57:44 +00:00
function openPodcastDetail ( podcast : Podcast ) {
selectedPodcast . value = podcast
selectedFilm . value = null
selectedSong . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-02 19:57:44 +00:00
}
function closePodcastDetail() {
selectedPodcast . value = null
}
2026-03-02 21:29:50 +00:00
function openArticleDetail ( article : WebSearchResult ) {
selectedArticle . value = article
selectedFilm . value = null
selectedSong . value = null
selectedPodcast . value = null
panelOpen . value = true
}
function closeArticleDetail() {
selectedArticle . value = null
}
2026-03-02 16:48:17 +00:00
function closePanel() {
panelOpen . value = false
selectedFilm . value = null
2026-03-02 18:16:04 +00:00
selectedSong . value = null
2026-03-02 19:57:44 +00:00
selectedPodcast . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
activeTab . value = 'film'
availableTabs . value = []
2026-03-02 16:48:17 +00:00
}
function showAllFilms() {
panelFilms . value = [... mockFilms ]
2026-03-02 18:16:04 +00:00
panelSongs . value = []
2026-03-02 19:57:44 +00:00
panelPodcasts . value = []
2026-03-02 16:48:17 +00:00
panelTitle . value = 'Your Film Library'
2026-03-02 18:16:04 +00:00
contentType . value = 'film'
2026-03-02 16:48:17 +00:00
panelOpen . value = true
selectedFilm . value = null
2026-03-02 18:16:04 +00:00
selectedSong . value = null
2026-03-02 19:57:44 +00:00
selectedPodcast . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-02 18:16:04 +00:00
}
function showAllSongs() {
panelFilms . value = []
panelSongs . value = [... mockSongs ]
2026-03-02 19:57:44 +00:00
panelPodcasts . value = []
2026-03-02 18:16:04 +00:00
panelTitle . value = 'Your Song Library'
contentType . value = 'song'
panelOpen . value = true
selectedFilm . value = null
selectedSong . value = null
2026-03-02 19:57:44 +00:00
selectedPodcast . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-02 19:57:44 +00:00
}
function showAllPodcasts() {
panelFilms . value = []
panelSongs . value = []
panelPodcasts . value = [... mockPodcasts ]
panelTitle . value = 'Your Podcast Library'
contentType . value = 'podcast'
panelOpen . value = true
selectedFilm . value = null
selectedSong . value = null
selectedPodcast . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-02 16:48:17 +00:00
}
return {
panelOpen ,
panelFilms ,
2026-03-02 18:16:04 +00:00
panelSongs ,
2026-03-02 19:57:44 +00:00
panelPodcasts ,
2026-03-02 21:29:50 +00:00
panelWebResults ,
panelWebsites ,
panelMagazineSections ,
panelMagazineHeroImage ,
2026-03-02 16:48:17 +00:00
selectedFilm ,
2026-03-02 18:16:04 +00:00
selectedSong ,
2026-03-02 19:57:44 +00:00
selectedPodcast ,
2026-03-02 21:29:50 +00:00
selectedArticle ,
2026-03-02 16:48:17 +00:00
panelTitle ,
2026-03-02 21:29:50 +00:00
panelQuery ,
2026-03-02 18:16:04 +00:00
contentType ,
2026-03-02 21:29:50 +00:00
activeTab ,
availableTabs ,
setActiveTab ,
2026-03-02 16:48:17 +00:00
extractFilmIds ,
resolveFilms ,
2026-03-02 18:16:04 +00:00
extractAllFilms ,
extractSongIds ,
resolveSongs ,
extractAllSongs ,
2026-03-02 19:57:44 +00:00
extractPodcastIds ,
resolvePodcasts ,
extractAllPodcasts ,
2026-03-02 21:29:50 +00:00
getContextualInlineContent ,
2026-03-02 16:48:17 +00:00
updatePanelFromText ,
stripFilmTags ,
2026-03-02 18:16:04 +00:00
stripSongTags ,
2026-03-02 19:57:44 +00:00
stripPodcastTags ,
2026-03-02 18:16:04 +00:00
stripContentTags ,
2026-03-02 21:29:50 +00:00
stripMarkdownLinks ,
2026-03-02 16:48:17 +00:00
openFilmDetail ,
closeFilmDetail ,
2026-03-02 18:16:04 +00:00
openSongDetail ,
closeSongDetail ,
2026-03-02 19:57:44 +00:00
openPodcastDetail ,
closePodcastDetail ,
2026-03-02 21:29:50 +00:00
openArticleDetail ,
closeArticleDetail ,
2026-03-02 16:48:17 +00:00
closePanel ,
showAllFilms ,
2026-03-02 18:16:04 +00:00
showAllSongs ,
2026-03-02 19:57:44 +00:00
showAllPodcasts ,
2026-03-02 16:48:17 +00:00
}
}