Setting the file. One moment.
Seed Blog · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page async function labelIdMap
— line 157
This file
Number 16.106
Position 106 of 828
Type JavaScript
Size 17 KB
Lines 338 blog/seed/ seed-blog.mjs
JavaScript · 338 lines · 17 KB
15 // "tags"?, "coverImageMediaId"? | "coverImageUrl"? | "coverImagePrompt"? }] }
16 // content blocks: { type:"heading", text, level? } | { type:"paragraph", text }
17 // | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,…] }
18 //
19 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
20 // read the live API reference; every call below
21 // carries a docs: line with its reference page.
22 import { readFileSync } from "node:fs" ;
23 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
24 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
25 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
26 import { isMain } from "../../shared/seed/main.mjs" ;
27
28 const API = "https://www.wixapis.com" ;
29 const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103" ;
30
31 export function makeCtx ({ cwd = process. cwd () } = {}) {
32 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
33 // a seed there unless --allow-parent is passed after the user confirmed).
34 const siteId = seedSiteId ({ cwd, argv: process.argv });
35 const token = wixToken (siteId, cwd);
36 return { token, siteId };
37 }
38
39 async function req ( ctx , path , { method = "POST" , body } = {}) {
40 const res = await fetch ( API + path, {
41 method,
42 headers: {
43 Authorization: `Bearer ${ ctx . token }` ,
44 "wix-site-id" : ctx.siteId,
45 "Content-Type" : "application/json" ,
46 },
47 body: body ? JSON . stringify (body) : undefined ,
48 });
49 const json = await res. json (). catch (() => ({}));
50 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
51 return json;
52 }
53
54 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
55
56 // ---- Ricos richContent builder (setup-blog.md § "CRITICAL RICOS NESTING") --------------------
57 // Rules baked in: TEXT is always a leaf inside a container; BLOCKQUOTE / LIST_ITEM wrap a
58 // PARAGRAPH; BULLETED_LIST / ORDERED_LIST wrap LIST_ITEM -> PARAGRAPH -> TEXT; every container
59 // node gets a unique id, TEXT leaves use id "". For node types not covered here (code, images)
60 // pass a pre-built `richContent` on the post instead — it's used verbatim.
61 const mkText = ( text ) => ({ type: "TEXT" , id: "" , nodes: [], textData: { text: text || "" , decorations: [] } });
62 const mkParagraph = ( id , text ) => ({ type: "PARAGRAPH" , id, nodes: [ mkText (text)], paragraphData: {} });
63
64 function mkRichContent ( blocks = [], postIdx = 0 ) {
65 let n = 0 ;
66 const id = () => `p${ postIdx }-n${ n ++ }` ;
67 const nodes = [];
68 for ( const b of blocks) {
69 switch (b.type) {
70 case "heading" :
71 nodes. push ({ type: "HEADING" , id: id (), nodes: [ mkText (b.text)], headingData: { level: b.level ?? 2 } });
72 break ;
73 case "quote" :
74 nodes. push ({ type: "BLOCKQUOTE" , id: id (), nodes: [ mkParagraph ( id (), b.text)], blockquoteData: { indentation: 1 } });
75 break ;
76 case "bulleted" :
77 case "ordered" : {
78 const listType = b.type === "bulleted" ? "BULLETED_LIST" : "ORDERED_LIST" ;
79 nodes. push ({
80 type: listType, id: id (),
81 nodes: (b.items ?? []). map (( item ) => ({ type: "LIST_ITEM" , id: id (), nodes: [ mkParagraph ( id (), item)] })),
82 });
83 break ;
84 }
85 case "paragraph" :
86 default :
87 nodes. push ( mkParagraph ( id (), b.text));
88 }
89 }
90 return { nodes };
91 }
92
93 // One post's plain data -> a flat Blog V3 draft-post object. media is omitted — covers are a
94 // separate pass (attachPostCovers), per the recipe.
95 function buildPost ( p , i , memberId ) {
96 return {
97 title: p.title,
98 memberId,
99 richContent: p.richContent ?? mkRichContent (p.content, i),
100 ... (p.categoryIds ? { categoryIds: p.categoryIds } : {}),
101 ... (p.tagIds ? { tagIds: p.tagIds } : {}),
102 };
103 }
104
105 // ---- operations ----------------------------------------------------------------------------
106
107 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
108 export async function installBlogApp ( ctx ) {
109 try {
110 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
111 tenant: { tenantType: "SITE" , id: ctx.siteId },
112 appInstance: { appDefId: BLOG_APP_ID , enabled: true },
113 } });
114 } catch {
115 /* already installed is fine */
116 }
117 }
118
119 // Every post create needs a REAL author memberId — a fabricated id fails with
120 // "memberIds ... do not exist". A provisioned site has the owner as members[0].
121 // docs: https://dev.wix.com/docs/api-reference/crm/members-contacts/members/member-management/members/list-members.md
122 export async function getAuthorMemberId ( ctx ) {
123 const r = await req (ctx, "/members/v1/members?fieldsets=PUBLIC&paging.limit=1" , { method: "GET" });
124 const id = r.members?.[ 0 ]?.id;
125 if ( ! id) throw new Error ( `No site member found for author attribution: ${ JSON . stringify ( r ). slice ( 0 , 400 ) }` );
126 return id;
127 }
128
129 /**
130 * Create posts, PUBLISHED (publish:true — unpublished posts never reach visitors).
131 * Endpoint auto-selected per the recipe: single-post endpoint for exactly one (nested
132 * `{ draftPost }` envelope), bulk for >= 2 — `bulk` sits BETWEEN v3 and draft-posts, and each
133 * bulk item is FLAT (wrapping it in `draftPost` 400s). Returns [{ id, index, success }].
134 * docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/create-draft-post.md
135 * docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/bulk-create-draft-posts.md
136 */
137 export async function createPosts ( ctx , posts , { memberId , publish = true } = {}) {
138 if ( ! memberId) throw new Error ( "createPosts requires opts.memberId (see getAuthorMemberId)" );
139 if (posts. length === 1 ) {
140 const r = await req (ctx, "/blog/v3/draft-posts" , { body: { draftPost: buildPost (posts[ 0 ], 0 , memberId), publish } });
141 return [{ id: r.draftPost?.id, index: 0 , success: !! r.draftPost?.id }];
142 }
143 const r = await req (ctx, "/blog/v3/bulk/draft-posts/create" , {
144 body: { draftPosts: posts. map (( p , i ) => buildPost (p, i, memberId)), publish },
145 });
146 // Bulk returns 200 even on partial failure — read per-item results[].itemMetadata.success.
147 return (r.results ?? []). map (( x ) => ({
148 id: x.itemMetadata?.id, index: x.itemMetadata?.originalIndex, success: !! x.itemMetadata?.success,
149 }));
150 }
151
152 // Existing label -> id, straight from the query (the source of truth for what persisted).
153 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/query-categories.md
154 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/query-tags.md
155 // Both live under /blog/v3/ on the public gateway (the SDK's host mapping also accepts the bare
156 // /v3/tags alias the earlier seeds called — one path for one endpoint here, the same one rest/ uses).
157 async function labelIdMap ( ctx , kind ) {
158 const path = kind === "categories" ? "/blog/v3/categories/query" : "/blog/v3/tags/query" ;
159 const r = await req (ctx, path, { body: { query: { paging: { limit: 100 } } } });
160 return new Map ((r[kind] ?? []). map (( x ) => [x.label, x.id]));
161 }
162
163 // Create category/tag labels resiliently. TWO hazards this absorbs:
164 // 1. Fresh-install provisioning window — for a few seconds after the Blog app installs,
165 // per-item category/tag creates return 200 with an id but DON'T persist (the id is a lie).
166 // So the create response is never trusted — re-query, treat the query as truth, re-create
167 // what's still missing until it sticks.
168 // 2. Idempotency — an already-present label (a partial-failure re-run) is skipped.
169 // Category bodies are NESTED (`{ category: { label } }`); tag bodies are FLAT (`{ label }`) —
170 // a `{ tag: { label } }` body sends an empty top-level label and 400s. Returns [{ id, name }].
171 async function ensureLabels ( ctx , kind , createPath , mkBody , names ) {
172 let map = await labelIdMap (ctx, kind);
173 for ( let attempt = 0 ; attempt < 8 ; attempt ++ ) {
174 const missing = names. filter (( n ) => ! map. has (n));
175 if ( ! missing. length ) break ;
176 if (attempt) await sleep ( 1500 ); // backoff only between retries — happy path pays nothing
177 for ( const name of missing) {
178 try { await req (ctx, createPath, { body: mkBody (name) }); }
179 catch (e) { if ( ! String (e.message). includes ( "-> 409" )) throw e; } // 409 = raced, already there
180 }
181 map = await labelIdMap (ctx, kind);
182 }
183 return names. map (( name ) => ({ id: map. get (name), name }));
184 }
185
186 /** Create categories idempotently by label (no bulk endpoint). Feed ids into post.categoryIds. */
187 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/create-category.md
188 export async function createCategories ( ctx , names ) {
189 return ensureLabels (ctx, "categories" , "/blog/v3/categories" , ( name ) => ({ category: { label: name } }), names);
190 }
191
192 /** Create tags idempotently by label. Feed ids into post.tagIds. */
193 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/create-tag.md
194 export async function createTags ( ctx , names ) {
195 return ensureLabels (ctx, "tags" , "/blog/v3/tags" , ( name ) => ({ label: name }), names);
196 }
197
198 // Blog binds a post cover by Wix Media file ID — an external url must be imported first; a
199 // plan `coverImagePrompt` is generated (Wix AI, 1 credit) then imported. Both live in the
200 // shared util (parallel, resilient, never blocks the seed).
201 export { importImage } from "../../shared/seed/images.mjs" ;
202
203 // covers: [{ postId, fileId }] where fileId is the Wix Media file.id from importImage. Per
204 // post: PATCH /blog/v3/draft-posts/{id} (NOT POST …/{id}/update — that 404s), setting
205 // media.displayed:true + media.custom:true + wixMedia.image.id (the id ALONE is a silent
206 // no-op), then RE-PUBLISH — the PATCH sets hasUnpublishedChanges, so the live post stays
207 // cover-less until republished. Image failures never block the run.
208 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/update-draft-post.md
209 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/publish-draft-post.md
210 export async function attachPostCovers ( ctx , covers ) {
211 let attached = 0 ;
212 for ( const { postId , fileId } of covers) {
213 try {
214 await req (ctx, `/blog/v3/draft-posts/${ postId }` , {
215 method: "PATCH" ,
216 body: { draftPost: { media: { displayed: true , custom: true , wixMedia: { image: { id: fileId } } } } },
217 });
218 await req (ctx, `/blog/v3/draft-posts/${ postId }/publish` , { method: "POST" });
219 attached ++ ;
220 } catch (e) {
221 console. error ( `cover attach skipped for post ${ postId }: ${ e . message }` );
222 }
223 }
224 return attached;
225 }
226
227 /**
228 * ONE-CALL seed: install → author memberId → categories/tags (names resolved to ids) →
229 * published posts → covers, ids threaded in memory. The default path.
230 */
231 export async function setupBlog ( ctx , { posts = [], categories = [], tags = [] } = {}) {
232 await installBlogApp (ctx);
233 await sleep ( 3000 ); // let a fresh Blog install settle so the first category/tag writes stick
234 // (correctness is still guaranteed by ensureLabels' verify-retry).
235 const memberId = await getAuthorMemberId (ctx);
236
237 const catNames = [ ...new Set ([ ... categories, ... posts. flatMap (( p ) => []. concat (p.category ?? [], p.categories ?? []))])];
238 const tagNames = [ ...new Set ([ ... tags, ... posts. flatMap (( p ) => []. concat (p.tags ?? []))])];
239 const cats = catNames. length ? await createCategories (ctx, catNames) : [];
240 const tgs = tagNames. length ? await createTags (ctx, tagNames) : [];
241 const catId = new Map (cats. map (( c ) => [c.name, c.id]));
242 const tagId = new Map (tgs. map (( t ) => [t.name, t.id]));
243
244 const created = await createPosts (ctx, posts. map (( p ) => {
245 const cn = []. concat (p.category ?? [], p.categories ?? []);
246 const tn = []. concat (p.tags ?? []);
247 return {
248 ... p,
249 ... (cn. length ? { categoryIds: cn. map (( n ) => catId. get (n)). filter (Boolean) } : {}),
250 ... (tn. length ? { tagIds: tn. map (( n ) => tagId. get (n)). filter (Boolean) } : {}),
251 };
252 }), { memberId });
253
254 // Pass 2 — covers: resolve (import by url / generate by prompt) in one parallel wave, then
255 // attach (PATCH + re-publish per post). Failures leave the post text-only; the seed's exit
256 // never depends on images.
257 const files = await resolveItemImages (ctx, created. map (( c , i ) => (
258 c?.id && c?.success
259 ? { mediaId: posts[i]?.coverImageMediaId, path: posts[i]?.coverImagePath, url: posts[i]?.coverImageUrl, prompt: posts[i]?.coverImagePrompt, displayName: `post-${ i }.png` }
260 : null
261 )));
262 const covers = created
263 . map (( c , i ) => (files[i] ? { postId: c.id, fileId: files[i].id } : null ))
264 . filter (Boolean);
265 const coversAttached = covers. length ? await attachPostCovers (ctx, covers) : 0 ;
266
267 // Pass 3 — a fresh Blog install can wipe categories (and tags) created in its first minute AFTER
268 // the query has confirmed them (run 151: three categories confirmed by the seed, gone by release,
269 // the posts left pointing at dead ids). So they are read back BY ID here, at the end; a missing one
270 // is created again and every post that named it is re-linked (PATCH the draft, publish).
271 const relinked = await ensureLabelsPersisted (ctx, { cats, tgs, created, posts, catId, tagId });
272
273 return { posts: created, categories: relinked.categories, tags: relinked.tags, coversAttached, relinkedPosts: relinked.posts };
274 }
275
276 // GET /blog/v3/categories/{id} · GET /blog/v3/tags/{id} — 404 when the fresh-install wipe took it.
277 async function labelExists ( ctx , kind , id ) {
278 try {
279 await req (ctx, `/blog/v3/${ kind }/${ id }` , { method: "GET" });
280 return true ;
281 } catch (e) {
282 if ( String (e.message). includes ( "-> 404" )) return false ;
283 throw e;
284 }
285 }
286
287 async function ensureLabelsPersisted ( ctx , { cats , tgs , created , posts , catId , tagId }) {
288 const remapCat = new Map ();
289 const remapTag = new Map ();
290 const missingCats = [];
291 const missingTags = [];
292 for ( const c of cats) if (c.id && ! ( await labelExists (ctx, "categories" , c.id))) missingCats. push (c);
293 for ( const t of tgs) if (t.id && ! ( await labelExists (ctx, "tags" , t.id))) missingTags. push (t);
294 if ( ! missingCats. length && ! missingTags. length ) return { categories: cats, tags: tgs, posts: 0 };
295 const freshCats = missingCats. length ? await createCategories (ctx, missingCats. map (( c ) => c.name)) : [];
296 const freshTags = missingTags. length ? await createTags (ctx, missingTags. map (( t ) => t.name)) : [];
297 for ( const [ i , c ] of missingCats. entries ()) if (freshCats[i]?.id) { remapCat. set (c.id, freshCats[i].id); catId. set (c.name, freshCats[i].id); }
298 for ( const [ i , t ] of missingTags. entries ()) if (freshTags[i]?.id) { remapTag. set (t.id, freshTags[i].id); tagId. set (t.name, freshTags[i].id); }
299 let relinked = 0 ;
300 for ( const [ i , c ] of created. entries ()) {
301 if ( ! c?.id || ! c?.success) continue ;
302 const cn = []. concat (posts[i]?.category ?? [], posts[i]?.categories ?? []);
303 const tn = []. concat (posts[i]?.tags ?? []);
304 const wantsCats = cn. map (( n ) => catId. get (n)). filter (Boolean);
305 const wantsTags = tn. map (( n ) => tagId. get (n)). filter (Boolean);
306 const touched = cn. some (( n ) => [ ... remapCat. values ()]. includes (catId. get (n))) || tn. some (( n ) => [ ... remapTag. values ()]. includes (tagId. get (n)));
307 if ( ! touched) continue ;
308 try {
309 await req (ctx, `/blog/v3/draft-posts/${ c . id }` , { method: "PATCH" , body: { draftPost: { ... (cn. length ? { categoryIds: wantsCats } : {}), ... (tn. length ? { tagIds: wantsTags } : {}) } } });
310 await req (ctx, `/blog/v3/draft-posts/${ c . id }/publish` , { method: "POST" });
311 relinked ++ ;
312 } catch (e) {
313 console. error ( `relink skipped for post ${ c . id }: ${ e . message }` );
314 }
315 }
316 const categories = cats. map (( c ) => ({ ... c, id: remapCat. get (c.id) ?? c.id, recreated: remapCat. has (c.id) || undefined }));
317 const tags = tgs. map (( t ) => ({ ... t, id: remapTag. get (t.id) ?? t.id, recreated: remapTag. has (t.id) || undefined }));
318 return { categories, tags, posts: relinked };
319 }
320
321 // ---- CLI entry ----------------------------------------------------------------------------------
322
323 const invokedDirectly = isMain ( import . meta .url);
324 if (invokedDirectly) {
325 const planPath = process.argv[ 2 ];
326 if ( ! planPath) {
327 console. error ( "usage: node seed-blog.mjs <plan.json> (run from the project root)" );
328 process. exit ( 1 );
329 }
330 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
331 const ctx = makeCtx ();
332 setupBlog (ctx, plan)
333 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
334 . catch (( e ) => {
335 console. error (e.message);
336 process. exit ( 1 );
337 });
338 }