Setting the file. One moment. Quick Mode · Rp Quick Woocommerce · wix/skills · Skills Docs102
function text
— line 102
This file
- Number
- 40.1
- Position
- 1 of 2
- Type
- JavaScript
- Size
- 18 KB
- Lines
- 223
scripts/quick-mode.js
JavaScript·223 lines·18 KB
ADAPTER_DIR
=
path.
resolve
(__dirname,
'..'
);
11const CONTRACT = require(path.join(ADAPTER_DIR, 'quick-mode.json'));
12const recovery = require(path.join(ADAPTER_DIR, '..', 'rp-quick-runtime', 'lib', 'limit-recovery.js'));
13
14async function readJson(filePath) {
15 return JSON.parse(await fs.readFile(filePath, 'utf8'));
16}
17async function writeJson(filePath, value) {
18 await fs.mkdir(path.dirname(filePath), { recursive: true });
19 await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
20}
21async function appendAudit(projectDir, entry) {
22 const file = path.join(projectDir, 'logs', 'quick-mode-audit.ndjson');
23 await fs.mkdir(path.dirname(file), { recursive: true });
24 await fs.appendFile(file, `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, 'utf8');
25}
26function origin(value) {
27 const url = new URL(value);
28 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('sourceUrl must use http or https');
29 return url.origin;
30}
31function fingerprint(value) {
32 return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
33}
34async function projectContext(projectDir) {
35 const decisions = await readJson(path.join(projectDir, 'orchestration', 'decisions.json'));
36 const sourceUrl = decisions.sourceUrl && decisions.sourceUrl.value;
37 const sourcePlatform = decisions.sourcePlatform && decisions.sourcePlatform.value;
38 if (!sourceUrl) throw new Error('quick mode requires orchestration.decisions.sourceUrl');
39 if (sourcePlatform !== 'woocommerce') throw new Error(`quick WooCommerce adapter requires sourcePlatform=woocommerce (got ${sourcePlatform || 'missing'})`);
40 return { sourceUrl: origin(sourceUrl), decisions };
41}
42async function fetchCollection(baseUrl, route) {
43 const records = [];
44 const pages = [];
45 for (let page = 1; ; page += 1) {
46 const url = new URL(route, `${baseUrl}/`);
47 url.searchParams.set('page', String(page));
48 url.searchParams.set('per_page', '100');
49 const response = await fetch(url, { headers: { accept: 'application/json' } });
50 const text = await response.text();
51 let body;
52 try { body = text ? JSON.parse(text) : null; } catch { body = null; }
53 if (!response.ok || !Array.isArray(body)) {
54 return { ok: false, status: response.status, url: url.toString(), bodyType: Array.isArray(body) ? 'array' : typeof body, records, pages };
55 }
56 records.push(...body);
57 pages.push({ page, count: body.length, url: url.toString() });
58 const totalPages = Number(response.headers.get('x-wp-totalpages'));
59 if (Number.isFinite(totalPages) ? page >= totalPages : body.length < 100) break;
60 }
61 return { ok: true, records, pages };
62}
63async function preflight(projectDir) {
64 const { sourceUrl } = await projectContext(projectDir);
65 const checks = [];
66 for (const entity of CONTRACT.entities) {
67 const result = await fetchCollection(sourceUrl, entity.route);
68 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 });
69 }
70 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 };
71 await writeJson(path.join(projectDir, 'quick-mode', 'preflight.json'), out);
72 return out;
73}
74async function plan(projectDir) {
75 const { sourceUrl } = await projectContext(projectDir);
76 const preflightResult = await readJson(path.join(projectDir, 'quick-mode', 'preflight.json'));
77 if (preflightResult.status !== 'passed') throw new Error('quick mode preflight must pass before planning');
78 const adapter = { id: CONTRACT.id, version: CONTRACT.version, fingerprint: fingerprint(CONTRACT) };
79 const planResult = { schemaVersion: 1, generatedAt: new Date().toISOString(), managementImportMode: 'quick', adapter, sourceUrl, entities: CONTRACT.entities, requiredWixCapabilities: CONTRACT.requiredWixCapabilities, excluded: CONTRACT.excluded, sourceAuth: 'none' };
80 await writeJson(path.join(projectDir, 'quick-mode', 'plan.json'), planResult);
81 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) });
82 return planResult;
83}
84async function extract(projectDir) {
85 const { sourceUrl } = await projectContext(projectDir);
86 const planResult = await readJson(path.join(projectDir, 'quick-mode', 'plan.json'));
87 if (planResult.adapter.fingerprint !== fingerprint(CONTRACT)) throw new Error('quick adapter contract changed; re-run preflight and plan');
88 const entities = {};
89 for (const entity of CONTRACT.entities) {
90 const result = await fetchCollection(sourceUrl, entity.route);
91 if (!result.ok) throw new Error(`${entity.id} extraction failed: ${result.status} ${result.url}`);
92 const file = path.join(projectDir, 'data', 'source-extract', `${entity.id}.ndjson`);
93 await fs.mkdir(path.dirname(file), { recursive: true });
94 await fs.writeFile(file, result.records.map((record) => JSON.stringify(record)).join('\n') + (result.records.length ? '\n' : ''), 'utf8');
95 entities[entity.id] = { file: path.relative(projectDir, file), route: entity.route, recordCount: result.records.length, pages: result.pages };
96 }
97 const manifest = { schemaVersion: 1, generatedAt: new Date().toISOString(), managementImportMode: 'quick', adapter: planResult.adapter, sourceUrl, entities };
98 await writeJson(path.join(projectDir, 'data', 'source-extract', 'manifest.json'), manifest);
99 return manifest;
100}
101function nonEmpty(value) { const text = value == null ? '' : String(value).trim(); return text || undefined; }
102function text(value) { return String(value || '').replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/"/g, '"').trim(); }
103function slug(value) { return nonEmpty(value)?.toLowerCase().replace(/[_\s]+/g, '-').replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || undefined; }
104function price(value, minor = 2) { const n = Number(value); return Number.isFinite(n) ? (n / (10 ** Number(minor || 2))).toFixed(Number(minor || 2)) : '0.00'; }
105async function readLines(filePath) { const raw = await fs.readFile(filePath, 'utf8'); return raw.split(/\r?\n/).filter(Boolean).map(JSON.parse); }
106async function loadWixEnv(projectDir) {
107 const values = {};
108 try {
109 for (const line of (await fs.readFile(path.join(projectDir, 'config', 'wix.env'), 'utf8')).split(/\r?\n/)) {
110 const m = line.trim().match(/^([A-Z0-9_]+)=(.*)$/); if (m) values[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim();
111 }
112 } catch (error) { if (error.code !== 'ENOENT') throw error; }
113 return { ...values, ...process.env };
114}
115async function loadCrosswalk(projectDir) {
116 try { return await readJson(path.join(projectDir, 'state', 'crosswalk', 'quick-mode.json')); }
117 catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
118}
119async function ensureFallbackAuthor(projectDir) {
120 const env = await loadWixEnv(projectDir);
121 if (env.WIX_BLOG_FALLBACK_MEMBER_ID) return { status: 'already_configured', memberId: env.WIX_BLOG_FALLBACK_MEMBER_ID };
122 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');
123 const writers = require(path.join(ADAPTER_DIR, '..', 'rp-target-wix', 'lib', 'wix-writers.js'));
124 const dryRun = writers.createDryRunConfig(env, process.argv.slice(2)).dryRun;
125 if (dryRun) return { status: 'planned_dry_run', memberId: null };
126 const wix = writers.createWixClient({ dryRun: false, authToken: env.WIX_AUTH_TOKEN, siteId: env.WIX_SITE_ID, projectDir });
127 const projectId = path.basename(projectDir).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'migration';
128 const email = `replatform+${projectId}@wix.com`;
129 const listed = await writers.listMembers(wix, { limit: 100 });
130 let member = (listed.members || []).find((item) => String(item.loginEmail || '').toLowerCase() === email);
131 if (!member) member = await writers.createMember(wix, { email, name: 'Imported content', slug: `imported-content-${projectId}` });
132 if (!member || !member.id) throw new Error('fallback Blog author was not created');
133 const configPath = path.join(projectDir, 'config', 'wix.env');
134 let raw = ''; try { raw = await fs.readFile(configPath, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
135 await fs.mkdir(path.dirname(configPath), { recursive: true });
136 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');
137 return { status: 'provisioned', memberId: member.id };
138}
139async function importData(projectDir) {
140 const planResult = await readJson(path.join(projectDir, 'quick-mode', 'plan.json'));
141 const manifest = await readJson(path.join(projectDir, 'data', 'source-extract', 'manifest.json'));
142 if (manifest.adapter.fingerprint !== planResult.adapter.fingerprint) throw new Error('extraction does not match quick plan; re-extract');
143 const env = await loadWixEnv(projectDir);
144 if (!env.WIX_AUTH_TOKEN || !env.WIX_SITE_ID) throw new Error('WIX_AUTH_TOKEN and WIX_SITE_ID are required for quick import');
145 const writers = require(path.join(ADAPTER_DIR, '..', 'rp-target-wix', 'lib', 'wix-writers.js'));
146 const dryRun = writers.createDryRunConfig(env, process.argv.slice(2)).dryRun;
147 const wix = writers.createWixClient({ dryRun, authToken: env.WIX_AUTH_TOKEN, siteId: env.WIX_SITE_ID, projectDir });
148 const sourceDir = path.join(projectDir, 'data', 'source-extract');
149 const crosswalk = await loadCrosswalk(projectDir);
150 const seen = (entity, sourceId) => crosswalk[entity] && crosswalk[entity][String(sourceId)];
151 const remember = (entity, sourceId, targetId) => { if (!dryRun) { crosswalk[entity] ||= {}; crosswalk[entity][String(sourceId)] = targetId; } };
152 const checkpointCrosswalk = async () => { if (!dryRun) await writeJson(path.join(projectDir, 'state', 'crosswalk', 'quick-mode.json'), crosswalk); };
153 const summary = { schemaVersion: 1, generatedAt: new Date().toISOString(), managementImportMode: 'quick', adapter: planResult.adapter, dryRun, imported: {}, recovered: 0, recoveryCounts: {}, excluded: CONTRACT.excluded };
154 const categoryIds = new Map(); const productTagIds = new Map(); const blogCategoryIds = new Map(); const blogTagIds = new Map();
155 const existingCategories = new Map((await writers.queryAllStoresCategories(wix)).map((item) => [slug(item.slug), item]));
156 for (const row of await readLines(path.join(sourceDir, 'product_category.ndjson'))) {
157 if (seen('product_category', row.id)) { categoryIds.set(String(row.id), seen('product_category', row.id)); continue; }
158 const sourceSlug = slug(row.slug); const existing = existingCategories.get(sourceSlug);
159 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);
160 remember('product_category', row.id, target.id); await checkpointCrosswalk();
161 }
162 summary.imported.product_category = categoryIds.size;
163 const existingProductTags = new Map(((await writers.sendDirectRest(wix, { method: 'GET', path: '/tags/v1/tags?fqdn=wix.stores.catalog.v3.product' })).tags || []).map((item) => [slug(item.name), item]));
164 for (const row of await readLines(path.join(sourceDir, 'product_tag.ndjson'))) {
165 if (seen('product_tag', row.id)) { productTagIds.set(String(row.id), seen('product_tag', row.id)); continue; }
166 const key = slug(row.name); const existing = existingProductTags.get(key);
167 const response = existing || (await writers.sendDirectRest(wix, { method: 'POST', path: '/tags/v1/tags', body: { tag: { name: text(row.name), fqdn: 'wix.stores.catalog.v3.product' } } })).tag;
168 productTagIds.set(String(row.id), response.id); remember('product_tag', row.id, response.id); await checkpointCrosswalk();
169 }
170 summary.imported.product_tag = productTagIds.size;
171 const existingBlogCategories = new Map(((await writers.sendDirectRest(wix, { method: 'POST', path: '/blog/v3/categories/query', body: {} })).categories || []).map((item) => [slug(item.slug), item]));
172 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(); }
173 summary.imported.blog_category = blogCategoryIds.size;
174 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); }
175 summary.imported.blog_tag = blogTagIds.size;
176 const fallbackMemberId = env.WIX_BLOG_FALLBACK_MEMBER_ID;
177 if (!fallbackMemberId) throw new Error('setup must provision WIX_BLOG_FALLBACK_MEMBER_ID before importing blog posts');
178 let posts = 0;
179 for (const row of await readLines(path.join(sourceDir, 'post.ndjson'))) {
180 if (seen('post', row.id)) { posts += 1; continue; }
181 await appendAudit(projectDir, { phase: 'post', sourceId: String(row.id), state: 'creating_draft' });
182 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() });
183 await appendAudit(projectDir, { phase: 'post', sourceId: String(row.id), state: 'publishing_draft', targetId: draft.id });
184 if (!dryRun) await writers.publishDraftPost(wix, draft.id); remember('post', row.id, draft.id); posts += 1;
185 await checkpointCrosswalk(); await appendAudit(projectDir, { phase: 'post', sourceId: String(row.id), state: 'completed', targetId: draft.id });
186 }
187 summary.imported.post = posts;
188 let products = 0;
189 // A process can stop after a product create succeeds but before the crosswalk is
190 // checkpointed. Build a complete destination slug index up front so a resumed run
191 // recovers that write instead of treating the source record as a new product.
192 const existingProducts = new Map(
193 (await writers.queryAllStoresProducts(wix)).map((item) => [slug(item.slug), item]),
194 );
195 const productRows = await readLines(path.join(sourceDir, 'product.ndjson'));
196 const productSlugCounts = new Map();
197 for (const row of productRows) {
198 const key = slug(row.slug);
199 productSlugCounts.set(key, (productSlugCounts.get(key) || 0) + 1);
200 }
201 for (const row of productRows) {
202 if (seen('product', row.id)) { products += 1; continue; }
203 const baseSlug = slug(row.slug);
204 const sourceSlug = productSlugCounts.get(baseSlug) > 1 ? `${baseSlug}-${row.id}` : baseSlug;
205 const actualPrice = price(row.prices?.price, row.prices?.currency_minor_unit); const regular = price(row.prices?.regular_price, row.prices?.currency_minor_unit);
206 const normalized = recovery.normalizeProduct({ sourceId: row.id, sku: nonEmpty(row.sku) || '', tagIds: (row.tags || []).map((tag) => productTagIds.get(String(tag.id))).filter(Boolean) });
207 const created = existingProducts.get(sourceSlug) || (productSlugCounts.get(baseSlug) > 1 && existingProducts.get(baseSlug)) || await writers.createStoresProduct(wix, { name: text(row.name), slug: sourceSlug, description: row.description || row.short_description || undefined, productType: 'PHYSICAL', visible: true, tags: { publicTags: { tagIds: normalized.tagIds } }, media: { itemsInfo: { items: (row.images || []).map((image) => image.src).filter(Boolean).map((url) => ({ url })) } }, 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 } } : {}) } }] } });
208 if (normalized.recoveries.length) { await recovery.appendRecoveries(projectDir, planResult.adapter, row.id, normalized.recoveries); recovery.recordRecoveredRecord(summary, normalized.recoveries); }
209 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); products += 1;
210 }
211 summary.imported.product = products;
212 if (!dryRun) await writeJson(path.join(projectDir, 'state', 'crosswalk', 'quick-mode.json'), crosswalk);
213 await recovery.writeFinalReport(projectDir, summary);
214 await writeJson(path.join(projectDir, 'execution', 'completion-report.json'), summary);
215 return summary;
216}
217async function main() {
218 const [command, projectDirArg] = process.argv.slice(2);
219 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>');
220 const result = await ({ preflight, plan, 'setup-author': ensureFallbackAuthor, extract, import: importData })[command](path.resolve(projectDirArg));
221 process.stdout.write(`${JSON.stringify({ ok: true, command, status: result.status || 'completed' })}\n`);
222}
223main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exit(1); });