diff --git a/.eslintrc b/.eslintrc index 497e93d7..7a7fa57d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -19,6 +19,7 @@ // Turn on errors for missing imports. "import/no-unresolved": "error", "import/named": "off", + "no-useless-constructor": "off", "no-console": [ "off" ] diff --git a/src/api/ApiClient.ts b/src/api/ApiClient.ts index 4327afa6..7c755f66 100644 --- a/src/api/ApiClient.ts +++ b/src/api/ApiClient.ts @@ -1,15 +1,13 @@ import { PHPResponse, PlaygroundClient } from '@wp-playground/client'; -import { BlogPostsApi } from '@/api/BlogPosts'; -import { PagesApi } from '@/api/Pages'; import { SettingsApi } from '@/api/Settings'; import { UsersApi } from '@/api/Users'; import { BlueprintsApi } from '@/api/Blueprints'; +import { SubjectsApi } from '@/api/SubjectsApi'; export class ApiClient { private readonly playgroundClient: PlaygroundClient; private readonly _siteUrl: string; - private readonly _blogPosts: BlogPostsApi; - private readonly _pages: PagesApi; + private readonly _subjects: SubjectsApi; private readonly _settings: SettingsApi; private readonly _users: UsersApi; private readonly _blueprints: BlueprintsApi; @@ -18,8 +16,7 @@ export class ApiClient { this.playgroundClient = playgroundClient; this._siteUrl = siteUrl; this._blueprints = new BlueprintsApi( this ); - this._blogPosts = new BlogPostsApi( this ); - this._pages = new PagesApi( this ); + this._subjects = new SubjectsApi( this ); this._settings = new SettingsApi( this ); this._users = new UsersApi( this ); } @@ -32,12 +29,8 @@ export class ApiClient { return this._blueprints; } - get blogPosts(): BlogPostsApi { - return this._blogPosts; - } - - get pages(): PagesApi { - return this._pages; + get subjects(): SubjectsApi { + return this._subjects; } get settings(): SettingsApi { diff --git a/src/api/ApiTypes.ts b/src/api/ApiTypes.ts index a62a901f..2d5bf1d4 100644 --- a/src/api/ApiTypes.ts +++ b/src/api/ApiTypes.ts @@ -6,12 +6,12 @@ export type ApiPost = { transformedId: number; previewUrl: string; sourceUrl: string; - rawDate: string; - parsedDate: string; - rawTitle: string; - parsedTitle: string; - rawContent: string; - parsedContent: string; + rawDate?: string; + parsedDate?: string; + rawTitle?: string; + parsedTitle?: string; + rawContent?: string; + parsedContent?: string; }; export type ApiPage = ApiPost; diff --git a/src/api/BlogPosts.ts b/src/api/BlogPosts.ts deleted file mode 100644 index 8011463b..00000000 --- a/src/api/BlogPosts.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { BlogPost } from '@/model/subject/BlogPost'; -import { ApiClient } from '@/api/ApiClient'; -import { SubjectType } from '@/model/subject/Subject'; -import { newDateField } from '@/model/field/DateField'; -import { newTextField } from '@/model/field/TextField'; -import { newHtmlField } from '@/model/field/HtmlField'; -import { ApiPost } from '@/api/ApiTypes'; - -export class BlogPostsApi { - // eslint-disable-next-line no-useless-constructor - constructor( private readonly client: ApiClient ) {} - - async create( blogPost: BlogPost ): Promise< BlogPost > { - const response = ( await this.client.post( '/blog-posts', { - sourceUrl: blogPost.sourceUrl, - } ) ) as ApiPost; - return fromApiResponse( response ); - } - - async update( id: number, post: BlogPost ): Promise< BlogPost > { - const response = ( await this.client.post( - `/blog-posts/${ id }`, - toApiRequest( post ) - ) ) as ApiPost; - return fromApiResponse( response ); - } - - async findById( id: string ): Promise< BlogPost | null > { - const post = ( await this.client.get( - '/blog-posts/' + id - ) ) as ApiPost; - return post ? fromApiResponse( post ) : null; - } - - async findBySourceUrl( sourceUrl: string ): Promise< BlogPost | null > { - const post = ( await this.client.get( - '/blog-posts?sourceurl=' + sourceUrl - ) ) as ApiPost; - return post ? fromApiResponse( post ) : null; - } -} - -function fromApiResponse( response: ApiPost ): BlogPost { - const date = newDateField( response.rawDate, response.parsedDate ); - const title = newTextField( response.rawTitle, response.parsedTitle ?? '' ); - const content = newHtmlField( - response.rawContent, - response.parsedContent ?? '' - ); - - return { - id: response.id, - type: SubjectType.BlogPost, - sourceUrl: response.sourceUrl, - transformedId: response.transformedId, - previewUrl: response.previewUrl, - title, - date, - content, - }; -} - -function toApiRequest( post: BlogPost ): ApiPost { - return { - id: post.id, - // read-only from api perspective, so including it has no effect - transformedId: post.transformedId, - // update not allowed so will be a bad request if you try to do it - sourceUrl: post.sourceUrl, - // read-only from api perspective, so including it has no effect - previewUrl: post.previewUrl, - rawDate: post.date.rawValue, - parsedDate: post.date.parsedValue.toISOString(), - rawTitle: post.title.rawValue, - parsedTitle: post.title.parsedValue, - rawContent: post.content.rawValue, - parsedContent: post.content.parsedValue, - }; -} diff --git a/src/api/Blueprints.ts b/src/api/Blueprints.ts index b833d9d8..6b10b040 100644 --- a/src/api/Blueprints.ts +++ b/src/api/Blueprints.ts @@ -1,6 +1,6 @@ import { ApiClient } from '@/api/ApiClient'; -import { Blueprint } from '@/model/blueprint/Blueprint'; -import { SubjectType } from '@/model/subject/Subject'; +import { Blueprint } from '@/model/Blueprint'; +import { SubjectType } from '@/model/Subject'; export class BlueprintsApi { // eslint-disable-next-line no-useless-constructor diff --git a/src/api/Pages.ts b/src/api/Pages.ts deleted file mode 100644 index e1f74486..00000000 --- a/src/api/Pages.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Page } from '@/model/subject/Page'; -import { ApiClient } from '@/api/ApiClient'; -import { SubjectType } from '@/model/subject/Subject'; -import { newTextField } from '@/model/field/TextField'; -import { newHtmlField } from '@/model/field/HtmlField'; -import { ApiPage } from '@/api/ApiTypes'; -import { newDateField } from '@/model/field/DateField'; - -export class PagesApi { - // eslint-disable-next-line no-useless-constructor - constructor( private readonly client: ApiClient ) {} - - async create( page: Page ): Promise< Page > { - const response = ( await this.client.post( '/pages', { - sourceUrl: page.sourceUrl, - } ) ) as ApiPage; - return fromApiResponse( response ); - } - - async update( id: number, page: Page ): Promise< Page > { - const response = ( await this.client.post( - `/pages/${ id }`, - toApiRequest( page ) - ) ) as ApiPage; - return fromApiResponse( response ); - } - - async findById( id: string ): Promise< Page | null > { - const page = ( await this.client.get( '/pages/' + id ) ) as ApiPage; - return page ? fromApiResponse( page ) : null; - } - - async findBySourceUrl( sourceUrl: string ): Promise< Page | null > { - const page = ( await this.client.get( - '/pages?sourceurl=' + sourceUrl - ) ) as ApiPage; - return page ? fromApiResponse( page ) : null; - } -} - -function fromApiResponse( response: ApiPage ): Page { - const date = newDateField( response.rawDate, response.parsedDate ); - const title = newTextField( response.rawTitle, response.parsedTitle ?? '' ); - const content = newHtmlField( - response.rawContent, - response.parsedContent ?? '' - ); - - return { - id: response.id, - type: SubjectType.Page, - sourceUrl: response.sourceUrl, - transformedId: response.transformedId, - previewUrl: response.previewUrl, - title, - date, - content, - }; -} - -function toApiRequest( page: Page ): ApiPage { - return { - id: page.id, - // read-only from api perspective, so including it has no effect - transformedId: page.transformedId, - // update not allowed so will be a bad request if you try to do it - sourceUrl: page.sourceUrl, - // read-only from api perspective, so including it has no effect - previewUrl: page.previewUrl, - rawDate: page.date.rawValue, - parsedDate: page.date.parsedValue.toISOString(), - rawTitle: page.title.rawValue, - parsedTitle: page.title.parsedValue, - rawContent: page.content.rawValue, - parsedContent: page.content.parsedValue, - }; -} diff --git a/src/api/SubjectsApi.ts b/src/api/SubjectsApi.ts new file mode 100644 index 00000000..24091e10 --- /dev/null +++ b/src/api/SubjectsApi.ts @@ -0,0 +1,111 @@ +import { ApiClient } from '@/api/ApiClient'; +import { Subject, SubjectType } from '@/model/Subject'; +import { ApiPost } from '@/api/ApiTypes'; +import { newDateField } from '@/model/field/DateField'; +import { newTextField } from '@/model/field/TextField'; +import { newHtmlField } from '@/model/field/HtmlField'; + +const endpoints = new Map< string, string >( [ + [ SubjectType.BlogPost, '/blog-posts' ], + [ SubjectType.Page, '/pages' ], +] ); + +export class SubjectsApi { + constructor( private readonly client: ApiClient ) {} + + async create( type: SubjectType, sourceUrl: string ): Promise< Subject > { + const path = getEndpoint( type ); + const response = ( await this.client.post( path, { + sourceUrl, + } ) ) as ApiPost; + return fromApiResponse( response ); + } + + async update( subject: Subject ): Promise< Subject > { + const path = `${ getEndpoint( subject.type ) }/${ subject.id }`; + const response = ( await this.client.post( + path, + toApiUpdateRequest( subject ) + ) ) as ApiPost; + return fromApiResponse( response ); + } + + async findById( type: SubjectType, id: number ): Promise< Subject | null > { + const path = `${ getEndpoint( type ) }/${ id }`; + const post = ( await this.client.get( path ) ) as ApiPost; + return post ? fromApiResponse( post ) : null; + } + + async findBySourceUrl( + type: SubjectType, + sourceUrl: string + ): Promise< Subject | null > { + const path = `${ getEndpoint( type ) }?sourceurl=${ sourceUrl }`; + const post = ( await this.client.get( path ) ) as ApiPost; + return post ? fromApiResponse( post ) : null; + } +} + +function getEndpoint( type: SubjectType ): string { + const endpoint = endpoints.get( type ); + if ( ! endpoint ) { + throw Error( `unknown endpoint: ${ type }` ); + } + return endpoint; +} + +function fromApiResponse( response: ApiPost ): Subject { + const date = newDateField( response.rawDate, response.parsedDate ); + const title = newTextField( response.rawTitle, response.parsedTitle ?? '' ); + const content = newHtmlField( + response.rawContent, + response.parsedContent ?? '' + ); + + return { + id: response.id, + type: SubjectType.BlogPost, + sourceUrl: response.sourceUrl, + transformedId: response.transformedId, + previewUrl: response.previewUrl, + fields: { + title, + date, + content, + }, + }; +} + +type UpdateBody = Omit< ApiPost, 'sourceUrl' | 'transformedId' | 'previewUrl' >; + +function toApiUpdateRequest( subject: Subject ): UpdateBody { + let request: UpdateBody = { + id: subject.id, + }; + + if ( subject.fields.date ) { + request = { + ...request, + rawDate: subject.fields.date.rawValue, + parsedDate: subject.fields.date.parsedValue.toISOString(), + }; + } + + if ( subject.fields.title ) { + request = { + ...request, + rawTitle: subject.fields.title.rawValue, + parsedTitle: subject.fields.title.parsedValue, + }; + } + + if ( subject.fields.content ) { + request = { + ...request, + rawContent: subject.fields.content.rawValue, + parsedContent: subject.fields.content.parsedValue, + }; + } + + return request; +} diff --git a/src/model/Blueprint.ts b/src/model/Blueprint.ts new file mode 100644 index 00000000..0bf0f5a0 --- /dev/null +++ b/src/model/Blueprint.ts @@ -0,0 +1,33 @@ +import { SubjectType } from '@/model/Subject'; + +export interface Blueprint { + type: SubjectType; + id: string; // TODO: Probably need to make this a number when we start storing Blueprints on the backend. + sourceUrl: string; + valid: boolean; + selectors: Record< string, string >; +} + +export function newBlueprint( + type: SubjectType, + sourceUrl: string +): Blueprint { + return { + id: '', + type, + sourceUrl, + valid: false, + selectors: {}, + }; +} + +export function validateBlueprint( blueprint: Blueprint ): boolean { + let isValid = true; + for ( const selector of Object.values( blueprint.selectors ) ) { + if ( selector === '' ) { + isValid = false; + break; + } + } + return isValid; +} diff --git a/src/model/subject/Schema.ts b/src/model/Schema.ts similarity index 84% rename from src/model/subject/Schema.ts rename to src/model/Schema.ts index 3c129d38..c6f9dda7 100644 --- a/src/model/subject/Schema.ts +++ b/src/model/Schema.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line import/no-unresolved import Schema from '@schema/schema.json'; -import { SubjectType } from '@/model/subject/Subject'; +import { SubjectType } from '@/model/Subject'; export function getSchema( subjectType: SubjectType ) { if ( ! Schema.hasOwnProperty( subjectType ) ) { diff --git a/src/model/Subject.ts b/src/model/Subject.ts new file mode 100644 index 00000000..9b4c4bb7 --- /dev/null +++ b/src/model/Subject.ts @@ -0,0 +1,26 @@ +import { Field } from '@/model/field/Field'; + +export enum SubjectType { + BlogPost = 'blog-post', + Page = 'page', +} + +export interface Subject { + type: SubjectType; + id: number; + transformedId: number; + sourceUrl: string; + previewUrl: string; + fields: Record< string, Field >; +} + +export function validateFields( subject: Subject ): boolean { + let isValid = true; + Object.keys( subject.fields ).forEach( ( key ) => { + const f = subject.fields[ key ]; + if ( f.rawValue === '' || f.parsedValue === '' ) { + isValid = false; + } + } ); + return isValid; +} diff --git a/src/model/blueprint/BlogPost.ts b/src/model/blueprint/BlogPost.ts deleted file mode 100644 index 9134fc5f..00000000 --- a/src/model/blueprint/BlogPost.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { SubjectType } from '@/model/subject/Subject'; -import { FieldType } from '@/model/field/Field'; -import { Blueprint } from '@/model/blueprint/Blueprint'; - -export interface BlogPostBlueprint extends Blueprint { - type: SubjectType.BlogPost; - fields: { - title: { type: FieldType.Text; selector?: string }; - date: { type: FieldType.Date; selector?: string }; - content: { type: FieldType.Html; selector?: string }; - }; -} - -export function newBlogPostBlueprint( sourceUrl: string ): BlogPostBlueprint { - return { - id: '', - type: SubjectType.BlogPost, - sourceUrl, - valid: false, - fields: { - date: { - type: FieldType.Date, - selector: '', - }, - title: { - type: FieldType.Text, - selector: '', - }, - content: { - type: FieldType.Html, - selector: '', - }, - }, - }; -} - -export function validateBlogpostBlueprint( - blueprint: BlogPostBlueprint -): boolean { - let isValid = true; - for ( const f of Object.values( blueprint.fields ) ) { - if ( f.selector === '' ) { - isValid = false; - break; - } - } - return isValid; -} diff --git a/src/model/blueprint/Blueprint.ts b/src/model/blueprint/Blueprint.ts deleted file mode 100644 index 4c232874..00000000 --- a/src/model/blueprint/Blueprint.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { SubjectType } from '@/model/subject/Subject'; -import { FieldType } from '@/model/field/Field'; - -export interface BlueprintField { - type: FieldType; - selector?: string; -} - -export interface Blueprint { - type: SubjectType; - id: string; // TODO: Probably need to make this a number when we start storing Blueprints on the backend. - sourceUrl: string; - valid: boolean; - fields: Record< string, BlueprintField >; -} diff --git a/src/model/blueprint/Page.ts b/src/model/blueprint/Page.ts deleted file mode 100644 index c27c5a9e..00000000 --- a/src/model/blueprint/Page.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { SubjectType } from '@/model/subject/Subject'; -import { FieldType } from '@/model/field/Field'; -import { Blueprint } from '@/model/blueprint/Blueprint'; - -export interface PageBlueprint extends Blueprint { - type: SubjectType.Page; - fields: { - title: { type: FieldType.Text; selector?: string }; - date: { type: FieldType.Date; selector?: string }; - content: { type: FieldType.Html; selector?: string }; - }; -} - -export function newPageBlueprint( sourceUrl: string ): PageBlueprint { - return { - id: '', - type: SubjectType.Page, - sourceUrl, - valid: false, - fields: { - date: { - type: FieldType.Date, - selector: '', - }, - title: { - type: FieldType.Text, - selector: '', - }, - content: { - type: FieldType.Html, - selector: '', - }, - }, - }; -} - -export function validatePageBlueprint( blueprint: PageBlueprint ): boolean { - let isValid = true; - for ( const f of Object.values( blueprint.fields ) ) { - if ( f.selector === '' ) { - isValid = false; - break; - } - } - return isValid; -} diff --git a/src/model/subject/BlogPost.ts b/src/model/subject/BlogPost.ts deleted file mode 100644 index f4ed9734..00000000 --- a/src/model/subject/BlogPost.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Subject, SubjectType } from '@/model/subject/Subject'; -import { newTextField, TextField } from '@/model/field/TextField'; -import { HtmlField, newHtmlField } from '@/model/field/HtmlField'; -import { DateField, newDateField } from '@/model/field/DateField'; - -export interface BlogPost extends Subject { - type: SubjectType.BlogPost; - date: DateField; - title: TextField; - content: HtmlField; -} - -export function newBlogPost( sourceUrl: string ): BlogPost { - return { - id: 0, - type: SubjectType.BlogPost, - transformedId: 0, - previewUrl: '', - sourceUrl, - date: newDateField(), - title: newTextField(), - content: newHtmlField(), - }; -} - -export function validateBlogPost( blogPost: BlogPost ): boolean { - const fields = [ blogPost.title, blogPost.date, blogPost.content ]; - let isValid = true; - for ( const f of fields ) { - if ( f.rawValue === '' || f.parsedValue === '' ) { - isValid = false; - break; - } - } - return isValid; -} diff --git a/src/model/subject/Page.ts b/src/model/subject/Page.ts deleted file mode 100644 index 7e08e4e6..00000000 --- a/src/model/subject/Page.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Subject, SubjectType } from '@/model/subject/Subject'; -import { newTextField, TextField } from '@/model/field/TextField'; -import { HtmlField, newHtmlField } from '@/model/field/HtmlField'; -import { DateField, newDateField } from '@/model/field/DateField'; - -export interface Page extends Subject { - type: SubjectType.Page; - date: DateField; - title: TextField; - content: HtmlField; -} - -export function newPage( sourceUrl: string ): Page { - return { - id: 0, - transformedId: 0, - previewUrl: '', - type: SubjectType.Page, - sourceUrl, - date: newDateField(), - title: newTextField(), - content: newHtmlField(), - }; -} - -export function validatePage( page: Page ): boolean { - const fields = [ page.title, page.date, page.content ]; - let isValid = true; - for ( const f of fields ) { - if ( f.rawValue === '' || f.parsedValue === '' ) { - isValid = false; - break; - } - } - return isValid; -} diff --git a/src/model/subject/Subject.ts b/src/model/subject/Subject.ts deleted file mode 100644 index 665e66d1..00000000 --- a/src/model/subject/Subject.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum SubjectType { - BlogPost = 'blog-post', - Page = 'page', -} - -export const humanReadableSubjectType: Map< SubjectType, string > = new Map( [ - [ SubjectType.BlogPost, 'Blog Post' ], - [ SubjectType.Page, 'Page' ], -] ); - -export interface Subject { - type: SubjectType; - id: number; - transformedId: number; - sourceUrl: string; - previewUrl: string; -} diff --git a/src/parser/blog-post.ts b/src/parser/blog-post.ts deleted file mode 100644 index a2d10663..00000000 --- a/src/parser/blog-post.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { pasteHandler, serialize } from '@wordpress/blocks'; -import { findDeepestChild } from '@/parser/util'; -import { DateField, newDateField } from '@/model/field/DateField'; -import { newTextField, TextField } from '@/model/field/TextField'; -import { HtmlField, newHtmlField } from '@/model/field/HtmlField'; -import { Field } from '@/model/field/Field'; - -export function parseBlogPostField( name: string, field: Field ): Field { - switch ( name ) { - case 'date': - return parseBlogPostDate( field.rawValue ); - case 'title': - return parseBlogPostTitle( field.rawValue ); - case 'content': - return parseBlogPostContent( field.rawValue ); - default: - throw Error( `unknown field type ${ field.type }` ); - } -} - -export function parseBlogPostDate( html: string ): DateField { - const container = document.createElement( 'div' ); - container.innerHTML = html.trim(); - const element = container.querySelector( 'time' ); - return newDateField( html, element ? element.dateTime : '' ); -} - -export function parseBlogPostTitle( html: string ): TextField { - const deepestChild = findDeepestChild( html ); - return newTextField( html, deepestChild?.innerHTML ?? '' ); -} - -export function parseBlogPostContent( html: string ): HtmlField { - return newHtmlField( html, serializeBlocks( html ) ); -} - -function serializeBlocks( html: string ): string { - const blocks = pasteHandler( { - mode: 'BLOCKS', - HTML: html, - } ); - return serialize( blocks ); -} diff --git a/src/parser/field.ts b/src/parser/field.ts new file mode 100644 index 00000000..bafa3a72 --- /dev/null +++ b/src/parser/field.ts @@ -0,0 +1,34 @@ +import { Field, FieldType } from '@/model/field/Field'; +import { DateField, newDateField } from '@/model/field/DateField'; +import { newTextField, TextField } from '@/model/field/TextField'; +import { findDeepestChild, htmlToBlocks } from '@/parser/util'; +import { HtmlField, newHtmlField } from '@/model/field/HtmlField'; + +export function parseField( field: Field ): Field { + switch ( field.type ) { + case FieldType.Date: + return parseDate( field.rawValue ); + case FieldType.Text: + return parseText( field.rawValue ); + case FieldType.Html: + return parseHtml( field.rawValue ); + default: + throw Error( `unknown field type ${ field.type }` ); + } +} + +function parseDate( html: string ): DateField { + const container = document.createElement( 'div' ); + container.innerHTML = html.trim(); + const element = container.querySelector( 'time' ); + return newDateField( html, element ? element.dateTime : '' ); +} + +function parseText( html: string ): TextField { + const deepestChild = findDeepestChild( html ); + return newTextField( html, deepestChild?.innerHTML ?? '' ); +} + +function parseHtml( html: string ): HtmlField { + return newHtmlField( html, htmlToBlocks( html ) ); +} diff --git a/src/parser/page.ts b/src/parser/page.ts deleted file mode 100644 index c8d2706d..00000000 --- a/src/parser/page.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { pasteHandler, serialize } from '@wordpress/blocks'; -import { findDeepestChild } from '@/parser/util'; -import { newTextField, TextField } from '@/model/field/TextField'; -import { HtmlField, newHtmlField } from '@/model/field/HtmlField'; -import { Field } from '@/model/field/Field'; -import { DateField, newDateField } from '@/model/field/DateField'; - -export function parsePageField( name: string, field: Field ): Field { - switch ( name ) { - case 'date': - return parsePageDate( field.rawValue ); - case 'title': - return parsePageTitle( field.rawValue ); - case 'content': - return parsePageContent( field.rawValue ); - default: - throw Error( `unknown field type ${ field.type }` ); - } -} - -export function parsePageDate( html: string ): DateField { - const container = document.createElement( 'div' ); - container.innerHTML = html.trim(); - const element = container.querySelector( 'time' ); - return newDateField( html, element ? element.dateTime : '' ); -} - -export function parsePageTitle( html: string ): TextField { - const deepestChild = findDeepestChild( html ); - return newTextField( html, deepestChild?.innerHTML ?? '' ); -} - -export function parsePageContent( html: string ): HtmlField { - return newHtmlField( html, serializeBlocks( html ) ); -} - -function serializeBlocks( html: string ): string { - const blocks = pasteHandler( { - mode: 'BLOCKS', - HTML: html, - } ); - return serialize( blocks ); -} diff --git a/src/parser/util.ts b/src/parser/util.ts index 608fbabd..fa089800 100644 --- a/src/parser/util.ts +++ b/src/parser/util.ts @@ -1,3 +1,13 @@ +import { pasteHandler, serialize } from '@wordpress/blocks'; + +export function htmlToBlocks( html: string ): string { + const blocks = pasteHandler( { + mode: 'BLOCKS', + HTML: html, + } ); + return serialize( blocks ); +} + export function findDeepestChild( html: string ): Element | undefined { const container = document.createElement( 'div' ); container.innerHTML = html.trim(); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c9be22df..9331ee8b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -12,9 +12,9 @@ import { useRouteLoaderData, } from 'react-router-dom'; import { StrictMode, useEffect, useState } from 'react'; -import { NewSession } from '@/ui/start/NewSession'; +import { NewSession } from '@/ui/session/NewSession'; import { ViewSession } from '@/ui/session/ViewSession'; -import { Home } from '@/ui/start/Home'; +import { Home } from '@/ui/Home'; import { getConfig, setConfig } from '@/storage/config'; import { getSession, listSessions, Session } from '@/storage/session'; import { PlaceholderPreview } from '@/ui/preview/PlaceholderPreview'; @@ -24,7 +24,7 @@ import { PlaygroundClient } from '@wp-playground/client'; import { Breadcrumbs } from '@/ui/components/Breadcrumbs'; import { NewBlueprint } from '@/ui/blueprints/NewBlueprint'; import { EditBlueprint } from '@/ui/blueprints/EditBlueprint'; -import { SubjectType } from '@/model/subject/Subject'; +import { SubjectType } from '@/model/Subject'; import { ImportWithBlueprint } from '@/ui/import/ImportWithBlueprint'; import { StartPageImport } from '@/ui/import/pages/StartPageImport'; import { SelectNavigation } from '@/ui/import/pages/SelectNavigation'; diff --git a/src/ui/start/Home.tsx b/src/ui/Home.tsx similarity index 100% rename from src/ui/start/Home.tsx rename to src/ui/Home.tsx diff --git a/src/ui/blueprints/BlueprintEditor.tsx b/src/ui/blueprints/BlueprintEditor.tsx new file mode 100644 index 00000000..4749762c --- /dev/null +++ b/src/ui/blueprints/BlueprintEditor.tsx @@ -0,0 +1,42 @@ +import { Field } from '@/model/field/Field'; +import { FieldsEditor } from '@/ui/components/FieldsEditor/FieldsEditor'; +import { getSchema } from '@/model/Schema'; +import { Subject } from '@/model/Subject'; +import { Blueprint } from '@/model/Blueprint'; + +interface Props { + blueprint: Blueprint; + subject: Subject; + onFieldChanged: ( name: string, field: Field, selector: string ) => void; +} + +export function BlueprintEditor( props: Props ) { + const { blueprint, subject, onFieldChanged } = props; + const schema = getSchema( subject.type ); + const schemaFields = schema.fields; + + const subjectFields: { name: string; field: Field }[] = []; + const selectors: { + name: string; + selector?: string; + }[] = []; + + Object.keys( schemaFields ).forEach( ( name ) => { + subjectFields.push( { + name, + field: subject.fields[ name ], + } ); + selectors.push( { + name, + selector: blueprint.selectors[ name ], + } ); + } ); + + return ( + + ); +} diff --git a/src/ui/blueprints/EditBlueprint.tsx b/src/ui/blueprints/EditBlueprint.tsx index 7ba4589e..57c079f2 100644 --- a/src/ui/blueprints/EditBlueprint.tsx +++ b/src/ui/blueprints/EditBlueprint.tsx @@ -1,25 +1,17 @@ import { useNavigate, useParams } from 'react-router-dom'; import { ReactElement, useEffect } from 'react'; import { useSessionContext } from '@/ui/session/SessionProvider'; -import { BlogPostBlueprintEditor } from '@/ui/blueprints/blog-post/BlogPostBlueprintEditor'; -import { PageBlueprintEditor } from '@/ui/blueprints/blog-post/PageBlueprintEditor'; +import { BlueprintEditor } from '@/ui/blueprints/BlueprintEditor'; import { Toolbar } from '@/ui/components/Toolbar'; -import { parseBlogPostField } from '@/parser/blog-post'; -import { parsePageField } from '@/parser/page'; -import { SubjectType } from '@/model/subject/Subject'; +import { validateFields } from '@/model/Subject'; import { Screens } from '@/ui/App'; import { useBlueprint } from '@/ui/hooks/useBlueprint'; import { useSubject } from '@/ui/hooks/useSubject'; import { Field } from '@/model/field/Field'; -import { BlogPost, validateBlogPost } from '@/model/subject/BlogPost'; -import { - BlogPostBlueprint, - validateBlogpostBlueprint, -} from '@/model/blueprint/BlogPost'; -import { Page, validatePage } from '@/model/subject/Page'; -import { PageBlueprint, validatePageBlueprint } from '@/model/blueprint/Page'; import { CommandTypes, sendCommandToContent } from '@/bus/Command'; import { Button } from '@wordpress/components'; +import { validateBlueprint } from '@/model/Blueprint'; +import { parseField } from '@/parser/field'; export function EditBlueprint() { const params = useParams(); @@ -59,72 +51,29 @@ export function EditBlueprint() { return; } - blueprint.fields[ name ].selector = selector; - - const subjectFieldsToUpdate: Record< string, Field > = {}; - switch ( subject.type ) { - case SubjectType.BlogPost: - blueprint.valid = validateBlogpostBlueprint( - blueprint as BlogPostBlueprint - ); - subjectFieldsToUpdate[ name ] = parseBlogPostField( - name, - field - ); - break; - case SubjectType.Page: - blueprint.valid = validatePageBlueprint( - blueprint as PageBlueprint - ); - subjectFieldsToUpdate[ name ] = parsePageField( name, field ); - break; - default: - throw Error( `unknown subject type ${ subject.type }` ); - } + blueprint.selectors[ name ] = selector; + blueprint.valid = validateBlueprint( blueprint ); + subject.fields[ name ] = parseField( field ); const bp = await apiClient!.blueprints.update( blueprint ); setBlueprint( bp ); - const updatedSubject = { - ...subject, - ...subjectFieldsToUpdate, - } as BlogPost; - const p = await apiClient!.blogPosts.update( - subject.id, - updatedSubject - ); + const p = await apiClient!.subjects.update( subject ); setSubject( p ); - void playgroundClient!.goTo( '/?p=' + subject.transformedId ); } let isValid = false; let editor: ReactElement | undefined; if ( blueprint && subject ) { - switch ( subject.type ) { - case SubjectType.BlogPost: - isValid = validateBlogPost( subject as BlogPost ); - editor = ( - - ); - break; - case SubjectType.Page: - isValid = validatePage( subject as Page ); - editor = ( - - ); - break; - default: - throw Error( `unknown subject type ${ subject.type }` ); - } + editor = ( + + ); + isValid = validateFields( subject ); } if ( isValid ) { diff --git a/src/ui/blueprints/NewBlueprint.tsx b/src/ui/blueprints/NewBlueprint.tsx index 6d908b91..6edb5f61 100644 --- a/src/ui/blueprints/NewBlueprint.tsx +++ b/src/ui/blueprints/NewBlueprint.tsx @@ -3,16 +3,15 @@ import { useSessionContext } from '@/ui/session/SessionProvider'; import { useNavigate, useParams } from 'react-router-dom'; import { Screens } from '@/ui/App'; import { Toolbar } from '@/ui/components/Toolbar'; -import { humanReadableSubjectType, SubjectType } from '@/model/subject/Subject'; -import { newBlogPostBlueprint } from '@/model/blueprint/BlogPost'; -import { newPageBlueprint } from '@/model/blueprint/Page'; -import { Blueprint } from '@/model/blueprint/Blueprint'; +import { SubjectType } from '@/model/Subject'; +import { newBlueprint } from '@/model/Blueprint'; import { CommandTypes, CurrentPageInfo, sendCommandToContent, } from '@/bus/Command'; import { Button } from '@wordpress/components'; +import { getSchema } from '@/model/Schema'; export function NewBlueprint() { const params = useParams(); @@ -22,7 +21,7 @@ export function NewBlueprint() { const { session, apiClient } = useSessionContext(); // Check if there is already a blueprint for the subjectType and if so, - // redirect to that blueprint's edit screen is the blueprint is not valid yet, + // redirect to that blueprint's edit screen if the blueprint is not valid yet, // or redirect to the import screen if the blueprint is already valid. useEffect( () => { if ( ! apiClient ) { @@ -48,12 +47,8 @@ export function NewBlueprint() { maybeRedirect().catch( console.error ); }, [ session.id, apiClient, subjectType, navigate ] ); - const navigateMessage = ( - <> - Navigate to the page of a{ ' ' } - { humanReadableSubjectType.get( subjectType ) }. - - ); + const schema = getSchema( subjectType ); + const navigateMessage = <>Navigate to the page of a { schema.title }.; const element = ( <> @@ -66,23 +61,9 @@ export function NewBlueprint() { type: CommandTypes.GetCurrentPageInfo, payload: {}, } ) ) as CurrentPageInfo; - let blueprint: Blueprint | null; - switch ( subjectType ) { - case SubjectType.BlogPost: - blueprint = await apiClient!.blueprints.create( - newBlogPostBlueprint( currentPage.url ) - ); - break; - case SubjectType.Page: - blueprint = await apiClient!.blueprints.create( - newPageBlueprint( currentPage.url ) - ); - break; - default: - throw Error( - `unknown post type ${ subjectType }` - ); - } + const blueprint = await apiClient!.blueprints.create( + newBlueprint( subjectType, currentPage.url ) + ); navigate( Screens.blueprints.edit( session.id, blueprint.id ) ); diff --git a/src/ui/blueprints/blog-post/BlogPostBlueprintEditor.tsx b/src/ui/blueprints/blog-post/BlogPostBlueprintEditor.tsx deleted file mode 100644 index 847f4f38..00000000 --- a/src/ui/blueprints/blog-post/BlogPostBlueprintEditor.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Field } from '@/model/field/Field'; -import { BlogPost } from '@/model/subject/BlogPost'; -import { BlogPostBlueprint } from '@/model/blueprint/BlogPost'; -import { FieldsEditor } from '@/ui/components/FieldsEditor/FieldsEditor'; - -interface Props { - blueprint: BlogPostBlueprint; - subject: BlogPost; - onFieldChanged: ( name: string, field: Field, selector: string ) => void; -} - -export function BlogPostBlueprintEditor( props: Props ) { - const { blueprint, subject, onFieldChanged } = props; - - const subjectFields: { name: string; field: Field }[] = [ - { name: 'title', field: subject.title }, - { name: 'date', field: subject.date }, - { name: 'content', field: subject.content }, - ]; - - const selectors: { - name: string; - selector?: string; - }[] = [ - { - name: 'title', - selector: blueprint.fields.title.selector, - }, - { - name: 'date', - selector: blueprint.fields.date.selector, - }, - { - name: 'content', - selector: blueprint.fields.content.selector, - }, - ]; - - return ( - - ); -} diff --git a/src/ui/blueprints/blog-post/PageBlueprintEditor.tsx b/src/ui/blueprints/blog-post/PageBlueprintEditor.tsx deleted file mode 100644 index 73643709..00000000 --- a/src/ui/blueprints/blog-post/PageBlueprintEditor.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Field } from '@/model/field/Field'; -import { FieldsEditor } from '@/ui/components/FieldsEditor/FieldsEditor'; -import { PageBlueprint } from '@/model/blueprint/Page'; -import { Page } from '@/model/subject/Page'; - -interface Props { - blueprint: PageBlueprint; - subject: Page; - onFieldChanged: ( name: string, field: Field, selector: string ) => void; -} - -export function PageBlueprintEditor( props: Props ) { - const { blueprint, subject, onFieldChanged } = props; - - const subjectFields: { name: string; field: Field }[] = [ - { name: 'title', field: subject.title }, - { name: 'content', field: subject.content }, - ]; - - const selectors: { - name: string; - selector?: string; - }[] = [ - { - name: 'title', - selector: blueprint.fields.title.selector, - }, - { - name: 'content', - selector: blueprint.fields.content.selector, - }, - ]; - - return ( - - ); -} diff --git a/src/ui/blueprints/ContentEventHandler.tsx b/src/ui/components/ContentEventHandler.tsx similarity index 100% rename from src/ui/blueprints/ContentEventHandler.tsx rename to src/ui/components/ContentEventHandler.tsx diff --git a/src/ui/components/FieldsEditor/FieldsEditor.tsx b/src/ui/components/FieldsEditor/FieldsEditor.tsx index aca7a2c5..58b2af21 100644 --- a/src/ui/components/FieldsEditor/FieldsEditor.tsx +++ b/src/ui/components/FieldsEditor/FieldsEditor.tsx @@ -2,8 +2,8 @@ import { Field } from '@/model/field/Field'; import { ReactElement, useEffect, useMemo, useState } from 'react'; import { CommandTypes, sendCommandToContent } from '@/bus/Command'; import { SingleFieldEditor } from '@/ui/components/FieldsEditor/SingleFieldEditor'; -import { ContentEventHandler } from '@/ui/blueprints/ContentEventHandler'; import { EventTypes } from '@/bus/Event'; +import { ContentEventHandler } from '@/ui/components/ContentEventHandler'; // Displays a list of fields that can be "edited" by selecting the content of each field, // which is done by clicking on elements in the source site. diff --git a/src/ui/hooks/useBlueprint.ts b/src/ui/hooks/useBlueprint.ts index 0ff44014..63d85910 100644 --- a/src/ui/hooks/useBlueprint.ts +++ b/src/ui/hooks/useBlueprint.ts @@ -1,4 +1,4 @@ -import { Blueprint } from '@/model/blueprint/Blueprint'; +import { Blueprint } from '@/model/Blueprint'; import { useSessionContext } from '@/ui/session/SessionProvider'; import { useEffect, useState } from 'react'; diff --git a/src/ui/hooks/useSubject.ts b/src/ui/hooks/useSubject.ts index 3242f8d9..4322b9ae 100644 --- a/src/ui/hooks/useSubject.ts +++ b/src/ui/hooks/useSubject.ts @@ -1,8 +1,6 @@ -import { Subject, SubjectType } from '@/model/subject/Subject'; +import { Subject, SubjectType } from '@/model/Subject'; import { useEffect, useState } from 'react'; import { useSessionContext } from '@/ui/session/SessionProvider'; -import { newBlogPost } from '@/model/subject/BlogPost'; -import { newPage } from '@/model/subject/Page'; // Create or load a Subject by its source URL. // If a Subject already exists for the source URL, we use that Subject, @@ -19,33 +17,12 @@ export function useSubject( if ( ! type || ! sourceUrl || ! apiClient ) { return; } - let subj: Subject | null; - switch ( type ) { - case SubjectType.BlogPost: - subj = - await apiClient!.blogPosts.findBySourceUrl( sourceUrl ); - break; - case SubjectType.Page: - subj = await apiClient!.pages.findBySourceUrl( sourceUrl ); - break; - default: - throw Error( `unknown blueprint type ${ type }` ); - } + let subj = await apiClient!.subjects.findBySourceUrl( + type, + sourceUrl + ); if ( ! subj ) { - switch ( type ) { - case SubjectType.BlogPost: - subj = await apiClient!.blogPosts.create( - newBlogPost( sourceUrl ) - ); - break; - case SubjectType.Page: - subj = await apiClient!.pages.create( - newPage( sourceUrl ) - ); - break; - default: - throw Error( `unknown blueprint type ${ type }` ); - } + subj = await apiClient!.subjects.create( type, sourceUrl ); } setSubject( subj ); } diff --git a/src/ui/import/ImportWithBlueprint.tsx b/src/ui/import/ImportWithBlueprint.tsx index 3248771e..9567bb10 100644 --- a/src/ui/import/ImportWithBlueprint.tsx +++ b/src/ui/import/ImportWithBlueprint.tsx @@ -1,11 +1,12 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useBlueprint } from '@/ui/hooks/useBlueprint'; -import { humanReadableSubjectType } from '@/model/subject/Subject'; +import { SubjectType } from '@/model/Subject'; import { Toolbar } from '@/ui/components/Toolbar'; import { ReactElement, useEffect } from 'react'; import { Screens } from '@/ui/App'; import { useSessionContext } from '@/ui/session/SessionProvider'; import { Button } from '@wordpress/components'; +import { getSchema } from '@/model/Schema'; export function ImportWithBlueprint() { const params = useParams(); @@ -23,15 +24,18 @@ export function ImportWithBlueprint() { const fields: ReactElement[] = []; if ( blueprint ) { - for ( const [ name, field ] of Object.entries( blueprint.fields ) ) { + for ( const [ name, selector ] of Object.entries( + blueprint.selectors + ) ) { fields.push(
  • - { name }: { field.selector } + { name }: { selector }
  • ); } } + const schema = getSchema( SubjectType.Page ); return ( <> { ! blueprint ? ( @@ -63,9 +67,8 @@ export function ImportWithBlueprint() { Continue - We'll now import{ ' ' } - { humanReadableSubjectType.get( blueprint.type ) }s using - the following selectors: + We'll now import { schema.title }s using the following + selectors:

      { fields }
    diff --git a/src/ui/import/pages/ImportPage.tsx b/src/ui/import/pages/ImportPage.tsx index 6d03b551..9bf872fa 100644 --- a/src/ui/import/pages/ImportPage.tsx +++ b/src/ui/import/pages/ImportPage.tsx @@ -3,14 +3,14 @@ import { useSessionContext } from '@/ui/session/SessionProvider'; import { useNavigate, useParams } from 'react-router-dom'; import { useSelectedPages } from '@/ui/import/pages/useSelectedPages'; import { useSubject } from '@/ui/hooks/useSubject'; -import { SubjectType } from '@/model/subject/Subject'; +import { SubjectType } from '@/model/Subject'; import { Field } from '@/model/field/Field'; -import { Page } from '@/model/subject/Page'; import { FieldsEditor } from '@/ui/components/FieldsEditor/FieldsEditor'; import { CommandTypes, sendCommandToContent } from '@/bus/Command'; -import { parsePageField } from '@/parser/page'; import { Toolbar } from '@/ui/import/pages/Toolbar'; import { Screens } from '@/ui/App'; +import { parseField } from '@/parser/field'; +import { getSchema } from '@/model/Schema'; // Import a specific page. // The urls of pages to import come from local storage. @@ -22,7 +22,6 @@ export function ImportPage() { const [ selectedPages ] = useSelectedPages(); const [ sourceUrl, setSourceUrl ] = useState< string >(); const [ subject, setPage ] = useSubject( SubjectType.Page, sourceUrl ); - const page: Page | undefined = subject ? ( subject as Page ) : undefined; // Find the url of the page to import. useEffect( () => { @@ -51,33 +50,30 @@ export function ImportPage() { // Make playground navigate to the transformed post of the page. useEffect( () => { - if ( page && !! playgroundClient ) { - void playgroundClient.goTo( page.previewUrl ); + if ( subject && !! playgroundClient ) { + void playgroundClient.goTo( subject.previewUrl ); } - }, [ page, playgroundClient ] ); + }, [ subject, playgroundClient ] ); - if ( ! page ) { + if ( ! subject ) { return 'Loading...'; } - const fields: { name: string; field: Field }[] = [ - { name: 'title', field: page.title }, - { name: 'content', field: page.content }, - ]; - + const schema = getSchema( SubjectType.Page ); + const schemaFields = schema.fields; + const fields: { name: string; field: Field }[] = []; const selectors: { name: string; selector?: string; - }[] = [ - { - name: 'title', - selector: '', - }, - { - name: 'content', - selector: '', - }, - ]; + }[] = []; + + Object.keys( schemaFields ).forEach( ( name ) => { + fields.push( { + name, + field: subject.fields[ name ], + } ); + selectors.push( { name, selector: '' } ); + } ); const backUrl = pageIndex === 0 @@ -102,10 +98,9 @@ export function ImportPage() { fields={ fields } selectors={ selectors } onFieldChanged={ async ( name: string, field: Field ) => { - // @ts-ignore - page[ name ] = parsePageField( name, field ); - const p = await apiClient!.pages.update( page!.id, page ); - setPage( p ); + subject.fields[ name ] = parseField( field ); + const s = await apiClient!.subjects.update( subject ); + setPage( s ); } } /> diff --git a/src/ui/import/pages/SelectNavigation.tsx b/src/ui/import/pages/SelectNavigation.tsx index a458d110..136e5b85 100644 --- a/src/ui/import/pages/SelectNavigation.tsx +++ b/src/ui/import/pages/SelectNavigation.tsx @@ -3,11 +3,11 @@ import { useSelectedPages } from '@/ui/import/pages/useSelectedPages'; import { EventTypes } from '@/bus/Event'; import { CommandTypes, sendCommandToContent } from '@/bus/Command'; import { Screens } from '@/ui/App'; -import { ContentEventHandler } from '@/ui/blueprints/ContentEventHandler'; import { useSessionContext } from '@/ui/session/SessionProvider'; import { useNavigate } from 'react-router-dom'; import { useEffect } from 'react'; import { Toolbar } from '@/ui/import/pages/Toolbar'; +import { ContentEventHandler } from '@/ui/components/ContentEventHandler'; // Ask the user where the navigation is and store its html in local storage. // Once we have the navigation html, proceed to next step. diff --git a/src/ui/main.ts b/src/ui/main.ts index 6b4f1f83..e2eeb6d0 100644 --- a/src/ui/main.ts +++ b/src/ui/main.ts @@ -3,14 +3,8 @@ import { Container, createRoot } from 'react-dom/client'; import { initParser } from '@/parser/init'; import '@wordpress/components/build-style/style.css'; import './app.css'; -import { getSchema } from '@/model/subject/Schema'; -import { SubjectType } from '@/model/subject/Subject'; initParser(); -// TODO: This is only here so that webpack embeds the schema.json into the js bundle. -// TODO: Once we call the getSchema() function from elsewhere, this call should be removed. -getSchema( SubjectType.BlogPost ); - const root = createRoot( document.getElementById( 'app' ) as Container ); root.render( await createApp() ); diff --git a/src/ui/start/NewSession.tsx b/src/ui/session/NewSession.tsx similarity index 100% rename from src/ui/start/NewSession.tsx rename to src/ui/session/NewSession.tsx diff --git a/src/ui/session/ViewSession.tsx b/src/ui/session/ViewSession.tsx index 1b4ae866..b878c1ec 100644 --- a/src/ui/session/ViewSession.tsx +++ b/src/ui/session/ViewSession.tsx @@ -1,7 +1,7 @@ import { useSessionContext } from '@/ui/session/SessionProvider'; import { useNavigate } from 'react-router-dom'; import { Screens } from '@/ui/App'; -import { SubjectType } from '@/model/subject/Subject'; +import { SubjectType } from '@/model/Subject'; import { Button } from '@wordpress/components'; export function ViewSession() {