ETA Transition Helper

Mit dem ETA Transition Helper haben Werbetreibende jetzt die Möglichkeit, die Erstellung erweiterter Textanzeigen aus vorhandenen Standardtextanzeigen im Bulk-Verfahren individuell anzupassen.

Das Tool umfasst zwei Hauptkomponenten:

  1. Ein AdWords-Skript, mit dem Sie Ihre Standardtextanzeigen in eine Google Tabelle kopieren und erweiterte Textanzeigen erstellen können, die Ihrem AdWords-Konto hinzugefügt werden
  2. Eine Google Tabelle mit Standardtextanzeigen, in der sich die entsprechenden erweiterten Textanzeigen konfigurieren lassen

In diesem Leitfaden werden die Einrichtung und die Anzeigenerstellung beschrieben.

Installation und Einrichtung

Wählen Sie vor der Einrichtung das AdWords-Konto mit den Standardtextanzeigen aus, anhand derer Ihre erweiterten Textanzeigen erstellt werden sollen. Sie können das Skript in einem Verwaltungs- oder einem Kundenkonto installieren.

Skript hinzufügen

  1. Melden Sie sich in dem gewünschten AdWords-Konto an.
  2. Wählen Sie links in der Navigationsleiste Bulk-Vorgänge aus und klicken Sie auf Skripts.

  3. Klicken Sie auf die rote Schaltfläche + SKRIPT.
  4. Entfernen Sie alles aus dem Textbereich und kopieren Sie stattdessen den Quellcode hinein, der sich unten auf dieser Seite befindet.
  5. Wenn Sie das Skript in einem Verwaltungskonto installieren, können Sie bestimmte untergeordnete Verwaltungskonten oder Kundenkonten auf die weiße Liste setzen, indem Sie die entsprechenden Kundennummern in das Skript eintragen. Die Felder selectByAccountIds und selectBySubMccId sind standardmäßig auskommentiert. Entfernen Sie bei Bedarf den Kommentarbefehl für ein oder beide Felder. Für die Ausrichtung auf eine Liste mit Kundennummern von Kundenkonten tragen Sie die Nummern in das Feld selectByAccountIds ein (siehe nachstehende Abbildung). Wenn Sie alle Kundenkonten in einem untergeordneten Verwaltungskonto verarbeiten möchten, haben Sie die Möglichkeit, dieses Verwaltungskonto im Feld selectBySubMccId anzugeben. Sie können aber nur einen Wert ins Feld selectBySubMccId eintragen. Falls Sie eine Liste mit Kundennummern von Kundenkonten haben wie etwa 123-456-7890 und 098-765-4321, aber das Skript nur auf die Kundenkonten in einem bestimmten untergeordneten Verwaltungskonto ausrichten möchten (z. B. wenn 123-456-7890 zu 765-432-1098 gehört), können Sie beide Felder verwenden (siehe nachstehende Abbildung). In diesem Fall wird nur Konto 123-456-7890 verarbeitet.

    Damit bei der Verarbeitung die Obergrenzen nicht überschritten werden, sollte das Skript nur in maximal 50 Konten gleichzeitig ausgeführt werden.

  6. Tragen Sie in das Feld Skript oberhalb des Textbereichs den Namen des Skripts ein. Der Skriptname sollte die Versionsnummer enthalten, z. B. "ETA Transition Helper Vx.y".
  7. Bei der erstmaligen Ausführung des Skripts wird eine Google Tabelle erstellt und an die dem AdWords-Konto zugeordnete E-Mail-Adresse gesendet. Wenn die Tabelle an eine andere Adresse gesendet werden soll, tragen Sie diese in das E-Mail-Feld oben im Skript ein (siehe obige Abbildung).
  8. Klicken Sie oben links im Textbereich auf die Schaltfläche Speichern, um das Skript zu speichern.
  9. Klicken Sie auf Jetzt autorisieren. Damit erlauben Sie, dass das Skript in Ihrem Namen ausgeführt wird. Über ein Pop-up-Fenster werden Sie anschließend dazu aufgefordert, zu gestatten, dass mit dem Skript Ihre AdWords-Kampagnen in Ihrem Namen verwaltet werden. Klicken Sie auf Zulassen.
  10. Nun klicken Sie auf die Schaltfläche Skript jetzt ausführen unterhalb des Textbereichs, um das Skript erstmalig auszuführen. Daraufhin öffnet sich ein Textfeld. Wählen Sie entweder VORSCHAU oder Ohne Vorschau ausführen aus.

    Bei der erstmaligen Ausführung des Skripts werden bei beiden Optionen die aktivierten Standardtextanzeigen mit der höchsten Leistung in Ihrem Konto in die generierte Tabelle eingefügt. Bei einem Konto mit rund 2.000 aktiven Standardtextanzeigen und numOfAds = 10 sollte der Export der Anzeigen in die Tabelle mithilfe des Skripts in weniger als zwei Minuten abgeschlossen sein. Im Feld numOfAds ist die maximale Anzahl an erweiterten Textanzeigen angegeben, die bei Ausführung des Skripts erstellt werden können. Die Standardeinstellung ist 800 und kann für Ihre jeweiligen Anwendungszwecke optimiert werden. Wir raten davon ab, für numOfAds eine höhere Zahl als den Standardwert von 800 festzulegen.

    Sie sehen eine Zusammenfassung aller Änderungen, die bei der erstmaligen Ausführung des Skripts vorgenommen wurden, wenn Sie auf der Seite mit der Skriptübersicht auf Details oder Protokolle klicken (siehe nachstehende Abbildung).

    Standardmäßig wird die Leistung anhand der Klickrate und der Impressionen bestimmt. Der Leistungsmaßstab und die Anzahl der in die Tabelle importierten Anzeigen lassen sich im Skript konfigurieren.

  11. Schließen Sie das Skript nicht. Wir kommen in den folgenden Schritten darauf zurück. Künftig kann das Skript direkt auf dem Startbildschirm Bulk-Vorgänge > Skripts ausgeführt werden.

Tabelle vorbereiten

Bei der erstmaligen Ausführung des Skripts wird die Tabellenvorlage erstellt und der entsprechende Link an die eingetragene E-Mail-Adresse gesendet. Öffnen Sie die Tabelle.

So wird der Zugriff auf die Tabelle für andere Google-Konten freigegeben:

  1. Klicken Sie auf die Schaltfläche Share (Freigeben) oben rechts. Daraufhin öffnet sich ein Pop-up-Fenster, in dem sich die Freigabeberechtigungen konfigurieren lassen.
  2. Tragen Sie die dem AdWords-Konto des Nutzers zugeordnete E-Mail-Adresse ein.
  3. Wählen Sie im Drop-down-Menü die Option Can edit (Darf bearbeiten) aus, um dem mit dem AdWords-Konto verknüpften Google-Konto Bearbeitungszugriff auf die Tabelle zu geben. Klicken Sie auf Send (Senden).

Erweiterte Textanzeigen erstellen

Prima – jetzt können Sie erweiterte Textanzeigen erstellen.

Wenden wir uns nun kurz der Tabelle zu. Schreibgeschützte Felder haben graue Spaltenüberschriften, während die Spaltenüberschriften der bearbeitbaren Felder blau oder orange sind. Es gibt drei Hauptgruppen von Spalten:

  1. Die erste Gruppe enthält Informationen zu den vorhandenen Standardtextanzeigen, die importiert wurden. Die meisten Felder sind schreibgeschützt, also grau. Das einzige bearbeitbare Feld ist der STA Status (Status der Standardtextanzeige). Es ist blau. Bei Änderung des STA Status wird die Standardtextanzeige bei der nächsten Ausführung des Skripts aktualisiert. Nichtübereinstimmungen zwischen angezeigter und finaler URL werden markiert, denn diese Felder müssen bei erweiterten Textanzeigen identisch sein.
  2. Die zweite Gruppe von Spalten ist für die zu erstellenden erweiterten Textanzeigen bestimmt. Headline 1 (Anzeigentitel 1) und Description (Beschreibung) sind anhand der entsprechenden Standardtextanzeige bereits vorausgefüllt. Diese Gruppe umfasst ebenfalls schreibgeschützte graue Felder und blaue Eingabefelder.
  3. Der dritte Abschnitt ist für das Flag Ready To Upload? (Bereit zum Hochladen?) reserviert, mit dem angegeben wird, dass bei der nächsten Ausführung des Skripts eine erweiterte Textanzeige erstellt werden kann.

Wenn Sie den Status Ihrer vorhandenen Standardtextanzeigen ändern möchten, passen Sie einfach den Wert in der Spalte STA Status (Status der Standardtextanzeige) entsprechend an:

Erstellen wir nun erweiterte Textanzeigen.

  1. Hierbei müssen Sie zumindest einen zweiten Anzeigentitel hinzufügen. Sie können aber auch weitere Felder festlegen, z. B. ETA Status (Status der erweiterten Textanzeige), Mobile Final URL (Finale URL für Mobilgeräte) oder Path 1 (Pfad 1). Außerdem haben Sie die Möglichkeit, die vorausgefüllten Felder zu ändern. Es gibt Validierungsregeln, um sicherzustellen, dass bei den Feldern die entsprechenden Zeichenbeschränkungen eingehalten werden, sowie Spalten, in denen angegeben ist, wie viele Zeichen hinzugefügt werden können. In diesem AdWords-Hilfeartikel finden Sie außerdem Tipps und Tricks zum Erstellen effektiver Textanzeigen.
  2. Bei der Bearbeitung der Felder für erweiterte Textanzeigen können Sie eine Vorschau der Anzeige in der aktiven Zelle aufrufen. Wählen Sie hierzu in der ersten Tabellenzeile Click to Preview (Für Vorschau klicken) aus. Die angezeigte URL wird aus der Standardtextanzeige übernommen und nicht direkt anhand der finalen URL ermittelt. Deshalb kann sie bei der Auslieferung abweichen.
  3. Wenn Sie mit dem Aussehen der erweiterten Textanzeigen zufrieden sind, setzen Sie die Spalte Ready To Upload? (Bereit zum Hochladen?) auf Yes (Ja). Dadurch wird bei Ausführung des Skripts eine erweiterte Textanzeige erstellt und der Status der Standardtextanzeige aktualisiert.
  4. Wenn Sie das Skript zum zweiten Mal ausführen, werden erweiterte Textanzeigen erstellt. Vor der Ausführung eines Skripts sollten Sie sich immer eine Vorschau ansehen. Klicken Sie hierzu auf der Seite mit der Skriptübersicht auf Bearbeiten und anschließend auf die rote Schaltfläche VORSCHAU. So sehen Sie, welche Änderungen bei Ausführung des Skripts vorgenommen worden wären. Wenn Sie mit den Änderungen einverstanden sind, klicken Sie auf Skript jetzt ausführen.
  5. Nach der Ausführung des Skripts haben die Standardtextanzeige und die erweiterte Textanzeige das Label eta-upgrade (Umstellung auf erweiterte Textanzeige). Unerwünschte Änderungen können Sie entsprechend der Anleitung in diesem AdWords-Hilfeartikel rückgängig machen.

Quellcode

// @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;
};

Feedback geben zu...

AdWords scripts
AdWords scripts