2026-03-02 18:16:04 +00:00
import { ref } from 'vue'
2026-03-03 09:31:18 +00:00
import type { Film , Song , Podcast , Book , TVSeries , ImageItem , Place } 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-03 01:27:00 +00:00
import { generatePosterFallback , generateSongCoverFallback , generateBookCoverFallback } from '@/composables/useImageFallback'
2026-03-02 21:29:50 +00:00
import { fetchRssFromUrls } from '@/composables/useRssFetch'
2026-03-03 09:31:18 +00:00
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code'
2026-03-02 21:29:50 +00:00
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-03 07:30:19 +00:00
/** Heading group this section belongs to (for banner display) */
group? : string
2026-03-02 21:29:50 +00:00
}
2026-03-02 16:48:17 +00:00
const panelOpen = ref ( false )
const panelFilms = ref < Film [] >([])
2026-03-03 01:27:00 +00:00
const panelBooks = ref < Book [] >([])
2026-03-03 06:50:03 +00:00
const panelTVSeries = ref < TVSeries [] >([])
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-03 07:15:02 +00:00
const panelImages = ref < ImageItem [] >([])
2026-03-03 09:31:18 +00:00
const panelPlaces = ref < Place [] >([])
2026-03-02 16:48:17 +00:00
const selectedFilm = ref < Film | null >( null )
2026-03-03 01:27:00 +00:00
const selectedBook = ref < Book | null >( null )
2026-03-03 06:50:03 +00:00
const selectedTVSeries = ref < TVSeries | 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-03 07:15:02 +00:00
const selectedImage = ref < ImageItem | null >( null )
2026-03-03 09:31:18 +00:00
const selectedPlace = ref < Place | null >( null )
const selectedWebsite = ref < WebSearchResult | null >( null )
const selectedMagazineSection = ref < MagazineSection | null >( null )
const magazineSectionIndex = ref ( 0 )
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 >,
2026-03-03 07:30:19 +00:00
group? : string ,
2026-03-02 21:38:01 +00:00
) : void {
2026-03-03 07:30:19 +00:00
const t = title . trim (). replace ( /[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
const c = content . trim (). replace ( /[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').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-03 07:30:19 +00:00
group ,
2026-03-02 21:38:01 +00:00
})
}
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
2026-03-03 07:30:19 +00:00
function cleanMagazineContent ( raw : string ) : string {
return raw
. replace ( /\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g , '' ) // strip content tags
. replace ( /^\s*\n---\s*\n?/g , '' ) // strip --- separators
. replace ( /\n---\s*$/g , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
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 >()
2026-03-03 07:30:19 +00:00
// Strip sources/references and "For deeper" sections at the end
let cleanedText = text
. replace ( /\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i , '' )
. replace ( /\n+For deeper[^:]*:[\s\S]*$/im , '' )
// 1. Find all ## or ### headings and extract content between them
const headingPositions : { title : string ; start : number ; contentStart : number }[] = []
const headingLineRe = /^#{2,3}\s+(.+)$/gm
2026-03-02 21:38:01 +00:00
let m : RegExpExecArray | null
2026-03-03 07:30:19 +00:00
while (( m = headingLineRe . exec ( cleanedText )) !== null ) {
const rawTitle = m [ 1 ]. trim ()
const title = rawTitle
. replace ( /[\p{Emoji_Presentation}\p{Extended_Pictographic}]\uFE0F?\s*/gu, '')
. replace ( /^[#*_\s-]+/ , '' )
. trim ()
if ( title . length > 1 ) {
headingPositions . push ({ title , start : m.index , contentStart : m.index + m [ 0 ]. length })
}
}
// For each heading, extract individual bullet points as separate sections
for ( let i = 0 ; i < headingPositions . length ; i ++ ) {
const hp = headingPositions [ i ]
const nextStart = i + 1 < headingPositions . length ? headingPositions [ i + 1 ]. start : cleanedText.length
const rawContent = cleanMagazineContent ( cleanedText . slice ( hp . contentStart , nextStart ))
if ( rawContent . length < 20 ) continue
// Split bullets within this heading block
const bullets = rawContent
. split ( /\n\s*[-•]\s+/ )
. map ( s => s . replace ( /^[-•]\s*/ , '' ). trim ())
. filter ( s => s . length > 10 )
if ( bullets . length >= 2 ) {
// Multiple bullets: each bullet becomes its own section under this heading group
for ( const bullet of bullets ) {
const cleaned = cleanMagazineContent ( bullet )
if ( cleaned . length < 15 ) continue
// Extract bold title from bullet if present: **Title**: content or **Title** — content
const boldMatch = /^\*\*([^*]+)\*\*\s*[:\u2014\u2013– ]\s*(.+)/s.exec(cleaned)
if ( boldMatch ) {
addSection ( sections , boldMatch [ 1 ]. trim (), boldMatch [ 2 ]. trim (), seen , hp . title )
} else {
// No bold title — use first sentence as title, full text as content
const sentenceMatch = /^([^.!?]{10,80}[.!?])/ . exec ( cleaned )
const bulletTitle = sentenceMatch ? sentenceMatch [ 1 ] : cleaned . slice ( 0 , 60 )
addSection ( sections , bulletTitle , cleaned , seen , hp . title )
}
}
} else {
// Single block content — add as one section
addSection ( sections , hp . title , rawContent , 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 07:30:19 +00:00
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|$)/gim
while (( m = campRe . exec ( cleanedText )) !== null ) {
2026-03-02 21:38:01 +00:00
const title = m [ 1 ]. trim ()
2026-03-03 07:30:19 +00:00
const content = cleanMagazineContent ( m [ 2 ])
2026-03-02 21:38:01 +00:00
if ( content . length > 15 ) addSection ( sections , title , content , seen )
}
2026-03-03 07:30:19 +00:00
// 3. Intro paragraph before first heading or ---
const firstHeadingIdx = headingPositions . length > 0 ? headingPositions [ 0 ]. start : - 1
const introEnd = firstHeadingIdx > 0 ? firstHeadingIdx : cleanedText.indexOf ( '\n---\n' )
if ( introEnd > 0 ) {
const intro = cleanedText . slice ( 0 , introEnd ). trim ()
. replace ( /^[#*_\s-]+/gm , '' ). trim ()
if ( intro . length > 30 && ! seen . has ( 'Summary' )) {
2026-03-02 21:38:01 +00:00
addSection ( sections , 'Summary' , intro , seen )
}
}
2026-03-03 07:30:19 +00:00
// 4. "This is being called..." / closing paragraph
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i . exec ( cleanedText )
2026-03-02 21:38:01 +00:00
if ( closingMatch && ! seen . has ( 'Key' )) {
addSection ( sections , 'Key takeaway' , closingMatch [ 1 ]. trim (), seen )
}
2026-03-03 07:30:19 +00:00
// Preserve document order: Summary first, then heading content in order, then key
const order = [ 'Summary' , 'Key takeaway' ]
2026-03-02 21:38:01 +00:00
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'
2026-03-03 01:27:00 +00:00
if ( /\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/ . test ( q )) return 'book'
2026-03-03 06:50:03 +00:00
if ( /\b(tv show|tv series|series|television|streaming|binge|watch)\b/ . test ( q )) return 'tvshow'
2026-03-03 07:15:02 +00:00
if ( /\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/ . test ( q )) return 'image'
2026-03-03 09:31:18 +00:00
if ( /\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/ . test ( q )) return 'place'
2026-03-02 21:29:50 +00:00
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 ,
2026-03-03 01:27:00 +00:00
hasBooks : boolean ,
2026-03-03 06:50:03 +00:00
hasTVSeries : boolean ,
2026-03-03 07:15:02 +00:00
hasImages : boolean ,
2026-03-03 09:31:18 +00:00
hasPlaces : boolean ,
2026-03-02 21:29:50 +00:00
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
}
2026-03-03 09:31:18 +00:00
if ( hasMagazine && ! hasFilms && ! hasSongs && ! hasPodcasts && ! hasBooks && ! hasTVSeries && ! hasImages && ! hasPlaces && ! hasNews && ! hasWebsites ) {
2026-03-02 21:29:50 +00:00
return [ 'magazine' ]
}
2026-03-03 09:31:18 +00:00
if ( hasWebsites && ! hasFilms && ! hasSongs && ! hasPodcasts && ! hasBooks && ! hasTVSeries && ! hasImages && ! hasPlaces && ! hasNews && ! hasMagazine ) {
2026-03-02 21:29:50 +00:00
return [ 'websites' ]
}
const all : ContentTab [] = []
if ( hasFilms ) all . push ( 'film' )
2026-03-03 01:27:00 +00:00
if ( hasBooks ) all . push ( 'book' )
2026-03-03 06:50:03 +00:00
if ( hasTVSeries ) all . push ( 'tvshow' )
2026-03-03 07:15:02 +00:00
if ( hasImages ) all . push ( 'image' )
2026-03-03 09:31:18 +00:00
if ( hasPlaces ) all . push ( 'place' )
2026-03-02 21:29:50 +00:00
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
2026-03-03 01:27:00 +00:00
const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
2026-03-03 06:50:03 +00:00
const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
2026-03-03 09:31:18 +00:00
const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
2026-03-03 06:50:03 +00:00
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-03 01:27:00 +00:00
function extractExternalBooks ( text : string ) : Book [] {
const books : Book [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( BOOK_EXT_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim ()
const author = match [ 2 ]. trim ()
const year = match [ 3 ] ? parseInt ( match [ 3 ], 10 ) : undefined
const key = ` ${ title . toLowerCase () } | ${ author . toLowerCase () } `
if ( seen . has ( key )) continue
seen . add ( key )
books . push ({
id : `ext- ${ key . replace ( /\W/g , '-' ) } ` ,
title ,
author ,
year ,
coverUrl : undefined ,
description : extractDescriptionForTag ( text , match . index , match [ 0 ]. length ),
genres : [],
sources : [],
})
}
return books
}
/** Extract book-like patterns from AI response text:
* - "Title" by Author (quoted)
* - **Title** by Author (bold markdown)
* - Title — Author (list format)
* Only matches when context suggests books (book query or book-like response) */
function extractBooksFromPatterns ( text : string ) : Book [] {
const books : { title : string ; author : string ; year? : number ; desc : string ; pos : number }[] = []
const seen = new Set < string >()
const patterns : { re : RegExp ; titleIdx : number ; authorIdx : number }[] = [
// "Title" by Author
{ re : /["""]([^"""]{2,80})["""]\s+by\s+([A-Z][^,\n\.]{1,50}?)(?:\s*[,()\n\.]|$)/gi , titleIdx : 1 , authorIdx : 2 },
2026-03-03 07:03:29 +00:00
// **Title** by/—/– Author (with optional italic on author)
{ re : /\*\*([^*]{2,80})\*\*\s+(?:by|—|– )\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/g , titleIdx : 1 , authorIdx : 2 },
2026-03-03 01:27:00 +00:00
// - Title — Author or Title by Author (in list)
2026-03-03 07:03:29 +00:00
{ re : /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|– )\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/gm , titleIdx : 1 , authorIdx : 2 },
2026-03-03 01:27:00 +00:00
]
for ( const { re , titleIdx , authorIdx } of patterns ) {
let m : RegExpExecArray | null
const rx = new RegExp ( re . source , re . flags )
while (( m = rx . exec ( text )) !== null ) {
2026-03-03 07:03:29 +00:00
const title = m [ titleIdx ]. trim (). replace ( /^\*\*|\*\*$/g , '' ). replace ( /^\[|\]$/g , '' )
2026-03-03 01:27:00 +00:00
const author = m [ authorIdx ]. trim (). replace ( /^\*\*|\*\*$/g , '' )
if ( title . length < 2 || author . length < 2 ) continue
// Skip if it looks like a film/song/podcast tag
if ( /\[\[(film|song|podcast|book)(_ext)?:/ . test ( title )) continue
// Skip numbers-only
if ( /^\d{4}$/ . test ( title ) || /^\d{4}$/ . test ( author )) continue
2026-03-03 07:03:29 +00:00
// Skip markdown links [text](url) — these are source references, not books
const afterMatch = text . substring ( m . index + m [ 0 ]. length , m . index + m [ 0 ]. length + 200 )
if ( /^\s*\]\s*\(https?:/ . test ( afterMatch ) || /\]\(https?:\/\// . test ( m [ 0 ])) continue
2026-03-03 01:27:00 +00:00
const key = ` ${ title . toLowerCase () } | ${ author . toLowerCase () } `
if ( seen . has ( key )) continue
seen . add ( key )
const desc = extractDescriptionForTag ( text , m . index , m [ 0 ]. length )
books . push ({ title , author , desc , pos : m.index })
}
}
return books
. sort (( a , b ) => a . pos - b . pos )
. map (({ title , author , desc }) => ({
id : `ext- ${ ` ${ title } | ${ author } ` . toLowerCase (). replace ( /\W/g , '-' ) } ` ,
title ,
author ,
description : desc ,
coverUrl : undefined ,
genres : [],
sources : [],
}))
}
function isBookQuery ( q : string ) : boolean {
return /\b(book|books|read|reading|novel|novels|author|nonfiction|non-fiction|recommend.*read|must.read|literature)\b/i . test ( q )
}
function isBookLikeResponse ( text : string ) : boolean {
return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i . test ( text ) &&
( text . match ( /\bby\s+[A-Z]/g ) ? . length ?? 0 ) >= 2
}
function extractAllBooks ( text : string , userQuery : string ) : Book [] {
const externalBooks = extractExternalBooks ( text )
if ( externalBooks . length > 0 ) return externalBooks
// Only do fallback pattern matching if the query or response looks book-related
if ( ! isBookQuery ( userQuery ) && ! isBookLikeResponse ( text )) return []
2026-03-03 07:03:29 +00:00
// Skip if other content types are the primary content (not just supplementary refs)
// When user explicitly asked about books, film/song tags are just cross-references
if ( ! isBookQuery ( userQuery )) {
if ( extractFilmIds ( text ). length > 0 || /\[\[film_ext:/ . test ( text )) return []
if ( extractSongIds ( text ). length > 0 || /\[\[song_ext:/ . test ( text )) return []
}
2026-03-03 01:27:00 +00:00
if ( isNewsLikeResponse ( text )) return []
2026-03-03 07:03:29 +00:00
// Strip sources/references section at the end to avoid matching markdown links as books
const cleanText = text . replace ( /\n---\n\s*(?:Sources|References|Links):?\s*\n[\s\S]*$/i , '' )
return extractBooksFromPatterns ( cleanText )
2026-03-03 01:27:00 +00:00
}
2026-03-03 06:50:03 +00:00
function extractExternalTVSeries ( text : string ) : TVSeries [] {
const series : TVSeries [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( TV_EXT_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const title = match [ 1 ]. trim ()
const creator = match [ 2 ]. trim ()
const year = match [ 3 ] ? parseInt ( match [ 3 ], 10 ) : undefined
const key = title . toLowerCase ()
if ( seen . has ( key )) continue
seen . add ( key )
series . push ({
id : `ext- ${ key . replace ( /\W/g , '-' ) } ` ,
title ,
creator : creator || undefined ,
year ,
synopsis : extractDescriptionForTag ( text , match . index , match [ 0 ]. length ),
genres : [],
sources : [],
})
}
return series
}
function isTVQuery ( q : string ) : boolean {
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season)\b/i . test ( q )
}
function isTVLikeResponse ( text : string ) : boolean {
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i . test ( text ) &&
( text . match ( /\bseason\b/gi ) ? . length ?? 0 ) >= 2
}
function extractTVSeriesFromPatterns ( text : string ) : TVSeries [] {
const series : { title : string ; desc : string ; pos : number }[] = []
const seen = new Set < string >()
const patterns : RegExp [] = [
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi ,
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi ,
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-– ]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm ,
]
for ( const re of patterns ) {
let m : RegExpExecArray | null
const rx = new RegExp ( re . source , re . flags )
while (( m = rx . exec ( text )) !== null ) {
const title = m [ 1 ]. trim (). replace ( /^\*\*|\*\*$/g , '' )
if ( title . length < 2 ) continue
if ( /\[\[(film|song|podcast|book|tv)(_ext)?:/ . test ( title )) continue
const key = title . toLowerCase ()
if ( seen . has ( key )) continue
seen . add ( key )
const desc = extractDescriptionForTag ( text , m . index , m [ 0 ]. length )
series . push ({ title , desc , pos : m.index })
}
}
return series
. sort (( a , b ) => a . pos - b . pos )
. map (({ title , desc }) => ({
id : `ext- ${ title . toLowerCase (). replace ( /\W/g , '-' ) } ` ,
title ,
synopsis : desc ,
genres : [],
sources : [],
}))
}
function convertFilmExtToTVSeries ( text : string ) : TVSeries [] {
const series : TVSeries [] = []
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 creator = match [ 3 ]. trim ()
const key = title . toLowerCase ()
if ( seen . has ( key )) continue
seen . add ( key )
series . push ({
id : `ext- ${ key . replace ( /\W/g , '-' ) } ` ,
title ,
year ,
synopsis : extractDescriptionForTag ( text , match . index , match [ 0 ]. length ),
creator ,
genres : [],
sources : [],
})
}
return series
}
function extractAllTVSeries ( text : string , userQuery : string ) : TVSeries [] {
const external = extractExternalTVSeries ( text )
if ( external . length > 0 ) return external
if ( ! isTVQuery ( userQuery ) && ! isTVLikeResponse ( text )) return []
if ( isNewsLikeResponse ( text )) return []
// When user asked about TV but AI used film_ext tags, convert them
if ( isTVQuery ( userQuery ) && /\[\[film_ext:/ . test ( text )) {
return convertFilmExtToTVSeries ( text )
}
if ( extractFilmIds ( text ). length > 0 ) return []
return extractTVSeriesFromPatterns ( text )
}
2026-03-03 07:15:02 +00:00
/** Extract images from markdown image syntax and raw image URLs */
function extractImages ( text : string ) : ImageItem [] {
const images : ImageItem [] = []
const seen = new Set < string >()
// Match markdown images: 
const mdImgRe = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/gi
let m : RegExpExecArray | null
while (( m = mdImgRe . exec ( text )) !== null ) {
const alt = m [ 1 ]. trim ()
const url = m [ 2 ]. trim ()
if ( seen . has ( url )) continue
seen . add ( url )
const domain = new URL ( url ). hostname . replace ( /^www\./ , '' )
images . push ({
id : `img- ${ seen . size } ` ,
url ,
alt : alt || undefined ,
title : alt || undefined ,
source : domain ,
})
}
// Match raw image URLs not already captured
const urlRe = /(https?:\/\/[^\s)"'>]+\.(?:jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff)(?:\?[^\s)"'>]*)?)/gi
while (( m = urlRe . exec ( text )) !== null ) {
const url = m [ 1 ]. trim ()
if ( seen . has ( url )) continue
seen . add ( url )
const domain = new URL ( url ). hostname . replace ( /^www\./ , '' )
images . push ({
id : `img- ${ seen . size } ` ,
url ,
source : domain ,
})
}
return images
}
function isImageQuery ( q : string ) : boolean {
return /\b(image|images|photo|photos|picture|pictures|screenshot|screenshots|gallery|artwork|illustration|visual|infographic|diagram|chart)\b/i . test ( q )
}
function extractAllImages ( text : string , userQuery : string ) : ImageItem [] {
const images = extractImages ( text )
// Only surface as a tab if user asked about images or there are multiple images
if ( images . length === 0 ) return []
if ( isImageQuery ( userQuery ) || images . length >= 2 ) return images
return []
}
2026-03-03 09:31:18 +00:00
function isPlaceQuery ( q : string ) : boolean {
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i . test ( q )
}
function isPlaceLikeResponse ( text : string ) : boolean {
return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i . test ( text ) &&
( text . match ( /\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi ) ? . length ?? 0 ) >= 2
}
/** Extract places from [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]] tags */
function extractExternalPlaces ( text : string ) : Place [] {
const places : Place [] = []
const seen = new Set < string >()
let match : RegExpExecArray | null
const re = new RegExp ( PLACE_EXT_RE . source , 'gi' )
while (( match = re . exec ( text )) !== null ) {
const name = match [ 1 ]. trim ()
const cuisine = match [ 2 ] ? . trim () || undefined
const city = match [ 3 ] ? . trim () || undefined
const rating = match [ 4 ] ? parseFloat ( match [ 4 ]) : undefined
const priceLevel = match [ 5 ] ? parseInt ( match [ 5 ], 10 ) : undefined
const address = match [ 6 ] ? . trim () || undefined
const key = name . toLowerCase ()
if ( seen . has ( key )) continue
seen . add ( key )
places . push ({
id : `ext-place- ${ key . replace ( /\W/g , '-' ) } ` ,
name ,
cuisine ,
city ,
rating : rating && ! isNaN ( rating ) ? rating : undefined ,
priceLevel : priceLevel && priceLevel >= 1 && priceLevel <= 4 ? priceLevel : undefined ,
address ,
description : extractDescriptionForTag ( text , match . index , match [ 0 ]. length ),
sources : [],
})
}
return places
}
/** Extract place-like patterns from AI response text */
function extractPlacesFromPatterns ( text : string ) : Place [] {
const places : { name : string ; cuisine? : string ; city? : string ; rating? : number ; priceLevel? : number ; desc : string ; pos : number }[] = []
const seen = new Set < string >()
const patterns : RegExp [] = [
// **Name** — cuisine/category, details
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi ,
// Numbered list: 1. **Name** (cuisine) or 1. Name — description
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim ,
]
for ( const re of patterns ) {
let m : RegExpExecArray | null
const rx = new RegExp ( re . source , re . flags )
while (( m = rx . exec ( text )) !== null ) {
const name = m [ 1 ]. trim (). replace ( /^\*\*|\*\*$/g , '' ). replace ( /^\[|\]$/g , '' )
if ( name . length < 2 ) continue
if ( /\[\[(film|song|podcast|book|tv|place)(_ext)?:/ . test ( name )) continue
const key = name . toLowerCase ()
if ( seen . has ( key )) continue
seen . add ( key )
const cuisine = m [ 2 ] ? . trim ()
const desc = extractDescriptionForTag ( text , m . index , m [ 0 ]. length )
// Try to extract rating from nearby text
const nearby = text . slice ( m . index , m . index + 300 )
const ratingMatch = /(\d\.?\d?)\s*(?:\/\s*5|stars?|★)/i . exec ( nearby )
const rating = ratingMatch ? parseFloat ( ratingMatch [ 1 ]) : undefined
const priceMatch = /(\${1,4})\b/ . exec ( nearby )
const priceLevel = priceMatch ? priceMatch [ 1 ]. length : undefined
places . push ({ name , cuisine , desc , rating , priceLevel , pos : m.index })
}
}
return places
. sort (( a , b ) => a . pos - b . pos )
. map (({ name , cuisine , desc , rating , priceLevel }) => ({
id : `ext-place- ${ name . toLowerCase (). replace ( /\W/g , '-' ) } ` ,
name ,
cuisine ,
rating ,
priceLevel ,
description : desc ,
sources : [],
}))
}
function extractAllPlaces ( text : string , userQuery : string ) : Place [] {
const external = extractExternalPlaces ( text )
if ( external . length > 0 ) return external
if ( ! isPlaceQuery ( userQuery ) && ! isPlaceLikeResponse ( text )) return []
if ( isNewsLikeResponse ( text )) return []
return extractPlacesFromPatterns ( text )
}
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 )
2026-03-03 06:50:03 +00:00
let films = extractAllFilms ( text )
2026-03-02 19:57:44 +00:00
const podcasts = extractAllPodcasts ( text )
2026-03-03 01:27:00 +00:00
const books = extractAllBooks ( text , userQuery )
2026-03-03 06:50:03 +00:00
const tvSeries = extractAllTVSeries ( text , userQuery )
// When film_ext tags were converted to TV series, only keep library films
if ( tvSeries . length > 0 && isTVQuery ( userQuery )) {
films = films . filter ( f => ! f . id . startsWith ( 'ext-' ))
}
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`
})
}
2026-03-03 07:15:02 +00:00
const images = extractAllImages ( text , userQuery )
2026-03-03 09:31:18 +00:00
const places = extractAllPlaces ( text , userQuery )
2026-03-03 07:15:02 +00:00
2026-03-03 09:31:18 +00:00
const tabs = filterTabsByContext ( userQuery , films . length > 0 , songs . length > 0 , podcasts . length > 0 , books . length > 0 , tvSeries . length > 0 , images . length > 0 , places . length > 0 , hasNews , hasWebsites , hasMagazine )
2026-03-02 21:29:50 +00:00
availableTabs . value = tabs . length > 0 ? tabs : [ 'film' ]
activeTab . value = tabs [ 0 ] ?? 'film'
const showFilms = tabs . includes ( 'film' )
2026-03-03 01:27:00 +00:00
const showBooks = tabs . includes ( 'book' )
2026-03-03 06:50:03 +00:00
const showTVSeries = tabs . includes ( 'tvshow' )
2026-03-03 07:15:02 +00:00
const showImages = tabs . includes ( 'image' )
2026-03-03 09:31:18 +00:00
const showPlaces = tabs . includes ( 'place' )
2026-03-02 21:29:50 +00:00
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 : []
2026-03-03 01:27:00 +00:00
const visibleBooks = showBooks ? books : []
2026-03-03 06:50:03 +00:00
const visibleTVSeries = showTVSeries ? tvSeries : []
2026-03-03 07:15:02 +00:00
const visibleImages = showImages ? images : []
2026-03-03 09:31:18 +00:00
const visiblePlaces = showPlaces ? places : []
2026-03-02 21:29:50 +00:00
const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : []
const visibleWebsites = showWebsites ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
panelFilms . value = visibleFilms
2026-03-03 01:27:00 +00:00
panelBooks . value = visibleBooks
2026-03-03 06:50:03 +00:00
panelTVSeries . value = visibleTVSeries
2026-03-03 07:15:02 +00:00
panelImages . value = visibleImages
2026-03-03 09:31:18 +00:00
panelPlaces . value = visiblePlaces
2026-03-02 21:29:50 +00:00
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
2026-03-03 01:27:00 +00:00
selectedBook . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . value = null
2026-03-02 21:29:50 +00:00
selectedSong . value = null
selectedPodcast . value = null
if ( visibleFilms . length > 0 ) contentType . value = 'film'
2026-03-03 01:27:00 +00:00
else if ( visibleBooks . length > 0 ) contentType . value = 'film'
2026-03-03 06:50:03 +00:00
else if ( visibleTVSeries . length > 0 ) contentType . value = 'film'
2026-03-02 21:29:50 +00:00
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'
2026-03-03 06:50:03 +00:00
// Title follows the primary (first) tab
const primary = tabs [ 0 ]
2026-03-03 09:31:18 +00:00
if ( primary === 'place' && visiblePlaces . length > 0 ) {
panelTitle . value = visiblePlaces . length === 1 ? visiblePlaces [ 0 ]. name : ` ${ visiblePlaces . length } Places`
} else if ( primary === 'tvshow' && visibleTVSeries . length > 0 ) {
2026-03-03 06:50:03 +00:00
panelTitle . value = visibleTVSeries . length === 1 ? visibleTVSeries [ 0 ]. title : ` ${ visibleTVSeries . length } TV Series`
} else if ( primary === 'book' && visibleBooks . length > 0 ) {
panelTitle . value = visibleBooks . length === 1 ? visibleBooks [ 0 ]. title : ` ${ visibleBooks . length } Books`
2026-03-03 07:15:02 +00:00
} else if ( primary === 'image' && visibleImages . length > 0 ) {
panelTitle . value = ` ${ visibleImages . length } Images`
2026-03-03 06:50:03 +00:00
} else if ( visibleFilms . length === 1 ) panelTitle . value = visibleFilms [ 0 ]. title
2026-03-02 21:29:50 +00:00
else if ( visibleFilms . length > 1 ) panelTitle . value = ` ${ visibleFilms . length } Films`
2026-03-03 01:27:00 +00:00
else if ( visibleBooks . length === 1 ) panelTitle . value = visibleBooks [ 0 ]. title
else if ( visibleBooks . length > 1 ) panelTitle . value = ` ${ visibleBooks . length } Books`
2026-03-03 06:50:03 +00:00
else if ( visibleTVSeries . length === 1 ) panelTitle . value = visibleTVSeries [ 0 ]. title
else if ( visibleTVSeries . length > 1 ) panelTitle . value = ` ${ visibleTVSeries . length } TV Series`
2026-03-02 21:29:50 +00:00
else if ( visibleSongs . length === 1 ) panelTitle . value = visibleSongs [ 0 ]. title
else if ( visibleSongs . length > 1 ) panelTitle . value = ` ${ visibleSongs . length } Songs`
2026-03-03 09:31:18 +00:00
else if ( visiblePlaces . length === 1 ) panelTitle . value = visiblePlaces [ 0 ]. name
else if ( visiblePlaces . length > 1 ) panelTitle . value = ` ${ visiblePlaces . length } Places`
2026-03-02 21:29:50 +00:00
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 [] = []) {
2026-03-03 06:50:03 +00:00
let films = extractAllFilms ( text )
2026-03-02 21:29:50 +00:00
const songs = extractAllSongs ( text )
const podcasts = extractAllPodcasts ( text )
2026-03-03 01:27:00 +00:00
const books = extractAllBooks ( text , userQuery )
2026-03-03 06:50:03 +00:00
const tvSeries = extractAllTVSeries ( text , userQuery )
if ( tvSeries . length > 0 && isTVQuery ( userQuery )) {
films = films . filter ( f => ! f . id . startsWith ( 'ext-' ))
}
2026-03-02 21:29:50 +00:00
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
2026-03-03 07:15:02 +00:00
const images = extractAllImages ( text , userQuery )
2026-03-03 09:31:18 +00:00
const places = extractAllPlaces ( text , userQuery )
const tabs = filterTabsByContext ( userQuery , films . length > 0 , songs . length > 0 , podcasts . length > 0 , books . length > 0 , tvSeries . length > 0 , images . length > 0 , places . length > 0 , hasNews , hasWebsites , hasMagazine )
2026-03-02 21:29:50 +00:00
return {
films : tabs.includes ( 'film' ) ? films : [],
2026-03-03 01:27:00 +00:00
books : tabs.includes ( 'book' ) ? books : [],
2026-03-03 06:50:03 +00:00
tvSeries : tabs.includes ( 'tvshow' ) ? tvSeries : [],
2026-03-03 07:15:02 +00:00
images : tabs.includes ( 'image' ) ? images : [],
2026-03-03 09:31:18 +00:00
places : tabs.includes ( 'place' ) ? places : [],
2026-03-02 21:29:50 +00:00
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-03 01:27:00 +00:00
function stripBookTags ( text : string ) : string {
return text
. replace ( BOOK_TAG_RE , '' )
. replace ( BOOK_EXT_RE , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
2026-03-03 06:50:03 +00:00
function stripTVTags ( text : string ) : string {
return text
. replace ( TV_EXT_RE , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
2026-03-03 09:31:18 +00:00
function stripPlaceTags ( text : string ) : string {
return text
. replace ( PLACE_EXT_RE , '' )
. replace ( /\n{3,}/g , '\n\n' )
. trim ()
}
2026-03-02 18:16:04 +00:00
function stripContentTags ( text : string ) : string {
2026-03-03 09:31:18 +00:00
return stripFilmTags ( stripSongTags ( stripPodcastTags ( stripBookTags ( stripTVTags ( stripPlaceTags ( 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-03 01:27:00 +00:00
selectedBook . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . 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-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . value = null
2026-03-02 16:48:17 +00:00
}
function closeFilmDetail() {
selectedFilm . value = null
}
2026-03-03 01:27:00 +00:00
function openBookDetail ( book : Book ) {
selectedBook . value = book
selectedFilm . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . value = null
2026-03-03 01:27:00 +00:00
selectedSong . value = null
selectedPodcast . value = null
selectedArticle . value = null
2026-03-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . value = null
2026-03-03 01:27:00 +00:00
}
function closeBookDetail() {
selectedBook . value = null
}
2026-03-02 18:16:04 +00:00
function openSongDetail ( song : Song ) {
selectedSong . value = song
selectedFilm . value = null
2026-03-03 01:27:00 +00:00
selectedBook . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . 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-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . 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
2026-03-03 01:27:00 +00:00
selectedBook . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . value = null
2026-03-02 19:57:44 +00:00
selectedSong . value = null
2026-03-02 21:29:50 +00:00
selectedArticle . value = null
2026-03-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . 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
2026-03-03 01:27:00 +00:00
selectedBook . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . value = null
2026-03-02 21:29:50 +00:00
selectedSong . value = null
selectedPodcast . value = null
2026-03-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . value = null
2026-03-02 21:29:50 +00:00
panelOpen . value = true
}
function closeArticleDetail() {
selectedArticle . value = null
}
2026-03-03 09:31:18 +00:00
function openWebsiteDetail ( website : WebSearchResult ) {
selectedWebsite . value = website
selectedFilm . value = null
selectedBook . value = null
selectedTVSeries . value = null
selectedImage . value = null
selectedPlace . value = null
selectedSong . value = null
selectedPodcast . value = null
selectedArticle . value = null
panelOpen . value = true
}
function closeWebsiteDetail() {
selectedWebsite . value = null
}
function openMagazineSectionDetail ( section : MagazineSection , index : number ) {
selectedMagazineSection . value = section
magazineSectionIndex . value = index
selectedFilm . value = null
selectedBook . value = null
selectedTVSeries . value = null
selectedImage . value = null
selectedPlace . value = null
selectedSong . value = null
selectedPodcast . value = null
selectedArticle . value = null
selectedWebsite . value = null
}
function closeMagazineSectionDetail() {
selectedMagazineSection . value = null
}
function navigateMagazineSection ( direction : 'prev' | 'next' ) {
const sections = panelMagazineSections . value
if ( ! sections . length ) return
let idx = magazineSectionIndex . value
idx += direction === 'next' ? 1 : - 1
if ( idx < 0 ) idx = sections . length - 1
if ( idx >= sections . length ) idx = 0
magazineSectionIndex . value = idx
selectedMagazineSection . value = sections [ idx ]
}
2026-03-03 06:50:03 +00:00
function openTVSeriesDetail ( series : TVSeries ) {
selectedTVSeries . value = series
selectedFilm . value = null
selectedBook . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . value = null
2026-03-03 06:50:03 +00:00
selectedSong . value = null
selectedPodcast . value = null
selectedArticle . value = null
2026-03-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . value = null
2026-03-03 06:50:03 +00:00
}
function closeTVSeriesDetail() {
selectedTVSeries . value = null
}
2026-03-03 07:15:02 +00:00
function openImageDetail ( image : ImageItem ) {
selectedImage . value = image
selectedFilm . value = null
selectedBook . value = null
selectedTVSeries . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . value = null
2026-03-03 07:15:02 +00:00
selectedSong . value = null
selectedPodcast . value = null
selectedArticle . value = null
2026-03-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . value = null
2026-03-03 07:15:02 +00:00
}
function closeImageDetail() {
selectedImage . value = null
}
2026-03-03 09:31:18 +00:00
function openPlaceDetail ( place : Place ) {
selectedPlace . value = place
selectedFilm . value = null
selectedBook . value = null
selectedTVSeries . value = null
selectedImage . value = null
selectedSong . value = null
selectedPodcast . value = null
selectedArticle . value = null
selectedWebsite . value = null
selectedMagazineSection . value = null
}
function closePlaceDetail() {
selectedPlace . value = null
}
2026-03-02 16:48:17 +00:00
function closePanel() {
panelOpen . value = false
selectedFilm . value = null
2026-03-03 01:27:00 +00:00
selectedBook . value = null
2026-03-03 06:50:03 +00:00
selectedTVSeries . value = null
2026-03-03 07:15:02 +00:00
selectedImage . value = null
2026-03-03 09:31:18 +00:00
selectedPlace . 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-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . value = null
2026-03-02 21:29:50 +00:00
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-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . 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-03 09:31:18 +00:00
selectedWebsite . value = null
selectedMagazineSection . 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-03 01:27:00 +00:00
panelBooks ,
2026-03-03 06:50:03 +00:00
panelTVSeries ,
2026-03-03 07:15:02 +00:00
panelImages ,
2026-03-03 09:31:18 +00:00
panelPlaces ,
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-03 01:27:00 +00:00
selectedBook ,
2026-03-03 06:50:03 +00:00
selectedTVSeries ,
2026-03-03 07:15:02 +00:00
selectedImage ,
2026-03-03 09:31:18 +00:00
selectedPlace ,
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-03 09:31:18 +00:00
selectedWebsite ,
selectedMagazineSection ,
magazineSectionIndex ,
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 ,
2026-03-03 01:27:00 +00:00
extractAllBooks ,
2026-03-03 06:50:03 +00:00
extractAllTVSeries ,
2026-03-03 01:27:00 +00:00
openBookDetail ,
closeBookDetail ,
2026-03-03 06:50:03 +00:00
openTVSeriesDetail ,
closeTVSeriesDetail ,
2026-03-03 07:15:02 +00:00
openImageDetail ,
closeImageDetail ,
2026-03-03 09:31:18 +00:00
openPlaceDetail ,
closePlaceDetail ,
2026-03-02 18:16:04 +00:00
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-03 09:31:18 +00:00
openWebsiteDetail ,
closeWebsiteDetail ,
openMagazineSectionDetail ,
closeMagazineSectionDetail ,
navigateMagazineSection ,
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
}
}