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; private syncHistory: ISyncReport[]; constructor(sourceGhost: Ghost, targetGhosts: Ghost[]) { this.sourceGhost = sourceGhost; this.targetGhosts = targetGhosts; this.syncMappings = new Map(); this.syncHistory = []; } private addMapping(contentType: string, sourceId: string, sourceSlug: string, targetUrl: string, targetId: string) { const key = `${contentType}:${sourceId}`; const existing = this.syncMappings.get(key); if (existing) { const targetMapping = existing.targetMappings.find(tm => tm.targetUrl === targetUrl); if (targetMapping) { targetMapping.targetId = targetId; targetMapping.lastSynced = new Date(); } else { existing.targetMappings.push({ targetUrl, targetId, lastSynced: new Date() }); } } else { this.syncMappings.set(key, { sourceId, sourceSlug, targetMappings: [{ targetUrl, targetId, lastSynced: new Date() }] }); } } private getMapping(contentType: string, sourceId: string, targetUrl: string): string | undefined { const key = `${contentType}:${sourceId}`; const mapping = this.syncMappings.get(key); if (!mapping) return undefined; const targetMapping = mapping.targetMappings.find(tm => tm.targetUrl === targetUrl); return targetMapping?.targetId; } public async syncTags(optionsArg?: ISyncOptions): Promise { const startTime = Date.now(); const report: ISyncReport = { contentType: 'tags', totalItems: 0, targetReports: [], duration: 0, timestamp: new Date() }; const sourceTags = await this.sourceGhost.getTags(optionsArg?.filter ? { filter: optionsArg.filter } : {}); report.totalItems = sourceTags.length; for (const targetGhost of this.targetGhosts) { const targetReport: ISyncTargetReport = { targetUrl: targetGhost.options.baseUrl, results: [], successCount: 0, failureCount: 0 }; for (const sourceTag of sourceTags) { try { let targetTag: any; let status: 'created' | 'updated' | 'skipped' = 'created'; try { targetTag = await targetGhost.getTagBySlug(sourceTag.slug); if (!optionsArg?.dryRun) { await targetTag.update({ name: sourceTag.name, description: sourceTag.description, feature_image: sourceTag.feature_image, visibility: sourceTag.visibility, meta_title: sourceTag.meta_title, meta_description: sourceTag.meta_description }); } status = 'updated'; } catch (error) { if (!optionsArg?.dryRun) { targetTag = await targetGhost.createTag({ name: sourceTag.name, slug: sourceTag.slug, description: sourceTag.description, feature_image: sourceTag.feature_image, visibility: sourceTag.visibility, meta_title: sourceTag.meta_title, meta_description: sourceTag.meta_description }); } } if (!optionsArg?.dryRun && targetTag) { this.addMapping('tags', sourceTag.id, sourceTag.slug, targetGhost.options.baseUrl, targetTag.tagData.id); } targetReport.results.push({ sourceId: sourceTag.id, sourceSlug: sourceTag.slug, targetId: targetTag?.tagData?.id, status }); targetReport.successCount++; } catch (error) { targetReport.results.push({ sourceId: sourceTag.id, sourceSlug: sourceTag.slug, status: 'failed', error: error instanceof Error ? error.message : String(error) }); targetReport.failureCount++; } } report.targetReports.push(targetReport); } report.duration = Date.now() - startTime; this.syncHistory.push(report); return report; } public async syncPosts(optionsArg?: ISyncOptions): Promise { const startTime = Date.now(); const report: ISyncReport = { contentType: 'posts', totalItems: 0, targetReports: [], duration: 0, timestamp: new Date() }; const sourcePosts = await this.sourceGhost.getPosts(optionsArg?.filter ? { filter: optionsArg.filter } : {}); report.totalItems = sourcePosts.length; for (const targetGhost of this.targetGhosts) { const targetReport: ISyncTargetReport = { targetUrl: targetGhost.options.baseUrl, results: [], successCount: 0, failureCount: 0 }; for (const sourcePost of sourcePosts) { try { const postData = sourcePost.postData; const tagSlugs = postData.tags?.map(t => t.slug) || []; const targetTagIds: string[] = []; for (const tagSlug of tagSlugs) { try { const targetTag = await targetGhost.getTagBySlug(tagSlug); targetTagIds.push(targetTag.tagData.id); } catch (error) { } } const syncData: any = { title: postData.title, slug: postData.slug, html: postData.html, feature_image: postData.feature_image, featured: postData.featured, status: postData.status, visibility: postData.visibility, meta_title: postData.meta_title, meta_description: postData.meta_description, published_at: postData.published_at, custom_excerpt: postData.custom_excerpt, tags: targetTagIds.length > 0 ? targetTagIds.map(id => ({ id })) : undefined }; let targetPost: any; let status: 'created' | 'updated' = 'created'; try { targetPost = await targetGhost.contentApi.posts.read({ slug: postData.slug }, { formats: ['html'] }); if (!optionsArg?.dryRun) { const updated = await targetGhost.adminApi.posts.edit({ ...syncData, id: targetPost.id, updated_at: targetPost.updated_at }); targetPost = updated; } status = 'updated'; } catch (error) { if (!optionsArg?.dryRun) { targetPost = await targetGhost.adminApi.posts.add(syncData); } } if (!optionsArg?.dryRun && targetPost) { this.addMapping('posts', postData.id, postData.slug, targetGhost.options.baseUrl, targetPost.id); } targetReport.results.push({ sourceId: postData.id, sourceSlug: postData.slug, targetId: targetPost?.id, status }); targetReport.successCount++; } catch (error) { targetReport.results.push({ sourceId: sourcePost.postData.id, sourceSlug: sourcePost.postData.slug, status: 'failed', error: error instanceof Error ? error.message : String(error) }); targetReport.failureCount++; } } report.targetReports.push(targetReport); } report.duration = Date.now() - startTime; this.syncHistory.push(report); return report; } public async syncPages(optionsArg?: ISyncOptions): Promise { const startTime = Date.now(); const report: ISyncReport = { contentType: 'pages', totalItems: 0, targetReports: [], duration: 0, timestamp: new Date() }; const sourcePages = await this.sourceGhost.getPages(optionsArg?.filter ? { filter: optionsArg.filter } : {}); report.totalItems = sourcePages.length; for (const targetGhost of this.targetGhosts) { const targetReport: ISyncTargetReport = { targetUrl: targetGhost.options.baseUrl, results: [], successCount: 0, failureCount: 0 }; for (const sourcePage of sourcePages) { try { const pageData = sourcePage.pageData; const syncData: Partial = { title: pageData.title, slug: pageData.slug, html: pageData.html, feature_image: pageData.feature_image, featured: pageData.featured, status: pageData.status, visibility: pageData.visibility, meta_title: pageData.meta_title, meta_description: pageData.meta_description, published_at: pageData.published_at, custom_excerpt: pageData.custom_excerpt }; let targetPage: any; let status: 'created' | 'updated' = 'created'; try { targetPage = await targetGhost.contentApi.pages.read({ slug: pageData.slug }, { formats: ['html'] }); if (!optionsArg?.dryRun) { const updated = await targetGhost.adminApi.pages.edit({ ...syncData, id: targetPage.id, updated_at: targetPage.updated_at }); targetPage = updated; } status = 'updated'; } catch (error) { if (!optionsArg?.dryRun) { targetPage = await targetGhost.adminApi.pages.add(syncData); } } if (!optionsArg?.dryRun && targetPage) { this.addMapping('pages', pageData.id, pageData.slug, targetGhost.options.baseUrl, targetPage.id); } targetReport.results.push({ sourceId: pageData.id, sourceSlug: pageData.slug, targetId: targetPage?.id, status }); targetReport.successCount++; } catch (error) { targetReport.results.push({ sourceId: sourcePage.pageData.id, sourceSlug: sourcePage.pageData.slug, status: 'failed', error: error instanceof Error ? error.message : String(error) }); targetReport.failureCount++; } } report.targetReports.push(targetReport); } report.duration = Date.now() - startTime; this.syncHistory.push(report); return report; } public async syncAll(optionsArg?: { types?: Array<'posts' | 'pages' | 'tags'>; syncOptions?: ISyncOptions }): Promise { const types = optionsArg?.types || ['tags', 'posts', 'pages']; const reports: ISyncReport[] = []; for (const type of types) { if (type === 'tags') { reports.push(await this.syncTags(optionsArg?.syncOptions)); } else if (type === 'posts') { reports.push(await this.syncPosts(optionsArg?.syncOptions)); } else if (type === 'pages') { reports.push(await this.syncPages(optionsArg?.syncOptions)); } } return reports; } public getSyncStatus(): { totalMappings: number; mappings: ISyncMapping[]; recentSyncs: ISyncReport[]; } { return { totalMappings: this.syncMappings.size, mappings: Array.from(this.syncMappings.values()), recentSyncs: this.syncHistory.slice(-10) }; } public clearSyncHistory(): void { this.syncHistory = []; } public clearMappings(): void { this.syncMappings.clear(); } }