466 lines
12 KiB
TypeScript
466 lines
12 KiB
TypeScript
/**
|
|
* WordPress import and source probing APIs
|
|
*/
|
|
|
|
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
|
|
|
// =============================================================================
|
|
// WordPress Import API
|
|
// =============================================================================
|
|
|
|
/** Field compatibility status */
|
|
export type FieldCompatibility =
|
|
| "compatible" // Field exists with compatible type
|
|
| "type_mismatch" // Field exists but type differs
|
|
| "missing"; // Field doesn't exist
|
|
|
|
/** Single field definition for import */
|
|
export interface ImportFieldDef {
|
|
slug: string;
|
|
label: string;
|
|
type: string;
|
|
required: boolean;
|
|
}
|
|
|
|
/** Schema status for a collection */
|
|
export interface CollectionSchemaStatus {
|
|
exists: boolean;
|
|
fieldStatus: Record<
|
|
string,
|
|
{
|
|
status: FieldCompatibility;
|
|
existingType?: string;
|
|
requiredType: string;
|
|
}
|
|
>;
|
|
canImport: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
/** Post type with full schema info */
|
|
export interface PostTypeAnalysis {
|
|
name: string;
|
|
count: number;
|
|
suggestedCollection: string;
|
|
requiredFields: ImportFieldDef[];
|
|
schemaStatus: CollectionSchemaStatus;
|
|
}
|
|
|
|
/** Individual attachment info for media import */
|
|
export interface AttachmentInfo {
|
|
id?: number;
|
|
title?: string;
|
|
url?: string;
|
|
filename?: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
/** Navigation menu from WordPress */
|
|
export interface NavMenu {
|
|
name: string;
|
|
slug: string;
|
|
count: number;
|
|
}
|
|
|
|
/** Custom taxonomy from WordPress */
|
|
export interface CustomTaxonomy {
|
|
name: string;
|
|
slug: string;
|
|
count: number;
|
|
hierarchical: boolean;
|
|
}
|
|
|
|
/** Author info from WordPress */
|
|
export interface WpAuthorInfo {
|
|
id?: number;
|
|
login?: string;
|
|
email?: string;
|
|
displayName?: string;
|
|
postCount: number;
|
|
}
|
|
|
|
export interface WxrAnalysis {
|
|
site: {
|
|
title: string;
|
|
url: string;
|
|
};
|
|
postTypes: PostTypeAnalysis[];
|
|
attachments: {
|
|
count: number;
|
|
items: AttachmentInfo[];
|
|
};
|
|
categories: number;
|
|
tags: number;
|
|
authors: WpAuthorInfo[];
|
|
customFields: Array<{
|
|
key: string;
|
|
count: number;
|
|
samples: string[];
|
|
suggestedField: string;
|
|
suggestedType: string;
|
|
isInternal: boolean;
|
|
}>;
|
|
/** Navigation menus found in the export */
|
|
navMenus?: NavMenu[];
|
|
/** Custom taxonomies found in the export */
|
|
customTaxonomies?: CustomTaxonomy[];
|
|
}
|
|
|
|
export interface PrepareRequest {
|
|
postTypes: Array<{
|
|
name: string;
|
|
collection: string;
|
|
fields: ImportFieldDef[];
|
|
}>;
|
|
}
|
|
|
|
export interface PrepareResult {
|
|
success: boolean;
|
|
collectionsCreated: string[];
|
|
fieldsCreated: Array<{ collection: string; field: string }>;
|
|
errors: Array<{ collection: string; error: string }>;
|
|
}
|
|
|
|
/** Author mapping from WP author login to EmDash user ID */
|
|
export interface AuthorMapping {
|
|
/** WordPress author login */
|
|
wpLogin: string;
|
|
/** WordPress author display name (for UI) */
|
|
wpDisplayName: string;
|
|
/** WordPress author email (for matching) */
|
|
wpEmail?: string;
|
|
/** EmDash user ID to assign (null = leave unassigned) */
|
|
emdashUserId: string | null;
|
|
/** Number of posts by this author */
|
|
postCount: number;
|
|
}
|
|
|
|
export interface ImportConfig {
|
|
postTypeMappings: Record<
|
|
string,
|
|
{
|
|
collection: string;
|
|
enabled: boolean;
|
|
}
|
|
>;
|
|
skipExisting: boolean;
|
|
/** Author mappings (WP author login -> EmDash user ID) */
|
|
authorMappings?: Record<string, string | null>;
|
|
}
|
|
|
|
export interface ImportResult {
|
|
success: boolean;
|
|
imported: number;
|
|
skipped: number;
|
|
errors: Array<{ title: string; error: string }>;
|
|
byCollection: Record<string, number>;
|
|
}
|
|
|
|
/**
|
|
* Analyze a WordPress WXR file
|
|
*/
|
|
export async function analyzeWxr(file: File): Promise<WxrAnalysis> {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress/analyze`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
return parseApiResponse<WxrAnalysis>(response, "Failed to analyze file");
|
|
}
|
|
|
|
/**
|
|
* Prepare WordPress import (create collections/fields)
|
|
*/
|
|
export async function prepareWxrImport(request: PrepareRequest): Promise<PrepareResult> {
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress/prepare`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(request),
|
|
});
|
|
return parseApiResponse<PrepareResult>(response, "Failed to prepare import");
|
|
}
|
|
|
|
/**
|
|
* Execute WordPress import
|
|
*/
|
|
export async function executeWxrImport(file: File, config: ImportConfig): Promise<ImportResult> {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
formData.append("config", JSON.stringify(config));
|
|
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress/execute`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
return parseApiResponse<ImportResult>(response, "Failed to import");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Media Import API
|
|
// =============================================================================
|
|
|
|
export interface MediaImportResult {
|
|
imported: Array<{
|
|
wpId?: number;
|
|
originalUrl: string;
|
|
newUrl: string;
|
|
mediaId: string;
|
|
}>;
|
|
failed: Array<{
|
|
wpId?: number;
|
|
originalUrl: string;
|
|
error: string;
|
|
}>;
|
|
urlMap: Record<string, string>;
|
|
}
|
|
|
|
/** Progress update sent during streaming media import */
|
|
export interface MediaImportProgress {
|
|
type: "progress";
|
|
current: number;
|
|
total: number;
|
|
filename?: string;
|
|
status: "downloading" | "uploading" | "done" | "skipped" | "failed";
|
|
error?: string;
|
|
}
|
|
|
|
export interface RewriteUrlsResult {
|
|
updated: number;
|
|
byCollection: Record<string, number>;
|
|
urlsRewritten: number;
|
|
errors: Array<{ collection: string; id: string; error: string }>;
|
|
}
|
|
|
|
/**
|
|
* Import media from WordPress with streaming progress
|
|
*
|
|
* @param attachments - Array of attachments to import
|
|
* @param onProgress - Callback for progress updates (optional)
|
|
* @returns Final import result
|
|
*/
|
|
export async function importWxrMedia(
|
|
attachments: AttachmentInfo[],
|
|
onProgress?: (progress: MediaImportProgress) => void,
|
|
): Promise<MediaImportResult> {
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress/media`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ attachments, stream: !!onProgress }),
|
|
});
|
|
|
|
if (!response.ok) await throwResponseError(response, "Failed to import media");
|
|
|
|
// If no progress callback, just parse as JSON (non-streaming mode)
|
|
// Note: streaming NDJSON responses are excluded from the { data } envelope
|
|
if (!onProgress) {
|
|
return parseApiResponse<MediaImportResult>(response, "Failed to import media");
|
|
}
|
|
|
|
// Streaming mode: read NDJSON line by line
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
throw new Error("Response body is not readable");
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let result: MediaImportResult | null = null;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Process complete lines
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
|
|
try {
|
|
const parsed: { type?: string; imported?: unknown } = JSON.parse(line);
|
|
if (parsed.type === "progress") {
|
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "progress"
|
|
onProgress(parsed as MediaImportProgress);
|
|
} else if (parsed.type === "result" || parsed.imported) {
|
|
// Final result (has type: "result" or is the result object)
|
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
|
|
result = parsed as MediaImportResult;
|
|
}
|
|
} catch {
|
|
// Ignore parse errors for incomplete JSON
|
|
console.warn("Failed to parse NDJSON line:", line);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any remaining data in buffer
|
|
if (buffer.trim()) {
|
|
try {
|
|
const parsed: { type?: string; imported?: unknown } = JSON.parse(buffer);
|
|
if (parsed.type === "result" || parsed.imported) {
|
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
|
|
result = parsed as MediaImportResult;
|
|
}
|
|
} catch {
|
|
console.warn("Failed to parse final NDJSON:", buffer);
|
|
}
|
|
}
|
|
|
|
if (!result) {
|
|
throw new Error("No result received from media import");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Import Source Probing
|
|
// =============================================================================
|
|
|
|
/** Capabilities of an import source */
|
|
export interface SourceCapabilities {
|
|
publicContent: boolean;
|
|
privateContent: boolean;
|
|
customPostTypes: boolean;
|
|
allMeta: boolean;
|
|
mediaStream: boolean;
|
|
}
|
|
|
|
/** Auth requirements for import */
|
|
export interface SourceAuth {
|
|
type: "oauth" | "token" | "password" | "none";
|
|
provider?: string;
|
|
oauthUrl?: string;
|
|
instructions?: string;
|
|
}
|
|
|
|
/** Suggested action after probing */
|
|
export type SuggestedAction =
|
|
| { type: "proceed" }
|
|
| { type: "oauth"; url: string; provider: string }
|
|
| { type: "upload"; instructions: string }
|
|
| { type: "install-plugin"; instructions: string };
|
|
|
|
/** Result from probing a single source */
|
|
export interface SourceProbeResult {
|
|
sourceId: string;
|
|
confidence: "definite" | "likely" | "possible";
|
|
detected: {
|
|
platform: string;
|
|
version?: string;
|
|
siteTitle?: string;
|
|
siteUrl?: string;
|
|
};
|
|
capabilities: SourceCapabilities;
|
|
auth?: SourceAuth;
|
|
suggestedAction: SuggestedAction;
|
|
preview?: {
|
|
posts?: number;
|
|
pages?: number;
|
|
media?: number;
|
|
};
|
|
}
|
|
|
|
/** Combined probe result */
|
|
export interface ProbeResult {
|
|
url: string;
|
|
isWordPress: boolean;
|
|
bestMatch: SourceProbeResult | null;
|
|
allMatches: SourceProbeResult[];
|
|
}
|
|
|
|
/**
|
|
* Probe a URL to detect import source
|
|
*/
|
|
export async function probeImportUrl(url: string): Promise<ProbeResult> {
|
|
const response = await apiFetch(`${API_BASE}/import/probe`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url }),
|
|
});
|
|
const data = await parseApiResponse<{ result: ProbeResult }>(response, "Failed to probe URL");
|
|
return data.result;
|
|
}
|
|
|
|
/**
|
|
* Rewrite URLs in content after media import
|
|
*/
|
|
export async function rewriteContentUrls(
|
|
urlMap: Record<string, string>,
|
|
collections?: string[],
|
|
): Promise<RewriteUrlsResult> {
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress/rewrite-urls`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ urlMap, collections }),
|
|
});
|
|
return parseApiResponse<RewriteUrlsResult>(response, "Failed to rewrite URLs");
|
|
}
|
|
|
|
// =============================================================================
|
|
// WordPress Plugin Direct Import API
|
|
// =============================================================================
|
|
|
|
/** WordPress Plugin analysis result */
|
|
export interface WpPluginAnalysis {
|
|
sourceId: string;
|
|
site: {
|
|
title: string;
|
|
url: string;
|
|
};
|
|
postTypes: PostTypeAnalysis[];
|
|
attachments: {
|
|
count: number;
|
|
items: AttachmentInfo[];
|
|
};
|
|
categories: number;
|
|
tags: number;
|
|
authors: WpAuthorInfo[];
|
|
/** Navigation menus found via the plugin */
|
|
navMenus?: NavMenu[];
|
|
/** Custom taxonomies found via the plugin */
|
|
customTaxonomies?: CustomTaxonomy[];
|
|
}
|
|
|
|
/**
|
|
* Analyze a WordPress site with EmDash Exporter plugin
|
|
*/
|
|
export async function analyzeWpPluginSite(url: string, token: string): Promise<WpPluginAnalysis> {
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/analyze`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url, token }),
|
|
});
|
|
const data = await parseApiResponse<{ analysis: WpPluginAnalysis }>(
|
|
response,
|
|
"Failed to analyze WordPress site",
|
|
);
|
|
return data.analysis;
|
|
}
|
|
|
|
/**
|
|
* Execute import from WordPress plugin API
|
|
*/
|
|
export async function executeWpPluginImport(
|
|
url: string,
|
|
token: string,
|
|
config: ImportConfig,
|
|
): Promise<ImportResult> {
|
|
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/execute`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url, token, config }),
|
|
});
|
|
const data = await parseApiResponse<{ result: ImportResult }>(
|
|
response,
|
|
"Failed to import from WordPress",
|
|
);
|
|
return data.result;
|
|
}
|