Setting the file. One moment.
Blog Post Media · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
223
async function postRecord
— line 223
This file
Number 41.129
Position 129 of 174
Type JavaScript
Size 29 KB
Lines 602 lib/ blog-post-media.js
JavaScript · 602 lines · 29 KB
// it — no draft, no publish, no crosswalk row — until every medium is confirmed READY. Do NOT
14 // add a partial-post fallback (import the text, drop a failed cover, keep a source URL for a
15 // failed inline image): that option was offered and not approved.
16 //
17 // Why the post waits for READY although a PENDING file id is already referenceable in blog
18 // content: that fact (see wix-writers.js, media import section) is true, but the policy needs
19 // failure discovery BEFORE the post ships, and only a settled file can say it failed.
20 //
21 // Caller shape (the generated blog importer):
22 //
23 // const richContent = await convertHtmlToRichContent(wix, post.contentHtml); // no media option
24 // const media = await importAndConfirmPostMedia(wix, { post, richContent, state, fetchFeaturedMedia });
25 // if (!media.allReady) continue; // nothing written; the state file already says why
26 // applyPostMedia(richContent, media); // rewrites the SAME document: no second conversion
27 // // createDraftPost(wix, { ..., richContent, heroImageId: media.heroImageId }), crosswalk row, then
28 // await state.markResolved(post.id, { targetPostId, runId, resumed: media.resumed });
29 //
30 // and on the crosswalk-gate path (the post is already written):
31 //
32 // await state.closeIfCrosswalked(post.id, { targetPostId, runId });
33 //
34 // Both close the record AND own the recovery-log entry for a post that had been pending, so
35 // no caller ordering can lose it (see "Recovery-log entry" below).
36 //
37 // Inline identity. rewriteInlineMedia replaces an image only when its `imageData.image.src.url`
38 // EXACTLY equals a map key. So keys are collected from the converted Ricos document, never from
39 // raw HTML: the collection key and the rewrite key are the same string by construction, with no
40 // canonicalizer to drift from the converter's own entity decoding and encoding.
41 //
42 // Out of scope here: images the converter does not emit as a Ricos image node (lazy-load markup
43 // with the URL only in data-src/srcset), and inline video/audio.
44
45 const crypto = require ( 'node:crypto' );
46 const fs = require ( 'node:fs/promises' );
47 const path = require ( 'node:path' );
48 const writers = require ( './wix-writers.js' );
49 const { resolveImageUrl } = require ( '../../rp-source-wordpress/lib/wp-image-url.js' );
50
51 const SCHEMA_VERSION = 1 ;
52 const STATE_FILE = 'pending-blog-media.ndjson' ;
53 const OUTCOMES = [ 'imported' , 'ready' , 'fetch-failed' , 'unresolvable-url' , 'import-rejected' , 'failed' , 'not-ready' , 'status-unknown' ];
54 // A medium whose last row carries a file id in one of these outcomes is resumed, never
55 // re-imported: `ready` is reused as-is, the others are polled again.
56 const RESUMABLE = new Set ([ 'imported' , 'ready' , 'not-ready' , 'status-unknown' ]);
57
58 function postStableKey ( postOrId ) {
59 const id = postOrId && typeof postOrId === 'object' ? postOrId.id : postOrId;
60 if (id === undefined || id === null || id === '' ) throw new Error ( 'blog-post-media: a post id is required' );
61 return `wordpress:post:${ String ( id ) }` ;
62 }
63
64 function postTitle ( post ) {
65 const t = post && post.title;
66 if ( typeof t === 'string' ) return t;
67 if (t && typeof t.rendered === 'string' ) return t.rendered;
68 return null ;
69 }
70
71 // --- collection --------------------------------------------------------------------------
72
73 function collectRewriteKeys ( richContent ) {
74 const keys = [];
75 const visit = ( node ) => {
76 if ( ! node || typeof node !== 'object' ) return ;
77 const url = node.imageData && node.imageData.image && node.imageData.image.src && node.imageData.image.src.url;
78 if ( typeof url === 'string' && url !== '' ) keys. push (url);
79 (node.nodes || []). forEach (visit);
80 };
81 ((richContent && richContent.nodes) || []). forEach (visit);
82 return keys;
83 }
84
85 // The import URL for one rewrite key: WHATWG resolution against the post's permalink, so
86 // relative and protocol-relative sources become absolute. Anything that is not http(s) after
87 // that (data:, blob:, a relative key with no permalink, malformed) is unresolvable.
88 function resolveImportUrl ( key , base ) {
89 let url;
90 try {
91 url = base ? new URL (key, base) : new URL (key);
92 } catch {
93 return null ;
94 }
95 return url.protocol === 'http:' || url.protocol === 'https:' ? url. toString () : null ;
96 }
97
98 // What this post needs imported. Inline media are deduped by import URL: every rewrite key that
99 // resolves to the same URL (the same string twice, or a relative and an absolute form) shares one
100 // import and one file id. An unresolvable key is its own medium, already failed.
101 function collectPostMedia ({ post , richContent }) {
102 const media = [];
103 const featuredId = post && post.featured_media;
104 if (featuredId !== undefined && featuredId !== null && String (featuredId) !== '' && String (featuredId) !== '0' ) {
105 media. push ({ mediaKey: `featured:${ String ( featuredId ) }` , kind: 'featured' , wpMediaId: String (featuredId), sourceUrl: null , rewriteKeys: [] });
106 }
107 const byMediaKey = new Map ();
108 for ( const key of collectRewriteKeys (richContent)) {
109 const importUrl = resolveImportUrl (key, post && post.link);
110 const mediaKey = `inline:${ importUrl || key }` ;
111 let item = byMediaKey. get (mediaKey);
112 if ( ! item) {
113 item = { mediaKey, kind: 'inline' , wpMediaId: null , sourceUrl: importUrl, rewriteKeys: [] };
114 if ( ! importUrl) item.unresolvable = true ;
115 byMediaKey. set (mediaKey, item);
116 media. push (item);
117 }
118 if ( ! item.rewriteKeys. includes (key)) item.rewriteKeys. push (key);
119 }
120 return media;
121 }
122
123 // --- pending-media state (append-only, authoritative recovery record) -------------------
124 //
125 // migrations/<project>/state/crosswalk/pending-blog-media.ndjson. One row per state change; for a
126 // (sourceStableKey, mediaKey) the last row wins. A `resolved` row closes the post and later
127 // media rows for it are ignored. Crash discipline matches the pending-invoice file: split on the
128 // byte; a torn tail is truncated on disk before the next append; a complete unterminated last
129 // row is kept and terminated first; an unparsable line anywhere else throws as corruption.
130 //
131 // Recovery-log entry. A post that a rerun resolves after it had been pending owes one standard
132 // recovery-log entry. The `resolved` row records that debt (`recoveryOwed`) with a recoveryId and
133 // timestamp fixed at that moment; a later `recoveryLogged` row pays it. Until then every run that
134 // meets the post — normally through the crosswalk gate — re-checks the recovery log for that
135 // recoveryId and appends only if it is absent, then writes `recoveryLogged`. A crash (or a failed
136 // append) anywhere between the crosswalk row and `recoveryLogged` is therefore repaired by the
137 // next run, and never produces a second entry.
138 //
139 // `recovery` defaults to skills/wix-replatform/lib/import-recovery.js; tests may inject one with
140 // the same { loadRecoveryLog, appendRecoveryLogEntry } shape.
141 function createPendingBlogMediaState ({ projectDir , recovery } = {}) {
142 if ( ! projectDir) throw new Error ( 'createPendingBlogMediaState needs the migration projectDir' );
143 const recoveryApi = () => recovery || require ( '../../../lib/import-recovery.js' );
144 const filePath = path. join (projectDir, 'state' , 'crosswalk' , STATE_FILE );
145 let droppedPartialLine = null ;
146
147 async function readRows () {
148 let buffer;
149 try {
150 buffer = await fs. readFile (filePath);
151 } catch (error) {
152 if (error && error.code === 'ENOENT' ) return { rows: [], needsTerminator: false };
153 throw error;
154 }
155 const lastNewline = buffer. lastIndexOf ( 0x0a );
156 const completeBytes = lastNewline === - 1 ? 0 : lastNewline + 1 ;
157 const complete = completeBytes > 0 ? buffer. subarray ( 0 , completeBytes). toString ( 'utf8' ). split ( ' \n ' ) : [];
158 if (complete. length > 0 ) complete. pop ();
159 const rows = [];
160 complete. forEach (( raw , i ) => {
161 const line = raw. replace ( / \r $ / , '' ). trim ();
162 if ( ! line) return ;
163 try { rows. push ( JSON . parse (line)); } catch (error) { throw new Error ( `${ filePath }:${ i + 1 } invalid NDJSON: ${ error . message }` ); }
164 });
165 const tail = buffer. subarray (completeBytes). toString ( 'utf8' );
166 let needsTerminator = false ;
167 if (tail. trim ()) {
168 let row = null ;
169 try { row = JSON . parse (tail. trim ()); } catch { row = null ; }
170 if (row) {
171 rows. push (row);
172 needsTerminator = true ;
173 } else {
174 await fs. truncate (filePath, completeBytes);
175 droppedPartialLine = { line: complete. length + 1 , length: tail. length };
176 }
177 }
178 return { rows, needsTerminator };
179 }
180
181 async function append ( row ) {
182 const { needsTerminator } = await readRows (); // repair first, so this append starts a clean line
183 await fs. mkdir (path. dirname (filePath), { recursive: true });
184 if (needsTerminator) await fs. appendFile (filePath, ' \n ' , 'utf8' );
185 await fs. appendFile (filePath, `${ JSON . stringify ({ schemaVersion: SCHEMA_VERSION , ... row , updatedAt: new Date (). toISOString () }) } \n ` , 'utf8' );
186 }
187
188 // Fold the file into { sourceStableKey -> { title, resolved, targetPostId, media: Map } }.
189 async function fold () {
190 const { rows } = await readRows ();
191 const posts = new Map ();
192 for ( const row of rows) {
193 if ( ! row || typeof row.sourceStableKey !== 'string' ) continue ;
194 let post = posts. get (row.sourceStableKey);
195 if ( ! post) {
196 post = {
197 sourceStableKey: row.sourceStableKey, title: null , resolved: false , targetPostId: null , media: new Map (),
198 recoveryOwed: false , recoveryId: null , resolvedAt: null , resolvedRunId: null , recoveryLogged: false ,
199 };
200 posts. set (row.sourceStableKey, post);
201 }
202 if (row.recoveryLogged === true ) {
203 if (post.resolved && row.recoveryId === post.recoveryId) post.recoveryLogged = true ;
204 continue ;
205 }
206 if (post.resolved) continue ; // closed: later media rows are ignored
207 if (row.resolved === true ) {
208 post.resolved = true ;
209 post.targetPostId = row.targetPostId || null ;
210 post.recoveryOwed = row.recoveryOwed === true ;
211 post.recoveryId = row.recoveryId || null ;
212 post.resolvedAt = row.resolvedAt || null ;
213 post.resolvedRunId = row.resolvedRunId || null ;
214 continue ;
215 }
216 if ( typeof row.mediaKey !== 'string' ) continue ;
217 if (row.title) post.title = row.title;
218 post.media. set (row.mediaKey, row);
219 }
220 return posts;
221 }
222
223 async function postRecord ( postOrId ) {
224 return ( await fold ()). get ( postStableKey (postOrId)) || null ;
225 }
226
227 // Pay an owed recovery-log entry, idempotently: the entry is rebuilt from the `resolved` row,
228 // so its recoveryId is stable and an entry already in the log is not appended again.
229 async function settleRecoveryLog ( record ) {
230 if ( ! record || ! record.resolved || ! record.recoveryOwed || record.recoveryLogged) return false ;
231 const api = recoveryApi ();
232 const existing = api.recoveryEntriesOf ? api. recoveryEntriesOf ( await api. loadRecoveryLog (projectDir)) : await api. loadRecoveryLog (projectDir);
233 if ( ! existing. some (( entry ) => entry && entry.recoveryId === record.recoveryId)) {
234 await api. appendRecoveryLogEntry (projectDir, buildBlogMediaRecoveryEntry ({
235 runId: record.resolvedRunId,
236 sourceStableKey: record.sourceStableKey,
237 targetPostId: record.targetPostId,
238 recoveryId: record.recoveryId,
239 timestamp: record.resolvedAt,
240 }));
241 }
242 await append ({ sourceStableKey: record.sourceStableKey, recoveryLogged: true , recoveryId: record.recoveryId });
243 return true ;
244 }
245
246 async function close ( postOrId , { targetPostId = null , runId = null , recoveryOwed = false } = {}) {
247 const key = postStableKey (postOrId);
248 let record = await postRecord (postOrId);
249 if ( ! record) return { closed: false , recoveryLogged: false }; // no media: never had a record
250 let closed = false ;
251 if ( ! record.resolved) {
252 if (recoveryOwed && ( typeof runId !== 'string' || runId === '' )) {
253 throw new Error ( `blog-post-media: closing ${ key } owes a recovery-log entry, so a runId is required` );
254 }
255 const resolvedAt = new Date (). toISOString ();
256 const recoveryId = recoveryOwed
257 ? `recovery-blog-media-${ crypto . createHash ( 'sha256' ). update ( `${ key }|${ resolvedAt }` ). digest ( 'hex' ). slice ( 0 , 12 ) }`
258 : null ;
259 await append ({
260 sourceStableKey: key,
261 resolved: true ,
262 targetPostId: targetPostId == null ? null : String (targetPostId),
263 recoveryOwed: Boolean (recoveryOwed),
264 recoveryId,
265 resolvedAt,
266 resolvedRunId: recoveryOwed ? runId : null ,
267 });
268 closed = true ;
269 record = await postRecord (postOrId);
270 }
271 return { closed, recoveryLogged: await settleRecoveryLog (record) };
272 }
273
274 return {
275 path: filePath,
276 readRows,
277 fold,
278 postRecord,
279 appendMedia : ( row ) => append (row),
280 // After the post write and its crosswalk row. `resumed` is importAndConfirmPostMedia's flag:
281 // a post that had been pending before this run owes a recovery-log entry, appended here.
282 markResolved : ( postOrId , { targetPostId = null , runId = null , resumed = false } = {}) =>
283 close (postOrId, { targetPostId, runId, recoveryOwed: Boolean (resumed) }),
284 // The crosswalk gate skipped this post: it is written, but an earlier run stopped before
285 // closing its record or before paying its recovery-log entry. No media calls. An open record
286 // closed here is a rerun resolving a pending post, so it owes the entry.
287 closeIfCrosswalked : ( postOrId , { targetPostId = null , runId = null } = {}) =>
288 close (postOrId, { targetPostId, runId, recoveryOwed: true }),
289 droppedPartialLine : () => droppedPartialLine,
290 };
291 }
292
293 // --- import and confirm ------------------------------------------------------------------
294
295 function mediaRow ( post , item , outcome , { fileId = null , reason = null , sizeInBytes = null } = {}) {
296 if ( ! OUTCOMES . includes (outcome)) throw new Error ( `blog-post-media: unknown outcome ${ outcome }` );
297 return {
298 sourceStableKey: postStableKey (post),
299 title: postTitle (post),
300 mediaKey: item.mediaKey,
301 kind: item.kind,
302 wpMediaId: item.wpMediaId,
303 sourceUrl: item.sourceUrl,
304 rewriteKeys: item.rewriteKeys,
305 fileId: fileId == null ? null : String (fileId),
306 ... (sizeInBytes == null ? {} : { sizeInBytes: String (sizeInBytes) }),
307 outcome,
308 reason: reason == null ? null : String (reason). slice ( 0 , 300 ),
309 };
310 }
311
312 function errorText ( error ) {
313 if ( ! error) return 'unknown error' ;
314 const status = error.status ? `${ error . status } ` : '' ;
315 return `${ status }${ error . message || String ( error ) }` ;
316 }
317
318 // Import every distinct medium of one post, settle every file in parallel, record each step.
319 // Returns { allReady, heroImageId, inlineBySourceUrl, items, resumed }. `resumed` is true when the
320 // post already had an open record from an earlier run (the caller's recovery-log trigger).
321 //
322 // Options: `state` from createPendingBlogMediaState; `fetchFeaturedMedia(id)` returns the
323 // WordPress `wp/v2/media/{id}` record (or a URL string, or null); `settle` is passed to
324 // waitUntilFileReady as { tries, delayMs }; `displayName` is optional.
325 async function importAndConfirmPostMedia ( wix , { post , richContent , state , fetchFeaturedMedia , settle } = {}) {
326 if ( ! state) throw new Error ( 'importAndConfirmPostMedia needs the pending-media state' );
327 if ( typeof fetchFeaturedMedia !== 'function' ) throw new Error ( 'importAndConfirmPostMedia needs fetchFeaturedMedia(id)' );
328 const record = await state. postRecord (post);
329 if (record && record.resolved) {
330 // Closed as written to `targetPostId`, yet the caller's crosswalk gate let it through. That is
331 // an inconsistency between two state files, not a media failure; guessing would either
332 // duplicate the post or write it without media. Stop and let the operator decide.
333 throw Object. assign (
334 new Error ( `${ postStableKey ( post ) } is recorded as written (Wix post ${ record . targetPostId }) but is not in the crosswalk; repair the crosswalk before importing it again` ),
335 { code: 'BLOG_MEDIA_STATE_INCONSISTENT' },
336 );
337 }
338 const prior = record ? record.media : new Map ();
339 const items = collectPostMedia ({ post, richContent });
340 const toSettle = [];
341
342 for ( const item of items) {
343 const last = prior. get (item.mediaKey);
344 if (last && last.fileId && RESUMABLE . has (last.outcome)) {
345 if (item.kind === 'featured' && last.sourceUrl) item.sourceUrl = last.sourceUrl;
346 item.fileId = last.fileId;
347 if (last.outcome === 'ready' && (item.kind !== 'featured' || Number (last.sizeInBytes) > 0 )) {
348 item.outcome = 'ready' ; continue ;
349 }
350 toSettle. push (item);
351 continue ;
352 }
353 if (item.unresolvable) {
354 item.outcome = 'unresolvable-url' ;
355 item.reason = post && post.link ? 'not an http(s) URL' : 'relative URL and the post has no link to resolve it against' ;
356 await state. appendMedia ( mediaRow (post, item, item.outcome, { reason: item.reason }));
357 continue ;
358 }
359 if (item.kind === 'featured' ) {
360 let resolved = null ;
361 try {
362 const got = await fetchFeaturedMedia (item.wpMediaId);
363 resolved = typeof got === 'string' ? got : (got ? resolveImageUrl (got) : null );
364 if ( ! resolved) item.reason = 'WordPress media record has no usable image URL' ;
365 } catch (error) {
366 item.reason = `fetching WordPress media ${ item . wpMediaId } failed: ${ errorText ( error ) }` ;
367 }
368 const absolute = resolved ? resolveImportUrl (resolved, post && post.link) : null ;
369 if ( ! absolute) {
370 item.outcome = 'fetch-failed' ;
371 if (resolved) item.reason = 'WordPress media URL is not an http(s) URL' ;
372 await state. appendMedia ( mediaRow (post, item, item.outcome, { reason: item.reason }));
373 continue ;
374 }
375 item.sourceUrl = absolute;
376 }
377 let file;
378 try {
379 file = await writers. importMedia (wix, {
380 sourceUrl: item.sourceUrl,
381 displayName: displayNameFor (item.sourceUrl),
382 mediaType: 'IMAGE' ,
383 wpId: item.wpMediaId != null ? item.wpMediaId : undefined ,
384 });
385 } catch (error) {
386 if (wixAuth. isRunInterruption (error)) throw error;
387 item.outcome = 'import-rejected' ;
388 item.reason = errorText (error);
389 await state. appendMedia ( mediaRow (post, item, item.outcome, { reason: item.reason }));
390 continue ;
391 }
392 if ( ! file || ! file.id) {
393 item.outcome = 'import-rejected' ;
394 item.reason = 'import response carried no file id' ;
395 await state. appendMedia ( mediaRow (post, item, item.outcome, { reason: item.reason }));
396 continue ;
397 }
398 item.fileId = String (file.id);
399 // Checkpoint BEFORE polling: a crash from here on resumes this id instead of importing again.
400 await state. appendMedia ( mediaRow (post, item, 'imported' , { fileId: item.fileId }));
401 toSettle. push (item);
402 }
403
404 const settled = await Promise . all (toSettle. map ( async ( item ) => {
405 try {
406 const ready = await writers. waitUntilFileReady (wix, item.fileId, settle || {});
407 if (ready && (item.kind !== 'featured' || Number (ready.sizeInBytes) > 0 ))
408 return { item, outcome: 'ready' , sizeInBytes: ready.sizeInBytes };
409 if (ready && item.kind === 'featured' )
410 return { item, outcome: 'not-ready' , reason: 'cover is READY but has no non-zero sizeInBytes' };
411 return { item, outcome: 'not-ready' , reason: 'still PENDING when the settle window ended' };
412 } catch (error) {
413 if (wixAuth. isRunInterruption (error)) throw error;
414 if (error && error.code === 'MEDIA_IMPORT_FAILED' ) return { item, outcome: 'failed' , reason: 'Wix reported the import FAILED' };
415 return { item, outcome: 'status-unknown' , reason: `reading the file status failed: ${ errorText ( error ) }` };
416 }
417 }));
418 for ( const { item , outcome , reason , sizeInBytes } of settled) {
419 item.outcome = outcome;
420 item.reason = reason || null ;
421 if (outcome === 'failed' ) item.fileId = null ; // a FAILED file is imported afresh next time
422 await state. appendMedia ( mediaRow (post, item, outcome,
423 { fileId: item.fileId, reason: item.reason, sizeInBytes }));
424 }
425
426 const allReady = items. every (( item ) => item.outcome === 'ready' );
427 const featured = items. find (( item ) => item.kind === 'featured' );
428 const inlineBySourceUrl = new Map ();
429 for ( const item of items) {
430 if (item.kind === 'inline' && item.outcome === 'ready' ) {
431 for ( const key of item.rewriteKeys) inlineBySourceUrl. set (key, item.fileId);
432 }
433 }
434 return {
435 allReady,
436 heroImageId: featured && featured.outcome === 'ready' ? featured.fileId : null ,
437 inlineBySourceUrl,
438 items: items. map (({ unresolvable , ... rest }) => ({ ... rest, fileId: rest.fileId || null , reason: rest.reason || null })),
439 resumed: Boolean (record),
440 };
441 }
442
443 function displayNameFor ( url ) {
444 try {
445 const last = new URL (url).pathname. split ( '/' ). filter (Boolean). pop ();
446 return last ? decodeURIComponent (last) : undefined ;
447 } catch {
448 return undefined ;
449 }
450 }
451
452 // Rewrite the inline images of the SAME converted document. Only for an allReady result: a
453 // partial rewrite is the unapproved fallback.
454 function applyPostMedia ( richContent , result ) {
455 if ( ! result || ! result.allReady) throw new Error ( 'applyPostMedia: only an allReady media result may be applied to a post' );
456 return writers. rewriteInlineMedia (richContent, result.inlineBySourceUrl);
457 }
458
459 // Crosswalk hits must still enter the build runner for legacy reconciliation, but
460 // must not re-import media or close pending state before Wix identity is checked.
461 // Prefer the existing media ledger; a pre-ledger import supplies its local media
462 // crosswalk through resolveFromCrosswalk without making a new Wix media write.
463 // LIVE-FOUND: a legacy post whose inline image has no media row anywhere (never imported by any
464 // earlier run) was held on every pass. With `wix`, such an inline image is imported now through the
465 // same checkpointed path as a new post's (import, record `imported`, wait until READY): a resumed
466 // pass picks up the recorded file instead of importing again. Without `wix`, as before, it stays
467 // unresolved. Featured images and unresolvable URLs are never imported here.
468 async function importMissingLegacyInline ( wix , post , item , row , state , settle ) {
469 if ( ! wix || item.kind !== 'inline' || item.unresolvable || ! item.sourceUrl) return row;
470 let fileId = row && row.fileId && RESUMABLE . has (row.outcome) ? String (row.fileId) : null ;
471 if ( ! fileId) {
472 let file;
473 // A rejected import is recorded like a new post's (import-rejected) and leaves the post pending;
474 // it never escapes the media stage.
475 try { file = await writers. importMedia (wix, { sourceUrl: item.sourceUrl, displayName: displayNameFor (item.sourceUrl), mediaType: 'IMAGE' }); }
476 catch (error) { if (wixAuth. isRunInterruption (error)) throw error; await state. appendMedia ( mediaRow (post, item, 'import-rejected' , { reason: errorText (error) })); return row; }
477 if ( ! file || ! file.id) { await state. appendMedia ( mediaRow (post, item, 'import-rejected' , { reason: 'import response carried no file id' })); return row; }
478 fileId = String (file.id);
479 await state. appendMedia ( mediaRow (post, item, 'imported' , { fileId }));
480 }
481 try {
482 const ready = await writers. waitUntilFileReady (wix, fileId, settle || {});
483 const outcome = ready ? 'ready' : 'not-ready' ;
484 await state. appendMedia ( mediaRow (post, item, outcome, { fileId, sizeInBytes: ready ? ready.sizeInBytes : null , reason: ready ? null : 'still PENDING when the settle window ended' }));
485 return { outcome, fileId, sizeInBytes: ready ? ready.sizeInBytes : null };
486 } catch (error) {
487 if (wixAuth. isRunInterruption (error)) throw error;
488 const outcome = error && error.code === 'MEDIA_IMPORT_FAILED' ? 'failed' : 'status-unknown' ;
489 await state. appendMedia ( mediaRow (post, item, outcome, { fileId: outcome === 'failed' ? null : fileId, reason: errorText (error) }));
490 return row;
491 }
492 }
493 async function existingPostMedia ({ post , richContent , state , resolveFromCrosswalk , wix = null , settle = null } = {}) {
494 if ( ! state) throw new Error ( 'existingPostMedia needs pending-media state' );
495 const needed = collectPostMedia ({ post, richContent });
496 if ( ! needed. length ) return { allReady: true , heroImageId: null ,
497 inlineBySourceUrl: new Map (), resumed: false , items: [] };
498 const record = await state. postRecord (post);
499 const local = record?.media;
500 const resolved = [];
501 for ( const item of needed) {
502 let row = local?. get (item.mediaKey);
503 if (( ! row || row.outcome !== 'ready' || ! row.fileId ||
504 item.kind === 'featured' && ! ( Number (row.sizeInBytes) > 0 )) &&
505 typeof resolveFromCrosswalk === 'function' ) row = await resolveFromCrosswalk (post, item);
506 if ( ! row || row.outcome !== 'ready' || ! row.fileId) row = await importMissingLegacyInline (wix, post, item, row || local?. get (item.mediaKey), state, settle);
507 if ( ! row || row.outcome !== 'ready' || ! row.fileId ||
508 item.kind === 'featured' && ! ( Number (row.sizeInBytes) > 0 )) {
509 throw Object. assign (
510 new Error ( `blog legacy crosswalk ${ postStableKey ( post ) } lacks confirmed READY media ${ item . mediaKey }` ),
511 { code: 'BLOG_LEGACY_MEDIA_UNRESOLVED' });
512 }
513 resolved. push ({ ... item, fileId: String (row.fileId), outcome: 'ready' });
514 }
515 const inlineBySourceUrl = new Map ();
516 for ( const item of resolved. filter ( value => value.kind === 'inline' ))
517 for ( const key of item.rewriteKeys) inlineBySourceUrl. set (key, item.fileId);
518 return { allReady: true ,
519 heroImageId: resolved. find ( item => item.kind === 'featured' )?.fileId || null ,
520 inlineBySourceUrl, resumed: Boolean (record && ! record.resolved), items: resolved };
521 }
522
523 // --- report ------------------------------------------------------------------------------
524
525 function isCrosswalked ( crosswalk , key ) {
526 if ( ! crosswalk) return false ;
527 if ( typeof crosswalk === 'function' ) return Boolean ( crosswalk (key));
528 if ( typeof crosswalk.has === 'function' ) return crosswalk. has (key);
529 return false ;
530 }
531
532 // Derived from the state file, never kept as a separate list. A post is pending when its record
533 // is open and it is not in the crosswalk — including when every medium is already READY but the
534 // post write was never recorded (`awaitingWrite`: a run stopped between the last READY row and
535 // the crosswalk row). `recoveryLogOwed` lists written posts whose recovery-log entry is not yet
536 // confirmed; the next run's crosswalk gate pays it. `crosswalk` is a Set/Map of sourceStableKeys
537 // or a function(sourceStableKey) -> truthy.
538 async function summarizePendingBlogMedia ({ state , crosswalk } = {}) {
539 if ( ! state) throw new Error ( 'summarizePendingBlogMedia needs the pending-media state' );
540 const pending = [];
541 const recoveryLogOwed = [];
542 for ( const post of ( await state. fold ()). values ()) {
543 if (post.resolved) {
544 if (post.recoveryOwed && ! post.recoveryLogged) recoveryLogOwed. push ({ sourceStableKey: post.sourceStableKey, targetPostId: post.targetPostId, recoveryId: post.recoveryId });
545 continue ;
546 }
547 if ( isCrosswalked (crosswalk, post.sourceStableKey)) continue ;
548 const media = [ ... post.media. values ()]
549 . filter (( row ) => row.outcome !== 'ready' )
550 . map (( row ) => ({ mediaKey: row.mediaKey, kind: row.kind, outcome: row.outcome, reason: row.reason || null , wpMediaId: row.wpMediaId || null , sourceUrl: row.sourceUrl || null , fileId: row.fileId || null }));
551 const awaitingWrite = media. length === 0 ;
552 pending. push ({
553 sourceStableKey: post.sourceStableKey,
554 title: post.title,
555 awaitingWrite,
556 reason: awaitingWrite ? 'every medium is READY; the post write was not recorded — the next run writes it' : null ,
557 media,
558 });
559 }
560 return { pendingMedia: pending. length , pending, recoveryLogOwed, statePath: 'state/crosswalk/pending-blog-media.ndjson' };
561 }
562
563 // The standard recovery-log entry for a post a rerun resolved after it had been pending. The
564 // state's markResolved / closeIfCrosswalked append it; callers do not.
565 function buildBlogMediaRecoveryEntry ({ runId , post , sourceStableKey , targetPostId , recoveryId , timestamp , crosswalkChanges = { added: 1 , updated: 0 } }) {
566 const key = sourceStableKey || postStableKey (post);
567 return {
568 ... (recoveryId ? { recoveryId } : {}),
569 ... (timestamp ? { timestamp } : {}),
570 runId,
571 mode: 'resumed' ,
572 status: 'complete' ,
573 reason: 'blog_post_media_resolved' ,
574 selectionFilters: { entity: 'blog-post' , sourceStableKey: key },
575 recordsSelected: 1 ,
576 recordsAttempted: 1 ,
577 imported: 1 ,
578 alreadyPresent: 0 ,
579 failed: 0 ,
580 deferred: 0 ,
581 unresolved: 0 ,
582 crosswalkChanges,
583 summaryChanges: { blogPostsPendingMedia: - 1 , blogPostsImported: 1 },
584 outcome: `blog post ${ key } resolved as Wix post ${ targetPostId } after its media reached READY` ,
585 logs: [ 'state/crosswalk/pending-blog-media.ndjson' ],
586 };
587 }
588
589 module . exports = {
590 SCHEMA_VERSION,
591 STATE_FILE,
592 OUTCOMES,
593 postStableKey,
594 collectPostMedia,
595 resolveImportUrl,
596 createPendingBlogMediaState,
597 importAndConfirmPostMedia,
598 applyPostMedia,
599 existingPostMedia,
600 summarizePendingBlogMedia,
601 buildBlogMediaRecoveryEntry,
602 };