Wire up ContentPanel with tab navigation for all existing content types (films, songs, podcasts, news, websites, magazine). Add complete Books pipeline: type definition, extraction (tags + pattern matching), cover fetching from Open Library, BookCard/BookGrid/BookDetail components, inline chat cards, and prompt index badges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1115 lines
42 KiB
TypeScript
1115 lines
42 KiB
TypeScript
import { ref } from 'vue'
|
||
import type { Film, Song, Podcast, Book } from '@aiui/core/types/content'
|
||
import type { WebSearchResult } from '@aiui/core/types/message'
|
||
import { mockFilms } from '@/mocks/films'
|
||
import { mockSongs } from '@/mocks/songs'
|
||
import { mockPodcasts } from '@/mocks/podcasts'
|
||
import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback'
|
||
import { fetchRssFromUrls } from '@/composables/useRssFetch'
|
||
|
||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | '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
|
||
}
|
||
|
||
const panelOpen = ref(false)
|
||
const panelFilms = ref<Film[]>([])
|
||
const panelBooks = ref<Book[]>([])
|
||
const panelWebResults = ref<WebSearchResult[]>([])
|
||
const panelRssArticles = ref<WebSearchResult[]>([])
|
||
const panelWebsites = ref<WebSearchResult[]>([])
|
||
const panelMagazineSections = ref<MagazineSection[]>([])
|
||
const panelMagazineHeroImage = ref<string | null>(null)
|
||
const panelSongs = ref<Song[]>([])
|
||
const panelPodcasts = ref<Podcast[]>([])
|
||
const selectedFilm = ref<Film | null>(null)
|
||
const selectedBook = ref<Book | null>(null)
|
||
const selectedSong = ref<Song | null>(null)
|
||
const selectedPodcast = ref<Podcast | null>(null)
|
||
const selectedArticle = ref<WebSearchResult | null>(null)
|
||
const panelTitle = ref('Recommended Films')
|
||
const panelQuery = ref('')
|
||
const contentType = ref<'film' | 'song' | 'podcast'>('film')
|
||
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)
|
||
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
|
||
}
|
||
}
|
||
|
||
function extractAuthorFromText(text: string): string | undefined {
|
||
const patterns = [
|
||
/(?: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?))/,
|
||
]
|
||
for (const re of patterns) {
|
||
const m = re.exec(text)
|
||
if (m) {
|
||
const name = m[1].trim().slice(0, 60)
|
||
if (name.length > 3 && name.length < 50) return name
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function extractFirstImageFromText(text: string): string | undefined {
|
||
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(text)
|
||
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
|
||
}
|
||
}
|
||
|
||
const MAGAZINE_CONTENT_MAX = 2000
|
||
|
||
function addSection(
|
||
sections: MagazineSection[],
|
||
title: string,
|
||
content: string,
|
||
seen: Set<string>,
|
||
): void {
|
||
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)
|
||
if (t.length < 2 || c.length < 15) return
|
||
const key = `${t.slice(0, 50)}`
|
||
if (seen.has(key)) return
|
||
seen.add(key)
|
||
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
|
||
sections.push({
|
||
title: t,
|
||
content: c,
|
||
url: extractUrlFromText(content),
|
||
author: extractAuthorFromText(content),
|
||
imageUrl,
|
||
})
|
||
}
|
||
|
||
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
|
||
function extractMagazineSections(text: string): MagazineSection[] {
|
||
const sections: MagazineSection[] = []
|
||
const seen = new Set<string>()
|
||
const blockContents = new Set<string>() // avoid duplicate bullets already in ## blocks
|
||
|
||
// 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
|
||
let m: RegExpExecArray | null
|
||
while ((m = headingRe.exec(text)) !== null) {
|
||
const title = m[1].trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').split(':')[0].trim()
|
||
const content = m[2].trim()
|
||
if (content.length > 20) {
|
||
blockContents.add(content)
|
||
addSection(sections, title, content, seen)
|
||
}
|
||
}
|
||
|
||
// 2. **Pro/ Anti camp** blocks with their bullets
|
||
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|\z)/gim
|
||
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)
|
||
}
|
||
|
||
// 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
|
||
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
|
||
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n#{2,3}\s|\z)/gms
|
||
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)
|
||
}
|
||
|
||
// 5. Intro paragraph before first ## / ### or ---
|
||
const introMatch = /^([\s\S]+?)(?=\n#{2,3}\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
|
||
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
|
||
})
|
||
|
||
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 (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
|
||
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,
|
||
hasBooks: 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 && !hasBooks && !hasNews && !hasWebsites) {
|
||
return ['magazine']
|
||
}
|
||
|
||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasNews && !hasMagazine) {
|
||
return ['websites']
|
||
}
|
||
|
||
const all: ContentTab[] = []
|
||
if (hasFilms) all.push('film')
|
||
if (hasBooks) all.push('book')
|
||
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
|
||
}
|
||
|
||
|
||
const FILM_TAG_RE = /\[\[film:(f?\d+)\]\]/gi
|
||
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
|
||
|
||
/** 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
|
||
}
|
||
const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
|
||
const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||
|
||
const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
|
||
const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||
|
||
/** 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
|
||
}
|
||
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()]
|
||
}
|
||
|
||
export function useContentPanel() {
|
||
function normalizeFilmId(raw: string): string {
|
||
return raw.startsWith('f') ? raw : `f${raw}`
|
||
}
|
||
|
||
function extractFilmIds(text: string): string[] {
|
||
const ids: string[] = []
|
||
let match: RegExpExecArray | null
|
||
const re = new RegExp(FILM_TAG_RE.source, 'gi')
|
||
while ((match = re.exec(text)) !== null) {
|
||
const id = normalizeFilmId(match[1])
|
||
if (!ids.includes(id)) ids.push(id)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
function resolveFilms(ids: string[]): Film[] {
|
||
return ids
|
||
.map((id) => mockFilms.find((f) => f.id === id))
|
||
.filter((f): f is Film => !!f)
|
||
}
|
||
|
||
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 : ''
|
||
}
|
||
|
||
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),
|
||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||
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()
|
||
if (!looksLikeSong(title, artist)) continue
|
||
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,
|
||
coverUrl: generateSongCoverFallback(title, artist),
|
||
sources: [],
|
||
})
|
||
}
|
||
return songs
|
||
}
|
||
|
||
/** Infer songs from plain text when no tags present. Requires title+artist within 100 chars, whole-word artist. */
|
||
function extractSongsFromLibraryMatch(text: string): Song[] {
|
||
const lower = text.toLowerCase()
|
||
const found: { song: Song; pos: number }[] = []
|
||
const seen = new Set<string>()
|
||
for (const song of mockSongs) {
|
||
const key = song.id
|
||
if (seen.has(key)) continue
|
||
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
|
||
if (!looksLikeSong(title, artist)) continue
|
||
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
|
||
seen.add(key)
|
||
songs.push({ title, artist, pos: m.index })
|
||
}
|
||
}
|
||
|
||
return songs
|
||
.sort((a, b) => a.pos - b.pos)
|
||
.map(({ title, artist }) => ({
|
||
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
|
||
title,
|
||
artist,
|
||
coverUrl: generateSongCoverFallback(title, artist),
|
||
sources: [],
|
||
}))
|
||
}
|
||
|
||
function extractAllSongs(text: string): Song[] {
|
||
const librarySongs = resolveSongs(extractSongIds(text))
|
||
const externalSongs = extractExternalSongs(text)
|
||
if (librarySongs.length > 0 || externalSongs.length > 0) {
|
||
return [...librarySongs, ...externalSongs]
|
||
}
|
||
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
|
||
if (extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text)) return []
|
||
if (isNewsLikeResponse(text)) return []
|
||
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]
|
||
}
|
||
|
||
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()
|
||
if (!looksLikePodcast(title, host)) continue
|
||
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,
|
||
coverUrl: undefined,
|
||
sources: [],
|
||
})
|
||
}
|
||
return podcasts
|
||
}
|
||
|
||
function extractAllPodcasts(text: string): Podcast[] {
|
||
const libraryPodcasts = resolvePodcasts(extractPodcastIds(text))
|
||
const externalPodcasts = extractExternalPodcasts(text)
|
||
return [...libraryPodcasts, ...externalPodcasts]
|
||
}
|
||
|
||
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 },
|
||
// **Title** by Author
|
||
{ re: /\*\*([^*]{2,80})\*\*\s+by\s+([A-Z][^,\n\.]{1,50}?)(?:\s*[,()\n\.]|$)/g, titleIdx: 1, authorIdx: 2 },
|
||
// - Title — Author or Title by Author (in list)
|
||
{ 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 },
|
||
]
|
||
|
||
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) {
|
||
const title = m[titleIdx].trim().replace(/^\*\*|\*\*$/g, '')
|
||
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
|
||
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 []
|
||
// Skip if other content types are already tagged
|
||
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
|
||
if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return []
|
||
if (isNewsLikeResponse(text)) return []
|
||
return extractBooksFromPatterns(text)
|
||
}
|
||
|
||
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
|
||
panelQuery.value = userQuery.trim()
|
||
const songs = extractAllSongs(text)
|
||
const films = extractAllFilms(text)
|
||
const podcasts = extractAllPodcasts(text)
|
||
const books = extractAllBooks(text, userQuery)
|
||
const fromMarkdown = extractMarkdownLinks(text)
|
||
const boldDomains = extractBoldDomainLinks(text)
|
||
|
||
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)
|
||
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))
|
||
|
||
// 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) : []
|
||
|
||
// Fetch RSS from website URLs only when news context — avoid surfacing RSS from docs/resource links
|
||
if (mergedWebsites.length > 0 && newsContext) {
|
||
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, books.length > 0, hasNews, hasWebsites, hasMagazine)
|
||
availableTabs.value = tabs.length > 0 ? tabs : ['film']
|
||
activeTab.value = tabs[0] ?? 'film'
|
||
|
||
const showFilms = tabs.includes('film')
|
||
const showBooks = tabs.includes('book')
|
||
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 visibleBooks = showBooks ? books : []
|
||
const visibleSongs = showSongs ? songs : []
|
||
const visiblePodcasts = showPodcasts ? podcasts : []
|
||
const visibleNews = showNews ? mergedNews : []
|
||
const visibleWebsites = showWebsites ? mergedWebsites : []
|
||
const visibleMagazineSections = showMagazine ? magazineSections : []
|
||
|
||
panelFilms.value = visibleFilms
|
||
panelBooks.value = visibleBooks
|
||
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
|
||
selectedBook.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
|
||
if (visibleFilms.length > 0) contentType.value = 'film'
|
||
else if (visibleBooks.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 (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title
|
||
else if (visibleBooks.length > 1) panelTitle.value = `${visibleBooks.length} Books`
|
||
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 books = extractAllBooks(text, userQuery)
|
||
const magazineSections = extractMagazineSections(text)
|
||
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))
|
||
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, books.length > 0, hasNews, hasWebsites, hasMagazine)
|
||
return {
|
||
films: tabs.includes('film') ? films : [],
|
||
books: tabs.includes('book') ? books : [],
|
||
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 : [],
|
||
}
|
||
}
|
||
|
||
function stripFilmTags(text: string): string {
|
||
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()
|
||
}
|
||
|
||
function stripPodcastTags(text: string): string {
|
||
return text
|
||
.replace(PODCAST_TAG_RE, '')
|
||
.replace(PODCAST_EXT_RE, '')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim()
|
||
}
|
||
|
||
function stripBookTags(text: string): string {
|
||
return text
|
||
.replace(BOOK_TAG_RE, '')
|
||
.replace(BOOK_EXT_RE, '')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim()
|
||
}
|
||
|
||
function stripContentTags(text: string): string {
|
||
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(text))))
|
||
}
|
||
|
||
/** 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()
|
||
}
|
||
|
||
function openFilmDetail(film: Film) {
|
||
selectedFilm.value = film
|
||
selectedBook.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
function closeFilmDetail() {
|
||
selectedFilm.value = null
|
||
}
|
||
|
||
function openBookDetail(book: Book) {
|
||
selectedBook.value = book
|
||
selectedFilm.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
function closeBookDetail() {
|
||
selectedBook.value = null
|
||
}
|
||
|
||
function openSongDetail(song: Song) {
|
||
selectedSong.value = song
|
||
selectedFilm.value = null
|
||
selectedBook.value = null
|
||
selectedPodcast.value = null
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
function closeSongDetail() {
|
||
selectedSong.value = null
|
||
}
|
||
|
||
function openPodcastDetail(podcast: Podcast) {
|
||
selectedPodcast.value = podcast
|
||
selectedFilm.value = null
|
||
selectedBook.value = null
|
||
selectedSong.value = null
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
function closePodcastDetail() {
|
||
selectedPodcast.value = null
|
||
}
|
||
|
||
function openArticleDetail(article: WebSearchResult) {
|
||
selectedArticle.value = article
|
||
selectedFilm.value = null
|
||
selectedBook.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
panelOpen.value = true
|
||
}
|
||
|
||
function closeArticleDetail() {
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
function closePanel() {
|
||
panelOpen.value = false
|
||
selectedFilm.value = null
|
||
selectedBook.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
selectedArticle.value = null
|
||
activeTab.value = 'film'
|
||
availableTabs.value = []
|
||
}
|
||
|
||
function showAllFilms() {
|
||
panelFilms.value = [...mockFilms]
|
||
panelSongs.value = []
|
||
panelPodcasts.value = []
|
||
panelTitle.value = 'Your Film Library'
|
||
contentType.value = 'film'
|
||
panelOpen.value = true
|
||
selectedFilm.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
function showAllSongs() {
|
||
panelFilms.value = []
|
||
panelSongs.value = [...mockSongs]
|
||
panelPodcasts.value = []
|
||
panelTitle.value = 'Your Song Library'
|
||
contentType.value = 'song'
|
||
panelOpen.value = true
|
||
selectedFilm.value = null
|
||
selectedSong.value = null
|
||
selectedPodcast.value = null
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
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
|
||
selectedArticle.value = null
|
||
}
|
||
|
||
return {
|
||
panelOpen,
|
||
panelFilms,
|
||
panelBooks,
|
||
panelSongs,
|
||
panelPodcasts,
|
||
panelWebResults,
|
||
panelWebsites,
|
||
panelMagazineSections,
|
||
panelMagazineHeroImage,
|
||
selectedFilm,
|
||
selectedBook,
|
||
selectedSong,
|
||
selectedPodcast,
|
||
selectedArticle,
|
||
panelTitle,
|
||
panelQuery,
|
||
contentType,
|
||
activeTab,
|
||
availableTabs,
|
||
setActiveTab,
|
||
extractFilmIds,
|
||
resolveFilms,
|
||
extractAllFilms,
|
||
extractAllBooks,
|
||
openBookDetail,
|
||
closeBookDetail,
|
||
extractSongIds,
|
||
resolveSongs,
|
||
extractAllSongs,
|
||
extractPodcastIds,
|
||
resolvePodcasts,
|
||
extractAllPodcasts,
|
||
getContextualInlineContent,
|
||
updatePanelFromText,
|
||
stripFilmTags,
|
||
stripSongTags,
|
||
stripPodcastTags,
|
||
stripContentTags,
|
||
stripMarkdownLinks,
|
||
openFilmDetail,
|
||
closeFilmDetail,
|
||
openSongDetail,
|
||
closeSongDetail,
|
||
openPodcastDetail,
|
||
closePodcastDetail,
|
||
openArticleDetail,
|
||
closeArticleDetail,
|
||
closePanel,
|
||
showAllFilms,
|
||
showAllSongs,
|
||
showAllPodcasts,
|
||
}
|
||
}
|