blob: ccf313638884140ab10c7e2e644fd690ca82a1fc [file] [log] [blame]
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * Functions in this script parse DevTools frontend .js and module.json files,
7 * collect localizable strings, check if frontend strings are in .grd/.grdp
8 * files and report error if present.
9 */
10
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000011const path = require('path');
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000012const localizationUtils = require('./localization_utils');
13const escodegen = localizationUtils.escodegen;
14const esprimaTypes = localizationUtils.esprimaTypes;
15const esprima = localizationUtils.esprima;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000016const extensionStringKeys = ['category', 'destination', 'title', 'title-mac'];
17
18// Format of frontendStrings
19// { IDS_md5-hash => {
20// string: string,
21// code: string,
Mandy Chen1e9d87b2019-09-18 17:18:15 +000022// isShared: boolean,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000023// filepath: string,
Mandy Chenc94d52a2019-06-11 22:51:53 +000024// grdpPath: string,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000025// location: {
26// start: {
27// line: number, (1-based)
28// column: number (0-based)
29// },
30// end: {
31// line: number,
32// column: number
33// }
34// },
35// arguments: string[]
36// }
37// }
38const frontendStrings = new Map();
39
40// Format
41// {
Mandy Chen4a7ad052019-07-16 16:09:29 +000042// IDS_KEY => a list of {
Mandy Chen81d4fc42019-07-11 23:12:02 +000043// actualIDSKey: string, // the IDS key in the message tag
Mandy Chenc94d52a2019-06-11 22:51:53 +000044// description: string,
Mandy Chen4a7ad052019-07-16 16:09:29 +000045// grdpPath: string,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000046// location: {
47// start: {
48// line: number
49// },
50// end: {
51// line: number
52// }
53// }
54// }
55// }
56const IDSkeys = new Map();
Mandy Chenc94d52a2019-06-11 22:51:53 +000057const fileToGRDPMap = new Map();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000058
59const devtoolsFrontendPath = path.resolve(__dirname, '..', '..', 'front_end');
60
Mandy Chen4a7ad052019-07-16 16:09:29 +000061async function parseLocalizableResourceMaps() {
Mandy Chenc94d52a2019-06-11 22:51:53 +000062 const grdpToFiles = new Map();
63 const dirs = await localizationUtils.getChildDirectoriesFromDirectory(devtoolsFrontendPath);
64 const grdpToFilesPromises = dirs.map(dir => {
65 const files = [];
66 grdpToFiles.set(path.resolve(dir, `${path.basename(dir)}_strings.grdp`), files);
67 return localizationUtils.getFilesFromDirectory(dir, files, ['.js', 'module.json']);
68 });
69 await Promise.all(grdpToFilesPromises);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000070
Mandy Chen4a7ad052019-07-16 16:09:29 +000071 const promises = [];
Mandy Chenc94d52a2019-06-11 22:51:53 +000072 for (const [grdpPath, files] of grdpToFiles) {
73 files.forEach(file => fileToGRDPMap.set(file, grdpPath));
Mandy Chen4a7ad052019-07-16 16:09:29 +000074 promises.push(parseLocalizableStrings(files));
Mandy Chenc94d52a2019-06-11 22:51:53 +000075 }
76 await Promise.all(promises);
Mandy Chen4a7ad052019-07-16 16:09:29 +000077 // Parse grd(p) files after frontend strings are processed so we know
78 // what to add or remove based on frontend strings
79 await parseIDSKeys(localizationUtils.GRD_PATH);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000080}
81
82/**
Mandy Chen7a8829b2019-06-25 22:13:07 +000083 * The following functions parse localizable strings (wrapped in Common.UIString,
84 * Common.UIStringFormat, UI.formatLocalized or ls``) from devtools frontend files.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000085 */
86
Mandy Chen4a7ad052019-07-16 16:09:29 +000087async function parseLocalizableStrings(devtoolsFiles) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000088 const promises = devtoolsFiles.map(filePath => parseLocalizableStringsFromFile(filePath));
89 await Promise.all(promises);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000090}
91
92async function parseLocalizableStringsFromFile(filePath) {
93 const fileContent = await localizationUtils.parseFileContent(filePath);
94 if (path.basename(filePath) === 'module.json')
95 return parseLocalizableStringFromModuleJson(fileContent, filePath);
96
Mandy Chen436efc72019-09-18 17:43:40 +000097 let ast;
98 try {
99 ast = esprima.parseModule(fileContent, {loc: true});
100 } catch (e) {
101 throw new Error(
102 `DevTools localization parser failed:\n${localizationUtils.getRelativeFilePathFromSrc(filePath)}: ${
103 e.message}` +
104 `\nThis error is likely due to unsupported JavaScript features.` +
105 ` Such features are not supported by eslint either and will cause presubmit to fail.` +
106 ` Please update the code and use official JavaScript features.`);
107 }
108 for (const node of ast.body) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000109 parseLocalizableStringFromNode(node, filePath);
Mandy Chen436efc72019-09-18 17:43:40 +0000110 }
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000111}
112
113function parseLocalizableStringFromModuleJson(fileContent, filePath) {
114 const fileJSON = JSON.parse(fileContent);
115 if (!fileJSON.extensions)
116 return;
117
118 for (const extension of fileJSON.extensions) {
119 for (const key in extension) {
120 if (extensionStringKeys.includes(key)) {
121 addString(extension[key], extension[key], filePath);
122 } else if (key === 'device') {
123 addString(extension.device.title, extension.device.title, filePath);
124 } else if (key === 'options') {
125 for (const option of extension.options) {
126 addString(option.title, option.title, filePath);
127 if (option.text !== undefined)
128 addString(option.text, option.text, filePath);
129 }
Mandy Chen609679b2019-09-10 16:04:08 +0000130 } else if (key === 'defaultValue' && Array.isArray(extension[key])) {
131 for (const defaultVal of extension[key]) {
132 if (defaultVal.title)
133 addString(defaultVal.title, defaultVal.title, filePath);
134 }
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000135 }
136 }
137 }
138}
139
140function parseLocalizableStringFromNode(node, filePath) {
141 if (!node)
142 return;
143
144 if (Array.isArray(node)) {
145 for (const child of node)
146 parseLocalizableStringFromNode(child, filePath);
147
148 return;
149 }
150
151 const keys = Object.keys(node);
152 const objKeys = keys.filter(key => key !== 'loc' && typeof node[key] === 'object');
153 if (objKeys.length === 0) {
154 // base case: all values are non-objects -> node is a leaf
155 return;
156 }
157
158 const locCase = localizationUtils.getLocalizationCase(node);
159 switch (locCase) {
160 case 'Common.UIString':
Mandy Chen7a8829b2019-06-25 22:13:07 +0000161 case 'Common.UIStringFormat':
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000162 handleCommonUIString(node, filePath);
163 break;
164 case 'UI.formatLocalized':
165 if (node.arguments !== undefined && node.arguments[1] !== undefined && node.arguments[1].elements !== undefined)
166 handleCommonUIString(node, filePath, node.arguments[1].elements);
167 break;
168 case 'Tagged Template':
169 handleTemplateLiteral(node.quasi, escodegen.generate(node), filePath);
170 break;
171 case null:
172 break;
173 default:
174 throw new Error(
175 `${filePath}${localizationUtils.getLocationMessage(node.loc)}: unexpected localization case for node: ${
176 escodegen.generate(node)}`);
177 }
178
179 for (const key of objKeys) {
180 // recursively parse all the child nodes
181 parseLocalizableStringFromNode(node[key], filePath);
182 }
183}
184
185function handleCommonUIString(node, filePath, argumentNodes) {
186 if (argumentNodes === undefined)
187 argumentNodes = node.arguments.slice(1);
188 const firstArgType = node.arguments[0].type;
189 switch (firstArgType) {
190 case esprimaTypes.LITERAL:
191 const message = node.arguments[0].value;
192 addString(message, escodegen.generate(node), filePath, node.loc, argumentNodes);
193 break;
194 case esprimaTypes.TEMP_LITERAL:
195 handleTemplateLiteral(node.arguments[0], escodegen.generate(node), filePath, argumentNodes);
196 break;
197 default:
198 break;
199 }
200}
201
202function handleTemplateLiteral(node, code, filePath, argumentNodes) {
203 if (node.expressions.length === 0) {
204 // template literal does not contain any variables, parse the value
205 addString(node.quasis[0].value.cooked, code, filePath, node.loc, argumentNodes);
206 return;
207 }
208
209 argumentNodes = node.expressions;
210 let processedMsg = '';
211 for (let i = 0; i < node.quasis.length; i++) {
212 processedMsg += node.quasis[i].value.cooked;
213 if (i < node.expressions.length) {
214 // add placeholder for variable so that
215 // the ph tag gets generated
216 processedMsg += '%s';
217 }
218 }
219 addString(processedMsg, code, filePath, node.loc, argumentNodes);
220}
221
222function addString(str, code, filePath, location, argumentNodes) {
Mandy Chen1e9d87b2019-09-18 17:18:15 +0000223 const ids = localizationUtils.getIDSKey(str);
224
225 // In the case of duplicates, the corresponding grdp message should be added
226 // to the shared strings file only if the duplicate strings span across different
227 // grdp files
228 const existingString = frontendStrings.get(ids);
229 if (existingString) {
230 if (!existingString.isShared && existingString.grdpPath !== fileToGRDPMap.get(filePath)) {
231 existingString.isShared = true;
232 existingString.grdpPath = localizationUtils.SHARED_STRINGS_PATH;
233 }
234 return;
235 }
236
237 const currentString =
238 {string: str, code: code, isShared: false, filepath: filePath, grdpPath: fileToGRDPMap.get(filePath)};
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000239 if (location)
240 currentString.location = location;
241 if (argumentNodes && argumentNodes.length > 0)
242 currentString.arguments = argumentNodes.map(argNode => escodegen.generate(argNode));
243
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000244 frontendStrings.set(ids, currentString);
245}
246
247/**
248 * The following functions parse <message>s and their IDS keys from
249 * devtools frontend grdp files.
250 */
251
Mandy Chen4a7ad052019-07-16 16:09:29 +0000252async function parseIDSKeys(grdFilePath) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000253 // NOTE: this function assumes that no <message> tags are present in the parent
254 const grdpFilePaths = await parseGRDFile(grdFilePath);
255 await parseGRDPFiles(grdpFilePaths);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000256}
257
258async function parseGRDFile(grdFilePath) {
259 const fileContent = await localizationUtils.parseFileContent(grdFilePath);
260 const grdFileDir = path.dirname(grdFilePath);
261 const partFileRegex = /<part file="(.*?)"/g;
262
263 let match;
264 const grdpFilePaths = new Set();
265 while ((match = partFileRegex.exec(fileContent)) !== null) {
266 if (match.index === partFileRegex.lastIndex)
267 partFileRegex.lastIndex++;
268 // match[0]: full match
269 // match[1]: part file path
270 grdpFilePaths.add(path.resolve(grdFileDir, match[1]));
271 }
272 return grdpFilePaths;
273}
274
275function parseGRDPFiles(grdpFilePaths) {
276 const promises = Array.from(grdpFilePaths, grdpFilePath => parseGRDPFile(grdpFilePath));
277 return Promise.all(promises);
278}
279
280function trimGrdpPlaceholder(placeholder) {
281 const exampleRegex = new RegExp('<ex>.*?<\/ex>', 'gms');
282 // $1s<ex>my example</ex> -> $1s
283 return placeholder.replace(exampleRegex, '').trim();
284}
285
286function convertToFrontendPlaceholders(message) {
287 // <ph name="phname">$1s<ex>my example</ex></ph> and <ph name="phname2">$2.3f</ph>
288 // match[0]: <ph name="phname1">$1s</ph>
289 // match[1]: $1s<ex>my example</ex>
290 let placeholderRegex = new RegExp('<ph[^>]*>(.*?)<\/ph>', 'gms');
291 let match;
292 while ((match = placeholderRegex.exec(message)) !== null) {
293 const placeholder = match[0];
294 const placeholderValue = trimGrdpPlaceholder(match[1]);
295 const newPlaceholderValue = placeholderValue.replace(/\$[1-9]/, '%');
296 message =
297 message.substring(0, match.index) + newPlaceholderValue + message.substring(match.index + placeholder.length);
298 // Modified the message, so search from the beginning of the string again.
299 placeholderRegex.lastIndex = 0;
300 }
301 return message;
302}
303
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000304async function parseGRDPFile(filePath) {
305 const fileContent = await localizationUtils.parseFileContent(filePath);
306
Mandy Chen78552632019-06-12 00:55:43 +0000307 function stripWhitespacePadding(message) {
308 let match = message.match(/^'''/);
309 if (match)
310 message = message.substring(3);
311 match = message.match(/(.*?)'''$/);
312 if (match)
313 message = match[1];
314 return message;
315 }
316
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000317 // Example:
Mandy Chen81d4fc42019-07-11 23:12:02 +0000318 // <message name="IDS_DEVTOOLS_md5_hash" desc="Description of this message">
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000319 // Message text here with optional placeholders <ph name="phname">$1s</ph>
320 // </message>
321 // match[0]: the entire '<message>...</message>' block.
Mandy Chen81d4fc42019-07-11 23:12:02 +0000322 // match[1]: 'IDS_DEVTOOLS_md5_hash'
323 // match[2]: 'Description of this message'
324 // match[3]: ' Message text here with optional placeholders <ph name="phname">$1s</ph>\n '
325 const messageRegex = new RegExp('<message[^>]*name="([^"]*)"[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>', 'gms');
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000326 let match;
327 while ((match = messageRegex.exec(fileContent)) !== null) {
Mandy Chend97200b2019-07-29 21:13:39 +0000328 const line = localizationUtils.lineNumberOfIndex(fileContent, match.index);
Mandy Chen81d4fc42019-07-11 23:12:02 +0000329 const actualIDSKey = match[1];
330 const description = match[2];
331 let message = match[3];
Mandy Chen78552632019-06-12 00:55:43 +0000332 message = convertToFrontendPlaceholders(message.trim());
333 message = stripWhitespacePadding(message);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000334 message = localizationUtils.sanitizeStringIntoFrontendFormat(message);
335
336 const ids = localizationUtils.getIDSKey(message);
Mandy Chen4a7ad052019-07-16 16:09:29 +0000337 addMessage(ids, actualIDSKey, filePath, line, description);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000338 }
339}
340
Mandy Chen4a7ad052019-07-16 16:09:29 +0000341function addMessage(expectedIDSKey, actualIDSKey, grdpPath, line, description) {
342 if (!IDSkeys.has(expectedIDSKey))
343 IDSkeys.set(expectedIDSKey, []);
344
345 IDSkeys.get(expectedIDSKey).push({actualIDSKey, grdpPath, location: {start: {line}, end: {line}}, description});
346}
347
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000348/**
349 * The following functions compare frontend localizable strings
Mandy Chen81d4fc42019-07-11 23:12:02 +0000350 * with grdp <message>s and report error of resources to add,
351 * remove or modify.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000352 */
Mandy Chen4a7ad052019-07-16 16:09:29 +0000353async function getAndReportResourcesToAdd() {
354 const keysToAddToGRD = getMessagesToAdd();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000355 if (keysToAddToGRD.size === 0)
356 return;
357
358 let errorStr = 'The following frontend string(s) need to be added to GRD/GRDP file(s).\n';
359 errorStr += 'Please refer to auto-generated message(s) below and modify as needed.\n\n';
360
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000361 // Example error message:
362 // third_party/blink/renderer/devtools/front_end/network/NetworkDataGridNode.js Line 973: ls`(disk cache)`
363 // Add a new message tag for this string to third_party\blink\renderer\devtools\front_end\network\network_strings.grdp
364 // <message name="IDS_DEVTOOLS_ad86890fb40822a3b12627efaca4ecd7" desc="Fill in the description.">
365 // (disk cache)
366 // </message>
367 for (const [key, stringObj] of keysToAddToGRD) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000368 errorStr += `${localizationUtils.getRelativeFilePathFromSrc(stringObj.filepath)}${
369 localizationUtils.getLocationMessage(stringObj.location)}: ${stringObj.code}\n`;
370 errorStr += `Add a new message tag for this string to ${
Mandy Chenc94d52a2019-06-11 22:51:53 +0000371 localizationUtils.getRelativeFilePathFromSrc(fileToGRDPMap.get(stringObj.filepath))}\n\n`;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000372 errorStr += localizationUtils.createGrdpMessage(key, stringObj);
373 }
374 return errorStr;
375}
376
Mandy Chen4a7ad052019-07-16 16:09:29 +0000377function getAndReportResourcesToRemove() {
378 const keysToRemoveFromGRD = getMessagesToRemove();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000379 if (keysToRemoveFromGRD.size === 0)
380 return;
381
382 let errorStr =
383 '\nThe message(s) associated with the following IDS key(s) should be removed from its GRD/GRDP file(s):\n';
384 // Example error message:
Mandy Chen4a7ad052019-07-16 16:09:29 +0000385 // third_party/blink/renderer/devtools/front_end/accessibility/accessibility_strings.grdp Line 300: IDS_DEVTOOLS_c9bbad3047af039c14d0e7ec957bb867
386 for (const [ids, messages] of keysToRemoveFromGRD) {
387 messages.forEach(
388 message => errorStr += `${localizationUtils.getRelativeFilePathFromSrc(message.grdpPath)}${
389 localizationUtils.getLocationMessage(message.location)}: ${ids}\n\n`);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000390 }
391 return errorStr;
392}
393
Mandy Chen81d4fc42019-07-11 23:12:02 +0000394function getAndReportIDSKeysToModify() {
395 const messagesToModify = getIDSKeysToModify();
396 if (messagesToModify.size === 0)
397 return;
398
399 let errorStr = '\nThe following GRD/GRDP message(s) do not have the correct IDS key.\n';
400 errorStr += 'Please update the key(s) by changing the "name" value.\n\n';
401
Mandy Chen4a7ad052019-07-16 16:09:29 +0000402 for (const [expectedIDSKey, messages] of messagesToModify) {
403 messages.forEach(
404 message => errorStr += `${localizationUtils.getRelativeFilePathFromSrc(message.grdpPath)}${
405 localizationUtils.getLocationMessage(
406 message.location)}:\n${message.actualIDSKey} --> ${expectedIDSKey}\n\n`);
Mandy Chen81d4fc42019-07-11 23:12:02 +0000407 }
408 return errorStr;
409}
410
Mandy Chen4a7ad052019-07-16 16:09:29 +0000411function getMessagesToAdd() {
412 // If a message with ids key exists in grdpPath
413 function messageExists(ids, grdpPath) {
414 const messages = IDSkeys.get(ids);
415 return messages.some(message => message.grdpPath === grdpPath);
416 }
417
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000418 const difference = [];
Mandy Chen4a7ad052019-07-16 16:09:29 +0000419 for (const [ids, frontendString] of frontendStrings) {
420 if (!IDSkeys.has(ids) || !messageExists(ids, frontendString.grdpPath))
421 difference.push([ids, frontendString]);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000422 }
423 return new Map(difference.sort());
424}
425
Mandy Chen4a7ad052019-07-16 16:09:29 +0000426// Return a map from the expected IDS key to a list of messages
427// whose actual IDS keys need to be modified.
Mandy Chen81d4fc42019-07-11 23:12:02 +0000428function getIDSKeysToModify() {
429 const messagesToModify = new Map();
Mandy Chen4a7ad052019-07-16 16:09:29 +0000430 for (const [expectedIDSKey, messages] of IDSkeys) {
431 for (const message of messages) {
432 if (expectedIDSKey !== message.actualIDSKey) {
433 if (messagesToModify.has(expectedIDSKey))
434 messagesToModify.get(expectedIDSKey).push(message);
435 else
436 messagesToModify.set(expectedIDSKey, [message]);
437 }
438 }
Mandy Chen81d4fc42019-07-11 23:12:02 +0000439 }
440 return messagesToModify;
441}
442
Mandy Chen4a7ad052019-07-16 16:09:29 +0000443function getMessagesToRemove() {
444 const difference = new Map();
445 for (const [ids, messages] of IDSkeys) {
446 if (!frontendStrings.has(ids)) {
447 difference.set(ids, messages);
448 continue;
449 }
450
451 const expectedGrdpPath = frontendStrings.get(ids).grdpPath;
452 const messagesInGrdp = [];
453 const messagesToRemove = [];
454 messages.forEach(message => {
455 if (message.grdpPath !== expectedGrdpPath)
456 messagesToRemove.push(message);
457 else
458 messagesInGrdp.push(message);
459 });
460
461 if (messagesToRemove.length === 0 && messagesInGrdp.length === 1)
462 continue;
463
464 if (messagesInGrdp.length > 1) {
465 // If there are more than one messages with ids in the
466 // expected grdp file, keep one with the longest
467 // description and delete all the other messages
468 const longestDescription = getLongestDescription(messagesInGrdp);
469 let foundMessageToKeep = false;
470 for (const message of messagesInGrdp) {
471 if (message.description === longestDescription && !foundMessageToKeep) {
472 foundMessageToKeep = true;
473 continue;
474 }
475 messagesToRemove.push(message);
476 }
477 }
478 difference.set(ids, messagesToRemove);
479 }
480 return difference;
481}
482
483function getLongestDescription(messages) {
484 let longestDescription = '';
485 messages.forEach(message => {
486 if (message.description.length > longestDescription.length)
487 longestDescription = message.description;
488 });
489 return longestDescription;
490}
491
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000492module.exports = {
Mandy Chenc94d52a2019-06-11 22:51:53 +0000493 frontendStrings,
494 IDSkeys,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000495 parseLocalizableResourceMaps,
Mandy Chen81d4fc42019-07-11 23:12:02 +0000496 getAndReportIDSKeysToModify,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000497 getAndReportResourcesToAdd,
498 getAndReportResourcesToRemove,
Mandy Chen4a7ad052019-07-16 16:09:29 +0000499 getIDSKeysToModify,
500 getLongestDescription,
501 getMessagesToAdd,
502 getMessagesToRemove,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000503};