diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 92d637ff4a4..6f6f08de40b 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -6,6 +6,7 @@ import * as db from "../db/db.js" import { imageStore } from "../db/model/Image.js" import { GdocXImage } from "../db/model/GdocXImage.js" import * as wpdb from "../db/wpdb.js" +import { DEPRECATEDgetTopics } from "../db/DEPRECATEDwpdb.js" import { UNCATEGORIZED_TAG_ID, BAKE_ON_CHANGE, @@ -567,7 +568,7 @@ apiRouter.get( ) apiRouter.get("/topics.json", async (req: Request, res: Response) => ({ - topics: await wpdb.getTopics(), + topics: await DEPRECATEDgetTopics(), })) apiRouter.get( "/editorData/variables.json", diff --git a/baker/GrapherBaker.tsx b/baker/GrapherBaker.tsx index 5251c9a9b0e..d2592c903b8 100644 --- a/baker/GrapherBaker.tsx +++ b/baker/GrapherBaker.tsx @@ -16,7 +16,6 @@ import { } from "@ourworldindata/utils" import { getRelatedArticles, - getRelatedCharts, getRelatedChartsForVariable, getRelatedResearchAndWritingForVariable, isWordpressAPIEnabled, @@ -33,7 +32,10 @@ import { import * as db from "../db/db.js" import { glob } from "glob" import { isPathRedirectedToExplorer } from "../explorerAdminServer/ExplorerRedirects.js" -import { getPostEnrichedBySlug } from "../db/model/Post.js" +import { + getPostEnrichedBySlug, + getPostRelatedCharts, +} from "../db/model/Post.js" import { JsonError, GrapherInterface, @@ -319,7 +321,7 @@ const renderGrapherPage = async (grapher: GrapherInterface) => { const post = postSlug ? await getPostEnrichedBySlug(postSlug) : undefined const relatedCharts = post && isWordpressDBEnabled - ? await getRelatedCharts(post.id) + ? await getPostRelatedCharts(post.id) : undefined const relatedArticles = grapher.id && isWordpressAPIEnabled diff --git a/baker/SiteBaker.tsx b/baker/SiteBaker.tsx index caf2d333483..ffe8a633074 100644 --- a/baker/SiteBaker.tsx +++ b/baker/SiteBaker.tsx @@ -75,7 +75,13 @@ import { bakeAllPublishedExplorers, } from "./ExplorerBaker.js" import { ExplorerAdminServer } from "../explorerAdminServer/ExplorerAdminServer.js" -import { postsTable } from "../db/model/Post.js" +import { + getBlogIndex, + getFullPost, + getPostsFromSnapshots, + postsFlushCache, + postsTable, +} from "../db/model/Post.js" import { GdocPost } from "../db/model/Gdoc/GdocPost.js" import { Image } from "../db/model/Image.js" import { generateEmbedSnippet } from "../site/viteUtils.js" @@ -424,11 +430,14 @@ export class SiteBaker { private async removeDeletedPosts() { if (!this.bakeSteps.has("removeDeletedPosts")) return - const postsApi = await wpdb.getPosts() + + await db.getConnection() + + const postsApi = await getPostsFromSnapshots() const postSlugs = [] for (const postApi of postsApi) { - const post = await wpdb.getFullPost(postApi) + const post = await getFullPost(postApi) postSlugs.push(post.slug) } @@ -454,7 +463,7 @@ export class SiteBaker { const alreadyPublishedViaGdocsSlugsSet = await db.getSlugsWithPublishedGdocsSuccessors(db.knexInstance()) - const postsApi = await wpdb.getPosts( + const postsApi = await getPostsFromSnapshots( undefined, (postrow) => !alreadyPublishedViaGdocsSlugsSet.has(postrow.slug) ) @@ -462,7 +471,7 @@ export class SiteBaker { await pMap( postsApi, async (postApi) => - wpdb.getFullPost(postApi).then((post) => this.bakePost(post)), + getFullPost(postApi).then((post) => this.bakePost(post)), { concurrency: 10 } ) @@ -783,7 +792,7 @@ export class SiteBaker { // Bake the blog index private async bakeBlogIndex() { if (!this.bakeSteps.has("blogIndex")) return - const allPosts = await wpdb.getBlogIndex() + const allPosts = await getBlogIndex() const numPages = Math.ceil(allPosts.length / BLOG_POSTS_PER_PAGE) for (let i = 1; i <= numPages; i++) { @@ -962,7 +971,7 @@ export class SiteBaker { private flushCache() { // Clear caches to allow garbage collection while waiting for next run - wpdb.flushCache() + postsFlushCache() siteBakingFlushCache() redirectsFlushCache() this.progressBar.tick({ name: "✅ cache flushed" }) diff --git a/baker/algolia/indexToAlgolia.tsx b/baker/algolia/indexToAlgolia.tsx index 402490c36e0..c1f51ec998e 100644 --- a/baker/algolia/indexToAlgolia.tsx +++ b/baker/algolia/indexToAlgolia.tsx @@ -26,6 +26,7 @@ import { Pageview } from "../../db/model/Pageview.js" import { GdocPost } from "../../db/model/Gdoc/GdocPost.js" import { ArticleBlocks } from "../../site/gdocs/components/ArticleBlocks.js" import React from "react" +import { getFullPost, getPostsFromSnapshots } from "../../db/model/Post.js" interface TypeAndImportance { type: PageType @@ -92,7 +93,7 @@ async function generateWordpressRecords( const records: PageRecord[] = [] for (const postApi of postsApi) { - const rawPost = await wpdb.getFullPost(postApi) + const rawPost = await getFullPost(postApi) if (isEmpty(rawPost.content)) { // we have some posts that are only placeholders (e.g. for a redirect); don't index these console.log( @@ -193,7 +194,7 @@ const getPagesRecords = async () => { // TODO: the knex instance should be handed down as a parameter const slugsWithPublishedGdocsSuccessors = await db.getSlugsWithPublishedGdocsSuccessors(db.knexInstance()) - const postsApi = await wpdb.getPosts(undefined, (post) => { + const postsApi = await getPostsFromSnapshots(undefined, (post) => { // Two things can happen here: // 1. There's a published Gdoc with the same slug // 2. This post has a Gdoc successor (which might have a different slug) diff --git a/baker/pageOverrides.tsx b/baker/pageOverrides.tsx index 17b895fdfde..c46b69e40b3 100644 --- a/baker/pageOverrides.tsx +++ b/baker/pageOverrides.tsx @@ -2,14 +2,17 @@ import { PageOverrides } from "../site/LongFormPage.js" import { BAKED_BASE_URL } from "../settings/serverSettings.js" import { urlToSlug, FullPost, JsonError } from "@ourworldindata/utils" import { FormattingOptions } from "@ourworldindata/types" -import { getPostBySlug, isPostCitable } from "../db/wpdb.js" import { getTopSubnavigationParentItem } from "../site/SiteSubnavigation.js" import { logErrorAndMaybeSendToBugsnag } from "../serverUtils/errorLog.js" +import { + getFullPostBySlugFromSnapshot, + isPostSlugCitable, +} from "../db/model/Post.js" export const getPostBySlugLogToSlackNoThrow = async (slug: string) => { let post try { - post = await getPostBySlug(slug) + post = await getFullPostBySlugFromSnapshot(slug) } catch (err) { logErrorAndMaybeSendToBugsnag(err) } finally { @@ -61,7 +64,7 @@ export const getPageOverrides = async ( const landing = await getLandingOnlyIfParent(post, formattingOptions) if (!landing) return - const isParentLandingCitable = await isPostCitable(landing) + const isParentLandingCitable = isPostSlugCitable(landing.slug) if (!isParentLandingCitable) return return { diff --git a/baker/siteRenderers.tsx b/baker/siteRenderers.tsx index a43dabcb1da..07b133e01b9 100644 --- a/baker/siteRenderers.tsx +++ b/baker/siteRenderers.tsx @@ -60,13 +60,6 @@ import { import { FormattingOptions, GrapherInterface } from "@ourworldindata/types" import { CountryProfileSpec } from "../site/countryProfileProjects.js" import { formatPost } from "./formatWordpressPost.js" -import { - getBlogIndex, - getLatestPostRevision, - getPostBySlug, - isPostCitable, - getBlockContent, -} from "../db/wpdb.js" import { queryMysql, knexTable } from "../db/db.js" import { getPageOverrides, isPageOverridesCitable } from "./pageOverrides.js" import { ProminentLink } from "../site/blocks/ProminentLink.js" @@ -87,7 +80,14 @@ import { ExplorerAdminServer } from "../explorerAdminServer/ExplorerAdminServer. import { GIT_CMS_DIR } from "../gitCms/GitCmsConstants.js" import { ExplorerFullQueryParams } from "../explorer/ExplorerConstants.js" import { resolveInternalRedirect } from "./redirects.js" -import { postsTable } from "../db/model/Post.js" +import { + getBlockContentFromSnapshot, + getBlogIndex, + getFullPostByIdFromSnapshot, + getFullPostBySlugFromSnapshot, + isPostSlugCitable, + postsTable, +} from "../db/model/Post.js" import { GdocPost } from "../db/model/Gdoc/GdocPost.js" import { logErrorAndMaybeSendToBugsnag } from "../serverUtils/errorLog.js" import { GdocFactory } from "../db/model/Gdoc/GdocFactory.js" @@ -190,12 +190,12 @@ export const renderGdoc = (gdoc: OwidGdoc, isPreviewing: boolean = false) => { } export const renderPageBySlug = async (slug: string) => { - const post = await getPostBySlug(slug) + const post = await getFullPostBySlugFromSnapshot(slug) return renderPost(post) } export const renderPreview = async (postId: number): Promise => { - const postApi = await getLatestPostRevision(postId) + const postApi = await getFullPostByIdFromSnapshot(postId) return renderPost(postApi) } @@ -229,7 +229,7 @@ export const renderPost = async ( const pageOverrides = await getPageOverrides(post, formattingOptions) const citationStatus = - (await isPostCitable(post)) || isPageOverridesCitable(pageOverrides) + isPostSlugCitable(post.slug) || isPageOverridesCitable(pageOverrides) return renderToHtmlPage( => { // Get formatted content from generic covid country profile page. - const genericCountryProfilePost = await getPostBySlug( + const genericCountryProfilePost = await getFullPostBySlugFromSnapshot( profileSpec.genericProfileSlug ) @@ -500,7 +500,7 @@ const getCountryProfilePost = memoize( // todo: we used to flush cache of this thing. const getCountryProfileLandingPost = memoize( async (profileSpec: CountryProfileSpec) => { - return getPostBySlug(profileSpec.landingPageSlug) + return getFullPostBySlugFromSnapshot(profileSpec.landingPageSlug) } ) @@ -559,7 +559,7 @@ const renderPostThumbnailBySlug = async ( let post try { - post = await getPostBySlug(slug) + post = await getFullPostBySlugFromSnapshot(slug) } catch (err) { // if no post is found, then we return early instead of throwing } @@ -599,7 +599,11 @@ export const renderProminentLinks = async ( ? (await Chart.getBySlug(resolvedUrl.slug))?.config ?.title // optim? : resolvedUrl.slug && - (await getPostBySlug(resolvedUrl.slug)).title) + ( + await getFullPostBySlugFromSnapshot( + resolvedUrl.slug + ) + ).title) } finally { if (!title) { logErrorAndMaybeSendToBugsnag( @@ -709,7 +713,7 @@ export const renderExplorerPage = async ( const wpContent = program.wpBlockId ? await renderReusableBlock( - await getBlockContent(program.wpBlockId), + await getBlockContentFromSnapshot(program.wpBlockId), program.wpBlockId ) : undefined diff --git a/baker/sitemap.ts b/baker/sitemap.ts index 5a563c3d792..fcc00552431 100644 --- a/baker/sitemap.ts +++ b/baker/sitemap.ts @@ -6,13 +6,13 @@ import { } from "../settings/serverSettings.js" import { dayjs, countries, queryParamsToStr } from "@ourworldindata/utils" import * as db from "../db/db.js" -import * as wpdb from "../db/wpdb.js" import urljoin from "url-join" import { countryProfileSpecs } from "../site/countryProfileProjects.js" import { ExplorerAdminServer } from "../explorerAdminServer/ExplorerAdminServer.js" import { EXPLORERS_ROUTE_FOLDER } from "../explorer/ExplorerConstants.js" import { ExplorerProgram } from "../explorer/ExplorerProgram.js" import { GdocPost } from "../db/model/Gdoc/GdocPost.js" +import { getPostsFromSnapshots } from "../db/model/Post.js" interface SitemapUrl { loc: string @@ -62,7 +62,7 @@ export const makeSitemap = async (explorerAdminServer: ExplorerAdminServer) => { const knex = db.knexInstance() const alreadyPublishedViaGdocsSlugsSet = await db.getSlugsWithPublishedGdocsSuccessors(knex) - const postsApi = await wpdb.getPosts( + const postsApi = await getPostsFromSnapshots( undefined, (postrow) => !alreadyPublishedViaGdocsSlugsSet.has(postrow.slug) ) diff --git a/db/DEPRECATEDwpdb.ts b/db/DEPRECATEDwpdb.ts new file mode 100644 index 00000000000..8de9003ddf5 --- /dev/null +++ b/db/DEPRECATEDwpdb.ts @@ -0,0 +1,255 @@ +/** + * + * DO NOT USE - DEPRECATED - FOR DOCUMENTATION PURPOSES ONLY + * + * Note: This file contains now deprecated functions that were querying the Wordpress + * tables or APIs. It is kept around for easier reference during the transition period + * but should not be used for new code. + */ + +import { + WP_PostType, + FilterFnPostRestApi, + PostRestApi, + FullPost, + JsonError, + Topic, +} from "@ourworldindata/types" +import { BLOG_SLUG } from "../settings/serverSettings.js" +import { + WP_API_ENDPOINT, + apiQuery, + getPostApiBySlugFromApi, + isWordpressAPIEnabled, + singleton, + OWID_API_ENDPOINT, + getEndpointSlugFromType, + getBlockApiFromApi, + graphqlQuery, + ENTRIES_CATEGORY_ID, +} from "./wpdb.js" +import { getFullPost } from "./model/Post.js" + +// Limit not supported with multiple post types: When passing multiple post +// types, the limit is applied to the resulting array of sequentially sorted +// posts (all blog posts, then all pages, ...), so there will be a predominance +// of a certain post type. +export const DEPRECATEDgetPosts = async ( + postTypes: string[] = [WP_PostType.Post, WP_PostType.Page], + filterFunc?: FilterFnPostRestApi, + limit?: number +): Promise => { + if (!isWordpressAPIEnabled) return [] + + const perPage = 20 + const posts: PostRestApi[] = [] + + for (const postType of postTypes) { + const endpoint = `${WP_API_ENDPOINT}/${getEndpointSlugFromType( + postType + )}` + + // Get number of items to retrieve + const headers = await apiQuery(endpoint, { + searchParams: [["per_page", 1]], + returnResponseHeadersOnly: true, + }) + const maxAvailable = headers.get("X-WP-TotalPages") + const count = limit && limit < maxAvailable ? limit : maxAvailable + + for (let page = 1; page <= Math.ceil(count / perPage); page++) { + const postsCurrentPage = await apiQuery(endpoint, { + searchParams: [ + ["per_page", perPage], + ["page", page], + ], + }) + posts.push(...postsCurrentPage) + } + } + + // Published pages excluded from public views + const excludedSlugs = [BLOG_SLUG] + + const filterConditions: Array = [ + (post): boolean => !excludedSlugs.includes(post.slug), + (post): boolean => !post.slug.endsWith("-country-profile"), + ] + if (filterFunc) filterConditions.push(filterFunc) + + const filteredPosts = posts.filter((post) => + filterConditions.every((c) => c(post)) + ) + + return limit ? filteredPosts.slice(0, limit) : filteredPosts +} + +// We might want to cache this as the network of prominent links densifies and +// multiple requests to the same posts are happening. +export const DEPRECATEDgetPostBySlugFromApi = async ( + slug: string +): Promise => { + if (!isWordpressAPIEnabled) { + throw new JsonError(`Need wordpress API to match slug ${slug}`, 404) + } + + const postApi = await getPostApiBySlugFromApi(slug) + + return getFullPost(postApi) +} + +// the /revisions endpoint does not send back all the metadata required for +// the proper rendering of the post (e.g. authors), hence the double request. +export const DEPRECATEDgetLatestPostRevision = async ( + id: number +): Promise => { + const type = await DEPRECATEDgetPostType(id) + const endpointSlug = getEndpointSlugFromType(type) + + const postApi = await apiQuery(`${WP_API_ENDPOINT}/${endpointSlug}/${id}`) + + const revision = ( + await apiQuery( + `${WP_API_ENDPOINT}/${endpointSlug}/${id}/revisions?per_page=1` + ) + )[0] + + // Since WP does not store metadata for revisions, some elements of a + // previewed page will not reflect the latest edits: + // - published date (will show the correct one - that is the one in the + // sidebar - for unpublished posts though. For published posts, the + // current published date is displayed, regardless of what is shown + // and could have been modified in the sidebar.) + // - authors + // ... + return getFullPost({ + ...postApi, + content: revision.content, + title: revision.title, + }) +} + +export const DEPRECATEDgetTagsByPostId = async (): Promise< + Map +> => { + const tagsByPostId = new Map() + const rows = await singleton.query(` + SELECT p.id, t.name + FROM wp_posts p + JOIN wp_term_relationships tr + on (p.id=tr.object_id) + JOIN wp_term_taxonomy tt + on (tt.term_taxonomy_id=tr.term_taxonomy_id + and tt.taxonomy='post_tag') + JOIN wp_terms t + on (tt.term_id=t.term_id) + `) + + for (const row of rows) { + let cats = tagsByPostId.get(row.id) + if (!cats) { + cats = [] + tagsByPostId.set(row.id, cats) + } + cats.push(row.name) + } + + return tagsByPostId +} + +// Retrieve a map of post ids to authors +let cachedAuthorship: Map | undefined +export const DEPRECATEDgetAuthorship = async (): Promise< + Map +> => { + if (cachedAuthorship) return cachedAuthorship + + const authorRows = await singleton.query(` + SELECT object_id, terms.description FROM wp_term_relationships AS rels + LEFT JOIN wp_term_taxonomy AS terms ON terms.term_taxonomy_id=rels.term_taxonomy_id + WHERE terms.taxonomy='author' + ORDER BY rels.term_order ASC + `) + + const authorship = new Map() + for (const row of authorRows) { + let authors = authorship.get(row.object_id) + if (!authors) { + authors = [] + authorship.set(row.object_id, authors) + } + authors.push(row.description.split(" ").slice(0, 2).join(" ")) + } + + cachedAuthorship = authorship + return authorship +} + +// todo / refactor : narrow down scope to getPostTypeById? +export const DEPRECATEDgetPostType = async ( + search: number | string +): Promise => { + const paramName = typeof search === "number" ? "id" : "slug" + return apiQuery(`${OWID_API_ENDPOINT}/type`, { + searchParams: [[paramName, search]], + }) +} + +let cachedFeaturedImages: Map | undefined +export const DEPRECATEDgetFeaturedImages = async (): Promise< + Map +> => { + if (cachedFeaturedImages) return cachedFeaturedImages + + const rows = await singleton.query( + `SELECT wp_postmeta.post_id, wp_posts.guid FROM wp_postmeta INNER JOIN wp_posts ON wp_posts.ID=wp_postmeta.meta_value WHERE wp_postmeta.meta_key='_thumbnail_id'` + ) + + const featuredImages = new Map() + for (const row of rows) { + featuredImages.set(row.post_id, row.guid) + } + + cachedFeaturedImages = featuredImages + return featuredImages +} + +export const DEPRECATEDgetBlockContentFromApi = async ( + id: number +): Promise => { + if (!isWordpressAPIEnabled) return undefined + + const post = await getBlockApiFromApi(id) + + return post.data?.wpBlock?.content ?? undefined +} + +export const DEPRECATEDgetTopics = async ( + cursor: string = "" +): Promise => { + if (!isWordpressAPIEnabled) return [] + + const query = `query { + pages (first: 100, after:"${cursor}", where: {categoryId:${ENTRIES_CATEGORY_ID}} ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id: databaseId + name: title + } + } + }` + + const documents = await graphqlQuery(query, { cursor }) + const pageInfo = documents.data.pages.pageInfo + const topics: Topic[] = documents.data.pages.nodes + if (topics.length === 0) return [] + + if (pageInfo.hasNextPage) { + return topics.concat(await DEPRECATEDgetTopics(pageInfo.endCursor)) + } else { + return topics + } +} diff --git a/db/migrateWpPostsToArchieMl.ts b/db/migrateWpPostsToArchieMl.ts index 15156bb7c6c..c474c517034 100644 --- a/db/migrateWpPostsToArchieMl.ts +++ b/db/migrateWpPostsToArchieMl.ts @@ -19,7 +19,7 @@ import { adjustHeadingLevels, findMinimumHeadingLevel, } from "./model/Gdoc/htmlToEnriched.js" -import { getRelatedCharts, isPostCitable } from "./wpdb.js" +import { getPostRelatedCharts, isPostSlugCitable } from "./model/Post.js" import { enrichedBlocksToMarkdown } from "./model/Gdoc/enrichedToMarkdown.js" // slugs from all the linear entries we want to migrate from @edomt @@ -108,13 +108,10 @@ const migrate = async (): Promise => { const text = post.content let relatedCharts: RelatedChart[] = [] if (isEntry) { - relatedCharts = await getRelatedCharts(post.id) + relatedCharts = await getPostRelatedCharts(post.id) } - const shouldIncludeMaxAsAuthor = await isPostCitable( - // Only needs post.slug - post as any - ) + const shouldIncludeMaxAsAuthor = isPostSlugCitable(post.slug) if ( shouldIncludeMaxAsAuthor && post.authors && diff --git a/db/model/Post.ts b/db/model/Post.ts index f9a4247f844..1e968c9406a 100644 --- a/db/model/Post.ts +++ b/db/model/Post.ts @@ -1,6 +1,29 @@ -import * as db from "../db.js" +import * as db from "../db" +import { + DbRawPost, + DbEnrichedPost, + parsePostRow, + parsePostWpApiSnapshot, + FullPost, + JsonError, + CategoryWithEntries, + WP_PostType, + FilterFnPostRestApi, + PostRestApi, + RelatedChart, + IndexPost, + OwidGdocPostInterface, + IMAGES_DIRECTORY, + snapshotIsPostRestApi, + snapshotIsBlockGraphQlApi, +} from "@ourworldindata/types" import { Knex } from "knex" -import { DbEnrichedPost, DbRawPost, parsePostRow } from "@ourworldindata/utils" +import { memoize, orderBy } from "lodash" +import { BAKED_BASE_URL } from "../../settings/clientSettings.js" +import { BLOG_SLUG } from "../../settings/serverSettings.js" +import { GdocPost } from "./Gdoc/GdocPost.js" +import { SiteNavigationStatic } from "../../site/SiteNavigation.js" +import { decodeHTML } from "entities" export const postsTable = "posts" @@ -63,7 +86,12 @@ export const setTagsForPost = async ( export const getPostRawBySlug = async ( slug: string ): Promise => - (await db.knexTable("posts").where({ slug: slug }))[0] + (await db.knexTable(postsTable).where({ slug }))[0] + +export const getPostRawById = async ( + id: number +): Promise => + (await db.knexTable(postsTable).where({ id }))[0] export const getPostEnrichedBySlug = async ( slug: string @@ -72,3 +100,192 @@ export const getPostEnrichedBySlug = async ( if (!post) return undefined return parsePostRow(post) } + +export const getPostEnrichedById = async ( + id: number +): Promise => { + const post = await getPostRawById(id) + if (!post) return undefined + return parsePostRow(post) +} + +export const getFullPostBySlugFromSnapshot = async ( + slug: string +): Promise => { + const postEnriched = await getPostEnrichedBySlug(slug) + if ( + !postEnriched?.wpApiSnapshot || + !snapshotIsPostRestApi(postEnriched.wpApiSnapshot) + ) + throw new JsonError(`No page snapshot found by slug ${slug}`, 404) + + return getFullPost(postEnriched.wpApiSnapshot) +} + +export const getFullPostByIdFromSnapshot = async ( + id: number +): Promise => { + const postEnriched = await getPostEnrichedById(id) + if ( + !postEnriched?.wpApiSnapshot || + !snapshotIsPostRestApi(postEnriched.wpApiSnapshot) + ) + throw new JsonError(`No page snapshot found by id ${id}`, 404) + + return getFullPost(postEnriched.wpApiSnapshot) +} + +export const isPostSlugCitable = (slug: string): boolean => { + const entries = SiteNavigationStatic.categories + return entries.some((category) => { + return ( + category.entries.some((entry) => entry.slug === slug) || + (category.subcategories ?? []).some( + (subcategory: CategoryWithEntries) => { + return subcategory.entries.some( + (subCategoryEntry) => subCategoryEntry.slug === slug + ) + } + ) + ) + }) +} + +export const getPostsFromSnapshots = async ( + postTypes: string[] = [WP_PostType.Post, WP_PostType.Page], + filterFunc?: FilterFnPostRestApi +): Promise => { + const rawPosts: Pick[] = ( + await db.knexInstance().raw( + ` + SELECT wpApiSnapshot FROM ${postsTable} + WHERE wpApiSnapshot IS NOT NULL + AND status = "publish" + AND type IN (?) + ORDER BY wpApiSnapshot->>'$.date' DESC; + `, + [postTypes] + ) + )[0] + + const posts = rawPosts + .map((p) => p.wpApiSnapshot) + .filter((snapshot) => snapshot !== null) + .map((snapshot) => parsePostWpApiSnapshot(snapshot!)) + + // Published pages excluded from public views + const excludedSlugs = [BLOG_SLUG] + + const filterConditions: Array = [ + (post): boolean => !excludedSlugs.includes(post.slug), + (post): boolean => !post.slug.endsWith("-country-profile"), + ] + if (filterFunc) filterConditions.push(filterFunc) + + return posts.filter((post) => filterConditions.every((c) => c(post))) +} + +export const getPostRelatedCharts = async ( + postId: number +): Promise => + db.queryMysql(` + SELECT DISTINCT + charts.config->>"$.slug" AS slug, + charts.config->>"$.title" AS title, + charts.config->>"$.variantName" AS variantName, + chart_tags.keyChartLevel + FROM charts + INNER JOIN chart_tags ON charts.id=chart_tags.chartId + INNER JOIN post_tags ON chart_tags.tagId=post_tags.tag_id + WHERE post_tags.post_id=${postId} + AND charts.config->>"$.isPublished" = "true" + ORDER BY title ASC + `) + +export const getFullPost = async ( + postApi: PostRestApi, + excludeContent?: boolean +): Promise => ({ + id: postApi.id, + type: postApi.type, + slug: postApi.slug, + path: postApi.slug, // kept for transitioning between legacy BPES (blog post as entry section) and future hierarchical paths + title: decodeHTML(postApi.title.rendered), + date: new Date(postApi.date_gmt), + modifiedDate: new Date(postApi.modified_gmt), + authors: postApi.authors_name || [], + content: excludeContent ? "" : postApi.content.rendered, + excerpt: decodeHTML(postApi.excerpt.rendered), + imageUrl: `${BAKED_BASE_URL}${ + postApi.featured_media_paths.medium_large ?? "/default-thumbnail.jpg" + }`, + thumbnailUrl: `${BAKED_BASE_URL}${ + postApi.featured_media_paths?.thumbnail ?? "/default-thumbnail.jpg" + }`, + imageId: postApi.featured_media, + relatedCharts: + postApi.type === "page" + ? await getPostRelatedCharts(postApi.id) + : undefined, +}) + +const selectHomepagePosts: FilterFnPostRestApi = (post) => + post.meta?.owid_publication_context_meta_field?.homepage === true + +export const getBlogIndex = memoize(async (): Promise => { + await db.getConnection() // side effect: ensure connection is established + const gdocPosts = await GdocPost.getListedGdocs() + const wpPosts = await Promise.all( + await getPostsFromSnapshots( + [WP_PostType.Post], + selectHomepagePosts + ).then((posts) => posts.map((post) => getFullPost(post, true))) + ) + + const gdocSlugs = new Set(gdocPosts.map(({ slug }) => slug)) + const posts = [...mapGdocsToWordpressPosts(gdocPosts)] + + // Only adding each wpPost if there isn't already a gdoc with the same slug, + // to make sure we use the most up-to-date metadata + for (const wpPost of wpPosts) { + if (!gdocSlugs.has(wpPost.slug)) { + posts.push(wpPost) + } + } + + return orderBy(posts, (post) => post.date.getTime(), ["desc"]) +}) + +export const mapGdocsToWordpressPosts = ( + gdocs: OwidGdocPostInterface[] +): IndexPost[] => { + return gdocs.map((gdoc) => ({ + title: gdoc.content["atom-title"] || gdoc.content.title || "Untitled", + slug: gdoc.slug, + type: gdoc.content.type, + date: gdoc.publishedAt as Date, + modifiedDate: gdoc.updatedAt as Date, + authors: gdoc.content.authors, + excerpt: gdoc.content["atom-excerpt"] || gdoc.content.excerpt, + imageUrl: gdoc.content["featured-image"] + ? `${BAKED_BASE_URL}${IMAGES_DIRECTORY}${gdoc.content["featured-image"]}` + : `${BAKED_BASE_URL}/default-thumbnail.jpg`, + })) +} + +export const postsFlushCache = (): void => { + getBlogIndex.cache.clear?.() +} + +export const getBlockContentFromSnapshot = async ( + id: number +): Promise => { + const enrichedBlock = await getPostEnrichedById(id) + if ( + !enrichedBlock?.wpApiSnapshot || + !snapshotIsBlockGraphQlApi(enrichedBlock.wpApiSnapshot) + ) + return + + return enrichedBlock?.wpApiSnapshot.data?.wpBlock?.content +} diff --git a/db/syncPostsToGrapher.ts b/db/syncPostsToGrapher.ts index b66a7a34f4a..b4579ea167d 100644 --- a/db/syncPostsToGrapher.ts +++ b/db/syncPostsToGrapher.ts @@ -350,8 +350,8 @@ const syncPostsToGrapher = async (): Promise => { ), wpApiSnapshot: post.post_type === "wp_block" - ? await wpdb.getBlockApi(post.ID) - : await wpdb.getPostApiBySlug(post.post_name), + ? await wpdb.getBlockApiFromApi(post.ID) + : await wpdb.getPostApiBySlugFromApi(post.post_name), featured_image: post.featured_image || "", published_at: post.post_date_gmt === zeroDateString diff --git a/db/wpdb.ts b/db/wpdb.ts index 41ecf2483b4..efc8c89acd1 100644 --- a/db/wpdb.ts +++ b/db/wpdb.ts @@ -1,4 +1,3 @@ -import { decodeHTML } from "entities" import { DatabaseConnection } from "./DatabaseConnection.js" import { WORDPRESS_DB_NAME, @@ -9,8 +8,6 @@ import { WORDPRESS_API_PASS, WORDPRESS_API_USER, WORDPRESS_URL, - BAKED_BASE_URL, - BLOG_SLUG, } from "../settings/serverSettings.js" import * as db from "./db.js" import { Knex, knex } from "knex" @@ -18,36 +15,26 @@ import { Base64 } from "js-base64" import { registerExitHandler } from "./cleanup.js" import { RelatedChart, - CategoryWithEntries, - FullPost, WP_PostType, DocumentNode, PostReference, JsonError, - FilterFnPostRestApi, PostRestApi, TopicId, GraphType, - memoize, - IndexPost, - orderBy, - IMAGES_DIRECTORY, uniqBy, sortBy, DataPageRelatedResearch, OwidGdocType, Tag, - OwidGdocPostInterface, } from "@ourworldindata/utils" -import { OwidGdocLinkType, Topic } from "@ourworldindata/types" +import { OwidGdocLinkType } from "@ourworldindata/types" import { getContentGraph, WPPostTypeToGraphDocumentType, } from "./contentGraph.js" import { TOPICS_CONTENT_GRAPH } from "../settings/clientSettings.js" -import { GdocPost } from "./model/Gdoc/GdocPost.js" import { Link } from "./model/Link.js" -import { SiteNavigationStatic } from "../site/SiteNavigation.js" let _knexInstance: Knex @@ -117,9 +104,9 @@ class WPDB { export const singleton = new WPDB() -const WP_API_ENDPOINT = `${WORDPRESS_URL}/wp-json/wp/v2` -const OWID_API_ENDPOINT = `${WORDPRESS_URL}/wp-json/owid/v1` -const WP_GRAPHQL_ENDPOINT = `${WORDPRESS_URL}/wp/graphql` +export const WP_API_ENDPOINT = `${WORDPRESS_URL}/wp-json/wp/v2` +export const OWID_API_ENDPOINT = `${WORDPRESS_URL}/wp-json/owid/v1` +export const WP_GRAPHQL_ENDPOINT = `${WORDPRESS_URL}/wp/graphql` export const ENTRIES_CATEGORY_ID = 44 @@ -130,7 +117,7 @@ export const ENTRIES_CATEGORY_ID = 44 * every query. So it is the caller's responsibility to throw (if necessary) on * "faux 404". */ -const graphqlQuery = async ( +export const graphqlQuery = async ( query: string, variables: any = {} ): Promise => { @@ -155,7 +142,7 @@ const graphqlQuery = async ( * * Note: throws on response.status >= 200 && response.status < 300. */ -const apiQuery = async ( +export const apiQuery = async ( endpoint: string, params?: { returnResponseHeadersOnly?: boolean @@ -188,58 +175,6 @@ const apiQuery = async ( : response.json() } -// Retrieve a map of post ids to authors -let cachedAuthorship: Map | undefined -export const getAuthorship = async (): Promise> => { - if (cachedAuthorship) return cachedAuthorship - - const authorRows = await singleton.query(` - SELECT object_id, terms.description FROM wp_term_relationships AS rels - LEFT JOIN wp_term_taxonomy AS terms ON terms.term_taxonomy_id=rels.term_taxonomy_id - WHERE terms.taxonomy='author' - ORDER BY rels.term_order ASC - `) - - const authorship = new Map() - for (const row of authorRows) { - let authors = authorship.get(row.object_id) - if (!authors) { - authors = [] - authorship.set(row.object_id, authors) - } - authors.push(row.description.split(" ").slice(0, 2).join(" ")) - } - - cachedAuthorship = authorship - return authorship -} - -export const getTagsByPostId = async (): Promise> => { - const tagsByPostId = new Map() - const rows = await singleton.query(` - SELECT p.id, t.name - FROM wp_posts p - JOIN wp_term_relationships tr - on (p.id=tr.object_id) - JOIN wp_term_taxonomy tt - on (tt.term_taxonomy_id=tr.term_taxonomy_id - and tt.taxonomy='post_tag') - JOIN wp_terms t - on (tt.term_id=t.term_id) - `) - - for (const row of rows) { - let cats = tagsByPostId.get(row.id) - if (!cats) { - cats = [] - tagsByPostId.set(row.id, cats) - } - cats.push(row.name) - } - - return tagsByPostId -} - export const getDocumentsInfo = async ( type: WP_PostType, cursor: string = "", @@ -306,23 +241,6 @@ export const getDocumentsInfo = async ( } } -export const isPostCitable = async (post: FullPost): Promise => { - const entries = SiteNavigationStatic.categories - return entries.some((category) => { - return ( - category.entries.some((entry) => entry.slug === post.slug) || - (category.subcategories ?? []).some( - (subcategory: CategoryWithEntries) => { - return subcategory.entries.some( - (subCategoryEntry) => - subCategoryEntry.slug === post.slug - ) - } - ) - ) - }) -} - export const getPermalinks = async (): Promise<{ // Strip trailing slashes, and convert __ into / to allow custom subdirs like /about/media-coverage get: (ID: number, postName: string) => string @@ -332,90 +250,8 @@ export const getPermalinks = async (): Promise<{ postName.replace(/\/+$/g, "").replace(/--/g, "/").replace(/__/g, "/"), }) -let cachedFeaturedImages: Map | undefined -export const getFeaturedImages = async (): Promise> => { - if (cachedFeaturedImages) return cachedFeaturedImages - - const rows = await singleton.query( - `SELECT wp_postmeta.post_id, wp_posts.guid FROM wp_postmeta INNER JOIN wp_posts ON wp_posts.ID=wp_postmeta.meta_value WHERE wp_postmeta.meta_key='_thumbnail_id'` - ) - - const featuredImages = new Map() - for (const row of rows) { - featuredImages.set(row.post_id, row.guid) - } - - cachedFeaturedImages = featuredImages - return featuredImages -} - // page => pages, post => posts -const getEndpointSlugFromType = (type: string): string => `${type}s` - -export const selectHomepagePosts: FilterFnPostRestApi = (post) => - post.meta?.owid_publication_context_meta_field?.homepage === true - -// Limit not supported with multiple post types: -// When passing multiple post types, the limit is applied to the resulting array -// of sequentially sorted posts (all blog posts, then all pages, ...), so there -// will be a predominance of a certain post type. -export const getPosts = async ( - postTypes: string[] = [WP_PostType.Post, WP_PostType.Page], - filterFunc?: FilterFnPostRestApi, - limit?: number -): Promise => { - if (!isWordpressAPIEnabled) return [] - - const perPage = 20 - const posts: PostRestApi[] = [] - - for (const postType of postTypes) { - const endpoint = `${WP_API_ENDPOINT}/${getEndpointSlugFromType( - postType - )}` - - // Get number of items to retrieve - const headers = await apiQuery(endpoint, { - searchParams: [["per_page", 1]], - returnResponseHeadersOnly: true, - }) - const maxAvailable = headers.get("X-WP-TotalPages") - const count = limit && limit < maxAvailable ? limit : maxAvailable - - for (let page = 1; page <= Math.ceil(count / perPage); page++) { - const postsCurrentPage = await apiQuery(endpoint, { - searchParams: [ - ["per_page", perPage], - ["page", page], - ], - }) - posts.push(...postsCurrentPage) - } - } - - // Published pages excluded from public views - const excludedSlugs = [BLOG_SLUG] - - const filterConditions: Array = [ - (post): boolean => !excludedSlugs.includes(post.slug), - (post): boolean => !post.slug.endsWith("-country-profile"), - ] - if (filterFunc) filterConditions.push(filterFunc) - - const filteredPosts = posts.filter((post) => - filterConditions.every((c) => c(post)) - ) - - return limit ? filteredPosts.slice(0, limit) : filteredPosts -} - -// todo / refactor : narrow down scope to getPostTypeById? -export const getPostType = async (search: number | string): Promise => { - const paramName = typeof search === "number" ? "id" : "slug" - return apiQuery(`${OWID_API_ENDPOINT}/type`, { - searchParams: [[paramName, search]], - }) -} +export const getEndpointSlugFromType = (type: string): string => `${type}s` // The API query in getPostType is cleaner but slower, which becomes more of an // issue with prominent links requesting posts by slugs (getPostBySlug) to @@ -446,7 +282,9 @@ export const getPostIdAndTypeBySlug = async ( return { id: rows[0].ID, type: rows[0].post_type } } -export const getPostApiBySlug = async (slug: string): Promise => { +export const getPostApiBySlugFromApi = async ( + slug: string +): Promise => { if (!isWordpressAPIEnabled) { throw new JsonError(`Need wordpress API to match slug ${slug}`, 404) } @@ -460,64 +298,6 @@ export const getPostApiBySlug = async (slug: string): Promise => { return apiQuery(`${WP_API_ENDPOINT}/${getEndpointSlugFromType(type)}/${id}`) } -// We might want to cache this as the network of prominent links densifies and -// multiple requests to the same posts are happening. -export const getPostBySlug = async (slug: string): Promise => { - if (!isWordpressAPIEnabled) { - throw new JsonError(`Need wordpress API to match slug ${slug}`, 404) - } - - const postApi = await getPostApiBySlug(slug) - - return getFullPost(postApi) -} - -// the /revisions endpoint does not send back all the metadata required for -// the proper rendering of the post (e.g. authors), hence the double request. -export const getLatestPostRevision = async (id: number): Promise => { - const type = await getPostType(id) - const endpointSlug = getEndpointSlugFromType(type) - - const postApi = await apiQuery(`${WP_API_ENDPOINT}/${endpointSlug}/${id}`) - - const revision = ( - await apiQuery( - `${WP_API_ENDPOINT}/${endpointSlug}/${id}/revisions?per_page=1` - ) - )[0] - - // Since WP does not store metadata for revisions, some elements of a - // previewed page will not reflect the latest edits: - // - published date (will show the correct one - that is the one in the - // sidebar - for unpublished posts though. For published posts, the - // current published date is displayed, regardless of what is shown - // and could have been modified in the sidebar.) - // - authors - // ... - return getFullPost({ - ...postApi, - content: revision.content, - title: revision.title, - }) -} - -export const getRelatedCharts = async ( - postId: number -): Promise => - db.queryMysql(` - SELECT DISTINCT - charts.config->>"$.slug" AS slug, - charts.config->>"$.title" AS title, - charts.config->>"$.variantName" AS variantName, - chart_tags.keyChartLevel - FROM charts - INNER JOIN chart_tags ON charts.id=chart_tags.chartId - INNER JOIN post_tags ON chart_tags.tagId=post_tags.tag_id - WHERE post_tags.post_id=${postId} - AND charts.config->>"$.isPublished" = "true" - ORDER BY title ASC - `) - export const getPostTags = async ( postId: number ): Promise[]> => { @@ -745,7 +525,7 @@ export const getRelatedArticles = async ( ) } -export const getBlockApi = async (id: number): Promise => { +export const getBlockApiFromApi = async (id: number): Promise => { if (!isWordpressAPIEnabled) return undefined const query = ` @@ -758,119 +538,11 @@ export const getBlockApi = async (id: number): Promise => { return graphqlQuery(query, { id }) } -export const getBlockContent = async ( - id: number -): Promise => { - if (!isWordpressAPIEnabled) return undefined - - const post = await getBlockApi(id) - - return post.data?.wpBlock?.content ?? undefined -} - -export const getFullPost = async ( - postApi: PostRestApi, - excludeContent?: boolean -): Promise => ({ - id: postApi.id, - type: postApi.type, - slug: postApi.slug, - path: postApi.slug, // kept for transitioning between legacy BPES (blog post as entry section) and future hierarchical paths - title: decodeHTML(postApi.title.rendered), - date: new Date(postApi.date_gmt), - modifiedDate: new Date(postApi.modified_gmt), - authors: postApi.authors_name || [], - content: excludeContent ? "" : postApi.content.rendered, - excerpt: decodeHTML(postApi.excerpt.rendered), - imageUrl: `${BAKED_BASE_URL}${ - postApi.featured_media_paths.medium_large ?? "/default-thumbnail.jpg" - }`, - thumbnailUrl: `${BAKED_BASE_URL}${ - postApi.featured_media_paths?.thumbnail ?? "/default-thumbnail.jpg" - }`, - imageId: postApi.featured_media, - relatedCharts: - postApi.type === "page" - ? await getRelatedCharts(postApi.id) - : undefined, -}) - -export const getBlogIndex = memoize(async (): Promise => { - await db.getConnection() // side effect: ensure connection is established - const gdocPosts = await GdocPost.getListedGdocs() - const wpPosts = await Promise.all( - await getPosts([WP_PostType.Post], selectHomepagePosts).then((posts) => - posts.map((post) => getFullPost(post, true)) - ) - ) - - const gdocSlugs = new Set(gdocPosts.map(({ slug }) => slug)) - const posts = [...mapGdocsToWordpressPosts(gdocPosts)] - - // Only adding each wpPost if there isn't already a gdoc with the same slug, - // to make sure we use the most up-to-date metadata - for (const wpPost of wpPosts) { - if (!gdocSlugs.has(wpPost.slug)) { - posts.push(wpPost) - } - } - - return orderBy(posts, (post) => post.date.getTime(), ["desc"]) -}) - -export const mapGdocsToWordpressPosts = ( - gdocs: OwidGdocPostInterface[] -): IndexPost[] => { - return gdocs.map((gdoc) => ({ - title: gdoc.content["atom-title"] || gdoc.content.title || "Untitled", - slug: gdoc.slug, - type: gdoc.content.type, - date: gdoc.publishedAt as Date, - modifiedDate: gdoc.updatedAt as Date, - authors: gdoc.content.authors, - excerpt: gdoc.content["atom-excerpt"] || gdoc.content.excerpt, - imageUrl: gdoc.content["featured-image"] - ? `${BAKED_BASE_URL}${IMAGES_DIRECTORY}${gdoc.content["featured-image"]}` - : `${BAKED_BASE_URL}/default-thumbnail.jpg`, - })) -} - -export const getTopics = async (cursor: string = ""): Promise => { - if (!isWordpressAPIEnabled) return [] - const query = `query { - pages (first: 100, after:"${cursor}", where: {categoryId:${ENTRIES_CATEGORY_ID}} ) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id: databaseId - name: title - } - } - }` - - const documents = await graphqlQuery(query, { cursor }) - const pageInfo = documents.data.pages.pageInfo - const topics: Topic[] = documents.data.pages.nodes - if (topics.length === 0) return [] - - if (pageInfo.hasNextPage) { - return topics.concat(await getTopics(pageInfo.endCursor)) - } else { - return topics - } -} - export interface TablepressTable { tableId: string data: string[][] } - -let cachedTables: Map | undefined export const getTables = async (): Promise> => { - if (cachedTables) return cachedTables - const optRows = await singleton.query(` SELECT option_value AS json FROM wp_options WHERE option_name='tablepress_tables' `) @@ -886,23 +558,16 @@ export const getTables = async (): Promise> => { tableContents.set(row.ID, row.post_content) } - cachedTables = new Map() + const tables = new Map() for (const tableId in tableToPostIds) { const data = JSON.parse( tableContents.get(tableToPostIds[tableId]) || "[]" ) - cachedTables.set(tableId, { + tables.set(tableId, { tableId: tableId, data: data, }) } - return cachedTables -} - -export const flushCache = (): void => { - cachedAuthorship = undefined - cachedFeaturedImages = undefined - getBlogIndex.cache.clear?.() - cachedTables = undefined + return tables } diff --git a/packages/@ourworldindata/types/src/dbTypes/Posts.ts b/packages/@ourworldindata/types/src/dbTypes/Posts.ts index 51f3e436204..b58b67b8144 100644 --- a/packages/@ourworldindata/types/src/dbTypes/Posts.ts +++ b/packages/@ourworldindata/types/src/dbTypes/Posts.ts @@ -2,6 +2,7 @@ import { WP_PostType, FormattingOptions, PostRestApi, + BlockGraphQlApi, } from "../wordpressTypes/WordpressTypes.js" import { OwidArticleBackportingStatistics, @@ -43,7 +44,7 @@ export type DbEnrichedPost = Omit< formattingOptions: FormattingOptions | null archieml: OwidGdocPostInterface | null archieml_update_statistics: OwidArticleBackportingStatistics | null - wpApiSnapshot: PostRestApi | null + wpApiSnapshot: PostRestApi | BlockGraphQlApi | null } export interface DbRawPostWithGdocPublishStatus extends DbRawPost { isGdocPublished: boolean @@ -65,6 +66,10 @@ export function parsePostArchieml(archieml: string): any { return JSON.parse(archieml) } +export function parsePostWpApiSnapshot(wpApiSnapshot: string): PostRestApi { + return JSON.parse(wpApiSnapshot) +} + export function parsePostRow(postRow: DbRawPost): DbEnrichedPost { return { ...postRow, @@ -77,7 +82,7 @@ export function parsePostRow(postRow: DbRawPost): DbEnrichedPost { ? JSON.parse(postRow.archieml_update_statistics) : null, wpApiSnapshot: postRow.wpApiSnapshot - ? JSON.parse(postRow.wpApiSnapshot) + ? parsePostWpApiSnapshot(postRow.wpApiSnapshot) : null, } } @@ -94,3 +99,17 @@ export function serializePostRow(postRow: DbEnrichedPost): DbRawPost { wpApiSnapshot: JSON.stringify(postRow.wpApiSnapshot), } } + +export const snapshotIsPostRestApi = ( + snapshot: PostRestApi | BlockGraphQlApi +): snapshot is PostRestApi => { + return [WP_PostType.Page, WP_PostType.Post].includes( + (snapshot as PostRestApi).type + ) +} + +export const snapshotIsBlockGraphQlApi = ( + snapshot: PostRestApi | BlockGraphQlApi +): snapshot is BlockGraphQlApi => { + return (snapshot as BlockGraphQlApi).data?.wpBlock?.content !== undefined +} diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index 9dd060b9978..c9de653fa08 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -138,6 +138,7 @@ export { WP_ColumnStyle, WP_PostType, type PostRestApi, + type BlockGraphQlApi, type FilterFnPostRestApi, type FormattingOptions, SubNavId, @@ -499,8 +500,11 @@ export { parsePostFormattingOptions, parsePostAuthors, parsePostRow, + parsePostWpApiSnapshot, serializePostRow, parsePostArchieml, + snapshotIsPostRestApi, + snapshotIsBlockGraphQlApi, } from "./dbTypes/Posts.js" export { type DbInsertPostGdoc, diff --git a/packages/@ourworldindata/types/src/wordpressTypes/WordpressTypes.ts b/packages/@ourworldindata/types/src/wordpressTypes/WordpressTypes.ts index c605d099ae3..0d87c93630d 100644 --- a/packages/@ourworldindata/types/src/wordpressTypes/WordpressTypes.ts +++ b/packages/@ourworldindata/types/src/wordpressTypes/WordpressTypes.ts @@ -51,6 +51,14 @@ export interface PostRestApi { } } +export interface BlockGraphQlApi { + data?: { + wpBlock?: { + content: string + } + } +} + export type FilterFnPostRestApi = (post: PostRestApi) => boolean export enum WP_ColumnStyle {