PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.0
7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.7.0 All 43 releases
speechkit / src / editor / components / content-id / classic-metabox.js

classic-metabox.js in BeyondWords – AI audio for publishers 7.0.0, at src/editor/components/content-id/classic-metabox.js

758 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* global beyondwordsData, BeyondWords */
2
3 ( function () {
4 'use strict';
5
6 /**
7 * Content statuses that mean "still processing" — keep polling.
8 *
9 * @type {string[]}
10 */
11 const NON_TERMINAL_STATUSES = [ 'draft', 'queued', 'processing' ];
12
13 /**
14 * Poll `fetchStatus` until the content reaches a terminal status.
15 *
16 * Mirror of src/editor/lib/poll-content-status.js, inlined because this
17 * classic-editor script is enqueued raw (not built) and cannot `import`.
18 *
19 * @param {Object} options Options.
20 * @param {Function} options.fetchStatus () => Promise<{ status }>.
21 * @param {Function} [options.onTick] Called per non-terminal poll.
22 * @param {Function} [options.isHidden] () => boolean; skip the call when true.
23 * @param {Function} [options.isCancelled] () => boolean; stop polling when true.
24 * @param {number} [options.intervalMs] Delay between polls.
25 * @param {number} [options.timeoutMs] Overall time budget.
26 * @return {Promise<{status: (string|undefined), timedOut: boolean}>} Result;
27 * a superseded (cancelled) poll stops without resolving.
28 */
29 function pollContentStatus( options ) {
30 const fetchStatus = options.fetchStatus;
31 const onTick = options.onTick;
32 const isHidden = options.isHidden;
33 const isCancelled =
34 options.isCancelled ||
35 function () {
36 return false;
37 };
38 const intervalMs = options.intervalMs || 3000;
39 const timeoutMs = options.timeoutMs || 120000;
40 const start = Date.now();
41 let lastStatus;
42 let hiddenMs = 0;
43
44 return new Promise( function ( resolve ) {
45 function tick() {
46 if ( isCancelled() ) {
47 return;
48 }
49
50 // Budget measures visible time — a hidden tab resumes polling on
51 // return instead of timing out having never fetched.
52 if ( Date.now() - start - hiddenMs >= timeoutMs ) {
53 resolve( { status: lastStatus, timedOut: true } );
54 return;
55 }
56
57 // Skip the upstream call while the tab is hidden — each poll is
58 // an uncached upstream API call.
59 if ( isHidden && isHidden() ) {
60 const hiddenAt = Date.now();
61 setTimeout( function () {
62 hiddenMs += Date.now() - hiddenAt;
63 tick();
64 }, intervalMs );
65 return;
66 }
67
68 fetchStatus()
69 .then( function ( result ) {
70 if ( isCancelled() ) {
71 return;
72 }
73
74 const status = result && result.status;
75 lastStatus = status;
76
77 if ( NON_TERMINAL_STATUSES.indexOf( status ) === -1 ) {
78 resolve( { status, timedOut: false } );
79 return;
80 }
81
82 if ( onTick ) {
83 onTick( status );
84 }
85
86 setTimeout( tick, intervalMs );
87 } )
88 .catch( function () {
89 if ( isCancelled() ) {
90 return;
91 }
92
93 // Transient failure — keep polling until the budget.
94 setTimeout( tick, intervalMs );
95 } );
96 }
97
98 tick();
99 } );
100 }
101
102 /**
103 * Resolve once the BeyondWords player SDK global is available.
104 *
105 * The SDK loads from a deferred <script>; by the time polling finishes it is
106 * almost always ready, but guard with a short bounded wait just in case.
107 *
108 * @return {Promise<void>} Resolves when ready (or after the wait budget).
109 */
110 function whenBeyondWordsReady() {
111 return new Promise( function ( resolve ) {
112 function ready() {
113 return (
114 typeof BeyondWords !== 'undefined' &&
115 typeof BeyondWords.Player === 'function'
116 );
117 }
118
119 if ( ready() ) {
120 resolve();
121 return;
122 }
123
124 let attempts = 0;
125 const id = setInterval( function () {
126 attempts += 1;
127 if ( ready() || attempts > 100 ) {
128 clearInterval( id );
129 resolve();
130 }
131 }, 100 );
132 } );
133 }
134
135 /**
136 * Render the spinner + loading text into the player container.
137 *
138 * @param {HTMLElement} container The player container.
139 */
140 function showMetaboxLoading( container ) {
141 container.innerHTML = '';
142
143 const spinner = document.createElement( 'span' );
144 spinner.className = 'spinner is-active';
145 spinner.style.float = 'none';
146 spinner.style.margin = '0 8px 0 0';
147
148 const text = document.createElement( 'span' );
149 text.className = 'beyondwords-player-loading-text';
150 text.textContent = wp.i18n.__( 'Generating…', 'speechkit' );
151
152 container.appendChild( spinner );
153 container.appendChild( text );
154 }
155
156 /**
157 * Replace the container contents with a terminal message (error / skipped /
158 * timeout), or clear it when there is nothing to say.
159 *
160 * @param {HTMLElement} container The player container.
161 * @param {Object} result The poll result { status, timedOut }.
162 */
163 function showMetaboxMessage( container, result ) {
164 let message = '';
165
166 if ( result.timedOut ) {
167 message = wp.i18n.__(
168 'Generation is taking longer than expected. Refresh to check again.',
169 'speechkit'
170 );
171 } else if ( result.status === 'error' ) {
172 message = wp.i18n.__( 'Generation failed.', 'speechkit' );
173 } else if ( result.status === 'skipped' ) {
174 message = wp.i18n.__( 'No content was generated.', 'speechkit' );
175 }
176
177 container.innerHTML = '';
178
179 if ( message ) {
180 const p = document.createElement( 'p' );
181 p.className = 'beyondwords-player-message';
182 p.textContent = message;
183 container.appendChild( p );
184 }
185 }
186
187 /**
188 * Poll the content status, then embed the player once it is `processed`.
189 *
190 * Constructing the player only on `processed` means a 404 is never
191 * CDN-cached; terminal error / skipped / timeout shows a message instead.
192 *
193 * @param {HTMLElement} container The #beyondwords-metabox-player element.
194 */
195 function initMetaboxPlayer( container ) {
196 if ( ! container ) {
197 return;
198 }
199
200 if (
201 typeof beyondwordsData === 'undefined' ||
202 ! beyondwordsData.root
203 ) {
204 return;
205 }
206
207 const projectId = container.getAttribute( 'data-project-id' );
208 const contentId = container.getAttribute( 'data-content-id' );
209 const previewToken =
210 container.getAttribute( 'data-preview-token' ) || '';
211
212 if ( ! projectId || ! contentId ) {
213 return;
214 }
215
216 // A newer init for this container supersedes any in-flight poll.
217 const token = {};
218 container.beyondwordsPollToken = token;
219 const isCancelled = function () {
220 return container.beyondwordsPollToken !== token;
221 };
222
223 function embedPlayer() {
224 whenBeyondWordsReady().then( function () {
225 if ( isCancelled() ) {
226 return;
227 }
228
229 if (
230 typeof BeyondWords === 'undefined' ||
231 typeof BeyondWords.Player !== 'function'
232 ) {
233 // SDK never emitted on this page or failed its bounded wait; generation
234 // itself succeeded, so clear the spinner. A refresh re-loads the SDK.
235 container.innerHTML = '';
236 return;
237 }
238
239 container.innerHTML = '';
240
241 // The SDK constructor can throw; contain it so a preview-only
242 // error can't surface as a fatal.
243 try {
244 new BeyondWords.Player( {
245 target: container,
246 projectId: Number( projectId ),
247 contentId,
248 previewToken,
249 adverts: [],
250 analyticsConsent: 'none',
251 introsOutros: [],
252 playerStyle: 'small',
253 widgetStyle: 'none',
254 } );
255 } catch {
256 // Preview failed to initialise; saved content is intact.
257 }
258 } );
259 }
260
261 showMetaboxLoading( container );
262
263 pollContentStatus( {
264 fetchStatus() {
265 return fetch(
266 beyondwordsData.root +
267 'beyondwords/v1/projects/' +
268 encodeURIComponent( projectId ) +
269 '/content/' +
270 encodeURIComponent( contentId ),
271 {
272 credentials: 'same-origin',
273 headers: {
274 'X-WP-Nonce': beyondwordsData.nonce,
275 },
276 }
277 )
278 .then( function ( response ) {
279 if ( ! response.ok ) {
280 throw new Error( response.statusText );
281 }
282 return response.json();
283 } )
284 .then( function ( data ) {
285 return { status: data.status };
286 } );
287 },
288 isHidden() {
289 return document.hidden;
290 },
291 isCancelled,
292 } ).then( function ( result ) {
293 if ( isCancelled() ) {
294 return;
295 }
296
297 if ( ! result.timedOut && result.status === 'processed' ) {
298 embedPlayer();
299 } else {
300 showMetaboxMessage( container, result );
301 }
302 } );
303 }
304
305 /**
306 * Poll the post's own meta until deferred generation writes a content ID.
307 *
308 * See doc/async-rest-migration.md.
309 *
310 * @param {HTMLElement} container The #beyondwords-metabox-player element.
311 */
312 function awaitContentId( container ) {
313 if (
314 typeof beyondwordsData === 'undefined' ||
315 ! beyondwordsData.root
316 ) {
317 return;
318 }
319
320 const postId = container.getAttribute( 'data-post-id' );
321
322 if ( ! postId ) {
323 return;
324 }
325
326 // A newer init for this container supersedes any in-flight poll.
327 const token = {};
328 container.beyondwordsPollToken = token;
329 const isCancelled = function () {
330 return container.beyondwordsPollToken !== token;
331 };
332
333 const restBase = getRestBase();
334 let meta = {};
335
336 showMetaboxLoading( container );
337
338 pollContentStatus( {
339 fetchStatus() {
340 return fetch(
341 beyondwordsData.root +
342 'wp/v2/' +
343 restBase +
344 '/' +
345 encodeURIComponent( postId ) +
346 '?context=edit',
347 {
348 credentials: 'same-origin',
349 headers: {
350 'X-WP-Nonce': beyondwordsData.nonce,
351 },
352 }
353 )
354 .then( function ( response ) {
355 if ( ! response.ok ) {
356 throw new Error( response.statusText );
357 }
358 return response.json();
359 } )
360 .then( function ( data ) {
361 meta = ( data && data.meta ) || {};
362
363 // 'queued' is non-terminal, so polling continues.
364 return {
365 status: meta.beyondwords_content_id
366 ? 'found'
367 : 'queued',
368 };
369 } );
370 },
371 isHidden() {
372 return document.hidden;
373 },
374 isCancelled,
375 } ).then( function ( result ) {
376 if ( isCancelled() ) {
377 return;
378 }
379
380 if ( result.timedOut || result.status !== 'found' ) {
381 showMetaboxMessage( container, result );
382 return;
383 }
384
385 container.setAttribute(
386 'data-content-id',
387 meta.beyondwords_content_id
388 );
389 container.setAttribute(
390 'data-preview-token',
391 meta.beyondwords_preview_token || ''
392 );
393 if ( meta.beyondwords_project_id ) {
394 container.setAttribute(
395 'data-project-id',
396 meta.beyondwords_project_id
397 );
398 }
399 container.removeAttribute( 'data-await-content' );
400
401 // Poll for `processed`, else the player 404s and the CDN caches it.
402 initMetaboxPlayer( container );
403 } );
404 }
405
406 /**
407 * Remove any existing notice from the metabox.
408 */
409 function clearNotice() {
410 const container = document.getElementById(
411 'beyondwords-metabox-content-id'
412 );
413 if ( ! container ) {
414 return;
415 }
416
417 const existing = container.querySelector(
418 '.beyondwords-content-id-notice'
419 );
420 if ( existing ) {
421 existing.remove();
422 }
423 }
424
425 /**
426 * Show a dismissible notice inside the metabox.
427 *
428 * @param {string} message Notice text.
429 * @param {string} type 'success' or 'error'.
430 */
431 function showNotice( message, type ) {
432 clearNotice();
433
434 const container = document.getElementById(
435 'beyondwords-metabox-content-id'
436 );
437 if ( ! container ) {
438 return;
439 }
440
441 const notice = document.createElement( 'div' );
442 notice.className =
443 'beyondwords-content-id-notice ' +
444 ( type === 'error' ? 'beyondwords-error' : 'beyondwords-success' );
445 const p = document.createElement( 'p' );
446 p.textContent = message;
447 notice.appendChild( p );
448
449 container.appendChild( notice );
450 }
451
452 /**
453 * Build the REST base for the current post type.
454 *
455 * Prefers the data-rest-base attribute set by PHP; falls back to a
456 * simple mapping for core post types.
457 *
458 * @param {HTMLElement} button The fetch button element.
459 * @return {string} The REST base slug.
460 */
461 function getRestBase( button ) {
462 const base = button && button.getAttribute( 'data-rest-base' );
463 if ( base ) {
464 return base;
465 }
466
467 const postTypeInput = document.getElementById( 'post_type' );
468 const postType = ( postTypeInput && postTypeInput.value ) || 'post';
469
470 if ( postType === 'post' ) {
471 return 'posts';
472 }
473 if ( postType === 'page' ) {
474 return 'pages';
475 }
476 return postType;
477 }
478
479 /**
480 * Save meta to the post via the WP REST API.
481 *
482 * @param {string} restBase REST base for the post type.
483 * @param {string} postId The post ID.
484 * @param {Object} meta Meta key/value pairs.
485 * @return {Promise} Resolves with the fetch Response.
486 */
487 function savePostMeta( restBase, postId, meta ) {
488 return fetch(
489 beyondwordsData.root + 'wp/v2/' + restBase + '/' + postId,
490 {
491 method: 'POST',
492 credentials: 'same-origin',
493 headers: {
494 'Content-Type': 'application/json',
495 'X-WP-Nonce': beyondwordsData.nonce,
496 },
497 body: JSON.stringify( { meta } ),
498 }
499 ).then( function ( response ) {
500 if ( ! response.ok ) {
501 throw new Error( 'Failed to save' );
502 }
503 return response;
504 } );
505 }
506
507 /**
508 * Update visible metabox form controls to reflect the fetched data.
509 *
510 * @param {Object} meta The meta values that were saved.
511 */
512 function updateMetaboxUI( meta ) {
513 const contentIdInput = document.getElementById(
514 'beyondwords_content_id'
515 );
516 if ( contentIdInput && meta.beyondwords_content_id !== undefined ) {
517 contentIdInput.value = meta.beyondwords_content_id;
518 }
519
520 const generateAudioCheckbox = document.getElementById(
521 'beyondwords_generate_audio'
522 );
523 if ( generateAudioCheckbox ) {
524 generateAudioCheckbox.checked =
525 meta.beyondwords_generate_audio === '1';
526 }
527
528 // Match option values directly so a malformed API value can't throw.
529 const languageSelect = document.getElementById(
530 'beyondwords_language_code'
531 );
532 if ( languageSelect && meta.beyondwords_language_code ) {
533 const hasOption = [ ...languageSelect.options ].some(
534 ( option ) => option.value === meta.beyondwords_language_code
535 );
536 if ( hasOption ) {
537 languageSelect.value = meta.beyondwords_language_code;
538 }
539 }
540
541 // Clear any previous error notices rendered by PHP.
542 const errorContainer = document.getElementById(
543 'beyondwords-metabox-errors'
544 );
545 if ( errorContainer ) {
546 errorContainer.remove();
547 }
548
549 // Route through initMetaboxPlayer so still-processing content is polled
550 // until `processed`, not embedded straight away (which would CDN-cache a 404).
551 if ( meta.beyondwords_content_id && meta.beyondwords_project_id ) {
552 let playerContainer = document.getElementById(
553 'beyondwords-metabox-player'
554 );
555
556 if ( ! playerContainer ) {
557 // Create the container if the post had no content before.
558 const metabox = document.getElementById( 'beyondwords' );
559 const inner = metabox && metabox.querySelector( '.inside' );
560 if ( inner ) {
561 playerContainer = document.createElement( 'div' );
562 playerContainer.id = 'beyondwords-metabox-player';
563 playerContainer.style.margin = '13px 0';
564 inner.insertBefore( playerContainer, inner.firstChild );
565 }
566 }
567
568 if ( playerContainer ) {
569 playerContainer.setAttribute(
570 'data-project-id',
571 meta.beyondwords_project_id
572 );
573 playerContainer.setAttribute(
574 'data-content-id',
575 meta.beyondwords_content_id
576 );
577 playerContainer.setAttribute(
578 'data-preview-token',
579 meta.beyondwords_preview_token || ''
580 );
581
582 initMetaboxPlayer( playerContainer );
583 }
584 }
585 }
586
587 /**
588 * Set the loading state of the fetch button and input.
589 *
590 * @param {HTMLElement} button The fetch button.
591 * @param {HTMLElement} input The content ID input.
592 * @param {boolean} loading Whether we are loading.
593 * @param {HTMLElement=} spinner The spinner element (removed when not loading).
594 * @return {HTMLElement|null} The spinner element when loading starts, null otherwise.
595 */
596 function setLoading( button, input, loading, spinner ) {
597 button.disabled = loading;
598 input.readOnly = loading;
599
600 if ( loading && ! spinner ) {
601 const s = document.createElement( 'span' );
602 s.className = 'spinner is-active';
603 // Left of the button, so the right-aligned button doesn't shift.
604 button.parentNode.insertBefore( s, button );
605 return s;
606 }
607
608 if ( ! loading && spinner && spinner.parentNode ) {
609 spinner.remove();
610 }
611
612 return null;
613 }
614
615 function handleFetchClick( event ) {
616 const button = event.target.closest(
617 '#beyondwords__content-id--fetch'
618 );
619 if ( ! button ) {
620 return;
621 }
622
623 const input = document.getElementById( 'beyondwords_content_id' );
624 const contentId = input ? input.value.trim() : '';
625 const projectId = button.getAttribute( 'data-project-id' );
626 const postIdInput = document.getElementById( 'post_ID' );
627 const postId = postIdInput ? postIdInput.value : '';
628
629 if ( ! contentId || ! projectId || ! postId ) {
630 return;
631 }
632
633 if (
634 typeof beyondwordsData === 'undefined' ||
635 ! beyondwordsData.root
636 ) {
637 return;
638 }
639
640 const restBase = getRestBase( button );
641 clearNotice();
642 let spinner = setLoading( button, input, true );
643
644 fetch(
645 beyondwordsData.root +
646 'beyondwords/v1/projects/' +
647 encodeURIComponent( projectId ) +
648 '/content/' +
649 encodeURIComponent( contentId ),
650 {
651 method: 'GET',
652 credentials: 'same-origin',
653 headers: {
654 'X-WP-Nonce': beyondwordsData.nonce,
655 },
656 }
657 )
658 .then( function ( response ) {
659 if ( ! response.ok ) {
660 throw new Error( response.statusText );
661 }
662 return response.json();
663 } )
664 .then( function ( data ) {
665 const meta = {
666 beyondwords_generate_audio: '0',
667 beyondwords_project_id: String( data.project_id || '' ),
668 beyondwords_content_id: data.id || '',
669 beyondwords_preview_token: data.preview_token || '',
670 beyondwords_language_code: data.language || '',
671 beyondwords_body_voice_id: String(
672 data.body_voice_id || ''
673 ),
674 beyondwords_delete_content: '',
675 beyondwords_error_message: '',
676 };
677
678 return savePostMeta( restBase, postId, meta ).then(
679 function () {
680 // UI refresh is best-effort — contain failures so they can't
681 // reject the chain into the .catch and overwrite the saved meta.
682 try {
683 updateMetaboxUI( meta );
684 } catch {
685 // UI refresh failed; the saved content is intact.
686 }
687 showNotice(
688 wp.i18n.__(
689 'Content fetched and saved successfully.',
690 'speechkit'
691 ),
692 'success'
693 );
694 }
695 );
696 } )
697 .catch( function ( fetchError ) {
698 if ( fetchError.message === 'Failed to save' ) {
699 showNotice(
700 wp.i18n.__(
701 'Failed to save fetched content.',
702 'speechkit'
703 ),
704 'error'
705 );
706 return;
707 }
708
709 const errorMeta = {
710 beyondwords_content_id: contentId,
711 beyondwords_error_message: wp.i18n.__(
712 'Failed to fetch content. Please check the Content ID.',
713 'speechkit'
714 ),
715 };
716
717 savePostMeta( restBase, postId, errorMeta )
718 .catch( function () {
719 // Ignore save failure — still show the notice.
720 } )
721 .then( function () {
722 showNotice(
723 wp.i18n.__(
724 'Failed to fetch content. Please check the Content ID.',
725 'speechkit'
726 ),
727 'error'
728 );
729 } );
730 } )
731 .finally( function () {
732 spinner = setLoading( button, input, false, spinner );
733 } );
734 }
735
736 function init() {
737 document.body.addEventListener( 'click', handleFetchClick );
738
739 // Embed the player preview once its content has finished processing.
740 const playerContainer = document.getElementById(
741 'beyondwords-metabox-player'
742 );
743 if ( playerContainer ) {
744 if ( playerContainer.getAttribute( 'data-content-id' ) ) {
745 initMetaboxPlayer( playerContainer );
746 } else if ( playerContainer.getAttribute( 'data-await-content' ) ) {
747 awaitContentId( playerContainer );
748 }
749 }
750 }
751
752 if ( document.readyState !== 'loading' ) {
753 init();
754 } else {
755 document.addEventListener( 'DOMContentLoaded', init );
756 }
757 } )();
758