diff --git a/changelog.md b/changelog.md index 7e4953a..9044efb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-10-08 - 2.1.0 - feat(syncedinstance) +Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README + +- Introduce SyncedInstance class (ts/classes.syncedinstance.ts) to synchronize tags, posts and pages across Ghost instances with mapping and history support. New APIs: syncTags, syncPosts, syncPages, syncAll, getSyncStatus, clearSyncHistory, clearMappings. +- Export SyncedInstance from the package entry point (ts/index.ts). +- Add integration tests for SyncedInstance (test/test.syncedinstance.node.ts). +- Expand README (readme.md) with Quick Start, detailed Multi-Instance Synchronization docs, examples and updated API reference. + ## 2025-10-08 - 2.0.0 - BREAKING CHANGE(classes.ghost) Remove Settings and Webhooks browse/read APIs, remove noisy console.error logs, and update tests/docs diff --git a/readme.md b/readme.md index f397b82..4852d01 100644 --- a/readme.md +++ b/readme.md @@ -1,515 +1,662 @@ -# @apiclient.xyz/ghost -An unofficial Ghost API package +# @apiclient.xyz/ghost ๐ป -## Install -To install the @apiclient.xyz/ghost package, you can use npm or yarn. Make sure you're using a package manager that supports ESM and TypeScript. +> The **unofficial** TypeScript-first Ghost CMS API client that actually makes sense + +A modern, fully-typed API client for Ghost CMS that wraps both the Content and Admin APIs into an elegant, developer-friendly interface. Built with TypeScript, designed for humans. + +## ๐ Why This Library? + +- **๐ฏ TypeScript Native** - Full type safety for all Ghost API operations +- **๐ฅ Dual API Support** - Unified interface for both Content and Admin APIs +- **โก Modern Async/Await** - No callback hell, just clean promises +- **๐จ Elegant API** - Intuitive methods that match your mental model +- **๐ Smart Filtering** - Built-in minimatch support for flexible queries +- **๐ Multi-Instance Sync** - Synchronize content across multiple Ghost sites +- **๐ช Production Ready** - Battle-tested with comprehensive error handling + +## ๐ฆ Installation -NPM: ```bash npm install @apiclient.xyz/ghost ``` -Yarn: +Or with pnpm: + ```bash -yarn add @apiclient.xyz/ghost +pnpm install @apiclient.xyz/ghost ``` -## Usage - -Below is a detailed guide on how to use the `@apiclient.xyz/ghost` package in your TypeScript projects. We will cover everything from initialization to advanced usage scenarios. - -### Initialization - -First, you need to import the necessary classes and initialize the Ghost instance with the required API keys. +## ๐ฏ Quick Start ```typescript import { Ghost } from '@apiclient.xyz/ghost'; -// Initialize the Ghost instance -const ghostInstance = new Ghost({ - baseUrl: 'https://your-ghost-url.com', - contentApiKey: 'your-content-api-key', - adminApiKey: 'your-admin-api-key' +const ghost = new Ghost({ + baseUrl: 'https://your-ghost-site.com', + contentApiKey: 'your_content_api_key', + adminApiKey: 'your_admin_api_key' +}); + +const posts = await ghost.getPosts(); +posts.forEach(post => console.log(post.getTitle())); +``` + +That's it. No complicated setup, no boilerplate. Just pure Ghost API goodness. + +## ๐ Core API + +### ๐ Authentication & Setup + +Initialize the Ghost client with your API credentials: + +```typescript +const ghost = new Ghost({ + baseUrl: 'https://your-ghost-site.com', + contentApiKey: 'your_content_api_key', // For reading public content + adminApiKey: 'your_admin_api_key' // For write operations }); ``` -### Basic Usage +## ๐ Posts -#### Fetching Posts - -You can fetch posts with the following method. This will give you an array of `Post` instances. +### Get All Posts ```typescript -// Fetch all posts -const posts = await ghostInstance.getPosts(); +const posts = await ghost.getPosts(); -// Print titles of all posts posts.forEach(post => { console.log(post.getTitle()); + console.log(post.getExcerpt()); + console.log(post.getFeatureImage()); }); ``` -#### Fetching a Single Post by ID - -To fetch a single post by its ID: +### Filter Posts ```typescript -const postId = 'your-post-id'; -const post = await ghostInstance.getPostById(postId); +const techPosts = await ghost.getPosts({ + tag: 'technology', + limit: 10 +}); +const featuredPosts = await ghost.getPosts({ + featured: true, + limit: 5 +}); + +const authorPosts = await ghost.getPosts({ + author: 'john-doe' +}); +``` + +### Get Single Post + +```typescript +const post = await ghost.getPostById('post-id'); console.log(post.getTitle()); console.log(post.getHtml()); +console.log(post.getAuthor()); ``` -### Post Class Methods - -The `Post` class has several methods that can be useful for different scenarios. - -#### Getting Post Data - -These methods allow you to retrieve various parts of the post data. +### Create Post ```typescript -const postId = post.getId(); -const postTitle = post.getTitle(); -const postHtml = post.getHtml(); -const postExcerpt = post.getExcerpt(); -const postFeatureImage = post.getFeatureImage(); - -console.log(`ID: ${postId}`); -console.log(`Title: ${postTitle}`); -console.log(`HTML: ${postHtml}`); -console.log(`Excerpt: ${postExcerpt}`); -console.log(`Feature Image: ${postFeatureImage}`); +const newPost = await ghost.createPost({ + title: 'My Awesome Post', + html: '
This is the content of my post.
', + feature_image: 'https://example.com/image.jpg', + tags: [{ id: 'tag-id' }], + excerpt: 'A brief summary of the post' +}); ``` -#### Updating a Post - -To update a post, you can use the `update` method. Make sure you have the necessary permissions and fields. +Or create from HTML specifically: ```typescript -const updatedPostData = { +const post = await ghost.createPostFromHtml({ + title: 'My HTML Post', + html: 'Content here
' +}); +``` + +### Update Post + +```typescript +const post = await ghost.getPostById('post-id'); +await post.update({ ...post.toJson(), title: 'Updated Title', - html: 'Updated HTML content
' -}; - -await post.update(updatedPostData); -console.log('Post updated successfully'); + html: 'Updated content
' +}); ``` -#### Deleting a Post - -To delete a post: +### Delete Post ```typescript +const post = await ghost.getPostById('post-id'); await post.delete(); -console.log('Post deleted successfully'); ``` -### Advanced Scenarios +### Search Posts -#### Creating a New Post - -You can create a new post using the `createPost` method of the `Ghost` class. +Full-text search across post titles: ```typescript -const newPostData = { - id: 'new-post-id', - title: 'New Post Title', - html: 'This is the content of the new post.
', - excerpt: 'New post excerpt', - feature_image: 'https://your-image-url.com/image.jpg' -}; - -const newPost = await ghostInstance.createPost(newPostData); - -console.log('New post created successfully'); -console.log(`ID: ${newPost.getId()}`); -console.log(`Title: ${newPost.getTitle()}`); +const results = await ghost.searchPosts('typescript tutorial', { limit: 10 }); +results.forEach(post => console.log(post.getTitle())); ``` -#### Error Handling +### Related Posts -Both the `Ghost` and `Post` classes throw errors that you can catch and handle. +Get posts with similar tags: ```typescript -try { - const posts = await ghostInstance.getPosts(); - console.log('Posts fetched successfully'); -} catch (error) { - console.error('Error fetching posts:', error); -} +const post = await ghost.getPostById('post-id'); +const related = await ghost.getRelatedPosts(post.getId(), 5); +related.forEach(p => console.log(`Related: ${p.getTitle()}`)); ``` -Similarly, for updating or deleting a post: +### Bulk Operations ```typescript -try { - await post.update({ title: 'Updated Title' }); - console.log('Post updated successfully'); -} catch (error) { - console.error('Error updating post:', error); -} - -try { - await post.delete(); - console.log('Post deleted successfully'); -} catch (error) { - console.error('Error deleting post:', error); -} -``` - -#### Fetching Posts with Filters and Options - -The `getPosts` method can accept various filters and options. - -```typescript -const filteredPosts = await ghostInstance.getPosts({ limit: 10, include: 'tags,authors' }); - -filteredPosts.forEach(post => { - console.log(post.getTitle()); -}); -``` - -#### Fetching Tags - -You can fetch all tags from your Ghost site using the `getTags` method. - -```typescript -const tags = await ghostInstance.getTags(); - -tags.forEach(tag => { - console.log(`${tag.name} (${tag.slug})`); -}); -``` - -#### Fetching Tags with Minimatch Filtering - -The `getTags` method supports filtering tags using minimatch patterns on tag slugs. - -```typescript -// Fetch all tags starting with 'tech-' -const techTags = await ghostInstance.getTags({ filter: 'tech-*' }); - -// Fetch all tags containing 'blog' -const blogTags = await ghostInstance.getTags({ filter: '*blog*' }); - -// Fetch with limit -const limitedTags = await ghostInstance.getTags({ limit: 10, filter: 'a*' }); - -limitedTags.forEach(tag => { - console.log(tag.name); -}); -``` - -#### Working with Tag Class - -Fetch individual tags and manage them with the `Tag` class. - -```typescript -// Get tag by slug -const tag = await ghostInstance.getTagBySlug('tech'); -console.log(tag.getName()); -console.log(tag.getDescription()); - -// Create a new tag -const newTag = await ghostInstance.createTag({ - name: 'JavaScript', - slug: 'javascript', - description: 'Posts about JavaScript' +await ghost.bulkUpdatePosts(['id1', 'id2', 'id3'], { + featured: true }); -// Update a tag -await newTag.update({ - description: 'All things JavaScript' -}); - -// Delete a tag -await newTag.delete(); +await ghost.bulkDeletePosts(['id4', 'id5', 'id6']); ``` -#### Fetching Authors +## ๐ Pages -Manage and filter authors with minimatch patterns. +Pages work similarly to posts but are for static content: ```typescript -// Get all authors -const authors = await ghostInstance.getAuthors(); -authors.forEach(author => { - console.log(`${author.getName()} (${author.getSlug()})`); -}); +const pages = await ghost.getPages(); -// Filter authors with minimatch -const filteredAuthors = await ghostInstance.getAuthors({ filter: 'j*' }); +const aboutPage = await ghost.getPageBySlug('about'); +console.log(aboutPage.getHtml()); -// Get author by slug -const author = await ghostInstance.getAuthorBySlug('john-doe'); -console.log(author.getBio()); - -// Update author -await author.update({ - bio: 'Updated bio information' -}); -``` - -#### Working with Pages - -Ghost pages are similar to posts but for static content. - -```typescript -// Get all pages -const pages = await ghostInstance.getPages(); -pages.forEach(page => { - console.log(page.getTitle()); -}); - -// Filter pages with minimatch -const filteredPages = await ghostInstance.getPages({ filter: 'about*' }); - -// Get page by slug -const page = await ghostInstance.getPageBySlug('about'); -console.log(page.getHtml()); - -// Create a new page -const newPage = await ghostInstance.createPage({ - title: 'Contact Us', +const newPage = await ghost.createPage({ + title: 'Contact', html: 'Contact information...
' }); -// Update a page await newPage.update({ - html: 'Updated contact information...
' + html: 'Updated content
' }); -// Delete a page await newPage.delete(); ``` -#### Enhanced Post Filtering - -The `getPosts` method now supports multiple filtering options. +Filter pages with minimatch patterns: ```typescript -// Filter by tag -const techPosts = await ghostInstance.getPosts({ tag: 'tech', limit: 10 }); - -// Filter by author -const authorPosts = await ghostInstance.getPosts({ author: 'john-doe' }); - -// Get only featured posts -const featuredPosts = await ghostInstance.getPosts({ featured: true }); - -// Combine multiple filters -const filteredPosts = await ghostInstance.getPosts({ - tag: 'javascript', - featured: true, - limit: 5 +const filteredPages = await ghost.getPages({ + filter: 'about*', + limit: 10 }); ``` -#### Searching Posts +## ๐ท๏ธ Tags -Full-text search across post titles and excerpts. +### Get Tags ```typescript -// Search for posts containing a keyword -const searchResults = await ghostInstance.searchPosts('ghost api', { limit: 10 }); +const tags = await ghost.getTags(); +tags.forEach(tag => console.log(`${tag.name} (${tag.slug})`)); +``` -searchResults.forEach(post => { - console.log(post.getTitle()); - console.log(post.getExcerpt()); +### Filter Tags with Minimatch + +```typescript +const techTags = await ghost.getTags({ filter: 'tech-*' }); +const blogTags = await ghost.getTags({ filter: '*blog*' }); +``` + +### Get Single Tag + +```typescript +const tag = await ghost.getTagBySlug('javascript'); +console.log(tag.getName()); +console.log(tag.getDescription()); +``` + +### Create, Update, Delete Tags + +```typescript +const newTag = await ghost.createTag({ + name: 'TypeScript', + slug: 'typescript', + description: 'All about TypeScript' +}); + +await newTag.update({ + description: 'Everything TypeScript related' +}); + +await newTag.delete(); +``` + +## ๐ค Authors + +### Get Authors + +```typescript +const authors = await ghost.getAuthors(); +authors.forEach(author => { + console.log(`${author.getName()} (${author.getSlug()})`); }); ``` -#### Finding Related Posts - -Get posts with similar tags. +### Filter Authors ```typescript -const post = await ghostInstance.getPostById('some-post-id'); - -// Get related posts based on tags -const relatedPosts = await ghostInstance.getRelatedPosts(post.getId(), 5); - -relatedPosts.forEach(relatedPost => { - console.log(`Related: ${relatedPost.getTitle()}`); +const filteredAuthors = await ghost.getAuthors({ + filter: 'j*', + limit: 10 }); ``` -#### Image Upload - -Upload images to your Ghost site. +### Get Single Author ```typescript -// Upload an image file -const imageUrl = await ghostInstance.uploadImage('/path/to/image.jpg'); +const author = await ghost.getAuthorBySlug('john-doe'); +console.log(author.getBio()); +console.log(author.getProfileImage()); +``` -// Use the uploaded image URL in a post -await ghostInstance.createPost({ +### Update Author + +```typescript +await author.update({ + bio: 'Updated bio information', + website: 'https://johndoe.com' +}); +``` + +## ๐ฅ Members + +Manage your Ghost site members (requires Ghost membership features): + +### Get Members + +```typescript +const members = await ghost.getMembers({ limit: 100 }); +members.forEach(member => { + console.log(`${member.getName()} - ${member.getEmail()}`); + console.log(`Status: ${member.getStatus()}`); +}); +``` + +### Filter Members + +```typescript +const gmailMembers = await ghost.getMembers({ + filter: '*@gmail.com' +}); +``` + +### Get Single Member + +```typescript +const member = await ghost.getMemberByEmail('user@example.com'); +console.log(member.getStatus()); +console.log(member.getLabels()); +``` + +### Create Member + +```typescript +const newMember = await ghost.createMember({ + email: 'newuser@example.com', + name: 'New User', + note: 'VIP member' +}); +``` + +### Update and Delete Members + +```typescript +await member.update({ + name: 'Updated Name', + note: 'Premium member' +}); + +await member.delete(); +``` + +## ๐ช Webhooks + +Manage webhooks for Ghost events: + +```typescript +const webhook = await ghost.createWebhook({ + event: 'post.published', + target_url: 'https://example.com/webhook', + name: 'Post Published Webhook' +}); + +await ghost.updateWebhook(webhook.id, { + target_url: 'https://example.com/new-webhook' +}); + +await ghost.deleteWebhook(webhook.id); +``` + +**Note:** The Ghost Admin API only supports creating, updating, and deleting webhooks. Browsing and reading individual webhooks are not supported by the underlying SDK. + +## ๐ผ๏ธ Image Upload + +Upload images to your Ghost site: + +```typescript +const imageUrl = await ghost.uploadImage('/path/to/image.jpg'); + +await ghost.createPost({ title: 'Post with Image', html: 'Content here
', feature_image: imageUrl }); ``` -#### Bulk Operations +## ๐ Multi-Instance Synchronization -Perform operations on multiple posts at once. +The `SyncedInstance` class enables you to synchronize content across multiple Ghost instances - perfect for staging environments, multi-region deployments, or content distribution. + +### Setup ```typescript -// Bulk update posts -const postIds = ['id1', 'id2', 'id3']; -await ghostInstance.bulkUpdatePosts(postIds, { - featured: true +import { Ghost, SyncedInstance } from '@apiclient.xyz/ghost'; + +const sourceGhost = new Ghost({ + baseUrl: 'https://source.ghost.com', + contentApiKey: 'source_content_key', + adminApiKey: 'source_admin_key' }); -// Bulk delete posts -await ghostInstance.bulkDeletePosts(['id4', 'id5']); +const targetGhost1 = new Ghost({ + baseUrl: 'https://target1.ghost.com', + contentApiKey: 'target1_content_key', + adminApiKey: 'target1_admin_key' +}); + +const targetGhost2 = new Ghost({ + baseUrl: 'https://target2.ghost.com', + contentApiKey: 'target2_content_key', + adminApiKey: 'target2_admin_key' +}); + +const synced = new SyncedInstance(sourceGhost, [targetGhost1, targetGhost2]); ``` -#### Members Management - -Manage your Ghost site members (requires Ghost membership features enabled). +### Sync Content ```typescript -// Get all members -const members = await ghostInstance.getMembers({ limit: 100 }); -members.forEach(member => { - console.log(`${member.getName()} - ${member.getEmail()}`); -}); +const tagReport = await synced.syncTags(); +console.log(`Synced ${tagReport.totalItems} tags`); +console.log(`Duration: ${tagReport.duration}ms`); -// Filter members with minimatch -const filteredMembers = await ghostInstance.getMembers({ - filter: '*@gmail.com' -}); +const postReport = await synced.syncPosts(); +console.log(`Success: ${postReport.targetReports[0].successCount}`); +console.log(`Failed: ${postReport.targetReports[0].failureCount}`); -// Get member by email -const member = await ghostInstance.getMemberByEmail('user@example.com'); -console.log(member.getStatus()); - -// Create a new member -const newMember = await ghostInstance.createMember({ - email: 'newuser@example.com', - name: 'New User' -}); - -// Update a member -await newMember.update({ - name: 'Updated Name', - note: 'VIP member' -}); - -// Delete a member -await newMember.delete(); +const pageReport = await synced.syncPages(); ``` -#### Webhooks Management - -Manage webhooks for Ghost events. Note: The Ghost Admin API only supports creating, updating, and deleting webhooks (browsing and reading individual webhooks are not supported by the underlying SDK). +### Sync Options ```typescript -// Create a webhook -const newWebhook = await ghostInstance.createWebhook({ - event: 'post.published', - target_url: 'https://example.com/webhook', - name: 'Post Published Webhook' +const report = await synced.syncPosts({ + filter: 'featured-*', + dryRun: true, + incremental: true }); -// Update a webhook -await ghostInstance.updateWebhook(newWebhook.id, { - target_url: 'https://example.com/new-webhook' +report.targetReports.forEach(targetReport => { + console.log(`Target: ${targetReport.targetUrl}`); + targetReport.results.forEach(result => { + console.log(` ${result.sourceSlug}: ${result.status}`); + }); }); - -// Delete a webhook -await ghostInstance.deleteWebhook(newWebhook.id); ``` -### Example Projects +### Sync Everything -To give you a comprehensive understanding, let's look at a couple of example projects. +```typescript +const reports = await synced.syncAll({ + types: ['tags', 'posts', 'pages'], + syncOptions: { + dryRun: false + } +}); -#### Example 1: A Basic Blog +reports.forEach(report => { + console.log(`${report.contentType}: ${report.totalItems} items`); +}); +``` -In this scenario, we will create a simple script to fetch all posts and display their titles. +### Sync Status & History + +```typescript +const status = synced.getSyncStatus(); +console.log(`Total mappings: ${status.totalMappings}`); +console.log(`Recent syncs: ${status.recentSyncs.length}`); + +status.mappings.forEach(mapping => { + console.log(`Source: ${mapping.sourceSlug}`); + mapping.targetMappings.forEach(tm => { + console.log(` -> ${tm.targetUrl} (${tm.targetId})`); + }); +}); + +synced.clearSyncHistory(); +synced.clearMappings(); +``` + +## ๐จ Complete Example + +Here's a comprehensive example showing various operations: ```typescript import { Ghost } from '@apiclient.xyz/ghost'; -(async () => { - const ghostInstance = new Ghost({ - baseUrl: 'https://your-ghost-url.com', - contentApiKey: 'your-content-api-key', - adminApiKey: 'your-admin-api-key' - }); - - try { - const posts = await ghostInstance.getPosts(); - posts.forEach(post => console.log(post.getTitle())); - } catch (error) { - console.error('Error fetching posts:', error); - } -})(); -``` - -#### Example 2: Post Management Tool - -In this example, let's create a tool that can fetch, create, update, and delete posts. - -```typescript -import { Ghost, Post, IPostOptions } from '@apiclient.xyz/ghost'; - -const ghostInstance = new Ghost({ - baseUrl: 'https://your-ghost-url.com', - contentApiKey: 'your-content-api-key', - adminApiKey: 'your-admin-api-key' +const ghost = new Ghost({ + baseUrl: 'https://your-ghost-site.com', + contentApiKey: 'your_content_key', + adminApiKey: 'your_admin_key' }); -(async () => { - // Fetch posts - const posts = await ghostInstance.getPosts(); - console.log('Fetched posts:'); - posts.forEach(post => console.log(post.getTitle())); +async function main() { + const imageUrl = await ghost.uploadImage('./banner.jpg'); + + const tag = await ghost.createTag({ + name: 'Tutorial', + slug: 'tutorial', + description: 'Step-by-step guides' + }); - // Create a new post - const newPostData: IPostOptions = { - id: 'new-post-id', - title: 'New Post Title', - html: 'This is the content of the new post.
', - }; + const post = await ghost.createPost({ + title: 'Getting Started with Ghost', + html: 'This is an introduction...
', + feature_image: imageUrl, + tags: [{ id: tag.getId() }], + featured: true + }); - const newPost = await ghostInstance.createPost(newPostData); - console.log('New post created:', newPost.getTitle()); + console.log(`Created post: ${post.getTitle()}`); + + const related = await ghost.getRelatedPosts(post.getId(), 5); + console.log(`Found ${related.length} related posts`); - // Update the new post - const updatedPost = await newPost.update({ title: 'Updated Post Title' }); - console.log('Post updated:', updatedPost.getTitle()); + const searchResults = await ghost.searchPosts('getting started', { limit: 10 }); + console.log(`Search found ${searchResults.length} posts`); +} - // Delete the new post - await updatedPost.delete(); - console.log('Post deleted'); -})(); +main().catch(console.error); ``` -### Unit Tests +## ๐ Error Handling -This package includes unit tests written using the `@push.rocks/tapbundle` and `@push.rocks/qenv` libraries. Here is how you can run them. +All methods throw errors that you can catch and handle: -1. Install the development dependencies: +```typescript +try { + const post = await ghost.getPostById('invalid-id'); +} catch (error) { + console.error('Failed to fetch post:', error); +} + +try { + await post.update({ title: 'New Title' }); +} catch (error) { + console.error('Failed to update post:', error); +} +``` + +## ๐ API Reference + +### Ghost Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getPosts(options?)` | Get all posts with optional filtering | `PromiseThis is a test post for syncing
', + status: 'published', + tags: [{ id: testTag.tagData.id }] + }); + expect(testPost).toBeInstanceOf(ghost.Post); +}); + +tap.test('sync posts from source to target', async () => { + const report = await syncedInstance.syncPosts(); + + expect(report).toBeTruthy(); + expect(report.contentType).toEqual('posts'); + expect(report.totalItems).toBeGreaterThan(0); + expect(report.targetReports).toBeArray(); + expect(report.targetReports.length).toEqual(1); +}); + +tap.test('verify post sync in status', async () => { + const status = syncedInstance.getSyncStatus(); + expect(status.recentSyncs).toBeArray(); + const postSync = status.recentSyncs.find(s => s.contentType === 'posts'); + expect(postSync).toBeTruthy(); +}); + +tap.test('create test page on source', async () => { + const timestamp = Date.now(); + testPage = await sourceGhost.createPage({ + title: `Sync Test Page ${timestamp}`, + slug: `sync-test-page-${timestamp}`, + html: 'This is a test page for syncing
', + status: 'published' + }); + expect(testPage).toBeInstanceOf(ghost.Page); +}); + +tap.test('sync pages from source to target', async () => { + const report = await syncedInstance.syncPages(); + + expect(report).toBeTruthy(); + expect(report.contentType).toEqual('pages'); + expect(report.totalItems).toBeGreaterThan(0); + expect(report.targetReports).toBeArray(); + expect(report.targetReports.length).toEqual(1); +}); + +tap.test('verify page sync in status', async () => { + const status = syncedInstance.getSyncStatus(); + const pageSync = status.recentSyncs.find(s => s.contentType === 'pages'); + expect(pageSync).toBeTruthy(); +}); + +tap.test('test syncAll method', async () => { + const reports = await syncedInstance.syncAll({ + types: ['tags', 'posts', 'pages'], + syncOptions: { dryRun: true } + }); + + expect(reports).toBeArray(); + expect(reports.length).toEqual(3); + expect(reports[0].contentType).toEqual('tags'); + expect(reports[1].contentType).toEqual('posts'); + expect(reports[2].contentType).toEqual('pages'); +}); + +tap.test('test clear methods', async () => { + syncedInstance.clearMappings(); + syncedInstance.clearSyncHistory(); + + const status = syncedInstance.getSyncStatus(); + expect(status.totalMappings).toEqual(0); + expect(status.recentSyncs.length).toEqual(0); +}); + +tap.test('update tag and re-sync', async () => { + await testTag.update({ + description: 'Updated description for sync test' + }); + + const report = await syncedInstance.syncTags(); + expect(report).toBeTruthy(); + expect(report.totalItems).toBeGreaterThan(0); +}); + +tap.test('cleanup - delete synced content', async () => { + if (testPost) { + await testPost.delete(); + const targetPosts = await targetGhost.getPosts({ filter: `slug:${testPost.postData.slug}` }); + if (targetPosts.length > 0) { + await targetPosts[0].delete(); + } + } + + if (testPage) { + await testPage.delete(); + try { + const targetPage = await targetGhost.getPageBySlug(testPage.pageData.slug); + await targetPage.delete(); + } catch (error) { + } + } + + if (testTag) { + await testTag.delete(); + try { + const targetTag = await targetGhost.getTagBySlug(testTag.tagData.slug); + await targetTag.delete(); + } catch (error) { + } + } +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c4dfa7a..c3d40d2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@apiclient.xyz/ghost', - version: '2.0.0', + version: '2.1.0', description: 'An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.' } diff --git a/ts/classes.syncedinstance.ts b/ts/classes.syncedinstance.ts new file mode 100644 index 0000000..2e9f8aa --- /dev/null +++ b/ts/classes.syncedinstance.ts @@ -0,0 +1,406 @@ +import * as plugins from './ghost.plugins.js'; +import { Ghost } from './classes.ghost.js'; +import { type IPost } from './classes.post.js'; +import { type IPage } from './classes.page.js'; +import { type ITag } from './classes.post.js'; + +export interface ISyncOptions { + incremental?: boolean; + filter?: string; + dryRun?: boolean; +} + +export interface ISyncItemResult { + sourceId: string; + sourceSlug: string; + targetId?: string; + status: 'created' | 'updated' | 'skipped' | 'failed'; + error?: string; +} + +export interface ISyncTargetReport { + targetUrl: string; + results: ISyncItemResult[]; + successCount: number; + failureCount: number; +} + +export interface ISyncReport { + contentType: 'posts' | 'pages' | 'tags'; + totalItems: number; + targetReports: ISyncTargetReport[]; + duration: number; + timestamp: Date; +} + +export interface ISyncMapping { + sourceId: string; + sourceSlug: string; + targetMappings: Array<{ + targetUrl: string; + targetId: string; + lastSynced: Date; + }>; +} + +export class SyncedInstance { + public sourceGhost: Ghost; + public targetGhosts: Ghost[]; + private syncMappings: Map