diff --git a/apphelper/CHANGELOG.md b/apphelper/CHANGELOG.md index a5302b5..502889b 100644 --- a/apphelper/CHANGELOG.md +++ b/apphelper/CHANGELOG.md @@ -1,5 +1,21 @@ # @churchapps/apphelper +## 0.15.0 + +### Minor Changes + +- 39e47d3: Image craft + responsive image performance for the website builder. + + apphelper: + + - New pluggable image-optimizer seam (`setImageOptimizer` / `responsiveImgProps` / `optimizedBackgroundImage`). Default is identity, so non-Next hosts (the Vite editor) render plain `` unchanged; B1App registers a Next.js `getImageProps`-backed optimizer to emit `srcset`/`sizes` and WebP/AVIF backgrounds. + - Content images (`image`, `card`, `textWithPhoto`, `logo`) now route their `src` through the seam; `logo` also gains the missing `loading="lazy" decoding="async"`. + - `BoxElement` image backgrounds get optimized loading, a `focalPoint` (`background-position`), and an opt-in tint overlay (`.boxBG:before`, driven by `--overlay-color` / `--overlay-opacity`, default off). + + helpers: + + - Documented the new `box` answer keys (`focalPoint`, `overlayColor`, `backgroundOpacity`) in `ElementTypes`. + ## 0.14.0 ### Minor Changes diff --git a/apphelper/package.json b/apphelper/package.json index 8087802..acac5e2 100644 --- a/apphelper/package.json +++ b/apphelper/package.json @@ -1,6 +1,6 @@ { "name": "@churchapps/apphelper", - "version": "0.14.0", + "version": "0.15.0", "description": "Library of helper functions, components, and feature modules (donations/forms/login/markdown/website) for React and NextJS ChurchApps", "type": "module", "main": "dist/index.js", @@ -138,7 +138,7 @@ "slug": "^11.0.0" }, "devDependencies": { - "@churchapps/helpers": "^1.7.1", + "@churchapps/helpers": "^1.8.1", "@eslint/js": "^10.0.1", "@stripe/react-stripe-js": "^3.10.0", "@stripe/stripe-js": "^7.9.0", diff --git a/apphelper/src/website/components/elementTypes/BoxElement.tsx b/apphelper/src/website/components/elementTypes/BoxElement.tsx index 42b2d7b..e85b8cf 100644 --- a/apphelper/src/website/components/elementTypes/BoxElement.tsx +++ b/apphelper/src/website/components/elementTypes/BoxElement.tsx @@ -1,5 +1,5 @@ import React, { CSSProperties } from "react"; -import { ElementInterface, SectionInterface } from "../../helpers"; +import { ElementInterface, SectionInterface, optimizedBackgroundImage } from "../../helpers"; import { DroppableArea } from "../admin/DroppableArea"; import { Element } from "../Element"; import { ApiHelper } from "../../.."; @@ -37,12 +37,17 @@ export function BoxElement(props: Props) { }; + const isImageBg = props.element.answers?.background?.indexOf("/") > -1; + const getStyle = () => { - let result: CSSProperties = { }; - if (props.element.answers?.background?.indexOf("/") > -1) { - result = { backgroundImage: "url('" + props.element.answers?.background + "')" }; - } else { - result = { background: props.element.answers?.background }; + const result: CSSProperties = isImageBg + ? { backgroundImage: optimizedBackgroundImage(props.element.answers?.background) } + : { background: props.element.answers?.background }; + if (isImageBg) { + if (props.element.answers?.focalPoint) result.backgroundPosition = props.element.answers.focalPoint; + // overlay defaults off (opacity 0) so existing image boxes don't darken + (result as any)["--overlay-color"] = props.element.answers?.overlayColor || "#000"; + (result as any)["--overlay-opacity"] = props.element.answers?.backgroundOpacity || "0"; } if (props.element.answers?.textColor?.startsWith("var(")) result.color = props.element.answers?.textColor; result.padding = 15; @@ -54,6 +59,7 @@ export function BoxElement(props: Props) { const getClass = () => { let result = "elBox"; + if (isImageBg) result += " boxBG"; let hc = props.element.answers?.headingColor; if (hc) { hc = hc.replace("var(--", "").replace(")", ""); diff --git a/apphelper/src/website/components/elementTypes/CardElement.tsx b/apphelper/src/website/components/elementTypes/CardElement.tsx index dd60c52..90e3782 100644 --- a/apphelper/src/website/components/elementTypes/CardElement.tsx +++ b/apphelper/src/website/components/elementTypes/CardElement.tsx @@ -1,8 +1,10 @@ import React from "react"; -import { ElementInterface, SectionInterface } from "../../helpers"; +import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers"; import { HtmlPreview } from "./HtmlPreview"; import { Card, CardContent } from "@mui/material"; +const IMG_SIZES = "(max-width:768px) 100vw, 33vw"; + interface Props { element: ElementInterface; onEdit?: (section: SectionInterface | null, element: ElementInterface) => void; } @@ -20,7 +22,7 @@ export const CardElement: React.FC = (props) => { let photoContent = <>; if (props.element.answers?.photo) { - const photo = {props.element.answers?.photoAlt; + const photo = {props.element.answers?.photoAlt; if (props.element.answers?.url) photoContent = ({photo}); else photoContent = (photo); } diff --git a/apphelper/src/website/components/elementTypes/ImageElement.tsx b/apphelper/src/website/components/elementTypes/ImageElement.tsx index e0396fa..e7ebb0c 100644 --- a/apphelper/src/website/components/elementTypes/ImageElement.tsx +++ b/apphelper/src/website/components/elementTypes/ImageElement.tsx @@ -1,6 +1,8 @@ import React, { CSSProperties, useCallback, useEffect, useState } from "react"; import ReactDOM from "react-dom"; -import { ElementInterface, SectionInterface } from "../../helpers"; +import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers"; + +const IMG_SIZES = "(max-width:768px) 100vw, 66vw"; interface Props { element: ElementInterface; @@ -63,7 +65,7 @@ export const ImageElement = ({ element }: Props) => { if (imageUrl) { const imgTag = ( {element.answers?.photoAlt = (props) => { const photo = ( {props.element.answers?.photoAlt ); const photoContent = (props.element.answers?.url) ? {photo} : photo; diff --git a/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx b/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx index 44d0ed6..f9a2c21 100644 --- a/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx +++ b/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx @@ -1,7 +1,9 @@ -import { ElementInterface, SectionInterface } from "../../helpers"; +import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers"; import { Grid } from "@mui/material"; import { HtmlPreview } from "./HtmlPreview"; +const IMG_SIZES = "(max-width:768px) 100vw, 33vw"; + interface Props { element: ElementInterface; onEdit?: (section: SectionInterface | null, element: ElementInterface) => void; } export const TextWithPhoto: React.FC = props => { @@ -21,7 +23,7 @@ export const TextWithPhoto: React.FC = props => { result = ( - {props.element.answers?.photoAlt + {props.element.answers?.photoAlt {textComponent} @@ -36,7 +38,7 @@ export const TextWithPhoto: React.FC = props => { {textComponent} - {props.element.answers?.photoAlt + {props.element.answers?.photoAlt ); @@ -45,14 +47,14 @@ export const TextWithPhoto: React.FC = props => { result = ( <> {textComponent} - {props.element.answers?.photoAlt + {props.element.answers?.photoAlt ); break; case "top": result = ( <> - {props.element.answers?.photoAlt + {props.element.answers?.photoAlt {textComponent} ); diff --git a/apphelper/src/website/helpers/imageOptimizer.ts b/apphelper/src/website/helpers/imageOptimizer.ts new file mode 100644 index 0000000..e978210 --- /dev/null +++ b/apphelper/src/website/helpers/imageOptimizer.ts @@ -0,0 +1,33 @@ +// Pluggable image-optimization seam. Host apps (B1App / Next.js) register an optimizer that +// produces responsive props and optimized CSS backgrounds via the Next image pipeline. +// The default is identity, so the Vite editor and any non-Next host render plain images unchanged. + +export interface ResponsiveImgProps { + src: string; + srcSet?: string; + sizes?: string; +} + +export interface ImageOptimizer { + // Responsive props to spread onto an . + img: (url: string, sizes?: string) => ResponsiveImgProps; + // A CSS background-image value: url('…') or image-set(…). + background: (url: string) => string; +} + +const identity: ImageOptimizer = { + img: (url) => ({ src: url }), + background: (url) => `url('${url}')` +}; + +let current: ImageOptimizer = identity; + +export const setImageOptimizer = (optimizer: ImageOptimizer | null) => { + current = optimizer || identity; +}; + +export const responsiveImgProps = (url: string, sizes?: string): ResponsiveImgProps => + url ? current.img(url, sizes) : { src: url }; + +export const optimizedBackgroundImage = (url: string): string => + url ? current.background(url) : `url('${url}')`; diff --git a/apphelper/src/website/helpers/index.ts b/apphelper/src/website/helpers/index.ts index 08c387d..dce2346 100644 --- a/apphelper/src/website/helpers/index.ts +++ b/apphelper/src/website/helpers/index.ts @@ -32,6 +32,7 @@ export interface SectionInterface { } export * from "./StyleHelper"; +export * from "./imageOptimizer"; export * from "./EnvironmentHelper"; export * from "./interfaces"; export * from "./StreamingServiceHelper"; diff --git a/apphelper/src/website/styles/pages.css b/apphelper/src/website/styles/pages.css index 2faddb8..a3a3a69 100644 --- a/apphelper/src/website/styles/pages.css +++ b/apphelper/src/website/styles/pages.css @@ -156,6 +156,28 @@ body { box-sizing: inherit; } +/* Box background overlay/tint — opt-in via inline --overlay-opacity (defaults off) */ +.page .boxBG { + background-size: cover; + background-position: 50% 50%; + position: relative; +} + +.page .boxBG:before { + content: ''; + position: absolute; + inset: 0; + background: var(--overlay-color, #000); + opacity: var(--overlay-opacity, 0); + pointer-events: none; + box-sizing: inherit; +} + +.page .boxBG > * { + position: relative; + z-index: 1; +} + /* .section { color: #333; diff --git a/content-providers/CHANGELOG.md b/content-providers/CHANGELOG.md index 8ddb74c..5573fa1 100644 --- a/content-providers/CHANGELOG.md +++ b/content-providers/CHANGELOG.md @@ -1,5 +1,34 @@ # @churchapps/content-providers +## 0.4.0 + +### Minor Changes + +- ddd001f: Slim down the content-providers surface to what the apps actually use. + + Breaking: + + - Removed `FormatResolver` and the `FormatConverters` namespace (plus the `ResolvedFormatMeta` and `FormatResolverOptions` types). No shipping consumer used them — apps call `provider.getPlaylist()` / `provider.getInstructions()` directly. The cross-format derivation that `FormatResolver` provided lives on only in the dev playground. + - Removed the unused `presentations` field from `ProviderCapabilities` and retired the never-implemented "presentations" format (dead `getPresentations` stubs, `convertFilesToPresentations`). Capabilities are now `browse` / `playlist` / `instructions` / `mediaLicensing`. + + Added: + + - Export `LifeChurchProvider` from the package root (it was registered but not exported). + + Internal (no API change): + + - `instructionsToPlaylist` moved into `utils` (it's a general converter, used by B1Church and the playground). + - B1Church device-flow now delegates to the shared `DeviceFlowHelper` instead of a duplicate copy. + - Provider registration is a single list instead of constructing and registering each provider twice. + +- 39e47d3: Trim the content-providers utility surface to what consumers actually use. + + Breaking: + + - Removed the duration-estimation module (`estimateDuration`, `estimateImageDuration`, `estimateTextDuration`, `countWords`, `DEFAULT_DURATION_CONFIG`, `DurationEstimationConfig`). The never-used text-estimation code is gone; image duration is now a single internal `IMAGE_DURATION_SECONDS = 15` constant. + - Removed the path helpers `buildPath`, `appendToPath`, and `generatePath` — no consumer or internal caller used them. + - Stopped exporting internal-only utilities from the package root: `detectMediaType`, `isMediaFile`, `createFolder`, `createFile`, `parsePath`, `getSegment`, and the `OAuthHelper` / `DeviceFlowHelper` / `ApiHelper` classes. They're still used internally — just no longer part of the public API. `TokenHelper` and `navigateToPath` remain exported. + ## 0.3.1 ### Patch Changes diff --git a/content-providers/eslint.config.js b/content-providers/eslint.config.js index eaf00f4..f22e75c 100644 --- a/content-providers/eslint.config.js +++ b/content-providers/eslint.config.js @@ -7,7 +7,7 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); export default tseslint.config([ - { ignores: ["node_modules/", "dist/", "build/", ".next/", "coverage/", "*.config.js"] }, + { ignores: ["node_modules/", "dist/", "build/", ".next/", "coverage/", "*.config.js", "playground/", "cli/"] }, { files: ["**/*.{ts,tsx,js,jsx}"], extends: [js.configs.recommended, tseslint.configs.recommended], diff --git a/content-providers/package.json b/content-providers/package.json index ab614e6..0d7b827 100644 --- a/content-providers/package.json +++ b/content-providers/package.json @@ -1,6 +1,6 @@ { "name": "@churchapps/content-providers", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "Helper classes for interacting with third party providers", "main": "./dist/index.js", @@ -19,6 +19,7 @@ "build": "tsup", "dev": "vite", "cli": "yarn exec tsx cli/playground.ts", + "test": "tsx --test tests/*.test.ts", "prepublishOnly": "yarn build", "lint": "eslint --fix src/", "lint:check": "eslint src/" diff --git a/content-providers/playground/api.ts b/content-providers/playground/api.ts index 3255cbe..5e6b334 100644 --- a/content-providers/playground/api.ts +++ b/content-providers/playground/api.ts @@ -1,7 +1,7 @@ import { state } from './state'; import { showLoading, showStatus } from './ui'; -import { FormatResolver, ContentItem, ContentFile, Plan, Instructions, ContentFolder } from '../src'; -import type { ResolvedFormatMeta } from '../src'; +import { ContentItem, ContentFile, Instructions, ContentFolder } from '../src'; +import { getPlaylistWithMeta, getInstructionsWithMeta, type ResolvedFormatMeta } from './formats'; /** * Result type for viewAsPlaylist function @@ -59,8 +59,7 @@ export async function viewAsPlaylist(folder: ContentFolder): Promise { + if (provider.capabilities.playlist && provider.getPlaylist) { + const result = await provider.getPlaylist(path, auth); + if (result && result.length > 0) return { data: result, meta: { isNative: true, isLossy: false } }; + } + // Fall back to deriving a playlist from instructions (e.g. providers that only expose playlists + // at leaf depth, like Jesus Film and High Voltage Kids at the collection level). + if (provider.capabilities.instructions && provider.getInstructions) { + const instructions = await provider.getInstructions(path, auth); + if (instructions) return { data: instructionsToPlaylist(instructions), meta: { isNative: false, sourceFormat: 'instructions', isLossy: false } }; + } + return { data: null, meta: { isNative: false, isLossy: false } }; +} + +export async function getInstructionsWithMeta(provider: IProvider, path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Instructions | null; meta: ResolvedFormatMeta }> { + if (provider.capabilities.instructions && provider.getInstructions) { + const result = await provider.getInstructions(path, auth); + if (result) return { data: result, meta: { isNative: true, isLossy: false } }; + } + return { data: null, meta: { isNative: false, isLossy: false } }; +} diff --git a/content-providers/playground/views/common.ts b/content-providers/playground/views/common.ts index 2805553..c307822 100644 --- a/content-providers/playground/views/common.ts +++ b/content-providers/playground/views/common.ts @@ -2,7 +2,7 @@ import { state, elements } from '../state'; import { escapeHtml, renderJsonViewer } from '../utils'; import { showStatus, showModal } from '../ui'; import { getAvailableProviders, ContentItem, ContentFolder, ContentFile, isContentFolder, isContentFile } from '../../src'; -import type { ResolvedFormatMeta } from '../../src'; +import type { ResolvedFormatMeta } from '../formats'; /** * Render provider cards in the providers grid @@ -41,9 +41,8 @@ export function renderProviders(onProviderClick: (providerId: string) => void): const caps = provider.capabilities; // Determine what formats can be derived - const canDerivePlaylist = caps.presentations || caps.instructions; - const canDerivePresentations = caps.instructions || caps.playlist; - const canDeriveExpanded = caps.presentations || caps.playlist; + const canDerivePlaylist = caps.instructions; + const canDeriveExpanded = caps.playlist; // Playlist badge if (caps.playlist) { @@ -52,13 +51,6 @@ export function renderProviders(onProviderClick: (providerId: string) => void): capBadges += 'Playlist*'; } - // Presentations badge - if (caps.presentations) { - capBadges += 'Presentations'; - } else if (canDerivePresentations) { - capBadges += 'Presentations*'; - } - // Instructions badge if (caps.instructions) { capBadges += 'Instructions'; diff --git a/content-providers/playground/views/instructions.ts b/content-providers/playground/views/instructions.ts index 8846e4c..5773d37 100644 --- a/content-providers/playground/views/instructions.ts +++ b/content-providers/playground/views/instructions.ts @@ -2,7 +2,7 @@ import { state, elements } from '../state'; import { escapeHtml, renderJsonViewer } from '../utils'; import { showStatus } from '../ui'; import { Instructions, InstructionItem } from '../../src'; -import type { ResolvedFormatMeta } from '../../src'; +import type { ResolvedFormatMeta } from '../formats'; import { renderFormatSourceBadge } from './common'; /** diff --git a/content-providers/playground/views/plans.ts b/content-providers/playground/views/plans.ts index 6795249..82d41d4 100644 --- a/content-providers/playground/views/plans.ts +++ b/content-providers/playground/views/plans.ts @@ -2,7 +2,7 @@ import { state, elements } from '../state'; import { escapeHtml, renderJsonViewer, formatDuration } from '../utils'; import { showStatus, showModal } from '../ui'; import { Plan, PlanPresentation, ContentItem, isContentFile } from '../../src'; -import type { ResolvedFormatMeta } from '../../src'; +import type { ResolvedFormatMeta } from '../formats'; import { renderFormatSourceBadge } from './common'; import { playPlanFiles } from './playlist'; diff --git a/content-providers/playground/views/playlist.ts b/content-providers/playground/views/playlist.ts index 517baa6..dedb7fe 100644 --- a/content-providers/playground/views/playlist.ts +++ b/content-providers/playground/views/playlist.ts @@ -2,7 +2,7 @@ import { state, elements } from '../state'; import { escapeHtml, renderJsonViewer } from '../utils'; import { showStatus, showModal } from '../ui'; import { ContentFile, ContentItem, isContentFile } from '../../src'; -import type { ResolvedFormatMeta } from '../../src'; +import type { ResolvedFormatMeta } from '../formats'; import { renderFormatSourceBadge } from './common'; /** diff --git a/content-providers/src/FormatConverters.ts b/content-providers/src/FormatConverters.ts deleted file mode 100644 index 8984572..0000000 --- a/content-providers/src/FormatConverters.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { ContentFile, Plan, PlanSection, PlanPresentation, Instructions, InstructionItem } from "./interfaces"; -import { detectMediaType } from "./utils"; - -function generateId(): string { - return "gen-" + Math.random().toString(36).substring(2, 11); -} - -function mapItemTypeToActionType(itemType?: string): "play" | "other" { - switch (itemType) { - case "action": - case "lessonAction": - case "providerPresentation": - case "play": - case "addon": - case "add-on": - case "lessonAddOn": - case "providerFile": return "play"; - default: return "other"; - } -} - -// LOSSLESS: All file data preserved, only hierarchy lost -export function presentationsToPlaylist(plan: Plan): ContentFile[] { - if (plan.allFiles && plan.allFiles.length > 0) { - return [...plan.allFiles]; - } - - const files: ContentFile[] = []; - for (const section of plan.sections) { - for (const presentation of section.presentations) { - files.push(...presentation.files); - } - } - return files; -} - -function mapActionTypeToItemType(actionType: "play" | "other"): string { - switch (actionType) { - case "play": return "action"; - default: return "item"; - } -} - -// LOSSLESS: All hierarchy and file data preserved -export function presentationsToExpandedInstructions(plan: Plan): Instructions { - return { - name: plan.name, - items: plan.sections.map(section => ({ - id: section.id, - itemType: "section", - label: section.name, - children: section.presentations.map(pres => ({ - id: pres.id, - itemType: mapActionTypeToItemType(pres.actionType), - label: pres.name, - description: pres.actionType !== "other" ? pres.actionType : undefined, - seconds: pres.files.reduce((sum, f) => sum + (f.seconds || 0), 0) || undefined, - children: pres.files.map(f => ({ - id: f.id, - itemType: "file", - label: f.title, - seconds: f.seconds, - downloadUrl: f.downloadUrl || f.url, - thumbnail: f.thumbnail - })) - })) - })) - }; -} - -// LOSSLESS for media: All items with downloadUrl become files -export function instructionsToPlaylist(instructions: Instructions): ContentFile[] { - const files: ContentFile[] = []; - - function extractFiles(items: InstructionItem[]) { - for (const item of items) { - if (item.downloadUrl && (item.itemType === "file" || !item.children?.length)) { - files.push({ type: "file", id: item.id || item.relatedId || generateId(), title: item.label || "Untitled", mediaType: detectMediaType(item.downloadUrl), url: item.downloadUrl, downloadUrl: item.downloadUrl, seconds: item.seconds, thumbnail: item.thumbnail }); - } - if (item.children) { - extractFiles(item.children); - } - } - } - - extractFiles(instructions.items); - return files; -} - -export const expandedInstructionsToPlaylist = instructionsToPlaylist; - -// LOSSLESS when instructions have proper structure -export function instructionsToPresentations(instructions: Instructions, planId?: string): Plan { - const allFiles: ContentFile[] = []; - - const sections: PlanSection[] = instructions.items.filter(item => item.children && item.children.length > 0).map(sectionItem => { - const presentations: PlanPresentation[] = (sectionItem.children || []).map(presItem => { - const files: ContentFile[] = []; - - if (presItem.children && presItem.children.length > 0) { - for (const child of presItem.children) { - if (child.downloadUrl) { - const file: ContentFile = { type: "file", id: child.id || child.relatedId || generateId(), title: child.label || "Untitled", mediaType: detectMediaType(child.downloadUrl), url: child.downloadUrl, downloadUrl: child.downloadUrl, seconds: child.seconds, thumbnail: child.thumbnail }; - allFiles.push(file); - files.push(file); - } - } - } - - if (files.length === 0 && presItem.downloadUrl) { - const file: ContentFile = { type: "file", id: presItem.id || presItem.relatedId || generateId(), title: presItem.label || "Untitled", mediaType: detectMediaType(presItem.downloadUrl), url: presItem.downloadUrl, downloadUrl: presItem.downloadUrl, seconds: presItem.seconds, thumbnail: presItem.thumbnail }; - allFiles.push(file); - files.push(file); - } - - return { id: presItem.id || presItem.relatedId || generateId(), name: presItem.label || "Presentation", actionType: mapItemTypeToActionType(presItem.itemType), files }; - }); - - return { id: sectionItem.id || sectionItem.relatedId || generateId(), name: sectionItem.label || "Section", presentations }; - }); - - return { id: planId || generateId(), name: instructions.name || "Plan", sections, allFiles }; -} - -export const expandedInstructionsToPresentations = instructionsToPresentations; - -// LOSSY: No structural information - all files in one section -export function playlistToPresentations(files: ContentFile[], planName: string = "Playlist", sectionName: string = "Content"): Plan { - const presentations: PlanPresentation[] = files.map((file, index) => ({ id: `pres-${index}-${file.id}`, name: file.title, actionType: "play" as const, files: [file] })); - return { id: "playlist-plan-" + generateId(), name: planName, sections: [{ id: "main-section", name: sectionName, presentations }], allFiles: [...files] }; -} - -// LOSSY: Minimal structure - just file references in a single section -export function playlistToInstructions(files: ContentFile[], name: string = "Playlist"): Instructions { - return { name, items: [{ id: "main-section", itemType: "section", label: "Content", children: files.map((file, index) => ({ id: file.id || `item-${index}`, itemType: "file", label: file.title, seconds: file.seconds, downloadUrl: file.downloadUrl || file.url, thumbnail: file.thumbnail })) }] }; -} - -export const playlistToExpandedInstructions = playlistToInstructions; diff --git a/content-providers/src/FormatResolver.ts b/content-providers/src/FormatResolver.ts deleted file mode 100644 index 08dc35e..0000000 --- a/content-providers/src/FormatResolver.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { IProvider, ContentFile, ContentProviderAuthData, Instructions } from "./interfaces"; -import * as Converters from "./FormatConverters"; -import { parsePath } from "./pathUtils"; - -export interface FormatResolverOptions { - allowLossy?: boolean; -} - -export interface ResolvedFormatMeta { - isNative: boolean; - sourceFormat?: "playlist" | "presentations" | "instructions"; - isLossy: boolean; -} - -export class FormatResolver { - private provider: IProvider; - private options: Required; - - constructor(provider: IProvider, options: FormatResolverOptions = {}) { - this.provider = provider; - this.options = { allowLossy: options.allowLossy ?? true }; - } - - getProvider(): IProvider { - return this.provider; - } - - /** Extract the last segment from a path to use as fallback ID/title */ - private getIdFromPath(path: string): string { - const { segments } = parsePath(path); - return segments[segments.length - 1] || "content"; - } - - async getPlaylist(path: string, auth?: ContentProviderAuthData | null): Promise { - const caps = this.provider.capabilities; - - if (caps.playlist && this.provider.getPlaylist) { - const result = await this.provider.getPlaylist(path, auth); - if (result && result.length > 0) return result; - } - - // if (caps.presentations) { - // const plan = await this.provider.getPresentations(path, auth); - // if (plan) return Converters.presentationsToPlaylist(plan); - // } - - if (caps.instructions && this.provider.getInstructions) { - const expanded = await this.provider.getInstructions(path, auth); - if (expanded) return Converters.instructionsToPlaylist(expanded); - } - - return null; - } - - async getPlaylistWithMeta(path: string, auth?: ContentProviderAuthData | null): Promise<{ data: ContentFile[] | null; meta: ResolvedFormatMeta }> { - const caps = this.provider.capabilities; - - if (caps.playlist && this.provider.getPlaylist) { - const result = await this.provider.getPlaylist(path, auth); - if (result && result.length > 0) { - return { data: result, meta: { isNative: true, isLossy: false } }; - } - } - - // if (caps.presentations) { - // const plan = await this.provider.getPresentations(path, auth); - // if (plan) return { data: Converters.presentationsToPlaylist(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } }; - // } - - if (caps.instructions && this.provider.getInstructions) { - const expanded = await this.provider.getInstructions(path, auth); - if (expanded) return { data: Converters.instructionsToPlaylist(expanded), meta: { isNative: false, sourceFormat: "instructions", isLossy: false } }; - } - - return { data: null, meta: { isNative: false, isLossy: false } }; - } - - // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise { - // const caps = this.provider.capabilities; - // const fallbackId = this.getIdFromPath(path); - - // if (caps.presentations) { - // const result = await this.provider.getPresentations(path, auth); - // if (result) return result; - // } - - // if (caps.instructions && this.provider.getInstructions) { - // const expanded = await this.provider.getInstructions(path, auth); - // if (expanded) return Converters.instructionsToPresentations(expanded, fallbackId); - // } - - // if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) { - // const playlist = await this.provider.getPlaylist(path, auth); - // if (playlist && playlist.length > 0) { - // return Converters.playlistToPresentations(playlist, fallbackId); - // } - // } - - // return null; - // } - - // async getPresentationsWithMeta(path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Plan | null; meta: ResolvedFormatMeta }> { - // const caps = this.provider.capabilities; - // const fallbackId = this.getIdFromPath(path); - - // if (caps.presentations) { - // const result = await this.provider.getPresentations(path, auth); - // if (result) { - // return { data: result, meta: { isNative: true, isLossy: false } }; - // } - // } - - // if (caps.instructions && this.provider.getInstructions) { - // const expanded = await this.provider.getInstructions(path, auth); - // if (expanded) return { data: Converters.instructionsToPresentations(expanded, fallbackId), meta: { isNative: false, sourceFormat: "instructions", isLossy: false } }; - // } - - // if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) { - // const playlist = await this.provider.getPlaylist(path, auth); - // if (playlist && playlist.length > 0) return { data: Converters.playlistToPresentations(playlist, fallbackId), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } }; - // } - - // return { data: null, meta: { isNative: false, isLossy: false } }; - // } - - async getInstructions(path: string, auth?: ContentProviderAuthData | null): Promise { - const caps = this.provider.capabilities; - const fallbackTitle = this.getIdFromPath(path); - - if (caps.instructions && this.provider.getInstructions) { - const result = await this.provider.getInstructions(path, auth); - if (result) return result; - } - - // if (caps.presentations) { - // const plan = await this.provider.getPresentations(path, auth); - // if (plan) return Converters.presentationsToExpandedInstructions(plan); - // } - - if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) { - const playlist = await this.provider.getPlaylist(path, auth); - if (playlist && playlist.length > 0) { - return Converters.playlistToInstructions(playlist, fallbackTitle); - } - } - - return null; - } - - async getInstructionsWithMeta(path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Instructions | null; meta: ResolvedFormatMeta }> { - const caps = this.provider.capabilities; - const fallbackTitle = this.getIdFromPath(path); - - if (caps.instructions && this.provider.getInstructions) { - const result = await this.provider.getInstructions(path, auth); - if (result) { - return { data: result, meta: { isNative: true, isLossy: false } }; - } - } - - // if (caps.presentations) { - // const plan = await this.provider.getPresentations(path, auth); - // if (plan) return { data: Converters.presentationsToExpandedInstructions(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } }; - // } - - if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) { - const playlist = await this.provider.getPlaylist(path, auth); - if (playlist && playlist.length > 0) return { data: Converters.playlistToInstructions(playlist, fallbackTitle), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } }; - } - - return { data: null, meta: { isNative: false, isLossy: false } }; - } -} diff --git a/content-providers/src/durationUtils.ts b/content-providers/src/durationUtils.ts deleted file mode 100644 index 13dbce3..0000000 --- a/content-providers/src/durationUtils.ts +++ /dev/null @@ -1,74 +0,0 @@ -export interface DurationEstimationConfig { - secondsPerImage: number; // Default: 15 - wordsPerMinute: number; // Default: 150 -} - -export const DEFAULT_DURATION_CONFIG: DurationEstimationConfig = { - secondsPerImage: 15, - wordsPerMinute: 150 -}; - -/** - * Count words in text (splits on whitespace) - */ -export function countWords(text: string): number { - if (!text || !text.trim()) return 0; - return text.trim().split(/\s+/).length; -} - -/** - * Estimate duration for image content - * @returns Duration in seconds (default: 15) - */ -export function estimateImageDuration( - config: Partial = {} -): number { - return config.secondsPerImage ?? DEFAULT_DURATION_CONFIG.secondsPerImage; -} - -/** - * Estimate duration for text content based on word count - * @param text - The text content - * @param config - Optional configuration overrides - * @returns Duration in seconds - */ -export function estimateTextDuration( - text: string, - config: Partial = {} -): number { - const words = countWords(text); - const wpm = config.wordsPerMinute ?? DEFAULT_DURATION_CONFIG.wordsPerMinute; - return Math.ceil((words / wpm) * 60); -} - -/** - * Estimate duration based on media type - * @param mediaType - "video" | "image" | "text" - * @param options - Text content or word count for text estimation - * @returns Duration in seconds (0 for video/unknown) - */ -export function estimateDuration( - mediaType: "video" | "image" | "text", - options?: { - text?: string; - wordCount?: number; - config?: Partial; - } -): number { - const config = options?.config ?? {}; - - switch (mediaType) { - case "image": return estimateImageDuration(config); - case "text": - if (options?.wordCount) { - const wpm = config.wordsPerMinute ?? DEFAULT_DURATION_CONFIG.wordsPerMinute; - return Math.ceil((options.wordCount / wpm) * 60); - } - if (options?.text) { - return estimateTextDuration(options.text, config); - } - return 0; - case "video": - default: return 0; - } -} diff --git a/content-providers/src/index.ts b/content-providers/src/index.ts index 25a98f2..575a718 100644 --- a/content-providers/src/index.ts +++ b/content-providers/src/index.ts @@ -11,26 +11,10 @@ export const VERSION = __PACKAGE_VERSION__; export * from "./interfaces"; // Utilities -export { detectMediaType, isMediaFile, createFolder, createFile } from "./utils"; -export { parsePath, getSegment, buildPath, appendToPath } from "./pathUtils"; -export { navigateToPath, generatePath } from "./instructionPathUtils"; -export { - estimateDuration, - estimateImageDuration, - estimateTextDuration, - countWords, - DEFAULT_DURATION_CONFIG, - type DurationEstimationConfig -} from "./durationUtils"; - -// Format conversion utilities (access via FormatConverters namespace) -export * as FormatConverters from "./FormatConverters"; - -// Format resolver -export { FormatResolver, type FormatResolverOptions, type ResolvedFormatMeta } from "./FormatResolver"; +export { navigateToPath } from "./instructionPathUtils"; // Helper classes (for standalone use or custom providers) -export { OAuthHelper, TokenHelper, DeviceFlowHelper, ApiHelper } from "./helpers"; +export { TokenHelper } from "./helpers"; // Built-in providers export { APlayProvider } from "./providers/aPlay"; @@ -43,6 +27,7 @@ export { BibleProjectProvider } from "./providers/bibleProject"; export { HighVoltageKidsProvider } from "./providers/highVoltage"; export { JesusFilmProvider } from "./providers/jesusFilm"; export { CbnProvider } from "./providers/cbn"; +export { LifeChurchProvider } from "./providers/lifeChurch"; // Registry functions export { diff --git a/content-providers/src/instructionPathUtils.ts b/content-providers/src/instructionPathUtils.ts index a4645fe..3ef91c3 100644 --- a/content-providers/src/instructionPathUtils.ts +++ b/content-providers/src/instructionPathUtils.ts @@ -18,11 +18,3 @@ export function navigateToPath(instructions: Instructions, path: string): Instru return current; } - -/** - * Generate a path string for an item given its position in the tree. - * Used when selecting an item to store its path. - */ -export function generatePath(indices: number[]): string { - return indices.join("."); -} diff --git a/content-providers/src/interfaces.ts b/content-providers/src/interfaces.ts index 3e9d532..fe652b1 100644 --- a/content-providers/src/interfaces.ts +++ b/content-providers/src/interfaces.ts @@ -227,7 +227,6 @@ export interface VenueActionsResponseInterface { export interface ProviderCapabilities { browse: boolean; - presentations: boolean; playlist: boolean; instructions: boolean; mediaLicensing: boolean; @@ -259,7 +258,6 @@ export interface IProvider { // Core methods (required) browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise; - // getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise; // Auth methods (required) supportsDeviceFlow(): boolean; diff --git a/content-providers/src/pathUtils.ts b/content-providers/src/pathUtils.ts index b1123e6..eda97d5 100644 --- a/content-providers/src/pathUtils.ts +++ b/content-providers/src/pathUtils.ts @@ -32,27 +32,3 @@ export function getSegment(path: string | null | undefined, index: number): stri const { segments } = parsePath(path); return segments[index] ?? null; } - -/** - * Build a path string from segments. - * @param segments - Array of path segments - * @returns Path string with leading slash - */ -export function buildPath(segments: string[]): string { - if (segments.length === 0) return "/"; - return "/" + segments.join("/"); -} - -/** - * Append a segment to an existing path. - * @param basePath - The base path - * @param segment - The segment to append - * @returns New path with segment appended - */ -export function appendToPath(basePath: string | null | undefined, segment: string): string { - if (!basePath || basePath === "/" || basePath === "") { - return "/" + segment; - } - const cleanBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; - return cleanBase + "/" + segment; -} diff --git a/content-providers/src/providers/aPlay/APlayConverters.ts b/content-providers/src/providers/aPlay/APlayConverters.ts index 8c2e43b..4fd4d64 100644 --- a/content-providers/src/providers/aPlay/APlayConverters.ts +++ b/content-providers/src/providers/aPlay/APlayConverters.ts @@ -1,4 +1,4 @@ -import { ContentFile, ContentItem, PlanPresentation, InstructionItem } from "../../interfaces"; +import { ContentFile, ContentItem, InstructionItem } from "../../interfaces"; import { detectMediaType, createFolder, createFile } from "../../utils"; import { parsePath } from "../../pathUtils"; @@ -89,28 +89,6 @@ export function convertProductsToFolders(products: Record[], cu }); } -/** - * Convert files to presentations - */ -interface ConvertFilesResult { - presentations: PlanPresentation[]; - plan: { - id: string; - name: string; - sections: { id: string; name: string; presentations: PlanPresentation[] }[]; - allFiles: ContentFile[]; - }; -} - -export function convertFilesToPresentations(files: ContentFile[], libraryId: string): ConvertFilesResult { - const title = "Library"; - const presentations: PlanPresentation[] = files.map(f => ({ id: f.id, name: f.title, actionType: "play" as const, files: [f] })); - return { - presentations, - plan: { id: libraryId, name: title, sections: [{ id: `section-${libraryId}`, name: title, presentations }], allFiles: files } - }; -} - /** * Convert files to instructions */ diff --git a/content-providers/src/providers/aPlay/APlayProvider.ts b/content-providers/src/providers/aPlay/APlayProvider.ts index ddc48ac..92e3933 100644 --- a/content-providers/src/providers/aPlay/APlayProvider.ts +++ b/content-providers/src/providers/aPlay/APlayProvider.ts @@ -50,7 +50,7 @@ export class APlayProvider implements IProvider { readonly requiresAuth = true; readonly authTypes: AuthType[] = ["oauth_pkce"]; - readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: true }; + readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: true }; async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise { const { segments, depth } = parsePath(path); @@ -110,16 +110,6 @@ export class APlayProvider implements IProvider { return convertMediaToFiles(mediaItems); } - // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise { - // const libraryId = extractLibraryId(path); - // if (!libraryId) return null; - - // const files = await this.getMediaFiles(libraryId, auth) as ContentFile[]; - // if (files.length === 0) return null; - - // return convertFilesToPresentations(files, libraryId).plan; - // } - async getPlaylist(path: string, auth?: ContentProviderAuthData | null, _resolution?: number): Promise { const libraryId = extractLibraryId(path); if (!libraryId) return null; diff --git a/content-providers/src/providers/b1Church/B1ChurchAuth.ts b/content-providers/src/providers/b1Church/B1ChurchAuth.ts index 0b5236e..c71499e 100644 --- a/content-providers/src/providers/b1Church/B1ChurchAuth.ts +++ b/content-providers/src/providers/b1Church/B1ChurchAuth.ts @@ -1,4 +1,4 @@ -import { ContentProviderAuthData, ContentProviderConfig, DeviceAuthorizationResponse, DeviceFlowPollResult } from "../../interfaces"; +import { ContentProviderAuthData, ContentProviderConfig } from "../../interfaces"; async function generateCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder(); @@ -79,41 +79,3 @@ export async function refreshTokenWithSecret(config: ContentProviderConfig, auth return null; } } - -export async function initiateDeviceFlow(config: ContentProviderConfig): Promise { - if (!config.supportsDeviceFlow || !config.deviceAuthEndpoint) return null; - - try { - const response = await fetch(`${config.oauthBase}${config.deviceAuthEndpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: config.clientId, scope: config.scopes.join(" ") }) }); - - if (!response.ok) { - return null; - } - - return await response.json(); - } catch { - return null; - } -} - -export async function pollDeviceFlowToken(config: ContentProviderConfig, deviceCode: string): Promise { - try { - const response = await fetch(`${config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: config.clientId }) }); - - if (response.ok) { - const data = await response.json(); - return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1000), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") }; - } - - const errorData = await response.json(); - switch (errorData.error) { - case "authorization_pending": return { error: "authorization_pending" }; - case "slow_down": return { error: "slow_down", shouldSlowDown: true }; - case "expired_token": return null; - case "access_denied": return null; - default: return null; - } - } catch { - return { error: "network_error" }; - } -} diff --git a/content-providers/src/providers/b1Church/B1ChurchProvider.ts b/content-providers/src/providers/b1Church/B1ChurchProvider.ts index aebea20..e8a42cd 100644 --- a/content-providers/src/providers/b1Church/B1ChurchProvider.ts +++ b/content-providers/src/providers/b1Church/B1ChurchProvider.ts @@ -1,8 +1,8 @@ import { ContentProviderConfig, ContentProviderAuthData, ContentItem, ContentFile, ProviderLogos, Plan, PlanPresentation, Instructions, ProviderCapabilities, DeviceAuthorizationResponse, DeviceFlowPollResult, IProvider, AuthType, InstructionItem, CurrentPlan } from "../../interfaces"; import { parsePath } from "../../pathUtils"; import { navigateToPath } from "../../instructionPathUtils"; -import { instructionsToPlaylist } from "../../FormatConverters"; -import { ApiHelper } from "../../helpers"; +import { ApiHelper, DeviceFlowHelper } from "../../helpers"; +import { instructionsToPlaylist } from "../../utils"; import { getProvider } from "../registry"; import { B1Plan, B1PlanItem } from "./B1ChurchTypes"; import * as B1ChurchAuth from "./B1ChurchAuth"; @@ -32,6 +32,7 @@ function isExternalProviderItem(item: B1PlanItem): boolean { export class B1ChurchProvider implements IProvider { private readonly apiHelper = new ApiHelper(); + private readonly deviceFlowHelper = new DeviceFlowHelper(); // Unified cache for external provider data to avoid duplicate calls across methods private readonly externalContentCache = { @@ -54,7 +55,7 @@ export class B1ChurchProvider implements IProvider { readonly requiresAuth = true; readonly authTypes: AuthType[] = ["oauth_pkce", "device_flow"]; - readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false }; + readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false }; async buildAuthUrl(codeVerifier: string, redirectUri: string, state?: string): Promise<{ url: string; challengeMethod: string }> { return B1ChurchAuth.buildB1AuthUrl(this.config, this.appBase, redirectUri, codeVerifier, state); @@ -73,11 +74,11 @@ export class B1ChurchProvider implements IProvider { } async initiateDeviceFlow(): Promise { - return B1ChurchAuth.initiateDeviceFlow(this.config); + return this.deviceFlowHelper.initiateDeviceFlow(this.config); } async pollDeviceFlowToken(deviceCode: string): Promise { - return B1ChurchAuth.pollDeviceFlowToken(this.config, deviceCode); + return this.deviceFlowHelper.pollDeviceFlowToken(this.config, deviceCode); } async browse(path?: string | null, authData?: ContentProviderAuthData | null): Promise { @@ -188,125 +189,6 @@ export class B1ChurchProvider implements IProvider { return imageMap; } - // async getPresentations(path: string, authData?: ContentProviderAuthData | null): Promise { - // const { segments, depth } = parsePath(path); - - // if (depth < 4 || segments[0] !== "ministries") return null; - - // const ministryId = segments[1]; - // const planId = segments[3]; - // const planTypeId = segments[2]; - - // // Need to fetch plan details to get churchId and contentId - // const plans = await fetchPlans(planTypeId, authData); - // const planFolder = plans.find(p => p.id === planId); - // if (!planFolder) return null; - - // const churchId = planFolder.churchId; - // const venueId = planFolder.contentId; - // const planTitle = planFolder.name || "Plan"; - - // if (!churchId) { - // console.warn("[B1Church getPresentations] planFolder missing churchId:", planFolder.id); - // return null; - // } - - // const pathFn = this.config.endpoints.planItems as (churchId: string, planId: string) => string; - // const planItems = await this.apiRequest(pathFn(churchId, planId), authData); - - // // If no planItems but plan has associated provider content, fetch from that provider - // if ((!planItems || planItems.length === 0) && planFolder.providerId && planFolder.providerPlanId) { - // const externalPlan = await fetchFromProviderProxy( - // "getPresentations", - // ministryId, - // planFolder.providerId, - // planFolder.providerPlanId, - // authData - // ); - // if (externalPlan) { - // return { id: planId, name: planTitle, sections: externalPlan.sections, allFiles: externalPlan.allFiles }; - // } - // } - - // if (!planItems || !Array.isArray(planItems)) return null; - - // const venueFeed = venueId ? await fetchVenueFeed(venueId) : null; - - // const sections: PlanSection[] = []; - // const allFiles: ContentFile[] = []; - - // for (const sectionItem of planItems) { - // const presentations: PlanPresentation[] = []; - - // for (const child of sectionItem.children || []) { - // // Try external provider resolution first (cached, uses providerContentPath) - // if (isExternalProviderItem(child) && child.providerId && child.providerPath) { - // const cacheKey = `${child.providerId}:${child.providerPath}`; - - // let externalPlan = this.externalContentCache.plans.get(cacheKey); - // if (externalPlan === undefined) { - // externalPlan = await fetchFromProviderProxy( - // "getPresentations", - // ministryId, - // child.providerId, - // child.providerPath, - // authData - // ); - // this.externalContentCache.plans.set(cacheKey, externalPlan); - // } - - // if (externalPlan) { - // if (child.providerContentPath) { - // // Fetch instructions to enable path-based lookup (with caching) - // let externalInstructions = this.externalContentCache.instructions.get(cacheKey); - // if (externalInstructions === undefined) { - // externalInstructions = await fetchFromProviderProxy( - // "getInstructions", - // ministryId, - // child.providerId, - // child.providerPath, - // authData - // ); - // this.externalContentCache.instructions.set(cacheKey, externalInstructions); - // } - // // Find and use only the specific presentation - // const matchingPresentation = this.findPresentationByPath(externalPlan, externalInstructions, child.providerContentPath); - // if (matchingPresentation) { - // presentations.push(matchingPresentation); - // if (Array.isArray(matchingPresentation.files)) { - // allFiles.push(...matchingPresentation.files); - // } - // } - // } else { - // // Add all presentations from the external plan - // for (const section of externalPlan.sections || []) { - // if (Array.isArray(section.presentations)) { - // presentations.push(...section.presentations); - // } - // } - // if (Array.isArray(externalPlan.allFiles)) { - // allFiles.push(...externalPlan.allFiles); - // } - // } - // } - // } else { - // // Handle internal items (venue feed sections, link-based files, etc.) - // const presentation = await planItemToPresentation(child, venueFeed); - // if (presentation) { - // presentations.push(presentation); - // allFiles.push(...presentation.files); - // } - // } - // } - - // if (presentations.length > 0 || sectionItem.label) { - // sections.push({ id: sectionItem.id, name: sectionItem.label || "Section", presentations }); - // } - // } - - // return { id: planId, name: planTitle, sections, allFiles }; - // } - private planTypeId: string | null = null; setPairingData(data: unknown) { diff --git a/content-providers/src/providers/bibleProject/BibleProjectProvider.ts b/content-providers/src/providers/bibleProject/BibleProjectProvider.ts index 30ad80f..0976a76 100644 --- a/content-providers/src/providers/bibleProject/BibleProjectProvider.ts +++ b/content-providers/src/providers/bibleProject/BibleProjectProvider.ts @@ -41,7 +41,6 @@ export class BibleProjectProvider implements IProvider { readonly authTypes: AuthType[] = ["none"]; readonly capabilities: ProviderCapabilities = { browse: true, - presentations: true, playlist: true, instructions: true, mediaLicensing: false @@ -107,40 +106,6 @@ export class BibleProjectProvider implements IProvider { return [createFile(video.id, video.title, video.videoUrl, { mediaType: "video", muxPlaybackId: video.muxPlaybackId, seconds: 0 })]; } - // async getPresentations(path: string, _auth?: ContentProviderAuthData | null): Promise { - // const { segments, depth } = parsePath(path); - - // if (depth < 1) return null; - - // const collectionSlug = segments[0]; - // const collection = this.data.collections.find(c => slugify(c.name) === collectionSlug); - // if (!collection) return null; - - // // For collection level (depth 1), create a plan with all videos - // if (depth === 1) { - // const allFiles: ContentFile[] = []; - // const presentations: PlanPresentation[] = collection.videos.map(video => { - // const file: ContentFile = { type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, thumbnail: this.getMuxThumbnail(video.muxPlaybackId), muxPlaybackId: video.muxPlaybackId, seconds: 0 }; - // allFiles.push(file); - // return { id: video.id, name: video.title, actionType: "play" as const, files: [file] }; - // }); - - // return { id: slugify(collection.name), name: collection.name, thumbnail: collection.image || undefined, sections: [{ id: "videos", name: "Videos", presentations }], allFiles }; - // } - - // // For video level (depth 2, single video), create a simple plan - // if (depth === 2) { - // const videoId = segments[1]; - // const video = collection.videos.find(v => v.id === videoId); - // if (!video) return null; - - // const file: ContentFile = { type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, thumbnail: this.getMuxThumbnail(video.muxPlaybackId), muxPlaybackId: video.muxPlaybackId, seconds: 0 }; - // return { id: video.id, name: video.title, thumbnail: this.getMuxThumbnail(video.muxPlaybackId), sections: [{ id: "main", name: "Content", presentations: [{ id: video.id, name: video.title, actionType: "play", files: [file] }] }], allFiles: [file] }; - // } - - // return null; - // } - async getPlaylist(path: string, _auth?: ContentProviderAuthData | null, _resolution?: number): Promise { const { segments, depth } = parsePath(path); diff --git a/content-providers/src/providers/cbn/CbnProvider.ts b/content-providers/src/providers/cbn/CbnProvider.ts index 3d62608..65d890e 100644 --- a/content-providers/src/providers/cbn/CbnProvider.ts +++ b/content-providers/src/providers/cbn/CbnProvider.ts @@ -49,7 +49,7 @@ export class CbnProvider implements IProvider { readonly requiresAuth = true; readonly authTypes: AuthType[] = ["device_flow"]; - readonly capabilities: ProviderCapabilities = { browse: true, presentations: false, playlist: true, instructions: true, mediaLicensing: false }; + readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false }; async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise { const { segments, depth } = parsePath(path); diff --git a/content-providers/src/providers/dropbox/DropboxProvider.ts b/content-providers/src/providers/dropbox/DropboxProvider.ts index ce25906..9e32c21 100644 --- a/content-providers/src/providers/dropbox/DropboxProvider.ts +++ b/content-providers/src/providers/dropbox/DropboxProvider.ts @@ -45,7 +45,6 @@ export class DropboxProvider implements IProvider { readonly authTypes: AuthType[] = ["oauth_pkce"]; readonly capabilities: ProviderCapabilities = { browse: true, - presentations: false, playlist: true, instructions: true, mediaLicensing: false diff --git a/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts b/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts index d61cad3..b852b7c 100644 --- a/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts +++ b/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts @@ -1,5 +1,5 @@ import { Instructions, InstructionItem } from "../../interfaces"; -import { estimateDuration } from "../../durationUtils"; +import { IMAGE_DURATION_SECONDS } from "../../utils"; import { LessonFileJson, LessonFolder, StudyFolder } from "./HighVoltageKidsInterfaces"; /** @@ -22,7 +22,7 @@ export function groupFilesIntoActions(files: LessonFileJson[], thumbnail?: strin const flushGroup = () => { if (currentGroup.length === 0) return; const children: InstructionItem[] = currentGroup.map(file => { - const seconds = estimateDuration(file.mediaType as "video" | "image"); + const seconds = file.mediaType === "image" ? IMAGE_DURATION_SECONDS : 0; return { id: file.id, itemType: "file" as const, @@ -67,7 +67,7 @@ export function groupFilesIntoActions(files: LessonFileJson[], thumbnail?: strin export function buildStudyInstructions(study: StudyFolder): Instructions { const lessonItems: InstructionItem[] = study.lessons.map(lesson => { const fileItems: InstructionItem[] = lesson.files.map(file => { - const seconds = estimateDuration(file.mediaType as "video" | "image"); + const seconds = file.mediaType === "image" ? IMAGE_DURATION_SECONDS : 0; return { id: file.id, itemType: "file", label: file.title, seconds, downloadUrl: file.url, thumbnail: lesson.image }; }); return { id: lesson.id, itemType: "action", label: lesson.name, actionType: "play", children: fileItems }; diff --git a/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts b/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts index 7b8decd..01ab46c 100644 --- a/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts +++ b/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts @@ -39,7 +39,6 @@ export class HighVoltageKidsProvider implements IProvider { readonly authTypes: AuthType[] = ["none"]; readonly capabilities: ProviderCapabilities = { browse: true, - presentations: true, playlist: true, instructions: true, mediaLicensing: false @@ -56,25 +55,6 @@ export class HighVoltageKidsProvider implements IProvider { return []; } - // async getPresentations(path: string, _auth?: ContentProviderAuthData | null): Promise { - // const { segments, depth } = parsePath(path); - - // if (depth < 2) return null; - - // const study = findStudy(this.data, segments[0], segments[1]); - // if (!study) return null; - - // if (depth === 2) return buildStudyPlan(study); - - // if (depth === 3) { - // const lesson = findLesson(this.data, segments[0], segments[1], segments[2]); - // if (!lesson) return null; - // return buildLessonPlan(lesson); - // } - - // return null; - // } - async getPlaylist(path: string, _auth?: ContentProviderAuthData | null, _resolution?: number): Promise { const { segments, depth } = parsePath(path); diff --git a/content-providers/src/providers/index.ts b/content-providers/src/providers/index.ts index 9ae026c..e5471f9 100644 --- a/content-providers/src/providers/index.ts +++ b/content-providers/src/providers/index.ts @@ -1,5 +1,5 @@ -import { ProviderInfo, ProviderLogos } from "../interfaces"; -import { providerRegistry, getProvider, getAllProviders } from "./registry"; +import { IProvider, ProviderInfo, ProviderLogos } from "../interfaces"; +import { registerProvider, getProvider, getAllProviders } from "./registry"; import { APlayProvider } from "./aPlay"; import { B1ChurchProvider } from "./b1Church"; import { DropboxProvider } from "./dropbox"; @@ -78,29 +78,20 @@ const unimplementedProviders: UnimplementedProvider[] = [ // Register built-in providers function initializeProviders() { - const aplay = new APlayProvider(); - const b1Church = new B1ChurchProvider(); - const dropbox = new DropboxProvider(); - const bibleProject = new BibleProjectProvider(); - const highVoltageKids = new HighVoltageKidsProvider(); - const lessonsChurch = new LessonsChurchProvider(); - const lifeChurch = new LifeChurchProvider(); - const planningCenter = new PlanningCenterProvider(); - const signPresenter = new SignPresenterProvider(); - const jesusFilm = new JesusFilmProvider(); - const cbn = new CbnProvider(); - - providerRegistry.set(aplay.id, aplay); - providerRegistry.set(b1Church.id, b1Church); - providerRegistry.set(dropbox.id, dropbox); - providerRegistry.set(bibleProject.id, bibleProject); - providerRegistry.set(highVoltageKids.id, highVoltageKids); - providerRegistry.set(jesusFilm.id, jesusFilm); - providerRegistry.set(lessonsChurch.id, lessonsChurch); - providerRegistry.set(lifeChurch.id, lifeChurch); - providerRegistry.set(planningCenter.id, planningCenter); - providerRegistry.set(signPresenter.id, signPresenter); - providerRegistry.set(cbn.id, cbn); + const providers: IProvider[] = [ + new APlayProvider(), + new B1ChurchProvider(), + new DropboxProvider(), + new BibleProjectProvider(), + new HighVoltageKidsProvider(), + new JesusFilmProvider(), + new LessonsChurchProvider(), + new LifeChurchProvider(), + new PlanningCenterProvider(), + new SignPresenterProvider(), + new CbnProvider() + ]; + for (const provider of providers) registerProvider(provider); } // Initialize on module load @@ -139,7 +130,7 @@ export function getAvailableProviders(ids?: string[]): ProviderInfo[] { implemented: false, requiresAuth: false, authTypes: [], - capabilities: { browse: false, presentations: false, playlist: false, instructions: false, mediaLicensing: false } + capabilities: { browse: false, playlist: false, instructions: false, mediaLicensing: false } })); const all = [...implemented, ...comingSoon]; diff --git a/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts b/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts index 015cc6f..b9fa967 100644 --- a/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts +++ b/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts @@ -47,7 +47,6 @@ export class JesusFilmProvider implements IProvider { readonly authTypes: AuthType[] = ["none"]; readonly capabilities: ProviderCapabilities = { browse: true, - presentations: true, playlist: true, instructions: true, mediaLicensing: false diff --git a/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts b/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts index 08c52ca..45cdd4b 100644 --- a/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts +++ b/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts @@ -1,6 +1,5 @@ import { ContentFile, FeedVenueInterface, Plan, PlanSection, PlanPresentation, InstructionItem, Instructions, VenueActionsResponseInterface } from "../../interfaces"; -import { detectMediaType } from "../../utils"; -import { estimateImageDuration } from "../../durationUtils"; +import { detectMediaType, IMAGE_DURATION_SECONDS } from "../../utils"; import { apiRequest, API_BASE } from "./LessonsChurchApi"; export function normalizeItemType(type?: string): string | undefined { @@ -140,7 +139,7 @@ export function buildSectionActionsMap(actionsResponse: VenueActionsResponseInte for (const section of actionsResponse.sections) { if (section.id && section.actions) { sectionActionsMap.set(section.id, section.actions.map(action => { - const seconds = action.seconds ?? estimateImageDuration(); + const seconds = action.seconds ?? IMAGE_DURATION_SECONDS; const rawActionType = action.actionType?.toLowerCase() || ""; const hasFiles = rawActionType === "play" || rawActionType === "add-on"; const thumbnail = (action.id && actionThumbnailMap.get(action.id)) || lessonImage; diff --git a/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts b/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts index f0f8065..fb4b61a 100644 --- a/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts +++ b/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts @@ -28,7 +28,7 @@ export class LessonsChurchProvider implements IProvider { readonly requiresAuth = false; readonly authTypes: AuthType[] = ["none"]; - readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false }; + readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false }; async getPlaylist(path: string, _auth?: ContentProviderAuthData | null, resolution?: number): Promise { const { segments } = parsePath(path); @@ -177,22 +177,6 @@ export class LessonsChurchProvider implements IProvider { return filtered.map(addOn => createFolder(addOn.id as string, addOn.name as string, `${currentPath}/${addOn.id}`, addOn.image as string | undefined, true)); } - // async getPresentations(path: string, _auth?: ContentProviderAuthData | null): Promise { - // const venueId = getSegment(path, 4); - // if (venueId) { - // const venueData = await apiRequest(`/venues/public/feed/${venueId}`); - // if (!venueData) return null; - // return convertVenueToPlan(venueData); - // } - - // const { segments } = parsePath(path); - // if (segments[0] === "addons" && segments.length === 2) { - // return convertAddOnCategoryToPlan(segments[1]); - // } - - // return null; - // } - async getInstructions(path: string, _auth?: ContentProviderAuthData | null): Promise { const venueId = getSegment(path, 4); if (venueId) { diff --git a/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts b/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts index 5768952..78669b5 100644 --- a/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts +++ b/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts @@ -46,7 +46,6 @@ export class LifeChurchProvider implements IProvider { readonly authTypes: AuthType[] = ["none"]; readonly capabilities: ProviderCapabilities = { browse: true, - presentations: true, playlist: true, instructions: true, mediaLicensing: false diff --git a/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts b/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts index 87a7fad..993a038 100644 --- a/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts +++ b/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts @@ -30,7 +30,7 @@ export class PlanningCenterProvider implements IProvider { readonly requiresAuth = true; readonly authTypes: AuthType[] = ["oauth_pkce"]; - readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false }; + readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false }; async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise { const { segments, depth } = parsePath(path); @@ -102,70 +102,6 @@ export class PlanningCenterProvider implements IProvider { return response.data.map((item) => ({ type: "file" as const, id: item.id, title: item.attributes.title || "", mediaType: "image" as const, url: "" })); } - // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise { - // const { segments, depth } = parsePath(path); - - // if (depth < 3 || segments[0] !== "serviceTypes") return null; - - // const serviceTypeId = segments[1]; - // const planId = segments[2]; - - // const pathFn = this.config.endpoints.planItems as (stId: string, pId: string) => string; - // const response = await this.apiRequest<{ data: PCOPlanItem[] }>( - // `${pathFn(serviceTypeId, planId)}?per_page=100`, - // auth - // ); - - // if (!response?.data) return null; - - // const plans = await this.getPlans(serviceTypeId, `/serviceTypes/${serviceTypeId}`, auth); - // const plan = plans.find(p => p.id === planId); - // const planTitle = plan?.title || "Plan"; - - // const sections: PlanSection[] = []; - // const allFiles: ContentFile[] = []; - // let currentSection: PlanSection | null = null; - - // for (const item of response.data) { - // const itemType = item.attributes.item_type; - - // if (itemType === "header") { - // if (currentSection && currentSection.presentations.length > 0) sections.push(currentSection); - // currentSection = { id: item.id, name: item.attributes.title || "Section", presentations: [] }; - // continue; - // } - - // if (!currentSection) { - // currentSection = { id: `default-${planId}`, name: "Service", presentations: [] }; - // } - - // const presentation = await convertToPresentation(this.config, item, auth); - // if (presentation) { - // currentSection.presentations.push(presentation); - // allFiles.push(...presentation.files); - // } - // } - - // if (currentSection && currentSection.presentations.length > 0) { - // sections.push(currentSection); - // } - - // return { id: planId, name: planTitle as string, sections, allFiles }; - // } - - // async getPlaylist(path: string, auth?: ContentProviderAuthData | null, _resolution?: number): Promise { - // const plan = await this.getPresentations(path, auth); - // if (!plan) return null; - // return plan.allFiles.length > 0 ? plan.allFiles : null; - // } - - // async getInstructions(path: string, auth?: ContentProviderAuthData | null): Promise { - // const plan = await this.getPresentations(path, auth); - // if (!plan) return null; - - // return buildInstructionsFromPlan(plan); - // } - supportsDeviceFlow(): boolean { return false; } diff --git a/content-providers/src/providers/signPresenter/SignPresenterProvider.ts b/content-providers/src/providers/signPresenter/SignPresenterProvider.ts index 00bac83..26f0d3f 100644 --- a/content-providers/src/providers/signPresenter/SignPresenterProvider.ts +++ b/content-providers/src/providers/signPresenter/SignPresenterProvider.ts @@ -27,7 +27,7 @@ export class SignPresenterProvider implements IProvider { readonly requiresAuth = true; readonly authTypes: AuthType[] = ["oauth_pkce", "device_flow"]; - readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false }; + readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false }; async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise { const { segments, depth } = parsePath(path); @@ -116,25 +116,6 @@ export class SignPresenterProvider implements IProvider { return files; } - // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise { - // const { segments, depth } = parsePath(path); - - // if (depth < 2 || segments[0] !== "playlists") return null; - - // const playlistId = segments[1]; - // const files = await this.getMessages(playlistId, auth) as ContentFile[]; - // if (files.length === 0) return null; - - // // Get playlist info for title - // const playlists = await this.getPlaylists(auth); - // const playlist = playlists.find(p => p.id === playlistId); - // const title = playlist?.title || "Playlist"; - // const thumbnail = (playlist as Record | undefined)?.image as string | undefined; - - // const presentations: PlanPresentation[] = files.map(f => ({ id: f.id, name: f.title, actionType: "play" as const, files: [f] })); - // return { id: playlistId, name: title as string, thumbnail, sections: [{ id: `section-${playlistId}`, name: title as string, presentations }], allFiles: files }; - // } - async getPlaylist(path: string, auth?: ContentProviderAuthData | null, _resolution?: number): Promise { const { segments, depth } = parsePath(path); diff --git a/content-providers/src/utils.ts b/content-providers/src/utils.ts index 6e8ac9c..1175d75 100644 --- a/content-providers/src/utils.ts +++ b/content-providers/src/utils.ts @@ -1,4 +1,6 @@ -import { ContentFolder, ContentFile } from "./interfaces"; +import { ContentFolder, ContentFile, Instructions, InstructionItem } from "./interfaces"; + +export const IMAGE_DURATION_SECONDS = 15; export function slugify(text: string): string { return text @@ -32,3 +34,24 @@ export function createFolder(id: string, title: string, path: string, thumbnail? export function createFile(id: string, title: string, url: string, options?: { mediaType?: "video" | "image"; thumbnail?: string; muxPlaybackId?: string; seconds?: number; loop?: boolean; loopVideo?: boolean; streamUrl?: string; }): ContentFile { return { type: "file", id, title, url, mediaType: options?.mediaType ?? detectMediaType(url), thumbnail: options?.thumbnail, muxPlaybackId: options?.muxPlaybackId, seconds: options?.seconds, loop: options?.loop, loopVideo: options?.loopVideo, streamUrl: options?.streamUrl }; } + +function generateId(): string { + return "gen-" + Math.random().toString(36).substring(2, 11); +} + +/** Flatten an Instructions tree into a playlist of downloadable files. */ +export function instructionsToPlaylist(instructions: Instructions): ContentFile[] { + const files: ContentFile[] = []; + + function extractFiles(items: InstructionItem[]) { + for (const item of items) { + if (item.downloadUrl && (item.itemType === "file" || !item.children?.length)) { + files.push({ type: "file", id: item.id || item.relatedId || generateId(), title: item.label || "Untitled", mediaType: detectMediaType(item.downloadUrl), url: item.downloadUrl, downloadUrl: item.downloadUrl, seconds: item.seconds, thumbnail: item.thumbnail }); + } + if (item.children) extractFiles(item.children); + } + } + + extractFiles(instructions.items); + return files; +} diff --git a/content-providers/tests/contentProviders.test.ts b/content-providers/tests/contentProviders.test.ts new file mode 100644 index 0000000..36178aa --- /dev/null +++ b/content-providers/tests/contentProviders.test.ts @@ -0,0 +1,241 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// Import from submodules rather than ../src/index: the index injects a tsup build-time define +// (__PACKAGE_VERSION__) that is absent under tsx. Importing providers/index still runs +// initializeProviders(), so the registry is populated. The build/tsc step verifies the index barrel. +import { getProvider, getAllProviders, getProviderConfig, getAvailableProviders } from "../src/providers/index"; +import { parsePath, getSegment } from "../src/pathUtils"; +import { navigateToPath } from "../src/instructionPathUtils"; +import { detectMediaType, isMediaFile, createFolder, createFile } from "../src/utils"; +// instructionsToPlaylist was relocated from FormatConverters into utils (a general content +// converter used by B1Church and the playground). Behavior must hold across the move. +import { instructionsToPlaylist } from "../src/utils"; +import { getPlaylistWithMeta } from "../playground/formats"; + +const EXPECTED_IDS = "dropbox lessonschurch aplay jesusfilm signpresenter b1church bibleproject planningcenter cbn highvoltagekids lifechurch".split(" "); +const COMING_SOON_IDS = "awana freeshow gocurriculum iteachchurch ministrystuff".split(" "); +const DEVICE_FLOW_IDS = new Set(["signpresenter", "b1church", "cbn"]); + +// --- Registry --- + +test("registry holds exactly the 11 built-in providers", () => { + const all = getAllProviders(); + assert.equal(all.length, EXPECTED_IDS.length); + const ids = all.map(p => p.id).sort(); + assert.deepEqual(ids, [...EXPECTED_IDS].sort()); +}); + +test("getProvider resolves every built-in id and returns null otherwise", () => { + for (const id of EXPECTED_IDS) { + const provider = getProvider(id); + assert.ok(provider, `expected provider for ${id}`); + assert.equal(provider.id, id); + } + assert.equal(getProvider("does-not-exist"), null); +}); + +test("getProviderConfig returns the provider config or null", () => { + const config = getProviderConfig("signpresenter"); + assert.ok(config); + assert.equal(config.id, "signpresenter"); + assert.equal(getProviderConfig("nope"), null); +}); + +test("getAvailableProviders lists implemented + coming-soon and filters by id", () => { + const all = getAvailableProviders(); + const implemented = all.filter(p => p.implemented); + assert.equal(implemented.length, EXPECTED_IDS.length); + for (const id of COMING_SOON_IDS) { + const info = all.find(p => p.id === id); + assert.ok(info, `expected coming-soon entry for ${id}`); + assert.equal(info.implemented, false); + } + const filtered = getAvailableProviders(["dropbox", "awana"]); + assert.equal(filtered.length, 2); +}); + +// --- Provider contract (data-driven over all providers) --- + +test("every provider satisfies the IProvider shape", () => { + for (const provider of getAllProviders()) { + assert.equal(typeof provider.id, "string"); + assert.equal(typeof provider.name, "string"); + assert.equal(provider.config.id, provider.id); + assert.equal(typeof provider.requiresAuth, "boolean"); + assert.ok(Array.isArray(provider.authTypes)); + const caps = provider.capabilities; + for (const key of ["browse", "playlist", "instructions", "mediaLicensing"]) { + assert.equal(typeof (caps as Record)[key], "boolean", `${provider.id}.capabilities.${key}`); + } + assert.equal(typeof provider.browse, "function"); + assert.equal(typeof provider.supportsDeviceFlow, "function"); + assert.equal(typeof provider.supportsDeviceFlow(), "boolean"); + } +}); + +test("supportsDeviceFlow matches the device-flow providers", () => { + for (const provider of getAllProviders()) { + assert.equal(provider.supportsDeviceFlow(), DEVICE_FLOW_IDS.has(provider.id), provider.id); + } +}); + +// --- Auth surface that B1Admin's ContentProviderAuthManager drives generically --- + +test("device-flow providers expose initiate/poll methods (B1Admin contract)", () => { + for (const id of DEVICE_FLOW_IDS) { + const provider = getProvider(id) as any; + assert.equal(typeof provider.initiateDeviceFlow, "function", `${id}.initiateDeviceFlow`); + assert.equal(typeof provider.pollDeviceFlowToken, "function", `${id}.pollDeviceFlowToken`); + } +}); + +test("signpresenter exposes the full PKCE method set (B1Admin contract)", () => { + const provider = getProvider("signpresenter") as any; + for (const method of ["generateCodeVerifier", "buildAuthUrl", "exchangeCodeForTokens"]) { + assert.equal(typeof provider[method], "function", `signpresenter.${method}`); + } +}); + +test("dropbox exposes its RN-specific auth methods and no device flow (FreePlay contract)", () => { + const provider = getProvider("dropbox") as any; + assert.equal(typeof provider.buildAuthUrlFromChallenge, "function"); + assert.equal(typeof provider.exchangeCodeForTokens, "function"); + assert.equal(provider.supportsDeviceFlow(), false); +}); + +test("generateCodeVerifier returns a 64-char PKCE string", () => { + const provider = getProvider("signpresenter") as any; + const verifier: string = provider.generateCodeVerifier(); + assert.equal(verifier.length, 64); + assert.match(verifier, /^[A-Za-z0-9\-._~]{64}$/); +}); + +// --- Offline browse (depth 0 is pure, no network) --- + +test("depth-0 browse returns the provider root folder without network", async () => { + const sign = await getProvider("signpresenter")!.browse(null); + assert.equal(sign.length, 1); + assert.equal(sign[0].type, "folder"); + assert.equal((sign[0] as any).path, "/playlists"); + + const aplay = await getProvider("aplay")!.browse(null); + assert.equal((aplay[0] as any).path, "/modules"); + assert.equal((aplay[0] as any).title, "Modules"); + + const b1 = await getProvider("b1church")!.browse(null); + assert.equal((b1[0] as any).path, "/ministries"); +}); + +// --- pathUtils --- + +test("parsePath splits segments and computes depth", () => { + assert.deepEqual(parsePath("/a/b/c"), { segments: ["a", "b", "c"], depth: 3 }); + assert.deepEqual(parsePath("a/b"), { segments: ["a", "b"], depth: 2 }); + for (const empty of [null, undefined, "", "/"]) { + assert.deepEqual(parsePath(empty as string), { segments: [], depth: 0 }); + } +}); + +test("getSegment returns the indexed segment or null", () => { + assert.equal(getSegment("/a/b/c", 1), "b"); + assert.equal(getSegment("/a", 5), null); +}); + +// --- instructionPathUtils (used by B1Admin + B1App) --- + +test("navigateToPath walks the dot-notation tree", () => { + const tree = { + name: "T", + items: [ + { id: "a", label: "A", children: [{ id: "a0", label: "A0" }, { id: "a1", label: "A1", children: [{ id: "a1x", label: "A1X" }] }] }, + { id: "b", label: "B" } + ] + }; + assert.equal(navigateToPath(tree, "0")?.id, "a"); + assert.equal(navigateToPath(tree, "1")?.id, "b"); + assert.equal(navigateToPath(tree, "0.1")?.id, "a1"); + assert.equal(navigateToPath(tree, "0.1.0")?.id, "a1x"); + assert.equal(navigateToPath(tree, "9"), null); + assert.equal(navigateToPath(tree, ""), null); + assert.equal(navigateToPath(tree, "x"), null); +}); + +// --- utils --- + +test("detectMediaType honours explicit type then extension", () => { + assert.equal(detectMediaType("https://x/clip.mp4"), "video"); + assert.equal(detectMediaType("https://x/pic.jpg"), "image"); + assert.equal(detectMediaType("https://stream.mux.com/abc"), "video"); + assert.equal(detectMediaType("https://x/file", "video"), "video"); + assert.equal(detectMediaType("https://x/file", "image/png"), "image"); + assert.equal(detectMediaType("https://x/no-extension"), "image"); +}); + +test("isMediaFile / createFolder / createFile build the expected shapes", () => { + assert.equal(isMediaFile("a.mp4"), true); + assert.equal(isMediaFile("a.txt"), false); + + const folder = createFolder("f1", "Folder", "/p", "thumb.jpg", true); + assert.equal(folder.type, "folder"); + assert.equal(folder.id, "f1"); + assert.equal(folder.path, "/p"); + assert.equal(folder.isLeaf, true); + + const file = createFile("c1", "Clip", "https://x/clip.mp4"); + assert.equal(file.type, "file"); + assert.equal(file.mediaType, "video"); + assert.equal(file.url, "https://x/clip.mp4"); +}); + +// --- instructionsToPlaylist (relocated function: behavior must be preserved) --- + +test("instructionsToPlaylist flattens downloadable leaves into files", () => { + const instructions = { + name: "Plan", + items: [ + { + id: "sec", + itemType: "section", + label: "Section", + children: [{ id: "f1", itemType: "file", label: "Clip One", downloadUrl: "https://x/v.mp4", seconds: 10 }] + } + ] + }; + const files = instructionsToPlaylist(instructions); + assert.equal(files.length, 1); + assert.equal(files[0].type, "file"); + assert.equal(files[0].id, "f1"); + assert.equal(files[0].url, "https://x/v.mp4"); + assert.equal(files[0].mediaType, "video"); + assert.equal(files[0].seconds, 10); +}); + +// --- Playground shim fallback (regression: Jesus Film / High Voltage Kids return null from +// getPlaylist at collection depth, so the playlist view must derive from instructions) --- + +test("getPlaylistWithMeta derives a playlist from instructions when getPlaylist yields nothing", async () => { + const stub = { + capabilities: { browse: true, playlist: true, instructions: true, mediaLicensing: false }, + getPlaylist: async () => null, + getInstructions: async () => ({ name: "S", items: [{ id: "f1", itemType: "file", label: "Clip", downloadUrl: "https://x/v.mp4", seconds: 5 }] }) + } as any; + + const { data, meta } = await getPlaylistWithMeta(stub, "/collection", null); + assert.equal(data?.length, 1); + assert.equal(data?.[0].url, "https://x/v.mp4"); + assert.equal(meta.isNative, false); + assert.equal(meta.sourceFormat, "instructions"); +}); + +test("getPlaylistWithMeta prefers the native playlist when getPlaylist returns files", async () => { + const stub = { + capabilities: { browse: true, playlist: true, instructions: true, mediaLicensing: false }, + getPlaylist: async () => [{ type: "file", id: "n1", title: "Native", mediaType: "video", url: "https://x/n.mp4" }], + getInstructions: async () => { throw new Error("instructions fallback should not run when playlist is native"); } + } as any; + + const { data, meta } = await getPlaylistWithMeta(stub, "/leaf", null); + assert.equal(data?.[0].id, "n1"); + assert.equal(meta.isNative, true); +}); diff --git a/helpers/CHANGELOG.md b/helpers/CHANGELOG.md index 9770649..5b23d56 100644 --- a/helpers/CHANGELOG.md +++ b/helpers/CHANGELOG.md @@ -1,5 +1,21 @@ # @churchapps/helpers +## 1.8.1 + +### Patch Changes + +- 39e47d3: Image craft + responsive image performance for the website builder. + + apphelper: + + - New pluggable image-optimizer seam (`setImageOptimizer` / `responsiveImgProps` / `optimizedBackgroundImage`). Default is identity, so non-Next hosts (the Vite editor) render plain `` unchanged; B1App registers a Next.js `getImageProps`-backed optimizer to emit `srcset`/`sizes` and WebP/AVIF backgrounds. + - Content images (`image`, `card`, `textWithPhoto`, `logo`) now route their `src` through the seam; `logo` also gains the missing `loading="lazy" decoding="async"`. + - `BoxElement` image backgrounds get optimized loading, a `focalPoint` (`background-position`), and an opt-in tint overlay (`.boxBG:before`, driven by `--overlay-color` / `--overlay-opacity`, default off). + + helpers: + + - Documented the new `box` answer keys (`focalPoint`, `overlayColor`, `backgroundOpacity`) in `ElementTypes`. + ## 1.8.0 ### Minor Changes diff --git a/helpers/package.json b/helpers/package.json index 77ae7f3..74668d8 100644 --- a/helpers/package.json +++ b/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@churchapps/helpers", - "version": "1.8.0", + "version": "1.8.1", "type": "module", "description": "Library of helper functions not specific to any one ChurchApps project or framework.", "main": "dist/index.js", diff --git a/helpers/src/ElementTypes.ts b/helpers/src/ElementTypes.ts index e988408..221026b 100644 --- a/helpers/src/ElementTypes.ts +++ b/helpers/src/ElementTypes.ts @@ -66,6 +66,9 @@ export const ElementTypes: Record = { rounded: { type: "string", enum: ["true", "false"], description: "Checkbox boolean stored as the string \"true\"/\"false\"." }, translucent: { type: "string", enum: ["true", "false"], description: "Checkbox boolean stored as the string \"true\"/\"false\"." }, background: { type: "string", description: "Color hex, CSS var token (var(--light)), or image URL (any value containing \"/\" is treated as a background image)." }, + focalPoint: { type: "string", description: "CSS background-position string (e.g. \"50% 30%\") applied when background is an image; keeps the chosen point in view when the cover-cropped image is scaled. Defaults to center." }, + overlayColor: { type: "string", description: "Color hex of the tint overlay drawn over an image background via the .boxBG:before layer (default #000)." }, + backgroundOpacity: { type: ["number", "string"], description: "Opacity 0-1 of the image background overlay; defaults to 0 (no overlay) for boxes." }, textColor: { type: "string", description: "Color hex or CSS var token; only var(...) values are applied as CSS color by the renderer." }, headingColor: { type: "string", description: "CSS var token; mapped to a headings* CSS class by the renderer." }, linkColor: { type: "string", description: "CSS var token; mapped to a links* CSS class by the renderer." }, diff --git a/yarn.lock b/yarn.lock index 412dd2f..31d8ded 100644 --- a/yarn.lock +++ b/yarn.lock @@ -904,7 +904,7 @@ __metadata: version: 0.0.0-use.local resolution: "@churchapps/apphelper@workspace:apphelper" dependencies: - "@churchapps/helpers": "npm:^1.7.1" + "@churchapps/helpers": "npm:^1.8.1" "@emotion/cache": "npm:^11.14.0" "@emotion/react": "npm:^11.14.0" "@emotion/styled": "npm:^11.14.1" @@ -1013,7 +1013,7 @@ __metadata: languageName: unknown linkType: soft -"@churchapps/helpers@npm:^1.6.0, @churchapps/helpers@npm:^1.7.1, @churchapps/helpers@workspace:helpers": +"@churchapps/helpers@npm:^1.6.0, @churchapps/helpers@npm:^1.8.1, @churchapps/helpers@workspace:helpers": version: 0.0.0-use.local resolution: "@churchapps/helpers@workspace:helpers" dependencies: