diff --git a/src/lib/pushers/asset-pusher.ts b/src/lib/pushers/asset-pusher.ts index 800664a..684097e 100644 --- a/src/lib/pushers/asset-pusher.ts +++ b/src/lib/pushers/asset-pusher.ts @@ -16,7 +16,7 @@ function extractErrorMessage(error: any): string { // Check for direct axios response data first (our direct axios calls) if (error?.response?.data) { const data = error.response.data; - if (typeof data === 'string') return data; + if (typeof data === "string") return data; if (data.exceptionMessage) return data.exceptionMessage; // Agility API format if (data.message) return data.message; if (data.error) return data.error; @@ -27,7 +27,7 @@ function extractErrorMessage(error: any): string { // Check for SDK-wrapped axios response data if (error?.innerError?.response?.data) { const data = error.innerError.response.data; - if (typeof data === 'string') return data; + if (typeof data === "string") return data; if (data.exceptionMessage) return data.exceptionMessage; if (data.message) return data.message; if (data.error) return data.error; @@ -41,7 +41,7 @@ function extractErrorMessage(error: any): string { export async function pushAssets( sourceData: mgmtApi.Media[], // TODO: Type these targetData: mgmtApi.Media[], // TODO: Type these - onProgress?: (processed: number, total: number, status?: "success" | "error") => void + onProgress?: (processed: number, total: number, status?: "success" | "error") => void, ): Promise<{ status: "success" | "error"; successful: number; failed: number; skipped: number }> { // Extract data from sourceData - unified parameter pattern const assets: mgmtApi.Media[] = sourceData || []; @@ -93,19 +93,18 @@ export async function pushAssets( // root level folder needs to be "/", otherwise the variable is OK to use. let folderPath = containerFolderPath === "." ? "/" : containerFolderPath; - // Use simplified change detection pattern const existingMapping = referenceMapper.getAssetMapping(media, "source"); - + // Also check if asset already exists in target by originKey (path+filename) - const targetAssetByOriginKey = targetData.find(t => t.originKey === media.originKey); - + const targetAssetByOriginKey = targetData.find((t) => t.originKey === media.originKey); + // Debug logging for asset matching (verbose mode) if (state.verbose && !existingMapping && !targetAssetByOriginKey) { // Show first few target originKeys for comparison - const sampleTargetKeys = targetData.slice(0, 3).map(t => t.originKey); + const sampleTargetKeys = targetData.slice(0, 3).map((t) => t.originKey); } - + // If no mapping but asset exists by originKey in target, create mapping and skip if (!existingMapping && targetAssetByOriginKey) { referenceMapper.addMapping(media, targetAssetByOriginKey); @@ -113,16 +112,25 @@ export async function pushAssets( skipped++; continue; } - + const shouldCreate = existingMapping === null && !targetAssetByOriginKey; // get the target asset, check if the source and targets need updates - const targetAsset: mgmtApi.Media = targetData.find(targetAsset => targetAsset.mediaID === existingMapping?.targetMediaID) || null; - const isTargetSafe = existingMapping !== null && referenceMapper.hasTargetChanged(targetAsset); + const targetAsset: mgmtApi.Media = + targetData.find((targetAsset) => targetAsset.mediaID === existingMapping?.targetMediaID) || null; + const hasTargetChanges = existingMapping !== null && referenceMapper.hasTargetChanged(targetAsset); const hasSourceChanges = existingMapping !== null && referenceMapper.hasSourceChanged(media); - const shouldUpdate = existingMapping !== null && isTargetSafe && hasSourceChanges; - const shouldSkip = existingMapping !== null && !isTargetSafe && !hasSourceChanges; + const isConflict = existingMapping !== null && hasTargetChanges && hasSourceChanges; + const isOverwrite = state.overwrite; + const shouldSkip = existingMapping !== null && !hasTargetChanges && !hasSourceChanges; + // Update if: + // We have existing mapping + // AND We are not skipping because target AND source have no changes + // AND Either IF + // We have no conflict + // OR we are in overwrite mode (force update even if conflict) + const shouldUpdate = existingMapping !== null && !shouldSkip && (!isConflict || isOverwrite); if (shouldCreate) { // Asset needs to be created (doesn't exist in target) @@ -134,7 +142,7 @@ export async function pushAssets( sourceGuid[0], targetGuid[0], referenceMapper, - logger + logger, ); referenceMapper.addMapping(media, createdAsset); successful++; @@ -148,7 +156,7 @@ export async function pushAssets( sourceGuid[0], targetGuid[0], referenceMapper, - logger + logger, ); referenceMapper.addMapping(media, updatedAsset); successful++; @@ -160,8 +168,7 @@ export async function pushAssets( } catch (error: any) { const errorMsg = extractErrorMessage(error); logger.asset.error(media, errorMsg, targetGuid[0]); - - + failed++; currentStatus = "error"; overallStatus = "error"; @@ -175,7 +182,7 @@ export async function pushAssets( } console.log( - ansiColors.yellow(`Processed ${successful}/${totalAssets} assets (${failed} failed, ${skipped} skipped)`) + ansiColors.yellow(`Processed ${successful}/${totalAssets} assets (${failed} failed, ${skipped} skipped)`), ); return { status: overallStatus, successful, failed, skipped }; } @@ -192,28 +199,27 @@ async function createAsset( sourceGuid: string, targetGuid: string, referenceMapper: AssetMapper, - logger: Logs + logger: Logs, ): Promise { // Handle gallery if present let targetMediaGroupingID = await resolveGalleryMapping(media, apiClient, sourceGuid, targetGuid); - const fs = require('fs'); - const pathModule = require('path'); - + const fs = require("fs"); + const pathModule = require("path"); + // Resolve to absolute path from workspace root const resolvedPath = pathModule.resolve(process.cwd(), absoluteLocalFilePath); - + // Check file exists and has content if (!fs.existsSync(resolvedPath)) { throw new Error(`Local asset file not found: ${resolvedPath}`); } - + const fileStats = fs.statSync(resolvedPath); if (fileStats.size === 0) { throw new Error(`Local asset file is empty (0 bytes): ${resolvedPath}`); } - // Build form data with file stream using resolved absolute path const form = new FormData(); const fileStream = fs.createReadStream(resolvedPath); @@ -221,24 +227,24 @@ async function createAsset( // Make direct axios call with form-data headers (SDK bug workaround) // The SDK's executePost doesn't include form.getHeaders() which is required for multipart uploads - const axios = require('axios'); - + const axios = require("axios"); + // Get the base URL from the API client's options const baseUrl = (apiClient as any)._options?.baseUrl || determineBaseUrl(targetGuid); const token = (apiClient as any)._options?.token; - + const apiPath = `asset/upload?folderPath=${encodeURIComponent(folderPath)}&groupingID=${targetMediaGroupingID}`; const url = `${baseUrl}/api/v1/instance/${targetGuid}/${apiPath}`; - + const response = await axios.post(url, form, { headers: { ...form.getHeaders(), // Critical: include multipart boundary - 'Authorization': `Bearer ${token}`, - 'Cache-Control': 'no-cache' + Authorization: `Bearer ${token}`, + "Cache-Control": "no-cache", }, maxContentLength: Infinity, maxBodyLength: Infinity, - httpsAgent: state.local ? new (require('https').Agent)({ rejectUnauthorized: false }) : undefined + httpsAgent: state.local ? new (require("https").Agent)({ rejectUnauthorized: false }) : undefined, }); const uploadedMediaArray = response.data as mgmtApi.Media[]; @@ -258,13 +264,13 @@ async function createAsset( * Determine base URL for a GUID (fallback if not available from SDK) */ function determineBaseUrl(guid: string): string { - const separator = guid.split('-'); - if (separator[1] === 'd') return "https://mgmt-dev.aglty.io"; - if (separator[1] === 'u') return "https://mgmt.aglty.io"; - if (separator[1] === 'us2') return "https://mgmt-usa2.aglty.io"; - if (separator[1] === 'c') return "https://mgmt-ca.aglty.io"; - if (separator[1] === 'e') return "https://mgmt-eu.aglty.io"; - if (separator[1] === 'a') return "https://mgmt-aus.aglty.io"; + const separator = guid.split("-"); + if (separator[1] === "d") return "https://mgmt-dev.aglty.io"; + if (separator[1] === "u") return "https://mgmt.aglty.io"; + if (separator[1] === "us2") return "https://mgmt-usa2.aglty.io"; + if (separator[1] === "c") return "https://mgmt-ca.aglty.io"; + if (separator[1] === "e") return "https://mgmt-eu.aglty.io"; + if (separator[1] === "a") return "https://mgmt-aus.aglty.io"; return "https://mgmt.aglty.io"; } @@ -280,50 +286,50 @@ async function updateAsset( sourceGuid: string, targetGuid: string, referenceMapper: AssetMapper, - logger: Logs + logger: Logs, ): Promise { // Handle gallery if present let targetMediaGroupingID = await resolveGalleryMapping(media, apiClient, sourceGuid, targetGuid); - - const fs = require('fs'); - const pathModule = require('path'); - + + const fs = require("fs"); + const pathModule = require("path"); + // Resolve to absolute path from workspace root const resolvedPath = pathModule.resolve(process.cwd(), absoluteLocalFilePath); - + // Check file exists and has content if (!fs.existsSync(resolvedPath)) { throw new Error(`Local asset file not found: ${resolvedPath}`); } - + const fileStats = fs.statSync(resolvedPath); if (fileStats.size === 0) { throw new Error(`Local asset file is empty (0 bytes): ${resolvedPath}`); } - + // Build form data with file stream using resolved absolute path const form = new FormData(); const fileStream = fs.createReadStream(resolvedPath); form.append("files", fileStream, media.fileName); // Make direct axios call with form-data headers (SDK bug workaround) - const axios = require('axios'); - + const axios = require("axios"); + const baseUrl = (apiClient as any)._options?.baseUrl || determineBaseUrl(targetGuid); const token = (apiClient as any)._options?.token; - + const apiPath = `asset/upload?folderPath=${encodeURIComponent(folderPath)}&groupingID=${targetMediaGroupingID}`; const url = `${baseUrl}/api/v1/instance/${targetGuid}/${apiPath}`; - + const response = await axios.post(url, form, { headers: { ...form.getHeaders(), - 'Authorization': `Bearer ${token}`, - 'Cache-Control': 'no-cache' + Authorization: `Bearer ${token}`, + "Cache-Control": "no-cache", }, maxContentLength: Infinity, maxBodyLength: Infinity, - httpsAgent: state.local ? new (require('https').Agent)({ rejectUnauthorized: false }) : undefined + httpsAgent: state.local ? new (require("https").Agent)({ rejectUnauthorized: false }) : undefined, }); const uploadedMediaArray = response.data as mgmtApi.Media[]; @@ -331,7 +337,7 @@ async function updateAsset( if (!uploadedMediaArray || uploadedMediaArray.length === 0) { throw new Error(`API did not return uploaded media details for ${media.fileName}`); } - + const uploadedMedia = uploadedMediaArray[0]; logger.asset.uploaded(media, "uploaded", targetGuid); @@ -348,7 +354,7 @@ async function resolveGalleryMapping( apiClient: mgmtApi.ApiClient, sourceGuid: string, - targetGuid: string + targetGuid: string, // referenceMapper: AssetMapper, ): Promise { let targetMediaGroupingID = -1; @@ -375,7 +381,7 @@ async function resolveGalleryMapping( } catch (error: any) { // Gallery doesn't exist - this is normal, asset will upload without gallery console.log( - `[Asset] Gallery ${media.mediaGroupingName} not found - asset will upload without gallery association` + `[Asset] Gallery ${media.mediaGroupingName} not found - asset will upload without gallery association`, ); } } diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 6e209c0..fb1ffdb 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -94,11 +94,9 @@ export async function pushContainers( const hasTargetChanges = containerMapper.hasTargetChanged(targetContainer); const hasSourceChanges = containerMapper.hasSourceChanged(sourceContainer); shouldUpdate = !hasTargetChanges && hasSourceChanges; - shouldSkip = - (existingMapping !== null && hasTargetChanges && !hasSourceChanges) || - (existingMapping !== null && !hasSourceChanges && !hasTargetChanges); + shouldSkip = !hasSourceChanges && !hasTargetChanges; - if (overwrite) { + if (overwrite && hasTargetChanges) { shouldUpdate = true; shouldSkip = false; } @@ -110,7 +108,11 @@ export async function pushContainers( // Container exists and is up to date - skip logger.container.skipped(sourceContainer, "up to date, skipping", targetGuid[0]); skipped++; - } else if (shouldUpdate) { + }else if (hasTargetChanges && !overwrite) { + // Container exists and is up to date - skip + logger.container.error(sourceContainer, "Conflict detected, use --overwrite to force changes", targetGuid[0]); + skipped++; + }else if (shouldUpdate) { // Container exists but needs updating const updateResult = await updateExistingContainer( diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index 24ba7fa..b8420aa 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -72,10 +72,6 @@ export class ContentBatchProcessor { `[${progress}%] Bulk batch ${batchNumber}/${contentBatches.length}: Processing ${contentBatch.length} ${batchType} content items (ETA: ${etaMinutes}m)...`, ); - // if (onProgress) { - // onProgress(batchNumber, contentBatches.length, processedSoFar, contentItems.length, "processing"); - // } - try { // Prepare content payloads for bulk upload @@ -191,17 +187,6 @@ export class ContentBatchProcessor { // Don't fail the entire batch due to callback errors } } - - // if (onProgress) { - // onProgress( - // batchNumber, - // contentBatches.length, - // processedSoFar + contentBatch.length, - // contentItems.length, - // "success" - // ); - // } - // Add small delay between batches to prevent API throttling if (i < contentBatches.length - 1) { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -218,21 +203,9 @@ export class ContentBatchProcessor { totalFailureCount += failedBatchItems.length; allFailedItems.push(...failedBatchItems); - - // if (onProgress) { - // onProgress( - // batchNumber, - // contentBatches.length, - // processedSoFar + contentBatch.length, - // contentItems.length, - // "error" - // ); - // } } } - // console.log(`🎯 Content batch processing complete: ${totalSuccessCount} success, ${totalFailureCount} failed`); - // Filter final publishableIds to only include items Published (state === 2) in source const publishableItems = allSuccessfulItems.filter((item) => { const sourceState = item.originalContent?.properties?.state; @@ -364,7 +337,6 @@ export class ContentBatchProcessor { existingContentID = existingTargetContentItem ? existingTargetContentItem.contentID : -1; } - if (!existingMapping && !existingTargetContentItem) { //see if this content item has been mapped in another locale existingContentID = await findContentInOtherLocale({ diff --git a/src/lib/pushers/content-pusher/util/change-detection.ts b/src/lib/pushers/content-pusher/util/change-detection.ts index c216845..03af6fe 100644 --- a/src/lib/pushers/content-pusher/util/change-detection.ts +++ b/src/lib/pushers/content-pusher/util/change-detection.ts @@ -36,20 +36,20 @@ export function changeDetection( const itemName = sourceEntity.properties?.referenceName || `ID:${sourceEntity.contentID}`; - if (!mapping && !targetEntity) { - //if we have no target content and no mapping - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: No mapping and no target entity → CREATE`); - // } - return { - entity: null, - shouldUpdate: false, - shouldCreate: true, - shouldSkip: false, - isConflict: false, - reason: "Mapping and Target Content Item doesn't exist" - }; - } + if (!mapping && !targetEntity) { + //if we have no target content and no mapping + // if (state.verbose) { + // console.log(`[ChangeDetection] ${itemName}: No mapping and no target entity → CREATE`); + // } + return { + entity: null, + shouldUpdate: false, + shouldCreate: true, + shouldSkip: false, + isConflict: false, + reason: "Mapping and Target Content Item doesn't exist", + }; + } // Check if update is needed based on version or modification date const sourceVersion = sourceEntity.properties?.versionID || 0; @@ -108,17 +108,6 @@ export function changeDetection( }; } - if (overwrite) { - return { - entity: targetEntity, - shouldUpdate: true, - shouldCreate: false, - shouldSkip: false, - isConflict: false, - reason: "Overwrite mode enabled", - }; - } - return { entity: targetEntity, shouldUpdate: false, diff --git a/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts b/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts index 40940c3..cb44195 100644 --- a/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts @@ -142,15 +142,15 @@ describe("changeDetection — update path", () => { // ─── overwrite mode ─────────────────────────────────────────────────────────── describe("changeDetection — overwrite mode", () => { - it("returns shouldUpdate=true in overwrite mode even when source is not newer", () => { + it("returns shouldUpdate=false in overwrite mode when source is not newer", () => { setState({ overwrite: true, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 5); const target = makeContent(100, 5); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); const result = changeDetection(source, target, mapping, "en-us"); - expect(result.shouldUpdate).toBe(true); - expect(result.shouldSkip).toBe(false); - expect(result.reason).toMatch(/overwrite/i); + expect(result.shouldUpdate).toBe(false); + expect(result.shouldSkip).toBe(true); + expect(result.reason).toMatch(/Entity exists and is up to date/i); }); it("returns conflict=false in overwrite flag is present when changes to source and target", () => { diff --git a/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts b/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts index d90c66c..8d6eaf7 100644 --- a/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts @@ -1,15 +1,15 @@ -import { resetState, setState } from 'core/state'; -import { findContentInTargetInstance } from '../find-content-in-target-instance'; +import { resetState, setState } from "core/state"; +import { findContentInTargetInstance } from "../find-content-in-target-instance"; -jest.mock('lib/mappers/content-item-mapper', () => ({ +jest.mock("lib/mappers/content-item-mapper", () => ({ ContentItemMapper: jest.fn(), })); beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -21,27 +21,29 @@ afterEach(() => { function makeContent(id: number, versionID = 0, referenceName = `ref-${id}`): any { return { contentID: id, - properties: { referenceName, versionID, definitionName: 'Model' }, + properties: { referenceName, versionID, definitionName: "Model" }, fields: {}, }; } -function makeMapper(opts: { - mapping?: any; - targetEntity?: any; - locale?: string; -} = {}): any { +function makeMapper( + opts: { + mapping?: any; + targetEntity?: any; + locale?: string; + } = {}, +): any { return { getContentItemMappingByContentID: jest.fn().mockReturnValue(opts.mapping ?? null), getMappedEntity: jest.fn().mockReturnValue(opts.targetEntity ?? null), - locale: opts.locale ?? 'en-us', + locale: opts.locale ?? "en-us", }; } // ─── no mapping exists ──────────────────────────────────────────────────────── -describe('findContentInTargetInstance — no mapping', () => { - it('returns shouldCreate=true when no mapping and no target entity', () => { +describe("findContentInTargetInstance — no mapping", () => { + it("returns shouldCreate=true when no mapping and no target entity", () => { const source = makeContent(1, 5); const mapper = makeMapper({ mapping: null, targetEntity: null }); const result = findContentInTargetInstance({ sourceContent: source, referenceMapper: mapper }); @@ -51,7 +53,7 @@ describe('findContentInTargetInstance — no mapping', () => { expect(result.isConflict).toBe(false); }); - it('does not call getMappedEntity when no mapping exists', () => { + it("does not call getMappedEntity when no mapping exists", () => { const source = makeContent(1, 5); const mapper = makeMapper({ mapping: null }); findContentInTargetInstance({ sourceContent: source, referenceMapper: mapper }); @@ -61,12 +63,12 @@ describe('findContentInTargetInstance — no mapping', () => { // ─── mapping exists, target entity found ───────────────────────────────────── -describe('findContentInTargetInstance — mapping and target entity exist', () => { - it('returns shouldSkip=true when source and target versions are unchanged', () => { - setState({ overwrite: false, sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("findContentInTargetInstance — mapping and target entity exist", () => { + it("returns shouldSkip=true when source and target versions are unchanged", () => { + setState({ overwrite: false, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const mapping = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 5, @@ -80,11 +82,11 @@ describe('findContentInTargetInstance — mapping and target entity exist', () = expect(result.content).toBe(target); }); - it('returns shouldUpdate=true when source version is newer than mapped', () => { - setState({ overwrite: false, sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); + it("returns shouldUpdate=true when source version is newer than mapped", () => { + setState({ overwrite: false, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const mapping = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 3, @@ -99,8 +101,8 @@ describe('findContentInTargetInstance — mapping and target entity exist', () = it('calls getMappedEntity with the mapping and "target" type', () => { const mapping = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 5, @@ -110,18 +112,18 @@ describe('findContentInTargetInstance — mapping and target entity exist', () = const target = makeContent(100, 5); const mapper = makeMapper({ mapping, targetEntity: target }); findContentInTargetInstance({ sourceContent: source, referenceMapper: mapper }); - expect(mapper.getMappedEntity).toHaveBeenCalledWith(mapping, 'target'); + expect(mapper.getMappedEntity).toHaveBeenCalledWith(mapping, "target"); }); }); // ─── mapping exists but target entity is missing ───────────────────────────── -describe('findContentInTargetInstance — mapping exists, target entity missing', () => { - it('treats missing target entity as null when running changeDetection', () => { +describe("findContentInTargetInstance — mapping exists, target entity missing", () => { + it("treats missing target entity as null when running changeDetection", () => { setState({ overwrite: false }); const mapping = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 5, @@ -139,12 +141,12 @@ describe('findContentInTargetInstance — mapping exists, target entity missing' // ─── conflict detection ─────────────────────────────────────────────────────── -describe('findContentInTargetInstance — conflict detection', () => { - it('returns isConflict=true when both source and target versions changed', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("findContentInTargetInstance — conflict detection", () => { + it("returns isConflict=true when both source and target versions changed", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const mapping = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 5, @@ -160,12 +162,12 @@ describe('findContentInTargetInstance — conflict detection', () => { // ─── overwrite mode ─────────────────────────────────────────────────────────── -describe('findContentInTargetInstance — overwrite mode', () => { - it('returns shouldUpdate=true in overwrite mode for up-to-date items', () => { - setState({ overwrite: true, sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("findContentInTargetInstance — overwrite mode", () => { + it("returns shouldUpdate=false in overwrite mode for up-to-date items", () => { + setState({ overwrite: true, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const mapping = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 5, @@ -175,26 +177,26 @@ describe('findContentInTargetInstance — overwrite mode', () => { const target = makeContent(100, 5); const mapper = makeMapper({ mapping, targetEntity: target }); const result = findContentInTargetInstance({ sourceContent: source, referenceMapper: mapper }); - expect(result.shouldUpdate).toBe(true); + expect(result.shouldUpdate).toBe(false); }); }); // ─── result shape ───────────────────────────────────────────────────────────── -describe('findContentInTargetInstance — result shape', () => { - it('always returns all required fields', () => { +describe("findContentInTargetInstance — result shape", () => { + it("always returns all required fields", () => { const source = makeContent(1, 5); const mapper = makeMapper({ mapping: null, targetEntity: null }); const result = findContentInTargetInstance({ sourceContent: source, referenceMapper: mapper }); - expect(result).toHaveProperty('content'); - expect(result).toHaveProperty('shouldUpdate'); - expect(result).toHaveProperty('shouldCreate'); - expect(result).toHaveProperty('shouldSkip'); - expect(result).toHaveProperty('isConflict'); - expect(result).toHaveProperty('decision'); + expect(result).toHaveProperty("content"); + expect(result).toHaveProperty("shouldUpdate"); + expect(result).toHaveProperty("shouldCreate"); + expect(result).toHaveProperty("shouldSkip"); + expect(result).toHaveProperty("isConflict"); + expect(result).toHaveProperty("decision"); }); - it('content is null when entity is null in decision', () => { + it("content is null when entity is null in decision", () => { const source = makeContent(1, 5); const mapper = makeMapper({ mapping: null, targetEntity: null }); const result = findContentInTargetInstance({ sourceContent: source, referenceMapper: mapper }); diff --git a/src/lib/pushers/gallery-pusher.ts b/src/lib/pushers/gallery-pusher.ts index 05bd6d4..74fde48 100644 --- a/src/lib/pushers/gallery-pusher.ts +++ b/src/lib/pushers/gallery-pusher.ts @@ -36,7 +36,6 @@ function extractErrorMessage(error: any): string { export async function pushGalleries( sourceData: mgmtApi.assetMediaGrouping[], targetData: mgmtApi.assetMediaGrouping[] - // onProgress?: (processed: number, total: number, status?: 'success' | 'error') => void ): Promise<{ status: "success" | "error"; successful: number; failed: number; skipped: number }> { // Extract data from sourceData - unified parameter pattern const galleries: mgmtApi.assetMediaGrouping[] = sourceData || []; diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index a5c1b18..3ed9fec 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -37,7 +37,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp let shouldCreateStub = []; let shouldUpdateFields = []; - let shouldSkip = []; + let shouldSkip: { model: mgmtApi.Model; reason: string }[] = []; let stubCreated = []; for (const sourceModel of models) { @@ -76,7 +76,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // Create the mapping for existing target models (ensures containers can reference them) referenceMapper.addMapping(sourceModel, targetModel); // Add to skip list since model already exists and is up to date - shouldSkip.push(sourceModel); + shouldSkip.push({ model: sourceModel, reason: "Skipping and adding default Agility mappings." }); continue; // Skip remaining conditions - mapping is now created, no further action needed } else { const targetMapping = targetModel.id ? referenceMapper.getModelMappingByID(targetModel.id, "target") : null; @@ -104,7 +104,10 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // if the mapping exists, and the source has changed, we need to update the fields // Added a special case for RichTextArea to handle the conflict scenario where the source has changed and the target has changed (first sync). // This will attempt to update the model, and write the mappings - if ((sourceMapping && hasSourceChanged) || (sourceMapping && fieldCountChanged)) { + if ( + (sourceMapping && hasSourceChanged && !hasTargetChanged) || + (sourceMapping && fieldCountChanged && !hasTargetChanged) + ) { shouldUpdateFields.push(sourceModel); continue; } @@ -116,18 +119,21 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // if the mapping exists, and the target has changed, we need to skip the model, not safe to update if (sourceMapping && hasTargetChanged) { - shouldSkip.push(sourceModel); + shouldSkip.push({ + model: sourceModel, + reason: "Warning: target model has changed! Add `--overwrite` flag to force update.", + }); continue; } // if the mapping exists, and the source and target have not changed, we need to skip the model if (sourceMapping && !hasSourceChanged && !hasTargetChanged && !state.overwrite) { - shouldSkip.push(sourceModel); + shouldSkip.push({ model: sourceModel, reason: "Model has not changed, skipping." }); continue; } - if (sourceMapping && !hasSourceChanged && !hasTargetChanged && state.overwrite) { - shouldSkip.push(sourceModel); + if (sourceMapping && !hasSourceChanged && !hasTargetChanged) { + shouldSkip.push({ model: sourceModel, reason: "Models have not changed, skipping." }); continue; } } @@ -169,7 +175,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp } for (const model of shouldSkip) { - logger.model.skipped(model, "up to date, skipping", targetGuid[0]); + logger.model.skipped(model.model, model.reason, targetGuid[0]); skipped++; } diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index 533fef0..6145544 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -86,13 +86,13 @@ export async function processPage({ } const hasSourceChanged = pageMapper.hasSourceChanged(page); - const targetChangeResult = pageMapper.hasTargetChanged(existingPage, pageMapping); + const hasTargetChanged = pageMapper.hasTargetChanged(existingPage, pageMapping); // A conflict exists whenever the target has changed independently — regardless of whether // the source also changed. Even if source is unchanged today, a future source push would // silently overwrite the target's independent changes without this guard. - const isConflict = targetChangeResult !== null; - const updateRequired = (hasSourceChanged && !isConflict) || overwrite; + const isConflict = hasTargetChanged !== null; + const updateRequired = (hasSourceChanged && !isConflict) || (overwrite && isConflict); const createRequired = !existingPage; const pageTypeDisplay = @@ -110,7 +110,7 @@ export async function processPage({ const targetUrl = `https://app.agilitycms.com/instance/${targetGuid}/${locale}/pages/${targetPageID}`; let reason: string; - if (targetChangeResult === 'file_missing') { + if (hasTargetChanged === 'file_missing') { reason = "target page may have been unpublished or deleted — cannot verify its current state"; } else if (hasSourceChanged) { reason = "changes detected in both source and target";