422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
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<string, ISyncMapping>;
|
|
private syncHistory: ISyncReport[];
|
|
|
|
constructor(sourceGhost: Ghost, targetGhosts: Ghost[]) {
|
|
// Validate that no target instance is the same as the source instance
|
|
const sourceUrl = sourceGhost.options.baseUrl.replace(/\/$/, '').toLowerCase();
|
|
|
|
for (const targetGhost of targetGhosts) {
|
|
const targetUrl = targetGhost.options.baseUrl.replace(/\/$/, '').toLowerCase();
|
|
|
|
if (sourceUrl === targetUrl) {
|
|
throw new Error(
|
|
`Cannot sync to the same instance. Source and target both point to: ${sourceUrl}. ` +
|
|
`This would create a circular sync and cause excessive API calls.`
|
|
);
|
|
}
|
|
}
|
|
|
|
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<ISyncReport> {
|
|
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,
|
|
slug: sourceTag.slug,
|
|
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<ISyncReport> {
|
|
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<ISyncReport> {
|
|
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<IPage> = {
|
|
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<ISyncReport[]> {
|
|
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();
|
|
}
|
|
}
|