mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
664 lines
23 KiB
TypeScript
664 lines
23 KiB
TypeScript
/**
|
|
* S27 — pure contract of the public Central de Conteúdos (`/conteudos`): the
|
|
* URL vocabulary (path, filters, search), the in-memory filtering over the
|
|
* published list and the serializable view model the cards and the piece page
|
|
* render. No I/O and no `server-only`: the cached public read, the server
|
|
* components and the unit tests share this module.
|
|
*
|
|
* The staff list contract (`src/utilities/content/contentPieceListUrl.ts`) is
|
|
* deliberately NOT reused: its vocabulary (status, processing, page) is
|
|
* internal and the public surface has its own facets with a single value each.
|
|
*
|
|
* S38 — the six personalized-card models are synthetic items of the same board
|
|
* (`CardCatalogItem`): no row, no file, no publication — each item is an invite
|
|
* that routes the visitor to `/cards?model=<id>`. They share the facet/search
|
|
* pipeline (Tipo gains `card`; the geographic facets never do) and the honest
|
|
* empty state.
|
|
*/
|
|
import { CARD_MODELS, type CardModel, type CardModelId } from '@/lib/cardModels'
|
|
import {
|
|
CONTENT_PIECE_TYPES,
|
|
contentPieceIsPublic,
|
|
contentPieceOriginLabels,
|
|
contentPieceTopicLabel,
|
|
contentPieceTypeLabels,
|
|
isContentPieceOrigin,
|
|
isContentPieceStatus,
|
|
isContentPieceTopic,
|
|
isContentPieceType,
|
|
type ContentPieceOrigin,
|
|
type ContentPieceType,
|
|
} from '@/lib/contentPiece'
|
|
import { slugify } from '@/lib/slug'
|
|
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
|
|
import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets'
|
|
import { buildHighlightedExcerpt, type SpeechHighlightPart } from '@/lib/speechHighlight'
|
|
import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch'
|
|
|
|
export const CONTENT_PIECE_CATALOG_PATH = '/conteudos'
|
|
|
|
export const contentPiecePublicPath = (slug: string): string =>
|
|
`${CONTENT_PIECE_CATALOG_PATH}/${slug}`
|
|
|
|
export const contentPieceMediaPath = (slug: string): string =>
|
|
`${contentPiecePublicPath(slug)}/midia`
|
|
|
|
export const CONTENT_PIECE_CATALOG_FACETS = [
|
|
'tipo',
|
|
'cidade',
|
|
'regiao',
|
|
'tema',
|
|
'instituicao',
|
|
] as const
|
|
|
|
export type ContentPieceCatalogFacet = (typeof CONTENT_PIECE_CATALOG_FACETS)[number]
|
|
|
|
export const contentPieceCatalogFacetLabels: Record<ContentPieceCatalogFacet, string> = {
|
|
tipo: 'Tipo',
|
|
cidade: 'Cidade',
|
|
regiao: 'Região',
|
|
tema: 'Tema',
|
|
instituicao: 'Instituição',
|
|
}
|
|
|
|
/**
|
|
* S28 — search mode of the public catalogue. `tema` asks the theme expansion
|
|
* for related terms; the literal search is the default and is never serialized
|
|
* (existing deep links stay byte-identical). Only meaningful alongside `q`.
|
|
*/
|
|
export type ContentPieceCatalogMode = 'tema'
|
|
|
|
export type ContentPieceCatalogParams = {
|
|
tipo: ContentPieceType | null
|
|
cidade: string | null
|
|
regiao: string | null
|
|
tema: SpeechTopic | null
|
|
instituicao: string | null
|
|
q: string
|
|
mode: ContentPieceCatalogMode | null
|
|
}
|
|
|
|
export type ContentPieceCatalogSearchParams = Record<string, string | string[] | undefined>
|
|
|
|
const firstValue = (value: string | string[] | undefined): string =>
|
|
(Array.isArray(value) ? value[0] : value)?.trim() ?? ''
|
|
|
|
const FACET_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
|
|
/**
|
|
* One value per facet; unknown enum values and non-slug labels are dropped
|
|
* (a hand-typed `?tipo=../etc` never reaches a query or a comparison).
|
|
*/
|
|
export const parseContentPieceCatalogParams = (
|
|
raw: ContentPieceCatalogSearchParams,
|
|
): ContentPieceCatalogParams => {
|
|
const tipo = firstValue(raw.tipo)
|
|
const tema = firstValue(raw.tema)
|
|
const q = firstValue(raw.q)
|
|
const slugFacet = (facet: ContentPieceCatalogFacet): string | null => {
|
|
const value = firstValue(raw[facet])
|
|
return value && FACET_SLUG_PATTERN.test(value) ? value : null
|
|
}
|
|
|
|
return {
|
|
tipo: isContentPieceType(tipo) ? tipo : null,
|
|
cidade: slugFacet('cidade'),
|
|
regiao: slugFacet('regiao'),
|
|
tema: isContentPieceTopic(tema) ? tema : null,
|
|
instituicao: slugFacet('instituicao'),
|
|
q,
|
|
// Only `tema` is meaningful; anything else (and the default) means exact,
|
|
// and a mode without a term has nothing to expand.
|
|
mode: firstValue(raw.mode) === 'tema' && q ? 'tema' : null,
|
|
}
|
|
}
|
|
|
|
/** Canonical listing URL: fixed facet order, no empty params, no params at all when clean. */
|
|
export const buildContentPieceCatalogHref = (
|
|
params: Partial<ContentPieceCatalogParams>,
|
|
): string => {
|
|
const search = new URLSearchParams()
|
|
if (params.tipo) search.set('tipo', params.tipo)
|
|
if (params.cidade) search.set('cidade', params.cidade)
|
|
if (params.regiao) search.set('regiao', params.regiao)
|
|
if (params.tema) search.set('tema', params.tema)
|
|
if (params.instituicao) search.set('instituicao', params.instituicao)
|
|
const term = params.q?.trim()
|
|
if (term) {
|
|
search.set('q', term)
|
|
// `tema` is the only non-default mode and it only makes sense with a query,
|
|
// so a mode without `q` canonicalizes away.
|
|
if (params.mode === 'tema') search.set('mode', 'tema')
|
|
}
|
|
|
|
const query = search.toString()
|
|
return query ? `${CONTENT_PIECE_CATALOG_PATH}?${query}` : CONTENT_PIECE_CATALOG_PATH
|
|
}
|
|
|
|
type ContentPieceCatalogFacetOption = { value: string; label: string }
|
|
|
|
export type ContentPieceCatalogFacets = Record<
|
|
ContentPieceCatalogFacet,
|
|
ContentPieceCatalogFacetOption[]
|
|
>
|
|
|
|
export type ContentPieceCatalogActiveFilter = {
|
|
facet: ContentPieceCatalogFacet | 'q'
|
|
label: string
|
|
value: string
|
|
removeHref: string
|
|
}
|
|
|
|
const sortedOptions = (options: Map<string, string>): ContentPieceCatalogFacetOption[] =>
|
|
[...options.entries()]
|
|
.map(([value, label]) => ({ value, label }))
|
|
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
|
|
|
|
/**
|
|
* Facet options are derived from the published items alone: a filter that
|
|
* cannot match anything never renders. Tipo/Tema keep the persisted enum
|
|
* order; the label facets are alphabetical by their pt-BR label.
|
|
*
|
|
* S38 — a card item feeds ONLY the `tipo` facet (`card`): the models are not
|
|
* territorial and declare no theme, so Cidade/Região/Instituição/Tema never
|
|
* grow from them.
|
|
*/
|
|
export const contentPieceCatalogFacets = (
|
|
items: readonly ContentCatalogItem[],
|
|
): ContentPieceCatalogFacets => {
|
|
const types = new Set<string>()
|
|
const topics = new Set<string>()
|
|
const cities = new Map<string, string>()
|
|
const regions = new Map<string, string>()
|
|
const institutions = new Map<string, string>()
|
|
|
|
for (const item of items) {
|
|
if (isCardCatalogItem(item)) {
|
|
types.add('card')
|
|
continue
|
|
}
|
|
types.add(item.type)
|
|
for (const topic of item.topics) topics.add(topic)
|
|
if (item.cityLabel) cities.set(slugify(item.cityLabel), item.cityLabel)
|
|
if (item.regionLabel) regions.set(slugify(item.regionLabel), item.regionLabel)
|
|
if (item.institution) institutions.set(slugify(item.institution), item.institution)
|
|
}
|
|
|
|
return {
|
|
tipo: CONTENT_PIECE_TYPES.filter((type) => types.has(type)).map((type) => ({
|
|
value: type,
|
|
label: contentPieceTypeLabels[type],
|
|
})),
|
|
tema: SPEECH_TOPICS.filter((topic) => topics.has(topic.value)).map((topic) => ({
|
|
value: topic.value,
|
|
label: topic.label,
|
|
})),
|
|
cidade: sortedOptions(cities),
|
|
regiao: sortedOptions(regions),
|
|
instituicao: sortedOptions(institutions),
|
|
}
|
|
}
|
|
|
|
/** The active filters in canonical order, with the URL that removes each one. */
|
|
export const contentPieceCatalogActiveFilters = (
|
|
params: ContentPieceCatalogParams,
|
|
facets: ContentPieceCatalogFacets,
|
|
): ContentPieceCatalogActiveFilter[] => {
|
|
const filters: ContentPieceCatalogActiveFilter[] = []
|
|
|
|
for (const facet of CONTENT_PIECE_CATALOG_FACETS) {
|
|
const value = params[facet]
|
|
if (!value) continue
|
|
const option = facets[facet].find((candidate) => candidate.value === value)
|
|
filters.push({
|
|
facet,
|
|
label: contentPieceCatalogFacetLabels[facet],
|
|
value: option?.label ?? value,
|
|
removeHref: buildContentPieceCatalogHref({ ...params, [facet]: null }),
|
|
})
|
|
}
|
|
|
|
if (params.q.trim()) {
|
|
filters.push({
|
|
facet: 'q',
|
|
label: 'Busca',
|
|
value: params.q.trim(),
|
|
// Clearing the search clears the mode with it: `tema` has nothing to
|
|
// expand without a query.
|
|
removeHref: buildContentPieceCatalogHref({ ...params, q: '', mode: null }),
|
|
})
|
|
}
|
|
|
|
return filters
|
|
}
|
|
|
|
/**
|
|
* S28 — the expanded terms that may claim "Tema" on a result: empty/duplicate
|
|
* terms are dropped and a term equal to the literal query is not a theme match
|
|
* (the piece surfaced by the query itself, not by the expansion).
|
|
*/
|
|
export const contentPieceThemeTerms = (query: string, themeTerms: readonly string[]): string[] => {
|
|
const normalizedQuery = normalizeForSearch(query)
|
|
return uniqueByNormalizedForm(themeTerms).filter(
|
|
(term) => normalizeForSearch(term) !== normalizedQuery,
|
|
)
|
|
}
|
|
|
|
const normalizedSearchTerms = (query: string, themeTerms: readonly string[]): string[] => {
|
|
const seen = new Set<string>()
|
|
const terms: string[] = []
|
|
for (const value of [query, ...themeTerms]) {
|
|
const normalized = normalizeForSearch(value)
|
|
if (!normalized || seen.has(normalized)) continue
|
|
seen.add(normalized)
|
|
terms.push(normalized)
|
|
}
|
|
return terms
|
|
}
|
|
|
|
/**
|
|
* All active facets combine (AND); the term matches the denormalized haystack.
|
|
*
|
|
* S38 — a card item matches the Tipo facet only as `card` and never matches a
|
|
* geographic/theme facet (the models are not territorial and declare no theme);
|
|
* the term search runs over its `searchText` (name + aliases + description)
|
|
* exactly like a piece's.
|
|
*/
|
|
export const filterContentPieceCatalogItems = (
|
|
items: readonly ContentCatalogItem[],
|
|
params: ContentPieceCatalogParams,
|
|
themeTerms: readonly string[] = [],
|
|
): ContentCatalogItem[] => {
|
|
const searchTerms = normalizedSearchTerms(params.q, themeTerms)
|
|
|
|
return items.filter((item) => {
|
|
if (isCardCatalogItem(item)) {
|
|
if (params.tipo && params.tipo !== 'card') return false
|
|
if (params.cidade || params.regiao || params.tema || params.instituicao) return false
|
|
if (searchTerms.length === 0) return true
|
|
|
|
const haystack = normalizeForSearch(item.searchText)
|
|
return searchTerms.some((term) => haystack.includes(term))
|
|
}
|
|
if (
|
|
(params.tipo && item.type !== params.tipo) ||
|
|
(params.cidade && (item.cityLabel === null || slugify(item.cityLabel) !== params.cidade)) ||
|
|
(params.regiao &&
|
|
(item.regionLabel === null || slugify(item.regionLabel) !== params.regiao)) ||
|
|
(params.tema && !item.topics.includes(params.tema)) ||
|
|
(params.instituicao &&
|
|
(item.institution === null || slugify(item.institution) !== params.instituicao))
|
|
) {
|
|
return false
|
|
}
|
|
if (searchTerms.length === 0) return true
|
|
|
|
const haystack = normalizeForSearch(item.searchText)
|
|
return searchTerms.some((term) => haystack.includes(term))
|
|
})
|
|
}
|
|
|
|
export type ContentPiecePublicSource = {
|
|
id: number
|
|
slug?: string | null
|
|
title?: string | null
|
|
type?: string | null
|
|
status?: string | null
|
|
origin?: string | null
|
|
topics?: string[] | null
|
|
cityLabel?: string | null
|
|
region?: string | null
|
|
institution?: string | null
|
|
description?: string | null
|
|
transcript?: string | null
|
|
sourceUrl?: string | null
|
|
durationSeconds?: number | null
|
|
pieceDate?: string | null
|
|
searchText?: string | null
|
|
media?: number | { id: number; filename?: string | null; mimeType?: string | null } | null
|
|
}
|
|
|
|
export type ContentPiecePublicItem = {
|
|
id: number
|
|
slug: string
|
|
title: string
|
|
type: ContentPieceType
|
|
typeLabel: string
|
|
origin: ContentPieceOrigin
|
|
originLabel: string
|
|
/** A link piece (Instagram/YouTube) has no archived file: it shares the platform link. */
|
|
isLink: boolean
|
|
sourceUrl: string | null
|
|
topics: SpeechTopic[]
|
|
topicLabels: string[]
|
|
cityLabel: string | null
|
|
regionLabel: string | null
|
|
institution: string | null
|
|
description: string | null
|
|
/** Opening of the text/transcript, for the `texto` card asset. */
|
|
excerpt: string | null
|
|
durationLabel: string | null
|
|
pieceDateLabel: string | null
|
|
/** `Tema · Local` (or institution/city fallbacks) — the card's metadata line. */
|
|
metaLabel: string
|
|
searchText: string
|
|
/** S28 — why the piece appeared in the theme mode; null in the literal search. */
|
|
themeMatch: ContentPieceThemeMatch | null
|
|
publicPath: string
|
|
/** The archived file, or null on a link piece — never partially populated. */
|
|
file: ContentPiecePublicFile | null
|
|
}
|
|
|
|
type ContentPiecePublicFile = {
|
|
id: number
|
|
path: string
|
|
mimeType: string | null
|
|
downloadFilename: string
|
|
}
|
|
/**
|
|
* S38 — the studio deep link of one model. `/cards` already validates the id
|
|
* and pre-selects the model (`isCardModelId`), so the item never carries a
|
|
* second selection contract.
|
|
*/
|
|
const cardModelCatalogHref = (modelId: CardModelId): string => `/cards?model=${modelId}`
|
|
|
|
/**
|
|
* S38 — the art of a card item: the model's real file, or the neutral
|
|
* placeholder of the photo models (their master is the official frame with a
|
|
* transparent photo window, never a filled example).
|
|
*/
|
|
export type CardCatalogArt =
|
|
| { kind: 'image'; src: string }
|
|
| { kind: 'placeholder'; shape: 'square' | 'portrait' }
|
|
|
|
/**
|
|
* S38 — one personalized-card model as a catalogue item. Synthetic by
|
|
* contract: no row, no file, no publication — `itemKind` marks the card side
|
|
* of the board union and the guard below is the only narrowing point.
|
|
*/
|
|
export type CardCatalogItem = {
|
|
itemKind: 'card'
|
|
modelId: CardModelId
|
|
title: string
|
|
description: string
|
|
aliases: readonly string[]
|
|
badge: string | null
|
|
art: CardCatalogArt
|
|
href: string
|
|
/** Raw haystack (name + aliases + description); normalized at match time. */
|
|
searchText: string
|
|
}
|
|
|
|
/** One item of the public board: a published piece or a synthetic card model. */
|
|
export type ContentCatalogItem = ContentPiecePublicItem | CardCatalogItem
|
|
|
|
export const isCardCatalogItem = (item: ContentCatalogItem): item is CardCatalogItem =>
|
|
'itemKind' in item
|
|
|
|
const cardModelArt = (model: CardModel): CardCatalogArt =>
|
|
model.kind === 'photo'
|
|
? { kind: 'placeholder', shape: model.width === model.height ? 'square' : 'portrait' }
|
|
: { kind: 'image', src: model.previewSrc ?? model.assetSrc }
|
|
|
|
/**
|
|
* S38 — the six models as catalogue items, in the committed catalogue order.
|
|
* Nothing is written and nothing is published: the item is an invite to the
|
|
* studio, never a piece to download or share.
|
|
*/
|
|
export const cardCatalogItems = (): CardCatalogItem[] =>
|
|
CARD_MODELS.map((model) => ({
|
|
itemKind: 'card',
|
|
modelId: model.id,
|
|
title: model.label,
|
|
description: model.description,
|
|
aliases: model.aliases,
|
|
badge: model.badge ?? null,
|
|
art: cardModelArt(model),
|
|
href: cardModelCatalogHref(model.id),
|
|
searchText: [model.label, ...model.aliases, model.description].join(' '),
|
|
}))
|
|
|
|
/**
|
|
* S38 — the items of the public board: the published pieces plus the synthetic
|
|
* card models. The guardrail lives here: the cards only ride along a non-empty
|
|
* Central — with nothing published, the board is the honest empty state (which
|
|
* carries the studio path), never a grid of cards.
|
|
*/
|
|
export const contentCatalogItems = (
|
|
items: readonly ContentPiecePublicItem[],
|
|
cards: readonly CardCatalogItem[],
|
|
): ContentCatalogItem[] => (items.length > 0 ? [...items, ...cards] : [...items])
|
|
|
|
export type ContentPieceMediaKind = 'video' | 'audio' | 'image' | 'text' | 'other'
|
|
|
|
/** S28 — where the "Por que apareceu" passage came from (label of the fallback). */
|
|
type ContentPieceThemeEvidenceSource = 'transcript' | 'description' | 'excerpt'
|
|
|
|
type ContentPieceThemeEvidence = {
|
|
parts: SpeechHighlightPart[]
|
|
truncatedStart: boolean
|
|
truncatedEnd: boolean
|
|
/**
|
|
* True when the passage is the window that carries the matched term (the
|
|
* quoted, highlighted one); false when it is a real passage of the piece that
|
|
* does not contain the term (no quotes, no highlight, no claim).
|
|
*/
|
|
quoted: boolean
|
|
source: ContentPieceThemeEvidenceSource
|
|
}
|
|
|
|
export type ContentPieceThemeMatch = {
|
|
/** The expanded term that surfaced the piece (never the literal query). */
|
|
term: string
|
|
/** Real passage for "Por que apareceu"; null when the piece carries no text. */
|
|
evidence: ContentPieceThemeEvidence | null
|
|
}
|
|
|
|
/**
|
|
* How the card/preview renders the archived file: the stored MIME decides
|
|
* (video/audio play on demand, image previews, text shows the excerpt); an
|
|
* unknown MIME falls back to the editorial type, and anything else stays a
|
|
* download-only tile. A link piece has no file at all.
|
|
*/
|
|
export const contentPieceMediaKind = (
|
|
item: Pick<ContentPiecePublicItem, 'isLink' | 'file' | 'type'>,
|
|
): ContentPieceMediaKind | null => {
|
|
if (item.isLink) return null
|
|
|
|
const mime = item.file?.mimeType ?? ''
|
|
if (mime.startsWith('video/')) return 'video'
|
|
if (mime.startsWith('audio/')) return 'audio'
|
|
if (mime.startsWith('image/')) return 'image'
|
|
if (mime.startsWith('text/')) return 'text'
|
|
if (mime) return 'other'
|
|
|
|
if (item.type === 'video') return 'video'
|
|
if (item.type === 'audio') return 'audio'
|
|
if (item.type === 'foto' || item.type === 'card') return 'image'
|
|
if (item.type === 'texto') return 'text'
|
|
return 'other'
|
|
}
|
|
|
|
const DOWNLOAD_PREFIX = 'jorge-solla-1313'
|
|
const DOWNLOAD_FALLBACK_EXTENSION = 'bin'
|
|
|
|
/**
|
|
* Legible download name: the stored upload filename can carry accents/spaces,
|
|
* so the base comes from the public slug and the extension from the actual
|
|
* file (same contract as the jingle download name).
|
|
*/
|
|
export const contentPieceDownloadFilename = (slug: string, filename?: string | null): string => {
|
|
const base = slugify(slug) || 'peca'
|
|
const lastDot = filename?.lastIndexOf('.') ?? -1
|
|
const extension = lastDot > 0 ? filename?.slice(lastDot + 1).toLowerCase() : null
|
|
const safeExtension =
|
|
extension && /^[a-z0-9]{1,5}$/.test(extension) ? extension : DOWNLOAD_FALLBACK_EXTENSION
|
|
|
|
return `${DOWNLOAD_PREFIX}-${base}.${safeExtension}`
|
|
}
|
|
|
|
const EXCERPT_MAX_LENGTH = 140
|
|
|
|
/** First non-empty paragraph of the text/transcript, truncated for the asset tile. */
|
|
export const contentPieceTextExcerpt = (
|
|
transcript: string | null | undefined,
|
|
maxLength = EXCERPT_MAX_LENGTH,
|
|
): string | null => {
|
|
const paragraph = (transcript ?? '')
|
|
.split(/\n+/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean)
|
|
if (!paragraph) return null
|
|
|
|
return paragraph.length <= maxLength
|
|
? paragraph
|
|
: `${paragraph.slice(0, maxLength - 1).trimEnd()}…`
|
|
}
|
|
|
|
const durationLabelOf = (seconds: number | null | undefined): string | null =>
|
|
typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
|
|
? formatSpeechClock(seconds)
|
|
: null
|
|
|
|
/**
|
|
* S28 — the first expanded term that actually surfaced this piece. The gate is
|
|
* the same normalized `includes` the filter runs over `searchText`, so the card
|
|
* never claims a theme the search did not use. The evidence is the window that
|
|
* carries the term (`transcript` > `description`, highlighted as one phrase);
|
|
* when the match came from another part of the haystack (title, topic, city,
|
|
* institution) the block falls back to a real passage of the piece without
|
|
* highlight — text of the piece, never a fabricated quote. Terms must already
|
|
* be filtered by `contentPieceThemeTerms` (the literal query is not a theme).
|
|
*/
|
|
export const contentPieceThemeMatch = (
|
|
record: ContentPiecePublicSource,
|
|
themeTerms: readonly string[],
|
|
): ContentPieceThemeMatch | null => {
|
|
if (themeTerms.length === 0) return null
|
|
|
|
const haystack = normalizeForSearch(record.searchText ?? '')
|
|
const transcript = record.transcript?.trim() ?? ''
|
|
const description = record.description?.trim() ?? ''
|
|
|
|
for (const term of themeTerms) {
|
|
const normalized = normalizeForSearch(term)
|
|
if (!normalized || !haystack.includes(normalized)) continue
|
|
|
|
if (normalizeForSearch(transcript).includes(normalized)) {
|
|
return {
|
|
term,
|
|
evidence: {
|
|
...buildHighlightedExcerpt(transcript, term, { phrase: true }),
|
|
quoted: true,
|
|
source: 'transcript',
|
|
},
|
|
}
|
|
}
|
|
if (normalizeForSearch(description).includes(normalized)) {
|
|
return {
|
|
term,
|
|
evidence: {
|
|
...buildHighlightedExcerpt(description, term, { phrase: true }),
|
|
quoted: true,
|
|
source: 'description',
|
|
},
|
|
}
|
|
}
|
|
|
|
// A real short passage of the piece (never the whole 1200-char description
|
|
// on a card), without quotes: there is no matched term to point at.
|
|
const fallback = contentPieceTextExcerpt(description || transcript)
|
|
return {
|
|
term,
|
|
evidence: fallback
|
|
? {
|
|
parts: [{ text: fallback, highlighted: false }],
|
|
truncatedStart: false,
|
|
truncatedEnd: false,
|
|
quoted: false,
|
|
source: description ? 'description' : 'excerpt',
|
|
}
|
|
: null,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const dateLabelOf = (value: string | null | undefined): string | null =>
|
|
value && /^\d{4}-\d{2}-\d{2}/.test(value) ? formatSpeechDate(value) : null
|
|
|
|
const mediaOf = (
|
|
media: ContentPiecePublicSource['media'],
|
|
): { id: number; filename: string | null; mimeType: string | null } | null => {
|
|
if (typeof media === 'number') return { id: media, filename: null, mimeType: null }
|
|
if (media && typeof media === 'object' && typeof media.id === 'number') {
|
|
return { id: media.id, filename: media.filename ?? null, mimeType: media.mimeType ?? null }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Public view of one piece, or null when it may not be shown: no slug (never
|
|
* published) or the `contentPieceIsPublic` predicate fails (published without
|
|
* file and without link). The gate lives here too, not only in the query.
|
|
*
|
|
* S28 — `themeTerms` (already filtered by `contentPieceThemeTerms`) annotates
|
|
* the item with the theme match; empty in the literal search.
|
|
*/
|
|
export const toContentPiecePublicItem = (
|
|
record: ContentPiecePublicSource,
|
|
{ themeTerms = [] }: { themeTerms?: readonly string[] } = {},
|
|
): ContentPiecePublicItem | null => {
|
|
const slug = record.slug?.trim()
|
|
if (!slug) return null
|
|
|
|
const status = isContentPieceStatus(record.status) ? record.status : 'rascunho'
|
|
const sourceUrl = record.sourceUrl?.trim() || null
|
|
const media = mediaOf(record.media)
|
|
if (!contentPieceIsPublic({ status, hasFile: media !== null, sourceUrl })) return null
|
|
|
|
const type = isContentPieceType(record.type) ? record.type : 'foto'
|
|
const origin = isContentPieceOrigin(record.origin) ? record.origin : 'arquivo'
|
|
const topics = (record.topics ?? []).filter(isContentPieceTopic)
|
|
const topicLabels = topics.map(contentPieceTopicLabel)
|
|
const cityLabel = record.cityLabel?.trim() || null
|
|
const regionLabel = record.region?.trim() || null
|
|
const institution = record.institution?.trim() || null
|
|
const local = cityLabel ?? regionLabel
|
|
const metaLabel = [topicLabels[0] ?? institution, local].filter(Boolean).join(' · ')
|
|
const isLink = media === null && sourceUrl !== null
|
|
|
|
return {
|
|
id: record.id,
|
|
slug,
|
|
title: record.title?.trim() || `Peça ${record.id}`,
|
|
type,
|
|
typeLabel: contentPieceTypeLabels[type],
|
|
origin,
|
|
originLabel: contentPieceOriginLabels[origin],
|
|
isLink,
|
|
sourceUrl,
|
|
topics,
|
|
topicLabels,
|
|
cityLabel,
|
|
regionLabel,
|
|
institution,
|
|
description: record.description?.trim() || null,
|
|
excerpt: contentPieceTextExcerpt(record.transcript),
|
|
durationLabel: durationLabelOf(record.durationSeconds),
|
|
pieceDateLabel: dateLabelOf(record.pieceDate),
|
|
metaLabel,
|
|
searchText: record.searchText ?? '',
|
|
themeMatch: contentPieceThemeMatch(record, themeTerms),
|
|
publicPath: contentPiecePublicPath(slug),
|
|
file: media
|
|
? {
|
|
id: media.id,
|
|
path: contentPieceMediaPath(slug),
|
|
mimeType: media.mimeType,
|
|
downloadFilename: contentPieceDownloadFilename(slug, media.filename),
|
|
}
|
|
: null,
|
|
}
|
|
}
|