ETA Transition Helper

Com o ETA Transition Helper, os anunciantes agora podem personalizar a criação em massa de anúncios de texto expandidos (ETAs, na sigla em inglês) a partir de anúncios de texto padrão (STAs, na sigla em inglês) existentes.

A ferramenta tem dois componentes principais:

  1. um script do Google AdWords que copia seus STAs para uma Planilha Google e depois ajuda você a criar ETAs e adicioná-los à sua conta do Google AdWords
  2. uma Planilha Google que exibe STAs e permite configurar os ETAs associados

Este guia orientará você no processo de configuração e criação de anúncios.

Instalação e configuração

Antes de iniciar o processo de configuração, selecione a conta do Google AdWords que contém os STAs que você deseja usar como base para os seus ETAs. Você pode instalar o script em uma conta de administrador ou em uma conta de cliente.

Adição do script

  1. Faça login na conta do Google AdWords que você selecionou acima.
  2. Selecione Operações em massa na barra de navegação à esquerda e clique em Scripts.

  3. Clique no botão vermelho + SCRIPT.
  4. Remova tudo na área de texto. Em seguida, copie o código-fonte da parte inferior desta página e cole-o na área de texto.
  5. Se você decidiu instalar o script em uma conta de administrador, pode colocar na lista de permissões contas específicas de subadministradores ou de clientes inserindo seus CIDs (IDs do cliente) no script. Por padrão, os campos selectByAccountIds e selectBySubMccId são comentados. Dependendo do seu caso de uso, remova o comentário de um ou de ambos os campos. Se você deseja segmentar uma lista de CIDs de contas de clientes, insira-os no campo selectByAccountIds, conforme mostrado abaixo. Se você deseja processar todas as contas de clientes de uma conta de subadministrador, especifique-a no campo selectBySubMccId. É possível inserir apenas um valor no campo selectBySubMccId. Se você tem uma lista de CIDs de contas de clientes (por exemplo, 123-456-7890, 098-765-4321), mas deseja segmentar somente as contas de clientes que estão em uma conta de subadministrador específica (por exemplo, em que 123-456-7890 pertence a 765-432-1098), pode usar os dois campos, conforme mostrado abaixo (somente a conta 123-456-7890 será processada).

    Para garantir que o processamento não exceda os limites, é recomendável executar o script em até 50 contas por vez.

  6. Atribua um nome ao script. Para isso, insira um nome no campo Script, localizado acima da área de texto. É recomendável incluir o número da versão no nome do script, por exemplo, "ETA Transition Helper VX.Y".
  7. Durante a primeira execução, o script gerará uma planilha do Planilhas Google e a enviará ao endereço de e-mail associado à conta do Google AdWords. Se você deseja enviar a planilha a outro endereço, insira-o no campo de e-mail na parte superior do script, conforme mostrado na imagem acima.
  8. Salve o script. Para isso, clique no botão Salvar no canto superior esquerdo da área de texto.
  9. Clique em Autorizar agora para permitir que o script aja em seu nome. Uma janela pop-up aparecerá pedindo que você permita que esse script gerencie suas campanhas do Google AdWords em seu nome. Clique em Permitir.
  10. Execute o script pela primeira vez. Para isso, clique no botão Executar script agora abaixo da área de texto. Isso abrirá uma caixa de texto. Selecione VISUALIZAR ou Executar sem visualização.

    Na primeira execução do script, as duas opções preencherão a planilha gerada com os STAs ativados de melhor desempenho da sua conta. Para uma conta com aproximadamente 2.000 STAs ativos e numOfAds = 10, esperamos que o script termine de exportar os anúncios para a planilha em menos de dois minutos. O campo numOfAds representa o número máximo de ETAs que estão disponíveis para criação em determinada execução. Ele é definido como 800 por padrão e pode ser otimizado para o seu caso de uso. Não é recomendável definir o campo numOfAds como um número maior do que o valor padrão de 800.

    Para ler um resumo de todas as alterações feitas durante a primeira execução do script, clique em Detalhes ou em Registros na página de visão geral dos scripts, conforme mostrado abaixo.

    Por padrão, o desempenho é determinado como uma função da CTR e de impressões. A medida de desempenho e o número de anúncios que são inseridos na planilha podem ser configurados no script.

  11. Mantenha o script aberto. Voltaremos a ele nas etapas abaixo. As futuras execuções do script poderão ser feitas diretamente na tela inicial Operações em massa > Scripts.

Preparação da planilha

A primeira execução do script cria o modelo de planilha e envia o link dele para o endereço de e-mail inserido anteriormente. Abra a planilha.

Se você precisar conceder acesso à planilha a outras Contas do Google, faça o seguinte:

  1. Clique no botão Compartilhar no canto superior direito. Essa ação abre uma janela pop-up em que você pode configurar as permissões de compartilhamento.
  2. Insira o endereço de e-mail associado à conta do Google AdWords do usuário.
  3. Verifique se a Conta do Google associada à conta do Google AdWords tem acesso de edição à planilha. Para isso, selecione Pode editar na lista suspensa. Clique em Enviar.

Criação de anúncios de texto expandidos

Viva! Você já pode criar anúncios de texto expandidos!

Primeiro, vamos examinar sua planilha. Os campos somente leitura têm cabeçalhos de coluna cinza, e os campos editáveis têm cabeçalhos azuis e laranja. Há três grupos principais de colunas:

  1. O primeiro grupo fornece informações sobre os STAs existentes que foram importados. Você verá que a maioria dos campos é somente leitura (cinza). O único campo editável é o Status do STA (azul). Se você alterar o Status do STA, esse anúncio será atualizado durante a próxima execução do script. Se os URLs finais e de visualização não forem correspondentes, isso será destacado, pois esses campos precisam ser idênticos para os ETAs.
  2. O segundo grupo de colunas será designado para os ETAs que você criará em breve. Você verá que os campos Título 1 e Descrição já estarão pré-preenchidos com base no STA correspondente. Mais uma vez, esse grupo será dividido em campos somente leitura (cinza) e de entrada (azuis).
  3. A terceira seção é para a sinalização Pronto para fazer o upload?, que indica quando um ETA está pronto para ser criado na próxima execução do script.

Se você deseja alterar o status dos STAs existentes, basta alterar o valor na coluna Status do STA:

Agora, vamos criar alguns ETAs.

  1. O requisito mínimo para criar um ETA é adicionar um segundo título, mas você também pode definir quaisquer outros campos, como Status do ETA, URL final para dispositivos móveis ou Caminho 1. Você também pode alterar qualquer um dos campos pré-preenchidos. Há regras de validação para garantir que os campos estejam em conformidade com a maioria das restrições de caracteres, bem como colunas que mostram quantos caracteres você pode adicionar. Além disso, leia nosso artigo da Central de Ajuda e veja dicas e truques para criar anúncios de texto eficazes.
  2. Ao editar os campos de ETA, você pode solicitar uma visualização de um anúncio na célula ativa. Para isso, selecione o botão Clique para visualizar na primeira linha da planilha. O URL de visualização é extraído do anúncio de texto padrão. Ele não é computado diretamente do URL final e, portanto, pode ser diferente no momento da veiculação.
  3. Quando estiver satisfeito com a aparência dos seus ETAs, mude a coluna Pronto para fazer o upload? para Sim. Desse modo, o ETA será criado, e o status do STA será atualizado quando o script for executado.
  4. Na segunda vez que você executar o script, os ETAs serão criados. É recomendável visualizar sempre um script antes de executá-lo. Para fazer isso, selecione Editar na página de visão geral dos scripts e clique no botão vermelho VISUALIZAR. Assim, você pode revisar as alterações que teriam sido feitas se o script tivesse sido executado. Quando você quiser aplicar as alterações, clique em Executar o script agora.
  5. Depois que o script for executado, você verá que tanto o STA quanto o ETA associado a ele terão o rótulo eta-upgrade. Para reverter alterações indesejadas, siga as etapas no nosso artigo da Central de Ajuda.

Código-fonte

// @license Copyright 2016, Google Inc. All Rights Reserved.
//
// 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.
//

/**
 * @name ETA Transition Helper.
 *
 * @overview An AdWords Script that exports standard text ads from an account
 *           to a Spreadsheet and then imports newly created expanded text ads
 *           back to the AdWords account.
 *
 *           see https://developers.google.com/adwords/scripts/docs/solutions/mccapp-eta-transition-helper
 *           for more details.
 *
 * @author AdWords Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 1.0
 *
 * @changelog
 * - version 1.0
 *   - Released initial version.
 */
//////////////////////////////////////////////////////////////////////////
//////////////////////////// CONFIGURATION ///////////////////////////////
//////////////////////////////////////////////////////////////////////////
var CONFIG = {
  // Select by CIDs (For MCC only).
  //selectByAccountIds: ['123-456-7890', '098-765-4321'],

  // Select by sub-mcc (For MCC only).
  //selectBySubMccId: '123-456-7890',

  // Recipient email address for script notifications.
  email: '',

  // Set DEBUG mode. In this mode, the script will print messages to the log
  // output screen.
  debug: true,

  spreadsheet: {
    // ID of template spreadsheet.
    templateId: '1C66jjF57dZ5Me8vbUDnXTRXTNekHIVEwEQF8PnVD9Ks',

    // Name of the spreadsheet where all ads are exported and later imported to
    // upload ETAs.
    targetName: 'ETA Transition Helper v1.0',

    // Stores a reference to Sheet.
    // Do not set any values for `sheet`, this is dynamically filled when
    // initialized.
    sheet: null,

    // The name of the sheet inside the spreadsheet.
    sheetName: 'main',

    // The first row where the content starts.
    firstContentRow: 4,

    // The row index, in spreadsheet, where header is located.
    headerRow: 3,

    // Column to check and determine if row is not empty.
    nonEmptyColumnCheck: 'customerName',

    // Holds mappings between column names and their spreadsheet index.
    // Do not set any values for `columnNamesToIndices`, this is dynamically
    // filled when initialized.
    columnNamesToIndices: {},

    // Default status for each new ETA.
    defaultStatus: 'paused',

    // Column mapping.
    columns: [
              // User and Ad parent (Adgroup and Campaign) information.
              'customerId',
              'customerName',
              'campaignId',
              'campaignName',
              'adGroupId',
              'adGroupName',

              // STA related attributes (Read Only).
              'staId',
              'headline',
              'description1',
              'description2',
              'staApprovalStatus',
              'displayUrl',

              // STA related attributes (Editable).
              'staStatus',

              // STA performance metrics (Read Only).
              'impressions',
              'clicks',
              'ctr',

              // Shared attributes between both ETA and STA (Editable).
              'finalUrl',
              'mobileFinalUrl',
              'trackingTemplate',
              'customParameters',
              'labels',

              // ETA related attributes (Editable).
              'headline1',
              'charactersRemainingH1',
              'headline2',
              'charactersRemainingH2',
              'description',
              'charactersRemainingDesc',
              'path1',
              'path2',
              'etaStatus',

              // ETA related attributes (Read Only).
              'etaApprovalStatus',
              'etaCreated',
              'etaId',

              'readyToUpload',

              'errorMessage'],

    // Map between report fields and spreadsheet columns.
    // This mapping is used when exporting report data
    // to the spreadsheet. Will skip fields with `null` values.
    reportFieldMap: {
      customerId: 'accountId',
      customerName: 'accountName',
      campaignId: 'campaignId',
      campaignName: 'campaignName',
      adGroupId: 'adGroupId',
      adGroupName: 'adGroupName',

      staId: 'id',
      headline: 'headline',
      description1: 'description1',
      description2: 'description2',
      staApprovalStatus: 'combinedApprovalStatus',
      displayUrl: 'displayUrl',
      staStatus: 'status',

      impressions: 'impressions',
      clicks: 'clicks',
      ctr: 'ctr',

      finalUrl: 'creativeFinalUrls',
      mobileFinalUrl: 'creativeFinalMobileUrls',
      trackingTemplate: 'creativeTrackingUrlTemplate',
      customParameters: 'creativeUrlCustomParameters',
      labels: 'labels',

      headline1: 'headlinePart1',
      charactersRemainingH1: null,
      headline2: 'headlinePart2',
      charactersRemainingH2: null,
      description: 'description',
      charactersRemainingDesc: null,
      path1: 'path1',
      path2: 'path2',
      etaStatus: null,

      etaApprovalStatus: null,
      etaCreated: null,
      etaId: null,

      readyToUpload: null,

      errorMessage: null
    },

    // Columns with formulas to avoid overriding.
    // Will copy these fields when appending new rows in the spreadsheet.
    columnsWithFormulas: ['charactersRemainingH1',
                          'charactersRemainingH2',
                          'charactersRemainingDesc',
                          'etaCreated']
  },

  // The total number of ads we would like to handle.
  numOfAds: 800,

  // The total number of accounts we would like to handle (MCC only).
  numOfAccounts: 50,

  // The duration used to download the Ad Performance Report.
  duration: 'LAST_30_DAYS',

  // The API version to use when downloading the Ad Performance Report.
  apiVersion: 'v201705',

  // When sorting ads by performance, use the values listed here as a criteria
  // when comparing different performance values. For example, if ad A has
  // 100 impressions and ad B has 90 impressions, ad A should be more important
  // for us - unless impressionsThreshold is equal or higher to 10, then we'll
  // consider both ads with the same magnitude of impressions and therefore
  // compare their CTR.
  performance: {
    impressionsThreshold: 10,
    ctrThreshold: 0.1
  },

  // Fields to select from Ad Performance Report and export to selected
  // spreadsheet.
  reportFields: ['CampaignId', 'CampaignName', 'AdGroupId', 'AdGroupName', 'Id',
                 'Headline', 'Description1', 'Description2',
                 'CombinedApprovalStatus', 'DisplayUrl',

                 'Status',

                 'Impressions', 'Clicks', 'Ctr',

                 'CreativeFinalUrls', 'CreativeFinalMobileUrls',
                 'CreativeTrackingUrlTemplate', 'CreativeUrlCustomParameters',
                 'Labels',

                 'HeadlinePart1', 'HeadlinePart2', 'Description',
                 'Path1', 'Path2'
  ],

  // The default label to apply to Ads if no value is present in the label
  // column.
  defaultLabelName: 'eta-upgrade',

  // Cache key for storing changes done to platform.
  changeCacheKey: 'platform_changes',

  // Time the value will remain in the cache, in seconds.
  cacheExpiration: 21600,

  // The start date to filter ETA reports by.
  etaReportStartDate: '20160801'
};

// Email template types.
var SPREADSHEET_CREATED = 1;

// Preview mode indicator.
var IS_PREVIEW = AdWordsApp.getExecutionInfo().isPreview();

//////////////////////////////////////////////////////////////////////////
///////////////////////////////// MAIN ///////////////////////////////////
//////////////////////////////////////////////////////////////////////////

// Declaration required for non-MCC accounts.
var MccApp = MccApp || undefined;

/**
 * Main application entry.
 *
 * This will run first.
 */
function main() {
  initConfig();

  if (!validateSpreadsheet(CONFIG.spreadsheet)) {
    Logger.log('Terminating execution due to malformed spreadsheet format.');
    return;
  }

  var errorCount;
  if (MccApp) {
    print('Exporting STAs from MCC');
    printExportResults(exportSTAMCC());

    print('Processing spreadsheet');
    errorCount = syncSpreadsheetMCC();
    print('Processing complete');
  } else {
    print('Exporting STAs from account');
    printExportResults(exportSTA(CONFIG.numOfAds));

    print('Processing spreadsheet');
    errorCount = syncSpreadsheet();
    print('Processing complete');
  }

  if (errorCount > 0) {
    throw 'Script runtime error. An error occured, please check the logs.';
  }
}


/**
 * Initializes dynamic properties of CONFIG.
 */
function initConfig() {
  var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);
  var spreadsheet = sheet.getParent();
  if (!canEditSpreadsheet(spreadsheet)) {
    throw 'User account does not have permission to edit the spreadsheet. ' +
          'Please ensure the permission is set to `Can Edit` instead of ' +
          '`Can Comment` or `Can View`.';
  }

  if (!sheet) {
    throw 'Could not find a sheet named "' + CONFIG.spreadsheet.sheetName +
        '" in the spreadsheet. Please fix your spreadsheet.';
  }

  CONFIG.spreadsheet.sheet = sheet;

  CONFIG.spreadsheet.columns.forEach(function(columnName, index) {
    CONFIG.spreadsheet.columnNamesToIndices[columnName] = index;
  });

  // Set default email address to the spreadsheet's owner email address.
  if (isEmptyString(CONFIG.email)) {
    CONFIG.email = getFileOwnerEmail(spreadsheet);
  }

  // Notify which email address is used.
  print('* This script will send notifications to: ' + CONFIG.email);
}

/**
 * Checks the number of accounts to process and warns the user if the number
 * is above the recommended threshold.
 *
 * @param {ManagedAccountIterator} accountIterator The account iterator of the
 *   accounts that will be used.
 */
function processAccountLimit(accountIterator) {
  var accountsToProcess = accountIterator.totalNumEntities();
  print('Accounts to process: ' + accountsToProcess);
  if (accountsToProcess > CONFIG.numOfAccounts) {
    print('WARNING: Number of accounts is above the recommended threshold: ' +
        CONFIG.numOfAccounts);
    print('Please use "selectByAccountIds" or "selectBySubMccId" to select');
    print('which accounts to use from the CONFIG');
  }
}

/**
 * Get the accountIterator from the MCC using the selectors specified in the
 * config.
 *
 * @return {ManagedAccountIterator} The iterator for the selected accounts.
 * @throws {string}
 */
function getAccountIteratorFromMCC() {
  var selectBySubMccId = CONFIG.selectBySubMccId || null;
  var selectByAccountIds = CONFIG.selectByAccountIds || null;

  // Build account iterator.
  var accIterBuild = MccApp.accounts();

  // Restrict to sub-mcc.
  if (selectBySubMccId) {
    if (!isString(selectBySubMccId)) {
      throw 'selectBySubMccId: is not a string';
    }
    accIterBuild = accIterBuild.withCondition("ManagerCustomerId = '" +
        validateCid(selectBySubMccId) + "'");
  }

  // Restrict to list of account IDs.
  if (selectByAccountIds) {
    if (!Array.isArray(selectByAccountIds)) {
      throw 'selectByAccountIds: is not an Array';
    }
    for (var i = 0; i < selectByAccountIds.length; i++) {
      validateCid(selectByAccountIds[i]);
    }
    accIterBuild = accIterBuild.withIds(selectByAccountIds);
  }

  return accIterBuild.get();
}

/**
 * From the Ad Performance Report, copy all enabled and
 * not disapproved Text Ads to the configured spreadsheet.
 *
 * This function operates only for MCC accounts.
 *
 * The function will iterate over each client sub-account exporting in turn.
 * @return {{exportedCount: number,
 *           remainingRowCount: number,
 *           accountsProcessed: number}}
 *           An object containing statistics from the STA export.
 *             exportedCount     The number of exported Ads during exportSTA.
 *             remainingRowCount The number of remaining rows available in the
 *                               spreadsheet.
 *             accountsProcessed The number of accounts processed in the export.
 */
function exportSTAMCC() {
  // Store the current MCC account.
  var mccAccount = AdWordsApp.currentAccount();

  // Count the number of available STAs to export.
  var maxAdsToExport = CONFIG.numOfAds;

  var resultObject = {
    exportedCount: 0,
    remainingRowCount: maxAdsToExport,
    accountsProcessed: 0
  };

  // Get account iterator from MCC config.
  var accountIterator;
  try {
    accountIterator = getAccountIteratorFromMCC();
  }
  catch (err) {
    print(err);
    return resultObject;
  }
  if (accountIterator) {
    processAccountLimit(accountIterator);
    while (accountIterator.hasNext()) {
      var account = accountIterator.next();

      // Set the account as the client account as the active account.
      MccApp.select(account);
      print('Account: ' + account.getName() +
                 ' (' + account.getCustomerId() + ')');

      // Execute the export.
      var exportResult = exportSTA(maxAdsToExport);

      resultObject.exportedCount += exportResult.exportedCount;
      resultObject.remainingRowCount = exportResult.remainingRowCount;
      resultObject.accountsProcessed++;

      if (resultObject.remainingRowCount <= 0) {
        print('Maximum number of Ads reached, skipping export');
        break;
      }
    }
  }

  MccApp.select(mccAccount);

  return resultObject;
}


/**
 * From the Ad Performance Report, copy all enabled and
 * not disapproved Text Ads to the configured spreadsheet.
 *
 * @param {number} maxAdsToExport The maximum number of ads to export.
 *
 * @return {{exportedCount: number,
 *           remainingRowCount: number}}
 *           An object containing statistics from the STA export.
 *             exportedCount     The number of exported Ads during exportSTA.
 *             remainingRowCount The number of remaining rows available in the
 *                               spreadsheet.
 */
function exportSTA(maxAdsToExport) {
  if (isEmpty(maxAdsToExport) || isNaN(maxAdsToExport)) {
    throw 'Failed to export STAs to the spreadsheet. Please specify a ' +
          'valid integer number in CONFIG.numOfAds.';
  }

  // Download Ad Performance Report.
  // Get the top most performing ads.
  var report = AdWordsApp.report(
      'SELECT ' + CONFIG.reportFields.join(',') + ' ' +
      'FROM     AD_PERFORMANCE_REPORT ' +
      'WHERE    AdType = "TEXT_AD" ' +
      '         AND AdGroupStatus = "ENABLED" ' +
      '         AND CampaignStatus = "ENABLED" ' +
      '         AND Status = "ENABLED" ' +
      '         AND CombinedApprovalStatus != "DISAPPROVED" ' +
      'DURING   ' + CONFIG.duration, {
        apiVersion: CONFIG.apiVersion
      });

  var resultObject = {
    exportedCount: 0,
    remainingRowCount: maxAdsToExport,
    accountsProcessed: 1
  };

  var mostPerformingAds = getMostPerformingAds(report, CONFIG);
  if (mostPerformingAds !== null) {
    // Open spreadsheet.
    var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);

    var rowObject = getContentRows(sheet,
                                    CONFIG.spreadsheet.firstContentRow,
                                    CONFIG.spreadsheet.nonEmptyColumnCheck,
                                    CONFIG.spreadsheet.columnNamesToIndices,
                                    false);
    var nonEmptyRows = rowObject.rows;

    // If sheet is not empty, only export ads not present in sheet.
    if (nonEmptyRows.length > 0) {
      // Switch `mostPerformingAds` to an id=>Ad object structure.
      var mostPerformingAdsObject =
          createIndexableObjectFromKeys(mostPerformingAds, ['id']);

      // For any ad present in the spreadsheet, set value to null.
      nonEmptyRows.forEach(function(row) {
        var staId = row.getNumber('staId');
        if (staId in mostPerformingAdsObject) {
          mostPerformingAdsObject[staId] = null;
        }

        // Subtract the number of empty ETAs from the total ads available
        // to export. This will preserve a bounded number of ETAs in the
        // spreadsheet.
        var etaId = row.getString('etaId');
        if (isEmptyString(etaId)) {
          maxAdsToExport--;
          resultObject.remainingRowCount--;
        }
      });

      // Keep ads that are not null (not present in spreadsheet).
      var reportsNotPresentInSheet = [];
      Object.keys(mostPerformingAdsObject).forEach(function(adId) {
        var ad = mostPerformingAdsObject[adId];
        if (mostPerformingAdsObject.hasOwnProperty(adId) && !isEmpty(ad)) {
          reportsNotPresentInSheet.push(ad);
        }
      });

      mostPerformingAds = reportsNotPresentInSheet;
    }

    // Traverse all ads and export them to the spreadsheet.
    maxAdsToExport = Math.min(Math.max(maxAdsToExport, 0),
                              mostPerformingAds.length);
    for (var i = 0; i < maxAdsToExport; i++) {
      mostPerformingAds[i].export(sheet, CONFIG.spreadsheet);
    }

    resultObject.exportedCount = maxAdsToExport;

    // Update the number of remaining rows to export.
    resultObject.remainingRowCount -= maxAdsToExport;

    // Print the number of ads exported.
    print(resultObject.exportedCount + ' ads exported to ' +
          CONFIG.spreadsheet.sheet.getParent().getUrl());
  }

  return resultObject;
}

/**
 * Print the result of the exportSTA function from the exportResults object.
 *
 * @param {{exportedCount: number,
 *          accountsProcessed: number}}
 *          exportResults An object containing statistics from the STA export.
 *             exportedCount     The number of exported Ads during exportSTA.
 *             accountsProcessed The number of accounts processed in the export.
 */
function printExportResults(exportResults) {
  print('Export Results:\n' +
        '  Total accounts processed: ' + exportResults.accountsProcessed +
        '\n' +
        '  Total STAs exported:      ' + exportResults.exportedCount + '\n');
}

/**
 * Sync content from and to the exported spreadsheet.
 *
 * This function operates only for MCC accounts.
 *
 * This function will iterate over each sub-account calling syncSpreadsheet for
 * the account CID
 *
 * @return {number} Returns the error count during the sync.
 *                  For example: update ad status, approval reason
 *                  and create ETAs that are flagged as ready for upload.
 */
function syncSpreadsheetMCC() {
  var errorCount = 0;

  // Store the current MCC account
  var mccAccount = AdWordsApp.currentAccount();

  // Get account iterator from MCC config.
  var accountIterator;
  try {
    accountIterator = getAccountIteratorFromMCC();
  }
  catch (err) {
    errorCount += 1;
  }
  if (accountIterator) {
    processAccountLimit(accountIterator);
    while (accountIterator.hasNext()) {
      var account = accountIterator.next();

      // Set the account as the client account as the active account
      MccApp.select(account);
      print('Account: ' + account.getName() +
            ' (' + account.getCustomerId() + ')');

      errorCount += syncSpreadsheet(account.getCustomerId());
    }
  }

  MccApp.select(mccAccount);

  return errorCount;
}


/**
 * Sync content from and to the exported spreadsheet.
 *
 * @param {string|null|undefined} customerId sync for the customer Id provided.
 *                                If null or undefined, then sync all rows.
 *
 * @return {number} Returns the error count during the sync.
 *                  For example: update ad status, approval reason
 *                  and create ETAs that are flagged as ready for upload.
 */
function syncSpreadsheet(customerId) {
  var errorCount = 0;

  var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);

  // Combine Ad performing reports with rows in spreadsheet.
  var spreadsheetRowsAndReport;
  try {
    spreadsheetRowsAndReport =
      getReportWithSpreadsheetRows(customerId ? [customerId] : []);
  } catch(err) {
    Logger.log('Failed to retrieve report rows: ' + err);
    spreadsheetRowsAndReport = [];
    errorCount++;
  }

  // Keep track of changes made to AdWords platform.
  var allChanges = [];

  spreadsheetRowsAndReport.forEach(function(spreadsheetRowAndReport) {
    // `startOfIterationErrorCount` will keep track of the error count at the
    // start of this iteration.
    var startOfIterationErrorCount = errorCount;
    var spreadsheetRow = spreadsheetRowAndReport.row;

    var sta = spreadsheetRowAndReport.sta;
    var eta = spreadsheetRowAndReport.eta;

    // Keep track of changes made to STA.
    var staChanges = new AdChange(spreadsheetRow.getNumber('staId'),
                                  spreadsheetRow.getNumber('adGroupId'));
    // Keep track of changes made to ETA (etaId may be empty here).
    var etaChanges = new AdChange(spreadsheetRow.getNumber('etaId'),
                                  spreadsheetRow.getNumber('adGroupId'));
    // Save changes for this iteration.
    allChanges.push({
      sta: staChanges.getChangeStruct(),
      eta: [etaChanges.getChangeStruct()]
    });

    // If STA is null (report was not retrieved), then retrieve it.
    // STA is only null when it's not returned in `getMostPerformingAds` for
    // this specific row.
    if (isEmpty(sta)) {
      sta = getAdFromRow(spreadsheetRow, 'STA');
      if (isEmpty(sta)) {
        errorCount += 1;
        try {
          spreadsheetRow.markAsError('Error retrieving STA with Id ' +
              spreadsheetRow.getNumber('staId'));
        } catch (err) {
          spreadsheetRow.markAsError('Error retrieving STA with missing Id, ' +
              'row : ' + spreadsheetRow.getRowIndex());
        }
      }
    }

    errorCount += syncETA(eta, spreadsheetRow, etaChanges);
    errorCount += syncSTA(sta, spreadsheetRow, staChanges);
  });

  allChanges.map(function(change) {
    function _printChanges(type, id, changes) {
      function _print(field) {
        if (!field) {
          return;
        }

        var prefix = type + ' (' + id + '): ';
        if (isEmptyString(id)) {
          prefix = type + ' ';
        }

        print(prefix + field.fieldName + ' changed from "' +
              field.oldValue + '" to "' + field.newValue + '"');
      }

      if (Array.isArray(changes)) {
        changes.map(_print);
      } else {
        _print(changes);
      }
    }

    if (change.sta) {
      _printChanges('Standard text ad', change.sta.adId, change.sta.changes);
    }

    if (change.eta) {
      change.eta.map(function(eta) {
        if (eta.created) {
          print('Expanded text ad (' + eta.adId + '): created');
          _printChanges('+', null, eta.changes);
        } else {
          _printChanges('+ Expanded text ad', eta.adId, eta.changes);
        }
      });
    }
  });

  return errorCount;
}


/**
 * Retrieves labels that will be used to apply on ETA or STA.
 *
 * @param {SpreadsheetRow} spreadsheetRow A row in spreadsheet.
 * @param {string} defaultLabel
 *
 * @return {Array<string>} An array of label names.
 */
function getLabelsToApply(spreadsheetRow, defaultLabel) {
  var labelNames = spreadsheetRow.getArray('labels');

  if (isEmptyString(labelNames)) {
    labelNames = [defaultLabel];
  } else if (labelNames.indexOf(defaultLabel) === -1) {
    labelNames.push(defaultLabel);
  }

  return labelNames;
}


/**
 * Syncs STA in AdWords platform with STA in spreadsheet.
 * Currently only sync labels and status, only if ETA
 * has been created.
 *
 * @param {Ad} sta Ad object to apply changes to.
 * @param {SpreadsheetRow} spreadsheetRow A row in spreadsheet.
 * @param {AdChange} staChanges An object used for tracking changes
 *                              made in AdWords platform.
 *
 * @return {number} A count of errors encountered.
 */
function syncSTA(sta, spreadsheetRow, staChanges) {

  var errorCount = 0;
  // Get labels to apply to STAs and newly created ETAs.
  var labelNames = getLabelsToApply(spreadsheetRow, CONFIG.defaultLabelName);

  // If STA object and ETA Id is present.
  if (!isEmpty(sta) &&
      (!isEmptyString(spreadsheetRow.getString('etaId')) &&
      spreadsheetRow.getNumber('etaId') !== 0)) {

    // Determine if STA status will need to be updated.
    // If the setStatus operation is successful, track it.
    var oldStatus = sta.getStatus();
    var spreadsheetSTAStatus = spreadsheetRow.getString('staStatus');
    var hasNewStatus = (oldStatus !== spreadsheetSTAStatus);
    if (hasNewStatus && sta.syncStatus(spreadsheetSTAStatus)) {
      staChanges.trackChange('staStatus', oldStatus, spreadsheetSTAStatus);
    } else if (hasNewStatus) {
      errorCount += 1;
      try {
        spreadsheetRow.markAsError('Error syncing status for STA with id ' +
            spreadsheetRow.getNumber('staId'));
      } catch (err) {
        spreadsheetRow.markAsError('Error syncing status for STA with ' +
            'missing Id, row : ' + spreadsheetRow.getRowIndex());
      }
    }

    if (spreadsheetSTAStatus !== Ad.statuses.DISABLED &&
        sta.getStatus() !== Ad.statuses.DISABLED) {
      // Sync label(s) on STA.
      var currentLabels = sta.getLabels();
      // Applies labels, set in spreadsheet, on to STA.
      if (sta.syncLabels(labelNames)) {
        staChanges.trackChange('labels', currentLabels, labelNames);
      } else {
        errorCount += 1;
        try {
          spreadsheetRow.markAsError('Error applying labels ' + labelNames +
              ' on STA with Id ' + spreadsheetRow.getNumber('staId'));
        } catch (err) {
          spreadsheetRow.markAsError('Error applying labels on STA with ' +
              'missing Id, row : ' + spreadsheetRow.getRowIndex());
        }
      }
    }
  }

  return errorCount;
}


/**
 * Syncs ETA in AdWords platform with STA in spreadsheet.
 * Currently creates ETA if ready, sync labels, status, approval reason.
 *
 * @param {Ad} eta Ad object to apply changes to.
 * @param {SpreadsheetRow} spreadsheetRow A row in spreadsheet.
 * @param {AdChange} etaChanges An object used for tracking changes
 *                              made in AdWords platform.
 *
 * @return {number} A count of errors encountered.
 */
function syncETA(eta, spreadsheetRow, etaChanges) {

  var errorCount = 0;
  // Get labels to apply to STAs and newly created ETAs.
  var labelNames = getLabelsToApply(spreadsheetRow, CONFIG.defaultLabelName);

  // If ready to upload and ETA ID is not set.
  if (spreadsheetRow.getString('readyToUpload').trim().toLowerCase() ===
      'yes' && (isEmptyString(spreadsheetRow.getNumber('etaId')) ||
      spreadsheetRow.getNumber('etaId') === 0)) {

    // Create a new ETA.
    var result = createETA(spreadsheetRow);
    eta = result.ad;

    // Only apply ETA changes, if ETA object is present.
    if (isEmpty(eta)) {
      errorCount += 1;
      if (result.errors.length > 0) {
        spreadsheetRow.markAsError(result.errors);
      } else {
        spreadsheetRow.markAsError('Error creating new ETA for STA with Id ' +
                                   spreadsheetRow.getNumber('staId'));
      }
    } else {
      etaChanges.trackCreate(eta.getId(),
                             spreadsheetRow.getNumber('adGroupId'));

      if (!IS_PREVIEW) {
        // Update spreadsheet with ETA values.
        spreadsheetRow.set('etaId', eta.getId());
        // If ETA status is not defined set it to paused.
        if (isEmptyString(spreadsheetRow.getString('etaStatus'))) {
          spreadsheetRow.set('etaStatus', 'paused');
        }
      }

      // Applies labels, set in spreadsheet, on to ETA.
      if (eta.syncLabels(labelNames)) {
        etaChanges.trackChange('labels', '', labelNames);
      } else {
        errorCount += 1;
        spreadsheetRow.markAsError('Error applying labels ' + labelNames +
            ' on ETA with Id ' + eta.getId());
      }

      var spreadsheetETAStatus = spreadsheetRow.getString('etaStatus');
      if (eta.syncStatus(spreadsheetETAStatus)) {
        etaChanges.trackChange('status', '', spreadsheetETAStatus);
      } else {
        errorCount += 1;
        spreadsheetRow.markAsError('Error syncing status for ETA with Id ' +
            eta.getId());
      }
    }
  } else if (!isEmptyString(spreadsheetRow.getNumber('etaId')) &&
             spreadsheetRow.getNumber('etaId') !== 0) {

    // If ETA is null, retrieve it.
    // ETA is only null when it's not returned in `getETAReports` for this
    // specific row.
    if (isEmpty(eta)) {
      eta = getAdFromRow(spreadsheetRow, 'ETA');
    }

    if (isEmpty(eta)) {
      errorCount += 1;
      try {
        spreadsheetRow.markAsError('Error retrieving ETA with Id ' +
                                   spreadsheetRow.getNumber('etaId'));
      }
      catch (err) {
        spreadsheetRow.markAsError('Error retrieving ETA with missing Id, ' +
                                   'row :  ' + spreadsheetRow.getRowIndex());
      }
    // Only apply ETA changes, if ETA object is present.
    } else {
      // Set latest approval status of ETA in spreadsheet.
      var etaApprovalStatus = eta.getApprovalStatus();
      if (!isEmptyString(etaApprovalStatus)) {
        spreadsheetRow.set('etaApprovalStatus', etaApprovalStatus);
      }

      var currentStatus = eta.getStatus();
      var spreadsheetETAStatus = spreadsheetRow.getString('etaStatus');
      if (eta.syncStatus(spreadsheetETAStatus)) {
        etaChanges.trackChange('status', currentStatus, spreadsheetETAStatus);
      }

      // If ETA has not been disabled.
      if (spreadsheetETAStatus !== Ad.statuses.DISABLED) {
        // Sync label(s) on ETA.
        var currentLabels = eta.getLabels();
        // Applies labels, set in spreadsheet, on to ETA.
        if (eta.syncLabels(labelNames)) {
          etaChanges.trackChange('labels', currentLabels, labelNames);
        } else {
          errorCount += 1;
          spreadsheetRow.markAsError('Error applying labels ' + labelNames +
              ' on ETA with Id ' + eta.getId());
        }
      }
    }
  }

  return errorCount;
}

//////////////////////////////////////////////////////////////////////////
////////////////////////////////// AD ////////////////////////////////////
//////////////////////////////////////////////////////////////////////////

// Depends on the following global functions:
// - createLabel
// - isEmpty
// - isEmptyString
// - print



/**
 * Represents an Ad.
 *
 * @param {Object} row A report row from which to parse an Ad. This object
 *                     is expected to have properties included in `rowFields`.
 * @param {Array<string>} rowFields An array of fields to select from `row`.
 * @param {AdWordsApp.Ad} adWordsAppAd An Ad object.
 * @param {AdWordsApp.Account} account Current AW account.
 *
 * @constructor
 */
function Ad(row, rowFields, adWordsAppAd, account) {
  this.row = {
    accountId: account.getCustomerId(),
    accountName: account.getName()
  };

  // Stores AdWordsApp.Ad.
  this.ad = null;
  if (!isEmpty(adWordsAppAd)) {
    this.ad = adWordsAppAd;
  }

  if (!isEmpty(row) && !isEmpty(rowFields) && Array.isArray(rowFields)) {
    // Copy all selected fields.
    var self = this;
    rowFields.forEach(function(field) {
      // Lowercase the first character.
      var normalizedFieldName = field.charAt(0).toLowerCase() +
          field.substring(1, field.length);

      if (normalizedFieldName === 'status' ||
          normalizedFieldName === 'combinedApprovalStatus') {
        self.row[normalizedFieldName] = row[field].trim().toLowerCase();
      } else {
        self.row[normalizedFieldName] = row[field];
      }

      // Assign `id` to self, for efficient access.
      if (normalizedFieldName === 'id') {
        self.id = row[field];
      }
    });
  }
}


/**
 * Supported statuses.
 * @enum {string}
 */
Ad.statuses = {
  ENABLED: 'enabled',
  PAUSED: 'paused',
  DISABLED: 'disabled'
};


/**
 * Returns true if this instance has an AdWordsApp.Ad object.
 *
 * @return {boolean}
 */
Ad.prototype.hasAdWordsAppAd = function() {
  return (!isEmpty(this.ad));
};


/**
 * Returns an Ad's id.
 *
 * @return {number}
 * @throws {string}
 */
Ad.prototype.getId = function() {
  // If AdWordsApp.Ad object is present.
  if (this.hasAdWordsAppAd()) {
    return this.ad.getId();
  // Otherwise, dealing with Ad Report object.
  } else if (!isEmptyString(this.row.id)) {
    return this.row.id;
  } else {
    throw 'Invalid ad object.';
  }
};


/**
 * Returns an Ad's labels.
 *
 * @return {Array<string>} An array of label names.
 * @throws {string}
 */
Ad.prototype.getLabels = function() {
  var labelNames = [];

  // If AdWordsApp.Ad object is present.
  if (this.hasAdWordsAppAd()) {
    if (this.ad.getId() > 0) {
      var labelIterator = this.ad.labels().get();

      while (labelIterator.hasNext()) {
        var label = labelIterator.next();
        labelNames.push(label.getName());
      }
    }
  // Dealing with an Ad report.
  } else if (this.row['labels'] !== undefined &&
             typeof this.row.labels === 'string') {
    // If no labels are present.
    if (isEmpty(this.row.labels) || this.row.labels === '--') {
      return [];
    } else {
      try {
        labelNames = JSON.parse(this.row.labels);
      } catch (err) {
        throw 'Invalid JSON string stored in Ad.labels';
      }
    }
    // Dealing with an unknown object.
  } else {
    throw 'Invalid ad object';
  }

  return labelNames;
};


/**
 * Determines which labels to remove and which labels to add.
 *
 * @param {Array<string>} newLabelNames An array of new label names to apply.
 *
 * @return {{add: Array<string>,
 *           remove: Array<string>,
 *           hasDiff: boolean}} An object with new labels to apply under an
 *                              `add` attribute, labels to remove under a
 *                              `remove` attribute, and a `hasDiff` attribute
 *                              used to indicate if any differences were found.
 *
 * @private
 */
Ad.prototype.diffLabels_ = function(newLabelNames) {
  // `newLabelNames` must be an array.
  if (!Array.isArray(newLabelNames)) {
    throw 'Incorrect `newLabelNames` parameter.';
  }

  var diff = {
    add: [],
    remove: [],
    hasDiff: false
  };

  var currentLabelNames = this.getLabels();

  newLabelNames.forEach(function(newLabelName) {
    newLabelName = newLabelName.trim();
    // If new label is not present in current labels, append to `add`.
    if (currentLabelNames.indexOf(newLabelName) === -1) {
      diff.add.push(newLabelName);
      diff.hasDiff = true;
    }
    return;
  });

  currentLabelNames.forEach(function(currentLabelName) {
    currentLabelName = currentLabelName.trim();
    // If current label is not present in new labels, append to `remove`.
    if (newLabelNames.indexOf(currentLabelName) === -1) {
      diff.remove.push(currentLabelName);
      diff.hasDiff = true;
    }
    return;
  });

  return diff;
};


/**
 * Adds labels on Ad based on `labelNamesDiff` values.
 *
 * @param {Array<string>} newLabels Contains an array of label names to add.
 *
 * @return {boolean} Representing whether all labels were successfully applied.
 */
Ad.prototype.syncLabels = function(newLabels) {
  // If newLabels is empty, nothing to add.
  if (isEmpty(newLabels) || newLabels.length <= 0) {
    return true;
  }

  var allApplied = true;
  var labelNamesDiff = this.diffLabels_(newLabels);

  // If differences are present, make sure we have AdWordsApp.Ad object.
  if (labelNamesDiff.hasDiff && !this.hasAdWordsAppAd()) {
    var success = this.getAd();
    if (!success) {
      return false;
    }
  }

  var self = this;
  labelNamesDiff.add.forEach(function(labelName) {
    // Create label, if it doesn't exist.
    var success = createLabel(labelName);

    if (success) {
      try {
        if (self.ad.getId() > 0) {
          self.ad.applyLabel(labelName);
        }
      } catch (err) {
        print('Failed to apply ' + labelName + ' to Ad Id: ' + self.getId(),
            [err]);
        allApplied = false;
      }
    } else {
      print('Failed to create ' + labelName + ' and apply to Ad Id: ' +
            self.getId());
      allApplied = false;
    }
  });

  return allApplied;
};


/**
 * Returns status of Ad.
 *
 * @return {string}
 */
Ad.prototype.getStatus = function() {
  var adStatus = '';

  // If we have AdWordsApp.Ad object.
  if (this.hasAdWordsAppAd()) {
    if (this.ad.getId() > 0) {
      if (this.ad.isEnabled()) {
        adStatus = Ad.statuses.ENABLED;
      } else if (this.ad.isPaused()) {
        adStatus = Ad.statuses.PAUSED;
      }
    }
  // Otherwise, dealing with Ad Report object.
  } else if (!isEmptyString(this.row.status)) {
    adStatus = this.row.status;
  } else {
    throw 'Invalid ad object.';
  }

  return adStatus;
};


/**
 * Returns approval status of Ad.
 *
 * @return {?string}
 */
Ad.prototype.getApprovalStatus = function() {
  var approvalStatus = null;

  // If dealing with an AdWordsApp.Ad object.
  if (this.hasAdWordsAppAd()) {
    if (this.ad.getId() > 0) {
      approvalStatus = this.ad.getApprovalStatus();
    }
  // Otherwise, dealing with Ad Report object.
  } else {
    approvalStatus = this.row.combinedApprovalStatus;
  }

  if (isEmptyString(approvalStatus)) {
    return null;
  }

  return approvalStatus.trim().toLowerCase();
};


/**
 * Syncs the status of an Ad in spreadsheet with AdWordsApp.Ad.
 *
 * @param {Ad.statuses} status Possible choices are `enabled`, `paused`, and
 *                             `disabled`.
 *
 * @return {boolean} A boolean indicating if operation was successfull.
 */
Ad.prototype.syncStatus = function(status) {
  if (isEmptyString(status)) {
    throw 'Incorrect status parameter.';
  }

  var normalizedStatus = status.trim().toLowerCase();

  // If status does not differ, no changes need to be made.
  if (this.getStatus() === normalizedStatus) {
    return true;
  // Otherwise, change will have to be made, therefore
  // make sure AdWordsApp.Ad is present.
  } else if (!this.hasAdWordsAppAd()) {
    var success = this.getAd();
    if (!success) {
      return false;
    }
  }

  try {
    if (this.ad.getId() < 0) {
      // Negative ad ID indicate running in preview mode.
      return true;
    }

    switch (normalizedStatus) {
      case Ad.statuses.ENABLED:
        this.ad.enable();
        return true;
      case Ad.statuses.PAUSED:
        this.ad.pause();
        return true;
      // For no value, set to pause.
      case '':
        this.ad.pause();
        return true;
      default:
        return false;
    }
  } catch (err) {
    return false;
  }
};


/**
 * Retrieves and sets an Ad object to this instance.
 *
 * @return {boolean} Whether the ad was found or not.
 */
Ad.prototype.getAd = function() {
  if (isEmptyString(this.row.adGroupId) || isEmptyString(this.row.id)) {
    throw 'Invalid Ad instance.';
  }

  var adIdAndAdgroupId = [[this.row.adGroupId, this.row.id]];

  var adSelector = AdWordsApp.ads()
                   .withIds(adIdAndAdgroupId);

  var adIterator = adSelector.get();

  // There is at most 1 Ad.
  if (adIterator.hasNext()) {
    this.ad = adIterator.next();
    return true;
  } else {
    return false;
  }
};


/**
 * Export ad to selected Google sheet.
 *
 * @param {Sheet} sheet Output sheet.
 * @param {{columns: Array<string>,
 *          reportFieldMap: Object,
 *          columnNamesToIndices: Object,
 *          nonEmptyColumnCheck: string
 *        }} sheetConfig Configuration for selected sheet.
 */
Ad.prototype.export = function(sheet, sheetConfig) {
  if (isEmpty(sheetConfig) ||
      isEmpty(sheetConfig.columns) ||
      isEmpty(sheetConfig.reportFieldMap) ||
      isEmpty(sheetConfig.columnNamesToIndices) ||
      isEmpty(sheetConfig.nonEmptyColumnCheck)) {
    throw 'Failed exporting ad ' + this.id + '. Must provide valid sheet ' +
          'configuration when exporting an Ad to a spreadsheet';
  }

  var fields = [];
  var self = this;

  // Check if exporting to the first content row in the spreadsheet.
  var lastRow = sheet.getLastRow();
  var colIndex =
      sheetConfig.columnNamesToIndices[sheetConfig.nonEmptyColumnCheck] + 1;

  var isFirstRow = (lastRow === sheetConfig.firstContentRow) &&
      isEmptyString(sheet.getRange(lastRow, colIndex).getValue());

  // Parse all columns.
  sheetConfig.columns.forEach(function(key) {
    var field;
    // Detect whether this field is a formula and copy the formula from
    // previous cell if necessary.
    if (!isFirstRow && !isEmpty(sheetConfig.columnsWithFormulas) &&
        sheetConfig.columnsWithFormulas.indexOf(key) !== -1) {
      // Copy formula.
      var formulaIndex = sheetConfig.columns.indexOf(key);
      if (formulaIndex !== -1) {
        // Add 1 to index because spreadsheet begins at index 1.
        var formulaPreviousCell = sheet.getRange(lastRow, formulaIndex + 1);

        // Return the formula from one cell above this row.
        field = formulaPreviousCell.getFormulaR1C1();
      }
    }

    if (isEmptyString(field)) {
    // Parse field by column name.
     field = self.parseField_(key, sheetConfig);
    }

    // Stage field.
    fields.push(field);
  });

  if (isFirstRow) {
    // Because we're "freezing" rows, we can't delete all other non-frozen
    // rows and so we're left with one empty row. Instead of adding a new
    // row, replace the values in the cells.
    var prevSectionIndex = 1;
    var values = [];

    // Copy fields in batches.
    fields.forEach(function(field) {
      if (!isEmptyString(field)) {
        // Batch fields together to avoid making many I/O operations.
        values.push(field);
      } else if (values.length > 0) {
        // Flush fields.
        var row = sheet.getRange(lastRow, prevSectionIndex, 1, values.length);
        row.setValues([values]);

        // Update section start index.
        prevSectionIndex += values.length + 1;

        // Reset batch.
        values = [];
      } else {
        // Skip empty field.
        prevSectionIndex++;
      }
    });
  } else {
    // Add a new row and copy all fields.
    sheet.insertRowAfter(lastRow);
    lastRow++;

    // Flush fields.
    var row = sheet.getRange(lastRow, 1, 1, fields.length);
    row.setValues([fields]);
  }
};

/**
 * Parse a field based on a column key.
 *
 * @param {string} key Column key.
 * @param {{reportFieldMap: Array<dict>,
 *          defaultStatus: string
 *          }} sheetConfig A configuration object that contains a mapping from
 *                         spreadsheet column names to report columns names,
 *                         and a default value for `etaStatus`.
 *
 * @return {string} A field based on the column key.
 *
 * @private
 */
Ad.prototype.parseField_ = function(key, sheetConfig) {
  var proxyKey = sheetConfig.reportFieldMap[key];
  if (proxyKey === undefined) {
    throw 'Failed exporting ad ' + this.id +
          '. Missing report field mapping for: ' + key + '.';
  }

  // Get the field value using the key from the spreadhseet-column name.
  var field = this.row[proxyKey];

  // Handle specific columns differently.
  switch (key) {
    case 'description':
      // Concatenate description 1 and description 2
      // in case description field is blank.
      if (isEmptyString(field)) {
        field = this.row.description1 +
                (isEmptyString(this.row.description2) ? '' :
                 '\n' + this.row.description2);
      }

      break;

    case 'headline1':
      // If headline part 1 is blank, use headline instead.
      if (isEmptyString(field)) {
        field = this.row.headline;
      }

      break;

    case 'etaStatus':
      if (isEmptyString(field)) {
        field = sheetConfig.defaultStatus;
      }

      break;

    case 'finalUrl':
    case 'mobileFinalUrl':
      // Support only single value for Final URL and
      // Mobile Final URL.
      try {
        field = JSON.parse(field);
      } catch (ignore) {
        // Value is most likely already a string, use it
        // directly. For example "--".
      }

      field = getFirstElementInArray(field);
      break;
  }

  if (isEmptyString(field) || field === '--') {
    // Replace all undefined fields because Range.setValues won't play well
    // with them.
    // Replace all '--' with empty strings to avoid exporting empty values
    // formatted like that.
    field = '';
  }

  return field;
};

//////////////////////////////////////////////////////////////////////////
//////////////////////////////// UTILS ///////////////////////////////////
//////////////////////////////////////////////////////////////////////////

/**
 * Get a unique file tag that be used to locate the file.
 *
 * @param {string} seed A seed string used to generate a unique tag.
 *
 * @return {string} A unique string associated with the current account and
 *                  the seed used.
 */
function getUniqueFileTag(seed) {
  // Use the account ID as an additional identifier.
  var currentAccount = AdWordsApp.currentAccount();
  return seed + '-' + currentAccount.getCustomerId();
}


/**
 * Compare two arrays. Nested objects within the array will be compared
 * by reference. Therefore, an identical objects but of two different
 * instances will be considered not equal.
 *
 * For example:
 * isArrayShallowEquals([1, {a: 1}], [1, {a: 1}]) // false.
 * var obj = {a: 1};
 * isArrayShallowEquals([1, obj], [1, obj]) // true.
 *
 * @param {Array} a Array A.
 * @param {Array} b Array B.
 *
 * @return {boolean} Whether both arrays are shallowly equal or not.
 */
function isArrayShallowEquals(a, b) {
  // Condition 1: one of the arrays is empty.
  if ((!a && b) || (!b && a)) {
    return false;
  }

  // Condition 2: both arrays are null or undefined.
  if (a == null && a == b) {
    return true;
  }

  // Condition 3: either one of the arguments is not an array.
  if (!Array.isArray(a) || !Array.isArray(b)) {
    return false;
  }

  // Condition 4: arrays of different length.
  if (a.length !== b.length) {
    return false;
  }

  // Condition 5: compare each element in the arrays.
  for (var i = 0; i < a.length; i++) {
    // Shallow compare.
    if (a[i] !== b[i]) {
      return false;
    }
  }

  return true;
}


/**
 * Get the first element in an array.
 *
 * @param {Array} arr The array from which to get the first element.
 *
 * @return {Object} The first element in the array or `null` if empty. If
 *                   `arr` is not an array, return it as is.
 */
function getFirstElementInArray(arr) {
  if (!arr || !Array.isArray(arr)) {
    return arr;
  }

  if (arr.length === 0) {
    return null;
  }

  return arr[0];
}


/**
 * Check if given string is empty.
 *
 * @param {string} str Empty string candidate.
 *
 * @return {boolean} True if string is empty, o/w false.
 */
function isEmptyString(str) {
  if (str == null) {
    return true;
  }

  if (isString(str)) {
    return str.trim() === '';
  }

  return false;
}


/**
 * Check if `obj` is null or undefined.
 *
 * @param {Object} obj
 *
 * @return {boolean}
 */
function isEmpty(obj) {
  return obj === null || obj === undefined;
}


/**
 * Check if `obj` is an object.
 *
 * @param {Object} obj
 *
 * @return {boolean}
 */
function isObject(obj) {
  // Check if obj exists and has Object string value
  if (!obj ||
      toString.call(obj) !== '[object Object]') {
    return false;
  }
  // Check is constructor is owned
  if (obj.constructor &&
      !hasOwnProperty.call(obj, 'constructor') &&
      !hasOwnProperty.call(obj.constructor.prototype, 'isPrototypeOf')) {
    return false;
  }
  // Empty if no properties OR
  // All properties are owned is last enum property is owned
  var key;
  for (key in obj) {}

  return key === undefined || hasOwnProperty.call(obj, key);
}


/**
 * Check if `obj` is a valid params object.
 *
 * @param {Object} obj
 * @param {Array} valueTypes A list of valid value types allowed as parameters
 * @param {boolean} emptyIsInvalid A boolean defining if an empty object is
 *                                 invalid
 *
 * @return {Object} An object with { success: <boolean>, message: <string> },
 *                   where message is the reason for failure if success is
 *                   false.
 */
function isValidParamsObject(obj, valueTypes, emptyIsInvalid) {
  var result = {
    success: false,
    message: ''
  };

  if (!emptyIsInvalid && (isEmpty(obj) || isEmptyString(obj))) {
    result.success = true;
    return result;
  }

  if (obj === null) {
    result.message = 'params is null';
    return result;
  }
  if (obj === undefined) {
    result.message = 'params is undefined';
    return result;
  }
  if (!isObject(obj)) {
    result.message = 'params is not an object';
    return result;
  }
  if (!valueTypes || !Array.isArray(valueTypes)) {
    result.message = 'valueTypes is not a valid array';
    return result;
  }
  var keyCount = 0;
  for (var key in obj) {
    if (obj.hasOwnProperty(key)) {
      var value = obj[key];
      if (valueTypes.indexOf(typeof value) === -1) {
        result.message = 'key `' + key + '` has an invalid type `' +
            typeof value + '`';
        return result;
      }
    }
    keyCount++;
  }
  if (emptyIsInvalid && keyCount === 0) {
    result.message = 'params is empty';
    return result;
  }
  result.success = true;
  return result;
}


/**
 * Print to logger if in DEBUG mode.
 *
 * @param {string} msg The message to print with Logger.
 * @param {?Array} arr An array of strings to print.
 */
function print(msg, arr) {
  if (CONFIG.debug) {
    if (arr) {
      Logger.log(msg + ' ' + arr.join(', '));
    } else {
      Logger.log(msg);
    }
  }
}


/**
 * Check if `str` is a string.
 *
 * @param {string} str Questionable string.
 *
 * @return {boolean} Whether `str` is indeed a string.
 */
function isString(str) {
  return typeof str === 'string' || str instanceof String;
}


/**
 * Compares the performance between two ads.
 *
 * @param {Ad} a Ad a.
 * @param {Ad} b Ad b.
 *
 * @return {number} +1 if Ad a has better performance, -1 if Ad b has better
 *         performance, and 0 if equal.
 */
function comparePerformance(a, b) {
  var impressionsDiff = a.row.impressions - b.row.impressions;
  var ctrDiff = parseFloat(a.row.ctr) - parseFloat(b.row.ctr);

  var absImpressionsDiff = Math.abs(impressionsDiff);
  var absCtrDiff = Math.abs(ctrDiff);

  /**
   * Helper function to compare impressions and CTR.
   *
   * @param {number} impressionDiff The difference between both impressions.
   * @param {number} ctrDiff The difference between both CTRs.
   *
   * @return {number} +1 if impressionDiff is > 0, or if impressionDiff === 0
   *                  and ctrDiff > 0. -1 if impressionDiff is < 0, or if
   *                  impressionDiff === 0 and ctrDiff < 0. Otherwise, returns
   *                  0.
   */
  function _compare(impressionDiff, ctrDiff) {
    // Choose the ad with the most impressions.
    if (impressionsDiff > 0) {
      return 1;
    } else if (impressionsDiff < 0) {
      return -1;
    } else {
      // Both ads have equal impressions, choose highest CTR.
      if (ctrDiff > 0) {
        return 1;
      } else if (ctrDiff < 0) {
        return -1;
      } else {
        return 0;
      }
    }
  }

  if (absImpressionsDiff < CONFIG.performance.impressionsThreshold) {
    // The difference in impressions is insignificant, gauge at CTR.
    if (absCtrDiff < CONFIG.performance.ctrThreshold) {
      // The difference in CTR is insignificant, choose the highest impresssion,
      // or the highest CTR in case impressions are equal.
      return _compare(impressionsDiff, ctrDiff);
    } else if (ctrDiff > 0) {
      // The difference in CTR is significant, therefore if it's positive it
      // should be in favor of *this* ad.
      return 1;
    } else {
      // The difference in CTR is significant, therefore if it's negative it
      // should be in favor of *compared* ad.
      return -1;
    }
  } else if (impressionsDiff > 0) {
    // The difference in impressions is significant, therefore if it's positive
    // it should be in favor of *this* ad.
    return 1;
  } else {
    // The difference in impressions is significant, therefore if it's negative
    // it should be in favor of *compared* ad.
    return -1;
  }
}


/**
 * Composite to reverse ad compare in order to have the most perfoming ad at
 * index 0.
 *
 * @param {function (number, number) : boolean} compareFunc The original compare
 *                                                          function.
 *
 * @return {function (number, number) : boolean} A reverse version of
 *                                               compareFunc.
 */
function reverseCompare(compareFunc) {
  return function(a, b) {
    return -compareFunc(a, b);
  };
}


/**
 * Traverse the report and keep the top most NUM_OF_ADS that
 * are considered most performing based on the configuration given.
 *
 * @param {AdWordsApp.Report} report The report from which we want to
 *                                   extrapolate the most performing ads.
 * @param {{reportFields: Array<string>,
 *          numOfAds: number}} config The configuration used to define which
 *                                    ads to consider most performing.
 *
 * @return {Array<Ad>} A sorted array of Ads by performance
 */
function getMostPerformingAds(report, config) {
  if (report === null) {
    throw 'Failed to find most performing ads: Report used for retrieving ' +
          'top performing ads is empty';
  }

  // Traverse all rows.
  var currentAccount = AdWordsApp.currentAccount();
  var result = [];
  var rows = report.rows();
  while (rows.hasNext()) {
    var ad = new Ad(rows.next(), config.reportFields, null, currentAccount);
    result.push(ad);
  }

  // Sort by performance.
  result.sort(reverseCompare(comparePerformance));
  return result;
}


/**
 * Retrieve ETA reports.
 *
 * @param {{etaReportStartDate: string,
 *          reportFields: Array<string>,
 *          apiVersion: string}} config The configuration used to select and
 *                                      parse ETA reports. `etaReportStartDate`
 *                                      should be formatted as YYYYMMDD.
 *
 * @return {Array<Ad>} A sorted array of ETAs.
 */
function getETAReports(config) {
  var report = AdWordsApp.report(
      'SELECT ' + config.reportFields.join(',') + ' ' +
      'FROM     AD_PERFORMANCE_REPORT ' +
      'WHERE    AdType = "EXPANDED_TEXT_AD" ' +
      'AND Date >= "' + config.etaReportStartDate + '"', {
        apiVersion: config.apiVersion
      });

  if (report === null) {
    return null;
  }

  // Traverse all rows.
  var currentAccount = AdWordsApp.currentAccount();
  var result = [];
  var rows = report.rows();
  while (rows.hasNext()) {
    var ad = new Ad(rows.next(), config.reportFields, null, currentAccount);
    result.push(ad);
  }

  return result;
}


/**
 * Attempt to open an existing spreadsheet with description containing
 * `config.templateId`. If not found, copy template spreadsheet with id
 * `config.templateId` and return its handle.
 *
 * @param {{templateId: string,
 *          targetName: string}} config A configuration object with meta-data
 *                                      about the target spreadsheet.
 * @param {?string} emailToNotify An email address to send out notification
 *                                when creating a new spreadsheet. If none is
 *                                given, will use the email address of the
 *                                owner of the spreadsheet.
 *
 * @return {Spreadsheet} Selected spreadsheet.
 */
function getSpreadsheet(config, emailToNotify) {
  if (isEmpty(config)) {
    throw 'Config is missing. Cannot get spreadsheet';
  }

  if (isEmptyString(config.templateId) || isEmptyString(config.targetName)) {
    throw 'Config must include both `templateId` and `targetName` fields.';
  }

  var spreadsheetFile;
  // Attempt to locate the spreadsheet.
  // Throws an exception when spreadsheet not found.
  var files = DriveApp.searchFiles('fullText contains "' +
                                   getUniqueFileTag(config.templateId) + '"');
  if (files.hasNext()) {
    spreadsheetFile = files.next();
    if (files.hasNext()) {
      print('WARNING: more than one file with \'' + config.templateId +
                 '\' detected.');
    }
  } else {
    // Create a copy of the template spreadsheet.
    spreadsheetFile = copyFile(config.templateId, config.targetName);
    if (spreadsheetFile) {
      // Notify via email.
      if (isEmptyString(emailToNotify)) {
        // Set the default email address to owner's email address.
        emailToNotify = getFileOwnerEmail(spreadsheetFile);
      }

      notify(emailToNotify, SPREADSHEET_CREATED, spreadsheetFile.getUrl());
    }
  }

  // Get spreadsheet by ID.
  var spreadsheet = null;
  try {
    spreadsheet = !!spreadsheetFile &&
      SpreadsheetApp.openById(spreadsheetFile.getId());
  } catch (err) {
    print(err);
  }

  if (!spreadsheet) {
    throw 'Spreadsheet is missing or unavailable. Please ensure the URL is ' +
          'correct and that you have permission to edit';
  } else {
    return spreadsheet;
  }
}


/**
 * Open spreadsheet with name `config.targetName` and select its sheet with
 * name `config.sheetName`.
 *
 * @param {{sheetName: string,
 *          templateId: string,
 *          targetName: string}} config Containing sheet meta-data.
 * @param {?string} emailToNotify An email address to send out notification
 *                                when creating a new spreadsheet. If none is
 *                                given, will use the email address of the
 *                                owner of the spreadsheet.
 *
 * @return {Sheet} Selected sheet.
 */
function getSheet(config, emailToNotify) {
  if (isEmptyString(config.sheetName)) {
    throw 'Config must include `sheetName` field. Please set to the name of ' +
          'sheet within the spreadsheet e.g. main';
  }

  var spreadsheet = getSpreadsheet(config, emailToNotify);
  var sheet = spreadsheet.getSheetByName(config.sheetName);
  if (!sheet) {
    throw 'Spreadsheet is missing a sheet named: ' + config.sheetName +
          '. Please ensure the sheet exists in the spreadsheet';
  }

  return sheet;
}


/**
 * Retrieves cached sheet.
 *
 * @param {{sheet: Sheet,
 *          sheetName: string,
 *          templateId: string,
 *          targetName: string}} config An object to retrieve cached sheet from.
 * @param {?string} emailToNotify An email address to send out notification
 *                                when creating a new spreadsheet. If none is
 *                                given, will use the email address of the
 *                                owner of the spreadsheet.
 *
 * @return {?Sheet} Selected sheet.
 */
function getCachedSheet(config, emailToNotify) {
  // If sheet already present.
  if (!isEmpty(config.sheet)) {
    return config.sheet;
  // Else, cache and return.
  } else {
    var sheet = getSheet(config, emailToNotify);
    config.sheet = sheet;
    return sheet;
  }
}


/**
 * Returns a boolean if the current user is able to edit the specified
 * spreadsheet.
 *
 * @param {Spreadsheet} spreadsheet The spreadsheet to detect edit permissions
 *                                  on.
 *
 * @return {boolean} Returns true if the current user can edit the spreadsheet
 *                   provided.
 */
function canEditSpreadsheet(spreadsheet) {
  // Validate the spreadsheet argument exists.
  if (isEmpty(spreadsheet)) {
    throw 'Argument provided to canEditSpreadsheet is not a valid spreadsheet';
  }
  // Return RANGE level protections.
  var protections = spreadsheet.getProtections(
        SpreadsheetApp.ProtectionType.RANGE);

  if (protections.length === 0) {
    // No protections at a spreadsheet level.
    return true;
  }
  for (var i = 0; i < protections.length; i++) {
    if (protections[i].canEdit()) {
      return true;
    }
  }
  // If no protections allow editing, not editable.
  return false;
}


/**
 * Wrap `func` so that it may receive default input.
 * ```
 * For example:
 * function add(a, b) {
 *   return a + b;
 * }
 *
 * var add1 = compose(add, 1);
 * add1(2); // 3
 *
 * @param {Function} func The function to wrap.
 *
 * @return {Function} The composed function. (for lack of better name.)
 */
function compose(func) {
  var prevArgs = Array.prototype.slice.call(arguments, 1);
  return function() {
    var newArgs = Array.prototype.slice.call(arguments);
    var args = prevArgs.concat(newArgs);
    return func.apply(this, args);
  };
}


/**
 * Validate the structure of the spreadsheet.
 *
 * @param {{templateId: string,
 *          targetName: string,
 *          columns: Array<string>,
 *          firstContentRow: number,
 *          columnMappingSpecialCases: Object
 *        }} config Spreadsheet configuration. `templateId` and `targetName`
 *                  are used to find the spreadsheet. `columns` are the
 *                  expected columns in the spreadsheet. `firstContentRow` is
 *                  the first row index after the header row.
 *                  `columnsMappingSpecialCases` is a dictionary (string:
 *                  string) for headers that require special handling.
 *
 * @return {boolean} Whether the spreadsheet is valid.
 */
function validateSpreadsheet(config) {
  /**
   * Remove whitespaces, any non-numeric value, and lower case given header.
   *
   * @param {Object} specialCases A dictionary (string: string) for
   *                              headers that require special handling.
   * @param {string} header The header to parse.
   *
   * @return {string} A normalized header.
   */
  function _parseHeader(specialCases, header) {
    // Remove whitespaces, non-alphanumeric characters and lowercase
    // all letters.
    var parsed = header.replace(/ |\?|\W/g, '').toLowerCase();

    // Handle special cases.
    if (!isEmpty(specialCases)) {
      var specialCase = specialCases[parsed];
      if (!isEmptyString(specialCase)) {
        parsed = specialCase;
      }
    }

    return parsed;
  }

  /**
   * Verify that the existing headers are equal to expected headers.
   *
   * @param {Array<string>} headers Existing headers.
   * @param {Array<string>} expectedHeaders The expected headers.
   *
   * @return {{pass: boolean, mapping: Object}} The verification result.
   *         `pass` indicates whether the validation passed. `mapping`
   *         (string: string) contains meta-data on failed mapping.
   */
  function _checkHeaders(headers, expectedHeaders) {
    var pass = true;
    var failureMapping = {};

    // Create a copy of expectedHeaders so we could manipulate it.
    expectedHeaders = expectedHeaders.slice();

    // Go over every header.
    headers.forEach(function(header) {
      var expectedHeader = expectedHeaders.shift();
      if (header !== expectedHeader) {
        pass = false;
        failureMapping[expectedHeader] = header;
      }
    });

    return {
      pass: pass,
      mapping: failureMapping
    };
  }

  /**
   * Validate a spreadsheet's column headers.
   *
   * @param {{templateId: string,
   *          targetName: string,
   *          columns: Array<string>,
   *          firstContentRow: number,
   *          columnMappingSpecialCases: {'string': string}
   *        }} config Spreadsheet configuration.
   *
   * @return {{pass: boolean, mapping: Object}} The verification result.
   *         `pass` indicates whether the validation passed. `mapping`
   *         (string: string) contains meta-data on failed mapping.
   */
  function _verifyColumns(config) {
    var sheet = getCachedSheet(config);
    var header = sheet.getRange(config.firstContentRow - 1, 1, 1,
                                config.columns.length);
    var headerValues = header.getValues()[0];

    var passObj = _checkHeaders(config.columns.map(compose(_parseHeader,
                                config.columnMappingSpecialCases)),
                                headerValues.map(compose(_parseHeader,
                                config.columnMappingSpecialCases)));
    return passObj;
  }

  // Verify columns.
  var passMain = _verifyColumns(config);
  if (!passMain.pass) {
    Logger.log('\nColumns mapping (sheet:script):\n' +
               JSON.stringify(passMain.mapping, null, 2));
  }

  return passMain.pass;
}


/**
 * Retrieves an Ad object from a SpreadsheetRow object.
 *
 * @param {SpreadsheetRow} row
 * @param {string} type The type of ad. Options are either 'STA' or 'ETA'.
 *
 * @return {?Ad} Returns an Ad if found, or null if no ad is found.
 */
function getAdFromRow(row, type) {
  var ad = null;
  if (type === 'ETA') {
    try {
      ad = getAd([row.get('adGroupId'), row.get('etaId')]);
    } catch (err) {
      Logger.log('Failed to get ETA due to: ' + err);
    }
  }
  else if (type === 'STA') {
    try {
      ad = getAd([row.get('adGroupId'), row.get('staId')]);
    } catch (err) {
      Logger.log('Failed to get STA due to: ' + err);
    }
  }
  else {
    throw 'Incorrect Ad type.';
  }

  return ad;
}


/**
 * Retrieves an Ad object.
 *
 * @param {Array<number>} adIdAndAdgroupId An array consisting of an
 *                                AdGroup Id and Ad Id. AdGroup Id and Ad Id
 *                                is the unique key for identifying an Ad.
 *
 * @return {?Ad} Returns an Ad if found, or null if no ad is found.
 */
function getAd(adIdAndAdgroupId) {
  if (!adIdAndAdgroupId || adIdAndAdgroupId.length < 2 ||
      isEmptyString(adIdAndAdgroupId[0]) ||
      isEmptyString(adIdAndAdgroupId[1])) {
    throw '`adIdAndAdgroupId` must include two values: adGroup ID, and Ad ID';
  }

  var adSelector = AdWordsApp.ads()
                   .withIds([adIdAndAdgroupId]);

  var adIterator = adSelector.get();

  // There is at most 1 Ad.
  if (adIterator.hasNext()) {
    var currentAccount = AdWordsApp.currentAccount();
    return new Ad(null, null, adIterator.next(), currentAccount);
  } else {
    return null;
  }
}



/**
 * An object representation of a row in a spreadsheet.
 *
 * @constructor
 *
 * @param {Array<Object>} values The values in this row.
 * @param {Range} allRange A range where this row is part of.
 * @param {number} rowOffset The row offset for this row within `allRange`.
 * @param {Object} columnNamesToIndices A mapping between a columnName
 *                 and an index.
 */
function SpreadsheetRow(values, allRange, rowOffset, columnNamesToIndices) {
  if (isEmpty(values) || isEmpty(allRange) || isNaN(rowOffset) ||
      isEmpty(columnNamesToIndices)) {
    throw 'Unable to retrieve row in spreadsheet because of incorrect ' +
        'parameters passed to the "SpreadsheetRow" function.';
  }

  // Because of the hard dependency in 'errorMessage', validate whether it
  // exists.
  if (!columnNamesToIndices.hasOwnProperty('errorMessage') ||
      isEmpty(columnNamesToIndices.errorMessage)) {
    throw '`errorMessage` is a required attribute for `columnNamesToIndices` ' +
        'in order to be able to properly identify where to write error ' +
        'messages in a spreadsheet row';
  }

  // The Range of the row in sheet.
  this.range_ = allRange.offset(rowOffset, 0, 1, allRange.getLastColumn());

  // The index of the row in sheet.
  this.rowIndex_ = this.range_.getRowIndex();

  // Storing a reference to this object and it will be used as read-only.
  this.columnNamesToIndices_ = columnNamesToIndices;

  this.hasErrors_ = false;
  // The values of the row in sheet.
  this.values_ = values;

  // Start fresh by removing any existing errors in this particular row.
  this.markAsResolved();
  // Check for proper values.
  this.validateValues_();
}


/**
 * Validates values in this row, if any improper values are detected
 * then display appropriate error message in error column.
 *
 * @private
 */
SpreadsheetRow.prototype.validateValues_ = function() {
  var staStatusValue = this.get('staStatus');

  if (!isSupportedStatus(staStatusValue)) {
    this.markAsError('Unsuported STA status with value of \'' +
                     staStatusValue + '\'.');
  }

  var etaStatusValue = this.get('etaStatus');

  if (!isSupportedStatus(etaStatusValue)) {
    this.markAsError('Unsuported ETA status with value of \'' +
                     etaStatusValue + '\'.');
  }
};


/**
 * Retrieve the column index of a column name.
 *
 * @param {string} columnName The name of the column in the spreadsheet we want
 *                            to retrieve index for.
 *
 * @return {number} The index of columnName.
 * @private
 */
SpreadsheetRow.prototype.getColumnIndex_ = function(columnName) {
  if (!(columnName in this.columnNamesToIndices_)) {
    throw 'Column "' + columnName + '" does not exist.';
  } else {
    return this.columnNamesToIndices_[columnName];
  }
};


/**
 * Sets the value of a given column for this row.
 *
 * @param {string} columnName
 * @param {string} value
 *
 */
SpreadsheetRow.prototype.set = function(columnName, value) {
  var columnIndex = this.getColumnIndex_(columnName);

  // Add 1, because SpreadSheetApp's Range is relative to 1.
  this.range_.getCell(1, columnIndex + 1).setValue(value);
  this.values_[columnIndex] = value;
};


/**
 * Gets the value at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {number|boolean|Date|string} The value stored at given column.
 */
SpreadsheetRow.prototype.get = function(columnName) {
  var columnIndex = this.getColumnIndex_(columnName);

  return this.values_[columnIndex];
};


/**
 * Parse value as a JSON object. Value must be either string or an array.
 * Also handles the case where a value may be a string with out
 * quotations.
 *
 * @param {string} columnName
 *
 * @return {Array}
 */
SpreadsheetRow.prototype.getArray = function(columnName) {
  var columnIndex = this.getColumnIndex_(columnName);

  var values = [];

  if (isEmptyString(this.values_[columnIndex])) {
    return values;
  }

  try {
    values = JSON.parse(this.values_[columnIndex]);
  } catch (err) {
    // Maybe a valid string with no quotations is present.
    var valueWithQuotes = '"' + this.values_[columnIndex] + '"';

    try {
      values = JSON.parse(valueWithQuotes);
    } catch (err) {
      throw 'Incorrect JSON value stored in `' + columnName + '` column.';
    }
  }

  // Must be an array or string.
  if (!Array.isArray(values) && (typeof values !== 'string')) {
    throw 'Incorrect array value stored in `' + columnName + '` column.';
  }

  // At this point it must be an array or string. If it's a string,
  // keep return type consistent by always returning an array.
  if (!Array.isArray(values)) {
    values = [values];
  }

  return values;
};


/**
 * Gets the number value at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {number} The number value stored at given column.
 * @throws {string}
 */
SpreadsheetRow.prototype.getNumber = function(columnName) {
  var value = this.get(columnName);

  if (isNaN(value)) {
    throw 'Value stored in "' + columnName + '" is not a valid number.';
  }

  return Number(value);
};


/**
 * Gets the string value at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {string} The string value stored at given column.
 */
SpreadsheetRow.prototype.getString = function(columnName) {
  var value = this.get(columnName);

  return value.toString();
};


/**
 * Retrieve the cell at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {Range}
 */
SpreadsheetRow.prototype.getCell = function(columnName) {
  var columnIndex = this.getColumnIndex_(columnName);
  return this.range_.getCell(1, columnIndex + 1);
};


/**
 * Returns true if row has been marked with an error.
 *
 * @return {boolean}
 */
SpreadsheetRow.prototype.hasErrors = function() {
  return this.hasErrors_;
};


/**
 * Highlights row signalling that an error has occured.
 *
 * @param {string|Array<string>} messages Messages to print and add to error
 *                                      column. Can be a single string or an
 *                                      array of strings.
 */
SpreadsheetRow.prototype.markAsError = function(messages) {
  if (isEmpty(messages) || isEmptyString(messages)) {
    throw 'Empty `messages` parameter provided. A non-empty message must be ' +
        'provided.';
  }

  this.range_.setBackground('red');

  if (Array.isArray(messages)) {
    messages = messages.join('\n- ');
  }

  messages = '- ' + messages + '\n';

  // Append to any content already present in `errorMessage` column.
  this.set('errorMessage', this.get('errorMessage') + messages);
  print(messages);
  this.hasErrors_ = true;
};


/**
 * Removes any previous highlights set on this row.
 */
SpreadsheetRow.prototype.markAsResolved = function() {
  this.range_.setBackground(null);
  this.set('errorMessage', '');
};


/**
 * Retrieve the index of this row in spreadsheet.
 *
 * @return {number}
 */
SpreadsheetRow.prototype.getRowIndex = function() {
  return this.rowIndex_;
};


/**
 * Determines whether the `status` parameter is equivalant to one of the
 * supported Ad statuses.
 *
 * @param {string} status
 *
 * @return {boolean}
 */
function isSupportedStatus(status) {
  return (status === Ad.statuses.ENABLED ||
      status === Ad.statuses.PAUSED);
}


/**
 * Makes an array of objects indexable by a certain attribute in it's objects.
 *
 * @param {Array<Object>} objects An array of objects from which index
 *                                keys will be retrieved from.
 * @param {Array<string>} keys The keys to set index as. If more than one
 *                             element, then keys will get concatenated.
 *
 * @return {Object}
 */
function createIndexableObjectFromKeys(objects, keys) {
  if (isEmpty(objects)) {
    return {};
  }

  var indexableObject = {};

  objects.forEach(function(object) {
    var newIndexArray = [];
    keys.forEach(function(key) {
      var indexValue = object[key];
      if (!isEmptyString(indexValue)) {
        newIndexArray.push(indexValue);
      }
    });

    if (newIndexArray.length > 0) {
      var newIndexString = newIndexArray.join('|');
      if (!isEmptyString(newIndexString)) {
        indexableObject[newIndexString] = object;
      }
    }
  });

  return indexableObject;
}


/**
 * Retrieves the last row checked when syncing spreadsheet and increments
 * value by 1.
 *
 * @param {Sheet} sheet
 * @param {string} key
 *
 * @return {Object} An object with a reference to cell and integer representing
 *                   the index of last row checked.
 */
function getLastRowCheck(sheet, key) {

  var cell = sheet.getRange(key);
  var lastRowChecked = cell.getValue();

  if (isNaN(lastRowChecked)) {
    return {
      cell: null,
      value: 0
    };
  }

  cell.setValue(lastRowChecked + 1);

  return {
    cell: cell,
    value: lastRowChecked
  };
}


/**
 * Retrieves all non-empty rows, starting from config.startRowIndex.
 *
 * @param {Sheet} sheet A sheet in the spreadsheet.
 * @param {number} firstContentRow The index which content is expected to start.
 * @param {number} nonEmptyColumnCheck The column index to check and determine
 *                                  if row is considered empty.
 * @param {Object} columnNamesToIndices A mapping between column indices and
 *                 column names.
 * @param {boolean} isValidOnly If true, invalid rows will be filtered out.
 * @param {Array<SpreadsheetRow>} rowCache The row cache to use for the rows
 *                                         output. Only valid if it matches the
 *                                         target output size.
 *
 * @return {{rows: Array<SpreadsheetRow>,
 *           maxRows: number,
 *           newRowCache: Array<SpreadsheetRow> }} Object containing:
 *                     rows Containing the main content of the sheet.
 *                     maxRows The maximum number of row values possible in
 *                             the range.
 *                     newRowCache A sparse array of all rows added + cached
 *                                 rows during this execution of getContentRows.
 */
function getContentRows(sheet, firstContentRow, nonEmptyColumnCheck,
                        columnNamesToIndices, isValidOnly, rowCache) {
  // Make sure parameters are valid.
  if (isNaN(firstContentRow) ||
      firstContentRow <= 0 ||
      isEmptyString(nonEmptyColumnCheck) ||
      isEmptyString(columnNamesToIndices) ||
      (!isEmpty(rowCache) && !Array.isArray(rowCache))) {
    throw 'Unable to retrieve spreadsheet\'s content because of incorrect ' +
        'parameters passed to the "getContentRows" function.';
  }

  // Retrieve all the range at once to avoid checking each row separately.
  var endRowIndex = sheet.getLastRow();
  var lastColumnIndex = sheet.getLastColumn();

  if (firstContentRow > endRowIndex) {
    // Spreadsheet is empty.
    return {
      rows: []
    };
  }

  var range = sheet.getRange(firstContentRow, 1,
                             endRowIndex - firstContentRow + 1,
                             lastColumnIndex);
  var values = range.getValues();
  var maxRows = range.getNumRows();

  if (rowCache &&
      rowCache.length !== maxRows) {
    throw 'Parameter rowCache passed to "getContentRows" function ' +
        'is incorrect size: ' +
        'rowCache.length: ' + rowCache.length + '!== ' +
        'maxRows: ' + maxRows;
  }

  var spreadsheetRows = [];
  var nonEmptyIndexCheck = columnNamesToIndices[nonEmptyColumnCheck];

  if (isEmpty(nonEmptyIndexCheck)) {
    throw 'Please make sure to supply a value for `nonEmptyColumnCheck` that ' +
          'defines whether a row is considered to be empty or not.';
  }

  var newRowCache = [];
  newRowCache.length = maxRows;
  for (var rowOffset = 0; rowOffset < maxRows; rowOffset++) {
    // Check against the nonEmptyColumnIndex, to make sure
    // this row contains values.
    if (!isEmptyString(values[rowOffset][nonEmptyIndexCheck])) {
      var row = null;
      if (rowCache) {
        row = rowCache[rowOffset];
      }

      if (!row) {
        row = new SpreadsheetRow(values[rowOffset], range, rowOffset,
                                     columnNamesToIndices);
      }

      if (!row) {
        throw 'Spreadsheet row was not correctly created for rowOffset: ' +
            rowOffset;
      }

      // Cache row, even if it contains errors.
      newRowCache[rowOffset] = row;
      if (isValidOnly && row.hasErrors()) {
        continue;
      }

      spreadsheetRows.push(row);
    }
  }

  return {
    rows: spreadsheetRows,
    newRowCache: newRowCache,
    maxRows: maxRows
  };
}


/**
 * Send an email notification.
 *
 * @param {string} to The email address to send the notification to.
 * @param {number} event The event type.
 * @param {Object} payload The payload used in the email template.
 *
 * @return {boolean} Success/failure.
 */
function notify(to, event, payload) {
  var template = getEmailTemplate(event, payload);
  if (template) {
    // Strip down HTML elements for devices that don't render HTML.
    var body = template.body.replace(/<b>|<\/b>|<i>|<\/i>/g, '')
          .replace(/<br\/>/g, '\n');
    try {
      MailApp.sendEmail(to, template.subject, body, {
        htmlBody: template.body
      });

      // Successfully sent email notification.
      return true;
    } catch (err) {
      print('\n******* ERROR: Failed to send email to: ' + to + '. ' +
            err + '*******\n');
    }
  }

  // Failed to send email notification.
  return false;
}


/**
 * Get email template by event type.
 *
 * @param {number} event The event type.
 * @param {Object} payload The payload used in the template.
 *
 * @return {Object} An object with `subject` and `body` attributes,
 *                  or `null` if not found.
 */
function getEmailTemplate(event, payload) {
  var template = null;
  switch (event) {
    case SPREADSHEET_CREATED:
      template = {
        subject: '[ETA Transition Helper] installed successfully',
        body: 'Hello,<br/><br/>' +
              'The ETA Transition Helper was installed successfully on your ' +
              'account \'' + AdWordsApp.currentAccount().getName() +
              '\'.<br/><br/>' +
              'To view the exported standard text ads and begin creating ' +
              'expanded text ads open <a href=\'' + payload + '\'>this ' +
              'spreadsheet</a>.<br/><br/>' +
              'Yours,<br/>' +
              'ETA Transition Helper'
      };

      break;
  }

  return template;
}


/**
 * Copies an existing file.
 *
 * @param {string} sourceId The ID of the existing file.
 * @param {string} outputName The output file name.
 *
 * @return {?File} A handler on the new file, or null if failed.
 */
function copyFile(sourceId, outputName) {
  var output = null;
  try {
    var source = DriveApp.getFileById(sourceId);
    var destination = DriveApp.getRootFolder();
    // Create the copy in the root folder.
    output = source.makeCopy(outputName, destination);
    output.setDescription('[' + getUniqueFileTag(sourceId) + '] ' + outputName);
    print('- Created a new file: ' + output.getUrl());
  } catch (e) {
    print('\n******* ERROR: Failed to copy file: ' + e + '*******');
  }

  return output;
}


/**
 * Get the email address of the user who owns this file.
 *
 * @param {File|Spreadsheet} file Selected file.
 *
 * @return {?string} The email address or `null` if no owner found.
 */
function getFileOwnerEmail(file) {
  var owner = file && file.getOwner();
  return owner && owner.getEmail();
}

/**
 * Validate the CID for an account in the format 123-456-7890.
 *
 * @param {string} cid The CID string to validate.
 *
 * @return {number} Return the CID in numerical value i.e. 1234567890
 * @throws {string}
 */
function validateCid(cid) {
  if (!cid) {
    throw 'Invalid CID: Empty or null : ' + cid;
  }
  if (!isString(cid)) {
    throw 'Invalid CID: Is not a string : ' + cid;
  }
  var cidArray = cid.split('-');
  if (cidArray.length !== 3) {
    throw 'Invalid CID: Incorrect separators : ' + cid;
  }
  if (cidArray[0].length !== 3) {
    throw 'Invalid CID: Incorrect part 1 : ' + cid;
  }
  if (cidArray[1].length !== 3) {
    throw 'Invalid CID: Incorrect part 2 : ' + cid;
  }
  if (cidArray[2].length !== 4) {
    throw 'Invalid CID: Incorrect part 3 : ' + cid;
  }
  var cidInt = parseInt(cidArray.join(''), 10);
  if (isNaN(cidInt)) {
    throw 'Invalid CID: isNaN : ' + cid;
  }
  return cidInt;
}

//////////////////////////////////////////////////////////////////////////
/////////////////////// SYNC SPREADSHEET HELPERS /////////////////////////
//////////////////////////////////////////////////////////////////////////


/**
 * Combines an array of Ads with their respective rows in the spreadsheet.
 *
 * @param {Array<string>} customerIds An array of customerIds to filter
 *                                    resulting Ads. Use empty array to not
 *                                    filter any Ads.
 *
 * @return {Array<Object>} Returns an array of objects, in which each object
 *                         has a reference to a given row in the spreadsheet,
 *                         and its respective report.
 */
function getReportWithSpreadsheetRows(customerIds) {
  if (!Array.isArray(customerIds)) {
    throw "customerIds is not a valid array";
  }

  // Open spreadsheet.
  var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);

  var rowObject = getContentRows(sheet,
                                  CONFIG.spreadsheet.firstContentRow,
                                  CONFIG.spreadsheet.nonEmptyColumnCheck,
                                  CONFIG.spreadsheet.columnNamesToIndices,
                                  true,
                                  CONFIG.spreadsheet.rowCache);
  var nonEmptyValidRows = rowObject.rows;
  CONFIG.spreadsheet.rowCache = rowObject.newRowCache;

  // Get rows containing content.
  var report = AdWordsApp.report(
      'SELECT ' + CONFIG.reportFields.join(',') + ' ' +
      'FROM     AD_PERFORMANCE_REPORT ' +
      'WHERE    AdType = "TEXT_AD" ' +
      '         AND Status = "ENABLED" ' +
      '         AND CombinedApprovalStatus != "DISAPPROVED" ' +
      'DURING   ' + CONFIG.duration, {
        apiVersion: CONFIG.apiVersion
      });

  var mostPerformingAds = getMostPerformingAds(report, CONFIG);
  var performingETA = getETAReports(CONFIG);

  // Swith Ads to a [id]=>Ad object structure.
  mostPerformingAds = createIndexableObjectFromKeys(mostPerformingAds, ['id']);
  performingETA = createIndexableObjectFromKeys(performingETA, ['id']);

  var rowsAndAds = [];

  nonEmptyValidRows.forEach(function(row) {
   // Skip if this row does not belong to any of the customerIds passed.
    if (customerIds.length > 0 &&
        customerIds.indexOf(row.get('customerId')) === -1) {
      return;
    }

    var sta = mostPerformingAds[row.get('staId')];
    var eta = performingETA[row.get('etaId')];

    if (!sta) {
      sta = null;
    }

    if (!eta) {
      eta = null;
    }

    rowsAndAds.push({
      row: row,
      sta: sta,
      eta: eta
    });
  });

  return rowsAndAds;
}


/**
 * Splices `spreadsheetRowsAndReport` according to `lastRowCheckedIndex`
 *
 * @param {Array<Object>} spreadsheetRowsAndReport
 * @param {number} lastRowCheckedIndex The index of last row processed in
 *                                     previous run.
 * @param {number} headerIndex The index of the row containing header.
 */
function spliceFromLastRowChecked(spreadsheetRowsAndReport, lastRowCheckedIndex,
                                  headerIndex) {
  if (isNaN(lastRowCheckedIndex)) {
    lastRowCheckedIndex = 0;
  }
  // Get Index stored in lastRowCheckedCell relative to header.
  lastRowCheckedIndex = lastRowCheckedIndex - headerIndex;

  // If lastRowCheckedIndex is between 0 and number of rows in spreadsheet
  // (not inclusive) then we can ignore all rows up to lastRowCheckedIndex.
  if (lastRowCheckedIndex > 0 &&
      lastRowCheckedIndex < spreadsheetRowsAndReport.length) {
    spreadsheetRowsAndReport.splice(0, lastRowCheckedIndex);
  }
}


/**
 * Determines whether 'etaObj' has the necessary fields and values
 * set for ETA creation.
 *
 * @param {Object} etaObj An ETA object with relevant ETA attributes for ETA
 *                        creation.
 *
 * @return {Object} An Ad object with 'ad' and 'errors' attributes. If no errors
 *                  were encountered then 'ad' will contain an Ad object of
 *                  newly created Ad, and 'errors' will be an empty array.
 *                  Otherwise, if an error was encountered then 'error' will be
 *                  an array of strings and 'ad' will be null.
 */
function isValidForETACreation(etaObj) {
  var returnObject = {
    ad: null,
    errors: []
  };

  /**
   * REQUIRED:
   * - finalURL
   * - headline1
   * - headline2
   * - description
   */

  if (!etaObj.finalURLs || etaObj.finalURLs.length < 1) {
    returnObject.errors.push('Failed to create ETA: finalUrl is missing.' +
                             ' [Required]');
  }

  if (etaObj.finalURLs && etaObj.finalURLs.length > 1) {
    returnObject.errors.push('Failed to create ETA: finalUrl supports only a' +
                             ' single URL');
  }

  if (!etaObj.headline1) {
    returnObject.errors.push('Failed to create ETA: headline1 is missing.' +
                             ' [Required]');
  }

  if (!etaObj.headline2) {
    returnObject.errors.push('Failed to create ETA: headline2 is missing.' +
                             ' [Required]');
  }

  if (!etaObj.description) {
    returnObject.errors.push('Failed to create ETA: description is missing.' +
                             ' [Required]');
  }

  /**
  * OPTIONAL:
  * - path1
  *   - path2 (Only if path1)
  * - mobileFinalURL
  * - trackingTemplate
  * - customParameters
  */

  if (etaObj.path2 && !etaObj.path1) {
    returnObject.errors.push('Failed to create ETA: path1 is missing. Setting' +
                             ' path2 requires path1 to be set');
  }

  if (etaObj.mobileFinalURLs && etaObj.mobileFinalURLs.length > 1) {
    returnObject.errors.push('Failed to create ETA: mobileFinalURL supports' +
                             ' only a single URL');
  }

  if (!isValidParamsObject(etaObj.customParameters,
                           ['string'],
                           false).success) {
    returnObject.errors.push('Failed to create ETA: customParameters is not' +
                             ' a valid parameters object');
  }

  return returnObject;
}


/**
 * Parses a `spreadsheetRow` for appropriate ETA attributes.
 *
 * @param {SpreadSheetRow} spreadsheetRow A row in the spreadsheet.
 *
 * @return {Object} Returns an object containing the necessary fields
 *                  for creating an ETA.
 * @throws {string}
 */
function parseETA(spreadsheetRow) {

  var customParametersStr = spreadsheetRow.getString('customParameters');
  var customParameters = null;
  if (customParametersStr) {
    try {
      customParameters = JSON.parse(customParametersStr);
    }
    catch (err) {
      throw 'Invalid customParemeters value in spreadsheet.';
    }
  }


  return {
    campaignId: spreadsheetRow.getNumber('campaignId'),
    adGroupId: spreadsheetRow.getNumber('adGroupId'),
    finalURLs: spreadsheetRow.getArray('finalUrl'),
    headline1: spreadsheetRow.getString('headline1'),
    headline2: spreadsheetRow.getString('headline2'),
    description: spreadsheetRow.getString('description'),
    path1: spreadsheetRow.getString('path1'),
    path2: spreadsheetRow.getString('path2'),
    mobileFinalURLs: spreadsheetRow.getArray('mobileFinalUrl'),
    trackingTemplate: spreadsheetRow.getString('trackingTemplate'),
    customParameters: customParameters
  };
}


/**
 * Creates a new ETA.
 *
 * @param {SpreadSheetRow} spreadsheetRow A row in the spreadsheet.
 *
 * @return {Object} An Ad object with 'ad' and 'errors' attributes. If no errors
 *                  were encountered then 'ad' will contain an Ad object of
 *                  newly created Ad, and 'errors' will be an empty array.
 *                  Otherwise, if an error was encountered then 'error' will be
 *                  an array of strings and 'ad' will be null.
 */
function createETA(spreadsheetRow) {
  var etaObj;
  // Retrieve ETA fields from spreadsheet row.
  try {
    etaObj = parseETA(spreadsheetRow);
  }
  catch (err) {
    return {
      ad: null,
      errors: ['Failed to create ETA: ' + err.message]
    };
  }

  var returnObject = isValidForETACreation(etaObj);

  // Retrieve parent Campaign to check if campaign exists.
  var campaignIterator = AdWordsApp.campaigns()
      .withIds([etaObj.campaignId])
      .get();

  if (!campaignIterator.hasNext()) {
    returnObject.errors.push('Unable to create ETA because Campaign parent' +
                             ' with id ' + etaObj.campaignId + ' no longer' +
                             ' exists.');
  }

  // Retrieve parent AdGroup to add new Ad and check if AdGroup exists.
  var adGroupIterator = AdWordsApp.adGroups()
      .withIds([etaObj.adGroupId])
      .get();

  if (!adGroupIterator.hasNext()) {
    returnObject.errors.push('Unable to create ETA because AdGroup parent' +
                             ' with id ' + etaObj.adGroupId + ' no longer' +
                             ' exists.');
  }

  var adGroup = adGroupIterator.next();

  if (returnObject.errors.length === 0) {
    try {
      var adBuilder = adGroup.newAd().expandedTextAdBuilder()
          .withHeadlinePart1(etaObj.headline1)
          .withHeadlinePart2(etaObj.headline2)
          .withDescription(etaObj.description)
          .withFinalUrl(etaObj.finalURLs[0]);

      if (etaObj.path1) {
        adBuilder.withPath1(etaObj.path1);
        if (etaObj.path2) {
          adBuilder.withPath2(etaObj.path2);
        }
      }

      if (etaObj.mobileFinalURLs && etaObj.mobileFinalURLs[0]) {
        adBuilder.withMobileFinalUrl(etaObj.mobileFinalURLs[0]);
      }

      if (etaObj.trackingTemplate) {
        adBuilder.withTrackingTemplate(etaObj.trackingTemplate);
      }

      if (etaObj.customParameters) {
        adBuilder.withCustomParameters(etaObj.customParameters);
      }

      var result = adBuilder.build();
      if (!result.isSuccessful()) {
        // No ad was created.
        var errors = result.getErrors();

        var message = 'Failed to create ETA with errors:';
        for (var i = 0; i < errors.length; i++) {
          message += '\n' + errors[i];
        }

        returnObject.errors.push(message);
      } else {
        var currentAccount = AdWordsApp.currentAccount();
        returnObject.ad = new Ad(null, null, result.getResult(),
                                 currentAccount);
      }
    }
    catch (err) {
      var message = 'Failed to create ETA: ' + err.message;
      returnObject.errors.push(message);
    }
  }

  return returnObject;
}


/**
 * Creates a label if it doesn't exist.
 *
 * @param {string} labelName The name of the label to create.
 *
 * @return {boolean} True if label was successfully created, or already exist.
 */
function createLabel(labelName) {
  if (isEmptyString(labelName)) {
    return false;
  }

  var trimmedLabelName = labelName.trim();

  // There can only be one label with `labelName`.
  var labelIterator = AdWordsApp.labels()
      .withCondition('Name = "' + trimmedLabelName + '"')
      .get();

  // If labelIterator has no value, then create label.
  if (!labelIterator.hasNext()) {
    try {
      AdWordsApp.createLabel(trimmedLabelName);
      return true;
    } catch (err) {
      print('Failed to create ' + trimmedLabelName);
      return false;
    }
  }

  // Label exist, no need to create it.
  return true;
}



/**
 * Used for tracking changes to an Ad.
 *
 * @param {?number} adId An id of an Ad.
 * @param {?number} adGroupId An id of an AdGroup.
 *
 * @constructor
 */
function AdChange(adId, adGroupId) {
  this.structure = {
    adId: null,
    adGroupId: null
  };

  this.structure.adId = null;
  this.structure.adGroupId = null;

  if (!isEmpty(adId) && !isEmpty(adGroupId)) {
    this.structure.adId = adId;
    this.structure.adGroupId = adGroupId;
  }

  this.structure.changes = [];
}


/**
 * Append an object representing change, if changeFunction returns a truthful
 * value. This serves as a wrapper to all Ad change events such as changing
 * status, labels, etc.
 *
 * @param {string} fieldName The name field being changed.
 * @param {string} oldValue
 * @param {string} newValue
 */
AdChange.prototype.trackChange = function(fieldName, oldValue, newValue) {
  if (oldValue === newValue || isArrayShallowEquals(oldValue, newValue)) {
    return;
  }

  this.structure.changes.push({
    fieldName: fieldName,
    oldValue: oldValue,
    newValue: newValue
  });
};


/**
 * This serves as a wrapper for Ad create changes.
 *
 * @param {number} adId Id of newly created Ad.
 * @param {number} adGroupId Id of AdGroup parent of newly created Ad.
 */
AdChange.prototype.trackCreate = function(adId, adGroupId) {
  this.structure.created = true;
  this.structure.adId = adId;
  this.structure.adGroupId = adGroupId;
};


/**
 * Retrieves a reference to changes. Changes are represented by the internal
 * `structure` property.
 *
 * @return {Object}
 */
AdChange.prototype.getChangeStruct = function() {
  return this.structure;
};

/**
 * Reset all changes that were tracked.
 */
AdChange.prototype.resetChanges = function() {
  this.structure.changes = [];
  this.structure.created = false;
};

Enviar comentários sobre…

Precisa de ajuda? Acesse nossa página de suporte.