diff --git a/src/config.ts b/src/config.ts index 6bf4fb1..bf0a2a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,11 +44,15 @@ interface Config { 'VertexAI Api Domain Part'?: string; 'Gemini Model'?: string; 'Image Generation Model'?: string; + 'Is Promotion Mode': string; } export const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Config')!; -const DEFAULT_CONFIG = { +export const promotionSheet = + SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Promotion_Config')!; + +const DEFAULT_CONFIG: Config = { 'Ads API Key': '', 'Manager ID': '', 'Account ID': '', @@ -79,6 +83,7 @@ const DEFAULT_CONFIG = { 'VertexAI Api Domain Part': undefined, 'Gemini Model': undefined, 'Image Generation Model': undefined, + 'Is Promotion Mode': 'no', }; export const ADIOS_MODES = { @@ -95,3 +100,15 @@ export const CONFIG: Config = .reduce((res, e) => { return { ...res, [e[0]]: e[1] }; }, DEFAULT_CONFIG) ?? DEFAULT_CONFIG; + +export const PROMOTION_CONFIG: Config = + { + ...promotionSheet + ?.getRange('A2:B') + .getDisplayValues() + .filter(e => e[0]) + .reduce((res, e) => { + return { ...res, [e[0]]: e[1] }; + }, DEFAULT_CONFIG), + 'Is Promotion Mode': 'yes', // Set to 'true' for promotion config + } ?? DEFAULT_CONFIG; diff --git a/src/frontend-helper.ts b/src/frontend-helper.ts index 74b8a5d..88f5dbc 100644 --- a/src/frontend-helper.ts +++ b/src/frontend-helper.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CONFIG } from './config'; +import { CONFIG, PROMOTION_CONFIG } from './config'; import { GcsApi } from './gcs-api'; import { ImagePolicyViolations, - PolicyViolation, POLICY_VIOLATIONS_FILE, + PolicyViolation, } from './gemini-validation-service'; import { GoogleAdsApiFactory } from './google-ads-api-mock'; @@ -51,8 +51,9 @@ export interface Image { selected?: boolean; issues?: ImageIssue[]; } - -const gcsApi = new GcsApi(CONFIG['GCS Bucket']); +const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; +const config = isPromotionMode ? PROMOTION_CONFIG : CONFIG; +const gcsApi = new GcsApi(config['GCS Bucket']); const getData = () => { const gcsImages = gcsApi.listAllImages(GoogleAdsApiFactory.getAdsAccountId()); @@ -65,22 +66,22 @@ const getData = () => { const statusFolder = e.name.split('/')[2]; let status: IMAGE_STATUS | null = null; switch (statusFolder) { - case CONFIG['Generated DIR']: + case config['Generated DIR']: status = IMAGE_STATUS.GENERATED; break; - case CONFIG['Uploaded DIR']: + case config['Uploaded DIR']: status = IMAGE_STATUS.UPLOADED; break; - case CONFIG['Validated DIR']: + case config['Validated DIR']: status = IMAGE_STATUS.VALIDATED; break; - case CONFIG['Disapproved DIR']: + case config['Disapproved DIR']: status = IMAGE_STATUS.DISAPPROVED; break; - case CONFIG['Bad performance DIR']: + case config['Bad performance DIR']: status = IMAGE_STATUS.BAD_PERFORMANCE; break; - case CONFIG['Rejected DIR']: + case config['Rejected DIR']: status = IMAGE_STATUS.REJECTED; break; default: @@ -99,7 +100,7 @@ const getData = () => { adGroups[adGroupId].push({ filename, - url: `https://storage.mtls.cloud.google.com/${CONFIG['GCS Bucket']}/${e.name}`, + url: `https://storage.mtls.cloud.google.com/${config['GCS Bucket']}/${e.name}`, status, issues, }); @@ -116,17 +117,17 @@ const getData = () => { }; const setImageStatus = (images: Image[], status: IMAGE_STATUS) => { - const gcsApi = new GcsApi(CONFIG['GCS Bucket']); + const gcsApi = new GcsApi(config['GCS Bucket']); images.forEach(image => { const adGroupId = image.filename.split('|')[0]; gcsApi.moveImage( GoogleAdsApiFactory.getAdsAccountId(), adGroupId, image.filename, - CONFIG['Generated DIR'], + config['Generated DIR'], status === IMAGE_STATUS.VALIDATED - ? CONFIG['Validated DIR'] - : CONFIG['Rejected DIR'] + ? config['Validated DIR'] + : config['Rejected DIR'] ); }); }; @@ -164,7 +165,7 @@ class PolicyStatusByAdGroup { [filename: string]: PolicyViolation[]; } { const fullName = `${GoogleAdsApiFactory.getAdsAccountId()}/${adGroupId}/${ - CONFIG['Generated DIR'] + config['Generated DIR'] }/${POLICY_VIOLATIONS_FILE}`; try { diff --git a/src/google-ads-api.ts b/src/google-ads-api.ts index 2afb99e..9de43cc 100644 --- a/src/google-ads-api.ts +++ b/src/google-ads-api.ts @@ -138,6 +138,22 @@ export class GoogleAdsApi implements GoogleAdsApiInterface { }); } + pauseAdGroupAssets(resourceNames: string[]) { + const operations = resourceNames.map(resourceName => ({ + update: { + resource_name: resourceName, + status: 'PAUSED', + }, + update_mask: { + paths: ['status'], + }, + })); + return this.post('/adGroupAssets:mutate', { + customer_id: this._customerId, + operations, + }); + } + deleteExtensionFeedItem(resourceName?: string) { if (!resourceName) { return; diff --git a/src/image-extension-service.ts b/src/image-extension-service.ts index cedca9b..65ac3db 100644 --- a/src/image-extension-service.ts +++ b/src/image-extension-service.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CONFIG } from './config'; +import { CONFIG, PROMOTION_CONFIG } from './config'; import { GcsApi } from './gcs-api'; import { GoogleAdsApi } from './google-ads-api'; import { Triggerable } from './triggerable'; @@ -23,24 +23,34 @@ export class ImageExtensionService extends Triggerable { private readonly _gcsApi; private readonly _googleAdsApi; + private readonly _config; + private readonly _isPromotionMode: boolean; - constructor() { + constructor(isPromotionMode: boolean) { super(); - this._gcsApi = new GcsApi(CONFIG['GCS Bucket']); + this._isPromotionMode = isPromotionMode; + this._config = this._isPromotionMode ? PROMOTION_CONFIG : CONFIG; + Logger.log( + `Config sheet chosen is: ${ + this._isPromotionMode ? 'PROMOTION_CONFIG' : 'CONFIG' + }` + ); + this._gcsApi = new GcsApi(this._config['GCS Bucket']); this._googleAdsApi = new GoogleAdsApi( - CONFIG['Ads API Key'], - CONFIG['Manager ID'], - CONFIG['Account ID'] + this._config['Ads API Key'], + this._config['Manager ID'], + this._config['Account ID'] ); } run() { this.deleteTrigger(); const adGroups = this._googleAdsApi.getAdGroups(); + const lastProcessedKey = this._isPromotionMode + ? 'lastPromotionImageExtensionProcessedAdGroupId' + : 'lastImageExtensionProcessedAdGroupId'; const lastImageExtensionProcessedAdGroupId = - PropertiesService.getScriptProperties().getProperty( - 'lastImageExtensionProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); let startIndex = 0; if (lastImageExtensionProcessedAdGroupId) { const lastIndex = adGroups.findIndex( @@ -52,10 +62,10 @@ export class ImageExtensionService extends Triggerable { const adGroup = adGroups[i]; if (this.shouldTerminate()) { Logger.log( - `The function is reaching the 6 minute timeout, and therfore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` + `The function is reaching the 6 minute timeout, and therefore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` ); PropertiesService.getScriptProperties().setProperty( - 'lastImageExtensionProcessedAdGroupId', + lastProcessedKey, adGroup.adGroup.id ); this.createTriggerForNextRun(); @@ -65,8 +75,8 @@ export class ImageExtensionService extends Triggerable { `Processing Ad Group ${adGroup.adGroup.name} (${adGroup.adGroup.id})...` ); const uploadedToGcsImages = this._gcsApi - .listImages(CONFIG['Account ID'], adGroup.adGroup.id, [ - CONFIG['Uploaded DIR'], + .listImages(this._config['Account ID'], adGroup.adGroup.id, [ + this._config['Uploaded DIR'], ]) ?.items?.map(e => e.name.split('/').slice(-1)[0]); const notLinkedAssets = this._googleAdsApi @@ -92,54 +102,64 @@ export class ImageExtensionService extends Triggerable { notLinkedAssets ); } - const adGroupAssetsToDelete = this._googleAdsApi - .getAdGroupAssetsForAdGroup(adGroup.adGroup.id) - .filter(e => !uploadedToGcsImages?.includes(e.asset.name)) - .map(e => e.adGroupAsset.resourceName); - if (adGroupAssetsToDelete?.length > 0) { - Logger.log( - `Deleting ${adGroupAssetsToDelete.length} ad group assets for ad group ${adGroup.adGroup.id}...` - ); - this._googleAdsApi.deleteAdGroupAssets(adGroupAssetsToDelete); + // Only delete assets if not in promotion mode + if (!this._isPromotionMode) { + const adGroupAssetsToDelete = this._googleAdsApi + .getAdGroupAssetsForAdGroup(adGroup.adGroup.id) + .filter(e => !uploadedToGcsImages?.includes(e.asset.name)) + .map(e => e.adGroupAsset.resourceName); + if (adGroupAssetsToDelete?.length > 0) { + Logger.log( + `Deleting ${adGroupAssetsToDelete.length} ad group assets for ad group ${adGroup.adGroup.id}...` + ); + this._googleAdsApi.deleteAdGroupAssets(adGroupAssetsToDelete); + } + } else { + Logger.log('Skipping deletion of existing assets in promotion mode.'); } PropertiesService.getScriptProperties().setProperty( - 'lastImageExtensionProcessedAdGroupId', + lastProcessedKey, adGroup.adGroup.id ); } Logger.log('Finished Extension Process.'); //If script completes without timing out, clear the stored ad group ID and any triggers - PropertiesService.getScriptProperties().deleteProperty( - 'lastImageExtensionProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); this.deleteTrigger(); } static triggeredRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; + Logger.log(`triggeredRun method:`); + Logger.log(`Is Promotion Mode: ${isPromotionMode}`); + PropertiesService.getScriptProperties().setProperty( `${ImageExtensionService.name}StartTime`, new Date().getTime().toString() ); - const imageExtensionService = new ImageExtensionService(); + const imageExtensionService = new ImageExtensionService(isPromotionMode); imageExtensionService.run(); } static manuallyRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; + Logger.log(`manuallyRun method:`); + Logger.log(`Is Promotion Mode: ${isPromotionMode}`); + PropertiesService.getScriptProperties().setProperty( `${ImageExtensionService.name}StartTime`, new Date().getTime().toString() ); + const lastProcessedKey = isPromotionMode + ? 'lastPromotionImageExtensionProcessedAdGroupId' + : 'lastImageExtensionProcessedAdGroupId'; const lastImageExtensionProcessedAdGroupId = - PropertiesService.getScriptProperties().getProperty( - 'lastImageExtensionProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); if (lastImageExtensionProcessedAdGroupId) { - PropertiesService.getScriptProperties().deleteProperty( - 'lastImageExtensionProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); Logger.log('Cleared last processed Ad Group ID for a fresh manual run.'); } - const imageExtensionService = new ImageExtensionService(); + const imageExtensionService = new ImageExtensionService(isPromotionMode); imageExtensionService.run(); } } diff --git a/src/image-generation-service.ts b/src/image-generation-service.ts index 073f8a4..97b5d68 100644 --- a/src/image-generation-service.ts +++ b/src/image-generation-service.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ADIOS_MODES, CONFIG } from './config'; +import { ADIOS_MODES, CONFIG, PROMOTION_CONFIG } from './config'; import { GcsApi } from './gcs-api'; import { GoogleAdsApiFactory } from './google-ads-api-mock'; import { Triggerable } from './triggerable'; @@ -28,16 +28,25 @@ export class ImageGenerationService extends Triggerable { private readonly _gcsApi; private readonly _vertexAiApi; private readonly _googleAdsApi; + private readonly _config; + private readonly _isPromotionMode: boolean; - constructor() { + constructor(isPromotionMode: boolean) { super(); - this._gcsApi = new GcsApi(CONFIG['GCS Bucket']); + this._isPromotionMode = isPromotionMode; + this._config = this._isPromotionMode ? PROMOTION_CONFIG : CONFIG; + Logger.log( + `Config sheet chosen is: ${ + this._isPromotionMode ? 'PROMOTION_CONFIG' : 'CONFIG' + }` + ); + this._gcsApi = new GcsApi(this._config['GCS Bucket']); this._vertexAiApi = new VertexAiApi( - CONFIG['GCP Project'], - CONFIG['GCP Region'], - CONFIG['VertexAI Api Domain Part'], - CONFIG['Gemini Model'], - CONFIG['Image Generation Model'] + this._config['GCP Project'], + this._config['GCP Region'], + this._config['VertexAI Api Domain Part'], + this._config['Gemini Model'], + this._config['Image Generation Model'] ); this._googleAdsApi = GoogleAdsApiFactory.createObject(); } @@ -46,11 +55,11 @@ export class ImageGenerationService extends Triggerable { const MAX_TRIES = 3; this.deleteTrigger(); const adGroups = this._googleAdsApi.getAdGroups(); - + const lastProcessedKey = this._isPromotionMode + ? 'lastPromotionImageGenerationProcessedAdGroupId' + : 'lastImageGenerationProcessedAdGroupId'; const lastImageGenerationProcessedAdGroupId = - PropertiesService.getScriptProperties().getProperty( - 'lastImageGenerationProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); let startIndex = 0; if (lastImageGenerationProcessedAdGroupId) { const lastIndex = adGroups.findIndex( @@ -62,10 +71,10 @@ export class ImageGenerationService extends Triggerable { const adGroup = adGroups[i]; if (this.shouldTerminate()) { Logger.log( - `The function is reaching the 6 minute timeout, and therfore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` + `The function is reaching the 6 minute timeout, and therefore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` ); PropertiesService.getScriptProperties().setProperty( - 'lastImageGenerationProcessedAdGroupId', + lastProcessedKey, adGroup.adGroup.id ); this.createTriggerForNextRun(); @@ -82,12 +91,12 @@ export class ImageGenerationService extends Triggerable { adGroup.customer.id, adGroup.adGroup.id, [ - CONFIG['Generated DIR'], - CONFIG['Uploaded DIR'], - CONFIG['Validated DIR'], + this._config['Generated DIR'], + this._config['Uploaded DIR'], + this._config['Validated DIR'], ] ); - if (existingImgCount >= CONFIG['Number of images per Ad Group']!) { + if (existingImgCount >= this._config['Number of images per Ad Group']!) { Logger.log( `Ad Group ${adGroup.adGroup.name}(${adGroup.adGroup.id}) has enough generated images, skipping...` ); @@ -95,7 +104,7 @@ export class ImageGenerationService extends Triggerable { } // Calculate how many images have to be generated for this Ad Group in the whole execution, in batches const adGroupImgCount = - CONFIG['Number of images per Ad Group'] - existingImgCount; + this._config['Number of images per Ad Group'] - existingImgCount; Logger.log( `Generating ${adGroupImgCount} images for ${adGroup.adGroup.name}(${adGroup.adGroup.id})...` ); @@ -110,9 +119,9 @@ export class ImageGenerationService extends Triggerable { let gAdsData = ''; // Kwds or AdGroup data let imgPrompt = ''; // Prompt that will be sent to Vision API (Imagen) - switch (CONFIG['Adios Mode']) { + switch (this._config['Adios Mode']) { case ADIOS_MODES.AD_GROUP: { - const regex = new RegExp(CONFIG['Ad Group Name Regex']); + const regex = new RegExp(this._config['Ad Group Name Regex']); const matchGroups = this.getRegexMatchGroups( adGroup.adGroup.name, regex @@ -124,7 +133,7 @@ export class ImageGenerationService extends Triggerable { Logger.log( `No matching groups found for ${adGroup.adGroup.name} with ${regex}. Using full prompt.` ); - gAdsData = CONFIG['ImgGen Prompt']; + gAdsData = this._config['ImgGen Prompt']; } break; } @@ -153,20 +162,20 @@ export class ImageGenerationService extends Triggerable { break; } default: - // TODO: Prevent execution if Config is not correctly filled - console.error(`Unknown mode: ${CONFIG['Adios Mode']}`); + // TODO: Prevent execution if this._config is not correctly filled + console.error(`Unknown mode: ${this._config['Adios Mode']}`); } // Keywords mode -> generate Imagen Prompt through Gemini API - if (CONFIG['Adios Mode'] === ADIOS_MODES.AD_GROUP) { + if (this._config['Adios Mode'] === ADIOS_MODES.AD_GROUP) { imgPrompt = gAdsData; } else { // Call Gemini to generate the Img Prompt - const promptContext = CONFIG['Text Prompt Context']; - let textPrompt = `${promptContext} ${CONFIG['Text Prompt']} ${gAdsData}`; + const promptContext = this._config['Text Prompt Context']; + let textPrompt = `${promptContext} ${this._config['Text Prompt']} ${gAdsData}`; - if (CONFIG['Text Prompt Suffix']) { - textPrompt += ' ' + CONFIG['Text Prompt Suffix']; + if (this._config['Text Prompt Suffix']) { + textPrompt += ' ' + this._config['Text Prompt Suffix']; } Logger.log('Prompt to generate Imagen Prompt: ' + textPrompt); try { @@ -186,12 +195,12 @@ export class ImageGenerationService extends Triggerable { } } - if (CONFIG['Prompt translations sheet']) { + if (this._config['Prompt translations sheet']) { imgPrompt = this.applyTranslations(imgPrompt); } - if (CONFIG['ImgGen Prompt Suffix']) { - imgPrompt += ' ' + CONFIG['ImgGen Prompt Suffix']; + if (this._config['ImgGen Prompt Suffix']) { + imgPrompt += ' ' + this._config['ImgGen Prompt Suffix']; } Logger.log( @@ -228,7 +237,7 @@ export class ImageGenerationService extends Triggerable { adGroup.adGroup.id, adGroup.adGroup.name ); - const folder = `${adGroup.customer.id}/${adGroup.adGroup.id}/${CONFIG['Generated DIR']}`; + const folder = `${adGroup.customer.id}/${adGroup.adGroup.id}/${this._config['Generated DIR']}`; const imageBlob = Utilities.newBlob( Utilities.base64Decode(image), 'image/png', @@ -241,14 +250,12 @@ export class ImageGenerationService extends Triggerable { generatedImages += images.length; } PropertiesService.getScriptProperties().setProperty( - 'lastImageGenerationProcessedAdGroupId', + lastProcessedKey, adGroup.adGroup.id ); } Logger.log('Finished generating.'); - PropertiesService.getScriptProperties().deleteProperty( - 'lastImageGenerationProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); this.deleteTrigger(); } /** @@ -297,7 +304,7 @@ export class ImageGenerationService extends Triggerable { * placeholder and return "A photo of a London in sharp, 4k". */ createPrompt(obj: { [key: string]: string }) { - let prompt = CONFIG['ImgGen Prompt']; + let prompt = this._config['ImgGen Prompt']; for (const [key, value] of Object.entries(obj)) { prompt = prompt.replaceAll('${' + key + '}', value); } @@ -311,11 +318,11 @@ export class ImageGenerationService extends Triggerable { */ applyTranslations(prompt: string) { const error = `Error: No translations are found. - Please check that sheet "${CONFIG['Prompt translations sheet']}" exists + Please check that sheet "${this._config['Prompt translations sheet']}" exists and contains the translations. Remember, the first row is always header.`; const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( - CONFIG['Prompt translations sheet'] + this._config['Prompt translations sheet'] ); if (!sheet) { throw error; @@ -330,30 +337,35 @@ export class ImageGenerationService extends Triggerable { } static triggeredRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; + Logger.log(`triggeredRun method:`); + Logger.log(`Is Promotion Mode: ${isPromotionMode}`); PropertiesService.getScriptProperties().setProperty( `${ImageGenerationService.name}StartTime`, new Date().getTime().toString() ); - const imageGenerationService = new ImageGenerationService(); + const imageGenerationService = new ImageGenerationService(isPromotionMode); imageGenerationService.run(); } static manuallyRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; + Logger.log(`manuallyRun method:`); + Logger.log(`Is Promotion Mode: ${isPromotionMode}`); PropertiesService.getScriptProperties().setProperty( `${ImageGenerationService.name}StartTime`, new Date().getTime().toString() ); + const lastProcessedKey = isPromotionMode + ? 'lastPromotionImageGenerationProcessedAdGroupId' + : 'lastImageGenerationProcessedAdGroupId'; const lastImageGenerationProcessedAdGroupId = - PropertiesService.getScriptProperties().getProperty( - 'lastImageGenerationProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); if (lastImageGenerationProcessedAdGroupId) { - PropertiesService.getScriptProperties().deleteProperty( - 'lastImageGenerationProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); Logger.log('Cleared last processed Ad Group ID for a fresh manual run.'); } - const imageGenerationService = new ImageGenerationService(); + const imageGenerationService = new ImageGenerationService(isPromotionMode); imageGenerationService.run(); } } diff --git a/src/image-pause-service.ts b/src/image-pause-service.ts new file mode 100644 index 0000000..395b432 --- /dev/null +++ b/src/image-pause-service.ts @@ -0,0 +1,149 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CONFIG, PROMOTION_CONFIG } from './config'; +import { GcsApi } from './gcs-api'; +import { GoogleAdsApi } from './google-ads-api'; +import { Triggerable } from './triggerable'; + +export class ImagePauseService extends Triggerable { + private readonly _gcsApi; + private readonly _googleAdsApi; + private readonly _config; + private readonly _isPromotionMode: boolean; + + constructor(isPromotionMode: boolean) { + super(); + this._isPromotionMode = isPromotionMode; + this._config = this._isPromotionMode ? PROMOTION_CONFIG : CONFIG; + Logger.log( + `Config sheet chosen is: ${ + this._isPromotionMode ? 'PROMOTION_CONFIG' : 'CONFIG' + }` + ); + this._gcsApi = new GcsApi(this._config['GCS Bucket']); + this._googleAdsApi = new GoogleAdsApi( + this._config['Ads API Key'], + this._config['Manager ID'], + this._config['Account ID'] + ); + } + + run() { + this.deleteTrigger(); + const adGroups = this._googleAdsApi.getAdGroups(); + const lastProcessedKey = this._isPromotionMode + ? 'lastPromotionImagePauseProcessedAdGroupId' + : 'lastImagePauseProcessedAdGroupId'; + const lastImagePauseProcessedAdGroupId = + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); + let startIndex = 0; + if (lastImagePauseProcessedAdGroupId) { + const lastIndex = adGroups.findIndex( + adGroup => adGroup.adGroup.id === lastImagePauseProcessedAdGroupId + ); + startIndex = Math.max(lastIndex, 0); + } + for (let i = startIndex; i < adGroups.length; i++) { + const adGroup = adGroups[i]; + if (this.shouldTerminate()) { + Logger.log( + `The function is reaching the 6 minute timeout, and therefore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` + ); + PropertiesService.getScriptProperties().setProperty( + lastProcessedKey, + adGroup.adGroup.id + ); + this.createTriggerForNextRun(); + return; // Exit the function to prevent further execution + } + Logger.log( + `Processing Ad Group ${adGroup.adGroup.name} (${adGroup.adGroup.id})...` + ); + + const uploadedImages = this._gcsApi + .listImages(this._config['Account ID'], adGroup.adGroup.id, [ + this._config['Uploaded DIR'], + ]) + ?.items?.map(e => e.name.split('/').slice(-1)[0]); + + if (uploadedImages && uploadedImages.length > 0) { + const adGroupAssets = this._googleAdsApi.getAdGroupAssetsForAdGroup( + adGroup.adGroup.id + ); + const assetsToPause = adGroupAssets + .filter(asset => uploadedImages.includes(asset.asset.name)) + .map(asset => asset.adGroupAsset.resourceName); + + if (assetsToPause.length > 0) { + Logger.log( + `Pausing ${assetsToPause.length} ad group assets for ad group ${adGroup.adGroup.id}...` + ); + this._googleAdsApi.pauseAdGroupAssets(assetsToPause); + } else { + Logger.log(`No assets to pause for ad group ${adGroup.adGroup.id}.`); + } + } else { + Logger.log( + `No uploaded images found for ad group ${adGroup.adGroup.id}.` + ); + } + + PropertiesService.getScriptProperties().setProperty( + lastProcessedKey, + adGroup.adGroup.id + ); + } + Logger.log('Finished Pause Process.'); + //If script completes without timing out, clear the stored ad group ID and any triggers + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); + this.deleteTrigger(); + } + + static triggeredRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; + Logger.log(`triggeredRun method:`); + Logger.log(`Is Promotion Mode: ${isPromotionMode}`); + + PropertiesService.getScriptProperties().setProperty( + `${ImagePauseService.name}StartTime`, + new Date().getTime().toString() + ); + const imagePauseService = new ImagePauseService(isPromotionMode); + imagePauseService.run(); + } + + static manuallyRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; + Logger.log(`manuallyRun method:`); + Logger.log(`Is Promotion Mode: ${isPromotionMode}`); + + PropertiesService.getScriptProperties().setProperty( + `${ImagePauseService.name}StartTime`, + new Date().getTime().toString() + ); + const lastProcessedKey = isPromotionMode + ? 'lastPromotionImagePauseProcessedAdGroupId' + : 'lastImagePauseProcessedAdGroupId'; + const lastImagePauseProcessedAdGroupId = + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); + if (lastImagePauseProcessedAdGroupId) { + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); + Logger.log('Cleared last processed Ad Group ID for a fresh manual run.'); + } + const imagePauseService = new ImagePauseService(isPromotionMode); + imagePauseService.run(); + } +} diff --git a/src/image-upload-service.ts b/src/image-upload-service.ts index bbbb9e8..43d0fbe 100644 --- a/src/image-upload-service.ts +++ b/src/image-upload-service.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CONFIG } from './config'; +import { CONFIG, PROMOTION_CONFIG } from './config'; import { GcsApi } from './gcs-api'; import { GoogleAdsApi } from './google-ads-api'; import { Triggerable } from './triggerable'; @@ -21,24 +21,35 @@ import { Triggerable } from './triggerable'; export class ImageUploadService extends Triggerable { private readonly _gcsApi; private readonly _googleAdsApi; + private readonly _config; + private readonly _isPromotionMode: boolean; - constructor() { + constructor(isPromotionMode: boolean) { super(); - this._gcsApi = new GcsApi(CONFIG['GCS Bucket']); + this._isPromotionMode = isPromotionMode; + this._config = this._isPromotionMode ? PROMOTION_CONFIG : CONFIG; + Logger.log( + `Config sheet chosen is: ${ + this._isPromotionMode ? 'PROMOTION_CONFIG' : 'CONFIG' + }` + ); + this._gcsApi = new GcsApi(this._config['GCS Bucket']); this._googleAdsApi = new GoogleAdsApi( - CONFIG['Ads API Key'], - CONFIG['Manager ID'], - CONFIG['Account ID'] + this._config['Ads API Key'], + this._config['Manager ID'], + this._config['Account ID'] ); } run() { this.deleteTrigger(); + Logger.log(`config bucket chosen: ${this._config['GCS Bucket']}`); const adGroups = this._googleAdsApi.getAdGroups(); + const lastProcessedKey = this._isPromotionMode + ? 'lastPromotionImageUploadProcessedAdGroupId' + : 'lastImageUploadProcessedAdGroupId'; const lastImageUploadProcessedAdGroupId = - PropertiesService.getScriptProperties().getProperty( - 'lastImageUploadProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); let startIndex = 0; if (lastImageUploadProcessedAdGroupId) { const lastIndex = adGroups.findIndex( @@ -50,10 +61,10 @@ export class ImageUploadService extends Triggerable { const adGroup = adGroups[i]; if (this.shouldTerminate()) { Logger.log( - `The function is reaching the 6 minute timeout, and therfore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` + `The function is reaching the 6 minute timeout, and therefore will create a trigger to rerun from this ad group: ${adGroup.adGroup.name} and then self terminate.` ); PropertiesService.getScriptProperties().setProperty( - 'lastImageUploadProcessedAdGroupId', + lastProcessedKey, adGroup.adGroup.id ); this.createTriggerForNextRun(); @@ -62,9 +73,10 @@ export class ImageUploadService extends Triggerable { Logger.log( `Processing Ad Group ${adGroup.adGroup.name} (${adGroup.adGroup.id})...` ); - const imgFolder = CONFIG['Validated DIR'] || CONFIG['Generated DIR']; + const imgFolder = + this._config['Validated DIR'] || this._config['Generated DIR']; const images = this._gcsApi.getImages( - CONFIG['Account ID'], + this._config['Account ID'], adGroup.adGroup.id, [imgFolder] ) as GoogleCloud.Storage.Image[]; @@ -74,14 +86,14 @@ export class ImageUploadService extends Triggerable { } else { this._googleAdsApi.uploadImageAssets(images); this._gcsApi.moveImages( - CONFIG['Account ID'], + this._config['Account ID'], adGroup.adGroup.id, images, imgFolder, - CONFIG['Uploaded DIR'] + this._config['Uploaded DIR'] ); PropertiesService.getScriptProperties().setProperty( - 'lastImageUploadProcessedAdGroupId', + lastProcessedKey, adGroup.adGroup.id ); } @@ -89,37 +101,36 @@ export class ImageUploadService extends Triggerable { } Logger.log('Finished uploading images.'); // If script completes without timing out, clear the stored ad group ID and any triggers - PropertiesService.getScriptProperties().deleteProperty( - 'lastImageUploadProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); this.deleteTrigger(); } static triggeredRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; PropertiesService.getScriptProperties().setProperty( `${ImageUploadService.name}StartTime`, new Date().getTime().toString() ); - const imageUploadService = new ImageUploadService(); + const imageUploadService = new ImageUploadService(isPromotionMode); imageUploadService.run(); } static manuallyRun() { + const isPromotionMode = CONFIG['Is Promotion Mode'] === 'yes'; PropertiesService.getScriptProperties().setProperty( `${ImageUploadService.name}StartTime`, new Date().getTime().toString() ); + const lastProcessedKey = isPromotionMode + ? 'lastPromotionImageUploadProcessedAdGroupId' + : 'lastImageUploadProcessedAdGroupId'; const lastImageUploadProcessedAdGroupId = - PropertiesService.getScriptProperties().getProperty( - 'lastImageUploadProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().getProperty(lastProcessedKey); if (lastImageUploadProcessedAdGroupId) { - PropertiesService.getScriptProperties().deleteProperty( - 'lastImageUploadProcessedAdGroupId' - ); + PropertiesService.getScriptProperties().deleteProperty(lastProcessedKey); Logger.log('Cleared last processed Ad Group ID for a fresh manual run.'); } - const imageUploadService = new ImageUploadService(); + const imageUploadService = new ImageUploadService(isPromotionMode); imageUploadService.run(); } } diff --git a/src/index.ts b/src/index.ts index 16ddb9d..b7d167d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,19 +14,21 @@ * limitations under the License. */ -import { menu } from './menu'; +import { ExperimentsService } from './experiments-service'; +import { FRONTEND_HELPER } from './frontend-helper'; +import { GeminiValidationService } from './gemini-validation-service'; import { ImageExtensionService } from './image-extension-service'; import { ImageGenerationService } from './image-generation-service'; +import { ImagePauseService } from './image-pause-service'; import { ImageUploadService } from './image-upload-service'; -import { ExperimentsService } from './experiments-service'; -import { GeminiValidationService } from './gemini-validation-service'; -import { FRONTEND_HELPER } from './frontend-helper'; +import { menu } from './menu'; import { uiHelper } from './ui-helper'; menu; ImageExtensionService; ImageGenerationService; ImageUploadService; +ImagePauseService; ExperimentsService; GeminiValidationService; FRONTEND_HELPER; diff --git a/src/menu.ts b/src/menu.ts index 4df47fb..1431b1c 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -25,6 +25,7 @@ function onOpen() { .addItem('Image generation', 'ImageGenerationService.manuallyRun') .addItem('Image upload', 'ImageUploadService.manuallyRun') .addItem('Image assets linking', 'ImageExtensionService.manuallyRun') + .addItem('Image Pause', 'ImagePauseService.manuallyRun') .addItem('Create experiments', 'runExperimentsService') .addItem('Policy validation', 'runGeminiValidationService') ) @@ -65,6 +66,7 @@ const allowedFunctions = [ 'ImageGenerationService.manuallyRun', 'ImageUploadService.manuallyRun', 'ImageExtensionService.manuallyRun', + 'ImagePauseService.manuallyRun', ]; class AdiosTriggers { /**