1 /*global jQuery:true, GCN: true */ 2 (function (GCN) { 3 4 'use strict'; 5 6 /** 7 * Checks whether or not a tag container instance has data for a tag of the 8 * given name. 9 * 10 * @param {TagContainerAPI} container The container in which to look 11 * for the tag. 12 * @param {string} tagname The name of the tag to find. 13 * @return {boolean} True if the container contains a data for the tag of 14 * the given name. 15 */ 16 function hasTagData(container, tagname) { 17 return !!container._data.tags[tagname]; 18 } 19 20 /** 21 * Extends the internal taglist with data from the given collection of tags. 22 * Will overwrite the data of any tag whose name is matched in the `tags' 23 * associative array. 24 * 25 * @param {TagContainerAPI} container The container in which to look 26 * for the tag. 27 * @param {object} tags Associative array of tag data, mapped against the 28 * data's corresponding tag name. 29 */ 30 function extendTags(container, tags) { 31 jQuery.extend(container._data.tags, tags); 32 } 33 34 /** 35 * Gets the construct matching the given keyword. 36 * 37 * @param {string} keyword Construct keyword. 38 * @param {NodeAPI} node The node inwhich to search for the construct. 39 * @param {function(object)} success Callback function to receive the 40 * successfully found construct. 41 * @param {function(GCNError):boolean} error Optional custom error handler. 42 */ 43 function getConstruct(keyword, node, success, error) { 44 node.constructs(function (constructs) { 45 if (constructs[keyword]) { 46 success(constructs[keyword]); 47 } else { 48 var err = GCN.createError( 49 'CONSTRUCT_NOT_FOUND', 50 'Cannot find construct `' + keyword + '\'', 51 constructs 52 ); 53 GCN.handleError(err, error); 54 } 55 }, error); 56 } 57 58 /** 59 * Creates an new tag via the GCN REST-API. 60 * 61 * @param {TagAPI} tag A representation of the tag which will be created in 62 * the GCN backend. 63 * @param {object} data The request body that will be serialized into json. 64 * @param {function(TagAPI)} success Callback function to receive the 65 * successfully created tag. 66 * @param {function(GCNError):boolean} error Optional custom error handler. 67 */ 68 function newTag(tag, data, success, error) { 69 var obj = tag.parent(); 70 var url = GCN.settings.BACKEND_PATH + '/rest/' + obj._type 71 + '/newtag/' + obj._data.id + GCN._getChannelParameter(obj); 72 obj._authAjax({ 73 type: 'POST', 74 url: url, 75 json: data, 76 error: function (xhr, status, msg) { 77 GCN.handleHttpError(xhr, msg, error); 78 tag._vacate(); 79 }, 80 success: function (response) { 81 obj._handleCreateTagResponse(tag, response, success, 82 error); 83 } 84 }, error); 85 } 86 87 /** 88 * Checks whether exactly one of the following combination of options is 89 * provided: 90 * 91 * 1. `keyword' alone 92 * or 93 * 2. `constructId' alone 94 * or 95 * 3. `sourcePageId' and `sourceTagname' together. 96 * 97 * Each of these options are mutually exclusive. 98 * 99 * @param {Object} options 100 * @return {boolean} True if only one combination of the possible options 101 * above is contained in the given options object. 102 */ 103 function isValidCreateTagOptions(options) { 104 // If the sum is 0, it means that no options was specified. 105 // 106 // If the sum is greater than 0 but less than 2, it means that either 107 // `sourcePageId' or `sourceTagname' was specified, but not both as 108 // required. 109 // 110 // If the sum is greater than 2, it means that more than one 111 // combination of settings was provided, which is one too many. 112 return 2 === (options.sourcePageId ? 1 : 0) + 113 (options.sourceTagname ? 1 : 0) + 114 (options.keyword ? 2 : 0) + 115 (options.constructId ? 2 : 0); 116 } 117 118 /** 119 * Parse the arguments passed into createTag() into a normalized object. 120 * 121 * @param {Arguments} createTagArgumenents An Arguments object. 122 * @parma {object} Normalized map of arguments. 123 */ 124 function parseCreateTagArguments(createTagArguments) { 125 var args = Array.prototype.slice.call(createTagArguments); 126 if (0 === args.length) { 127 return { 128 error: '`createtag()\' requires at least one argument. See ' + 129 'documentation.' 130 }; 131 } 132 133 var options; 134 135 // The first argument must either be a string, number or an object. 136 switch (jQuery.type(args[0])) { 137 case 'string': 138 options = {keyword: args[0]}; 139 break; 140 case 'number': 141 options = {constructId: args[0]}; 142 break; 143 case 'object': 144 if (!isValidCreateTagOptions(args[0])) { 145 return { 146 error: 'createTag() requires exactly one of the ' + 147 'following, mutually exclusive, settings to be' + 148 'used: either `keyword\', `constructId\' or a ' + 149 'combination of `sourcePageId\' and ' + 150 '`sourceTagname\'.' 151 }; 152 } 153 options = args[0]; 154 break; 155 default: 156 options = {}; 157 } 158 159 // Determine success() and error(): arguments 2-3. 160 var i; 161 for (i = 1; i < args.length; i++) { 162 if (jQuery.type(args[i]) === 'function') { 163 if (options.success) { 164 options.error = args[i]; 165 } else { 166 options.success = args[i]; 167 } 168 } 169 } 170 171 return { 172 options: options 173 }; 174 } 175 176 /** 177 * Given an object containing information about a tag, determines whether 178 * or not we should treat a tag as a editabled block. 179 * 180 * Relying on `onlyeditables' property to determine whether or not a given 181 * tag is a block or an editable is unreliable since it is possible to have 182 * a block which only contains editables: 183 * 184 * { 185 * "tagname":"content", 186 * "editables":[{ 187 * "element":"GENTICS_EDITABLE_1234", 188 * "readonly":false, 189 * "partname":"editablepart" 190 * }], 191 * "element":"GENTICS_BLOCK_1234", 192 * "onlyeditables":true 193 * "tagname":"tagwitheditable" 194 * } 195 * 196 * In the above example, even though `onlyeditable' is true the tag is 197 * still a block, since the tag's element and the editable's element are 198 * not the same. 199 * 200 * @param {object} tag A object holding the sets of blocks and editables 201 * that belong to a tag. 202 * @return {boolean} True if the tag 203 */ 204 function isBlock(tag) { 205 if (!tag.editables || tag.editables.length > 1) { 206 return true; 207 } 208 return ( 209 (1 === tag.editables.length) 210 && 211 (tag.editables[0].element !== tag.element) 212 ); 213 } 214 215 /** 216 * Given a data object received from a REST API "/rest/page/render" 217 * call maps the blocks and editables into a list of each. 218 * 219 * The set of blocks and the set of editables that are returned are not 220 * mutually exclusive--if a tag is determined to be both an editable 221 * and a block, it will be included in both sets. 222 * 223 * @param {object} data 224 * @return {object<string, Array.<object>>} A map containing a set of 225 * editables and a set of 226 * blocks. 227 */ 228 function getEditablesAndBlocks(data) { 229 if (!data || !data.tags) { 230 return { 231 blocks: [], 232 editables: [] 233 }; 234 } 235 236 var tag; 237 var tags = data.tags; 238 var blocks = []; 239 var editables = []; 240 var i; 241 var j; 242 243 for (i = 0; i < tags.length; i++) { 244 tag = tags[i]; 245 if (tag.editables) { 246 for (j = 0; j < tag.editables.length; j++) { 247 tag.editables[j].tagname = tag.tagname; 248 } 249 editables = editables.concat(tag.editables); 250 } 251 if (isBlock(tag)) { 252 blocks.push(tag); 253 } 254 } 255 256 return { 257 blocks: blocks, 258 editables: editables 259 }; 260 } 261 262 /** 263 * Abstract class that is implemented by tag containers such as 264 * {@link PageAPI} or {@link TemplateAPI} 265 * 266 * @class 267 * @name TagContainerAPI 268 */ 269 GCN.TagContainerAPI = GCN.defineChainback({ 270 /** @lends TagContainerAPI */ 271 272 /** 273 * @private 274 * @type {object<number, string>} Hash, mapping tag ids to their 275 * corresponding names. 276 */ 277 _tagIdToNameMap: null, 278 279 /** 280 * @private 281 * @type {object<number, string>} Hash, mapping tag ids to their 282 * corresponding names for newly created 283 * tags. 284 */ 285 _createdTagIdToNameMap: {}, 286 287 /** 288 * @private 289 * @type {Array.<object>} A set of blocks that are are to be removed 290 * from this content object when saving it. 291 * This array is populated during the save 292 * process. It get filled just before 293 * persisting the data to the server, and gets 294 * emptied as soon as the save operation 295 * succeeds. 296 */ 297 _deletedBlocks: [], 298 299 /** 300 * @private 301 * @type {Array.<object>} A set of tags that are are to be removed from 302 * from this content object when it is saved. 303 */ 304 _deletedTags: [], 305 306 /** 307 * Searching for a tag of a given id from the object structure that is 308 * returned by the REST API would require O(N) time. This function, 309 * builds a hash that maps the tag id with its corresponding name, so 310 * that it can be mapped in O(1) time instead. 311 * 312 * @private 313 * @return {object<number,string>} A hash map where the key is the tag 314 * id, and the value is the tag name. 315 */ 316 '!_mapTagIdsToNames': function () { 317 var name; 318 var map = {}; 319 var tags = this._data.tags; 320 for (name in tags) { 321 if (tags.hasOwnProperty(name)) { 322 map[tags[name].id] = name; 323 } 324 } 325 return map; 326 }, 327 328 /** 329 * Retrieves data for a tag from the internal data object. 330 * 331 * @private 332 * @param {string} name The name of the tag. 333 * @return {!object} The tag data, or null if a there if no tag 334 * matching the given name. 335 */ 336 '!_getTagData': function (name) { 337 return (this._data.tags && this._data.tags[name]) || 338 (this._shadow.tags && this._shadow.tags[name]); 339 }, 340 341 /** 342 * Get the tag whose id is `id'. 343 * Builds the `_tagIdToNameMap' hash map if it doesn't already exist. 344 * 345 * @todo: Should we deprecate this? 346 * @private 347 * @param {number} id Id of tag to retrieve. 348 * @return {object} The tag's data. 349 */ 350 '!_getTagDataById': function (id) { 351 if (!this._tagIdToNameMap) { 352 this._tagIdToNameMap = this._mapTagIdsToNames(); 353 } 354 return this._getTagData(this._tagIdToNameMap[id] || 355 this._createdTagIdToNameMap[id]); 356 }, 357 358 /** 359 * Extracts the editables and blocks that have been rendered from the 360 * REST API render call's response data. 361 * 362 * @param {object} data The response object received from the 363 * renderTemplate() call. 364 * @return {object} An object containing two properties: an array of 365 * blocks, and an array of editables. 366 */ 367 '!_processRenderedTags': getEditablesAndBlocks, 368 369 // !!! 370 // WARNING adding to folder is neccessary as jsdoc will report a 371 // name confict otherwise 372 // !!! 373 /** 374 * Get this content object's node. 375 * 376 * @function 377 * @name node 378 * @memberOf TagContainerAPI 379 * @param {funtion(NodeAPI)=} success Optional callback to receive a 380 * {@link NodeAPI} object as the 381 * only argument. 382 * @param {function(GCNError):boolean=} error Optional custom error 383 * handler. 384 * @return {NodeAPI} This object's node. 385 */ 386 '!node': function (success, error) { 387 return this.folder().node(); 388 }, 389 390 // !!! 391 // WARNING adding to folder is neccessary as jsdoc will report a 392 // name confict otherwise 393 // !!! 394 /** 395 * Get this content object's parent folder. 396 * 397 * @function 398 * @name folder 399 * @memberOf TagContainerAPI 400 * @param {funtion(FolderAPI)=} 401 * success Optional callback to receive a {@link FolderAPI} 402 * object as the only argument. 403 * @param {function(GCNError):boolean=} 404 * error Optional custom error handler. 405 * @return {FolderAPI} This object's parent folder. 406 */ 407 '!folder': function (success, error) { 408 var id = this._fetched ? this.prop('folderId') : null; 409 return this._continue(GCN.FolderAPI, id, success, error); 410 }, 411 412 /** 413 * Gets a tag of the specified id, contained in this content object. 414 * 415 * @name tag 416 * @function 417 * @memberOf TagContainerAPI 418 * @param {number} id Id of tag to retrieve. 419 * @param {function} success 420 * @param {function} error 421 * @return TagAPI 422 */ 423 '!tag': function (id, success, error) { 424 return this._continue(GCN.TagAPI, id, success, error); 425 }, 426 427 /** 428 * Retrieves a collection of tags from this content object. 429 * 430 * @name tags 431 * @function 432 * @memberOf TagContainerAPI 433 * @param {object|string|number} settings (Optional) 434 * @param {function} success callback 435 * @param {function} error (Optional) 436 * @return TagContainerAPI 437 */ 438 '!tags': function () { 439 var args = Array.prototype.slice.call(arguments); 440 441 if (args.length === 0) { 442 return; 443 } 444 445 var i; 446 var j = args.length; 447 var filter = {}; 448 var filters; 449 var hasFilter = false; 450 var success; 451 var error; 452 453 // Determine `success', `error', `filter' 454 for (i = 0; i < j; ++i) { 455 switch (jQuery.type(args[i])) { 456 case 'function': 457 if (success) { 458 error = args[i]; 459 } else { 460 success = args[i]; 461 } 462 break; 463 case 'number': 464 case 'string': 465 filters = [args[i]]; 466 break; 467 case 'array': 468 filters = args[i]; 469 break; 470 } 471 } 472 473 if (jQuery.type(filters) === 'array') { 474 var k = filters.length; 475 while (k) { 476 filter[filters[--k]] = true; 477 } 478 hasFilter = true; 479 } 480 481 var that = this; 482 483 if (success) { 484 this._read(function () { 485 var tags = that._data.tags; 486 var tag; 487 var list = []; 488 489 for (tag in tags) { 490 if (tags.hasOwnProperty(tag)) { 491 if (!hasFilter || filter[tag]) { 492 list.push(that._continue(GCN.TagAPI, tags[tag], 493 null, error)); 494 } 495 } 496 } 497 498 that._invoke(success, [list]); 499 }, error); 500 } 501 }, 502 503 /** 504 * Internal method to create a tag of a given tagtype in this content 505 * object. 506 * 507 * Not all tag containers allow for new tags to be created on them, 508 * therefore this method will only be surfaced by tag containers which 509 * do allow this. 510 * 511 * @private 512 * @param {string|number|object} construct either the keyword of the 513 * construct, or the ID of the construct 514 * or an object with the following 515 * properties 516 * <ul> 517 * <li><i>keyword</i> keyword of the construct</li> 518 * <li><i>constructId</i> ID of the construct</li> 519 * <li><i>magicValue</i> magic value to be filled into the tag</li> 520 * <li><i>sourcePageId</i> source page id</li> 521 * <li><i>sourceTagname</i> source tag name</li> 522 * </ul> 523 * @param {function(TagAPI)=} success Optional callback that will 524 * receive the newly created tag as 525 * its only argument. 526 * @param {function(GCNError):boolean=} error Optional custom error 527 * handler. 528 * @return {TagAPI} The newly created tag. 529 */ 530 '!_createTag': function () { 531 var args = parseCreateTagArguments(arguments); 532 533 if (args.error) { 534 GCN.handleError( 535 GCN.createError('INVALID_ARGUMENTS', args.error, arguments), 536 args.error 537 ); 538 return; 539 } 540 541 var obj = this; 542 543 // We use a uniqueId to avoid a fetus being created. 544 // This is to avoid the following scenario: 545 // 546 // var tag1 = container.createTag(...); 547 // var tag2 = container.createTag(...); 548 // tag1 === tag2 // is true which is wrong 549 // 550 // However, for all other cases, where we get an existing object, 551 // we want this behaviour: 552 // 553 // var folder1 = page(1).folder(...); 554 // var folder2 = page(1).folder(...); 555 // folder1 === folder2 // is true which is correct 556 // 557 // So, createTag() is different from other chainback methods in 558 // that each invokation must create a new instance, while other 559 // chainback methods must return the same. 560 // 561 // The id will be reset as soon as the tag object is realized. 562 // This happens below as soon as we get a success response with the 563 // correct tag id. 564 var newId = GCN.uniqueId('TagApi-unique-'); 565 566 // Create a new TagAPI instance linked to this tag container. Also 567 // acquire a lock on the newly created tag object so that any 568 // further operations on it will be queued until the tag object is 569 // fully realized. 570 var tag = obj._continue(GCN.TagAPI, newId)._procure(); 571 572 var options = args.options; 573 var copying = !!(options.sourcePageId && options.sourceTagname); 574 575 var onCreate = function () { 576 if (options.success) { 577 obj._invoke(options.success, [tag]); 578 } 579 tag._vacate(); 580 }; 581 582 if (copying) { 583 newTag(tag, { 584 copyPageId: options.sourcePageId, 585 copyTagname: options.sourceTagname 586 }, onCreate, options.error); 587 } else { 588 if (options.constructId) { 589 newTag(tag, { 590 magicValue: options.magicValue, 591 constructId: options.constructId 592 }, onCreate, options.error); 593 } else { 594 // ASSERT(options.keyword) 595 getConstruct(options.keyword, obj.node(), function (construct) { 596 newTag(tag, { 597 magicValue: options.magicValue, 598 constructId: construct.constructId 599 }, onCreate, options.error); 600 }, options.error); 601 } 602 } 603 604 return tag; 605 }, 606 607 /** 608 * Internal helper method to handle the create tag response. 609 * 610 * @private 611 * @param {TagAPI} tag 612 * @param {object} response response object from the REST call 613 * @param {function(TagContainerAPI)=} success optional success handler 614 * @param {function(GCNError):boolean=} error optional error handler 615 */ 616 '!_handleCreateTagResponse': function (tag, response, success, error) { 617 var obj = this; 618 619 if (GCN.getResponseCode(response) === 'OK') { 620 var data = response.tag; 621 tag._name = data.name; 622 tag._data = data; 623 tag._fetched = true; 624 625 // The tag's id is still the temporary unique id that was given 626 // to it in _createTag(). We have to realize the tag so that 627 // it gets the correct id. The new id changes its hash, so it 628 // must also be removed and reinserted from the caches. 629 tag._removeFromTempCache(tag); 630 tag._setHash(data.id)._addToCache(); 631 632 // Add this tag into the tag's container `_shadow' object, and 633 // `_tagIdToNameMap hash'. 634 var shouldCreateObjectIfUndefined = true; 635 obj._update('tags.' + GCN.escapePropertyName(data.name), 636 data, error, shouldCreateObjectIfUndefined); 637 638 // TODO: We need to store the tag inside the `_data' object for 639 // now. A change should be made so that when containers are 640 // saved, the data in the `_shadow' object is properly 641 // transfered into the _data object. 642 obj._data.tags[data.name] = data; 643 644 if (!obj.hasOwnProperty('_createdTagIdToNameMap')) { 645 obj._createdTagIdToNameMap = {}; 646 } 647 648 obj._createdTagIdToNameMap[data.id] = data.name; 649 650 tag.prop('active', true); 651 652 if (success) { 653 success(); 654 } 655 } else { 656 tag._die(GCN.getResponseCode(response)); 657 GCN.handleResponseError(response, error); 658 } 659 }, 660 661 /** 662 * Internal method to delete the specified tag from this content 663 * object. 664 * 665 * @private 666 * @param {string} keyword The keyword of the tag to be deleted. 667 * @param {function(TagContainerAPI)=} success Optional callback that 668 * receive this object as 669 * its only argument. 670 * @param {function(GCNError):boolean=} error Optional custom error 671 * handler. 672 */ 673 '!_removeTag': function (keyword, success, error) { 674 this.tag(keyword).remove(success, error); 675 }, 676 677 /** 678 * Internal method to delete a set of tags from this content object. 679 * 680 * @private 681 * @param {Array.<string>} keywords The keywords of the set of tags to be 682 * deleted. 683 * @param {function(TagContainerAPI)=} success Optional callback that 684 * receive this object as 685 * its only argument. 686 * @param {function(GCNError):boolean=} error Optional custom error 687 * handler. 688 */ 689 '!_removeTags': function (keywords, success, error) { 690 var that = this; 691 this.tags(keywords, function (tags) { 692 var j = tags.length; 693 while (j--) { 694 tags[j].remove(null, error); 695 } 696 if (success) { 697 that.save(success, error); 698 } 699 }, error); 700 } 701 702 }); 703 704 GCN.TagContainerAPI.hasTagData = hasTagData; 705 GCN.TagContainerAPI.extendTags = extendTags; 706 GCN.TagContainerAPI.getEditablesAndBlocks = getEditablesAndBlocks; 707 708 }(GCN)); 709