UNPKG

sb-mig

Version:

CLI to rule the world. (and handle stuff related to Storyblok CMS)

447 lines (446 loc) 17.1 kB
import chalk from "chalk"; import { mapWithConcurrency } from "../../utils/async-utils.js"; import Logger from "../../utils/logger.js"; import { notNullish } from "../../utils/object-utils.js"; import { getAllItemsWithPagination } from "../utils/request.js"; const resolveStoryLabel = (content, storyId) => content?.full_slug || content?.slug || content?.name || String(storyId); const DEFAULT_PUBLISH_LANGUAGE = "[default]"; const STORY_CONTENT_FETCH_CONCURRENCY = 10; const isSkippedStoryPublishState = (publishState) => publishState?.shouldPublish === false; const isDefaultLanguageToken = (language) => language.toLowerCase() === "default" || language === DEFAULT_PUBLISH_LANGUAGE; const normalizePublishLanguageCodes = (languages) => { const normalized = languages.map((language) => { const trimmed = language.trim(); return isDefaultLanguageToken(trimmed) ? DEFAULT_PUBLISH_LANGUAGE : trimmed; }); const cleanLanguages = normalized.filter((language) => language.length > 0); if (cleanLanguages.length === 0) { throw new Error("Publish languages cannot be empty."); } return Array.from(new Set(cleanLanguages)); }; export const resolveStoryPublishState = (story) => { if (story?.published !== true) { return { status: "draft", shouldPublish: false, skipReason: "source_story_draft", message: "source story was draft-only", }; } if (story.unpublished_changes === true) { return { status: "published_with_unpublished_changes", shouldPublish: false, skipReason: "source_story_has_unpublished_changes", message: "source story had unpublished draft changes", }; } if (story.unpublished_changes !== false) { return { status: "published_unknown", shouldPublish: false, skipReason: "source_story_publish_state_unknown", message: "source story publish state was missing unpublished_changes", }; } return { status: "published_clean", shouldPublish: true, }; }; const withSkippedPublish = ({ updateResult, story, storyId, spaceId, publishLanguages, publishState, }) => { const storyLabel = resolveStoryLabel(story, String(storyId)); Logger.warning(`Skipping publish for story '${storyLabel}' in space '${spaceId}' because ${publishState.message}.`); return { ...updateResult, sourcePublishState: publishState.status, publishSkippedReason: publishState.skipReason, ...(publishLanguages ? { publishLanguages } : {}), }; }; export const parsePublishLanguagesOption = (publishLanguages) => { if (!publishLanguages) { return "default"; } const trimmed = publishLanguages.trim(); if (trimmed.length === 0) { return "default"; } if (trimmed.toLowerCase() === "all") { return "all"; } const languages = normalizePublishLanguageCodes(trimmed.split(",")); if (languages.length === 1 && languages[0] === DEFAULT_PUBLISH_LANGUAGE) { return "default"; } return languages; }; const readSpaceLanguageCodes = (spaceResponse) => { const space = spaceResponse?.data?.space || spaceResponse?.space || {}; const languageSources = [ space.languages, space.language_codes, space.options?.languages, ]; for (const source of languageSources) { if (!Array.isArray(source)) { continue; } return source .map((language) => { if (typeof language === "string") { return language; } if (typeof language?.code === "string") { return language.code; } return ""; }) .filter((language) => language.trim().length > 0); } return []; }; export const resolvePublishLanguageCodes = async (publishLanguages, config) => { if (!publishLanguages || publishLanguages === "default") { return [DEFAULT_PUBLISH_LANGUAGE]; } if (Array.isArray(publishLanguages)) { return normalizePublishLanguageCodes(publishLanguages); } const spaceResponse = await config.sbApi.get(`spaces/${config.spaceId}`); const spaceLanguageCodes = readSpaceLanguageCodes(spaceResponse); if (spaceLanguageCodes.length === 0) { Logger.warning(`No configured Storyblok languages were found for space '${config.spaceId}'. Publishing only ${DEFAULT_PUBLISH_LANGUAGE}.`); } return normalizePublishLanguageCodes([ DEFAULT_PUBLISH_LANGUAGE, ...spaceLanguageCodes, ]); }; const resolveStoryblokErrorResponse = (err) => { if (typeof err?.response === "string" && err.response.trim().length > 0) { return err.response.trim(); } if (typeof err?.response?.data === "string" && err.response.data.trim().length > 0) { return err.response.data.trim(); } if (typeof err?.response?.message === "string" && err.response.message.trim().length > 0) { return err.response.message.trim(); } if (typeof err?.message === "string" && err.message.trim().length > 0) { return err.message.trim(); } return undefined; }; export const removeStory = (args, config) => { const { storyId } = args; const { spaceId, sbApi } = config; Logger.log(`Removing ${storyId} from ${spaceId}`); return sbApi .delete(`spaces/${spaceId}/stories/${storyId}`, {}) .then((res) => { Logger.success(`Successfully removed: ${res.data.story.name} with ${res.data.story.id} id.`); return res; }) .catch(() => { Logger.error(`Unable to remove: ${storyId}`); }); }; export const removeAllStories = async (config) => { const { spaceId } = config; Logger.warning(`Trying to remove all stories from space with spaceId: ${spaceId}`); const stories = await getAllStories({}, config); const onlyRootStories = (story) => story.story.parent_id === 0 || story.story.parent_id === null; const allResponses = Promise.all(stories .filter(onlyRootStories) .map(async (story) => await removeStory({ storyId: story?.story?.id }, config))); return allResponses; }; // GET export const getAllStories = async (args, config) => { const { options } = args; const { spaceId, sbApi } = config; Logger.log(`Trying to get all Stories from: ${spaceId}`); const params = notNullish({ with_slug: options?.with_slug, starts_with: options?.starts_with, language: options?.language, }); console.log("These are params i will use: "); console.log(params); const allStoriesWithoutContent = await getAllItemsWithPagination({ apiFn: ({ per_page, page }) => sbApi.get(`spaces/${spaceId}/stories/`, { ...params, per_page, page, }), params: { spaceId, }, itemsKey: "stories", }); Logger.success(`Successfully pre-fetched ${allStoriesWithoutContent.length} stories.`); let heartBeat = 0; const allStories = await mapWithConcurrency(allStoriesWithoutContent, STORY_CONTENT_FETCH_CONCURRENCY, async (story) => { const result = await getStoryById(story.id, config); heartBeat++; if (heartBeat % 10 === 0 || heartBeat === allStoriesWithoutContent.length) { Logger.success(`Successfully fetched ${heartBeat} stories with full content.`); } return result; }); return allStories; }; // GET export const getStoryById = (storyId, config) => { const { spaceId, sbApi, debug } = config; if (debug) { console.log(`Trying to get Story with id: ${storyId} from space: ${spaceId}, to fill content field.`); } return sbApi .get(`spaces/${spaceId}/stories/${storyId}`) .then((res) => { if (debug) { Logger.success(`Successfuly fetched story with content, with id: ${storyId} from space: ${spaceId}.`); } return res.data; }) .catch((err) => Logger.error(err)); }; export const getStoryBySlug = async (slug, config) => { const { spaceId, sbApi } = config; const storiesWithoutContent = await sbApi .get(`spaces/${spaceId}/stories/`, { per_page: 100, // @ts-ignore with_slug: slug, }) .then((res) => res.data.stories) .catch((err) => console.error(err)); const storiesWithContent = await Promise.all(storiesWithoutContent.map(async (story) => await getStoryById(story.id, config))); return storiesWithContent[0]; }; // CREATE export const createStory = (content, config) => { const { spaceId, sbApi } = config; Logger.log(`Creating story with name: ${content.name} in space: ${spaceId}`); return sbApi .post(`spaces/${spaceId}/stories/`, { story: content, publish: true, }) .then((res) => res.data) .catch((err) => console.error(err)); }; // UPDATE export const updateStory = (content, storyId, options, config) => { const { spaceId, sbApi } = config; const storyLabel = resolveStoryLabel(content, storyId); Logger.warning("Trying to update Story..."); Logger.log(`Updating story with name: ${content.name} in space: ${spaceId}`); // console.log("THis is content to update: "); // console.log(JSON.stringify(content, null, 2)); return sbApi .put(`spaces/${spaceId}/stories/${storyId}`, { story: content, publish: options.publish === true, force_update: options.force_update === true, }) .then((res) => { console.log(`${chalk.green(res.data.story.full_slug)} updated.`); return { ok: true, stage: "update", id: res.data.story.id, name: res.data.story.name, slug: res.data.story.full_slug, data: res.data, }; }) .catch((err) => { const status = err?.status || err?.response?.status; const responseMessage = resolveStoryblokErrorResponse(err); const statusLabel = status ? `status ${status}` : "unknown status"; const responseLabel = responseMessage ? ` Response: ${responseMessage}` : ""; Logger.error(`Failed to update story '${storyLabel}' in space '${spaceId}' (${statusLabel}).${responseLabel}`); return { ok: false, stage: "update", id: storyId, name: content?.name, slug: content?.full_slug || content?.slug, spaceId, status, response: responseMessage, error: err, }; }); }; export const publishStoryLanguages = async ({ storyId, story, languages, }, config) => { const { spaceId, sbApi } = config; const lang = languages.join(","); const storyLabel = resolveStoryLabel(story, String(storyId)); Logger.log(`Publishing story '${storyLabel}' in space '${spaceId}' for languages: ${lang}`); return sbApi .get(`spaces/${spaceId}/stories/${storyId}/publish`, { lang }) .then((res) => { const publishedStory = res?.data?.story || story || {}; Logger.success(`Published story '${storyLabel}' for languages: ${lang}`); return { ok: true, stage: "publish", id: publishedStory.id || storyId, name: publishedStory.name || story?.name, slug: publishedStory.full_slug || publishedStory.slug || story?.full_slug || story?.slug, spaceId, publishLanguages: languages, data: res?.data, }; }) .catch((err) => { const status = err?.status || err?.response?.status; const responseMessage = resolveStoryblokErrorResponse(err); const statusLabel = status ? `status ${status}` : "unknown status"; const responseLabel = responseMessage ? ` Response: ${responseMessage}` : ""; Logger.error(`Failed to publish story '${storyLabel}' in space '${spaceId}' (${statusLabel}).${responseLabel}`); return { ok: false, stage: "publish", id: storyId, name: story?.name, slug: story?.full_slug || story?.slug, spaceId, status, response: responseMessage, publishLanguages: languages, error: err, }; }); }; export const updateStories = async (args, config) => { const { stories, options, spaceId } = args; const shouldPublishLanguages = options.publish && options.publishLanguages !== undefined; const shouldPreservePublishState = Boolean(options.preservePublishState); const publishLanguages = shouldPublishLanguages ? await resolvePublishLanguageCodes(options.publishLanguages, { ...config, spaceId, }) : undefined; return Promise.allSettled( // Run through stories, and update the space with migrated version of stories stories.map(async (stories) => { const story = stories.story; const publishState = shouldPreservePublishState ? resolveStoryPublishState(story) : undefined; const shouldPublishStory = options.publish && (!publishState || publishState.shouldPublish); const updateResult = await updateStory(story, story.id, { publish: shouldPublishStory && !shouldPublishLanguages, force_update: options.force_update, }, { ...config, spaceId }); if (options.publish && updateResult?.ok && isSkippedStoryPublishState(publishState)) { return withSkippedPublish({ updateResult, story, storyId: story.id, spaceId, publishLanguages, publishState, }); } if (!shouldPublishLanguages || !publishLanguages || !updateResult?.ok) { return updateResult; } return publishStoryLanguages({ storyId: story.id, story, languages: publishLanguages, }, { ...config, spaceId }); })); }; export const upsertStory = async (args, config) => { console.log("Modifying story... in space with id:"); console.log(config.spaceId); const { storyId, storySlug, content } = args; console.log("This are args passed: "); console.log(args); if (storyId) { // if this exist than we update story with this id console.log("You've selected storyid!"); } else if (storySlug) { // if this exist than we update story with this slug (probably when we try to add story from one space to another, console.log("You've selected slug!"); const foundStory = await getStoryBySlug(storySlug, config); console.log("This is story"); console.log(foundStory); if (foundStory) { // then update the story } else { const { story: { parent_id, id, parent, ...rest }, } = content; console.log("We are going to create story"); const response = await createStory(rest, config); console.log("This is response"); console.log(response); } } else { // if this exist than we create new story console.log("Nothing passed, creating story..."); } }; export const deepUpsertStory = async (args, config) => { console.log("Modifying story... in space with id:"); console.log(config.spaceId); const { storyId, storySlug, content } = args; console.log("This are args passed: "); console.log(args); if (storyId) { // if this exist than we update story with this id console.log("You've selected storyid!"); } else if (storySlug) { // if this exist than we update story with this slug (probably when we try to add story from one space to another, console.log("You've selected slug!"); const slugs = storySlug.split("/"); console.log("Slugs for which we need to check for existence of stories"); console.log(slugs); // const foundStory = await managementApi.stories.getStoryBySlug(storySlug, config) // console.log("This is story") // console.log(foundStory) // if(foundStory) { // // then update the story // } else { // const {story: {parent_id, id, parent,...rest}} = content // console.log("We are going to create story") // const response = await managementApi.stories.createStory(rest, config) // console.log("This is response") // console.log(response) // } } else { // if this exist than we create new story console.log("Nothing passed, creating story..."); } };