├── .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 | 17 | Status: Ready 18 |
19 |
20 |
21 |
22 |
23 | 24 | 25 | 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 | 17 | Status: Ready 18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 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 | --------------------------------------------------------------------------------