├── .gitignore
├── tutorials
└── images
│ ├── bk-placement1.png
│ └── bk-placement2.png
├── appsscript.json
├── Bulkdozer.html
├── CONTRIBUTING.md
├── BulkdozerQA.html
├── Cache.js
├── Terms_and_Conditions.md
├── logger.html
├── IdStore.js
├── Code.js
├── Fields.js
├── FeedProvider.js
├── runner.html
├── CampaignManagerDAO.js
├── SidebarController.js
├── LICENSE
├── QA.js
├── SheetDAO.js
├── README.md
└── sidebar.html
/.gitignore:
--------------------------------------------------------------------------------
1 | clientSpecific*.js
2 | .clasp.json
3 |
--------------------------------------------------------------------------------
/tutorials/images/bk-placement1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google/bulkdozer/HEAD/tutorials/images/bk-placement1.png
--------------------------------------------------------------------------------
/tutorials/images/bk-placement2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google/bulkdozer/HEAD/tutorials/images/bk-placement2.png
--------------------------------------------------------------------------------
/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/Mexico_City",
3 | "dependencies": {
4 | "enabledAdvancedServices": [{
5 | "userSymbol": "DoubleClickCampaigns",
6 | "serviceId": "dfareporting",
7 | "version": "v3.5"
8 | }]
9 | },
10 | "exceptionLogging": "STACKDRIVER",
11 | "oauthScopes": ["https://www.googleapis.com/auth/script.container.ui", "https://www.googleapis.com/auth/dfareporting", "https://www.googleapis.com/auth/dfatrafficking", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.external_request"],
12 | "runtimeVersion": "V8"
13 | }
14 |
--------------------------------------------------------------------------------
/Bulkdozer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Bulkdozer
4 |
5 | Notices
6 |
7 |
8 | This version of Bulkdozer is experimental and should not used against live campaigns or campaigns that will be launched in the future. If you decide to do so you assume all risk and responsibility of such usage.
9 |
10 |
11 | By using any of the functionality below you agree with the Bulkdozer Terms and Conditions .
12 |
13 |
14 | Closing this sidebar mid process will cause the process to stop shortly thereafter, however the feed will be in an inconsistent state and should be fully cleared or loaded from CM before any subsequent operations are executed.
15 |
16 |
17 | Status: Ready
18 |
19 |
20 | Clear Feed
21 | Load from CM
22 | Push to CM
23 | != include('runner'); ?>
24 | != include('sidebar'); ?>
25 | != include('logger'); ?>
26 |
27 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement (CLA). You (or your employer) retain the copyright to your
10 | contribution; this simply gives us permission to use and redistribute your
11 | contributions as part of the project. Head over to
12 | to see your current agreements on file or
13 | to sign a new one.
14 |
15 | You generally only need to submit a CLA once, so if you've already submitted one
16 | (even if it was for a different project), you probably don't need to do it
17 | again.
18 |
19 | ## Code reviews
20 |
21 | All submissions, including submissions by project members, require review. We
22 | use GitHub pull requests for this purpose. Consult
23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
24 | information on using pull requests.
25 |
26 | ## Community Guidelines
27 |
28 | This project follows
29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/).
30 |
31 |
--------------------------------------------------------------------------------
/BulkdozerQA.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Bulkdozer
4 |
5 | Notices
6 |
7 |
8 | This version of Bulkdozer is experimental and should not used against live campaigns or campaigns that will be launched in the future. If you decide to do so you assume all risk and responsibility of such usage.
9 |
10 |
11 | By using any of the functionality below you agree with the Bulkdozer Terms and Conditions .
12 |
13 |
14 | Closing this sidebar mid process will cause the process to stop shortly thereafter, however the feed will be in an inconsistent state and should be fully cleared or loaded from CM before any subsequent operations are executed.
15 |
16 |
17 | Status: Ready
18 |
19 |
20 | Clear Feed
21 | Load from CM
22 |
23 | != include('runner'); ?>
24 | != include('sidebar'); ?>
25 | != include('logger'); ?>
26 |
27 |
--------------------------------------------------------------------------------
/Cache.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | // Use this to use in memory cache, it is not shared across jobs, and it may
23 | // exceed the apps script memory limit, but it is significantly faster
24 | var DEFAULT_CACHE_MODE = 'MEMORY';
25 |
26 | // Use this to use the Apps Script cache service, it is shared across jobs and
27 | // it automatically evicts items to avoid exceeding limits, but it is slower
28 | //var DEFAULT_CACHE_MODE = 'SERVICE';
29 |
30 | /** Singleton instance of cache */
31 | var cache = null;
32 |
33 | /** In memory cache */
34 | var InMemoryCache = function() {
35 | cache = {};
36 |
37 | /** Gets an item from the cache
38 | *
39 | * params:
40 | * key: The key to the item in the cache
41 | *
42 | * returns: The item identified by the key in the cache, or null if not found
43 | */
44 | this.get = function(key) {
45 | return cache[key];
46 | }
47 |
48 | this.put = function(key, item) {
49 | cache[key] = item;
50 | }
51 | }
52 |
53 | function getCache(mode) {
54 | mode = mode || DEFAULT_CACHE_MODE;
55 | if(!cache) {
56 | if(mode == 'SERVICE') {
57 | cache = CacheService.getUserCache();
58 | } else if(mode == 'MEMORY') {
59 | cache = new InMemoryCache();
60 | }
61 | }
62 |
63 | return cache;
64 | }
65 |
--------------------------------------------------------------------------------
/Terms_and_Conditions.md:
--------------------------------------------------------------------------------
1 | # Terms & Conditions
2 |
3 |
4 | Bulkdozer is an experimental application, not an officially supported
5 | Google product. It is provided without any guarantees, warranty, or
6 | liability to Google. By using it the user authorizes it to make changes
7 | to Campaign Manager on the user's behalf, and you assume all risks and
8 | responsibilities for those changes.
9 |
10 | Bulkdozer should be considered a reference implementation of how to
11 | interact with the Campaign Manager API to make bulk edits. Before using
12 | it, an engineer in the user's organization must review the code and
13 | ensure the company is comfortable with how it works. The moment a user
14 | decides to use it for updates on live campaigns, or campaigns that will
15 | become live, the user assumes full responsibility over intended and
16 | unintended changes made by it.
17 |
18 | Google may host Bulkdozer for a client on Google’s infrastructure for a
19 | short period of time while the client tests the tool and works internally
20 | to secure resources to support Bulkdozer long term, this hosted version
21 | is intended for testing and demo purposes only, and must not be used to
22 | make changes to live campaigns or campaigns that will become live. These
23 | terms apply equally to Bulkdozer instances hosted by Google or the client.
24 |
25 | It’s also important to note that Google doesn’t guarantee or promise any
26 | particular results from implementing the changes you authorize or that
27 | will be carried out by Bulkdozer. You will be responsible for any impact
28 | these changes have on your account, including impact on your campaign
29 | performance or spending. Be sure to monitor your account regularly so you
30 | understand what’s happening and can make campaign adjustments as necessary.
31 |
32 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS “AS IS” AND ANY
33 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
34 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
35 | DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR
36 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
37 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
38 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
39 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
40 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
41 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
42 | POSSIBILITY OF SUCH DAMAGE.
43 |
44 | ----
45 | © 2022 Google Inc. - Apache License, Version 2.0
46 |
47 | [Back to main page](https://github.com/google/bulkdozer)
48 |
--------------------------------------------------------------------------------
/logger.html:
--------------------------------------------------------------------------------
1 |
116 |
--------------------------------------------------------------------------------
/IdStore.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | /**
23 | * Handles the id store which is used for translating ext ids into concrete ids
24 | * across multiple bulkdozer executions
25 | */
26 | var IdStore = function(sheetDAO) {
27 |
28 | // Maximun number of characters to be written per cell
29 | var CELL_CHAR_LIMIT = 50000;
30 |
31 | // Dictionary used for mapping the ids
32 | var store;
33 |
34 | /**
35 | * Translates an id, returns null if the id isn't found
36 | * params:
37 | * tabName: name of the sheet for which to translate ids
38 | * id: id to translate
39 | *
40 | * returns: translated id, if ext returns concrete id, if concrete id returns
41 | * ext
42 | */
43 | this.translate = function(tabName, id) {
44 | if(store[tabName] && store[tabName][id]) {
45 | return store[tabName][id];
46 | }
47 |
48 | return null;
49 | }
50 |
51 | /**
52 | * Adds an id to the store
53 | *
54 | * params:
55 | * tabName: name of the sheet tab for the id
56 | * id: id to map
57 | * extId: ext id to map
58 | */
59 | this.addId = function(tabName, id, extId) {
60 | if(!store[tabName]) {
61 | store[tabName] = {}
62 | }
63 |
64 | store[tabName][id] = extId;
65 | store[tabName][extId] = id;
66 | }
67 |
68 | /**
69 | * Saves store to the sheet
70 | */
71 | this.store = function() {
72 | var raw = JSON.stringify(store);
73 | var values = [];
74 |
75 | sheetDAO.clear('Store', 'A1:Z1');
76 |
77 | for(var i = 0; i < raw.length; i += CELL_CHAR_LIMIT) {
78 | values.push(raw.substring(i, i + CELL_CHAR_LIMIT));
79 | }
80 |
81 | if(values.length == 0) {
82 | values.push('{}');
83 | }
84 |
85 | sheetDAO.setValues('Store', 'A1:' + sheetDAO.columns[values.length - 1] + '1', [values]);
86 | }
87 |
88 | /**
89 | * Loads the store from the sheet
90 | */
91 | this.load = function() {
92 | var values = sheetDAO.getValues('Store', 'A1:Z1');
93 | var raw = '';
94 |
95 | for(var i = 0; i < values[0].length && values[0][i]; i++) {
96 | raw += values[0][i];
97 | }
98 |
99 | if(!raw) {
100 | raw = '{}';
101 | }
102 |
103 | store = JSON.parse(raw);
104 | }
105 |
106 | /**
107 | * Clears the store
108 | */
109 | this.clear = function() {
110 | store = {};
111 | this.store();
112 | }
113 |
114 | /**
115 | * Initializes the store with existing data
116 | *
117 | * params:
118 | * data: The data to use to initialize the store
119 | */
120 | this.initialize = function(data) {
121 | store = data;
122 | }
123 |
124 | /**
125 | * Returns internal representation of this object for persistence
126 | */
127 | this.getData = function() {
128 | return store;
129 | }
130 | }
131 |
132 | // Singleton implementation for the idStore
133 | var idStore;
134 | function getIdStore() {
135 | if(!idStore) {
136 | idStore = new IdStore(new SheetDAO());
137 | }
138 |
139 | return idStore;
140 | }
141 |
--------------------------------------------------------------------------------
/Code.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | const DEFAULT_SLEEP = 8 * 1000;
23 | const DEFAULT_RETRIES = 4;
24 |
25 | // Declare context object so we can call functions by name, this enables
26 | // configuration based functionality, so the tool behaves according to settings
27 | // defined in the sheet.
28 | var context = this;
29 |
30 | /**
31 | * onOpen handler to display Bulkdozer menu
32 | */
33 | function onOpen(e) {
34 | SpreadsheetApp.getUi()
35 | .createMenu('Bulkdozer')
36 | .addItem('Open', 'bulkdozer')
37 | .addToUi();
38 | }
39 |
40 | /**
41 | * Bulkdozer menu that displays the sidebar
42 | */
43 | function bulkdozer() {
44 | var html = null;
45 | if(getSheetDAO().isQA()) {
46 | var html = HtmlService.createTemplateFromFile('BulkdozerQA')
47 | .evaluate()
48 | .setTitle('Bulkdozer');
49 | } else {
50 | var html = HtmlService.createTemplateFromFile('Bulkdozer')
51 | .evaluate()
52 | .setTitle('Bulkdozer');
53 | }
54 |
55 | SpreadsheetApp.getUi().showSidebar(html);
56 | }
57 |
58 | /**
59 | * For each implementation, invokes func(index, item) for each item in the list
60 | * list: Array of items to iterate
61 | * func: function that takes integer index and item as parameters
62 | */
63 | function forEach(items, func) {
64 | if(Array.isArray(items)) {
65 | for(var i = 0; i < items.length; i++) {
66 | if(func) {
67 | func(i, items[i]);
68 | }
69 | }
70 | }
71 | }
72 |
73 | /**
74 | * Given an error raised by an API call, determines if the error has a chance
75 | * of succeeding if it is retried. A good example of a "retriable" error is
76 | * rate limit, in which case waiting for a few seconds and trying again might
77 | * refresh the quota and allow the transaction to go through. This method is
78 | * desidned to be used by the _retry function.
79 | *
80 | * params:
81 | * error: error to verify
82 | *
83 | * returns: true if the error is "retriable", false otherwise
84 | */
85 | function isRetriableError(error) {
86 | var retriableErroMessages = [
87 | 'failed while accessing document with id',
88 | 'internal error',
89 | 'user rate limit exceeded',
90 | 'quota exceeded',
91 | '502',
92 | 'try again later',
93 | 'failed while accessing document',
94 | 'empty response'
95 | ];
96 |
97 | var message = null;
98 | var result = false;
99 |
100 | if(error) {
101 | if(typeof(error) == 'string') {
102 | message = error;
103 | } else if(error.message) {
104 | message = error.message;
105 | } else if(error.details && error.details.message) {
106 | message = error.details.message;
107 | }
108 |
109 | message = message ? message.toLowerCase() : null;
110 | }
111 |
112 | if(message) {
113 | retriableErroMessages.forEach(function(retriableMessage) {
114 | if(message.indexOf(retriableMessage) != -1) {
115 | result = true;
116 | }
117 | });
118 | }
119 |
120 | return result;
121 | }
122 |
123 | /**
124 | * Wrapper to add retries and exponential backoff on API calls
125 | *
126 | * params:
127 | * fn: function to be invoked, the return of this funcntion is returned
128 | * retries: Number of ties to retry
129 | * sleep: How many milliseconds to sleep, it will be doubled at each retry.
130 | *
131 | * returns: The return of fn
132 | */
133 | function _retry(fn, retries, sleep) {
134 | try {
135 | var result = fn();
136 | return result;
137 | } catch(error) {
138 | if(isRetriableError(error) && retries > 0) {
139 | Utilities.sleep(sleep);
140 | return _retry(fn, retries - 1, sleep * 2);
141 | } else {
142 | throw error;
143 | }
144 | }
145 | }
146 |
147 |
--------------------------------------------------------------------------------
/Fields.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | var fields = {
23 | // Common Fields
24 | 'archived': 'Archived',
25 | 'activeStatus': 'Active Status',
26 |
27 | // Advertiser
28 | 'advertiserId': 'Advertiser ID',
29 | 'advertiserName': 'Advertiser Name',
30 |
31 | // Site
32 | 'siteId': 'Site ID',
33 | 'siteName': 'Site Name',
34 |
35 | // Landing Page
36 | 'landingPageId': 'Landing Page ID',
37 | 'landingPageName': 'Landing Page Name',
38 | 'landingPageUrl': 'Landing Page URL',
39 |
40 | // Campaign
41 | 'campaignId': 'Campaign ID',
42 | 'campaignName': 'Campaign Name',
43 | 'campaignStartDate': 'Campaign Start Date',
44 | 'campaignEndDate': 'Campaign End Date',
45 | 'billingInvoiceCode': 'Billing Invoice Code',
46 |
47 | // Event Tag
48 | 'eventTagId': 'Event Tag ID',
49 | 'eventTagName': 'Event Tag Name',
50 | 'eventTagStatus': 'Event Tag Status',
51 | 'enableByDefault': 'Enable By Default',
52 | 'eventTagType': 'Event Tag Type',
53 | 'eventTagUrl': 'Event Tag URL',
54 | 'enabled': 'Enabled',
55 |
56 | // Placement Group
57 | 'placementGroupId': 'Placement Group ID',
58 | 'placementGroupName': 'Placement Group Name',
59 | 'placementGroupType': 'Placement Group Type',
60 | 'placementGroupStartDate': 'Placement Group Start Date',
61 | 'placementGroupEndDate': 'Placement Group End Date',
62 | 'placementGroupPricingType': 'Pricing Type',
63 |
64 | // Placement
65 | 'placementId': 'Placement ID',
66 | 'placementName': 'Placement Name',
67 | 'activeView': 'Active View and Verification',
68 | 'adBlocking': 'Ad Blocking',
69 | 'placementStartDate': 'Placement Start Date',
70 | 'placementEndDate': 'Placement End Date',
71 | 'placementType': 'Type',
72 | 'placementPricingScheduleCostStructure': 'Pricing Schedule Cost Structure',
73 | 'pricingScheduleTestingStart': 'Pricing Schedule Testing Starts',
74 | 'placementSkippable': 'Skippable',
75 | 'placementSkipOffsetSeconds': 'Skip Offset Seconds',
76 | 'placementSkipOffsetPercentage': 'Skip Offset Percentage',
77 | 'placementProgressOffsetSeconds': 'Progress Offset Seconds',
78 | 'placementProgressOffsetPercentage': 'Progress Offset Percentage',
79 | 'placementAdditionalKeyValues': 'Additional Key Values',
80 | 'placementAssetSize': 'Asset Size',
81 | 'placementStatusUnknown': 'PLACEMENT_STATUS_UNKNOWN',
82 | 'placementStatusActive': 'PLACEMENT_STATUS_ACTIVE',
83 | 'placementStatusInactive': 'PLACEMENT_STATUS_INACTIVE',
84 | 'placementStatusArchived': 'PLACEMENT_STATUS_ARCHIVED',
85 | 'placementStatusPermanentlyArchived': 'PLACEMENT_STATUS_PERMANENTLY_ARCHIVED',
86 |
87 | // Placement Pricing Schedule
88 | 'pricingPeriodStart': 'Pricing Period Start Date',
89 | 'pricingPeriodEnd': 'Pricing Period End Date',
90 | 'pricingPeriodRate': 'Pricing Period Rate',
91 | 'pricingPeriodUnits': 'Pricing Period Units',
92 |
93 | // Creative
94 | 'creativeId': 'Creative ID',
95 | 'creativeName': 'Creative Name',
96 | 'creativeType': 'Creative Type',
97 | 'thirdPartyUrlType': '3P URL Type',
98 | 'thirdPartyUrl': '3P URL',
99 | 'creativeActive': 'Creative Active',
100 | 'redirectUrl': 'Redirect URL',
101 | 'creativeSize': 'Creative Size',
102 | 'htmlCode': 'HTML',
103 |
104 | // Ad
105 | 'creativeRotation': 'Creative Rotation',
106 | 'creativeRotationWeight': 'Creative Rotation Weight',
107 | 'creativeRotationSequence': 'Creative Rotation Sequence',
108 | 'adPriority': 'Ad Priority',
109 | 'adId': 'Ad ID',
110 | 'adName': 'Ad Name',
111 | 'adStartDate': 'Ad Start Date',
112 | 'adEndDate': 'Ad End Date',
113 | 'adActive': 'Ad Active',
114 | 'adArchived': 'Ad Archived',
115 | 'adCreativeAssignmentStartDate': 'Start Date',
116 | 'adCreativeAssignmentEndDate': 'End Date',
117 | 'hardCutoff': 'Hard Cutoff',
118 | 'adType': 'Ad Type',
119 | 'customClickThroughUrl': 'Custom URL',
120 |
121 | // Dynamic Targeting Keys
122 | 'dynamicTargetingKeyName': 'Key Name',
123 | 'dynamicTargetingKeyObjectType': 'Object Type',
124 | 'dynamicTargetingKeyObjectID': 'Object ID',
125 |
126 | };
127 |
--------------------------------------------------------------------------------
/FeedProvider.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | /**
23 | * Feed provider reads and writes feed items to the sheet, it implements and
24 | * iterator like interface to simplify the process of looping through and
25 | * updating feed items
26 | *
27 | * params:
28 | * tab: string | list of strings: Name of the tab to read and write the feed.
29 | * It can be a string representing the name of the tab, or a list of strings to
30 | * be used in order of priority, i.e. if a list the first tab that exists will
31 | * be used, this is helpful in case of falling back to a different tab, for
32 | * instance the QA tab.
33 | *
34 | * keys: string | list of strings: Name of the column or columns that should be
35 | * used to uniquely identify a row in the feed. This is used to dedup rows for
36 | * a given entity, which is useful when pushing data from the QA tab to CM. If
37 | * no key is defined, every row is returned and no deduping happens.
38 | */
39 | var FeedProvider = function(tabName, keys) {
40 |
41 | var sheetDAO = getSheetDAO();
42 | var _index = -1;
43 | var _feed = null;
44 |
45 | if(keys && !Array.isArray(keys)) {
46 | keys = [keys];
47 | }
48 |
49 | /**
50 | * Based on the key fields defined in the keys constructor parameter, returns
51 | * the key for a given feed item.
52 | *
53 | * params:
54 | * feedItem: feed item to genetare the key for.
55 | */
56 | function generateKey(feedItem) {
57 | var result = [];
58 |
59 | forEach(keys, function(index, key) {
60 | if(feedItem[key]) {
61 | result.push(feedItem[key]);
62 | }
63 | });
64 |
65 | return result.join('|');
66 | }
67 |
68 | /**
69 | * Applies changes to all duplicated items and re-dups feed so it can be
70 | * written back to the sheet
71 | *
72 | * returns: feed ready to be written back to the sheet
73 | */
74 | function applyChanges() {
75 | var result = [];
76 |
77 | if(!keys) {
78 | return _feed;
79 | } else {
80 | forEach(_feed, function(index, feedItem) {
81 | var original = feedItem._original;
82 | var changes = {};
83 |
84 | result.push(feedItem);
85 |
86 | forEach(Object.getOwnPropertyNames(feedItem), function(index, propertyName) {
87 | if(propertyName[0] != "_") {
88 | if(original[propertyName] !== feedItem[propertyName]) {
89 | changes[propertyName] = feedItem[propertyName];
90 | }
91 | }
92 | });
93 |
94 | forEach(feedItem._dups, function(index, dupFeedItem) {
95 | forEach(Object.getOwnPropertyNames(changes), function(index, propertyName) {
96 | dupFeedItem[propertyName] = changes[propertyName];
97 | });
98 |
99 | result.push(dupFeedItem);
100 | });
101 | });
102 | }
103 |
104 | return result;
105 | }
106 |
107 | /**
108 | * Loads feed from the sheet
109 | */
110 | this.load = function() {
111 | if(tabName) {
112 | this.setFeed(sheetDAO.sheetToDict(tabName));
113 | }
114 |
115 | return this;
116 | }
117 |
118 | /**
119 | * Is the feed empty?
120 | *
121 | * returns: true if the feed is empty, false otherwise
122 | */
123 | this.isEmpty = function() {
124 | return _feed && _feed.length > 0;
125 | }
126 |
127 | /**
128 | * Resets the feedProvider to the first item in the feed
129 | */
130 | this.reset = function() {
131 | _index = -1;
132 |
133 | return this;
134 | }
135 |
136 | /**
137 | * Sets a feed to this provider
138 | */
139 | this.setFeed = function(feed) {
140 | if(!keys) {
141 | _feed = feed;
142 | } else {
143 | _feed = [];
144 | feedMap = {};
145 |
146 | forEach(feed, function(index, feedItem) {
147 | if(feedItem._deduped) {
148 | _feed.push(feedItem);
149 | } else {
150 | var key = generateKey(feedItem) || 'unkeyed';
151 |
152 | feedItem._deduped = true;
153 |
154 | if(key === 'unkeyed') {
155 | feedItem.unkeyed = true;
156 | }
157 |
158 | if(feedMap[key]) {
159 | feedMap[key]._dups.push(feedItem);
160 | } else {
161 | feedItem._original = JSON.parse(JSON.stringify(feedItem));
162 | feedItem._dups = [];
163 | feedMap[key] = feedItem;
164 | _feed.push(feedItem);
165 | }
166 | }
167 | });
168 | }
169 |
170 | return this;
171 | }
172 |
173 | /**
174 | * Returns the next feed item, or null if no more items are available
175 | *
176 | * returns: feed item
177 | */
178 | this.next = function() {
179 | if(_feed) {
180 | _index++;
181 |
182 | /*
183 | if(keys) {
184 | for(;_index < _feed.length && !generateKey(_feed[_index]); _index++);
185 | }
186 | */
187 |
188 | if(_index < _feed.length) {
189 | return _feed[_index];
190 | }
191 | }
192 |
193 | return null;
194 | }
195 |
196 | /**
197 | * Writes feed back to the sheet
198 | */
199 | this.save = function() {
200 | if(_feed && tabName) {
201 | var rawFeed = applyChanges();
202 | sheetDAO.clear(tabName, "A2:AZ");
203 | sheetDAO.dictToSheet(tabName, rawFeed);
204 | this.setFeed(rawFeed);
205 | }
206 |
207 | return this;
208 | }
209 |
210 | }
211 |
--------------------------------------------------------------------------------
/runner.html:
--------------------------------------------------------------------------------
1 |
205 |
--------------------------------------------------------------------------------
/CampaignManagerDAO.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | /**
23 | * Object responsible for all interactions with the Campaign Manager API
24 | *
25 | * params:
26 | * profileId: The profile id to be used to access the Campaign Manager API
27 | */
28 | var CampaignManagerDAO = function(profileId) {
29 | // PRIVATE FIELDS
30 | // 8 seconds default sleep, 4 retries, waiting twice as long for each retry.
31 | // This means that the total wait time in the worst case scenario is 248
32 | // seconds, or just over 4 minutes, ensuring the retries will be exhausted
33 | // within the 5 minutes runtime
34 | const CACHE_EXPIRATION = 21600;
35 |
36 | var cache = getCache();
37 | var listCache = {};
38 | var userProperties = PropertiesService.getUserProperties();
39 | var jobId = userProperties.getProperty('jobId');
40 |
41 | function getCacheKey(entity, id) {
42 | return entity + '|' + id + '|' + jobId;
43 | }
44 |
45 | // PRIVATE METHODS
46 | function getListCacheKey(entity, listName, options) {
47 | var result = entity;
48 |
49 | if(options) {
50 | result += JSON.stringify(options);
51 | }
52 |
53 | return result;
54 | }
55 |
56 | /**
57 | * Fetches items from Campaign Manager based on the provided parameters and it
58 | * handles pagination by fetching all pages. This uses the list method of the
59 | * API
60 | *
61 | * params:
62 | * entity: The name of the Campaign Manager API entity
63 | * listName: Name of the list returned by the API
64 | * options: Additional options to be passed to the list API call
65 | *
66 | * returns: Array with all items that match the specified search
67 | */
68 | function fetchAll(entity, listName, options) {
69 | var response = _retry(function() {
70 | return DoubleClickCampaigns[entity].list(profileId, options)
71 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
72 | var result = [];
73 |
74 | while (response && response[listName] && response[listName].length > 0) {
75 | result = result.concat(response[listName]);
76 |
77 | if (response.nextPageToken) {
78 | // Change due to bug: 276008048
79 | // as long as we provide filters in later API list request along with the pageToken, It works fine in v4.
80 | requestOptions = {'pageToken': response.nextPageToken, ...options};
81 | response = _retry(function() {
82 | return DoubleClickCampaigns[entity].list(profileId, requestOptions);
83 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
84 | } else {
85 | response = null;
86 | }
87 | }
88 |
89 | return result;
90 | }
91 |
92 | // PUBLIC METHODS
93 |
94 | /**
95 | * Sets the cache object to be used by this instance. This allows for
96 | * controlling which type of cache to use, e.g. in memory or cache service.
97 | *
98 | * params:
99 | * newCache: Object to be used for caching
100 | */
101 | this.setCache = function(newCache) {
102 | cache = newCache;
103 | }
104 |
105 | /**
106 | * Fetches a list of items from CM
107 | *
108 | * params:
109 | * entity: The name of the Campagign Manager API entity
110 | * listName: The name of the list returned by the API
111 | * options: Any additional option that should be passed to the list call
112 | *
113 | * returns: List of items returned from the API
114 | */
115 | this.list = function(entity, listName, options) {
116 | var result = [];
117 | var cacheKey = getListCacheKey(entity, listName, options);
118 |
119 | if (listCache[cacheKey]) {
120 | result = listCache[cacheKey];
121 | } else {
122 | console.log('Invoking API to list ' + entity);
123 |
124 | // Check for ids present in the search options
125 | // to create batches if length > 500
126 | if(!options['ids']) {
127 | result = fetchAll(entity, listName, options);
128 | } else {
129 | let batches = createBatches(options['ids']);
130 | // Make API calls in batches to avoid the 500 ids limit error
131 | for(let i = 0; i < batches.length; i++) {
132 | options['ids'] = batches[i];
133 | result = result.concat(fetchAll(entity, listName, options));
134 | }
135 | }
136 |
137 | listCache[cacheKey] = result;
138 |
139 | for (var i = 0; i < result.length; i++) {
140 | var item = result[i];
141 |
142 | if (JSON.stringify(item).length < 100000 && item['id']) {
143 | cache.put(getCacheKey(entity, item['id']), item, CACHE_EXPIRATION);
144 | }
145 | }
146 | }
147 |
148 | return result;
149 | }
150 |
151 | /**
152 | * Creates batches of 500 ids for the search options query params
153 | *
154 | * params:
155 | * ids: the id list in the search options obj
156 | */
157 | function createBatches(ids) {
158 | let batches = [];
159 | let tempArray;
160 | let limit = 500;
161 | for (let i = 0, j = ids.length; i < j; i += limit) {
162 | tempArray = ids.slice(i, (i + limit));
163 | batches.push(tempArray);
164 | }
165 | return batches;
166 | }
167 |
168 | /**
169 | * Fetches a specific item from Campaign Manager. This method uses cache
170 | *
171 | * params:
172 | * entity: the entity name on the Campaign Manager API
173 | * id: the id of the item to fetch
174 | */
175 | this.get = function(entity, id) {
176 | if (!id) {
177 | return null;
178 | }
179 |
180 | if (cache.get(getCacheKey(entity, id))) {
181 | return JSON.parse(cache.get(getCacheKey(entity, id)));
182 | } else {
183 | console.log('Invoking API to fetch ' + entity);
184 | var result = _retry(function() {
185 | return DoubleClickCampaigns[entity].get(profileId, id);
186 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
187 |
188 | if (result) {
189 | cache.put(getCacheKey(entity, id), result);
190 | }
191 |
192 | return result;
193 | }
194 | }
195 |
196 | /**
197 | * Inserts or updates item in Campaign Manager
198 | *
199 | * params:
200 | * entity: the name of the Campaign Manager entity
201 | * obj: Object to insert or update
202 | */
203 | this.update = function(entity, obj) {
204 | console.log('Updating entity ' + entity);
205 | console.log('entity id: ' + obj.id);
206 | if(obj.id) {
207 | return _retry(function() {
208 | return DoubleClickCampaigns[entity].update(obj, profileId);
209 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
210 | } else {
211 | return _retry(function() {
212 | return DoubleClickCampaigns[entity].insert(obj, profileId);
213 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
214 | }
215 | }
216 |
217 | /**
218 | * Associates a creative to a campaign
219 | *
220 | * params:
221 | * campaignId: The ID of the campaign to associate the creative with
222 | * creativeId: The ID of the creative to associate
223 | */
224 | this.associateCreativeToCampaign = function(campaignId, creativeId) {
225 |
226 | var resource = {
227 | 'creativeId': creativeId
228 | }
229 |
230 | _retry(function() {
231 | DoubleClickCampaigns.CampaignCreativeAssociations.insert(resource, profileId, campaignId);
232 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
233 | }
234 |
235 | /**
236 | * Fetches a list of Campaign Manager Sizes that match the width and height
237 | * specified.
238 | *
239 | * params:
240 | * width: width of the Size to search
241 | * height: height of the Size to search
242 | *
243 | * returns: List of sizes that match width and height specified.
244 | */
245 | this.getSize = function(width, height) {
246 | return _retry(function() {
247 | return DoubleClickCampaigns.Sizes.list(profileId, {
248 | 'height': height,
249 | 'width': width
250 | }).sizes;
251 | }, DEFAULT_RETRIES, DEFAULT_SLEEP);
252 | }
253 |
254 | /**
255 | * Fetches items of a given entity based on a list of ids. Since CM has a
256 | * limitation of 500 ids per "list" call, this method splits the ids in chunks
257 | * of up to 500 items and merges the result.
258 | *
259 | * params:
260 | * entity: Name of the CM entity to fetch
261 | * listName: Name of the list field returned by the api
262 | * ids: Array of integers representing the ids to fetch
263 | *
264 | * returns: Array of entities returned by CM
265 | */
266 | this.chunkFetch = function(entity, listName, ids) {
267 | var result = [];
268 |
269 | for(var i = 0; i < ids.length; i += 500) {
270 | var chunk = ids.slice(i, i + 500);
271 |
272 | result = result.concat(this.list(entity, listName, {'ids': chunk}));
273 | }
274 |
275 | return result;
276 | }
277 |
278 | }
279 |
--------------------------------------------------------------------------------
/SidebarController.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | /**
23 | * This module contains functions that are called by the sidebar to interact
24 | * with server side functionality
25 | */
26 |
27 | /**
28 | * Returns the content of an html file so it can be included in the sidebar
29 | */
30 | function include(filename) {
31 | return HtmlService.createHtmlOutputFromFile(filename).getContent();
32 | }
33 |
34 | /**
35 | * Clears a given range in the sheet
36 | *
37 | * params:
38 | * job: job passed by the sidebar
39 | * job.a1Notation: required parameter identifying the range to clear
40 | *
41 | * returns: The job object
42 | */
43 | function _clear(job) {
44 | var sheetDAO = new SheetDAO();
45 |
46 | console.log('clearing and wiping');
47 | console.log(job.sheetName);
48 | console.log(job.range);
49 |
50 | sheetDAO.clear(job.sheetName, job.range);
51 |
52 | return job;
53 | }
54 | function clear(job) {
55 | return _invoke('_clear', job);
56 | }
57 |
58 | /**
59 | * Navigates sheets to a specific tab
60 | */
61 | function _goToTab(tabName) {
62 | var sheetDAO = new SheetDAO();
63 |
64 | sheetDAO.goToTab(tabName);
65 | }
66 | function goToTab(tabName) {
67 | return _invoke('_goToTab', tabName);
68 | }
69 |
70 | /**
71 | * Loads data from CM
72 | *
73 | * params:
74 | * job.entity defines which entity to load
75 | */
76 | function _identifySpecifiedItemsToLoad(job) {
77 | var loader = getLoader(job.entity);
78 |
79 | loader.identifyItemsToLoad(job);
80 |
81 | return job;
82 | }
83 | function identifySpecifiedItemsToLoad(job) {
84 | return _invoke('_identifySpecifiedItemsToLoad', job);
85 | }
86 |
87 | /**
88 | * Fetches items to load from CM, maps to feed and writes to the sheet
89 | * params:
90 | * job.entity defines which entity to load
91 | * job.idsToLoad defines specific item ids to load
92 | * job.parentItemIds ids of parent items to load child items for
93 | */
94 | function _cmLoad(job) {
95 | var loader = getLoader(job.entity);
96 |
97 | loader.load(job);
98 |
99 | return job;
100 | }
101 | function cmLoad(job) {
102 | return _invoke('_cmLoad', job);
103 | }
104 |
105 | /**
106 | * Fetches items to load from CM, maps to feed and writes to the sheet
107 | * params:
108 | * job.entity defines which entity to load
109 | * job.idsToLoad defines specific item ids to load
110 | * job.parentItemIds ids of parent items to load child items for
111 | */
112 | function _cmFetch(job) {
113 | console.log("fetching: ");
114 | console.log(job.entity);
115 | var loader = getLoader(job.entity);
116 |
117 | job.itemsToLoad = loader.fetchItemsToLoad(job);
118 |
119 | return job;
120 | }
121 | function cmFetch(job) {
122 | return _invoke('_cmFetch', job);
123 | }
124 |
125 | /**
126 | * Given lists of CM objects builds a hierarchy
127 | * params:
128 | * job.campaigns: List of campaigns
129 | * job.placements: List of placements
130 | * job.placementGroups: List of placement groups
131 | * job.ads: List of ads
132 | * job.landingPages: List of Landing Pages
133 | * job.creatives: List of creatives
134 | * job.eventTags: List of event tags
135 | *
136 | * returns: job.hierarchy
137 | */
138 | function _qa(job) {
139 | doBuildHierarchy(job);
140 |
141 | var qaFunctionName = getSheetDAO().getValue('Store', 'B3');
142 |
143 | context[qaFunctionName](job);
144 |
145 | // This is needed for large campaigns, it is too much data to transmit to the
146 | // front end
147 | job.hierarchy = [];
148 | job.campaigns = [];
149 | job.placements = [];
150 | job.placementGroups = [];
151 | job.ads = [];
152 |
153 | return job;
154 | }
155 | function qa(job) {
156 | return _invoke('_qa', job);
157 | }
158 |
159 | /**
160 | * Pushes data to CM
161 | *
162 | * params:
163 | * job: the job object
164 | * job.entity: name of the entity to use to find the correct loader
165 | * job.feedItem: the dictionary representing an item in the feed
166 | */
167 | function _cmPush(job) {
168 | var loader = getLoader(job.entity);
169 |
170 | loader.push(job);
171 |
172 | return job;
173 | }
174 | function cmPush(job) {
175 | return _invoke('_cmPush', job);
176 | }
177 |
178 | /**
179 | * Updates the feed after the push
180 | *
181 | * params:
182 | * job: the job object
183 | * job.entity: name of the entity to use to find the correct loader
184 | * job.feed: list of dictionaries with items to update
185 | */
186 | function _updateFeed(job) {
187 | var loader = getLoader(job.entity);
188 |
189 | loader.updateFeed(job);
190 |
191 | return job;
192 | }
193 | function updateFeed(job) {
194 | return _invoke('_updateFeed', job);
195 | }
196 |
197 | /**
198 | * Saves the ID map to the Store tab
199 | *
200 | * params:
201 | * job: the job object
202 | *
203 | * returns: job.idMap with the current id map in the sheet
204 | */
205 | function _saveIdMap(job) {
206 | getIdStore().initialize(job.idMap);
207 |
208 | getIdStore().store();
209 |
210 | return job;
211 | }
212 | function saveIdMap(job) {
213 | return _invoke('_saveIdMap', job);
214 | }
215 |
216 | /**
217 | * Loads the ID map from the Store tab
218 | *
219 | * params:
220 | * job: the job object
221 | *
222 | * returns: job.idMap with the current id map in the sheet
223 | */
224 | function _loadIdMap(job) {
225 | getIdStore().load();
226 |
227 | job.idMap = getIdStore().getData();
228 |
229 | return job;
230 | }
231 | function loadIdMap(job) {
232 | return _invoke('_loadIdMap', job);
233 | }
234 |
235 | /**
236 | * Shows a confirm dialog
237 | *
238 | * params:
239 | * job.title: The title for the dialog
240 | * job.message: The message to display
241 | *
242 | * returns job: job.result is populated with the user's response from the dialog.
243 | */
244 | function _userConfirmation(job) {
245 | var ui = SpreadsheetApp.getUi();
246 |
247 | job.result = ui.alert(job.title, job.message, ui.ButtonSet.YES_NO);
248 |
249 | return job;
250 | }
251 | function userConfirmation(job) {
252 | return _invoke('_userConfirmation', job);
253 | }
254 |
255 |
256 | /**
257 | * Write logs to the Log tab
258 | *
259 | * params:
260 | * job.jobs: List of jobs to process
261 | * job.jobs[1..N].logs: logs to output
262 | * job.offset: offset to write in case existing logs already exist. If offset
263 | * is 0 this also clears the log tab
264 | */
265 | function _writeLogs(job) {
266 | var sheetDAO = new SheetDAO();
267 | var output = [];
268 |
269 | job.offset = job.offset || 0;
270 | var range = 'A' + (job.offset + 1) + ':B';
271 |
272 | for(var i = 0; i < job.jobs.length && job.jobs[i].logs; i++) {
273 | var logs = job.jobs[i].logs;
274 |
275 | for(var j = 0; j < logs.length; j++) {
276 | output.push(logs[j]);
277 | }
278 |
279 | job.jobs[i].logs = [];
280 | }
281 |
282 | if(output.length > 0) {
283 | job.offset += output.length;
284 |
285 | sheetDAO.setValues('Log', range + (job.offset), output);
286 | }
287 |
288 | return job;
289 | }
290 | function writeLogs(job) {
291 | return _invoke('_writeLogs', job);
292 | }
293 |
294 | /**
295 | * Initializes a push job. Primarily focused on incrementing the job id which
296 | * is part of cache keys, this prevents from stale objects in the cache to be
297 | * reused by a new job execution.
298 | *
299 | * params:
300 | * job: Empty object
301 | */
302 | function _initializeJob(job) {
303 | var userProperties = PropertiesService.getUserProperties();
304 |
305 | var jobId = userProperties.getProperty('jobId');
306 |
307 | if(!jobId) {
308 | userProperties.setProperty('jobId', 0);
309 | } else {
310 | userProperties.setProperty('jobId', Number(jobId) + 1);
311 | }
312 |
313 | job.jobId = userProperties.getProperty('jobId');
314 |
315 | return job;
316 | }
317 | function initializeJob(job) {
318 | return _invoke('_initializeJob', job);
319 | }
320 |
321 | /**
322 | * Creates load jobs for items in the feed for a particular entity.
323 | *
324 | * params:
325 | * job: the job object
326 | * job.entity: the name of the entity to use to identify the correct loader to
327 | * use
328 | */
329 | function _createPushJobs(job) {
330 | var loader = getLoader(job.entity);
331 |
332 | loader.createPushJobs(job);
333 |
334 | return job;
335 | }
336 | function createPushJobs(job) {
337 | return _invoke('_createPushJobs', job);
338 | }
339 |
340 | /**
341 | * Reads and returns the entity configurations defined in the Entity Configs
342 | * tab
343 | *
344 | * params:
345 | * job: the job object
346 | * job.entityConfigs: Dictionary with the entity configurations
347 | *
348 | */
349 | function _getEntityConfigs(job) {
350 | var entityConfigs = getSheetDAO().sheetToDict('Entity Configs');
351 | job.entityConfigs = {};
352 |
353 | forEach(entityConfigs, function(index, entityConfig) {
354 | job.entityConfigs[entityConfig['Entity']] = entityConfig['Mode'];
355 | });
356 |
357 | return job;
358 | }
359 | function getEntityConfigs(job) {
360 | return _invoke('_getEntityConfigs', job);
361 | }
362 |
363 | /**
364 | * Function that safely tries to parse an input as a JSON object, if it fails it
365 | * doesn't throw an excaption, rather it just returns the input
366 | *
367 | * params:
368 | * input: input value to try to parse
369 | *
370 | * result: either the json object resulting from parsing input, or input itself
371 | * if it is not a valid json
372 | */
373 | function parse(input) {
374 | try {
375 | return JSON.parse(input);
376 | } catch(error) {
377 | return input;
378 | }
379 | }
380 |
381 | /**
382 | * Decorator that provides basic error handling for job invocation
383 | */
384 | function _invoke(functionName, input) {
385 | try {
386 | var job = parse(input);
387 |
388 | return JSON.stringify(this[functionName](job));
389 | } catch(error) {
390 | console.log(error);
391 | job.error = error;
392 |
393 | throw JSON.stringify(job);
394 | }
395 | }
396 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | i
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/QA.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | var CUSTOM_URL = 'Custom URL';
23 | var CLICK_TRACKER = 'Click Tracker';
24 |
25 | /**
26 | * Calls function for each ad.
27 | *
28 | * parameters:
29 | * hierarchy: CM Hierarchy created by buildHierarchy function
30 | * func: callback function with the following signature: f(campaign,
31 | * placementGroup, placement, ad)
32 | * Note: For placements assigned directly to the campaign (i.e. no placement
33 | * group) the placementGroup parameter in the callback will be null
34 | */
35 | function forEachAd(hierarchy, func) {
36 | forEach(hierarchy, function(index, campaign) {
37 | forEach(campaign.placementGroups, function(index, placementGroup) {
38 | forEach(placementGroup.placements, function(index, placement) {
39 | forEach(placement.ads, function(index, ad) {
40 | func(campaign, placementGroup, placement, ad);
41 | });
42 | });
43 | });
44 |
45 | forEach(campaign.placements, function(index, placement) {
46 | forEach(placement.ads, function(index, ad) {
47 | func(campaign, null, placement, ad);
48 | });
49 | });
50 | });
51 | }
52 |
53 | /**
54 | * Calls function for each ad creative assignment.
55 | *
56 | * parameters:
57 | * hierarchy: CM Hierarchy created by buildHierarchy function
58 | * func: callback function with the following signature: f(campaign,
59 | * placementGroup, placement, ad, creativeAssignment)
60 | * Note: For placements assigned directly to the campaign (i.e. no placement
61 | * group) the placementGroup parameter in the callback will be null
62 | */
63 | function forEachAdCreativeAssignment(hierarchy, func) {
64 | forEach(hierarchy, function(index, campaign) {
65 | forEach(campaign.placementGroups, function(index, placementGroup) {
66 | forEach(placementGroup.placements, function(index, placement) {
67 | forEach(placement.ads, function(index, ad) {
68 | if(ad.creatives && ad.creatives.length > 0) {
69 | forEach(ad.creatives, function(index, creative) {
70 | func(campaign, placementGroup, placement, ad, creative);
71 | });
72 | } else {
73 | func(campaign, placementGroup, placement, ad);
74 | }
75 | });
76 | });
77 | });
78 |
79 | forEach(campaign.placements, function(index, placement) {
80 | forEach(placement.ads, function(index, ad) {
81 | forEach(ad.creatives, function(index, creative) {
82 | func(campaign, null, placement, ad, creative);
83 | });
84 | });
85 | });
86 | });
87 | }
88 |
89 | /**
90 | * Implements the Default QA style
91 | */
92 | function qaByCreativeRotation(job) {
93 | if(!job.logs) {
94 | job.logs = [];
95 | }
96 | job.logs.push([new Date(), 'Generating QA Report']);
97 |
98 | var feed = [];
99 |
100 | var cmDAO = new CampaignManagerDAO(getProfileId());
101 |
102 | forEachAdCreativeAssignment(job.hierarchy, function(campaign, placementGroup, placement, ad, creative) {
103 | var feedItem = {};
104 |
105 | feedItem['Campaign Name'] = campaign.name;
106 | feedItem['Campaign ID'] = campaign.id;
107 | feedItem['Advertiser ID'] = campaign.advertiserId;
108 | feedItem['Campaign Start Date'] = campaign.startDate;
109 | feedItem['Campaign End Date'] = campaign.endDate;
110 |
111 | if(placementGroup) {
112 | feedItem['Placement Group Name'] = placementGroup.name;
113 | feedItem['Placement Group ID'] = placementGroup.id;
114 |
115 | feedItem['Site ID'] = placementGroup.siteId;
116 |
117 | if(placementGroup.pricingSchedule && placementGroup.pricingSchedule.pricingPeriods && placementGroup.pricingSchedule.pricingPeriods.length > 0) {
118 | feedItem['Rate'] = placementGroup.pricingSchedule.pricingPeriods[0].rateOrCostNanos / 1000000000;
119 | feedItem['Units'] = placementGroup.pricingSchedule.pricingPeriods[0].units;
120 | }
121 |
122 | feedItem['Placement Group Type'] = placementGroup.placementGroupType;
123 |
124 | feedItem['Placement Group Start Date'] = getDataUtils().formatDateUserFormat(placementGroup.pricingSchedule.startDate);
125 | feedItem['Placement Group End Date'] = getDataUtils().formatDateUserFormat(placementGroup.pricingSchedule.endDate);
126 | feedItem['Pricing Type'] = placementGroup.pricingSchedule.pricingType;
127 | } else {
128 | feedItem['Site ID'] = placement.siteId;
129 | }
130 |
131 | feedItem['Placement Name'] = placement.name;
132 | feedItem['Placement ID'] = placement.id
133 | feedItem['Placement Start Date'] = getDataUtils().formatDateUserFormat(placement.pricingSchedule.startDate);
134 | feedItem['Placement End Date'] = getDataUtils().formatDateUserFormat(placement.pricingSchedule.endDate);
135 | feedItem['Ad Blocking'] = placement.adBlockingOptOut;
136 | feedItem['Pricing Schedule Cost Structure'] = placement.pricingSchedule.pricingType;
137 | feedItem['Type'] = placement.compatibility;
138 |
139 | feedItem['Ad Name'] = ad.name;
140 | feedItem['Ad ID'] = ad.id;
141 | feedItem['Ad Type'] = ad.type;
142 | feedItem['Asset Size'] = ad.size ? ad.size.width + 'x' + ad.size.height : '';
143 | feedItem['Ad Start Date'] = getDataUtils().formatDateTimeUserFormat(ad.startTime);
144 | feedItem['Ad End Date'] = getDataUtils().formatDateTimeUserFormat(ad.endTime);
145 | if(ad.deliverySchedule) {
146 | feedItem['Ad Priority'] = ad.deliverySchedule.priority;
147 | }
148 |
149 | if(ad.targetingTemplateId) {
150 | var targetingTemplate = cmDAO.get('TargetingTemplates', ad.targetingTemplateId);
151 |
152 | if(targetingTemplate) {
153 | feedItem['Targeting Template ID'] = targetingTemplate.id;
154 | feedItem['Targeting Template Name'] = targetingTemplate.name;
155 | }
156 | }
157 | feedItem['Hard Cutoff'] = ad.deliverySchedule ? ad.deliverySchedule.hardCutoff : '';
158 |
159 | if(creative) {
160 | feedItem['Creative Name'] = creative.creative.name;
161 | feedItem['Creative ID'] = creative.creative.id;
162 | feedItem['Creative Size'] = creative.size ? creative.size.width + 'x' + creative.size.height : '';
163 | feedItem['Creative Rotation Weight'] = creative.weight;
164 | feedItem['Creative Start Date'] = getDataUtils().formatDateTimeUserFormat(creative.startTime);
165 | feedItem['Creative End Date'] = getDataUtils().formatDateTimeUserFormat(creative.endTime);
166 | if(creative.landingPage) {
167 | feedItem['Landing Page Name'] = creative.landingPage.name;
168 | feedItem['Landing Page URL'] = creative.landingPage.url;
169 | feedItem['Landing Page ID'] = creative.landingPage.id;
170 | } else if (creative.clickThroughUrl && creative.clickThroughUrl.customClickThroughUrl) {
171 | feedItem['Landing Page Name'] = CUSTOM_URL;
172 | feedItem['Landing Page URL'] = creative.clickThroughUrl.customClickThroughUrl;
173 | }
174 |
175 | if(ad.weightTotal) {
176 | feedItem['Creative Rotation %'] = (creative.weight / ad.weightTotal * 100) + '%';
177 | }
178 | }
179 |
180 | if(ad.type == 'AD_SERVING_CLICK_TRACKER' && ad.clickThroughUrl &&
181 | ad.clickThroughUrl.computedClickThroughUrl) {
182 | feedItem['Landing Page Name'] = CLICK_TRACKER;
183 | feedItem['Landing Page URL'] = ad.clickThroughUrl.computedClickThroughUrl;
184 | }
185 |
186 | feed.push(feedItem);
187 | });
188 |
189 | new FeedProvider('QA').setFeed(feed).save();
190 | }
191 |
192 | /**
193 | * Implements the Aggregated Creative Rotation QA style
194 | */
195 | function qaByAdAggregatedCreativeRotation(job) {
196 |
197 | if(!job.logs) {
198 | job.logs = [];
199 | }
200 | job.logs.push([new Date(), 'Generating QA Report']);
201 |
202 | var feed = [];
203 |
204 | var cmDAO = new CampaignManagerDAO(getProfileId());
205 |
206 | forEachAd(job.hierarchy, function(campaign, placementGroup, placement, ad) {
207 | var feedItem = {};
208 | feed.push(feedItem);
209 |
210 | var site = cmDAO.get('Sites', placement.siteId);
211 |
212 | // Campaign
213 | feedItem['Campaign ID'] = campaign.id;
214 | feedItem['Campaign Name'] = campaign.name;
215 |
216 | // Site
217 | feedItem['Site Name'] = site.name;
218 |
219 | // Placement
220 | feedItem['Placement ID'] = placement.id;
221 | feedItem['Placement Name'] = placement.name;
222 | if(placement.compatibility == 'IN_STREAM_VIDEO') {
223 | feedItem['Placement Size'] = 'In stream video';
224 | } else {
225 | feedItem['Placement Size'] = placement.size ? placement.size.width + 'x' + placement.size.height : '';
226 | }
227 |
228 | feedItem['Placement Start Date'] = getDataUtils().formatDateUserFormat(placement.pricingSchedule.startDate);
229 | feedItem['Placement End Date'] = getDataUtils().formatDateUserFormat(placement.pricingSchedule.endDate);
230 |
231 | // Ad
232 | feedItem['Ad Name'] = ad.name;
233 | //feedItem['Ad Created Date'] = getDataUtils().formatDateTime(ad.createInfo.time);
234 | feedItem['Ad Created Date'] = getDataUtils().formatDateUserFormat(new Date(parseInt(ad.createInfo.time)));
235 | feedItem['Ad Last Modified Date'] = getDataUtils().formatDateUserFormat(new Date(parseInt(ad.lastModifiedInfo.time)));
236 |
237 | // Creative
238 | var creativeNames = [];
239 | var creativeWeights = [];
240 | var landingPageNames = [];
241 | var landingPageUrls = [];
242 |
243 | forEach(ad.creatives, function(index, creative) {
244 | creativeNames.push(creative.creative.name);
245 |
246 | creativeWeights.push(creative.weight);
247 |
248 | if(creative.landingPage) {
249 | landingPageNames.push(creative.landingPage.name);
250 | landingPageUrls.push(creative.landingPage.url);
251 | } else if(creative.clickThroughUrl && creative.clickThroughUrl.customClickThroughUrl) {
252 | landingPageNames.push( CUSTOM_URL);
253 | landingPageUrls.push(creative.clickThroughUrl.customClickThroughUrl);
254 | }
255 | });
256 |
257 | if(ad.type == 'AD_SERVING_CLICK_TRACKER' && ad.landingPage) {
258 | var landingPage = ad.landingPage;
259 |
260 | landingPageNames.push(landingPage.name);
261 | landingPageUrls.push(landingPage.url);
262 | }
263 |
264 | feedItem['Creative Names'] = creativeNames.join('\n');
265 | feedItem['Landing Page Name'] = landingPageNames.join('\n');
266 | feedItem['Landing Page URL'] = landingPageUrls.join('\n');
267 | feedItem['Creative Rotation Weight'] = creativeWeights.join('\n');
268 |
269 | feedItem['Creative Rotation'] = getDataUtils().creativeRotationType(ad.creativeRotation);
270 |
271 | });
272 |
273 | new FeedProvider('QA').setFeed(feed).save();
274 | }
275 |
276 | /**
277 | * Implements the Landing Page QA style
278 | */
279 | function qaLandingPage(job) {
280 | if(!job.logs) {
281 | job.logs = [];
282 | }
283 |
284 | job.logs.push([new Date(), 'Generating QA Report']);
285 |
286 | var feed = [];
287 |
288 | var cmDAO = new CampaignManagerDAO(getProfileId());
289 |
290 | forEach(job.landingPages, function(index, landingPage) {
291 | var feedItem = {};
292 | feed.push(feedItem);
293 |
294 | feedItem['Landing Page ID'] = landingPage.id;
295 | feedItem['Landing Page Name'] = landingPage.name;
296 | feedItem['Landing Page URL'] = landingPage.url;
297 | feedItem['Campaign ID'] = landingPage.campaign.id;
298 | feedItem['Campaign Name'] = landingPage.campaign.name;
299 | });
300 |
301 | new FeedProvider('QA').setFeed(feed).save();
302 | }
303 |
--------------------------------------------------------------------------------
/SheetDAO.js:
--------------------------------------------------------------------------------
1 | /***************************************************************************
2 | *
3 | * Copyright 2020 Google Inc.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * https://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | *
17 | * Note that these code samples being shared are not official Google
18 | * products and are not formally supported.
19 | *
20 | ***************************************************************************/
21 |
22 | /**
23 | * Handles all interactions with the sheet, such as reading data, writing data,
24 | * clearing ranges, etc.
25 | */
26 | var SheetDAO = function() {
27 | // PRIVATE FIELDS
28 |
29 | // Allows private methods to access this object
30 | var that = this;
31 |
32 | // Defines the maximun number of columns to consider in a sheet
33 | const MAX_SHEET_WIDTH = 52;
34 |
35 | // Column names to facilitate index to a1 notation syntax translation
36 | var columns = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
37 | 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA', 'AB', 'AC', 'AD',
38 | 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR',
39 | 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ'];
40 |
41 | this.columns = columns;
42 |
43 |
44 | // Object that holds sheet cache in memory
45 | var sheetCache = {};
46 |
47 | // PRIVATE METHODS
48 |
49 | /**
50 | * Turns a feed item into a sheet row based on the fiels in the header
51 | *
52 | * params:
53 | * header: array of strings representing the headers of the sheet
54 | * item: the feed item to write, a dictionary with fields that match the
55 | * header fields
56 | *
57 | * returns: array of values to be written to the sheet
58 | */
59 | function dictToRow(header, item) {
60 | var result = [];
61 |
62 | for(var i = 0; i < header.length; i++) {
63 | var value = item[header[i]];
64 |
65 | if(value !== undefined && value !== null) {
66 | result.push(item[header[i]].toString());
67 | } else {
68 | result.push(null);
69 | }
70 | }
71 |
72 | return result;
73 | }
74 |
75 | /**
76 | * Gets the header row of a sheet
77 | *
78 | * params:
79 | * sheetName: name of the sheet to get the header
80 | *
81 | * returns: Array representing each column of the header row of the identified
82 | * sheet
83 | */
84 | function getHeader(sheetName) {
85 | var result = cacheGet(sheetName, 'header');
86 |
87 | if(!result) {
88 | var sheet = getSheet(sheetName);
89 | var header = sheet.getSheetValues(1, 1, 1, MAX_SHEET_WIDTH)[0];
90 | var result = [];
91 |
92 | for(var i = 0; i < header.length && header.slice(i).join(''); i++) {
93 | result.push(header[i]);
94 | }
95 |
96 | if(!sheetCache[sheetName]) {
97 | sheetCache[sheetName] = {};
98 | }
99 |
100 | cachePut(sheetName, 'header', result);
101 | }
102 |
103 | return result;
104 | }
105 |
106 | /**
107 | * Gets data from a sheet
108 | *
109 | * params:
110 | * sheetName: name of the sheet from which to get the data
111 | */
112 | function getData(sheetName) {
113 |
114 | var result = cacheGet(sheetName, 'data');
115 |
116 | if(!result) {
117 | var header = getHeader(sheetName);
118 | var data;
119 |
120 | data = that.getValues(sheetName, 'A2:' + columns[header.length + 1]);
121 | result = [];
122 |
123 | for(var i = 0; i < data.length; i++) {
124 | var row = data[i];
125 |
126 | if(row.join('')) {
127 | result.push(row);
128 | } else {
129 | break;
130 | }
131 | }
132 |
133 | cachePut(sheetName, 'data', result);
134 | }
135 |
136 | return result;
137 | }
138 |
139 | /**
140 | * Returns a list with all sheets, uses cache for performance reasons
141 | *
142 | * returns: Array of sheets
143 | */
144 | function getSheets() {
145 | var result = cacheGet('all', 'sheets');
146 |
147 | if(!result) {
148 | result = SpreadsheetApp.getActive().getSheets();
149 |
150 | cachePut('all', 'sheets', result);
151 | }
152 |
153 | return result;
154 | }
155 |
156 | /**
157 | * Gets the sheet object that represents a given sheet
158 | *
159 | * params:
160 | * sheetName: Name of the sheet to return
161 | *
162 | * returns: The sheet object
163 | */
164 | function getSheet(sheetName) {
165 | var result = cacheGet(sheetName, 'sheet');
166 |
167 | if(!result) {
168 | var sheets = getSheets();
169 |
170 | for(var i = 0; i < sheets.length; i++) {
171 | var sheet = sheets[i];
172 |
173 | if(sheet.getName() === sheetName) {
174 | result = sheet;
175 | break;
176 | }
177 | }
178 |
179 | cachePut(sheetName, 'sheet', result);
180 | }
181 |
182 | return result;
183 | }
184 |
185 | /**
186 | * Fetches data from the cache
187 | *
188 | * params:
189 | * sheetName: The cache is sheet based, this is the name of the sheet used to
190 | * identify the cache
191 | * key: key within the sheet cache to fetch
192 | *
193 | * returns: The value from the cache if there is a value that matches the
194 | * sheetName and key, null otherwise
195 | */
196 | function cacheGet(sheetName, key) {
197 | if(sheetCache[sheetName] && sheetCache[sheetName][key]) {
198 | return sheetCache[sheetName][key];
199 | }
200 |
201 | return null;
202 | }
203 |
204 | /**
205 | * Puts data into the sheet cache
206 | *
207 | * params:
208 | * sheetName: The cache is sheet based, this is the name of the sheet used to
209 | * identify the cache
210 | * key: key within the sheet cache to put
211 | * value: value to put in the cache
212 | */
213 | function cachePut(sheetName, key, value) {
214 | if(!sheetCache[sheetName]) {
215 | sheetCache[sheetName] = {};
216 | }
217 |
218 | sheetCache[sheetName][key] = value;
219 | }
220 |
221 | /**
222 | * Returns a range object representing the specified range. This method will
223 | * cache the range for performance reasons avoiding multiple calls to the API
224 | *
225 | * params:
226 | * sheetName: name of the tab
227 | * range: A1 notation of the range
228 | *
229 | * returns: Range object representing the identified range
230 | */
231 | function getRange(sheetName, range) {
232 | var result = cacheGet(sheetName, range);
233 |
234 | if(!result) {
235 | var sheet = getSheet(sheetName);
236 |
237 | if(sheet) {
238 | result = sheet.getRange(range);
239 |
240 | cachePut(sheetName, range, result);
241 | }
242 | }
243 |
244 | return result;
245 | }
246 |
247 | /**
248 | * Turns a row into a dictionary based on the field specification of a header
249 | * row
250 | *
251 | * params:
252 | * header: array representing the header of the sheet
253 | * row: row with data
254 | *
255 | * returns: dictionary with each field in the header and data from row
256 | */
257 | function rowToDict(header, row) {
258 | var result = {};
259 |
260 | for(var i = 0; i < header.length; i++) {
261 | result[header[i]] = row[i];
262 | }
263 |
264 | return result;
265 | }
266 |
267 | // PUBLIC METHODS
268 |
269 | this.isQA = function() {
270 | return getSheet('QA') != null;
271 | }
272 | /**
273 | * Clears a range in the sheet
274 | *
275 | * params:
276 | * sheetName: Name of the sheet to clear
277 | * range: range within the sheet to clear
278 | */
279 | this.clear = function(sheetName, range) {
280 | var range = getRange(sheetName, range);
281 |
282 | if(range) {
283 | range.clear();
284 | }
285 | }
286 |
287 | /**
288 | * Opens the specified tab
289 | *
290 | * params:
291 | * sheetName: The name of the tab to open
292 | */
293 | this.goToTab = function(sheetName) {
294 | console.log('going to tab ' + sheetName);
295 | SpreadsheetApp.setActiveSheet(SpreadsheetApp.getActive().getSheetByName(sheetName));
296 | }
297 |
298 | /**
299 | * Turns a sheet into a dictionary with each field name being the column header
300 | * which is assumed to be row 1 of the sheet
301 | *
302 | * params:
303 | * sheetName: name of the sheet to transform
304 | * noCache: boolean, if true the cache isn't used, otherwise cache is used
305 | */
306 | this.sheetToDict = function(sheetName, noCache) {
307 | if(!this.tabExists(sheetName)) {
308 | return [];
309 | }
310 |
311 | var result = noCache ? null : cacheGet(sheetName, 'dict');
312 |
313 | if(!result) {
314 | var header = getHeader(sheetName);
315 | var data = getData(sheetName);
316 | var result = [];
317 |
318 | for(var i = 0; i < data.length && data[i].join('') != ''; i++) {
319 | result.push(rowToDict(header, data[i]));
320 | }
321 |
322 | cachePut(sheetName, 'dict');
323 | }
324 |
325 | return result;
326 | }
327 |
328 | /**
329 | * Checks if a tab exists
330 | *
331 | * params:
332 | * tabName: name of the tab to check
333 | *
334 | * returns: true if exists, false otherwise
335 | */
336 | this.tabExists = function(tabName) {
337 | return getSheet(tabName) ? true : false;
338 | }
339 |
340 | /**
341 | * Gets data from a particular range in a spreadsheet
342 | *
343 | * params:
344 | * sheetName: Name of the sheet (tab) from which to read the data
345 | * range: Range in the sheet from which to get the data
346 | *
347 | * returns: array of arrays with the values identified
348 | */
349 | this.getValues = function(sheetName, range) {
350 | var result = cacheGet(sheetName, 'values' + range);
351 |
352 | if(!result) {
353 | var range = getRange(sheetName, range);
354 |
355 | var result = _retry(function() {
356 | return range.getValues();
357 | }, 3, 2 * 1000);
358 |
359 | cachePut(sheetName, 'values' + range, result);
360 | }
361 |
362 | return result;
363 | }
364 |
365 | /**
366 | * Returns a single value from a cell in the sheet
367 | *
368 | * params:
369 | * sheetName: Name of the sheet to read from
370 | * range: a1 notation of a specific cell
371 | */
372 | this.getValue = function(sheetName, range) {
373 | var values = this.getValues(sheetName, range);
374 |
375 | if(values.length > 0 && values[0].length > 0) {
376 | return values[0][0];
377 | }
378 |
379 | return null;
380 | }
381 |
382 | /**
383 | * Write values to a particular sheet and range
384 | *
385 | * params:
386 | * sheetName: Name of the sheet where to write
387 | * range: range in which to write
388 | * values: array of arrays containing values to write
389 | */
390 | this.setValues = function(sheetName, range, values) {
391 | var sheetRange = getRange(sheetName, range);
392 |
393 | sheetRange.clear();
394 |
395 | var response = _retry(function() {
396 | sheetRange.setValues(values);
397 | }, 3, 2 * 1000);
398 | }
399 |
400 | /**
401 | * Writes a feed to a sheet
402 | *
403 | * params:
404 | * sheetName: name of the sheet in which to write the feed
405 | * items: list of items in the feed format to write to the sheet
406 | */
407 | this.dictToSheet = function(sheetName, items) {
408 | var header = getHeader(sheetName);
409 |
410 | this.clear(sheetName, '!A2:AZ');
411 |
412 | var rows = [];
413 |
414 | for(var i = 0; i < items.length; i++) {
415 | rows.push(dictToRow(header, items[i]));
416 | }
417 |
418 | if(rows.length > 0) {
419 | this.setValues(sheetName, '!A2:' + columns[rows[0].length - 1] + (rows.length + 1), rows);
420 | }
421 | }
422 | }
423 |
424 | // Singleton implementation for the sheet dao
425 | var sheetDAO;
426 | function getSheetDAO() {
427 | if(!sheetDAO) {
428 | sheetDAO = new SheetDAO();
429 | }
430 |
431 | return sheetDAO;
432 | }
433 |
434 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # IMPORTANT: Bulkdozer Sunset
4 |
5 | The Google team is sunsetting Bulkdozer on August 15th, 2023. Bulkdozer was originally developed to help users automate bulk editing capabilities that can reduce trafficking time in Campaign Manager 360.
6 | As Google products evolve, the gap that Bulkdozer covers has successfully shrunk and we encourage users to work with the new product features and offerings.
7 |
8 | Campaign Manager 360 added a new feature to leverage spreadsheets to easily manage CM360 entities and assignments in bulk. The new Campaign Import/Export functionality will allow you to specify the format of the spreadsheet so that you can easily update CM360. This update adds column selection and saved templates, row filtering, custom timezone for dates and more. Please visit the [CM360 support page](https://support.google.com/campaignmanager/answer/13391368) for more information.
9 |
10 | We appreciate the contributions of the community that helped make Bulkdozer a success for many years.
11 |
12 | # Bulkdozer
13 |
14 | - [Bulkdozer](#bulkdozer)
15 | - [Solution Overview](#solution-overview)
16 | - [Solution Requirements](#solution-requirements)
17 | - [Installation](#installation)
18 | - [Apps Script Advanced Service Configuration](#apps-script-advanced-service-configuration)
19 | - [Solution Manual](#solution-manual)
20 | - [**Solution Setup, Basics & Legend**](#solution-setup-basics--legend)
21 | - [**Color Legend**](#color-legend)
22 | - [**Guide: Event Tag Creation for Existing Campaign**](#guide-event-tag-creation-for-existing-campaign)
23 | - [**Guide: Create New Ads, Assign to Placements**](#guide-create-new-ads-assign-to-placements)
24 | - [**Guide: Bulk Creative Swap**](#guide-bulk-creative-swap)
25 | - [Terms and Conditions](#terms-and-conditions)
26 | - [Support](#support)
27 | - [Other Resources](#other-resources)
28 |
29 |
30 |
31 | ## Solution Overview
32 |
33 | Bulkdozer is a Google Sheets-based tool leveraging Apps Script and the CM360 API to load and visualize CM360 campaign data, allowing the user to traffic more efficiently at scale. Bulkdozer can create and edit Campaigns, Placement Groups, Placements, Ads, Creative Assignments, Landing Pages and Event Tags.
34 |
35 | ## Solution Requirements
36 |
37 | - Products
38 | - Campaign Manager 360
39 | - Google Workspace (Google Sheets)
40 |
41 |
42 |
43 | ## Installation
44 |
45 | Use the link below to navigate to the tool. Refer to the
46 | "Instructions" tab of for details on how to deploy and use the solution.
47 |
48 | - [Bulkdozer 0.4](https://docs.google.com/spreadsheets/d/1jVXiLiAS_n9-7v7Ekw52UzNE2bEiCi7CCmjWxKB7kcw/edit?usp=sharing)
49 |
50 | ### Apps Script Advanced Service Configuration
51 | Due to recent Apps Scripts changes, an extra configuration is required for Bulkdozer to work properly. Please follow the steps below:
52 | 1. In your Bulkdozer Google Sheet copy navigate from menu 'Tools' > 'Script Editor'.
53 | 2. In the Script Editor, navigate to the 'Editor' tab in the outer left nav (icon looks like '< >' brackets)
54 | 3. Under the 'Services' section of the inner left nav, click on the vertical three-dots next to the 'DoubleClickCampaigns' service and click 'Remove' and then click 'Remove service' on the confirmation pop-up.
55 | 4. Click the '+' button next to Services in the inner left nav to re-add the service.
56 | 5. In the list of services select 'Campaign Manager 360 API' and check that the version is 'v4' at this time and that the 'Identifier' is the same 'DoubleClickCampaigns'.
57 |
58 | ## Solution Manual
59 |
60 | ### **Solution Setup, Basics & Legend**
61 |
62 | 1. Make a Copy of the Bulkdozer Google Sheet [Bulkdozer 0.4](https://docs.google.com/spreadsheets/d/1jVXiLiAS_n9-7v7Ekw52UzNE2bEiCi7CCmjWxKB7kcw/edit?usp=sharing)
63 |
64 | 2. Your permissions in Bulkdozer are tied to your CM360 Profile’s Permission for a given CM360 Account. In the Store tab, next to profileid (Row 2B), input your CM360 Profile ID for the appropriate CM360 Account that you will be trafficking in. Without this, Bulkdozer will not work.
65 |
66 | 3. **The Bulkdozer Sidebar:** The Bulkdozer sidebar is where you access all Bulkdozer functionality. To open the sidebar select the custom menu at the top Bulkdozer -> Open.
67 |
68 | - **Status:** The "Status" text tells you what Bulkdozer is currently doing. "Ready" means it is not executing any jobs and it is ready to process your next command. When jobs are running, this status text changes to reflect the actions that are being performed. Ensure you see “Ready” everytime after Loading From/Clearing Feed/Pushing To CM.
69 |
70 | - **Clear Feed:** This button will clear CM data from all tabs in the feed, preparing it for the next activity, such as switching to work on a new campaign. In general, best practice is to Clear Feed before each use.
71 |
72 | - **Load from CM:** This button will load data from CM
73 |
74 | - Which data to load is identified by the IDs in the ID columns of the respective tabs. For instance, if you would like to load all entities under a given Campaign, enter the Campaign ID in the Campaign ID column of the Campaign tab and click Load from CM.
75 |
76 | - **Push to CM:** This button will push any feed changes back to CM.
77 |
78 | - Bulkdozer loads data from CM based on IDs you input in the respective tabs. You can load entire campaigns or specific Placement Groups, Placements, and Ads. Bulkdozer will load items in "cascade", e.g. if you decide to load a Campaign(s) everything under the specified Campaign(s) will be loaded. If you decide to load Placements, everything under the placements will be loaded but not upstream items such as Campaigns or Placement groups. This behavior is intended to allow you to load only the specific items you want to modify.
79 |
80 | - Next, go to the tab that represents the top level entities you want to load. E.g. if you want to load an entire Campaign go to the Campaign Tab and enter the appropriate CM360 Campaign ID in the Campaign ID field. If you only wanted to load specific placements go to the Placement tab and enter the Placement IDs instead, and so on.
81 |
82 | - You can also mix and match, for instance you could load 1 entire campaign, 3 placement groups from another campaign, and 2 placements from yet another placement group by specifying the correct ids in the correct tabs. Bulkdozer’s power is in its flexibility to bulk edit across many Placements, Campaigns and Advertisers. You may very well choose to pull in several Campaigns and traffic them all at once in a single sheet.
83 |
84 | - Finally open the sidebar and click "Load from CM". Monitor the Log tab until the sidebar status changes to "Ready", which indicates the loading process is complete and CM data is populated in the respective tabs.
85 |
86 | [Back to top](#top_page)
87 |
88 | ### **Color Legend**
89 |
90 | * **Orange:** User provided, used on insert only, not updateable after initial creation.
91 |
92 | * **Black: IDs. User provided:**
93 | - In the “ext..” format for initial creation or, actual IDs for existing items for mapping purposes.
94 | - **Creating new entities, the “ext..” format:**
95 | - Seen below, there are two existing Placements in this CM Campaign. We are telling Bulkdozer to create a third placement by entering “extP3” in the Placement ID field, while also filling out the other fields. This is what your entry might look like BEFORE clicking “Push to CM”:
96 |
97 | - Once the push is completed (Status: Ready), extP3 will be replaced by the newly created CM360 Placement ID, looking something like this:
98 |
99 | - You can use any alphanumeric combination after ext to notate a new entry. You could just as easily use “extNewThing99” in this Placement ID example to create an additional Placement. We advise on simple naming conventions to make it easier to manage, such as “extP1” and “extP2” for two new unique Placements, “extA1” for a new Ad, “extC1” for a new Campaign, etc.
100 |
101 | * **Gray:** Populated upon “Load From CM” by Bulkdozer for trafficker convenience.
102 | - Example, in the Placement tab the Campaign Name column is provided so you know the Campaign with which a Placement is tied to but- it is not an editable field.
103 | - Not used for insert or update.
104 |
105 | * **Green:** Updateable fields, used for inserting new items, and can be updated in subsequent executions.
106 |
107 | Column Headers that are underlined are required fields for their tab.
108 |
109 | [Back to top](#top_page)
110 |
111 | ### **Guide: Event Tag Creation for Existing Campaign**
112 | 1. Load From CM: at the Campaign-level. Enter the appropriate Campaign ID in the that field on the Campaign tab
113 |
114 | 2. Let's create a new Event Tag that we will apply in bulk to all Ads under one of our Placements.
115 |
116 | 3. Go to the Event Tag tab enter a new row:
117 | - Advertiser ID: The same Advertiser ID as your campaign, refer to the Campaign tab.
118 | - Campaign ID: Select your campaign ID from the dropdown.
119 | - Event Tag ID: extET1
120 | - Event Tag Name: Bulkdozer Event Tag 1
121 | - Event Tag Status: (ENABLED)
122 | - Enable By Default: (TRUE or FALSE)
123 | - Event Tag Type: (Image or Script)
124 | - Event Tag URL: Any valid url starting with https://
125 |
126 | 4. Associate Event tag to an Ad
127 | - Navigate to the Event Tag Ad Assignment tab:
128 | - Event Tag ID: Select EXT1 from dropdown
129 | - Ad ID: Select appropriate Ad ID from dropdown
130 | Status: Select ACTIVE from dropdown
131 |
132 | 5. Push to CM
133 |
134 | [Back to top](#top_page)
135 |
136 | ### **Guide: Create New Ads, Assign to Placements**
137 |
138 | 1. Load From CM: at the Campaign-level. Enter the appropriate Campaign ID in the that field on the Campaign tab
139 |
140 | 2. Create a new Ad
141 | - Go to the Ad tab and add a new row with the following:
142 | - Campaign ID: Pick the campaign ID from the drop down.
143 | - Ad Type: AD_SERVING_DEFAULT_AD
144 | - Ad Priority: AD_PRIORITY_1
145 | - Ad ID: extAD1
146 | - Ad Name: Bulkdozer new ad in existing campaign
147 | - Ad Start Date: Same as the start date in the Campaign tab
148 | - Ad End Date: Same as the end date in the Campaign tab
149 | - Fields not mentioned above can be left blank.
150 |
151 | 3. Assign new Ad to existing Placements: In the Ad Placement Assignment tab, add a new row:
152 | - Placement ID: Pick an existing placement ID from the drop down.
153 | - Ad ID: extAD1
154 |
155 | 4. Assign existing creative to new Ad: In the Ad Creative Assignment tab, add a new row and include:
156 | - Ad ID: extAD1
157 | - Creative ID: Pick a creative ID from the drop down.
158 | - Fields not mentioned above can be left blank.
159 |
160 | 5. Push to CM
161 |
162 | [Back to top](#top_page)
163 |
164 | ### **Guide: Bulk Creative Swap**
165 |
166 | **Important:** Bulkdozer helps with the swapping of Creative assignments. However, the actual creative must be previously loaded into either the Advertiser or (preferably) the Campaign. Bulkdozer does not facilitate the uploading of creatives.
167 |
168 | 1. Load From CM: at the Campaign-level. Enter the appropriate Campaign ID in the that field on the Campaign tab
169 |
170 | 2. (Skip this step if the creative is already uploaded to the Campaign-level) Pull Creative stored in the CM Advertiser into a specific Campaign. Go to the Creative Tab and enter a new row including:
171 | - Advertiser ID: The Advertiser ID associated with the applicable Campaign and Creative.
172 | - Campaign ID: Select your Campaign in the drop down.
173 | - Creative ID: The ID of the Creative you want to import from the Advertiser-level to the Campaign-level for assigning
174 | - Creative Name: Name of the Creative you want to import from the Advertiser-level to the Campaign-level for assigning (To note: what is entered here will replace that Creative’s Name at both the Advertiser and Campaigns-level. Leave blank if keeping the existing name)
175 |
176 | 3. Swap the creatives
177 | - Go to the Ad Creative Assignment tab, and update the Creative ID Column to be the ID of the Creative you want to swap in to each respective Ad.
178 |
179 | 4. Push to CM
180 |
181 | [Back to top](#top_page)
182 |
183 | # Terms and Conditions
184 |
185 | By using Bulkdozer the user agrees with the
186 | [Terms & Conditions](Terms_and_Conditions.md).
187 |
188 | [Back to top](#top_page)
189 |
190 |
191 |
192 | ## Support
193 |
194 | Bulkdozer is community supported, if you have any issues or questions please post a new issue [here](https://github.com/google/bulkdozer/issues).
195 |
196 | Sign up for updates and announcements:
197 | [Bulkdozer Announcements](https://groups.google.com/forum/#!forum/bulkdozer-announcements).
198 |
199 | [Back to top](#top_page)
200 |
201 | ## Other Resources
202 |
203 | The files below are a simplified version of [Bulkdozer 0.4](https://docs.google.com/spreadsheets/d/1jVXiLiAS_n9-7v7Ekw52UzNE2bEiCi7CCmjWxKB7kcw/edit?usp=sharing) and only include a specific Bulkdozer feature.
204 |
205 | **These modules are not actively maintained**
206 | - [QA Tools 0.15](https://docs.google.com/spreadsheets/d/10sZGoK8Z9BMb_6QKeOzJ4D698mJ4mNbtDY8ASfoyh9I/edit?usp=sharing)
207 | - [Event Tag QA Tool](https://docs.google.com/spreadsheets/d/1Pj4DqHibkTSoo6zQGDpksxpnxyhojNa3pBkMAvX0p6A/edit?usp=sharing&resourcekey=0-Vw7rukq3OH8cZLPR4IEk5g)
208 | - [Event Tag Editor 0.4](https://docs.google.com/spreadsheets/d/1_ox81ztsuxVaNsEjYvr783urGP41Hv1vnyP-hl0kUKo/edit?usp=sharing&resourcekey=0-CWepPTVXFFaBDg0CvD4rzg)
209 | - [Landing Page QA Tool & Editor 0.5](https://docs.google.com/spreadsheets/d/1TB9FxPfMWvaCikWvhMKnkfT-rd9LQskI-VI7QaVFJ7s/edit?usp=sharing)
210 | - [Key Value Editor 0.4](https://docs.google.com/spreadsheets/d/1Fn7sOlw89gC2mwedM1aSE0r-8l7hCWLFKYOZMfEG-F0/edit?usp=sharing&resourcekey=0-9ZmyZDAy0Yc75kUuagRecQ)
211 | - [Cost Editor 0.4](https://docs.google.com/spreadsheets/d/1PKtDaEeWUUt-22VxWqBqrAZ4ZohllCswA6d9EMJUIBc/edit?usp=sharing&resourcekey=0-jLeKkIWxah0YaHrdfab7nQ)
212 | - [Creative Loader 0.7](https://docs.google.com/spreadsheets/d/1U6LJesagSfb_jQbS-JZsqvt-3CEyB0mciO84SqiF_yc/edit?usp=sharing)
213 |
214 | [Back to top](#top_page)
215 |
--------------------------------------------------------------------------------
/sidebar.html:
--------------------------------------------------------------------------------
1 |
1229 |
--------------------------------------------------------------------------------