diff --git a/.changeset/clever-yaks-push.md b/.changeset/clever-yaks-push.md new file mode 100644 index 0000000000..8f0b743b85 --- /dev/null +++ b/.changeset/clever-yaks-push.md @@ -0,0 +1,6 @@ +--- +"@studiocms/devapps": patch +"@studiocms/core": patch +--- + +Expand PageData table schema and add Catagory and Tags schemas, and extend WP-importer diff --git a/packages/studiocms_core/src/db/config.ts b/packages/studiocms_core/src/db/config.ts index d323753654..28be127aff 100644 --- a/packages/studiocms_core/src/db/config.ts +++ b/packages/studiocms_core/src/db/config.ts @@ -3,6 +3,8 @@ import { defineDb } from 'astro:db'; import { StudioCMSPageContent, StudioCMSPageData, + StudioCMSPageDataCategories, + StudioCMSPageDataTags, StudioCMSPermissions, StudioCMSSessionTable, StudioCMSSiteConfig, @@ -14,6 +16,8 @@ export default defineDb({ tables: { StudioCMSPageContent, StudioCMSPageData, + StudioCMSPageDataCategories, + StudioCMSPageDataTags, StudioCMSPermissions, StudioCMSSessionTable, StudioCMSSiteConfig, diff --git a/packages/studiocms_core/src/db/tables.ts b/packages/studiocms_core/src/db/tables.ts index 6ca1bd86fc..2ba60b045d 100644 --- a/packages/studiocms_core/src/db/tables.ts +++ b/packages/studiocms_core/src/db/tables.ts @@ -46,6 +46,29 @@ export const StudioCMSPageData = defineTable({ default: 'https://images.unsplash.com/photo-1707343843982-f8275f3994c5?q=80&w=1032&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }), + catagories: column.json({ default: [], optional: true }), + tags: column.json({ default: [], optional: true }), + }, +}); + +export const StudioCMSPageDataTags = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + description: column.text(), + name: column.text(), + slug: column.text(), + meta: column.json({ default: {} }), + }, +}); + +export const StudioCMSPageDataCategories = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + parent: column.number({ optional: true }), + description: column.text(), + name: column.text(), + slug: column.text(), + meta: column.json({ default: {} }), }, }); @@ -65,6 +88,8 @@ export const StudioCMSSiteConfig = defineTable({ id: column.number({ primaryKey: true }), title: column.text(), description: column.text(), + defaultOgImage: column.text({ optional: true }), + siteIcon: column.text({ optional: true }), }, }); diff --git a/packages/studiocms_core/src/db/tsTables.ts b/packages/studiocms_core/src/db/tsTables.ts index a014bba615..9fac4e3b4f 100644 --- a/packages/studiocms_core/src/db/tsTables.ts +++ b/packages/studiocms_core/src/db/tsTables.ts @@ -2,6 +2,8 @@ import { asDrizzleTable } from '@astrojs/db/utils'; import { StudioCMSPageContent, StudioCMSPageData, + StudioCMSPageDataCategories, + StudioCMSPageDataTags, StudioCMSPermissions, StudioCMSSessionTable, StudioCMSSiteConfig, @@ -20,6 +22,21 @@ export const tsPageContent = asDrizzleTable('StudioCMSPageContent', StudioCMSPag */ export const tsPageData = asDrizzleTable('StudioCMSPageData', StudioCMSPageData); +/** + * # StudioCMS - Page Data Categories Table + * @description Exported TypeSafe Table definition for use in StudioCMS Integrations + */ +export const tsPageDataCategories = asDrizzleTable( + 'StudioCMSPageDataCategories', + StudioCMSPageDataCategories +); + +/** + * # StudioCMS - Page Data Tags Table + * @description Exported TypeSafe Table definition for use in StudioCMS Integrations + */ +export const tsPageDataTags = asDrizzleTable('StudioCMSPageDataTags', StudioCMSPageDataTags); + /** * # StudioCMS - Permissions Table * @description Exported TypeSafe Table definition for use in StudioCMS Integrations diff --git a/packages/studiocms_devapps/src/apps/wp-importer.ts b/packages/studiocms_devapps/src/apps/wp-importer.ts index 4847fb9015..e27d900a4c 100644 --- a/packages/studiocms_devapps/src/apps/wp-importer.ts +++ b/packages/studiocms_devapps/src/apps/wp-importer.ts @@ -139,6 +139,7 @@ export default defineToolbarApp({ diff --git a/packages/studiocms_devapps/src/routes/wp-api-importer.ts b/packages/studiocms_devapps/src/routes/wp-api-importer.ts index 34e38b1163..46d68a264f 100644 --- a/packages/studiocms_devapps/src/routes/wp-api-importer.ts +++ b/packages/studiocms_devapps/src/routes/wp-api-importer.ts @@ -1,5 +1,9 @@ import type { APIContext, APIRoute } from 'astro'; -import { importPagesFromWPAPI, importPostsFromWPAPI } from '../utils/wp-api'; +import { + importPagesFromWPAPI, + importPostsFromWPAPI, + importSettingsFromWPAPI, +} from '../utils/wp-api'; export const POST: APIRoute = async ({ request }: APIContext) => { const data = await request.formData(); @@ -41,6 +45,9 @@ export const POST: APIRoute = async ({ request }: APIContext) => { case 'posts': await importPostsFromWPAPI(url, useBlogPluginValue); break; + case 'settings': + await importSettingsFromWPAPI(url); + break; default: throw new Error('Invalid import type'); } diff --git a/packages/studiocms_devapps/src/schema/wp-api/index.ts b/packages/studiocms_devapps/src/schema/wp-api/index.ts index cae120d1a2..07f3ab4292 100644 --- a/packages/studiocms_devapps/src/schema/wp-api/index.ts +++ b/packages/studiocms_devapps/src/schema/wp-api/index.ts @@ -43,5 +43,35 @@ export const PostSchema = PageSchema.extend({ tags: z.array(z.number()), }); +export const TagSchema = z.object({ + id: z.number(), + count: z.number(), + description: z.string(), + link: z.string().url(), + name: z.string(), + slug: z.string(), + taxonomy: z.string(), + meta: z.array(z.any()).or(z.record(z.any())), +}); + +export const CategorySchema = TagSchema.extend({ + parent: z.number(), +}); + +export const SiteSettingsSchema = z.object({ + name: z.string(), + description: z.string(), + url: z.string(), + home: z.string(), + gmt_offset: z.coerce.number(), + timezone_string: z.string(), + site_logo: z.number().optional(), + site_icon: z.number().optional(), + site_icon_url: z.string().optional(), +}); + export type Page = typeof PageSchema._output; export type Post = typeof PostSchema._output; +export type Tag = typeof TagSchema._output; +export type Category = typeof CategorySchema._output; +export type SiteSettings = typeof SiteSettingsSchema._output; diff --git a/packages/studiocms_devapps/src/utils/wp-api/converters.ts b/packages/studiocms_devapps/src/utils/wp-api/converters.ts index ae431b9b1b..13b43ca477 100644 --- a/packages/studiocms_devapps/src/utils/wp-api/converters.ts +++ b/packages/studiocms_devapps/src/utils/wp-api/converters.ts @@ -1,8 +1,10 @@ import path from 'node:path'; +import { db, eq } from 'astro:db'; import Config from 'virtual:studiocms-devapps/wp-api/configPath'; +import { tsPageDataCategories, tsPageDataTags } from '@studiocms/core/db/tsTables'; import { decode } from 'html-entities'; import TurndownService from 'turndown'; -import type { Page, Post } from '../../schema/wp-api'; +import type { Category, Page, Post, Tag } from '../../schema/wp-api'; import type { PageContent, PageData } from './index'; import { apiEndpoint, @@ -17,11 +19,11 @@ const WPImportFolder = path.resolve(ASTROPUBLICFOLDER, 'wp-import'); const pagesImagesFolder = path.resolve(WPImportFolder, 'pages'); const postsImagesFolder = path.resolve(WPImportFolder, 'posts'); -export const ConvertToPageData = async (page: unknown): Promise => { +export const ConvertToPageData = async (page: unknown, endpoint: string): Promise => { const data = page as Page; const titleImageId = data.featured_media; - const titleImageURL = apiEndpoint(`${titleImageId}`, 'media'); + const titleImageURL = apiEndpoint(endpoint, 'media', `${titleImageId}`); const titleImageResponse = await fetch(titleImageURL); const titleImageJson = await titleImageResponse.json(); const titleImage = await downloadPostImage(titleImageJson.source_url, pagesImagesFolder); @@ -76,17 +78,112 @@ export const ConvertToPageContent = async ( return pageContent; }; -export const ConvertToPostData = async (post: unknown, useBlogPkg: boolean): Promise => { +export const generateCatagories = async (categories: number[], endpoint: string) => { + const newCatagories: Category[] = []; + + for (const catagoryId of categories) { + // Check if catagory already exists in the database + const catagoryExists = await db + .select() + .from(tsPageDataCategories) + .where(eq(tsPageDataCategories.id, catagoryId)) + .get(); + + if (catagoryExists) { + console.log(`Catagory with id ${catagoryId} already exists in the database`); + continue; + } + + const catagoryURL = apiEndpoint(endpoint, 'catagories', `${catagoryId}`); + const response = await fetch(catagoryURL); + const json = await response.json(); + newCatagories.push(json); + } + + if (newCatagories.length > 0) { + const catagoryData = newCatagories.map((catagory) => { + const data: typeof tsPageDataCategories.$inferInsert = { + id: catagory.id, + name: catagory.name, + slug: catagory.slug, + description: catagory.description, + meta: JSON.stringify(catagory.meta), + }; + + if (catagory.parent) { + data.parent = catagory.parent; + } + + return data; + }); + + for (const catagory of catagoryData) { + console.log(`Inserting catagory with id ${catagory.id} into the database`); + await db.insert(tsPageDataCategories).values(catagory); + } + } +}; + +export const generateTags = async (tags: number[], endpoint: string) => { + const newTags: Tag[] = []; + + for (const tagId of tags) { + // Check if tag already exists in the database + const tagExists = await db + .select() + .from(tsPageDataTags) + .where(eq(tsPageDataTags.id, tagId)) + .get(); + + if (tagExists) { + console.log(`Tag with id ${tagId} already exists in the database`); + continue; + } + + const tagURL = apiEndpoint(endpoint, 'tags', `${tagId}`); + const response = await fetch(tagURL); + const json = await response.json(); + newTags.push(json); + } + + if (newTags.length > 0) { + const tagData = newTags.map((tag) => { + const data: typeof tsPageDataTags.$inferInsert = { + id: tag.id, + name: tag.name, + slug: tag.slug, + description: tag.description, + meta: JSON.stringify(tag.meta), + }; + + return data; + }); + + for (const tag of tagData) { + console.log(`Inserting tag with id ${tag.id} into the database`); + await db.insert(tsPageDataTags).values(tag); + } + } +}; + +export const ConvertToPostData = async ( + post: unknown, + useBlogPkg: boolean, + endpoint: string +): Promise => { const data = post as Post; const titleImageId = data.featured_media; - const titleImageURL = apiEndpoint(`${titleImageId}`, 'media'); + const titleImageURL = apiEndpoint(endpoint, 'media', `${titleImageId}`); const titleImageResponse = await fetch(titleImageURL); const titleImageJson = await titleImageResponse.json(); const titleImage = await downloadPostImage(titleImageJson.source_url, pagesImagesFolder); const pkg = useBlogPkg ? '@studiocms/blog' : 'studiocms'; + await generateCatagories(data.categories, endpoint); + await generateTags(data.tags, endpoint); + const pageData: PageData = { id: crypto.randomUUID(), title: data.title.rendered, @@ -97,6 +194,8 @@ export const ConvertToPostData = async (post: unknown, useBlogPkg: boolean): Pro showOnNav: false, contentLang: 'default', package: pkg, + catagories: JSON.stringify(data.categories), + tags: JSON.stringify(data.tags), }; if (titleImage) { diff --git a/packages/studiocms_devapps/src/utils/wp-api/index.ts b/packages/studiocms_devapps/src/utils/wp-api/index.ts index a5ed7c12fe..68166bf8ac 100644 --- a/packages/studiocms_devapps/src/utils/wp-api/index.ts +++ b/packages/studiocms_devapps/src/utils/wp-api/index.ts @@ -1,34 +1,39 @@ +import path from 'node:path'; /// -import { db } from 'astro:db'; -import { tsPageContent, tsPageData } from '@studiocms/core/db/tsTables'; -import type { Page } from '../../schema/wp-api'; +import { db, eq } from 'astro:db'; +import Config from 'virtual:studiocms-devapps/wp-api/configPath'; +import { CMSSiteConfigId } from '@studiocms/core/consts'; +import { tsPageContent, tsPageData, tsSiteConfig } from '@studiocms/core/db/tsTables'; +import type { Page, SiteSettings } from '../../schema/wp-api'; import { ConvertToPageContent, ConvertToPageData, ConvertToPostContent, ConvertToPostData, } from './converters'; -import { apiEndpoint, fetchAll } from './utils'; +import { apiEndpoint, downloadPostImage, fetchAll } from './utils'; + +const ASTROPUBLICFOLDER = path.resolve(Config.projectRoot, 'public'); export type PageData = typeof tsPageData.$inferInsert; export type PageContent = typeof tsPageContent.$inferInsert; -const generatePageFromData = async (page: unknown) => { - const pageData = await ConvertToPageData(page); +const generatePageFromData = async (page: unknown, endpoint: string) => { + const pageData = await ConvertToPageData(page, endpoint); const pageContent = await ConvertToPageContent(pageData, page); return { pageData, pageContent }; }; -const generatePostFromData = async (post: unknown, useBlogPkg: boolean) => { - const pageData = await ConvertToPostData(post, useBlogPkg); +const generatePostFromData = async (post: unknown, useBlogPkg: boolean, endpoint: string) => { + const pageData = await ConvertToPostData(post, useBlogPkg, endpoint); const pageContent = await ConvertToPostContent(pageData, post); return { pageData, pageContent }; }; -const importPage = async (page: unknown) => { - const { pageData, pageContent } = await generatePageFromData(page); +const importPage = async (page: unknown, endpoint: string) => { + const { pageData, pageContent } = await generatePageFromData(page, endpoint); const pageDataResult = await db .insert(tsPageData) @@ -65,15 +70,15 @@ export const importPagesFromWPAPI = async (endpoint: string) => { try { for (const page of pages) { console.log('importing page:', page.title.rendered); - await importPage(page); + await importPage(page, endpoint); } } catch (error) { console.error('Failed to import pages from WP-API:', error); } }; -const importPost = async (post: unknown, useBlogPkg: boolean) => { - const { pageData, pageContent } = await generatePostFromData(post, useBlogPkg); +const importPost = async (post: unknown, useBlogPkg: boolean, endpoint: string) => { + const { pageData, pageContent } = await generatePostFromData(post, useBlogPkg, endpoint); const pageDataResult = await db .insert(tsPageData) @@ -110,9 +115,60 @@ export const importPostsFromWPAPI = async (endpoint: string, useBlogPkg: boolean try { for (const post of posts) { console.log('importing post: ', post.title.rendered); - await importPost(post, useBlogPkg); + await importPost(post, useBlogPkg, endpoint); } } catch (error) { console.error('Failed to import posts from WP-API: ', error); } }; + +export const importSettingsFromWPAPI = async (endpoint: string) => { + const url = apiEndpoint(endpoint, 'settings'); + + console.log('Fetching site settings from: ', url.origin); + + const response = await fetch(url); + const settings: SiteSettings = await response.json(); + + console.log('Importing site settings: ', settings); + + let siteIcon: string | undefined = undefined; + + if (settings.site_icon_url) { + siteIcon = await downloadPostImage(settings.site_icon_url, ASTROPUBLICFOLDER); + } + + if (!settings.site_icon_url && settings.site_logo) { + const siteLogoURL = apiEndpoint(endpoint, 'media', `${settings.site_logo}`); + const siteLogoResponse = await fetch(siteLogoURL); + const siteLogoJson = await siteLogoResponse.json(); + siteIcon = await downloadPostImage(siteLogoJson.source_url, ASTROPUBLICFOLDER); + } + + const siteConfig: typeof tsSiteConfig.$inferInsert = { + id: CMSSiteConfigId, + title: settings.name, + description: settings.description, + }; + + if (siteIcon) { + siteConfig.siteIcon = siteIcon; + } + + try { + const insert = await db + .update(tsSiteConfig) + .set(siteConfig) + .where(eq(tsSiteConfig.id, CMSSiteConfigId)) + .returning({ id: tsSiteConfig.id }) + .get(); + + if (insert) { + console.log('Updated site settings'); + } else { + console.error('Failed to update site settings'); + } + } catch (error) { + console.error('Failed to import site settings from WP-API: ', error); + } +}; diff --git a/packages/studiocms_devapps/src/utils/wp-api/utils.ts b/packages/studiocms_devapps/src/utils/wp-api/utils.ts index dcdc920d87..e09486476c 100644 --- a/packages/studiocms_devapps/src/utils/wp-api/utils.ts +++ b/packages/studiocms_devapps/src/utils/wp-api/utils.ts @@ -106,7 +106,11 @@ export const downloadAndUpdateImages = async (html: string, pathToFolder: string return $.html(); }; -export const apiEndpoint = (endpoint: string, type: 'posts' | 'pages' | 'media') => { +export const apiEndpoint = ( + endpoint: string, + type: 'posts' | 'pages' | 'media' | 'catagories' | 'tags' | 'settings', + path?: string +) => { if (!endpoint) { throw new AstroError( 'Missing `endpoint` argument.', @@ -115,8 +119,15 @@ export const apiEndpoint = (endpoint: string, type: 'posts' | 'pages' | 'media') } let newEndpoint = endpoint; if (!newEndpoint.endsWith('/')) newEndpoint += '/'; + const apiBase = new URL(newEndpoint); - apiBase.pathname = `wp-json/wp/v2/${type}`; + + if (type === 'settings') { + apiBase.pathname = 'wp-json/'; + return apiBase; + } + + apiBase.pathname = `wp-json/wp/v2/${type}/${path ? `${path}/` : ''}`; return apiBase; };