search.js (49134B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 Zotero.Search = function(params = {}) { 27 Zotero.Search._super.apply(this); 28 29 this._name = null; 30 31 this._scope = null; 32 this._scopeIncludeChildren = null; 33 this._sql = null; 34 this._sqlParams = false; 35 this._maxSearchConditionID = -1; 36 this._conditions = {}; 37 this._hasPrimaryConditions = false; 38 39 Zotero.Utilities.assignProps(this, params, ['name', 'libraryID']); 40 } 41 42 Zotero.extendClass(Zotero.DataObject, Zotero.Search); 43 44 Zotero.Search.prototype._objectType = 'search'; 45 Zotero.Search.prototype._dataTypes = Zotero.Search._super.prototype._dataTypes.concat([ 46 'conditions' 47 ]); 48 49 Zotero.Search.prototype.getID = function(){ 50 Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.id'); 51 return this._id; 52 } 53 54 Zotero.Search.prototype.getName = function() { 55 Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.name'); 56 return this.name; 57 } 58 59 Zotero.Search.prototype.setName = function(val) { 60 Zotero.debug('Zotero.Search.setName() is deprecated -- use Search.name'); 61 this.name = val; 62 } 63 64 Zotero.defineProperty(Zotero.Search.prototype, 'id', { 65 get: function() { return this._get('id'); }, 66 set: function(val) { return this._set('id', val); } 67 }); 68 Zotero.defineProperty(Zotero.Search.prototype, 'libraryID', { 69 get: function() { return this._get('libraryID'); }, 70 set: function(val) { return this._set('libraryID', val); } 71 }); 72 Zotero.defineProperty(Zotero.Search.prototype, 'key', { 73 get: function() { return this._get('key'); }, 74 set: function(val) { return this._set('key', val); } 75 }); 76 Zotero.defineProperty(Zotero.Search.prototype, 'name', { 77 get: function() { return this._get('name'); }, 78 set: function(val) { return this._set('name', val); } 79 }); 80 Zotero.defineProperty(Zotero.Search.prototype, 'version', { 81 get: function() { return this._get('version'); }, 82 set: function(val) { return this._set('version', val); } 83 }); 84 Zotero.defineProperty(Zotero.Search.prototype, 'synced', { 85 get: function() { return this._get('synced'); }, 86 set: function(val) { return this._set('synced', val); } 87 }); 88 Zotero.defineProperty(Zotero.Search.prototype, 'conditions', { 89 get: function() { return this.getConditions(); } 90 }); 91 Zotero.defineProperty(Zotero.Search.prototype, '_canHaveParent', { 92 value: false 93 }); 94 95 Zotero.defineProperty(Zotero.Search.prototype, 'treeViewID', { 96 get: function () { 97 return "S" + this.id 98 } 99 }); 100 101 Zotero.defineProperty(Zotero.Search.prototype, 'treeViewImage', { 102 get: function () { 103 if (Zotero.isMac) { 104 return `chrome://zotero-platform/content/treesource-search${Zotero.hiDPISuffix}.png`; 105 } 106 return "chrome://zotero/skin/treesource-search" + Zotero.hiDPISuffix + ".png"; 107 } 108 }); 109 110 Zotero.Search.prototype.loadFromRow = function (row) { 111 var primaryFields = this._ObjectsClass.primaryFields; 112 for (let i=0; i<primaryFields.length; i++) { 113 let col = primaryFields[i]; 114 try { 115 var val = row[col]; 116 } 117 catch (e) { 118 Zotero.debug('Skipping missing ' + this._objectType + ' field ' + col); 119 continue; 120 } 121 122 switch (col) { 123 case this._ObjectsClass.idColumn: 124 col = 'id'; 125 break; 126 127 // Integer 128 case 'libraryID': 129 val = parseInt(val); 130 break; 131 132 // Integer or 0 133 case 'version': 134 val = val ? parseInt(val) : 0; 135 break; 136 137 // Boolean 138 case 'synced': 139 val = !!val; 140 break; 141 142 default: 143 val = val || ''; 144 } 145 146 this['_' + col] = val; 147 } 148 149 this._loaded.primaryData = true; 150 this._clearChanged('primaryData'); 151 this._identified = true; 152 } 153 154 Zotero.Search.prototype._initSave = Zotero.Promise.coroutine(function* (env) { 155 if (!this.name) { 156 throw new Error('Name not provided for saved search'); 157 } 158 return Zotero.Search._super.prototype._initSave.apply(this, arguments); 159 }); 160 161 Zotero.Search.prototype._saveData = Zotero.Promise.coroutine(function* (env) { 162 var isNew = env.isNew; 163 var options = env.options; 164 165 var searchID = this._id = this.id ? this.id : Zotero.ID.get('savedSearches'); 166 167 env.sqlColumns.push( 168 'savedSearchName' 169 ); 170 env.sqlValues.push( 171 { string: this.name } 172 ); 173 174 if (env.sqlColumns.length) { 175 if (isNew) { 176 env.sqlColumns.unshift('savedSearchID'); 177 env.sqlValues.unshift(searchID ? { int: searchID } : null); 178 179 let placeholders = env.sqlColumns.map(() => '?').join(); 180 let sql = "INSERT INTO savedSearches (" + env.sqlColumns.join(', ') + ") " 181 + "VALUES (" + placeholders + ")"; 182 yield Zotero.DB.queryAsync(sql, env.sqlValues); 183 } 184 else { 185 let sql = 'UPDATE savedSearches SET ' 186 + env.sqlColumns.map(x => x + '=?').join(', ') + ' WHERE savedSearchID=?'; 187 env.sqlValues.push(searchID ? { int: searchID } : null); 188 yield Zotero.DB.queryAsync(sql, env.sqlValues); 189 } 190 } 191 192 if (this._changed.conditions) { 193 if (!isNew) { 194 var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?"; 195 yield Zotero.DB.queryAsync(sql, this.id); 196 } 197 198 var i = 0; 199 var sql = "INSERT INTO savedSearchConditions " 200 + "(savedSearchID, searchConditionID, condition, operator, value, required) " 201 + "VALUES (?,?,?,?,?,?)"; 202 for (let id in this._conditions) { 203 let condition = this._conditions[id]; 204 205 // Convert condition and mode to "condition[/mode]" 206 let conditionString = condition.mode ? 207 condition.condition + '/' + condition.mode : 208 condition.condition 209 210 var sqlParams = [ 211 searchID, 212 i, 213 conditionString, 214 condition.operator ? condition.operator : null, 215 condition.value ? condition.value : null, 216 condition.required ? 1 : null 217 ]; 218 yield Zotero.DB.queryAsync(sql, sqlParams); 219 i++; 220 } 221 } 222 }); 223 224 Zotero.Search.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) { 225 if (env.isNew) { 226 // Update library searches status 227 yield Zotero.Libraries.get(this.libraryID).updateSearches(); 228 229 Zotero.Notifier.queue('add', 'search', this.id, env.notifierData, env.options.notifierQueue); 230 } 231 else if (!env.options.skipNotifier) { 232 Zotero.Notifier.queue('modify', 'search', this.id, env.notifierData, env.options.notifierQueue); 233 } 234 235 if (env.isNew && Zotero.Libraries.isGroupLibrary(this.libraryID)) { 236 var groupID = Zotero.Groups.getGroupIDFromLibraryID(this.libraryID); 237 var group = yield Zotero.Groups.get(groupID); 238 group.clearSearchCache(); 239 } 240 241 if (!env.skipCache) { 242 yield this.reload(); 243 // If new, there's no other data we don't have, so we can mark everything as loaded 244 if (env.isNew) { 245 this._markAllDataTypeLoadStates(true); 246 } 247 this._clearChanged(); 248 } 249 250 return env.isNew ? this.id : true; 251 }); 252 253 254 Zotero.Search.prototype.clone = function (libraryID) { 255 var s = new Zotero.Search(); 256 s.libraryID = libraryID === undefined ? this.libraryID : libraryID; 257 258 var conditions = this.getConditions(); 259 260 for (let condition of Object.values(conditions)) { 261 var name = condition.mode ? 262 condition.condition + '/' + condition.mode : 263 condition.condition 264 265 s.addCondition(name, condition.operator, condition.value, 266 condition.required); 267 } 268 269 return s; 270 }; 271 272 273 Zotero.Search.prototype._eraseData = Zotero.Promise.coroutine(function* (env) { 274 Zotero.DB.requireTransaction(); 275 276 var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?"; 277 yield Zotero.DB.queryAsync(sql, this.id); 278 279 var sql = "DELETE FROM savedSearches WHERE savedSearchID=?"; 280 yield Zotero.DB.queryAsync(sql, this.id); 281 }); 282 283 Zotero.Search.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) { 284 yield Zotero.Search._super.prototype._finalizeErase.call(this, env); 285 286 // Update library searches status 287 yield Zotero.Libraries.get(this.libraryID).updateSearches(); 288 }); 289 290 291 Zotero.Search.prototype.addCondition = function (condition, operator, value, required) { 292 this._requireData('conditions'); 293 294 if (!Zotero.SearchConditions.hasOperator(condition, operator)){ 295 let e = new Error("Invalid operator '" + operator + "' for condition " + condition); 296 e.name = "ZoteroUnknownFieldError"; 297 throw e; 298 } 299 300 // Shortcut to add a condition on every table -- does not return an id 301 if (condition.match(/^quicksearch/)) { 302 var parts = Zotero.SearchConditions.parseSearchString(value); 303 304 for (let part of parts) { 305 this.addCondition('blockStart'); 306 307 // Allow searching for exact object key 308 if (operator == 'contains' && Zotero.Utilities.isValidObjectKey(part.text)) { 309 this.addCondition('key', 'is', part.text, false); 310 } 311 312 if (condition == 'quicksearch-titleCreatorYear') { 313 this.addCondition('title', operator, part.text, false); 314 this.addCondition('publicationTitle', operator, part.text, false); 315 this.addCondition('shortTitle', operator, part.text, false); 316 this.addCondition('court', operator, part.text, false); 317 this.addCondition('year', operator, part.text, false); 318 } 319 else { 320 this.addCondition('field', operator, part.text, false); 321 this.addCondition('tag', operator, part.text, false); 322 this.addCondition('note', operator, part.text, false); 323 } 324 this.addCondition('creator', operator, part.text, false); 325 326 if (condition == 'quicksearch-everything') { 327 this.addCondition('annotation', operator, part.text, false); 328 329 if (part.inQuotes) { 330 this.addCondition('fulltextContent', operator, part.text, false); 331 } 332 else { 333 var splits = Zotero.Fulltext.semanticSplitter(part.text); 334 for (let split of splits) { 335 this.addCondition('fulltextWord', operator, split, false); 336 } 337 } 338 } 339 340 this.addCondition('blockEnd'); 341 } 342 343 if (condition == 'quicksearch-titleCreatorYear') { 344 this.addCondition('noChildren', 'true'); 345 } 346 347 return false; 348 } 349 // Shortcut to add a collection (which must be loaded first) 350 else if (condition == 'collectionID') { 351 let {libraryID, key} = Zotero.Collections.getLibraryAndKeyFromID(value); 352 if (!key) { 353 let msg = "Collection " + value + " not found"; 354 Zotero.debug(msg, 2); 355 Components.utils.reportError(msg); 356 return; 357 } 358 if (this.libraryID && libraryID != this.libraryID) { 359 Zotero.logError(new Error("Collection " + value + " is in different library")); 360 return; 361 } 362 return this.addCondition('collection', operator, key, required); 363 } 364 // Shortcut to add a saved search (which must be loaded first) 365 else if (condition == 'savedSearchID') { 366 let {libraryID, key} = Zotero.Searches.getLibraryAndKeyFromID(value); 367 if (!key) { 368 let msg = "Saved search " + value + " not found"; 369 Zotero.debug(msg, 2); 370 Components.utils.reportError(msg); 371 return; 372 } 373 if (this.libraryID && libraryID != this.libraryID) { 374 Zotero.logError(new Error("Collection " + value + " is in different library")); 375 return; 376 } 377 return this.addCondition('savedSearch', operator, key, required); 378 } 379 // Parse old-style collection/savedSearch conditions ('0_ABCD2345' -> 'ABCD2345') 380 else if (condition == 'collection' || condition == 'savedSearch') { 381 if (value.includes('_')) { 382 Zotero.logError(`'condition' value '${value}' should be an object key`); 383 let [_, objKey] = value.split('_'); 384 value = objKey; 385 } 386 } 387 388 var searchConditionID = ++this._maxSearchConditionID; 389 390 let mode; 391 [condition, mode] = Zotero.SearchConditions.parseCondition(condition); 392 393 if (typeof value == 'string') value = value.normalize(); 394 395 this._conditions[searchConditionID] = { 396 id: searchConditionID, 397 condition: condition, 398 mode: mode, 399 operator: operator, 400 value: value, 401 required: !!required 402 }; 403 404 this._sql = null; 405 this._sqlParams = false; 406 this._markFieldChange('conditions', this._conditions); 407 this._changed.conditions = true; 408 409 return searchConditionID; 410 } 411 412 413 /* 414 * Sets scope of search to the results of the passed Search object 415 */ 416 Zotero.Search.prototype.setScope = function (searchObj, includeChildren) { 417 this._scope = searchObj; 418 this._scopeIncludeChildren = includeChildren; 419 } 420 421 422 /** 423 * @param {Number} searchConditionID 424 * @param {String} condition 425 * @param {String} operator 426 * @param {String} value 427 * @param {Boolean} [required] 428 * @return {Promise} 429 */ 430 Zotero.Search.prototype.updateCondition = function (searchConditionID, condition, operator, value, required) { 431 this._requireData('conditions'); 432 433 if (typeof this._conditions[searchConditionID] == 'undefined'){ 434 throw new Error('Invalid searchConditionID ' + searchConditionID); 435 } 436 437 if (!Zotero.SearchConditions.hasOperator(condition, operator)){ 438 let e = new Error("Invalid operator '" + operator + "' for condition " + condition); 439 e.name = "ZoteroUnknownFieldError"; 440 throw e; 441 } 442 443 var [condition, mode] = Zotero.SearchConditions.parseCondition(condition); 444 445 if (typeof value == 'string') value = value.normalize(); 446 447 this._conditions[searchConditionID] = { 448 id: parseInt(searchConditionID), 449 condition: condition, 450 mode: mode, 451 operator: operator, 452 value: value, 453 required: !!required 454 }; 455 456 this._sql = null; 457 this._sqlParams = false; 458 this._markFieldChange('conditions', this._conditions); 459 this._changed.conditions = true; 460 } 461 462 463 Zotero.Search.prototype.removeCondition = function (searchConditionID) { 464 this._requireData('conditions'); 465 466 if (typeof this._conditions[searchConditionID] == 'undefined'){ 467 throw new Error('Invalid searchConditionID ' + searchConditionID + ' in removeCondition()'); 468 } 469 470 delete this._conditions[searchConditionID]; 471 this._maxSearchConditionID--; 472 this._markFieldChange('conditions', this._conditions); 473 this._changed.conditions = true; 474 } 475 476 477 /* 478 * Returns an array with 'condition', 'operator', 'value', 'required' 479 * for the given searchConditionID 480 */ 481 Zotero.Search.prototype.getCondition = function(searchConditionID){ 482 this._requireData('conditions'); 483 return this._conditions[searchConditionID]; 484 } 485 486 487 /* 488 * Returns an object of conditions/operator/value sets used in the search, 489 * indexed by searchConditionID 490 */ 491 Zotero.Search.prototype.getConditions = function(){ 492 this._requireData('conditions'); 493 var conditions = {}; 494 for (let id in this._conditions) { 495 let condition = this._conditions[id]; 496 conditions[id] = { 497 id: id, 498 condition: condition.condition, 499 mode: condition.mode, 500 operator: condition.operator, 501 value: condition.value, 502 required: condition.required 503 }; 504 } 505 return conditions; 506 } 507 508 509 Zotero.Search.prototype.hasPostSearchFilter = function() { 510 this._requireData('conditions'); 511 for (let i of Object.values(this._conditions)) { 512 if (i.condition == 'fulltextContent'){ 513 return true; 514 } 515 } 516 return false; 517 } 518 519 520 /** 521 * Run the search and return an array of item ids for results 522 * 523 * @param {Boolean} [asTempTable=false] 524 * @return {Promise} 525 */ 526 Zotero.Search.prototype.search = Zotero.Promise.coroutine(function* (asTempTable) { 527 var tmpTable; 528 529 // Mark conditions as loaded 530 // TODO: Necessary? 531 if (!this._identified) { 532 this._requireData('conditions'); 533 } 534 try { 535 if (!this._sql){ 536 yield this._buildQuery(); 537 } 538 539 // Default to 'all' mode 540 var joinMode = 'all'; 541 542 // Set some variables for conditions to avoid further lookups 543 for (let condition of Object.values(this._conditions)) { 544 switch (condition.condition) { 545 case 'joinMode': 546 if (condition.operator == 'any') { 547 joinMode = 'any'; 548 } 549 break; 550 551 case 'fulltextContent': 552 var fulltextContent = true; 553 break; 554 555 case 'includeParentsAndChildren': 556 if (condition.operator == 'true') { 557 var includeParentsAndChildren = true; 558 } 559 break; 560 561 case 'includeParents': 562 if (condition.operator == 'true') { 563 var includeParents = true; 564 } 565 break; 566 567 case 'includeChildren': 568 if (condition.operator == 'true') { 569 var includeChildren = true; 570 } 571 break; 572 573 case 'blockStart': 574 var hasQuicksearch = true; 575 break; 576 } 577 } 578 579 // Run a subsearch to define the superset of possible results 580 if (this._scope) { 581 // If subsearch has post-search filter, run and insert ids into temp table 582 if (this._scope.hasPostSearchFilter()) { 583 var ids = yield this._scope.search(); 584 if (!ids) { 585 return []; 586 } 587 tmpTable = yield Zotero.Search.idsToTempTable(ids); 588 } 589 // Otherwise, just copy to temp table directly 590 else { 591 tmpTable = "tmpSearchResults_" + Zotero.randomString(8); 592 var sql = "CREATE TEMPORARY TABLE " + tmpTable + " AS " 593 + (yield this._scope.getSQL()); 594 yield Zotero.DB.queryAsync(sql, yield this._scope.getSQLParams()); 595 var sql = "CREATE INDEX " + tmpTable + "_itemID ON " + tmpTable + "(itemID)"; 596 yield Zotero.DB.queryAsync(sql); 597 } 598 599 // Search ids in temp table 600 var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + this._sql + ") " 601 + "AND (" 602 + "itemID IN (SELECT itemID FROM " + tmpTable + ")"; 603 604 if (this._scopeIncludeChildren) { 605 sql += " OR itemID IN (SELECT itemID FROM itemAttachments" 606 + " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + ")) OR " 607 + "itemID IN (SELECT itemID FROM itemNotes" 608 + " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + "))"; 609 } 610 sql += ")"; 611 612 var res = yield Zotero.DB.valueQueryAsync(sql, this._sqlParams); 613 var ids = res ? res.split(",").map(id => parseInt(id)) : []; 614 /* 615 // DEBUG: Should this be here? 616 // 617 if (!ids) { 618 Zotero.DB.query("DROP TABLE " + tmpTable); 619 Zotero.DB.commitTransaction(); 620 return false; 621 } 622 */ 623 } 624 // Or just run main search 625 else { 626 var ids = yield Zotero.DB.columnQueryAsync(this._sql, this._sqlParams); 627 } 628 629 //Zotero.debug('IDs from main search or subsearch: '); 630 //Zotero.debug(ids); 631 632 //Zotero.debug('Join mode: ' + joinMode); 633 634 // Filter results with fulltext search 635 // 636 // If join mode ALL, return the (intersection of main and fulltext word search) 637 // filtered by fulltext content 638 // 639 // If join mode ANY or there's a quicksearch (which we assume 640 // fulltextContent is part of), return the union of the main search and 641 // (a separate fulltext word search filtered by fulltext content) 642 for (let condition of Object.values(this._conditions)){ 643 if (condition['condition']=='fulltextContent'){ 644 var fulltextWordIntersectionFilter = (val, index, array) => !!hash[val]; 645 var fulltextWordIntersectionConditionFilter = function(val, index, array) { 646 return hash[val] ? 647 (condition.operator == 'contains') : 648 (condition.operator == 'doesNotContain'); 649 }; 650 651 // Regexp mode -- don't use fulltext word index 652 if (condition.mode && condition.mode.startsWith('regexp')) { 653 // In an ANY search with other conditions that alter the results, only bother 654 // scanning items that haven't already been found by the main search, as long as 655 // they're in the right library 656 if (joinMode == 'any' && this._hasPrimaryConditions) { 657 if (!tmpTable) { 658 tmpTable = yield Zotero.Search.idsToTempTable(ids); 659 } 660 661 var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE " 662 + "itemID NOT IN (SELECT itemID FROM " + tmpTable + ")"; 663 if (this.libraryID) { 664 sql += " AND libraryID=" + parseInt(this.libraryID); 665 } 666 667 var res = yield Zotero.DB.valueQueryAsync(sql); 668 var scopeIDs = res ? res.split(",").map(id => parseInt(id)) : []; 669 } 670 // If an ALL search, scan only items from the main search 671 else { 672 var scopeIDs = ids; 673 } 674 } 675 // If not regexp mode, run a new search against the fulltext word 676 // index for words in this phrase 677 else { 678 Zotero.debug('Running subsearch against fulltext word index'); 679 var s = new Zotero.Search(); 680 if (this.libraryID) { 681 s.libraryID = this.libraryID; 682 } 683 684 // Add any necessary conditions to the fulltext word search -- 685 // those that are required in an ANY search and any outside the 686 // quicksearch in an ALL search 687 for (let c of Object.values(this._conditions)) { 688 if (c.condition == 'blockStart') { 689 var inQS = true; 690 continue; 691 } 692 else if (c.condition == 'blockEnd') { 693 inQS = false; 694 continue; 695 } 696 else if (c.condition == 'fulltextContent' || inQS) { 697 continue; 698 } 699 else if (joinMode == 'any' && !c.required) { 700 continue; 701 } 702 s.addCondition(c.condition, c.operator, c.value); 703 } 704 705 var splits = Zotero.Fulltext.semanticSplitter(condition.value); 706 for (let split of splits){ 707 s.addCondition('fulltextWord', condition.operator, split); 708 } 709 var fulltextWordIDs = yield s.search(); 710 711 //Zotero.debug("Fulltext word IDs"); 712 //Zotero.debug(fulltextWordIDs); 713 714 // If ALL mode, set intersection of main search and fulltext word index 715 // as the scope for the fulltext content search 716 if (joinMode == 'all' && !hasQuicksearch) { 717 var hash = {}; 718 for (let i=0; i<fulltextWordIDs.length; i++) { 719 hash[fulltextWordIDs[i]] = true; 720 } 721 722 if (ids) { 723 var scopeIDs = ids.filter(fulltextWordIntersectionFilter); 724 } 725 else { 726 var scopeIDs = []; 727 } 728 } 729 // If ANY mode, just use fulltext word index hits for content search, 730 // since the main results will be added in below 731 else { 732 var scopeIDs = fulltextWordIDs; 733 } 734 } 735 736 if (scopeIDs && scopeIDs.length) { 737 var fulltextIDs = yield Zotero.Fulltext.findTextInItems(scopeIDs, 738 condition['value'], condition['mode']); 739 740 var hash = {}; 741 for (let i=0; i<fulltextIDs.length; i++) { 742 hash[fulltextIDs[i].id] = true; 743 } 744 745 filteredIDs = scopeIDs.filter(fulltextWordIntersectionConditionFilter); 746 } 747 else { 748 var filteredIDs = []; 749 } 750 751 //Zotero.debug("Filtered IDs:") 752 //Zotero.debug(filteredIDs); 753 754 // If join mode ANY, add any new items from the fulltext content 755 // search to the main search results 756 // 757 // We only do this if there are primary conditions that alter the 758 // main search, since otherwise all items will match 759 if (this._hasPrimaryConditions && (joinMode == 'any' || hasQuicksearch)) { 760 //Zotero.debug("Adding filtered IDs to main set"); 761 for (let i=0; i<filteredIDs.length; i++) { 762 let id = filteredIDs[i]; 763 if (ids.indexOf(id) == -1) { 764 ids.push(id); 765 } 766 } 767 } 768 else { 769 //Zotero.debug("Replacing main set with filtered IDs"); 770 ids = filteredIDs; 771 } 772 } 773 } 774 775 if (this.hasPostSearchFilter() && 776 (includeParentsAndChildren || includeParents || includeChildren)) { 777 var tmpTable = yield Zotero.Search.idsToTempTable(ids); 778 779 if (includeParentsAndChildren || includeParents) { 780 //Zotero.debug("Adding parent items to result set"); 781 var sql = "SELECT parentItemID FROM itemAttachments " 782 + "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ") " 783 + " AND parentItemID IS NOT NULL " 784 + "UNION SELECT parentItemID FROM itemNotes " 785 + "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ")" 786 + " AND parentItemID IS NOT NULL"; 787 } 788 789 if (includeParentsAndChildren || includeChildren) { 790 //Zotero.debug("Adding child items to result set"); 791 var childrenSQL = "SELECT itemID FROM itemAttachments WHERE " 792 + "parentItemID IN (SELECT itemID FROM " + tmpTable + ") UNION " 793 + "SELECT itemID FROM itemNotes WHERE parentItemID IN " 794 + "(SELECT itemID FROM " + tmpTable + ")"; 795 796 if (includeParentsAndChildren || includeParents) { 797 sql += " UNION " + childrenSQL; 798 } 799 else { 800 sql = childrenSQL; 801 } 802 } 803 804 sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + sql + ")"; 805 var res = yield Zotero.DB.valueQueryAsync(sql); 806 var parentChildIDs = res ? res.split(",").map(id => parseInt(id)) : []; 807 808 // Add parents and children to main ids 809 for (let id of parentChildIDs) { 810 if (!ids.includes(id)) { 811 ids.push(id); 812 } 813 } 814 } 815 } 816 finally { 817 if (tmpTable && !asTempTable) { 818 yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable); 819 } 820 } 821 822 //Zotero.debug('Final result set'); 823 //Zotero.debug(ids); 824 825 if (!ids || !ids.length) { 826 return []; 827 } 828 829 if (asTempTable) { 830 return Zotero.Search.idsToTempTable(ids); 831 } 832 return ids; 833 }); 834 835 836 /** 837 * Populate the object's data from an API JSON data object 838 * 839 * If this object is identified (has an id or library/key), loadAll() must have been called. 840 */ 841 Zotero.Search.prototype.fromJSON = function (json) { 842 if (!json.name) { 843 throw new Error("'name' property not provided for search"); 844 } 845 this.name = json.name; 846 847 Object.keys(this.getConditions()).forEach(id => this.removeCondition(id)); 848 for (let i = 0; i < json.conditions.length; i++) { 849 let condition = json.conditions[i]; 850 this.addCondition( 851 condition.condition, 852 condition.operator, 853 condition.value 854 ); 855 } 856 } 857 858 859 Zotero.Search.prototype.toJSON = function (options = {}) { 860 var env = this._preToJSON(options); 861 var mode = env.mode; 862 863 var obj = env.obj = {}; 864 obj.key = this.key; 865 obj.version = this.version; 866 obj.name = this.name; 867 var conditions = this.getConditions(); 868 obj.conditions = Object.keys(conditions) 869 .map(x => ({ 870 condition: conditions[x].condition 871 + (conditions[x].mode !== false ? "/" + conditions[x].mode : ""), 872 operator: conditions[x].operator, 873 // TODO: Change joinMode to use 'is' + 'any' instead of operator 'any'? 874 value: conditions[x].value ? conditions[x].value : "" 875 })); 876 return this._postToJSON(env); 877 } 878 879 880 /* 881 * Get the SQL string for the search 882 */ 883 Zotero.Search.prototype.getSQL = Zotero.Promise.coroutine(function* () { 884 if (!this._sql) { 885 yield this._buildQuery(); 886 } 887 return this._sql; 888 }); 889 890 891 Zotero.Search.prototype.getSQLParams = Zotero.Promise.coroutine(function* () { 892 if (!this._sql) { 893 yield this._buildQuery(); 894 } 895 return this._sqlParams; 896 }); 897 898 899 /* 900 * Batch insert 901 */ 902 Zotero.Search.idsToTempTable = Zotero.Promise.coroutine(function* (ids) { 903 var tmpTable = "tmpSearchResults_" + Zotero.randomString(8); 904 905 Zotero.debug(`Creating ${tmpTable} with ${ids.length} item${ids.length != 1 ? 's' : ''}`); 906 var sql = "CREATE TEMPORARY TABLE " + tmpTable; 907 if (ids.length) { 908 sql += " AS " 909 + "WITH cte(itemID) AS (" 910 + "VALUES " + ids.map(id => "(" + parseInt(id) + ")").join(',') 911 + ") " 912 + "SELECT * FROM cte"; 913 } 914 else { 915 sql += " (itemID INTEGER PRIMARY KEY)"; 916 } 917 yield Zotero.DB.queryAsync(sql, false, { debug: false }); 918 if (ids.length) { 919 yield Zotero.DB.queryAsync(`CREATE UNIQUE INDEX ${tmpTable}_itemID ON ${tmpTable}(itemID)`); 920 } 921 922 return tmpTable; 923 }); 924 925 926 /* 927 * Build the SQL query for the search 928 */ 929 Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () { 930 this._requireData('conditions'); 931 932 var sql = 'SELECT itemID FROM items'; 933 var sqlParams = []; 934 // Separate ANY conditions for 'required' condition support 935 var anySQL = ''; 936 var anySQLParams = []; 937 938 var conditions = []; 939 940 let lastCondition; 941 for (let condition of Object.values(this._conditions)) { 942 let name = condition.condition; 943 let conditionData = Zotero.SearchConditions.get(name); 944 945 // Has a table (or 'savedSearch', which doesn't have a table but isn't special) 946 if (conditionData.table || name == 'savedSearch' || name == 'tempTable') { 947 // For conditions with an inline filter using 'is'/'isNot', combine with last condition 948 // if the same 949 if (lastCondition 950 && ((!lastCondition.alias && !condition.alias && name == lastCondition.name) 951 || (lastCondition.alias && condition.alias && lastCondition.alias == condition.alias)) 952 && condition.operator.startsWith('is') 953 && condition.operator == lastCondition.operator 954 && conditionData.inlineFilter) { 955 if (!Array.isArray(lastCondition.value)) { 956 lastCondition.value = [lastCondition.value]; 957 } 958 lastCondition.value.push(condition.value); 959 continue; 960 } 961 962 lastCondition = { 963 name: conditionData.name, 964 alias: conditionData.name != name ? name : false, 965 table: conditionData.table, 966 field: conditionData.field, 967 operator: condition.operator, 968 value: condition.value, 969 flags: conditionData.flags, 970 required: condition.required, 971 inlineFilter: conditionData.inlineFilter 972 }; 973 conditions.push(lastCondition); 974 975 this._hasPrimaryConditions = true; 976 } 977 978 // Handle special conditions 979 else { 980 switch (conditionData.name) { 981 case 'deleted': 982 var deleted = condition.operator == 'true'; 983 continue; 984 985 case 'noChildren': 986 var noChildren = condition.operator == 'true'; 987 continue; 988 989 case 'includeParentsAndChildren': 990 var includeParentsAndChildren = condition.operator == 'true'; 991 continue; 992 993 case 'includeParents': 994 var includeParents = condition.operator == 'true'; 995 continue; 996 997 case 'includeChildren': 998 var includeChildren = condition.operator == 'true'; 999 continue; 1000 1001 case 'unfiled': 1002 var unfiled = condition.operator == 'true'; 1003 continue; 1004 1005 case 'publications': 1006 var publications = condition.operator == 'true'; 1007 continue; 1008 1009 // Search subcollections 1010 case 'recursive': 1011 var recursive = condition.operator == 'true'; 1012 continue; 1013 1014 // Join mode ('any' or 'all') 1015 case 'joinMode': 1016 var joinMode = condition.operator.toUpperCase(); 1017 continue; 1018 1019 case 'fulltextContent': 1020 // Handled in Search.search() 1021 continue; 1022 1023 // For quicksearch block markers 1024 case 'blockStart': 1025 conditions.push({name:'blockStart'}); 1026 continue; 1027 case 'blockEnd': 1028 conditions.push({name:'blockEnd'}); 1029 continue; 1030 } 1031 1032 throw new Error('Unhandled special condition ' + name); 1033 } 1034 } 1035 1036 // Exclude deleted items (and their child items) by default 1037 let not = deleted ? "" : "NOT "; 1038 let op = deleted ? "OR" : "AND"; 1039 sql += " WHERE (" 1040 + `itemID ${not} IN (SELECT itemID FROM deletedItems) ` 1041 + `${op} itemID ${not}IN (SELECT itemID FROM itemNotes ` 1042 + "WHERE parentItemID IS NOT NULL AND " 1043 + "parentItemID IN (SELECT itemID FROM deletedItems)) " 1044 + `${op} itemID ${not}IN (SELECT itemID FROM itemAttachments ` 1045 + "WHERE parentItemID IS NOT NULL AND " 1046 + "parentItemID IN (SELECT itemID FROM deletedItems))" 1047 + ")"; 1048 1049 if (noChildren){ 1050 sql += " AND (itemID NOT IN (SELECT itemID FROM itemNotes " 1051 + "WHERE parentItemID IS NOT NULL) AND itemID NOT IN " 1052 + "(SELECT itemID FROM itemAttachments " 1053 + "WHERE parentItemID IS NOT NULL))"; 1054 } 1055 1056 if (unfiled) { 1057 sql += " AND (itemID NOT IN (SELECT itemID FROM collectionItems) " 1058 // Exclude children 1059 + "AND itemID NOT IN " 1060 + "(SELECT itemID FROM itemAttachments WHERE parentItemID IS NOT NULL " 1061 + "UNION SELECT itemID FROM itemNotes WHERE parentItemID IS NOT NULL)" 1062 + ") " 1063 // Exclude My Publications 1064 + "AND itemID NOT IN (SELECT itemID FROM publicationsItems)"; 1065 } 1066 1067 if (publications) { 1068 sql += " AND (itemID IN (SELECT itemID FROM publicationsItems))"; 1069 } 1070 1071 // Limit to library search belongs to 1072 // 1073 // This is equivalent to adding libraryID as a search condition, 1074 // but it works with ANY 1075 if (this.libraryID !== null) { 1076 sql += " AND (itemID IN (SELECT itemID FROM items WHERE libraryID=?))"; 1077 sqlParams.push(this.libraryID); 1078 } 1079 1080 if (this._hasPrimaryConditions) { 1081 sql += " AND "; 1082 1083 for (let condition of Object.values(conditions)){ 1084 var skipOperators = false; 1085 var openParens = 0; 1086 var condSQL = ''; 1087 var selectOpenParens = 0; 1088 var condSelectSQL = ''; 1089 var condSQLParams = []; 1090 1091 // 1092 // Special table handling 1093 // 1094 if (condition['table']){ 1095 switch (condition['table']){ 1096 default: 1097 condSelectSQL += 'itemID ' 1098 switch (condition['operator']){ 1099 case 'isNot': 1100 case 'doesNotContain': 1101 condSelectSQL += 'NOT '; 1102 break; 1103 } 1104 condSelectSQL += 'IN ('; 1105 selectOpenParens = 1; 1106 condSQL += 'SELECT itemID FROM ' + 1107 condition['table'] + ' WHERE ('; 1108 openParens = 1; 1109 } 1110 } 1111 1112 // 1113 // Special condition handling 1114 // 1115 switch (condition['name']){ 1116 case 'field': 1117 case 'datefield': 1118 case 'numberfield': 1119 if (condition['alias']) { 1120 // Add base field 1121 condSQLParams.push( 1122 Zotero.ItemFields.getID(condition['alias']) 1123 ); 1124 var typeFields = Zotero.ItemFields.getTypeFieldsFromBase(condition['alias']); 1125 if (typeFields) { 1126 condSQL += 'fieldID IN (?,'; 1127 // Add type-specific fields 1128 for (let fieldID of typeFields) { 1129 condSQL += '?,'; 1130 condSQLParams.push(fieldID); 1131 } 1132 condSQL = condSQL.substr(0, condSQL.length - 1); 1133 condSQL += ') AND '; 1134 } 1135 else { 1136 condSQL += 'fieldID=? AND '; 1137 } 1138 } 1139 1140 condSQL += "valueID IN (SELECT valueID FROM " 1141 + "itemDataValues WHERE "; 1142 1143 openParens++; 1144 break; 1145 1146 case 'year': 1147 condSQLParams.push(Zotero.ItemFields.getID('date')); 1148 //Add base field 1149 var dateFields = Zotero.ItemFields.getTypeFieldsFromBase('date'); 1150 if (dateFields) { 1151 condSQL += 'fieldID IN (?,'; 1152 // Add type-specific date fields (dateEnacted, dateDecided, issueDate) 1153 for (let fieldID of dateFields) { 1154 condSQL += '?,'; 1155 condSQLParams.push(fieldID); 1156 } 1157 condSQL = condSQL.substr(0, condSQL.length - 1); 1158 condSQL += ') AND '; 1159 } 1160 1161 condSQL += "valueID IN (SELECT valueID FROM " 1162 + "itemDataValues WHERE "; 1163 1164 openParens++; 1165 break; 1166 1167 case 'collection': 1168 case 'savedSearch': 1169 let obj; 1170 let objLibraryID; 1171 let objKey = condition.value; 1172 let objectType = condition.name == 'collection' ? 'collection' : 'search'; 1173 let objectTypeClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1174 1175 // libraryID assigned on search 1176 if (this.libraryID !== null) { 1177 objLibraryID = this.libraryID; 1178 } 1179 1180 // If search doesn't have a libraryID, check all possible libraries 1181 // for the collection/search 1182 if (objLibraryID === undefined) { 1183 let foundLibraryID = false; 1184 for (let c of Object.values(this._conditions)) { 1185 if (c.condition == 'libraryID' && c.operator == 'is') { 1186 foundLibraryID = true; 1187 obj = yield objectTypeClass.getByLibraryAndKeyAsync( 1188 c.value, objKey 1189 ); 1190 if (obj) { 1191 break; 1192 } 1193 } 1194 } 1195 if (!foundLibraryID) { 1196 Zotero.debug("WARNING: libraryID condition not found for " 1197 + objectType + " in search", 2); 1198 } 1199 } 1200 else { 1201 obj = yield objectTypeClass.getByLibraryAndKeyAsync( 1202 objLibraryID, objKey 1203 ); 1204 } 1205 if (!obj) { 1206 var msg = objectType.charAt(0).toUpperCase() + objectType.substr(1) 1207 + " " + objKey + " specified in search not found"; 1208 Zotero.debug(msg, 2); 1209 Zotero.log(msg, 'warning', 'chrome://zotero/content/xpcom/search.js'); 1210 if (objectType == 'search') { 1211 continue; 1212 } 1213 obj = { 1214 id: 0 1215 }; 1216 } 1217 if (objectType == 'search' && obj == this) { 1218 Zotero.warn(`Search "${this.name}" references itself -- skipping condition`); 1219 continue; 1220 } 1221 1222 if (objectType == 'collection') { 1223 let ids = [obj.id]; 1224 1225 // Search descendent collections if recursive search 1226 if (recursive){ 1227 ids = ids.concat(obj.getDescendents(false, 'collection').map(d => d.id)); 1228 } 1229 1230 condSQL += 'collectionID IN (' + ids.join(', ') + ')'; 1231 } 1232 // Saved search 1233 else { 1234 // Check if there are any post-search filters 1235 var hasFilter = obj.hasPostSearchFilter(); 1236 1237 // This is an ugly and inefficient way of doing a 1238 // subsearch, but it's necessary if there are any 1239 // post-search filters (e.g. fulltext scanning) in the 1240 // subsearch 1241 // 1242 // DEBUG: it's possible there's a query length limit here 1243 // or that this slows things down with large libraries 1244 // -- should probably use a temporary table instead 1245 if (hasFilter){ 1246 let subids = yield obj.search(); 1247 condSQL += subids.join(); 1248 } 1249 // Otherwise just put the SQL in a subquery 1250 else { 1251 condSQL += "itemID "; 1252 if (condition.operator == 'isNot') { 1253 condSQL += "NOT "; 1254 } 1255 condSQL += "IN ("; 1256 condSQL += yield obj.getSQL(); 1257 let subpar = yield obj.getSQLParams(); 1258 for (let k in subpar){ 1259 condSQLParams.push(subpar[k]); 1260 } 1261 } 1262 condSQL += ")"; 1263 } 1264 1265 skipOperators = true; 1266 break; 1267 1268 case 'itemType': 1269 condSQL += "itemTypeID IN (SELECT itemTypeID FROM itemTypesCombined WHERE "; 1270 openParens++; 1271 break; 1272 1273 case 'fileTypeID': 1274 var ftSQL = 'SELECT mimeType FROM fileTypeMimeTypes ' 1275 + 'WHERE fileTypeID IN (' 1276 + 'SELECT fileTypeID FROM fileTypes WHERE ' 1277 + 'fileTypeID=?)'; 1278 var patterns = yield Zotero.DB.columnQueryAsync(ftSQL, { int: condition.value }); 1279 if (patterns) { 1280 for (let str of patterns) { 1281 condSQL += 'contentType LIKE ? OR '; 1282 condSQLParams.push(str + '%'); 1283 } 1284 condSQL = condSQL.substring(0, condSQL.length - 4); 1285 } 1286 else { 1287 throw ("Invalid fileTypeID '" + condition.value + "' specified in search.js") 1288 } 1289 skipOperators = true; 1290 break; 1291 1292 case 'tag': 1293 condSQL += "tagID IN (SELECT tagID FROM tags WHERE "; 1294 openParens++; 1295 break; 1296 1297 case 'creator': 1298 case 'lastName': 1299 condSQL += "creatorID IN (SELECT creatorID FROM creators WHERE "; 1300 openParens++; 1301 break; 1302 1303 case 'childNote': 1304 condSQL += "itemID IN (SELECT parentItemID FROM " 1305 + "itemNotes WHERE "; 1306 openParens++; 1307 break; 1308 1309 case 'fulltextWord': 1310 condSQL += "wordID IN (SELECT wordID FROM fulltextWords " 1311 + "WHERE "; 1312 openParens++; 1313 break; 1314 1315 case 'tempTable': 1316 condSQL += "itemID IN (SELECT id FROM " + condition.value + ")"; 1317 skipOperators = true; 1318 break; 1319 1320 // For quicksearch blocks 1321 case 'blockStart': 1322 case 'blockEnd': 1323 skipOperators = true; 1324 break; 1325 } 1326 1327 if (!skipOperators){ 1328 // Special handling for date fields 1329 // 1330 // Note: We assume full datetimes are already UTC and don't 1331 // need to be handled specially 1332 if ((condition['name']=='dateAdded' || 1333 condition['name']=='dateModified' || 1334 condition['name']=='datefield') && 1335 !Zotero.Date.isSQLDateTime(condition['value'])){ 1336 1337 // TODO: document these flags 1338 var parseDate = null; 1339 var alt = null; 1340 var useFreeform = null; 1341 1342 switch (condition['operator']){ 1343 case 'is': 1344 case 'isNot': 1345 var parseDate = true; 1346 var alt = '__'; 1347 var useFreeform = true; 1348 break; 1349 1350 case 'isBefore': 1351 var parseDate = true; 1352 var alt = '00'; 1353 var useFreeform = false; 1354 break; 1355 1356 case 'isAfter': 1357 var parseDate = true; 1358 // '__' used here just so the > string comparison 1359 // doesn't match dates in the specified year 1360 var alt = '__'; 1361 var useFreeform = false; 1362 break; 1363 1364 case 'isInTheLast': 1365 var parseDate = false; 1366 break; 1367 1368 default: 1369 throw ('Invalid date field operator in search'); 1370 } 1371 1372 // Convert stored UTC dates to localtime 1373 // 1374 // It'd be nice not to deal with time zones here at all, 1375 // but otherwise searching for the date part of a field 1376 // stored as UTC that wraps midnight would be unsuccessful 1377 if (condition['name']=='dateAdded' || 1378 condition['name']=='dateModified' || 1379 condition['alias']=='accessDate'){ 1380 condSQL += "DATE(" + condition['field'] + ", 'localtime')"; 1381 } 1382 // Only use first (SQL) part of multipart dates 1383 else { 1384 condSQL += "SUBSTR(" + condition['field'] + ", 1, 10)"; 1385 } 1386 1387 if (parseDate){ 1388 var go = false; 1389 var dateparts = Zotero.Date.strToDate(condition.value); 1390 1391 // Search on SQL date -- underscore is 1392 // single-character wildcard 1393 // 1394 // If isBefore or isAfter, month and day fall back 1395 // to '00' so that a search for just a year works 1396 // (and no year will just not find anything) 1397 var sqldate = dateparts.year ? 1398 Zotero.Utilities.lpad(dateparts.year, '0', 4) : '____'; 1399 sqldate += '-' 1400 sqldate += dateparts.month || dateparts.month === 0 ? 1401 Zotero.Utilities.lpad(dateparts.month + 1, '0', 2) : alt; 1402 sqldate += '-'; 1403 sqldate += dateparts.day ? 1404 Zotero.Utilities.lpad(dateparts.day, '0', 2) : alt; 1405 1406 if (sqldate!='____-__-__'){ 1407 go = true; 1408 1409 switch (condition['operator']){ 1410 case 'is': 1411 case 'isNot': 1412 condSQL += ' LIKE ?'; 1413 break; 1414 1415 case 'isBefore': 1416 condSQL += '<?'; 1417 condSQL += ' AND ' + condition['field'] + 1418 ">'0000-00-00'"; 1419 break; 1420 1421 case 'isAfter': 1422 condSQL += '>?'; 1423 break; 1424 } 1425 1426 condSQLParams.push({string:sqldate}); 1427 } 1428 1429 // Search for any remaining parts individually 1430 if (useFreeform && dateparts['part']){ 1431 go = true; 1432 var parts = dateparts['part'].split(' '); 1433 for (let part of parts) { 1434 condSQL += " AND SUBSTR(" + condition['field'] + ", 12, 100)"; 1435 condSQL += " LIKE ?"; 1436 condSQLParams.push('%' + part + '%'); 1437 } 1438 } 1439 1440 // If neither part used, invalidate clause 1441 if (!go){ 1442 condSQL += '=0'; 1443 } 1444 } 1445 1446 else { 1447 switch (condition['operator']){ 1448 case 'isInTheLast': 1449 condSQL += ">DATE('NOW', 'localtime', ?)"; // e.g. ('NOW', '-10 DAYS') 1450 condSQLParams.push({string: '-' + condition['value']}); 1451 break; 1452 } 1453 } 1454 } 1455 1456 // Non-date fields 1457 else { 1458 switch (condition.operator) { 1459 // Cast strings as integers for < and > comparisons, 1460 // at least until 1461 case 'isLessThan': 1462 case 'isGreaterThan': 1463 condSQL += "CAST(" + condition['field'] + " AS INT)"; 1464 // Make sure either field is an integer or 1465 // converting to an integer and back to a string 1466 // yields the same result (i.e. it's numeric) 1467 var opAppend = " AND (TYPEOF(" 1468 + condition['field'] + ") = 'integer' OR " 1469 + "CAST(" 1470 + "CAST(" + condition['field'] + " AS INT)" 1471 + " AS STRING) = " + condition['field'] + ")" 1472 break; 1473 1474 default: 1475 condSQL += condition['field']; 1476 } 1477 1478 switch (condition['operator']){ 1479 case 'contains': 1480 case 'doesNotContain': // excluded with NOT IN above 1481 condSQL += ' LIKE ?'; 1482 // For fields with 'leftbound' flag, perform a 1483 // leftbound search even for 'contains' condition 1484 if (condition['flags'] && 1485 condition['flags']['leftbound'] && 1486 Zotero.Prefs.get('search.useLeftBound')) { 1487 condSQLParams.push(condition['value'] + '%'); 1488 } 1489 else { 1490 condSQLParams.push('%' + condition['value'] + '%'); 1491 } 1492 break; 1493 1494 case 'is': 1495 case 'isNot': // excluded with NOT IN above 1496 // If inline filter is available, embed value directly to get around 1497 // the max bound parameter limit 1498 if (condition.inlineFilter) { 1499 let src = Array.isArray(condition.value) 1500 ? condition.value : [condition.value]; 1501 let values = []; 1502 1503 for (let val of src) { 1504 val = condition.inlineFilter(val); 1505 if (val) { 1506 values.push(val); 1507 } 1508 } 1509 1510 if (!values.length) { 1511 continue; 1512 } 1513 1514 condSQL += values.length > 1 1515 ? ` IN (${values.join(', ')})` 1516 : `=${values[0]}`; 1517 } 1518 else { 1519 // Automatically cast values which might 1520 // have been stored as integers 1521 if (condition.value && typeof condition.value == 'string' 1522 && condition.value.match(/^[1-9]+[0-9]*$/)) { 1523 condSQL += ' LIKE ?'; 1524 } 1525 else if (condition.value === null) { 1526 condSQL += ' IS NULL'; 1527 break; 1528 } 1529 else { 1530 condSQL += '=?'; 1531 } 1532 condSQLParams.push(condition['value']); 1533 } 1534 break; 1535 1536 case 'beginsWith': 1537 condSQL += ' LIKE ?'; 1538 condSQLParams.push(condition['value'] + '%'); 1539 break; 1540 1541 case 'isLessThan': 1542 condSQL += '<?'; 1543 condSQLParams.push({int:condition['value']}); 1544 condSQL += opAppend; 1545 break; 1546 1547 case 'isGreaterThan': 1548 condSQL += '>?'; 1549 condSQLParams.push({int:condition['value']}); 1550 condSQL += opAppend; 1551 break; 1552 1553 // Next two only used with full datetimes 1554 case 'isBefore': 1555 condSQL += '<?'; 1556 condSQLParams.push({string:condition['value']}); 1557 break; 1558 1559 case 'isAfter': 1560 condSQL += '>?'; 1561 condSQLParams.push({string:condition['value']}); 1562 break; 1563 } 1564 } 1565 } 1566 1567 // Close open parentheses 1568 for (var k=openParens; k>0; k--){ 1569 condSQL += ')'; 1570 } 1571 1572 if (includeParentsAndChildren || includeParents) { 1573 var parentSQL = "SELECT itemID FROM items WHERE " 1574 + "itemID IN (SELECT parentItemID FROM itemAttachments " 1575 + "WHERE itemID IN (" + condSQL + ")) " 1576 + "OR itemID IN (SELECT parentItemID FROM itemNotes " 1577 + "WHERE itemID IN (" + condSQL + ")) "; 1578 var parentSQLParams = condSQLParams.concat(condSQLParams); 1579 } 1580 1581 if (includeParentsAndChildren || includeChildren) { 1582 var childrenSQL = "SELECT itemID FROM itemAttachments WHERE " 1583 + "parentItemID IN (" + condSQL + ") UNION " 1584 + "SELECT itemID FROM itemNotes " 1585 + "WHERE parentItemID IN (" + condSQL + ")"; 1586 var childSQLParams = condSQLParams.concat(condSQLParams); 1587 } 1588 1589 if (includeParentsAndChildren || includeParents) { 1590 condSQL += " UNION " + parentSQL; 1591 condSQLParams = condSQLParams.concat(parentSQLParams); 1592 } 1593 1594 if (includeParentsAndChildren || includeChildren) { 1595 condSQL += " UNION " + childrenSQL; 1596 condSQLParams = condSQLParams.concat(childSQLParams); 1597 } 1598 1599 condSQL = condSelectSQL + condSQL; 1600 1601 // Close open parentheses 1602 for (var k=selectOpenParens; k>0; k--) { 1603 condSQL += ')'; 1604 } 1605 1606 // Little hack to support multiple quicksearch words 1607 if (condition['name'] == 'blockStart') { 1608 var inQS = true; 1609 var qsSQL = ''; 1610 var qsParams = []; 1611 continue; 1612 } 1613 else if (condition['name'] == 'blockEnd') { 1614 inQS = false; 1615 // Strip ' OR ' from last condition 1616 qsSQL = qsSQL.substring(0, qsSQL.length-4); 1617 1618 // Add to existing quicksearch words 1619 if (!quicksearchSQLSet) { 1620 var quicksearchSQLSet = []; 1621 var quicksearchParamsSet = []; 1622 } 1623 quicksearchSQLSet.push(qsSQL); 1624 quicksearchParamsSet.push(qsParams); 1625 } 1626 else if (inQS) { 1627 qsSQL += condSQL + ' OR '; 1628 qsParams = qsParams.concat(condSQLParams); 1629 } 1630 // Keep non-required conditions separate if in ANY mode 1631 else if (!condition['required'] && joinMode == 'ANY') { 1632 anySQL += condSQL + ' OR '; 1633 anySQLParams = anySQLParams.concat(condSQLParams); 1634 } 1635 else { 1636 condSQL += ' AND '; 1637 sql += condSQL; 1638 sqlParams = sqlParams.concat(condSQLParams); 1639 } 1640 } 1641 1642 // Add on ANY conditions 1643 if (anySQL){ 1644 sql += '(' + anySQL; 1645 sqlParams = sqlParams.concat(anySQLParams); 1646 sql = sql.substring(0, sql.length-4); // remove last ' OR ' 1647 sql += ')'; 1648 } 1649 else { 1650 sql = sql.substring(0, sql.length-5); // remove last ' AND ' 1651 } 1652 1653 // Add on quicksearch conditions 1654 if (quicksearchSQLSet) { 1655 sql = "SELECT itemID FROM items WHERE itemID IN (" + sql + ") " 1656 + "AND ((" + quicksearchSQLSet.join(') AND (') + "))"; 1657 1658 for (var k=0; k<quicksearchParamsSet.length; k++) { 1659 sqlParams = sqlParams.concat(quicksearchParamsSet[k]); 1660 } 1661 } 1662 } 1663 1664 this._sql = sql; 1665 this._sqlParams = sqlParams.length ? sqlParams : false; 1666 });