Setting the file. One moment.
Quick Mode · Rp Quick Woocommerce · wix/skills · Skills Docs
ContentsBack to the top of the page 127
async function extract
— line 127
This file
Number 33.1
Position 1 of 2
Type JavaScript
Size 28 KB
Lines 334 scripts/ quick-mode.js
JavaScript · 334 lines · 28 KB
documentUrlGuard
=
require
(
'../../../lib/document-url-guard'
);
10 const catalogBaseline = require ( '../../../lib/catalog-baseline' );
11 const fs = require ( 'node:fs/promises' );
12 const path = require ( 'node:path' );
13 const crypto = require ( 'node:crypto' );
14
15 const ADAPTER_DIR = path. resolve (__dirname, '..' );
16 const CONTRACT_PATH = path. join ( ADAPTER_DIR , 'quick-mode.json' );
17 const CONTRACT = require ( CONTRACT_PATH );
18 const sourceCoverage = require (path. join ( ADAPTER_DIR , '..' , '..' , 'lib' , 'source-coverage.js' ));
19 const sourceRegional = require (path. join ( ADAPTER_DIR , '..' , '..' , 'lib' , 'source-regional.js' ));
20 const { buildQuickWooCommerceRegional } = require (path. join ( ADAPTER_DIR , '..' , 'rp-source-wordpress' , 'lib' , 'regional-capture.js' ));
21 const recovery = require (path. join ( ADAPTER_DIR , '..' , 'rp-quick-runtime' , 'lib' , 'limit-recovery.js' ));
22 const importProgress = require (path. join ( ADAPTER_DIR , '..' , 'rp-quick-runtime' , 'lib' , 'import-progress.js' ));
23 // On the public Store API this is a no-op — `src` there is already the original — so
24 // the URLs produced here do not change. It is called anyway because naming an image field
25 // directly is how a whole catalog once shipped as thumbnails: the same idiom got copied onto the
26 // authenticated wc/v3 payload, where `src` can be the smallest crop.
27 const { resolveImageUrls } = require (path. join ( ADAPTER_DIR , '..' , 'rp-source-wordpress' , 'lib' , 'wp-image-url.js' ));
28 const { composeProductDescription } = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-build.js' ));
29
30 async function readJson ( filePath ) {
31 return JSON . parse ( await fs. readFile (filePath, 'utf8' ));
32 }
33 async function writeJson ( filePath , value ) {
34 await fs. mkdir (path. dirname (filePath), { recursive: true });
35 await fs. writeFile (filePath, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
36 }
37 // A dry run never touches the live audit log.
38 async function appendAudit ( projectDir , entry , dryRun = false ) {
39 if (dryRun) return ;
40 const file = path. join (projectDir, 'logs' , 'quick-mode-audit.ndjson' );
41 await fs. mkdir (path. dirname (file), { recursive: true });
42 await fs. appendFile (file, `${ JSON . stringify ({ timestamp: new Date (). toISOString () , ... entry }) } \n ` , 'utf8' );
43 }
44 function origin ( value ) {
45 const url = new URL (value);
46 if ( ! [ 'http:' , 'https:' ]. includes (url.protocol)) throw new Error ( 'sourceUrl must use http or https' );
47 // Every capture below fetches this origin from the operator's machine: refuse loopback, private and
48 // link-local (cloud metadata) hosts. Lexical only; it does not resolve DNS. RP_TRUSTED_ORIGINS (the
49 // same variable the headless bundle uses) names origins the operator explicitly trusts, e.g. a local
50 // test server.
51 const trusted = String (process.env. RP_TRUSTED_ORIGINS || '' ). split ( ',' ). map (( entry ) => entry. trim ()). filter (Boolean);
52 if ( ! trusted. includes (url.origin) && documentUrlGuard. isPrivateHostname (url.hostname)) throw new Error ( 'sourceUrl must be a public host, not a loopback, private or link-local address' );
53 return url.origin;
54 }
55 function fingerprint ( value ) {
56 return crypto. createHash ( 'sha256' ). update ( JSON . stringify (value)). digest ( 'hex' );
57 }
58 async function projectContext ( projectDir ) {
59 const decisions = await readJson (path. join (projectDir, 'orchestration' , 'decisions.json' ));
60 const sourceUrl = decisions.sourceUrl && decisions.sourceUrl.value;
61 const sourcePlatform = decisions.sourcePlatform && decisions.sourcePlatform.value;
62 if ( ! sourceUrl) throw new Error ( 'quick mode requires orchestration.decisions.sourceUrl' );
63 if (sourcePlatform !== 'woocommerce' ) throw new Error ( `quick WooCommerce adapter requires sourcePlatform=woocommerce (got ${ sourcePlatform || 'missing'})` );
64 return { sourceUrl: origin (sourceUrl), decisions };
65 }
66 async function fetchCollection ( baseUrl , route ) {
67 const records = [];
68 const pages = [];
69 for ( let page = 1 ; ; page += 1 ) {
70 const url = new URL (route, `${ baseUrl }/` );
71 url.searchParams. set ( 'page' , String (page));
72 url.searchParams. set ( 'per_page' , '100' );
73 const response = await fetch (url, { headers: { accept: 'application/json' } });
74 const text = await response. text ();
75 let body;
76 try { body = text ? JSON . parse (text) : null ; } catch { body = null ; }
77 if ( ! response.ok || ! Array. isArray (body)) {
78 return { ok: false , status: response.status, url: url. toString (), bodyType: Array. isArray (body) ? 'array' : typeof body, records, pages };
79 }
80 records. push ( ... body);
81 pages. push ({ page, count: body. length , url: url. toString () });
82 const totalPages = Number (response.headers. get ( 'x-wp-totalpages' ));
83 if (Number. isFinite (totalPages) ? page >= totalPages : body. length < 100 ) break ;
84 }
85 return { ok: true , records, pages };
86 }
87 async function preflight ( projectDir ) {
88 const { sourceUrl } = await projectContext (projectDir);
89 const checks = [];
90 for ( const entity of CONTRACT .entities) {
91 const result = await fetchCollection (sourceUrl, entity.route);
92 checks. push ({ entity: entity.id, route: entity.route, ok: result.ok, status: result.status || 200 , url: result.url || result.pages[ 0 ]?.url || null , responseShape: result.ok ? 'array' : result.bodyType });
93 }
94 const out = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), adapter: { id: CONTRACT .id, version: CONTRACT .version, fingerprint: fingerprint ( CONTRACT ) }, sourceUrl, status: checks. every (( check ) => check.ok) ? 'passed' : 'blocked' , checks };
95 await writeJson (path. join (projectDir, 'quick-mode' , 'preflight.json' ), out);
96 return out;
97 }
98 // Two bounded public GETs -- the first Store API product and the storefront home page -- for the
99 // store's currency and language. A read that fails is an explicit unknown in the file, never a
100 // plan failure.
101 async function readPublic ( url , accept , parse ) {
102 try {
103 const response = await fetch (url, { headers: { accept }, signal: AbortSignal. timeout ( 15000 ) });
104 return response.ok ? parse ( await response. text ()) : null ;
105 } catch { return null ; }
106 }
107 async function captureRegional ( projectDir , sourceUrl , generatedAt ) {
108 const products = await readPublic ( new URL ( '/wp-json/wc/store/v1/products?per_page=1' , sourceUrl), 'application/json' , ( body ) => { const parsed = JSON . parse (body); return Array. isArray (parsed) ? parsed : null ; });
109 const page = await readPublic ( new URL ( '/' , sourceUrl), 'text/html' , ( html ) => ({ html }));
110 return sourceRegional. writeSourceRegional (projectDir, buildQuickWooCommerceRegional ({ products, page, generatedAt }));
111 }
112 async function plan ( projectDir ) {
113 const { sourceUrl } = await projectContext (projectDir);
114 const preflightResult = await readJson (path. join (projectDir, 'quick-mode' , 'preflight.json' ));
115 if (preflightResult.status !== 'passed' ) throw new Error ( 'quick mode preflight must pass before planning' );
116 const adapter = { id: CONTRACT .id, version: CONTRACT .version, fingerprint: fingerprint ( CONTRACT ) };
117 const generatedAt = new Date (). toISOString ();
118 // Every excluded[] capability becomes a declared-exclusion coverage row, checked by the same
119 // reconcile rule as a full run before the plan is written.
120 const coverage = await sourceCoverage. writeQuickPlanCoverage (projectDir, CONTRACT , { rawText: await fs. readFile ( CONTRACT_PATH , 'utf8' ), generatedAt });
121 await captureRegional (projectDir, sourceUrl, generatedAt);
122 const planResult = { schemaVersion: 1 , generatedAt, managementImportMode: 'quick' , adapter, sourceUrl, entities: CONTRACT .entities, requiredWixCapabilities: CONTRACT .requiredWixCapabilities, excluded: CONTRACT .excluded, excludedCapabilities: CONTRACT .excludedCapabilities, sourceAuth: 'none' , sourceCoverageContract: coverage.sourceCoverageContract };
123 await writeJson (path. join (projectDir, 'quick-mode' , 'plan.json' ), planResult);
124 await writeJson (path. join (projectDir, 'execution' , 'execution-manifest.json' ), { schemaVersion: 1 , generatedAt: planResult.generatedAt, managementImportMode: 'quick' , adapter, extractCommand: 'rp-quick-woocommerce extract' , importCommand: 'rp-quick-woocommerce import' , entities: CONTRACT .entities. map (( entity ) => entity.id) });
125 return planResult;
126 }
127 async function extract ( projectDir ) {
128 const { sourceUrl } = await projectContext (projectDir);
129 const planResult = await readJson (path. join (projectDir, 'quick-mode' , 'plan.json' ));
130 if (planResult.adapter.fingerprint !== fingerprint ( CONTRACT )) throw new Error ( 'quick adapter contract changed; re-run preflight and plan' );
131 const entities = {};
132 for ( const entity of CONTRACT .entities) {
133 const result = await fetchCollection (sourceUrl, entity.route);
134 if ( ! result.ok) throw new Error ( `${ entity . id } extraction failed: ${ result . status } ${ result . url }` );
135 const file = path. join (projectDir, 'data' , 'source-extract' , `${ entity . id }.ndjson` );
136 await fs. mkdir (path. dirname (file), { recursive: true });
137 await fs. writeFile (file, result.records. map (( record ) => JSON . stringify (record)). join ( ' \n ' ) + (result.records. length ? ' \n ' : '' ), 'utf8' );
138 entities[entity.id] = { file: path. relative (projectDir, file), route: entity.route, recordCount: result.records. length , pages: result.pages };
139 }
140 const manifest = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), managementImportMode: 'quick' , adapter: planResult.adapter, sourceUrl, entities };
141 await writeJson (path. join (projectDir, 'data' , 'source-extract' , 'manifest.json' ), manifest);
142 return manifest;
143 }
144 function nonEmpty ( value ) { const text = value == null ? '' : String (value). trim (); return text || undefined ; }
145 function text ( value ) { return String (value || '' ). replace ( /< [ ^ >] * >/ g , '' ). replace ( /&/ g , '&' ). replace ( /"/ g , '"' ). trim (); }
146 function slug ( value ) { return nonEmpty (value)?. toLowerCase (). replace ( / [_\s] + / g , '-' ). replace ( / [ ^ a-z0-9-] + / g , '-' ). replace ( /- + / g , '-' ). replace ( / ^ - | - $ / g , '' ) || undefined ; }
147 function price ( value , minor = 2 ) { const n = Number (value); return Number. isFinite (n) ? (n / ( 10 ** Number (minor || 2 ))). toFixed ( Number (minor || 2 )) : '0.00' ; }
148 async function readLines ( filePath ) { const raw = await fs. readFile (filePath, 'utf8' ); return raw. split ( / \r ? \n / ). filter (Boolean). map ( JSON .parse); }
149 async function loadWixEnv ( projectDir ) {
150 const values = {};
151 try {
152 for ( const line of ( await fs. readFile (path. join (projectDir, 'config' , 'wix.env' ), 'utf8' )). split ( / \r ? \n / )) {
153 const m = line. trim (). match ( / ^ ( [A-Z0-9_] + )=( . * ) $ / ); if (m) values[m[ 1 ]] = m[ 2 ]. replace ( / ^ ['"] | ['"] $ / g , '' ). trim ();
154 }
155 } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
156 return { ... values, ... process.env };
157 }
158 async function loadCrosswalk ( projectDir ) {
159 try { return await readJson (path. join (projectDir, 'state' , 'crosswalk' , 'quick-mode.json' )); }
160 catch (error) { if (error.code === 'ENOENT' ) return {}; throw error; }
161 }
162 // A token from config/wix.env renews itself for this site; a process-env override is used as given.
163 function wixCredentials ( projectDir , env , dryRun ) { return dryRun || process.env. WIX_AUTH_TOKEN ? { authToken: env. WIX_AUTH_TOKEN } : { tokenSource: wixAuth. createWixTokenSource ({ projectDir, siteId: env. WIX_SITE_ID }) }; }
164 async function ensureFallbackAuthor ( projectDir ) {
165 const env = await loadWixEnv (projectDir);
166 if (env. WIX_BLOG_FALLBACK_MEMBER_ID ) return { status: 'already_configured' , memberId: env. WIX_BLOG_FALLBACK_MEMBER_ID };
167 if ( ! env. WIX_AUTH_TOKEN || ! env. WIX_SITE_ID ) throw new Error ( 'WIX_AUTH_TOKEN and WIX_SITE_ID are required to provision the Blog fallback author' );
168 const writers = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-writers.js' ));
169 const dryRun = writers. createDryRunConfig (env, process.argv. slice ( 2 )).dryRun;
170 if (dryRun) return { status: 'planned_dry_run' , memberId: null };
171 const quickRun = capabilities. startQuickRun ({ projectDir, adapterId: 'quick-woocommerce' , siteId: env. WIX_SITE_ID , dryRun: false });
172 const wix = writers. createWixClient ({ capability: capabilities. bindQuickSetup (quickRun, 'blog-fallback-author' ), ... wixCredentials (projectDir, env, false ) });
173 const projectId = path. basename (projectDir). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( / ^ - | - $ / g , '' ) || 'migration' ;
174 const email = `replatform+${ projectId }@wix.com` ;
175 const listed = await writers. listMembers (wix, { limit: 100 });
176 let member = (listed.members || []). find (( item ) => String (item.loginEmail || '' ). toLowerCase () === email);
177 if ( ! member) member = await writers. ensureBlogFallbackAuthor (wix, { email, name: 'Imported content' , slug: `imported-content-${ projectId }` });
178 if ( ! member || ! member.id) throw new Error ( 'fallback Blog author was not created' );
179 const configPath = path. join (projectDir, 'config' , 'wix.env' );
180 let raw = '' ; try { raw = await fs. readFile (configPath, 'utf8' ); } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
181 await fs. mkdir (path. dirname (configPath), { recursive: true });
182 await fs. writeFile (configPath, `${ raw . replace ( / \n ? WIX_BLOG_FALLBACK_MEMBER_ID= . * (?: \n |$ )/ g , ' \n ' ). replace ( / \s *$ / , ' \n ' ) }WIX_BLOG_FALLBACK_MEMBER_ID=${ member . id } \n ` , 'utf8' );
183 return { status: 'provisioned' , memberId: member.id };
184 }
185 async function importData ( projectDir ) {
186 const planResult = await readJson (path. join (projectDir, 'quick-mode' , 'plan.json' ));
187 const manifest = await readJson (path. join (projectDir, 'data' , 'source-extract' , 'manifest.json' ));
188 importProgress. assertQuickImportAdapter ({ plan: planResult, manifest, current: { id: CONTRACT .id, version: CONTRACT .version, fingerprint: fingerprint ( CONTRACT ) } });
189 // Fail closed before any write: a stamped plan with a missing or unreconciled coverage file
190 // must not produce a completion summary.
191 await sourceCoverage. quickSummaryCoverage (projectDir);
192 const env = await loadWixEnv (projectDir);
193 if ( ! env. WIX_AUTH_TOKEN || ! env. WIX_SITE_ID ) throw new Error ( 'WIX_AUTH_TOKEN and WIX_SITE_ID are required for quick import' );
194 const writers = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-writers.js' ));
195 const dryRun = writers. createDryRunConfig (env, process.argv. slice ( 2 )).dryRun;
196 const quickRun = capabilities. startQuickRun ({ projectDir, adapterId: 'quick-woocommerce' , siteId: env. WIX_SITE_ID , dryRun });
197 // The site's currency (and time zone) is checked before an entity that writes money or wall-clock
198 // records gets its write capability. A refused entity gets no client and writes nothing; whatever
199 // depends on it is skipped too, and every other entity still imports.
200 const regionalRefused = {};
201 const clientFor = async ( id ) => {
202 const entity = CONTRACT .entities. find (( row ) => row.id === id);
203 const upstream = entity.dependsOn. find (( dependency ) => regionalRefused[dependency]);
204 if (upstream) { regionalRefused[id] = 'DEPENDENCY_REFUSED' ; return null ; }
205 // Every other entity builds on the regional setup step: a held entity writes nothing (files only; no site read).
206 const dependency = siteRegional. regionalDependency ({ projectDir, siteId: env. WIX_SITE_ID , entityRefs: [entity.target, ... (entity.auxiliaryTargets || [])] });
207 if ( ! dependency.ok) { regionalRefused[id] = dependency.code; return null ; }
208 return writers. createWixClient ({ capability: capabilities. bindQuickEntity (quickRun, id), ... wixCredentials (projectDir, env, dryRun) });
209 };
210 const sourceDir = path. join (projectDir, 'data' , 'source-extract' );
211 const crosswalk = await loadCrosswalk (projectDir);
212 // Durable pre-write evidence, before the first destination create. Some
213 // crosswalk checkpoints are deferred (blog tags, products), so the crosswalk cannot serve.
214 if ( ! dryRun) await importProgress. writeQuickImportStartedMarker (projectDir, planResult.adapter);
215 const seen = ( entity , sourceId ) => crosswalk[entity] && crosswalk[entity][ String (sourceId)];
216 const remember = ( entity , sourceId , targetId ) => { if ( ! dryRun) { crosswalk[entity] ||= {}; crosswalk[entity][ String (sourceId)] = targetId; } };
217 const checkpointCrosswalk = async () => { if ( ! dryRun) await writeJson (path. join (projectDir, 'state' , 'crosswalk' , 'quick-mode.json' ), crosswalk); };
218 const summary = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), managementImportMode: 'quick' , adapter: planResult.adapter, dryRun, imported: {}, recovered: 0 , recoveryCounts: {}, excluded: CONTRACT .excluded, regionalRefused };
219 const categoryIds = new Map (); const productTagIds = new Map (); const blogCategoryIds = new Map (); const blogTagIds = new Map ();
220 // A source record whose slug matches an item that was on the store before this import is held,
221 // not adopted and not created; the completion report turns these into operator actions.
222 const holdCollision = ( entity , sourceEntity , sourceId , target ) => {
223 if ( ! dryRun) catalogBaseline. recordCollision (projectDir, { entity, sourceKey: String (sourceId), targetId: target.id, slug: target.slug ?? null });
224 summary.skipped ||= {}; summary.skipped[sourceEntity] ||= [];
225 summary.skipped[sourceEntity]. push ({ sourceId: String (sourceId), reason: catalogBaseline. collisionError (entity, target.id).message });
226 };
227 let wix = await clientFor ( 'product_category' );
228 if (wix) {
229 // Categories that were on the store before this import are never adopted by slug.
230 const baselineCategoryIds = catalogBaseline. protectedIds (projectDir).categories;
231 const destinationCategories = await writers. queryAllStoresCategories (wix);
232 const heldCategories = new Map (destinationCategories. filter (( item ) => baselineCategoryIds. has (item.id)). map (( item ) => [ slug (item.slug), item]));
233 const existingCategories = new Map (destinationCategories. filter (( item ) => ! baselineCategoryIds. has (item.id)). map (( item ) => [ slug (item.slug), item]));
234 for ( const row of await readLines (path. join (sourceDir, 'product_category.ndjson' ))) {
235 if ( seen ( 'product_category' , row.id) && baselineCategoryIds. has ( seen ( 'product_category' , row.id))) { holdCollision ( 'stores/category' , 'product_category' , row.id, { id: seen ( 'product_category' , row.id) }); continue ; }
236 if ( seen ( 'product_category' , row.id)) { categoryIds. set ( String (row.id), seen ( 'product_category' , row.id)); continue ; }
237 const sourceSlug = slug (row.slug); const existing = existingCategories. get (sourceSlug);
238 if ( ! existing && heldCategories. has (sourceSlug)) { holdCollision ( 'stores/category' , 'product_category' , row.id, heldCategories. get (sourceSlug)); continue ; }
239 const target = existing || await writers. createStoresCategory (wix, { name: text (row.name), slug: sourceSlug, description: nonEmpty ( text (row.description). slice ( 0 , 600 )), treeReference: writers. STORES_TREE_REFERENCE }); categoryIds. set ( String (row.id), target.id);
240 remember ( 'product_category' , row.id, target.id); if ( ! dryRun) catalogBaseline. clearCollision (projectDir, 'stores/category' , row.id); await checkpointCrosswalk ();
241 }
242 summary.imported.product_category = categoryIds.size;
243 capabilities. finishQuickEntity (quickRun, 'product_category' );
244 }
245 wix = await clientFor ( 'product_tag' );
246 if (wix) {
247 const existingProductTags = new Map ((( await wix. send (writers. buildListStoresProductTagsRequest ())).tags || []). map (( item ) => [ slug (item.name), item]));
248 for ( const row of await readLines (path. join (sourceDir, 'product_tag.ndjson' ))) {
249 if ( seen ( 'product_tag' , row.id)) { productTagIds. set ( String (row.id), seen ( 'product_tag' , row.id)); continue ; }
250 const key = slug (row.name); const existing = existingProductTags. get (key);
251 const response = existing || ( await wix. send (writers. buildCreateStoresProductTagRequest ( text (row.name)))).tag;
252 productTagIds. set ( String (row.id), response.id); remember ( 'product_tag' , row.id, response.id); await checkpointCrosswalk ();
253 }
254 summary.imported.product_tag = productTagIds.size;
255 capabilities. finishQuickEntity (quickRun, 'product_tag' );
256 }
257 wix = await clientFor ( 'blog_category' );
258 if (wix) {
259 const existingBlogCategories = new Map ((( await wix. send (writers. buildQueryBlogCategoriesRequest ())).categories || []). map (( item ) => [ slug (item.slug), item]));
260 for ( const row of await readLines (path. join (sourceDir, 'blog_category.ndjson' ))) { if ( seen ( 'blog_category' , row.id)) { blogCategoryIds. set ( String (row.id), seen ( 'blog_category' , row.id)); continue ; } const existing = existingBlogCategories. get ( slug (row.slug)); const created = existing || await writers. createBlogCategory (wix, { label: text (row.name), slug: slug (row.slug), description: nonEmpty ( text (row.description)) }); blogCategoryIds. set ( String (row.id), created.id); remember ( 'blog_category' , row.id, created.id); await checkpointCrosswalk (); }
261 summary.imported.blog_category = blogCategoryIds.size;
262 capabilities. finishQuickEntity (quickRun, 'blog_category' );
263 }
264 wix = await clientFor ( 'blog_tag' );
265 if (wix) {
266 for ( const row of await readLines (path. join (sourceDir, 'blog_tag.ndjson' ))) { if ( seen ( 'blog_tag' , row.id)) { blogTagIds. set ( String (row.id), seen ( 'blog_tag' , row.id)); continue ; } const created = await writers. createBlogTag (wix, { label: text (row.name) }); blogTagIds. set ( String (row.id), created.id); remember ( 'blog_tag' , row.id, created.id); }
267 summary.imported.blog_tag = blogTagIds.size;
268 capabilities. finishQuickEntity (quickRun, 'blog_tag' );
269 }
270 wix = await clientFor ( 'post' );
271 if (wix) {
272 const fallbackMemberId = env. WIX_BLOG_FALLBACK_MEMBER_ID ;
273 if ( ! fallbackMemberId) throw new Error ( 'setup must provision WIX_BLOG_FALLBACK_MEMBER_ID before importing blog posts' );
274 let posts = 0 ;
275 for ( const row of await readLines (path. join (sourceDir, 'post.ndjson' ))) {
276 if ( seen ( 'post' , row.id)) { posts += 1 ; continue ; }
277 await appendAudit (projectDir, { phase: 'post' , sourceId: String (row.id), state: 'creating_draft' }, dryRun);
278 const draft = await writers. createDraftPost (wix, { title: text (row.title?.rendered), memberId: fallbackMemberId, richContent: await writers. convertHtmlToRichContent (wix, row.content?.rendered || '<p></p>' ), excerpt: text (row.excerpt?.rendered), slug: slug (row.slug), categoryIds: (row.categories || []). map (( id ) => blogCategoryIds. get ( String (id))). filter (Boolean), tagIds: (row.tags || []). map (( id ) => blogTagIds. get ( String (id))). filter (Boolean), firstPublishedDate: Number. isNaN ( new Date (row.date). valueOf ()) ? undefined : new Date (row.date). toISOString () });
279 await appendAudit (projectDir, { phase: 'post' , sourceId: String (row.id), state: 'publishing_draft' , targetId: draft.id }, dryRun);
280 if ( ! dryRun) await writers. publishDraftPost (wix, draft.id); remember ( 'post' , row.id, draft.id); posts += 1 ;
281 await checkpointCrosswalk (); await appendAudit (projectDir, { phase: 'post' , sourceId: String (row.id), state: 'completed' , targetId: draft.id }, dryRun);
282 }
283 summary.imported.post = posts;
284 capabilities. finishQuickEntity (quickRun, 'post' );
285 }
286 wix = await clientFor ( 'product' );
287 if (wix) {
288 let products = 0 ;
289 // A process can stop after a product create succeeds but before the crosswalk is
290 // checkpointed. Build a complete destination slug index up front so a resumed run
291 // recovers that write instead of treating the source record as a new product.
292 const baselineProductIds = catalogBaseline. protectedIds (projectDir).products;
293 const destinationProducts = await writers. queryAllStoresProducts (wix);
294 const heldProducts = new Map (destinationProducts. filter (( item ) => baselineProductIds. has (item.id)). map (( item ) => [ slug (item.slug), item]));
295 const existingProducts = new Map (destinationProducts. filter (( item ) => ! baselineProductIds. has (item.id)). map (( item ) => [ slug (item.slug), item]));
296 const productRows = await readLines (path. join (sourceDir, 'product.ndjson' ));
297 const productSlugCounts = new Map ();
298 for ( const row of productRows) {
299 const key = slug (row.slug);
300 productSlugCounts. set (key, (productSlugCounts. get (key) || 0 ) + 1 );
301 }
302 for ( const row of productRows) {
303 // A saved target id is checked too: an earlier run may have adopted a baseline product.
304 if ( seen ( 'product' , row.id) && baselineProductIds. has ( seen ( 'product' , row.id))) { holdCollision ( 'stores/product' , 'product' , row.id, destinationProducts. find (( item ) => item.id === seen ( 'product' , row.id)) || { id: seen ( 'product' , row.id) }); continue ; }
305 if ( seen ( 'product' , row.id)) { products += 1 ; continue ; }
306 const baseSlug = slug (row.slug);
307 const sourceSlug = productSlugCounts. get (baseSlug) > 1 ? `${ baseSlug }-${ row . id }` : baseSlug;
308 const heldProduct = ! existingProducts. has (sourceSlug) && heldProducts. get (sourceSlug);
309 if (heldProduct) { holdCollision ( 'stores/product' , 'product' , row.id, heldProduct); continue ; }
310 const actualPrice = price (row.prices?.price, row.prices?.currency_minor_unit); const regular = price (row.prices?.regular_price, row.prices?.currency_minor_unit);
311 const normalized = recovery. normalizeProduct ({ sourceId: row.id, sku: nonEmpty (row.sku) || '' , tagIds: (row.tags || []). map (( tag ) => productTagIds. get ( String (tag.id))). filter (Boolean) });
312 const created = existingProducts. get (sourceSlug) || (productSlugCounts. get (baseSlug) > 1 && existingProducts. get (baseSlug)) || await writers. createStoresProduct (wix, { name: text (row.name), slug: sourceSlug, description: composeProductDescription ({ description: row.description, shortDescription: row.short_description }), productType: 'PHYSICAL' , visible: true , tags: { publicTags: { tagIds: normalized.tagIds } }, media: { itemsInfo: { items: resolveImageUrls (row.images). map (({ url , altText , displayName }) => ({ url, ... (altText ? { altText } : {}), ... (displayName ? { displayName } : {}) })) } }, variantsInfo: { variants: [{ visible: true , sku: normalized.sku || undefined , inStock: row.is_in_stock !== false , price: { actualPrice: { amount: actualPrice }, ... ( Number (regular) > Number (actualPrice) ? { compareAtPrice: { amount: regular } } : {}) } }] } });
313 if (normalized.recoveries. length ) { if ( ! dryRun) await recovery. appendRecoveries (projectDir, planResult.adapter, row.id, normalized.recoveries); recovery. recordRecoveredRecord (summary, normalized.recoveries); }
314 const targets = (row.categories || []). map (( category ) => categoryIds. get ( String (category.id))). filter (Boolean); if (targets. length ) await writers. bulkAddItemToCategories (wix, { productId: created.id, categoryIds: targets }); remember ( 'product' , row.id, created.id); if ( ! dryRun) catalogBaseline. clearCollision (projectDir, 'stores/product' , row.id); products += 1 ;
315 }
316 summary.imported.product = products;
317 capabilities. finishQuickEntity (quickRun, 'product' );
318 }
319 if ( ! dryRun) await writeJson (path. join (projectDir, 'state' , 'crosswalk' , 'quick-mode.json' ), crosswalk);
320 // A refusal means the run did not import everything: it is never reported completed (as in Shopify's quick mode).
321 if ( ! dryRun && Object. keys (regionalRefused). length ) summary.status = 'partial' ;
322 Object. assign (summary, await sourceCoverage. quickSummaryCoverage (projectDir));
323 catalogBaseline. quickReport (projectDir, summary);
324 await recovery. writeFinalReport (projectDir, summary, { dryRun });
325 await writeJson (path. join (projectDir, 'execution' , dryRun ? 'dry-run-completion-report.json' : 'completion-report.json' ), summary);
326 return summary;
327 }
328 async function main () {
329 const [ command , projectDirArg ] = process.argv. slice ( 2 );
330 if ( ! command || ! projectDirArg || ! [ 'preflight' , 'plan' , 'setup-author' , 'extract' , 'import' ]. includes (command)) throw new Error ( 'Usage: quick-mode.js <preflight|plan|setup-author|extract|import> <projectDir>' );
331 const result = await ({ preflight, plan, 'setup-author' : ensureFallbackAuthor, extract, import: importData })[command](path. resolve (projectDirArg));
332 process.stdout. write ( `${ JSON . stringify ({ ok: true , command , status: result.status || 'completed' }) } \n ` );
333 }
334 main (). catch ( async ( error ) => { process.stderr. write ( `${ error . message } \n ` ); process. exit ( await wixAuth. stopExitCode (error, process.argv[ 3 ] && path. resolve (process.argv[ 3 ]))); });