top of page
20220824_125638.jpg

Welcome to

First United
Methodist Church

Browse our events and announcements

Sunday Worship

We offer in person services at 8:00am, 9:00am & 10:30am. Our 9:00 and 10:30am services are streamed via on Facebook, YouTube, and Twitch live every Sunday. Past recordings are available on all streaming platforms.

  • Facebook
  • YouTube
  • Instagram

import { fetch } from "wix-fetch"; import { secrets } from "wix-secrets-backend.v2"; import { elevate } from "wix-auth"; import { ANNOUNCEMENT_SYNC_CONFIG as CONFIG, SECRET_NAMES } from "./announcement-config.js"; import { buildDriveItemDetailsUrl, mapWithConcurrency, normalizeMimeType, safeErrorMessage, sortOrderFromFilename, unwrapSecretValue } from "./announcement-helpers.js"; const getSecretValue = elevate(secrets.getSecretValue); const MEDIA_MARKER = "[m365-announcement-sync]"; const MAX_YODECK_ATTEMPTS = 6; export async function syncYodeckAnnouncementSlides({ accessToken, graphImages }) { const settings = CONFIG.yodeck; if (!settings?.enabled) { return { status: "DISABLED" }; } const authorization = await getYodeckAuthorization(); const existingMedia = await listAllResources("/media", authorization); const managedMedia = existingMedia.filter(media => Boolean(readMediaMarker(media.description)) ); const mediaByGraphId = new Map(); for (const media of managedMedia) { const marker = readMediaMarker(media.description); if (marker?.graphItemId) { mediaByGraphId.set(marker.graphItemId, media); } } const orderedImages = [...graphImages].sort((left, right) => { const orderDifference = sortOrderFromFilename(left.name) - sortOrderFromFilename(right.name); if (orderDifference !== 0) { return orderDifference; } return String(left.name).localeCompare(String(right.name), undefined, { numeric: true, sensitivity: "base" }); }); // Yodeck's standard API rate limit is intentionally handled one file at a // time. yodeckRequest() also honors Retry-After when the service throttles. const results = await mapWithConcurrency( orderedImages, settings.uploadConcurrency || 1, async graphItem => { try { return await synchronizeYodeckMedia({ graphItem, existingMedia: mediaByGraphId.get(graphItem.id), accessToken, authorization }); } catch (error) { const message = safeErrorMessage(error); console.error( `Yodeck synchronization failed for ${graphItem.name}:`, message ); return { status: "ERROR", graphItemId: graphItem.id, fileName: graphItem.name, error: message }; } } ); const errors = results.filter(result => result.status === "ERROR"); // Preserve the previous working playlist if even one image failed. if (errors.length > 0) { throw new Error( `${errors.length} Yodeck image upload(s) failed. ` + `The playlist was not changed. First error: ${errors[0].error}` ); } const playlistItems = results.map((result, index) => ({ id: result.mediaId, priority: index + 1, duration: settings.defaultDuration, name: result.mediaName, type: "media" })); const playlist = await createOrUpdatePlaylist( playlistItems, authorization ); return { status: "READY", playlistId: playlist.id, playlistName: settings.playlistName, playlistItems: playlistItems.length, created: results.filter(result => result.status === "CREATED").length, updated: results.filter(result => result.status === "UPDATED").length, unchanged: results.filter(result => result.status === "UNCHANGED").length }; } async function getYodeckAuthorization() { const secretResult = await getSecretValue( SECRET_NAMES.yodeckApiToken ); let token = unwrapSecretValue( secretResult, SECRET_NAMES.yodeckApiToken ).trim(); // Accept the token alone, "label:token", or the complete "Token ..." value. token = token.replace(/^Token\s+/i, ""); const credential = token.includes(":") ? token : `${CONFIG.yodeck.tokenLabel}:${token}`; return `Token ${credential}`; } async function synchronizeYodeckMedia({ graphItem, existingMedia, accessToken, authorization }) { const version = graphItem.eTag || graphItem.lastModifiedDateTime || ""; const existingMarker = readMediaMarker(existingMedia?.description); const uploadRequired = !existingMedia || existingMarker?.version !== version; const created = !existingMedia; if (!uploadRequired) { return { status: "UNCHANGED", graphItemId: graphItem.id, mediaId: existingMedia.id, mediaName: existingMedia.name || graphItem.name }; } // Yodeck can import an image directly from a URL. Using Microsoft's // short-lived download URL avoids an S3 PUT that Wix sends as a chunked // request (which S3 rejects with HTTP 501). const downloadUrl = await getGraphImageDownloadUrl( graphItem, accessToken ); const media = created ? await yodeckRequest( "/media", authorization, { method: "POST", body: buildCreateMediaPayload( graphItem, version, downloadUrl ) } ) : await yodeckRequest( `/media/${existingMedia.id}`, authorization, { method: "PATCH", body: buildMetadataPayload( graphItem, version, downloadUrl ) } ); if (!media?.id) { throw new Error( `Yodeck did not return a media ID for ${graphItem.name}.` ); } return { status: created ? "CREATED" : "UPDATED", graphItemId: graphItem.id, mediaId: media.id, mediaName: media.name || graphItem.name }; } function buildCreateMediaPayload(graphItem, version, downloadUrl) { return { name: graphItem.name, media_origin: { type: "image", source: "url", format: null }, ...buildMetadataPayload(graphItem, version, downloadUrl) }; } function buildMetadataPayload(graphItem, version, downloadUrl) { return { name: graphItem.name, description: MEDIA_MARKER + JSON.stringify({ graphItemId: graphItem.id, version }), default_duration: CONFIG.yodeck.defaultDuration, arguments: { download_from_url: downloadUrl } }; } function readMediaMarker(description) { const text = String(description || ""); if (!text.startsWith(MEDIA_MARKER)) { return null; } try { return JSON.parse(text.slice(MEDIA_MARKER.length)); } catch (error) { return null; } } async function getGraphImageDownloadUrl(graphItem, accessToken) { const driveId = graphItem?.parentReference?.driveId || CONFIG.source.driveId; if (!driveId) { throw new Error( `Microsoft did not return a drive ID for ${graphItem.name}.` ); } const graphDetails = await graphGet( buildDriveItemDetailsUrl(driveId, graphItem.id), accessToken ); const downloadUrl = graphDetails["@microsoft.graph.downloadUrl"]; if (!downloadUrl) { throw new Error( `Microsoft did not return a download URL for ${graphItem.name}.` ); } return downloadUrl; } async function createOrUpdatePlaylist(playlistItems, authorization) { const playlists = await listAllResources( "/playlists", authorization ); const requestedName = CONFIG.yodeck.playlistName; const existingPlaylist = playlists.find( playlist => String(playlist.name).toLowerCase() === requestedName.toLowerCase() ); const payload = { name: requestedName, description: "Automatically synchronized from the Microsoft 365 " + "Announcement Slides folder.", items: playlistItems, playback_options: { synced_playback: false, random_playback: false } }; if (existingPlaylist) { return yodeckRequest( `/playlists/${existingPlaylist.id}`, authorization, { method: "PATCH", body: payload } ); } return yodeckRequest( "/playlists", authorization, { method: "POST", body: { ...payload, workspace: null } } ); } async function listAllResources(path, authorization) { const resources = []; const limit = 100; let offset = 0; while (true) { const separator = path.includes("?") ? "&" : "?"; const payload = await yodeckRequest( `${path}${separator}limit=${limit}&offset=${offset}`, authorization ); const page = extractResourcePage(payload); if (!Array.isArray(page)) { throw new Error(`Yodeck returned an invalid list for ${path}.`); } resources.push(...page); const total = Number( payload?.count ?? payload?.total_count ?? payload?.total ); if ( page.length < limit || (Number.isFinite(total) && resources.length >= total) ) { break; } offset += limit; if (offset > 10000) { throw new Error( `Yodeck pagination exceeded the safety limit for ${path}.` ); } } return resources; } function extractResourcePage(payload) { if (Array.isArray(payload)) { return payload; } return ( payload?.results || payload?.items || payload?.value || payload?.objects || payload?.data || [] ); } async function yodeckRequest(path, authorization, options = {}) { const url = /^https?:\/\//i.test(path) ? path : `${CONFIG.yodeck.apiBaseUrl}${path}`; for (let attempt = 1; attempt <= MAX_YODECK_ATTEMPTS; attempt += 1) { const requestOptions = { method: options.method || "GET", headers: { Authorization: authorization, Accept: "application/json" } }; if (options.body !== undefined) { requestOptions.headers["Content-Type"] = "application/json"; requestOptions.body = JSON.stringify(options.body); } const response = await fetch(url, requestOptions); const rawBody = await response.text(); let payload = null; if (rawBody) { try { payload = JSON.parse(rawBody); } catch (error) { payload = { rawBody }; } } if (response.status === 429 && attempt < MAX_YODECK_ATTEMPTS) { const retryAfter = readRetryAfterSeconds(response, payload); console.warn( `Yodeck rate limit reached; retrying ${path} in ` + `${retryAfter} second(s).` ); await wait(retryAfter * 1000); continue; } if (!response.ok) { const message = payload?.error?.message || payload?.detail || payload?.message || rawBody || "Unknown Yodeck error"; const details = payload?.error?.details; const detailText = details ? ` Details: ${JSON.stringify(details)}` : ""; throw new Error( `Yodeck request failed (${response.status}): ` + `${String(message)}${detailText}` ); } return payload; } throw new Error(`Yodeck request did not complete for ${path}.`); } function readRetryAfterSeconds(response, payload) { let headerValue = ""; try { headerValue = response.headers?.get("Retry-After") || ""; } catch (error) { // Fall through to the message parser and default value. } let seconds = Number(headerValue); if (!Number.isFinite(seconds) || seconds <= 0) { const message = payload?.error?.message || payload?.detail || payload?.message || ""; const match = String(message).match(/(?:in|after)\s+(\d+)\s+seconds?/i); seconds = match ? Number(match[1]) : 12; } return Math.min(Math.max(Math.ceil(seconds), 1), 60); } function wait(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); } async function graphGet(url, accessToken) { const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" } }); let payload; try { payload = await response.json(); } catch (error) { throw new Error( "Microsoft Graph returned a response that was not valid JSON." ); } if (!response.ok) { throw new Error( `Microsoft Graph request failed (${response.status}): ` + `${payload?.error?.message || "Unknown Graph error"}` ); } return payload; } async function readBinaryResponse(response) { if (typeof response.arrayBuffer === "function") { return response.arrayBuffer(); } if (typeof response.buffer === "function") { return response.buffer(); } throw new Error( "This Wix runtime cannot read binary HTTP responses." ); }

White Typography September News Email Header_edited.jpg

Stay up to date,
sign up here

Contact Us

Church Office 

Mon-Thur 9 am-1 pm

Closed Fridays

​

Office Phone & Fax

517.546.2730    517.546.5076 fax

​

Office Email

office@howellfumc.com

Location

1230 Bower St. 

Howell, MI 48843

Howell First United Methodist Church

1230 Bower St, Howell, MI 48843 

(517) 546-2730

office@howellfumc.com

Office Hours: Mon-Thu 9am-1pm

FUMC Howell Circle Cross_edited_edited.p

Service Times 

Worship

8 am (Chapel)

9 am (Fellowship Hall)

​

10:30 am (Sanctuary)

  • Facebook
  • YouTube
  • Instagram

© 2025 First United Methodist Church

bottom of page