mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
550 lines
20 KiB
TypeScript
550 lines
20 KiB
TypeScript
/**
|
||
* C167 — pure rules of a speech cut: the status/step vocabulary, the
|
||
* deterministic metadata fallback, the ffmpeg command for the exact
|
||
* [start, end] MP4 and the public view model. No I/O and no `server-only`:
|
||
* the dialog (client) and the job (server) share this module.
|
||
*/
|
||
import { CAMPAIGN_COMMUNICATION_ACERVO, campaignInternetSpeechHref } from '@/lib/campaignPaths'
|
||
import { formatBahiaCivilDate } from '@/lib/campaignTime'
|
||
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
|
||
import { relationshipId } from '@/lib/relationship'
|
||
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
|
||
import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
|
||
import {
|
||
parseYoutubeVideoId,
|
||
speechVodCoordinates,
|
||
type SpeechVodCoordinatesSource,
|
||
} from '@/lib/speechVod'
|
||
import { webSpeechDisplayTitle, type WebSpeechPlatform } from '@/lib/webSpeech'
|
||
|
||
export const SPEECH_CUT_STATUSES = ['processing', 'published', 'unpublished', 'failed'] as const
|
||
export type SpeechCutStatus = (typeof SPEECH_CUT_STATUSES)[number]
|
||
|
||
/** One owner for the persisted field limits (collection, schema and dialog). */
|
||
export const SPEECH_CUT_TITLE_MAX_LENGTH = 200
|
||
export const SPEECH_CUT_DESCRIPTION_MAX_LENGTH = 2000
|
||
|
||
export const speechCutStatusLabels: Record<SpeechCutStatus, string> = {
|
||
processing: 'Processando',
|
||
published: 'Publicado',
|
||
unpublished: 'Despublicado',
|
||
failed: 'Falhou',
|
||
}
|
||
|
||
const isSpeechCutStatus = (value: unknown): value is SpeechCutStatus =>
|
||
SPEECH_CUT_STATUSES.includes(value as SpeechCutStatus)
|
||
|
||
export const SPEECH_CUT_STEPS = ['resolving', 'cutting', 'metadata', 'publishing'] as const
|
||
export type SpeechCutStep = (typeof SPEECH_CUT_STEPS)[number]
|
||
|
||
/** The four honest progress steps the dialog shows (draft scene 2). */
|
||
export const speechCutStepLabels: Record<SpeechCutStep, string> = {
|
||
resolving: 'Localizando o trecho na Câmara',
|
||
cutting: 'Cortando o trecho',
|
||
metadata: 'Gerando título e descrição',
|
||
publishing: 'Publicando a página do corte',
|
||
}
|
||
|
||
/** C217 — the web speech has no Câmara to locate; only this step renames. */
|
||
const WEB_SPEECH_CUT_STEP_LABELS: Record<SpeechCutStep, string> = {
|
||
...speechCutStepLabels,
|
||
resolving: 'Localizando o arquivo da fala',
|
||
}
|
||
|
||
/**
|
||
* C217 — which acquisition path a cut uses: the Câmara VOD or the private
|
||
* mirrored file of a web speech. The predicate is the single owner of the
|
||
* decision (the action refuses and the job branches through it); `null` means
|
||
* the speech cannot be cut at all — a web speech without a mirror never falls
|
||
* back to the Câmara VOD.
|
||
*
|
||
* `speechCutOriginKind` is the narrower vocabulary question ("whose speech is
|
||
* this?") that the copy/prompt paths ask: a web speech without a mirror is
|
||
* still a web speech, never a Câmara one.
|
||
*/
|
||
export type SpeechCutSourceKind = 'camara' | 'web'
|
||
|
||
type SpeechCutSourceRecord = SpeechVodCoordinatesSource & {
|
||
origin?: string | null
|
||
mirroredMedia?: number | { id: number } | null
|
||
}
|
||
|
||
export const speechCutOriginKind = (speech: { origin?: string | null }): SpeechCutSourceKind =>
|
||
speech.origin === 'web' ? 'web' : 'camara'
|
||
|
||
export const speechCutSourceKind = (speech: SpeechCutSourceRecord): SpeechCutSourceKind | null => {
|
||
if (speechCutOriginKind(speech) === 'web') {
|
||
return relationshipId(speech.mirroredMedia) === null ? null : 'web'
|
||
}
|
||
return speechVodCoordinates(speech) ? 'camara' : null
|
||
}
|
||
|
||
const isSpeechCutStep = (value: unknown): value is SpeechCutStep =>
|
||
SPEECH_CUT_STEPS.includes(value as SpeechCutStep)
|
||
|
||
/** C217 — the step label by source; the Câmara keeps the historical literals. */
|
||
export const speechCutStepLabel = (
|
||
step: SpeechCutStep,
|
||
source: SpeechCutSourceKind = 'camara',
|
||
): string => (source === 'web' ? WEB_SPEECH_CUT_STEP_LABELS[step] : speechCutStepLabels[step])
|
||
|
||
/**
|
||
* C169 — the causes the job and the reaper store in `error` (the operator's
|
||
* detail) and the honest pt-BR copy the dialog shows for each one. Only these
|
||
* literals — or the failure step — ever reach the person; a raw transport
|
||
* message (URL, stderr) stays in the admin.
|
||
*/
|
||
export const SPEECH_CUT_FAILURE_SPEECH_GONE = 'A fala deste corte não está mais disponível.'
|
||
export const SPEECH_CUT_FAILURE_GENERATING = 'A Câmara ainda está gerando o vídeo deste trecho.'
|
||
export const SPEECH_CUT_FAILURE_UNAVAILABLE = 'A Câmara não entregou o arquivo deste trecho.'
|
||
export const SPEECH_CUT_FAILURE_UNPLAYABLE =
|
||
'A Câmara não entregou um arquivo jogável deste trecho.'
|
||
export const SPEECH_CUT_FAILURE_INTERRUPTED = 'O corte foi interrompido antes de terminar.'
|
||
/** C217 — the web cut died because the private mirror is gone. */
|
||
export const SPEECH_CUT_FAILURE_MIRROR_MISSING =
|
||
'O arquivo espelhado desta fala não está mais disponível.'
|
||
|
||
const SPEECH_CUT_FAILURE_INELIGIBLE_COPY = 'Esta fala não tem trecho de vídeo para cortar.'
|
||
const SPEECH_CUT_FAILURE_UNAVAILABLE_COPY =
|
||
'A Câmara não disponibiliza mais o arquivo deste trecho.'
|
||
const SPEECH_CUT_FAILURE_UNPLAYABLE_COPY =
|
||
'A Câmara não disponibilizou um arquivo válido deste trecho.'
|
||
const SPEECH_CUT_FAILURE_RESOLVING_COPY = 'Não foi possível localizar o trecho na Câmara.'
|
||
const SPEECH_CUT_FAILURE_MIRROR_RESOLVING_COPY = 'Não foi possível localizar o arquivo da fala.'
|
||
const SPEECH_CUT_FAILURE_CUTTING_COPY = 'Não foi possível cortar o trecho.'
|
||
const SPEECH_CUT_FAILURE_STORING_COPY = 'Não foi possível guardar o arquivo do corte.'
|
||
const SPEECH_CUT_FAILURE_UNKNOWN_COPY = 'Não foi possível preparar o corte.'
|
||
|
||
/**
|
||
* Maps the stored cause of a failed cut to what the person reads: the exact
|
||
* literal when the job named it, otherwise the failure step (cortar ≠ guardar),
|
||
* otherwise a generic-but-honest line. Null when the row stored no cause at all.
|
||
*/
|
||
export const speechCutFailureMessage = ({
|
||
error,
|
||
step,
|
||
source = 'camara',
|
||
}: {
|
||
error?: string | null
|
||
step?: SpeechCutStep | null
|
||
source?: SpeechCutSourceKind
|
||
}): string | null => {
|
||
const stored = error?.trim()
|
||
if (!stored) return null
|
||
|
||
if (stored === SPEECH_CUT_FAILURE_SPEECH_GONE) return SPEECH_CUT_FAILURE_SPEECH_GONE
|
||
if (stored === SPEECH_VOD_INELIGIBLE_MESSAGE) return SPEECH_CUT_FAILURE_INELIGIBLE_COPY
|
||
if (stored === SPEECH_CUT_FAILURE_GENERATING) return SPEECH_CUT_FAILURE_GENERATING
|
||
if (stored === SPEECH_CUT_FAILURE_UNAVAILABLE) return SPEECH_CUT_FAILURE_UNAVAILABLE_COPY
|
||
if (stored === SPEECH_CUT_FAILURE_UNPLAYABLE) return SPEECH_CUT_FAILURE_UNPLAYABLE_COPY
|
||
if (stored === SPEECH_CUT_FAILURE_INTERRUPTED) return SPEECH_CUT_FAILURE_INTERRUPTED
|
||
if (stored === SPEECH_CUT_FAILURE_MIRROR_MISSING) return SPEECH_CUT_FAILURE_MIRROR_MISSING
|
||
|
||
if (step === 'resolving') {
|
||
return source === 'web'
|
||
? SPEECH_CUT_FAILURE_MIRROR_RESOLVING_COPY
|
||
: SPEECH_CUT_FAILURE_RESOLVING_COPY
|
||
}
|
||
if (step === 'cutting') return SPEECH_CUT_FAILURE_CUTTING_COPY
|
||
if (step === 'metadata' || step === 'publishing') return SPEECH_CUT_FAILURE_STORING_COPY
|
||
return SPEECH_CUT_FAILURE_UNKNOWN_COPY
|
||
}
|
||
|
||
type SpeechCutStepState = {
|
||
step: SpeechCutStep
|
||
label: string
|
||
state: 'done' | 'current' | 'queued'
|
||
}
|
||
|
||
/** Step list of a running cut: everything before `current` is done, the rest queued. */
|
||
export const speechCutStepStates = (
|
||
current: SpeechCutStep | null,
|
||
source: SpeechCutSourceKind = 'camara',
|
||
): SpeechCutStepState[] => {
|
||
const currentIndex = current ? SPEECH_CUT_STEPS.indexOf(current) : -1
|
||
return SPEECH_CUT_STEPS.map((step, index) => ({
|
||
step,
|
||
label: speechCutStepLabel(step, source),
|
||
state: index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'queued',
|
||
}))
|
||
}
|
||
|
||
const FALLBACK_SPEECH_TYPE = 'fala'
|
||
|
||
/** Trim + clip used by every metadata path (fallback and AI output). */
|
||
export const clipSpeechCutText = (value: string, maximum: number): string =>
|
||
value.slice(0, maximum).trim()
|
||
|
||
/**
|
||
* Deterministic fallback for title/description when the AI is unavailable: the
|
||
* title always names the excerpt (`Trecho de <tipo> — <data>`) and the
|
||
* description prefers the official summary. Never empty — both fields are
|
||
* required on the row and on the public page.
|
||
*/
|
||
export const buildSpeechCutFallbackMetadata = ({
|
||
speechType,
|
||
dateLabel,
|
||
summary,
|
||
source = 'camara',
|
||
}: {
|
||
speechType: string | null
|
||
dateLabel: string
|
||
summary: string | null
|
||
/** C217 — the web source never claims the Câmara in the description. */
|
||
source?: SpeechCutSourceKind
|
||
}): { title: string; description: string } => {
|
||
const type = speechType?.trim() || FALLBACK_SPEECH_TYPE
|
||
return {
|
||
title: clipSpeechCutText(`Trecho de ${type} — ${dateLabel}`, SPEECH_CUT_TITLE_MAX_LENGTH),
|
||
description: clipSpeechCutText(
|
||
summary?.trim() ||
|
||
(source === 'web'
|
||
? `Trecho de ${type} de ${dateLabel}, publicado na internet.`
|
||
: `Trecho de ${type} de ${dateLabel}, na Câmara dos Deputados.`),
|
||
SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
|
||
),
|
||
}
|
||
}
|
||
|
||
type SpeechCutFfmpegInput = {
|
||
inputPath: string
|
||
outputPath: string
|
||
startSeconds: number
|
||
endSeconds: number
|
||
}
|
||
|
||
/**
|
||
* The exact-window prefix both cut commands share: `-ss` before `-i` seeks fast
|
||
* and `-t` carries the duration so a timestamp offset in the source cannot
|
||
* shift the end. Args are returned as an array — never a shell string.
|
||
*/
|
||
const cutWindowArgs = (input: SpeechCutFfmpegInput): string[] => {
|
||
const start = Math.max(0, Math.round(input.startSeconds))
|
||
const duration = Math.max(0, Math.round(input.endSeconds) - start)
|
||
return [
|
||
'-nostdin',
|
||
'-hide_banner',
|
||
'-y',
|
||
'-ss',
|
||
String(start),
|
||
'-i',
|
||
input.inputPath,
|
||
'-t',
|
||
String(duration),
|
||
]
|
||
}
|
||
|
||
/**
|
||
* Exact-cut command: the re-encode (no `-c copy`) starts the output exactly at
|
||
* the picked second instead of the previous keyframe; `+faststart` lets the
|
||
* proxied MP4 play progressively.
|
||
*/
|
||
export const buildSpeechCutFfmpegArgs = ({
|
||
inputPath,
|
||
outputPath,
|
||
startSeconds,
|
||
endSeconds,
|
||
}: SpeechCutFfmpegInput): string[] => [
|
||
...cutWindowArgs({ inputPath, outputPath, startSeconds, endSeconds }),
|
||
'-map',
|
||
'0:v:0',
|
||
'-map',
|
||
'0:a?',
|
||
'-c:v',
|
||
'libx264',
|
||
'-preset',
|
||
'veryfast',
|
||
'-crf',
|
||
'20',
|
||
'-pix_fmt',
|
||
'yuv420p',
|
||
'-c:a',
|
||
'aac',
|
||
'-b:a',
|
||
'128k',
|
||
'-movflags',
|
||
'+faststart',
|
||
outputPath,
|
||
]
|
||
|
||
/**
|
||
* C217 — the audio-only variant of the exact cut (radio/direct-audio mirrors):
|
||
* the same `-ss`/`-t` window, but the input has no video stream to map, so the
|
||
* output is an audio-only MP4 (AAC + `+faststart`) — the same `.mp4` contract
|
||
* the library card and the public page already play.
|
||
*/
|
||
export const buildSpeechCutAudioFfmpegArgs = ({
|
||
inputPath,
|
||
outputPath,
|
||
startSeconds,
|
||
endSeconds,
|
||
}: SpeechCutFfmpegInput): string[] => [
|
||
...cutWindowArgs({ inputPath, outputPath, startSeconds, endSeconds }),
|
||
'-map',
|
||
'0:a:0',
|
||
'-c:a',
|
||
'aac',
|
||
'-b:a',
|
||
'128k',
|
||
'-movflags',
|
||
'+faststart',
|
||
outputPath,
|
||
]
|
||
|
||
/** Public, unlisted page of a cut — the numeric id survives a title edit. */
|
||
export const speechCutPublicPath = (id: number): string => `/corte/${id}`
|
||
|
||
type SpeechCutShare = {
|
||
url: string
|
||
message: string
|
||
whatsAppUrl: string
|
||
}
|
||
|
||
/** Share payload of a published cut: the title followed by the public link. */
|
||
export const buildSpeechCutShare = ({
|
||
title,
|
||
url,
|
||
}: {
|
||
title: string
|
||
url: string
|
||
}): SpeechCutShare => {
|
||
const message = `${title.trim()} ${url}`
|
||
return { url, message, whatsAppUrl: buildWhatsAppTextShareUrl(message) }
|
||
}
|
||
|
||
type CutMediaRecord = { url?: string | null; filename?: string | null }
|
||
type CutSpeechRecord = {
|
||
/** Populated at depth 1; absent when the relation was not expanded. */
|
||
id?: number
|
||
youtubeUrl?: string | null
|
||
type?: string | null
|
||
phase?: string | null
|
||
speechAt?: string | null
|
||
/** C217 — web rows carry their own origin vocabulary and display title. */
|
||
origin?: string | null
|
||
platform?: WebSpeechPlatform | null
|
||
title?: string | null
|
||
}
|
||
type CutDurationRecord = { durationSeconds?: number | null }
|
||
|
||
export type SpeechCutRecordForView = CutDurationRecord & {
|
||
id: number
|
||
status: string
|
||
step?: string | null
|
||
title: string
|
||
description: string
|
||
startSeconds: number
|
||
endSeconds: number
|
||
media?: number | CutMediaRecord | null
|
||
speech?: number | CutSpeechRecord | null
|
||
publishedAt?: string | null
|
||
createdAt?: string | null
|
||
updatedAt?: string | null
|
||
/** Raw internal cause of a failure; never handed to the client as-is. */
|
||
error?: string | null
|
||
}
|
||
|
||
export type SpeechCutViewModel = {
|
||
id: number
|
||
status: SpeechCutStatus
|
||
step: SpeechCutStep | null
|
||
title: string
|
||
description: string
|
||
startSeconds: number
|
||
endSeconds: number
|
||
/** Stored span, stable even if the speech row changes; falls back to end − start. */
|
||
durationSeconds: number
|
||
durationLabel: string
|
||
publicPath: string
|
||
mediaUrl: string | null
|
||
mediaFilename: string | null
|
||
youtubeVideoId: string | null
|
||
publishedAt: string | null
|
||
/** C169 — honest cause of a failure; null unless `status === 'failed'`. */
|
||
failureMessage: string | null
|
||
}
|
||
|
||
/**
|
||
* C180 — the narrow view of a cut for the discovery surfaces ("Cortes desta
|
||
* fala" and the search nesting): everything they render, nothing they don't
|
||
* (`media*`/`publicPath`/`youtubeVideoId`/`failureMessage`/`step` stay out).
|
||
*/
|
||
export type SpeechCutSummaryViewModel = {
|
||
id: number
|
||
status: SpeechCutStatus
|
||
title: string
|
||
description: string
|
||
/** Stored span, stable even if the speech row changes; falls back to end − start. */
|
||
durationSeconds: number
|
||
}
|
||
|
||
/**
|
||
* The shape the summary mapper needs. Payload does not reflect `select` in the
|
||
* result type, so this must stay field-for-field with `speechCutSummarySelect`
|
||
* in `utilities/speech/speechCutPageData.ts`.
|
||
*/
|
||
type SpeechCutSummaryRecord = Pick<
|
||
SpeechCutRecordForView,
|
||
'id' | 'status' | 'title' | 'description' | 'startSeconds' | 'endSeconds' | 'durationSeconds'
|
||
>
|
||
|
||
const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null =>
|
||
typeof media === 'object' && media !== null ? media : null
|
||
|
||
const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null =>
|
||
typeof speech === 'object' && speech !== null ? (speech.youtubeUrl ?? null) : null
|
||
|
||
/** C217 — the source of a cut, read from its origin speech (absent → Câmara). */
|
||
const speechSourceOf = (speech: SpeechCutRecordForView['speech']): SpeechCutSourceKind =>
|
||
typeof speech === 'object' && speech !== null ? speechCutOriginKind(speech) : 'camara'
|
||
|
||
/** Stored span when present, else the raw [start, end] fallback, floored at 0. */
|
||
const cutDurationSeconds = (record: SpeechCutSummaryRecord): number =>
|
||
typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds)
|
||
? Math.max(0, Math.round(record.durationSeconds))
|
||
: Math.max(0, Math.round(record.endSeconds) - Math.round(record.startSeconds))
|
||
|
||
/**
|
||
* Internal/status wire view of a cut. `error` and `createdBy` never pass
|
||
* through here: the public page and the dialog receive only what they render —
|
||
* a failure becomes the mapped `failureMessage`, never the raw detail.
|
||
*/
|
||
export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutViewModel => {
|
||
const media = mediaOf(record.media)
|
||
const status = isSpeechCutStatus(record.status) ? record.status : 'failed'
|
||
const step = isSpeechCutStep(record.step) ? record.step : null
|
||
const durationSeconds = cutDurationSeconds(record)
|
||
|
||
return {
|
||
id: record.id,
|
||
status,
|
||
step,
|
||
title: record.title,
|
||
description: record.description,
|
||
startSeconds: record.startSeconds,
|
||
endSeconds: record.endSeconds,
|
||
durationSeconds,
|
||
durationLabel: formatSpeechSpan(durationSeconds),
|
||
publicPath: speechCutPublicPath(record.id),
|
||
mediaUrl: media?.url ?? null,
|
||
mediaFilename: media?.filename ?? null,
|
||
youtubeVideoId: parseYoutubeVideoId(youtubeUrlOf(record.speech)),
|
||
publishedAt: record.publishedAt ?? null,
|
||
failureMessage:
|
||
status === 'failed'
|
||
? speechCutFailureMessage({
|
||
error: record.error,
|
||
step,
|
||
source: speechSourceOf(record.speech),
|
||
})
|
||
: null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* C180 — narrow view of a cut for the discovery surfaces, reusing the same
|
||
* duration fallback and status normalization as the full view (an unknown
|
||
* status still degrades to `failed`, so the badge never receives a stray value).
|
||
*/
|
||
export const toSpeechCutSummaryViewModel = (
|
||
record: SpeechCutSummaryRecord,
|
||
): SpeechCutSummaryViewModel => ({
|
||
id: record.id,
|
||
status: isSpeechCutStatus(record.status) ? record.status : 'failed',
|
||
title: record.title,
|
||
description: record.description,
|
||
durationSeconds: cutDurationSeconds(record),
|
||
})
|
||
|
||
/**
|
||
* C180 — volume ceiling for the origin `id in` the acervo ORs into its textual
|
||
* `where`. A common term can match thousands of cuts; the cap degrades the
|
||
* "origin-only" surface (fewer origin speeches), never the result's correctness
|
||
* — the "Fala de origem" flag comes from the predicate, not from these ids.
|
||
* Revisit when the catalog approaches ~5–10k cuts (same trigger as C168).
|
||
*/
|
||
export const SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT = 200
|
||
|
||
/**
|
||
* Deduped origin speech ids of a batch of cuts, capped at `limit`. Depth 0
|
||
* keeps an unresolved relation as the raw id; a populated relation contributes
|
||
* its `.id`, and a missing/null relation is skipped.
|
||
*/
|
||
export const originSpeechIdsOfCuts = (
|
||
cuts: readonly { speech?: number | CutSpeechRecord | null }[],
|
||
limit = SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT,
|
||
): number[] => {
|
||
const ids = new Set<number>()
|
||
for (const cut of cuts) {
|
||
if (ids.size >= limit) break
|
||
const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id
|
||
if (typeof speechId === 'number') ids.add(speechId)
|
||
}
|
||
return [...ids]
|
||
}
|
||
|
||
/** Origin of a cut in the library: the speech it was cut from, with its link. */
|
||
export type SpeechCutOriginViewModel = {
|
||
id: number
|
||
/** C217 — the Câmara link and label stay as they were; the web has its own. */
|
||
source: SpeechCutSourceKind
|
||
/** "Fala · Breves Comunicações · 11/08/2026" (Câmara) or the web speech title. */
|
||
label: string
|
||
/** C217 — the web platform of the origin (drives the pill); null on Câmara rows. */
|
||
platform: WebSpeechPlatform | null
|
||
href: string
|
||
}
|
||
|
||
export type SpeechCutLibraryItemViewModel = SpeechCutViewModel & {
|
||
/** "Criado em dd/mm/aaaa" date part; null when the row has no timestamp. */
|
||
createdAtLabel: string | null
|
||
/** Null when the source speech was deleted (FK `SET NULL`) — degrade, don't break. */
|
||
origin: SpeechCutOriginViewModel | null
|
||
}
|
||
|
||
const formatCreatedAtLabel = (createdAt: string | null | undefined): string | null => {
|
||
if (!createdAt) return null
|
||
const instant = new Date(createdAt)
|
||
if (Number.isNaN(instant.getTime())) return null
|
||
return formatSpeechDate(formatBahiaCivilDate(instant))
|
||
}
|
||
|
||
const originOf = (speech: SpeechCutRecordForView['speech']): SpeechCutOriginViewModel | null => {
|
||
if (typeof speech !== 'object' || speech === null || typeof speech.id !== 'number') return null
|
||
|
||
// C217 — a web speech links back to its own detail and shows the platform;
|
||
// the Câmara composition is unchanged byte for byte.
|
||
if (speech.origin === 'web') {
|
||
return {
|
||
id: speech.id,
|
||
source: 'web',
|
||
label: webSpeechDisplayTitle({ id: speech.id, title: speech.title }),
|
||
platform: speech.platform ?? null,
|
||
href: campaignInternetSpeechHref(speech.id),
|
||
}
|
||
}
|
||
|
||
const dateLabel = speech.speechAt ? formatSpeechDate(speech.speechAt) : null
|
||
const label =
|
||
[speech.type ?? 'Fala', speech.phase, dateLabel].filter(Boolean).join(' · ') || 'Fala'
|
||
return {
|
||
id: speech.id,
|
||
source: 'camara',
|
||
label,
|
||
platform: null,
|
||
href: `${CAMPAIGN_COMMUNICATION_ACERVO}/${speech.id}`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* C168 — library view of a cut: the base view plus the origin speech (label +
|
||
* link to the acervo) and the creation date the list shows. `error` and
|
||
* `createdBy` still never pass through (a failure travels as `failureMessage`).
|
||
*/
|
||
export const toSpeechCutLibraryItemViewModel = (
|
||
record: SpeechCutRecordForView,
|
||
): SpeechCutLibraryItemViewModel => ({
|
||
...toSpeechCutViewModel(record),
|
||
createdAtLabel: formatCreatedAtLabel(record.createdAt),
|
||
origin: originOf(record.speech),
|
||
})
|